## 🛡️ Security Audit & Hardening Report ### 1. Executive Summary PR นี้ทำการตรวจสอบและปิดช่องโหว่ด้านความปลอดภัยของ **Backend API (Elysia.js)** ทั่วทั้งระบบ โดยบังคับใช้ **Admin Authentication Guard (`adminOnly` Middleware)** กับทุก Endpoint ที่เกี่ยวข้องกับ: 1. การจัดการและแก้ไขข้อมูลแคตตาล็อกสินค้า (Product & Category CRUD, Bulk Import/Delete) 2. ข้อมูลความลับทางการค้าและสูตรการผลิต (**Bill of Materials - BOM**, ต้นทุน Raw Materials, Profitability/MRP Forecast) 3. ข้อมูลส่วนบุคคลของลูกค้า (**PII** เช่น รายชื่อลูกค้า CRM, RFM Segment, ข้อความติดต่อ Contact Form, อีเมล Newsletter Subscribers) 4. การจัดการคำสั่งซื้อและสถานะ (Order Status Update, Tracking Assignment) 5. การจัดการ Assets และเนื้อหาเว็บไซต์ (Articles, Media Deletion, Category Layout Sections) พร้อมทั้งกำจัดจุดรั่วไหลของข้อมูลสูตรสินค้า (`bomItems` และราคาต้นทุน `rawMaterial`) ที่เคยหลุดออกไปใน Public Catalog Query (`GET /products` และ `GET /products/:id`) --- ### 2. Root Cause & Technical Findings #### 2.1 Elysia Lifecycle Plugin Scoping Flaw - **ปัญหาเดิม:** Middleware `adminOnly` ถูกสร้างด้วย `new Elysia({ name: 'admin-auth' }).onBeforeHandle(...)` โดยไม่มีออปชัน `{ as: 'scoped' }` - **ผลกระทบ:** ใน Elysia.js v1.2+ หากไม่ระบุ `{ as: 'scoped' }` หรือ `{ as: 'global' }` ฮุก `onBeforeHandle` ที่ประกาศใน plugin ย่อยจะไม่ถูก propagate ไปยัง Parent Route เมื่อถูกเรียกใช้ผ่าน `.use(adminOnly)` ส่งผลให้ middleware ไม่ทำงานจริงในบาง Controller - **การแก้ไข:** เพิ่ม `{ as: 'scoped' }` ใน `onBeforeHandle` ของ `apps/backend/src/middleware/auth.ts` และรวมศูนย์ `JWT_SECRET` ให้เป็น Single Source of Truth #### 2.2 Business Confidentiality Leak in Public Queries - **ปัญหาเดิม:** `GET /api/products` และ `GET /api/products/:id` ทำการ query `variants` โดย `include: { bomItems: { include: { rawMaterial: true } } }` - **ผลกระทบ:** ผู้ใช้ทั่วไปหรือ Bot ที่เรียกดูหน้าร้าน สามารถมองเห็นสูตรผสม ปริมาณสารเคมี/วัตถุดิบ และราคาต้นทุนต่อหน่วย (`pricePerUnit`) ของสินค้าทุกรายการ - **การแก้ไข:** ตัด `bomItems` ออกจาก Public Queries ทั้งหมด ข้อมูล BOM จะเปิดให้เข้าถึงเฉพาะผ่าน `GET /api/products/:id/bom` และ `GET /api/products/bom-status` ซึ่งถูกครอบด้วย `adminOnly` เท่านั้น --- ### 3. Comprehensive Security Matrix | Module | Route / Endpoint | Method | Previous State | New Protected State | Justification | | :--- | :--- | :---: | :---: | :---: | :--- | | **Products** | `/api/products` | GET | Public (leaked BOM) | **Public (Clean)** | หน้าร้านใช้แสดงสินค้า (ตัดข้อมูลต้นทุน BOM ออก) | | | `/api/products/brands` | GET | Public | **Public** | ตัวกรองแบรนด์สำหรับหน้าร้าน | | | `/api/products/:id` | GET | Public (leaked BOM) | **Public (Clean)** | หน้ารายละเอียดสินค้า (ตัดข้อมูลต้นทุน BOM ออก) | | | `/api/products/:id/detail-sections` | GET | Public | **Public** | CMS Page Builder สำหรับ Product Page | | | `/api/products/bom-status` | GET | ⚠️ Public | 🔒 **Admin Only** | รายการสถานะสูตร BOM และต้นทุนรวมของทุกล็อต | | | `/api/products/:id/bom` | GET, PUT | ⚠️ Public | 🔒 **Admin Only** | ความลับทางการค้า/สูตรผสมของสินค้า | | | `/api/products` | POST | ⚠️ Public | 🔒 **Admin Only** | ป้องกันการสร้างสินค้าโดยไม่ได้รับอนุญาต | | | `/api/products/:id` | PUT, PATCH, DELETE | ⚠️ Public | 🔒 **Admin Only** | ป้องกันการแก้ไข/ลบสินค้า | | | `/api/products/bulk`, `/bulk-delete` | POST | ⚠️ Public | 🔒 **Admin Only** | ป้องกันการโจมตี Mass Write/Delete | | | `/api/products/brands` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการแบรนด์สินค้า | | | `/api/products/:id/images` | POST | ⚠️ Public | 🔒 **Admin Only** | เพิ่มรูปภาพสินค้า | | | `/api/products/:id/detail-sections/**` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | แก้ไข Section ในหน้ารายละเอียดสินค้า | | **Categories** | `/api/categories` | GET | Public | **Public** | แสดงหมวดหมู่สินค้าหน้าร้าน | | | `/api/categories/:id` | GET | Public | **Public** | รายละเอียดหมวดหมู่ | | | `/api/categories/:id/products` | GET | Public | **Public** | แสดงรายการสินค้าในหมวดหมู่ | | | `/api/categories/:id/sections` | GET | Public | **Public** | Category Page Builder read | | | `/api/categories/bulk-import` | POST | ⚠️ Public | 🔒 **Admin Only** | นำเข้าหมวดหมู่จำนวนมาก | | | `/api/categories` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการหมวดหมู่สินค้า | | | `/api/categories/:id/media`, `/image` | PUT, PATCH | ⚠️ Public | 🔒 **Admin Only** | จัดการรูปภาพหมวดหมู่ | | | `/api/categories/:id/sections/**` | POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการ Section ของหมวดหมู่ | | **Financial & MRP** | `/api/profitability/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | กำไร-ขาดทุน, Margin, ค่าธรรมเนียม, MRP 30 วัน | | | `/api/raw-materials/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | สารเคมี, สต็อกวัตถุดิบ, ราคาต้นทุนต่อหน่วย | | | `/api/business-analytics/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | แผนที่การเงิน Omnichannel, Repeat Analytics | | **CRM & Customers** | `/api/crm/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | Customer RFM Scores, Churn Risk, VIP Lists | | | `/api/customers` | GET, POST | ⚠️ Public | 🔒 **Admin Only** | รายชื่อลูกค้า, ที่อยู่, เบอร์โทรศัพท์, การปรับแต้ม | | | `/api/contacts` | GET, PATCH | ⚠️ Public | 🔒 **Admin Only** | ข้อความติดต่อจากลูกค้าหน้าร้าน (PII) | | | `/api/contacts` | POST | Public | **Public** | ฟอร์มติดต่อเราสำหรับผู้เยี่ยมชม | | | `/api/newsletter/subscribers` | GET | ⚠️ Public | 🔒 **Admin Only** | รายชื่ออีเมลผู้ติดตาม Newsletter | | | `/api/newsletter/subscribe` | POST | Public | **Public** | ฟอร์มสมัครรับจดหมายข่าว | | **Orders & Sales** | `/api/orders` | POST, GET /:id, PATCH /:id/slip | Public | **Public** | ลูกค้าสั่งซื้อ, อัปโหลดสลิป, ดูคำสั่งซื้อของตน | | | `/api/orders/track` | POST | Public | **Public** | ติดตามพัสดุ (ต้องกรอก OrderId + เบอร์โทรตรงกัน) | | | `/api/orders` | GET | ⚠️ Public | 🔒 **Admin Only** | รายการคำสั่งซื้อทั้งหมด | | | `/api/orders/:id/status`, `/tracking` | PATCH | ⚠️ Public | 🔒 **Admin Only** | เปลี่ยนสถานะคำสั่งซื้อ, ใส่เลข Tracking | | | `/api/orders/customer/:customerId` | GET | ⚠️ Public | 🔒 **Admin Only** | ดูประวัติสั่งซื้อตาม Customer ID | | | `/api/tiktok/**`, `/lazada-orders/**`, `/shopee/**` | ALL | ⚠️ Public | 🔒 **Admin Only** | ซิงค์คำสั่งซื้อและค่าธรรมเนียม Marketplace | | **Promotions & CMS** | `/api/coupons/active`, `/validate` | GET, POST | Public | **Public** | ลูกค้าใช้คูปองและตรวจสอบส่วนลด | | | `/api/coupons` (Admin CRUD) | GET, POST, PATCH, DELETE | ⚠️ Public | 🔒 **Admin Only** | สร้าง/แก้ไข/ลบคูปอง | | | `/api/promotions/active` | GET | Public | **Public** | แสดงโปรโมชั่นหน้าร้าน | | | `/api/promotions` (Admin CRUD) | GET, POST, PUT, DELETE | ⚠️ Public | 🔒 **Admin Only** | จัดการแคมเปญโปรโมชั่น | | | `/api/articles` | GET, GET /:slug, GET /id/:id | Public | **Public** | อ่านบทความบล็อกหน้าร้าน | | | `/api/articles` (Mutations) | POST, PUT, DELETE, PATCH | ⚠️ Public | 🔒 **Admin Only** | จัดการบทความและคำแปลภาษา | | | `/api/media` | GET, DELETE | ⚠️ Public | 🔒 **Admin Only** | รายการ Media Library และการลบไฟล์ | | | `/api/product-attributes/definitions/**` | POST, PUT, PATCH | ⚠️ Public | 🔒 **Admin Only** | การสร้างและแก้ไข Attribute Definitions | --- ### 4. Client Compatibility & Verification - **Storefront & Website (`apps/website`, `apps/spa`):** - ไม่ได้รับผลกระทบใดๆ ทุก Route ที่จำเป็นสำหรับการเรนเดอร์ (Catalog, Detail, Reviews, Sections, Checkout, Tracking, Contact, Newsletter) ยังคงเป็น Public - Payload เบาลงและปลอดภัยขึ้นเนื่องจากไม่มีการส่ง `bomItems` ที่ไม่จำเป็น - **Admin Panel (`apps/admin`):** - ไม่ได้รับผลกระทบใดๆ เนื่องจากระบบ API Client ของ Admin ส่ง `Authorization: Bearer <token>` อยู่แล้วในทุก Request - **Automated Test Results:** - เพิ่ม Security Contract Test: `apps/backend/src/routes/auth-guard.contract.test.ts` - ผ่านการทดสอบทั้งหมด 95/95 tests (0 failures) - ทดสอบผ่าน Production Build (`tsc -b && vite build`) สมบูรณ์ 100% --- ### 5. OWASP Standards Compliance - **A01:2021 – Broken Access Control:** แก้ไขช่องโหว่ Direct Object Reference และ Unprotected Administrative Interfaces โดยบังคับใช้ JWT Authentication ในทุกจุดสำคัญ - **A04:2021 – Insecure Design:** แก้ไขการรั่วไหลของข้อมูลสูตรสินค้าและต้นทุนใน Public Response ด้วย Least Privilege Data Exposure Model --------- Co-authored-by: Your Name <you@example.com> Reviewed-on: #15 Co-authored-by: antigravity-bot <bot@kittisak.com>
378 lines
17 KiB
TypeScript
378 lines
17 KiB
TypeScript
import { Elysia, t } from "elysia";
|
|
import prisma from "../lib/prisma";
|
|
import { adminOnly } from "../middleware/auth";
|
|
|
|
const toNum = (val: any): number => {
|
|
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: any): string => String(val ?? "").trim();
|
|
|
|
const MONTHS: Record<string, string> = { 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: any): string => {
|
|
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" })
|
|
.use(adminOnly)
|
|
|
|
// ── Import (Excel rows as JSON) ───────────────────────────────────────────
|
|
.post(
|
|
"/import",
|
|
async ({ body, set }) => {
|
|
try {
|
|
const { rows, shop } = body as { rows: any[]; shop?: string };
|
|
const shopName = shop || "hom-aroma";
|
|
|
|
console.log(`[LazadaOrder] Received ${rows.length} raw rows for shop: ${shopName}`);
|
|
|
|
// Group rows by orderNumber
|
|
const orderMap = new Map<string, { meta: any; items: any[] }>();
|
|
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: any) {
|
|
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 as string) || "hom-aroma";
|
|
const result = await prisma.lazadaOrder.deleteMany({ where: { shop } });
|
|
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
|
|
} catch (error: any) {
|
|
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 as string) || "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 as string) || "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 as string) || "hom-aroma";
|
|
const dateFrom = query.dateFrom as string | undefined;
|
|
const dateTo = query.dateTo as string | undefined;
|
|
|
|
const where: any = { 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<string, { month: string; revenue: number; orders: number; cancelled: number; items: number; platformDiscount: number; sellerDiscount: number }>();
|
|
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<string, number>();
|
|
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<string, number>();
|
|
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<string, { name: string; variation: string; qty: number; revenue: number }>();
|
|
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 as string | undefined;
|
|
const dateTo = query.dateTo as string | undefined;
|
|
|
|
const where: any = {};
|
|
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<string, {
|
|
shop: string; orders: number; completed: number; cancelled: number;
|
|
revenue: number; shippingFee: number; platformDiscount: number; sellerDiscount: number; items: number;
|
|
}>();
|
|
|
|
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<string, Map<string, { revenue: number; orders: number }>>();
|
|
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: any = { 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<string, { name: string; variation: string; qty: number; revenue: number }>();
|
|
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,
|
|
}
|
|
};
|
|
});
|