Adds two PO-level charges shown below GST, per issue #133 ask 2. - Stored as ABSOLUTE rupee amounts on PurchaseOrder.tcsAmount / discountAmount (Decimal?, default 0; null/0 on historical & imported POs). Migration added. - Discount is applied post-GST. totalAmount folds the charges in (net payable = subtotal + GST + TCS − Discount), so payments / reports / advance all use the true amount due. lib/po-money.ts is the single source of truth. - Forms (create + edit) render a shared TcsDiscountFields with a % control bidirectionally linked to the rupee value (percentage is convenience only, taken against the GST-inclusive total; only the absolute amount is persisted). - createPo / updatePo store & compute; both manager-edit actions PRESERVE the PO's TCS/Discount when recomputing the total; import leaves them at 0. - PO detail shows TCS / Discount / Net payable below GST; PDF + XLSX export show the same breakdown and a corrected grand total. Tests: lib/po-money unit tests; po-tcs-discount integration test (create / edit / manager-line-edit preservation). Docs: CLAUDE.md GST section + wiki Purchase Orders (TCS/Discount + a full "what import sets vs. not" field-mapping table). Full unit (360) + integration (305) suites green; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
169 lines
6.2 KiB
TypeScript
169 lines
6.2 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { createPoSchema } from "@/lib/validations/po";
|
|
import { poNetPayable } from "@/lib/po-money";
|
|
import { parsePoTerms } from "@/lib/terms";
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
export async function managerEditPo(
|
|
poId: string,
|
|
formData: FormData
|
|
): Promise<{ ok: true } | { error: string }> {
|
|
const session = await auth();
|
|
if (!session?.user || !hasPermission(session.user.role, "approve_po")) {
|
|
return { error: "Forbidden" };
|
|
}
|
|
|
|
const po = await db.purchaseOrder.findUnique({
|
|
where: { id: poId },
|
|
include: { lineItems: { orderBy: { sortOrder: "asc" } } },
|
|
});
|
|
if (!po) return { error: "PO not found" };
|
|
if (po.status !== "MGR_REVIEW") return { error: "PO can only be edited while under review." };
|
|
|
|
// Parse line items from FormData
|
|
const lineItems: Array<{
|
|
name: string; description?: string; quantity: number; unit: string;
|
|
size?: string; unitPrice: number; gstRate: number;
|
|
}> = [];
|
|
let i = 0;
|
|
while (formData.has(`lineItems[${i}].name`)) {
|
|
lineItems.push({
|
|
name: formData.get(`lineItems[${i}].name`) as string,
|
|
description: (formData.get(`lineItems[${i}].description`) as string) || undefined,
|
|
quantity: Number(formData.get(`lineItems[${i}].quantity`)),
|
|
unit: formData.get(`lineItems[${i}].unit`) as string,
|
|
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
|
|
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
|
|
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
|
|
});
|
|
i++;
|
|
}
|
|
|
|
const parsed = createPoSchema.safeParse({
|
|
title: formData.get("title"),
|
|
vesselId: formData.get("vesselId"),
|
|
accountId: formData.get("accountId"),
|
|
companyId: (formData.get("companyId") as string) || undefined,
|
|
projectCode: formData.get("projectCode") || undefined,
|
|
dateRequired: formData.get("dateRequired") || undefined,
|
|
vendorId: formData.get("vendorId") || undefined,
|
|
piQuotationNo: formData.get("piQuotationNo") || undefined,
|
|
piQuotationDate: formData.get("piQuotationDate") || undefined,
|
|
requisitionNo: formData.get("requisitionNo") || undefined,
|
|
requisitionDate: formData.get("requisitionDate") || undefined,
|
|
placeOfDelivery: formData.get("placeOfDelivery") || undefined,
|
|
tcDelivery: formData.get("tcDelivery") || undefined,
|
|
tcDispatch: formData.get("tcDispatch") || undefined,
|
|
tcInspection: formData.get("tcInspection") || undefined,
|
|
tcTransitInsurance: formData.get("tcTransitInsurance") || undefined,
|
|
tcPaymentTerms: formData.get("tcPaymentTerms") || undefined,
|
|
tcOthers: formData.get("tcOthers") || undefined,
|
|
lineItems,
|
|
});
|
|
|
|
if (!parsed.success) {
|
|
return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
|
}
|
|
|
|
const data = parsed.data;
|
|
let termsRaw: unknown = [];
|
|
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
|
|
const terms = parsePoTerms(termsRaw);
|
|
|
|
// Preserve PO-level TCS / Discount charges (#133) when recomputing the total.
|
|
const newTotal = poNetPayable(
|
|
data.lineItems,
|
|
Number(po.tcsAmount ?? 0),
|
|
Number(po.discountAmount ?? 0)
|
|
);
|
|
|
|
// Snapshot all original values for the audit trail
|
|
const extPo = po as typeof po & {
|
|
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
|
requisitionNo?: string | null; requisitionDate?: Date | null;
|
|
placeOfDelivery?: string | null;
|
|
tcDelivery?: string | null; tcDispatch?: string | null;
|
|
tcInspection?: string | null; tcTransitInsurance?: string | null;
|
|
tcPaymentTerms?: string | null; tcOthers?: string | null;
|
|
};
|
|
const original = {
|
|
title: po.title,
|
|
vesselId: po.vesselId,
|
|
accountId: po.accountId,
|
|
vendorId: po.vendorId,
|
|
projectCode: po.projectCode,
|
|
piQuotationNo: extPo.piQuotationNo,
|
|
requisitionNo: extPo.requisitionNo,
|
|
placeOfDelivery: extPo.placeOfDelivery,
|
|
tcDelivery: extPo.tcDelivery,
|
|
tcDispatch: extPo.tcDispatch,
|
|
tcInspection: extPo.tcInspection,
|
|
tcTransitInsurance: extPo.tcTransitInsurance,
|
|
tcPaymentTerms: extPo.tcPaymentTerms,
|
|
tcOthers: extPo.tcOthers,
|
|
totalAmount: Number(po.totalAmount),
|
|
lineItems: po.lineItems.map((li) => ({
|
|
name: (li as typeof li & { name: string }).name,
|
|
description: li.description ?? undefined,
|
|
quantity: Number(li.quantity),
|
|
unit: li.unit,
|
|
size: li.size ?? undefined,
|
|
unitPrice: Number(li.unitPrice),
|
|
gstRate: Number(li.gstRate ?? 0.18),
|
|
})),
|
|
};
|
|
|
|
await db.purchaseOrder.update({
|
|
where: { id: poId },
|
|
data: {
|
|
title: data.title,
|
|
vesselId: data.vesselId,
|
|
accountId: data.accountId,
|
|
companyId: data.companyId ?? null,
|
|
vendorId: data.vendorId ?? null,
|
|
projectCode: data.projectCode ?? null,
|
|
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,
|
|
piQuotationNo: data.piQuotationNo ?? null,
|
|
piQuotationDate: data.piQuotationDate ? new Date(data.piQuotationDate) : null,
|
|
requisitionNo: data.requisitionNo ?? null,
|
|
requisitionDate: data.requisitionDate ? new Date(data.requisitionDate) : null,
|
|
placeOfDelivery: data.placeOfDelivery ?? null,
|
|
tcDelivery: data.tcDelivery ?? null,
|
|
tcDispatch: data.tcDispatch ?? null,
|
|
tcInspection: data.tcInspection ?? null,
|
|
tcTransitInsurance: data.tcTransitInsurance ?? null,
|
|
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
|
tcOthers: data.tcOthers ?? null,
|
|
terms,
|
|
totalAmount: newTotal,
|
|
lineItems: {
|
|
deleteMany: {},
|
|
create: data.lineItems.map((item, idx) => ({
|
|
name: item.name,
|
|
description: item.description ?? null,
|
|
quantity: item.quantity,
|
|
unit: item.unit,
|
|
size: item.size ?? null,
|
|
unitPrice: item.unitPrice,
|
|
totalPrice: item.quantity * item.unitPrice,
|
|
gstRate: item.gstRate,
|
|
sortOrder: idx,
|
|
})),
|
|
},
|
|
actions: {
|
|
create: {
|
|
actionType: "MANAGER_LINE_EDIT",
|
|
actorId: session.user.id,
|
|
metadata: { original },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
revalidatePath(`/approvals/${poId}`);
|
|
return { ok: true };
|
|
}
|