feat(po): register line items in the product catalogue on approval
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 31s

Previously a PO's free-text line items only became reusable catalogue products
(/inventory/items) on full payment (markPaid → syncProductCatalog). An approved-
but-unpaid PO's items weren't selectable for further POs yet.

- extract syncProductCatalog into lib/product-catalog.ts (shared).
- call it from approvePo so approved items are immediately catalogued (create
  product by name if unknown, link the line item, upsert last/per-vendor price);
  payment still re-syncs to refresh prices. Idempotent.
- test: approving a PO with a free-text line creates + links the product and
  records the per-vendor price.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-24 04:59:47 +05:30
parent fced7cc307
commit 70f3230c36
5 changed files with 158 additions and 97 deletions

View file

@ -132,6 +132,10 @@ The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVen
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
### Product catalogue sync (`lib/product-catalog.ts`)
`syncProductCatalog(poId, lineItems, vendorId, actorId)` registers a PO's line items as reusable **`Product`s** (the `/inventory/items` catalogue): a line item with no `productId` is matched to an existing product by name (case-insensitive) or a new product is created, then the line item is linked back; `lastPrice`/`lastVendorId` and the per-vendor `ProductVendorPrice` are upserted. It runs **at approval** (`approvePo`) so an approved PO's items are immediately reusable in further POs, **and again at full payment** (`markPaid`) to refresh prices on the final figures. Idempotent — re-running matches the same product. (Import takes its own auto-create path.)
### Import → Closed
`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices.

View file

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { canPerformAction } from "@/lib/po-state-machine";
import { approvePoSchema } from "@/lib/validations/po";
import { syncProductCatalog } from "@/lib/product-catalog";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
@ -84,6 +85,12 @@ export async function approvePo({
revalidatePath(`/admin/sites/${siteId}`);
}
// Register the line items in the product catalogue (/inventory/items) on
// approval, so an approved PO's items are immediately reusable in further POs.
// Idempotent; payment re-syncs to refresh prices on the final figures.
await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id);
revalidatePath("/inventory/items");
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
await notify({
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",

View file

@ -4,107 +4,12 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { canPerformAction } from "@/lib/po-state-machine";
import { processPaymentSchema } from "@/lib/validations/po";
import { syncProductCatalog } from "@/lib/product-catalog";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
function nameToCode(name: string): string {
const slug = name.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.substring(0, 20);
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
}
// Sync product catalog after payment is confirmed:
// - Auto-create products for unlinked line items (matched by name or brand new)
// - Upsert per-vendor prices for all items
async function syncProductCatalog(
poId: string,
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
vendorId: string | null,
actorId: string
) {
const updatedProductIds: string[] = [];
for (const li of lineItems) {
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
let productId = li.productId;
let priceChanged = false;
if (!productId) {
// Try to find an existing product by name (case-insensitive)
const existing = await db.product.findFirst({
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
select: { id: true, lastPrice: true },
});
if (existing) {
productId = existing.id;
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
} else {
// Create a new product — first-time registration, not a price update
const code = nameToCode(li.name);
try {
const created = await db.product.create({
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
});
productId = created.id;
} catch {
// Code collision (extremely unlikely) — add extra entropy
const created = await db.product.create({
data: {
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
name: li.name,
lastPrice: unitPrice,
lastVendorId: vendorId,
},
});
productId = created.id;
}
}
// Link the line item to the product for future reference
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
} else {
const current = await db.product.findUnique({
where: { id: productId },
select: { lastPrice: true },
});
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
}
// Always update lastPrice / lastVendorId on the product
await db.product.update({
where: { id: productId },
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
});
// Upsert per-vendor price if PO has a vendor
if (vendorId) {
await db.productVendorPrice.upsert({
where: { productId_vendorId: { productId, vendorId } },
update: { price: unitPrice },
create: { productId, vendorId, price: unitPrice },
});
}
if (priceChanged) updatedProductIds.push(productId);
}
if (updatedProductIds.length > 0) {
await db.pOAction.create({
data: {
actionType: "PRODUCT_PRICE_UPDATED",
actorId,
poId,
metadata: { updatedProductIds },
},
});
}
}
// Step 1: Accounts picks up the PO — MGR_APPROVED → SENT_FOR_PAYMENT
export async function processPayment({ poId }: { poId: string }): Promise<ActionResult> {
const session = await auth();

105
App/lib/product-catalog.ts Normal file
View file

@ -0,0 +1,105 @@
import { db } from "@/lib/db";
/**
* Product catalogue sync registers a PO's line items as reusable `Product`s
* (the `/inventory/items` catalogue) and keeps last/per-vendor prices fresh:
* - line items with no `productId` are matched to an existing product by name,
* or a brand-new product is created, and the line item is linked back;
* - `lastPrice`/`lastVendorId` and the per-vendor price are upserted.
*
* Called at **approval** (so approved items are immediately reusable in further
* POs) and again at **payment** (to refresh prices on the final figures). The
* function is idempotent re-running matches the same product by name/id.
*/
function nameToCode(name: string): string {
const slug = name.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.substring(0, 20);
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
}
export async function syncProductCatalog(
poId: string,
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
vendorId: string | null,
actorId: string
) {
const updatedProductIds: string[] = [];
for (const li of lineItems) {
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
let productId = li.productId;
let priceChanged = false;
if (!productId) {
// Try to find an existing product by name (case-insensitive)
const existing = await db.product.findFirst({
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
select: { id: true, lastPrice: true },
});
if (existing) {
productId = existing.id;
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
} else {
// Create a new product — first-time registration, not a price update
const code = nameToCode(li.name);
try {
const created = await db.product.create({
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
});
productId = created.id;
} catch {
// Code collision (extremely unlikely) — add extra entropy
const created = await db.product.create({
data: {
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
name: li.name,
lastPrice: unitPrice,
lastVendorId: vendorId,
},
});
productId = created.id;
}
}
// Link the line item to the product for future reference
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
} else {
const current = await db.product.findUnique({
where: { id: productId },
select: { lastPrice: true },
});
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
}
// Always update lastPrice / lastVendorId on the product
await db.product.update({
where: { id: productId },
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
});
// Upsert per-vendor price if PO has a vendor
if (vendorId) {
await db.productVendorPrice.upsert({
where: { productId_vendorId: { productId, vendorId } },
update: { price: unitPrice },
create: { productId, vendorId, price: unitPrice },
});
}
if (priceChanged) updatedProductIds.push(productId);
}
if (updatedProductIds.length > 0) {
await db.pOAction.create({
data: {
actionType: "PRODUCT_PRICE_UPDATED",
actorId,
poId,
metadata: { updatedProductIds },
},
});
}
}

View file

@ -2,7 +2,7 @@
* Integration tests for manager approval server actions.
* Covers: M-02 (approve / approve+note), M-03 (reject), M-04 (request edits, vendor ID), S-06 (provide vendor ID), S-07 (resubmit after edits).
*/
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@ -47,6 +47,12 @@ afterEach(async () => {
await deletePosByTitle(PREFIX);
});
afterAll(async () => {
// Products auto-created by the catalogue-on-approval test.
await db.productVendorPrice.deleteMany({ where: { product: { name: { startsWith: PREFIX } } } });
await db.product.deleteMany({ where: { name: { startsWith: PREFIX } } });
});
// Helper: create a PO in MGR_REVIEW state
async function createSubmittedPo(title: string): Promise<string> {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
@ -159,6 +165,40 @@ describe("issue #92 — advance payment on approval", () => {
});
});
// ── Product catalogue registered on approval (so items are reusable) ─────────
describe("product catalogue on approval", () => {
it("creates a catalogue product for a free-text line item and links it", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const itemName = `${PREFIX}Starter VPS hosting`;
const form = makePoForm({
title: `${PREFIX}CatApprove`,
vesselId,
accountId,
intent: "submit",
lineItems: [{ description: itemName, quantity: 1, unit: "pc", unitPrice: 459.95 }],
});
const { id: poId } = (await createPo(form)) as { id: string };
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
// No catalogue product exists for this name before approval.
expect(await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } })).toBeNull();
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
expect(await approvePo({ poId })).toEqual({ ok: true });
const product = await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } });
expect(product).not.toBeNull();
expect(Number(product!.lastPrice)).toBe(459.95);
// The line item is linked back to the new product, and a per-vendor price is recorded.
const li = await db.pOLineItem.findFirstOrThrow({ where: { poId } });
expect(li.productId).toBe(product!.id);
const pvp = await db.productVendorPrice.findFirst({ where: { productId: product!.id, vendorId } });
expect(pvp).not.toBeNull();
});
});
// ── M-03: Reject ──────────────────────────────────────────────────────────────
describe("M-03 — reject PO", () => {