This commit is contained in:
Your Name committed 2026-09-05 05:33:58 +07:00
1 parent 037ee775db
commit abee5249ca
14 files changed
+128241 -2

No files matched your search

+208
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => {
if (val === null || val === undefined || val === "") return 0;
@@ -390,4 +391,211 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
aov: completedOrders > 0 ? totalRevenue / completedOrders : 0,
}
};
})
// ── GET Thailand Sales Map (77 Provinces & Top 20) ──────────────────────
.get("/sales-map", async ({ query }) => {
const shop = (query.shop as string) || "hom-aroma";
const dateFrom = query.dateFrom as string | undefined;
const dateTo = query.dateTo as string | undefined;
const where: any = {};
if (shop && shop !== "all") {
where.shop = shop;
}
if (dateFrom || dateTo) {
where.createdTime = {};
if (dateFrom) where.createdTime.gte = dateFrom;
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
const orders = await prisma.tiktokOrder.findMany({
where,
select: {
orderId: true,
province: true,
district: true,
orderAmount: true,
status: true,
createdTime: true,
},
});
// Map each order to standard province
const provSalesMap = new Map<string, { code: string; nameTh: string; nameEn: string; region: string; regionTh: string; revenue: number; orders: number }>();
// Initialize with all 77 provinces
for (const p of THAILAND_PROVINCES) {
provSalesMap.set(p.nameTh, {
code: p.code,
nameTh: p.nameTh,
nameEn: p.nameEn,
region: p.region,
regionTh: p.regionTh,
revenue: 0,
orders: 0,
});
}
let totalRevenue = 0;
let totalOrders = orders.length;
let unmappedOrders = 0;
let unmappedRevenue = 0;
for (const o of orders) {
totalRevenue += o.orderAmount;
const matched = findProvince(o.province);
if (matched) {
const p = provSalesMap.get(matched.nameTh)!;
p.revenue += o.orderAmount;
p.orders += 1;
} else {
unmappedOrders += 1;
unmappedRevenue += o.orderAmount;
}
}
const provinceList = Array.from(provSalesMap.values());
const activeProvinces = provinceList.filter(p => p.orders > 0);
const coverageCount = activeProvinces.length;
// Top 20 provinces by revenue
const topProvinces = [...provinceList]
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 20)
.map((p, idx) => ({
rank: idx + 1,
...p,
aov: p.orders > 0 ? p.revenue / p.orders : 0,
}));
// Region Breakdown
const regionMap = new Map<string, { region: string; regionTh: string; revenue: number; orders: number }>();
for (const p of provinceList) {
if (!regionMap.has(p.region)) {
regionMap.set(p.region, { region: p.region, regionTh: p.regionTh, revenue: 0, orders: 0 });
}
const r = regionMap.get(p.region)!;
r.revenue += p.revenue;
r.orders += p.orders;
}
const regionBreakdown = Array.from(regionMap.values())
.map(r => ({
...r,
percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0,
}))
.sort((a, b) => b.revenue - a.revenue);
const topRegion = regionBreakdown[0] || { region: 'Northeast', regionTh: 'ภาคอีสาน', revenue: 0, orders: 0 };
const aov = totalOrders > 0 ? totalRevenue / totalOrders : 0;
return {
kpi: {
totalRevenue,
totalOrders,
coverageCount,
coverageTotal: 77,
coveragePercent: (coverageCount / 77) * 100,
aov,
topRegion: {
name: topRegion.regionTh,
orders: topRegion.orders,
revenue: topRegion.revenue,
},
unmappedOrders,
unmappedRevenue,
},
provinces: provinceList,
topProvinces,
regionBreakdown,
};
})
// ── GET District Level Details & Orders ──────────────────────────────────
.get("/sales-map/district", async ({ query, set }) => {
const provinceQuery = query.province as string;
if (!provinceQuery) {
set.status = 400;
return { error: "Province is required" };
}
const targetProv = findProvince(provinceQuery);
const provNameTh = targetProv ? targetProv.nameTh : provinceQuery;
const shop = query.shop as string | undefined;
const dateFrom = query.dateFrom as string | undefined;
const dateTo = query.dateTo as string | undefined;
const where: any = {};
if (shop && shop !== "all") {
where.shop = shop;
}
if (dateFrom || dateTo) {
where.createdTime = {};
if (dateFrom) where.createdTime.gte = dateFrom;
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
// Fetch all orders matching filter
const allOrders = await prisma.tiktokOrder.findMany({
where,
include: {
items: true,
},
orderBy: { createdTime: "desc" },
});
// Filter in JS using findProvince to match variants
const matchedOrders = allOrders.filter(o => {
const p = findProvince(o.province);
return p ? p.nameTh === provNameTh : (o.province?.includes(provNameTh) || provNameTh.includes(o.province || ''));
});
// Group by cleaned district name
const districtMap = new Map<string, { districtName: string; revenue: number; orders: number }>();
let provRevenue = 0;
for (const o of matchedOrders) {
provRevenue += o.orderAmount;
const dName = cleanDistrictName(o.district) || "ไม่ระบุอำเภอ";
if (!districtMap.has(dName)) {
districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0 });
}
const d = districtMap.get(dName)!;
d.revenue += o.orderAmount;
d.orders += 1;
}
const districts = Array.from(districtMap.values()).sort((a, b) => b.revenue - a.revenue);
return {
province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: 'Central', regionTh: 'ภาคกลาง', code: '' },
summary: {
totalRevenue: provRevenue,
totalOrders: matchedOrders.length,
districtCount: districts.length,
},
districts,
orders: matchedOrders.map(o => ({
orderId: o.orderId,
shop: o.shop,
status: o.status,
subStatus: o.subStatus,
buyerUsername: o.buyerUsername,
createdTime: o.createdTime,
orderAmount: o.orderAmount,
shippingFee: o.shippingFee,
province: o.province,
district: cleanDistrictName(o.district) || o.district || 'ไม่ระบุอำเภอ',
itemsCount: o.items.reduce((s, i) => s + i.quantity, 0),
items: o.items.map(i => ({
productName: i.productName,
variation: i.variation,
sellerSku: i.sellerSku,
quantity: i.quantity,
subtotalAfterDiscount: i.subtotalAfterDiscount,
unitOriginalPrice: i.unitOriginalPrice,
})),
})),
};
});