pelagia-portal/App/app/(portal)/po/[id]/receipt/actions.ts
Hardik 280966a369 refactor: revert cost centre to vessels only, remove vessel-site link
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>
2026-05-30 18:14:24 +05:30

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