x
This commit is contained in:
1 parent
abee5249ca
commit
2c8da0ae2a
24 files changed
+9413
-414
No files matched your search
@@ -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;
|
||||
@@ -8,6 +9,13 @@ const toNum = (val: any): number => {
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
export const shopeeRoute = new Elysia({ prefix: "/shopee" })
|
||||
// ── Import Orders ─────────────────────────────────────────────────────────
|
||||
.post(
|
||||
@@ -340,4 +348,212 @@ export const shopeeRoute = new Elysia({ prefix: "/shopee" })
|
||||
totalItems: itemAgg._sum.quantity ?? 0,
|
||||
totalItemRevenue: itemAgg._sum.totalPayment ?? 0,
|
||||
};
|
||||
})
|
||||
|
||||
// ── GET Thailand Sales Map (Shopee 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.orderCreatedAt = {};
|
||||
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
|
||||
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
|
||||
}
|
||||
|
||||
const orders = await prisma.shopeeOrder.findMany({
|
||||
where,
|
||||
include: {
|
||||
items: {
|
||||
select: {
|
||||
rawData: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const provSalesMap = new Map<string, { code: string; nameTh: string; nameEn: string; region: string; regionTh: string; revenue: number; orders: 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,
|
||||
});
|
||||
}
|
||||
|
||||
let totalRevenue = 0;
|
||||
let totalOrders = orders.length;
|
||||
let unmappedOrders = 0;
|
||||
let unmappedRevenue = 0;
|
||||
|
||||
for (const o of orders) {
|
||||
totalRevenue += o.totalAmount;
|
||||
const { province } = extractShopeeAddress(o);
|
||||
const matched = findProvince(province);
|
||||
if (matched) {
|
||||
const p = provSalesMap.get(matched.nameTh)!;
|
||||
p.revenue += o.totalAmount;
|
||||
p.orders += 1;
|
||||
} else {
|
||||
unmappedOrders += 1;
|
||||
unmappedRevenue += o.totalAmount;
|
||||
}
|
||||
}
|
||||
|
||||
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: 'Central', 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 (Shopee) ──────────────────────────
|
||||
.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.orderCreatedAt = {};
|
||||
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
|
||||
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
|
||||
}
|
||||
|
||||
const allOrders = await prisma.shopeeOrder.findMany({
|
||||
where,
|
||||
include: {
|
||||
items: true,
|
||||
},
|
||||
orderBy: { orderCreatedAt: "desc" },
|
||||
});
|
||||
|
||||
// Filter in JS by matching extracted address
|
||||
const matchedOrders = allOrders.filter(o => {
|
||||
const { province } = extractShopeeAddress(o);
|
||||
const p = findProvince(province);
|
||||
return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || ''));
|
||||
});
|
||||
|
||||
const districtMap = new Map<string, { districtName: string; revenue: number; orders: number }>();
|
||||
let provRevenue = 0;
|
||||
|
||||
for (const o of matchedOrders) {
|
||||
provRevenue += o.totalAmount;
|
||||
const { district } = extractShopeeAddress(o);
|
||||
const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ";
|
||||
if (!districtMap.has(dName)) {
|
||||
districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0 });
|
||||
}
|
||||
const d = districtMap.get(dName)!;
|
||||
d.revenue += o.totalAmount;
|
||||
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 => {
|
||||
const { district, province } = extractShopeeAddress(o);
|
||||
return {
|
||||
orderId: o.orderId,
|
||||
shop: o.shop,
|
||||
status: o.status,
|
||||
buyerUsername: o.buyerUsername,
|
||||
createdTime: o.orderCreatedAt,
|
||||
orderAmount: o.totalAmount,
|
||||
shippingFee: o.shippingFee,
|
||||
province: province || 'ไม่ระบุจังหวัด',
|
||||
district: cleanDistrictName(district) || district || 'ไม่ระบุอำเภอ',
|
||||
itemsCount: o.items.reduce((s, i) => s + i.quantity, 0),
|
||||
items: o.items.map(i => ({
|
||||
productName: i.productName,
|
||||
variation: i.variantName,
|
||||
sellerSku: i.skuRef || i.parentSku,
|
||||
quantity: i.quantity,
|
||||
subtotalAfterDiscount: i.totalPayment,
|
||||
unitOriginalPrice: i.originalPrice || i.dealPrice,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user