This commit is contained in:
Your Name committed 2026-09-06 14:39:13 +07:00
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;
+78 -3
View File
@@ -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])
}
+3 -1
View File
@@ -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
+216
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => {
if (val === null || val === undefined || val === "") return 0;
@@ -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,
})),
};
}),
};
});
+563 -47
View File
@@ -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,
},
};
})
+509
View File
@@ -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,
};
});
+107
View File
@@ -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;
}