feat(assignments): support line-based returns and authenticated updates

This commit is contained in:
2026-06-19 17:14:22 +02:00
parent 965a04a468
commit c1763ed007
5 changed files with 427 additions and 52 deletions
+164 -11
View File
@@ -2,7 +2,11 @@ import type { Prisma } from "@/generated/prisma/client"
import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma"
import type { CreateAssignmentData } from "@/schemas/assignment.schema"
import type { Assignment, AssignmentWithPersonItemAsset } from "@/types"
import type {
Assignment,
AssignmentStockReturnInput,
AssignmentWithPersonItemAsset,
} from "@/types"
type LegacyAssignmentWriteData = CreateAssignmentData & {
createdBy?: string
@@ -13,6 +17,12 @@ type LegacyAssignmentUpdateData = Partial<LegacyAssignmentWriteData> & {
returnDate?: Date | null
}
type ReturnStockAssignmentResult = {
returnedQuantity: number
fullyReturned: boolean
returnedLines: { itemId: string; quantity: number }[]
}
type AssignmentWithLines = Prisma.AssignmentGetPayload<{
include: {
person: true
@@ -27,6 +37,29 @@ const assignmentInclude = {
assetLines: { include: { asset: { include: { item: true } } } },
} satisfies Prisma.AssignmentInclude
function normalizeReturnLines(
returns: AssignmentStockReturnInput[],
): AssignmentStockReturnInput[] {
const aggregated = new Map<string, AssignmentStockReturnInput>()
for (const entry of returns) {
const existing = aggregated.get(entry.assignmentLineId)
if (existing) {
aggregated.set(entry.assignmentLineId, {
...existing,
quantity: existing.quantity + entry.quantity,
notes: existing.notes ?? entry.notes,
})
continue
}
aggregated.set(entry.assignmentLineId, { ...entry })
}
return [...aggregated.values()]
}
function toLegacyAssignment(
assignment: AssignmentWithLines,
): AssignmentWithPersonItemAsset {
@@ -51,7 +84,7 @@ function toLegacyAssignment(
}
function toLegacyAssignmentRecord(
assignment: Prisma.AssignmentGetPayload<{}>,
assignment: Prisma.AssignmentGetPayload<Prisma.AssignmentDefaultArgs>,
data: Pick<LegacyAssignmentUpdateData, "itemId" | "assetId" | "quantity">,
): Assignment {
return {
@@ -132,6 +165,120 @@ export const AssignmentService = {
return assignment ? toLegacyAssignment(assignment) : null
},
returnStockAssignment: async (
id: string,
receivedById: string | undefined,
returns: AssignmentStockReturnInput[] | undefined,
db: Prisma.TransactionClient | typeof prisma = prisma,
): Promise<ReturnStockAssignmentResult | null> => {
const assignment = await db.assignment.findFirst({
where: {
id,
status: { in: ["OPEN", "PARTIALLY_RETURNED"] },
},
include: {
stockLines: {
orderBy: {
createdAt: "asc",
},
},
},
})
if (!assignment || assignment.stockLines.length === 0) {
return null
}
const returnerId = receivedById ?? assignment.createdById
const remainingReturns =
returns !== undefined
? normalizeReturnLines(returns)
: assignment.stockLines
.filter((line) => line.returnedQuantity < line.quantity)
.map((line) => ({
assignmentLineId: line.id,
quantity: line.quantity - line.returnedQuantity,
notes: undefined,
}))
if (remainingReturns.length === 0) {
return null
}
const stockLinesById = new Map(
assignment.stockLines.map((line) => [line.id, line]),
)
const returnTimestamp = new Date()
let returnedQuantity = 0
const returnedLines: { itemId: string; quantity: number }[] = []
for (const returnLine of remainingReturns) {
const stockLine = stockLinesById.get(returnLine.assignmentLineId)
if (!stockLine) {
return null
}
if (!Number.isInteger(returnLine.quantity) || returnLine.quantity <= 0) {
return null
}
const remainingQuantity = stockLine.quantity - stockLine.returnedQuantity
if (returnLine.quantity > remainingQuantity) {
return null
}
await db.assignmentStockReturn.create({
data: {
assignmentLineId: stockLine.id,
quantity: returnLine.quantity,
receivedById: returnerId,
returnedAt: returnTimestamp,
notes: returnLine.notes,
},
})
await db.assignmentStockLine.update({
where: { id: stockLine.id },
data: {
returnedQuantity: {
increment: returnLine.quantity,
},
},
})
returnedQuantity += returnLine.quantity
returnedLines.push({
itemId: stockLine.itemId,
quantity: returnLine.quantity,
})
}
const fullyReturned = assignment.stockLines.every(
(line) =>
line.returnedQuantity +
(remainingReturns.find((entry) => entry.assignmentLineId === line.id)
?.quantity ?? 0) >=
line.quantity,
)
await db.assignment.update({
where: { id },
data: {
status: fullyReturned ? "RETURNED" : "PARTIALLY_RETURNED",
closedAt: fullyReturned ? returnTimestamp : null,
closedById: fullyReturned ? returnerId : null,
},
})
return {
returnedQuantity,
fullyReturned,
returnedLines,
}
},
findAllByPerson: async (
personId: string,
): Promise<AssignmentWithPersonItemAsset[]> => {
@@ -233,19 +380,25 @@ export const AssignmentService = {
id,
status: { in: ["OPEN", "PARTIALLY_RETURNED"] },
},
include: { stockLines: true, assetLines: { include: { asset: true } } },
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 },
}),
),
)
if (assignment.stockLines.length > 0) {
const result = await AssignmentService.returnStockAssignment(
id,
actorId ?? assignment.createdById,
undefined,
db,
)
return Boolean(result?.fullyReturned)
}
await Promise.all(
assignment.assetLines
.filter((line) => !line.returnedAt)