x
This commit is contained in:
1 parent
abee5249ca
commit
2c8da0ae2a
24 files changed
+9413
-414
No files matched your search
@@ -0,0 +1,40 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketplaceProduct" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"channel" TEXT NOT NULL,
|
||||
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
|
||||
"marketplaceSku" TEXT NOT NULL DEFAULT '',
|
||||
"productName" TEXT NOT NULL,
|
||||
"variation" TEXT NOT NULL DEFAULT '',
|
||||
"avgPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"totalSoldQty" INTEGER NOT NULL DEFAULT 0,
|
||||
"totalRevenue" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"firstSoldAt" TEXT,
|
||||
"lastSoldAt" TEXT,
|
||||
"masterVariantId" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MarketplaceProduct_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketplaceProduct_channel_idx" ON "MarketplaceProduct"("channel");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketplaceProduct_shop_idx" ON "MarketplaceProduct"("shop");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketplaceProduct_marketplaceSku_idx" ON "MarketplaceProduct"("marketplaceSku");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketplaceProduct_masterVariantId_idx" ON "MarketplaceProduct"("masterVariantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketplaceProduct_totalRevenue_idx" ON "MarketplaceProduct"("totalRevenue");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MarketplaceProduct_channel_shop_productName_variation_marke_key" ON "MarketplaceProduct"("channel", "shop", "productName", "variation", "marketplaceSku");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketplaceProduct" ADD CONSTRAINT "MarketplaceProduct_masterVariantId_fkey" FOREIGN KEY ("masterVariantId") REFERENCES "ProductVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -112,6 +112,7 @@ model ProductVariant {
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
bomItems BomItem[]
|
||||
marketplaceProducts MarketplaceProduct[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -497,6 +498,12 @@ model TiktokOrder {
|
||||
shippingFee Float @default(0) // Shipping Fee After Discount
|
||||
originalShipping Float @default(0)
|
||||
taxes Float @default(0)
|
||||
// Settlement Financials
|
||||
commissionFee Float @default(0) // ค่าคอมมิชชั่น TikTok
|
||||
affiliateFee Float @default(0) // ค่าคอมมิชชั่นแอฟฟิลิเอต
|
||||
netSettlement Float @default(0) // ยอดเงินโอนจริงสุทธิ
|
||||
settledAt String? // วันที่โอนเงิน
|
||||
isSettled Boolean @default(false)
|
||||
// Geography
|
||||
province String?
|
||||
district String?
|
||||
@@ -509,9 +516,9 @@ model TiktokOrder {
|
||||
deliveredTime String?
|
||||
cancelledTime String?
|
||||
|
||||
items TiktokOrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
items TiktokOrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([shop])
|
||||
@@index([status])
|
||||
@@ -537,6 +544,44 @@ model TiktokOrderItem {
|
||||
weight Float @default(0)
|
||||
}
|
||||
|
||||
model TiktokSettlement {
|
||||
id Int @id @default(autoincrement())
|
||||
orderId String @unique // หมายเลขคำสั่งซื้อ/การปรับ
|
||||
shop String @default("hom-aroma")
|
||||
transactionType String? // ประเภทธุรกรรม (คำสั่งซื้อ / การปรับยอด)
|
||||
orderCreatedTime String? // เวลาที่สร้างคำสั่งซื้อ
|
||||
orderSettledTime String? // เวลาที่ชำระคำสั่งซื้อ / วันที่โอนเงิน
|
||||
currency String @default("THB")
|
||||
|
||||
// Financial breakdowns
|
||||
settlementAmount Float @default(0) // ยอดการชำระเงินทั้งหมด (Net Payout)
|
||||
grossRevenue Float @default(0) // รายได้ทั้งหมด
|
||||
subtotalAfterSellerDiscount Float @default(0) // ยอดรวมค่าสินค้าหลังหักส่วนลดจากผู้ขาย
|
||||
subtotalBeforeDiscount Float @default(0) // ยอดรวมค่าสินค้าก่อนหักส่วนลด
|
||||
sellerDiscount Float @default(0) // ส่วนลดจากร้านค้า
|
||||
refundAmount Float @default(0) // ยอดรวมเงินคืน
|
||||
|
||||
// Fees
|
||||
totalFee Float @default(0) // ค่าธรรมเนียมทั้งหมด
|
||||
orderFee Float @default(0) // ค่าธรรมเนียมคำสั่งซื้อ
|
||||
commissionFee Float @default(0) // ค่าคอมมิชชั่น TikTok Shop
|
||||
affiliateCommission Float @default(0) // ค่าคอมมิชชั่นแอฟฟิลิเอต
|
||||
affiliatePartnerFee Float @default(0) // ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต
|
||||
growthSupportFee Float @default(0) // ค่าธรรมเนียมสนับสนุนการเติบโตของร้านค้า
|
||||
shippingFeeActual Float @default(0) // ค่าธรรมเนียมการจัดส่งจริง
|
||||
platformShippingDiscount Float @default(0) // ส่วนลดค่าธรรมเนียมการจัดส่งจากแพลตฟอร์ม
|
||||
|
||||
customerPayment Float @default(0) // การชำระเงินของลูกค้า
|
||||
rawData Json? // Full raw row data for audit
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([shop])
|
||||
@@index([orderSettledTime])
|
||||
@@index([orderId])
|
||||
}
|
||||
|
||||
// ── Lazada Orders ──────────────────────────────────────────────────────────────
|
||||
model LazadaOrder {
|
||||
id Int @id @default(autoincrement())
|
||||
@@ -715,3 +760,33 @@ model ContactMessage {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ── Marketplace Products & SKU Mapping ──────────────────────────────────────────
|
||||
model MarketplaceProduct {
|
||||
id Int @id @default(autoincrement())
|
||||
channel String // "tiktok" | "shopee" | "lazada"
|
||||
shop String @default("hom-aroma")
|
||||
marketplaceSku String @default("") // sellerSku / skuRef / skuId
|
||||
productName String // Item name on marketplace
|
||||
variation String @default("") // Variation / flavor / size
|
||||
avgPrice Float @default(0)
|
||||
totalSoldQty Int @default(0)
|
||||
totalRevenue Float @default(0)
|
||||
firstSoldAt String?
|
||||
lastSoldAt String?
|
||||
|
||||
// Link to Master Core Catalog
|
||||
masterVariantId Int?
|
||||
masterVariant ProductVariant? @relation(fields: [masterVariantId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([channel, shop, productName, variation, marketplaceSku])
|
||||
@@index([channel])
|
||||
@@index([shop])
|
||||
@@index([marketplaceSku])
|
||||
@@index([masterVariantId])
|
||||
@@index([totalRevenue])
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import { mediaRoute } from "./routes/media";
|
||||
import { contactsRoute } from "./routes/contacts";
|
||||
import { productAttributesRoute } from "./routes/product-attributes";
|
||||
import { backupRoute } from "./routes/backup";
|
||||
import { businessAnalyticsRoute } from "./routes/businessAnalytics";
|
||||
import { toolsRoute } from "./routes/tools";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||
@@ -136,7 +138,7 @@ const app = new Elysia()
|
||||
return file;
|
||||
})
|
||||
.group("/api", (app) =>
|
||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute)
|
||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute)
|
||||
)
|
||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -7,7 +7,7 @@ const toNum = (val: any): number => {
|
||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
};
|
||||
|
||||
// TikTok CSV values can have trailing \t — trim everything
|
||||
const str = (val: any): string => String(val ?? "").replace(/\t/g, "").trim();
|
||||
@@ -26,9 +26,216 @@ const parseTiktokDate = (val: any): string => {
|
||||
return d;
|
||||
};
|
||||
|
||||
// Helper to enrich TikTok orders with BOM & Profitability
|
||||
async function enrichOrdersWithBOM(orders: any[], shop: string) {
|
||||
if (!orders || orders.length === 0) return [];
|
||||
|
||||
const orderIds = orders.map((o) => o.orderId).filter(Boolean);
|
||||
|
||||
// Fetch all MarketplaceProduct mappings, master variants, and settlements for this batch
|
||||
const [marketplaceProducts, masterVariants, settlements] = await Promise.all([
|
||||
prisma.marketplaceProduct.findMany({
|
||||
where: { channel: "tiktok", shop },
|
||||
include: {
|
||||
masterVariant: {
|
||||
include: {
|
||||
product: { select: { id: true, name: true } },
|
||||
bomItems: {
|
||||
include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.productVariant.findMany({
|
||||
include: {
|
||||
product: { select: { id: true, name: true } },
|
||||
bomItems: {
|
||||
include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
orderIds.length > 0
|
||||
? prisma.tiktokSettlement.findMany({ where: { orderId: { in: orderIds } } })
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
// Index Settlements
|
||||
const settlementMap = new Map<string, any>();
|
||||
for (const s of settlements) {
|
||||
settlementMap.set(s.orderId, s);
|
||||
}
|
||||
|
||||
// Index MarketplaceProducts
|
||||
const mpExactMap = new Map<string, any>(); // shop:::productName:::variation:::sku
|
||||
const mpSkuMap = new Map<string, any>(); // shop:::sku
|
||||
const mpNameVarMap = new Map<string, any>(); // shop:::productName:::variation
|
||||
const variantSkuMap = new Map<string, any>(); // SKU uppercase -> ProductVariant
|
||||
|
||||
for (const mp of marketplaceProducts) {
|
||||
const s = (mp.shop || "hom-aroma").trim().toLowerCase();
|
||||
const pName = (mp.productName || "").trim().toLowerCase();
|
||||
const vName = (mp.variation || "").trim().toLowerCase();
|
||||
const sku = (mp.marketplaceSku || "").trim().toLowerCase();
|
||||
|
||||
const exactKey = `${s}:::${pName}:::${vName}:::${sku}`;
|
||||
mpExactMap.set(exactKey, mp);
|
||||
|
||||
if (sku) {
|
||||
mpSkuMap.set(`${s}:::${sku}`, mp);
|
||||
}
|
||||
if (pName) {
|
||||
mpNameVarMap.set(`${s}:::${pName}:::${vName}`, mp);
|
||||
}
|
||||
}
|
||||
|
||||
for (const v of masterVariants) {
|
||||
if (v.sku) {
|
||||
variantSkuMap.set(v.sku.trim().toLowerCase(), v);
|
||||
}
|
||||
}
|
||||
|
||||
return orders.map((order) => {
|
||||
const oShop = (order.shop || "hom-aroma").trim().toLowerCase();
|
||||
let orderTotalBomCost = 0;
|
||||
let missingBomCount = 0;
|
||||
|
||||
const enrichedItems = (order.items || []).map((item: any) => {
|
||||
const pName = (item.productName || "").trim().toLowerCase();
|
||||
const vName = (item.variation || "").trim().toLowerCase();
|
||||
const sSku = (item.sellerSku || item.skuId || "").trim().toLowerCase();
|
||||
|
||||
// Resolve Master Variant
|
||||
let resolvedVariant: any = null;
|
||||
let matchedVia = "";
|
||||
|
||||
// 1. Exact Match
|
||||
const exact = mpExactMap.get(`${oShop}:::${pName}:::${vName}:::${sSku}`);
|
||||
if (exact?.masterVariant) {
|
||||
resolvedVariant = exact.masterVariant;
|
||||
matchedVia = "marketplace_exact";
|
||||
}
|
||||
|
||||
// 2. SKU Match in MarketplaceProduct
|
||||
if (!resolvedVariant && sSku) {
|
||||
const bySku = mpSkuMap.get(`${oShop}:::${sSku}`);
|
||||
if (bySku?.masterVariant) {
|
||||
resolvedVariant = bySku.masterVariant;
|
||||
matchedVia = "marketplace_sku";
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Name + Variation Match in MarketplaceProduct
|
||||
if (!resolvedVariant && pName) {
|
||||
const byNameVar = mpNameVarMap.get(`${oShop}:::${pName}:::${vName}`);
|
||||
if (byNameVar?.masterVariant) {
|
||||
resolvedVariant = byNameVar.masterVariant;
|
||||
matchedVia = "marketplace_name_variation";
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Direct Core Variant SKU match
|
||||
if (!resolvedVariant && sSku) {
|
||||
const direct = variantSkuMap.get(sSku);
|
||||
if (direct) {
|
||||
resolvedVariant = direct;
|
||||
matchedVia = "direct_sku";
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate BOM Cost
|
||||
let unitBomCost = 0;
|
||||
let bomItemsDetail: any[] = [];
|
||||
let hasBom = false;
|
||||
let hasMapping = !!resolvedVariant;
|
||||
|
||||
if (resolvedVariant) {
|
||||
const bItems = resolvedVariant.bomItems || [];
|
||||
unitBomCost = bItems.reduce((acc: number, b: any) => {
|
||||
return acc + (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0);
|
||||
}, 0);
|
||||
|
||||
bomItemsDetail = bItems.map((b: any) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
name: b.rawMaterial?.name || "วัตถุดิบ",
|
||||
unit: b.rawMaterial?.unit || "หน่วย",
|
||||
pricePerUnit: b.rawMaterial?.pricePerUnit || 0,
|
||||
quantity: b.quantity || 0,
|
||||
lineCost: (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0),
|
||||
}));
|
||||
|
||||
hasBom = unitBomCost > 0 && bItems.length > 0;
|
||||
}
|
||||
|
||||
const qty = Number(item.quantity) || 1;
|
||||
const totalBomCost = unitBomCost * qty;
|
||||
const itemRevenue = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
|
||||
const profit = itemRevenue - totalBomCost;
|
||||
const marginPercent = itemRevenue > 0 ? (profit / itemRevenue) * 100 : 0;
|
||||
|
||||
if (!hasBom) {
|
||||
missingBomCount++;
|
||||
}
|
||||
|
||||
orderTotalBomCost += totalBomCost;
|
||||
|
||||
return {
|
||||
...item,
|
||||
unitBomCost,
|
||||
totalBomCost,
|
||||
profit,
|
||||
marginPercent,
|
||||
hasBom,
|
||||
hasMapping,
|
||||
matchedVia,
|
||||
masterProductId: resolvedVariant?.productId || (typeof resolvedVariant?.product?.id === "number" ? resolvedVariant.product.id : null),
|
||||
masterProductName: typeof resolvedVariant?.product?.name === "string" ? resolvedVariant.product.name : (resolvedVariant?.product?.name as any)?.th || (resolvedVariant?.product?.name as any)?.en || null,
|
||||
masterSku: resolvedVariant?.sku || null,
|
||||
masterVariantId: resolvedVariant?.id || null,
|
||||
bomItemsDetail,
|
||||
};
|
||||
});
|
||||
|
||||
const settlement = settlementMap.get(order.orderId);
|
||||
const rawComm = (settlement?.commissionFee ?? order.commissionFee ?? 0);
|
||||
const commissionFee = Math.abs(rawComm);
|
||||
const rawAff = settlement
|
||||
? (settlement.affiliateCommission + settlement.affiliatePartnerFee)
|
||||
: (order.affiliateFee ?? 0);
|
||||
const affiliateFee = Math.abs(rawAff);
|
||||
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (order.orderAmount - commissionFee - affiliateFee);
|
||||
const isSettled = order.isSettled || !!settlement;
|
||||
const settledAt = order.settledAt || settlement?.orderSettledTime || null;
|
||||
|
||||
const orderEstProfit = order.orderAmount - orderTotalBomCost;
|
||||
const orderNetProfit = (isSettled && settlement ? settlement.settlementAmount : (order.orderAmount - commissionFee - affiliateFee)) - orderTotalBomCost;
|
||||
const orderGrossMargin = order.orderAmount > 0 ? (orderEstProfit / order.orderAmount) * 100 : 0;
|
||||
const allBomComplete = missingBomCount === 0 && enrichedItems.length > 0;
|
||||
const hasIncompleteBom = missingBomCount > 0;
|
||||
|
||||
return {
|
||||
...order,
|
||||
items: enrichedItems,
|
||||
settlement: settlement || null,
|
||||
orderTotalBomCost,
|
||||
orderEstProfit,
|
||||
orderNetProfit,
|
||||
commissionFee,
|
||||
affiliateFee,
|
||||
netSettlement,
|
||||
isSettled,
|
||||
settledAt,
|
||||
orderGrossMargin,
|
||||
missingBomCount,
|
||||
allBomComplete,
|
||||
hasIncompleteBom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
|
||||
// ── Import (CSV rows as JSON) ──────────────────────────────────────────────
|
||||
// ── Import Orders (CSV rows as JSON) ───────────────────────────────────────
|
||||
.post(
|
||||
"/import",
|
||||
async ({ body, set }) => {
|
||||
@@ -57,6 +264,8 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
const chunk = entries.slice(i, i + CHUNK);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [orderId, { meta, items }] of chunk) {
|
||||
const existingSettlement = await tx.tiktokSettlement.findUnique({ where: { orderId } });
|
||||
|
||||
// Delete old items first (sync strategy)
|
||||
await tx.tiktokOrderItem.deleteMany({ where: { orderId } });
|
||||
|
||||
@@ -104,6 +313,11 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
shippedTime: parseTiktokDate(meta["Shipped Time"]),
|
||||
deliveredTime: parseTiktokDate(meta["Delivered Time"]),
|
||||
cancelledTime: parseTiktokDate(meta["Cancelled Time"]),
|
||||
commissionFee: existingSettlement ? existingSettlement.commissionFee : 0,
|
||||
affiliateFee: existingSettlement ? (existingSettlement.affiliateCommission + existingSettlement.affiliatePartnerFee) : 0,
|
||||
netSettlement: existingSettlement ? existingSettlement.settlementAmount : 0,
|
||||
settledAt: existingSettlement ? existingSettlement.orderSettledTime : null,
|
||||
isSettled: !!existingSettlement,
|
||||
};
|
||||
|
||||
await tx.tiktokOrder.upsert({
|
||||
@@ -114,7 +328,6 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
upserted++;
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
console.log(`[TiktokOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`);
|
||||
}
|
||||
|
||||
console.log(`[TiktokOrder] ✅ Done — ${upserted} orders upserted`);
|
||||
@@ -128,56 +341,301 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
|
||||
)
|
||||
|
||||
// ── Import Settlement (Income XLSX rows as JSON) ───────────────────────────
|
||||
.post(
|
||||
"/import-settlement",
|
||||
async ({ body, set }) => {
|
||||
try {
|
||||
const { rows, shop } = body as { rows: any[]; shop?: string };
|
||||
const shopName = shop || "hom-aroma";
|
||||
|
||||
console.log(`[TiktokSettlement] Received ${rows.length} raw settlement rows for shop: ${shopName}`);
|
||||
|
||||
let upsertedCount = 0;
|
||||
let matchedOrdersCount = 0;
|
||||
let totalSettlementAmount = 0;
|
||||
let totalCommission = 0;
|
||||
let totalAffiliate = 0;
|
||||
|
||||
const CHUNK = 50;
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const chunk = rows.slice(i, i + CHUNK);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const row of chunk) {
|
||||
const orderId = str(
|
||||
row["หมายเลขคำสั่งซื้อ/การปรับ"] ||
|
||||
row["หมายเลขคำสั่งซื้อ"] ||
|
||||
row["Order ID"] ||
|
||||
row["order_id"]
|
||||
);
|
||||
if (!orderId) continue;
|
||||
|
||||
const transactionType = str(row["ประเภทธุรกรรม"] || row["Transaction Type"]);
|
||||
const orderCreatedTime = str(row["เวลาที่สร้างคำสั่งซื้อ"] || row["Order Created Time"]);
|
||||
const orderSettledTime = str(row["เวลาที่ชำระคำสั่งซื้อ"] || row["Order Settled Time"] || row["เวลาที่สำเร็จ"]);
|
||||
const currency = str(row["สกุลเงิน"] || row["Currency"] || "THB");
|
||||
|
||||
const settlementAmount = toNum(row["ยอดการชำระเงินทั้งหมด"] || row["Total Settlement Amount"] || row["จำนวน"]);
|
||||
const grossRevenue = toNum(row["รายได้ทั้งหมด"] || row["Total Revenue"]);
|
||||
const subtotalAfterSellerDiscount = toNum(row["ยอดรวมค่าสินค้าหลังหักส่วนลดจากผู้ขาย"]);
|
||||
const subtotalBeforeDiscount = toNum(row["ยอดรวมค่าสินค้าก่อนหักส่วนลด"]);
|
||||
const sellerDiscount = toNum(row["ส่วนลดจากร้านค้า"]);
|
||||
const refundAmount = toNum(row["ยอดรวมเงินคืนหลังหักส่วนลดจากผู้ขาย"]);
|
||||
|
||||
const totalFee = toNum(row["ค่าธรรมเนียมทั้งหมด"]);
|
||||
const orderFee = toNum(row["ค่าธรรมเนียมคำสั่งซื้อ"]);
|
||||
const commissionFee = Math.abs(toNum(row["ค่าคอมมิชชั่น TikTok Shop"]));
|
||||
const affiliateCommission = Math.abs(toNum(row["ค่าคอมมิชชั่นแอฟฟิลิเอต"]));
|
||||
const affiliatePartnerFee = Math.abs(toNum(row["ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต"]));
|
||||
const growthSupportFee = toNum(row["ค่าธรรมเนียมสนับสนุนการเติบโตของร้านค้า"]);
|
||||
const shippingFeeActual = toNum(row["ค่าธรรมเนียมการจัดส่งจริง"]);
|
||||
const platformShippingDiscount = toNum(row["ส่วนลดค่าธรรมเนียมการจัดส่งจากแพลตฟอร์ม"]);
|
||||
const customerPayment = toNum(row["การชำระเงินของลูกค้า"]);
|
||||
|
||||
const totalAff = affiliateCommission + affiliatePartnerFee;
|
||||
|
||||
totalSettlementAmount += settlementAmount;
|
||||
totalCommission += commissionFee;
|
||||
totalAffiliate += totalAff;
|
||||
|
||||
// Upsert into TiktokSettlement
|
||||
await tx.tiktokSettlement.upsert({
|
||||
where: { orderId },
|
||||
update: {
|
||||
shop: shopName,
|
||||
transactionType,
|
||||
orderCreatedTime,
|
||||
orderSettledTime,
|
||||
currency,
|
||||
settlementAmount,
|
||||
grossRevenue,
|
||||
subtotalAfterSellerDiscount,
|
||||
subtotalBeforeDiscount,
|
||||
sellerDiscount,
|
||||
refundAmount,
|
||||
totalFee,
|
||||
orderFee,
|
||||
commissionFee,
|
||||
affiliateCommission,
|
||||
affiliatePartnerFee,
|
||||
growthSupportFee,
|
||||
shippingFeeActual,
|
||||
platformShippingDiscount,
|
||||
customerPayment,
|
||||
rawData: row,
|
||||
},
|
||||
create: {
|
||||
orderId,
|
||||
shop: shopName,
|
||||
transactionType,
|
||||
orderCreatedTime,
|
||||
orderSettledTime,
|
||||
currency,
|
||||
settlementAmount,
|
||||
grossRevenue,
|
||||
subtotalAfterSellerDiscount,
|
||||
subtotalBeforeDiscount,
|
||||
sellerDiscount,
|
||||
refundAmount,
|
||||
totalFee,
|
||||
orderFee,
|
||||
commissionFee,
|
||||
affiliateCommission,
|
||||
affiliatePartnerFee,
|
||||
growthSupportFee,
|
||||
shippingFeeActual,
|
||||
platformShippingDiscount,
|
||||
customerPayment,
|
||||
rawData: row,
|
||||
},
|
||||
});
|
||||
upsertedCount++;
|
||||
|
||||
// Update corresponding TiktokOrder if it exists
|
||||
const matchedOrder = await tx.tiktokOrder.findUnique({ where: { orderId } });
|
||||
if (matchedOrder) {
|
||||
await tx.tiktokOrder.update({
|
||||
where: { orderId },
|
||||
data: {
|
||||
commissionFee,
|
||||
affiliateFee: totalAff,
|
||||
netSettlement: settlementAmount,
|
||||
settledAt: orderSettledTime,
|
||||
isSettled: true,
|
||||
},
|
||||
});
|
||||
matchedOrdersCount++;
|
||||
}
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
}
|
||||
|
||||
console.log(`[TiktokSettlement] ✅ Done — ${upsertedCount} settlements saved, ${matchedOrdersCount} orders linked`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `นำเข้าใบแจ้งยอดสำเร็จ ${upsertedCount} รายการ (เชื่อมโยงคำสั่งซื้อแล้ว ${matchedOrdersCount} ออเดอร์)`,
|
||||
settlementsCount: upsertedCount,
|
||||
matchedOrdersCount,
|
||||
totalSettlementAmount,
|
||||
totalCommission,
|
||||
totalAffiliate,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`[TiktokSettlement] ❌ 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.tiktokOrder.deleteMany({ where: { shop } });
|
||||
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
|
||||
const [orderResult, settlementResult] = await Promise.all([
|
||||
prisma.tiktokOrder.deleteMany({ where: { shop } }),
|
||||
prisma.tiktokSettlement.deleteMany({ where: { shop } }),
|
||||
]);
|
||||
return {
|
||||
success: true,
|
||||
message: `ลบข้อมูลร้าน ${shop} สำเร็จ (คำสั่งซื้อ: ${orderResult.count}, ใบแจ้งยอด: ${settlementResult.count})`,
|
||||
count: orderResult.count + settlementResult.count,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`[TiktokOrder] ❌ Clear Error:`, error.message);
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET Single Order ──────────────────────────────────────────────────────
|
||||
// ── GET Single Order Detail (with BOM Breakdown & Settlement) ──────────────
|
||||
.get("/orders/:orderId", async ({ params, set }) => {
|
||||
const order = await prisma.tiktokOrder.findUnique({ where: { orderId: params.orderId }, include: { items: true } });
|
||||
if (!order) { set.status = 404; return { error: "Order not found" }; }
|
||||
return order;
|
||||
const order = await prisma.tiktokOrder.findUnique({
|
||||
where: { orderId: params.orderId },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
const [enriched] = await enrichOrdersWithBOM([order], order.shop);
|
||||
return enriched || order;
|
||||
})
|
||||
|
||||
// ── GET Orders (paginated) ────────────────────────────────────────────────
|
||||
// ── GET Orders (paginated & searchable with BOM & Settlement) ──────────────
|
||||
.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.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, skip, take: limit }),
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 20);
|
||||
const skip = (page - 1) * limit;
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const search = ((query.search as string) || "").trim();
|
||||
const status = (query.status as string) || "";
|
||||
const bomStatus = (query.bomStatus as string) || "all"; // "all" | "missing" | "complete"
|
||||
const settlementStatus = (query.settlementStatus as string) || "all"; // "all" | "settled" | "pending"
|
||||
|
||||
const where: any = { shop };
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ orderId: { contains: search, mode: "insensitive" as const } },
|
||||
{ buyerUsername: { contains: search, mode: "insensitive" as const } },
|
||||
{ trackingId: { contains: search, mode: "insensitive" as const } },
|
||||
{ province: { contains: search, mode: "insensitive" as const } },
|
||||
{ district: { contains: search, mode: "insensitive" as const } },
|
||||
{ items: { some: { productName: { contains: search, mode: "insensitive" as const } } } },
|
||||
{ items: { some: { sellerSku: { contains: search, mode: "insensitive" as const } } } },
|
||||
{ items: { some: { variation: { contains: search, mode: "insensitive" as const } } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (status && status !== "all") {
|
||||
where.status = { contains: status, mode: "insensitive" as const };
|
||||
}
|
||||
|
||||
if (settlementStatus === "settled") {
|
||||
where.isSettled = true;
|
||||
} else if (settlementStatus === "pending") {
|
||||
where.isSettled = false;
|
||||
}
|
||||
|
||||
const [rawItems, total] = await Promise.all([
|
||||
prisma.tiktokOrder.findMany({
|
||||
where,
|
||||
include: { items: true },
|
||||
orderBy: { createdTime: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.tiktokOrder.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
|
||||
const items = await enrichOrdersWithBOM(rawItems, shop);
|
||||
|
||||
let filteredItems = items;
|
||||
if (bomStatus === "missing") {
|
||||
filteredItems = items.filter((o) => o.hasIncompleteBom);
|
||||
} else if (bomStatus === "complete") {
|
||||
filteredItems = items.filter((o) => o.allBomComplete);
|
||||
}
|
||||
|
||||
return { items: filteredItems, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
|
||||
// ── GET Summary ───────────────────────────────────────────────────────────
|
||||
// ── GET Summary (With BOM Cost, Settlement & Profit Breakdown) ─────────────
|
||||
.get("/summary", async ({ query }) => {
|
||||
const shop = (query.shop as string) || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [orderCount, agg, itemAgg] = await Promise.all([
|
||||
const [orderCount, agg, itemAgg, allOrders, settlementAgg] = await Promise.all([
|
||||
prisma.tiktokOrder.count({ where }),
|
||||
prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true } }),
|
||||
prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true, commissionFee: true, affiliateFee: true, netSettlement: true } }),
|
||||
prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }),
|
||||
prisma.tiktokOrder.findMany({ where, include: { items: true } }),
|
||||
prisma.tiktokSettlement.aggregate({ where: { shop }, _sum: { settlementAmount: true, commissionFee: true, affiliateCommission: true, affiliatePartnerFee: true, orderFee: true, totalFee: true } }),
|
||||
]);
|
||||
|
||||
const enrichedOrders = await enrichOrdersWithBOM(allOrders, shop);
|
||||
let totalBOMCost = 0;
|
||||
let ordersMissingBomCount = 0;
|
||||
let ordersWithBOMCount = 0;
|
||||
let totalNetProfit = 0;
|
||||
|
||||
for (const o of enrichedOrders) {
|
||||
totalBOMCost += o.orderTotalBomCost || 0;
|
||||
totalNetProfit += o.orderNetProfit || 0;
|
||||
if (o.hasIncompleteBom) {
|
||||
ordersMissingBomCount++;
|
||||
} else {
|
||||
ordersWithBOMCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const totalAmount = agg._sum.orderAmount ?? 0;
|
||||
const totalEstProfit = totalAmount - totalBOMCost;
|
||||
const grossProfitMargin = totalAmount > 0 ? (totalEstProfit / totalAmount) * 100 : 0;
|
||||
|
||||
const totalCommission = settlementAgg._sum.commissionFee ?? (agg._sum.commissionFee ?? 0);
|
||||
const totalAffiliate = ((settlementAgg._sum.affiliateCommission ?? 0) + (settlementAgg._sum.affiliatePartnerFee ?? 0)) || (agg._sum.affiliateFee ?? 0);
|
||||
const totalSettlementAmount = settlementAgg._sum.settlementAmount ?? (agg._sum.netSettlement ?? 0);
|
||||
|
||||
return {
|
||||
orderCount,
|
||||
totalAmount: agg._sum.orderAmount ?? 0,
|
||||
totalAmount,
|
||||
totalShipping: agg._sum.shippingFee ?? 0,
|
||||
totalRefund: agg._sum.refundAmount ?? 0,
|
||||
totalTaxes: agg._sum.taxes ?? 0,
|
||||
totalItems: itemAgg._sum.quantity ?? 0,
|
||||
totalSkuRevenue: itemAgg._sum.subtotalAfterDiscount ?? 0,
|
||||
totalBOMCost,
|
||||
totalEstProfit,
|
||||
grossProfitMargin,
|
||||
ordersMissingBomCount,
|
||||
ordersWithBOMCount,
|
||||
totalCommission,
|
||||
totalAffiliate,
|
||||
totalSettlementAmount,
|
||||
totalNetProfit,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -194,28 +652,63 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
|
||||
const allOrders = await prisma.tiktokOrder.findMany({
|
||||
const rawOrders = await prisma.tiktokOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderId: true, status: true, subStatus: true, createdTime: true,
|
||||
orderAmount: true, shippingFee: true, refundAmount: true, taxes: true,
|
||||
paymentMethod: true, province: true,
|
||||
items: { select: { quantity: true, subtotalAfterDiscount: true, platformDiscount: true, sellerDiscount: true } },
|
||||
include: {
|
||||
items: true,
|
||||
},
|
||||
orderBy: { createdTime: "asc" },
|
||||
});
|
||||
|
||||
// Enrich with BOM & Settlement
|
||||
const allOrders = await enrichOrdersWithBOM(rawOrders, shop);
|
||||
|
||||
// Monthly aggregation
|
||||
const monthMap = new Map<string, { month: string; revenue: number; orders: number; cancelled: number; items: number; platformDiscount: number; sellerDiscount: number }>();
|
||||
const monthMap = new Map<string, {
|
||||
month: string;
|
||||
revenue: number;
|
||||
orders: number;
|
||||
cancelled: number;
|
||||
items: number;
|
||||
platformDiscount: number;
|
||||
sellerDiscount: number;
|
||||
commission: number;
|
||||
affiliate: number;
|
||||
bomCost: number;
|
||||
netProfit: number;
|
||||
settlement: number;
|
||||
}>();
|
||||
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createdTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthMap.has(month)) monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0 });
|
||||
if (!monthMap.has(month)) {
|
||||
monthMap.set(month, {
|
||||
month,
|
||||
revenue: 0,
|
||||
orders: 0,
|
||||
cancelled: 0,
|
||||
items: 0,
|
||||
platformDiscount: 0,
|
||||
sellerDiscount: 0,
|
||||
commission: 0,
|
||||
affiliate: 0,
|
||||
bomCost: 0,
|
||||
netProfit: 0,
|
||||
settlement: 0,
|
||||
});
|
||||
}
|
||||
const m = monthMap.get(month)!;
|
||||
m.orders += 1;
|
||||
m.revenue += o.orderAmount;
|
||||
m.platformDiscount += o.items.reduce((s, i) => s + i.platformDiscount, 0);
|
||||
m.sellerDiscount += o.items.reduce((s, i) => s + i.sellerDiscount, 0);
|
||||
if (o.status.includes("ยกเลิก") || o.status.includes("cancel")) m.cancelled += 1;
|
||||
m.items += o.items.reduce((s, i) => s + i.quantity, 0);
|
||||
m.platformDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0);
|
||||
m.sellerDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0);
|
||||
m.commission += (o.commissionFee || 0);
|
||||
m.affiliate += (o.affiliateFee || 0);
|
||||
m.bomCost += (o.orderTotalBomCost || 0);
|
||||
m.netProfit += (o.orderNetProfit || 0);
|
||||
m.settlement += (o.netSettlement || 0);
|
||||
if (o.status?.includes("ยกเลิก") || o.status?.includes("cancel")) m.cancelled += 1;
|
||||
m.items += (o.items || []).reduce((s: number, i: any) => s + (i.quantity || 0), 0);
|
||||
}
|
||||
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||
|
||||
@@ -254,23 +747,46 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
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 totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0);
|
||||
const totalPlatformDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.platformDiscount, 0);
|
||||
const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.sellerDiscount, 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length;
|
||||
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
|
||||
const completedOrders = totalOrders - cancelledOrders;
|
||||
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0);
|
||||
const totalPlatformDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.platformDiscount || 0), 0);
|
||||
const totalSellerDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.sellerDiscount || 0), 0);
|
||||
const totalCommission = allOrders.reduce((s, o) => s + (o.commissionFee || 0), 0);
|
||||
const totalAffiliate = allOrders.reduce((s, o) => s + (o.affiliateFee || 0), 0);
|
||||
const totalSettlementAmount = allOrders.reduce((s, o) => s + (o.netSettlement || 0), 0);
|
||||
const totalBOMCost = allOrders.reduce((s, o) => s + (o.orderTotalBomCost || 0), 0);
|
||||
const totalNetProfit = allOrders.reduce((s, o) => s + (o.orderNetProfit || 0), 0);
|
||||
const netProfitMargin = totalRevenue > 0 ? (totalNetProfit / totalRevenue) * 100 : 0;
|
||||
const totalOrders = allOrders.length;
|
||||
const cancelledOrders = allOrders.filter(o => o.status?.includes("ยกเลิก")).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,
|
||||
monthly,
|
||||
statusBreakdown,
|
||||
paymentBreakdown,
|
||||
topProducts,
|
||||
summary: {
|
||||
totalRevenue, totalShipping, totalRefund, totalPlatformDisc, totalSellerDisc,
|
||||
totalRevenue,
|
||||
totalShipping,
|
||||
totalRefund,
|
||||
totalPlatformDisc,
|
||||
totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping,
|
||||
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
|
||||
totalOrders,
|
||||
completedOrders,
|
||||
cancelledOrders,
|
||||
cancelRate,
|
||||
aov,
|
||||
totalCommission,
|
||||
totalAffiliate,
|
||||
totalSettlementAmount,
|
||||
totalBOMCost,
|
||||
totalNetProfit,
|
||||
netProfitMargin,
|
||||
},
|
||||
};
|
||||
})
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const toolsRoute = new Elysia({ prefix: "/tools" })
|
||||
|
||||
// ── 1. GET Master Product Variants (For mapping dropdown/picker) ────────────
|
||||
.get("/marketplace-products/master-variants", async () => {
|
||||
const variants = await prisma.productVariant.findMany({
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: { select: { name: true } },
|
||||
brand: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
bomItems: {
|
||||
include: {
|
||||
rawMaterial: {
|
||||
select: { pricePerUnit: true, name: true, unit: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ product: { name: "asc" } },
|
||||
{ sku: "asc" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = variants.map((v) => {
|
||||
// Calculate unit BOM cost
|
||||
const bomCost = (v.bomItems || []).reduce((sum, b) => {
|
||||
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
|
||||
}, 0);
|
||||
|
||||
const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
|
||||
const displayName = `${v.product?.name || "Unnamed Product"} ${optionsStr ? `(${optionsStr})` : ""} ${v.sku ? `[${v.sku}]` : ""}`.trim();
|
||||
|
||||
return {
|
||||
id: v.id,
|
||||
sku: v.sku || "",
|
||||
barcode: v.barcode || "",
|
||||
price: v.price || 0,
|
||||
stock: v.stock || 0,
|
||||
optionsStr,
|
||||
productName: v.product?.name || "",
|
||||
categoryName: v.product?.category?.name || "",
|
||||
brandName: v.product?.brand?.name || "",
|
||||
bomCost,
|
||||
displayName,
|
||||
};
|
||||
});
|
||||
|
||||
return { variants: result };
|
||||
})
|
||||
|
||||
// ── 2. POST Sync & Extract Marketplace Products ──────────────────────────────
|
||||
.post("/marketplace-products/sync", async () => {
|
||||
// A. Fetch all existing variants for auto-matching
|
||||
const masterVariants = await prisma.productVariant.findMany({
|
||||
select: { id: true, sku: true, barcode: true },
|
||||
});
|
||||
|
||||
const skuToVariantId = new Map<string, number>();
|
||||
for (const v of masterVariants) {
|
||||
if (v.sku?.trim()) skuToVariantId.set(v.sku.trim().toLowerCase(), v.id);
|
||||
if (v.barcode?.trim()) skuToVariantId.set(v.barcode.trim().toLowerCase(), v.id);
|
||||
}
|
||||
|
||||
// Existing marketplace products map to preserve manual mappings
|
||||
const existingMp = await prisma.marketplaceProduct.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
channel: true,
|
||||
shop: true,
|
||||
productName: true,
|
||||
variation: true,
|
||||
marketplaceSku: true,
|
||||
masterVariantId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const existingMap = new Map<string, { id: number; masterVariantId: number | null }>();
|
||||
for (const ep of existingMp) {
|
||||
const key = `${ep.channel}:::${ep.shop}:::${ep.productName}:::${ep.variation}:::${ep.marketplaceSku}`;
|
||||
existingMap.set(key, { id: ep.id, masterVariantId: ep.masterVariantId });
|
||||
}
|
||||
|
||||
// In-memory aggregation map
|
||||
interface AggregatedItem {
|
||||
channel: string;
|
||||
shop: string;
|
||||
marketplaceSku: string;
|
||||
productName: string;
|
||||
variation: string;
|
||||
totalSoldQty: number;
|
||||
totalRevenue: number;
|
||||
firstSoldAt: string | null;
|
||||
lastSoldAt: string | null;
|
||||
}
|
||||
|
||||
const aggregated = new Map<string, AggregatedItem>();
|
||||
|
||||
const updateAgg = (
|
||||
channel: string,
|
||||
shop: string,
|
||||
sku: string,
|
||||
name: string,
|
||||
variation: string,
|
||||
qty: number,
|
||||
rev: number,
|
||||
time: string | null
|
||||
) => {
|
||||
const cleanSku = (sku || "").trim();
|
||||
const cleanName = (name || "").trim() || "ไม่ระบุชื่อสินค้า";
|
||||
const cleanVar = (variation || "").trim();
|
||||
const cleanShop = (shop || "").trim() || "hom-aroma";
|
||||
|
||||
const key = `${channel}:::${cleanShop}:::${cleanName}:::${cleanVar}:::${cleanSku}`;
|
||||
let item = aggregated.get(key);
|
||||
|
||||
if (!item) {
|
||||
item = {
|
||||
channel,
|
||||
shop: cleanShop,
|
||||
marketplaceSku: cleanSku,
|
||||
productName: cleanName,
|
||||
variation: cleanVar,
|
||||
totalSoldQty: 0,
|
||||
totalRevenue: 0,
|
||||
firstSoldAt: time,
|
||||
lastSoldAt: time,
|
||||
};
|
||||
aggregated.set(key, item);
|
||||
}
|
||||
|
||||
item.totalSoldQty += qty;
|
||||
item.totalRevenue += rev;
|
||||
|
||||
if (time) {
|
||||
if (!item.firstSoldAt || time < item.firstSoldAt) item.firstSoldAt = time;
|
||||
if (!item.lastSoldAt || time > item.lastSoldAt) item.lastSoldAt = time;
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Scan TikTok Order Items
|
||||
const tiktokItems = await prisma.tiktokOrderItem.findMany({
|
||||
include: {
|
||||
order: { select: { shop: true, createdTime: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const it of tiktokItems) {
|
||||
const shop = it.order?.shop || "hom-aroma";
|
||||
const sku = it.sellerSku || it.skuId || "";
|
||||
const rev = it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : (it.unitOriginalPrice * (it.quantity || 1));
|
||||
updateAgg("tiktok", shop, sku, it.productName, it.variation || "", it.quantity || 1, rev, it.order?.createdTime || null);
|
||||
}
|
||||
|
||||
// 2. Scan Shopee Order Items
|
||||
const shopeeItems = await prisma.shopeeOrderItem.findMany({
|
||||
include: {
|
||||
order: { select: { shop: true, orderCreatedAt: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const it of shopeeItems) {
|
||||
const shop = it.order?.shop || "hom-aroma";
|
||||
const sku = it.skuRef || it.parentSku || "";
|
||||
const rev = it.totalPayment > 0 ? it.totalPayment : (it.dealPrice * (it.quantity || 1));
|
||||
updateAgg("shopee", shop, sku, it.productName, it.variantName || "", it.quantity || 1, rev, it.order?.orderCreatedAt || null);
|
||||
}
|
||||
|
||||
// 3. Scan Lazada Order Items
|
||||
const lazadaItems = await prisma.lazadaOrderItem.findMany({
|
||||
include: {
|
||||
order: { select: { shop: true, createTime: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const it of lazadaItems) {
|
||||
const shop = it.order?.shop || "hom-aroma";
|
||||
const sku = it.sellerSku || it.lazadaSku || "";
|
||||
const rev = it.paidPrice > 0 ? it.paidPrice : it.unitPrice;
|
||||
updateAgg("lazada", shop, sku, it.itemName, it.variation || "", 1, rev, it.order?.createTime || null);
|
||||
}
|
||||
|
||||
// Upsert into Database
|
||||
let syncedCount = 0;
|
||||
let matchedCount = 0;
|
||||
|
||||
const itemsToProcess = Array.from(aggregated.values());
|
||||
const batchSize = 100;
|
||||
|
||||
for (let i = 0; i < itemsToProcess.length; i += batchSize) {
|
||||
const chunk = itemsToProcess.slice(i, i + batchSize);
|
||||
await prisma.$transaction(
|
||||
chunk.map((item) => {
|
||||
const key = `${item.channel}:::${item.shop}:::${item.productName}:::${item.variation}:::${item.marketplaceSku}`;
|
||||
const existing = existingMap.get(key);
|
||||
|
||||
// Determine masterVariantId
|
||||
let masterVariantId = existing?.masterVariantId || null;
|
||||
|
||||
// If unmapped, try auto-matching by SKU
|
||||
if (!masterVariantId && item.marketplaceSku) {
|
||||
const normalized = item.marketplaceSku.trim().toLowerCase();
|
||||
const autoMatchId = skuToVariantId.get(normalized);
|
||||
if (autoMatchId) {
|
||||
masterVariantId = autoMatchId;
|
||||
matchedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const avgPrice = item.totalSoldQty > 0 ? item.totalRevenue / item.totalSoldQty : 0;
|
||||
|
||||
return prisma.marketplaceProduct.upsert({
|
||||
where: {
|
||||
channel_shop_productName_variation_marketplaceSku: {
|
||||
channel: item.channel,
|
||||
shop: item.shop,
|
||||
productName: item.productName,
|
||||
variation: item.variation,
|
||||
marketplaceSku: item.marketplaceSku,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
channel: item.channel,
|
||||
shop: item.shop,
|
||||
marketplaceSku: item.marketplaceSku,
|
||||
productName: item.productName,
|
||||
variation: item.variation,
|
||||
avgPrice,
|
||||
totalSoldQty: item.totalSoldQty,
|
||||
totalRevenue: item.totalRevenue,
|
||||
firstSoldAt: item.firstSoldAt,
|
||||
lastSoldAt: item.lastSoldAt,
|
||||
masterVariantId,
|
||||
},
|
||||
update: {
|
||||
avgPrice,
|
||||
totalSoldQty: item.totalSoldQty,
|
||||
totalRevenue: item.totalRevenue,
|
||||
firstSoldAt: item.firstSoldAt,
|
||||
lastSoldAt: item.lastSoldAt,
|
||||
// If already mapped manually, preserve it; otherwise use newly matched
|
||||
...(masterVariantId ? { masterVariantId } : {}),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
syncedCount += chunk.length;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
syncedCount,
|
||||
matchedCount,
|
||||
totalMarketplaceProducts: itemsToProcess.length,
|
||||
};
|
||||
})
|
||||
|
||||
// ── 3. GET List of Marketplace Products with Profitability ──────────────────
|
||||
.get("/marketplace-products", async ({ query }) => {
|
||||
const channel = (query.channel as string) || "all";
|
||||
const shop = (query.shop as string) || "all";
|
||||
const status = (query.status as string) || "all"; // 'all' | 'mapped' | 'unmapped'
|
||||
const search = (query.search as string || "").trim();
|
||||
const sortBy = (query.sortBy as string) || "revenue"; // 'revenue' | 'sold' | 'name' | 'recent'
|
||||
const page = parseInt(query.page as string || "1", 10);
|
||||
const limit = parseInt(query.limit as string || "50", 10);
|
||||
|
||||
// Build Where Clause
|
||||
const where: any = {};
|
||||
|
||||
if (channel !== "all") where.channel = channel;
|
||||
if (shop !== "all") where.shop = shop;
|
||||
if (status === "mapped") where.masterVariantId = { not: null };
|
||||
if (status === "unmapped") where.masterVariantId = null;
|
||||
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ productName: { contains: search, mode: "insensitive" } },
|
||||
{ marketplaceSku: { contains: search, mode: "insensitive" } },
|
||||
{ variation: { contains: search, mode: "insensitive" } },
|
||||
{ masterVariant: { sku: { contains: search, mode: "insensitive" } } },
|
||||
{ masterVariant: { product: { name: { contains: search, mode: "insensitive" } } } },
|
||||
];
|
||||
}
|
||||
|
||||
// Build OrderBy Clause
|
||||
let orderBy: any = [{ totalRevenue: "desc" }];
|
||||
if (sortBy === "sold") orderBy = [{ totalSoldQty: "desc" }];
|
||||
if (sortBy === "name") orderBy = [{ productName: "asc" }];
|
||||
if (sortBy === "recent") orderBy = [{ lastSoldAt: "desc" }];
|
||||
|
||||
// Fetch Global KPIs across all items (without pagination limits)
|
||||
const allItems = await prisma.marketplaceProduct.findMany({
|
||||
where: channel !== "all" || shop !== "all" ? { ...(channel !== "all" ? { channel } : {}), ...(shop !== "all" ? { shop } : {}) } : {},
|
||||
include: {
|
||||
masterVariant: {
|
||||
include: {
|
||||
bomItems: {
|
||||
include: { rawMaterial: { select: { pricePerUnit: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let totalSkus = allItems.length;
|
||||
let mappedSkus = 0;
|
||||
let totalSoldQty = 0;
|
||||
let totalRevenue = 0;
|
||||
let totalEstCost = 0;
|
||||
let totalEstProfit = 0;
|
||||
|
||||
for (const item of allItems) {
|
||||
totalSoldQty += item.totalSoldQty;
|
||||
totalRevenue += item.totalRevenue;
|
||||
|
||||
if (item.masterVariantId && item.masterVariant) {
|
||||
mappedSkus++;
|
||||
const unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => {
|
||||
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
|
||||
}, 0);
|
||||
|
||||
const cost = unitBomCost * item.totalSoldQty;
|
||||
totalEstCost += cost;
|
||||
totalEstProfit += (item.totalRevenue - cost);
|
||||
}
|
||||
}
|
||||
|
||||
const unmappedSkus = totalSkus - mappedSkus;
|
||||
const mappedPercent = totalSkus > 0 ? (mappedSkus / totalSkus) * 100 : 0;
|
||||
const grossProfitMargin = totalRevenue > 0 ? (totalEstProfit / totalRevenue) * 100 : 0;
|
||||
|
||||
// Fetch Paginated Items
|
||||
const [items, totalCount] = await Promise.all([
|
||||
prisma.marketplaceProduct.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
include: {
|
||||
masterVariant: {
|
||||
include: {
|
||||
product: {
|
||||
select: { id: true, name: true, category: { select: { name: true } } },
|
||||
},
|
||||
bomItems: {
|
||||
include: {
|
||||
rawMaterial: { select: { name: true, pricePerUnit: true, unit: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.marketplaceProduct.count({ where }),
|
||||
]);
|
||||
|
||||
// Format output with profitability details
|
||||
const formattedItems = items.map((item) => {
|
||||
let unitBomCost = 0;
|
||||
let totalCost = 0;
|
||||
let profit = 0;
|
||||
let marginPercent = 0;
|
||||
|
||||
if (item.masterVariant) {
|
||||
unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => {
|
||||
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
|
||||
}, 0);
|
||||
totalCost = unitBomCost * item.totalSoldQty;
|
||||
profit = item.totalRevenue - totalCost;
|
||||
marginPercent = item.totalRevenue > 0 ? (profit / item.totalRevenue) * 100 : 0;
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
channel: item.channel,
|
||||
shop: item.shop,
|
||||
marketplaceSku: item.marketplaceSku,
|
||||
productName: item.productName,
|
||||
variation: item.variation,
|
||||
avgPrice: item.avgPrice,
|
||||
totalSoldQty: item.totalSoldQty,
|
||||
totalRevenue: item.totalRevenue,
|
||||
firstSoldAt: item.firstSoldAt,
|
||||
lastSoldAt: item.lastSoldAt,
|
||||
masterVariantId: item.masterVariantId,
|
||||
masterVariant: item.masterVariant
|
||||
? {
|
||||
id: item.masterVariant.id,
|
||||
sku: item.masterVariant.sku,
|
||||
barcode: item.masterVariant.barcode,
|
||||
productName: item.masterVariant.product?.name,
|
||||
categoryName: item.masterVariant.product?.category?.name,
|
||||
option1: item.masterVariant.option1,
|
||||
option2: item.masterVariant.option2,
|
||||
option3: item.masterVariant.option3,
|
||||
unitBomCost,
|
||||
}
|
||||
: null,
|
||||
profitability: item.masterVariant
|
||||
? {
|
||||
unitBomCost,
|
||||
totalCost,
|
||||
profit,
|
||||
marginPercent,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
kpi: {
|
||||
totalSkus,
|
||||
mappedSkus,
|
||||
unmappedSkus,
|
||||
mappedPercent,
|
||||
totalSoldQty,
|
||||
totalRevenue,
|
||||
totalEstCost,
|
||||
totalEstProfit,
|
||||
grossProfitMargin,
|
||||
},
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalCount / limit) || 1,
|
||||
},
|
||||
items: formattedItems,
|
||||
};
|
||||
})
|
||||
|
||||
// ── 4. POST Link / Unlink Master Variant ─────────────────────────────────────
|
||||
.post(
|
||||
"/marketplace-products/map",
|
||||
async ({ body }: { body: { marketplaceProductId: number; masterVariantId: number | null } }) => {
|
||||
const { marketplaceProductId, masterVariantId } = body;
|
||||
|
||||
const updated = await prisma.marketplaceProduct.update({
|
||||
where: { id: marketplaceProductId },
|
||||
data: { masterVariantId },
|
||||
include: {
|
||||
masterVariant: {
|
||||
include: {
|
||||
product: { select: { id: true, name: true } },
|
||||
bomItems: {
|
||||
include: { rawMaterial: { select: { pricePerUnit: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, item: updated };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
marketplaceProductId: t.Number(),
|
||||
masterVariantId: t.Nullable(t.Number()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// ── 5. POST Bulk Auto Match Unmapped Items ──────────────────────────────────
|
||||
.post("/marketplace-products/bulk-auto-match", async () => {
|
||||
const masterVariants = await prisma.productVariant.findMany({
|
||||
select: { id: true, sku: true, barcode: true },
|
||||
});
|
||||
|
||||
const skuMap = new Map<string, number>();
|
||||
for (const v of masterVariants) {
|
||||
if (v.sku?.trim()) skuMap.set(v.sku.trim().toLowerCase(), v.id);
|
||||
if (v.barcode?.trim()) skuMap.set(v.barcode.trim().toLowerCase(), v.id);
|
||||
}
|
||||
|
||||
const unmapped = await prisma.marketplaceProduct.findMany({
|
||||
where: { masterVariantId: null, marketplaceSku: { not: "" } },
|
||||
});
|
||||
|
||||
let matched = 0;
|
||||
for (const item of unmapped) {
|
||||
const norm = item.marketplaceSku.trim().toLowerCase();
|
||||
const variantId = skuMap.get(norm);
|
||||
if (variantId) {
|
||||
await prisma.marketplaceProduct.update({
|
||||
where: { id: item.id },
|
||||
data: { masterVariantId: variantId },
|
||||
});
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
|
||||
const remainingUnmapped = await prisma.marketplaceProduct.count({
|
||||
where: { masterVariantId: null },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
matched,
|
||||
remainingUnmapped,
|
||||
};
|
||||
});
|
||||
@@ -144,3 +144,110 @@ export function cleanDistrictName(input: string | null | undefined): string {
|
||||
.replace(/^(อำเภอ|อ\.|เขต|แขวง|ตำบล|ต\.)\s*/, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const POSTCODE_EXCEPTIONS: Record<string, string> = {
|
||||
'10540': 'สมุทรปราการ',
|
||||
'10270': 'สมุทรปราการ',
|
||||
'10280': 'สมุทรปราการ',
|
||||
'10560': 'สมุทรปราการ',
|
||||
'13170': 'พระนครศรีอยุธยา',
|
||||
'13180': 'พระนครศรีอยุธยา',
|
||||
};
|
||||
|
||||
const POSTCODE_PREFIX_TO_PROVINCE_NAME: Record<string, string> = {
|
||||
'10': 'กรุงเทพมหานคร',
|
||||
'11': 'นนทบุรี',
|
||||
'12': 'ปทุมธานี',
|
||||
'13': 'พระนครศรีอยุธยา',
|
||||
'14': 'อ่างทอง',
|
||||
'15': 'ลพบุรี',
|
||||
'16': 'สิงห์บุรี',
|
||||
'17': 'ชัยนาท',
|
||||
'18': 'สระบุรี',
|
||||
'19': 'สระบุรี',
|
||||
'20': 'ชลบุรี',
|
||||
'21': 'ระยอง',
|
||||
'22': 'จันทบุรี',
|
||||
'23': 'ตราด',
|
||||
'24': 'ฉะเชิงเทรา',
|
||||
'25': 'ปราจีนบุรี',
|
||||
'26': 'นครนายก',
|
||||
'27': 'สระแก้ว',
|
||||
'30': 'นครราชสีมา',
|
||||
'31': 'บุรีรัมย์',
|
||||
'32': 'สุรินทร์',
|
||||
'33': 'ศรีสะเกษ',
|
||||
'34': 'อุบลราชธานี',
|
||||
'35': 'ยโสธร',
|
||||
'36': 'ชัยภูมิ',
|
||||
'37': 'อำนาจเจริญ',
|
||||
'38': 'บึงกาฬ',
|
||||
'39': 'หนองบัวลำภู',
|
||||
'40': 'ขอนแก่น',
|
||||
'41': 'อุดรธานี',
|
||||
'42': 'เลย',
|
||||
'43': 'หนองคาย',
|
||||
'44': 'มหาสารคาม',
|
||||
'45': 'ร้อยเอ็ด',
|
||||
'46': 'กาฬสินธุ์',
|
||||
'47': 'สกลนคร',
|
||||
'48': 'นครพนม',
|
||||
'49': 'มุกดาหาร',
|
||||
'50': 'เชียงใหม่',
|
||||
'51': 'ลำพูน',
|
||||
'52': 'ลำปาง',
|
||||
'53': 'อุตรดิตถ์',
|
||||
'54': 'แพร่',
|
||||
'55': 'น่าน',
|
||||
'56': 'พะเยา',
|
||||
'57': 'เชียงราย',
|
||||
'58': 'แม่ฮ่องสอน',
|
||||
'60': 'นครสวรรค์',
|
||||
'61': 'อุทัยธานี',
|
||||
'62': 'กำแพงเพชร',
|
||||
'63': 'ตาก',
|
||||
'64': 'สุโขทัย',
|
||||
'65': 'พิษณุโลก',
|
||||
'66': 'พิจิตร',
|
||||
'67': 'เพชรบูรณ์',
|
||||
'70': 'ราชบุรี',
|
||||
'71': 'กาญจนบุรี',
|
||||
'72': 'สุพรรณบุรี',
|
||||
'73': 'นครปฐม',
|
||||
'74': 'สมุทรสาคร',
|
||||
'75': 'สมุทรสงคราม',
|
||||
'76': 'เพชรบุรี',
|
||||
'77': 'ประจวบคีรีขันธ์',
|
||||
'80': 'นครศรีธรรมราช',
|
||||
'81': 'กระบี่',
|
||||
'82': 'พังงา',
|
||||
'83': 'ภูเก็ต',
|
||||
'84': 'สุราษฎร์ธานี',
|
||||
'85': 'ระนอง',
|
||||
'86': 'ชุมพร',
|
||||
'90': 'สงขลา',
|
||||
'91': 'สตูล',
|
||||
'92': 'ตรัง',
|
||||
'93': 'พัทลุง',
|
||||
'94': 'ปัตตานี',
|
||||
'95': 'ยะลา',
|
||||
'96': 'นราธิวาส',
|
||||
};
|
||||
|
||||
export function findProvinceByPostcode(postcode: string | null | undefined): ProvinceMeta | null {
|
||||
if (!postcode) return null;
|
||||
const clean = String(postcode).replace(/\D/g, '').trim();
|
||||
if (clean.length < 2) return null;
|
||||
|
||||
if (POSTCODE_EXCEPTIONS[clean]) {
|
||||
const provName = POSTCODE_EXCEPTIONS[clean];
|
||||
return THAILAND_PROVINCES.find(p => p.nameTh === provName) || null;
|
||||
}
|
||||
|
||||
const prefix = clean.slice(0, 2);
|
||||
const provName = POSTCODE_PREFIX_TO_PROVINCE_NAME[prefix];
|
||||
if (provName) {
|
||||
return THAILAND_PROVINCES.find(p => p.nameTh === provName) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user