diff --git a/App/app/(portal)/approvals/[id]/actions.ts b/App/app/(portal)/approvals/[id]/actions.ts index c6a427e..6257eff 100644 --- a/App/app/(portal)/approvals/[id]/actions.ts +++ b/App/app/(portal)/approvals/[id]/actions.ts @@ -8,7 +8,7 @@ import { revalidatePath } from "next/cache"; type ActionResult = { ok: true } | { error: string }; -export async function approvepo({ +export async function approvePo({ poId, note, withNote = false, @@ -22,7 +22,7 @@ export async function approvepo({ const po = await db.purchaseOrder.findUnique({ where: { id: poId }, - include: { submitter: true }, + include: { submitter: true, lineItems: true }, }); if (!po) return { error: "PO not found" }; @@ -51,6 +51,20 @@ export async function approvepo({ }, }); + // Add line items to site inventory immediately on approval (not on closure) + const siteId = po.siteId ?? null; + if (siteId) { + for (const li of po.lineItems) { + if (!li.productId) continue; + await db.itemInventory.upsert({ + where: { productId_siteId: { productId: li.productId, siteId } }, + update: { quantity: { increment: Number(li.quantity) } }, + create: { productId: li.productId, siteId, quantity: Number(li.quantity) }, + }); + } + revalidatePath(`/admin/sites/${siteId}`); + } + const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }); await notify({ event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED", diff --git a/App/app/(portal)/approvals/[id]/approval-actions.tsx b/App/app/(portal)/approvals/[id]/approval-actions.tsx index 0f92e2b..ac83965 100644 --- a/App/app/(portal)/approvals/[id]/approval-actions.tsx +++ b/App/app/(portal)/approvals/[id]/approval-actions.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { approvepo, rejectPo, requestEdits, requestVendorId } from "./actions"; +import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions"; import type { POStatus } from "@prisma/client"; export function ApprovalActions({ @@ -26,8 +26,8 @@ export function ApprovalActions({ setPending(action); setError(""); let result: { ok: true } | { error: string } | undefined; - if (action === "approve") result = await approvepo({ poId, note }); - else if (action === "approve_note") result = await approvepo({ poId, note, withNote: true }); + if (action === "approve") result = await approvePo({ poId, note }); + else if (action === "approve_note") result = await approvePo({ poId, note, withNote: true }); else if (action === "reject") result = await rejectPo({ poId, note }); else if (action === "request_edits") result = await requestEdits({ poId, note }); else if (action === "request_vendor_id") result = await requestVendorId({ poId }); diff --git a/App/app/(portal)/po/[id]/receipt/actions.ts b/App/app/(portal)/po/[id]/receipt/actions.ts index 094c50c..aa227f9 100644 --- a/App/app/(portal)/po/[id]/receipt/actions.ts +++ b/App/app/(portal)/po/[id]/receipt/actions.ts @@ -131,23 +131,6 @@ export async function confirmReceipt({ }, }); - // 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}`); - } - // 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 } }); diff --git a/App/tests/integration/approval-actions.test.ts b/App/tests/integration/approval-actions.test.ts index b8139ab..f9fc9c5 100644 --- a/App/tests/integration/approval-actions.test.ts +++ b/App/tests/integration/approval-actions.test.ts @@ -201,6 +201,135 @@ describe("S-06 — provide vendor ID", () => { }); }); +// ── Inventory update on approval ───────────────────────────────────────────── + +describe("inventory — updated at MGR_APPROVED, not at closure", () => { + it("increments site inventory for line items with productId on approval", async () => { + const site = await db.site.findFirstOrThrow({ where: { code: "BOM" } }); + const product = await db.product.findFirstOrThrow({ where: { code: "LUBE-EP80W90" } }); + + const before = await db.itemInventory.findUnique({ + where: { productId_siteId: { productId: product.id, siteId: site.id } }, + }); + const qtyBefore = Number(before?.quantity ?? 0); + + const po = await db.purchaseOrder.create({ + data: { + poNumber: `INVTEST-${Date.now()}`, + title: `${PREFIX}InvApproval`, + status: "MGR_REVIEW", + totalAmount: 1000, + currency: "INR", + vesselId, + accountId, + vendorId, + siteId: site.id, + submitterId: techId, + submittedAt: new Date(), + lineItems: { + create: [{ + name: "Gear Oil 80W90", + quantity: 5, + unit: "L", + unitPrice: 182, + totalPrice: 910, + gstRate: 0.18, + sortOrder: 0, + productId: product.id, + }], + }, + actions: { create: { actionType: "SUBMITTED", actorId: techId } }, + }, + }); + + vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); + const result = await approvePo({ poId: po.id }); + expect(result).toEqual({ ok: true }); + + const after = await db.itemInventory.findUnique({ + where: { productId_siteId: { productId: product.id, siteId: site.id } }, + }); + expect(Number(after?.quantity)).toBe(qtyBefore + 5); + }); + + it("skips inventory update for line items without a productId", async () => { + const site = await db.site.findFirstOrThrow({ where: { code: "BOM" } }); + const countBefore = await db.itemInventory.count({ where: { siteId: site.id } }); + + const po = await db.purchaseOrder.create({ + data: { + poNumber: `INVTEST-NOPROD-${Date.now()}`, + title: `${PREFIX}InvNoProduct`, + status: "MGR_REVIEW", + totalAmount: 500, + currency: "INR", + vesselId, + accountId, + vendorId, + siteId: site.id, + submitterId: techId, + submittedAt: new Date(), + lineItems: { + create: [{ + name: "Ad-hoc supply", + quantity: 2, + unit: "pc", + unitPrice: 100, + totalPrice: 200, + gstRate: 0.18, + sortOrder: 0, + productId: null, + }], + }, + actions: { create: { actionType: "SUBMITTED", actorId: techId } }, + }, + }); + + vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); + await approvePo({ poId: po.id }); + + const countAfter = await db.itemInventory.count({ where: { siteId: site.id } }); + expect(countAfter).toBe(countBefore); + }); + + it("does not touch inventory for vessel-only POs (no siteId)", async () => { + const totalBefore = await db.itemInventory.count(); + + const po = await db.purchaseOrder.create({ + data: { + poNumber: `INVTEST-VESSEL-${Date.now()}`, + title: `${PREFIX}InvVessel`, + status: "MGR_REVIEW", + totalAmount: 300, + currency: "INR", + vesselId, + accountId, + vendorId, + submitterId: techId, + submittedAt: new Date(), + lineItems: { + create: [{ + name: "Vessel supply", + quantity: 3, + unit: "pc", + unitPrice: 50, + totalPrice: 150, + gstRate: 0.18, + sortOrder: 0, + }], + }, + actions: { create: { actionType: "SUBMITTED", actorId: techId } }, + }, + }); + + vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER")); + await approvePo({ poId: po.id }); + + const totalAfter = await db.itemInventory.count(); + expect(totalAfter).toBe(totalBefore); + }); +}); + // ── S-07: Edit and resubmit ────────────────────────────────────────────────── describe("S-07 — edit and resubmit after edits requested", () => {