feat(items): emit ISSUE/ADJUSTMENT movement on stock decrease
This commit is contained in:
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user