From 575cd2d9a08b02207e06e68305953441b0a71764 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Thu, 25 Jun 2026 03:03:26 +0200 Subject: [PATCH] feat(items): emit ISSUE/ADJUSTMENT movement on stock decrease --- src/schemas/item.schema.ts | 25 +++++ src/services/movement.service.ts | 11 +- src/use-cases/item.use-cases.ts | 13 +++ .../use-cases/item.use-cases.test.ts | 106 ++++++++++++++++++ 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/schemas/item.schema.ts b/src/schemas/item.schema.ts index 3d4bfeb..da324f8 100644 --- a/src/schemas/item.schema.ts +++ b/src/schemas/item.schema.ts @@ -1,5 +1,6 @@ import { z } from "zod" +import type { InventoryMovementReason } from "@/generated/prisma/client" import type { Dictionary } from "@/i18n/dictionaries" export type ItemSchemaCopy = Dictionary["inventory"]["items"]["schema"] @@ -72,6 +73,29 @@ function buildOptionalStatusSchema(copy: ItemSchemaCopy) { ).optional() } +function buildOptionalReasonSchema() { + return z + .preprocess( + (value) => (value === "" || value === null ? undefined : value), + z.nativeEnum({ + PURCHASE: "PURCHASE", + MANUAL_ENTRY: "MANUAL_ENTRY", + EMPLOYEE_ASSIGNMENT: "EMPLOYEE_ASSIGNMENT", + EMPLOYEE_RETURN: "EMPLOYEE_RETURN", + INVENTORY_CORRECTION: "INVENTORY_CORRECTION", + DAMAGE: "DAMAGE", + REPAIR: "REPAIR", + REPAIR_RETURN: "REPAIR_RETURN", + LOSS: "LOSS", + THEFT: "THEFT", + DISPOSAL: "DISPOSAL", + INITIAL_LOAD: "INITIAL_LOAD", + OTHER: "OTHER", + } satisfies Record), + ) + .optional() +} + export function buildCreateItemSchema(copy: ItemSchemaCopy) { return z.object({ name: z.string().min(1, { @@ -106,6 +130,7 @@ export function buildUpdateItemSchema(copy: ItemSchemaCopy) { }), trackingType: buildOptionalTrackingTypeSchema(copy), status: buildOptionalStatusSchema(copy), + reason: buildOptionalReasonSchema(), }) } diff --git a/src/services/movement.service.ts b/src/services/movement.service.ts index 26f8717..e96298d 100644 --- a/src/services/movement.service.ts +++ b/src/services/movement.service.ts @@ -112,6 +112,11 @@ function getMovementPerson(movement: { return movement.assignment?.person || null } +type CreateMovementServiceInput = CreateMovementFormType & { + userId: string + stockDeltaSign?: 1 | -1 +} + export const MovementService = { findAll: async ({ page, pageSize }: { page?: number; pageSize?: number }) => { const result = await paginate({ @@ -174,7 +179,7 @@ export const MovementService = { } }, create: async ( - data: CreateMovementFormType & { userId: string }, + data: CreateMovementServiceInput, db: Prisma.TransactionClient | typeof prisma = prisma, ): Promise => { const { @@ -184,9 +189,11 @@ export const MovementService = { quantity, type, userId, + stockDeltaSign, ...rest } = data - const stockDelta = quantity * stockDeltaSignMap[type] + const sign = stockDeltaSign ?? stockDeltaSignMap[type] + const stockDelta = quantity * sign const item = itemId ? await db.item.findUnique({ where: { id: itemId }, diff --git a/src/use-cases/item.use-cases.ts b/src/use-cases/item.use-cases.ts index 9375800..eaa2c14 100644 --- a/src/use-cases/item.use-cases.ts +++ b/src/use-cases/item.use-cases.ts @@ -128,6 +128,7 @@ export async function updateItemUseCase( status, minStock, targetStock, + reason, } = input try { @@ -170,6 +171,18 @@ export async function updateItemUseCase( }, tx, ) + } else if (updatedStock < existingItem.stock) { + const isInventoryCorrection = reason === "INVENTORY_CORRECTION" + await MovementService.create( + { + type: isInventoryCorrection ? "ADJUSTMENT" : "OUT", + itemId: id, + quantity: existingItem.stock - updatedStock, + stockDeltaSign: isInventoryCorrection ? -1 : undefined, + userId: actorId, + }, + tx, + ) } return { diff --git a/tests/integration/use-cases/item.use-cases.test.ts b/tests/integration/use-cases/item.use-cases.test.ts index b6a5949..a80369a 100644 --- a/tests/integration/use-cases/item.use-cases.test.ts +++ b/tests/integration/use-cases/item.use-cases.test.ts @@ -179,6 +179,112 @@ describe("item use-cases", () => { }) }) + it("writes an ISSUE movement when a QUANTITY item stock decreases to zero", async () => { + const actor = await createTestUser(prisma) + const category = await createTestCategory(prisma) + + const created = await createItemUseCase({ + actorId: actor.id, + name: "Cable", + categoryId: category.id, + stock: 1, + }) + + expect(created).toEqual({ success: true }) + + const item = await prisma.item.findUniqueOrThrow({ + where: { sku: "CABLE" }, + }) + + const updated = await updateItemUseCase({ + actorId: actor.id, + id: item.id, + name: "Cable", + categoryId: category.id, + stock: 0, + }) + + expect(updated).toEqual({ success: true }) + + const refreshedItem = await prisma.item.findUniqueOrThrow({ + where: { id: item.id }, + }) + + expect(refreshedItem.stock).toBe(0) + + const movements = await prisma.inventoryMovement.findMany({ + where: { stockLines: { some: { itemId: item.id } } }, + include: { stockLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }) + + expect(movements).toHaveLength(2) + expect(movements[1]).toMatchObject({ + type: "ISSUE", + performedById: actor.id, + }) + expect(movements[1].stockLines[0]).toMatchObject({ + itemId: item.id, + stockDelta: -1, + previousStock: 1, + newStock: 0, + }) + }) + + it("writes an ADJUSTMENT movement with INVENTORY_CORRECTION reason when QUANTITY item decreases with reason", async () => { + const actor = await createTestUser(prisma) + const category = await createTestCategory(prisma) + + const created = await createItemUseCase({ + actorId: actor.id, + name: "Mouse", + categoryId: category.id, + stock: 5, + }) + + expect(created).toEqual({ success: true }) + + const item = await prisma.item.findUniqueOrThrow({ + where: { sku: "MOUSE" }, + }) + + const updated = await updateItemUseCase({ + actorId: actor.id, + id: item.id, + name: "Mouse", + categoryId: category.id, + stock: 4, + reason: "INVENTORY_CORRECTION", + }) + + expect(updated).toEqual({ success: true }) + + const refreshedItem = await prisma.item.findUniqueOrThrow({ + where: { id: item.id }, + }) + + expect(refreshedItem.stock).toBe(4) + + const movements = await prisma.inventoryMovement.findMany({ + where: { stockLines: { some: { itemId: item.id } } }, + include: { stockLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }) + + expect(movements).toHaveLength(2) + expect(movements[1]).toMatchObject({ + type: "ADJUSTMENT", + reason: "INVENTORY_CORRECTION", + performedById: actor.id, + }) + expect(movements[1].stockLines[0]).toMatchObject({ + itemId: item.id, + stockDelta: -1, + previousStock: 5, + newStock: 4, + }) + }) + it("blocks deleting items with stock and soft deletes empty items", async () => { const actor = await createTestUser(prisma) const category = await createTestCategory(prisma)