1118 lines
45 KiB
TypeScript
1118 lines
45 KiB
TypeScript
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<string, any>();
|
|
for (const s of settlements) {
|
|
settlementMap.set(s.orderId, s);
|
|
}
|
|
|
|
// Index MarketplaceProducts
|
|
const mpExactMap = new Map<string, any>(); // shop:::productName:::variation:::sku
|
|
const mpSkuMap = new Map<string, any>(); // shop:::sku
|
|
const mpNameVarMap = new Map<string, any>(); // shop:::productName:::variation
|
|
const variantSkuMap = new Map<string, any>(); // 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<string, { meta: any; items: any[] }>();
|
|
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<string, {
|
|
month: string;
|
|
revenue: number;
|
|
orders: number;
|
|
cancelled: number;
|
|
items: number;
|
|
platformDiscount: number;
|
|
sellerDiscount: number;
|
|
commission: number;
|
|
affiliate: number;
|
|
bomCost: number;
|
|
netProfit: number;
|
|
settlement: number;
|
|
}>();
|
|
|
|
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<string, number>();
|
|
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<string, number>();
|
|
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<string, { name: string; sku: string; variation: string; qty: number; revenue: number }>();
|
|
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<string, {
|
|
shop: string; orders: number; completed: number; cancelled: number;
|
|
revenue: number; shippingFee: number; platformDiscount: number; sellerDiscount: number; items: number;
|
|
}>();
|
|
|
|
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<string, Map<string, { revenue: number; orders: number }>>();
|
|
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<string, { name: string; variation: string; qty: number; revenue: number }>();
|
|
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<string, { code: string; nameTh: string; nameEn: string; region: string; regionTh: string; revenue: number; orders: number }>();
|
|
|
|
// 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<string, { region: string; regionTh: string; revenue: number; orders: number }>();
|
|
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<string, { districtName: string; revenue: number; orders: number }>();
|
|
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,
|
|
})),
|
|
})),
|
|
};
|
|
});
|