From a0a1e1bdc81b0e2b258790035b44d89b91fec296 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Thu, 25 Jun 2026 03:22:08 +0200 Subject: [PATCH] feat(movements): gate StockMovementLine on trackingType QUANTITY --- src/services/movement.service.ts | 4 +- src/use-cases/asset.use-cases.ts | 5 +- src/use-cases/assignment.use-cases.ts | 22 +++-- src/use-cases/item.use-cases.ts | 16 ++- .../use-cases/asset.use-cases.test.ts | 97 +++++++++++++++++++ .../use-cases/assignment.use-cases.test.ts | 51 ++++++++++ .../use-cases/item.use-cases.test.ts | 78 +++++++++++++++ 7 files changed, 257 insertions(+), 16 deletions(-) diff --git a/src/services/movement.service.ts b/src/services/movement.service.ts index aff9b4a..14639ab 100644 --- a/src/services/movement.service.ts +++ b/src/services/movement.service.ts @@ -197,7 +197,7 @@ export const MovementService = { const item = itemId ? await db.item.findUnique({ where: { id: itemId }, - select: { stock: true }, + select: { stock: true, trackingType: true }, }) : null const asset = assetId @@ -214,7 +214,7 @@ export const MovementService = { reason: movementReasonMap[type], performedById: userId, stockLines: - itemId && item + itemId && item && item.trackingType === "QUANTITY" ? { create: { itemId, diff --git a/src/use-cases/asset.use-cases.ts b/src/use-cases/asset.use-cases.ts index 68c6e31..7bf8f94 100644 --- a/src/use-cases/asset.use-cases.ts +++ b/src/use-cases/asset.use-cases.ts @@ -173,7 +173,7 @@ export async function createAssetUseCase( ) : null - if (status === "AVAILABLE") { + if (status === "AVAILABLE" && item.trackingType === "QUANTITY") { await ItemService.updateStock(itemId, 1, tx) } @@ -290,10 +290,13 @@ export async function updateAssetUseCase( tx, ) + const isSerializedItem = item.trackingType === "SERIALIZED" const shouldIncrementNextItemStock = + !isSerializedItem && transition.willBeAvailable && (!transition.wasAvailable || transition.itemChanged) const shouldDecrementPreviousItemStock = + !isSerializedItem && transition.wasAvailable && (!transition.willBeAvailable || transition.itemChanged) diff --git a/src/use-cases/assignment.use-cases.ts b/src/use-cases/assignment.use-cases.ts index 0769f58..f619f54 100644 --- a/src/use-cases/assignment.use-cases.ts +++ b/src/use-cases/assignment.use-cases.ts @@ -146,7 +146,7 @@ export async function createAssignmentUseCase( return createAssignmentError({ itemId: ["Item not found"] }) } - if (item.stock < quantity) { + if (item.trackingType === "QUANTITY" && item.stock < quantity) { return createAssignmentError({ quantity: ["Item does not have enough stock"], }) @@ -166,16 +166,18 @@ export async function createAssignmentUseCase( } } - const stockWasDecremented = await ItemService.decrementStockIfAvailable( - itemId, - quantity, - tx, - ) + if (item.trackingType === "QUANTITY") { + const stockWasDecremented = await ItemService.decrementStockIfAvailable( + itemId, + quantity, + tx, + ) - if (!stockWasDecremented) { - return createAssignmentError({ - quantity: ["Item does not have enough stock"], - }) + if (!stockWasDecremented) { + return createAssignmentError({ + quantity: ["Item does not have enough stock"], + }) + } } if (assetId) { diff --git a/src/use-cases/item.use-cases.ts b/src/use-cases/item.use-cases.ts index eaa2c14..9c5a3e6 100644 --- a/src/use-cases/item.use-cases.ts +++ b/src/use-cases/item.use-cases.ts @@ -85,12 +85,12 @@ export async function createItemUseCase( minStock, targetStock, category: { connect: { id: categoryId } }, - stock: stock || 0, + stock: trackingType === "SERIALIZED" ? 0 : stock || 0, }, tx, ) - if (stock > 0) { + if (trackingType === "QUANTITY" && stock > 0) { await MovementService.create( { type: "IN", @@ -145,10 +145,14 @@ export async function updateItemUseCase( return itemError({ name: ["An item with this name already exists"] }) } + const effectiveTrackingType = + trackingType ?? existingItem.trackingType + const isSerialized = effectiveTrackingType === "SERIALIZED" + await ItemService.update( id, { - stock: stock ?? existingItem.stock, + stock: isSerialized ? 0 : (stock ?? existingItem.stock), name: name || existingItem.name, trackingType: trackingType ?? existingItem.trackingType, status: status ?? existingItem.status, @@ -159,6 +163,12 @@ export async function updateItemUseCase( tx, ) + if (isSerialized) { + return { + success: true, + } + } + const updatedStock = stock ?? existingItem.stock if (updatedStock > existingItem.stock) { diff --git a/tests/integration/use-cases/asset.use-cases.test.ts b/tests/integration/use-cases/asset.use-cases.test.ts index d09fc9e..bd3a975 100644 --- a/tests/integration/use-cases/asset.use-cases.test.ts +++ b/tests/integration/use-cases/asset.use-cases.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import type { PrismaClient } from "@/generated/prisma/client" import { + createTestCategory, createTestItem, createTestPerson, createTestUser, @@ -185,6 +186,102 @@ describe("asset use-cases", () => { expect(await AssetService.findAll()).toHaveLength(0) }) + it("creates a SERIALIZED asset in AVAILABLE status without writing a stock line or incrementing stock", async () => { + const actor = await createTestUser(prisma) + const category = await createTestCategory(prisma) + const item = await prisma.item.create({ + data: { + sku: "PHONE-SKU", + name: "Phone", + trackingType: "SERIALIZED", + stock: 0, + category: { connect: { id: category.id } }, + }, + }) + + const result = await createAssetUseCase({ + actorId: actor.id, + itemId: item.id, + serialNumber: "SERIALIZED-AVAILABLE-001", + status: "AVAILABLE", + }) + + expect(result.success).toBe(true) + if (!result.success) throw new Error("Expected asset creation success") + + const [updatedItem, movements] = await Promise.all([ + prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + prisma.inventoryMovement.findMany({ + include: { stockLines: true, assetLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(updatedItem.stock).toBe(0) + expect(movements).toHaveLength(1) + expect(movements[0]).toMatchObject({ + type: "RECEIPT", + performedById: actor.id, + }) + expect(movements[0].stockLines).toEqual([]) + expect(movements[0].assetLines).toHaveLength(1) + expect(movements[0].assetLines[0]).toMatchObject({ + assetId: result.assetId, + }) + }) + + it("updates a SERIALIZED asset from AVAILABLE to ASSIGNED without writing a stock line", async () => { + const actor = await createTestUser(prisma) + const person = await createTestPerson(prisma) + const category = await createTestCategory(prisma) + const item = await prisma.item.create({ + data: { + sku: "LAPTOP-SKU", + name: "Laptop", + trackingType: "SERIALIZED", + stock: 0, + category: { connect: { id: category.id } }, + }, + }) + + const created = await createAssetUseCase({ + actorId: actor.id, + itemId: item.id, + serialNumber: "SERIALIZED-ASSIGN-001", + status: "AVAILABLE", + }) + + expect(created.success).toBe(true) + if (!created.success) throw new Error("Expected asset creation success") + + const updated = await updateAssetUseCase({ + actorId: actor.id, + id: created.assetId, + itemId: item.id, + serialNumber: "SERIALIZED-ASSIGN-001", + status: "ASSIGNED", + personId: person.id, + }) + + expect(updated.success).toBe(true) + + const [updatedItem, movements] = await Promise.all([ + prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + prisma.inventoryMovement.findMany({ + include: { stockLines: true, assetLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(updatedItem.stock).toBe(0) + expect(movements).toHaveLength(2) + expect(movements[1].stockLines).toEqual([]) + expect(movements[1].assetLines).toHaveLength(1) + expect(movements[1].assetLines[0]).toMatchObject({ + assetId: created.assetId, + }) + }) + it("creates an assigned asset with assignment and ASSIGNMENT movement", async () => { const actor = await createTestUser(prisma) const person = await createTestPerson(prisma) diff --git a/tests/integration/use-cases/assignment.use-cases.test.ts b/tests/integration/use-cases/assignment.use-cases.test.ts index 4452a8f..d7b4b49 100644 --- a/tests/integration/use-cases/assignment.use-cases.test.ts +++ b/tests/integration/use-cases/assignment.use-cases.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import type { PrismaClient } from "@/generated/prisma/client" import { + createTestCategory, createTestItem, createTestPerson, createTestUser, @@ -38,6 +39,56 @@ afterAll(async () => { }) describe("assignment use-cases", () => { + it("creates a SERIALIZED assignment without writing a stock line", async () => { + const actor = await createTestUser(prisma) + const person = await createTestPerson(prisma) + const category = await createTestCategory(prisma) + const item = await prisma.item.create({ + data: { + sku: "SERIALIZED-ASSIGNMENT-SKU", + name: "Serial Item", + trackingType: "SERIALIZED", + stock: 0, + category: { connect: { id: category.id } }, + }, + }) + const asset = await prisma.asset.create({ + data: { + serialNumber: "SERIALIZED-ASSIGNMENT-ASSET-001", + itemId: item.id, + status: "AVAILABLE", + }, + }) + + const result = await createAssignmentUseCase({ + actorId: actor.id, + personId: person.id, + assetId: asset.id, + lines: [{ itemId: item.id, quantity: 1 }], + }) + + expect(result.success).toBe(true) + if (!result.success) throw new Error("Expected assignment creation success") + + const [updatedItem, updatedAsset, movements] = await Promise.all([ + prisma.item.findUniqueOrThrow({ where: { id: item.id } }), + prisma.asset.findUniqueOrThrow({ where: { id: asset.id } }), + prisma.inventoryMovement.findMany({ + include: { stockLines: true, assetLines: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ]) + + expect(updatedItem.stock).toBe(0) + expect(updatedAsset.status).toBe("ASSIGNED") + expect(movements).toHaveLength(1) + expect(movements[0].stockLines).toEqual([]) + expect(movements[0].assetLines).toHaveLength(1) + expect(movements[0].assetLines[0]).toMatchObject({ + assetId: asset.id, + }) + }) + it("creates an assignment, decrements stock, and records an ASSIGNMENT movement", async () => { const actor = await createTestUser(prisma) const person = await createTestPerson(prisma) diff --git a/tests/integration/use-cases/item.use-cases.test.ts b/tests/integration/use-cases/item.use-cases.test.ts index a80369a..7735265 100644 --- a/tests/integration/use-cases/item.use-cases.test.ts +++ b/tests/integration/use-cases/item.use-cases.test.ts @@ -151,6 +151,12 @@ describe("item use-cases", () => { minStock: 2, targetStock: 8, }) + + const stockMovements = await prisma.inventoryMovement.findMany({ + where: { stockLines: { some: { itemId: item.id } } }, + }) + + expect(stockMovements).toHaveLength(1) }) it("rejects duplicate item names", async () => { @@ -285,6 +291,78 @@ describe("item use-cases", () => { }) }) + it("creates a SERIALIZED item with stock value and persists stock as 0 with no movement", async () => { + const actor = await createTestUser(prisma) + const category = await createTestCategory(prisma) + + const result = await createItemUseCase({ + actorId: actor.id, + name: "Phone", + categoryId: category.id, + stock: 3, + trackingType: "SERIALIZED", + }) + + expect(result).toEqual({ success: true }) + + const item = await prisma.item.findUniqueOrThrow({ + where: { sku: "PHONE" }, + }) + + expect(item).toMatchObject({ + stock: 0, + trackingType: "SERIALIZED", + }) + + const movements = await prisma.inventoryMovement.findMany({ + where: { stockLines: { some: { itemId: item.id } } }, + }) + + expect(movements).toHaveLength(0) + }) + + it("updates a SERIALIZED item with stock value and leaves stock unchanged with no movement", async () => { + const actor = await createTestUser(prisma) + const category = await createTestCategory(prisma) + + const created = await createItemUseCase({ + actorId: actor.id, + name: "Tablet", + categoryId: category.id, + stock: 0, + trackingType: "SERIALIZED", + }) + + expect(created).toEqual({ success: true }) + + const item = await prisma.item.findUniqueOrThrow({ + where: { sku: "TABLET" }, + }) + + const updated = await updateItemUseCase({ + actorId: actor.id, + id: item.id, + name: "Tablet", + categoryId: category.id, + stock: 5, + trackingType: "SERIALIZED", + }) + + 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 } } }, + }) + + expect(movements).toHaveLength(0) + }) + it("blocks deleting items with stock and soft deletes empty items", async () => { const actor = await createTestUser(prisma) const category = await createTestCategory(prisma)