import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; // ── Date Range Helper ──────────────────────────────────────────────────────── interface DateRangeInfo { start: Date | null; end: Date | null; label: string; daysCount: number; period: string; } function resolveDateRange(period?: string, startStr?: string, endStr?: string): DateRangeInfo { const p = (period || "all").trim().toLowerCase(); const now = new Date(); if (p === "7d") { const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); return { period: p, start, end: now, label: "7 วันล่าสุด (Last 7 Days)", daysCount: 7 }; } if (p === "30d") { const start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); return { period: p, start, end: now, label: "30 วันล่าสุด (Last 30 Days)", daysCount: 30 }; } if (p === "60d") { const start = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000); return { period: p, start, end: now, label: "60 วันล่าสุด (Last 60 Days)", daysCount: 60 }; } if (p === "90d") { const start = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000); return { period: p, start, end: now, label: "90 วันล่าสุด (Last 90 Days)", daysCount: 90 }; } if (p === "this_month") { const start = new Date(now.getFullYear(), now.getMonth(), 1); const days = Math.ceil((now.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) || 1; return { period: p, start, end: now, label: "เดือนนี้ (This Month)", daysCount: days }; } if (p === "last_month") { const start = new Date(now.getFullYear(), now.getMonth() - 1, 1); const end = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); const days = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) || 30; return { period: p, start, end, label: "เดือนที่แล้ว (Last Month)", daysCount: days }; } if (p === "custom" && startStr) { const start = new Date(startStr); const end = endStr ? new Date(`${endStr}T23:59:59.999Z`) : now; const days = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000))); return { period: p, start, end, label: `${startStr} ถึง ${endStr || "ปัจจุบัน"}`, daysCount: days }; } // Default: All Time return { period: "all", start: null, end: null, label: "ทั้งหมด (All Time)", daysCount: 365 }; } function parseOrderDate(dateStr: string | Date | null | undefined): Date | null { if (!dateStr) return null; if (dateStr instanceof Date) return dateStr; const cleaned = String(dateStr).trim().replace(/\//g, "-"); const d = new Date(cleaned); return isNaN(d.getTime()) ? null : d; } const toNum = (val: any): number => { if (val === null || val === undefined || val === "") return 0; const s = String(val).replace(/[^0-9.\-]/g, ""); const n = parseFloat(s); return isNaN(n) ? 0 : n; }; const DEFAULT_FALLBACK_FEE_PERCENT = 30.0; // 30% conservative platform deduction fallback export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) // ───────────────────────────────────────────────────────────────────────────── // 1. Cost & Profitability Matrix (Catalog-Level Baseline) // ───────────────────────────────────────────────────────────────────────────── .get( "/matrix", async ({ query }) => { const search = (query.search || "").trim(); const category = (query.category || "all").trim(); const marginFilter = (query.margin || "all").trim(); const sortBy = query.sortBy || "margin_desc"; const page = query.page ? parseInt(query.page) : 1; const limit = query.limit ? parseInt(query.limit) : 50; // Fetch all active variants with Product, Category, and BOM const variants = await prisma.productVariant.findMany({ where: { product: { isDeleted: false } }, include: { product: { select: { id: true, name: true, category: { select: { id: true, name: true } }, brand: { select: { id: true, name: true } }, translations: { select: { langCode: true, name: true } }, }, }, bomItems: { include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true }, }, }, }, }, orderBy: [ { product: { name: "asc" } }, { sku: "asc" }, ], }); const mapped = variants.map((v) => { const thName = v.product.translations?.find((t) => t.langCode === "th")?.name; const productName = v.product.name || thName || `Product #${v.product.id}`; const categoryName = v.product.category?.name || "Uncategorized"; const brandName = v.product.brand?.name || "HomAroma"; const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); const retailPrice = v.price || 0; let liquidCost = 0; let packagingCost = 0; const bomItemsFormatted = (v.bomItems || []).map((b) => { const rm = b.rawMaterial; const unitPrice = rm?.pricePerUnit || 0; const cost = Math.round(b.quantity * unitPrice * 100) / 100; const isLiquid = rm?.unit === "ml" || rm?.name.includes("หัวน้ำหอม") || rm?.name.includes("DPG") || rm?.name.includes("DPM") || rm?.name.includes("น้ำหอม"); if (isLiquid) { liquidCost += cost; } else { packagingCost += cost; } return { id: b.id, rawMaterialId: rm?.id, name: rm?.name || "Unknown Material", unit: rm?.unit || "piece", unitPrice, quantity: b.quantity, totalCost: cost, isLiquid, }; }); liquidCost = Math.round(liquidCost * 100) / 100; packagingCost = Math.round(packagingCost * 100) / 100; const totalBomCost = Math.round((liquidCost + packagingCost) * 100) / 100; const grossProfit = Math.round((retailPrice - totalBomCost) * 100) / 100; const grossMargin = retailPrice > 0 ? Math.round(((retailPrice - totalBomCost) / retailPrice) * 1000) / 10 : 0; const minAcceptablePrice = totalBomCost / (1 - 0.40); const maxDiscountPercent = retailPrice > minAcceptablePrice ? Math.round(((retailPrice - minAcceptablePrice) / retailPrice) * 100) : 0; let marginHealth: "high" | "mid" | "low" = "mid"; if (grossMargin >= 70) marginHealth = "high"; else if (grossMargin < 55) marginHealth = "low"; return { id: v.id, sku: v.sku || `VAR-${v.id}`, productId: v.product.id, productName, categoryName, brandName, optionsStr, retailPrice, liquidCost, packagingCost, totalBomCost, grossProfit, grossMargin, marginHealth, maxDiscountPercent, bomCount: bomItemsFormatted.length, bomItems: bomItemsFormatted, }; }); const totalVariants = mapped.length; const configuredBomVariants = mapped.filter((m) => m.bomCount > 0).length; const totalCatalogRevenueAtFullList = mapped.reduce((s, m) => s + m.retailPrice, 0); const totalCatalogBomCost = mapped.reduce((s, m) => s + m.totalBomCost, 0); const avgGrossMargin = totalCatalogRevenueAtFullList > 0 ? Math.round(((totalCatalogRevenueAtFullList - totalCatalogBomCost) / totalCatalogRevenueAtFullList) * 1000) / 10 : 0; const highMarginCount = mapped.filter((m) => m.marginHealth === "high").length; const midMarginCount = mapped.filter((m) => m.marginHealth === "mid").length; const lowMarginCount = mapped.filter((m) => m.marginHealth === "low").length; const categoryMap = new Map(); for (const item of mapped) { let entry = categoryMap.get(item.categoryName); if (!entry) { entry = { count: 0, totalRev: 0, totalCost: 0 }; categoryMap.set(item.categoryName, entry); } entry.count++; entry.totalRev += item.retailPrice; entry.totalCost += item.totalBomCost; } const categoryBreakdown = Array.from(categoryMap.entries()).map(([name, stat]) => ({ categoryName: name, variantsCount: stat.count, avgPrice: stat.count > 0 ? Math.round(stat.totalRev / stat.count) : 0, avgCost: stat.count > 0 ? Math.round((stat.totalCost / stat.count) * 100) / 100 : 0, avgMargin: stat.totalRev > 0 ? Math.round(((stat.totalRev - stat.totalCost) / stat.totalRev) * 1000) / 10 : 0, })); let filtered = mapped; if (search) { const s = search.toLowerCase(); filtered = filtered.filter( (m) => m.productName.toLowerCase().includes(s) || m.sku.toLowerCase().includes(s) || m.optionsStr.toLowerCase().includes(s) || m.categoryName.toLowerCase().includes(s) ); } if (category !== "all") { filtered = filtered.filter((m) => m.categoryName.toLowerCase() === category.toLowerCase()); } if (marginFilter !== "all") { filtered = filtered.filter((m) => m.marginHealth === marginFilter); } filtered.sort((a, b) => { if (sortBy === "margin_desc") return b.grossMargin - a.grossMargin; if (sortBy === "margin_asc") return a.grossMargin - b.grossMargin; if (sortBy === "profit_desc") return b.grossProfit - a.grossProfit; if (sortBy === "profit_asc") return a.grossProfit - b.grossProfit; if (sortBy === "cost_desc") return b.totalBomCost - a.totalBomCost; if (sortBy === "cost_asc") return a.totalBomCost - b.totalBomCost; if (sortBy === "price_desc") return b.retailPrice - a.retailPrice; if (sortBy === "price_asc") return a.retailPrice - b.retailPrice; if (sortBy === "name_asc") return a.productName.localeCompare(b.productName); return 0; }); const totalFiltered = filtered.length; const paginated = filtered.slice((page - 1) * limit, page * limit); return { kpis: { totalVariants, configuredBomVariants, avgGrossMargin, totalCatalogRevenueAtFullList, totalCatalogBomCost, highMarginCount, midMarginCount, lowMarginCount, categoryBreakdown, }, pagination: { total: totalFiltered, page, limit, totalPages: Math.ceil(totalFiltered / limit) || 1, }, items: paginated, }; }, { query: t.Object({ search: t.Optional(t.String()), category: t.Optional(t.String()), margin: t.Optional(t.String()), sortBy: t.Optional(t.String()), page: t.Optional(t.String()), limit: t.Optional(t.String()), }), } ) // ───────────────────────────────────────────────────────────────────────────── // 2. Omnichannel Net Margin (Supports Date Range Filtering) // ───────────────────────────────────────────────────────────────────────────── .get("/omnichannel", async ({ query }) => { const search = (query.search || "").trim().toLowerCase(); const channel = (query.channel || "all").trim().toLowerCase(); const dateRange = resolveDateRange(query.period || "this_month", query.startDate, query.endDate); // 1. Fetch mapped marketplace products with master variants & BOM const mpProducts = await prisma.marketplaceProduct.findMany({ where: { masterVariantId: { not: null }, ...(channel !== "all" ? { channel } : {}), }, include: { masterVariant: { include: { product: { select: { id: true, name: true, category: { select: { name: true } }, }, }, bomItems: { include: { rawMaterial: { select: { pricePerUnit: true, name: true } } }, }, }, }, }, orderBy: { totalRevenue: "desc" }, }); // Look up helper: map marketplace item keys to marketplaceProduct.id const mpLookup = new Map(); for (const mp of mpProducts) { const key = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`; mpLookup.set(key, mp.id); if (mp.marketplaceSku) { mpLookup.set(`${mp.channel}:::${mp.marketplaceSku}`, mp.id); } } // 2. Dynamic Order Aggregator (Actual Fee Extraction with 30% Fallback) interface DynamicSalesEntry { soldQty: number; revenue: number; actualFeeSum: number; fallbackFeeSum: number; actualCount: number; fallbackCount: number; } const dynamicSalesMap = new Map(); const start = dateRange.start; const end = dateRange.end || new Date(); // A. TikTok Orders if (channel === "all" || channel === "tiktok") { const ttOrders = await prisma.tiktokOrder.findMany({ where: { createdTime: { not: null } }, select: { orderId: true, createdTime: true, shop: true, orderAmount: true, commissionFee: true, affiliateFee: true, netSettlement: true, isSettled: true, items: { select: { sellerSku: true, skuId: true, productName: true, variation: true, quantity: true, subtotalAfterDiscount: true, unitOriginalPrice: true, }, }, }, }); for (const order of ttOrders) { const orderDate = parseOrderDate(order.createdTime); if (start && (!orderDate || orderDate < start || orderDate > end)) continue; const orderAmount = order.orderAmount || 0; // Check actual deduction from settlement or commission const hasSettlement = order.isSettled && order.netSettlement > 0; const actualOrderFee = hasSettlement ? Math.max(0, orderAmount - order.netSettlement) : ((order.commissionFee || 0) + (order.affiliateFee || 0)); const isActual = actualOrderFee > 0; for (const item of order.items) { const sku = item.sellerSku || item.skuId || ""; const key1 = `tiktok:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variation || ""}:::${sku}`; const key2 = `tiktok:::${sku}`; const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null); if (!mpId) continue; const qty = item.quantity || 1; const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty); const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0, actualFeeSum: 0, fallbackFeeSum: 0, actualCount: 0, fallbackCount: 0, }; cur.soldQty += qty; cur.revenue += rev; if (isActual) { // Proportional share of actual order deduction const share = orderAmount > 0 ? (rev / orderAmount) : 1; cur.actualFeeSum += actualOrderFee * share; cur.actualCount += 1; } else { // Fallback 30% cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100); cur.fallbackCount += 1; } dynamicSalesMap.set(mpId, cur); } } } // B. Shopee Orders if (channel === "all" || channel === "shopee") { const spOrders = await prisma.shopeeOrder.findMany({ where: { orderCreatedAt: { not: null } }, select: { orderId: true, orderCreatedAt: true, shop: true, totalAmount: true, items: { select: { parentSku: true, skuRef: true, productName: true, variantName: true, quantity: true, totalPayment: true, dealPrice: true, rawData: true, }, }, }, }); for (const order of spOrders) { const orderDate = parseOrderDate(order.orderCreatedAt); if (start && (!orderDate || orderDate < start || orderDate > end)) continue; // Shopee Order-level actual fee from rawData (first item has order-level fees) const firstRaw = (order.items[0]?.rawData as any) || {}; const comm = toNum(firstRaw["ค่าคอมมิชชั่น"]); const service = toNum(firstRaw["ค่าบริการ"]); const tx = toNum(firstRaw["Transaction Fee"]); const voucher = toNum(firstRaw["โค้ดส่วนลดชำระโดยผู้ขาย"]); const orderActualFee = comm + service + tx + voucher; const isActual = orderActualFee > 0; const orderTotalRev = order.totalAmount || order.items.reduce((sum, it) => sum + (it.totalPayment > 0 ? it.totalPayment : (it.dealPrice * it.quantity)), 0); for (const item of order.items) { const sku = item.skuRef || item.parentSku || ""; const key1 = `shopee:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variantName || ""}:::${sku}`; const key2 = `shopee:::${sku}`; const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null); if (!mpId) continue; const qty = item.quantity || 1; const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty); const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0, actualFeeSum: 0, fallbackFeeSum: 0, actualCount: 0, fallbackCount: 0, }; cur.soldQty += qty; cur.revenue += rev; if (isActual) { const share = orderTotalRev > 0 ? (rev / orderTotalRev) : 1; cur.actualFeeSum += orderActualFee * share; cur.actualCount += 1; } else { // Fallback 30% cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100); cur.fallbackCount += 1; } dynamicSalesMap.set(mpId, cur); } } } // C. Lazada Orders if (channel === "all" || channel === "lazada") { const lzOrders = await prisma.lazadaOrder.findMany({ where: { createTime: { not: null } }, select: { createTime: true, shop: true, items: { select: { sellerSku: true, lazadaSku: true, itemName: true, variation: true, paidPrice: true, unitPrice: true, }, }, }, }); for (const order of lzOrders) { const orderDate = parseOrderDate(order.createTime); if (start && (!orderDate || orderDate < start || orderDate > end)) continue; for (const item of order.items) { const sku = item.sellerSku || item.lazadaSku || ""; const key1 = `lazada:::${order.shop || "hom-aroma"}:::${item.itemName}:::${item.variation || ""}:::${sku}`; const key2 = `lazada:::${sku}`; const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null); if (!mpId) continue; const qty = 1; const rev = item.paidPrice > 0 ? item.paidPrice : item.unitPrice; const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0, actualFeeSum: 0, fallbackFeeSum: 0, actualCount: 0, fallbackCount: 0, }; cur.soldQty += qty; cur.revenue += rev; // Lazada has no settlement import yet -> 30% fallback cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100); cur.fallbackCount += 1; dynamicSalesMap.set(mpId, cur); } } } // 3. Map Items & Accumulate KPIs let totalGrossRevenue = 0; let totalEstNetRevenue = 0; let totalBOMCost = 0; let totalEstNetProfit = 0; let totalUnitsSold = 0; let totalPlatformFee = 0; let totalActualFeeSum = 0; let totalFallbackFeeSum = 0; let totalActualOrdersCount = 0; let totalFallbackOrdersCount = 0; const channelStats: Record = { shopee: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 }, lazada: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 }, tiktok: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 }, }; const mappedItems: any[] = []; for (const item of mpProducts) { let soldQty = 0; let rev = 0; let platformFee = 0; let feeSource: "actual" | "hybrid" | "fallback_30pct" = "fallback_30pct"; let actualFeePart = 0; let fallbackFeePart = 0; const dyn = dynamicSalesMap.get(item.id); if (dyn && dyn.soldQty > 0) { soldQty = dyn.soldQty; rev = dyn.revenue; actualFeePart = dyn.actualFeeSum; fallbackFeePart = dyn.fallbackFeeSum; platformFee = Math.round((dyn.actualFeeSum + dyn.fallbackFeeSum) * 100) / 100; if (dyn.actualCount > 0 && dyn.fallbackCount === 0) { feeSource = "actual"; } else if (dyn.actualCount > 0 && dyn.fallbackCount > 0) { feeSource = "hybrid"; } else { feeSource = "fallback_30pct"; } totalActualOrdersCount += dyn.actualCount; totalFallbackOrdersCount += dyn.fallbackCount; } else if (!start) { // All-time fallback from MarketplaceProduct aggregate if no order details if (item.totalSoldQty === 0 && item.totalRevenue === 0) continue; soldQty = item.totalSoldQty; rev = item.totalRevenue; platformFee = Math.round(rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100) * 100) / 100; fallbackFeePart = platformFee; feeSource = "fallback_30pct"; totalFallbackOrdersCount += 1; } else { // Date range filtered and no sales in this period continue; } const v = item.masterVariant!; const unitBomCost = (v.bomItems || []).reduce( (sum, b) => sum + b.quantity * (b.rawMaterial?.pricePerUnit || 0), 0 ); const itemBomCost = Math.round(unitBomCost * soldQty * 100) / 100; const ch = item.channel.toLowerCase(); const netRevenue = Math.round((rev - platformFee) * 100) / 100; const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100; const netMarginPercent = rev > 0 ? Math.round((netProfit / rev) * 1000) / 10 : 0; const effectiveFeePercent = rev > 0 ? Math.round((platformFee / rev) * 1000) / 10 : DEFAULT_FALLBACK_FEE_PERCENT; const avgSellingPrice = soldQty > 0 ? Math.round((rev / soldQty) * 100) / 100 : item.avgPrice; const directPrice = v.price || avgSellingPrice || 0; const directGrossProfit = directPrice - unitBomCost; const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0; totalGrossRevenue += rev; totalPlatformFee += platformFee; totalEstNetRevenue += netRevenue; totalBOMCost += itemBomCost; totalEstNetProfit += netProfit; totalUnitsSold += soldQty; totalActualFeeSum += actualFeePart; totalFallbackFeeSum += fallbackFeePart; if (channelStats[ch]) { channelStats[ch].soldQty += soldQty; channelStats[ch].revenue += rev; channelStats[ch].platformFee += platformFee; channelStats[ch].bomCost += itemBomCost; channelStats[ch].netProfit += netProfit; } mappedItems.push({ id: item.id, channel: item.channel, shop: item.shop, marketplaceSku: item.marketplaceSku, marketplaceProductName: item.productName, variation: item.variation, avgSellingPrice, totalSoldQty: soldQty, totalRevenue: rev, masterVariantId: v.id, masterSku: v.sku, productName: v.product?.name || "Product", categoryName: v.product?.category?.name || "Uncategorized", unitBomCost: Math.round(unitBomCost * 100) / 100, totalBomCost: itemBomCost, platformFee, effectiveFeePercent, feeSource, // "actual" | "hybrid" | "fallback_30pct" netRevenue, netProfit, netMarginPercent, directPrice, directMarginPercent, }); } let filtered = mappedItems; if (search) { filtered = filtered.filter( (m) => m.marketplaceProductName.toLowerCase().includes(search) || m.productName.toLowerCase().includes(search) || m.masterSku?.toLowerCase().includes(search) || m.variation.toLowerCase().includes(search) ); } const overallNetMargin = totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0; const effectiveFeeRate = totalGrossRevenue > 0 ? Math.round((totalPlatformFee / totalGrossRevenue) * 1000) / 10 : DEFAULT_FALLBACK_FEE_PERCENT; return { periodInfo: { period: dateRange.period, label: dateRange.label, startDate: dateRange.start ? dateRange.start.toISOString().slice(0, 10) : null, endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null, daysCount: dateRange.daysCount, }, feePolicy: { mode: "hybrid_actual_with_30pct_fallback", fallbackPercent: DEFAULT_FALLBACK_FEE_PERCENT, description: "Shopee uses actual fees from report rawData. TikTok uses actual net settlement & fees. Any unmapped or non-settled order falls back to 30% platform deduction.", }, kpis: { totalMappedProducts: mappedItems.length, totalUnitsSold, totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100, totalPlatformFee: Math.round(totalPlatformFee * 100) / 100, totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100, totalBOMCost: Math.round(totalBOMCost * 100) / 100, totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100, overallNetMargin, effectiveFeeRate, actualFeeAmount: Math.round(totalActualFeeSum * 100) / 100, fallbackFeeAmount: Math.round(totalFallbackFeeSum * 100) / 100, actualOrdersCount: totalActualOrdersCount, fallbackOrdersCount: totalFallbackOrdersCount, channelStats, }, items: filtered, }; }) // ───────────────────────────────────────────────────────────────────────────── // 3. MRP & Material Requirement Planning (Supports Date Range & Forecasting) // ───────────────────────────────────────────────────────────────────────────── .get("/mrp", async ({ query }) => { const dateRange = resolveDateRange(query.period, query.startDate, query.endDate); // 1. Fetch raw materials const rawMaterials = await prisma.rawMaterial.findMany({ orderBy: { name: "asc" }, include: { bomItems: { include: { productVariant: { include: { product: { select: { id: true, name: true, category: { select: { name: true } } } }, }, }, }, }, }, }); // 2. Fetch sales quantities per Master Variant const variantSoldMap = new Map(); if (!dateRange.start) { // All Time: Aggregate from MarketplaceProduct const mpProducts = await prisma.marketplaceProduct.findMany({ where: { masterVariantId: { not: null } }, select: { masterVariantId: true, totalSoldQty: true }, }); for (const mp of mpProducts) { if (mp.masterVariantId) { const current = variantSoldMap.get(mp.masterVariantId) || 0; variantSoldMap.set(mp.masterVariantId, current + mp.totalSoldQty); } } } else { // Date Range Filter: Aggregate from actual Order items const start = dateRange.start; const end = dateRange.end || new Date(); // Look up helper: maps sellerSku / SKU to masterVariantId const variants = await prisma.productVariant.findMany({ select: { id: true, sku: true, barcode: true }, }); const skuMap = new Map(); for (const v of variants) { if (v.sku) skuMap.set(v.sku.trim().toLowerCase(), v.id); if (v.barcode) skuMap.set(v.barcode.trim().toLowerCase(), v.id); } const mps = await prisma.marketplaceProduct.findMany({ where: { masterVariantId: { not: null } }, select: { channel: true, shop: true, productName: true, variation: true, marketplaceSku: true, masterVariantId: true }, }); const mpKeyMap = new Map(); for (const mp of mps) { const k = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`; mpKeyMap.set(k, mp.masterVariantId!); if (mp.marketplaceSku) mpKeyMap.set(`${mp.channel}:::${mp.marketplaceSku.trim().toLowerCase()}`, mp.masterVariantId!); } // Scan TikTok Orders const ttOrders = await prisma.tiktokOrder.findMany({ where: { createdTime: { not: null } }, select: { createdTime: true, shop: true, items: { select: { sellerSku: true, skuId: true, productName: true, variation: true, quantity: true } }, }, }); for (const o of ttOrders) { const d = parseOrderDate(o.createdTime); if (!d || d < start || d > end) continue; for (const it of o.items) { const sku = (it.sellerSku || it.skuId || "").trim().toLowerCase(); const k = `tiktok:::${o.shop || "hom-aroma"}:::${it.productName}:::${it.variation || ""}:::${sku}`; const vId = mpKeyMap.get(k) || (sku ? mpKeyMap.get(`tiktok:::${sku}`) || skuMap.get(sku) : null); if (vId) { variantSoldMap.set(vId, (variantSoldMap.get(vId) || 0) + (it.quantity || 1)); } } } // Scan Shopee Orders const spOrders = await prisma.shopeeOrder.findMany({ where: { orderCreatedAt: { not: null } }, select: { orderCreatedAt: true, shop: true, items: { select: { parentSku: true, skuRef: true, productName: true, variantName: true, quantity: true } }, }, }); for (const o of spOrders) { const d = parseOrderDate(o.orderCreatedAt); if (!d || d < start || d > end) continue; for (const it of o.items) { const sku = (it.skuRef || it.parentSku || "").trim().toLowerCase(); const k = `shopee:::${o.shop || "hom-aroma"}:::${it.productName}:::${it.variantName || ""}:::${sku}`; const vId = mpKeyMap.get(k) || (sku ? mpKeyMap.get(`shopee:::${sku}`) || skuMap.get(sku) : null); if (vId) { variantSoldMap.set(vId, (variantSoldMap.get(vId) || 0) + (it.quantity || 1)); } } } // Scan Lazada Orders const lzOrders = await prisma.lazadaOrder.findMany({ where: { createTime: { not: null } }, select: { createTime: true, shop: true, items: { select: { sellerSku: true, lazadaSku: true, itemName: true, variation: true } }, }, }); for (const o of lzOrders) { const d = parseOrderDate(o.createTime); if (!d || d < start || d > end) continue; for (const it of o.items) { const sku = (it.sellerSku || it.lazadaSku || "").trim().toLowerCase(); const k = `lazada:::${o.shop || "hom-aroma"}:::${it.itemName}:::${it.variation || ""}:::${sku}`; const vId = mpKeyMap.get(k) || (sku ? mpKeyMap.get(`lazada:::${sku}`) || skuMap.get(sku) : null); if (vId) { variantSoldMap.set(vId, (variantSoldMap.get(vId) || 0) + 1); } } } } // 3. Reverse Explosion: For each Raw Material, compute consumption and 30-day forecast let totalMaterialCostConsumed = 0; const daysCount = dateRange.daysCount || 30; const materialUsageList = rawMaterials.map((rm) => { let totalConsumedQty = 0; const associatedProductsMap = new Map(); for (const b of rm.bomItems) { const vId = b.productVariantId; const unitsSold = variantSoldMap.get(vId) || 0; const materialUsedForVariant = unitsSold * b.quantity; totalConsumedQty += materialUsedForVariant; const prodName = b.productVariant.product?.name || "Product"; const entry = associatedProductsMap.get(prodName) || { qty: 0, variantCount: 0 }; entry.qty += materialUsedForVariant; entry.variantCount++; associatedProductsMap.set(prodName, entry); } const totalCost = Math.round(totalConsumedQty * rm.pricePerUnit * 100) / 100; totalMaterialCostConsumed += totalCost; // Forecast Metrics const dailyBurnRate = Math.round((totalConsumedQty / daysCount) * 100) / 100; const forecast30DaysQty = Math.round(dailyBurnRate * 30 * 10) / 10; const forecast30DaysCost = Math.round(forecast30DaysQty * rm.pricePerUnit * 100) / 100; const topProducts = Array.from(associatedProductsMap.entries()) .map(([name, stat]) => ({ name, qty: Math.round(stat.qty * 10) / 10 })) .sort((a, b) => b.qty - a.qty) .slice(0, 3); const isLiquid = rm.unit === "ml" || rm.name.includes("หัวน้ำหอม") || rm.name.includes("DPG") || rm.name.includes("DPM"); return { id: rm.id, name: rm.name, unit: rm.unit, pricePerUnit: rm.pricePerUnit, isLiquid, linkedVariantsCount: rm.bomItems.length, totalConsumedQty: Math.round(totalConsumedQty * 100) / 100, totalConsumedCost: totalCost, dailyBurnRate, forecast30DaysQty, forecast30DaysCost, topProducts, }; }); materialUsageList.sort((a, b) => b.totalConsumedCost - a.totalConsumedCost); const fragranceMaterials = materialUsageList.filter((m) => m.name.includes("หัวน้ำหอม")); const packagingMaterials = materialUsageList.filter((m) => !m.isLiquid); const solventMaterials = materialUsageList.filter((m) => m.name.includes("DPG") || m.name.includes("DPM")); return { periodInfo: { period: dateRange.period, label: dateRange.label, startDate: dateRange.start ? dateRange.start.toISOString().slice(0, 10) : null, endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null, daysCount, }, kpis: { totalRawMaterials: rawMaterials.length, totalMaterialCostConsumed: Math.round(totalMaterialCostConsumed * 100) / 100, topFragrancesByCost: fragranceMaterials.slice(0, 5), topPackagingByCost: packagingMaterials.slice(0, 5), }, categories: { fragrances: fragranceMaterials, solvents: solventMaterials, packaging: packagingMaterials, }, allMaterials: materialUsageList, }; });