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
+6
View File
@@ -248,7 +248,13 @@ export async function importItems(formData: ImportFormType) {
if (!existingItem) {
newItem = await ItemService.create({
sku: name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, ""),
name,
trackingType: "QUANTITY",
stock: assigned ? 0 : stock || 0,
category: {
connect: { id: categoryId ? categoryId : newCategory?.id || "" },
+2 -1
View File
@@ -17,11 +17,12 @@ const createAssetStatuses = ["AVAILABLE", "ASSIGNED"] as const
const updateAssetStatuses = [
"AVAILABLE",
"ASSIGNED",
"RESERVED",
"IN_REPAIR",
"BROKEN",
"LOST",
"STOLEN",
"DISPOSED",
"RETIRED",
] as const
function buildAssetBaseSchema(copy: AssetSchemaCopy) {
+53 -4
View File
@@ -6,6 +6,35 @@ import type {
AssetWithItemAndCategory,
} from "@/types/asset"
type AssetWithActiveAssignmentLine = Prisma.AssetGetPayload<{
include: {
item: true
assignmentLines: { include: { assignment: true } }
}
}>
function toAssetWithAssignment(
asset: AssetWithActiveAssignmentLine | null,
): AssetWithAssignment | null {
if (!asset) return null
const activeAssignment = asset.assignmentLines[0]?.assignment ?? null
return {
...asset,
assignment: activeAssignment
? {
...activeAssignment,
assignmentDate: activeAssignment.assignedAt,
returnDate: activeAssignment.closedAt,
itemId: asset.itemId,
assetId: asset.id,
quantity: 1,
}
: null,
}
}
export const AssetService = {
findAll: async (opts?: {
includeItem?: boolean
@@ -97,10 +126,20 @@ export const AssetService = {
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<AssetWithAssignment | null> => {
return db.asset.findUnique({
const asset = await db.asset.findUnique({
where: { id },
include: { item: true, assignment: true },
include: {
item: true,
assignmentLines: {
where: { returnedAt: null },
include: { assignment: true },
orderBy: { assignedAt: "desc" },
take: 1,
},
},
})
return toAssetWithAssignment(asset)
},
findByItemId: async (itemId: string): Promise<Asset[]> => {
@@ -111,10 +150,20 @@ export const AssetService = {
serialNumber: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<AssetWithAssignment | null> => {
return db.asset.findUnique({
const asset = await db.asset.findUnique({
where: { serialNumber },
include: { item: true, assignment: true },
include: {
item: true,
assignmentLines: {
where: { returnedAt: null },
include: { assignment: true },
orderBy: { assignedAt: "desc" },
take: 1,
},
},
})
return toAssetWithAssignment(asset)
},
create: async (
+230 -56
View File
@@ -4,23 +4,79 @@ import prisma from "@/lib/prisma"
import type { CreateAssignmentData } from "@/schemas/assignment.schema"
import type { Assignment, AssignmentWithPersonItemAsset } from "@/types"
type LegacyAssignmentWriteData = CreateAssignmentData & {
createdBy?: string
createdById?: string
}
type LegacyAssignmentUpdateData = Partial<LegacyAssignmentWriteData> & {
returnDate?: Date | null
}
type AssignmentWithLines = Prisma.AssignmentGetPayload<{
include: {
person: true
stockLines: { include: { item: true } }
assetLines: { include: { asset: { include: { item: true } } } }
}
}>
const assignmentInclude = {
person: true,
stockLines: { include: { item: true } },
assetLines: { include: { asset: { include: { item: true } } } },
} satisfies Prisma.AssignmentInclude
function toLegacyAssignment(
assignment: AssignmentWithLines,
): AssignmentWithPersonItemAsset {
const stockLine = assignment.stockLines[0]
const assetLine = assignment.assetLines[0]
const item = stockLine?.item ?? assetLine?.asset.item ?? null
const asset = assetLine?.asset ?? null
const quantity = stockLine?.quantity ?? (assetLine ? 1 : null)
const returnDate = assignment.closedAt ?? assetLine?.returnedAt ?? null
return {
...assignment,
assignmentDate: assignment.assignedAt,
returnDate,
person: assignment.person,
item,
asset,
itemId: item?.id ?? null,
assetId: asset?.id ?? null,
quantity,
}
}
function toLegacyAssignmentRecord(
assignment: Prisma.AssignmentGetPayload<{}>,
data: Pick<LegacyAssignmentUpdateData, "itemId" | "assetId" | "quantity">,
): Assignment {
return {
...assignment,
assignmentDate: assignment.assignedAt,
returnDate: assignment.closedAt,
itemId: data.itemId ?? null,
assetId: data.assetId ?? null,
quantity: data.quantity ?? null,
}
}
export const AssignmentService = {
findAllWithPerson: async (): Promise<AssignmentWithPersonItemAsset[]> => {
return prisma.assignment.findMany({
const assignments = await prisma.assignment.findMany({
where: {
returnDate: {
equals: null,
},
},
include: {
person: true,
item: true,
asset: true,
status: { in: ["OPEN", "PARTIALLY_RETURNED"] },
},
include: assignmentInclude,
orderBy: {
createdAt: "desc",
},
})
return assignments.map(toLegacyAssignment)
},
findAllWithPersonPaginated: async ({
page,
@@ -31,14 +87,12 @@ export const AssignmentService = {
pageSize?: number
search?: string
}) => {
return paginate<AssignmentWithPersonItemAsset>({
const result = await paginate<AssignmentWithLines>({
model: prisma.assignment,
page,
pageSize,
where: {
returnDate: {
equals: null,
},
status: { in: ["OPEN", "PARTIALLY_RETURNED"] },
...(search
? {
OR: [
@@ -56,93 +110,213 @@ export const AssignmentService = {
}
: {}),
},
include: {
person: true,
item: true,
asset: true,
},
include: assignmentInclude,
orderBy: {
createdAt: "desc",
},
})
return {
...result,
data: result.data.map(toLegacyAssignment),
}
},
findById: async (
id: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<AssignmentWithPersonItemAsset | null> => {
return db.assignment.findUnique({
const assignment = await db.assignment.findUnique({
where: { id },
include: {
person: true,
item: true,
asset: true,
},
include: assignmentInclude,
})
return assignment ? toLegacyAssignment(assignment) : null
},
findAllByPerson: async (
personId: string,
): Promise<AssignmentWithPersonItemAsset[]> => {
return prisma.assignment.findMany({
where: { personId: personId },
include: {
person: true,
item: true,
asset: true,
},
const assignments = await prisma.assignment.findMany({
where: { personId },
include: assignmentInclude,
})
return assignments.map(toLegacyAssignment)
},
create: async (
data: CreateAssignmentData & { createdBy: string },
data: LegacyAssignmentWriteData,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Assignment> => {
const { personId, ...rest } = data
return db.assignment.create({
const {
personId,
itemId,
assetId,
quantity,
assignmentDate,
createdBy,
createdById,
notes,
} = data
const assignment = await db.assignment.create({
data: {
...rest,
personId: personId,
personId,
notes,
assignedAt: assignmentDate,
createdById: createdById ?? createdBy ?? "",
stockLines:
!assetId && itemId
? {
create: {
itemId,
quantity,
notes,
},
}
: undefined,
assetLines: assetId
? {
create: {
assetId,
assignedAt: assignmentDate,
notes,
},
}
: undefined,
},
})
return toLegacyAssignmentRecord(assignment, { itemId, assetId, quantity })
},
delete: async (id: string): Promise<Assignment> => {
return prisma.assignment.update({
where: { id },
data: {
returnDate: new Date(),
personId: null,
quantity: null,
assetId: null,
itemId: null,
},
const closedAt = new Date()
return prisma.$transaction(async (tx) => {
const assignmentWithAssetLines = await tx.assignment.findUniqueOrThrow({
where: { id },
include: { assetLines: { include: { asset: true } } },
})
await Promise.all(
assignmentWithAssetLines.assetLines
.filter((line) => !line.returnedAt)
.map((line) =>
tx.assignmentAssetLine.update({
where: { id: line.id },
data: {
returnedAt: closedAt,
returnedById: assignmentWithAssetLines.createdById,
returnStatus: line.asset.status,
},
}),
),
)
const assignment = await tx.assignment.update({
where: { id },
data: {
status: "RETURNED",
closedAt,
},
})
return toLegacyAssignmentRecord(assignment, {})
})
},
markReturnedIfActive: async (
id: string,
actorId?: string,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<boolean> => {
const result = await db.assignment.updateMany({
const closedAt = new Date()
const assignment = await db.assignment.findFirst({
where: {
id,
returnDate: null,
status: { in: ["OPEN", "PARTIALLY_RETURNED"] },
},
include: { stockLines: true, assetLines: { include: { asset: true } } },
})
if (!assignment) return false
await Promise.all(
assignment.stockLines.map((line) =>
db.assignmentStockLine.update({
where: { id: line.id },
data: { returnedQuantity: line.quantity },
}),
),
)
await Promise.all(
assignment.assetLines
.filter((line) => !line.returnedAt)
.map((line) =>
db.assignmentAssetLine.update({
where: { id: line.id },
data: {
returnedAt: closedAt,
returnedById: actorId ?? assignment.createdById,
returnStatus: line.asset.status,
},
}),
),
)
await db.assignment.update({
where: { id },
data: {
returnDate: new Date(),
personId: null,
quantity: null,
assetId: null,
itemId: null,
status: "RETURNED",
closedAt,
closedById: actorId,
},
})
return result.count === 1
return true
},
update: async (
id: string,
data: Prisma.AssignmentUpdateInput | Prisma.AssignmentUncheckedUpdateInput,
data: LegacyAssignmentUpdateData,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Assignment> => {
return db.assignment.update({
const {
itemId,
assetId,
quantity,
assignmentDate,
returnDate,
createdBy,
createdById,
...assignmentData
} = data
const assignment = await db.assignment.update({
where: { id },
data,
data: {
...assignmentData,
assignedAt: assignmentDate,
closedAt: returnDate,
status: returnDate ? "RETURNED" : "OPEN",
createdById: createdById ?? createdBy,
},
})
if (itemId || quantity) {
await db.assignmentStockLine.updateMany({
where: { assignmentId: id },
data: {
itemId,
quantity,
},
})
}
if (assetId || returnDate) {
await db.assignmentAssetLine.updateMany({
where: { assignmentId: id },
data: {
assetId,
returnedAt: returnDate,
},
})
}
return toLegacyAssignmentRecord(assignment, { itemId, assetId, quantity })
},
}
+29 -7
View File
@@ -56,8 +56,14 @@ export const ItemService = {
assets: opts?.includeAssets
? { select: { id: true, serialNumber: true, status: true } }
: false,
movements: opts?.includeMovements
? { select: { id: true, type: true, quantity: true } }
stockMovementLines: opts?.includeMovements
? {
select: {
id: true,
stockDelta: true,
movement: { select: { type: true } },
},
}
: false,
},
})
@@ -114,13 +120,23 @@ export const ItemService = {
findByIdWithAssetAndMovementCount: async (
id: string,
): Promise<ItemWithAssetAndMovementCount | null> => {
return prisma.item.findUnique({
const item = await prisma.item.findUnique({
where: { id },
include: {
category: { select: { id: true, name: true } },
_count: { select: { assets: true, movements: true } },
_count: { select: { assets: true, stockMovementLines: true } },
},
})
return item
? {
...item,
_count: {
assets: item._count.assets,
movements: item._count.stockMovementLines,
},
}
: null
},
findByName: async (
@@ -129,7 +145,7 @@ export const ItemService = {
): Promise<Item | null> => {
return db.item.findFirst({
where: { name },
include: { category: true, assets: true, movements: true },
include: { category: true, assets: true, stockMovementLines: true },
}) as Promise<Item | null>
},
@@ -139,7 +155,7 @@ export const ItemService = {
): Promise<Item | null> => {
return db.item.findUnique({
where: { id },
include: { category: true, assets: true, movements: true },
include: { category: true, assets: true, stockMovementLines: true },
}) as Promise<Item | null>
},
@@ -153,7 +169,13 @@ export const ItemService = {
include: {
category: true,
assets: { select: { id: true, serialNumber: true, status: true } },
movements: { select: { id: true, type: true, quantity: true } },
stockMovementLines: {
select: {
id: true,
stockDelta: true,
movement: { select: { type: true } },
},
},
},
}) as Promise<Item[]>
},
+244 -26
View File
@@ -1,4 +1,4 @@
import type { MovementType, Prisma } from "@/generated/prisma/client"
import type { InventoryMovementType, Prisma } from "@/generated/prisma/client"
import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma"
import type { CreateMovementFormType } from "@/schemas/movement.schema"
@@ -6,7 +6,7 @@ import type { Movement } from "@/types"
type MovementListResult = {
id: string
type: MovementType
type: string
quantity: number
createdAt: Date
item: { name: string } | null
@@ -14,65 +14,283 @@ type MovementListResult = {
person: { firstName: string; lastName: string } | null
}
type InventoryMovementWithDetails = {
id: string
type: InventoryMovementType
createdAt: Date
stockLines: { stockDelta: number; item: { name: string } }[]
assetLines: {
asset: { serialNumber: string; item: { name: string } }
}[]
assignment: { person: { firstName: string; lastName: string } } | null
}
const movementTypeMap = {
IN: "RECEIPT",
OUT: "ISSUE",
ASSIGNMENT: "ASSIGNMENT",
RETURN: "RETURN",
ADJUSTMENT: "ADJUSTMENT",
} as const satisfies Record<
CreateMovementFormType["type"],
InventoryMovementType
>
const movementReasonMap = {
IN: "MANUAL_ENTRY",
OUT: "OTHER",
ASSIGNMENT: "EMPLOYEE_ASSIGNMENT",
RETURN: "EMPLOYEE_RETURN",
ADJUSTMENT: "INVENTORY_CORRECTION",
} as const satisfies Record<
CreateMovementFormType["type"],
Prisma.InventoryMovementCreateInput["reason"]
>
const stockDeltaSignMap = {
IN: 1,
OUT: -1,
ASSIGNMENT: -1,
RETURN: 1,
ADJUSTMENT: 1,
} as const satisfies Record<CreateMovementFormType["type"], 1 | -1>
function toLegacyMovementType(type: InventoryMovementType) {
if (type === "RECEIPT") return "IN"
if (type === "ISSUE") return "OUT"
return type
}
function getStockSnapshot(currentStock: number, stockDelta: number) {
if (stockDelta < 0) {
return {
previousStock: currentStock + Math.abs(stockDelta),
newStock: currentStock,
}
}
const previousStock = Math.max(currentStock - stockDelta, 0)
return {
previousStock,
newStock: previousStock + stockDelta,
}
}
function getMovementQuantity(movement: {
stockLines?: { stockDelta: number }[]
assetLines?: unknown[]
}) {
const stockQuantity = movement.stockLines?.reduce(
(total, line) => total + Math.abs(line.stockDelta),
0,
)
return stockQuantity || movement.assetLines?.length || 0
}
function getMovementItem(movement: {
stockLines?: { item: { name: string } }[]
assetLines?: { asset: { item: { name: string } } }[]
}) {
return (
movement.stockLines?.[0]?.item ||
movement.assetLines?.[0]?.asset.item ||
null
)
}
function getMovementAsset(movement: {
assetLines?: { asset: { serialNumber: string } }[]
}) {
return movement.assetLines?.[0]?.asset || null
}
function getMovementPerson(movement: {
assignment?: { person: { firstName: string; lastName: string } } | null
}) {
return movement.assignment?.person || null
}
export const MovementService = {
findAll: async ({ page, pageSize }: { page?: number; pageSize?: number }) => {
return paginate<MovementListResult>({
model: prisma.movement,
const result = await paginate<InventoryMovementWithDetails>({
model: prisma.inventoryMovement,
page,
pageSize,
orderBy: {
createdAt: "desc",
},
select: {
id: true,
type: true,
quantity: true,
createdAt: true,
item: {
select: {
name: true,
include: {
stockLines: {
include: {
item: {
select: {
name: true,
},
},
},
},
asset: {
select: {
serialNumber: true,
assetLines: {
include: {
asset: {
select: {
serialNumber: true,
item: {
select: {
name: true,
},
},
},
},
},
},
person: {
assignment: {
select: {
firstName: true,
lastName: true,
person: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
})
return {
...result,
data: result.data.map(
(movement): MovementListResult => ({
id: movement.id,
type: toLegacyMovementType(movement.type),
quantity: getMovementQuantity(movement),
createdAt: movement.createdAt,
item: getMovementItem(movement),
asset: getMovementAsset(movement),
person: getMovementPerson(movement),
}),
),
}
},
create: async (
data: CreateMovementFormType & { userId: string },
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Movement> => {
const { personId, ...rest } = data
return await db.movement.create({
const {
assetId,
itemId,
personId: _personId,
quantity,
type,
userId,
...rest
} = data
const stockDelta = quantity * stockDeltaSignMap[type]
const item = itemId
? await db.item.findUnique({
where: { id: itemId },
select: { stock: true },
})
: null
const asset = assetId
? await db.asset.findUnique({
where: { id: assetId },
select: { status: true },
})
: null
return await db.inventoryMovement.create({
data: {
...rest,
personId: personId,
type: movementTypeMap[type],
reason: movementReasonMap[type],
performedById: userId,
stockLines:
itemId && item
? {
create: {
itemId,
stockDelta,
...getStockSnapshot(item.stock, stockDelta),
},
}
: undefined,
assetLines:
assetId && asset
? {
create: {
assetId,
newStatus: asset.status,
},
}
: undefined,
},
})
},
update: async (
id: string,
data: Prisma.MovementUpdateInput,
data: Prisma.InventoryMovementUpdateInput,
): Promise<Movement> => {
return await prisma.movement.update({
return await prisma.inventoryMovement.update({
where: { id },
data,
})
},
findAllByItemId: async (itemId: string): Promise<Movement[]> => {
return await prisma.movement.findMany({
where: { itemId },
findAllByItemId: async (itemId: string): Promise<MovementListResult[]> => {
const movements = await prisma.inventoryMovement.findMany({
where: {
OR: [
{ stockLines: { some: { itemId } } },
{ assetLines: { some: { asset: { itemId } } } },
],
},
include: {
stockLines: {
where: { itemId },
include: {
item: {
select: { name: true },
},
},
},
assetLines: {
where: { asset: { itemId } },
include: {
asset: {
select: {
serialNumber: true,
item: {
select: { name: true },
},
},
},
},
},
assignment: {
select: {
person: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
})
return movements.map((movement) => ({
id: movement.id,
type: toLegacyMovementType(movement.type),
quantity: getMovementQuantity(movement),
createdAt: movement.createdAt,
item: getMovementItem(movement),
asset: getMovementAsset(movement),
person: getMovementPerson(movement),
}))
},
}
+5 -4
View File
@@ -1,14 +1,15 @@
import type {
Assignment,
Asset as PrismaAsset,
ItemStatus as PrismaItemStatus,
AssetStatus as PrismaAssetStatus,
} from "@/generated/prisma/client"
import type { Assignment } from "./assignment"
export type Asset = PrismaAsset
export type ItemStatus = PrismaItemStatus
export type ItemStatus = PrismaAssetStatus
export type UpdateAssetStatus = PrismaItemStatus
export type UpdateAssetStatus = PrismaAssetStatus
export type AssetWithAssignment = Asset & {
assignment: Assignment | null
+11 -1
View File
@@ -4,13 +4,23 @@ import type { Asset } from "./asset"
import type { Item } from "./item"
import type { Person } from "./person"
export type Assignment = PrismaAssignment
export type Assignment = PrismaAssignment & {
assignmentDate: Date
returnDate: Date | null
itemId: string | null
assetId: string | null
quantity: number | null
}
export type AssignmentSummary = Pick<Assignment, "id" | "quantity">
export type AssignmentWithPersonItemAsset = Assignment & {
assignmentDate: Date
returnDate: Date | null
person: Person | null
item: Item | null
asset: Asset | null
itemId: string | null
assetId: string | null
quantity: number | null
}
+2 -2
View File
@@ -1,3 +1,3 @@
import type { Movement as PrismaMovement } from "@/generated/prisma/client"
import type { InventoryMovement as PrismaInventoryMovement } from "@/generated/prisma/client"
export type Movement = PrismaMovement
export type Movement = PrismaInventoryMovement
+9 -5
View File
@@ -1,6 +1,5 @@
import {
type Assignment,
type ItemStatus,
type AssetStatus,
Prisma,
} from "@/generated/prisma/client"
import prisma from "@/lib/prisma"
@@ -12,6 +11,7 @@ import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service"
import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service"
import type { Assignment } from "@/types"
type FieldErrors = Record<string, string[]>
@@ -63,8 +63,8 @@ class AssetTransitionError extends Error {
}
type AssetTransitionInput = {
previousStatus: ItemStatus
nextStatus: ItemStatus
previousStatus: AssetStatus
nextStatus: AssetStatus
previousItemId: string | null
nextItemId: string
activeAssignment: Assignment | null
@@ -261,7 +261,11 @@ export async function updateAssetUseCase(
if (transition.activeAssignment && !transition.willBeAssigned) {
const activeAssignment = transition.activeAssignment
const assignmentWasReturned =
await AssignmentService.markReturnedIfActive(activeAssignment.id, tx)
await AssignmentService.markReturnedIfActive(
activeAssignment.id,
actorId,
tx,
)
if (!assignmentWasReturned) {
throw new AssetTransitionError({
+1
View File
@@ -301,6 +301,7 @@ export async function returnAssignmentUseCase(
const assignmentWasReturned = await AssignmentService.markReturnedIfActive(
id,
actorId,
tx,
)
+10
View File
@@ -37,6 +37,14 @@ function isUniqueConstraintError(error: unknown) {
)
}
function buildSkuFromName(name: string) {
return name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
}
export async function createItemUseCase(
input: CreateItemUseCaseInput,
): Promise<ItemUseCaseResult> {
@@ -58,7 +66,9 @@ export async function createItemUseCase(
const item = await ItemService.create(
{
sku: buildSkuFromName(name),
name,
trackingType: "QUANTITY",
category: { connect: { id: categoryId } },
stock: stock || 0,
},
+13 -3
View File
@@ -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 } },
},
+1 -1
View File
@@ -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({