import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; import { adminOnly } from "../middleware/auth"; 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; }; 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 }; } export const shopeeRoute = new Elysia({ prefix: "/shopee" }) .use(adminOnly) // ── Import Orders ───────────────────────────────────────────────────────── .post( "/import/orders", async ({ body, set }) => { try { const { rows, shop } = body as { rows: any[]; shop?: string }; const shopName = shop || "hom-aroma"; console.log(`[Shopee] Received ${rows.length} raw rows for shop: ${shopName}`); const orderMap = new Map(); for (const row of rows) { const orderId = String(row["หมายเลขคำสั่งซื้อ"] || "").trim(); if (!orderId) continue; if (!orderMap.has(orderId)) orderMap.set(orderId, { meta: row, items: [] }); orderMap.get(orderId)!.items.push(row); } console.log(`[Shopee] Grouped into ${orderMap.size} unique orders`); const CHUNK_SIZE = 30; const entries = Array.from(orderMap.entries()); let upserted = 0; for (let i = 0; i < entries.length; i += CHUNK_SIZE) { const chunk = entries.slice(i, i + CHUNK_SIZE); await prisma.$transaction(async (tx) => { for (const [orderId, { meta, items }] of chunk) { await tx.shopeeOrderItem.deleteMany({ where: { orderId } }); const itemsData = items.map((item) => ({ parentSku: String(item["เลขอ้างอิง Parent SKU"] || ""), productName: String(item["ชื่อสินค้า"] || ""), skuRef: String(item["เลขอ้างอิง SKU (SKU Reference No.)"] || ""), variantName: String(item["ชื่อตัวเลือก"] || ""), originalPrice: toNum(item["ราคาตั้งต้น"]), dealPrice: toNum(item["ราคาขาย"]), quantity: Math.max(1, Math.round(toNum(item["จำนวน"]))), totalPayment: toNum(item["ยอดชำระเงิน"]), rawData: item, })); const orderData = { shop: shopName, status: String(meta["สถานะการสั่งซื้อ"] || ""), buyerUsername: String(meta["ชื่อผู้ใช้ (ผู้ซื้อ)"] || ""), orderCreatedAt: String(meta["วันที่ทำการสั่งซื้อ"] || ""), paymentTime: String(meta["เวลาการชำระสินค้า"] || ""), paymentMethod: String(meta["ช่องทางการชำระเงิน"] || ""), trackingNo: String(meta["*หมายเลขติดตามพัสดุ"] || ""), totalAmount: toNum(meta["จำนวนเงินทั้งหมด"]), buyerPaidAmount: toNum(meta["ราคาสินค้าที่ชำระโดยผู้ซื้อ (THB)"]), shippingFee: toNum(meta["ค่าจัดส่งที่ชำระโดยผู้ซื้อ"]), commission: toNum(meta["ค่าคอมมิชชั่น"]), completedAt: String(meta["เวลาที่ทำการสั่งซื้อสำเร็จ"] || ""), }; await tx.shopeeOrder.upsert({ where: { orderId }, update: { ...orderData, items: { create: itemsData } }, create: { orderId, ...orderData, items: { create: itemsData } }, }); upserted++; } }, { timeout: 30000 }); console.log(`[Shopee] Progress: ${Math.min(i + CHUNK_SIZE, entries.length)}/${entries.length}`); } console.log(`[Shopee] ✅ Done — ${upserted} orders upserted`); return { success: true, message: `นำเข้าสำเร็จ ${upserted} คำสั่งซื้อ (จาก ${rows.length} แถว)`, ordersCount: upserted, rowsCount: rows.length }; } catch (error: any) { console.error(`[Shopee] ❌ 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()) }) } ) // ── GET Single Order ────────────────────────────────────────────────────── .get("/orders/:orderId", async ({ params, set }) => { const { orderId } = params; const order = await prisma.shopeeOrder.findUnique({ where: { orderId }, include: { items: true } }); if (!order) { set.status = 404; return { error: "Order not found" }; } return order; }) // ── GET Orders (paginated) ──────────────────────────────────────────────── .get("/orders", async ({ query }) => { const page = Number(query.page ?? 1); const limit = Number(query.limit ?? 30); const skip = (page - 1) * limit; const shop = query.shop || "hom-aroma"; const where = { shop }; const [items, total] = await Promise.all([ prisma.shopeeOrder.findMany({ where, include: { items: true }, orderBy: { orderCreatedAt: "desc" }, skip, take: limit }), prisma.shopeeOrder.count({ where }), ]); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; }) // ── GET Analytics (with date range filter) ─────────────────────────────── .get("/analytics", async ({ query }) => { const shop = query.shop || "hom-aroma"; const dateFrom = query.dateFrom as string | undefined; // "YYYY-MM-DD" const dateTo = query.dateTo as string | undefined; // Build where — orderCreatedAt stored as "YYYY-MM-DD HH:mm" string const where: any = { shop }; if (dateFrom || dateTo) { where.orderCreatedAt = {}; if (dateFrom) where.orderCreatedAt.gte = dateFrom; if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; } const allOrders = await prisma.shopeeOrder.findMany({ where, select: { orderId: true, status: true, orderCreatedAt: true, totalAmount: true, buyerPaidAmount: true, commission: true, shippingFee: true, paymentMethod: true, items: { select: { quantity: true, totalPayment: true } }, }, }); // Monthly aggregation const monthMap = new Map(); for (const o of allOrders) { const month = (o.orderCreatedAt ?? "").slice(0, 7) || "Unknown"; if (!monthMap.has(month)) monthMap.set(month, { month, revenue: 0, orders: 0, commission: 0, cancelled: 0, items: 0 }); const m = monthMap.get(month)!; m.orders += 1; m.revenue += o.totalAmount; m.commission += o.commission; if (o.status.includes("ยกเลิก")) m.cancelled += 1; m.items += o.items.reduce((s, i) => s + i.quantity, 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) { let status = o.status; if (status.includes("สำเร็จแล้ว")) status = "สำเร็จแล้ว"; else if (status.includes("ได้รับสินค้าแล้ว")) status = "ได้รับสินค้าแล้ว"; else if (status.includes("ยกเลิก")) status = "ยกเลิก"; statusMap.set(status, (statusMap.get(status) ?? 0) + 1); } const statusBreakdown = Array.from(statusMap.entries()) .map(([status, count]) => ({ status, count })) .sort((a, b) => b.count - a.count); // Payment method breakdown const paymentMap = new Map(); for (const o of allOrders) { const key = o.paymentMethod || "Unknown"; if (!paymentMap.has(key)) paymentMap.set(key, { count: 0, revenue: 0 }); const p = paymentMap.get(key)!; p.count += 1; p.revenue += o.buyerPaidAmount; } const paymentBreakdown = Array.from(paymentMap.entries()) .map(([method, v]) => ({ method, ...v })) .sort((a, b) => b.count - a.count); // Top products (from filtered order IDs) const filteredOrderIds = allOrders.map(o => o.orderId); const allItems = filteredOrderIds.length > 0 ? await prisma.shopeeOrderItem.findMany({ where: { orderId: { in: filteredOrderIds } }, select: { productName: true, variantName: true, quantity: true, totalPayment: true, dealPrice: true }, }) : []; const productMap = new Map(); for (const item of allItems) { const key = `${item.productName}__${item.variantName ?? ""}`; if (!productMap.has(key)) productMap.set(key, { name: item.productName, variant: item.variantName ?? "", qty: 0, revenue: 0, orders: 0 }); const p = productMap.get(key)!; p.qty += item.quantity; p.revenue += item.totalPayment; p.orders += 1; } 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.totalAmount, 0); const totalCommission = allOrders.reduce((s, o) => s + o.commission, 0); const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0); const totalOrders = allOrders.length; const completedOrders = allOrders.filter(o => !o.status.includes("ยกเลิก")).length; const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length; const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0; const netRevenue = totalRevenue - totalCommission - totalShipping; const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0; return { monthly, statusBreakdown, paymentBreakdown, topProducts, summary: { totalRevenue, totalCommission, totalShipping, netRevenue, totalOrders, completedOrders, cancelledOrders, cancelRate, aov, commissionRate: totalRevenue > 0 ? (totalCommission / totalRevenue) * 100 : 0, }, }; }) // ── 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.orderCreatedAt = {}; if (dateFrom) where.orderCreatedAt.gte = dateFrom; if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; } const allOrders = await prisma.shopeeOrder.findMany({ where, select: { orderId: true, shop: true, status: true, orderCreatedAt: true, totalAmount: true, buyerPaidAmount: true, commission: true, shippingFee: true, items: { select: { quantity: true, totalPayment: 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, commission: 0, shippingFee: 0, items: 0 }); } const s = shopMap.get(o.shop)!; s.orders += 1; s.revenue += o.totalAmount; s.commission += o.commission; s.shippingFee += o.shippingFee; s.items += o.items.reduce((acc, i) => acc + i.quantity, 0); if (o.status.includes("ยกเลิก")) s.cancelled += 1; else s.completed += 1; } const byShop = Array.from(shopMap.values()).map(s => ({ ...s, net: s.revenue - s.commission - s.shippingFee, cancelRate: s.orders > 0 ? (s.cancelled / s.orders) * 100 : 0, aov: s.completed > 0 ? s.revenue / s.completed : 0, })).sort((a, b) => b.revenue - a.revenue); // Monthly stacked by shop const monthShopMap = new Map>(); for (const o of allOrders) { const month = (o.orderCreatedAt ?? "").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, commission: 0 }); const ms = sm.get(o.shop)!; ms.revenue += o.totalAmount; ms.orders += 1; ms.commission += o.commission; } const shops = Array.from(shopMap.keys()); const monthly = Array.from(monthShopMap.entries()) .map(([month, sm]) => { const row: any = { month }; for (const shop of shops) { const d = sm.get(shop) ?? { revenue: 0, orders: 0, commission: 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); return row; }) .sort((a, b) => a.month.localeCompare(b.month)); // Top products across all shops (filtered by date range via orderId) const filteredOrderIds = allOrders.map(o => o.orderId); const allItems = filteredOrderIds.length > 0 ? await prisma.shopeeOrderItem.findMany({ where: { orderId: { in: filteredOrderIds } }, select: { productName: true, variantName: true, quantity: true, totalPayment: true, order: { select: { shop: true } } }, }) : []; const productMap = new Map(); for (const item of allItems) { const key = `${item.productName}__${item.variantName ?? ""}`; if (!productMap.has(key)) productMap.set(key, { name: item.productName, variant: item.variantName ?? "", qty: 0, revenue: 0 }); const p = productMap.get(key)!; p.qty += item.quantity; p.revenue += item.totalPayment; } const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10); // Grand totals const totalRevenue = allOrders.reduce((s, o) => s + o.totalAmount, 0); const totalCommission = allOrders.reduce((s, o) => s + o.commission, 0); const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0); const totalOrders = allOrders.length; const totalCancelled = allOrders.filter(o => o.status.includes("ยกเลิก")).length; return { byShop, monthly, topProducts, shops, totals: { totalRevenue, totalCommission, totalShipping, netRevenue: totalRevenue - totalCommission - totalShipping, totalOrders, totalCancelled, cancelRate: totalOrders > 0 ? (totalCancelled / totalOrders) * 100 : 0, aov: (totalOrders - totalCancelled) > 0 ? totalRevenue / (totalOrders - totalCancelled) : 0, commissionRate: totalRevenue > 0 ? (totalCommission / totalRevenue) * 100 : 0, }, }; }) // ── GET Summary ─────────────────────────────────────────────────────────── .get("/summary", async ({ query }) => { const shop = query.shop || "hom-aroma"; const where = { shop }; const [orderCount, agg, itemAgg] = await Promise.all([ prisma.shopeeOrder.count({ where }), prisma.shopeeOrder.aggregate({ where, _sum: { totalAmount: true, buyerPaidAmount: true, commission: true, shippingFee: true } }), prisma.shopeeOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, totalPayment: true } }), ]); return { orderCount, totalAmount: agg._sum.totalAmount ?? 0, buyerPaidAmount: agg._sum.buyerPaidAmount ?? 0, totalCommission: agg._sum.commission ?? 0, totalShipping: agg._sum.shippingFee ?? 0, totalItems: itemAgg._sum.quantity ?? 0, totalItemRevenue: itemAgg._sum.totalPayment ?? 0, }; }) // ── GET Thailand Sales Map (Shopee 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.orderCreatedAt = {}; if (dateFrom) where.orderCreatedAt.gte = dateFrom; if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; } const orders = await prisma.shopeeOrder.findMany({ where, include: { items: { select: { rawData: true, }, take: 1, }, }, }); 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, }); } let totalRevenue = 0; let totalOrders = orders.length; let unmappedOrders = 0; let unmappedRevenue = 0; for (const o of orders) { totalRevenue += o.totalAmount; const { province } = extractShopeeAddress(o); const matched = findProvince(province); if (matched) { const p = provSalesMap.get(matched.nameTh)!; p.revenue += o.totalAmount; p.orders += 1; } else { unmappedOrders += 1; unmappedRevenue += o.totalAmount; } } 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: 'Central', 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 (Shopee) ────────────────────────── .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.orderCreatedAt = {}; if (dateFrom) where.orderCreatedAt.gte = dateFrom; if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; } const allOrders = await prisma.shopeeOrder.findMany({ where, include: { items: true, }, orderBy: { orderCreatedAt: "desc" }, }); // Filter in JS by matching extracted address const matchedOrders = allOrders.filter(o => { const { province } = extractShopeeAddress(o); const p = findProvince(province); return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || '')); }); const districtMap = new Map(); let provRevenue = 0; for (const o of matchedOrders) { provRevenue += o.totalAmount; const { district } = extractShopeeAddress(o); const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ"; if (!districtMap.has(dName)) { districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0 }); } const d = districtMap.get(dName)!; d.revenue += o.totalAmount; 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 => { const { district, province } = extractShopeeAddress(o); return { orderId: o.orderId, shop: o.shop, status: o.status, buyerUsername: o.buyerUsername, createdTime: o.orderCreatedAt, orderAmount: o.totalAmount, shippingFee: o.shippingFee, province: province || 'ไม่ระบุจังหวัด', district: cleanDistrictName(district) || district || 'ไม่ระบุอำเภอ', itemsCount: o.items.reduce((s, i) => s + i.quantity, 0), items: o.items.map(i => ({ productName: i.productName, variation: i.variantName, sellerSku: i.skuRef || i.parentSku, quantity: i.quantity, subtotalAfterDiscount: i.totalPayment, unitOriginalPrice: i.originalPrice || i.dealPrice, })), }; }), }; });