diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c3c77fb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## Unreleased + +### Breaking Changes + +- **W-3 (fix-assignment-asset-movement-audit)**: `Assignment.id` is no longer stable across person swaps. When `updateAssignmentUseCase` or `updateAssetUseCase` changes a person on an active assignment, the old assignment is now closed (with `closedAt`/`closedById` set; `AssignmentStockReturn` row created for QUANTITY; `AssignmentAssetLine.returnedAt` set for SERIALIZED) and a NEW `Assignment` is created with a new `id`. Any code that holds an `Assignment.id` expecting stability across person swaps must be updated to look up the new id (e.g., via the active `Assignment` for the person/asset). The change replaces a phantom `RETURN` + `ASSIGNMENT` pattern that wrote both movements to the same `Assignment.id` without creating an `AssignmentStockReturn` row. + +## Released + +_Unreleased changes appear above. Released versions will be added below as they are tagged._ diff --git a/src/use-cases/asset.use-cases.ts b/src/use-cases/asset.use-cases.ts index d5a66d7..ca47c58 100644 --- a/src/use-cases/asset.use-cases.ts +++ b/src/use-cases/asset.use-cases.ts @@ -6,6 +6,7 @@ import { AssignmentService } from "@/services/assignment.service" import { ItemService } from "@/services/item.service" import { MovementService } from "@/services/movement.service" import type { Assignment } from "@/types" +import { reassignAssignment } from "@/use-cases/assignment.use-cases" type FieldErrors = Record @@ -472,46 +473,15 @@ export async function updateAssetUseCase( if (activeAssignment) { if (transition.personChanged) { - await MovementService.create( - { - type: "RETURN", - quantity: activeAssignment.quantity || 1, - itemId: activeAssignment.itemId || undefined, - assetId: activeAssignment.assetId || undefined, - personId: activeAssignment.personId || undefined, - assignmentId: activeAssignment.id, - userId: actorId, - previousStatus: transition.previousStatus, - }, + await reassignAssignment({ + oldAssignment: activeAssignment, + newPersonId: transition.nextPersonId, + newItemId: itemId, + newAssetId: id, + newQuantity: 1, + actorId, tx, - ) - - await MovementService.create( - { - type: "ASSIGNMENT", - quantity: 1, - itemId, - assetId: id, - personId: transition.nextPersonId, - assignmentId: activeAssignment.id, - userId: actorId, - previousStatus: transition.previousStatus, - }, - tx, - ) - - await AssignmentService.update( - activeAssignment.id, - { - createdBy: actorId, - itemId, - assetId: id, - personId: transition.nextPersonId, - quantity: 1, - returnDate: null, - }, - tx, - ) + }) } else { await AssignmentService.update( activeAssignment.id, diff --git a/src/use-cases/assignment.use-cases.ts b/src/use-cases/assignment.use-cases.ts index e9b5cd3..776f7d5 100644 --- a/src/use-cases/assignment.use-cases.ts +++ b/src/use-cases/assignment.use-cases.ts @@ -1,3 +1,4 @@ +import { type Prisma } from "@/generated/prisma/client" import prisma from "@/lib/prisma" import type { CreateAssignmentData, @@ -8,6 +9,7 @@ import { AssignmentService } from "@/services/assignment.service" import { ItemService } from "@/services/item.service" import { MovementService } from "@/services/movement.service" import type { + Assignment, AssignmentStockLineInput, AssignmentStockReturnInput, } from "@/types" @@ -123,6 +125,98 @@ function resolveAssignmentLine( } } +export type ReassignAssignmentArgs = { + oldAssignment: Assignment + newPersonId: string + newItemId?: string + newAssetId?: string + newQuantity: number + newNotes?: string + actorId: string + tx: Prisma.TransactionClient +} + +export async function reassignAssignment( + args: ReassignAssignmentArgs, +): Promise<{ newAssignment: Assignment }> { + const { + oldAssignment, + newPersonId, + newItemId, + newAssetId, + newQuantity, + newNotes, + actorId, + tx, + } = args + + const returnedQuantity = oldAssignment.quantity ?? 1 + + // 1. Close old assignment (handles both QUANTITY and SERIALIZED via markReturnedIfActive) + await AssignmentService.markReturnedIfActive(oldAssignment.id, actorId, tx) + + // 2. Mutate item.stock to restore the returned quantity, then write RETURN movement + // Skip for SERIALIZED items (stock is constrained to 0 and is not affected by assignments). + if (oldAssignment.itemId) { + const oldItem = await ItemService.findById(oldAssignment.itemId, tx) + if (oldItem && oldItem.trackingType === "QUANTITY") { + await ItemService.updateStock( + oldAssignment.itemId, + returnedQuantity, + tx, + ) + } + } + await MovementService.create( + { + type: "RETURN", + quantity: returnedQuantity, + itemId: oldAssignment.itemId || undefined, + assetId: oldAssignment.assetId || undefined, + personId: oldAssignment.personId || undefined, + assignmentId: oldAssignment.id, + userId: actorId, + }, + tx, + ) + + // 3. Create new assignment for the new person + const newAssignment = await AssignmentService.create( + { + personId: newPersonId, + itemId: newItemId, + assetId: newAssetId, + quantity: newQuantity, + notes: newNotes, + assignmentDate: new Date(), + createdBy: actorId, + }, + tx, + ) + + // 4. Mutate item.stock to deduct the new assigned quantity, then write ASSIGNMENT movement + if (newItemId) { + const newItem = await ItemService.findById(newItemId, tx) + if (newItem && newItem.trackingType === "QUANTITY") { + await ItemService.updateStock(newItemId, -newQuantity, tx) + } + } + await MovementService.create( + { + type: "ASSIGNMENT", + quantity: newQuantity, + itemId: newItemId, + assetId: newAssetId, + personId: newPersonId, + assignmentId: newAssignment.id, + userId: actorId, + }, + tx, + ) + + return { newAssignment } +} + export async function createAssignmentUseCase( input: CreateAssignmentUseCaseInput, ): Promise { @@ -276,46 +370,16 @@ export async function updateAssignmentUseCase( } if (assignment.personId !== personId) { - await MovementService.create( - { - type: "RETURN", - quantity: assignment.quantity || 1, - itemId: assignment.itemId || undefined, - assetId: assignment.assetId || undefined, - personId: assignment.personId || undefined, - assignmentId: id, - userId: actorId, - }, + await reassignAssignment({ + oldAssignment: assignment, + newPersonId: personId, + newItemId: itemId, + newAssetId: assetId, + newQuantity: quantity, + newNotes: notes, + actorId, tx, - ) - - await MovementService.create( - { - type: "ASSIGNMENT", - quantity, - itemId, - assetId: assetId || undefined, - personId, - assignmentId: id, - userId: actorId, - }, - tx, - ) - - await AssignmentService.update( - id, - { - createdBy: actorId, - personId: personId, - itemId, - assetId, - quantity, - notes, - assignmentDate, - returnDate: null, - }, - tx, - ) + }) } else { if (item && assignment.quantity !== quantity) { const stockDelta = quantity - (assignment.quantity ?? 0) diff --git a/tests/integration/use-cases/asset.use-cases.test.ts b/tests/integration/use-cases/asset.use-cases.test.ts index d422e84..b125aca 100644 --- a/tests/integration/use-cases/asset.use-cases.test.ts +++ b/tests/integration/use-cases/asset.use-cases.test.ts @@ -832,4 +832,113 @@ describe("asset use-cases", () => { newStatus: "ASSIGNED", }) }) + + it("closes the old assignment and creates a new one on person swap via updateAssetUseCase", async () => { + const actor = await createTestUser(prisma) + const personA = await createTestPerson(prisma, { firstName: "Alice" }) + const personB = await createTestPerson(prisma, { firstName: "Bob" }) + const category = await createTestCategory(prisma) + const item = await prisma.item.create({ + data: { + sku: "W3-ASSET-SKU", + name: "Asset Item", + trackingType: "SERIALIZED", + stock: 0, + category: { connect: { id: category.id } }, + }, + }) + + const created = await createAssetUseCase({ + actorId: actor.id, + itemId: item.id, + serialNumber: "W3-ASSET-001", + status: "ASSIGNED", + personId: personA.id, + }) + expect(created.success).toBe(true) + if (!created.success) throw new Error("Expected asset creation success") + + const oldAssignment = await prisma.assignment.findFirstOrThrow({ + where: { + status: "OPEN", + assetLines: { some: { assetId: created.assetId, returnedAt: null } }, + }, + }) + + const updated = await updateAssetUseCase({ + actorId: actor.id, + id: created.assetId, + itemId: item.id, + serialNumber: "W3-ASSET-001", + status: "ASSIGNED", + personId: personB.id, + }) + expect(updated.success).toBe(true) + if (!updated.success) throw new Error("Expected asset update success") + + const [oldAssignmentAfter, newAssignment, movements] = await Promise.all([ + prisma.assignment.findUniqueOrThrow({ + where: { id: oldAssignment.id }, + include: { assetLines: true }, + }), + prisma.assignment.findFirstOrThrow({ + where: { personId: personB.id, status: "OPEN" }, + include: { assetLines: true }, + }), + prisma.inventoryMovement.findMany({ + include: { + stockLines: true, + assetLines: true, + assignment: { select: { personId: true } }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(oldAssignmentAfter.status).toBe("RETURNED") + expect(oldAssignmentAfter.closedAt).toBeInstanceOf(Date) + expect(oldAssignmentAfter.assetLines[0].returnedAt).toBeInstanceOf(Date) + + expect(newAssignment.id).not.toBe(oldAssignment.id) + expect(newAssignment.personId).toBe(personB.id) + expect(newAssignment.assetLines[0]).toMatchObject({ + assetId: created.assetId, + returnedAt: null, + }) + + expect(movements).toHaveLength(3) + expect(movements.map((m) => m.type)).toEqual([ + "ASSIGNMENT", + "RETURN", + "ASSIGNMENT", + ]) + for (const movement of movements) { + expect(movement.stockLines).toEqual([]) + } + + const returnMovement = movements[1] + const newAssignmentMovement = movements[2] + + expect(returnMovement).toMatchObject({ + type: "RETURN", + assignmentId: oldAssignment.id, + }) + expect(returnMovement.assignment).toMatchObject({ + personId: personA.id, + }) + expect(returnMovement.assetLines[0]).toMatchObject({ + assetId: created.assetId, + }) + + expect(newAssignmentMovement).toMatchObject({ + type: "ASSIGNMENT", + assignmentId: newAssignment.id, + }) + expect(newAssignmentMovement.assignment).toMatchObject({ + personId: personB.id, + }) + expect(newAssignmentMovement.assetLines[0]).toMatchObject({ + assetId: created.assetId, + }) + }) }) diff --git a/tests/integration/use-cases/assignment.use-cases.test.ts b/tests/integration/use-cases/assignment.use-cases.test.ts index e35ca97..cbf0832 100644 --- a/tests/integration/use-cases/assignment.use-cases.test.ts +++ b/tests/integration/use-cases/assignment.use-cases.test.ts @@ -644,4 +644,244 @@ describe("assignment use-cases", () => { expect(movements).toHaveLength(movementsBefore) expect(movements.some((movement) => movement.type === "ADJUSTMENT")).toBe(false) }) + + it("closes the old assignment and creates a new one with RETURN+ASSIGNMENT pair on person swap for QUANTITY", async () => { + const actor = await createTestUser(prisma) + const personA = await createTestPerson(prisma, { firstName: "Alice" }) + const personB = await createTestPerson(prisma, { firstName: "Bob" }) + const item = await createTestItem(prisma, { stock: 5 }) + + const created = await createAssignmentUseCase({ + actorId: actor.id, + personId: personA.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: personB.id, + lines: [{ itemId: item.id, quantity: 2 }], + }) + expect(updated.success).toBe(true) + if (!updated.success) throw new Error("Expected assignment update success") + + const [oldAssignment, newAssignment, updatedItem, movements] = + await Promise.all([ + prisma.assignment.findUniqueOrThrow({ + where: { id: created.assignmentId }, + include: { stockLines: { include: { returns: true } } }, + }), + prisma.assignment.findFirstOrThrow({ + where: { personId: personB.id, status: "OPEN" }, + include: { stockLines: true }, + }), + prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + prisma.inventoryMovement.findMany({ + include: { + stockLines: true, + assignment: { select: { personId: true } }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(oldAssignment.status).toBe("RETURNED") + expect(oldAssignment.closedAt).toBeInstanceOf(Date) + expect(oldAssignment.closedById).toBe(actor.id) + expect(oldAssignment.stockLines[0].returns).toHaveLength(1) + expect(oldAssignment.stockLines[0].returns[0]).toMatchObject({ + quantity: 2, + receivedById: actor.id, + }) + + expect(newAssignment.id).not.toBe(created.assignmentId) + expect(newAssignment.personId).toBe(personB.id) + expect(newAssignment.stockLines[0]).toMatchObject({ + itemId: item.id, + quantity: 2, + }) + + expect(updatedItem.stock).toBe(3) + + expect(movements).toHaveLength(3) + expect(movements.map((m) => m.type)).toEqual([ + "ASSIGNMENT", + "RETURN", + "ASSIGNMENT", + ]) + + const returnMovement = movements[1] + const newAssignmentMovement = movements[2] + + expect(returnMovement).toMatchObject({ + type: "RETURN", + assignmentId: created.assignmentId, + }) + expect(returnMovement.assignment).toMatchObject({ + personId: personA.id, + }) + expect(returnMovement.stockLines[0]).toMatchObject({ + itemId: item.id, + stockDelta: 2, + previousStock: 3, + newStock: 5, + }) + + expect(newAssignmentMovement).toMatchObject({ + type: "ASSIGNMENT", + assignmentId: newAssignment.id, + }) + expect(newAssignmentMovement.assignment).toMatchObject({ + personId: personB.id, + }) + expect(newAssignmentMovement.stockLines[0]).toMatchObject({ + itemId: item.id, + stockDelta: -2, + previousStock: 5, + newStock: 3, + }) + }) + + it("closes the old AssignmentAssetLine and creates a new one on person swap for SERIALIZED", async () => { + const actor = await createTestUser(prisma) + const personA = await createTestPerson(prisma, { firstName: "Alice" }) + const personB = await createTestPerson(prisma, { firstName: "Bob" }) + const category = await createTestCategory(prisma) + const item = await prisma.item.create({ + data: { + sku: "W3-SERIAL-SKU", + name: "Serial Item", + trackingType: "SERIALIZED", + stock: 0, + category: { connect: { id: category.id } }, + }, + }) + const asset = await prisma.asset.create({ + data: { + serialNumber: "W3-SERIAL-ASSET-001", + itemId: item.id, + status: "AVAILABLE", + }, + }) + + const created = await createAssignmentUseCase({ + actorId: actor.id, + personId: personA.id, + assetId: asset.id, + lines: [{ itemId: item.id, quantity: 1 }], + }) + 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: personB.id, + assetId: asset.id, + lines: [{ itemId: item.id, quantity: 1 }], + }) + expect(updated.success).toBe(true) + if (!updated.success) throw new Error("Expected assignment update success") + + const [oldAssignment, newAssignment, movements] = await Promise.all([ + prisma.assignment.findUniqueOrThrow({ + where: { id: created.assignmentId }, + include: { assetLines: true }, + }), + prisma.assignment.findFirstOrThrow({ + where: { personId: personB.id, status: "OPEN" }, + include: { assetLines: true }, + }), + prisma.inventoryMovement.findMany({ + include: { + stockLines: true, + assetLines: true, + assignment: { select: { personId: true } }, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(oldAssignment.status).toBe("RETURNED") + expect(oldAssignment.closedAt).toBeInstanceOf(Date) + expect(oldAssignment.assetLines[0].returnedAt).toBeInstanceOf(Date) + + expect(newAssignment.id).not.toBe(created.assignmentId) + expect(newAssignment.personId).toBe(personB.id) + expect(newAssignment.assetLines[0]).toMatchObject({ + assetId: asset.id, + returnedAt: null, + }) + + expect(movements).toHaveLength(3) + expect(movements.map((m) => m.type)).toEqual([ + "ASSIGNMENT", + "RETURN", + "ASSIGNMENT", + ]) + for (const movement of movements) { + expect(movement.stockLines).toEqual([]) + } + + const returnMovement = movements[1] + const newAssignmentMovement = movements[2] + + expect(returnMovement).toMatchObject({ + type: "RETURN", + assignmentId: created.assignmentId, + }) + expect(returnMovement.assignment).toMatchObject({ + personId: personA.id, + }) + expect(returnMovement.assetLines[0]).toMatchObject({ + assetId: asset.id, + }) + + expect(newAssignmentMovement).toMatchObject({ + type: "ASSIGNMENT", + assignmentId: newAssignment.id, + }) + expect(newAssignmentMovement.assignment).toMatchObject({ + personId: personB.id, + }) + expect(newAssignmentMovement.assetLines[0]).toMatchObject({ + assetId: asset.id, + }) + }) + + it("writes no RETURN or ASSIGNMENT when the person is unchanged on update with quantity change", 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 movements = await prisma.inventoryMovement.findMany({ + include: { stockLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }) + + // ASSIGNMENT (create) + ADJUSTMENT (W-1) = 2 movements + expect(movements).toHaveLength(2) + expect(movements.map((m) => m.type)).toEqual(["ASSIGNMENT", "ADJUSTMENT"]) + expect(movements.some((m) => m.type === "RETURN")).toBe(false) + }) })