feat(items): emit ISSUE/ADJUSTMENT movement on stock decrease

This commit is contained in:
2026-06-25 03:03:26 +02:00
parent 0d38626f3a
commit 575cd2d9a0
4 changed files with 153 additions and 2 deletions
+25
View File
@@ -1,5 +1,6 @@
import { z } from "zod" import { z } from "zod"
import type { InventoryMovementReason } from "@/generated/prisma/client"
import type { Dictionary } from "@/i18n/dictionaries" import type { Dictionary } from "@/i18n/dictionaries"
export type ItemSchemaCopy = Dictionary["inventory"]["items"]["schema"] export type ItemSchemaCopy = Dictionary["inventory"]["items"]["schema"]
@@ -72,6 +73,29 @@ function buildOptionalStatusSchema(copy: ItemSchemaCopy) {
).optional() ).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<InventoryMovementReason, InventoryMovementReason>),
)
.optional()
}
export function buildCreateItemSchema(copy: ItemSchemaCopy) { export function buildCreateItemSchema(copy: ItemSchemaCopy) {
return z.object({ return z.object({
name: z.string().min(1, { name: z.string().min(1, {
@@ -106,6 +130,7 @@ export function buildUpdateItemSchema(copy: ItemSchemaCopy) {
}), }),
trackingType: buildOptionalTrackingTypeSchema(copy), trackingType: buildOptionalTrackingTypeSchema(copy),
status: buildOptionalStatusSchema(copy), status: buildOptionalStatusSchema(copy),
reason: buildOptionalReasonSchema(),
}) })
} }
+9 -2
View File
@@ -112,6 +112,11 @@ function getMovementPerson(movement: {
return movement.assignment?.person || null return movement.assignment?.person || null
} }
type CreateMovementServiceInput = CreateMovementFormType & {
userId: string
stockDeltaSign?: 1 | -1
}
export const MovementService = { export const MovementService = {
findAll: async ({ page, pageSize }: { page?: number; pageSize?: number }) => { findAll: async ({ page, pageSize }: { page?: number; pageSize?: number }) => {
const result = await paginate<InventoryMovementWithDetails>({ const result = await paginate<InventoryMovementWithDetails>({
@@ -174,7 +179,7 @@ export const MovementService = {
} }
}, },
create: async ( create: async (
data: CreateMovementFormType & { userId: string }, data: CreateMovementServiceInput,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Movement> => { ): Promise<Movement> => {
const { const {
@@ -184,9 +189,11 @@ export const MovementService = {
quantity, quantity,
type, type,
userId, userId,
stockDeltaSign,
...rest ...rest
} = data } = data
const stockDelta = quantity * stockDeltaSignMap[type] const sign = stockDeltaSign ?? stockDeltaSignMap[type]
const stockDelta = quantity * sign
const item = itemId const item = itemId
? await db.item.findUnique({ ? await db.item.findUnique({
where: { id: itemId }, where: { id: itemId },
+13
View File
@@ -128,6 +128,7 @@ export async function updateItemUseCase(
status, status,
minStock, minStock,
targetStock, targetStock,
reason,
} = input } = input
try { try {
@@ -170,6 +171,18 @@ export async function updateItemUseCase(
}, },
tx, 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 { return {
@@ -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 () => { it("blocks deleting items with stock and soft deletes empty items", async () => {
const actor = await createTestUser(prisma) const actor = await createTestUser(prisma)
const category = await createTestCategory(prisma) const category = await createTestCategory(prisma)