Files
homaroma/apps/backend/src/routes/tiktokOrder.js
T
2026-06-15 08:24:07 -04:00

371 lines
18 KiB
JavaScript

import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
const toNum = (val) => {
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) => 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) => {
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;
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) {
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 || "hom-aroma";
const result = await prisma.tiktokOrder.deleteMany({ where: { shop } });
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
}
catch (error) {
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 || "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 || "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 || "hom-aroma";
const dateFrom = query.dateFrom;
const dateTo = query.dateTo;
const where = { 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;
const dateTo = query.dateTo;
const where = {};
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 = { 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,
}
};
});
//# sourceMappingURL=tiktokOrder.js.map