From 6d34a2f74f6acb2a7d3a6f0f6b25c948f0e3b642 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Fri, 19 Jun 2026 01:05:33 +0200 Subject: [PATCH] feat(inventory): support line-based assignments and movements --- src/actions/import.actions.ts | 6 + src/schemas/asset.schema.ts | 3 +- src/services/asset.service.ts | 57 +++- src/services/assignment.service.ts | 286 ++++++++++++++---- src/services/item.service.ts | 36 ++- src/services/movement.service.ts | 270 +++++++++++++++-- src/types/asset.ts | 9 +- src/types/assignment.ts | 12 +- src/types/movement.ts | 4 +- src/use-cases/asset.use-cases.ts | 14 +- src/use-cases/assignment.use-cases.ts | 1 + src/use-cases/item.use-cases.ts | 10 + tests/integration/helpers/factories.ts | 16 +- tests/integration/helpers/test-db.ts | 2 +- .../use-cases/asset.use-cases.test.ts | 93 +++--- .../use-cases/assignment.use-cases.test.ts | 63 ++-- .../use-cases/item.use-cases.test.ts | 20 +- 17 files changed, 713 insertions(+), 189 deletions(-) diff --git a/src/actions/import.actions.ts b/src/actions/import.actions.ts index 1280af1..e5b0f03 100644 --- a/src/actions/import.actions.ts +++ b/src/actions/import.actions.ts @@ -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 || "" }, diff --git a/src/schemas/asset.schema.ts b/src/schemas/asset.schema.ts index fbef3bb..f704b26 100644 --- a/src/schemas/asset.schema.ts +++ b/src/schemas/asset.schema.ts @@ -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) { diff --git a/src/services/asset.service.ts b/src/services/asset.service.ts index 8168347..c4be523 100644 --- a/src/services/asset.service.ts +++ b/src/services/asset.service.ts @@ -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 => { - 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 => { @@ -111,10 +150,20 @@ export const AssetService = { serialNumber: string, db: Prisma.TransactionClient | typeof prisma = prisma, ): Promise => { - 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 ( diff --git a/src/services/assignment.service.ts b/src/services/assignment.service.ts index 8ff3e86..298db6f 100644 --- a/src/services/assignment.service.ts +++ b/src/services/assignment.service.ts @@ -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 & { + 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, +): 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 => { - 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({ + const result = await paginate({ 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 }) }, } diff --git a/src/services/item.service.ts b/src/services/item.service.ts index 95583eb..1fa4a3a 100644 --- a/src/services/item.service.ts +++ b/src/services/item.service.ts @@ -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 => { - 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 => { return db.item.findFirst({ where: { name }, - include: { category: true, assets: true, movements: true }, + include: { category: true, assets: true, stockMovementLines: true }, }) as Promise }, @@ -139,7 +155,7 @@ export const ItemService = { ): Promise => { return db.item.findUnique({ where: { id }, - include: { category: true, assets: true, movements: true }, + include: { category: true, assets: true, stockMovementLines: true }, }) as Promise }, @@ -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 }, diff --git a/src/services/movement.service.ts b/src/services/movement.service.ts index 39bb82a..26f8717 100644 --- a/src/services/movement.service.ts +++ b/src/services/movement.service.ts @@ -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 + +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({ - model: prisma.movement, + const result = await paginate({ + 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 => { - 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 => { - return await prisma.movement.update({ + return await prisma.inventoryMovement.update({ where: { id }, data, }) }, - findAllByItemId: async (itemId: string): Promise => { - return await prisma.movement.findMany({ - where: { itemId }, + findAllByItemId: async (itemId: string): Promise => { + 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), + })) }, } diff --git a/src/types/asset.ts b/src/types/asset.ts index 4a3c261..80e1e35 100644 --- a/src/types/asset.ts +++ b/src/types/asset.ts @@ -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 diff --git a/src/types/assignment.ts b/src/types/assignment.ts index 8ec13f8..61b927b 100644 --- a/src/types/assignment.ts +++ b/src/types/assignment.ts @@ -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 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 } diff --git a/src/types/movement.ts b/src/types/movement.ts index b20ed64..910a37f 100644 --- a/src/types/movement.ts +++ b/src/types/movement.ts @@ -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 diff --git a/src/use-cases/asset.use-cases.ts b/src/use-cases/asset.use-cases.ts index 267e692..f13effc 100644 --- a/src/use-cases/asset.use-cases.ts +++ b/src/use-cases/asset.use-cases.ts @@ -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 @@ -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({ diff --git a/src/use-cases/assignment.use-cases.ts b/src/use-cases/assignment.use-cases.ts index 96ea029..6d7080b 100644 --- a/src/use-cases/assignment.use-cases.ts +++ b/src/use-cases/assignment.use-cases.ts @@ -301,6 +301,7 @@ export async function returnAssignmentUseCase( const assignmentWasReturned = await AssignmentService.markReturnedIfActive( id, + actorId, tx, ) diff --git a/src/use-cases/item.use-cases.ts b/src/use-cases/item.use-cases.ts index 7c62691..579aa3a 100644 --- a/src/use-cases/item.use-cases.ts +++ b/src/use-cases/item.use-cases.ts @@ -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 { @@ -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, }, diff --git a/tests/integration/helpers/factories.ts b/tests/integration/helpers/factories.ts index d2219d5..07068c7 100644 --- a/tests/integration/helpers/factories.ts +++ b/tests/integration/helpers/factories.ts @@ -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 } }, }, diff --git a/tests/integration/helpers/test-db.ts b/tests/integration/helpers/test-db.ts index 94ee101..3593111 100644 --- a/tests/integration/helpers/test-db.ts +++ b/tests/integration/helpers/test-db.ts @@ -13,7 +13,7 @@ type TestDatabaseState = { const state: TestDatabaseState = {} const TABLES_TO_TRUNCATE = [ - "Movement", + "InventoryMovement", "Assignment", "Asset", "Item", diff --git a/tests/integration/use-cases/asset.use-cases.test.ts b/tests/integration/use-cases/asset.use-cases.test.ts index 68b3416..e56f337 100644 --- a/tests/integration/use-cases/asset.use-cases.test.ts +++ b/tests/integration/use-cases/asset.use-cases.test.ts @@ -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 }) }) }) diff --git a/tests/integration/use-cases/assignment.use-cases.test.ts b/tests/integration/use-cases/assignment.use-cases.test.ts index db3a8cf..e7864e4 100644 --- a/tests/integration/use-cases/assignment.use-cases.test.ts +++ b/tests/integration/use-cases/assignment.use-cases.test.ts @@ -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) }) }) diff --git a/tests/integration/use-cases/item.use-cases.test.ts b/tests/integration/use-cases/item.use-cases.test.ts index 472f5c7..d609fe2 100644 --- a/tests/integration/use-cases/item.use-cases.test.ts +++ b/tests/integration/use-cases/item.use-cases.test.ts @@ -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({