fix(inventory): add items to inventory on PO approval, not on closure
Moves the ItemInventory upsert from confirmReceipt (CLOSED) to approvePo (MGR_APPROVED) so site inventory is visible as soon as a purchase order is manager-approved, without waiting for full closure. - approvePo: fetch lineItems, upsert ItemInventory per site PO line item that has a productId; revalidate the site admin path. - confirmReceipt: remove the now-redundant inventory update block. - Rename approvepo → approvePo for consistency (fixes import mismatch in the existing integration test file). - Add three integration test cases covering: site PO inventory increment, line items without productId are skipped, vessel-only POs are untouched. Fixes #7 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
feb6fb745a
commit
66f2e133b1
4 changed files with 148 additions and 22 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue