Renames the product-catalogue pages (items + vendors, incl. their [id] detail
pages) out of /inventory into /catalogue. /inventory/cart is unchanged. All
internal links, redirects, revalidatePath calls, sidebar nav, and tests are
updated; next.config redirects keep old /inventory/{items,vendors}[/...] URLs
working (permanent) so existing bookmarks don't 404.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
3.6 KiB
TypeScript
105 lines
3.6 KiB
TypeScript
import { db } from "@/lib/db";
|
|
|
|
/**
|
|
* Product catalogue sync — registers a PO's line items as reusable `Product`s
|
|
* (the `/catalogue/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 },
|
|
},
|
|
});
|
|
}
|
|
}
|