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:
Hardik 2026-05-11 03:47:39 +05:30
parent f95b3279c8
commit f4e0d8ae63
5 changed files with 159 additions and 37 deletions

View file

@ -9,12 +9,96 @@ import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
// Step 1: Accounts picks up the PO — MGR_APPROVED → SENT_FOR_PAYMENT
export async function processPayment({
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,
}: {
poId: string;
}): Promise<ActionResult> {
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();
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] });

View file

@ -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),
})),
}))
);
}

View file

@ -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>
{hit.lastPrice != null && (
)}
</span>
{hit.lastPrice != null && hit.vendorPrices.length === 0 && (
<span className="shrink-0 text-xs text-neutral-500">{formatCurrency(hit.lastPrice)}</span>
)}
</button>

View file

@ -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;

View file

@ -97,6 +97,7 @@ model Vendor {
purchaseOrders PurchaseOrder[]
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 {