feat(inventory): support line-based assignments and movements
This commit is contained in:
@@ -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 || "" },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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()
|
||||||
where: { id },
|
|
||||||
data: {
|
return prisma.$transaction(async (tx) => {
|
||||||
returnDate: new Date(),
|
const assignmentWithAssetLines = await tx.assignment.findUniqueOrThrow({
|
||||||
personId: null,
|
where: { id },
|
||||||
quantity: null,
|
include: { assetLines: { include: { asset: true } } },
|
||||||
assetId: null,
|
})
|
||||||
itemId: null,
|
|
||||||
},
|
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 (
|
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: {
|
||||||
|
returnedAt: closedAt,
|
||||||
|
returnedById: actorId ?? assignment.createdById,
|
||||||
|
returnStatus: line.asset.status,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.assignment.update({
|
||||||
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
returnDate: new Date(),
|
status: "RETURNED",
|
||||||
personId: null,
|
closedAt,
|
||||||
quantity: null,
|
closedById: actorId,
|
||||||
assetId: null,
|
|
||||||
itemId: null,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
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 })
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,65 +14,283 @@ 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,
|
item: {
|
||||||
createdAt: true,
|
select: {
|
||||||
item: {
|
name: true,
|
||||||
select: {
|
},
|
||||||
name: true,
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
asset: {
|
assetLines: {
|
||||||
select: {
|
include: {
|
||||||
serialNumber: true,
|
asset: {
|
||||||
|
select: {
|
||||||
|
serialNumber: true,
|
||||||
|
item: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
person: {
|
assignment: {
|
||||||
select: {
|
select: {
|
||||||
firstName: true,
|
person: {
|
||||||
lastName: true,
|
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 (
|
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: { itemId },
|
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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ export async function returnAssignmentUseCase(
|
|||||||
|
|
||||||
const assignmentWasReturned = await AssignmentService.markReturnedIfActive(
|
const assignmentWasReturned = await AssignmentService.markReturnedIfActive(
|
||||||
id,
|
id,
|
||||||
|
actorId,
|
||||||
tx,
|
tx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user