feat(catalog): track per-vendor prices; auto-sync catalog on payment
Schema: - Add ProductVendorPrice table (productId, vendorId, price, updatedAt) with unique constraint on (productId, vendorId) Payment action (markPaid): - Auto-create Product for any unlinked line item (matched by name case-insensitively, or created fresh with auto-generated code) - Link POLineItem.productId for newly matched/created products - Upsert ProductVendorPrice for the PO vendor + unit price - Always update Product.lastPrice / lastVendorId as denormalized cache Search API: - Include vendorPrices[] in results (vendorId, vendorName, price) Editor dropdown: - Show per-vendor prices below item name when available Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f95b3279c8
commit
f4e0d8ae63
5 changed files with 159 additions and 37 deletions
|
|
@ -9,12 +9,96 @@ 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;
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
productId = existing.id;
|
||||
} else {
|
||||
// Create a new product
|
||||
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 } });
|
||||
}
|
||||
|
||||
// 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 },
|
||||
});
|
||||
}
|
||||
|
||||
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> {
|
||||
export async function processPayment({ poId }: { poId: string }): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
|
||||
|
|
@ -59,10 +143,7 @@ export async function markPaid({
|
|||
|
||||
const po = await db.purchaseOrder.findUnique({
|
||||
where: { id: poId },
|
||||
include: {
|
||||
submitter: true,
|
||||
lineItems: true,
|
||||
},
|
||||
include: { submitter: true, lineItems: true },
|
||||
});
|
||||
if (!po) return { error: "PO not found" };
|
||||
if (!canPerformAction(po.status, "mark_paid", session.user.role)) {
|
||||
|
|
@ -85,29 +166,8 @@ export async function markPaid({
|
|||
},
|
||||
});
|
||||
|
||||
// Auto-update product catalogue: set lastPrice + lastVendorId for each linked product
|
||||
const linkedItems = po.lineItems.filter((li) => li.productId !== null);
|
||||
if (linkedItems.length > 0) {
|
||||
await Promise.all(
|
||||
linkedItems.map((li) =>
|
||||
db.product.update({
|
||||
where: { id: li.productId! },
|
||||
data: {
|
||||
lastPrice: li.unitPrice,
|
||||
lastVendorId: po.vendorId ?? undefined,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
await db.pOAction.create({
|
||||
data: {
|
||||
actionType: "PRODUCT_PRICE_UPDATED",
|
||||
actorId: session.user.id,
|
||||
poId,
|
||||
metadata: { updatedProductIds: linkedItems.map((li) => li.productId) },
|
||||
},
|
||||
});
|
||||
}
|
||||
// Sync product catalog: auto-create new items, upsert per-vendor prices
|
||||
await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id);
|
||||
|
||||
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
|
||||
await notify({ event: "PAYMENT_SENT", po, recipients: [po.submitter, ...managers] });
|
||||
|
|
|
|||
|
|
@ -18,15 +18,37 @@ export async function GET(req: NextRequest) {
|
|||
{ description: { contains: q, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
select: { id: true, code: true, name: true, description: true, lastPrice: true },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
description: true,
|
||||
lastPrice: true,
|
||||
vendorPrices: {
|
||||
select: {
|
||||
price: true,
|
||||
updatedAt: true,
|
||||
vendor: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
products.map((p) => ({
|
||||
...p,
|
||||
id: p.id,
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
lastPrice: p.lastPrice != null ? Number(p.lastPrice) : null,
|
||||
vendorPrices: p.vendorPrices.map((vp) => ({
|
||||
vendorId: vp.vendor.id,
|
||||
vendorName: vp.vendor.name,
|
||||
price: Number(vp.price),
|
||||
})),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type ProductHit = {
|
|||
name: string;
|
||||
description: string | null;
|
||||
lastPrice: number | null;
|
||||
vendorPrices: { vendorId: string; vendorName: string; price: number }[];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
|
|
@ -156,13 +157,18 @@ function NameCell({
|
|||
className="w-full text-left px-3 py-2 hover:bg-primary-50 flex items-start gap-2"
|
||||
>
|
||||
<span className="font-mono text-xs text-neutral-400 shrink-0 mt-0.5 w-24 truncate">{hit.code}</span>
|
||||
<span className="flex-1">
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="font-medium text-neutral-900">{hit.name}</span>
|
||||
{hit.description && (
|
||||
<span className="block text-xs text-neutral-500 truncate">{hit.description}</span>
|
||||
)}
|
||||
{hit.vendorPrices.length > 0 && (
|
||||
<span className="block text-xs text-neutral-400 truncate mt-0.5">
|
||||
{hit.vendorPrices.map(vp => `${vp.vendorName}: ${formatCurrency(vp.price)}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{hit.lastPrice != null && (
|
||||
{hit.lastPrice != null && hit.vendorPrices.length === 0 && (
|
||||
<span className="shrink-0 text-xs text-neutral-500">{formatCurrency(hit.lastPrice)}</span>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "ProductVendorPrice" (
|
||||
"id" TEXT NOT NULL,
|
||||
"price" DECIMAL(12,2) NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"vendorId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductVendorPrice_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProductVendorPrice_productId_vendorId_key" ON "ProductVendorPrice"("productId", "vendorId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductVendorPrice" ADD CONSTRAINT "ProductVendorPrice_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProductVendorPrice" ADD CONSTRAINT "ProductVendorPrice_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
|
@ -96,7 +96,8 @@ model Vendor {
|
|||
createdAt DateTime @default(now())
|
||||
|
||||
purchaseOrders PurchaseOrder[]
|
||||
products Product[] @relation("ProductLastVendor")
|
||||
products Product[] @relation("ProductLastVendor")
|
||||
vendorPrices ProductVendorPrice[]
|
||||
}
|
||||
|
||||
model Product {
|
||||
|
|
@ -112,6 +113,20 @@ model Product {
|
|||
createdAt DateTime @default(now())
|
||||
|
||||
lineItems POLineItem[]
|
||||
vendorPrices ProductVendorPrice[]
|
||||
}
|
||||
|
||||
model ProductVendorPrice {
|
||||
id String @id @default(cuid())
|
||||
price Decimal @db.Decimal(12, 2)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
vendorId String
|
||||
vendor Vendor @relation(fields: [vendorId], references: [id])
|
||||
|
||||
@@unique([productId, vendorId])
|
||||
}
|
||||
|
||||
model PurchaseOrder {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue