2793 lines
96 KiB
TypeScript
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,
|
|
},
|
|
};
|
|
});
|
|
|