103 lines
3.6 KiB
TypeScript
103 lines
3.6 KiB
TypeScript
import { type NextRequest } from "next/server";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
|
import { ReceivePOSchema } from "@/lib/schemas";
|
|
import { audit } from "@/lib/audit";
|
|
import { clientIp } from "@/lib/request";
|
|
|
|
/**
|
|
* Record receipt of PO line quantities. Body is an array of
|
|
* { lineId, qty } — each qty is ADDED to the line's existing receivedQty.
|
|
* Over-receipt (receivedQty > qty) is rejected so accidentally typing two
|
|
* zeros doesn't silently corrupt inventory counts.
|
|
*
|
|
* Post-update, we auto-advance the PO status:
|
|
* - every line fully received => "received" (+ receivedAt)
|
|
* - any received > 0 but not all full => "partial"
|
|
* - otherwise status left as-is (shouldn't happen given we require >=1 receipt).
|
|
*/
|
|
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
|
try {
|
|
const actor = await requireRole("admin");
|
|
const { id } = await ctx.params;
|
|
const body = await parseJson(req, ReceivePOSchema);
|
|
|
|
const before = await prisma.purchaseOrder.findUnique({
|
|
where: { id },
|
|
include: { lines: true },
|
|
});
|
|
if (!before) throw new ApiError(404, "not_found", "Purchase order not found");
|
|
if (before.status === "cancelled" || before.status === "received") {
|
|
throw new ApiError(409, "po_terminal", `PO is ${before.status}; no more receipts accepted`);
|
|
}
|
|
|
|
const byId = new Map(before.lines.map((l) => [l.id, l]));
|
|
// Merge duplicate lineIds from the payload so the caller can post partials.
|
|
const deltas = new Map<string, number>();
|
|
for (const r of body.receipts) {
|
|
deltas.set(r.lineId, (deltas.get(r.lineId) ?? 0) + r.qty);
|
|
}
|
|
for (const [lineId, qty] of deltas) {
|
|
const line = byId.get(lineId);
|
|
if (!line) throw new ApiError(400, "unknown_line", `Line ${lineId} not on this PO`);
|
|
if (line.receivedQty + qty > line.qty) {
|
|
throw new ApiError(
|
|
400,
|
|
"over_receipt",
|
|
`Line ${lineId} would receive ${line.receivedQty + qty} > ordered ${line.qty}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const [lineId, qty] of deltas) {
|
|
await tx.pOLine.update({
|
|
where: { id: lineId },
|
|
data: { receivedQty: { increment: qty } },
|
|
});
|
|
}
|
|
});
|
|
|
|
// Re-read to compute the aggregate status.
|
|
const after = await prisma.purchaseOrder.findUnique({
|
|
where: { id },
|
|
include: { lines: true },
|
|
});
|
|
if (!after) throw new ApiError(500, "internal", "PO vanished mid-transaction");
|
|
|
|
const allFull = after.lines.every((l) => l.receivedQty >= l.qty);
|
|
const anyReceived = after.lines.some((l) => l.receivedQty > 0);
|
|
let nextStatus = after.status;
|
|
const extra: { receivedAt?: Date } = {};
|
|
if (allFull) {
|
|
nextStatus = "received";
|
|
if (!after.receivedAt) extra.receivedAt = new Date();
|
|
} else if (anyReceived) {
|
|
nextStatus = "partial";
|
|
}
|
|
|
|
const finalPo =
|
|
nextStatus !== after.status || extra.receivedAt
|
|
? await prisma.purchaseOrder.update({
|
|
where: { id },
|
|
data: { status: nextStatus, ...extra },
|
|
include: { lines: true },
|
|
})
|
|
: after;
|
|
|
|
await audit({
|
|
actorId: actor.id,
|
|
action: "receive",
|
|
entity: "PurchaseOrder",
|
|
entityId: id,
|
|
before: { status: before.status },
|
|
after: { status: finalPo.status, receipts: Array.from(deltas.entries()) },
|
|
ipAddress: clientIp(req),
|
|
});
|
|
|
|
return ok({ purchaseOrder: finalPo });
|
|
} catch (err) {
|
|
return errorResponse(err);
|
|
}
|
|
}
|