Partial-receipt flows call confirmReceipt multiple times. The nested `create` on the Receipt relation threw a unique-constraint error on the second call when both confirmations supplied notes, preventing any delivery from completing and blocking attachment uploads. Changed to `upsert` so subsequent confirmations update the existing Receipt row's notes instead of failing. Adds integration tests covering full receipt, partial receipt, the upsert scenario (two confirmations each with notes), and permission guards. Fixes #9
158 lines
5.1 KiB
TypeScript
158 lines
5.1 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { canPerformAction } from "@/lib/po-state-machine";
|
|
import { notify } from "@/lib/notifier";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
export async function confirmReceipt({
|
|
poId,
|
|
notes,
|
|
deliveries,
|
|
}: {
|
|
poId: string;
|
|
notes?: string;
|
|
/**
|
|
* Per-line delivery quantities for this receipt event.
|
|
* Key = line item id, value = quantity delivered now (not cumulative).
|
|
* If omitted or empty, all items are treated as fully delivered.
|
|
*/
|
|
deliveries?: Record<string, number>;
|
|
}): Promise<{ ok: true; partial: boolean } | { error: string }> {
|
|
const session = await auth();
|
|
if (!session?.user) return { error: "Unauthorized" };
|
|
|
|
const po = await db.purchaseOrder.findUnique({
|
|
where: { id: poId },
|
|
include: {
|
|
submitter: true,
|
|
lineItems: true,
|
|
vessel: true,
|
|
},
|
|
});
|
|
if (!po) return { error: "PO not found" };
|
|
|
|
const isAllowedStatus =
|
|
po.status === "PAID_DELIVERED" ||
|
|
po.status === "PARTIALLY_CLOSED" ||
|
|
po.status === "PARTIALLY_PAID";
|
|
if (!isAllowedStatus) {
|
|
return { error: "You cannot confirm receipt on this PO in its current state." };
|
|
}
|
|
if (
|
|
!canPerformAction(po.status, "confirm_receipt", session.user.role) &&
|
|
!canPerformAction(po.status, "confirm_partial_receipt", session.user.role)
|
|
) {
|
|
return { error: "You do not have permission to confirm receipt on this PO." };
|
|
}
|
|
if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") {
|
|
return { error: "You can only confirm receipt on your own purchase orders." };
|
|
}
|
|
|
|
// Reject negative delivery values — only remaining items may be delivered
|
|
if (deliveries) {
|
|
for (const [id, qty] of Object.entries(deliveries)) {
|
|
if (qty < 0) return { error: `Invalid delivery quantity for item ${id}: must be ≥ 0.` };
|
|
}
|
|
}
|
|
|
|
// Compute the updated deliveredQuantity for each line item
|
|
const lineUpdates = po.lineItems.map((li) => {
|
|
const prevDelivered = Number(li.deliveredQuantity ?? 0);
|
|
const nowDelivered = deliveries ? (deliveries[li.id] ?? 0) : Number(li.quantity);
|
|
const totalDelivered = prevDelivered + nowDelivered;
|
|
const ordered = Number(li.quantity);
|
|
return {
|
|
id: li.id,
|
|
productId: li.productId,
|
|
quantity: ordered,
|
|
deliveredQuantity: Math.min(totalDelivered, ordered),
|
|
nowDelivered: Math.min(nowDelivered, ordered - prevDelivered),
|
|
};
|
|
});
|
|
|
|
// Determine if all items are now fully delivered
|
|
const allDelivered = lineUpdates.every((u) => u.deliveredQuantity >= u.quantity);
|
|
|
|
// Re-fetch paidAmount for accurate check
|
|
const updatedPo = await db.purchaseOrder.findUnique({
|
|
where: { id: poId },
|
|
select: { paidAmount: true, totalAmount: true },
|
|
});
|
|
const fullyPaid =
|
|
Number(updatedPo?.paidAmount ?? 0) >= Number(updatedPo?.totalAmount ?? 0);
|
|
|
|
const newStatus: "CLOSED" | "PARTIALLY_CLOSED" | "PARTIALLY_PAID" =
|
|
allDelivered && fullyPaid
|
|
? "CLOSED"
|
|
: !allDelivered && fullyPaid
|
|
? "PARTIALLY_CLOSED"
|
|
: "PARTIALLY_PAID";
|
|
|
|
const isPartial = newStatus !== "CLOSED";
|
|
|
|
// Persist delivery quantities
|
|
await Promise.all(
|
|
lineUpdates.map((u) =>
|
|
db.pOLineItem.update({
|
|
where: { id: u.id },
|
|
data: { deliveredQuantity: u.deliveredQuantity },
|
|
})
|
|
)
|
|
);
|
|
|
|
// Update PO status and log action
|
|
await db.purchaseOrder.update({
|
|
where: { id: poId },
|
|
data: {
|
|
status: newStatus,
|
|
closedAt: newStatus === "CLOSED" ? new Date() : undefined,
|
|
receipt: notes
|
|
? {
|
|
upsert: {
|
|
create: { storageKey: "", fileName: "no-file", notes },
|
|
update: { notes },
|
|
},
|
|
}
|
|
: undefined,
|
|
actions: {
|
|
create: {
|
|
actionType: isPartial ? "PARTIAL_RECEIPT_CONFIRMED" : "RECEIPT_CONFIRMED",
|
|
actorId: session.user.id,
|
|
note: notes ?? null,
|
|
metadata: isPartial
|
|
? {
|
|
deliveries: lineUpdates.map((u) => ({
|
|
lineItemId: u.id,
|
|
deliveredNow: u.nowDelivered,
|
|
totalDelivered: u.deliveredQuantity,
|
|
ordered: u.quantity,
|
|
})),
|
|
}
|
|
: undefined,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Closing a PO auto-verifies its vendor (proof of a real, completed transaction).
|
|
if (newStatus === "CLOSED" && po.vendorId) {
|
|
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });
|
|
revalidatePath("/admin/vendors");
|
|
revalidatePath("/inventory/vendors");
|
|
}
|
|
|
|
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
|
|
if (newStatus === "CLOSED") {
|
|
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
|
await notify({ event: "RECEIPT_CONFIRMED", po, recipients: [...managers, ...accounts] });
|
|
} else {
|
|
await notify({ event: "PARTIAL_RECEIPT_CONFIRMED", po, recipients: managers });
|
|
}
|
|
|
|
revalidatePath(`/po/${poId}`);
|
|
revalidatePath("/dashboard");
|
|
revalidatePath("/my-orders");
|
|
return { ok: true, partial: isPartial };
|
|
}
|