feat(assignments): partial return schema, concurrency guard, domain error

This commit is contained in:
2026-06-25 21:02:46 +02:00
parent 9b023ee558
commit e4bd76a353
6 changed files with 365 additions and 88 deletions
+18 -3
View File
@@ -79,9 +79,24 @@ export const updateAssignmentSchema = buildUpdateAssignmentSchema(
export type UpdateAssignmentFormType = z.input<typeof updateAssignmentSchema> export type UpdateAssignmentFormType = z.input<typeof updateAssignmentSchema>
export type UpdateAssignmentData = z.output<typeof updateAssignmentSchema> export type UpdateAssignmentData = z.output<typeof updateAssignmentSchema>
export const returnAssignmentSchema = z.object({ export function buildReturnAssignmentSchema(copy: AssignmentSchemaCopy) {
return z.object({
id: z.string().min(1, { id: z.string().min(1, {
error: defaultAssignmentSchemaCopy.idRequired, 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<typeof returnAssignmentSchema> export type ReturnAssignmentFormType = z.infer<typeof returnAssignmentSchema>
+49 -1
View File
@@ -23,6 +23,12 @@ type ReturnStockAssignmentResult = {
returnedLines: { itemId: string; quantity: number }[] 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<{ type AssignmentWithLines = Prisma.AssignmentGetPayload<{
include: { include: {
person: true person: true
@@ -69,6 +75,9 @@ function toLegacyAssignment(
const asset = assetLine?.asset ?? null const asset = assetLine?.asset ?? null
const quantity = stockLine?.quantity ?? (assetLine ? 1 : null) const quantity = stockLine?.quantity ?? (assetLine ? 1 : null)
const returnDate = assignment.closedAt ?? assetLine?.returnedAt ?? null const returnDate = assignment.closedAt ?? assetLine?.returnedAt ?? null
const remainingQuantity = stockLine
? stockLine.quantity - stockLine.returnedQuantity
: undefined
return { return {
...assignment, ...assignment,
@@ -80,6 +89,8 @@ function toLegacyAssignment(
itemId: item?.id ?? null, itemId: item?.id ?? null,
assetId: asset?.id ?? null, assetId: asset?.id ?? null,
quantity, quantity,
assignmentLineId: stockLine?.id,
remainingQuantity,
} }
} }
@@ -209,10 +220,29 @@ export const AssignmentService = {
const stockLinesById = new Map( const stockLinesById = new Map(
assignment.stockLines.map((line) => [line.id, line]), assignment.stockLines.map((line) => [line.id, line]),
) )
const observedReturnedQuantities = new Map(
assignment.stockLines.map((line) => [line.id, line.returnedQuantity]),
)
const returnTimestamp = new Date() const returnTimestamp = new Date()
let returnedQuantity = 0 let returnedQuantity = 0
const returnedLines: { itemId: string; quantity: number }[] = [] 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) { for (const returnLine of remainingReturns) {
const stockLine = stockLinesById.get(returnLine.assignmentLineId) const stockLine = stockLinesById.get(returnLine.assignmentLineId)
@@ -224,7 +254,9 @@ export const AssignmentService = {
return null return null
} }
const remainingQuantity = stockLine.quantity - stockLine.returnedQuantity const observedReturnedQuantity =
observedReturnedQuantities.get(stockLine.id) ?? 0
const remainingQuantity = stockLine.quantity - observedReturnedQuantity
if (returnLine.quantity > remainingQuantity) { if (returnLine.quantity > remainingQuantity) {
return null return null
@@ -240,6 +272,21 @@ export const AssignmentService = {
}, },
}) })
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({ await db.assignmentStockLine.update({
where: { id: stockLine.id }, where: { id: stockLine.id },
data: { data: {
@@ -248,6 +295,7 @@ export const AssignmentService = {
}, },
}, },
}) })
}
returnedQuantity += returnLine.quantity returnedQuantity += returnLine.quantity
returnedLines.push({ returnedLines.push({
+2
View File
@@ -35,4 +35,6 @@ export type AssignmentWithPersonItemAsset = Assignment & {
itemId: string | null itemId: string | null
assetId: string | null assetId: string | null
quantity: number | null quantity: number | null
assignmentLineId?: string
remainingQuantity?: number
} }
+16 -12
View File
@@ -1,11 +1,14 @@
import { type Prisma } from "@/generated/prisma/client" import type { Prisma } from "@/generated/prisma/client"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import type { import type {
CreateAssignmentData, CreateAssignmentData,
UpdateAssignmentData, UpdateAssignmentData,
} from "@/schemas/assignment.schema" } from "@/schemas/assignment.schema"
import { AssetService } from "@/services/asset.service" 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 { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service" import { MovementService } from "@/services/movement.service"
import type { import type {
@@ -160,11 +163,7 @@ export async function reassignAssignment(
if (oldAssignment.itemId) { if (oldAssignment.itemId) {
const oldItem = await ItemService.findById(oldAssignment.itemId, tx) const oldItem = await ItemService.findById(oldAssignment.itemId, tx)
if (oldItem && oldItem.trackingType === "QUANTITY") { if (oldItem && oldItem.trackingType === "QUANTITY") {
await ItemService.updateStock( await ItemService.updateStock(oldAssignment.itemId, returnedQuantity, tx)
oldAssignment.itemId,
returnedQuantity,
tx,
)
} }
} }
await MovementService.create( await MovementService.create(
@@ -338,10 +337,7 @@ export async function updateAssignmentUseCase(
return updateAssignmentError({ id: ["Assignment not found"] }) return updateAssignmentError({ id: ["Assignment not found"] })
} }
if ( if (assignment.status === "RETURNED" || assignment.status === "CANCELLED") {
assignment.status === "RETURNED" ||
assignment.status === "CANCELLED"
) {
return updateAssignmentError({ id: ["Assignment is closed"] }) return updateAssignmentError({ id: ["Assignment is closed"] })
} }
@@ -427,7 +423,8 @@ export async function returnAssignmentUseCase(
return returnAssignmentError({ id: ["Assignment ID is required"] }) return returnAssignmentError({ id: ["Assignment ID is required"] })
} }
return prisma.$transaction(async (tx) => { try {
return await prisma.$transaction(async (tx) => {
const assignment = await AssignmentService.findById(id, tx) const assignment = await AssignmentService.findById(id, tx)
if (!assignment) { if (!assignment) {
@@ -483,4 +480,11 @@ export async function returnAssignmentUseCase(
success: true, success: true,
} }
}) })
} catch (error) {
if (error instanceof ConcurrentReturnError) {
return returnAssignmentError({ error: ["errorConcurrent"] })
}
throw error
}
} }
@@ -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 actor = await createTestUser(prisma)
const person = await createTestPerson(prisma) const person = await createTestPerson(prisma)
const item = await createTestItem(prisma, { stock: 5 }) const item = await createTestItem(prisma, { stock: 5 })
@@ -349,13 +349,26 @@ describe("assignment use-cases", () => {
returns: [ returns: [
{ {
assignmentLineId: assignment.stockLines[0].id, assignmentLineId: assignment.stockLines[0].id,
quantity: 3, quantity: 1,
}, },
], ],
}) })
expect(secondReturn).toEqual({ success: true }) 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({ const fullyReturned = await prisma.assignment.findUniqueOrThrow({
where: { id: created.assignmentId }, where: { id: created.assignmentId },
include: { stockLines: { include: { returns: true } } }, include: { stockLines: { include: { returns: true } } },
@@ -370,13 +383,102 @@ describe("assignment use-cases", () => {
quantity: 5, quantity: 5,
returnedQuantity: 5, returnedQuantity: 5,
}) })
expect(fullyReturned.stockLines[0].returns).toHaveLength(2) expect(fullyReturned.stockLines[0].returns).toHaveLength(3)
expect( expect(
await prisma.item.findUniqueOrThrow({ where: { id: item.id } }), await prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
).toMatchObject({ ).toMatchObject({
stock: 5, 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 () => { it("rejects returning the same assignment twice", async () => {
@@ -420,7 +522,8 @@ describe("assignment use-cases", () => {
lines: [{ itemId: item.id, quantity: 2 }], lines: [{ itemId: item.id, quantity: 2 }],
}) })
expect(created.success).toBe(true) 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({ const updated = await updateAssignmentUseCase({
actorId: actor.id, actorId: actor.id,
@@ -440,7 +543,9 @@ describe("assignment use-cases", () => {
]) ])
expect(updatedItem.stock).toBe(4) 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).toBeDefined()
expect(adjustment).toMatchObject({ expect(adjustment).toMatchObject({
type: "ADJUSTMENT", type: "ADJUSTMENT",
@@ -465,7 +570,8 @@ describe("assignment use-cases", () => {
lines: [{ itemId: item.id, quantity: 3 }], lines: [{ itemId: item.id, quantity: 3 }],
}) })
expect(created.success).toBe(true) 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({ const createdAssignment = await prisma.assignment.findUniqueOrThrow({
where: { id: created.assignmentId }, where: { id: created.assignmentId },
@@ -502,7 +608,9 @@ describe("assignment use-cases", () => {
]) ])
expect(updatedItem.stock).toBe(5) 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).toBeDefined()
expect(adjustment).toMatchObject({ expect(adjustment).toMatchObject({
type: "ADJUSTMENT", type: "ADJUSTMENT",
@@ -527,7 +635,8 @@ describe("assignment use-cases", () => {
lines: [{ itemId: item.id, quantity: 2 }], lines: [{ itemId: item.id, quantity: 2 }],
}) })
expect(created.success).toBe(true) 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({ const updated = await updateAssignmentUseCase({
actorId: actor.id, actorId: actor.id,
@@ -547,7 +656,9 @@ describe("assignment use-cases", () => {
]) ])
expect(updatedItem.stock).toBe(2) 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).toBeDefined()
expect(adjustment).toMatchObject({ expect(adjustment).toMatchObject({
type: "ADJUSTMENT", type: "ADJUSTMENT",
@@ -572,7 +683,8 @@ describe("assignment use-cases", () => {
lines: [{ itemId: item.id, quantity: 2 }], lines: [{ itemId: item.id, quantity: 2 }],
}) })
expect(created.success).toBe(true) 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({ const returned = await returnAssignmentUseCase({
id: created.assignmentId, id: created.assignmentId,
@@ -606,7 +718,9 @@ describe("assignment use-cases", () => {
expect(assignment.stockLines[0].quantity).toBe(2) expect(assignment.stockLines[0].quantity).toBe(2)
expect(movements).toHaveLength(movementsBefore) 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 () => { 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 }], lines: [{ itemId: item.id, quantity: 2 }],
}) })
expect(created.success).toBe(true) 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() const movementsBefore = await prisma.inventoryMovement.count()
@@ -642,7 +757,9 @@ describe("assignment use-cases", () => {
expect(updatedItem.stock).toBe(3) expect(updatedItem.stock).toBe(3)
expect(movements).toHaveLength(movementsBefore) 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 () => { 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 }], lines: [{ itemId: item.id, quantity: 2 }],
}) })
expect(created.success).toBe(true) 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({ const updated = await updateAssignmentUseCase({
actorId: actor.id, actorId: actor.id,
@@ -774,7 +892,8 @@ describe("assignment use-cases", () => {
lines: [{ itemId: item.id, quantity: 1 }], lines: [{ itemId: item.id, quantity: 1 }],
}) })
expect(created.success).toBe(true) 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({ const updated = await updateAssignmentUseCase({
actorId: actor.id, actorId: actor.id,
@@ -863,7 +982,8 @@ describe("assignment use-cases", () => {
lines: [{ itemId: item.id, quantity: 2 }], lines: [{ itemId: item.id, quantity: 2 }],
}) })
expect(created.success).toBe(true) 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({ const updated = await updateAssignmentUseCase({
actorId: actor.id, actorId: actor.id,
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
import { import {
buildCreateAssignmentSchema, buildCreateAssignmentSchema,
buildReturnAssignmentSchema,
buildUpdateAssignmentSchema, buildUpdateAssignmentSchema,
} from "@/schemas/assignment.schema" } 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()
}
})
})