feat(inventory): support line-based assignments and movements
This commit is contained in:
@@ -3,6 +3,8 @@ import type {
|
||||
PrismaClient,
|
||||
UserRole,
|
||||
} from "@/generated/prisma/client"
|
||||
import { UserStatus } from "@/generated/prisma/client"
|
||||
import { normalizeEmail } from "@/lib/email"
|
||||
|
||||
let sequence = 0
|
||||
|
||||
@@ -21,14 +23,20 @@ export async function createTestUser(
|
||||
}> = {},
|
||||
) {
|
||||
const suffix = nextSuffix()
|
||||
const email = overrides.email ?? `test-user-${suffix}@example.test`
|
||||
const status =
|
||||
(overrides.isActive ?? true) ? UserStatus.ACTIVE : UserStatus.DISABLED
|
||||
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
email: overrides.email ?? `test-user-${suffix}@example.test`,
|
||||
email,
|
||||
emailNormalized: normalizeEmail(email),
|
||||
name: overrides.name ?? "Test User",
|
||||
password: "hashed-password",
|
||||
passwordHash: "hashed-password",
|
||||
role: overrides.role ?? "ADMIN",
|
||||
isActive: overrides.isActive ?? true,
|
||||
status,
|
||||
...(status === UserStatus.ACTIVE ? { activatedAt: new Date() } : {}),
|
||||
passwordChangedAt: new Date(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -83,7 +91,9 @@ export async function createTestItem(
|
||||
|
||||
return prisma.item.create({
|
||||
data: {
|
||||
sku: `TEST-SKU-${suffix}`,
|
||||
name: overrides.name ?? `Test Item ${suffix}`,
|
||||
trackingType: "QUANTITY",
|
||||
stock: overrides.stock ?? 0,
|
||||
category: { connect: { id: categoryId } },
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ type TestDatabaseState = {
|
||||
const state: TestDatabaseState = {}
|
||||
|
||||
const TABLES_TO_TRUNCATE = [
|
||||
"Movement",
|
||||
"InventoryMovement",
|
||||
"Assignment",
|
||||
"Asset",
|
||||
"Item",
|
||||
|
||||
@@ -55,7 +55,8 @@ describe("asset use-cases", () => {
|
||||
const [asset, updatedItem, movements] = await Promise.all([
|
||||
prisma.asset.findUniqueOrThrow({ where: { id: result.assetId } }),
|
||||
prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
|
||||
prisma.movement.findMany({
|
||||
prisma.inventoryMovement.findMany({
|
||||
include: { stockLines: true, assetLines: true },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
}),
|
||||
])
|
||||
@@ -70,12 +71,11 @@ describe("asset use-cases", () => {
|
||||
expect(updatedItem.stock).toBe(1)
|
||||
expect(movements).toHaveLength(1)
|
||||
expect(movements[0]).toMatchObject({
|
||||
type: "IN",
|
||||
itemId: item.id,
|
||||
assetId: result.assetId,
|
||||
quantity: 1,
|
||||
userId: actor.id,
|
||||
type: "RECEIPT",
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 })
|
||||
expect(movements[0].assetLines[0]).toMatchObject({ assetId: result.assetId })
|
||||
})
|
||||
|
||||
it("creates an assigned asset with assignment and ASSIGNMENT movement", async () => {
|
||||
@@ -98,9 +98,14 @@ describe("asset use-cases", () => {
|
||||
prisma.asset.findUniqueOrThrow({ where: { id: result.assetId } }),
|
||||
prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
|
||||
prisma.assignment.findFirstOrThrow({
|
||||
where: { assetId: result.assetId, returnDate: null },
|
||||
where: {
|
||||
status: "OPEN",
|
||||
assetLines: { some: { assetId: result.assetId, returnedAt: null } },
|
||||
},
|
||||
include: { assetLines: true },
|
||||
}),
|
||||
prisma.movement.findMany({
|
||||
prisma.inventoryMovement.findMany({
|
||||
include: { stockLines: true, assetLines: true },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
}),
|
||||
])
|
||||
@@ -112,23 +117,19 @@ describe("asset use-cases", () => {
|
||||
})
|
||||
expect(updatedItem.stock).toBe(0)
|
||||
expect(assignment).toMatchObject({
|
||||
itemId: item.id,
|
||||
assetId: result.assetId,
|
||||
personId: person.id,
|
||||
quantity: 1,
|
||||
createdBy: actor.id,
|
||||
returnDate: null,
|
||||
createdById: actor.id,
|
||||
closedAt: null,
|
||||
})
|
||||
expect(assignment.assetLines[0]).toMatchObject({ assetId: result.assetId })
|
||||
expect(movements).toHaveLength(1)
|
||||
expect(movements[0]).toMatchObject({
|
||||
type: "ASSIGNMENT",
|
||||
itemId: item.id,
|
||||
assetId: result.assetId,
|
||||
personId: person.id,
|
||||
assignmentId: assignment.id,
|
||||
quantity: 1,
|
||||
userId: actor.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[0].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -1 })
|
||||
expect(movements[0].assetLines[0]).toMatchObject({ assetId: result.assetId })
|
||||
})
|
||||
|
||||
it("moves an available asset to assigned and back to available", async () => {
|
||||
@@ -161,18 +162,20 @@ describe("asset use-cases", () => {
|
||||
prisma.asset.findUniqueOrThrow({ where: { id: created.assetId } }),
|
||||
prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
|
||||
prisma.assignment.findFirstOrThrow({
|
||||
where: { assetId: created.assetId, returnDate: null },
|
||||
where: {
|
||||
status: "OPEN",
|
||||
assetLines: { some: { assetId: created.assetId, returnedAt: null } },
|
||||
},
|
||||
include: { assetLines: true },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(assignedAsset.status).toBe("ASSIGNED")
|
||||
expect(assignedItem.stock).toBe(0)
|
||||
expect(activeAssignment).toMatchObject({
|
||||
itemId: item.id,
|
||||
assetId: created.assetId,
|
||||
personId: person.id,
|
||||
quantity: 1,
|
||||
})
|
||||
expect(activeAssignment.assetLines[0]).toMatchObject({ assetId: created.assetId })
|
||||
|
||||
await expect(
|
||||
updateAssetUseCase({
|
||||
@@ -191,41 +194,37 @@ describe("asset use-cases", () => {
|
||||
prisma.assignment.findUniqueOrThrow({
|
||||
where: { id: activeAssignment.id },
|
||||
}),
|
||||
prisma.movement.findMany({
|
||||
prisma.inventoryMovement.findMany({
|
||||
include: { stockLines: true, assetLines: true },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
}),
|
||||
])
|
||||
|
||||
expect(availableAsset.status).toBe("AVAILABLE")
|
||||
expect(availableItem.stock).toBe(1)
|
||||
expect(returnedAssignment.returnDate).toBeInstanceOf(Date)
|
||||
expect(returnedAssignment.closedAt).toBeInstanceOf(Date)
|
||||
expect(returnedAssignment).toMatchObject({
|
||||
itemId: null,
|
||||
assetId: null,
|
||||
personId: null,
|
||||
quantity: null,
|
||||
personId: person.id,
|
||||
status: "RETURNED",
|
||||
})
|
||||
expect(movements).toHaveLength(3)
|
||||
expect(movements.map((movement) => movement.type)).toEqual([
|
||||
"IN",
|
||||
"RECEIPT",
|
||||
"ASSIGNMENT",
|
||||
"RETURN",
|
||||
])
|
||||
expect(movements[1]).toMatchObject({
|
||||
itemId: item.id,
|
||||
assetId: created.assetId,
|
||||
personId: person.id,
|
||||
assignmentId: activeAssignment.id,
|
||||
quantity: 1,
|
||||
userId: actor.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[1].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: -1 })
|
||||
expect(movements[1].assetLines[0]).toMatchObject({ assetId: created.assetId })
|
||||
expect(movements[2]).toMatchObject({
|
||||
itemId: item.id,
|
||||
assetId: created.assetId,
|
||||
assignmentId: activeAssignment.id,
|
||||
quantity: 1,
|
||||
userId: actor.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[2].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 })
|
||||
expect(movements[2].assetLines[0]).toMatchObject({ assetId: created.assetId })
|
||||
})
|
||||
|
||||
it("returns an active assignment without restoring stock when an assigned asset moves to a terminal status", async () => {
|
||||
@@ -245,7 +244,10 @@ describe("asset use-cases", () => {
|
||||
if (!created.success) throw new Error("Expected asset creation success")
|
||||
|
||||
const activeAssignment = await prisma.assignment.findFirstOrThrow({
|
||||
where: { assetId: created.assetId, returnDate: null },
|
||||
where: {
|
||||
status: "OPEN",
|
||||
assetLines: { some: { assetId: created.assetId, returnedAt: null } },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -265,14 +267,15 @@ describe("asset use-cases", () => {
|
||||
prisma.assignment.findUniqueOrThrow({
|
||||
where: { id: activeAssignment.id },
|
||||
}),
|
||||
prisma.movement.findMany({
|
||||
prisma.inventoryMovement.findMany({
|
||||
include: { stockLines: true, assetLines: true },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
}),
|
||||
])
|
||||
|
||||
expect(asset.status).toBe("BROKEN")
|
||||
expect(itemAfterUpdate.stock).toBe(0)
|
||||
expect(returnedAssignment.returnDate).toBeInstanceOf(Date)
|
||||
expect(returnedAssignment.closedAt).toBeInstanceOf(Date)
|
||||
expect(movements).toHaveLength(2)
|
||||
expect(movements.map((movement) => movement.type)).toEqual([
|
||||
"ASSIGNMENT",
|
||||
@@ -280,12 +283,10 @@ describe("asset use-cases", () => {
|
||||
])
|
||||
expect(movements[1]).toMatchObject({
|
||||
type: "RETURN",
|
||||
itemId: item.id,
|
||||
assetId: created.assetId,
|
||||
personId: person.id,
|
||||
assignmentId: activeAssignment.id,
|
||||
quantity: 1,
|
||||
userId: actor.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[1].stockLines[0]).toMatchObject({ itemId: item.id, stockDelta: 1 })
|
||||
expect(movements[1].assetLines[0]).toMatchObject({ assetId: created.assetId })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,30 +59,36 @@ describe("assignment use-cases", () => {
|
||||
prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
|
||||
prisma.assignment.findUniqueOrThrow({
|
||||
where: { id: result.assignmentId },
|
||||
include: { stockLines: true },
|
||||
}),
|
||||
prisma.movement.findMany({
|
||||
prisma.inventoryMovement.findMany({
|
||||
include: { stockLines: true },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
}),
|
||||
])
|
||||
|
||||
expect(updatedItem.stock).toBe(3)
|
||||
expect(assignment).toMatchObject({
|
||||
itemId: item.id,
|
||||
personId: person.id,
|
||||
quantity: 2,
|
||||
notes: "Initial assignment",
|
||||
createdBy: actor.id,
|
||||
returnDate: null,
|
||||
createdById: actor.id,
|
||||
closedAt: null,
|
||||
})
|
||||
expect(assignment.assignedAt).toEqual(assignmentDate)
|
||||
expect(assignment.stockLines[0]).toMatchObject({
|
||||
itemId: item.id,
|
||||
quantity: 2,
|
||||
returnedQuantity: 0,
|
||||
})
|
||||
expect(assignment.assignmentDate).toEqual(assignmentDate)
|
||||
expect(movements).toHaveLength(1)
|
||||
expect(movements[0]).toMatchObject({
|
||||
type: "ASSIGNMENT",
|
||||
itemId: item.id,
|
||||
personId: person.id,
|
||||
assignmentId: result.assignmentId,
|
||||
quantity: 2,
|
||||
userId: actor.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[0].stockLines[0]).toMatchObject({
|
||||
itemId: item.id,
|
||||
stockDelta: -2,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,7 +115,7 @@ describe("assignment use-cases", () => {
|
||||
prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
|
||||
).resolves.toMatchObject({ stock: 1 })
|
||||
await expect(prisma.assignment.count()).resolves.toBe(0)
|
||||
await expect(prisma.movement.count()).resolves.toBe(0)
|
||||
await expect(prisma.inventoryMovement.count()).resolves.toBe(0)
|
||||
})
|
||||
|
||||
it("returns an assignment, restores stock, closes it, and records a RETURN movement", async () => {
|
||||
@@ -139,34 +145,43 @@ describe("assignment use-cases", () => {
|
||||
prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
|
||||
prisma.assignment.findUniqueOrThrow({
|
||||
where: { id: created.assignmentId },
|
||||
include: { stockLines: true },
|
||||
}),
|
||||
prisma.movement.findMany({
|
||||
prisma.inventoryMovement.findMany({
|
||||
include: { stockLines: true },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
}),
|
||||
])
|
||||
|
||||
expect(updatedItem.stock).toBe(4)
|
||||
expect(assignment.returnDate).toBeInstanceOf(Date)
|
||||
expect(assignment.closedAt).toBeInstanceOf(Date)
|
||||
expect(assignment).toMatchObject({
|
||||
itemId: null,
|
||||
assetId: null,
|
||||
personId: null,
|
||||
quantity: null,
|
||||
personId: person.id,
|
||||
status: "RETURNED",
|
||||
})
|
||||
expect(assignment.stockLines[0]).toMatchObject({
|
||||
itemId: item.id,
|
||||
quantity: 3,
|
||||
returnedQuantity: 3,
|
||||
})
|
||||
expect(movements).toHaveLength(2)
|
||||
expect(movements[0]).toMatchObject({
|
||||
type: "ASSIGNMENT",
|
||||
itemId: item.id,
|
||||
assignmentId: created.assignmentId,
|
||||
quantity: 3,
|
||||
userId: actor.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[0].stockLines[0]).toMatchObject({
|
||||
itemId: item.id,
|
||||
stockDelta: -3,
|
||||
})
|
||||
expect(movements[1]).toMatchObject({
|
||||
type: "RETURN",
|
||||
itemId: item.id,
|
||||
assignmentId: created.assignmentId,
|
||||
quantity: 3,
|
||||
userId: actor.id,
|
||||
performedById: actor.id,
|
||||
})
|
||||
expect(movements[1].stockLines[0]).toMatchObject({
|
||||
itemId: item.id,
|
||||
stockDelta: 3,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -197,6 +212,6 @@ describe("assignment use-cases", () => {
|
||||
errors: { id: ["Assignment already returned"] },
|
||||
})
|
||||
|
||||
await expect(prisma.movement.count()).resolves.toBe(2)
|
||||
await expect(prisma.inventoryMovement.count()).resolves.toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,8 +46,8 @@ describe("item use-cases", () => {
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { name: "Laptop" },
|
||||
include: { movements: true },
|
||||
where: { sku: "LAPTOP" },
|
||||
include: { stockMovementLines: { include: { movement: true } } },
|
||||
})
|
||||
|
||||
expect(item).toMatchObject({
|
||||
@@ -56,11 +56,13 @@ describe("item use-cases", () => {
|
||||
stock: 3,
|
||||
deletedAt: null,
|
||||
})
|
||||
expect(item?.movements).toHaveLength(1)
|
||||
expect(item?.movements[0]).toMatchObject({
|
||||
type: "IN",
|
||||
quantity: 3,
|
||||
userId: actor.id,
|
||||
expect(item?.stockMovementLines).toHaveLength(1)
|
||||
expect(item?.stockMovementLines[0]).toMatchObject({
|
||||
stockDelta: 3,
|
||||
})
|
||||
expect(item?.stockMovementLines[0]?.movement).toMatchObject({
|
||||
type: "RECEIPT",
|
||||
performedById: actor.id,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +104,7 @@ describe("item use-cases", () => {
|
||||
})
|
||||
|
||||
const stockedItem = await prisma.item.findUniqueOrThrow({
|
||||
where: { name: "Keyboard" },
|
||||
where: { sku: "KEYBOARD" },
|
||||
})
|
||||
|
||||
await expect(deleteItemUseCase(stockedItem.id)).resolves.toEqual({
|
||||
@@ -118,7 +120,7 @@ describe("item use-cases", () => {
|
||||
})
|
||||
|
||||
const emptyItem = await prisma.item.findUniqueOrThrow({
|
||||
where: { name: "Mouse" },
|
||||
where: { sku: "MOUSE" },
|
||||
})
|
||||
|
||||
await expect(deleteItemUseCase(emptyItem.id)).resolves.toEqual({
|
||||
|
||||
Reference in New Issue
Block a user