diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 4213e2a..008a3c6 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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. diff --git a/App/app/(portal)/approvals/[id]/actions.ts b/App/app/(portal)/approvals/[id]/actions.ts index 4c04098..0d9d288 100644 --- a/App/app/(portal)/approvals/[id]/actions.ts +++ b/App/app/(portal)/approvals/[id]/actions.ts @@ -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", diff --git a/App/app/(portal)/payments/actions.ts b/App/app/(portal)/payments/actions.ts index 2e9f547..dc2092a 100644 --- a/App/app/(portal)/payments/actions.ts +++ b/App/app/(portal)/payments/actions.ts @@ -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 { const session = await auth(); diff --git a/App/lib/product-catalog.ts b/App/lib/product-catalog.ts new file mode 100644 index 0000000..d905011 --- /dev/null +++ b/App/lib/product-catalog.ts @@ -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 }, + }, + }); + } +} diff --git a/App/tests/integration/approval-actions.test.ts b/App/tests/integration/approval-actions.test.ts index 5006743..56e0a02 100644 --- a/App/tests/integration/approval-actions.test.ts +++ b/App/tests/integration/approval-actions.test.ts @@ -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 { vi.mocked(auth as unknown as () => Promise).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).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).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", () => {