Cost Centre on PO forms now shows only Vessels (plain vesselId field). Sites are a separate concept and not selectable as cost centres. - PurchaseOrder.vesselId is required again (NOT NULL restored) - Vessel.siteId and vessel->site relation removed from schema - DB migration: drops Vessel.siteId column, restores PO.vesselId NOT NULL - All PO forms (new/edit/import/manager-edit): plain vessel <select> with code-prefixed labels (e.g. "HNR1 — HNR 1") - History, approvals, dashboard, my-orders, payments: back to vesselId filter params and po.vessel.name display - Admin vessels: removed Site column and site-assignment dropdown - Admin sites detail page: removed "Assigned Vessels" section - Sites table: removed Vessels count column (no longer linked) - seed-prod.ts and seed.ts: vessels created without siteId - SearchableSelect accounting code picker retained from previous commit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
163 lines
5.2 KiB
TypeScript
163 lines
5.2 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
|
|
? { create: { storageKey: "", fileName: "no-file", 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,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Auto-update inventory for delivered quantities
|
|
const siteId =
|
|
(po as typeof po & { siteId?: string | null }).siteId ??
|
|
null;
|
|
|
|
if (siteId) {
|
|
for (const u of lineUpdates) {
|
|
if (!u.productId || u.nowDelivered <= 0) continue;
|
|
await db.itemInventory.upsert({
|
|
where: { productId_siteId: { productId: u.productId, siteId } },
|
|
update: { quantity: { increment: u.nowDelivered } },
|
|
create: { productId: u.productId, siteId, quantity: u.nowDelivered },
|
|
});
|
|
}
|
|
revalidatePath(`/admin/sites/${siteId}`);
|
|
}
|
|
|
|
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 };
|
|
}
|