Files
homaroma/apps/backend/src/routes/businessAnalytics.ts
T
2026-09-25 04:55:06 +07:00

2793 lines
96 KiB
TypeScript

import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces";
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 };
}
function extractLazadaAddress(order: any): { province: string; district: string } {
const reg = String(order.shippingRegion || "").trim();
const city = String(order.shippingCity || "").trim();
const postcode = String(order.shippingPostCode || "").trim();
// Try matching region or city first as province name
let matchedProv = findProvince(reg) || findProvince(city);
// Fallback to 5-digit/2-digit Postcode lookup
if (!matchedProv && postcode) {
matchedProv = findProvinceByPostcode(postcode);
}
const provinceName = matchedProv ? matchedProv.nameTh : (reg || city);
const districtName = cleanDistrictName(city) || cleanDistrictName(reg) || (postcode ? `รหัส ปณ. ${postcode}` : "ไม่ระบุอำเภอ");
return { province: provinceName, district: districtName };
}
// Helper to filter out / include cancelled orders
function buildStatusFilter(statusFilter: string = "completed") {
// 'completed' (default, excludes cancelled) | 'all' (includes all) | 'cancelled' (only cancelled)
if (statusFilter === "all") {
return {
tiktok: {},
shopee: {},
lazada: {},
};
}
if (statusFilter === "cancelled") {
return {
tiktok: { status: { contains: "ยกเลิก" } },
shopee: { status: { contains: "ยกเลิก" } },
lazada: { status: { in: ["canceled", "cancelled", "ยกเลิกแล้ว", "Package Returned", "Lost by 3PL"] } },
};
}
// Default 'completed': exclude cancelled
return {
tiktok: { status: { not: { contains: "ยกเลิก" } } },
shopee: { status: { not: { contains: "ยกเลิก" } } },
lazada: { status: { notIn: ["canceled", "cancelled", "ยกเลิกแล้ว", "Package Returned", "Lost by 3PL"] } },
};
}
export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" })
// ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ──────────
.get("/sales-map", async ({ query }) => {
const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada'
const statusFilter = (query.statusFilter as string) || "completed"; // 'completed' | 'all' | 'cancelled'
const sf = buildStatusFilter(statusFilter);
const tiktokShop = query.tiktokShop as string | undefined;
const shopeeShop = query.shopeeShop as string | undefined;
const lazadaShop = query.lazadaShop as string | undefined;
const dateFrom = query.dateFrom as string | undefined;
const dateTo = query.dateTo as string | undefined;
// 1. Fetch TikTok Orders if applicable
let tiktokOrders: any[] = [];
if (channel === "all" || channel === "tiktok") {
const where: any = { ...sf.tiktok };
if (tiktokShop && tiktokShop !== "all") {
where.shop = tiktokShop;
}
if (dateFrom || dateTo) {
where.createdTime = {};
if (dateFrom) where.createdTime.gte = dateFrom;
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
tiktokOrders = await prisma.tiktokOrder.findMany({
where,
select: {
orderId: true,
shop: true,
province: true,
district: true,
orderAmount: true,
createdTime: true,
},
});
}
// 2. Fetch Shopee Orders if applicable
let shopeeOrders: any[] = [];
if (channel === "all" || channel === "shopee") {
const where: any = { ...sf.shopee };
if (shopeeShop && shopeeShop !== "all") {
where.shop = shopeeShop;
}
if (dateFrom || dateTo) {
where.orderCreatedAt = {};
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
}
shopeeOrders = await prisma.shopeeOrder.findMany({
where,
include: {
items: {
take: 1,
select: { rawData: true },
},
},
});
}
// 3. Fetch Lazada Orders if applicable
let lazadaOrders: any[] = [];
if (channel === "all" || channel === "lazada") {
const where: any = { ...sf.lazada };
if (lazadaShop && lazadaShop !== "all") {
where.shop = lazadaShop;
}
if (dateFrom || dateTo) {
where.createTime = {};
if (dateFrom) where.createTime.gte = dateFrom;
if (dateTo) where.createTime.lte = dateTo + " 23:59";
}
lazadaOrders = await prisma.lazadaOrder.findMany({
where,
select: {
orderNumber: true,
shop: true,
shippingRegion: true,
shippingCity: true,
shippingPostCode: true,
orderAmount: true,
createTime: true,
},
});
}
// Initialize 77 provinces
const provSalesMap = new Map<string, {
code: string;
nameTh: string;
nameEn: string;
region: string;
regionTh: string;
revenue: number;
orders: number;
tiktokRevenue: number;
tiktokOrders: number;
shopeeRevenue: number;
shopeeOrders: number;
lazadaRevenue: number;
lazadaOrders: number;
}>();
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,
tiktokRevenue: 0,
tiktokOrders: 0,
shopeeRevenue: 0,
shopeeOrders: 0,
lazadaRevenue: 0,
lazadaOrders: 0,
});
}
let totalRevenue = 0;
let totalOrders = 0;
let totalTiktokRevenue = 0;
let totalTiktokOrders = 0;
let totalShopeeRevenue = 0;
let totalShopeeOrders = 0;
let totalLazadaRevenue = 0;
let totalLazadaOrders = 0;
let unmappedOrders = 0;
let unmappedRevenue = 0;
// Aggregate TikTok
for (const o of tiktokOrders) {
const amount = o.orderAmount || 0;
totalRevenue += amount;
totalOrders += 1;
totalTiktokRevenue += amount;
totalTiktokOrders += 1;
const matchedProv = findProvince(o.province);
if (matchedProv && provSalesMap.has(matchedProv.nameTh)) {
const p = provSalesMap.get(matchedProv.nameTh)!;
p.revenue += amount;
p.orders += 1;
p.tiktokRevenue += amount;
p.tiktokOrders += 1;
} else {
unmappedOrders += 1;
unmappedRevenue += amount;
}
}
// Aggregate Shopee
for (const o of shopeeOrders) {
const amount = o.totalAmount || 0;
totalRevenue += amount;
totalOrders += 1;
totalShopeeRevenue += amount;
totalShopeeOrders += 1;
const { province } = extractShopeeAddress(o);
const matchedProv = findProvince(province);
if (matchedProv && provSalesMap.has(matchedProv.nameTh)) {
const p = provSalesMap.get(matchedProv.nameTh)!;
p.revenue += amount;
p.orders += 1;
p.shopeeRevenue += amount;
p.shopeeOrders += 1;
} else {
unmappedOrders += 1;
unmappedRevenue += amount;
}
}
// Aggregate Lazada
for (const o of lazadaOrders) {
const amount = o.orderAmount || 0;
totalRevenue += amount;
totalOrders += 1;
totalLazadaRevenue += amount;
totalLazadaOrders += 1;
const { province } = extractLazadaAddress(o);
const matchedProv = findProvince(province);
if (matchedProv && provSalesMap.has(matchedProv.nameTh)) {
const p = provSalesMap.get(matchedProv.nameTh)!;
p.revenue += amount;
p.orders += 1;
p.lazadaRevenue += amount;
p.lazadaOrders += 1;
} else {
unmappedOrders += 1;
unmappedRevenue += amount;
}
}
const provinceList = Array.from(provSalesMap.values()).map(p => ({
...p,
tiktokShare: p.revenue > 0 ? (p.tiktokRevenue / p.revenue) * 100 : 0,
shopeeShare: p.revenue > 0 ? (p.shopeeRevenue / p.revenue) * 100 : 0,
lazadaShare: p.revenue > 0 ? (p.lazadaRevenue / p.revenue) * 100 : 0,
aov: p.orders > 0 ? p.revenue / p.orders : 0,
}));
const activeProvinces = provinceList.filter(p => p.orders > 0);
const coverageCount = activeProvinces.length;
// Top 20 provinces by total revenue
const topProvinces = [...provinceList]
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 20)
.map((p, idx) => ({
rank: idx + 1,
...p,
}));
// Region Breakdown
const regionMap = new Map<string, {
region: string;
regionTh: string;
revenue: number;
orders: number;
tiktokRevenue: number;
tiktokOrders: number;
shopeeRevenue: number;
shopeeOrders: number;
lazadaRevenue: number;
lazadaOrders: 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,
tiktokRevenue: 0,
tiktokOrders: 0,
shopeeRevenue: 0,
shopeeOrders: 0,
lazadaRevenue: 0,
lazadaOrders: 0,
});
}
const r = regionMap.get(p.region)!;
r.revenue += p.revenue;
r.orders += p.orders;
r.tiktokRevenue += p.tiktokRevenue;
r.tiktokOrders += p.tiktokOrders;
r.shopeeRevenue += p.shopeeRevenue;
r.shopeeOrders += p.shopeeOrders;
r.lazadaRevenue += p.lazadaRevenue;
r.lazadaOrders += p.lazadaOrders;
}
const regionBreakdown = Array.from(regionMap.values())
.map(r => ({
...r,
percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0,
tiktokShare: r.revenue > 0 ? (r.tiktokRevenue / r.revenue) * 100 : 0,
shopeeShare: r.revenue > 0 ? (r.shopeeRevenue / r.revenue) * 100 : 0,
lazadaShare: r.revenue > 0 ? (r.lazadaRevenue / r.revenue) * 100 : 0,
}))
.sort((a, b) => b.revenue - a.revenue);
const topRegion = regionBreakdown[0] || {
region: 'Central',
regionTh: 'ภาคกลาง',
revenue: 0,
orders: 0,
tiktokRevenue: 0,
shopeeRevenue: 0,
lazadaRevenue: 0,
};
return {
kpi: {
totalRevenue,
totalOrders,
totalTiktokRevenue,
totalTiktokOrders,
tiktokSharePercent: totalRevenue > 0 ? (totalTiktokRevenue / totalRevenue) * 100 : 0,
totalShopeeRevenue,
totalShopeeOrders,
shopeeSharePercent: totalRevenue > 0 ? (totalShopeeRevenue / totalRevenue) * 100 : 0,
totalLazadaRevenue,
totalLazadaOrders,
lazadaSharePercent: totalRevenue > 0 ? (totalLazadaRevenue / totalRevenue) * 100 : 0,
coverageCount,
coverageTotal: 77,
coveragePercent: (coverageCount / 77) * 100,
aov: totalOrders > 0 ? totalRevenue / totalOrders : 0,
tiktokAov: totalTiktokOrders > 0 ? totalTiktokRevenue / totalTiktokOrders : 0,
shopeeAov: totalShopeeOrders > 0 ? totalShopeeRevenue / totalShopeeOrders : 0,
lazadaAov: totalLazadaOrders > 0 ? totalLazadaRevenue / totalLazadaOrders : 0,
topRegion: {
name: topRegion.regionTh,
orders: topRegion.orders,
revenue: topRegion.revenue,
tiktokRevenue: topRegion.tiktokRevenue,
shopeeRevenue: topRegion.shopeeRevenue,
lazadaRevenue: topRegion.lazadaRevenue,
},
unmappedOrders,
unmappedRevenue,
},
provinces: provinceList,
topProvinces,
regionBreakdown,
};
})
// ── GET District Level Details & Orders (Omnichannel: TT + SP + LAZ) ───────
.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 channel = (query.channel as string) || "all";
const statusFilter = (query.statusFilter as string) || "completed";
const sf = buildStatusFilter(statusFilter);
const tiktokShop = query.tiktokShop as string | undefined;
const shopeeShop = query.shopeeShop as string | undefined;
const lazadaShop = query.lazadaShop as string | undefined;
const dateFrom = query.dateFrom as string | undefined;
const dateTo = query.dateTo as string | undefined;
// 1. Query TikTok Orders
let tiktokMatched: any[] = [];
if (channel === "all" || channel === "tiktok") {
const where: any = { ...sf.tiktok };
if (tiktokShop && tiktokShop !== "all") {
where.shop = tiktokShop;
}
if (dateFrom || dateTo) {
where.createdTime = {};
if (dateFrom) where.createdTime.gte = dateFrom;
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
const allTt = await prisma.tiktokOrder.findMany({
where,
include: { items: true },
orderBy: { createdTime: "desc" },
});
tiktokMatched = allTt.filter(o => {
const p = findProvince(o.province);
return p ? p.nameTh === provNameTh : (o.province?.includes(provNameTh) || provNameTh.includes(o.province || ''));
});
}
// 2. Query Shopee Orders
let shopeeMatched: any[] = [];
if (channel === "all" || channel === "shopee") {
const where: any = { ...sf.shopee };
if (shopeeShop && shopeeShop !== "all") {
where.shop = shopeeShop;
}
if (dateFrom || dateTo) {
where.orderCreatedAt = {};
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
}
const allSp = await prisma.shopeeOrder.findMany({
where,
include: { items: true },
orderBy: { orderCreatedAt: "desc" },
});
shopeeMatched = allSp.filter(o => {
const { province } = extractShopeeAddress(o);
const p = findProvince(province);
return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || ''));
});
}
// 3. Query Lazada Orders
let lazadaMatched: any[] = [];
if (channel === "all" || channel === "lazada") {
const where: any = { ...sf.lazada };
if (lazadaShop && lazadaShop !== "all") {
where.shop = lazadaShop;
}
if (dateFrom || dateTo) {
where.createTime = {};
if (dateFrom) where.createTime.gte = dateFrom;
if (dateTo) where.createTime.lte = dateTo + " 23:59";
}
const allLaz = await prisma.lazadaOrder.findMany({
where,
include: { items: true },
orderBy: { createTime: "desc" },
});
lazadaMatched = allLaz.filter(o => {
const { province } = extractLazadaAddress(o);
const p = findProvince(province);
return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || ''));
});
}
// Aggregate by District
const districtMap = new Map<string, {
districtName: string;
revenue: number;
orders: number;
tiktokRevenue: number;
tiktokOrders: number;
shopeeRevenue: number;
shopeeOrders: number;
lazadaRevenue: number;
lazadaOrders: number;
}>();
let provRevenue = 0;
let provTiktokRevenue = 0;
let provShopeeRevenue = 0;
let provLazadaRevenue = 0;
for (const o of tiktokMatched) {
const amount = o.orderAmount || 0;
provRevenue += amount;
provTiktokRevenue += amount;
const dName = cleanDistrictName(o.district) || "ไม่ระบุอำเภอ";
if (!districtMap.has(dName)) {
districtMap.set(dName, {
districtName: dName,
revenue: 0,
orders: 0,
tiktokRevenue: 0,
tiktokOrders: 0,
shopeeRevenue: 0,
shopeeOrders: 0,
lazadaRevenue: 0,
lazadaOrders: 0,
});
}
const d = districtMap.get(dName)!;
d.revenue += amount;
d.orders += 1;
d.tiktokRevenue += amount;
d.tiktokOrders += 1;
}
for (const o of shopeeMatched) {
const amount = o.totalAmount || 0;
provRevenue += amount;
provShopeeRevenue += amount;
const { district } = extractShopeeAddress(o);
const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ";
if (!districtMap.has(dName)) {
districtMap.set(dName, {
districtName: dName,
revenue: 0,
orders: 0,
tiktokRevenue: 0,
tiktokOrders: 0,
shopeeRevenue: 0,
shopeeOrders: 0,
lazadaRevenue: 0,
lazadaOrders: 0,
});
}
const d = districtMap.get(dName)!;
d.revenue += amount;
d.orders += 1;
d.shopeeRevenue += amount;
d.shopeeOrders += 1;
}
for (const o of lazadaMatched) {
const amount = o.orderAmount || 0;
provRevenue += amount;
provLazadaRevenue += amount;
const { district } = extractLazadaAddress(o);
const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ";
if (!districtMap.has(dName)) {
districtMap.set(dName, {
districtName: dName,
revenue: 0,
orders: 0,
tiktokRevenue: 0,
tiktokOrders: 0,
shopeeRevenue: 0,
shopeeOrders: 0,
lazadaRevenue: 0,
lazadaOrders: 0,
});
}
const d = districtMap.get(dName)!;
d.revenue += amount;
d.orders += 1;
d.lazadaRevenue += amount;
d.lazadaOrders += 1;
}
const districts = Array.from(districtMap.values()).sort((a, b) => b.revenue - a.revenue);
// Normalize unified orders list
const unifiedOrders = [
...tiktokMatched.map(o => ({
orderId: o.orderId,
channel: "tiktok" as const,
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: number, i: any) => s + i.quantity, 0),
items: o.items.map((i: any) => ({
productName: i.productName,
variation: i.variation,
sellerSku: i.sellerSku,
quantity: i.quantity,
subtotalAfterDiscount: i.subtotalAfterDiscount,
unitOriginalPrice: i.unitOriginalPrice,
})),
})),
...shopeeMatched.map(o => {
const { district, province } = extractShopeeAddress(o);
return {
orderId: o.orderId,
channel: "shopee" as const,
shop: o.shop,
status: o.status,
subStatus: null,
buyerUsername: o.buyerUsername,
createdTime: o.orderCreatedAt,
orderAmount: o.totalAmount,
shippingFee: o.shippingFee,
province: province || "ไม่ระบุจังหวัด",
district: cleanDistrictName(district) || district || "ไม่ระบุอำเภอ",
itemsCount: o.items.reduce((s: number, i: any) => s + i.quantity, 0),
items: o.items.map((i: any) => ({
productName: i.productName,
variation: i.variantName,
sellerSku: i.skuRef || i.parentSku,
quantity: i.quantity,
subtotalAfterDiscount: i.totalPayment,
unitOriginalPrice: i.originalPrice || i.dealPrice,
})),
};
}),
...lazadaMatched.map(o => {
const { district, province } = extractLazadaAddress(o);
return {
orderId: o.orderNumber,
channel: "lazada" as const,
shop: o.shop,
status: o.status,
subStatus: null,
buyerUsername: o.customerName || "ลูกค้า Lazada",
createdTime: o.createTime,
orderAmount: o.orderAmount,
shippingFee: o.shippingFee,
province: province || "ไม่ระบุจังหวัด",
district: cleanDistrictName(district) || district || "ไม่ระบุอำเภอ",
itemsCount: o.items.length,
items: o.items.map((i: any) => ({
productName: i.itemName,
variation: i.variation,
sellerSku: i.sellerSku || i.lazadaSku,
quantity: 1,
subtotalAfterDiscount: i.paidPrice,
unitOriginalPrice: i.unitPrice || i.paidPrice,
})),
};
}),
].sort((a, b) => String(b.createdTime || "").localeCompare(String(a.createdTime || "")));
return {
province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: "Central", regionTh: "ภาคกลาง", code: "" },
summary: {
totalRevenue: provRevenue,
totalOrders: unifiedOrders.length,
tiktokRevenue: provTiktokRevenue,
tiktokOrders: tiktokMatched.length,
shopeeRevenue: provShopeeRevenue,
shopeeOrders: shopeeMatched.length,
lazadaRevenue: provLazadaRevenue,
lazadaOrders: lazadaMatched.length,
districtCount: districts.length,
},
districts,
orders: unifiedOrders,
};
})
// ── 3. GET Master Products & Variants List (For Selection Dropdown) ─────────
.get("/products-list", async () => {
const products = await prisma.product.findMany({
where: { isDeleted: false },
include: {
category: { select: { id: true, name: true } },
brand: { select: { id: true, name: true } },
variants: {
include: {
marketplaceProducts: {
select: {
id: true,
channel: true,
shop: true,
productName: true,
variation: true,
marketplaceSku: true,
totalSoldQty: true,
totalRevenue: true,
},
},
},
},
},
orderBy: { name: "asc" },
});
const formatted = products.map((p) => {
let totalMappedMarketplaceCount = 0;
let totalSoldUnits = 0;
let totalRevenue = 0;
const variants = p.variants.map((v) => {
const mappedCount = v.marketplaceProducts.length;
totalMappedMarketplaceCount += mappedCount;
const vSold = v.marketplaceProducts.reduce((s, mp) => s + mp.totalSoldQty, 0);
const vRev = v.marketplaceProducts.reduce((s, mp) => s + mp.totalRevenue, 0);
totalSoldUnits += vSold;
totalRevenue += vRev;
const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
return {
id: v.id,
sku: v.sku || "",
barcode: v.barcode || "",
price: v.price || 0,
stock: v.stock || 0,
optionsStr,
displayName: `${optionsStr || "Default"} ${v.sku ? `[${v.sku}]` : ""}`.trim(),
mappedMarketplaceCount: mappedCount,
soldUnits: vSold,
revenue: vRev,
};
});
return {
id: p.id,
name: p.name || "Unnamed Product",
category: p.category?.name || "",
brand: p.brand?.name || "",
totalMappedMarketplaceCount,
totalSoldUnits,
totalRevenue,
variants,
};
});
return { products: formatted };
})
// ── 4. GET Product Geographical Sales Map (77 Provinces) ────────────────────
.get("/sales-map-products", async ({ query }) => {
const productId = query.productId ? parseInt(query.productId as string, 10) : undefined;
const variantId = query.variantId ? parseInt(query.variantId as string, 10) : undefined;
const channel = (query.channel as string) || "all";
const statusFilter = (query.statusFilter as string) || "completed";
const sf = buildStatusFilter(statusFilter);
const dateFrom = query.dateFrom as string | undefined;
const dateTo = query.dateTo as string | undefined;
const metric = (query.metric as string) || "revenue";
// 1. Resolve master variants to filter
let targetVariantIds: number[] = [];
let productInfo: any = null;
let variantInfo: any = null;
if (variantId) {
targetVariantIds = [variantId];
const v = await prisma.productVariant.findUnique({
where: { id: variantId },
include: { product: { include: { category: true } } },
});
if (v) {
const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
variantInfo = {
id: v.id,
sku: v.sku,
optionsStr,
productName: v.product?.name,
categoryName: v.product?.category?.name,
};
productInfo = {
id: v.product?.id,
name: v.product?.name,
};
}
} else if (productId) {
const p = await prisma.product.findUnique({
where: { id: productId },
include: { variants: true, category: true },
});
if (p) {
targetVariantIds = p.variants.map((v) => v.id);
productInfo = {
id: p.id,
name: p.name,
categoryName: p.category?.name,
variantsCount: p.variants.length,
};
}
}
// 2. Fetch linked MarketplaceProducts
const mappedMp = await prisma.marketplaceProduct.findMany({
where: {
...(targetVariantIds.length > 0 ? { masterVariantId: { in: targetVariantIds } } : {}),
...(channel !== "all" ? { channel } : {}),
},
});
const tiktokMps = mappedMp.filter((m) => m.channel === "tiktok");
const shopeeMps = mappedMp.filter((m) => m.channel === "shopee");
const lazadaMps = mappedMp.filter((m) => m.channel === "lazada");
const tiktokKeys = new Set(tiktokMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`));
const tiktokSkus = new Set(tiktokMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean));
const tiktokNames = new Set(tiktokMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean));
const shopeeKeys = new Set(shopeeMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`));
const shopeeSkus = new Set(shopeeMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean));
const shopeeNames = new Set(shopeeMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean));
const lazadaKeys = new Set(lazadaMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`));
const lazadaSkus = new Set(lazadaMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean));
const lazadaNames = new Set(lazadaMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean));
// 3. Query Orders with Line Items
let matchedLineItems: Array<{
channel: "tiktok" | "shopee" | "lazada";
shop: string;
orderId: string;
province: string;
district: string;
quantity: number;
revenue: number;
createdTime: string | null;
}> = [];
// A. TikTok Items
if ((channel === "all" || channel === "tiktok") && (tiktokKeys.size > 0 || tiktokSkus.size > 0 || tiktokNames.size > 0)) {
const where: any = { ...sf.tiktok };
if (dateFrom || dateTo) {
where.createdTime = {};
if (dateFrom) where.createdTime.gte = dateFrom;
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
const tiktokOrders = await prisma.tiktokOrder.findMany({
where,
select: {
orderId: true,
shop: true,
province: true,
district: true,
createdTime: true,
items: true,
},
});
for (const o of tiktokOrders) {
const prov = o.province || "";
const dist = o.district || "";
for (const it of o.items) {
const sku = it.sellerSku || it.skuId || "";
const name = it.productName || "";
const variation = it.variation || "";
const shop = o.shop || "hom-aroma";
const key = `${shop}:::${name}:::${variation}:::${sku}`;
const isMatch =
tiktokKeys.has(key) ||
(sku && tiktokSkus.has(sku.trim().toLowerCase())) ||
(name && tiktokNames.has(name.trim().toLowerCase()) && (!variation || tiktokMps.some((m) => m.variation === variation)));
if (isMatch) {
const qty = it.quantity || 1;
const rev = it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : it.unitOriginalPrice * qty;
matchedLineItems.push({
channel: "tiktok",
shop,
orderId: o.orderId,
province: prov,
district: dist,
quantity: qty,
revenue: rev,
createdTime: o.createdTime,
});
}
}
}
}
// B. Shopee Items
if ((channel === "all" || channel === "shopee") && (shopeeKeys.size > 0 || shopeeSkus.size > 0 || shopeeNames.size > 0)) {
const where: any = { ...sf.shopee };
if (dateFrom || dateTo) {
where.orderCreatedAt = {};
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
}
const shopeeOrders = await prisma.shopeeOrder.findMany({
where,
select: {
orderId: true,
shop: true,
orderCreatedAt: true,
items: true,
},
});
for (const o of shopeeOrders) {
const { province, district } = extractShopeeAddress(o);
for (const it of o.items) {
const sku = it.skuRef || it.parentSku || "";
const name = it.productName || "";
const variation = it.variantName || "";
const shop = o.shop || "hom-aroma";
const key = `${shop}:::${name}:::${variation}:::${sku}`;
const isMatch =
shopeeKeys.has(key) ||
(sku && shopeeSkus.has(sku.trim().toLowerCase())) ||
(name && shopeeNames.has(name.trim().toLowerCase()) && (!variation || shopeeMps.some((m) => m.variation === variation)));
if (isMatch) {
const qty = it.quantity || 1;
const rev = it.totalPayment > 0 ? it.totalPayment : it.dealPrice * qty;
matchedLineItems.push({
channel: "shopee",
shop,
orderId: o.orderId,
province,
district,
quantity: qty,
revenue: rev,
createdTime: o.orderCreatedAt,
});
}
}
}
}
// C. Lazada Items
if ((channel === "all" || channel === "lazada") && (lazadaKeys.size > 0 || lazadaSkus.size > 0 || lazadaNames.size > 0)) {
const where: any = { ...sf.lazada };
if (dateFrom || dateTo) {
where.createTime = {};
if (dateFrom) where.createTime.gte = dateFrom;
if (dateTo) where.createTime.lte = dateTo + " 23:59";
}
const lazadaOrders = await prisma.lazadaOrder.findMany({
where,
select: {
orderNumber: true,
shop: true,
createTime: true,
shippingRegion: true,
shippingCity: true,
shippingPostCode: true,
items: true,
},
});
for (const o of lazadaOrders) {
const { province, district } = extractLazadaAddress(o);
for (const it of o.items) {
const sku = it.sellerSku || it.lazadaSku || "";
const name = it.itemName || "";
const variation = it.variation || "";
const shop = o.shop || "hom-aroma";
const key = `${shop}:::${name}:::${variation}:::${sku}`;
const isMatch =
lazadaKeys.has(key) ||
(sku && lazadaSkus.has(sku.trim().toLowerCase())) ||
(name && lazadaNames.has(name.trim().toLowerCase()) && (!variation || lazadaMps.some((m) => m.variation === variation)));
if (isMatch) {
const qty = 1;
const rev = it.paidPrice > 0 ? it.paidPrice : it.unitPrice;
matchedLineItems.push({
channel: "lazada",
shop,
orderId: o.orderNumber,
province,
district,
quantity: qty,
revenue: rev,
createdTime: o.createTime,
});
}
}
}
}
// 4. Map & Aggregate by 77 Provinces
const provMap: Record<string, any> = {};
for (const p of THAILAND_PROVINCES) {
provMap[p.nameTh] = {
code: p.code,
nameTh: p.nameTh,
nameEn: p.nameEn,
region: p.region,
regionTh: p.regionTh,
revenue: 0,
unitsSold: 0,
ordersCount: 0,
tiktokRevenue: 0,
shopeeRevenue: 0,
lazadaRevenue: 0,
tiktokUnits: 0,
shopeeUnits: 0,
lazadaUnits: 0,
};
}
let totalRevenue = 0;
let totalUnitsSold = 0;
let totalTiktokRevenue = 0;
let totalShopeeRevenue = 0;
let totalLazadaRevenue = 0;
let totalTiktokUnits = 0;
let totalShopeeUnits = 0;
let totalLazadaUnits = 0;
const uniqueOrderIds = new Set<string>();
for (const it of matchedLineItems) {
const provMatch = findProvince(it.province);
const provNameTh = provMatch ? provMatch.nameTh : "กรุงเทพมหานคร";
if (provMap[provNameTh]) {
provMap[provNameTh].revenue += it.revenue;
provMap[provNameTh].unitsSold += it.quantity;
provMap[provNameTh].ordersCount += 1;
if (it.channel === "tiktok") {
provMap[provNameTh].tiktokRevenue += it.revenue;
provMap[provNameTh].tiktokUnits += it.quantity;
totalTiktokRevenue += it.revenue;
totalTiktokUnits += it.quantity;
} else if (it.channel === "shopee") {
provMap[provNameTh].shopeeRevenue += it.revenue;
provMap[provNameTh].shopeeUnits += it.quantity;
totalShopeeRevenue += it.revenue;
totalShopeeUnits += it.quantity;
} else if (it.channel === "lazada") {
provMap[provNameTh].lazadaRevenue += it.revenue;
provMap[provNameTh].lazadaUnits += it.quantity;
totalLazadaRevenue += it.revenue;
totalLazadaUnits += it.quantity;
}
totalRevenue += it.revenue;
totalUnitsSold += it.quantity;
uniqueOrderIds.add(it.orderId);
}
}
const provinces = Object.values(provMap).map((p: any) => {
const pRev = p.revenue;
const ttShare = pRev > 0 ? (p.tiktokRevenue / pRev) * 100 : 0;
const spShare = pRev > 0 ? (p.shopeeRevenue / pRev) * 100 : 0;
const lzShare = pRev > 0 ? (p.lazadaRevenue / pRev) * 100 : 0;
return {
...p,
tiktokShare: ttShare,
shopeeShare: spShare,
lazadaShare: lzShare,
aov: p.ordersCount > 0 ? p.revenue / p.ordersCount : 0,
};
});
const coveredProvinces = provinces.filter((p) => p.revenue > 0);
const coverageCount = coveredProvinces.length;
const coveragePercent = (coverageCount / 77) * 100;
const sortedByMetric = [...provinces].sort((a, b) => {
return metric === "quantity" ? b.unitsSold - a.unitsSold : b.revenue - a.revenue;
});
const topProvinces = sortedByMetric.slice(0, 20).map((p, idx) => ({
...p,
rank: idx + 1,
}));
const topProvince =
topProvinces.length > 0 && (topProvinces[0].revenue > 0 || topProvinces[0].unitsSold > 0) ? topProvinces[0] : null;
// Region breakdown
const regionsMap: Record<string, any> = {};
for (const p of provinces) {
if (!regionsMap[p.region]) {
regionsMap[p.region] = {
region: p.region,
regionTh: p.regionTh,
revenue: 0,
unitsSold: 0,
tiktokRevenue: 0,
shopeeRevenue: 0,
lazadaRevenue: 0,
orders: 0,
};
}
regionsMap[p.region].revenue += p.revenue;
regionsMap[p.region].unitsSold += p.unitsSold;
regionsMap[p.region].tiktokRevenue += p.tiktokRevenue;
regionsMap[p.region].shopeeRevenue += p.shopeeRevenue;
regionsMap[p.region].lazadaRevenue += p.lazadaRevenue;
regionsMap[p.region].orders += p.ordersCount;
}
const regionBreakdown = Object.values(regionsMap)
.map((r: any) => ({
...r,
percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0,
}))
.sort((a: any, b: any) => b.revenue - a.revenue);
return {
product: productInfo,
variant: variantInfo,
mappedMarketplaceCount: mappedMp.length,
kpi: {
totalRevenue,
totalUnitsSold,
totalOrders: uniqueOrderIds.size,
aov: uniqueOrderIds.size > 0 ? totalRevenue / uniqueOrderIds.size : 0,
coverageCount,
coveragePercent,
topProvince: topProvince
? { name: topProvince.nameTh, revenue: topProvince.revenue, unitsSold: topProvince.unitsSold }
: null,
topRegion: regionBreakdown.length > 0 ? { name: regionBreakdown[0].regionTh, revenue: regionBreakdown[0].revenue } : null,
totalTiktokRevenue,
totalShopeeRevenue,
totalLazadaRevenue,
totalTiktokUnits,
totalShopeeUnits,
totalLazadaUnits,
tiktokSharePercent: totalRevenue > 0 ? (totalTiktokRevenue / totalRevenue) * 100 : 0,
shopeeSharePercent: totalRevenue > 0 ? (totalShopeeRevenue / totalRevenue) * 100 : 0,
lazadaSharePercent: totalRevenue > 0 ? (totalLazadaRevenue / totalRevenue) * 100 : 0,
},
provinces,
topProvinces,
regionBreakdown,
};
})
// ── 5. GET Product District Level Sales Map & Orders ───────────────────────
.get("/sales-map-products/district", async ({ query }) => {
const provinceName = (query.province as string) || "กรุงเทพมหานคร";
const productId = query.productId ? parseInt(query.productId as string, 10) : undefined;
const variantId = query.variantId ? parseInt(query.variantId as string, 10) : undefined;
const channel = (query.channel as string) || "all";
const statusFilter = (query.statusFilter as string) || "completed";
const sf = buildStatusFilter(statusFilter);
const dateFrom = query.dateFrom as string | undefined;
const dateTo = query.dateTo as string | undefined;
let targetVariantIds: number[] = [];
if (variantId) {
targetVariantIds = [variantId];
} else if (productId) {
const p = await prisma.product.findUnique({
where: { id: productId },
include: { variants: true },
});
if (p) targetVariantIds = p.variants.map((v) => v.id);
}
const mappedMp = await prisma.marketplaceProduct.findMany({
where: {
...(targetVariantIds.length > 0 ? { masterVariantId: { in: targetVariantIds } } : {}),
...(channel !== "all" ? { channel } : {}),
},
});
const tiktokMps = mappedMp.filter((m) => m.channel === "tiktok");
const shopeeMps = mappedMp.filter((m) => m.channel === "shopee");
const lazadaMps = mappedMp.filter((m) => m.channel === "lazada");
const tiktokKeys = new Set(tiktokMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`));
const tiktokSkus = new Set(tiktokMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean));
const tiktokNames = new Set(tiktokMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean));
const shopeeKeys = new Set(shopeeMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`));
const shopeeSkus = new Set(shopeeMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean));
const shopeeNames = new Set(shopeeMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean));
const lazadaKeys = new Set(lazadaMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`));
const lazadaSkus = new Set(lazadaMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean));
const lazadaNames = new Set(lazadaMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean));
const targetProv = findProvince(provinceName);
const provNameTh = targetProv ? targetProv.nameTh : provinceName;
const districtMap: Record<string, any> = {};
const matchedOrders: any[] = [];
// A. TikTok
if (channel === "all" || channel === "tiktok") {
const where: any = { ...sf.tiktok };
if (dateFrom || dateTo) {
where.createdTime = {};
if (dateFrom) where.createdTime.gte = dateFrom;
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
const ttOrders = await prisma.tiktokOrder.findMany({
where,
include: { items: true },
});
for (const o of ttOrders) {
const p = findProvince(o.province || "");
if (p && p.nameTh === provNameTh) {
const matchingItems = o.items.filter((it) => {
const sku = it.sellerSku || it.skuId || "";
const name = it.productName || "";
const variation = it.variation || "";
const key = `${o.shop}:::${name}:::${variation}:::${sku}`;
return tiktokKeys.has(key) || (sku && tiktokSkus.has(sku.trim().toLowerCase())) || (name && tiktokNames.has(name.trim().toLowerCase()));
});
if (matchingItems.length > 0) {
const itemRev = matchingItems.reduce(
(s, it) => s + (it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : it.unitOriginalPrice * it.quantity),
0
);
const itemQty = matchingItems.reduce((s, it) => s + it.quantity, 0);
const dName = cleanDistrictName(o.district) || o.district || "ไม่ระบุอำเภอ";
if (!districtMap[dName]) {
districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 };
}
districtMap[dName].revenue += itemRev;
districtMap[dName].unitsSold += itemQty;
districtMap[dName].orders += 1;
districtMap[dName].tiktokRevenue += itemRev;
matchedOrders.push({
orderId: o.orderId,
channel: "tiktok",
shop: o.shop,
buyerUsername: o.buyerUsername || "ลูกค้า TikTok",
createdTime: o.createdTime,
orderAmount: itemRev,
shippingFee: o.shippingFee,
province: provNameTh,
district: dName,
items: matchingItems.map((it) => ({
productName: it.productName,
variation: it.variation,
sellerSku: it.sellerSku || it.skuId,
quantity: it.quantity,
subtotalAfterDiscount: it.subtotalAfterDiscount || it.unitOriginalPrice * it.quantity,
})),
});
}
}
}
}
// B. Shopee
if (channel === "all" || channel === "shopee") {
const where: any = { ...sf.shopee };
if (dateFrom || dateTo) {
where.orderCreatedAt = {};
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
}
const spOrders = await prisma.shopeeOrder.findMany({
where,
include: { items: true },
});
for (const o of spOrders) {
const { province, district } = extractShopeeAddress(o);
const p = findProvince(province);
if (p && p.nameTh === provNameTh) {
const matchingItems = o.items.filter((it) => {
const sku = it.skuRef || it.parentSku || "";
const name = it.productName || "";
const variation = it.variantName || "";
const key = `${o.shop}:::${name}:::${variation}:::${sku}`;
return shopeeKeys.has(key) || (sku && shopeeSkus.has(sku.trim().toLowerCase())) || (name && shopeeNames.has(name.trim().toLowerCase()));
});
if (matchingItems.length > 0) {
const itemRev = matchingItems.reduce(
(s, it) => s + (it.totalPayment > 0 ? it.totalPayment : it.dealPrice * it.quantity),
0
);
const itemQty = matchingItems.reduce((s, it) => s + it.quantity, 0);
const dName = cleanDistrictName(district) || district || "ไม่ระบุอำเภอ";
if (!districtMap[dName]) {
districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 };
}
districtMap[dName].revenue += itemRev;
districtMap[dName].unitsSold += itemQty;
districtMap[dName].orders += 1;
districtMap[dName].shopeeRevenue += itemRev;
matchedOrders.push({
orderId: o.orderId,
channel: "shopee",
shop: o.shop,
buyerUsername: o.buyerUsername || "ลูกค้า Shopee",
createdTime: o.orderCreatedAt,
orderAmount: itemRev,
shippingFee: o.shippingFee,
province: provNameTh,
district: dName,
items: matchingItems.map((it) => ({
productName: it.productName,
variation: it.variantName,
sellerSku: it.skuRef || it.parentSku,
quantity: it.quantity,
subtotalAfterDiscount: it.totalPayment || it.dealPrice * it.quantity,
})),
});
}
}
}
}
// C. Lazada
if (channel === "all" || channel === "lazada") {
const where: any = { ...sf.lazada };
if (dateFrom || dateTo) {
where.createTime = {};
if (dateFrom) where.createTime.gte = dateFrom;
if (dateTo) where.createTime.lte = dateTo + " 23:59";
}
const lzOrders = await prisma.lazadaOrder.findMany({
where,
include: { items: true },
});
for (const o of lzOrders) {
const { province, district } = extractLazadaAddress(o);
const p = findProvince(province);
if (p && p.nameTh === provNameTh) {
const matchingItems = o.items.filter((it) => {
const sku = it.sellerSku || it.lazadaSku || "";
const name = it.itemName || "";
const variation = it.variation || "";
const key = `${o.shop}:::${name}:::${variation}:::${sku}`;
return lazadaKeys.has(key) || (sku && lazadaSkus.has(sku.trim().toLowerCase())) || (name && lazadaNames.has(name.trim().toLowerCase()));
});
if (matchingItems.length > 0) {
const itemRev = matchingItems.reduce((s, it) => s + (it.paidPrice > 0 ? it.paidPrice : it.unitPrice), 0);
const itemQty = matchingItems.length;
const dName = cleanDistrictName(district) || district || "ไม่ระบุอำเภอ";
if (!districtMap[dName]) {
districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 };
}
districtMap[dName].revenue += itemRev;
districtMap[dName].unitsSold += itemQty;
districtMap[dName].orders += 1;
districtMap[dName].lazadaRevenue += itemRev;
matchedOrders.push({
orderId: o.orderNumber,
channel: "lazada",
shop: o.shop,
buyerUsername: o.customerName || "ลูกค้า Lazada",
createdTime: o.createTime,
orderAmount: itemRev,
shippingFee: o.shippingFee,
province: provNameTh,
district: dName,
items: matchingItems.map((it) => ({
productName: it.itemName,
variation: it.variation,
sellerSku: it.sellerSku || it.lazadaSku,
quantity: 1,
subtotalAfterDiscount: it.paidPrice || it.unitPrice,
})),
});
}
}
}
}
const districts = Object.values(districtMap).sort((a: any, b: any) => b.revenue - a.revenue);
matchedOrders.sort((a, b) => String(b.createdTime || "").localeCompare(String(a.createdTime || "")));
return {
province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: "Central", regionTh: "ภาคกลาง", code: "" },
summary: {
totalRevenue: districts.reduce((s: number, d: any) => s + d.revenue, 0),
totalUnitsSold: districts.reduce((s: number, d: any) => s + d.unitsSold, 0),
totalOrders: matchedOrders.length,
districtCount: districts.length,
},
districts,
orders: matchedOrders,
};
})
// ── GET Repeat Customers Analysis (Shopee + Lazada + TikTok) ──────────────
.get("/repeat-customers", async ({ query }) => {
const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada'
const statusFilter = (query.statusFilter as string) || "completed"; // 'completed' | 'all' | 'cancelled'
const sf = buildStatusFilter(statusFilter);
const tiktokShop = query.tiktokShop as string | undefined;
const shopeeShop = query.shopeeShop as string | undefined;
const lazadaShop = query.lazadaShop as string | undefined;
const dateFrom = query.dateFrom as string | undefined;
const dateTo = query.dateTo as string | undefined;
const minOrders = parseInt((query.minOrders as string) || "2", 10);
const search = ((query.search as string) || "").trim().toLowerCase();
const sortBy = (query.sortBy as string) || "orders"; // 'orders' | 'totalSpent' | 'lastOrderDate' | 'daysBetween'
const sortOrder = (query.sortOrder as string) || "desc"; // 'desc' | 'asc'
const page = Math.max(1, parseInt((query.page as string) || "1", 10));
const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "25", 10)));
type OrderSummary = {
orderId: string;
channel: "tiktok" | "shopee" | "lazada";
shop: string;
date: string;
timestamp: number;
amount: number;
status: string;
itemsCount: number;
productNames: string[];
};
type CustomerProfile = {
id: string;
identifier: string;
displayName: string;
primaryChannel: "tiktok" | "shopee" | "lazada";
channels: Set<string>;
shops: Set<string>;
orderCount: number;
totalSpent: number;
firstOrderDate: string;
lastOrderDate: string;
firstTimestamp: number;
lastTimestamp: number;
daysBetweenOrders: number;
location: string;
orders: OrderSummary[];
productFrequency: Map<string, number>;
};
const customerMap = new Map<string, CustomerProfile>();
// 1. Fetch TikTok Orders
if (channel === "all" || channel === "tiktok") {
const where: any = { ...sf.tiktok };
if (tiktokShop && tiktokShop !== "all") where.shop = tiktokShop;
if (dateFrom || dateTo) {
where.createdTime = {};
if (dateFrom) where.createdTime.gte = dateFrom;
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
const tiktokOrders = await prisma.tiktokOrder.findMany({
where,
select: {
orderId: true,
shop: true,
status: true,
buyerUsername: true,
orderAmount: true,
createdTime: true,
province: true,
district: true,
items: {
select: {
productName: true,
quantity: true,
},
},
},
});
for (const o of tiktokOrders) {
const rawUser = (o.buyerUsername || "").trim();
const identifier = rawUser || `TT_${o.orderId}`;
const key = `tiktok:${identifier.toLowerCase()}`;
const dateStr = o.createdTime || "";
const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0;
const amount = Number(o.orderAmount) || 0;
const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean);
const location = [o.district, o.province].filter(Boolean).join(", ") || o.province || "";
let prof = customerMap.get(key);
if (!prof) {
prof = {
id: key,
identifier,
displayName: rawUser || `TikTok #${o.orderId.slice(-6)}`,
primaryChannel: "tiktok",
channels: new Set(["tiktok"]),
shops: new Set([o.shop]),
orderCount: 0,
totalSpent: 0,
firstOrderDate: dateStr,
lastOrderDate: dateStr,
firstTimestamp: ts,
lastTimestamp: ts,
daysBetweenOrders: 0,
location,
orders: [],
productFrequency: new Map(),
};
customerMap.set(key, prof);
}
prof.orderCount += 1;
prof.totalSpent += amount;
prof.shops.add(o.shop);
if (location && !prof.location) prof.location = location;
if (ts > 0) {
if (!prof.firstTimestamp || ts < prof.firstTimestamp) {
prof.firstTimestamp = ts;
prof.firstOrderDate = dateStr;
}
if (!prof.lastTimestamp || ts > prof.lastTimestamp) {
prof.lastTimestamp = ts;
prof.lastOrderDate = dateStr;
}
}
for (const pName of prodNames) {
prof.productFrequency.set(pName, (prof.productFrequency.get(pName) || 0) + 1);
}
prof.orders.push({
orderId: o.orderId,
channel: "tiktok",
shop: o.shop,
date: dateStr,
timestamp: ts,
amount,
status: o.status,
itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0),
productNames: prodNames,
});
}
}
// 2. Fetch Shopee Orders
if (channel === "all" || channel === "shopee") {
const where: any = { ...sf.shopee };
if (shopeeShop && shopeeShop !== "all") where.shop = shopeeShop;
if (dateFrom || dateTo) {
where.orderCreatedAt = {};
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
}
const shopeeOrders = await prisma.shopeeOrder.findMany({
where,
select: {
orderId: true,
shop: true,
status: true,
buyerUsername: true,
orderCreatedAt: true,
buyerPaidAmount: true,
totalAmount: true,
items: {
select: {
productName: true,
quantity: true,
rawData: true,
},
},
},
});
for (const o of shopeeOrders) {
const rawUser = (o.buyerUsername || "").trim();
const identifier = rawUser || `SP_${o.orderId}`;
const key = `shopee:${identifier.toLowerCase()}`;
const dateStr = o.orderCreatedAt || "";
const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0;
const amount = Number(o.buyerPaidAmount || o.totalAmount) || 0;
const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean);
const { province, district } = extractShopeeAddress(o);
const location = [district, province].filter(Boolean).join(", ") || province || "";
let prof = customerMap.get(key);
if (!prof) {
prof = {
id: key,
identifier,
displayName: rawUser || `Shopee #${o.orderId.slice(-6)}`,
primaryChannel: "shopee",
channels: new Set(["shopee"]),
shops: new Set([o.shop]),
orderCount: 0,
totalSpent: 0,
firstOrderDate: dateStr,
lastOrderDate: dateStr,
firstTimestamp: ts,
lastTimestamp: ts,
daysBetweenOrders: 0,
location,
orders: [],
productFrequency: new Map(),
};
customerMap.set(key, prof);
}
prof.orderCount += 1;
prof.totalSpent += amount;
prof.shops.add(o.shop);
if (location && !prof.location) prof.location = location;
if (ts > 0) {
if (!prof.firstTimestamp || ts < prof.firstTimestamp) {
prof.firstTimestamp = ts;
prof.firstOrderDate = dateStr;
}
if (!prof.lastTimestamp || ts > prof.lastTimestamp) {
prof.lastTimestamp = ts;
prof.lastOrderDate = dateStr;
}
}
for (const pName of prodNames) {
prof.productFrequency.set(pName, (prof.productFrequency.get(pName) || 0) + 1);
}
prof.orders.push({
orderId: o.orderId,
channel: "shopee",
shop: o.shop,
date: dateStr,
timestamp: ts,
amount,
status: o.status,
itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0),
productNames: prodNames,
});
}
}
// 3. Fetch Lazada Orders
if (channel === "all" || channel === "lazada") {
const where: any = { ...sf.lazada };
if (lazadaShop && lazadaShop !== "all") where.shop = lazadaShop;
if (dateFrom || dateTo) {
where.createTime = {};
if (dateFrom) where.createTime.gte = dateFrom;
if (dateTo) where.createTime.lte = dateTo + " 23:59";
}
const lazadaOrders = await prisma.lazadaOrder.findMany({
where,
select: {
orderNumber: true,
shop: true,
status: true,
customerName: true,
shippingCity: true,
shippingRegion: true,
shippingPostCode: true,
orderAmount: true,
createTime: true,
items: {
select: {
itemName: true,
},
},
},
});
for (const o of lazadaOrders) {
const rawName = (o.customerName || "").trim();
const postCode = (o.shippingPostCode || "").trim();
const identifier = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`;
const key = `lazada:${identifier}`;
const dateStr = o.createTime || "";
const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0;
const amount = Number(o.orderAmount) || 0;
const prodNames = o.items.map((it) => it.itemName.trim()).filter(Boolean);
const { province, district } = extractLazadaAddress(o);
const location = [district, province].filter(Boolean).join(", ") || province || "";
let prof = customerMap.get(key);
if (!prof) {
prof = {
id: key,
identifier,
displayName: rawName ? `${rawName}${postCode ? ` (${postCode})` : ""}` : `Lazada #${o.orderNumber.slice(-6)}`,
primaryChannel: "lazada",
channels: new Set(["lazada"]),
shops: new Set([o.shop]),
orderCount: 0,
totalSpent: 0,
firstOrderDate: dateStr,
lastOrderDate: dateStr,
firstTimestamp: ts,
lastTimestamp: ts,
daysBetweenOrders: 0,
location,
orders: [],
productFrequency: new Map(),
};
customerMap.set(key, prof);
}
prof.orderCount += 1;
prof.totalSpent += amount;
prof.shops.add(o.shop);
if (location && !prof.location) prof.location = location;
if (ts > 0) {
if (!prof.firstTimestamp || ts < prof.firstTimestamp) {
prof.firstTimestamp = ts;
prof.firstOrderDate = dateStr;
}
if (!prof.lastTimestamp || ts > prof.lastTimestamp) {
prof.lastTimestamp = ts;
prof.lastOrderDate = dateStr;
}
}
for (const pName of prodNames) {
prof.productFrequency.set(pName, (prof.productFrequency.get(pName) || 0) + 1);
}
prof.orders.push({
orderId: o.orderNumber,
channel: "lazada",
shop: o.shop,
date: dateStr,
timestamp: ts,
amount,
status: o.status || "Completed",
itemsCount: o.items.length,
productNames: prodNames,
});
}
}
// 4. Calculate Aggregate Metrics & Repeat Stats
const allCustomers = Array.from(customerMap.values());
const totalCustomers = allCustomers.length;
let repeatCustomersCount = 0;
let singleOrderCustomersCount = 0;
let totalRevenue = 0;
let repeatRevenue = 0;
let totalIntervalDays = 0;
let customersWithIntervalCount = 0;
const cohorts = {
order1: { count: 0, revenue: 0 },
order2: { count: 0, revenue: 0 },
order3to5: { count: 0, revenue: 0 },
order6plus: { count: 0, revenue: 0 },
};
const channelStats: Record<string, { totalCust: number; repeatCust: number; totalRev: number; repeatRev: number }> = {
tiktok: { totalCust: 0, repeatCust: 0, totalRev: 0, repeatRev: 0 },
shopee: { totalCust: 0, repeatCust: 0, totalRev: 0, repeatRev: 0 },
lazada: { totalCust: 0, repeatCust: 0, totalRev: 0, repeatRev: 0 },
};
const repeatProductMap = new Map<string, { repeatBuyerCount: number; orderAppearances: number }>();
for (const c of allCustomers) {
totalRevenue += c.totalSpent;
const isRepeat = c.orderCount >= 2;
// Calculate days between orders
if (c.orderCount > 1 && c.firstTimestamp > 0 && c.lastTimestamp > c.firstTimestamp) {
const diffDays = (c.lastTimestamp - c.firstTimestamp) / (1000 * 60 * 60 * 24);
c.daysBetweenOrders = Number((diffDays / (c.orderCount - 1)).toFixed(1));
totalIntervalDays += c.daysBetweenOrders;
customersWithIntervalCount += 1;
}
if (isRepeat) {
repeatCustomersCount += 1;
repeatRevenue += c.totalSpent;
// Track products for repeat buyers
for (const [prodName, freq] of c.productFrequency.entries()) {
const prev = repeatProductMap.get(prodName) || { repeatBuyerCount: 0, orderAppearances: 0 };
repeatProductMap.set(prodName, {
repeatBuyerCount: prev.repeatBuyerCount + 1,
orderAppearances: prev.orderAppearances + freq,
});
}
} else {
singleOrderCustomersCount += 1;
}
// Cohorts
if (c.orderCount === 1) {
cohorts.order1.count += 1;
cohorts.order1.revenue += c.totalSpent;
} else if (c.orderCount === 2) {
cohorts.order2.count += 1;
cohorts.order2.revenue += c.totalSpent;
} else if (c.orderCount >= 3 && c.orderCount <= 5) {
cohorts.order3to5.count += 1;
cohorts.order3to5.revenue += c.totalSpent;
} else {
cohorts.order6plus.count += 1;
cohorts.order6plus.revenue += c.totalSpent;
}
// Channel stats
const ch = c.primaryChannel;
if (channelStats[ch]) {
channelStats[ch].totalCust += 1;
channelStats[ch].totalRev += c.totalSpent;
if (isRepeat) {
channelStats[ch].repeatCust += 1;
channelStats[ch].repeatRev += c.totalSpent;
}
}
}
// Top Repeat Products List
const topRepeatProducts = Array.from(repeatProductMap.entries())
.map(([name, data]) => ({
productName: name,
repeatBuyerCount: data.repeatBuyerCount,
orderAppearances: data.orderAppearances,
}))
.sort((a, b) => b.repeatBuyerCount - a.repeatBuyerCount)
.slice(0, 10);
// 5. Filter & Sort Customers for Table
let filteredCustomers = allCustomers;
// Minimum orders filter (default >= 2 for repeat buyers only)
if (minOrders > 1) {
filteredCustomers = filteredCustomers.filter((c) => c.orderCount >= minOrders);
}
// Search filter
if (search) {
filteredCustomers = filteredCustomers.filter(
(c) =>
c.displayName.toLowerCase().includes(search) ||
c.identifier.toLowerCase().includes(search) ||
c.location.toLowerCase().includes(search)
);
}
// Sorting
filteredCustomers.sort((a, b) => {
let comparison = 0;
if (sortBy === "totalSpent") {
comparison = b.totalSpent - a.totalSpent;
} else if (sortBy === "lastOrderDate") {
comparison = String(b.lastOrderDate || "").localeCompare(String(a.lastOrderDate || ""));
} else if (sortBy === "daysBetween") {
comparison = (a.daysBetweenOrders || 9999) - (b.daysBetweenOrders || 9999);
} else {
// default 'orders'
comparison = b.orderCount - a.orderCount || b.totalSpent - a.totalSpent;
}
return sortOrder === "asc" ? -comparison : comparison;
});
const totalFiltered = filteredCustomers.length;
const totalPages = Math.ceil(totalFiltered / limit);
const paginatedCustomers = filteredCustomers.slice((page - 1) * limit, page * limit);
// Clean output for customers
const formattedCustomers = paginatedCustomers.map((c) => {
// Top 3 products
const sortedProds = Array.from(c.productFrequency.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([name]) => name);
let predictedNextOrderDate = "";
if (c.lastTimestamp > 0 && c.daysBetweenOrders > 0) {
const predictedTs = c.lastTimestamp + c.daysBetweenOrders * 24 * 60 * 60 * 1000;
predictedNextOrderDate = new Date(predictedTs).toISOString().slice(0, 10);
}
return {
id: c.id,
identifier: c.identifier,
displayName: c.displayName,
primaryChannel: c.primaryChannel,
channels: Array.from(c.channels),
shops: Array.from(c.shops),
orderCount: c.orderCount,
totalSpent: Number(c.totalSpent.toFixed(2)),
avgOrderValue: c.orderCount > 0 ? Number((c.totalSpent / c.orderCount).toFixed(2)) : 0,
firstOrderDate: c.firstOrderDate,
lastOrderDate: c.lastOrderDate,
daysBetweenOrders: c.daysBetweenOrders,
predictedNextOrderDate,
location: c.location,
topProducts: sortedProds,
};
});
const repurchaseRate = totalCustomers > 0 ? Number(((repeatCustomersCount / totalCustomers) * 100).toFixed(2)) : 0;
const repeatRevenueShare = totalRevenue > 0 ? Number(((repeatRevenue / totalRevenue) * 100).toFixed(2)) : 0;
const avgInterval = customersWithIntervalCount > 0 ? Number((totalIntervalDays / customersWithIntervalCount).toFixed(1)) : 0;
return {
summary: {
totalCustomers,
repeatCustomers: repeatCustomersCount,
singleOrderCustomers: singleOrderCustomersCount,
repurchaseRate,
totalRevenue: Number(totalRevenue.toFixed(2)),
repeatRevenue: Number(repeatRevenue.toFixed(2)),
repeatRevenueShare,
avgRepurchaseIntervalDays: avgInterval,
},
cohorts: [
{
label: "1 คำสั่งซื้อ",
count: cohorts.order1.count,
revenue: Number(cohorts.order1.revenue.toFixed(2)),
percentage: totalCustomers > 0 ? Number(((cohorts.order1.count / totalCustomers) * 100).toFixed(1)) : 0,
},
{
label: "2 คำสั่งซื้อ",
count: cohorts.order2.count,
revenue: Number(cohorts.order2.revenue.toFixed(2)),
percentage: totalCustomers > 0 ? Number(((cohorts.order2.count / totalCustomers) * 100).toFixed(1)) : 0,
},
{
label: "3 - 5 คำสั่งซื้อ",
count: cohorts.order3to5.count,
revenue: Number(cohorts.order3to5.revenue.toFixed(2)),
percentage: totalCustomers > 0 ? Number(((cohorts.order3to5.count / totalCustomers) * 100).toFixed(1)) : 0,
},
{
label: "6+ คำสั่งซื้อ (VIP)",
count: cohorts.order6plus.count,
revenue: Number(cohorts.order6plus.revenue.toFixed(2)),
percentage: totalCustomers > 0 ? Number(((cohorts.order6plus.count / totalCustomers) * 100).toFixed(1)) : 0,
},
],
channelBreakdown: {
tiktok: {
...channelStats.tiktok,
repurchaseRate:
channelStats.tiktok.totalCust > 0
? Number(((channelStats.tiktok.repeatCust / channelStats.tiktok.totalCust) * 100).toFixed(1))
: 0,
},
shopee: {
...channelStats.shopee,
repurchaseRate:
channelStats.shopee.totalCust > 0
? Number(((channelStats.shopee.repeatCust / channelStats.shopee.totalCust) * 100).toFixed(1))
: 0,
},
lazada: {
...channelStats.lazada,
repurchaseRate:
channelStats.lazada.totalCust > 0
? Number(((channelStats.lazada.repeatCust / channelStats.lazada.totalCust) * 100).toFixed(1))
: 0,
},
},
topRepeatProducts,
customers: formattedCustomers,
pagination: {
page,
limit,
total: totalFiltered,
totalPages,
},
};
})
// ── GET Single Repeat Customer Detail & Orders History ─────────────────────
.get("/repeat-customers/detail", async ({ query }) => {
const channel = (query.channel as string) || "tiktok";
const identifier = (query.identifier as string) || "";
if (!identifier) {
return { error: "Missing identifier" };
}
let customerInfo: any = null;
let orders: any[] = [];
if (channel === "tiktok") {
const dbOrders = await prisma.tiktokOrder.findMany({
where: {
buyerUsername: identifier,
},
orderBy: { createdTime: "desc" },
include: {
items: true,
},
});
if (dbOrders.length > 0) {
const first = dbOrders[0];
customerInfo = {
identifier,
displayName: identifier,
channel: "tiktok",
shop: first.shop,
location: [first.district, first.province].filter(Boolean).join(", ") || first.province || "",
totalOrders: dbOrders.length,
totalSpent: dbOrders.reduce((s, o) => s + (Number(o.orderAmount) || 0), 0),
};
orders = dbOrders.map((o) => ({
orderId: o.orderId,
channel: "tiktok",
shop: o.shop,
date: o.createdTime,
status: o.status,
amount: Number(o.orderAmount) || 0,
shippingFee: Number(o.shippingFee) || 0,
trackingId: o.trackingId,
deliveryOption: o.deliveryOption,
items: o.items.map((it) => ({
name: it.productName,
variation: it.variation,
quantity: it.quantity,
price: Number(it.subtotalAfterDiscount || it.unitOriginalPrice) || 0,
})),
}));
}
} else if (channel === "shopee") {
const dbOrders = await prisma.shopeeOrder.findMany({
where: {
buyerUsername: identifier,
},
orderBy: { orderCreatedAt: "desc" },
include: {
items: true,
},
});
if (dbOrders.length > 0) {
const first = dbOrders[0];
const addr = extractShopeeAddress(first);
customerInfo = {
identifier,
displayName: identifier,
channel: "shopee",
shop: first.shop,
location: [addr.district, addr.province].filter(Boolean).join(", ") || addr.province || "",
totalOrders: dbOrders.length,
totalSpent: dbOrders.reduce((s, o) => s + (Number(o.buyerPaidAmount || o.totalAmount) || 0), 0),
};
orders = dbOrders.map((o) => ({
orderId: o.orderId,
channel: "shopee",
shop: o.shop,
date: o.orderCreatedAt,
status: o.status,
amount: Number(o.buyerPaidAmount || o.totalAmount) || 0,
shippingFee: Number(o.shippingFee) || 0,
trackingId: o.trackingNo,
items: o.items.map((it) => ({
name: it.productName,
variation: it.variantName,
quantity: it.quantity,
price: Number(it.dealPrice || it.originalPrice) || 0,
})),
}));
}
} else if (channel === "lazada") {
// Identifier format for lazada is: "name_postcode" or just name
const parts = identifier.split("_");
const namePart = parts[0];
const postCodePart = parts[1] || "";
const where: any = {
customerName: { equals: namePart, mode: "insensitive" },
};
if (postCodePart) {
where.shippingPostCode = postCodePart;
}
const dbOrders = await prisma.lazadaOrder.findMany({
where,
orderBy: { createTime: "desc" },
include: {
items: true,
},
});
if (dbOrders.length > 0) {
const first = dbOrders[0];
const addr = extractLazadaAddress(first);
customerInfo = {
identifier,
displayName: `${first.customerName || "ลูกค้า Lazada"}${first.shippingPostCode ? ` (${first.shippingPostCode})` : ""}`,
channel: "lazada",
shop: first.shop,
location: [addr.district, addr.province].filter(Boolean).join(", ") || addr.province || "",
totalOrders: dbOrders.length,
totalSpent: dbOrders.reduce((s, o) => s + (Number(o.orderAmount) || 0), 0),
};
orders = dbOrders.map((o) => ({
orderId: o.orderNumber,
channel: "lazada",
shop: o.shop,
date: o.createTime,
status: o.status || "Completed",
amount: Number(o.orderAmount) || 0,
shippingFee: Number(o.shippingFee) || 0,
trackingId: o.trackingCode,
items: o.items.map((it) => ({
name: it.itemName,
variation: it.variation,
quantity: 1,
price: Number(it.paidPrice || it.unitPrice) || 0,
})),
}));
}
}
return {
customer: customerInfo || {
identifier,
displayName: identifier,
channel,
totalOrders: 0,
totalSpent: 0,
},
orders,
};
})
// ── GET Repeat Customers Calendar View ─────────────────────────────────────
.get("/repeat-customers/calendar", async ({ query }) => {
const channel = (query.channel as string) || "all";
const statusFilter = (query.statusFilter as string) || "completed";
const sf = buildStatusFilter(statusFilter);
// Default to current month YYYY-MM if not provided
const now = new Date();
const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
const targetMonth = (query.month as string) || currentMonthStr; // e.g. "2026-08"
const monthStart = `${targetMonth}-01`;
const monthEnd = `${targetMonth}-31 23:59`;
// 1. Identify repeat buyers lifetime first
type CustIdMap = Map<string, { orderCount: number; displayName: string; channel: string }>;
const allCustomers = new Map<string, { orderCount: number; displayName: string; channel: string }>();
// TikTok
if (channel === "all" || channel === "tiktok") {
const tt = await prisma.tiktokOrder.findMany({
where: { ...sf.tiktok },
select: { orderId: true, buyerUsername: true },
});
for (const o of tt) {
const rawUser = (o.buyerUsername || "").trim();
const id = rawUser || `TT_${o.orderId}`;
const key = `tiktok:${id.toLowerCase()}`;
const prev = allCustomers.get(key) || { orderCount: 0, displayName: rawUser || `TikTok #${o.orderId.slice(-6)}`, channel: "tiktok" };
prev.orderCount += 1;
allCustomers.set(key, prev);
}
}
// Shopee
if (channel === "all" || channel === "shopee") {
const sp = await prisma.shopeeOrder.findMany({
where: { ...sf.shopee },
select: { orderId: true, buyerUsername: true },
});
for (const o of sp) {
const rawUser = (o.buyerUsername || "").trim();
const id = rawUser || `SP_${o.orderId}`;
const key = `shopee:${id.toLowerCase()}`;
const prev = allCustomers.get(key) || { orderCount: 0, displayName: rawUser || `Shopee #${o.orderId.slice(-6)}`, channel: "shopee" };
prev.orderCount += 1;
allCustomers.set(key, prev);
}
}
// Lazada
if (channel === "all" || channel === "lazada") {
const lz = await prisma.lazadaOrder.findMany({
where: { ...sf.lazada },
select: { orderNumber: true, customerName: true, shippingPostCode: true },
});
for (const o of lz) {
const rawName = (o.customerName || "").trim();
const postCode = (o.shippingPostCode || "").trim();
const id = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`;
const key = `lazada:${id}`;
const prev = allCustomers.get(key) || {
orderCount: 0,
displayName: rawName ? `${rawName}${postCode ? ` (${postCode})` : ""}` : `Lazada #${o.orderNumber.slice(-6)}`,
channel: "lazada",
};
prev.orderCount += 1;
allCustomers.set(key, prev);
}
}
// Set of repeat customer keys
const repeatKeys = new Set<string>();
for (const [k, v] of allCustomers.entries()) {
if (v.orderCount >= 2) {
repeatKeys.add(k);
}
}
// 2. Fetch orders in target month
type CalendarDayOrder = {
orderId: string;
channel: "tiktok" | "shopee" | "lazada";
shop: string;
customerKey: string;
customerName: string;
time: string;
amount: number;
status: string;
itemsCount: number;
topProducts: string[];
};
const daysMap = new Map<string, {
date: string;
ordersCount: number;
revenue: number;
uniqueCustomers: Set<string>;
orders: CalendarDayOrder[];
channelBreakdown: {
tiktok: { orders: number; revenue: number };
shopee: { orders: number; revenue: number };
lazada: { orders: number; revenue: number };
};
}>();
// Helper to add order to day
const addOrderToDay = (
dateStr: string,
order: CalendarDayOrder
) => {
const dayKey = dateStr.slice(0, 10);
if (!dayKey.startsWith(targetMonth)) return;
let day = daysMap.get(dayKey);
if (!day) {
day = {
date: dayKey,
ordersCount: 0,
revenue: 0,
uniqueCustomers: new Set(),
orders: [],
channelBreakdown: {
tiktok: { orders: 0, revenue: 0 },
shopee: { orders: 0, revenue: 0 },
lazada: { orders: 0, revenue: 0 },
},
};
daysMap.set(dayKey, day);
}
day.ordersCount += 1;
day.revenue += order.amount;
day.uniqueCustomers.add(order.customerKey);
day.orders.push(order);
day.channelBreakdown[order.channel].orders += 1;
day.channelBreakdown[order.channel].revenue += order.amount;
};
// Query TikTok orders in month
if (channel === "all" || channel === "tiktok") {
const ttOrders = await prisma.tiktokOrder.findMany({
where: {
...sf.tiktok,
createdTime: { gte: monthStart, lte: monthEnd },
},
select: {
orderId: true,
shop: true,
status: true,
buyerUsername: true,
orderAmount: true,
createdTime: true,
items: { select: { productName: true, quantity: true } },
},
});
for (const o of ttOrders) {
const rawUser = (o.buyerUsername || "").trim();
const id = rawUser || `TT_${o.orderId}`;
const key = `tiktok:${id.toLowerCase()}`;
if (!repeatKeys.has(key)) continue; // only repeat buyers
const custInfo = allCustomers.get(key);
const dateStr = o.createdTime || "";
const amount = Number(o.orderAmount) || 0;
const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean);
addOrderToDay(dateStr, {
orderId: o.orderId,
channel: "tiktok",
shop: o.shop,
customerKey: key,
customerName: custInfo?.displayName || rawUser || o.orderId,
time: dateStr.slice(11, 16) || "",
amount,
status: o.status,
itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0),
topProducts: prodNames.slice(0, 2),
});
}
}
// Query Shopee orders in month
if (channel === "all" || channel === "shopee") {
const spOrders = await prisma.shopeeOrder.findMany({
where: {
...sf.shopee,
orderCreatedAt: { gte: monthStart, lte: monthEnd },
},
select: {
orderId: true,
shop: true,
status: true,
buyerUsername: true,
buyerPaidAmount: true,
totalAmount: true,
orderCreatedAt: true,
items: { select: { productName: true, quantity: true } },
},
});
for (const o of spOrders) {
const rawUser = (o.buyerUsername || "").trim();
const id = rawUser || `SP_${o.orderId}`;
const key = `shopee:${id.toLowerCase()}`;
if (!repeatKeys.has(key)) continue;
const custInfo = allCustomers.get(key);
const dateStr = o.orderCreatedAt || "";
const amount = Number(o.buyerPaidAmount || o.totalAmount) || 0;
const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean);
addOrderToDay(dateStr, {
orderId: o.orderId,
channel: "shopee",
shop: o.shop,
customerKey: key,
customerName: custInfo?.displayName || rawUser || o.orderId,
time: dateStr.slice(11, 16) || "",
amount,
status: o.status,
itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0),
topProducts: prodNames.slice(0, 2),
});
}
}
// Query Lazada orders in month
if (channel === "all" || channel === "lazada") {
const lzOrders = await prisma.lazadaOrder.findMany({
where: {
...sf.lazada,
createTime: { gte: monthStart, lte: monthEnd },
},
select: {
orderNumber: true,
shop: true,
status: true,
customerName: true,
shippingPostCode: true,
orderAmount: true,
createTime: true,
items: { select: { itemName: true } },
},
});
for (const o of lzOrders) {
const rawName = (o.customerName || "").trim();
const postCode = (o.shippingPostCode || "").trim();
const id = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`;
const key = `lazada:${id}`;
if (!repeatKeys.has(key)) continue;
const custInfo = allCustomers.get(key);
const dateStr = o.createTime || "";
const amount = Number(o.orderAmount) || 0;
const prodNames = o.items.map((it) => it.itemName.trim()).filter(Boolean);
addOrderToDay(dateStr, {
orderId: o.orderNumber,
channel: "lazada",
shop: o.shop,
customerKey: key,
customerName: custInfo?.displayName || rawName || o.orderNumber,
time: dateStr.slice(11, 16) || "",
amount,
status: o.status || "Completed",
itemsCount: o.items.length,
topProducts: prodNames.slice(0, 2),
});
}
}
// Format days output
let monthTotalRevenue = 0;
let monthTotalOrders = 0;
const monthUniqueCustomers = new Set<string>();
let peakDay = { date: "", revenue: 0, orders: 0 };
const days = Array.from(daysMap.values()).map((d) => {
monthTotalRevenue += d.revenue;
monthTotalOrders += d.ordersCount;
d.uniqueCustomers.forEach((c) => monthUniqueCustomers.add(c));
if (d.revenue > peakDay.revenue) {
peakDay = { date: d.date, revenue: Number(d.revenue.toFixed(2)), orders: d.ordersCount };
}
// Sort orders newest time first
d.orders.sort((a, b) => b.time.localeCompare(a.time));
return {
date: d.date,
day: parseInt(d.date.slice(8, 10), 10),
ordersCount: d.ordersCount,
revenue: Number(d.revenue.toFixed(2)),
uniqueCustomersCount: d.uniqueCustomers.size,
channelBreakdown: {
tiktok: { orders: d.channelBreakdown.tiktok.orders, revenue: Number(d.channelBreakdown.tiktok.revenue.toFixed(2)) },
shopee: { orders: d.channelBreakdown.shopee.orders, revenue: Number(d.channelBreakdown.shopee.revenue.toFixed(2)) },
lazada: { orders: d.channelBreakdown.lazada.orders, revenue: Number(d.channelBreakdown.lazada.revenue.toFixed(2)) },
},
orders: d.orders,
};
});
return {
month: targetMonth,
summary: {
totalRepeatOrders: monthTotalOrders,
totalRepeatRevenue: Number(monthTotalRevenue.toFixed(2)),
totalUniqueRepeatCustomers: monthUniqueCustomers.size,
peakDay,
},
days,
};
})
// ── GET Repeat Customers Timeline / Journey Swimlane ──────────────────────
.get("/repeat-customers/timeline", async ({ query }) => {
const channel = (query.channel as string) || "all";
const statusFilter = (query.statusFilter as string) || "completed";
const sf = buildStatusFilter(statusFilter);
const search = ((query.search as string) || "").trim().toLowerCase();
const churnStatusFilter = (query.churnStatus as string) || "all"; // 'all' | 'healthy' | 'due_soon' | 'churn_risk'
const page = Math.max(1, parseInt((query.page as string) || "1", 10));
const limit = Math.max(1, Math.min(100, parseInt((query.limit as string) || "20", 10)));
type OrderNode = {
orderId: string;
channel: "tiktok" | "shopee" | "lazada";
shop: string;
date: string;
timestamp: number;
amount: number;
itemsCount: number;
productNames: string[];
};
type CustomerTimeline = {
id: string;
identifier: string;
displayName: string;
primaryChannel: "tiktok" | "shopee" | "lazada";
location: string;
orderCount: number;
totalSpent: number;
firstOrderDate: string;
lastOrderDate: string;
firstTimestamp: number;
lastTimestamp: number;
avgIntervalDays: number;
daysSinceLastOrder: number;
predictedNextOrderDate: string;
churnStatus: "healthy" | "due_soon" | "churn_risk";
orders: OrderNode[];
};
const customerMap = new Map<string, CustomerTimeline>();
// 1. TikTok
if (channel === "all" || channel === "tiktok") {
const orders = await prisma.tiktokOrder.findMany({
where: { ...sf.tiktok },
select: {
orderId: true,
shop: true,
buyerUsername: true,
orderAmount: true,
createdTime: true,
province: true,
district: true,
items: { select: { productName: true, quantity: true } },
},
});
for (const o of orders) {
const rawUser = (o.buyerUsername || "").trim();
const id = rawUser || `TT_${o.orderId}`;
const key = `tiktok:${id.toLowerCase()}`;
const dateStr = o.createdTime || "";
const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0;
const amount = Number(o.orderAmount) || 0;
const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean);
const loc = [o.district, o.province].filter(Boolean).join(", ") || o.province || "";
let c = customerMap.get(key);
if (!c) {
c = {
id: key,
identifier: id,
displayName: rawUser || `TikTok #${o.orderId.slice(-6)}`,
primaryChannel: "tiktok",
location: loc,
orderCount: 0,
totalSpent: 0,
firstOrderDate: dateStr,
lastOrderDate: dateStr,
firstTimestamp: ts,
lastTimestamp: ts,
avgIntervalDays: 0,
daysSinceLastOrder: 0,
predictedNextOrderDate: "",
churnStatus: "healthy",
orders: [],
};
customerMap.set(key, c);
}
c.orderCount += 1;
c.totalSpent += amount;
if (loc && !c.location) c.location = loc;
c.orders.push({
orderId: o.orderId,
channel: "tiktok",
shop: o.shop,
date: dateStr,
timestamp: ts,
amount,
itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0),
productNames: prodNames,
});
}
}
// 2. Shopee
if (channel === "all" || channel === "shopee") {
const orders = await prisma.shopeeOrder.findMany({
where: { ...sf.shopee },
select: {
orderId: true,
shop: true,
buyerUsername: true,
buyerPaidAmount: true,
totalAmount: true,
orderCreatedAt: true,
items: { select: { productName: true, quantity: true, rawData: true } },
},
});
for (const o of orders) {
const rawUser = (o.buyerUsername || "").trim();
const id = rawUser || `SP_${o.orderId}`;
const key = `shopee:${id.toLowerCase()}`;
const dateStr = o.orderCreatedAt || "";
const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0;
const amount = Number(o.buyerPaidAmount || o.totalAmount) || 0;
const prodNames = o.items.map((it) => it.productName.trim()).filter(Boolean);
const { province, district } = extractShopeeAddress(o);
const loc = [district, province].filter(Boolean).join(", ") || province || "";
let c = customerMap.get(key);
if (!c) {
c = {
id: key,
identifier: id,
displayName: rawUser || `Shopee #${o.orderId.slice(-6)}`,
primaryChannel: "shopee",
location: loc,
orderCount: 0,
totalSpent: 0,
firstOrderDate: dateStr,
lastOrderDate: dateStr,
firstTimestamp: ts,
lastTimestamp: ts,
avgIntervalDays: 0,
daysSinceLastOrder: 0,
predictedNextOrderDate: "",
churnStatus: "healthy",
orders: [],
};
customerMap.set(key, c);
}
c.orderCount += 1;
c.totalSpent += amount;
if (loc && !c.location) c.location = loc;
c.orders.push({
orderId: o.orderId,
channel: "shopee",
shop: o.shop,
date: dateStr,
timestamp: ts,
amount,
itemsCount: o.items.reduce((s, it) => s + (it.quantity || 1), 0),
productNames: prodNames,
});
}
}
// 3. Lazada
if (channel === "all" || channel === "lazada") {
const orders = await prisma.lazadaOrder.findMany({
where: { ...sf.lazada },
select: {
orderNumber: true,
shop: true,
customerName: true,
shippingCity: true,
shippingRegion: true,
shippingPostCode: true,
orderAmount: true,
createTime: true,
items: { select: { itemName: true } },
},
});
for (const o of orders) {
const rawName = (o.customerName || "").trim();
const postCode = (o.shippingPostCode || "").trim();
const id = rawName ? `${rawName.toLowerCase()}_${postCode}` : `LZ_${o.orderNumber}`;
const key = `lazada:${id}`;
const dateStr = o.createTime || "";
const ts = dateStr ? new Date(dateStr.replace(" ", "T")).getTime() || 0 : 0;
const amount = Number(o.orderAmount) || 0;
const prodNames = o.items.map((it) => it.itemName.trim()).filter(Boolean);
const { province, district } = extractLazadaAddress(o);
const loc = [district, province].filter(Boolean).join(", ") || province || "";
let c = customerMap.get(key);
if (!c) {
c = {
id: key,
identifier: id,
displayName: rawName ? `${rawName}${postCode ? ` (${postCode})` : ""}` : `Lazada #${o.orderNumber.slice(-6)}`,
primaryChannel: "lazada",
location: loc,
orderCount: 0,
totalSpent: 0,
firstOrderDate: dateStr,
lastOrderDate: dateStr,
firstTimestamp: ts,
lastTimestamp: ts,
avgIntervalDays: 0,
daysSinceLastOrder: 0,
predictedNextOrderDate: "",
churnStatus: "healthy",
orders: [],
};
customerMap.set(key, c);
}
c.orderCount += 1;
c.totalSpent += amount;
if (loc && !c.location) c.location = loc;
c.orders.push({
orderId: o.orderNumber,
channel: "lazada",
shop: o.shop,
date: dateStr,
timestamp: ts,
amount,
itemsCount: o.items.length,
productNames: prodNames,
});
}
}
// 4. Filter only repeat buyers (>= 2 orders), calculate cycles & churn
const nowTime = Date.now();
let healthyCount = 0;
let dueSoonCount = 0;
let churnRiskCount = 0;
const repeatCustomers: CustomerTimeline[] = [];
for (const c of customerMap.values()) {
if (c.orderCount < 2) continue;
// Sort orders chronologically
c.orders.sort((a, b) => a.timestamp - b.timestamp);
c.firstTimestamp = c.orders[0].timestamp;
c.firstOrderDate = c.orders[0].date;
c.lastTimestamp = c.orders[c.orders.length - 1].timestamp;
c.lastOrderDate = c.orders[c.orders.length - 1].date;
if (c.lastTimestamp > c.firstTimestamp) {
const totalSpanDays = (c.lastTimestamp - c.firstTimestamp) / (1000 * 60 * 60 * 24);
c.avgIntervalDays = Number((totalSpanDays / (c.orderCount - 1)).toFixed(1));
} else {
c.avgIntervalDays = 1;
}
const daysSince = Math.max(0, (nowTime - c.lastTimestamp) / (1000 * 60 * 60 * 24));
c.daysSinceLastOrder = Number(daysSince.toFixed(1));
// Predicted next purchase
const predictedTs = c.lastTimestamp + c.avgIntervalDays * 24 * 60 * 60 * 1000;
c.predictedNextOrderDate = new Date(predictedTs).toISOString().slice(0, 10);
// Churn status:
// healthy: daysSince <= avgInterval
// due_soon: daysSince > avgInterval && daysSince <= avgInterval * 1.5
// churn_risk: daysSince > avgInterval * 1.5
if (c.daysSinceLastOrder <= c.avgIntervalDays) {
c.churnStatus = "healthy";
healthyCount += 1;
} else if (c.daysSinceLastOrder <= c.avgIntervalDays * 1.5) {
c.churnStatus = "due_soon";
dueSoonCount += 1;
} else {
c.churnStatus = "churn_risk";
churnRiskCount += 1;
}
repeatCustomers.push(c);
}
// 5. Filter & Sort
let filtered = repeatCustomers;
if (churnStatusFilter !== "all") {
filtered = filtered.filter((c) => c.churnStatus === churnStatusFilter);
}
if (search) {
filtered = filtered.filter(
(c) =>
c.displayName.toLowerCase().includes(search) ||
c.identifier.toLowerCase().includes(search) ||
c.location.toLowerCase().includes(search)
);
}
// Sort: prioritizes due_soon and healthy active, then orders count
filtered.sort((a, b) => {
const churnWeight = { due_soon: 3, healthy: 2, churn_risk: 1 };
const diff = churnWeight[b.churnStatus] - churnWeight[a.churnStatus];
if (diff !== 0) return diff;
return b.orderCount - a.orderCount || b.totalSpent - a.totalSpent;
});
const totalFiltered = filtered.length;
const totalPages = Math.ceil(totalFiltered / limit);
const paginated = filtered.slice((page - 1) * limit, page * limit);
return {
summary: {
totalRepeatCustomers: repeatCustomers.length,
healthyCount,
dueSoonCount,
churnRiskCount,
},
customers: paginated,
pagination: {
page,
limit,
total: totalFiltered,
totalPages,
},
};
});