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) { if (!existingItem) {
newItem = await ItemService.create({ newItem = await ItemService.create({
sku: name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, ""),
name, name,
trackingType: "QUANTITY",
stock: assigned ? 0 : stock || 0, stock: assigned ? 0 : stock || 0,
category: { category: {
connect: { id: categoryId ? categoryId : newCategory?.id || "" }, connect: { id: categoryId ? categoryId : newCategory?.id || "" },
+2 -1
View File
@@ -17,11 +17,12 @@ const createAssetStatuses = ["AVAILABLE", "ASSIGNED"] as const
const updateAssetStatuses = [ const updateAssetStatuses = [
"AVAILABLE", "AVAILABLE",
"ASSIGNED", "ASSIGNED",
"RESERVED",
"IN_REPAIR", "IN_REPAIR",
"BROKEN", "BROKEN",
"LOST",
"STOLEN", "STOLEN",
"DISPOSED", "DISPOSED",
"RETIRED",
] as const ] as const
function buildAssetBaseSchema(copy: AssetSchemaCopy) { function buildAssetBaseSchema(copy: AssetSchemaCopy) {
+53 -4
View File
@@ -6,6 +6,35 @@ import type {
AssetWithItemAndCategory, AssetWithItemAndCategory,
} from "@/types/asset" } 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 = { export const AssetService = {
findAll: async (opts?: { findAll: async (opts?: {
includeItem?: boolean includeItem?: boolean
@@ -97,10 +126,20 @@ export const AssetService = {
id: string, id: string,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<AssetWithAssignment | null> => { ): Promise<AssetWithAssignment | null> => {
return db.asset.findUnique({ const asset = await db.asset.findUnique({
where: { id }, 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[]> => { findByItemId: async (itemId: string): Promise<Asset[]> => {
@@ -111,10 +150,20 @@ export const AssetService = {
serialNumber: string, serialNumber: string,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<AssetWithAssignment | null> => { ): Promise<AssetWithAssignment | null> => {
return db.asset.findUnique({ const asset = await db.asset.findUnique({
where: { serialNumber }, 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 ( create: async (
+227 -53
View File
@@ -4,23 +4,79 @@ import prisma from "@/lib/prisma"
import type { CreateAssignmentData } from "@/schemas/assignment.schema" import type { CreateAssignmentData } from "@/schemas/assignment.schema"
import type { Assignment, AssignmentWithPersonItemAsset } from "@/types" 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 = { export const AssignmentService = {
findAllWithPerson: async (): Promise<AssignmentWithPersonItemAsset[]> => { findAllWithPerson: async (): Promise<AssignmentWithPersonItemAsset[]> => {
return prisma.assignment.findMany({ const assignments = await prisma.assignment.findMany({
where: { where: {
returnDate: { status: { in: ["OPEN", "PARTIALLY_RETURNED"] },
equals: null,
},
},
include: {
person: true,
item: true,
asset: true,
}, },
include: assignmentInclude,
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",
}, },
}) })
return assignments.map(toLegacyAssignment)
}, },
findAllWithPersonPaginated: async ({ findAllWithPersonPaginated: async ({
page, page,
@@ -31,14 +87,12 @@ export const AssignmentService = {
pageSize?: number pageSize?: number
search?: string search?: string
}) => { }) => {
return paginate<AssignmentWithPersonItemAsset>({ const result = await paginate<AssignmentWithLines>({
model: prisma.assignment, model: prisma.assignment,
page, page,
pageSize, pageSize,
where: { where: {
returnDate: { status: { in: ["OPEN", "PARTIALLY_RETURNED"] },
equals: null,
},
...(search ...(search
? { ? {
OR: [ OR: [
@@ -56,93 +110,213 @@ export const AssignmentService = {
} }
: {}), : {}),
}, },
include: { include: assignmentInclude,
person: true,
item: true,
asset: true,
},
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",
}, },
}) })
return {
...result,
data: result.data.map(toLegacyAssignment),
}
}, },
findById: async ( findById: async (
id: string, id: string,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<AssignmentWithPersonItemAsset | null> => { ): Promise<AssignmentWithPersonItemAsset | null> => {
return db.assignment.findUnique({ const assignment = await db.assignment.findUnique({
where: { id }, where: { id },
include: { include: assignmentInclude,
person: true,
item: true,
asset: true,
},
}) })
return assignment ? toLegacyAssignment(assignment) : null
}, },
findAllByPerson: async ( findAllByPerson: async (
personId: string, personId: string,
): Promise<AssignmentWithPersonItemAsset[]> => { ): Promise<AssignmentWithPersonItemAsset[]> => {
return prisma.assignment.findMany({ const assignments = await prisma.assignment.findMany({
where: { personId: personId }, where: { personId },
include: { include: assignmentInclude,
person: true,
item: true,
asset: true,
},
}) })
return assignments.map(toLegacyAssignment)
}, },
create: async ( create: async (
data: CreateAssignmentData & { createdBy: string }, data: LegacyAssignmentWriteData,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Assignment> => { ): Promise<Assignment> => {
const { personId, ...rest } = data const {
return db.assignment.create({ personId,
itemId,
assetId,
quantity,
assignmentDate,
createdBy,
createdById,
notes,
} = data
const assignment = await db.assignment.create({
data: { 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> => { delete: async (id: string): Promise<Assignment> => {
return prisma.assignment.update({ 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 }, where: { id },
data: { data: {
returnDate: new Date(), status: "RETURNED",
personId: null, closedAt,
quantity: null,
assetId: null,
itemId: null,
}, },
}) })
return toLegacyAssignmentRecord(assignment, {})
})
}, },
markReturnedIfActive: async ( markReturnedIfActive: async (
id: string, id: string,
actorId?: string,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<boolean> => { ): Promise<boolean> => {
const result = await db.assignment.updateMany({ const closedAt = new Date()
const assignment = await db.assignment.findFirst({
where: { where: {
id, 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: { data: {
returnDate: new Date(), returnedAt: closedAt,
personId: null, returnedById: actorId ?? assignment.createdById,
quantity: null, returnStatus: line.asset.status,
assetId: null, },
itemId: null, }),
),
)
await db.assignment.update({
where: { id },
data: {
status: "RETURNED",
closedAt,
closedById: actorId,
}, },
}) })
return result.count === 1 return true
}, },
update: async ( update: async (
id: string, id: string,
data: Prisma.AssignmentUpdateInput | Prisma.AssignmentUncheckedUpdateInput, data: LegacyAssignmentUpdateData,
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Assignment> => { ): Promise<Assignment> => {
return db.assignment.update({ const {
itemId,
assetId,
quantity,
assignmentDate,
returnDate,
createdBy,
createdById,
...assignmentData
} = data
const assignment = await db.assignment.update({
where: { id }, 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 assets: opts?.includeAssets
? { select: { id: true, serialNumber: true, status: true } } ? { select: { id: true, serialNumber: true, status: true } }
: false, : false,
movements: opts?.includeMovements stockMovementLines: opts?.includeMovements
? { select: { id: true, type: true, quantity: true } } ? {
select: {
id: true,
stockDelta: true,
movement: { select: { type: true } },
},
}
: false, : false,
}, },
}) })
@@ -114,13 +120,23 @@ export const ItemService = {
findByIdWithAssetAndMovementCount: async ( findByIdWithAssetAndMovementCount: async (
id: string, id: string,
): Promise<ItemWithAssetAndMovementCount | null> => { ): Promise<ItemWithAssetAndMovementCount | null> => {
return prisma.item.findUnique({ const item = await prisma.item.findUnique({
where: { id }, where: { id },
include: { include: {
category: { select: { id: true, name: true } }, 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 ( findByName: async (
@@ -129,7 +145,7 @@ export const ItemService = {
): Promise<Item | null> => { ): Promise<Item | null> => {
return db.item.findFirst({ return db.item.findFirst({
where: { name }, where: { name },
include: { category: true, assets: true, movements: true }, include: { category: true, assets: true, stockMovementLines: true },
}) as Promise<Item | null> }) as Promise<Item | null>
}, },
@@ -139,7 +155,7 @@ export const ItemService = {
): Promise<Item | null> => { ): Promise<Item | null> => {
return db.item.findUnique({ return db.item.findUnique({
where: { id }, where: { id },
include: { category: true, assets: true, movements: true }, include: { category: true, assets: true, stockMovementLines: true },
}) as Promise<Item | null> }) as Promise<Item | null>
}, },
@@ -153,7 +169,13 @@ export const ItemService = {
include: { include: {
category: true, category: true,
assets: { select: { id: true, serialNumber: true, status: 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[]> }) as Promise<Item[]>
}, },
+234 -16
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 { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import type { CreateMovementFormType } from "@/schemas/movement.schema" import type { CreateMovementFormType } from "@/schemas/movement.schema"
@@ -6,7 +6,7 @@ import type { Movement } from "@/types"
type MovementListResult = { type MovementListResult = {
id: string id: string
type: MovementType type: string
quantity: number quantity: number
createdAt: Date createdAt: Date
item: { name: string } | null item: { name: string } | null
@@ -14,30 +14,139 @@ type MovementListResult = {
person: { firstName: string; lastName: string } | null 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 = { export const MovementService = {
findAll: async ({ page, pageSize }: { page?: number; pageSize?: number }) => { findAll: async ({ page, pageSize }: { page?: number; pageSize?: number }) => {
return paginate<MovementListResult>({ const result = await paginate<InventoryMovementWithDetails>({
model: prisma.movement, model: prisma.inventoryMovement,
page, page,
pageSize, pageSize,
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",
}, },
select: { include: {
id: true, stockLines: {
type: true, include: {
quantity: true,
createdAt: true,
item: { item: {
select: { select: {
name: true, name: true,
}, },
}, },
},
},
assetLines: {
include: {
asset: { asset: {
select: { select: {
serialNumber: true, serialNumber: true,
item: {
select: {
name: true,
}, },
}, },
},
},
},
},
assignment: {
select: {
person: { person: {
select: { select: {
firstName: true, firstName: true,
@@ -45,34 +154,143 @@ export const MovementService = {
}, },
}, },
}, },
},
},
}) })
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 ( create: async (
data: CreateMovementFormType & { userId: string }, data: CreateMovementFormType & { userId: string },
db: Prisma.TransactionClient | typeof prisma = prisma, db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<Movement> => { ): Promise<Movement> => {
const { personId, ...rest } = data const {
return await db.movement.create({ 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: { data: {
...rest, ...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 ( update: async (
id: string, id: string,
data: Prisma.MovementUpdateInput, data: Prisma.InventoryMovementUpdateInput,
): Promise<Movement> => { ): Promise<Movement> => {
return await prisma.movement.update({ return await prisma.inventoryMovement.update({
where: { id }, where: { id },
data, data,
}) })
}, },
findAllByItemId: async (itemId: string): Promise<Movement[]> => { findAllByItemId: async (itemId: string): Promise<MovementListResult[]> => {
return await prisma.movement.findMany({ const movements = await prisma.inventoryMovement.findMany({
where: {
OR: [
{ stockLines: { some: { itemId } } },
{ assetLines: { some: { asset: { itemId } } } },
],
},
include: {
stockLines: {
where: { itemId }, 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 { import type {
Assignment,
Asset as PrismaAsset, Asset as PrismaAsset,
ItemStatus as PrismaItemStatus, AssetStatus as PrismaAssetStatus,
} from "@/generated/prisma/client" } from "@/generated/prisma/client"
import type { Assignment } from "./assignment"
export type Asset = PrismaAsset export type Asset = PrismaAsset
export type ItemStatus = PrismaItemStatus export type ItemStatus = PrismaAssetStatus
export type UpdateAssetStatus = PrismaItemStatus export type UpdateAssetStatus = PrismaAssetStatus
export type AssetWithAssignment = Asset & { export type AssetWithAssignment = Asset & {
assignment: Assignment | null assignment: Assignment | null
+11 -1
View File
@@ -4,13 +4,23 @@ import type { Asset } from "./asset"
import type { Item } from "./item" import type { Item } from "./item"
import type { Person } from "./person" 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 AssignmentSummary = Pick<Assignment, "id" | "quantity">
export type AssignmentWithPersonItemAsset = Assignment & { export type AssignmentWithPersonItemAsset = Assignment & {
assignmentDate: Date
returnDate: Date | null returnDate: Date | null
person: Person | null person: Person | null
item: Item | null item: Item | null
asset: Asset | 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 { import {
type Assignment, type AssetStatus,
type ItemStatus,
Prisma, Prisma,
} from "@/generated/prisma/client" } from "@/generated/prisma/client"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
@@ -12,6 +11,7 @@ import { AssetService } from "@/services/asset.service"
import { AssignmentService } from "@/services/assignment.service" import { AssignmentService } from "@/services/assignment.service"
import { ItemService } from "@/services/item.service" import { ItemService } from "@/services/item.service"
import { MovementService } from "@/services/movement.service" import { MovementService } from "@/services/movement.service"
import type { Assignment } from "@/types"
type FieldErrors = Record<string, string[]> type FieldErrors = Record<string, string[]>
@@ -63,8 +63,8 @@ class AssetTransitionError extends Error {
} }
type AssetTransitionInput = { type AssetTransitionInput = {
previousStatus: ItemStatus previousStatus: AssetStatus
nextStatus: ItemStatus nextStatus: AssetStatus
previousItemId: string | null previousItemId: string | null
nextItemId: string nextItemId: string
activeAssignment: Assignment | null activeAssignment: Assignment | null
@@ -261,7 +261,11 @@ export async function updateAssetUseCase(
if (transition.activeAssignment && !transition.willBeAssigned) { if (transition.activeAssignment && !transition.willBeAssigned) {
const activeAssignment = transition.activeAssignment const activeAssignment = transition.activeAssignment
const assignmentWasReturned = const assignmentWasReturned =
await AssignmentService.markReturnedIfActive(activeAssignment.id, tx) await AssignmentService.markReturnedIfActive(
activeAssignment.id,
actorId,
tx,
)
if (!assignmentWasReturned) { if (!assignmentWasReturned) {
throw new AssetTransitionError({ throw new AssetTransitionError({
+1
View File
@@ -301,6 +301,7 @@ export async function returnAssignmentUseCase(
const assignmentWasReturned = await AssignmentService.markReturnedIfActive( const assignmentWasReturned = await AssignmentService.markReturnedIfActive(
id, id,
actorId,
tx, 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( export async function createItemUseCase(
input: CreateItemUseCaseInput, input: CreateItemUseCaseInput,
): Promise<ItemUseCaseResult> { ): Promise<ItemUseCaseResult> {
@@ -58,7 +66,9 @@ export async function createItemUseCase(
const item = await ItemService.create( const item = await ItemService.create(
{ {
sku: buildSkuFromName(name),
name, name,
trackingType: "QUANTITY",
category: { connect: { id: categoryId } }, category: { connect: { id: categoryId } },
stock: stock || 0, stock: stock || 0,
}, },
+13 -3
View File
@@ -3,6 +3,8 @@ import type {
PrismaClient, PrismaClient,
UserRole, UserRole,
} from "@/generated/prisma/client" } from "@/generated/prisma/client"
import { UserStatus } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email"
let sequence = 0 let sequence = 0
@@ -21,14 +23,20 @@ export async function createTestUser(
}> = {}, }> = {},
) { ) {
const suffix = nextSuffix() 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({ return prisma.user.create({
data: { data: {
email: overrides.email ?? `test-user-${suffix}@example.test`, email,
emailNormalized: normalizeEmail(email),
name: overrides.name ?? "Test User", name: overrides.name ?? "Test User",
password: "hashed-password", passwordHash: "hashed-password",
role: overrides.role ?? "ADMIN", 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({ return prisma.item.create({
data: { data: {
sku: `TEST-SKU-${suffix}`,
name: overrides.name ?? `Test Item ${suffix}`, name: overrides.name ?? `Test Item ${suffix}`,
trackingType: "QUANTITY",
stock: overrides.stock ?? 0, stock: overrides.stock ?? 0,
category: { connect: { id: categoryId } }, category: { connect: { id: categoryId } },
}, },
+1 -1
View File
@@ -13,7 +13,7 @@ type TestDatabaseState = {
const state: TestDatabaseState = {} const state: TestDatabaseState = {}
const TABLES_TO_TRUNCATE = [ const TABLES_TO_TRUNCATE = [
"Movement", "InventoryMovement",
"Assignment", "Assignment",
"Asset", "Asset",
"Item", "Item",
@@ -55,7 +55,8 @@ describe("asset use-cases", () => {
const [asset, updatedItem, movements] = await Promise.all([ const [asset, updatedItem, movements] = await Promise.all([
prisma.asset.findUniqueOrThrow({ where: { id: result.assetId } }), prisma.asset.findUniqueOrThrow({ where: { id: result.assetId } }),
prisma.item.findUniqueOrThrow({ where: { id: item.id } }), prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
prisma.movement.findMany({ prisma.inventoryMovement.findMany({
include: { stockLines: true, assetLines: true },
orderBy: [{ createdAt: "asc" }, { id: "asc" }], orderBy: [{ createdAt: "asc" }, { id: "asc" }],
}), }),
]) ])
@@ -70,12 +71,11 @@ describe("asset use-cases", () => {
expect(updatedItem.stock).toBe(1) expect(updatedItem.stock).toBe(1)
expect(movements).toHaveLength(1) expect(movements).toHaveLength(1)
expect(movements[0]).toMatchObject({ expect(movements[0]).toMatchObject({
type: "IN", type: "RECEIPT",
itemId: item.id, performedById: actor.id,
assetId: result.assetId,
quantity: 1,
userId: 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 () => { 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.asset.findUniqueOrThrow({ where: { id: result.assetId } }),
prisma.item.findUniqueOrThrow({ where: { id: item.id } }), prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
prisma.assignment.findFirstOrThrow({ 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" }], orderBy: [{ createdAt: "asc" }, { id: "asc" }],
}), }),
]) ])
@@ -112,23 +117,19 @@ describe("asset use-cases", () => {
}) })
expect(updatedItem.stock).toBe(0) expect(updatedItem.stock).toBe(0)
expect(assignment).toMatchObject({ expect(assignment).toMatchObject({
itemId: item.id,
assetId: result.assetId,
personId: person.id, personId: person.id,
quantity: 1, createdById: actor.id,
createdBy: actor.id, closedAt: null,
returnDate: null,
}) })
expect(assignment.assetLines[0]).toMatchObject({ assetId: result.assetId })
expect(movements).toHaveLength(1) expect(movements).toHaveLength(1)
expect(movements[0]).toMatchObject({ expect(movements[0]).toMatchObject({
type: "ASSIGNMENT", type: "ASSIGNMENT",
itemId: item.id,
assetId: result.assetId,
personId: person.id,
assignmentId: assignment.id, assignmentId: assignment.id,
quantity: 1, performedById: actor.id,
userId: 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 () => { 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.asset.findUniqueOrThrow({ where: { id: created.assetId } }),
prisma.item.findUniqueOrThrow({ where: { id: item.id } }), prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
prisma.assignment.findFirstOrThrow({ 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(assignedAsset.status).toBe("ASSIGNED")
expect(assignedItem.stock).toBe(0) expect(assignedItem.stock).toBe(0)
expect(activeAssignment).toMatchObject({ expect(activeAssignment).toMatchObject({
itemId: item.id,
assetId: created.assetId,
personId: person.id, personId: person.id,
quantity: 1,
}) })
expect(activeAssignment.assetLines[0]).toMatchObject({ assetId: created.assetId })
await expect( await expect(
updateAssetUseCase({ updateAssetUseCase({
@@ -191,41 +194,37 @@ describe("asset use-cases", () => {
prisma.assignment.findUniqueOrThrow({ prisma.assignment.findUniqueOrThrow({
where: { id: activeAssignment.id }, where: { id: activeAssignment.id },
}), }),
prisma.movement.findMany({ prisma.inventoryMovement.findMany({
include: { stockLines: true, assetLines: true },
orderBy: [{ createdAt: "asc" }, { id: "asc" }], orderBy: [{ createdAt: "asc" }, { id: "asc" }],
}), }),
]) ])
expect(availableAsset.status).toBe("AVAILABLE") expect(availableAsset.status).toBe("AVAILABLE")
expect(availableItem.stock).toBe(1) expect(availableItem.stock).toBe(1)
expect(returnedAssignment.returnDate).toBeInstanceOf(Date) expect(returnedAssignment.closedAt).toBeInstanceOf(Date)
expect(returnedAssignment).toMatchObject({ expect(returnedAssignment).toMatchObject({
itemId: null, personId: person.id,
assetId: null, status: "RETURNED",
personId: null,
quantity: null,
}) })
expect(movements).toHaveLength(3) expect(movements).toHaveLength(3)
expect(movements.map((movement) => movement.type)).toEqual([ expect(movements.map((movement) => movement.type)).toEqual([
"IN", "RECEIPT",
"ASSIGNMENT", "ASSIGNMENT",
"RETURN", "RETURN",
]) ])
expect(movements[1]).toMatchObject({ expect(movements[1]).toMatchObject({
itemId: item.id,
assetId: created.assetId,
personId: person.id,
assignmentId: activeAssignment.id, assignmentId: activeAssignment.id,
quantity: 1, performedById: actor.id,
userId: 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({ expect(movements[2]).toMatchObject({
itemId: item.id,
assetId: created.assetId,
assignmentId: activeAssignment.id, assignmentId: activeAssignment.id,
quantity: 1, performedById: actor.id,
userId: 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 () => { 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") if (!created.success) throw new Error("Expected asset creation success")
const activeAssignment = await prisma.assignment.findFirstOrThrow({ const activeAssignment = await prisma.assignment.findFirstOrThrow({
where: { assetId: created.assetId, returnDate: null }, where: {
status: "OPEN",
assetLines: { some: { assetId: created.assetId, returnedAt: null } },
},
}) })
await expect( await expect(
@@ -265,14 +267,15 @@ describe("asset use-cases", () => {
prisma.assignment.findUniqueOrThrow({ prisma.assignment.findUniqueOrThrow({
where: { id: activeAssignment.id }, where: { id: activeAssignment.id },
}), }),
prisma.movement.findMany({ prisma.inventoryMovement.findMany({
include: { stockLines: true, assetLines: true },
orderBy: [{ createdAt: "asc" }, { id: "asc" }], orderBy: [{ createdAt: "asc" }, { id: "asc" }],
}), }),
]) ])
expect(asset.status).toBe("BROKEN") expect(asset.status).toBe("BROKEN")
expect(itemAfterUpdate.stock).toBe(0) expect(itemAfterUpdate.stock).toBe(0)
expect(returnedAssignment.returnDate).toBeInstanceOf(Date) expect(returnedAssignment.closedAt).toBeInstanceOf(Date)
expect(movements).toHaveLength(2) expect(movements).toHaveLength(2)
expect(movements.map((movement) => movement.type)).toEqual([ expect(movements.map((movement) => movement.type)).toEqual([
"ASSIGNMENT", "ASSIGNMENT",
@@ -280,12 +283,10 @@ describe("asset use-cases", () => {
]) ])
expect(movements[1]).toMatchObject({ expect(movements[1]).toMatchObject({
type: "RETURN", type: "RETURN",
itemId: item.id,
assetId: created.assetId,
personId: person.id,
assignmentId: activeAssignment.id, assignmentId: activeAssignment.id,
quantity: 1, performedById: actor.id,
userId: 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.item.findUniqueOrThrow({ where: { id: item.id } }),
prisma.assignment.findUniqueOrThrow({ prisma.assignment.findUniqueOrThrow({
where: { id: result.assignmentId }, where: { id: result.assignmentId },
include: { stockLines: true },
}), }),
prisma.movement.findMany({ prisma.inventoryMovement.findMany({
include: { stockLines: true },
orderBy: [{ createdAt: "asc" }, { id: "asc" }], orderBy: [{ createdAt: "asc" }, { id: "asc" }],
}), }),
]) ])
expect(updatedItem.stock).toBe(3) expect(updatedItem.stock).toBe(3)
expect(assignment).toMatchObject({ expect(assignment).toMatchObject({
itemId: item.id,
personId: person.id, personId: person.id,
quantity: 2,
notes: "Initial assignment", notes: "Initial assignment",
createdBy: actor.id, createdById: actor.id,
returnDate: null, 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).toHaveLength(1)
expect(movements[0]).toMatchObject({ expect(movements[0]).toMatchObject({
type: "ASSIGNMENT", type: "ASSIGNMENT",
itemId: item.id,
personId: person.id,
assignmentId: result.assignmentId, assignmentId: result.assignmentId,
quantity: 2, performedById: actor.id,
userId: 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 } }), prisma.item.findUniqueOrThrow({ where: { id: item.id } }),
).resolves.toMatchObject({ stock: 1 }) ).resolves.toMatchObject({ stock: 1 })
await expect(prisma.assignment.count()).resolves.toBe(0) 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 () => { 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.item.findUniqueOrThrow({ where: { id: item.id } }),
prisma.assignment.findUniqueOrThrow({ prisma.assignment.findUniqueOrThrow({
where: { id: created.assignmentId }, where: { id: created.assignmentId },
include: { stockLines: true },
}), }),
prisma.movement.findMany({ prisma.inventoryMovement.findMany({
include: { stockLines: true },
orderBy: [{ createdAt: "asc" }, { id: "asc" }], orderBy: [{ createdAt: "asc" }, { id: "asc" }],
}), }),
]) ])
expect(updatedItem.stock).toBe(4) expect(updatedItem.stock).toBe(4)
expect(assignment.returnDate).toBeInstanceOf(Date) expect(assignment.closedAt).toBeInstanceOf(Date)
expect(assignment).toMatchObject({ expect(assignment).toMatchObject({
itemId: null, personId: person.id,
assetId: null, status: "RETURNED",
personId: null, })
quantity: null, expect(assignment.stockLines[0]).toMatchObject({
itemId: item.id,
quantity: 3,
returnedQuantity: 3,
}) })
expect(movements).toHaveLength(2) expect(movements).toHaveLength(2)
expect(movements[0]).toMatchObject({ expect(movements[0]).toMatchObject({
type: "ASSIGNMENT", type: "ASSIGNMENT",
itemId: item.id,
assignmentId: created.assignmentId, assignmentId: created.assignmentId,
quantity: 3, performedById: actor.id,
userId: actor.id, })
expect(movements[0].stockLines[0]).toMatchObject({
itemId: item.id,
stockDelta: -3,
}) })
expect(movements[1]).toMatchObject({ expect(movements[1]).toMatchObject({
type: "RETURN", type: "RETURN",
itemId: item.id,
assignmentId: created.assignmentId, assignmentId: created.assignmentId,
quantity: 3, performedById: actor.id,
userId: 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"] }, 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 }) expect(result).toEqual({ success: true })
const item = await prisma.item.findUnique({ const item = await prisma.item.findUnique({
where: { name: "Laptop" }, where: { sku: "LAPTOP" },
include: { movements: true }, include: { stockMovementLines: { include: { movement: true } } },
}) })
expect(item).toMatchObject({ expect(item).toMatchObject({
@@ -56,11 +56,13 @@ describe("item use-cases", () => {
stock: 3, stock: 3,
deletedAt: null, deletedAt: null,
}) })
expect(item?.movements).toHaveLength(1) expect(item?.stockMovementLines).toHaveLength(1)
expect(item?.movements[0]).toMatchObject({ expect(item?.stockMovementLines[0]).toMatchObject({
type: "IN", stockDelta: 3,
quantity: 3, })
userId: actor.id, 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({ const stockedItem = await prisma.item.findUniqueOrThrow({
where: { name: "Keyboard" }, where: { sku: "KEYBOARD" },
}) })
await expect(deleteItemUseCase(stockedItem.id)).resolves.toEqual({ await expect(deleteItemUseCase(stockedItem.id)).resolves.toEqual({
@@ -118,7 +120,7 @@ describe("item use-cases", () => {
}) })
const emptyItem = await prisma.item.findUniqueOrThrow({ const emptyItem = await prisma.item.findUniqueOrThrow({
where: { name: "Mouse" }, where: { sku: "MOUSE" },
}) })
await expect(deleteItemUseCase(emptyItem.id)).resolves.toEqual({ await expect(deleteItemUseCase(emptyItem.id)).resolves.toEqual({