import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces"; function extractShopeeAddress(order: any): { province: string; district: string } { const raw = (order.items?.[0]?.rawData as any) || {}; const province = String(raw["จังหวัด"] || raw["Province"] || raw["City"] || "").trim(); const district = String(raw["เขต/อำเภอ"] || raw["อำเภอ/เขต"] || raw["อำเภอ"] || raw["District"] || "").trim(); return { province, district }; } function extractLazadaAddress(order: any): { province: string; district: string } { const reg = String(order.shippingRegion || "").trim(); const city = String(order.shippingCity || "").trim(); const postcode = String(order.shippingPostCode || "").trim(); // Try matching region or city first as province name let matchedProv = findProvince(reg) || findProvince(city); // Fallback to 5-digit/2-digit Postcode lookup if (!matchedProv && postcode) { matchedProv = findProvinceByPostcode(postcode); } const provinceName = matchedProv ? matchedProv.nameTh : (reg || city); const districtName = cleanDistrictName(city) || cleanDistrictName(reg) || (postcode ? `รหัส ปณ. ${postcode}` : "ไม่ระบุอำเภอ"); return { province: provinceName, district: districtName }; } // Helper to filter out / include cancelled orders function buildStatusFilter(statusFilter: string = "completed") { // 'completed' (default, excludes cancelled) | 'all' (includes all) | 'cancelled' (only cancelled) if (statusFilter === "all") { return { tiktok: {}, shopee: {}, lazada: {}, }; } if (statusFilter === "cancelled") { return { tiktok: { status: { contains: "ยกเลิก" } }, shopee: { status: { contains: "ยกเลิก" } }, lazada: { status: { in: ["canceled", "cancelled", "ยกเลิกแล้ว", "Package Returned", "Lost by 3PL"] } }, }; } // Default 'completed': exclude cancelled return { tiktok: { status: { not: { contains: "ยกเลิก" } } }, shopee: { status: { not: { contains: "ยกเลิก" } } }, lazada: { status: { notIn: ["canceled", "cancelled", "ยกเลิกแล้ว", "Package Returned", "Lost by 3PL"] } }, }; } export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" }) // ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ────────── .get("/sales-map", async ({ query }) => { const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada' const statusFilter = (query.statusFilter as string) || "completed"; // 'completed' | 'all' | 'cancelled' const sf = buildStatusFilter(statusFilter); const tiktokShop = query.tiktokShop as string | undefined; const shopeeShop = query.shopeeShop as string | undefined; const lazadaShop = query.lazadaShop as string | undefined; const dateFrom = query.dateFrom as string | undefined; const dateTo = query.dateTo as string | undefined; // 1. Fetch TikTok Orders if applicable let tiktokOrders: any[] = []; if (channel === "all" || channel === "tiktok") { const where: any = { ...sf.tiktok }; if (tiktokShop && tiktokShop !== "all") { where.shop = tiktokShop; } if (dateFrom || dateTo) { where.createdTime = {}; if (dateFrom) where.createdTime.gte = dateFrom; if (dateTo) where.createdTime.lte = dateTo + " 23:59"; } tiktokOrders = await prisma.tiktokOrder.findMany({ where, select: { orderId: true, shop: true, province: true, district: true, orderAmount: true, createdTime: true, }, }); } // 2. Fetch Shopee Orders if applicable let shopeeOrders: any[] = []; if (channel === "all" || channel === "shopee") { const where: any = { ...sf.shopee }; if (shopeeShop && shopeeShop !== "all") { where.shop = shopeeShop; } if (dateFrom || dateTo) { where.orderCreatedAt = {}; if (dateFrom) where.orderCreatedAt.gte = dateFrom; if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; } shopeeOrders = await prisma.shopeeOrder.findMany({ where, include: { items: { take: 1, select: { rawData: true }, }, }, }); } // 3. Fetch Lazada Orders if applicable let lazadaOrders: any[] = []; if (channel === "all" || channel === "lazada") { const where: any = { ...sf.lazada }; if (lazadaShop && lazadaShop !== "all") { where.shop = lazadaShop; } if (dateFrom || dateTo) { where.createTime = {}; if (dateFrom) where.createTime.gte = dateFrom; if (dateTo) where.createTime.lte = dateTo + " 23:59"; } lazadaOrders = await prisma.lazadaOrder.findMany({ where, select: { orderNumber: true, shop: true, shippingRegion: true, shippingCity: true, shippingPostCode: true, orderAmount: true, createTime: true, }, }); } // Initialize 77 provinces const provSalesMap = new Map(); for (const p of THAILAND_PROVINCES) { provSalesMap.set(p.nameTh, { code: p.code, nameTh: p.nameTh, nameEn: p.nameEn, region: p.region, regionTh: p.regionTh, revenue: 0, orders: 0, tiktokRevenue: 0, tiktokOrders: 0, shopeeRevenue: 0, shopeeOrders: 0, lazadaRevenue: 0, lazadaOrders: 0, }); } let totalRevenue = 0; let totalOrders = 0; let totalTiktokRevenue = 0; let totalTiktokOrders = 0; let totalShopeeRevenue = 0; let totalShopeeOrders = 0; let totalLazadaRevenue = 0; let totalLazadaOrders = 0; let unmappedOrders = 0; let unmappedRevenue = 0; // Aggregate TikTok for (const o of tiktokOrders) { const amount = o.orderAmount || 0; totalRevenue += amount; totalOrders += 1; totalTiktokRevenue += amount; totalTiktokOrders += 1; const matchedProv = findProvince(o.province); if (matchedProv && provSalesMap.has(matchedProv.nameTh)) { const p = provSalesMap.get(matchedProv.nameTh)!; p.revenue += amount; p.orders += 1; p.tiktokRevenue += amount; p.tiktokOrders += 1; } else { unmappedOrders += 1; unmappedRevenue += amount; } } // Aggregate Shopee for (const o of shopeeOrders) { const amount = o.totalAmount || 0; totalRevenue += amount; totalOrders += 1; totalShopeeRevenue += amount; totalShopeeOrders += 1; const { province } = extractShopeeAddress(o); const matchedProv = findProvince(province); if (matchedProv && provSalesMap.has(matchedProv.nameTh)) { const p = provSalesMap.get(matchedProv.nameTh)!; p.revenue += amount; p.orders += 1; p.shopeeRevenue += amount; p.shopeeOrders += 1; } else { unmappedOrders += 1; unmappedRevenue += amount; } } // Aggregate Lazada for (const o of lazadaOrders) { const amount = o.orderAmount || 0; totalRevenue += amount; totalOrders += 1; totalLazadaRevenue += amount; totalLazadaOrders += 1; const { province } = extractLazadaAddress(o); const matchedProv = findProvince(province); if (matchedProv && provSalesMap.has(matchedProv.nameTh)) { const p = provSalesMap.get(matchedProv.nameTh)!; p.revenue += amount; p.orders += 1; p.lazadaRevenue += amount; p.lazadaOrders += 1; } else { unmappedOrders += 1; unmappedRevenue += amount; } } const provinceList = Array.from(provSalesMap.values()).map(p => ({ ...p, tiktokShare: p.revenue > 0 ? (p.tiktokRevenue / p.revenue) * 100 : 0, shopeeShare: p.revenue > 0 ? (p.shopeeRevenue / p.revenue) * 100 : 0, lazadaShare: p.revenue > 0 ? (p.lazadaRevenue / p.revenue) * 100 : 0, aov: p.orders > 0 ? p.revenue / p.orders : 0, })); const activeProvinces = provinceList.filter(p => p.orders > 0); const coverageCount = activeProvinces.length; // Top 20 provinces by total revenue const topProvinces = [...provinceList] .sort((a, b) => b.revenue - a.revenue) .slice(0, 20) .map((p, idx) => ({ rank: idx + 1, ...p, })); // Region Breakdown const regionMap = new Map(); for (const p of provinceList) { if (!regionMap.has(p.region)) { regionMap.set(p.region, { region: p.region, regionTh: p.regionTh, revenue: 0, orders: 0, tiktokRevenue: 0, tiktokOrders: 0, shopeeRevenue: 0, shopeeOrders: 0, lazadaRevenue: 0, lazadaOrders: 0, }); } const r = regionMap.get(p.region)!; r.revenue += p.revenue; r.orders += p.orders; r.tiktokRevenue += p.tiktokRevenue; r.tiktokOrders += p.tiktokOrders; r.shopeeRevenue += p.shopeeRevenue; r.shopeeOrders += p.shopeeOrders; r.lazadaRevenue += p.lazadaRevenue; r.lazadaOrders += p.lazadaOrders; } const regionBreakdown = Array.from(regionMap.values()) .map(r => ({ ...r, percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0, tiktokShare: r.revenue > 0 ? (r.tiktokRevenue / r.revenue) * 100 : 0, shopeeShare: r.revenue > 0 ? (r.shopeeRevenue / r.revenue) * 100 : 0, lazadaShare: r.revenue > 0 ? (r.lazadaRevenue / r.revenue) * 100 : 0, })) .sort((a, b) => b.revenue - a.revenue); const topRegion = regionBreakdown[0] || { region: 'Central', regionTh: 'ภาคกลาง', revenue: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0, }; return { kpi: { totalRevenue, totalOrders, totalTiktokRevenue, totalTiktokOrders, tiktokSharePercent: totalRevenue > 0 ? (totalTiktokRevenue / totalRevenue) * 100 : 0, totalShopeeRevenue, totalShopeeOrders, shopeeSharePercent: totalRevenue > 0 ? (totalShopeeRevenue / totalRevenue) * 100 : 0, totalLazadaRevenue, totalLazadaOrders, lazadaSharePercent: totalRevenue > 0 ? (totalLazadaRevenue / totalRevenue) * 100 : 0, coverageCount, coverageTotal: 77, coveragePercent: (coverageCount / 77) * 100, aov: totalOrders > 0 ? totalRevenue / totalOrders : 0, tiktokAov: totalTiktokOrders > 0 ? totalTiktokRevenue / totalTiktokOrders : 0, shopeeAov: totalShopeeOrders > 0 ? totalShopeeRevenue / totalShopeeOrders : 0, lazadaAov: totalLazadaOrders > 0 ? totalLazadaRevenue / totalLazadaOrders : 0, topRegion: { name: topRegion.regionTh, orders: topRegion.orders, revenue: topRegion.revenue, tiktokRevenue: topRegion.tiktokRevenue, shopeeRevenue: topRegion.shopeeRevenue, lazadaRevenue: topRegion.lazadaRevenue, }, unmappedOrders, unmappedRevenue, }, provinces: provinceList, topProvinces, regionBreakdown, }; }) // ── GET District Level Details & Orders (Omnichannel: TT + SP + LAZ) ─────── .get("/sales-map/district", async ({ query, set }) => { const provinceQuery = query.province as string; if (!provinceQuery) { set.status = 400; return { error: "Province is required" }; } const targetProv = findProvince(provinceQuery); const provNameTh = targetProv ? targetProv.nameTh : provinceQuery; const channel = (query.channel as string) || "all"; const statusFilter = (query.statusFilter as string) || "completed"; const sf = buildStatusFilter(statusFilter); const tiktokShop = query.tiktokShop as string | undefined; const shopeeShop = query.shopeeShop as string | undefined; const lazadaShop = query.lazadaShop as string | undefined; const dateFrom = query.dateFrom as string | undefined; const dateTo = query.dateTo as string | undefined; // 1. Query TikTok Orders let tiktokMatched: any[] = []; if (channel === "all" || channel === "tiktok") { const where: any = { ...sf.tiktok }; if (tiktokShop && tiktokShop !== "all") { where.shop = tiktokShop; } if (dateFrom || dateTo) { where.createdTime = {}; if (dateFrom) where.createdTime.gte = dateFrom; if (dateTo) where.createdTime.lte = dateTo + " 23:59"; } const allTt = await prisma.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, }); tiktokMatched = allTt.filter(o => { const p = findProvince(o.province); return p ? p.nameTh === provNameTh : (o.province?.includes(provNameTh) || provNameTh.includes(o.province || '')); }); } // 2. Query Shopee Orders let shopeeMatched: any[] = []; if (channel === "all" || channel === "shopee") { const where: any = { ...sf.shopee }; if (shopeeShop && shopeeShop !== "all") { where.shop = shopeeShop; } if (dateFrom || dateTo) { where.orderCreatedAt = {}; if (dateFrom) where.orderCreatedAt.gte = dateFrom; if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; } const allSp = await prisma.shopeeOrder.findMany({ where, include: { items: true }, orderBy: { orderCreatedAt: "desc" }, }); shopeeMatched = allSp.filter(o => { const { province } = extractShopeeAddress(o); const p = findProvince(province); return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || '')); }); } // 3. Query Lazada Orders let lazadaMatched: any[] = []; if (channel === "all" || channel === "lazada") { const where: any = { ...sf.lazada }; if (lazadaShop && lazadaShop !== "all") { where.shop = lazadaShop; } if (dateFrom || dateTo) { where.createTime = {}; if (dateFrom) where.createTime.gte = dateFrom; if (dateTo) where.createTime.lte = dateTo + " 23:59"; } const allLaz = await prisma.lazadaOrder.findMany({ where, include: { items: true }, orderBy: { createTime: "desc" }, }); lazadaMatched = allLaz.filter(o => { const { province } = extractLazadaAddress(o); const p = findProvince(province); return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || '')); }); } // Aggregate by District const districtMap = new Map(); let provRevenue = 0; let provTiktokRevenue = 0; let provShopeeRevenue = 0; let provLazadaRevenue = 0; for (const o of tiktokMatched) { const amount = o.orderAmount || 0; provRevenue += amount; provTiktokRevenue += amount; const dName = cleanDistrictName(o.district) || "ไม่ระบุอำเภอ"; if (!districtMap.has(dName)) { districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0, tiktokRevenue: 0, tiktokOrders: 0, shopeeRevenue: 0, shopeeOrders: 0, lazadaRevenue: 0, lazadaOrders: 0, }); } const d = districtMap.get(dName)!; d.revenue += amount; d.orders += 1; d.tiktokRevenue += amount; d.tiktokOrders += 1; } for (const o of shopeeMatched) { const amount = o.totalAmount || 0; provRevenue += amount; provShopeeRevenue += amount; const { district } = extractShopeeAddress(o); const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ"; if (!districtMap.has(dName)) { districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0, tiktokRevenue: 0, tiktokOrders: 0, shopeeRevenue: 0, shopeeOrders: 0, lazadaRevenue: 0, lazadaOrders: 0, }); } const d = districtMap.get(dName)!; d.revenue += amount; d.orders += 1; d.shopeeRevenue += amount; d.shopeeOrders += 1; } for (const o of lazadaMatched) { const amount = o.orderAmount || 0; provRevenue += amount; provLazadaRevenue += amount; const { district } = extractLazadaAddress(o); const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ"; if (!districtMap.has(dName)) { districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0, tiktokRevenue: 0, tiktokOrders: 0, shopeeRevenue: 0, shopeeOrders: 0, lazadaRevenue: 0, lazadaOrders: 0, }); } const d = districtMap.get(dName)!; d.revenue += amount; d.orders += 1; d.lazadaRevenue += amount; d.lazadaOrders += 1; } const districts = Array.from(districtMap.values()).sort((a, b) => b.revenue - a.revenue); // Normalize unified orders list const unifiedOrders = [ ...tiktokMatched.map(o => ({ orderId: o.orderId, channel: "tiktok" as const, shop: o.shop, status: o.status, subStatus: o.subStatus, buyerUsername: o.buyerUsername, createdTime: o.createdTime, orderAmount: o.orderAmount, shippingFee: o.shippingFee, province: o.province, district: cleanDistrictName(o.district) || o.district || "ไม่ระบุอำเภอ", itemsCount: o.items.reduce((s: number, i: any) => s + i.quantity, 0), items: o.items.map((i: any) => ({ productName: i.productName, variation: i.variation, sellerSku: i.sellerSku, quantity: i.quantity, subtotalAfterDiscount: i.subtotalAfterDiscount, unitOriginalPrice: i.unitOriginalPrice, })), })), ...shopeeMatched.map(o => { const { district, province } = extractShopeeAddress(o); return { orderId: o.orderId, channel: "shopee" as const, shop: o.shop, status: o.status, subStatus: null, buyerUsername: o.buyerUsername, createdTime: o.orderCreatedAt, orderAmount: o.totalAmount, shippingFee: o.shippingFee, province: province || "ไม่ระบุจังหวัด", district: cleanDistrictName(district) || district || "ไม่ระบุอำเภอ", itemsCount: o.items.reduce((s: number, i: any) => s + i.quantity, 0), items: o.items.map((i: any) => ({ productName: i.productName, variation: i.variantName, sellerSku: i.skuRef || i.parentSku, quantity: i.quantity, subtotalAfterDiscount: i.totalPayment, unitOriginalPrice: i.originalPrice || i.dealPrice, })), }; }), ...lazadaMatched.map(o => { const { district, province } = extractLazadaAddress(o); return { orderId: o.orderNumber, channel: "lazada" as const, shop: o.shop, status: o.status, subStatus: null, buyerUsername: o.customerName || "ลูกค้า Lazada", createdTime: o.createTime, orderAmount: o.orderAmount, shippingFee: o.shippingFee, province: province || "ไม่ระบุจังหวัด", district: cleanDistrictName(district) || district || "ไม่ระบุอำเภอ", itemsCount: o.items.length, items: o.items.map((i: any) => ({ productName: i.itemName, variation: i.variation, sellerSku: i.sellerSku || i.lazadaSku, quantity: 1, subtotalAfterDiscount: i.paidPrice, unitOriginalPrice: i.unitPrice || i.paidPrice, })), }; }), ].sort((a, b) => String(b.createdTime || "").localeCompare(String(a.createdTime || ""))); return { province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: "Central", regionTh: "ภาคกลาง", code: "" }, summary: { totalRevenue: provRevenue, totalOrders: unifiedOrders.length, tiktokRevenue: provTiktokRevenue, tiktokOrders: tiktokMatched.length, shopeeRevenue: provShopeeRevenue, shopeeOrders: shopeeMatched.length, lazadaRevenue: provLazadaRevenue, lazadaOrders: lazadaMatched.length, districtCount: districts.length, }, districts, orders: unifiedOrders, }; }) // ── 3. GET Master Products & Variants List (For Selection Dropdown) ───────── .get("/products-list", async () => { const products = await prisma.product.findMany({ where: { isDeleted: false }, include: { category: { select: { id: true, name: true } }, brand: { select: { id: true, name: true } }, variants: { include: { marketplaceProducts: { select: { id: true, channel: true, shop: true, productName: true, variation: true, marketplaceSku: true, totalSoldQty: true, totalRevenue: true, }, }, }, }, }, orderBy: { name: "asc" }, }); const formatted = products.map((p) => { let totalMappedMarketplaceCount = 0; let totalSoldUnits = 0; let totalRevenue = 0; const variants = p.variants.map((v) => { const mappedCount = v.marketplaceProducts.length; totalMappedMarketplaceCount += mappedCount; const vSold = v.marketplaceProducts.reduce((s, mp) => s + mp.totalSoldQty, 0); const vRev = v.marketplaceProducts.reduce((s, mp) => s + mp.totalRevenue, 0); totalSoldUnits += vSold; totalRevenue += vRev; const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); return { id: v.id, sku: v.sku || "", barcode: v.barcode || "", price: v.price || 0, stock: v.stock || 0, optionsStr, displayName: `${optionsStr || "Default"} ${v.sku ? `[${v.sku}]` : ""}`.trim(), mappedMarketplaceCount: mappedCount, soldUnits: vSold, revenue: vRev, }; }); return { id: p.id, name: p.name || "Unnamed Product", category: p.category?.name || "", brand: p.brand?.name || "", totalMappedMarketplaceCount, totalSoldUnits, totalRevenue, variants, }; }); return { products: formatted }; }) // ── 4. GET Product Geographical Sales Map (77 Provinces) ──────────────────── .get("/sales-map-products", async ({ query }) => { const productId = query.productId ? parseInt(query.productId as string, 10) : undefined; const variantId = query.variantId ? parseInt(query.variantId as string, 10) : undefined; const channel = (query.channel as string) || "all"; const statusFilter = (query.statusFilter as string) || "completed"; const sf = buildStatusFilter(statusFilter); const dateFrom = query.dateFrom as string | undefined; const dateTo = query.dateTo as string | undefined; const metric = (query.metric as string) || "revenue"; // 1. Resolve master variants to filter let targetVariantIds: number[] = []; let productInfo: any = null; let variantInfo: any = null; if (variantId) { targetVariantIds = [variantId]; const v = await prisma.productVariant.findUnique({ where: { id: variantId }, include: { product: { include: { category: true } } }, }); if (v) { const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); variantInfo = { id: v.id, sku: v.sku, optionsStr, productName: v.product?.name, categoryName: v.product?.category?.name, }; productInfo = { id: v.product?.id, name: v.product?.name, }; } } else if (productId) { const p = await prisma.product.findUnique({ where: { id: productId }, include: { variants: true, category: true }, }); if (p) { targetVariantIds = p.variants.map((v) => v.id); productInfo = { id: p.id, name: p.name, categoryName: p.category?.name, variantsCount: p.variants.length, }; } } // 2. Fetch linked MarketplaceProducts const mappedMp = await prisma.marketplaceProduct.findMany({ where: { ...(targetVariantIds.length > 0 ? { masterVariantId: { in: targetVariantIds } } : {}), ...(channel !== "all" ? { channel } : {}), }, }); const tiktokMps = mappedMp.filter((m) => m.channel === "tiktok"); const shopeeMps = mappedMp.filter((m) => m.channel === "shopee"); const lazadaMps = mappedMp.filter((m) => m.channel === "lazada"); const tiktokKeys = new Set(tiktokMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); const tiktokSkus = new Set(tiktokMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); const tiktokNames = new Set(tiktokMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); const shopeeKeys = new Set(shopeeMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); const shopeeSkus = new Set(shopeeMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); const shopeeNames = new Set(shopeeMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); const lazadaKeys = new Set(lazadaMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); const lazadaSkus = new Set(lazadaMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); const lazadaNames = new Set(lazadaMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); // 3. Query Orders with Line Items let matchedLineItems: Array<{ channel: "tiktok" | "shopee" | "lazada"; shop: string; orderId: string; province: string; district: string; quantity: number; revenue: number; createdTime: string | null; }> = []; // A. TikTok Items if ((channel === "all" || channel === "tiktok") && (tiktokKeys.size > 0 || tiktokSkus.size > 0 || tiktokNames.size > 0)) { const where: any = { ...sf.tiktok }; if (dateFrom || dateTo) { where.createdTime = {}; if (dateFrom) where.createdTime.gte = dateFrom; if (dateTo) where.createdTime.lte = dateTo + " 23:59"; } const tiktokOrders = await prisma.tiktokOrder.findMany({ where, select: { orderId: true, shop: true, province: true, district: true, createdTime: true, items: true, }, }); for (const o of tiktokOrders) { const prov = o.province || ""; const dist = o.district || ""; for (const it of o.items) { const sku = it.sellerSku || it.skuId || ""; const name = it.productName || ""; const variation = it.variation || ""; const shop = o.shop || "hom-aroma"; const key = `${shop}:::${name}:::${variation}:::${sku}`; const isMatch = tiktokKeys.has(key) || (sku && tiktokSkus.has(sku.trim().toLowerCase())) || (name && tiktokNames.has(name.trim().toLowerCase()) && (!variation || tiktokMps.some((m) => m.variation === variation))); if (isMatch) { const qty = it.quantity || 1; const rev = it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : it.unitOriginalPrice * qty; matchedLineItems.push({ channel: "tiktok", shop, orderId: o.orderId, province: prov, district: dist, quantity: qty, revenue: rev, createdTime: o.createdTime, }); } } } } // B. Shopee Items if ((channel === "all" || channel === "shopee") && (shopeeKeys.size > 0 || shopeeSkus.size > 0 || shopeeNames.size > 0)) { const where: any = { ...sf.shopee }; if (dateFrom || dateTo) { where.orderCreatedAt = {}; if (dateFrom) where.orderCreatedAt.gte = dateFrom; if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; } const shopeeOrders = await prisma.shopeeOrder.findMany({ where, select: { orderId: true, shop: true, orderCreatedAt: true, items: true, }, }); for (const o of shopeeOrders) { const { province, district } = extractShopeeAddress(o); for (const it of o.items) { const sku = it.skuRef || it.parentSku || ""; const name = it.productName || ""; const variation = it.variantName || ""; const shop = o.shop || "hom-aroma"; const key = `${shop}:::${name}:::${variation}:::${sku}`; const isMatch = shopeeKeys.has(key) || (sku && shopeeSkus.has(sku.trim().toLowerCase())) || (name && shopeeNames.has(name.trim().toLowerCase()) && (!variation || shopeeMps.some((m) => m.variation === variation))); if (isMatch) { const qty = it.quantity || 1; const rev = it.totalPayment > 0 ? it.totalPayment : it.dealPrice * qty; matchedLineItems.push({ channel: "shopee", shop, orderId: o.orderId, province, district, quantity: qty, revenue: rev, createdTime: o.orderCreatedAt, }); } } } } // C. Lazada Items if ((channel === "all" || channel === "lazada") && (lazadaKeys.size > 0 || lazadaSkus.size > 0 || lazadaNames.size > 0)) { const where: any = { ...sf.lazada }; if (dateFrom || dateTo) { where.createTime = {}; if (dateFrom) where.createTime.gte = dateFrom; if (dateTo) where.createTime.lte = dateTo + " 23:59"; } const lazadaOrders = await prisma.lazadaOrder.findMany({ where, select: { orderNumber: true, shop: true, createTime: true, shippingRegion: true, shippingCity: true, shippingPostCode: true, items: true, }, }); for (const o of lazadaOrders) { const { province, district } = extractLazadaAddress(o); for (const it of o.items) { const sku = it.sellerSku || it.lazadaSku || ""; const name = it.itemName || ""; const variation = it.variation || ""; const shop = o.shop || "hom-aroma"; const key = `${shop}:::${name}:::${variation}:::${sku}`; const isMatch = lazadaKeys.has(key) || (sku && lazadaSkus.has(sku.trim().toLowerCase())) || (name && lazadaNames.has(name.trim().toLowerCase()) && (!variation || lazadaMps.some((m) => m.variation === variation))); if (isMatch) { const qty = 1; const rev = it.paidPrice > 0 ? it.paidPrice : it.unitPrice; matchedLineItems.push({ channel: "lazada", shop, orderId: o.orderNumber, province, district, quantity: qty, revenue: rev, createdTime: o.createTime, }); } } } } // 4. Map & Aggregate by 77 Provinces const provMap: Record = {}; for (const p of THAILAND_PROVINCES) { provMap[p.nameTh] = { code: p.code, nameTh: p.nameTh, nameEn: p.nameEn, region: p.region, regionTh: p.regionTh, revenue: 0, unitsSold: 0, ordersCount: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0, tiktokUnits: 0, shopeeUnits: 0, lazadaUnits: 0, }; } let totalRevenue = 0; let totalUnitsSold = 0; let totalTiktokRevenue = 0; let totalShopeeRevenue = 0; let totalLazadaRevenue = 0; let totalTiktokUnits = 0; let totalShopeeUnits = 0; let totalLazadaUnits = 0; const uniqueOrderIds = new Set(); for (const it of matchedLineItems) { const provMatch = findProvince(it.province); const provNameTh = provMatch ? provMatch.nameTh : "กรุงเทพมหานคร"; if (provMap[provNameTh]) { provMap[provNameTh].revenue += it.revenue; provMap[provNameTh].unitsSold += it.quantity; provMap[provNameTh].ordersCount += 1; if (it.channel === "tiktok") { provMap[provNameTh].tiktokRevenue += it.revenue; provMap[provNameTh].tiktokUnits += it.quantity; totalTiktokRevenue += it.revenue; totalTiktokUnits += it.quantity; } else if (it.channel === "shopee") { provMap[provNameTh].shopeeRevenue += it.revenue; provMap[provNameTh].shopeeUnits += it.quantity; totalShopeeRevenue += it.revenue; totalShopeeUnits += it.quantity; } else if (it.channel === "lazada") { provMap[provNameTh].lazadaRevenue += it.revenue; provMap[provNameTh].lazadaUnits += it.quantity; totalLazadaRevenue += it.revenue; totalLazadaUnits += it.quantity; } totalRevenue += it.revenue; totalUnitsSold += it.quantity; uniqueOrderIds.add(it.orderId); } } const provinces = Object.values(provMap).map((p: any) => { const pRev = p.revenue; const ttShare = pRev > 0 ? (p.tiktokRevenue / pRev) * 100 : 0; const spShare = pRev > 0 ? (p.shopeeRevenue / pRev) * 100 : 0; const lzShare = pRev > 0 ? (p.lazadaRevenue / pRev) * 100 : 0; return { ...p, tiktokShare: ttShare, shopeeShare: spShare, lazadaShare: lzShare, aov: p.ordersCount > 0 ? p.revenue / p.ordersCount : 0, }; }); const coveredProvinces = provinces.filter((p) => p.revenue > 0); const coverageCount = coveredProvinces.length; const coveragePercent = (coverageCount / 77) * 100; const sortedByMetric = [...provinces].sort((a, b) => { return metric === "quantity" ? b.unitsSold - a.unitsSold : b.revenue - a.revenue; }); const topProvinces = sortedByMetric.slice(0, 20).map((p, idx) => ({ ...p, rank: idx + 1, })); const topProvince = topProvinces.length > 0 && (topProvinces[0].revenue > 0 || topProvinces[0].unitsSold > 0) ? topProvinces[0] : null; // Region breakdown const regionsMap: Record = {}; for (const p of provinces) { if (!regionsMap[p.region]) { regionsMap[p.region] = { region: p.region, regionTh: p.regionTh, revenue: 0, unitsSold: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0, orders: 0, }; } regionsMap[p.region].revenue += p.revenue; regionsMap[p.region].unitsSold += p.unitsSold; regionsMap[p.region].tiktokRevenue += p.tiktokRevenue; regionsMap[p.region].shopeeRevenue += p.shopeeRevenue; regionsMap[p.region].lazadaRevenue += p.lazadaRevenue; regionsMap[p.region].orders += p.ordersCount; } const regionBreakdown = Object.values(regionsMap) .map((r: any) => ({ ...r, percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0, })) .sort((a: any, b: any) => b.revenue - a.revenue); return { product: productInfo, variant: variantInfo, mappedMarketplaceCount: mappedMp.length, kpi: { totalRevenue, totalUnitsSold, totalOrders: uniqueOrderIds.size, aov: uniqueOrderIds.size > 0 ? totalRevenue / uniqueOrderIds.size : 0, coverageCount, coveragePercent, topProvince: topProvince ? { name: topProvince.nameTh, revenue: topProvince.revenue, unitsSold: topProvince.unitsSold } : null, topRegion: regionBreakdown.length > 0 ? { name: regionBreakdown[0].regionTh, revenue: regionBreakdown[0].revenue } : null, totalTiktokRevenue, totalShopeeRevenue, totalLazadaRevenue, totalTiktokUnits, totalShopeeUnits, totalLazadaUnits, tiktokSharePercent: totalRevenue > 0 ? (totalTiktokRevenue / totalRevenue) * 100 : 0, shopeeSharePercent: totalRevenue > 0 ? (totalShopeeRevenue / totalRevenue) * 100 : 0, lazadaSharePercent: totalRevenue > 0 ? (totalLazadaRevenue / totalRevenue) * 100 : 0, }, provinces, topProvinces, regionBreakdown, }; }) // ── 5. GET Product District Level Sales Map & Orders ─────────────────────── .get("/sales-map-products/district", async ({ query }) => { const provinceName = (query.province as string) || "กรุงเทพมหานคร"; const productId = query.productId ? parseInt(query.productId as string, 10) : undefined; const variantId = query.variantId ? parseInt(query.variantId as string, 10) : undefined; const channel = (query.channel as string) || "all"; const statusFilter = (query.statusFilter as string) || "completed"; const sf = buildStatusFilter(statusFilter); const dateFrom = query.dateFrom as string | undefined; const dateTo = query.dateTo as string | undefined; let targetVariantIds: number[] = []; if (variantId) { targetVariantIds = [variantId]; } else if (productId) { const p = await prisma.product.findUnique({ where: { id: productId }, include: { variants: true }, }); if (p) targetVariantIds = p.variants.map((v) => v.id); } const mappedMp = await prisma.marketplaceProduct.findMany({ where: { ...(targetVariantIds.length > 0 ? { masterVariantId: { in: targetVariantIds } } : {}), ...(channel !== "all" ? { channel } : {}), }, }); const tiktokMps = mappedMp.filter((m) => m.channel === "tiktok"); const shopeeMps = mappedMp.filter((m) => m.channel === "shopee"); const lazadaMps = mappedMp.filter((m) => m.channel === "lazada"); const tiktokKeys = new Set(tiktokMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); const tiktokSkus = new Set(tiktokMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); const tiktokNames = new Set(tiktokMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); const shopeeKeys = new Set(shopeeMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); const shopeeSkus = new Set(shopeeMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); const shopeeNames = new Set(shopeeMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); const lazadaKeys = new Set(lazadaMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); const lazadaSkus = new Set(lazadaMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); const lazadaNames = new Set(lazadaMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); const targetProv = findProvince(provinceName); const provNameTh = targetProv ? targetProv.nameTh : provinceName; const districtMap: Record = {}; const matchedOrders: any[] = []; // A. TikTok if (channel === "all" || channel === "tiktok") { const where: any = { ...sf.tiktok }; if (dateFrom || dateTo) { where.createdTime = {}; if (dateFrom) where.createdTime.gte = dateFrom; if (dateTo) where.createdTime.lte = dateTo + " 23:59"; } const ttOrders = await prisma.tiktokOrder.findMany({ where, include: { items: true }, }); for (const o of ttOrders) { const p = findProvince(o.province || ""); if (p && p.nameTh === provNameTh) { const matchingItems = o.items.filter((it) => { const sku = it.sellerSku || it.skuId || ""; const name = it.productName || ""; const variation = it.variation || ""; const key = `${o.shop}:::${name}:::${variation}:::${sku}`; return tiktokKeys.has(key) || (sku && tiktokSkus.has(sku.trim().toLowerCase())) || (name && tiktokNames.has(name.trim().toLowerCase())); }); if (matchingItems.length > 0) { const itemRev = matchingItems.reduce( (s, it) => s + (it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : it.unitOriginalPrice * it.quantity), 0 ); const itemQty = matchingItems.reduce((s, it) => s + it.quantity, 0); const dName = cleanDistrictName(o.district) || o.district || "ไม่ระบุอำเภอ"; if (!districtMap[dName]) { districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; } districtMap[dName].revenue += itemRev; districtMap[dName].unitsSold += itemQty; districtMap[dName].orders += 1; districtMap[dName].tiktokRevenue += itemRev; matchedOrders.push({ orderId: o.orderId, channel: "tiktok", shop: o.shop, buyerUsername: o.buyerUsername || "ลูกค้า TikTok", createdTime: o.createdTime, orderAmount: itemRev, shippingFee: o.shippingFee, province: provNameTh, district: dName, items: matchingItems.map((it) => ({ productName: it.productName, variation: it.variation, sellerSku: it.sellerSku || it.skuId, quantity: it.quantity, subtotalAfterDiscount: it.subtotalAfterDiscount || it.unitOriginalPrice * it.quantity, })), }); } } } } // B. Shopee if (channel === "all" || channel === "shopee") { const where: any = { ...sf.shopee }; if (dateFrom || dateTo) { where.orderCreatedAt = {}; if (dateFrom) where.orderCreatedAt.gte = dateFrom; if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; } const spOrders = await prisma.shopeeOrder.findMany({ where, include: { items: true }, }); for (const o of spOrders) { const { province, district } = extractShopeeAddress(o); const p = findProvince(province); if (p && p.nameTh === provNameTh) { const matchingItems = o.items.filter((it) => { const sku = it.skuRef || it.parentSku || ""; const name = it.productName || ""; const variation = it.variantName || ""; const key = `${o.shop}:::${name}:::${variation}:::${sku}`; return shopeeKeys.has(key) || (sku && shopeeSkus.has(sku.trim().toLowerCase())) || (name && shopeeNames.has(name.trim().toLowerCase())); }); if (matchingItems.length > 0) { const itemRev = matchingItems.reduce( (s, it) => s + (it.totalPayment > 0 ? it.totalPayment : it.dealPrice * it.quantity), 0 ); const itemQty = matchingItems.reduce((s, it) => s + it.quantity, 0); const dName = cleanDistrictName(district) || district || "ไม่ระบุอำเภอ"; if (!districtMap[dName]) { districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; } districtMap[dName].revenue += itemRev; districtMap[dName].unitsSold += itemQty; districtMap[dName].orders += 1; districtMap[dName].shopeeRevenue += itemRev; matchedOrders.push({ orderId: o.orderId, channel: "shopee", shop: o.shop, buyerUsername: o.buyerUsername || "ลูกค้า Shopee", createdTime: o.orderCreatedAt, orderAmount: itemRev, shippingFee: o.shippingFee, province: provNameTh, district: dName, items: matchingItems.map((it) => ({ productName: it.productName, variation: it.variantName, sellerSku: it.skuRef || it.parentSku, quantity: it.quantity, subtotalAfterDiscount: it.totalPayment || it.dealPrice * it.quantity, })), }); } } } } // C. Lazada if (channel === "all" || channel === "lazada") { const where: any = { ...sf.lazada }; if (dateFrom || dateTo) { where.createTime = {}; if (dateFrom) where.createTime.gte = dateFrom; if (dateTo) where.createTime.lte = dateTo + " 23:59"; } const lzOrders = await prisma.lazadaOrder.findMany({ where, include: { items: true }, }); for (const o of lzOrders) { const { province, district } = extractLazadaAddress(o); const p = findProvince(province); if (p && p.nameTh === provNameTh) { const matchingItems = o.items.filter((it) => { const sku = it.sellerSku || it.lazadaSku || ""; const name = it.itemName || ""; const variation = it.variation || ""; const key = `${o.shop}:::${name}:::${variation}:::${sku}`; return lazadaKeys.has(key) || (sku && lazadaSkus.has(sku.trim().toLowerCase())) || (name && lazadaNames.has(name.trim().toLowerCase())); }); if (matchingItems.length > 0) { const itemRev = matchingItems.reduce((s, it) => s + (it.paidPrice > 0 ? it.paidPrice : it.unitPrice), 0); const itemQty = matchingItems.length; const dName = cleanDistrictName(district) || district || "ไม่ระบุอำเภอ"; if (!districtMap[dName]) { districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; } districtMap[dName].revenue += itemRev; districtMap[dName].unitsSold += itemQty; districtMap[dName].orders += 1; districtMap[dName].lazadaRevenue += itemRev; matchedOrders.push({ orderId: o.orderNumber, channel: "lazada", shop: o.shop, buyerUsername: o.customerName || "ลูกค้า Lazada", createdTime: o.createTime, orderAmount: itemRev, shippingFee: o.shippingFee, province: provNameTh, district: dName, items: matchingItems.map((it) => ({ productName: it.itemName, variation: it.variation, sellerSku: it.sellerSku || it.lazadaSku, quantity: 1, subtotalAfterDiscount: it.paidPrice || it.unitPrice, })), }); } } } } const districts = Object.values(districtMap).sort((a: any, b: any) => b.revenue - a.revenue); matchedOrders.sort((a, b) => String(b.createdTime || "").localeCompare(String(a.createdTime || ""))); return { province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: "Central", regionTh: "ภาคกลาง", code: "" }, summary: { totalRevenue: districts.reduce((s: number, d: any) => s + d.revenue, 0), totalUnitsSold: districts.reduce((s: number, d: any) => s + d.unitsSold, 0), totalOrders: matchedOrders.length, districtCount: districts.length, }, districts, orders: matchedOrders, }; }) // ── GET Repeat Customers Analysis (Shopee + Lazada + TikTok) ────────────── .get("/repeat-customers", async ({ query }) => { const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada' const statusFilter = (query.statusFilter as string) || "completed"; // 'completed' | 'all' | 'cancelled' const sf = buildStatusFilter(statusFilter); const tiktokShop = query.tiktokShop as string | undefined; const shopeeShop = query.shopeeShop as string | undefined; const lazadaShop = query.lazadaShop as string | undefined; const dateFrom = query.dateFrom as string | undefined; const dateTo = query.dateTo as string | undefined; const minOrders = parseInt((query.minOrders as string) || "2", 10); const search = ((query.search as string) || "").trim().toLowerCase(); const sortBy = (query.sortBy as string) || "orders"; // 'orders' | 'totalSpent' | 'lastOrderDate' | 'daysBetween' const sortOrder = (query.sortOrder as string) || "desc"; // 'desc' | 'asc' const page = Math.max(1, parseInt((query.page as string) || "1", 10)); const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "25", 10))); type OrderSummary = { orderId: string; channel: "tiktok" | "shopee" | "lazada"; shop: string; date: string; timestamp: number; amount: number; status: string; itemsCount: number; productNames: string[]; }; type CustomerProfile = { id: string; identifier: string; displayName: string; primaryChannel: "tiktok" | "shopee" | "lazada"; channels: Set; shops: Set; orderCount: number; totalSpent: number; firstOrderDate: string; lastOrderDate: string; firstTimestamp: number; lastTimestamp: number; daysBetweenOrders: number; location: string; orders: OrderSummary[]; productFrequency: Map; }; const customerMap = new Map(); // 1. Fetch TikTok Orders if (channel === "all" || channel === "tiktok") { const where: any = { ...sf.tiktok }; if (tiktokShop && tiktokShop !== "all") where.shop = tiktokShop; if (dateFrom || dateTo) { where.createdTime = {}; if (dateFrom) where.createdTime.gte = dateFrom; if (dateTo) where.createdTime.lte = dateTo + " 23:59"; } const tiktokOrders = await prisma.tiktokOrder.findMany({ where, select: { orderId: true, shop: true, status: true, buyerUsername: true, orderAmount: true, createdTime: true, province: true, district: true, items: { select: { productName: true, quantity: true, }, }, }, }); for (const o of tiktokOrders) { const rawUser = (o.buyerUsername || "").trim(); const identifier = rawUser || `TT_${o.orderId}`; const key = `tiktok:${identifier.toLowerCase()}`; const dateStr = o.createdTime || ""; const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; const amount = Number(o.orderAmount) || 0; const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); const location = [o.district, o.province].filter(Boolean).join(", ") || o.province || ""; let prof = customerMap.get(key); if (!prof) { prof = { id: key, identifier, displayName: rawUser || `TikTok #${o.orderId.slice(-6)}`, primaryChannel: "tiktok", channels: new Set(["tiktok"]), shops: new Set([o.shop]), orderCount: 0, totalSpent: 0, firstOrderDate: dateStr, lastOrderDate: dateStr, firstTimestamp: ts, lastTimestamp: ts, daysBetweenOrders: 0, location, orders: [], productFrequency: new Map(), }; customerMap.set(key, prof); } prof.orderCount += 1; prof.totalSpent += amount; prof.shops.add(o.shop); if (location && !prof.location) prof.location = location; if (ts > 0) { if (!prof.firstTimestamp || ts < prof.firstTimestamp) { prof.firstTimestamp = ts; prof.firstOrderDate = dateStr; } if (!prof.lastTimestamp || ts > prof.lastTimestamp) { prof.lastTimestamp = ts; prof.lastOrderDate = dateStr; } } for (const pName of prodNames) { prof.productFrequency.set(pName, (prof.productFrequency.get(pName) || 0) + 1); } prof.orders.push({ orderId: o.orderId, channel: "tiktok", shop: o.shop, date: dateStr, timestamp: ts, amount, status: o.status, itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), productNames: prodNames, }); } } // 2. Fetch Shopee Orders if (channel === "all" || channel === "shopee") { const where: any = { ...sf.shopee }; if (shopeeShop && shopeeShop !== "all") where.shop = shopeeShop; if (dateFrom || dateTo) { where.orderCreatedAt = {}; if (dateFrom) where.orderCreatedAt.gte = dateFrom; if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; } const shopeeOrders = await prisma.shopeeOrder.findMany({ where, select: { orderId: true, shop: true, status: true, buyerUsername: true, orderCreatedAt: true, buyerPaidAmount: true, totalAmount: true, items: { select: { productName: true, quantity: true, rawData: true, }, }, }, }); for (const o of shopeeOrders) { const rawUser = (o.buyerUsername || "").trim(); const identifier = rawUser || `SP_${o.orderId}`; const key = `shopee:${identifier.toLowerCase()}`; const dateStr = o.orderCreatedAt || ""; const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; const amount = Number(o.buyerPaidAmount || o.totalAmount) || 0; const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); const { province, district } = extractShopeeAddress(o); const location = [district, province].filter(Boolean).join(", ") || province || ""; let prof = customerMap.get(key); if (!prof) { prof = { id: key, identifier, displayName: rawUser || `Shopee #${o.orderId.slice(-6)}`, primaryChannel: "shopee", channels: new Set(["shopee"]), shops: new Set([o.shop]), orderCount: 0, totalSpent: 0, firstOrderDate: dateStr, lastOrderDate: dateStr, firstTimestamp: ts, lastTimestamp: ts, daysBetweenOrders: 0, location, orders: [], productFrequency: new Map(), }; customerMap.set(key, prof); } prof.orderCount += 1; prof.totalSpent += amount; prof.shops.add(o.shop); if (location && !prof.location) prof.location = location; if (ts > 0) { if (!prof.firstTimestamp || ts < prof.firstTimestamp) { prof.firstTimestamp = ts; prof.firstOrderDate = dateStr; } if (!prof.lastTimestamp || ts > prof.lastTimestamp) { prof.lastTimestamp = ts; prof.lastOrderDate = dateStr; } } for (const pName of prodNames) { prof.productFrequency.set(pName, (prof.productFrequency.get(pName) || 0) + 1); } prof.orders.push({ orderId: o.orderId, channel: "shopee", shop: o.shop, date: dateStr, timestamp: ts, amount, status: o.status, itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), productNames: prodNames, }); } } // 3. Fetch Lazada Orders if (channel === "all" || channel === "lazada") { const where: any = { ...sf.lazada }; if (lazadaShop && lazadaShop !== "all") where.shop = lazadaShop; if (dateFrom || dateTo) { where.createTime = {}; if (dateFrom) where.createTime.gte = dateFrom; if (dateTo) where.createTime.lte = dateTo + " 23:59"; } const lazadaOrders = await prisma.lazadaOrder.findMany({ where, select: { orderNumber: true, shop: true, status: true, customerName: true, shippingCity: true, shippingRegion: true, shippingPostCode: true, orderAmount: true, createTime: true, items: { select: { itemName: true, }, }, }, }); for (const o of lazadaOrders) { const rawName = (o.customerName || "").trim(); const postCode = (o.shippingPostCode || "").trim(); const identifier = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`; const key = `lazada:${identifier}`; const dateStr = o.createTime || ""; const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; const amount = Number(o.orderAmount) || 0; const prodNames = o.items.map((it) => it.itemName.trim()).filter(Boolean); const { province, district } = extractLazadaAddress(o); const location = [district, province].filter(Boolean).join(", ") || province || ""; let prof = customerMap.get(key); if (!prof) { prof = { id: key, identifier, displayName: rawName ? `${rawName}${postCode ? ` (${postCode})` : ""}` : `Lazada #${o.orderNumber.slice(-6)}`, primaryChannel: "lazada", channels: new Set(["lazada"]), shops: new Set([o.shop]), orderCount: 0, totalSpent: 0, firstOrderDate: dateStr, lastOrderDate: dateStr, firstTimestamp: ts, lastTimestamp: ts, daysBetweenOrders: 0, location, orders: [], productFrequency: new Map(), }; customerMap.set(key, prof); } prof.orderCount += 1; prof.totalSpent += amount; prof.shops.add(o.shop); if (location && !prof.location) prof.location = location; if (ts > 0) { if (!prof.firstTimestamp || ts < prof.firstTimestamp) { prof.firstTimestamp = ts; prof.firstOrderDate = dateStr; } if (!prof.lastTimestamp || ts > prof.lastTimestamp) { prof.lastTimestamp = ts; prof.lastOrderDate = dateStr; } } for (const pName of prodNames) { prof.productFrequency.set(pName, (prof.productFrequency.get(pName) || 0) + 1); } prof.orders.push({ orderId: o.orderNumber, channel: "lazada", shop: o.shop, date: dateStr, timestamp: ts, amount, status: o.status || "Completed", itemsCount: o.items.length, productNames: prodNames, }); } } // 4. Calculate Aggregate Metrics & Repeat Stats const allCustomers = Array.from(customerMap.values()); const totalCustomers = allCustomers.length; let repeatCustomersCount = 0; let singleOrderCustomersCount = 0; let totalRevenue = 0; let repeatRevenue = 0; let totalIntervalDays = 0; let customersWithIntervalCount = 0; const cohorts = { order1: { count: 0, revenue: 0 }, order2: { count: 0, revenue: 0 }, order3to5: { count: 0, revenue: 0 }, order6plus: { count: 0, revenue: 0 }, }; const channelStats: Record = { tiktok: { totalCust: 0, repeatCust: 0, totalRev: 0, repeatRev: 0 }, shopee: { totalCust: 0, repeatCust: 0, totalRev: 0, repeatRev: 0 }, lazada: { totalCust: 0, repeatCust: 0, totalRev: 0, repeatRev: 0 }, }; const repeatProductMap = new Map(); for (const c of allCustomers) { totalRevenue += c.totalSpent; const isRepeat = c.orderCount >= 2; // Calculate days between orders if (c.orderCount > 1 && c.firstTimestamp > 0 && c.lastTimestamp > c.firstTimestamp) { const diffDays = (c.lastTimestamp - c.firstTimestamp) / (1000 * 60 * 60 * 24); c.daysBetweenOrders = Number((diffDays / (c.orderCount - 1)).toFixed(1)); totalIntervalDays += c.daysBetweenOrders; customersWithIntervalCount += 1; } if (isRepeat) { repeatCustomersCount += 1; repeatRevenue += c.totalSpent; // Track products for repeat buyers for (const [prodName, freq] of c.productFrequency.entries()) { const prev = repeatProductMap.get(prodName) || { repeatBuyerCount: 0, orderAppearances: 0 }; repeatProductMap.set(prodName, { repeatBuyerCount: prev.repeatBuyerCount + 1, orderAppearances: prev.orderAppearances + freq, }); } } else { singleOrderCustomersCount += 1; } // Cohorts if (c.orderCount === 1) { cohorts.order1.count += 1; cohorts.order1.revenue += c.totalSpent; } else if (c.orderCount === 2) { cohorts.order2.count += 1; cohorts.order2.revenue += c.totalSpent; } else if (c.orderCount >= 3 && c.orderCount <= 5) { cohorts.order3to5.count += 1; cohorts.order3to5.revenue += c.totalSpent; } else { cohorts.order6plus.count += 1; cohorts.order6plus.revenue += c.totalSpent; } // Channel stats const ch = c.primaryChannel; if (channelStats[ch]) { channelStats[ch].totalCust += 1; channelStats[ch].totalRev += c.totalSpent; if (isRepeat) { channelStats[ch].repeatCust += 1; channelStats[ch].repeatRev += c.totalSpent; } } } // Top Repeat Products List const topRepeatProducts = Array.from(repeatProductMap.entries()) .map(([name, data]) => ({ productName: name, repeatBuyerCount: data.repeatBuyerCount, orderAppearances: data.orderAppearances, })) .sort((a, b) => b.repeatBuyerCount - a.repeatBuyerCount) .slice(0, 10); // 5. Filter & Sort Customers for Table let filteredCustomers = allCustomers; // Minimum orders filter (default >= 2 for repeat buyers only) if (minOrders > 1) { filteredCustomers = filteredCustomers.filter((c) => c.orderCount >= minOrders); } // Search filter if (search) { filteredCustomers = filteredCustomers.filter( (c) => c.displayName.toLowerCase().includes(search) || c.identifier.toLowerCase().includes(search) || c.location.toLowerCase().includes(search) ); } // Sorting filteredCustomers.sort((a, b) => { let comparison = 0; if (sortBy === "totalSpent") { comparison = b.totalSpent - a.totalSpent; } else if (sortBy === "lastOrderDate") { comparison = String(b.lastOrderDate || "").localeCompare(String(a.lastOrderDate || "")); } else if (sortBy === "daysBetween") { comparison = (a.daysBetweenOrders || 9999) - (b.daysBetweenOrders || 9999); } else { // default 'orders' comparison = b.orderCount - a.orderCount || b.totalSpent - a.totalSpent; } return sortOrder === "asc" ? -comparison : comparison; }); const totalFiltered = filteredCustomers.length; const totalPages = Math.ceil(totalFiltered / limit); const paginatedCustomers = filteredCustomers.slice((page - 1) * limit, page * limit); // Clean output for customers const formattedCustomers = paginatedCustomers.map((c) => { // Top 3 products const sortedProds = Array.from(c.productFrequency.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([name]) => name); let predictedNextOrderDate = ""; if (c.lastTimestamp > 0 && c.daysBetweenOrders > 0) { const predictedTs = c.lastTimestamp + c.daysBetweenOrders * 24 * 60 * 60 * 1000; predictedNextOrderDate = new Date(predictedTs).toISOString().slice(0, 10); } return { id: c.id, identifier: c.identifier, displayName: c.displayName, primaryChannel: c.primaryChannel, channels: Array.from(c.channels), shops: Array.from(c.shops), orderCount: c.orderCount, totalSpent: Number(c.totalSpent.toFixed(2)), avgOrderValue: c.orderCount > 0 ? Number((c.totalSpent / c.orderCount).toFixed(2)) : 0, firstOrderDate: c.firstOrderDate, lastOrderDate: c.lastOrderDate, daysBetweenOrders: c.daysBetweenOrders, predictedNextOrderDate, location: c.location, topProducts: sortedProds, }; }); const repurchaseRate = totalCustomers > 0 ? Number(((repeatCustomersCount / totalCustomers) * 100).toFixed(2)) : 0; const repeatRevenueShare = totalRevenue > 0 ? Number(((repeatRevenue / totalRevenue) * 100).toFixed(2)) : 0; const avgInterval = customersWithIntervalCount > 0 ? Number((totalIntervalDays / customersWithIntervalCount).toFixed(1)) : 0; return { summary: { totalCustomers, repeatCustomers: repeatCustomersCount, singleOrderCustomers: singleOrderCustomersCount, repurchaseRate, totalRevenue: Number(totalRevenue.toFixed(2)), repeatRevenue: Number(repeatRevenue.toFixed(2)), repeatRevenueShare, avgRepurchaseIntervalDays: avgInterval, }, cohorts: [ { label: "1 คำสั่งซื้อ", count: cohorts.order1.count, revenue: Number(cohorts.order1.revenue.toFixed(2)), percentage: totalCustomers > 0 ? Number(((cohorts.order1.count / totalCustomers) * 100).toFixed(1)) : 0, }, { label: "2 คำสั่งซื้อ", count: cohorts.order2.count, revenue: Number(cohorts.order2.revenue.toFixed(2)), percentage: totalCustomers > 0 ? Number(((cohorts.order2.count / totalCustomers) * 100).toFixed(1)) : 0, }, { label: "3 - 5 คำสั่งซื้อ", count: cohorts.order3to5.count, revenue: Number(cohorts.order3to5.revenue.toFixed(2)), percentage: totalCustomers > 0 ? Number(((cohorts.order3to5.count / totalCustomers) * 100).toFixed(1)) : 0, }, { label: "6+ คำสั่งซื้อ (VIP)", count: cohorts.order6plus.count, revenue: Number(cohorts.order6plus.revenue.toFixed(2)), percentage: totalCustomers > 0 ? Number(((cohorts.order6plus.count / totalCustomers) * 100).toFixed(1)) : 0, }, ], channelBreakdown: { tiktok: { ...channelStats.tiktok, repurchaseRate: channelStats.tiktok.totalCust > 0 ? Number(((channelStats.tiktok.repeatCust / channelStats.tiktok.totalCust) * 100).toFixed(1)) : 0, }, shopee: { ...channelStats.shopee, repurchaseRate: channelStats.shopee.totalCust > 0 ? Number(((channelStats.shopee.repeatCust / channelStats.shopee.totalCust) * 100).toFixed(1)) : 0, }, lazada: { ...channelStats.lazada, repurchaseRate: channelStats.lazada.totalCust > 0 ? Number(((channelStats.lazada.repeatCust / channelStats.lazada.totalCust) * 100).toFixed(1)) : 0, }, }, topRepeatProducts, customers: formattedCustomers, pagination: { page, limit, total: totalFiltered, totalPages, }, }; }) // ── GET Single Repeat Customer Detail & Orders History ───────────────────── .get("/repeat-customers/detail", async ({ query }) => { const channel = (query.channel as string) || "tiktok"; const identifier = (query.identifier as string) || ""; if (!identifier) { return { error: "Missing identifier" }; } let customerInfo: any = null; let orders: any[] = []; if (channel === "tiktok") { const dbOrders = await prisma.tiktokOrder.findMany({ where: { buyerUsername: identifier, }, orderBy: { createdTime: "desc" }, include: { items: true, }, }); if (dbOrders.length > 0) { const first = dbOrders[0]; customerInfo = { identifier, displayName: identifier, channel: "tiktok", shop: first.shop, location: [first.district, first.province].filter(Boolean).join(", ") || first.province || "", totalOrders: dbOrders.length, totalSpent: dbOrders.reduce((s, o) => s + (Number(o.orderAmount) || 0), 0), }; orders = dbOrders.map((o) => ({ orderId: o.orderId, channel: "tiktok", shop: o.shop, date: o.createdTime, status: o.status, amount: Number(o.orderAmount) || 0, shippingFee: Number(o.shippingFee) || 0, trackingId: o.trackingId, deliveryOption: o.deliveryOption, items: o.items.map((it) => ({ name: it.productName, variation: it.variation, quantity: it.quantity, price: Number(it.subtotalAfterDiscount || it.unitOriginalPrice) || 0, })), })); } } else if (channel === "shopee") { const dbOrders = await prisma.shopeeOrder.findMany({ where: { buyerUsername: identifier, }, orderBy: { orderCreatedAt: "desc" }, include: { items: true, }, }); if (dbOrders.length > 0) { const first = dbOrders[0]; const addr = extractShopeeAddress(first); customerInfo = { identifier, displayName: identifier, channel: "shopee", shop: first.shop, location: [addr.district, addr.province].filter(Boolean).join(", ") || addr.province || "", totalOrders: dbOrders.length, totalSpent: dbOrders.reduce((s, o) => s + (Number(o.buyerPaidAmount || o.totalAmount) || 0), 0), }; orders = dbOrders.map((o) => ({ orderId: o.orderId, channel: "shopee", shop: o.shop, date: o.orderCreatedAt, status: o.status, amount: Number(o.buyerPaidAmount || o.totalAmount) || 0, shippingFee: Number(o.shippingFee) || 0, trackingId: o.trackingNo, items: o.items.map((it) => ({ name: it.productName, variation: it.variantName, quantity: it.quantity, price: Number(it.dealPrice || it.originalPrice) || 0, })), })); } } else if (channel === "lazada") { // Identifier format for lazada is: "name_postcode" or just name const parts = identifier.split("_"); const namePart = parts[0]; const postCodePart = parts[1] || ""; const where: any = { customerName: { equals: namePart, mode: "insensitive" }, }; if (postCodePart) { where.shippingPostCode = postCodePart; } const dbOrders = await prisma.lazadaOrder.findMany({ where, orderBy: { createTime: "desc" }, include: { items: true, }, }); if (dbOrders.length > 0) { const first = dbOrders[0]; const addr = extractLazadaAddress(first); customerInfo = { identifier, displayName: `${first.customerName || "ลูกค้า Lazada"}${first.shippingPostCode ? ` (${first.shippingPostCode})` : ""}`, channel: "lazada", shop: first.shop, location: [addr.district, addr.province].filter(Boolean).join(", ") || addr.province || "", totalOrders: dbOrders.length, totalSpent: dbOrders.reduce((s, o) => s + (Number(o.orderAmount) || 0), 0), }; orders = dbOrders.map((o) => ({ orderId: o.orderNumber, channel: "lazada", shop: o.shop, date: o.createTime, status: o.status || "Completed", amount: Number(o.orderAmount) || 0, shippingFee: Number(o.shippingFee) || 0, trackingId: o.trackingCode, items: o.items.map((it) => ({ name: it.itemName, variation: it.variation, quantity: 1, price: Number(it.paidPrice || it.unitPrice) || 0, })), })); } } return { customer: customerInfo || { identifier, displayName: identifier, channel, totalOrders: 0, totalSpent: 0, }, orders, }; }) // ── GET Repeat Customers Calendar View ───────────────────────────────────── .get("/repeat-customers/calendar", async ({ query }) => { const channel = (query.channel as string) || "all"; const statusFilter = (query.statusFilter as string) || "completed"; const sf = buildStatusFilter(statusFilter); // Default to current month YYYY-MM if not provided const now = new Date(); const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; const targetMonth = (query.month as string) || currentMonthStr; // e.g. "2026-08" const monthStart = `${targetMonth}-01`; const monthEnd = `${targetMonth}-31 23:59`; // 1. Identify repeat buyers lifetime first type CustIdMap = Map; const allCustomers = new Map(); // TikTok if (channel === "all" || channel === "tiktok") { const tt = await prisma.tiktokOrder.findMany({ where: { ...sf.tiktok }, select: { orderId: true, buyerUsername: true }, }); for (const o of tt) { const rawUser = (o.buyerUsername || "").trim(); const id = rawUser || `TT_${o.orderId}`; const key = `tiktok:${id.toLowerCase()}`; const prev = allCustomers.get(key) || { orderCount: 0, displayName: rawUser || `TikTok #${o.orderId.slice(-6)}`, channel: "tiktok" }; prev.orderCount += 1; allCustomers.set(key, prev); } } // Shopee if (channel === "all" || channel === "shopee") { const sp = await prisma.shopeeOrder.findMany({ where: { ...sf.shopee }, select: { orderId: true, buyerUsername: true }, }); for (const o of sp) { const rawUser = (o.buyerUsername || "").trim(); const id = rawUser || `SP_${o.orderId}`; const key = `shopee:${id.toLowerCase()}`; const prev = allCustomers.get(key) || { orderCount: 0, displayName: rawUser || `Shopee #${o.orderId.slice(-6)}`, channel: "shopee" }; prev.orderCount += 1; allCustomers.set(key, prev); } } // Lazada if (channel === "all" || channel === "lazada") { const lz = await prisma.lazadaOrder.findMany({ where: { ...sf.lazada }, select: { orderNumber: true, customerName: true, shippingPostCode: true }, }); for (const o of lz) { const rawName = (o.customerName || "").trim(); const postCode = (o.shippingPostCode || "").trim(); const id = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`; const key = `lazada:${id}`; const prev = allCustomers.get(key) || { orderCount: 0, displayName: rawName ? `${rawName}${postCode ? ` (${postCode})` : ""}` : `Lazada #${o.orderNumber.slice(-6)}`, channel: "lazada", }; prev.orderCount += 1; allCustomers.set(key, prev); } } // Set of repeat customer keys const repeatKeys = new Set(); for (const [k, v] of allCustomers.entries()) { if (v.orderCount >= 2) { repeatKeys.add(k); } } // 2. Fetch orders in target month type CalendarDayOrder = { orderId: string; channel: "tiktok" | "shopee" | "lazada"; shop: string; customerKey: string; customerName: string; time: string; amount: number; status: string; itemsCount: number; topProducts: string[]; }; const daysMap = new Map; orders: CalendarDayOrder[]; channelBreakdown: { tiktok: { orders: number; revenue: number }; shopee: { orders: number; revenue: number }; lazada: { orders: number; revenue: number }; }; }>(); // Helper to add order to day const addOrderToDay = ( dateStr: string, order: CalendarDayOrder ) => { const dayKey = dateStr.slice(0, 10); if (!dayKey.startsWith(targetMonth)) return; let day = daysMap.get(dayKey); if (!day) { day = { date: dayKey, ordersCount: 0, revenue: 0, uniqueCustomers: new Set(), orders: [], channelBreakdown: { tiktok: { orders: 0, revenue: 0 }, shopee: { orders: 0, revenue: 0 }, lazada: { orders: 0, revenue: 0 }, }, }; daysMap.set(dayKey, day); } day.ordersCount += 1; day.revenue += order.amount; day.uniqueCustomers.add(order.customerKey); day.orders.push(order); day.channelBreakdown[order.channel].orders += 1; day.channelBreakdown[order.channel].revenue += order.amount; }; // Query TikTok orders in month if (channel === "all" || channel === "tiktok") { const ttOrders = await prisma.tiktokOrder.findMany({ where: { ...sf.tiktok, createdTime: { gte: monthStart, lte: monthEnd }, }, select: { orderId: true, shop: true, status: true, buyerUsername: true, orderAmount: true, createdTime: true, items: { select: { productName: true, quantity: true } }, }, }); for (const o of ttOrders) { const rawUser = (o.buyerUsername || "").trim(); const id = rawUser || `TT_${o.orderId}`; const key = `tiktok:${id.toLowerCase()}`; if (!repeatKeys.has(key)) continue; // only repeat buyers const custInfo = allCustomers.get(key); const dateStr = o.createdTime || ""; const amount = Number(o.orderAmount) || 0; const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); addOrderToDay(dateStr, { orderId: o.orderId, channel: "tiktok", shop: o.shop, customerKey: key, customerName: custInfo?.displayName || rawUser || o.orderId, time: dateStr.slice(11, 16) || "", amount, status: o.status, itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), topProducts: prodNames.slice(0, 2), }); } } // Query Shopee orders in month if (channel === "all" || channel === "shopee") { const spOrders = await prisma.shopeeOrder.findMany({ where: { ...sf.shopee, orderCreatedAt: { gte: monthStart, lte: monthEnd }, }, select: { orderId: true, shop: true, status: true, buyerUsername: true, buyerPaidAmount: true, totalAmount: true, orderCreatedAt: true, items: { select: { productName: true, quantity: true } }, }, }); for (const o of spOrders) { const rawUser = (o.buyerUsername || "").trim(); const id = rawUser || `SP_${o.orderId}`; const key = `shopee:${id.toLowerCase()}`; if (!repeatKeys.has(key)) continue; const custInfo = allCustomers.get(key); const dateStr = o.orderCreatedAt || ""; const amount = Number(o.buyerPaidAmount || o.totalAmount) || 0; const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); addOrderToDay(dateStr, { orderId: o.orderId, channel: "shopee", shop: o.shop, customerKey: key, customerName: custInfo?.displayName || rawUser || o.orderId, time: dateStr.slice(11, 16) || "", amount, status: o.status, itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), topProducts: prodNames.slice(0, 2), }); } } // Query Lazada orders in month if (channel === "all" || channel === "lazada") { const lzOrders = await prisma.lazadaOrder.findMany({ where: { ...sf.lazada, createTime: { gte: monthStart, lte: monthEnd }, }, select: { orderNumber: true, shop: true, status: true, customerName: true, shippingPostCode: true, orderAmount: true, createTime: true, items: { select: { itemName: true } }, }, }); for (const o of lzOrders) { const rawName = (o.customerName || "").trim(); const postCode = (o.shippingPostCode || "").trim(); const id = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`; const key = `lazada:${id}`; if (!repeatKeys.has(key)) continue; const custInfo = allCustomers.get(key); const dateStr = o.createTime || ""; const amount = Number(o.orderAmount) || 0; const prodNames = o.items.map((it) => it.itemName.trim()).filter(Boolean); addOrderToDay(dateStr, { orderId: o.orderNumber, channel: "lazada", shop: o.shop, customerKey: key, customerName: custInfo?.displayName || rawName || o.orderNumber, time: dateStr.slice(11, 16) || "", amount, status: o.status || "Completed", itemsCount: o.items.length, topProducts: prodNames.slice(0, 2), }); } } // Format days output let monthTotalRevenue = 0; let monthTotalOrders = 0; const monthUniqueCustomers = new Set(); let peakDay = { date: "", revenue: 0, orders: 0 }; const days = Array.from(daysMap.values()).map((d) => { monthTotalRevenue += d.revenue; monthTotalOrders += d.ordersCount; d.uniqueCustomers.forEach((c) => monthUniqueCustomers.add(c)); if (d.revenue > peakDay.revenue) { peakDay = { date: d.date, revenue: Number(d.revenue.toFixed(2)), orders: d.ordersCount }; } // Sort orders newest time first d.orders.sort((a, b) => b.time.localeCompare(a.time)); return { date: d.date, day: parseInt(d.date.slice(8, 10), 10), ordersCount: d.ordersCount, revenue: Number(d.revenue.toFixed(2)), uniqueCustomersCount: d.uniqueCustomers.size, channelBreakdown: { tiktok: { orders: d.channelBreakdown.tiktok.orders, revenue: Number(d.channelBreakdown.tiktok.revenue.toFixed(2)) }, shopee: { orders: d.channelBreakdown.shopee.orders, revenue: Number(d.channelBreakdown.shopee.revenue.toFixed(2)) }, lazada: { orders: d.channelBreakdown.lazada.orders, revenue: Number(d.channelBreakdown.lazada.revenue.toFixed(2)) }, }, orders: d.orders, }; }); return { month: targetMonth, summary: { totalRepeatOrders: monthTotalOrders, totalRepeatRevenue: Number(monthTotalRevenue.toFixed(2)), totalUniqueRepeatCustomers: monthUniqueCustomers.size, peakDay, }, days, }; }) // ── GET Repeat Customers Timeline / Journey Swimlane ────────────────────── .get("/repeat-customers/timeline", async ({ query }) => { const channel = (query.channel as string) || "all"; const statusFilter = (query.statusFilter as string) || "completed"; const sf = buildStatusFilter(statusFilter); const search = ((query.search as string) || "").trim().toLowerCase(); const churnStatusFilter = (query.churnStatus as string) || "all"; // 'all' | 'healthy' | 'due_soon' | 'churn_risk' const page = Math.max(1, parseInt((query.page as string) || "1", 10)); const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "20", 10))); type OrderNode = { orderId: string; channel: "tiktok" | "shopee" | "lazada"; shop: string; date: string; timestamp: number; amount: number; itemsCount: number; productNames: string[]; }; type CustomerTimeline = { id: string; identifier: string; displayName: string; primaryChannel: "tiktok" | "shopee" | "lazada"; location: string; orderCount: number; totalSpent: number; firstOrderDate: string; lastOrderDate: string; firstTimestamp: number; lastTimestamp: number; avgIntervalDays: number; daysSinceLastOrder: number; predictedNextOrderDate: string; churnStatus: "healthy" | "due_soon" | "churn_risk"; orders: OrderNode[]; }; const customerMap = new Map(); // 1. TikTok if (channel === "all" || channel === "tiktok") { const orders = await prisma.tiktokOrder.findMany({ where: { ...sf.tiktok }, select: { orderId: true, shop: true, buyerUsername: true, orderAmount: true, createdTime: true, province: true, district: true, items: { select: { productName: true, quantity: true } }, }, }); for (const o of orders) { const rawUser = (o.buyerUsername || "").trim(); const id = rawUser || `TT_${o.orderId}`; const key = `tiktok:${id.toLowerCase()}`; const dateStr = o.createdTime || ""; const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; const amount = Number(o.orderAmount) || 0; const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); const loc = [o.district, o.province].filter(Boolean).join(", ") || o.province || ""; let c = customerMap.get(key); if (!c) { c = { id: key, identifier: id, displayName: rawUser || `TikTok #${o.orderId.slice(-6)}`, primaryChannel: "tiktok", location: loc, orderCount: 0, totalSpent: 0, firstOrderDate: dateStr, lastOrderDate: dateStr, firstTimestamp: ts, lastTimestamp: ts, avgIntervalDays: 0, daysSinceLastOrder: 0, predictedNextOrderDate: "", churnStatus: "healthy", orders: [], }; customerMap.set(key, c); } c.orderCount += 1; c.totalSpent += amount; if (loc && !c.location) c.location = loc; c.orders.push({ orderId: o.orderId, channel: "tiktok", shop: o.shop, date: dateStr, timestamp: ts, amount, itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), productNames: prodNames, }); } } // 2. Shopee if (channel === "all" || channel === "shopee") { const orders = await prisma.shopeeOrder.findMany({ where: { ...sf.shopee }, select: { orderId: true, shop: true, buyerUsername: true, buyerPaidAmount: true, totalAmount: true, orderCreatedAt: true, items: { select: { productName: true, quantity: true, rawData: true } }, }, }); for (const o of orders) { const rawUser = (o.buyerUsername || "").trim(); const id = rawUser || `SP_${o.orderId}`; const key = `shopee:${id.toLowerCase()}`; const dateStr = o.orderCreatedAt || ""; const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; const amount = Number(o.buyerPaidAmount || o.totalAmount) || 0; const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean); const { province, district } = extractShopeeAddress(o); const loc = [district, province].filter(Boolean).join(", ") || province || ""; let c = customerMap.get(key); if (!c) { c = { id: key, identifier: id, displayName: rawUser || `Shopee #${o.orderId.slice(-6)}`, primaryChannel: "shopee", location: loc, orderCount: 0, totalSpent: 0, firstOrderDate: dateStr, lastOrderDate: dateStr, firstTimestamp: ts, lastTimestamp: ts, avgIntervalDays: 0, daysSinceLastOrder: 0, predictedNextOrderDate: "", churnStatus: "healthy", orders: [], }; customerMap.set(key, c); } c.orderCount += 1; c.totalSpent += amount; if (loc && !c.location) c.location = loc; c.orders.push({ orderId: o.orderId, channel: "shopee", shop: o.shop, date: dateStr, timestamp: ts, amount, itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0), productNames: prodNames, }); } } // 3. Lazada if (channel === "all" || channel === "lazada") { const orders = await prisma.lazadaOrder.findMany({ where: { ...sf.lazada }, select: { orderNumber: true, shop: true, customerName: true, shippingCity: true, shippingRegion: true, shippingPostCode: true, orderAmount: true, createTime: true, items: { select: { itemName: true } }, }, }); for (const o of orders) { const rawName = (o.customerName || "").trim(); const postCode = (o.shippingPostCode || "").trim(); const id = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`; const key = `lazada:${id}`; const dateStr = o.createTime || ""; const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0; const amount = Number(o.orderAmount) || 0; const prodNames = o.items.map((it) => it.itemName.trim()).filter(Boolean); const { province, district } = extractLazadaAddress(o); const loc = [district, province].filter(Boolean).join(", ") || province || ""; let c = customerMap.get(key); if (!c) { c = { id: key, identifier: id, displayName: rawName ? `${rawName}${postCode ? ` (${postCode})` : ""}` : `Lazada #${o.orderNumber.slice(-6)}`, primaryChannel: "lazada", location: loc, orderCount: 0, totalSpent: 0, firstOrderDate: dateStr, lastOrderDate: dateStr, firstTimestamp: ts, lastTimestamp: ts, avgIntervalDays: 0, daysSinceLastOrder: 0, predictedNextOrderDate: "", churnStatus: "healthy", orders: [], }; customerMap.set(key, c); } c.orderCount += 1; c.totalSpent += amount; if (loc && !c.location) c.location = loc; c.orders.push({ orderId: o.orderNumber, channel: "lazada", shop: o.shop, date: dateStr, timestamp: ts, amount, itemsCount: o.items.length, productNames: prodNames, }); } } // 4. Filter only repeat buyers (>= 2 orders), calculate cycles & churn const nowTime = Date.now(); let healthyCount = 0; let dueSoonCount = 0; let churnRiskCount = 0; const repeatCustomers: CustomerTimeline[] = []; for (const c of customerMap.values()) { if (c.orderCount < 2) continue; // Sort orders chronologically c.orders.sort((a, b) => a.timestamp - b.timestamp); c.firstTimestamp = c.orders[0].timestamp; c.firstOrderDate = c.orders[0].date; c.lastTimestamp = c.orders[c.orders.length - 1].timestamp; c.lastOrderDate = c.orders[c.orders.length - 1].date; if (c.lastTimestamp > c.firstTimestamp) { const totalSpanDays = (c.lastTimestamp - c.firstTimestamp) / (1000 * 60 * 60 * 24); c.avgIntervalDays = Number((totalSpanDays / (c.orderCount - 1)).toFixed(1)); } else { c.avgIntervalDays = 1; } const daysSince = Math.max(0, (nowTime - c.lastTimestamp) / (1000 * 60 * 60 * 24)); c.daysSinceLastOrder = Number(daysSince.toFixed(1)); // Predicted next purchase const predictedTs = c.lastTimestamp + c.avgIntervalDays * 24 * 60 * 60 * 1000; c.predictedNextOrderDate = new Date(predictedTs).toISOString().slice(0, 10); // Churn status: // healthy: daysSince <= avgInterval // due_soon: daysSince > avgInterval && daysSince <= avgInterval * 1.5 // churn_risk: daysSince > avgInterval * 1.5 if (c.daysSinceLastOrder <= c.avgIntervalDays) { c.churnStatus = "healthy"; healthyCount += 1; } else if (c.daysSinceLastOrder <= c.avgIntervalDays * 1.5) { c.churnStatus = "due_soon"; dueSoonCount += 1; } else { c.churnStatus = "churn_risk"; churnRiskCount += 1; } repeatCustomers.push(c); } // 5. Filter & Sort let filtered = repeatCustomers; if (churnStatusFilter !== "all") { filtered = filtered.filter((c) => c.churnStatus === churnStatusFilter); } if (search) { filtered = filtered.filter( (c) => c.displayName.toLowerCase().includes(search) || c.identifier.toLowerCase().includes(search) || c.location.toLowerCase().includes(search) ); } // Sort: prioritizes due_soon and healthy active, then orders count filtered.sort((a, b) => { const churnWeight = { due_soon: 3, healthy: 2, churn_risk: 1 }; const diff = churnWeight[b.churnStatus] - churnWeight[a.churnStatus]; if (diff !== 0) return diff; return b.orderCount - a.orderCount || b.totalSpent - a.totalSpent; }); const totalFiltered = filtered.length; const totalPages = Math.ceil(totalFiltered / limit); const paginated = filtered.slice((page - 1) * limit, page * limit); return { summary: { totalRepeatCustomers: repeatCustomers.length, healthyCount, dueSoonCount, churnRiskCount, }, customers: paginated, pagination: { page, limit, total: totalFiltered, totalPages, }, }; });