import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; 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; }; export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) // ── Import (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) { // 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"]), }; await tx.tiktokOrder.upsert({ where: { orderId }, update: { ...orderData, items: { create: itemsData } }, create: { orderId, ...orderData, items: { create: itemsData } }, }); upserted++; } }, { timeout: 30000 }); console.log(`[TiktokOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`); } 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()) }) } ) // ── Clear Shop Data ─────────────────────────────────────────────────────── .delete("/clear", async ({ query, set }) => { try { const shop = (query.shop as string) || "hom-aroma"; const result = await prisma.tiktokOrder.deleteMany({ where: { shop } }); return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count }; } catch (error: any) { set.status = 500; return { success: false, message: error.message }; } }) // ── GET Single Order ────────────────────────────────────────────────────── .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" }; } 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 as string) || "hom-aroma"; const where = { shop }; const [items, total] = await Promise.all([ prisma.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, skip, take: limit }), prisma.tiktokOrder.count({ where }), ]); return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; }) // ── GET Summary ─────────────────────────────────────────────────────────── .get("/summary", async ({ query }) => { const shop = (query.shop as string) || "hom-aroma"; const where = { shop }; const [orderCount, agg, itemAgg] = await Promise.all([ prisma.tiktokOrder.count({ where }), prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true } }), prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }), ]); return { orderCount, totalAmount: agg._sum.orderAmount ?? 0, 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, }; }) // ── 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 allOrders = await prisma.tiktokOrder.findMany({ where, select: { orderId: true, status: true, subStatus: true, createdTime: true, orderAmount: true, shippingFee: true, refundAmount: true, taxes: true, paymentMethod: true, province: true, items: { select: { quantity: true, subtotalAfterDiscount: true, platformDiscount: true, sellerDiscount: true } }, }, }); // 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 }); const m = monthMap.get(month)!; m.orders += 1; m.revenue += o.orderAmount; m.platformDiscount += o.items.reduce((s, i) => s + i.platformDiscount, 0); m.sellerDiscount += o.items.reduce((s, i) => s + i.sellerDiscount, 0); if (o.status.includes("ยกเลิก") || o.status.includes("cancel")) 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) { 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); const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.sellerDiscount, 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, }, }; }) // ── 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, } }; });