diff --git a/src/use-cases/assignment.use-cases.ts b/src/use-cases/assignment.use-cases.ts index f619f54..e9b5cd3 100644 --- a/src/use-cases/assignment.use-cases.ts +++ b/src/use-cases/assignment.use-cases.ts @@ -244,8 +244,17 @@ export async function updateAssignmentUseCase( return updateAssignmentError({ id: ["Assignment not found"] }) } + if ( + assignment.status === "RETURNED" || + assignment.status === "CANCELLED" + ) { + return updateAssignmentError({ id: ["Assignment is closed"] }) + } + + let item = null + if (itemId) { - const item = await ItemService.findById(itemId, tx) + item = await ItemService.findById(itemId, tx) if (!item) { return updateAssignmentError({ itemId: ["Item not found"] }) @@ -308,6 +317,22 @@ export async function updateAssignmentUseCase( tx, ) } else { + if (item && assignment.quantity !== quantity) { + const stockDelta = quantity - (assignment.quantity ?? 0) + const adjustmentItemId = item.id + await ItemService.updateStock(adjustmentItemId, -stockDelta, tx) + await MovementService.create( + { + type: "ADJUSTMENT", + itemId: adjustmentItemId, + quantity: Math.abs(stockDelta), + stockDeltaSign: stockDelta < 0 ? 1 : -1, + userId: actorId, + }, + tx, + ) + } + await AssignmentService.update( id, { diff --git a/tests/integration/use-cases/assignment.use-cases.test.ts b/tests/integration/use-cases/assignment.use-cases.test.ts index d7b4b49..e35ca97 100644 --- a/tests/integration/use-cases/assignment.use-cases.test.ts +++ b/tests/integration/use-cases/assignment.use-cases.test.ts @@ -408,4 +408,240 @@ describe("assignment use-cases", () => { expect(await prisma.inventoryMovement.count()).toBe(2) }) + + it("writes an ADJUSTMENT movement with stockDelta=+1 when quantity decreases on an OPEN assignment", async () => { + const actor = await createTestUser(prisma) + const person = await createTestPerson(prisma) + const item = await createTestItem(prisma, { stock: 5 }) + + const created = await createAssignmentUseCase({ + actorId: actor.id, + personId: person.id, + lines: [{ itemId: item.id, quantity: 2 }], + }) + expect(created.success).toBe(true) + if (!created.success) throw new Error("Expected assignment creation success") + + const updated = await updateAssignmentUseCase({ + actorId: actor.id, + id: created.assignmentId, + personId: person.id, + lines: [{ itemId: item.id, quantity: 1 }], + }) + expect(updated.success).toBe(true) + if (!updated.success) throw new Error("Expected assignment update success") + + const [updatedItem, movements] = await Promise.all([ + prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + prisma.inventoryMovement.findMany({ + include: { stockLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(updatedItem.stock).toBe(4) + const adjustment = movements.find((movement) => movement.type === "ADJUSTMENT") + expect(adjustment).toBeDefined() + expect(adjustment).toMatchObject({ + type: "ADJUSTMENT", + reason: "INVENTORY_CORRECTION", + }) + expect(adjustment?.stockLines[0]).toMatchObject({ + itemId: item.id, + stockDelta: 1, + previousStock: 3, + newStock: 4, + }) + }) + + it("writes an ADJUSTMENT movement with stockDelta=+2 when quantity decreases on a PARTIALLY_RETURNED assignment", async () => { + const actor = await createTestUser(prisma) + const person = await createTestPerson(prisma) + const item = await createTestItem(prisma, { stock: 5 }) + + const created = await createAssignmentUseCase({ + actorId: actor.id, + personId: person.id, + lines: [{ itemId: item.id, quantity: 3 }], + }) + expect(created.success).toBe(true) + if (!created.success) throw new Error("Expected assignment creation success") + + const createdAssignment = await prisma.assignment.findUniqueOrThrow({ + where: { id: created.assignmentId }, + include: { stockLines: true }, + }) + + const partialReturn = await returnAssignmentUseCase({ + id: created.assignmentId, + actorId: actor.id, + returns: [ + { + assignmentLineId: createdAssignment.stockLines[0].id, + quantity: 1, + }, + ], + }) + expect(partialReturn).toEqual({ success: true }) + + const updated = await updateAssignmentUseCase({ + actorId: actor.id, + id: created.assignmentId, + personId: person.id, + lines: [{ itemId: item.id, quantity: 1 }], + }) + expect(updated.success).toBe(true) + if (!updated.success) throw new Error("Expected assignment update success") + + const [updatedItem, movements] = await Promise.all([ + prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + prisma.inventoryMovement.findMany({ + include: { stockLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(updatedItem.stock).toBe(5) + const adjustment = movements.find((movement) => movement.type === "ADJUSTMENT") + expect(adjustment).toBeDefined() + expect(adjustment).toMatchObject({ + type: "ADJUSTMENT", + reason: "INVENTORY_CORRECTION", + }) + expect(adjustment?.stockLines[0]).toMatchObject({ + itemId: item.id, + stockDelta: 2, + previousStock: 3, + newStock: 5, + }) + }) + + it("writes an ADJUSTMENT movement with stockDelta=-1 when quantity increases on an OPEN assignment", async () => { + const actor = await createTestUser(prisma) + const person = await createTestPerson(prisma) + const item = await createTestItem(prisma, { stock: 5 }) + + const created = await createAssignmentUseCase({ + actorId: actor.id, + personId: person.id, + lines: [{ itemId: item.id, quantity: 2 }], + }) + expect(created.success).toBe(true) + if (!created.success) throw new Error("Expected assignment creation success") + + const updated = await updateAssignmentUseCase({ + actorId: actor.id, + id: created.assignmentId, + personId: person.id, + lines: [{ itemId: item.id, quantity: 3 }], + }) + expect(updated.success).toBe(true) + if (!updated.success) throw new Error("Expected assignment update success") + + const [updatedItem, movements] = await Promise.all([ + prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + prisma.inventoryMovement.findMany({ + include: { stockLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(updatedItem.stock).toBe(2) + const adjustment = movements.find((movement) => movement.type === "ADJUSTMENT") + expect(adjustment).toBeDefined() + expect(adjustment).toMatchObject({ + type: "ADJUSTMENT", + reason: "INVENTORY_CORRECTION", + }) + expect(adjustment?.stockLines[0]).toMatchObject({ + itemId: item.id, + stockDelta: -1, + previousStock: 3, + newStock: 2, + }) + }) + + it("rejects a quantity change on a RETURNED assignment and writes no movement", async () => { + const actor = await createTestUser(prisma) + const person = await createTestPerson(prisma) + const item = await createTestItem(prisma, { stock: 5 }) + + const created = await createAssignmentUseCase({ + actorId: actor.id, + personId: person.id, + lines: [{ itemId: item.id, quantity: 2 }], + }) + expect(created.success).toBe(true) + if (!created.success) throw new Error("Expected assignment creation success") + + const returned = await returnAssignmentUseCase({ + id: created.assignmentId, + actorId: actor.id, + }) + expect(returned).toEqual({ success: true }) + + const movementsBefore = await prisma.inventoryMovement.count() + + const updated = await updateAssignmentUseCase({ + actorId: actor.id, + id: created.assignmentId, + personId: person.id, + lines: [{ itemId: item.id, quantity: 3 }], + }) + + expect(updated).toEqual({ + success: false, + errors: { id: ["Assignment is closed"] }, + }) + + const [assignment, movements] = await Promise.all([ + prisma.assignment.findUniqueOrThrow({ + where: { id: created.assignmentId }, + include: { stockLines: true }, + }), + prisma.inventoryMovement.findMany({ + include: { stockLines: true }, + }), + ]) + + expect(assignment.stockLines[0].quantity).toBe(2) + expect(movements).toHaveLength(movementsBefore) + expect(movements.some((movement) => movement.type === "ADJUSTMENT")).toBe(false) + }) + + it("writes no movement when quantity is unchanged on an OPEN assignment", async () => { + const actor = await createTestUser(prisma) + const person = await createTestPerson(prisma) + const item = await createTestItem(prisma, { stock: 5 }) + + const created = await createAssignmentUseCase({ + actorId: actor.id, + personId: person.id, + lines: [{ itemId: item.id, quantity: 2 }], + }) + expect(created.success).toBe(true) + if (!created.success) throw new Error("Expected assignment creation success") + + const movementsBefore = await prisma.inventoryMovement.count() + + const updated = await updateAssignmentUseCase({ + actorId: actor.id, + id: created.assignmentId, + personId: person.id, + lines: [{ itemId: item.id, quantity: 2 }], + }) + expect(updated).toEqual({ success: true }) + + const [updatedItem, movements] = await Promise.all([ + prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + prisma.inventoryMovement.findMany({ + include: { stockLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(updatedItem.stock).toBe(3) + expect(movements).toHaveLength(movementsBefore) + expect(movements.some((movement) => movement.type === "ADJUSTMENT")).toBe(false) + }) })