import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; export const toolsRoute = new Elysia({ prefix: "/tools" }) // ── 1. GET Master Product Variants (For mapping dropdown/picker) ──────────── .get("/marketplace-products/master-variants", async () => { const variants = await prisma.productVariant.findMany({ include: { product: { select: { id: true, name: true, category: { select: { name: true } }, brand: { select: { name: true } }, }, }, bomItems: { include: { rawMaterial: { select: { pricePerUnit: true, name: true, unit: true }, }, }, }, }, orderBy: [ { product: { name: "asc" } }, { sku: "asc" }, ], }); const result = variants.map((v) => { // Calculate unit BOM cost const bomCost = (v.bomItems || []).reduce((sum, b) => { return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0)); }, 0); const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); const displayName = `${v.product?.name || "Unnamed Product"} ${optionsStr ? `(${optionsStr})` : ""} ${v.sku ? `[${v.sku}]` : ""}`.trim(); return { id: v.id, sku: v.sku || "", barcode: v.barcode || "", price: v.price || 0, stock: v.stock || 0, optionsStr, productName: v.product?.name || "", categoryName: v.product?.category?.name || "", brandName: v.product?.brand?.name || "", bomCost, displayName, }; }); return { variants: result }; }) // ── 2. POST Sync & Extract Marketplace Products ────────────────────────────── .post("/marketplace-products/sync", async () => { // A. Fetch all existing variants for auto-matching const masterVariants = await prisma.productVariant.findMany({ select: { id: true, sku: true, barcode: true }, }); const skuToVariantId = new Map(); for (const v of masterVariants) { if (v.sku?.trim()) skuToVariantId.set(v.sku.trim().toLowerCase(), v.id); if (v.barcode?.trim()) skuToVariantId.set(v.barcode.trim().toLowerCase(), v.id); } // Existing marketplace products map to preserve manual mappings const existingMp = await prisma.marketplaceProduct.findMany({ select: { id: true, channel: true, shop: true, productName: true, variation: true, marketplaceSku: true, masterVariantId: true, }, }); const existingMap = new Map(); for (const ep of existingMp) { const key = `${ep.channel}:::${ep.shop}:::${ep.productName}:::${ep.variation}:::${ep.marketplaceSku}`; existingMap.set(key, { id: ep.id, masterVariantId: ep.masterVariantId }); } // In-memory aggregation map interface AggregatedItem { channel: string; shop: string; marketplaceSku: string; productName: string; variation: string; totalSoldQty: number; totalRevenue: number; firstSoldAt: string | null; lastSoldAt: string | null; } const aggregated = new Map(); const updateAgg = ( channel: string, shop: string, sku: string, name: string, variation: string, qty: number, rev: number, time: string | null ) => { const cleanSku = (sku || "").trim(); const cleanName = (name || "").trim() || "ไม่ระบุชื่อสินค้า"; const cleanVar = (variation || "").trim(); const cleanShop = (shop || "").trim() || "hom-aroma"; const key = `${channel}:::${cleanShop}:::${cleanName}:::${cleanVar}:::${cleanSku}`; let item = aggregated.get(key); if (!item) { item = { channel, shop: cleanShop, marketplaceSku: cleanSku, productName: cleanName, variation: cleanVar, totalSoldQty: 0, totalRevenue: 0, firstSoldAt: time, lastSoldAt: time, }; aggregated.set(key, item); } item.totalSoldQty += qty; item.totalRevenue += rev; if (time) { if (!item.firstSoldAt || time < item.firstSoldAt) item.firstSoldAt = time; if (!item.lastSoldAt || time > item.lastSoldAt) item.lastSoldAt = time; } }; // 1. Scan TikTok Order Items const tiktokItems = await prisma.tiktokOrderItem.findMany({ include: { order: { select: { shop: true, createdTime: true } }, }, }); for (const it of tiktokItems) { const shop = it.order?.shop || "hom-aroma"; const sku = it.sellerSku || it.skuId || ""; const rev = it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : (it.unitOriginalPrice * (it.quantity || 1)); updateAgg("tiktok", shop, sku, it.productName, it.variation || "", it.quantity || 1, rev, it.order?.createdTime || null); } // 2. Scan Shopee Order Items const shopeeItems = await prisma.shopeeOrderItem.findMany({ include: { order: { select: { shop: true, orderCreatedAt: true } }, }, }); for (const it of shopeeItems) { const shop = it.order?.shop || "hom-aroma"; const sku = it.skuRef || it.parentSku || ""; const rev = it.totalPayment > 0 ? it.totalPayment : (it.dealPrice * (it.quantity || 1)); updateAgg("shopee", shop, sku, it.productName, it.variantName || "", it.quantity || 1, rev, it.order?.orderCreatedAt || null); } // 3. Scan Lazada Order Items const lazadaItems = await prisma.lazadaOrderItem.findMany({ include: { order: { select: { shop: true, createTime: true } }, }, }); for (const it of lazadaItems) { const shop = it.order?.shop || "hom-aroma"; const sku = it.sellerSku || it.lazadaSku || ""; const rev = it.paidPrice > 0 ? it.paidPrice : it.unitPrice; updateAgg("lazada", shop, sku, it.itemName, it.variation || "", 1, rev, it.order?.createTime || null); } // Upsert into Database let syncedCount = 0; let matchedCount = 0; const itemsToProcess = Array.from(aggregated.values()); const batchSize = 100; for (let i = 0; i < itemsToProcess.length; i += batchSize) { const chunk = itemsToProcess.slice(i, i + batchSize); await prisma.$transaction( chunk.map((item) => { const key = `${item.channel}:::${item.shop}:::${item.productName}:::${item.variation}:::${item.marketplaceSku}`; const existing = existingMap.get(key); // Determine masterVariantId let masterVariantId = existing?.masterVariantId || null; // If unmapped, try auto-matching by SKU if (!masterVariantId && item.marketplaceSku) { const normalized = item.marketplaceSku.trim().toLowerCase(); const autoMatchId = skuToVariantId.get(normalized); if (autoMatchId) { masterVariantId = autoMatchId; matchedCount++; } } const avgPrice = item.totalSoldQty > 0 ? item.totalRevenue / item.totalSoldQty : 0; return prisma.marketplaceProduct.upsert({ where: { channel_shop_productName_variation_marketplaceSku: { channel: item.channel, shop: item.shop, productName: item.productName, variation: item.variation, marketplaceSku: item.marketplaceSku, }, }, create: { channel: item.channel, shop: item.shop, marketplaceSku: item.marketplaceSku, productName: item.productName, variation: item.variation, avgPrice, totalSoldQty: item.totalSoldQty, totalRevenue: item.totalRevenue, firstSoldAt: item.firstSoldAt, lastSoldAt: item.lastSoldAt, masterVariantId, }, update: { avgPrice, totalSoldQty: item.totalSoldQty, totalRevenue: item.totalRevenue, firstSoldAt: item.firstSoldAt, lastSoldAt: item.lastSoldAt, // If already mapped manually, preserve it; otherwise use newly matched ...(masterVariantId ? { masterVariantId } : {}), }, }); }) ); syncedCount += chunk.length; } return { success: true, syncedCount, matchedCount, totalMarketplaceProducts: itemsToProcess.length, }; }) // ── 3. GET List of Marketplace Products with Profitability ────────────────── .get("/marketplace-products", async ({ query }) => { const channel = (query.channel as string) || "all"; const shop = (query.shop as string) || "all"; const status = (query.status as string) || "all"; // 'all' | 'mapped' | 'unmapped' const search = (query.search as string || "").trim(); const sortBy = (query.sortBy as string) || "revenue"; // 'revenue' | 'sold' | 'name' | 'recent' const page = parseInt(query.page as string || "1", 10); const limit = parseInt(query.limit as string || "50", 10); // Build Where Clause const where: any = {}; if (channel !== "all") where.channel = channel; if (shop !== "all") where.shop = shop; if (status === "mapped") where.masterVariantId = { not: null }; if (status === "unmapped") where.masterVariantId = null; if (search) { where.OR = [ { productName: { contains: search, mode: "insensitive" } }, { marketplaceSku: { contains: search, mode: "insensitive" } }, { variation: { contains: search, mode: "insensitive" } }, { masterVariant: { sku: { contains: search, mode: "insensitive" } } }, { masterVariant: { product: { name: { contains: search, mode: "insensitive" } } } }, ]; } // Build OrderBy Clause let orderBy: any = [{ totalRevenue: "desc" }]; if (sortBy === "sold") orderBy = [{ totalSoldQty: "desc" }]; if (sortBy === "name") orderBy = [{ productName: "asc" }]; if (sortBy === "recent") orderBy = [{ lastSoldAt: "desc" }]; // Fetch Global KPIs across all items (without pagination limits) const allItems = await prisma.marketplaceProduct.findMany({ where: channel !== "all" || shop !== "all" ? { ...(channel !== "all" ? { channel } : {}), ...(shop !== "all" ? { shop } : {}) } : {}, include: { masterVariant: { include: { bomItems: { include: { rawMaterial: { select: { pricePerUnit: true } } }, }, }, }, }, }); let totalSkus = allItems.length; let mappedSkus = 0; let totalSoldQty = 0; let totalRevenue = 0; let totalEstCost = 0; let totalEstProfit = 0; for (const item of allItems) { totalSoldQty += item.totalSoldQty; totalRevenue += item.totalRevenue; if (item.masterVariantId && item.masterVariant) { mappedSkus++; const unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => { return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0)); }, 0); const cost = unitBomCost * item.totalSoldQty; totalEstCost += cost; totalEstProfit += (item.totalRevenue - cost); } } const unmappedSkus = totalSkus - mappedSkus; const mappedPercent = totalSkus > 0 ? (mappedSkus / totalSkus) * 100 : 0; const grossProfitMargin = totalRevenue > 0 ? (totalEstProfit / totalRevenue) * 100 : 0; // Fetch Paginated Items const [items, totalCount] = await Promise.all([ prisma.marketplaceProduct.findMany({ where, orderBy, skip: (page - 1) * limit, take: limit, include: { masterVariant: { include: { product: { select: { id: true, name: true, category: { select: { name: true } } }, }, bomItems: { include: { rawMaterial: { select: { name: true, pricePerUnit: true, unit: true } }, }, }, }, }, }, }), prisma.marketplaceProduct.count({ where }), ]); // Format output with profitability details const formattedItems = items.map((item) => { let unitBomCost = 0; let totalCost = 0; let profit = 0; let marginPercent = 0; if (item.masterVariant) { unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => { return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0)); }, 0); totalCost = unitBomCost * item.totalSoldQty; profit = item.totalRevenue - totalCost; marginPercent = item.totalRevenue > 0 ? (profit / item.totalRevenue) * 100 : 0; } return { id: item.id, channel: item.channel, shop: item.shop, marketplaceSku: item.marketplaceSku, productName: item.productName, variation: item.variation, avgPrice: item.avgPrice, totalSoldQty: item.totalSoldQty, totalRevenue: item.totalRevenue, firstSoldAt: item.firstSoldAt, lastSoldAt: item.lastSoldAt, masterVariantId: item.masterVariantId, masterVariant: item.masterVariant ? { id: item.masterVariant.id, sku: item.masterVariant.sku, barcode: item.masterVariant.barcode, productName: item.masterVariant.product?.name, categoryName: item.masterVariant.product?.category?.name, option1: item.masterVariant.option1, option2: item.masterVariant.option2, option3: item.masterVariant.option3, unitBomCost, } : null, profitability: item.masterVariant ? { unitBomCost, totalCost, profit, marginPercent, } : null, }; }); return { kpi: { totalSkus, mappedSkus, unmappedSkus, mappedPercent, totalSoldQty, totalRevenue, totalEstCost, totalEstProfit, grossProfitMargin, }, pagination: { total: totalCount, page, limit, totalPages: Math.ceil(totalCount / limit) || 1, }, items: formattedItems, }; }) // ── 4. POST Link / Unlink Master Variant ───────────────────────────────────── .post( "/marketplace-products/map", async ({ body }: { body: { marketplaceProductId: number; masterVariantId: number | null } }) => { const { marketplaceProductId, masterVariantId } = body; const updated = await prisma.marketplaceProduct.update({ where: { id: marketplaceProductId }, data: { masterVariantId }, include: { masterVariant: { include: { product: { select: { id: true, name: true } }, bomItems: { include: { rawMaterial: { select: { pricePerUnit: true } } }, }, }, }, }, }); return { success: true, item: updated }; }, { body: t.Object({ marketplaceProductId: t.Number(), masterVariantId: t.Nullable(t.Number()), }), } ) // ── 5. POST Bulk Auto Match Unmapped Items ────────────────────────────────── .post("/marketplace-products/bulk-auto-match", async () => { const masterVariants = await prisma.productVariant.findMany({ select: { id: true, sku: true, barcode: true }, }); const skuMap = new Map(); for (const v of masterVariants) { if (v.sku?.trim()) skuMap.set(v.sku.trim().toLowerCase(), v.id); if (v.barcode?.trim()) skuMap.set(v.barcode.trim().toLowerCase(), v.id); } const unmapped = await prisma.marketplaceProduct.findMany({ where: { masterVariantId: null, marketplaceSku: { not: "" } }, }); let matched = 0; for (const item of unmapped) { const norm = item.marketplaceSku.trim().toLowerCase(); const variantId = skuMap.get(norm); if (variantId) { await prisma.marketplaceProduct.update({ where: { id: item.id }, data: { masterVariantId: variantId }, }); matched++; } } const remainingUnmapped = await prisma.marketplaceProduct.count({ where: { masterVariantId: null }, }); return { success: true, matched, remainingUnmapped, }; });