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(); 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); } }