feat(inventory): support line-based assignments and movements

This commit is contained in:
2026-06-19 01:05:33 +02:00
parent 8e6a00c2a9
commit 6d34a2f74f
17 changed files with 713 additions and 189 deletions
@@ -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 })
})
})