feat(assignments): partial return schema, concurrency guard, domain error
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user