diff --git a/App/pelagia-portal/app/(portal)/payments/actions.ts b/App/pelagia-portal/app/(portal)/payments/actions.ts index d05e09e..2e59400 100644 --- a/App/pelagia-portal/app/(portal)/payments/actions.ts +++ b/App/pelagia-portal/app/(portal)/payments/actions.ts @@ -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 { +export async function processPayment({ poId }: { poId: string }): Promise { 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] }); diff --git a/App/pelagia-portal/app/api/products/search/route.ts b/App/pelagia-portal/app/api/products/search/route.ts index 866ade4..3b89361 100644 --- a/App/pelagia-portal/app/api/products/search/route.ts +++ b/App/pelagia-portal/app/api/products/search/route.ts @@ -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), + })), })) ); } diff --git a/App/pelagia-portal/components/po/po-line-items-editor.tsx b/App/pelagia-portal/components/po/po-line-items-editor.tsx index e0f2466..1dfd8fb 100644 --- a/App/pelagia-portal/components/po/po-line-items-editor.tsx +++ b/App/pelagia-portal/components/po/po-line-items-editor.tsx @@ -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" > {hit.code} - + {hit.name} {hit.description && ( {hit.description} )} + {hit.vendorPrices.length > 0 && ( + + {hit.vendorPrices.map(vp => `${vp.vendorName}: ${formatCurrency(vp.price)}`).join(" ยท ")} + + )} - {hit.lastPrice != null && ( + {hit.lastPrice != null && hit.vendorPrices.length === 0 && ( {formatCurrency(hit.lastPrice)} )} diff --git a/App/pelagia-portal/prisma/migrations/20260510221329_add_product_vendor_price/migration.sql b/App/pelagia-portal/prisma/migrations/20260510221329_add_product_vendor_price/migration.sql new file mode 100644 index 0000000..e277c0d --- /dev/null +++ b/App/pelagia-portal/prisma/migrations/20260510221329_add_product_vendor_price/migration.sql @@ -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; diff --git a/App/pelagia-portal/prisma/schema.prisma b/App/pelagia-portal/prisma/schema.prisma index 5dbc0c8..e70d148 100644 --- a/App/pelagia-portal/prisma/schema.prisma +++ b/App/pelagia-portal/prisma/schema.prisma @@ -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 {