From c1763ed0072c00bdd727fe2ebb1c1c56a31ec7a5 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 19 Jun 2026 17:14:22 +0200 Subject: [PATCH] feat(assignments): support line-based returns and authenticated updates --- src/actions/assignment.actions.ts | 14 ++ src/services/assignment.service.ts | 175 ++++++++++++++++-- src/types/assignment.ts | 12 ++ src/use-cases/assignment.use-cases.ts | 117 ++++++++---- .../use-cases/assignment.use-cases.test.ts | 161 +++++++++++++++- 5 files changed, 427 insertions(+), 52 deletions(-) diff --git a/src/actions/assignment.actions.ts b/src/actions/assignment.actions.ts index 4b9af46..f9181a6 100644 --- a/src/actions/assignment.actions.ts +++ b/src/actions/assignment.actions.ts @@ -37,6 +37,13 @@ export async function createAssignment(formData: CreateAssignmentFormType) { const result = await createAssignmentUseCase({ ...validatedFields.data, + lines: [ + { + itemId: validatedFields.data.itemId!, + quantity: validatedFields.data.quantity!, + notes: validatedFields.data.notes, + }, + ], actorId: createdBy, }) @@ -81,6 +88,13 @@ export async function updateAssignment(formData: UpdateAssignmentFormType) { const result = await updateAssignmentUseCase({ ...validatedFields.data, + lines: [ + { + itemId: validatedFields.data.itemId!, + quantity: validatedFields.data.quantity!, + notes: validatedFields.data.notes, + }, + ], actorId: createdBy, }) diff --git a/src/services/assignment.service.ts b/src/services/assignment.service.ts index 298db6f..792d89b 100644 --- a/src/services/assignment.service.ts +++ b/src/services/assignment.service.ts @@ -2,7 +2,11 @@ import type { Prisma } from "@/generated/prisma/client" import { paginate } from "@/lib/paginate" import prisma from "@/lib/prisma" import type { CreateAssignmentData } from "@/schemas/assignment.schema" -import type { Assignment, AssignmentWithPersonItemAsset } from "@/types" +import type { + Assignment, + AssignmentStockReturnInput, + AssignmentWithPersonItemAsset, +} from "@/types" type LegacyAssignmentWriteData = CreateAssignmentData & { createdBy?: string @@ -13,6 +17,12 @@ type LegacyAssignmentUpdateData = Partial & { returnDate?: Date | null } +type ReturnStockAssignmentResult = { + returnedQuantity: number + fullyReturned: boolean + returnedLines: { itemId: string; quantity: number }[] +} + type AssignmentWithLines = Prisma.AssignmentGetPayload<{ include: { person: true @@ -27,6 +37,29 @@ const assignmentInclude = { assetLines: { include: { asset: { include: { item: true } } } }, } satisfies Prisma.AssignmentInclude +function normalizeReturnLines( + returns: AssignmentStockReturnInput[], +): AssignmentStockReturnInput[] { + const aggregated = new Map() + + for (const entry of returns) { + const existing = aggregated.get(entry.assignmentLineId) + + if (existing) { + aggregated.set(entry.assignmentLineId, { + ...existing, + quantity: existing.quantity + entry.quantity, + notes: existing.notes ?? entry.notes, + }) + continue + } + + aggregated.set(entry.assignmentLineId, { ...entry }) + } + + return [...aggregated.values()] +} + function toLegacyAssignment( assignment: AssignmentWithLines, ): AssignmentWithPersonItemAsset { @@ -51,7 +84,7 @@ function toLegacyAssignment( } function toLegacyAssignmentRecord( - assignment: Prisma.AssignmentGetPayload<{}>, + assignment: Prisma.AssignmentGetPayload, data: Pick, ): Assignment { return { @@ -132,6 +165,120 @@ export const AssignmentService = { return assignment ? toLegacyAssignment(assignment) : null }, + returnStockAssignment: async ( + id: string, + receivedById: string | undefined, + returns: AssignmentStockReturnInput[] | undefined, + db: Prisma.TransactionClient | typeof prisma = prisma, + ): Promise => { + const assignment = await db.assignment.findFirst({ + where: { + id, + status: { in: ["OPEN", "PARTIALLY_RETURNED"] }, + }, + include: { + stockLines: { + orderBy: { + createdAt: "asc", + }, + }, + }, + }) + + if (!assignment || assignment.stockLines.length === 0) { + return null + } + + const returnerId = receivedById ?? assignment.createdById + + const remainingReturns = + returns !== undefined + ? normalizeReturnLines(returns) + : assignment.stockLines + .filter((line) => line.returnedQuantity < line.quantity) + .map((line) => ({ + assignmentLineId: line.id, + quantity: line.quantity - line.returnedQuantity, + notes: undefined, + })) + + if (remainingReturns.length === 0) { + return null + } + + const stockLinesById = new Map( + assignment.stockLines.map((line) => [line.id, line]), + ) + const returnTimestamp = new Date() + let returnedQuantity = 0 + const returnedLines: { itemId: string; quantity: number }[] = [] + + for (const returnLine of remainingReturns) { + const stockLine = stockLinesById.get(returnLine.assignmentLineId) + + if (!stockLine) { + return null + } + + if (!Number.isInteger(returnLine.quantity) || returnLine.quantity <= 0) { + return null + } + + const remainingQuantity = stockLine.quantity - stockLine.returnedQuantity + + if (returnLine.quantity > remainingQuantity) { + return null + } + + await db.assignmentStockReturn.create({ + data: { + assignmentLineId: stockLine.id, + quantity: returnLine.quantity, + receivedById: returnerId, + returnedAt: returnTimestamp, + notes: returnLine.notes, + }, + }) + + await db.assignmentStockLine.update({ + where: { id: stockLine.id }, + data: { + returnedQuantity: { + increment: returnLine.quantity, + }, + }, + }) + + returnedQuantity += returnLine.quantity + returnedLines.push({ + itemId: stockLine.itemId, + quantity: returnLine.quantity, + }) + } + + const fullyReturned = assignment.stockLines.every( + (line) => + line.returnedQuantity + + (remainingReturns.find((entry) => entry.assignmentLineId === line.id) + ?.quantity ?? 0) >= + line.quantity, + ) + + await db.assignment.update({ + where: { id }, + data: { + status: fullyReturned ? "RETURNED" : "PARTIALLY_RETURNED", + closedAt: fullyReturned ? returnTimestamp : null, + closedById: fullyReturned ? returnerId : null, + }, + }) + + return { + returnedQuantity, + fullyReturned, + returnedLines, + } + }, findAllByPerson: async ( personId: string, ): Promise => { @@ -233,19 +380,25 @@ export const AssignmentService = { id, status: { in: ["OPEN", "PARTIALLY_RETURNED"] }, }, - include: { stockLines: true, assetLines: { include: { asset: true } } }, + include: { + stockLines: true, + assetLines: { include: { asset: true } }, + }, }) if (!assignment) return false - await Promise.all( - assignment.stockLines.map((line) => - db.assignmentStockLine.update({ - where: { id: line.id }, - data: { returnedQuantity: line.quantity }, - }), - ), - ) + if (assignment.stockLines.length > 0) { + const result = await AssignmentService.returnStockAssignment( + id, + actorId ?? assignment.createdById, + undefined, + db, + ) + + return Boolean(result?.fullyReturned) + } + await Promise.all( assignment.assetLines .filter((line) => !line.returnedAt) diff --git a/src/types/assignment.ts b/src/types/assignment.ts index 61b927b..837745d 100644 --- a/src/types/assignment.ts +++ b/src/types/assignment.ts @@ -14,6 +14,18 @@ export type Assignment = PrismaAssignment & { export type AssignmentSummary = Pick +export type AssignmentStockLineInput = { + itemId: string + quantity: number + notes?: string +} + +export type AssignmentStockReturnInput = { + assignmentLineId: string + quantity: number + notes?: string +} + export type AssignmentWithPersonItemAsset = Assignment & { assignmentDate: Date returnDate: Date | null diff --git a/src/use-cases/assignment.use-cases.ts b/src/use-cases/assignment.use-cases.ts index 6d7080b..0769f58 100644 --- a/src/use-cases/assignment.use-cases.ts +++ b/src/use-cases/assignment.use-cases.ts @@ -7,20 +7,37 @@ import { AssetService } from "@/services/asset.service" import { AssignmentService } from "@/services/assignment.service" import { ItemService } from "@/services/item.service" import { MovementService } from "@/services/movement.service" +import type { + AssignmentStockLineInput, + AssignmentStockReturnInput, +} from "@/types" type FieldErrors = Record -type CreateAssignmentUseCaseInput = CreateAssignmentData & { +type CreateAssignmentUseCaseInput = Omit< + CreateAssignmentData, + "itemId" | "quantity" +> & { actorId: string + itemId?: string + quantity?: number + lines?: AssignmentStockLineInput[] } type ReturnAssignmentUseCaseInput = { id: string actorId: string + returns?: AssignmentStockReturnInput[] } -type UpdateAssignmentUseCaseInput = UpdateAssignmentData & { +type UpdateAssignmentUseCaseInput = Omit< + UpdateAssignmentData, + "itemId" | "quantity" +> & { actorId: string + itemId?: string + quantity?: number + lines?: AssignmentStockLineInput[] } type CreateAssignmentUseCaseResult = @@ -78,10 +95,45 @@ function updateAssignmentError( } } +function resolveAssignmentLine( + input: + | Pick< + CreateAssignmentUseCaseInput, + "itemId" | "quantity" | "notes" | "lines" + > + | Pick< + UpdateAssignmentUseCaseInput, + "itemId" | "quantity" | "notes" | "lines" + >, +): AssignmentStockLineInput | null { + const providedLine = input.lines?.[0] + + if (providedLine) { + return providedLine + } + + if (!input.itemId || typeof input.quantity !== "number") { + return null + } + + return { + itemId: input.itemId, + quantity: input.quantity, + notes: input.notes, + } +} + export async function createAssignmentUseCase( input: CreateAssignmentUseCaseInput, ): Promise { - const { actorId, itemId, assetId, quantity, personId } = input + const { actorId, assetId, personId } = input + const line = resolveAssignmentLine(input) + + if (!line || !personId || line.quantity <= 0) { + return createAssignmentError({ error: ["Invalid assignment data"] }) + } + + const { itemId, quantity, notes } = line if (!itemId || !personId || quantity <= 0) { return createAssignmentError({ error: ["Invalid assignment data"] }) @@ -142,7 +194,7 @@ export async function createAssignmentUseCase( assetId: assetId || undefined, quantity, personId, - notes: input.notes, + notes: notes ?? input.notes, assignmentDate: input.assignmentDate, createdBy: actorId, }, @@ -172,16 +224,12 @@ export async function createAssignmentUseCase( export async function updateAssignmentUseCase( input: UpdateAssignmentUseCaseInput, ): Promise { - const { - actorId, - id, - personId, - itemId, - assetId, - quantity, - notes, - assignmentDate, - } = input + const { actorId, id, personId, assetId } = input + const line = resolveAssignmentLine(input) + const itemId = line?.itemId ?? input.itemId + const quantity = line?.quantity ?? input.quantity ?? 0 + const notes = line?.notes ?? input.notes + const assignmentDate = input.assignmentDate if (!id) { return updateAssignmentError({ id: ["Assignment ID is required"] }) @@ -282,7 +330,7 @@ export async function updateAssignmentUseCase( export async function returnAssignmentUseCase( input: ReturnAssignmentUseCaseInput, ): Promise { - const { id, actorId } = input + const { id, actorId, returns } = input if (!id) { return returnAssignmentError({ id: ["Assignment ID is required"] }) @@ -299,18 +347,35 @@ export async function returnAssignmentUseCase( return returnAssignmentError({ id: ["Assignment already returned"] }) } - const assignmentWasReturned = await AssignmentService.markReturnedIfActive( + const returnResult = await AssignmentService.returnStockAssignment( id, actorId, + returns, tx, ) - if (!assignmentWasReturned) { - return returnAssignmentError({ id: ["Assignment already returned"] }) + if (!returnResult) { + return returnAssignmentError({ error: ["Invalid assignment data"] }) } - if (assignment.itemId && assignment.quantity) { - await ItemService.updateStock(assignment.itemId, assignment.quantity, tx) + 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) { @@ -323,18 +388,6 @@ export async function returnAssignmentUseCase( ) } - await MovementService.create( - { - type: "RETURN", - quantity: assignment.quantity || 1, - itemId: assignment.itemId || undefined, - assetId: assignment.assetId || undefined, - assignmentId: id, - userId: actorId, - }, - tx, - ) - return { success: true, } diff --git a/tests/integration/use-cases/assignment.use-cases.test.ts b/tests/integration/use-cases/assignment.use-cases.test.ts index e7864e4..4452a8f 100644 --- a/tests/integration/use-cases/assignment.use-cases.test.ts +++ b/tests/integration/use-cases/assignment.use-cases.test.ts @@ -13,6 +13,7 @@ import { let prisma: PrismaClient let createAssignmentUseCase: typeof import("@/use-cases/assignment.use-cases").createAssignmentUseCase +let updateAssignmentUseCase: typeof import("@/use-cases/assignment.use-cases").updateAssignmentUseCase let returnAssignmentUseCase: typeof import("@/use-cases/assignment.use-cases").returnAssignmentUseCase beforeAll(async () => { @@ -23,6 +24,7 @@ beforeAll(async () => { prisma = prismaModule.prisma createAssignmentUseCase = assignmentUseCases.createAssignmentUseCase + updateAssignmentUseCase = assignmentUseCases.updateAssignmentUseCase returnAssignmentUseCase = assignmentUseCases.returnAssignmentUseCase }) @@ -45,11 +47,10 @@ describe("assignment use-cases", () => { const result = await createAssignmentUseCase({ actorId: actor.id, - itemId: item.id, personId: person.id, - quantity: 2, assignmentDate, notes: "Initial assignment", + lines: [{ itemId: item.id, quantity: 2 }], }) expect(result.success).toBe(true) @@ -89,6 +90,44 @@ describe("assignment use-cases", () => { expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -2, + previousStock: 5, + newStock: 3, + }) + }) + + it("updates an assignment from a quantity line DTO and keeps the edited line quantity", 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) + + const assignment = await prisma.assignment.findUniqueOrThrow({ + where: { id: created.assignmentId }, + include: { stockLines: true }, + }) + + expect(assignment.stockLines).toHaveLength(1) + expect(assignment.stockLines[0]).toMatchObject({ + itemId: item.id, + quantity: 1, }) }) @@ -111,11 +150,13 @@ describe("assignment use-cases", () => { }, }) - await expect( - prisma.item.findUniqueOrThrow({ where: { id: item.id } }), - ).resolves.toMatchObject({ stock: 1 }) - await expect(prisma.assignment.count()).resolves.toBe(0) - await expect(prisma.inventoryMovement.count()).resolves.toBe(0) + expect( + await prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + ).toMatchObject({ + stock: 1, + }) + expect(await prisma.assignment.count()).toBe(0) + expect(await prisma.inventoryMovement.count()).toBe(0) }) it("returns an assignment, restores stock, closes it, and records a RETURN movement", async () => { @@ -145,7 +186,7 @@ describe("assignment use-cases", () => { prisma.item.findUniqueOrThrow({ where: { id: item.id } }), prisma.assignment.findUniqueOrThrow({ where: { id: created.assignmentId }, - include: { stockLines: true }, + include: { stockLines: { include: { returns: true } } }, }), prisma.inventoryMovement.findMany({ include: { stockLines: true }, @@ -158,12 +199,18 @@ describe("assignment use-cases", () => { expect(assignment).toMatchObject({ personId: person.id, status: "RETURNED", + closedById: actor.id, }) expect(assignment.stockLines[0]).toMatchObject({ itemId: item.id, quantity: 3, returnedQuantity: 3, }) + expect(assignment.stockLines[0].returns).toHaveLength(1) + expect(assignment.stockLines[0].returns[0]).toMatchObject({ + quantity: 3, + receivedById: actor.id, + }) expect(movements).toHaveLength(2) expect(movements[0]).toMatchObject({ type: "ASSIGNMENT", @@ -173,6 +220,8 @@ describe("assignment use-cases", () => { expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -3, + previousStock: 4, + newStock: 1, }) expect(movements[1]).toMatchObject({ type: "RETURN", @@ -182,6 +231,100 @@ describe("assignment use-cases", () => { expect(movements[1].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 3, + previousStock: 1, + newStock: 4, + }) + }) + + it("records partial quantity returns before fully closing the 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, + 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 firstReturn = await returnAssignmentUseCase({ + id: created.assignmentId, + actorId: actor.id, + returns: [ + { + assignmentLineId: assignment.stockLines[0].id, + quantity: 2, + notes: "First return batch", + }, + ], + }) + + expect(firstReturn).toEqual({ success: true }) + + const partiallyReturned = await prisma.assignment.findUniqueOrThrow({ + where: { id: created.assignmentId }, + include: { stockLines: { include: { returns: true } } }, + }) + + expect(partiallyReturned).toMatchObject({ + status: "PARTIALLY_RETURNED", + closedAt: null, + closedById: null, + }) + expect(partiallyReturned.stockLines[0]).toMatchObject({ + quantity: 5, + returnedQuantity: 2, + }) + expect(partiallyReturned.stockLines[0].returns).toHaveLength(1) + expect(partiallyReturned.stockLines[0].returns[0]).toMatchObject({ + quantity: 2, + receivedById: actor.id, + notes: "First return batch", + }) + + const secondReturn = await returnAssignmentUseCase({ + id: created.assignmentId, + actorId: actor.id, + returns: [ + { + assignmentLineId: assignment.stockLines[0].id, + quantity: 3, + }, + ], + }) + + expect(secondReturn).toEqual({ success: true }) + + const fullyReturned = await prisma.assignment.findUniqueOrThrow({ + where: { id: created.assignmentId }, + include: { stockLines: { include: { returns: true } } }, + }) + + expect(fullyReturned).toMatchObject({ + status: "RETURNED", + closedById: actor.id, + }) + expect(fullyReturned.closedAt).toBeInstanceOf(Date) + expect(fullyReturned.stockLines[0]).toMatchObject({ + quantity: 5, + returnedQuantity: 5, + }) + expect(fullyReturned.stockLines[0].returns).toHaveLength(2) + + expect( + await prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + ).toMatchObject({ + stock: 5, }) }) @@ -212,6 +355,6 @@ describe("assignment use-cases", () => { errors: { id: ["Assignment already returned"] }, }) - await expect(prisma.inventoryMovement.count()).resolves.toBe(2) + expect(await prisma.inventoryMovement.count()).toBe(2) }) })