import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces"; 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; }; // TikTok CSV values can have trailing \t — trim everything const str = (val: any): string => String(val ?? "").replace(/\t/g, "").trim(); // TikTok dates are "DD/MM/YYYY HH:mm:ss" -> convert to "YYYY-MM-DD HH:mm:ss" for sorting/filtering const parseTiktokDate = (val: any): string => { const d = str(val); if (!d) return ""; const [datePart, timePart] = d.split(" "); if (!datePart) return d; const parts = datePart.split("/"); if (parts.length === 3) { const [day, month, year] = parts; return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")} ${timePart || "00:00:00"}`; } return d; }; // Helper to enrich TikTok orders with BOM & Profitability async function enrichOrdersWithBOM(orders: any[], shop: string) { if (!orders || orders.length === 0) return []; const orderIds = orders.map((o) => o.orderId).filter(Boolean); // Fetch all MarketplaceProduct mappings, master variants, and settlements for this batch const [marketplaceProducts, masterVariants, settlements] = await Promise.all([ prisma.marketplaceProduct.findMany({ where: { channel: "tiktok", shop }, include: { masterVariant: { include: { product: { select: { id: true, name: true } }, bomItems: { include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } }, }, }, }, }, }), prisma.productVariant.findMany({ include: { product: { select: { id: true, name: true } }, bomItems: { include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } }, }, }, }), orderIds.length > 0 ? prisma.tiktokSettlement.findMany({ where: { orderId: { in: orderIds } } }) : Promise.resolve([]), ]); // Index Settlements const settlementMap = new Map(); for (const s of settlements) { settlementMap.set(s.orderId, s); } // Index MarketplaceProducts const mpExactMap = new Map(); // shop:::productName:::variation:::sku const mpSkuMap = new Map(); // shop:::sku const mpNameVarMap = new Map(); // shop:::productName:::variation const variantSkuMap = new Map(); // SKU uppercase -> ProductVariant for (const mp of marketplaceProducts) { const s = (mp.shop || "hom-aroma").trim().toLowerCase(); const pName = (mp.productName || "").trim().toLowerCase(); const vName = (mp.variation || "").trim().toLowerCase(); const sku = (mp.marketplaceSku || "").trim().toLowerCase(); const exactKey = `${s}:::${pName}:::${vName}:::${sku}`; mpExactMap.set(exactKey, mp); if (sku) { mpSkuMap.set(`${s}:::${sku}`, mp); } if (pName) { mpNameVarMap.set(`${s}:::${pName}:::${vName}`, mp); } } for (const v of masterVariants) { if (v.sku) { variantSkuMap.set(v.sku.trim().toLowerCase(), v); } } return orders.map((order) => { const oShop = (order.shop || "hom-aroma").trim().toLowerCase(); let orderTotalBomCost = 0; let missingBomCount = 0; const enrichedItems = (order.items || []).map((item: any) => { const pName = (item.productName || "").trim().toLowerCase(); const vName = (item.variation || "").trim().toLowerCase(); const sSku = (item.sellerSku || item.skuId || "").trim().toLowerCase(); // Resolve Master Variant let resolvedVariant: any = null; let matchedVia = ""; // 1. Exact Match const exact = mpExactMap.get(`${oShop}:::${pName}:::${vName}:::${sSku}`); if (exact?.masterVariant) { resolvedVariant = exact.masterVariant; matchedVia = "marketplace_exact"; } // 2. SKU Match in MarketplaceProduct if (!resolvedVariant && sSku) { const bySku = mpSkuMap.get(`${oShop}:::${sSku}`); if (bySku?.masterVariant) { resolvedVariant = bySku.masterVariant; matchedVia = "marketplace_sku"; } } // 3. Name + Variation Match in MarketplaceProduct if (!resolvedVariant && pName) { const byNameVar = mpNameVarMap.get(`${oShop}:::${pName}:::${vName}`); if (byNameVar?.masterVariant) { resolvedVariant = byNameVar.masterVariant; matchedVia = "marketplace_name_variation"; } } // 4. Direct Core Variant SKU match if (!resolvedVariant && sSku) { const direct = variantSkuMap.get(sSku); if (direct) { resolvedVariant = direct; matchedVia = "direct_sku"; } } // Calculate BOM Cost let unitBomCost = 0; let bomItemsDetail: any[] = []; let hasBom = false; let hasMapping = !!resolvedVariant; if (resolvedVariant) { const bItems = resolvedVariant.bomItems || []; unitBomCost = bItems.reduce((acc: number, b: any) => { return acc + (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0); }, 0); bomItemsDetail = bItems.map((b: any) => ({ rawMaterialId: b.rawMaterialId, name: b.rawMaterial?.name || "วัตถุดิบ", unit: b.rawMaterial?.unit || "หน่วย", pricePerUnit: b.rawMaterial?.pricePerUnit || 0, quantity: b.quantity || 0, lineCost: (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0), })); hasBom = unitBomCost > 0 && bItems.length > 0; } const qty = Number(item.quantity) || 1; const totalBomCost = unitBomCost * qty; const itemRevenue = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty); const profit = itemRevenue - totalBomCost; const marginPercent = itemRevenue > 0 ? (profit / itemRevenue) * 100 : 0; if (!hasBom) { missingBomCount++; } orderTotalBomCost += totalBomCost; return { ...item, unitBomCost, totalBomCost, profit, marginPercent, hasBom, hasMapping, matchedVia, masterProductId: resolvedVariant?.productId || (typeof resolvedVariant?.product?.id === "number" ? resolvedVariant.product.id : null), masterProductName: typeof resolvedVariant?.product?.name === "string" ? resolvedVariant.product.name : (resolvedVariant?.product?.name as any)?.th || (resolvedVariant?.product?.name as any)?.en || null, masterSku: resolvedVariant?.sku || null, masterVariantId: resolvedVariant?.id || null, bomItemsDetail, }; }); const settlement = settlementMap.get(order.orderId); const rawComm = (settlement?.commissionFee ?? order.commissionFee ?? 0); const commissionFee = Math.abs(rawComm); const rawAff = settlement ? (settlement.affiliateCommission + settlement.affiliatePartnerFee) : (order.affiliateFee ?? 0); const affiliateFee = Math.abs(rawAff); const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (order.orderAmount - commissionFee - affiliateFee); const isSettled = order.isSettled || !!settlement; const settledAt = order.settledAt || settlement?.orderSettledTime || null; const orderEstProfit = order.orderAmount - orderTotalBomCost; const orderNetProfit = (isSettled && settlement ? settlement.settlementAmount : (order.orderAmount - commissionFee - affiliateFee)) - orderTotalBomCost; const orderGrossMargin = order.orderAmount > 0 ? (orderEstProfit / order.orderAmount) * 100 : 0; const allBomComplete = missingBomCount === 0 && enrichedItems.length > 0; const hasIncompleteBom = missingBomCount > 0; return { ...order, items: enrichedItems, settlement: settlement || null, orderTotalBomCost, orderEstProfit, orderNetProfit, commissionFee, affiliateFee, netSettlement, isSettled, settledAt, orderGrossMargin, missingBomCount, allBomComplete, hasIncompleteBom, }; }); } export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) // ── Import Orders (CSV rows as JSON) ─────────────────────────────────────── .post( "/import", async ({ body, set }) => { try { const { rows, shop } = body as { rows: any[]; shop?: string }; const shopName = shop || "hom-aroma"; console.log(`[TiktokOrder] Received ${rows.length} raw rows for shop: ${shopName}`); // Group rows by Order ID (trim tabs) const orderMap = new Map(); for (const row of rows) { const orderId = str(row["Order ID"]); if (!orderId) continue; if (!orderMap.has(orderId)) orderMap.set(orderId, { meta: row, items: [] }); orderMap.get(orderId)!.items.push(row); } console.log(`[TiktokOrder] Grouped into ${orderMap.size} unique orders`); const CHUNK = 30; const entries = Array.from(orderMap.entries()); let upserted = 0; for (let i = 0; i < entries.length; i += CHUNK) { const chunk = entries.slice(i, i + CHUNK); await prisma.$transaction(async (tx) => { for (const [orderId, { meta, items }] of chunk) { const existingSettlement = await tx.tiktokSettlement.findUnique({ where: { orderId } }); // Delete old items first (sync strategy) await tx.tiktokOrderItem.deleteMany({ where: { orderId } }); const itemsData = items.map((item) => ({ skuId: str(item["SKU ID"]), sellerSku: str(item["Seller SKU"]), productName: str(item["Product Name"]), variation: str(item["Variation"]), quantity: Math.max(1, Math.round(toNum(item["Quantity"]))), skuQtyReturn: Math.round(toNum(item["Sku Quantity of return"])), unitOriginalPrice: toNum(item["SKU Unit Original Price"]), subtotalBeforeDiscount: toNum(item["SKU Subtotal Before Discount"]), platformDiscount: toNum(item["SKU Platform Discount"]), sellerDiscount: toNum(item["SKU Seller Discount"]), subtotalAfterDiscount: toNum(item["SKU Subtotal After Discount"]), productCategory: str(item["Product Category"]), weight: toNum(item["Weight(kg)"]), })); const orderData = { shop: shopName, status: str(meta["Order Status"]), subStatus: str(meta["Order Substatus"]), orderType: str(meta["Normal or Pre-order"]), cancelBy: str(meta["Cancel By"]), cancelReason: str(meta["Cancel Reason"]), fulfillmentType: str(meta["Fulfillment Type"]), warehouseName: str(meta["Warehouse Name"]), trackingId: str(meta["Tracking ID"]), deliveryOption: str(meta["Delivery Option"]), shippingProvider: str(meta["Shipping Provider Name"]), buyerUsername: str(meta["Buyer Username"]), paymentMethod: str(meta["Payment Method"]), orderAmount: toNum(meta["Order Amount"]), refundAmount: toNum(meta["Order Refund Amount"]), shippingFee: toNum(meta["Shipping Fee After Discount"]), originalShipping: toNum(meta["Original Shipping Fee"]), taxes: toNum(meta["Taxes"]), province: str(meta["Province"]), district: str(meta["District"]), country: str(meta["Country"]), createdTime: parseTiktokDate(meta["Created Time"]), paidTime: parseTiktokDate(meta["Paid Time"]), rtsTime: parseTiktokDate(meta["RTS Time"]), shippedTime: parseTiktokDate(meta["Shipped Time"]), deliveredTime: parseTiktokDate(meta["Delivered Time"]), cancelledTime: parseTiktokDate(meta["Cancelled Time"]), commissionFee: existingSettlement ? existingSettlement.commissionFee : 0, affiliateFee: existingSettlement ? (existingSettlement.affiliateCommission + existingSettlement.affiliatePartnerFee) : 0, netSettlement: existingSettlement ? existingSettlement.settlementAmount : 0, settledAt: existingSettlement ? existingSettlement.orderSettledTime : null, isSettled: !!existingSettlement, }; await tx.tiktokOrder.upsert({ where: { orderId }, update: { ...orderData, items: { create: itemsData } }, create: { orderId, ...orderData, items: { create: itemsData } }, }); upserted++; } }, { timeout: 30000 }); } console.log(`[TiktokOrder] ✅ Done — ${upserted} orders upserted`); return { success: true, message: `นำเข้าสำเร็จ ${upserted} คำสั่งซื้อ (${rows.length} แถว)`, ordersCount: upserted, rowsCount: rows.length }; } catch (error: any) { console.error(`[TiktokOrder] ❌ Error:`, error.message); set.status = 500; return { success: false, message: error.message }; } }, { body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) } ) // ── Import Settlement (Income XLSX rows as JSON) ─────────────────────────── .post( "/import-settlement", async ({ body, set }) => { try { const { rows, shop } = body as { rows: any[]; shop?: string }; const shopName = shop || "hom-aroma"; console.log(`[TiktokSettlement] Received ${rows.length} raw settlement rows for shop: ${shopName}`); let upsertedCount = 0; let matchedOrdersCount = 0; let totalSettlementAmount = 0; let totalCommission = 0; let totalAffiliate = 0; const CHUNK = 50; for (let i = 0; i < rows.length; i += CHUNK) { const chunk = rows.slice(i, i + CHUNK); await prisma.$transaction(async (tx) => { for (const row of chunk) { const orderId = str( row["หมายเลขคำสั่งซื้อ/การปรับ"] || row["หมายเลขคำสั่งซื้อ"] || row["Order ID"] || row["order_id"] ); if (!orderId) continue; const transactionType = str(row["ประเภทธุรกรรม"] || row["Transaction Type"]); const orderCreatedTime = str(row["เวลาที่สร้างคำสั่งซื้อ"] || row["Order Created Time"]); const orderSettledTime = str(row["เวลาที่ชำระคำสั่งซื้อ"] || row["Order Settled Time"] || row["เวลาที่สำเร็จ"]); const currency = str(row["สกุลเงิน"] || row["Currency"] || "THB"); const settlementAmount = toNum(row["ยอดการชำระเงินทั้งหมด"] || row["Total Settlement Amount"] || row["จำนวน"]); const grossRevenue = toNum(row["รายได้ทั้งหมด"] || row["Total Revenue"]); const subtotalAfterSellerDiscount = toNum(row["ยอดรวมค่าสินค้าหลังหักส่วนลดจากผู้ขาย"]); const subtotalBeforeDiscount = toNum(row["ยอดรวมค่าสินค้าก่อนหักส่วนลด"]); const sellerDiscount = toNum(row["ส่วนลดจากร้านค้า"]); const refundAmount = toNum(row["ยอดรวมเงินคืนหลังหักส่วนลดจากผู้ขาย"]); const totalFee = toNum(row["ค่าธรรมเนียมทั้งหมด"]); const orderFee = toNum(row["ค่าธรรมเนียมคำสั่งซื้อ"]); const commissionFee = Math.abs(toNum(row["ค่าคอมมิชชั่น TikTok Shop"])); const affiliateCommission = Math.abs(toNum(row["ค่าคอมมิชชั่นแอฟฟิลิเอต"])); const affiliatePartnerFee = Math.abs(toNum(row["ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต"])); const growthSupportFee = toNum(row["ค่าธรรมเนียมสนับสนุนการเติบโตของร้านค้า"]); const shippingFeeActual = toNum(row["ค่าธรรมเนียมการจัดส่งจริง"]); const platformShippingDiscount = toNum(row["ส่วนลดค่าธรรมเนียมการจัดส่งจากแพลตฟอร์ม"]); const customerPayment = toNum(row["การชำระเงินของลูกค้า"]); const totalAff = affiliateCommission + affiliatePartnerFee; totalSettlementAmount += settlementAmount; totalCommission += commissionFee; totalAffiliate += totalAff; // Upsert into TiktokSettlement await tx.tiktokSettlement.upsert({ where: { orderId }, update: { shop: shopName, transactionType, orderCreatedTime, orderSettledTime, currency, settlementAmount, grossRevenue, subtotalAfterSellerDiscount, subtotalBeforeDiscount, sellerDiscount, refundAmount, totalFee, orderFee, commissionFee, affiliateCommission, affiliatePartnerFee, growthSupportFee, shippingFeeActual, platformShippingDiscount, customerPayment, rawData: row, }, create: { orderId, shop: shopName, transactionType, orderCreatedTime, orderSettledTime, currency, settlementAmount, grossRevenue, subtotalAfterSellerDiscount, subtotalBeforeDiscount, sellerDiscount, refundAmount, totalFee, orderFee, commissionFee, affiliateCommission, affiliatePartnerFee, growthSupportFee, shippingFeeActual, platformShippingDiscount, customerPayment, rawData: row, }, }); upsertedCount++; // Update corresponding TiktokOrder if it exists const matchedOrder = await tx.tiktokOrder.findUnique({ where: { orderId } }); if (matchedOrder) { await tx.tiktokOrder.update({ where: { orderId }, data: { commissionFee, affiliateFee: totalAff, netSettlement: settlementAmount, settledAt: orderSettledTime, isSettled: true, }, }); matchedOrdersCount++; } } }, { timeout: 30000 }); } console.log(`[TiktokSettlement] ✅ Done — ${upsertedCount} settlements saved, ${matchedOrdersCount} orders linked`); return { success: true, message: `นำเข้าใบแจ้งยอดสำเร็จ ${upsertedCount} รายการ (เชื่อมโยงคำสั่งซื้อแล้ว ${matchedOrdersCount} ออเดอร์)`, settlementsCount: upsertedCount, matchedOrdersCount, totalSettlementAmount, totalCommission, totalAffiliate, }; } catch (error: any) { console.error(`[TiktokSettlement] ❌ Error:`, error.message); set.status = 500; return { success: false, message: error.message }; } }, { body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) } ) // ── Clear Shop Data ─────────────────────────────────────────────────────── .delete("/clear", async ({ query, set }) => { try { const shop = (query.shop as string) || "hom-aroma"; const [orderResult, settlementResult] = await Promise.all([ prisma.tiktokOrder.deleteMany({ where: { shop } }), prisma.tiktokSettlement.deleteMany({ where: { shop } }), ]); return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (คำสั่งซื้อ: ${orderResult.count}, ใบแจ้งยอด: ${settlementResult.count})`, count: orderResult.count + settlementResult.count, }; } catch (error: any) { console.error(`[TiktokOrder] ❌ Clear Error:`, error.message); set.status = 500; return { success: false, message: error.message }; } }) // ── GET Single Order Detail (with BOM Breakdown & Settlement) ────────────── .get("/orders/:orderId", async ({ params, set }) => { const order = await prisma.tiktokOrder.findUnique({ where: { orderId: params.orderId }, include: { items: true }, }); if (!order) { set.status = 404; return { error: "Order not found" }; } const [enriched] = await enrichOrdersWithBOM([order], order.shop); return enriched || order; }) // ── GET Orders (paginated & searchable with BOM & Settlement) ────────────── .get("/orders", async ({ query }) => { const page = Number(query.page ?? 1); const limit = Number(query.limit ?? 20); const skip = (page - 1) * limit; const shop = (query.shop as string) || "hom-aroma"; const search = ((query.search as string) || "").trim(); const status = (query.status as string) || ""; const bomStatus = (query.bomStatus as string) || "all"; // "all" | "missing" | "complete" const settlementStatus = (query.settlementStatus as string) || "all"; // "all" | "settled" | "pending" const where: any = { shop }; if (search) { where.OR = [ { orderId: { contains: search, mode: "insensitive" as const } }, { buyerUsername: { contains: search, mode: "insensitive" as const } }, { trackingId: { contains: search, mode: "insensitive" as const } }, { province: { contains: search, mode: "insensitive" as const } }, { district: { contains: search, mode: "insensitive" as const } }, { items: { some: { productName: { contains: search, mode: "insensitive" as const } } } }, { items: { some: { sellerSku: { contains: search, mode: "insensitive" as const } } } }, { items: { some: { variation: { contains: search, mode: "insensitive" as const } } } }, ]; } if (status && status !== "all") { where.status = { contains: status, mode: "insensitive" as const }; } if (settlementStatus === "settled") { where.isSettled = true; } else if (settlementStatus === "pending") { where.isSettled = false; } const [rawItems, total] = await Promise.all([ prisma.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, skip, take: limit, }), prisma.tiktokOrder.count({ where }), ]); const items = await enrichOrdersWithBOM(rawItems, shop); let filteredItems = items; if (bomStatus === "missing") { filteredItems = items.filter((o) => o.hasIncompleteBom); } else if (bomStatus === "complete") { filteredItems = items.filter((o) => o.allBomComplete); } return { items: filteredItems, total, page, limit, totalPages: Math.ceil(total / limit) }; }) // ── GET Summary (With BOM Cost, Settlement & Profit Breakdown) ───────────── .get("/summary", async ({ query }) => { const shop = (query.shop as string) || "hom-aroma"; const where = { shop }; const [orderCount, agg, itemAgg, allOrders, settlementAgg] = await Promise.all([ prisma.tiktokOrder.count({ where }), prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true, commissionFee: true, affiliateFee: true, netSettlement: true } }), prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }), prisma.tiktokOrder.findMany({ where, include: { items: true } }), prisma.tiktokSettlement.aggregate({ where: { shop }, _sum: { settlementAmount: true, commissionFee: true, affiliateCommission: true, affiliatePartnerFee: true, orderFee: true, totalFee: true } }), ]); const enrichedOrders = await enrichOrdersWithBOM(allOrders, shop); let totalBOMCost = 0; let ordersMissingBomCount = 0; let ordersWithBOMCount = 0; let totalNetProfit = 0; for (const o of enrichedOrders) { totalBOMCost += o.orderTotalBomCost || 0; totalNetProfit += o.orderNetProfit || 0; if (o.hasIncompleteBom) { ordersMissingBomCount++; } else { ordersWithBOMCount++; } } const totalAmount = agg._sum.orderAmount ?? 0; const totalEstProfit = totalAmount - totalBOMCost; const grossProfitMargin = totalAmount > 0 ? (totalEstProfit / totalAmount) * 100 : 0; const totalCommission = settlementAgg._sum.commissionFee ?? (agg._sum.commissionFee ?? 0); const totalAffiliate = ((settlementAgg._sum.affiliateCommission ?? 0) + (settlementAgg._sum.affiliatePartnerFee ?? 0)) || (agg._sum.affiliateFee ?? 0); const totalSettlementAmount = settlementAgg._sum.settlementAmount ?? (agg._sum.netSettlement ?? 0); return { orderCount, totalAmount, totalShipping: agg._sum.shippingFee ?? 0, totalRefund: agg._sum.refundAmount ?? 0, totalTaxes: agg._sum.taxes ?? 0, totalItems: itemAgg._sum.quantity ?? 0, totalSkuRevenue: itemAgg._sum.subtotalAfterDiscount ?? 0, totalBOMCost, totalEstProfit, grossProfitMargin, ordersMissingBomCount, ordersWithBOMCount, totalCommission, totalAffiliate, totalSettlementAmount, totalNetProfit, }; }) // ── GET Analytics (date range filter) ──────────────────────────────────── .get("/analytics", async ({ query }) => { const shop = (query.shop as string) || "hom-aroma"; const dateFrom = query.dateFrom as string | undefined; const dateTo = query.dateTo as string | undefined; const where: any = { shop }; if (dateFrom || dateTo) { where.createdTime = {}; if (dateFrom) where.createdTime.gte = dateFrom; if (dateTo) where.createdTime.lte = dateTo + " 23:59"; } const rawOrders = await prisma.tiktokOrder.findMany({ where, include: { items: true, }, orderBy: { createdTime: "asc" }, }); // Enrich with BOM & Settlement const allOrders = await enrichOrdersWithBOM(rawOrders, shop); // Monthly aggregation const monthMap = new Map(); for (const o of allOrders) { const month = (o.createdTime ?? "").slice(0, 7) || "Unknown"; if (!monthMap.has(month)) { monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0, commission: 0, affiliate: 0, bomCost: 0, netProfit: 0, settlement: 0, }); } const m = monthMap.get(month)!; m.orders += 1; m.revenue += o.orderAmount; m.platformDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0); m.sellerDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0); m.commission += (o.commissionFee || 0); m.affiliate += (o.affiliateFee || 0); m.bomCost += (o.orderTotalBomCost || 0); m.netProfit += (o.orderNetProfit || 0); m.settlement += (o.netSettlement || 0); if (o.status?.includes("ยกเลิก") || o.status?.includes("cancel")) m.cancelled += 1; m.items += (o.items || []).reduce((s: number, i: any) => s + (i.quantity || 0), 0); } const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); // Status breakdown const statusMap = new Map(); for (const o of allOrders) { const key = o.status || "Unknown"; statusMap.set(key, (statusMap.get(key) ?? 0) + 1); } const statusBreakdown = Array.from(statusMap.entries()).map(([status, count]) => ({ status, count })).sort((a, b) => b.count - a.count); // Payment method breakdown const payMap = new Map(); for (const o of allOrders) { const key = o.paymentMethod || "Unknown"; payMap.set(key, (payMap.get(key) ?? 0) + 1); } const paymentBreakdown = Array.from(payMap.entries()).map(([method, count]) => ({ method, count })).sort((a, b) => b.count - a.count); // Top products const filteredIds = allOrders.map(o => o.orderId); const allItems = filteredIds.length > 0 ? await prisma.tiktokOrderItem.findMany({ where: { orderId: { in: filteredIds } }, select: { productName: true, variation: true, quantity: true, subtotalAfterDiscount: true, sellerSku: true }, }) : []; const productMap = new Map(); for (const item of allItems) { const key = `${item.productName}__${item.variation ?? ""}`; if (!productMap.has(key)) productMap.set(key, { name: item.productName, sku: item.sellerSku ?? "", variation: item.variation ?? "", qty: 0, revenue: 0 }); const p = productMap.get(key)!; p.qty += item.quantity; p.revenue += item.subtotalAfterDiscount; } const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10); // Summary const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0); const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0); const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0); const totalPlatformDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.platformDiscount || 0), 0); const totalSellerDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.sellerDiscount || 0), 0); const totalCommission = allOrders.reduce((s, o) => s + (o.commissionFee || 0), 0); const totalAffiliate = allOrders.reduce((s, o) => s + (o.affiliateFee || 0), 0); const totalSettlementAmount = allOrders.reduce((s, o) => s + (o.netSettlement || 0), 0); const totalBOMCost = allOrders.reduce((s, o) => s + (o.orderTotalBomCost || 0), 0); const totalNetProfit = allOrders.reduce((s, o) => s + (o.orderNetProfit || 0), 0); const netProfitMargin = totalRevenue > 0 ? (totalNetProfit / totalRevenue) * 100 : 0; const totalOrders = allOrders.length; const cancelledOrders = allOrders.filter(o => o.status?.includes("ยกเลิก")).length; const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0; const completedOrders = totalOrders - cancelledOrders; const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0; return { monthly, statusBreakdown, paymentBreakdown, topProducts, summary: { totalRevenue, totalShipping, totalRefund, totalPlatformDisc, totalSellerDisc, netRevenue: totalRevenue - totalShipping, totalOrders, completedOrders, cancelledOrders, cancelRate, aov, totalCommission, totalAffiliate, totalSettlementAmount, totalBOMCost, totalNetProfit, netProfitMargin, }, }; }) // ── GET Analytics Overview (ALL shops combined) ────────────────────────── .get("/analytics-overview", async ({ query }) => { const dateFrom = query.dateFrom as string | undefined; const dateTo = query.dateTo as string | undefined; const where: any = {}; if (dateFrom || dateTo) { where.createdTime = {}; if (dateFrom) where.createdTime.gte = dateFrom; if (dateTo) where.createdTime.lte = dateTo + " 23:59"; } const allOrders = await prisma.tiktokOrder.findMany({ where, select: { orderId: true, shop: true, status: true, createdTime: true, orderAmount: true, shippingFee: true, refundAmount: true, items: { select: { quantity: true, platformDiscount: true, sellerDiscount: true, subtotalAfterDiscount: true } }, }, }); // Per-shop summary const shopMap = new Map(); for (const o of allOrders) { if (!shopMap.has(o.shop)) { shopMap.set(o.shop, { shop: o.shop, orders: 0, completed: 0, cancelled: 0, revenue: 0, shippingFee: 0, platformDiscount: 0, sellerDiscount: 0, items: 0 }); } const s = shopMap.get(o.shop)!; s.orders += 1; s.revenue += o.orderAmount; s.shippingFee += o.shippingFee; s.platformDiscount += o.items.reduce((acc, i) => acc + i.platformDiscount, 0); s.sellerDiscount += o.items.reduce((acc, i) => acc + i.sellerDiscount, 0); s.items += o.items.reduce((acc, i) => acc + i.quantity, 0); if (o.status.includes("ยกเลิก") || o.status.includes("cancel")) s.cancelled += 1; else s.completed += 1; } const shops = Array.from(shopMap.values()); // Monthly stacked by shop const monthShopMap = new Map>(); for (const o of allOrders) { const month = (o.createdTime ?? "").slice(0, 7) || "Unknown"; if (!monthShopMap.has(month)) monthShopMap.set(month, new Map()); const sm = monthShopMap.get(month)!; if (!sm.has(o.shop)) sm.set(o.shop, { revenue: 0, orders: 0 }); const ms = sm.get(o.shop)!; ms.revenue += o.orderAmount; ms.orders += 1; } const shopKeys = Array.from(shopMap.keys()); const monthly = Array.from(monthShopMap.entries()) .map(([month, sm]) => { const row: any = { month }; for (const shop of shopKeys) { const d = sm.get(shop) ?? { revenue: 0, orders: 0 }; row[`${shop}_revenue`] = d.revenue; row[`${shop}_orders`] = d.orders; } row.totalRevenue = Array.from(sm.values()).reduce((s, v) => s + v.revenue, 0); row.totalOrders = Array.from(sm.values()).reduce((s, v) => s + v.orders, 0); row.revenue = row.totalRevenue; // Fallback for global view return row; }) .sort((a, b) => a.month.localeCompare(b.month)); // Top Products Global const filteredIds = allOrders.map(o => o.orderId); const allItems = filteredIds.length > 0 ? await prisma.tiktokOrderItem.findMany({ where: { orderId: { in: filteredIds } }, select: { productName: true, variation: true, quantity: true, subtotalAfterDiscount: true }, }) : []; const productMap = new Map(); for (const item of allItems) { const key = `${item.productName}__${item.variation ?? ""}`; if (!productMap.has(key)) productMap.set(key, { name: item.productName, variation: item.variation ?? "", qty: 0, revenue: 0 }); const p = productMap.get(key)!; p.qty += item.quantity; p.revenue += item.subtotalAfterDiscount; } const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10); // Global summary const totalRevenue = shops.reduce((s, sh) => s + sh.revenue, 0); const totalShipping = shops.reduce((s, sh) => s + sh.shippingFee, 0); const totalPlatformDisc = shops.reduce((s, sh) => s + sh.platformDiscount, 0); const totalSellerDisc = shops.reduce((s, sh) => s + sh.sellerDiscount, 0); const totalOrders = shops.reduce((s, sh) => s + sh.orders, 0); const completedOrders = shops.reduce((s, sh) => s + sh.completed, 0); const cancelledOrders = shops.reduce((s, sh) => s + sh.cancelled, 0); return { shops, monthly, topProducts, summary: { totalRevenue, totalShipping, totalPlatformDisc, totalSellerDisc, netRevenue: totalRevenue - totalShipping, totalOrders, completedOrders, cancelledOrders, cancelRate: totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0, aov: completedOrders > 0 ? totalRevenue / completedOrders : 0, } }; }) // ── GET Thailand Sales Map (77 Provinces & Top 20) ────────────────────── .get("/sales-map", async ({ query }) => { const shop = (query.shop as string) || "hom-aroma"; const dateFrom = query.dateFrom as string | undefined; const dateTo = query.dateTo as string | undefined; const where: any = {}; if (shop && shop !== "all") { where.shop = shop; } if (dateFrom || dateTo) { where.createdTime = {}; if (dateFrom) where.createdTime.gte = dateFrom; if (dateTo) where.createdTime.lte = dateTo + " 23:59"; } const orders = await prisma.tiktokOrder.findMany({ where, select: { orderId: true, province: true, district: true, orderAmount: true, status: true, createdTime: true, }, }); // Map each order to standard province const provSalesMap = new Map(); // Initialize with all 77 provinces 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, }); } let totalRevenue = 0; let totalOrders = orders.length; let unmappedOrders = 0; let unmappedRevenue = 0; for (const o of orders) { totalRevenue += o.orderAmount; const matched = findProvince(o.province); if (matched) { const p = provSalesMap.get(matched.nameTh)!; p.revenue += o.orderAmount; p.orders += 1; } else { unmappedOrders += 1; unmappedRevenue += o.orderAmount; } } const provinceList = Array.from(provSalesMap.values()); const activeProvinces = provinceList.filter(p => p.orders > 0); const coverageCount = activeProvinces.length; // Top 20 provinces by revenue const topProvinces = [...provinceList] .sort((a, b) => b.revenue - a.revenue) .slice(0, 20) .map((p, idx) => ({ rank: idx + 1, ...p, aov: p.orders > 0 ? p.revenue / p.orders : 0, })); // 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 }); } const r = regionMap.get(p.region)!; r.revenue += p.revenue; r.orders += p.orders; } const regionBreakdown = Array.from(regionMap.values()) .map(r => ({ ...r, percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0, })) .sort((a, b) => b.revenue - a.revenue); const topRegion = regionBreakdown[0] || { region: 'Northeast', regionTh: 'ภาคอีสาน', revenue: 0, orders: 0 }; const aov = totalOrders > 0 ? totalRevenue / totalOrders : 0; return { kpi: { totalRevenue, totalOrders, coverageCount, coverageTotal: 77, coveragePercent: (coverageCount / 77) * 100, aov, topRegion: { name: topRegion.regionTh, orders: topRegion.orders, revenue: topRegion.revenue, }, unmappedOrders, unmappedRevenue, }, provinces: provinceList, topProvinces, regionBreakdown, }; }) // ── GET District Level Details & Orders ────────────────────────────────── .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 shop = query.shop as string | undefined; const dateFrom = query.dateFrom as string | undefined; const dateTo = query.dateTo as string | undefined; const where: any = {}; if (shop && shop !== "all") { where.shop = shop; } if (dateFrom || dateTo) { where.createdTime = {}; if (dateFrom) where.createdTime.gte = dateFrom; if (dateTo) where.createdTime.lte = dateTo + " 23:59"; } // Fetch all orders matching filter const allOrders = await prisma.tiktokOrder.findMany({ where, include: { items: true, }, orderBy: { createdTime: "desc" }, }); // Filter in JS using findProvince to match variants const matchedOrders = allOrders.filter(o => { const p = findProvince(o.province); return p ? p.nameTh === provNameTh : (o.province?.includes(provNameTh) || provNameTh.includes(o.province || '')); }); // Group by cleaned district name const districtMap = new Map(); let provRevenue = 0; for (const o of matchedOrders) { provRevenue += o.orderAmount; const dName = cleanDistrictName(o.district) || "ไม่ระบุอำเภอ"; if (!districtMap.has(dName)) { districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0 }); } const d = districtMap.get(dName)!; d.revenue += o.orderAmount; d.orders += 1; } const districts = Array.from(districtMap.values()).sort((a, b) => b.revenue - a.revenue); return { province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: 'Central', regionTh: 'ภาคกลาง', code: '' }, summary: { totalRevenue: provRevenue, totalOrders: matchedOrders.length, districtCount: districts.length, }, districts, orders: matchedOrders.map(o => ({ orderId: o.orderId, 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, i) => s + i.quantity, 0), items: o.items.map(i => ({ productName: i.productName, variation: i.variation, sellerSku: i.sellerSku, quantity: i.quantity, subtotalAfterDiscount: i.subtotalAfterDiscount, unitOriginalPrice: i.unitOriginalPrice, })), })), }; });