feat(items): emit ISSUE/ADJUSTMENT movement on stock decrease
This commit is contained in:
@@ -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<InventoryMovementReason, InventoryMovementReason>),
|
||||
)
|
||||
.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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<InventoryMovementWithDetails>({
|
||||
@@ -174,7 +179,7 @@ export const MovementService = {
|
||||
}
|
||||
},
|
||||
create: async (
|
||||
data: CreateMovementFormType & { userId: string },
|
||||
data: CreateMovementServiceInput,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Movement> => {
|
||||
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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user