feat(cms): implement multi-language support across frontend and admin UI
This commit is contained in:
1 parent
730e2a7b48
commit
eac45dd4a7
62 files changed
+1377
-5116
No files matched your search
@@ -1,348 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
const toNum = (val) => {
|
||||
if (val === null || val === undefined || val === "")
|
||||
return 0;
|
||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
const str = (val) => String(val ?? "").trim();
|
||||
const MONTHS = { Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06', Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12' };
|
||||
const parseLazadaDate = (val) => {
|
||||
const d = str(val);
|
||||
if (!d)
|
||||
return "";
|
||||
// e.g. "25 Apr 2026 23:24"
|
||||
const parts = d.split(" ");
|
||||
if (parts.length >= 4) {
|
||||
const [day, monthStr, year, time] = parts;
|
||||
const month = MONTHS[monthStr] || "01";
|
||||
return `${year}-${month}-${day.padStart(2, "0")} ${time}:00`;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
export const lazadaOrderRoute = new Elysia({ prefix: "/lazada-order" })
|
||||
// ── Import (Excel rows as JSON) ───────────────────────────────────────────
|
||||
.post("/import", async ({ body, set }) => {
|
||||
try {
|
||||
const { rows, shop } = body;
|
||||
const shopName = shop || "hom-aroma";
|
||||
console.log(`[LazadaOrder] Received ${rows.length} raw rows for shop: ${shopName}`);
|
||||
// Group rows by orderNumber
|
||||
const orderMap = new Map();
|
||||
for (const row of rows) {
|
||||
const orderNumber = str(row["orderNumber"]);
|
||||
if (!orderNumber)
|
||||
continue;
|
||||
if (!orderMap.has(orderNumber))
|
||||
orderMap.set(orderNumber, { meta: row, items: [] });
|
||||
orderMap.get(orderNumber).items.push(row);
|
||||
}
|
||||
console.log(`[LazadaOrder] Grouped into ${orderMap.size} unique orders`);
|
||||
const CHUNK = 30;
|
||||
const entries = Array.from(orderMap.entries());
|
||||
let upserted = 0;
|
||||
for (let i = 0; i < entries.length; i += CHUNK) {
|
||||
const chunk = entries.slice(i, i + CHUNK);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [orderNumber, { meta, items }] of chunk) {
|
||||
// Sync strategy: delete old items
|
||||
await tx.lazadaOrderItem.deleteMany({ where: { orderNumber } });
|
||||
let totalPaidPrice = 0;
|
||||
let totalShippingFee = 0;
|
||||
const itemsData = items.map((item) => {
|
||||
const paidPrice = toNum(item["paidPrice"]);
|
||||
const shippingFee = toNum(item["shippingFee"]);
|
||||
totalPaidPrice += paidPrice;
|
||||
totalShippingFee += shippingFee;
|
||||
return {
|
||||
orderItemId: str(item["orderItemId"]),
|
||||
sellerSku: str(item["sellerSku"]),
|
||||
lazadaSku: str(item["lazadaSku"]),
|
||||
itemName: str(item["itemName"]),
|
||||
variation: str(item["variation"]),
|
||||
unitPrice: toNum(item["unitPrice"]),
|
||||
paidPrice,
|
||||
sellerDiscountTotal: toNum(item["sellerDiscountTotal"]),
|
||||
platformDiscountTotal: toNum(item["platformDiscountTotal"]),
|
||||
shippingFee,
|
||||
status: str(item["status"]),
|
||||
};
|
||||
});
|
||||
// Decide order status based on items (or first item)
|
||||
// We'll just use the first item's status if they share it
|
||||
const orderStatus = str(meta["status"]);
|
||||
const orderData = {
|
||||
shop: shopName,
|
||||
createTime: parseLazadaDate(meta["createTime"]),
|
||||
updateTime: parseLazadaDate(meta["updateTime"]),
|
||||
deliveredDate: parseLazadaDate(meta["deliveredDate"]),
|
||||
customerName: str(meta["customerName"]),
|
||||
shippingCity: str(meta["shippingCity"]),
|
||||
shippingPostCode: str(meta["shippingPostCode"]),
|
||||
shippingRegion: str(meta["shippingRegion"]),
|
||||
payMethod: str(meta["payMethod"]),
|
||||
status: orderStatus,
|
||||
trackingCode: str(meta["trackingCode"]),
|
||||
shippingProvider: str(meta["shippingProvider"]),
|
||||
orderAmount: totalPaidPrice,
|
||||
shippingFee: totalShippingFee,
|
||||
};
|
||||
await tx.lazadaOrder.upsert({
|
||||
where: { orderNumber },
|
||||
update: { ...orderData, items: { create: itemsData } },
|
||||
create: { orderNumber, ...orderData, items: { create: itemsData } },
|
||||
});
|
||||
upserted++;
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
console.log(`[LazadaOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`);
|
||||
}
|
||||
console.log(`[LazadaOrder] ✅ Done — ${upserted} orders upserted`);
|
||||
return { success: true, message: `นำเข้าสำเร็จ ${upserted} คำสั่งซื้อ (${rows.length} แถว)`, ordersCount: upserted, rowsCount: rows.length };
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[LazadaOrder] ❌ Error:`, error.message);
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}, { body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) })
|
||||
// ── Clear Shop Data ───────────────────────────────────────────────────────
|
||||
.delete("/clear", async ({ query, set }) => {
|
||||
try {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const result = await prisma.lazadaOrder.deleteMany({ where: { shop } });
|
||||
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
|
||||
}
|
||||
catch (error) {
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
})
|
||||
// ── GET Single Order ──────────────────────────────────────────────────────
|
||||
.get("/orders/:orderNumber", async ({ params, set }) => {
|
||||
const order = await prisma.lazadaOrder.findUnique({ where: { orderNumber: params.orderNumber }, include: { items: true } });
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
return order;
|
||||
})
|
||||
// ── GET Orders (paginated) ────────────────────────────────────────────────
|
||||
.get("/orders", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 30);
|
||||
const skip = (page - 1) * limit;
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.lazadaOrder.findMany({ where, include: { items: true }, orderBy: { createTime: "desc" }, skip, take: limit }),
|
||||
prisma.lazadaOrder.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
// ── GET Summary ───────────────────────────────────────────────────────────
|
||||
.get("/summary", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [orderCount, agg] = await Promise.all([
|
||||
prisma.lazadaOrder.count({ where }),
|
||||
prisma.lazadaOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true } }),
|
||||
]);
|
||||
return {
|
||||
orderCount,
|
||||
totalAmount: agg._sum.orderAmount ?? 0,
|
||||
totalShipping: agg._sum.shippingFee ?? 0,
|
||||
};
|
||||
})
|
||||
// ── GET Analytics (Single Shop) ───────────────────────────────────────────
|
||||
.get("/analytics", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const dateFrom = query.dateFrom;
|
||||
const dateTo = query.dateTo;
|
||||
const where = { shop };
|
||||
if (dateFrom || dateTo) {
|
||||
where.createTime = {};
|
||||
if (dateFrom)
|
||||
where.createTime.gte = dateFrom;
|
||||
if (dateTo)
|
||||
where.createTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
const allOrders = await prisma.lazadaOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderNumber: true, status: true, createTime: true,
|
||||
orderAmount: true, shippingFee: true, payMethod: true,
|
||||
items: { select: { itemName: true, variation: true, sellerDiscountTotal: true, platformDiscountTotal: true, paidPrice: true, sellerSku: true } },
|
||||
},
|
||||
});
|
||||
// Monthly aggregation
|
||||
const monthMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthMap.has(month))
|
||||
monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0 });
|
||||
const m = monthMap.get(month);
|
||||
m.orders += 1;
|
||||
m.revenue += o.orderAmount;
|
||||
m.platformDiscount += o.items.reduce((s, i) => s + Math.abs(i.platformDiscountTotal), 0);
|
||||
m.sellerDiscount += o.items.reduce((s, i) => s + Math.abs(i.sellerDiscountTotal), 0);
|
||||
if (o.status?.toLowerCase().includes("canceled") || o.status?.toLowerCase().includes("failed"))
|
||||
m.cancelled += 1;
|
||||
m.items += o.items.length;
|
||||
}
|
||||
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||
// Status breakdown
|
||||
const statusMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const key = o.status || "Unknown";
|
||||
statusMap.set(key, (statusMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const statusBreakdown = Array.from(statusMap.entries()).map(([status, count]) => ({ status, count })).sort((a, b) => b.count - a.count);
|
||||
// Payment method breakdown
|
||||
const payMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const key = o.payMethod || "Unknown";
|
||||
payMap.set(key, (payMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const paymentBreakdown = Array.from(payMap.entries()).map(([method, count]) => ({ method, count })).sort((a, b) => b.count - a.count);
|
||||
// Top products
|
||||
const productMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
for (const item of o.items) {
|
||||
const key = `${item.itemName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key))
|
||||
productMap.set(key, { name: item.itemName, variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key);
|
||||
p.qty += 1; // Lazada export is 1 row per item quantity usually, so we add 1
|
||||
p.revenue += item.paidPrice;
|
||||
}
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
// Summary
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalPlatformDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + Math.abs(i.platformDiscountTotal), 0);
|
||||
const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + Math.abs(i.sellerDiscountTotal), 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const cancelledOrders = allOrders.filter(o => o.status?.toLowerCase().includes("canceled")).length;
|
||||
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
|
||||
const completedOrders = totalOrders - cancelledOrders;
|
||||
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
|
||||
return {
|
||||
monthly, statusBreakdown, paymentBreakdown, topProducts,
|
||||
summary: {
|
||||
totalRevenue, totalShipping, totalPlatformDisc, totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping, // Simple net (excluding lazada fees which aren't in this report usually)
|
||||
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
|
||||
},
|
||||
};
|
||||
})
|
||||
// ── GET Analytics Overview (ALL shops combined) ──────────────────────────
|
||||
.get("/analytics-overview", async ({ query }) => {
|
||||
const dateFrom = query.dateFrom;
|
||||
const dateTo = query.dateTo;
|
||||
const where = {};
|
||||
if (dateFrom || dateTo) {
|
||||
where.createTime = {};
|
||||
if (dateFrom)
|
||||
where.createTime.gte = dateFrom;
|
||||
if (dateTo)
|
||||
where.createTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
const allOrders = await prisma.lazadaOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderNumber: true, shop: true, status: true, createTime: true,
|
||||
orderAmount: true, shippingFee: true,
|
||||
items: { select: { platformDiscountTotal: true, sellerDiscountTotal: true, paidPrice: true, itemName: true, variation: true } },
|
||||
},
|
||||
});
|
||||
// Per-shop summary
|
||||
const shopMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
if (!shopMap.has(o.shop)) {
|
||||
shopMap.set(o.shop, { shop: o.shop, orders: 0, completed: 0, cancelled: 0, revenue: 0, shippingFee: 0, platformDiscount: 0, sellerDiscount: 0, items: 0 });
|
||||
}
|
||||
const s = shopMap.get(o.shop);
|
||||
s.orders += 1;
|
||||
s.revenue += o.orderAmount;
|
||||
s.shippingFee += o.shippingFee;
|
||||
s.platformDiscount += o.items.reduce((acc, i) => acc + Math.abs(i.platformDiscountTotal), 0);
|
||||
s.sellerDiscount += o.items.reduce((acc, i) => acc + Math.abs(i.sellerDiscountTotal), 0);
|
||||
s.items += o.items.length;
|
||||
if (o.status?.toLowerCase().includes("canceled"))
|
||||
s.cancelled += 1;
|
||||
else
|
||||
s.completed += 1;
|
||||
}
|
||||
const shops = Array.from(shopMap.values());
|
||||
// Monthly stacked by shop
|
||||
const monthShopMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthShopMap.has(month))
|
||||
monthShopMap.set(month, new Map());
|
||||
const sm = monthShopMap.get(month);
|
||||
if (!sm.has(o.shop))
|
||||
sm.set(o.shop, { revenue: 0, orders: 0 });
|
||||
const ms = sm.get(o.shop);
|
||||
ms.revenue += o.orderAmount;
|
||||
ms.orders += 1;
|
||||
}
|
||||
const shopKeys = Array.from(shopMap.keys());
|
||||
const monthly = Array.from(monthShopMap.entries())
|
||||
.map(([month, sm]) => {
|
||||
const row = { month };
|
||||
for (const shop of shopKeys) {
|
||||
const d = sm.get(shop) ?? { revenue: 0, orders: 0 };
|
||||
row[`${shop}_revenue`] = d.revenue;
|
||||
row[`${shop}_orders`] = d.orders;
|
||||
}
|
||||
row.totalRevenue = Array.from(sm.values()).reduce((s, v) => s + v.revenue, 0);
|
||||
row.totalOrders = Array.from(sm.values()).reduce((s, v) => s + v.orders, 0);
|
||||
row.revenue = row.totalRevenue; // Provide fallback for global view
|
||||
return row;
|
||||
})
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
// Top Products Global
|
||||
const productMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
for (const item of o.items) {
|
||||
const key = `${item.itemName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key))
|
||||
productMap.set(key, { name: item.itemName, variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key);
|
||||
p.qty += 1;
|
||||
p.revenue += item.paidPrice;
|
||||
}
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
// Global summary
|
||||
const totalRevenue = shops.reduce((s, sh) => s + sh.revenue, 0);
|
||||
const totalShipping = shops.reduce((s, sh) => s + sh.shippingFee, 0);
|
||||
const totalPlatformDisc = shops.reduce((s, sh) => s + sh.platformDiscount, 0);
|
||||
const totalSellerDisc = shops.reduce((s, sh) => s + sh.sellerDiscount, 0);
|
||||
const totalOrders = shops.reduce((s, sh) => s + sh.orders, 0);
|
||||
const completedOrders = shops.reduce((s, sh) => s + sh.completed, 0);
|
||||
const cancelledOrders = shops.reduce((s, sh) => s + sh.cancelled, 0);
|
||||
return {
|
||||
shops,
|
||||
monthly,
|
||||
topProducts,
|
||||
summary: {
|
||||
totalRevenue,
|
||||
totalShipping,
|
||||
totalPlatformDisc,
|
||||
totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping,
|
||||
totalOrders,
|
||||
completedOrders,
|
||||
cancelledOrders,
|
||||
cancelRate: totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0,
|
||||
aov: completedOrders > 0 ? totalRevenue / completedOrders : 0,
|
||||
}
|
||||
};
|
||||
});
|
||||
//# sourceMappingURL=lazadaOrder.js.map
|
||||
Reference in new issue
Block a user