From e4bd76a353028dc99ab11a8b79afa12c92173c86 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Thu, 25 Jun 2026 21:02:46 +0200 Subject: [PATCH] feat(assignments): partial return schema, concurrency guard, domain error --- src/schemas/assignment.schema.ts | 25 ++- src/services/assignment.service.ts | 64 +++++++- src/types/assignment.ts | 2 + src/use-cases/assignment.use-cases.ts | 122 +++++++------- .../use-cases/assignment.use-cases.test.ts | 152 ++++++++++++++++-- tests/unit/schemas/assignment.schema.test.ts | 88 ++++++++++ 6 files changed, 365 insertions(+), 88 deletions(-) diff --git a/src/schemas/assignment.schema.ts b/src/schemas/assignment.schema.ts index b9138da..c9dc1b2 100644 --- a/src/schemas/assignment.schema.ts +++ b/src/schemas/assignment.schema.ts @@ -79,9 +79,24 @@ export const updateAssignmentSchema = buildUpdateAssignmentSchema( export type UpdateAssignmentFormType = z.input export type UpdateAssignmentData = z.output -export const returnAssignmentSchema = z.object({ - id: z.string().min(1, { - error: defaultAssignmentSchemaCopy.idRequired, - }), -}) +export function buildReturnAssignmentSchema(copy: AssignmentSchemaCopy) { + return z.object({ + id: z.string().min(1, { + error: copy.idRequired, + }), + returns: z + .array( + z.object({ + assignmentLineId: z.string().min(1), + quantity: z.number().int().positive(), + notes: z.string().optional(), + }), + ) + .optional(), + }) +} + +export const returnAssignmentSchema = buildReturnAssignmentSchema( + defaultAssignmentSchemaCopy, +) export type ReturnAssignmentFormType = z.infer diff --git a/src/services/assignment.service.ts b/src/services/assignment.service.ts index 792d89b..01e8866 100644 --- a/src/services/assignment.service.ts +++ b/src/services/assignment.service.ts @@ -23,6 +23,12 @@ type ReturnStockAssignmentResult = { returnedLines: { itemId: string; quantity: number }[] } +export class ConcurrentReturnError extends Error { + constructor(public readonly assignmentLineId: string) { + super(`Concurrent return detected on line ${assignmentLineId}`) + } +} + type AssignmentWithLines = Prisma.AssignmentGetPayload<{ include: { person: true @@ -69,6 +75,9 @@ function toLegacyAssignment( const asset = assetLine?.asset ?? null const quantity = stockLine?.quantity ?? (assetLine ? 1 : null) const returnDate = assignment.closedAt ?? assetLine?.returnedAt ?? null + const remainingQuantity = stockLine + ? stockLine.quantity - stockLine.returnedQuantity + : undefined return { ...assignment, @@ -80,6 +89,8 @@ function toLegacyAssignment( itemId: item?.id ?? null, assetId: asset?.id ?? null, quantity, + assignmentLineId: stockLine?.id, + remainingQuantity, } } @@ -209,10 +220,29 @@ export const AssignmentService = { const stockLinesById = new Map( assignment.stockLines.map((line) => [line.id, line]), ) + const observedReturnedQuantities = new Map( + assignment.stockLines.map((line) => [line.id, line.returnedQuantity]), + ) const returnTimestamp = new Date() let returnedQuantity = 0 const returnedLines: { itemId: string; quantity: number }[] = [] + if (returns !== undefined) { + const lineIds = remainingReturns.map((entry) => entry.assignmentLineId) + const reloadedLines = await db.assignmentStockLine.findMany({ + where: { id: { in: lineIds } }, + }) + + for (const reloaded of reloadedLines) { + if ( + reloaded.returnedQuantity !== + observedReturnedQuantities.get(reloaded.id) + ) { + throw new ConcurrentReturnError(reloaded.id) + } + } + } + for (const returnLine of remainingReturns) { const stockLine = stockLinesById.get(returnLine.assignmentLineId) @@ -224,7 +254,9 @@ export const AssignmentService = { return null } - const remainingQuantity = stockLine.quantity - stockLine.returnedQuantity + const observedReturnedQuantity = + observedReturnedQuantities.get(stockLine.id) ?? 0 + const remainingQuantity = stockLine.quantity - observedReturnedQuantity if (returnLine.quantity > remainingQuantity) { return null @@ -240,14 +272,30 @@ export const AssignmentService = { }, }) - await db.assignmentStockLine.update({ - where: { id: stockLine.id }, - data: { - returnedQuantity: { - increment: returnLine.quantity, + if (returns !== undefined) { + const updated = await db.assignmentStockLine.updateMany({ + where: { + id: stockLine.id, + returnedQuantity: observedReturnedQuantity, }, - }, - }) + data: { + returnedQuantity: observedReturnedQuantity + returnLine.quantity, + }, + }) + + if (updated.count === 0) { + throw new ConcurrentReturnError(stockLine.id) + } + } else { + await db.assignmentStockLine.update({ + where: { id: stockLine.id }, + data: { + returnedQuantity: { + increment: returnLine.quantity, + }, + }, + }) + } returnedQuantity += returnLine.quantity returnedLines.push({ diff --git a/src/types/assignment.ts b/src/types/assignment.ts index 837745d..85fc42b 100644 --- a/src/types/assignment.ts +++ b/src/types/assignment.ts @@ -35,4 +35,6 @@ export type AssignmentWithPersonItemAsset = Assignment & { itemId: string | null assetId: string | null quantity: number | null + assignmentLineId?: string + remainingQuantity?: number } diff --git a/src/use-cases/assignment.use-cases.ts b/src/use-cases/assignment.use-cases.ts index 776f7d5..2dbba6f 100644 --- a/src/use-cases/assignment.use-cases.ts +++ b/src/use-cases/assignment.use-cases.ts @@ -1,11 +1,14 @@ -import { type Prisma } from "@/generated/prisma/client" +import type { Prisma } from "@/generated/prisma/client" import prisma from "@/lib/prisma" import type { CreateAssignmentData, UpdateAssignmentData, } from "@/schemas/assignment.schema" import { AssetService } from "@/services/asset.service" -import { AssignmentService } from "@/services/assignment.service" +import { + AssignmentService, + ConcurrentReturnError, +} from "@/services/assignment.service" import { ItemService } from "@/services/item.service" import { MovementService } from "@/services/movement.service" import type { @@ -160,11 +163,7 @@ export async function reassignAssignment( if (oldAssignment.itemId) { const oldItem = await ItemService.findById(oldAssignment.itemId, tx) if (oldItem && oldItem.trackingType === "QUANTITY") { - await ItemService.updateStock( - oldAssignment.itemId, - returnedQuantity, - tx, - ) + await ItemService.updateStock(oldAssignment.itemId, returnedQuantity, tx) } } await MovementService.create( @@ -338,10 +337,7 @@ export async function updateAssignmentUseCase( return updateAssignmentError({ id: ["Assignment not found"] }) } - if ( - assignment.status === "RETURNED" || - assignment.status === "CANCELLED" - ) { + if (assignment.status === "RETURNED" || assignment.status === "CANCELLED") { return updateAssignmentError({ id: ["Assignment is closed"] }) } @@ -427,60 +423,68 @@ export async function returnAssignmentUseCase( return returnAssignmentError({ id: ["Assignment ID is required"] }) } - return prisma.$transaction(async (tx) => { - const assignment = await AssignmentService.findById(id, tx) + try { + return await prisma.$transaction(async (tx) => { + const assignment = await AssignmentService.findById(id, tx) - if (!assignment) { - return returnAssignmentError({ id: ["Assignment not found"] }) - } + if (!assignment) { + return returnAssignmentError({ id: ["Assignment not found"] }) + } - if (assignment.returnDate) { - return returnAssignmentError({ id: ["Assignment already returned"] }) - } + if (assignment.returnDate) { + return returnAssignmentError({ id: ["Assignment already returned"] }) + } - const returnResult = await AssignmentService.returnStockAssignment( - id, - actorId, - returns, - tx, - ) - - if (!returnResult) { - return returnAssignmentError({ error: ["Invalid assignment data"] }) - } - - for (const returnedLine of returnResult.returnedLines) { - await ItemService.updateStock( - returnedLine.itemId, - returnedLine.quantity, + const returnResult = await AssignmentService.returnStockAssignment( + id, + actorId, + returns, tx, ) - await MovementService.create( - { - type: "RETURN", - quantity: returnedLine.quantity, - itemId: returnedLine.itemId, - assetId: assignment.assetId || undefined, - assignmentId: id, - userId: actorId, - }, - tx, - ) + if (!returnResult) { + return returnAssignmentError({ error: ["Invalid assignment data"] }) + } + + for (const returnedLine of returnResult.returnedLines) { + await ItemService.updateStock( + returnedLine.itemId, + returnedLine.quantity, + tx, + ) + + await MovementService.create( + { + type: "RETURN", + quantity: returnedLine.quantity, + itemId: returnedLine.itemId, + assetId: assignment.assetId || undefined, + assignmentId: id, + userId: actorId, + }, + tx, + ) + } + + if (assignment.assetId) { + await AssetService.update( + assignment.assetId, + { + status: "AVAILABLE", + }, + tx, + ) + } + + return { + success: true, + } + }) + } catch (error) { + if (error instanceof ConcurrentReturnError) { + return returnAssignmentError({ error: ["errorConcurrent"] }) } - if (assignment.assetId) { - await AssetService.update( - assignment.assetId, - { - status: "AVAILABLE", - }, - tx, - ) - } - - return { - success: true, - } - }) + throw error + } } diff --git a/tests/integration/use-cases/assignment.use-cases.test.ts b/tests/integration/use-cases/assignment.use-cases.test.ts index cbf0832..e72a514 100644 --- a/tests/integration/use-cases/assignment.use-cases.test.ts +++ b/tests/integration/use-cases/assignment.use-cases.test.ts @@ -287,7 +287,7 @@ describe("assignment use-cases", () => { }) }) - it("records partial quantity returns before fully closing the assignment", async () => { + it("records sequential partial returns of 2, 1, then 2 before fully closing the assignment", async () => { const actor = await createTestUser(prisma) const person = await createTestPerson(prisma) const item = await createTestItem(prisma, { stock: 5 }) @@ -349,13 +349,26 @@ describe("assignment use-cases", () => { returns: [ { assignmentLineId: assignment.stockLines[0].id, - quantity: 3, + quantity: 1, }, ], }) expect(secondReturn).toEqual({ success: true }) + const thirdReturn = await returnAssignmentUseCase({ + id: created.assignmentId, + actorId: actor.id, + returns: [ + { + assignmentLineId: assignment.stockLines[0].id, + quantity: 2, + }, + ], + }) + + expect(thirdReturn).toEqual({ success: true }) + const fullyReturned = await prisma.assignment.findUniqueOrThrow({ where: { id: created.assignmentId }, include: { stockLines: { include: { returns: true } } }, @@ -370,13 +383,102 @@ describe("assignment use-cases", () => { quantity: 5, returnedQuantity: 5, }) - expect(fullyReturned.stockLines[0].returns).toHaveLength(2) + expect(fullyReturned.stockLines[0].returns).toHaveLength(3) expect( await prisma.item.findUniqueOrThrow({ where: { id: item.id } }), ).toMatchObject({ stock: 5, }) + + const returnMovements = await prisma.inventoryMovement.findMany({ + where: { type: "RETURN" }, + include: { stockLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }) + + expect(returnMovements).toHaveLength(3) + expect(returnMovements[0].stockLines[0]).toMatchObject({ + stockDelta: 2, + previousStock: 0, + newStock: 2, + }) + expect(returnMovements[1].stockLines[0]).toMatchObject({ + stockDelta: 1, + previousStock: 2, + newStock: 3, + }) + expect(returnMovements[2].stockLines[0]).toMatchObject({ + stockDelta: 2, + previousStock: 3, + newStock: 5, + }) + }) + + it("rejects a concurrent partial return with a domain error", 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, + itemId: item.id, + personId: person.id, + quantity: 5, + }) + + expect(created.success).toBe(true) + if (!created.success) + throw new Error("Expected assignment creation success") + + const assignment = await prisma.assignment.findUniqueOrThrow({ + where: { id: created.assignmentId }, + include: { stockLines: true }, + }) + + const [resultA, resultB] = await Promise.all([ + returnAssignmentUseCase({ + id: created.assignmentId, + actorId: actor.id, + returns: [ + { + assignmentLineId: assignment.stockLines[0].id, + quantity: 2, + }, + ], + }), + returnAssignmentUseCase({ + id: created.assignmentId, + actorId: actor.id, + returns: [ + { + assignmentLineId: assignment.stockLines[0].id, + quantity: 1, + }, + ], + }), + ]) + + const successes = [resultA, resultB].filter((result) => result.success) + const failures = [resultA, resultB].filter((result) => !result.success) + + expect(successes).toHaveLength(1) + expect(failures).toHaveLength(1) + expect(failures[0]).toEqual({ + success: false, + errors: { error: ["errorConcurrent"] }, + }) + + const finalAssignment = await prisma.assignment.findUniqueOrThrow({ + where: { id: created.assignmentId }, + include: { stockLines: { include: { returns: true } } }, + }) + + expect(finalAssignment.status).toBe("PARTIALLY_RETURNED") + expect(finalAssignment.stockLines[0].returns).toHaveLength(1) + expect(finalAssignment.stockLines[0].returnedQuantity).toBe( + finalAssignment.stockLines[0].returns[0].quantity, + ) }) it("rejects returning the same assignment twice", async () => { @@ -420,7 +522,8 @@ describe("assignment use-cases", () => { lines: [{ itemId: item.id, quantity: 2 }], }) expect(created.success).toBe(true) - if (!created.success) throw new Error("Expected assignment creation success") + if (!created.success) + throw new Error("Expected assignment creation success") const updated = await updateAssignmentUseCase({ actorId: actor.id, @@ -440,7 +543,9 @@ describe("assignment use-cases", () => { ]) expect(updatedItem.stock).toBe(4) - const adjustment = movements.find((movement) => movement.type === "ADJUSTMENT") + const adjustment = movements.find( + (movement) => movement.type === "ADJUSTMENT", + ) expect(adjustment).toBeDefined() expect(adjustment).toMatchObject({ type: "ADJUSTMENT", @@ -465,7 +570,8 @@ describe("assignment use-cases", () => { lines: [{ itemId: item.id, quantity: 3 }], }) expect(created.success).toBe(true) - if (!created.success) throw new Error("Expected assignment creation success") + if (!created.success) + throw new Error("Expected assignment creation success") const createdAssignment = await prisma.assignment.findUniqueOrThrow({ where: { id: created.assignmentId }, @@ -502,7 +608,9 @@ describe("assignment use-cases", () => { ]) expect(updatedItem.stock).toBe(5) - const adjustment = movements.find((movement) => movement.type === "ADJUSTMENT") + const adjustment = movements.find( + (movement) => movement.type === "ADJUSTMENT", + ) expect(adjustment).toBeDefined() expect(adjustment).toMatchObject({ type: "ADJUSTMENT", @@ -527,7 +635,8 @@ describe("assignment use-cases", () => { lines: [{ itemId: item.id, quantity: 2 }], }) expect(created.success).toBe(true) - if (!created.success) throw new Error("Expected assignment creation success") + if (!created.success) + throw new Error("Expected assignment creation success") const updated = await updateAssignmentUseCase({ actorId: actor.id, @@ -547,7 +656,9 @@ describe("assignment use-cases", () => { ]) expect(updatedItem.stock).toBe(2) - const adjustment = movements.find((movement) => movement.type === "ADJUSTMENT") + const adjustment = movements.find( + (movement) => movement.type === "ADJUSTMENT", + ) expect(adjustment).toBeDefined() expect(adjustment).toMatchObject({ type: "ADJUSTMENT", @@ -572,7 +683,8 @@ describe("assignment use-cases", () => { lines: [{ itemId: item.id, quantity: 2 }], }) expect(created.success).toBe(true) - if (!created.success) throw new Error("Expected assignment creation success") + if (!created.success) + throw new Error("Expected assignment creation success") const returned = await returnAssignmentUseCase({ id: created.assignmentId, @@ -606,7 +718,9 @@ describe("assignment use-cases", () => { expect(assignment.stockLines[0].quantity).toBe(2) expect(movements).toHaveLength(movementsBefore) - expect(movements.some((movement) => movement.type === "ADJUSTMENT")).toBe(false) + expect(movements.some((movement) => movement.type === "ADJUSTMENT")).toBe( + false, + ) }) it("writes no movement when quantity is unchanged on an OPEN assignment", async () => { @@ -620,7 +734,8 @@ describe("assignment use-cases", () => { lines: [{ itemId: item.id, quantity: 2 }], }) expect(created.success).toBe(true) - if (!created.success) throw new Error("Expected assignment creation success") + if (!created.success) + throw new Error("Expected assignment creation success") const movementsBefore = await prisma.inventoryMovement.count() @@ -642,7 +757,9 @@ describe("assignment use-cases", () => { expect(updatedItem.stock).toBe(3) expect(movements).toHaveLength(movementsBefore) - expect(movements.some((movement) => movement.type === "ADJUSTMENT")).toBe(false) + 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 () => { @@ -657,7 +774,8 @@ describe("assignment use-cases", () => { lines: [{ itemId: item.id, quantity: 2 }], }) expect(created.success).toBe(true) - if (!created.success) throw new Error("Expected assignment creation success") + if (!created.success) + throw new Error("Expected assignment creation success") const updated = await updateAssignmentUseCase({ actorId: actor.id, @@ -774,7 +892,8 @@ describe("assignment use-cases", () => { lines: [{ itemId: item.id, quantity: 1 }], }) expect(created.success).toBe(true) - if (!created.success) throw new Error("Expected assignment creation success") + if (!created.success) + throw new Error("Expected assignment creation success") const updated = await updateAssignmentUseCase({ actorId: actor.id, @@ -863,7 +982,8 @@ describe("assignment use-cases", () => { lines: [{ itemId: item.id, quantity: 2 }], }) expect(created.success).toBe(true) - if (!created.success) throw new Error("Expected assignment creation success") + if (!created.success) + throw new Error("Expected assignment creation success") const updated = await updateAssignmentUseCase({ actorId: actor.id, diff --git a/tests/unit/schemas/assignment.schema.test.ts b/tests/unit/schemas/assignment.schema.test.ts index f6545dc..1c74dfa 100644 --- a/tests/unit/schemas/assignment.schema.test.ts +++ b/tests/unit/schemas/assignment.schema.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest" import { buildCreateAssignmentSchema, + buildReturnAssignmentSchema, buildUpdateAssignmentSchema, } from "@/schemas/assignment.schema" @@ -96,3 +97,90 @@ describe("assignment schema localization", () => { } }) }) + +describe("return assignment schema", () => { + it("accepts a partial return payload with optional notes", () => { + const result = buildReturnAssignmentSchema(schemaCopy).safeParse({ + id: "assignment-1", + returns: [ + { + assignmentLineId: "line-1", + quantity: 2, + notes: "Slightly damaged", + }, + ], + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.id).toBe("assignment-1") + expect(result.data.returns).toHaveLength(1) + expect(result.data.returns?.[0]).toMatchObject({ + assignmentLineId: "line-1", + quantity: 2, + notes: "Slightly damaged", + }) + } + }) + + it("rejects a non-integer quantity", () => { + const result = buildReturnAssignmentSchema(schemaCopy).safeParse({ + id: "assignment-1", + returns: [ + { + assignmentLineId: "line-1", + quantity: 1.5, + }, + ], + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.path.includes("quantity")), + ).toBe(true) + } + }) + + it("rejects a quantity less than or equal to zero", () => { + const zeroResult = buildReturnAssignmentSchema(schemaCopy).safeParse({ + id: "assignment-1", + returns: [{ assignmentLineId: "line-1", quantity: 0 }], + }) + + expect(zeroResult.success).toBe(false) + if (!zeroResult.success) { + expect( + zeroResult.error.issues.some((issue) => + issue.path.includes("quantity"), + ), + ).toBe(true) + } + + const negativeResult = buildReturnAssignmentSchema(schemaCopy).safeParse({ + id: "assignment-1", + returns: [{ assignmentLineId: "line-1", quantity: -1 }], + }) + + expect(negativeResult.success).toBe(false) + if (!negativeResult.success) { + expect( + negativeResult.error.issues.some((issue) => + issue.path.includes("quantity"), + ), + ).toBe(true) + } + }) + + it("keeps the legacy full-return shape valid", () => { + const result = buildReturnAssignmentSchema(schemaCopy).safeParse({ + id: "assignment-1", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.id).toBe("assignment-1") + expect(result.data.returns).toBeUndefined() + } + }) +})