feat(po): register line items in the product catalogue on approval
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:
parent
fced7cc307
commit
70f3230c36
5 changed files with 158 additions and 97 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
105
App/lib/product-catalog.ts
Normal 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 },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue