From 2c8da0ae2ac96fab060ad0cf8ad139e6bd5eb2dd Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 6 Sep 2026 14:39:13 +0700 Subject: [PATCH] x --- apps/admin/src/App.tsx | 13 + apps/admin/src/components/Sidebar.tsx | 74 + .../analytics/OmnichannelDistrictModal.tsx | 522 ++++++ .../analytics/ProductDistrictModal.tsx | 523 +++++++ .../tiktok-map/DistrictMapModal.tsx | 16 +- apps/admin/src/pages/ProductBomPage.tsx | 536 +++++-- .../admin/src/pages/ShopeeOrderDetailPage.tsx | 5 +- apps/admin/src/pages/ShopeePage.tsx | 222 ++- apps/admin/src/pages/ShopeeSalesMapPage.tsx | 525 +++++++ apps/admin/src/pages/TiktokAnalyticsPage.tsx | 144 +- .../admin/src/pages/TiktokOrderDetailPage.tsx | 512 ++++++ apps/admin/src/pages/TiktokOrderPage.tsx | 1068 +++++++++++-- .../analytics/OmnichannelSalesMapPage.tsx | 829 ++++++++++ .../pages/analytics/ProductSalesMapPage.tsx | 970 ++++++++++++ .../pages/tools/MarketplaceProductsPage.tsx | 908 +++++++++++ .../migration.sql | 40 + apps/backend/prisma/schema.prisma | 81 +- apps/backend/src/index.ts | 4 +- apps/backend/src/routes/businessAnalytics.ts | 1393 +++++++++++++++++ apps/backend/src/routes/shopee.ts | 216 +++ apps/backend/src/routes/tiktokOrder.ts | 610 +++++++- apps/backend/src/routes/tools.ts | 509 ++++++ apps/backend/src/utils/thaiProvinces.ts | 107 ++ income_20260906105930(UTC+7).xlsx | Bin 0 -> 67014 bytes 24 files changed, 9413 insertions(+), 414 deletions(-) create mode 100644 apps/admin/src/components/analytics/OmnichannelDistrictModal.tsx create mode 100644 apps/admin/src/components/analytics/ProductDistrictModal.tsx create mode 100644 apps/admin/src/pages/ShopeeSalesMapPage.tsx create mode 100644 apps/admin/src/pages/TiktokOrderDetailPage.tsx create mode 100644 apps/admin/src/pages/analytics/OmnichannelSalesMapPage.tsx create mode 100644 apps/admin/src/pages/analytics/ProductSalesMapPage.tsx create mode 100644 apps/admin/src/pages/tools/MarketplaceProductsPage.tsx create mode 100644 apps/backend/prisma/migrations/20260906094000_add_marketplace_product/migration.sql create mode 100644 apps/backend/src/routes/businessAnalytics.ts create mode 100644 apps/backend/src/routes/tools.ts create mode 100644 income_20260906105930(UTC+7).xlsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 9a83d06..ae8fda5 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -32,9 +32,11 @@ import TiktokPage from './pages/TiktokPage' import TiktokAnalysisPage from './pages/TiktokAnalysisPage' import ShopeePage from './pages/ShopeePage' import ShopeeOrderDetailPage from './pages/ShopeeOrderDetailPage' +import ShopeeSalesMapPage from './pages/ShopeeSalesMapPage' import ShopeeAnalyticsPage from './pages/ShopeeAnalyticsPage' import ShopeeOverviewPage from './pages/ShopeeOverviewPage' import TiktokOrderPage from './pages/TiktokOrderPage' +import TiktokOrderDetailPage from './pages/TiktokOrderDetailPage' import TiktokSalesMapPage from './pages/TiktokSalesMapPage' import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage' import TiktokOverviewPage from './pages/TiktokOverviewPage' @@ -54,6 +56,9 @@ import BackupCatalogPage from './pages/backup/BackupCatalogPage' import BackupOrdersPage from './pages/backup/BackupOrdersPage' import BackupCmsPage from './pages/backup/BackupCmsPage' import BackupMediaPage from './pages/backup/BackupMediaPage' +import OmnichannelSalesMapPage from './pages/analytics/OmnichannelSalesMapPage' +import ProductSalesMapPage from './pages/analytics/ProductSalesMapPage' +import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage' function App() { return ( @@ -103,9 +108,11 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> @@ -113,6 +120,12 @@ function App() { } /> } /> } /> + {/* Business Analytics */} + } /> + } /> + {/* Tools */} + } /> + } /> {/* Backup & Restore */} } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index 6804e64..3ebeda0 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -54,6 +54,15 @@ const SALES_MENU = [ { icon: Globe2, label: 'All Analytics', to: '/global-analytics' }, ] +const BUSINESS_ANALYTICS_MENU = [ + { icon: Globe2, label: 'Omnichannel Sales Map', to: '/business-analytics/sales-map' }, + { icon: Package, label: 'Product Sales Map', to: '/business-analytics/sales-map-products' }, +] + +const TOOL_MENU = [ + { icon: Package, label: 'Marketplace Products', to: '/tools/marketplace-products' }, +] + const TIKTOK_MENU = [ { icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' }, { icon: Map, label: 'Thailand Sales Map', to: '/tiktok-sales-map' }, @@ -70,6 +79,7 @@ const TIKTOK_LEGACY_MENU = [ const SHOPEE_MENU = [ { icon: ShoppingBag, label: 'Shopee (Hom Aroma)', to: '/shopee' }, + { icon: Map, label: 'Thailand Sales Map', to: '/shopee-sales-map' }, { icon: BarChart3, label: 'Shopee Analytics', to: '/shopee-analytics' }, { icon: Layers, label: 'Shopee ทุกร้าน', to: '/shopee-overview' }, { icon: Info, label: 'Shopee Shop Case', to: '/shopee-shop-case' }, @@ -127,6 +137,8 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { const location = useLocation() const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p)) const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p)) + const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') + const isInTools = location.pathname.startsWith('/tools') const isInTiktokLegacy = location.pathname === '/tiktok' || location.pathname.startsWith('/tiktok-analysis') const isInTiktok = location.pathname.startsWith('/tiktok') && !isInTiktokLegacy const isInShopee = location.pathname.startsWith('/shopee') @@ -139,6 +151,8 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { const [productOpen, setProductOpen] = useState(isInProduct) const [salesOpen, setSalesOpen] = useState(isInSales) + const [businessAnalyticsOpen, setBusinessAnalyticsOpen] = useState(isInBusinessAnalytics) + const [toolsOpen, setToolsOpen] = useState(isInTools) const [tiktokOpen, setTiktokOpen] = useState(isInTiktok) const [tiktokLegacyOpen, setTiktokLegacyOpen] = useState(isInTiktokLegacy) const [shopeeOpen, setShopeeOpen] = useState(isInShopee) @@ -260,6 +274,66 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { )} + {/* Business Analytics Group */} +
+ + + {!isCollapsed && businessAnalyticsOpen && ( +
+ {BUSINESS_ANALYTICS_MENU.map((item) => ( + + `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` + } + > + + {item.label} + + ))} +
+ )} +
+ + {/* Tools Group */} +
+ + + {!isCollapsed && toolsOpen && ( +
+ {TOOL_MENU.map((item) => ( + + `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` + } + > + + {item.label} + + ))} +
+ )} +
+ {/* TikTok Group */}
+
+ + {/* Modal Body: 3 Columns Layout */} +
+ {/* Col 1: District SVG Map (5 cols) */} +
+
+ + แผนที่รายอำเภอ ({paths.length} อำเภอ) + + คลิกที่อำเภอเพื่อเลือกหลายพื้นที่ +
+ +
setHoveredDistrict(null)} + > + {loadingMap || loadingData ? ( +
+ ⏳ กำลังโหลดแผนที่อำเภอ... +
+ ) : paths.length === 0 ? ( +
+ ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้ +
+ ) : ( + + + {paths.map((item: any, idx: number) => ( + toggleDistrict(item.districtName)} + onMouseMove={(e) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + setTooltipPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + setHoveredDistrict(item); + } + }} + /> + ))} + + + )} + + {/* Hover Tooltip */} + {hoveredDistrict && tooltipPos && ( +
+

+ + อ.{hoveredDistrict.districtName} +

+

+ {fmtBaht(hoveredDistrict.revenue)} ({hoveredDistrict.orders} ออเดอร์) +

+ {(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && ( +
+ TT: {fmtBaht(hoveredDistrict.tiktokRevenue)} + • + SP: {fmtBaht(hoveredDistrict.shopeeRevenue)} + • + LZ: {fmtBaht(hoveredDistrict.lazadaRevenue)} +
+ )} +
+ )} +
+ + {/* Map Legend */} +
+
+ + ฿0 +
+
+ + ฿{fmtNum(maxDistrictRevenue)} +
+
+ + เลือกอยู่ +
+
+
+ + {/* Col 2 & 3: Selection Stats & Order Table (7 cols) */} +
+ {/* Filter & Summary Header */} +
+
+
+ + + + +
+ + {selectedDistricts.size > 0 && ( + + )} +
+ + {/* KPI Cards for Selection */} +
+
+

ยอดขายรวม

+

{fmtBaht(selectionSummary.revenue)}

+
+
+

คำสั่งซื้อ

+

{selectionSummary.orders} ออเดอร์

+
+
+

TikTok

+

{fmtBaht(selectionSummary.tiktokRevenue)}

+
+
+

Shopee

+

{fmtBaht(selectionSummary.shopeeRevenue)}

+
+
+

Lazada

+

{fmtBaht(selectionSummary.lazadaRevenue)}

+
+
+ + {/* District Tags */} + {selectedDistricts.size > 0 && ( +
+ กรองเฉพาะ: + {Array.from(selectedDistricts).map((dName) => ( + + อ.{dName} + + + ))} +
+ )} + + {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40" + /> +
+
+ + {/* Orders Table */} +
+ {loadingData ? ( +
กำลังโหลดข้อมูลคำสั่งซื้อ...
+ ) : filteredOrders.length === 0 ? ( +
+ ไม่พบคำสั่งซื้อตามเงื่อนไขที่เลือก +
+ ) : ( +
+ {filteredOrders.map((order: any) => ( +
+
+
+
+ {order.channel === 'tiktok' ? ( + + TikTok + + ) : order.channel === 'shopee' ? ( + + Shopee + + ) : ( + + Lazada + + )} + {order.orderId} + + {order.shop} + +
+

+ ผู้ซื้อ: {order.buyerUsername || '-'} • อ.{order.district} • {order.createdTime?.slice(0, 16)} +

+
+
+

{fmtBaht(order.orderAmount)}

+

ค่าส่ง {fmtBaht(order.shippingFee)}

+
+
+ + {/* Items Preview */} + {order.items?.length > 0 && ( +
+ {order.items.map((it: any, iIdx: number) => ( +
+ + • {it.productName} {it.variation ? `(${it.variation})` : ''} + + + x{it.quantity} ({fmtBaht(it.subtotalAfterDiscount)}) + +
+ ))} +
+ )} + +
+ + ดูหน้าออเดอร์เต็ม + +
+
+ ))} +
+ )} +
+
+
+ + + ); +} diff --git a/apps/admin/src/components/analytics/ProductDistrictModal.tsx b/apps/admin/src/components/analytics/ProductDistrictModal.tsx new file mode 100644 index 0000000..5704b58 --- /dev/null +++ b/apps/admin/src/components/analytics/ProductDistrictModal.tsx @@ -0,0 +1,523 @@ +import { useEffect, useState, useMemo, useRef } from 'react'; +import * as d3Geo from 'd3-geo'; +import { X, Search, RotateCcw, MapPin, ExternalLink, Package } from 'lucide-react'; +import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils'; +import { API_BASE } from '@homaroma/types'; +import { Link } from 'react-router-dom'; + +interface ProductDistrictModalProps { + provinceName: string; + productId?: number | null; + variantId?: number | null; + productName?: string; + variantName?: string; + channel: string; + statusFilter?: string; + dateFrom?: string; + dateTo?: string; + onClose: () => void; +} + +export default function ProductDistrictModal({ + provinceName, + productId, + variantId, + productName, + variantName, + channel, + statusFilter, + dateFrom, + dateTo, + onClose, +}: ProductDistrictModalProps) { + const [districtGeo, setDistrictGeo] = useState(null); + const [loadingMap, setLoadingMap] = useState(true); + const [loadingData, setLoadingData] = useState(true); + const [districtData, setDistrictData] = useState(null); + + const [selectedDistricts, setSelectedDistricts] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [channelFilter, setChannelFilter] = useState<'all' | 'tiktok' | 'shopee' | 'lazada'>('all'); + const [hoveredDistrict, setHoveredDistrict] = useState(null); + const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null); + + const containerRef = useRef(null); + const width = 450; + const height = 450; + + // Load district GeoJSON + useEffect(() => { + fetch('/maps/thailand-districts.json') + .then((r) => r.json()) + .then((data) => setDistrictGeo(data)) + .catch((err) => console.error('Failed to load districts GeoJSON:', err)) + .finally(() => setLoadingMap(false)); + }, []); + + // Fetch backend API for product district level stats & orders + useEffect(() => { + setLoadingData(true); + const params = new URLSearchParams(); + params.set('province', provinceName); + if (variantId) params.set('variantId', variantId.toString()); + else if (productId) params.set('productId', productId.toString()); + + if (channel && channel !== 'all') params.set('channel', channel); + if (statusFilter) params.set('statusFilter', statusFilter); + if (dateFrom) params.set('dateFrom', dateFrom); + if (dateTo) params.set('dateTo', dateTo); + + fetch(`${API_BASE}/business-analytics/sales-map-products/district?${params.toString()}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((r) => r.json()) + .then((data) => setDistrictData(data)) + .catch((err) => console.error('Failed to load product district data:', err)) + .finally(() => setLoadingData(false)); + }, [provinceName, productId, variantId, channel, statusFilter, dateFrom, dateTo]); + + // Filter district GeoJSON for this province + const provinceDistrictsGeo = useMemo(() => { + if (!districtGeo?.features) return []; + return districtGeo.features.filter((f: any) => { + const pName = f.properties.pro_th || f.properties.pro_en || ''; + return pName.includes(provinceName) || provinceName.includes(pName); + }); + }, [districtGeo, provinceName]); + + // Calculate max district revenue for color scale + const maxDistrictRevenue = useMemo(() => { + if (!districtData?.districts?.length) return 1; + return Math.max(...districtData.districts.map((d: any) => d.revenue), 1); + }, [districtData]); + + // District sales map lookup + const districtSalesMap = useMemo(() => { + const map = new Map(); + if (districtData?.districts) { + for (const d of districtData.districts) { + map.set(d.districtName, d); + } + } + return map; + }, [districtData]); + + // Projection tailored to the province districts + const paths = useMemo(() => { + if (provinceDistrictsGeo.length === 0) return []; + + const featureCollection: any = { + type: 'FeatureCollection', + features: provinceDistrictsGeo, + }; + + const projection = d3Geo.geoMercator().fitSize([width, height], featureCollection); + const generator = d3Geo.geoPath().projection(projection); + + return provinceDistrictsGeo.map((feature: any) => { + const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim(); + const pathData = generator(feature); + const sales = districtSalesMap.get(dName); + const revenue = sales?.revenue || 0; + const unitsSold = sales?.unitsSold || 0; + const orders = sales?.orders || 0; + const tiktokRevenue = sales?.tiktokRevenue || 0; + const shopeeRevenue = sales?.shopeeRevenue || 0; + const lazadaRevenue = sales?.lazadaRevenue || 0; + const isSelected = selectedDistricts.has(dName); + + return { + feature, + pathData, + districtName: dName, + revenue, + unitsSold, + orders, + tiktokRevenue, + shopeeRevenue, + lazadaRevenue, + isSelected, + color: getDistrictColor(revenue, maxDistrictRevenue, isSelected), + }; + }); + }, [provinceDistrictsGeo, districtSalesMap, maxDistrictRevenue, selectedDistricts]); + + const toggleDistrict = (dName: string) => { + setSelectedDistricts((prev) => { + const next = new Set(prev); + if (next.has(dName)) next.delete(dName); + else next.add(dName); + return next; + }); + }; + + const clearSelectedDistricts = () => setSelectedDistricts(new Set()); + + // Filter orders by selected districts, channel filter, and search query + const filteredOrders = useMemo(() => { + if (!districtData?.orders) return []; + return districtData.orders.filter((o: any) => { + if (selectedDistricts.size > 0 && !selectedDistricts.has(o.district)) { + return false; + } + if (channelFilter !== 'all' && o.channel !== channelFilter) { + return false; + } + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + const inId = o.orderId.toLowerCase().includes(q); + const inUser = o.buyerUsername?.toLowerCase().includes(q); + const inDist = o.district?.toLowerCase().includes(q); + const inSku = o.items?.some((i: any) => + i.productName?.toLowerCase().includes(q) || i.sellerSku?.toLowerCase().includes(q) + ); + return inId || inUser || inDist || inSku; + } + return true; + }); + }, [districtData, selectedDistricts, channelFilter, searchQuery]); + + // Summary stats for selection + const selectionSummary = useMemo(() => { + if (!districtData?.orders) return { revenue: 0, units: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; + const orders = filteredOrders; + const rev = orders.reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0); + const units = orders.reduce((sum: number, o: any) => sum + (o.items?.reduce((is: number, it: any) => is + it.quantity, 0) || 0), 0); + const ttRev = orders.filter((o: any) => o.channel === 'tiktok').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0); + const spRev = orders.filter((o: any) => o.channel === 'shopee').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0); + const lzRev = orders.filter((o: any) => o.channel === 'lazada').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0); + return { + revenue: rev, + units, + orders: orders.length, + tiktokRevenue: ttRev, + shopeeRevenue: spRev, + lazadaRevenue: lzRev, + }; + }, [districtData, filteredOrders]); + + return ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+
+

จังหวัด{provinceName}

+ + + {productName} {variantName ? `(${variantName})` : ''} + +
+

+ สถิติยอดสั่งซื้อสินค้านี้แยกรายอำเภอ และตรวจสอบคำสั่งซื้อจริงข้ามแพลตฟอร์ม +

+
+
+ +
+ + {/* Modal Body: 3 Columns Layout */} +
+ {/* Col 1: District SVG Map (5 cols) */} +
+
+ + แผนที่รายอำเภอ ({paths.length} อำเภอ) + + คลิกที่อำเภอเพื่อเลือกหลายพื้นที่ +
+ +
setHoveredDistrict(null)} + > + {loadingMap || loadingData ? ( +
+ ⏳ กำลังโหลดแผนที่อำเภอ... +
+ ) : paths.length === 0 ? ( +
+ ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้ +
+ ) : ( + + + {paths.map((item: any, idx: number) => ( + toggleDistrict(item.districtName)} + onMouseMove={(e) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + setTooltipPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + setHoveredDistrict(item); + } + }} + /> + ))} + + + )} + + {/* Hover Tooltip */} + {hoveredDistrict && tooltipPos && ( +
+

+ + อ.{hoveredDistrict.districtName} +

+

+ {fmtBaht(hoveredDistrict.revenue)} ({hoveredDistrict.unitsSold} ชิ้น) +

+ {(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && ( +
+ TT: {fmtBaht(hoveredDistrict.tiktokRevenue)} + • + SP: {fmtBaht(hoveredDistrict.shopeeRevenue)} + • + LZ: {fmtBaht(hoveredDistrict.lazadaRevenue)} +
+ )} +
+ )} +
+ + {/* Map Legend */} +
+
+ + ฿0 +
+
+ + ฿{fmtNum(maxDistrictRevenue)} +
+
+ + เลือกอยู่ +
+
+
+ + {/* Col 2 & 3: Selection Stats & Order Table (7 cols) */} +
+ {/* Filter & Summary Header */} +
+
+
+ + + + +
+ + {selectedDistricts.size > 0 && ( + + )} +
+ + {/* KPI Cards for Selection */} +
+
+

ยอดขายรวม

+

{fmtBaht(selectionSummary.revenue)}

+
+
+

จำนวนชิ้นที่ขาย

+

{selectionSummary.units} ชิ้น

+
+
+

TikTok

+

{fmtBaht(selectionSummary.tiktokRevenue)}

+
+
+

Shopee

+

{fmtBaht(selectionSummary.shopeeRevenue)}

+
+
+

Lazada

+

{fmtBaht(selectionSummary.lazadaRevenue)}

+
+
+ + {/* District Tags */} + {selectedDistricts.size > 0 && ( +
+ กรองเฉพาะ: + {Array.from(selectedDistricts).map((dName) => ( + + อ.{dName} + + + ))} +
+ )} + + {/* Search Bar */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40" + /> +
+
+ + {/* Orders Table */} +
+ {loadingData ? ( +
กำลังโหลดข้อมูลคำสั่งซื้อ...
+ ) : filteredOrders.length === 0 ? ( +
+ ไม่พบคำสั่งซื้อที่มีสินค้านี้ตามเงื่อนไขที่เลือก +
+ ) : ( +
+ {filteredOrders.map((order: any) => ( +
+
+
+
+ {order.channel === 'tiktok' ? ( + + TikTok + + ) : order.channel === 'shopee' ? ( + + Shopee + + ) : ( + + Lazada + + )} + {order.orderId} + + {order.shop} + +
+

+ ผู้ซื้อ: {order.buyerUsername || '-'} • อ.{order.district} • {order.createdTime?.slice(0, 16)} +

+
+
+

{fmtBaht(order.orderAmount)}

+
+
+ + {/* Items Preview */} + {order.items?.length > 0 && ( +
+ {order.items.map((it: any, iIdx: number) => ( +
+ + • {it.productName} {it.variation ? `(${it.variation})` : ''} + + + x{it.quantity} ({fmtBaht(it.subtotalAfterDiscount)}) + +
+ ))} +
+ )} + +
+ + ดูหน้าออเดอร์เต็ม + +
+
+ ))} +
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx b/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx index 80453a3..18b3d32 100644 --- a/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx +++ b/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx @@ -9,6 +9,8 @@ interface DistrictMapModalProps { shop: string; dateFrom?: string; dateTo?: string; + platformName?: string; + apiEndpoint?: string; onClose: () => void; } @@ -17,6 +19,8 @@ export default function DistrictMapModal({ shop, dateFrom, dateTo, + platformName = 'TikTok', + apiEndpoint = '/tiktok-order/sales-map/district', onClose, }: DistrictMapModalProps) { const [districtGeo, setDistrictGeo] = useState(null); @@ -52,12 +56,16 @@ export default function DistrictMapModal({ if (dateFrom) params.set('dateFrom', dateFrom); if (dateTo) params.set('dateTo', dateTo); - fetch(`${API_BASE}/tiktok-order/sales-map/district?${params.toString()}`) + const fullUrl = apiEndpoint.startsWith('http') + ? `${apiEndpoint}?${params.toString()}` + : `${API_BASE}${apiEndpoint.startsWith('/') ? '' : '/'}${apiEndpoint}?${params.toString()}`; + + fetch(fullUrl) .then((r) => r.json()) .then((data) => setDistrictData(data)) .catch((err) => console.error('Failed to load district data:', err)) .finally(() => setLoadingData(false)); - }, [provinceName, shop, dateFrom, dateTo]); + }, [provinceName, shop, dateFrom, dateTo, apiEndpoint]); // Filter district features matching the current province const provinceFeatures = useMemo(() => { @@ -186,7 +194,7 @@ export default function DistrictMapModal({

- แผนที่อำเภอ & คำสั่งซื้อ TikTok • {provinceName} + แผนที่อำเภอ & คำสั่งซื้อ {platformName} • {provinceName}

คลิกอำเภอบนแผนที่เพื่อไฮไลต์และกรองข้อมูลฝั่งขวาอัตโนมัติ (สีส้มคืออำเภอที่ถูกเลือก) @@ -378,7 +386,7 @@ export default function DistrictMapModal({

- คำสั่งซื้อ TikTok ในพื้นที่ ({filteredOrders.length}) + คำสั่งซื้อ {platformName} ในพื้นที่ ({filteredOrders.length})
{selectedDistricts.size > 0 && ( diff --git a/apps/admin/src/pages/ProductBomPage.tsx b/apps/admin/src/pages/ProductBomPage.tsx index f41a4ff..4807f2c 100644 --- a/apps/admin/src/pages/ProductBomPage.tsx +++ b/apps/admin/src/pages/ProductBomPage.tsx @@ -1,16 +1,39 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate, Link } from 'react-router-dom' -import { Save, Trash2, Plus, Loader2, CheckCircle2, AlertCircle, Package } from 'lucide-react' +import { + Save, + Trash2, + Plus, + Loader2, + CheckCircle2, + AlertCircle, + Package, + ArrowLeft, + Coins, + Layers, + Calculator +} from 'lucide-react' import { API_BASE, type Product } from '@homaroma/types' +function fmtMoney(val: number | null | undefined): string { + if (val == null || isNaN(val)) return '฿0.00' + return ( + '฿' + + new Intl.NumberFormat('th-TH', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(val) + ) +} + export default function ProductBomPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() - + const [product, setProduct] = useState(null) const [variants, setVariants] = useState([]) const [rawMaterials, setRawMaterials] = useState([]) - + const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -28,10 +51,10 @@ export default function ProductBomPage() { if (id) { const prodRes = await fetch(`${API_BASE}/products/${id}?lang=all`) if (!prodRes.ok) throw new Error('Product not found') - + const prodData: Product = await prodRes.json() setProduct(prodData) - setVariants(JSON.parse(JSON.stringify(prodData.variants))) // Deep copy + setVariants(JSON.parse(JSON.stringify(prodData.variants || []))) // Deep copy } } catch (err: any) { setError(err.message) @@ -39,24 +62,40 @@ export default function ProductBomPage() { setLoading(false) } } - + fetchData() }, [id]) + const getMaterial = (rawMaterialId: number) => { + return rawMaterials.find((rm) => rm.id === Number(rawMaterialId)) + } + + const getLineCost = (bomItem: any): number => { + const rm = getMaterial(bomItem.rawMaterialId) + if (!rm) return 0 + const qty = Number(bomItem.quantity) || 0 + return (rm.pricePerUnit || 0) * qty + } + + const getVariantBomCost = (variant: any): number => { + if (!variant.bomItems || variant.bomItems.length === 0) return 0 + return variant.bomItems.reduce((acc: number, item: any) => acc + getLineCost(item), 0) + } + const handleSave = async () => { setSaving(true) setError(null) setSuccess(false) try { - if (!product) throw new Error("Product not loaded") + if (!product) throw new Error('Product not loaded') const payload = { categoryId: product.categoryId, name: product.name, options: product.options, - images: product.images, // Need to pass existing images to avoid deleting them in some backends - variants: variants.map(v => ({ + images: product.images, + variants: variants.map((v) => ({ id: v.id, option1: v.option1, option2: v.option2, @@ -65,11 +104,12 @@ export default function ProductBomPage() { stock: v.stock, sku: v.sku, barcode: v.barcode, - bomItems: v.bomItems?.map((b: any) => ({ - rawMaterialId: b.rawMaterialId, - quantity: Number(b.quantity) || 0 - })) || [] - })) + bomItems: + v.bomItems?.map((b: any) => ({ + rawMaterialId: Number(b.rawMaterialId) || 0, + quantity: Number(b.quantity) || 0, + })) || [], + })), } const response = await fetch(`${API_BASE}/products/${id}`, { @@ -79,9 +119,9 @@ export default function ProductBomPage() { }) if (!response.ok) throw new Error('Failed to save BOM') - + setSuccess(true) - setTimeout(() => setSuccess(false), 3000) + setTimeout(() => setSuccess(false), 3500) } catch (err: any) { setError(err.message) } finally { @@ -91,156 +131,408 @@ export default function ProductBomPage() { if (loading) { return ( -
+
+

กำลังโหลดโครงสร้างต้นทุน (BOM)...

) } if (!product) { - return
Product not found.
+ return
ไม่พบข้อมูลสินค้าที่ต้องการ
} - const productName = typeof product.name === 'string' ? product.name : (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product' + const productName = + typeof product.name === 'string' + ? product.name + : (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product' + + // Summary Metrics Across All Variants + const totalVariantsCount = variants.length + const configuredVariantsCount = variants.filter( + (v) => v.bomItems && v.bomItems.some((b: any) => b.rawMaterialId > 0 && Number(b.quantity) > 0) + ).length + const bomCosts = variants.map((v) => getVariantBomCost(v)).filter((c) => c > 0) + const minBomCost = bomCosts.length > 0 ? Math.min(...bomCosts) : 0 + const maxBomCost = bomCosts.length > 0 ? Math.max(...bomCosts) : 0 return ( -
+
{/* Header & Actions */}
- Products + + Products + / - BOM + Bill of Materials (BOM)

- BOM {productName} + โครงสร้างต้นทุน BOM: {productName}

+

+ คำนวณและแจกแจงต้นทุนวัตถุดิบ (Raw Materials) ต่อหน่วย พร้อมวิเคราะห์กำไรขั้นต้น (Gross Profit & Margin) +

- +
- - + +
+ {/* Alerts */} {success && ( -
- -

BOM updated successfully!

+
+ +
+

บันทึกโครงสร้างต้นทุน BOM สำเร็จ!

+

+ ระบบจะนำต้นทุน BOM ไปคำนวณกำไรในหน้ารายงานการขายและสินค้า Marketplace โดยอัตโนมัติ +

+
)} {error && ( -
- -

{error}

+
+ +

{error}

)} -
- {variants.length === 0 ? ( -
- No variants found for this product. + {/* ── Summary Overview KPI Cards ────────────────────────────────────────── */} +
+ {/* Total Variants */} +
+
+

จำนวนตัวเลือกสินค้า

+
- ) : variants.map((v, vIdx) => { - const variantName = [v.option1, v.option2, v.option3].filter(Boolean).join(' - ') || 'Default' - return ( -
-
-
-
- +
+

+ {totalVariantsCount} ตัวเลือก +

+

+ ผูก BOM แล้ว: {configuredVariantsCount} / {totalVariantsCount} +

+
+
+ + {/* BOM Cost Range */} +
+
+

ช่วงต้นทุน BOM

+ +
+
+

+ {minBomCost === 0 && maxBomCost === 0 + ? 'ยังไม่ระบุ' + : minBomCost === maxBomCost + ? fmtMoney(minBomCost) + : `${fmtMoney(minBomCost)} - ${fmtMoney(maxBomCost)}`} +

+

ต้นทุนวัตถุดิบต่อ 1 ชิ้น

+
+
+ + {/* Master Raw Materials Catalog */} +
+
+

คลังวัตถุดิบในระบบ

+ +
+
+

+ {rawMaterials.length} รายการ +

+ + จัดการราคาวัตถุดิบ → + +
+
+ + {/* Auto Calculation Status */} +
+
+

ระบบคำนวณอัตโนมัติ

+ +
+
+

+ Live Real-time Sync +

+

ปรับสูตร BOM ปุ๊บ ต้นทุนและกำไรจะอัปเดตทันที

+
+
+
+ + {/* ── Variants BOM List ─────────────────────────────────────────────────── */} +
+ {variants.length === 0 ? ( +
+ +

ไม่พบตัวเลือกสินค้า (Variants)

+

กรุณาเพิ่มตัวเลือกสินค้าในหน้ารายละเอียดสินค้าก่อน

+
+ ) : ( + variants.map((v, vIdx) => { + const variantName = + [v.option1, v.option2, v.option3].filter(Boolean).join(' - ') || 'Default' + const variantBomCost = getVariantBomCost(v) + const retailPrice = Number(v.price) || 0 + const profit = retailPrice - variantBomCost + const marginPercent = retailPrice > 0 ? (profit / retailPrice) * 100 : 0 + const hasBom = v.bomItems && v.bomItems.length > 0 + + return ( +
+ {/* Variant Header & Price Summary */} +
+
+
+ +
+
+
+

{variantName}

+ {v.sku && ( + + SKU: {v.sku} + + )} + {v.barcode && ( + + Barcode: {v.barcode} + + )} +
+

+ สต็อกคงเหลือ: {v.stock ?? 0} ชิ้น +

+
-
-

{variantName}

-

SKU: {v.sku || 'N/A'}

+ + {/* Financial KPI Badges for this Variant */} +
+ {/* Retail Price */} +
+ ราคาขาย + {fmtMoney(retailPrice)} +
+ + {/* Total BOM Cost */} +
+ + ต้นทุน BOM รวม + + {fmtMoney(variantBomCost)} +
+ + {/* Estimated Profit */} +
= 0 + ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' + : 'bg-rose-500/10 border-rose-500/30 text-rose-400' + }`} + > + + กำไรประเมิน / ชิ้น + +
+ {fmtMoney(profit)} + {retailPrice > 0 && ( + ({marginPercent.toFixed(1)}%) + )} +
+
+ + {/* Add Material Button */} +
- -
-
- {(!v.bomItems || v.bomItems.length === 0) ? ( -
- No materials specified for this variant. -
- ) : v.bomItems.map((bom: any, bIdx: number) => ( -
-
- - -
-
- - { - const newVariants = [...variants] - newVariants[vIdx].bomItems[bIdx].quantity = Number(e.target.value) - setVariants(newVariants) - }} - placeholder="0.00" - step="0.01" - className="w-full px-4 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner" - /> -
-
-
+ ) : ( + v.bomItems.map((bom: any, bIdx: number) => { + const selectedMat = getMaterial(bom.rawMaterialId) + const lineCost = getLineCost(bom) + + return ( +
+ {/* Col 1: Material Selector */} +
+ + +
+ + {/* Col 2: Price Per Unit Display */} +
+ +
+ {selectedMat ? ( + + {fmtMoney(selectedMat.pricePerUnit)}{' '} + /{selectedMat.unit} + + ) : ( + - + )} +
+
+ + {/* Col 3: Quantity Input */} +
+ +
+ { + const newVariants = [...variants] + newVariants[vIdx].bomItems[bIdx].quantity = + e.target.value === '' ? '' : Number(e.target.value) + setVariants(newVariants) + }} + placeholder="0.00" + step="any" + className="w-full px-3.5 py-2 bg-slate-900 border border-white/10 focus:border-cyan-400/40 rounded-xl outline-none text-xs text-white font-mono transition-all" + /> + {selectedMat && ( + + {selectedMat.unit} + + )} +
+
+ + {/* Col 4: Line Subtotal Cost Display */} +
+ +
+ {fmtMoney(lineCost)} +
+
+ + {/* Col 5: Remove Button */} +
+ +
+
+ ) + }) + )} +
+ + {/* Variant Footer Breakdown */} + {hasBom && ( +
+

+ ใช้วัตถุดิบทั้งหมด {v.bomItems.length} รายการ +

+
+ + ต้นทุนรวม BOM: {fmtMoney(variantBomCost)} + + + กำไรประเมิน:{' '} + = 0 ? 'text-emerald-400 text-sm' : 'text-rose-400 text-sm'}> + {fmtMoney(profit)} + + +
- ))} -
-
- ) - })} + )} +
+ ) + }) + )}
) diff --git a/apps/admin/src/pages/ShopeeOrderDetailPage.tsx b/apps/admin/src/pages/ShopeeOrderDetailPage.tsx index 4b99285..2d3a51b 100644 --- a/apps/admin/src/pages/ShopeeOrderDetailPage.tsx +++ b/apps/admin/src/pages/ShopeeOrderDetailPage.tsx @@ -15,8 +15,7 @@ import { Tag, BadgePercent, } from 'lucide-react' - -const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, '') +import { API_BASE } from '@homaroma/types' function authHeaders() { return { @@ -56,7 +55,7 @@ export default function ShopeeOrderDetailPage() { useEffect(() => { if (!orderId) return setLoading(true) - fetch(`${API_URL}/api/shopee/orders/${encodeURIComponent(orderId)}`, { headers: authHeaders() }) + fetch(`${API_BASE}/shopee/orders/${encodeURIComponent(orderId)}`, { headers: authHeaders() }) .then(r => r.json()) .then(data => { if (data.error) throw new Error(data.error) diff --git a/apps/admin/src/pages/ShopeePage.tsx b/apps/admin/src/pages/ShopeePage.tsx index ce8525c..f83e84f 100644 --- a/apps/admin/src/pages/ShopeePage.tsx +++ b/apps/admin/src/pages/ShopeePage.tsx @@ -1,9 +1,12 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import * as XLSX from 'xlsx'; -import { ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store } from 'lucide-react'; - -const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); +import { + ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, + ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store, + FileSpreadsheet, AlertTriangle, CheckCircle2, Files +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; // ── Shop Config ─────────────────────────────────────────────────────────────── export const SHOPEE_SHOPS = [ @@ -92,7 +95,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) useEffect(() => { setLoading(true); - fetch(`${API_URL}/api/shopee/orders?page=${page}&limit=20&shop=${shop}`, { headers: authHeaders() }) + fetch(`${API_BASE}/shopee/orders?page=${page}&limit=20&shop=${shop}`, { headers: authHeaders() }) .then(r => r.json()) .then(d => { setItems(d.items || []); @@ -181,13 +184,23 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) ); } -// ── Import Panel ────────────────────────────────────────────────────────────── +// ── Import Panel (Supports Multiple Excel Files & Duplicate Detection) ──────── +interface FileSummary { + name: string; + size: number; + sheetName: string; + rowsCount: number; + ordersCount: number; +} + function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }) { - const [previewRows, setPreviewRows] = useState([]); + const [fileSummaries, setFileSummaries] = useState([]); const [allRows, setAllRows] = useState([]); + const [previewRows, setPreviewRows] = useState([]); + const [crossFileDuplicateCount, setCrossFileDuplicateCount] = useState(0); + const [reading, setReading] = useState(false); const [loading, setLoading] = useState(false); const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); - const [sheetInfo, setSheetInfo] = useState(''); const shopCfg = getShop(shop); const readFile = (f: File) => new Promise((resolve, reject) => { @@ -201,37 +214,92 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }); const handleFileChange = async (e: React.ChangeEvent) => { - const f = e.target.files?.[0]; - if (!f) return; - setMsg(null); setAllRows([]); setPreviewRows([]); setSheetInfo(''); + const files = e.target.files ? Array.from(e.target.files) : []; + if (files.length === 0) return; + + setReading(true); + setMsg(null); + setAllRows([]); + setPreviewRows([]); + setFileSummaries([]); + setCrossFileDuplicateCount(0); + try { - const wb = await readFile(f); - const sheetName = wb.SheetNames.find(n => n.toLowerCase().includes('order')) ?? wb.SheetNames[0]; - const ws = wb.Sheets[sheetName]; - setSheetInfo(`Sheet: "${sheetName}" | ${f.name}`); - const rawRows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null, blankrows: false, raw: false }) as any[][]; - if (rawRows.length < 2) { setMsg({ text: 'ไม่พบข้อมูลในไฟล์', type: 'error' }); return; } - const headers = (rawRows[0] as any[]).map(h => h != null ? String(h).trim() : ''); - const rows: any[] = []; - for (let i = 1; i < rawRows.length; i++) { - const cells = rawRows[i] as any[]; - if (cells.every(c => c === null || c === undefined || c === '')) continue; - const row: any = {}; - headers.forEach((h, idx) => { row[h] = cells[idx] ?? null; }); - if (row['หมายเลขคำสั่งซื้อ']) rows.push(row); + const summaries: FileSummary[] = []; + const combinedRows: any[] = []; + const orderFileMap = new Map>(); + + for (const file of files) { + const wb = await readFile(file); + const sheetName = wb.SheetNames.find(n => n.toLowerCase().includes('order')) ?? wb.SheetNames[0]; + const ws = wb.Sheets[sheetName]; + const rawRows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null, blankrows: false, raw: false }) as any[][]; + + if (rawRows.length < 2) continue; + + const headers = (rawRows[0] as any[]).map(h => h != null ? String(h).trim() : ''); + const fileRows: any[] = []; + const fileOrderIds = new Set(); + + for (let i = 1; i < rawRows.length; i++) { + const cells = rawRows[i] as any[]; + if (cells.every(c => c === null || c === undefined || c === '')) continue; + const row: any = {}; + headers.forEach((h, idx) => { row[h] = cells[idx] ?? null; }); + const orderId = row['หมายเลขคำสั่งซื้อ'] ? String(row['หมายเลขคำสั่งซื้อ']).trim() : null; + if (orderId) { + fileRows.push(row); + fileOrderIds.add(orderId); + + if (!orderFileMap.has(orderId)) { + orderFileMap.set(orderId, new Set()); + } + orderFileMap.get(orderId)!.add(file.name); + } + } + + summaries.push({ + name: file.name, + size: file.size, + sheetName, + rowsCount: fileRows.length, + ordersCount: fileOrderIds.size, + }); + + combinedRows.push(...fileRows); } - setAllRows(rows); - setPreviewRows(rows.slice(0, 5)); + + if (combinedRows.length === 0) { + setMsg({ text: 'ไม่พบข้อมูลคำสั่งซื้อในไฟล์ที่เลือก', type: 'error' }); + setReading(false); + return; + } + + // Calculate cross-file duplicates + let duplicatesAcrossFiles = 0; + orderFileMap.forEach((fileSet) => { + if (fileSet.size > 1) { + duplicatesAcrossFiles++; + } + }); + + setFileSummaries(summaries); + setAllRows(combinedRows); + setPreviewRows(combinedRows.slice(0, 5)); + setCrossFileDuplicateCount(duplicatesAcrossFiles); } catch (err: any) { setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' }); + } finally { + setReading(false); } }; const handleImport = async () => { if (allRows.length === 0) return; - setLoading(true); setMsg(null); + setLoading(true); + setMsg(null); try { - const res = await fetch(`${API_URL}/api/shopee/import/orders`, { + const res = await fetch(`${API_BASE}/shopee/import/orders`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ rows: allRows, shop }), @@ -239,14 +307,21 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void const result = await res.json(); if (result.success) { setMsg({ text: result.message, type: 'success' }); - setAllRows([]); setPreviewRows([]); setSheetInfo(''); + setAllRows([]); + setPreviewRows([]); + setFileSummaries([]); + setCrossFileDuplicateCount(0); const inp = document.getElementById('shopee-file-input') as HTMLInputElement; if (inp) inp.value = ''; onSuccess(); - } else throw new Error(result.message); + } else { + throw new Error(result.message); + } } catch (e: any) { setMsg({ text: e.message, type: 'error' }); - } finally { setLoading(false); } + } finally { + setLoading(false); + } }; const uniqueOrdersCount = new Set(allRows.map(r => r['หมายเลขคำสั่งซื้อ'])).size; @@ -270,30 +345,81 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void - {sheetInfo && ( -
- 📋 {sheetInfo} - 0 ? 'text-emerald-400' : 'text-amber-400'}`}> - {allRows.length} แถว → {uniqueOrdersCount} คำสั่งซื้อ - + {reading && ( +
+ ⏳ กำลังประมวลผลไฟล์ Excel ทั้งหมด...
)} + {/* Selected Files Summary Cards */} + {fileSummaries.length > 0 && ( +
+
+
+ + ไฟล์ที่เลือก ({fileSummaries.length} ไฟล์) +
+
+ รวมทั้งหมด {allRows.length} แถวสินค้า → {uniqueOrdersCount} คำสั่งซื้อไม่ซ้ำ +
+
+ +
+ {fileSummaries.map((f, idx) => ( +
+
+ +
+

{f.name}

+

+ {(f.size / 1024).toFixed(1)} KB • Sheet: {f.sheetName} +

+
+
+
+ {f.rowsCount} แถว + {f.ordersCount} ออเดอร์ +
+
+ ))} +
+ + {/* Deduplication & Safety Alert */} + {crossFileDuplicateCount > 0 ? ( +
+ +
+

ตรวจพบคำสั่งซื้อซ้ำกันระหว่างไฟล์ {crossFileDuplicateCount} รายการ

+

+ ระบบจะทำการรวมข้อมูลและ Upsert (อัปเดตอัตโนมัติ) ตาม Order ID โดยไม่สร้างข้อมูลเบิ้ล และยอดขายจะไม่ทับซ้อน +

+
+
+ ) : ( +
+ + ไฟล์ทั้งหมดไม่พบหมายเลขคำสั่งซื้อที่ซ้ำกันข้ามไฟล์ +
+ )} +
+ )} + + {/* Preview Table */} {previewRows.length > 0 && (
-

Preview (5 แถวแรก):

+

Preview ตัวอย่างข้อมูลรวม (5 แถวแรก):

@@ -316,13 +442,17 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
-
+
@@ -343,7 +473,7 @@ export default function ShopeePage() { const loadSummary = (s: string) => { setSummary(null); - fetch(`${API_URL}/api/shopee/summary?shop=${s}`, { headers: authHeaders() }) + fetch(`${API_BASE}/shopee/summary?shop=${s}`, { headers: authHeaders() }) .then(r => r.json()) .then(setSummary) .catch(() => { }); diff --git a/apps/admin/src/pages/ShopeeSalesMapPage.tsx b/apps/admin/src/pages/ShopeeSalesMapPage.tsx new file mode 100644 index 0000000..2f75f63 --- /dev/null +++ b/apps/admin/src/pages/ShopeeSalesMapPage.tsx @@ -0,0 +1,525 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Map, ShoppingBag, DollarSign, Award, + Filter, Calendar, Table2, Search, + MapPin, +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; +import ThailandMapSvg from '../components/tiktok-map/ThailandMapSvg'; +import DistrictMapModal from '../components/tiktok-map/DistrictMapModal'; +import { fmtBaht, fmtNum } from '../utils/thaiGeoUtils'; +import { SHOPEE_SHOPS } from './ShopeePage'; + +type ViewMode = 'map' | 'list'; +type TimePeriod = 'all' | '30days' | 'this_year' | 'custom'; + +export default function ShopeeSalesMapPage() { + const [viewMode, setViewMode] = useState('map'); + const [shop, setShop] = useState('all'); + const [period, setPeriod] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + const [loading, setLoading] = useState(false); + const [mapData, setMapData] = useState(null); + const [selectedProvince, setSelectedProvince] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [listSearch, setListSearch] = useState(''); + + // Fetch sales map data + const loadData = () => { + setLoading(true); + const params = new URLSearchParams(); + if (shop && shop !== 'all') params.set('shop', shop); + if (dateFrom) params.set('dateFrom', dateFrom); + if (dateTo) params.set('dateTo', dateTo); + + fetch(`${API_BASE}/shopee/sales-map?${params.toString()}`) + .then((r) => r.json()) + .then((data) => setMapData(data)) + .catch((err) => console.error('Failed to load Shopee sales map data:', err)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + loadData(); + }, [shop, dateFrom, dateTo]); + + // Handle preset period change + const handlePeriodChange = (p: TimePeriod) => { + setPeriod(p); + const now = new Date(); + if (p === 'all') { + setDateFrom(''); + setDateTo(''); + } else if (p === '30days') { + const past = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + setDateFrom(past.toISOString().slice(0, 10)); + setDateTo(now.toISOString().slice(0, 10)); + } else if (p === 'this_year') { + const startOfYear = new Date(now.getFullYear(), 0, 1); + setDateFrom(startOfYear.toISOString().slice(0, 10)); + setDateTo(now.toISOString().slice(0, 10)); + } + }; + + // Provinces lookup map by nameTh + const provincesLookup = useMemo(() => { + const map: Record = {}; + if (mapData?.provinces) { + for (const p of mapData.provinces) { + map[p.nameTh] = p; + } + } + return map; + }, [mapData]); + + // Selected province detail + const currentProvinceInfo = useMemo(() => { + if (!selectedProvince || !provincesLookup[selectedProvince]) { + return null; + } + return provincesLookup[selectedProvince]; + }, [selectedProvince, provincesLookup]); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+
+

+ แผนที่ยอดขายประเทศไทย (Shopee Sales Map) +

+
+

+ วิเคราะห์การกระจายตัวของยอดขาย Shopee และศักยภาพลูกค้าแยกตาม 77 จังหวัด +

+
+
+ + {/* Top Control Bar */} +
+ {/* View Mode Toggle */} +
+ + +
+ + {/* Shop Switcher */} + + + {/* Period Preset */} + + + {/* Custom Date Pickers */} +
+ + { + setDateFrom(e.target.value); + setPeriod('custom'); + }} + className="bg-transparent text-xs text-slate-300 focus:outline-none" + /> + - + { + setDateTo(e.target.value); + setPeriod('custom'); + }} + className="bg-transparent text-xs text-slate-300 focus:outline-none" + /> +
+ + +
+
+ + {/* KPI 5 Cards Bar */} +
+ {/* KPI 1 */} +
+
+ ยอดขายรวม + + Shopee + +
+
+

+ {fmtBaht(mapData?.kpi?.totalRevenue || 0)} +

+

Shopee Total Revenue

+
+
+ + {/* KPI 2 */} +
+
+ คำสั่งซื้อสะสม + +
+
+

+ {fmtNum(mapData?.kpi?.totalOrders || 0)} +

+

รวมทุกคำสั่งซื้อ Shopee

+
+
+ + {/* KPI 3 */} +
+
+ การเข้าถึงจังหวัด + + {(mapData?.kpi?.coveragePercent || 0).toFixed(1)}% + +
+
+

+ {mapData?.kpi?.coverageCount || 0}{' '} + / 77 จังหวัด +

+

ครอบคลุมทั่วประเทศ

+
+
+ + {/* KPI 4 */} +
+
+ ยอดเฉลี่ย/ออเดอร์ + +
+
+

+ {fmtBaht(mapData?.kpi?.aov || 0)} +

+

AOV คำนวณจากทุกคำสั่งซื้อ

+
+
+ + {/* KPI 5 */} +
+
+ ภาคที่มีสัดส่วนสูงสุด + + Top Region + +
+
+

+ {mapData?.kpi?.topRegion?.name || 'ภาคกลาง'} +

+

+ {fmtNum(mapData?.kpi?.topRegion?.orders || 0)} ออเดอร์ ( + {fmtBaht(mapData?.kpi?.topRegion?.revenue || 0)}) +

+
+
+
+ + {/* Main Content Area */} + {viewMode === 'map' ? ( +
+ {/* Left/Center: Thailand SVG Map & Province Info Card (8 Cols) */} +
+
+
+

แผนที่ยอดขายประเทศไทย (Shopee)

+

+ สีเข้มหมายถึงยอดขายสูง คลิกจังหวัดเพื่อดูข้อมูลทั้งหมดของจังหวัดนั้น +

+
+ + {/* Map & Focus Card Row */} +
+ {/* SVG Map (7 Cols) */} +
+ { + if (selectedProvince === nameTh) { + setSelectedProvince(null); + } else { + setSelectedProvince(nameTh); + } + }} + /> +
+ + {/* Province Focus Card (5 Cols) */} +
+
+ + จังหวัด + + {selectedProvince && ( + + )} +
+ +
+

+ {currentProvinceInfo ? currentProvinceInfo.nameTh : 'ทั่วประเทศ'} +

+

+ {fmtBaht( + currentProvinceInfo + ? currentProvinceInfo.revenue + : mapData?.kpi?.totalRevenue || 0 + )} +

+

+ {fmtNum( + currentProvinceInfo + ? currentProvinceInfo.orders + : mapData?.kpi?.totalOrders || 0 + )}{' '} + คำสั่งซื้อ •{' '} + {currentProvinceInfo + ? currentProvinceInfo.regionTh + : `${mapData?.kpi?.coverageCount || 0} จังหวัดที่มียอดขาย`} +

+
+ + {/* Color Scale Legend */} +
+
+ ไม่มียอด + ยอดสูง +
+
+
+ + {/* Map Focus Button */} + {selectedProvince ? ( + + ) : ( +
+ เลือกจังหวัดแล้วทุกส่วนด้านล่างจะเปลี่ยนพร้อมกัน ทั้งแนวโน้ม กลุ่มสินค้า และร้านยอดสูง +
+ )} +
+
+
+
+ + {/* Right: Top 20 Provinces Ranking (4 Cols) */} +
+
+
+ +

จังหวัดยอดขายสูง (Shopee)

+
+ Top 20 +
+ +
+ {mapData?.topProvinces?.map((p: any) => { + const isSelected = selectedProvince === p.nameTh; + const isTop3 = p.rank <= 3; + return ( +
setSelectedProvince(p.nameTh)} + className={`flex items-center justify-between p-3 rounded-2xl cursor-pointer transition-all ${ + isSelected + ? 'bg-orange-500/20 border border-orange-500/40 text-orange-300' + : 'hover:bg-white/5 text-slate-300' + }`} + > +
+ + {p.rank} + +
+

+ {p.nameTh} +

+

+ {p.orders} ออเดอร์ • {p.regionTh} +

+
+
+ +
+

+ {fmtBaht(p.revenue)} +

+

+ AOV {fmtBaht(p.aov)} +

+
+
+ ); + })} +
+
+
+ ) : ( + /* List / Table View */ +
+
+

+ ตารางสรุปยอดขาย Shopee แยกราย 77 จังหวัด +

+
+ + setListSearch(e.target.value)} + placeholder="ค้นหาชื่อจังหวัด หรือ ภาค..." + className="w-full bg-slate-950/80 border border-white/10 rounded-xl pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/50" + /> +
+
+ +
+ + + + + + + + + + + + + + {mapData?.provinces + ?.filter( + (p: any) => + !listSearch || + p.nameTh.includes(listSearch) || + p.nameEn.toLowerCase().includes(listSearch.toLowerCase()) || + p.regionTh.includes(listSearch) + ) + .sort((a: any, b: any) => b.revenue - a.revenue) + .map((p: any) => ( + + + + + + + + + + ))} + +
รหัสจังหวัดภูมิภาคยอดขายรวม (฿)จำนวนออเดอร์AOV (฿/ออเดอร์)จัดการ
{p.code} + {p.nameTh}{' '} + ({p.nameEn}) + {p.regionTh} + {fmtBaht(p.revenue)} + + {fmtNum(p.orders)} + + {fmtBaht(p.orders > 0 ? p.revenue / p.orders : 0)} + + +
+
+
+ )} + + {/* District Focus Modal Dialog */} + {isModalOpen && selectedProvince && ( + setIsModalOpen(false)} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/TiktokAnalyticsPage.tsx b/apps/admin/src/pages/TiktokAnalyticsPage.tsx index 889cd8a..e1a5f16 100644 --- a/apps/admin/src/pages/TiktokAnalyticsPage.tsx +++ b/apps/admin/src/pages/TiktokAnalyticsPage.tsx @@ -297,15 +297,79 @@ export default function TiktokAnalyticsPage() { )}
- {/* KPI Cards Row 1 */} + {/* KPI Cards Row 1 - Main Financials */}
- - - - + + + +
- {/* KPI Cards Row 2 */} + {/* KPI Cards Row 2 - Fees & Commissions Breakdown */} +
+ + + + +
+ + {/* KPI Cards Row 3 - Operations & Orders */}
@@ -313,14 +377,17 @@ export default function TiktokAnalyticsPage() {
- {/* Monthly Revenue Chart */} + {/* Monthly Revenue & Financial Trend Chart */}
-
- -

ยอดขายรายเดือน

+
+
+ +

ยอดขาย ค่าธรรมเนียม และกำไรสุทธิรายเดือน

+
+ เปรียบเทียบยอดขาย ยอดโอน ค่าคอมมิชชั่น และกำไรสุทธิ
{monthlyChart.length > 0 ? ( -
+
@@ -329,8 +396,10 @@ export default function TiktokAnalyticsPage() { } cursor={{ fill: '#ffffff06' }} /> - - + + + +
@@ -472,33 +541,46 @@ export default function TiktokAnalyticsPage() {
- {/* Cost Waterfall Summary */} + {/* Complete Financial & Cost Waterfall Summary */}
-
- -

สรุปค่าใช้จ่ายและส่วนลด

+
+
+ +

โครงสร้างต้นทุน ค่าธรรมเนียม และกำไรสุทธิ (Financial Breakdown)

+
+ หักลบรายได้ตามลำดับสู่กำไรสุทธิแท้จริง
-
+ +
{[ - { label: 'ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', bar: 'bg-emerald-400' }, - { label: 'หัก: ค่าจัดส่ง (ผู้ซื้อจ่าย)', value: -summary.totalShipping, color: 'text-orange-400', bar: 'bg-orange-400' }, - { label: 'หัก: ส่วนลดร้านค้า', value: -summary.totalSellerDisc, color: 'text-rose-400', bar: 'bg-rose-400' }, - { label: 'ยอดสุทธิก่อนหักคอมมิชชั่น', value: summary.netRevenue, color: 'text-cyan-glow', bar: 'bg-cyan-glow', highlight: true }, + { label: '1. ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', sign: '+' }, + { label: '2. หัก: ส่วนลดร้านค้า', value: -summary.totalSellerDisc, color: 'text-rose-400', sign: '-' }, + { label: '3. หัก: ค่าคอม TikTok', value: -(summary.totalCommission || 0), color: 'text-rose-400', sign: '-' }, + { label: '4. หัก: ค่า Affiliate', value: -(summary.totalAffiliate || 0), color: 'text-orange-400', sign: '-' }, + { label: '5. หัก: ต้นทุน BOM สินค้า', value: -(summary.totalBOMCost || 0), color: 'text-amber-400', sign: '-' }, + { label: '6. กำไรสุทธิแท้จริง (Net Profit)', value: summary.totalNetProfit || 0, color: 'text-emerald-300 font-bold', highlight: true, sign: '=' }, ].map((item, i) => ( -
-

{item.label}

-

{fmtCurrency(Math.abs(item.value))}

+
+
+

{item.label}

+ {item.sign} +
+

{fmtCurrency(Math.abs(item.value))}

{summary.totalRevenue > 0 && ( -

{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอด

+

{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอดขาย

)}
))}
-
- -
-

ส่วนลด Platform สนับสนุน (TikTok ออกให้)

-

{fmtCurrency(summary.totalPlatformDisc)}

+ +
+ +
+
+

ส่วนลด Platform สนับสนุน (TikTok ช่วยจ่ายให้ลูกค้า)

+

ยอดเงินที่ TikTok ออกคูปองสนับสนุนให้ผู้ซื้อ โดยไม่หักเงินจากร้านค้า

+
+

{fmtCurrency(summary.totalPlatformDisc)}

diff --git a/apps/admin/src/pages/TiktokOrderDetailPage.tsx b/apps/admin/src/pages/TiktokOrderDetailPage.tsx new file mode 100644 index 0000000..db4579d --- /dev/null +++ b/apps/admin/src/pages/TiktokOrderDetailPage.tsx @@ -0,0 +1,512 @@ +import { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { + ArrowLeft, + ShoppingBag, + Package, + Truck, + User, + MapPin, + Receipt, + ExternalLink, + Loader2, + Calendar, + AlertTriangle, + CheckCircle2, + Calculator, + Layers, + ArrowUpRight, +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; +import { TIKTOK_SHOPS } from './TiktokOrderPage'; + +function authHeaders() { + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }; +} + +function fmt(val: number | null | undefined) { + if (val == null) return '0.00'; + return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val); +} + +function InfoRow({ label, value, mono }: { label: string; value?: string | null; mono?: boolean }) { + if (!value) return null; + return ( +
+

{label}

+

{value}

+
+ ); +} + +function statusStyle(status: string) { + if (status.includes('เสร็จ') || status.includes('สำเร็จ')) return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20'; + if (status.includes('จัดส่งแล้ว') || status.includes('ส่งแล้ว')) return 'bg-blue-500/15 text-blue-400 border-blue-500/20'; + if (status.includes('จะจัดส่ง') || status.includes('รอ')) return 'bg-amber-500/15 text-amber-400 border-amber-500/20'; + if (status.includes('ยกเลิก') || status.includes('cancel')) return 'bg-rose-500/15 text-rose-400 border-rose-500/20'; + return 'bg-white/5 text-slate-400 border-white/10'; +} + +export default function TiktokOrderDetailPage() { + const { orderId } = useParams<{ orderId: string }>(); + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!orderId) return; + setLoading(true); + fetch(`${API_BASE}/tiktok-order/orders/${encodeURIComponent(orderId)}`, { headers: authHeaders() }) + .then((r) => r.json()) + .then((data) => { + if (data.error) throw new Error(data.error); + setOrder(data); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [orderId]); + + if (loading) { + return ( +
+ +

กำลังโหลดรายละเอียดคำสั่งซื้อ TikTok และคำนวณต้นทุน BOM...

+
+ ); + } + + if (error || !order) { + return ( +
+ +

{error || 'ไม่พบคำสั่งซื้อนี้'}

+ + กลับหน้ารายการ + +
+ ); + } + + const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0]; + const hasIncompleteBom = order.hasIncompleteBom || order.missingBomCount > 0; + + return ( +
+ {/* Header Bar */} +
+
+ + + +
+
+

+ #{order.orderId} +

+ + {order.status} + + {order.subStatus && ( + + {order.subStatus} + + )} + + {/* BOM Status Pill */} + {hasIncompleteBom ? ( + + ขาด BOM ({order.missingBomCount} รายการ) + + ) : ( + + BOM ครบถ้วน + + )} +
+

+ ร้าน: {shopCfg.label} + • + สั่งซื้อเมื่อ: {order.createdTime || '—'} +

+
+
+ +
+ + ดูใน Sales Map + + + TikTok Seller Center + +
+
+ + {/* Missing BOM Alert Banner */} + {hasIncompleteBom && ( +
+
+
+ +
+
+

+ ตรวจพบสินค้าขาดสูตร BOM หรือยังไม่ได้ใส่ราคาต้นทุน ({order.missingBomCount} รายการ) +

+

+ พนักงานสามารถคลิกปุ่มสีแดงที่รายการสินค้าด้านล่าง เพื่อเข้าไปกรอกสูตร BOM หรือเชื่อมโยงสินค้าได้ทันที +

+
+
+ + ตรวจสอบตาราง Mapping + +
+ )} + + {/* Main Layout Grid */} +
+ {/* Left Column (2 Cols): Products Table & Details */} +
+ {/* Order Items Table */} +
+
+
+ +

+ รายการสินค้าในคำสั่งซื้อ ({order.items?.length || 0} SKU) +

+
+
+ ต้นทุนรวม BOM: 0 ? "text-amber-400" : "text-rose-400"}>฿{fmt(order.orderTotalBomCost)} +
+
+
+ {order.items?.map((item: any) => { + const isItemBomMissing = !item.hasBom; + return ( +
+
+ {/* Product Name & SKU */} +
+
+

{item.productName}

+ {isItemBomMissing ? ( + + 🔴 ขาด BOM / รอใส่ราคา + + ) : ( + + ✓ มีสูตร BOM + + )} +
+ +
+ {item.variation && ( + + ตัวเลือก: {item.variation} + + )} + {item.sellerSku && ( + + SKU: {item.sellerSku} + + )} + {item.masterProductName && ( + + เชื่อมโยง: {item.masterProductName} ({item.masterSku || 'No SKU'}) + + )} +
+
+ + {/* Pricing, Quantity & Subtotal */} +
+
+

ราคา/หน่วย

+

฿{fmt(item.unitOriginalPrice)}

+
+
+

จำนวน

+

x{item.quantity}

+
+
+

ยอดขาย (SKU)

+

฿{fmt(item.subtotalAfterDiscount)}

+
+
+
+ + {/* BOM & Profit Row */} +
+ {item.hasBom ? ( +
+
+ ต้นทุน BOM/ชิ้น: + ฿{fmt(item.unitBomCost)} +
+
+ ต้นทุนรวม: + ฿{fmt(item.totalBomCost)} +
+
+ กำไรขั้นต้น: + = 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + ฿{fmt(item.profit)} ({item.marginPercent?.toFixed(1)}%) + +
+
+ ) : ( +
+ + ไม่พบต้นทุน BOM สำหรับสินค้านี้ (ทำให้คำนวณกำไรไม่ได้) +
+ )} + + {/* Action Links */} +
+ {item.masterProductId ? ( + + + {item.hasBom ? 'แก้ไขสูตร BOM' : '➕ ไปใส่สูตร BOM'} + + + ) : ( + + + 🔗 ไปเชื่อมโยง Master SKU + + + )} +
+
+ + {/* BOM Raw Material Breakdown (if available) */} + {item.bomItemsDetail && item.bomItemsDetail.length > 0 && ( +
+

+ สูตรวัตถุดิบ BOM ({item.bomItemsDetail.length} รายการ): +

+
+ {item.bomItemsDetail.map((bom: any, bIdx: number) => ( +
+ • {bom.name} ({bom.quantity} {bom.unit}) + ฿{fmt(bom.lineCost)} +
+ ))} +
+
+ )} +
+ ); + })} +
+
+ + {/* Shipping & Delivery Info */} +
+
+ +

ข้อมูลการจัดส่งและขนส่ง

+
+
+ + + + + + +
+
+ + {/* Timeline Dates */} +
+
+ +

ไทม์ไลน์คำสั่งซื้อ

+
+
+ + + + + + +
+
+
+ + {/* Right Column (1 Col): Financials & Customer */} +
+ {/* BOM Cost & Profit Summary Card */} +
+
+ +

สรุปต้นทุน & กำไร (BOM)

+
+ +
+
+ ยอดขายสุทธิ (Revenue) + + ฿{fmt(order.orderAmount)} + +
+ +
+ ต้นทุนวัตถุดิบ (Total BOM Cost) + 0 ? 'text-amber-400' : 'text-rose-400'}`}> + ฿{fmt(order.orderTotalBomCost)} + +
+ +
+ ค่าคอมมิชชั่น (Commission) + + {order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'} + +
+ +
+ ค่าพันธมิตร (Affiliate) + + {order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'} + +
+ +
+
+ กำไรสุทธิ (Net Profit) +

Margin: {order.orderGrossMargin?.toFixed(1)}%

+
+ = 0 ? 'text-emerald-400' : 'text-rose-400' + }`}> + ฿{fmt(order.orderNetProfit ?? order.orderEstProfit)} + +
+ + {hasIncompleteBom && ( +
+
+ + คำเตือน: ตัวเลขยังไม่สมบูรณ์ +
+

+ มีสินค้า {order.missingBomCount} รายการ ยังไม่มีสูตร BOM หรือไม่มีราคาต้นทุน กรุณาใส่ราคา BOM เพื่อให้กำไรถูกต้อง +

+
+ )} +
+
+ + {/* Customer & Address */} +
+
+ +

ข้อมูลลูกค้าและพื้นที่

+
+
+ + +
+

+ พิกัดปลายทาง +

+
+ + + {order.district ? `อ.${order.district}, ` : ''}จ.{order.province || 'ไม่ระบุ'} + +
+
+
+
+ + {/* Payment & Financial Summary */} +
+
+ +

สรุปยอดเงิน (Financials)

+
+
+
+ ยอดรวมสินค้า + + ฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.subtotalBeforeDiscount || 0), 0))} + +
+
+ ส่วนลดแพลตฟอร์ม + + -฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0))} + +
+
+ ส่วนลดร้านค้า + + -฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0))} + +
+
+ ค่าจัดส่ง + ฿{fmt(order.shippingFee)} +
+
+ ภาษี (Taxes) + ฿{fmt(order.taxes)} +
+ {order.refundAmount > 0 && ( +
+ ยอดคืนเงิน (Refund) + -฿{fmt(order.refundAmount)} +
+ )} +
+ ยอดสุทธิ (Order Amount) + + ฿{fmt(order.orderAmount)} + +
+
+
+
+
+
+ ); +} + diff --git a/apps/admin/src/pages/TiktokOrderPage.tsx b/apps/admin/src/pages/TiktokOrderPage.tsx index 3957c79..243edf0 100644 --- a/apps/admin/src/pages/TiktokOrderPage.tsx +++ b/apps/admin/src/pages/TiktokOrderPage.tsx @@ -1,12 +1,14 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import Papa from 'papaparse'; +import * as XLSX from 'xlsx'; import { ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, - ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2, + Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2, + FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw, + BadgePercent, Wallet, Receipt } from 'lucide-react'; - -const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); +import { API_BASE } from '@homaroma/types'; // ── Shop Config ─────────────────────────────────────────────────────────────── export const TIKTOK_SHOPS = [ @@ -19,10 +21,19 @@ function getShop(id: string) { return TIKTOK_SHOPS.find(s => s.id === id) ?? TIK function authHeaders() { return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` }; } -function fmt(val: number) { +function fmt(val: number | null | undefined) { + if (val == null) return '0.00'; return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val); } +function statusColor(status: string) { + if (status.includes('เสร็จ') || status.includes('สำเร็จ')) return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20'; + if (status.includes('จัดส่งแล้ว') || status.includes('ส่งแล้ว')) return 'bg-blue-500/15 text-blue-400 border-blue-500/20'; + if (status.includes('จะจัดส่ง') || status.includes('รอ')) return 'bg-amber-500/15 text-amber-400 border-amber-500/20'; + if (status.includes('ยกเลิก') || status.includes('cancel')) return 'bg-rose-500/15 text-rose-400 border-rose-500/20'; + return 'bg-white/5 text-slate-400 border-white/10'; +} + // ── Shop Switcher ───────────────────────────────────────────────────────────── function ShopSwitcher({ shop, onChange }: { shop: string; onChange: (s: string) => void }) { return ( @@ -43,26 +54,58 @@ function ShopSwitcher({ shop, onChange }: { shop: string; onChange: (s: string) // ── Summary Cards ───────────────────────────────────────────────────────────── function SummaryCards({ summary, shopColor }: { summary: any | null; shopColor: string }) { if (!summary) return ( -
- {Array.from({ length: 4 }).map((_, i) =>
)} +
+ {Array.from({ length: 5 }).map((_, i) =>
)}
); + + const missingBom = (summary.ordersMissingBomCount || 0) > 0; + const hasSettlement = (summary.totalCommission || 0) > 0 || (summary.totalAffiliate || 0) > 0; + const cards = [ { label: 'คำสั่งซื้อทั้งหมด', value: summary.orderCount.toLocaleString(), sub: `${summary.totalItems?.toLocaleString()} รายการ SKU`, icon: ShoppingBag, color: shopColor }, - { label: 'ยอดรวม (Order Amount)', value: `฿${fmt(summary.totalAmount)}`, sub: `SKU Revenue ฿${fmt(summary.totalSkuRevenue)}`, icon: DollarSign, color: 'text-emerald-400' }, - { label: 'ค่าส่งรวม', value: `฿${fmt(summary.totalShipping)}`, sub: `คืนเงิน ฿${fmt(summary.totalRefund)}`, icon: ReceiptText, color: 'text-amber-400' }, - { label: 'ภาษีรวม', value: `฿${fmt(summary.totalTaxes)}`, sub: 'Tax collected', icon: Package, color: 'text-blue-400' }, + { label: 'ยอดขายรวม (Gross)', value: `฿${fmt(summary.totalAmount)}`, sub: `SKU Revenue ฿${fmt(summary.totalSkuRevenue)}`, icon: DollarSign, color: 'text-emerald-400' }, + { label: 'ต้นทุน BOM รวม', value: `฿${fmt(summary.totalBOMCost || 0)}`, sub: `กำไรประเมิน ฿${fmt(summary.totalEstProfit || 0)} (${(summary.grossProfitMargin || 0).toFixed(1)}%)`, icon: Coins, color: 'text-amber-400' }, + { + label: 'Commission & Affiliate', + value: `฿${fmt((summary.totalCommission || 0) + (summary.totalAffiliate || 0))}`, + sub: hasSettlement + ? `Comm: ฿${fmt(summary.totalCommission || 0)} | Aff: ฿${fmt(summary.totalAffiliate || 0)}` + : 'รอ Import ใบแจ้งยอด Settlement', + icon: BadgePercent, + color: 'text-violet-400', + }, + { + label: 'สถานะ BOM สินค้า', + value: missingBom ? `ขาด ${summary.ordersMissingBomCount} ออเดอร์` : 'BOM ครบ 100%', + sub: missingBom ? '🔴 มีสินค้ายังไม่ได้ใส่ราคา BOM' : '✓ คำนวณต้นทุนครบทุกออเดอร์', + icon: missingBom ? AlertTriangle : CheckCircle2, + color: missingBom ? 'text-rose-400' : 'text-emerald-400', + isAlert: missingBom, + }, ]; + return ( -
- {cards.map((c) => ( -
+
+ {cards.map((c: any) => ( +
-

{c.label}

+

{c.label}

-

{c.value}

- {c.sub &&

{c.sub}

} +
+

{c.value}

+ {c.sub && ( +

+ {c.sub} +

+ )} +
))}
@@ -76,81 +119,372 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [loading, setLoading] = useState(true); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [bomFilter, setBomFilter] = useState('all'); - useEffect(() => { setPage(1); }, [shop, refreshKey]); + useEffect(() => { + const timer = setTimeout(() => { + setSearch(searchInput); + setPage(1); + }, 300); + return () => clearTimeout(timer); + }, [searchInput]); + + useEffect(() => { + setPage(1); + }, [shop, refreshKey, statusFilter, bomFilter]); useEffect(() => { setLoading(true); - fetch(`${API_URL}/api/tiktok-order/orders?page=${page}&limit=20&shop=${shop}`, { headers: authHeaders() }) - .then(r => r.json()) - .then(d => { setItems(d.items || []); setTotal(d.total || 0); setTotalPages(d.totalPages || 1); }) - .finally(() => setLoading(false)); - }, [shop, page, refreshKey]); + const params = new URLSearchParams({ + page: String(page), + limit: '20', + shop, + }); + if (search.trim()) params.set('search', search.trim()); + if (statusFilter && statusFilter !== 'all') params.set('status', statusFilter); + if (bomFilter && bomFilter !== 'all') params.set('bomStatus', bomFilter); - const statusColor = (s: string) => { - if (s.includes('เสร็จ')) return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20'; - if (s.includes('จัดส่งแล้ว')) return 'bg-blue-500/15 text-blue-400 border-blue-500/20'; - if (s.includes('จะจัดส่ง') || s.includes('รอ')) return 'bg-amber-500/15 text-amber-400 border-amber-500/20'; - if (s.includes('ยกเลิก') || s.includes('cancel')) return 'bg-rose-500/15 text-rose-400 border-rose-500/20'; - return 'bg-white/5 text-slate-400 border-white/10'; + fetch(`${API_BASE}/tiktok-order/orders?${params.toString()}`, { headers: authHeaders() }) + .then((r) => r.json()) + .then((d) => { + setItems(d.items || []); + setTotal(d.total || 0); + setTotalPages(d.totalPages || 1); + }) + .catch(() => { + setItems([]); + setTotal(0); + }) + .finally(() => setLoading(false)); + }, [shop, page, refreshKey, search, statusFilter, bomFilter]); + + const clearFilters = () => { + setSearchInput(''); + setSearch(''); + setStatusFilter('all'); + setBomFilter('all'); }; return (
-
-

ทั้งหมด {total.toLocaleString()} คำสั่งซื้อ

-
- - {page} / {totalPages} - + {/* Filters Bar: Search & Status Tabs */} +
+ {/* Search Input Box */} +
+ + setSearchInput(e.target.value)} + placeholder="ค้นหา Order ID, ผู้ซื้อ, Tracking, SKU, ชื่อสินค้า..." + className="w-full pl-10 pr-9 py-2 rounded-xl bg-black/30 border border-white/10 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-rose-500/50 transition-colors font-mono" + /> + {searchInput && ( + + )} +
+ + {/* Status & BOM Filter Pills */} +
+ {/* BOM Filter Tabs */} +
+ + + +
+ + {/* Status Filter Tabs */} +
+ + + + + +
+ + {(searchInput || statusFilter !== 'all' || bomFilter !== 'all') && ( + + )}
-
- - + {/* Pagination & Counter Bar */} +
+

+ {search || statusFilter !== 'all' || bomFilter !== 'all' ? ( + <> + พบ {total.toLocaleString()} รายการ{' '} + {search && (จากคำค้นหา "{search}")} + {bomFilter === 'missing' && [กรองเฉพาะที่ขาด BOM]} + + ) : ( + <> + ทั้งหมด {total.toLocaleString()} คำสั่งซื้อ + + )} +

+
+ + + {page} / {totalPages} + + +
+
+ +
+
+ - - - - - - - - + + + + + + + + + + {loading ? ( - - ) : items.length === 0 ? ( - - ) : items.map((order) => ( - - - - - - - - - + - ))} + ) : items.length === 0 ? ( + + + + ) : ( + items.map((order) => { + const hasMissingBom = order.hasIncompleteBom; + return ( + + {/* Order ID & Settlement Badge */} + + + {/* Status */} + + + {/* Buyer */} + + + {/* Date */} + + + {/* SKU & BOM */} + + + {/* Order Amount */} + + + {/* BOM Cost */} + + + {/* Commission (฿) */} + + + {/* Affiliate (฿) */} + + + {/* Profit */} + + + {/* Actions */} + + + ); + }) + )}
Order IDสถานะผู้ซื้อวันที่สร้างSKUsยอดรวม (฿)ค่าส่ง (฿) + Order IDสถานะผู้ซื้อ / พื้นที่วันที่สร้างSKUs & BOMยอดขาย (฿)ต้นทุน BOM (฿)Commission (฿)Affiliate (฿)กำไรสุทธิ
กำลังโหลด...
ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน
{order.orderId} -
- {order.status} - {order.subStatus && {order.subStatus}} -
-
{order.buyerUsername}{order.createdTime?.slice(0, 16)} - - {order.items?.length ?? 0} SKU - - {fmt(order.orderAmount)}{fmt(order.shippingFee)} - - รายละเอียด - +
+
⏳
กำลังประมวลผลข้อมูล...
ยังไม่มีข้อมูล
+
+ + {order.orderId} + + {order.isSettled ? ( + + โอนแล้ว ฿{fmt(order.netSettlement)} + + ) : ( + รอโอนเงิน + )} +
+
+
+ + {order.status} + + {order.subStatus && {order.subStatus}} +
+
+

{order.buyerUsername || '-'}

+ {order.province &&

อ.{order.district || ''} จ.{order.province}

} +
{order.createdTime?.slice(0, 16)} +
+ + + {order.items?.length ?? 0} SKU + + {hasMissingBom ? ( + + + ขาด BOM ({order.missingBomCount}) + + ) : ( + + + BOM ครบ + + )} +
+
+ ฿{fmt(order.orderAmount)} + + {hasMissingBom ? ( +
+ + ยังไม่ระบุ + + {order.orderTotalBomCost > 0 && ( +

มีบางส่วน ฿{fmt(order.orderTotalBomCost)}

+ )} +
+ ) : ( + ฿{fmt(order.orderTotalBomCost)} + )} +
+ {order.commissionFee > 0 ? ( + -฿{fmt(order.commissionFee)} + ) : ( + - + )} + + {order.affiliateFee > 0 ? ( + -฿{fmt(order.affiliateFee)} + ) : ( + - + )} + + {hasMissingBom ? ( + รอใส่ราคา BOM + ) : ( +
+

= 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + ฿{fmt(order.orderNetProfit ?? order.orderEstProfit)} +

+

{order.orderGrossMargin?.toFixed(1)}%

+
+ )} +
+ + ดูออเดอร์ + +
@@ -158,21 +492,35 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) ); } -// ── Import Panel ────────────────────────────────────────────────────────────── +// ── Import Panel (Supports Both Orders CSV & Settlement XLSX) ────────────────── +interface FileSummary { + name: string; + size: number; + rowsCount: number; + ordersCount: number; +} + function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }) { + const [importType, setImportType] = useState<'orders' | 'settlement'>('orders'); + + // Orders CSV State + const [fileSummaries, setFileSummaries] = useState([]); const [allRows, setAllRows] = useState([]); const [previewRows, setPreviewRows] = useState([]); + const [crossFileDuplicateCount, setCrossFileDuplicateCount] = useState(0); + + // Settlement XLSX State + const [settlementRows, setSettlementRows] = useState([]); + const [settlementStats, setSettlementStats] = useState(null); + const [settlementFile, setSettlementFile] = useState(null); + + const [reading, setReading] = useState(false); const [loading, setLoading] = useState(false); const [clearing, setClearing] = useState(false); const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); - const [fileInfo, setFileInfo] = useState(''); const shopCfg = getShop(shop); - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setMsg(null); setAllRows([]); setPreviewRows([]); setFileInfo(''); - + const parseSingleCsv = (file: File) => new Promise((resolve, reject) => { Papa.parse(file, { header: true, skipEmptyLines: true, @@ -182,41 +530,241 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void const id = String(r['Order ID'] ?? '').replace(/\t/g, '').trim(); return id.length > 0; }); - setAllRows(rows); - setPreviewRows(rows.slice(0, 5)); - setFileInfo(`${file.name} | ${rows.length} แถว → ${new Set(rows.map(r => String(r['Order ID']).replace(/\t/g, '').trim())).size} คำสั่งซื้อ`); + resolve(rows); }, - error: (err) => setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' }), + error: reject, }); + }); + + const handleOrdersFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files ? Array.from(e.target.files) : []; + if (files.length === 0) return; + + setReading(true); + setMsg(null); + setAllRows([]); + setPreviewRows([]); + setFileSummaries([]); + setCrossFileDuplicateCount(0); + + try { + const summaries: FileSummary[] = []; + const combinedRows: any[] = []; + const orderFileMap = new Map>(); + + for (const file of files) { + const rows = await parseSingleCsv(file); + const fileOrderIds = new Set(); + + rows.forEach(r => { + const orderId = String(r['Order ID'] ?? '').replace(/\t/g, '').trim(); + if (orderId) { + fileOrderIds.add(orderId); + if (!orderFileMap.has(orderId)) { + orderFileMap.set(orderId, new Set()); + } + orderFileMap.get(orderId)!.add(file.name); + } + }); + + summaries.push({ + name: file.name, + size: file.size, + rowsCount: rows.length, + ordersCount: fileOrderIds.size, + }); + + combinedRows.push(...rows); + } + + if (combinedRows.length === 0) { + setMsg({ text: 'ไม่พบข้อมูลคำสั่งซื้อในไฟล์ CSV ที่เลือก', type: 'error' }); + setReading(false); + return; + } + + let duplicatesAcrossFiles = 0; + orderFileMap.forEach((fileSet) => { + if (fileSet.size > 1) { + duplicatesAcrossFiles++; + } + }); + + setFileSummaries(summaries); + setAllRows(combinedRows); + setPreviewRows(combinedRows.slice(0, 5)); + setCrossFileDuplicateCount(duplicatesAcrossFiles); + } catch (err: any) { + setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' }); + } finally { + setReading(false); + } }; - const handleImport = async () => { + const handleSettlementFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + const file = files[0]; + setSettlementFile(file); + setReading(true); + setMsg(null); + setSettlementRows([]); + setSettlementStats(null); + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const data = event.target?.result; + const workbook = XLSX.read(data, { type: 'binary' }); + + const targetSheetName = workbook.SheetNames.find(n => + n.includes('รายละเอียดคำสั่งซื้อ') || n.includes('คำสั่งซื้อ') || n.includes('Order') + ) || workbook.SheetNames[0]; + + const sheet = workbook.Sheets[targetSheetName]; + + // TikTok Shop XLSX exports often contain a broken / hardcoded !ref (e.g. A1:X2) + // Recalculate true !ref range from all existing cell keys + if (sheet) { + const range = { s: { c: 10000000, r: 10000000 }, e: { c: 0, r: 0 } }; + let hasCells = false; + for (const key of Object.keys(sheet)) { + if (key.startsWith('!')) continue; + try { + const cell = XLSX.utils.decode_cell(key); + range.s.r = Math.min(range.s.r, cell.r); + range.s.c = Math.min(range.s.c, cell.c); + range.e.r = Math.max(range.e.r, cell.r); + range.e.c = Math.max(range.e.c, cell.c); + hasCells = true; + } catch { + // ignore invalid cell keys + } + } + if (hasCells) { + sheet['!ref'] = XLSX.utils.encode_range(range); + } + } + + const rawRows: any[] = XLSX.utils.sheet_to_json(sheet, { defval: '' }); + + const validRows = rawRows.filter(r => { + const id = String(r['หมายเลขคำสั่งซื้อ/การปรับ'] || r['Order ID'] || r['หมายเลขคำสั่งซื้อ'] || '').trim(); + return id.length > 0; + }); + + if (validRows.length === 0) { + throw new Error(`ไม่พบรายการคำสั่งซื้อใน Sheet "${targetSheetName}"`); + } + + let totalSettlement = 0; + let totalRevenue = 0; + let totalCommission = 0; + let totalAffiliate = 0; + let totalFee = 0; + + validRows.forEach(r => { + const toN = (val: any) => { + if (val == null || val === '') return 0; + const s = String(val).replace(/[^0-9.\-]/g, ''); + const n = parseFloat(s); + return isNaN(n) ? 0 : n; + }; + totalSettlement += toN(r['ยอดการชำระเงินทั้งหมด'] || r['Total Settlement Amount']); + totalRevenue += toN(r['รายได้ทั้งหมด'] || r['Total Revenue']); + totalCommission += Math.abs(toN(r['ค่าคอมมิชชั่น TikTok Shop'])); + totalAffiliate += Math.abs(toN(r['ค่าคอมมิชชั่นแอฟฟิลิเอต'])) + Math.abs(toN(r['ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต'])); + totalFee += toN(r['ค่าธรรมเนียมทั้งหมด']); + }); + + setSettlementRows(validRows); + setSettlementStats({ + rowsCount: validRows.length, + sheetName: targetSheetName, + totalSettlement, + totalRevenue, + totalCommission, + totalAffiliate, + totalFee, + }); + } catch (err: any) { + setMsg({ text: `อ่านไฟล์ XLSX ไม่สำเร็จ: ${err.message}`, type: 'error' }); + } finally { + setReading(false); + } + }; + reader.onerror = () => { + setMsg({ text: 'เกิดข้อผิดพลาดในการอ่านไฟล์', type: 'error' }); + setReading(false); + }; + reader.readAsBinaryString(file); + }; + + const handleImportOrders = async () => { if (!allRows.length) return; - setLoading(true); setMsg(null); + setLoading(true); + setMsg(null); try { - const res = await fetch(`${API_URL}/api/tiktok-order/import`, { - method: 'POST', headers: authHeaders(), + const res = await fetch(`${API_BASE}/tiktok-order/import`, { + method: 'POST', + headers: authHeaders(), body: JSON.stringify({ rows: allRows, shop }), }); const result = await res.json(); if (result.success) { setMsg({ text: result.message, type: 'success' }); - setAllRows([]); setPreviewRows([]); setFileInfo(''); + setAllRows([]); + setPreviewRows([]); + setFileSummaries([]); + setCrossFileDuplicateCount(0); const inp = document.getElementById('tiktok-file-input') as HTMLInputElement; if (inp) inp.value = ''; onSuccess(); - } else throw new Error(result.message); + } else { + throw new Error(result.message); + } } catch (e: any) { setMsg({ text: e.message, type: 'error' }); - } finally { setLoading(false); } + } finally { + setLoading(false); + } + }; + + const handleImportSettlement = async () => { + if (!settlementRows.length) return; + setLoading(true); + setMsg(null); + try { + const res = await fetch(`${API_BASE}/tiktok-order/import-settlement`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ rows: settlementRows, shop }), + }); + const result = await res.json(); + if (result.success) { + setMsg({ text: result.message, type: 'success' }); + setSettlementRows([]); + setSettlementStats(null); + setSettlementFile(null); + const inp = document.getElementById('tiktok-settlement-input') as HTMLInputElement; + if (inp) inp.value = ''; + onSuccess(); + } else { + throw new Error(result.message); + } + } catch (e: any) { + setMsg({ text: e.message, type: 'error' }); + } finally { + setLoading(false); + } }; const handleClearData = async () => { - if (!confirm(`ยืนยันการลบข้อมูล TikTok ทั้งหมดของร้าน "${shopCfg.label}"?\nการกระทำนี้ไม่สามารถกู้คืนได้`)) return; + if (!confirm(`ยืนยันการลบข้อมูล TikTok ทั้งหมด (ทั้งคำสั่งซื้อและใบแจ้งยอด) ของร้าน "${shopCfg.label}"?\nการกระทำนี้ไม่สามารถกู้คืนได้`)) return; setClearing(true); setMsg(null); try { - const res = await fetch(`${API_URL}/api/tiktok-order/clear?shop=${shop}`, { + const res = await fetch(`${API_BASE}/tiktok-order/clear?shop=${shop}`, { method: 'DELETE', headers: authHeaders() }); @@ -238,13 +786,41 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void return (
- {/* Target shop indicator */} -
- -

- Import เข้าร้าน: {shopCfg.label} -

- {shopCfg.id} + {/* Target shop indicator & Sub-Tab Switcher */} +
+
+ +

+ Import เข้าร้าน: {shopCfg.label} +

+ {shopCfg.id} +
+ + {/* Mode Toggle */} +
+ + +
{msg && ( @@ -253,68 +829,246 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
)} - + {/* ───────────────────────────────────────────────────────────── */} + {/* 1. ORDERS CSV IMPORT MODE */} + {/* ───────────────────────────────────────────────────────────── */} + {importType === 'orders' && ( +
+ - {fileInfo && ( -
- 📋 {fileInfo} - 0 ? 'text-emerald-400' : 'text-amber-400'}`}>{uniqueOrders} orders + {reading && ( +
+ ⏳ กำลังประมวลผลไฟล์ CSV ทั้งหมด... +
+ )} + + {/* Selected Files Summary Cards */} + {fileSummaries.length > 0 && ( +
+
+
+ + ไฟล์ที่เลือก ({fileSummaries.length} ไฟล์) +
+
+ รวมทั้งหมด {allRows.length} แถวสินค้า → {uniqueOrders} คำสั่งซื้อไม่ซ้ำ +
+
+ +
+ {fileSummaries.map((f, idx) => ( +
+
+ +
+

{f.name}

+

+ {(f.size / 1024).toFixed(1)} KB • CSV Report +

+
+
+
+ {f.rowsCount} แถว + {f.ordersCount} ออเดอร์ +
+
+ ))} +
+ + {crossFileDuplicateCount > 0 ? ( +
+ +
+

ตรวจพบคำสั่งซื้อซ้ำกันระหว่างไฟล์ {crossFileDuplicateCount} รายการ

+

+ ระบบจะทำการรวมข้อมูลและ Upsert (อัปเดตอัตโนมัติ) ตาม Order ID โดยไม่สร้างข้อมูลเบิ้ล และยอดขายจะไม่ทับซ้อน +

+
+
+ ) : ( +
+ + ไฟล์ทั้งหมดไม่พบหมายเลขคำสั่งซื้อที่ซ้ำกันข้ามไฟล์ +
+ )} +
+ )} + + {/* Preview Table */} + {previewRows.length > 0 && ( +
+

Preview ตัวอย่างข้อมูลรวม (5 แถวแรก):

+
+ + + + {['Order ID', 'Order Status', 'Product Name', 'Variation', 'Quantity', 'SKU Subtotal After Discount', 'Order Amount'].map(k => ( + + ))} + + + + {previewRows.map((row, i) => ( + + {['Order ID', 'Order Status', 'Product Name', 'Variation', 'Quantity', 'SKU Subtotal After Discount', 'Order Amount'].map(k => ( + + ))} + + ))} + +
{k}
+ {String(row[k] ?? '-').replace(/\t/g, '').slice(0, 30)} +
+
+
+ + + +
+
+ )}
)} - {previewRows.length > 0 && ( -
-

Preview (5 แถวแรก):

-
- - - - {['Order ID', 'Order Status', 'Product Name', 'Variation', 'Quantity', 'SKU Subtotal After Discount', 'Order Amount'].map(k => ( - - ))} - - - - {previewRows.map((row, i) => ( - - {['Order ID', 'Order Status', 'Product Name', 'Variation', 'Quantity', 'SKU Subtotal After Discount', 'Order Amount'].map(k => ( - - ))} - - ))} - -
{k}
- {String(row[k] ?? '-').replace(/\t/g, '').slice(0, 30)} -
-
-
- + {/* ───────────────────────────────────────────────────────────── */} + {/* 2. SETTLEMENT XLSX IMPORT MODE */} + {/* ───────────────────────────────────────────────────────────── */} + {importType === 'settlement' && ( +
+ - -
+ {reading && ( +
+ ⏳ กำลังอ่านข้อมูลและแจกแจงค่าคอมมิชชั่นจากไฟล์ Excel... +
+ )} + + {/* Settlement Stats Breakdown Card */} + {settlementStats && ( +
+
+
+
+ +
+

+ สรุปข้อมูลรายงานการเงิน ({settlementFile?.name}) +

+

Sheet: {settlementStats.sheetName}

+
+
+ + พบ {settlementStats.rowsCount} รายการธุรกรรม + +
+ +
+
+

ยอดเงินโอนสุทธิ (Net Payout)

+

฿{fmt(settlementStats.totalSettlement)}

+
+
+

ยอดขายสินค้ารวม (Revenue)

+

฿{fmt(settlementStats.totalRevenue)}

+
+
+

ค่าคอมมิชชั่น TikTok

+

฿{fmt(settlementStats.totalCommission)}

+
+
+

ค่า Affiliate พันธมิตร

+

฿{fmt(settlementStats.totalAffiliate)}

+
+
+
+ + {/* Settlement Preview Table */} +
+

ตัวอย่างรายการใบแจ้งยอด (5 แถวแรก):

+
+ + + + + + + + + + + + + {settlementRows.slice(0, 5).map((r, i) => ( + + + + + + + + + ))} + +
Order IDวันที่ชำระยอดโอนสุทธิCommissionAffiliateค่าธรรมเนียมคำสั่งซื้อ
+ {r['หมายเลขคำสั่งซื้อ/การปรับ'] || r['Order ID']} + {r['เวลาที่ชำระคำสั่งซื้อ'] || r['Order Settled Time'] || '-'} + ฿{fmt(Number(r['ยอดการชำระเงินทั้งหมด'] || r['Total Settlement Amount']))} + + ฿{fmt(Math.abs(Number(r['ค่าคอมมิชชั่น TikTok Shop'] || 0)))} + + ฿{fmt(Math.abs(Number(r['ค่าคอมมิชชั่นแอฟฟิลิเอต'] || 0)) + Math.abs(Number(r['ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต'] || 0)))} + + ฿{fmt(Math.abs(Number(r['ค่าธรรมเนียมคำสั่งซื้อ'] || 0)))} +
+
+
+ +
+ +
+
+ )}
)}
@@ -333,7 +1087,7 @@ export default function TiktokOrderPage() { const loadSummary = (s: string) => { setSummary(null); - fetch(`${API_URL}/api/tiktok-order/summary?shop=${s}`, { headers: authHeaders() }) + fetch(`${API_BASE}/tiktok-order/summary?shop=${s}`, { headers: authHeaders() }) .then(r => r.json()).then(setSummary).catch(() => { }); }; @@ -363,7 +1117,7 @@ export default function TiktokOrderPage() {
diff --git a/apps/admin/src/pages/analytics/OmnichannelSalesMapPage.tsx b/apps/admin/src/pages/analytics/OmnichannelSalesMapPage.tsx new file mode 100644 index 0000000..28c71b8 --- /dev/null +++ b/apps/admin/src/pages/analytics/OmnichannelSalesMapPage.tsx @@ -0,0 +1,829 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Map, ShoppingBag, DollarSign, Award, + Calendar, Table2, Search, CheckCircle2, XCircle, Filter, + MapPin, Globe2, TrendingUp, Layers, ArrowUpRight +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; +import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg'; +import OmnichannelDistrictModal from '../../components/analytics/OmnichannelDistrictModal'; +import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils'; +import { TIKTOK_SHOPS } from '../TiktokOrderPage'; +import { SHOPEE_SHOPS } from '../ShopeePage'; +import { LAZADA_SHOPS } from '../LazadaOrderPage'; + +type ViewMode = 'map' | 'list'; +type TimePeriod = 'all' | '30days' | 'this_year' | 'custom'; +type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada'; +type StatusFilter = 'completed' | 'all' | 'cancelled'; + +export default function OmnichannelSalesMapPage() { + const [viewMode, setViewMode] = useState('map'); + const [channel, setChannel] = useState('all'); + const [tiktokShop, setTiktokShop] = useState('all'); + const [shopeeShop, setShopeeShop] = useState('all'); + const [lazadaShop, setLazadaShop] = useState('all'); + const [statusFilter, setStatusFilter] = useState('completed'); + const [period, setPeriod] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + const [loading, setLoading] = useState(false); + const [mapData, setMapData] = useState(null); + const [selectedProvince, setSelectedProvince] = useState(null); + const [showDistrictModal, setShowDistrictModal] = useState(false); + const [searchTable, setSearchTable] = useState(''); + + // Handle Period Presets + const handlePeriodChange = (newPeriod: TimePeriod) => { + setPeriod(newPeriod); + const now = new Date(); + if (newPeriod === 'all') { + setDateFrom(''); + setDateTo(''); + } else if (newPeriod === '30days') { + const past = new Date(); + past.setDate(now.getDate() - 30); + setDateFrom(past.toISOString().slice(0, 10)); + setDateTo(now.toISOString().slice(0, 10)); + } else if (newPeriod === 'this_year') { + const startOfYear = new Date(now.getFullYear(), 0, 1); + setDateFrom(startOfYear.toISOString().slice(0, 10)); + setDateTo(now.toISOString().slice(0, 10)); + } + }; + + // Fetch Map Data + const fetchData = () => { + setLoading(true); + const params = new URLSearchParams(); + if (channel !== 'all') params.set('channel', channel); + if (tiktokShop !== 'all') params.set('tiktokShop', tiktokShop); + if (shopeeShop !== 'all') params.set('shopeeShop', shopeeShop); + if (lazadaShop !== 'all') params.set('lazadaShop', lazadaShop); + if (statusFilter) params.set('statusFilter', statusFilter); + if (dateFrom) params.set('dateFrom', dateFrom); + if (dateTo) params.set('dateTo', dateTo); + + fetch(`${API_BASE}/business-analytics/sales-map?${params.toString()}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((r) => r.json()) + .then((data) => { + setMapData(data); + if (!selectedProvince && data.topProvinces?.length > 0) { + setSelectedProvince(data.topProvinces[0].nameTh); + } + }) + .catch((err) => console.error('Failed to load omnichannel map data:', err)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + fetchData(); + }, [channel, tiktokShop, shopeeShop, lazadaShop, statusFilter, dateFrom, dateTo]); + + // Provinces lookup record for SVG Map + const provincesLookup = useMemo(() => { + const map: Record = {}; + if (mapData?.provinces) { + for (const p of mapData.provinces) { + map[p.nameTh] = p; + } + } + return map; + }, [mapData]); + + // Lookup details of the currently selected province + const selectedProvinceDetail = useMemo(() => { + if (!mapData?.provinces || !selectedProvince) return null; + return mapData.provinces.find((p: any) => p.nameTh === selectedProvince) || null; + }, [mapData, selectedProvince]); + + // Filtered provinces for table view + const filteredProvinces = useMemo(() => { + if (!mapData?.provinces) return []; + if (!searchTable.trim()) return mapData.provinces; + const q = searchTable.toLowerCase(); + return mapData.provinces.filter( + (p: any) => + p.nameTh.toLowerCase().includes(q) || + p.nameEn.toLowerCase().includes(q) || + p.regionTh.toLowerCase().includes(q) + ); + }, [mapData, searchTable]); + + const kpi = mapData?.kpi; + + return ( +
+ {/* ── Page Header ──────────────────────────────────────────────────────── */} +
+
+
+ +
+
+
+

Omnichannel Sales Map

+ + TikTok + Shopee + Lazada + +
+

+ Business Analytics • แผนที่ยอดขายรวม 77 จังหวัด และระบบเจาะลึกรายอำเภอ 928 อำเภอ +

+
+
+ + {/* View Switcher */} +
+ + +
+
+ + {/* ── Filter Bar ────────────────────────────────────────────────────────── */} +
+
+ {/* Channel Selector */} +
+ + + + +
+ + {/* Time Period Filter */} +
+ + + + + +
+
+ + {/* Secondary Filters: Shop selections & Custom Date Pickers */} +
+
+ {/* TikTok Shop Dropdown */} + {(channel === 'all' || channel === 'tiktok') && ( +
+ ร้าน TikTok: + +
+ )} + + {/* Shopee Shop Dropdown */} + {(channel === 'all' || channel === 'shopee') && ( +
+ ร้าน Shopee: + +
+ )} + + {/* Lazada Shop Dropdown */} + {(channel === 'all' || channel === 'lazada') && ( +
+ ร้าน Lazada: + +
+ )} + + {/* Status Filter */} +
+ + สถานะ: + + + + +
+
+ + {/* Custom Date Range */} + {period === 'custom' && ( +
+ ตั้งแต่วันที่: + setDateFrom(e.target.value)} + className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none" + /> + ถึง + setDateTo(e.target.value)} + className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none" + /> +
+ )} +
+
+ + {/* ── KPI Summary Cards ─────────────────────────────────────────────────── */} +
+ {/* Total Revenue & Channel Split */} +
+
+

ยอดขายรวมทุกช่องทาง

+ +
+
+

+ {fmtBaht(kpi?.totalRevenue || 0)} +

+ {/* Split bar: TikTok Rose / Shopee Orange / Lazada Blue */} +
+
+
+
+
+
+
+ TT {kpi?.tiktokSharePercent?.toFixed(0)}% + SP {kpi?.shopeeSharePercent?.toFixed(0)}% + LZ {kpi?.lazadaSharePercent?.toFixed(0)}% +
+
+
+
+ + {/* Total Orders & Channel Count */} +
+
+

คำสั่งซื้อทั้งหมด

+ +
+
+

+ {fmtNum(kpi?.totalOrders || 0)} ออเดอร์ +

+
+ TT: {fmtNum(kpi?.totalTiktokOrders || 0)} + SP: {fmtNum(kpi?.totalShopeeOrders || 0)} + LZ: {fmtNum(kpi?.totalLazadaOrders || 0)} +
+
+
+ + {/* Province Coverage */} +
+
+

การเข้าถึงพื้นที่

+ +
+
+

+ {kpi?.coverageCount || 0} / 77 จังหวัด +

+

+ ครอบคลุม {kpi?.coveragePercent?.toFixed(1)}% ของประเทศไทย +

+
+
+ + {/* AOV (Average Order Value) */} +
+
+

ยอดซื้อเฉลี่ย (AOV)

+ +
+
+

+ {fmtBaht(kpi?.aov || 0)} +

+
+ TT: ฿{fmtNum(kpi?.tiktokAov || 0)} + SP: ฿{fmtNum(kpi?.shopeeAov || 0)} + LZ: ฿{fmtNum(kpi?.lazadaAov || 0)} +
+
+
+ + {/* Top Region */} +
+
+

ภูมิภาคยอดขายสูงสุด

+ +
+
+

+ {kpi?.topRegion?.name || '-'} +

+

+ {fmtBaht(kpi?.topRegion?.revenue || 0)} • {fmtNum(kpi?.topRegion?.orders || 0)} ออเดอร์ +

+
+
+
+ + {/* ── Main Section: Map View or Table View ─────────────────────────────── */} + {viewMode === 'map' ? ( +
+ {/* Map & Focus Card Container (8 cols) */} +
+
+
+
+ +

แผนที่ความหนาแน่นยอดขาย (Thailand Choropleth)

+
+
+
+ + ยอดขายน้อย +
+
+ + ยอดขายสูง +
+
+
+ + {/* Thailand SVG Map */} +
+ {loading ? ( +
+ ⏳ กำลังประมวลผลข้อมูลแผนที่... +
+ ) : ( + setSelectedProvince(provName)} + /> + )} +
+
+ + {/* Selected Province Details Card */} + {selectedProvinceDetail && ( +
+
+
+
+ +

จังหวัด{selectedProvinceDetail.nameTh}

+ ({selectedProvinceDetail.nameEn}) + + {selectedProvinceDetail.regionTh} + +
+
+ ยอดรวม: {fmtBaht(selectedProvinceDetail.revenue)} + คำสั่งซื้อ: {selectedProvinceDetail.orders} รายการ + AOV: {fmtBaht(selectedProvinceDetail.aov)} +
+ + {/* Platform Breakdown Mini Bar */} +
+ สัดส่วนยอดขาย: +
+
+
+
+
+ TT: {fmtBaht(selectedProvinceDetail.tiktokRevenue)} + SP: {fmtBaht(selectedProvinceDetail.shopeeRevenue)} + LZ: {fmtBaht(selectedProvinceDetail.lazadaRevenue)} +
+
+ + +
+
+ )} +
+ + {/* Top 20 Provinces Ranking & Region Breakdown (4 cols) */} +
+ {/* Top 20 Provinces Card */} +
+
+
+ +

Top 20 จังหวัดยอดขายสูงสุด

+
+ Omnichannel +
+ +
+ {mapData?.topProvinces?.map((item: any) => { + const isSelected = selectedProvince === item.nameTh; + return ( +
setSelectedProvince(item.nameTh)} + className={`p-3 rounded-xl border transition-all cursor-pointer ${ + isSelected + ? 'bg-orange-500/20 border-orange-500/40 shadow-sm' + : 'bg-white/[0.02] border-white/5 hover:bg-white/5 hover:border-white/10' + }`} + > +
+
+ + {item.rank} + +
+

{item.nameTh}

+

{item.orders} ออเดอร์

+
+
+
+

{fmtBaht(item.revenue)}

+
+ TT {item.tiktokShare?.toFixed(0)}% + • + SP {item.shopeeShare?.toFixed(0)}% + • + LZ {item.lazadaShare?.toFixed(0)}% +
+
+
+ + {/* Mini platform share bar */} +
+
+
+
+
+
+ ); + })} +
+
+ + {/* Region Breakdown Card */} +
+
+ +

สัดส่วนยอดขายตามภูมิภาค

+
+ +
+ {mapData?.regionBreakdown?.map((reg: any) => ( +
+
+ {reg.regionTh} + + {fmtBaht(reg.revenue)}{' '} + ({reg.percentage?.toFixed(1)}%) + +
+
+
+
+
+
+
+ {reg.orders} ออเดอร์ + + TT ฿{fmtNum(reg.tiktokRevenue)} | SP ฿{fmtNum(reg.shopeeRevenue)} | LZ ฿{fmtNum(reg.lazadaRevenue)} + +
+
+ ))} +
+
+
+
+ ) : ( + /* ── Table View: 77 Provinces ────────────────────────────────────────── */ +
+
+
+ + setSearchTable(e.target.value)} + className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40" + /> +
+

+ แสดง {filteredProvinces.length} จาก 77 จังหวัด +

+
+ +
+ + + + + + + + + + + + + + + + + {filteredProvinces.length === 0 ? ( + + + + ) : ( + filteredProvinces.map((p: any) => ( + + + + + + + + + + + + + + )) + )} + +
รหัสจังหวัดภูมิภาคยอดรวม (฿)ออเดอร์รวมTikTok (฿)Shopee (฿)Lazada (฿)AOV (฿)สัดส่วนช่องทาง +
+ ไม่พบข้อมูลจังหวัดตามคำค้นหา +
{p.code} + {p.nameTh} ({p.nameEn}) + + + {p.regionTh} + + + {p.revenue > 0 ? fmtBaht(p.revenue) : '-'} + + {p.orders > 0 ? `${p.orders} ออเดอร์` : '-'} + + {p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'} + + {p.shopeeRevenue > 0 ? fmtBaht(p.shopeeRevenue) : '-'} + + {p.lazadaRevenue > 0 ? fmtBaht(p.lazadaRevenue) : '-'} + + {p.aov > 0 ? fmtBaht(p.aov) : '-'} + + {p.revenue > 0 ? ( +
+
+
+
+
+
+
+ {p.tiktokShare?.toFixed(0)}% + {p.shopeeShare?.toFixed(0)}% + {p.lazadaShare?.toFixed(0)}% +
+
+ ) : ( + - + )} +
+ +
+
+
+ )} + + {/* ── District Level Modal ─────────────────────────────────────────────── */} + {showDistrictModal && selectedProvince && ( + setShowDistrictModal(false)} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/analytics/ProductSalesMapPage.tsx b/apps/admin/src/pages/analytics/ProductSalesMapPage.tsx new file mode 100644 index 0000000..3d6aa88 --- /dev/null +++ b/apps/admin/src/pages/analytics/ProductSalesMapPage.tsx @@ -0,0 +1,970 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Map, ShoppingBag, DollarSign, Award, + Calendar, Table2, Search, CheckCircle2, XCircle, Filter, + MapPin, Globe2, Layers, ArrowUpRight, + Package, ChevronDown, Loader2 +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; +import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg'; +import ProductDistrictModal from '../../components/analytics/ProductDistrictModal'; +import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils'; + +type ViewMode = 'map' | 'list'; +type TimePeriod = 'all' | '30days' | 'this_year' | 'custom'; +type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada'; +type MetricType = 'revenue' | 'quantity'; +type StatusFilter = 'completed' | 'all' | 'cancelled'; + +interface ProductItem { + id: number; + name: string; + category: string; + brand: string; + totalMappedMarketplaceCount: number; + totalSoldUnits: number; + totalRevenue: number; + variants: Array<{ + id: number; + sku: string; + barcode: string; + price: number; + stock: number; + optionsStr: string; + displayName: string; + mappedMarketplaceCount: number; + soldUnits: number; + revenue: number; + }>; +} + +export default function ProductSalesMapPage() { + const [viewMode, setViewMode] = useState('map'); + const [metric, setMetric] = useState('revenue'); + const [channel, setChannel] = useState('all'); + const [statusFilter, setStatusFilter] = useState('completed'); + const [period, setPeriod] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + // Products list + const [productsList, setProductsList] = useState([]); + const [loadingProducts, setLoadingProducts] = useState(true); + const [selectedProductId, setSelectedProductId] = useState(null); + const [selectedVariantId, setSelectedVariantId] = useState(null); + const [showProductDropdown, setShowProductDropdown] = useState(false); + const [productSearch, setProductSearch] = useState(''); + + // Map Data + const [loading, setLoading] = useState(false); + const [mapData, setMapData] = useState(null); + const [selectedProvince, setSelectedProvince] = useState(null); + const [showDistrictModal, setShowDistrictModal] = useState(false); + const [searchTable, setSearchTable] = useState(''); + + // 1. Fetch Products List + useEffect(() => { + setLoadingProducts(true); + fetch(`${API_BASE}/business-analytics/products-list`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((r) => r.json()) + .then((res) => { + if (res.products && res.products.length > 0) { + setProductsList(res.products); + // Default to first product + setSelectedProductId(res.products[0].id); + } + }) + .catch((err) => console.error('Failed to load products list:', err)) + .finally(() => setLoadingProducts(false)); + }, []); + + // Handle Period Presets + const handlePeriodChange = (newPeriod: TimePeriod) => { + setPeriod(newPeriod); + const now = new Date(); + if (newPeriod === 'all') { + setDateFrom(''); + setDateTo(''); + } else if (newPeriod === '30days') { + const past = new Date(); + past.setDate(now.getDate() - 30); + setDateFrom(past.toISOString().slice(0, 10)); + setDateTo(now.toISOString().slice(0, 10)); + } else if (newPeriod === 'this_year') { + const startOfYear = new Date(now.getFullYear(), 0, 1); + setDateFrom(startOfYear.toISOString().slice(0, 10)); + setDateTo(now.toISOString().slice(0, 10)); + } + }; + + // 2. Fetch Map Data for Selected Product / Variant + const fetchMapData = () => { + if (!selectedProductId && !selectedVariantId) return; + setLoading(true); + const params = new URLSearchParams(); + if (selectedVariantId) params.set('variantId', selectedVariantId.toString()); + else if (selectedProductId) params.set('productId', selectedProductId.toString()); + + if (channel !== 'all') params.set('channel', channel); + if (statusFilter) params.set('statusFilter', statusFilter); + if (dateFrom) params.set('dateFrom', dateFrom); + if (dateTo) params.set('dateTo', dateTo); + params.set('metric', metric); + + fetch(`${API_BASE}/business-analytics/sales-map-products?${params.toString()}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((r) => r.json()) + .then((data) => { + setMapData(data); + if (!selectedProvince && data.topProvinces?.length > 0) { + setSelectedProvince(data.topProvinces[0].nameTh); + } + }) + .catch((err) => console.error('Failed to load product map data:', err)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + fetchMapData(); + }, [selectedProductId, selectedVariantId, channel, metric, statusFilter, dateFrom, dateTo]); + + // Selected Product & Variant Details + const currentProduct = useMemo(() => { + return productsList.find((p) => p.id === selectedProductId) || null; + }, [productsList, selectedProductId]); + + const currentVariant = useMemo(() => { + if (!currentProduct || !selectedVariantId) return null; + return currentProduct.variants.find((v) => v.id === selectedVariantId) || null; + }, [currentProduct, selectedVariantId]); + + // Filter products in dropdown search + const filteredDropdownProducts = useMemo(() => { + if (!productSearch.trim()) return productsList; + const q = productSearch.toLowerCase(); + return productsList.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.category.toLowerCase().includes(q) || + p.variants.some((v) => v.sku.toLowerCase().includes(q) || v.optionsStr.toLowerCase().includes(q)) + ); + }, [productsList, productSearch]); + + // Provinces lookup record for SVG Map + const provincesLookup = useMemo(() => { + const map: Record = {}; + if (mapData?.provinces) { + for (const p of mapData.provinces) { + map[p.nameTh] = { + ...p, + // When metric is quantity, set revenue to unitsSold for SVG heat scale + revenue: metric === 'quantity' ? p.unitsSold : p.revenue, + }; + } + } + return map; + }, [mapData, metric]); + + // Lookup details of the currently selected province + const selectedProvinceDetail = useMemo(() => { + if (!mapData?.provinces || !selectedProvince) return null; + return mapData.provinces.find((p: any) => p.nameTh === selectedProvince) || null; + }, [mapData, selectedProvince]); + + // Filtered provinces for table view + const filteredProvinces = useMemo(() => { + if (!mapData?.provinces) return []; + if (!searchTable.trim()) return mapData.provinces; + const q = searchTable.toLowerCase(); + return mapData.provinces.filter( + (p: any) => + p.nameTh.toLowerCase().includes(q) || + p.nameEn.toLowerCase().includes(q) || + p.regionTh.toLowerCase().includes(q) + ); + }, [mapData, searchTable]); + + const kpi = mapData?.kpi; + + return ( +
+ {/* ── Page Header ──────────────────────────────────────────────────────── */} +
+
+
+ +
+
+
+

Product Sales Map

+ + Geo-Intelligence by Product + +
+

+ Business Analytics • วิเคราะห์ความหนาแน่นและพื้นที่ยอดขายของสินค้าแต่ละรายการใน 77 จังหวัด และ 928 อำเภอ +

+
+
+ + {/* View Switcher & Metric Switcher */} +
+ {/* Metric Switcher */} +
+ + +
+ + {/* View Switcher */} +
+ + +
+
+
+ + {/* ── Product & Variant Selector Bar ────────────────────────────────────── */} +
+
+
+ เลือกสินค้า Master: + + {/* Product Selector Dropdown Button */} +
+ + + {/* Dropdown Menu */} + {showProductDropdown && ( +
+
+ + setProductSearch(e.target.value)} + className="w-full pl-9 pr-3 py-1.5 rounded-xl bg-black/50 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40" + autoFocus + /> +
+ +
+ {filteredDropdownProducts.map((prod) => { + const isSelected = selectedProductId === prod.id && selectedVariantId === null; + return ( +
{ + setSelectedProductId(prod.id); + setSelectedVariantId(null); + setShowProductDropdown(false); + }} + className={`p-2.5 rounded-xl border transition-all cursor-pointer flex items-center justify-between text-xs ${ + isSelected + ? 'bg-indigo-500/20 border-indigo-500/40 text-white font-bold' + : 'bg-white/[0.02] border-white/5 hover:bg-white/5 text-slate-300' + }`} + > +
+

{prod.name}

+
+ {prod.category} + • {prod.variants.length} ตัวเลือก +
+
+
+

{fmtBaht(prod.totalRevenue)}

+

{fmtNum(prod.totalSoldUnits)} ชิ้น

+
+
+ ); + })} +
+
+ )} +
+
+ + {/* Variant Selector Tabs */} + {currentProduct && currentProduct.variants.length > 0 && ( +
+ ขนาด/ตัวเลือก: + + + {currentProduct.variants.map((v) => { + const isSelected = selectedVariantId === v.id; + return ( + + ); + })} +
+ )} +
+
+ + {/* ── Filter Bar ────────────────────────────────────────────────────────── */} +
+
+ {/* Channel Selector */} +
+ + + + +
+ + {/* Time Period Filter */} +
+ + + + + +
+
+ + {/* Secondary Row: Status Filter & Custom Date Range */} +
+ {/* Status Filter */} +
+ + สถานะ: + + + + +
+ + {/* Custom Date Range */} + {period === 'custom' && ( +
+ ตั้งแต่วันที่: + setDateFrom(e.target.value)} + className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none" + /> + ถึง + setDateTo(e.target.value)} + className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none" + /> +
+ )} +
+
+ + {/* ── KPI Summary Cards ─────────────────────────────────────────────────── */} +
+ {/* Product Total Revenue */} +
+
+

ยอดขายสินค้านี้รวม

+ +
+
+

+ {fmtBaht(kpi?.totalRevenue || 0)} +

+ {/* Split bar */} +
+
+
+
+
+
+
+ TT {kpi?.tiktokSharePercent?.toFixed(0)}% + SP {kpi?.shopeeSharePercent?.toFixed(0)}% + LZ {kpi?.lazadaSharePercent?.toFixed(0)}% +
+
+
+
+ + {/* Total Units Sold */} +
+
+

จำนวนชิ้นที่ขายได้

+ +
+
+

+ {fmtNum(kpi?.totalUnitsSold || 0)} ชิ้น +

+
+ TT: {fmtNum(kpi?.totalTiktokUnits || 0)} + SP: {fmtNum(kpi?.totalShopeeUnits || 0)} + LZ: {fmtNum(kpi?.totalLazadaUnits || 0)} +
+
+
+ + {/* Province Coverage */} +
+
+

การกระจายตัวในจังหวัด

+ +
+
+

+ {kpi?.coverageCount || 0} / 77 จังหวัด +

+

+ ครอบคลุม {kpi?.coveragePercent?.toFixed(1)}% ของประเทศไทย +

+
+
+ + {/* Top Province */} +
+
+

จังหวัดที่ขายดีที่สุด

+ +
+
+

+ {kpi?.topProvince?.name ? `จ.${kpi.topProvince.name}` : '-'} +

+

+ {fmtBaht(kpi?.topProvince?.revenue || 0)} • {fmtNum(kpi?.topProvince?.unitsSold || 0)} ชิ้น +

+
+
+ + {/* Top Region */} +
+
+

ภูมิภาคยอดขายสูงสุด

+ +
+
+

+ {kpi?.topRegion?.name || '-'} +

+

+ {fmtBaht(kpi?.topRegion?.revenue || 0)} +

+
+
+
+ + {/* ── Main Section: Map View or Table View ─────────────────────────────── */} + {viewMode === 'map' ? ( +
+ {/* Map & Focus Card Container (8 cols) */} +
+
+
+
+ +

+ แผนที่ความหนาแน่น {metric === 'quantity' ? 'จำนวนชิ้นที่ขาย (Units)' : 'ยอดขาย (Revenue)'} +

+
+
+
+ + น้อย +
+
+ + มาก +
+
+
+ + {/* Thailand SVG Map */} +
+ {loading ? ( +
+ + กำลังประมวลผลข้อมูลแผนที่สินค้า... +
+ ) : ( + setSelectedProvince(provName)} + /> + )} +
+
+ + {/* Selected Province Details Card */} + {selectedProvinceDetail && ( +
+
+
+
+ +

จังหวัด{selectedProvinceDetail.nameTh}

+ ({selectedProvinceDetail.nameEn}) + + {selectedProvinceDetail.regionTh} + +
+
+ ยอดขาย: {fmtBaht(selectedProvinceDetail.revenue)} + จำนวนที่ขาย: {selectedProvinceDetail.unitsSold} ชิ้น + คำสั่งซื้อ: {selectedProvinceDetail.ordersCount} ออเดอร์ +
+ + {/* Platform Breakdown Mini Bar */} +
+ สัดส่วนช่องทาง: +
+
+
+
+
+ TT: {fmtBaht(selectedProvinceDetail.tiktokRevenue)} + SP: {fmtBaht(selectedProvinceDetail.shopeeRevenue)} + LZ: {fmtBaht(selectedProvinceDetail.lazadaRevenue)} +
+
+ + +
+
+ )} +
+ + {/* Top 20 Provinces Ranking & Region Breakdown (4 cols) */} +
+ {/* Top 20 Provinces Card */} +
+
+
+ +

Top 20 จังหวัดที่สินค้านี้ขายดีที่สุด

+
+ By {metric} +
+ +
+ {mapData?.topProvinces?.map((item: any) => { + const isSelected = selectedProvince === item.nameTh; + return ( +
setSelectedProvince(item.nameTh)} + className={`p-3 rounded-xl border transition-all cursor-pointer ${ + isSelected + ? 'bg-indigo-500/20 border-indigo-500/40 shadow-sm' + : 'bg-white/[0.02] border-white/5 hover:bg-white/5 hover:border-white/10' + }`} + > +
+
+ + {item.rank} + +
+

{item.nameTh}

+

{item.unitsSold} ชิ้น

+
+
+
+

{fmtBaht(item.revenue)}

+
+ TT {item.tiktokShare?.toFixed(0)}% + • + SP {item.shopeeShare?.toFixed(0)}% + • + LZ {item.lazadaShare?.toFixed(0)}% +
+
+
+ + {/* Mini platform share bar */} +
+
+
+
+
+
+ ); + })} +
+
+ + {/* Region Breakdown Card */} +
+
+ +

สัดส่วนตามภูมิภาค

+
+ +
+ {mapData?.regionBreakdown?.map((reg: any) => ( +
+
+ {reg.regionTh} + + {fmtBaht(reg.revenue)}{' '} + ({reg.unitsSold} ชิ้น) + +
+
+
+
+
+
+
+ ))} +
+
+
+
+ ) : ( + /* ── Table View: 77 Provinces ────────────────────────────────────────── */ +
+
+
+ + setSearchTable(e.target.value)} + className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40" + /> +
+

+ แสดง {filteredProvinces.length} จาก 77 จังหวัด +

+
+ +
+ + + + + + + + + + + + + + + + {filteredProvinces.length === 0 ? ( + + + + ) : ( + filteredProvinces.map((p: any) => ( + + + + + + + + + + + + + )) + )} + +
รหัสจังหวัดภูมิภาคยอดรวม (฿)จำนวนที่ขาย (ชิ้น)TikTok (฿)Shopee (฿)Lazada (฿)สัดส่วนช่องทาง +
+ ไม่พบข้อมูลจังหวัดตามคำค้นหา +
{p.code} + {p.nameTh} ({p.nameEn}) + + + {p.regionTh} + + + {p.revenue > 0 ? fmtBaht(p.revenue) : '-'} + + {p.unitsSold > 0 ? `${p.unitsSold} ชิ้น` : '-'} + + {p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'} + + {p.shopeeRevenue > 0 ? fmtBaht(p.shopeeRevenue) : '-'} + + {p.lazadaRevenue > 0 ? fmtBaht(p.lazadaRevenue) : '-'} + + {p.revenue > 0 ? ( +
+
+
+
+
+
+
+ {p.tiktokShare?.toFixed(0)}% + {p.shopeeShare?.toFixed(0)}% + {p.lazadaShare?.toFixed(0)}% +
+
+ ) : ( + - + )} +
+ +
+
+
+ )} + + {/* ── Product District Level Modal ─────────────────────────────────────── */} + {showDistrictModal && selectedProvince && ( + setShowDistrictModal(false)} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx b/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx new file mode 100644 index 0000000..bcb893d --- /dev/null +++ b/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx @@ -0,0 +1,908 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Wrench, + RefreshCw, + Search, + CheckCircle2, + AlertCircle, + Link2, + Unlink, + DollarSign, + ShoppingBag, + TrendingUp, + Package, + Sparkles, + X, + ChevronLeft, + ChevronRight, + Loader2, +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; +import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils'; +import { TIKTOK_SHOPS } from '../TiktokOrderPage'; +import { SHOPEE_SHOPS } from '../ShopeePage'; +import { LAZADA_SHOPS } from '../LazadaOrderPage'; + +interface MasterVariant { + id: number; + sku: string; + barcode: string; + price: number; + stock: number; + optionsStr: string; + productName: string; + categoryName: string; + brandName: string; + bomCost: number; + displayName: string; +} + +export default function MarketplaceProductsPage() { + const [loading, setLoading] = useState(false); + const [syncing, setSyncing] = useState(false); + const [autoMatching, setAutoMatching] = useState(false); + const [data, setData] = useState(null); + + // Filters + const [channel, setChannel] = useState<'all' | 'tiktok' | 'shopee' | 'lazada'>('all'); + const [shop, setShop] = useState('all'); + const [status, setStatus] = useState<'all' | 'mapped' | 'unmapped'>('all'); + const [sortBy, setSortBy] = useState('revenue'); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const limit = 30; + + // Master variants for mapping picker + const [masterVariants, setMasterVariants] = useState([]); + const [mappingModalItem, setMappingModalItem] = useState(null); + const [variantSearch, setVariantSearch] = useState(''); + const [selectedVariantId, setSelectedVariantId] = useState(null); + const [savingMapping, setSavingMapping] = useState(false); + + // Toast / notification message + const [toast, setToast] = useState<{ type: 'success' | 'info' | 'error'; message: string } | null>(null); + + const showToast = (message: string, type: 'success' | 'info' | 'error' = 'success') => { + setToast({ type, message }); + setTimeout(() => setToast(null), 4000); + }; + + // 1. Fetch Marketplace Products + const fetchData = () => { + setLoading(true); + const params = new URLSearchParams(); + if (channel !== 'all') params.set('channel', channel); + if (shop !== 'all') params.set('shop', shop); + if (status !== 'all') params.set('status', status); + if (sortBy) params.set('sortBy', sortBy); + if (search) params.set('search', search); + params.set('page', page.toString()); + params.set('limit', limit.toString()); + + fetch(`${API_BASE}/tools/marketplace-products?${params.toString()}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((r) => r.json()) + .then((res) => setData(res)) + .catch((err) => { + console.error('Failed to load marketplace products:', err); + showToast('เกิดข้อผิดพลาดในการโหลดข้อมูลสินค้า', 'error'); + }) + .finally(() => setLoading(false)); + }; + + // 2. Fetch Master Variants + const fetchMasterVariants = () => { + fetch(`${API_BASE}/tools/marketplace-products/master-variants`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }) + .then((r) => r.json()) + .then((res) => { + if (res.variants) setMasterVariants(res.variants); + }) + .catch((err) => console.error('Failed to load master variants:', err)); + }; + + useEffect(() => { + fetchData(); + }, [channel, shop, status, sortBy, search, page]); + + useEffect(() => { + fetchMasterVariants(); + }, []); + + // 3. Trigger Sync All Platforms + const handleSyncAll = async () => { + setSyncing(true); + try { + const res = await fetch(`${API_BASE}/tools/marketplace-products/sync`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }); + const result = await res.json(); + if (result.success) { + showToast( + `Sync ข้อมูลสินค้าเรียบร้อย! ทั้งหมด ${fmtNum(result.totalMarketplaceProducts)} รายการ (จับคู่ใหม่อัตโนมัติ ${result.matchedCount} รายการ)`, + 'success' + ); + fetchData(); + } else { + showToast('ไม่สามารถ Sync ข้อมูลได้', 'error'); + } + } catch (err) { + console.error(err); + showToast('เกิดข้อผิดพลาดในการเชื่อมต่อ Sync', 'error'); + } finally { + setSyncing(false); + } + }; + + // 4. Trigger Bulk Auto Match + const handleBulkAutoMatch = async () => { + setAutoMatching(true); + try { + const res = await fetch(`${API_BASE}/tools/marketplace-products/bulk-auto-match`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + }); + const result = await res.json(); + if (result.success) { + showToast( + `Auto-Match สำเร็จ! จับคู่ SKU ตรงกันได้เพิ่ม ${fmtNum(result.matched)} รายการ (คงเหลือยังไม่ผูก ${fmtNum(result.remainingUnmapped)} รายการ)`, + 'success' + ); + fetchData(); + } + } catch (err) { + console.error(err); + showToast('เกิดข้อผิดพลาดในการ Auto-Match', 'error'); + } finally { + setAutoMatching(false); + } + }; + + // 5. Save Mapping for Single Item + const handleSaveMapping = async (marketplaceProductId: number, masterVariantId: number | null) => { + setSavingMapping(true); + try { + const res = await fetch(`${API_BASE}/tools/marketplace-products/map`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`, + }, + body: JSON.stringify({ marketplaceProductId, masterVariantId }), + }); + const result = await res.json(); + if (result.success) { + showToast(masterVariantId ? 'ผูกสินค้ากับ Master Catalog เรียบร้อย' : 'ยกเลิกการผูกสินค้าเรียบร้อย', 'success'); + setMappingModalItem(null); + fetchData(); + } + } catch (err) { + console.error(err); + showToast('ไม่สามารถบันทึกการผูกสินค้าได้', 'error'); + } finally { + setSavingMapping(false); + } + }; + + // Filter master variants inside modal + const filteredMasterVariants = useMemo(() => { + if (!variantSearch.trim()) return masterVariants; + const q = variantSearch.toLowerCase(); + return masterVariants.filter( + (v) => + v.productName.toLowerCase().includes(q) || + v.sku.toLowerCase().includes(q) || + v.barcode.toLowerCase().includes(q) || + v.optionsStr.toLowerCase().includes(q) || + v.categoryName.toLowerCase().includes(q) + ); + }, [masterVariants, variantSearch]); + + const kpi = data?.kpi; + const items = data?.items || []; + const pagination = data?.pagination; + + return ( +
+ {/* ── Toast Notification ──────────────────────────────────────────────── */} + {toast && ( +
+ {toast.type === 'success' ? ( + + ) : ( + + )} + {toast.message} +
+ )} + + {/* ── Page Header ──────────────────────────────────────────────────────── */} +
+
+
+ +
+
+
+

Marketplace Products & SKU Manager

+ + Tool • TikTok + Shopee + Lazada + +
+

+ รวมรายการสินค้าที่ขายได้จากทุก Marketplace และเชื่อมโยง (SKU Mapping) เข้ากับ Master Catalog เพื่อวิเคราะห์ต้นทุนและกำไร +

+
+
+ + {/* Action Buttons */} +
+ + + +
+
+ + {/* ── KPI Summary Cards ─────────────────────────────────────────────────── */} +
+ {/* Total SKUs */} +
+
+

สินค้าบน Marketplace

+ +
+
+

+ {fmtNum(kpi?.totalSkus || 0)} รายการ/SKU +

+

+ รวมทุกรูปแบบสินค้าที่เคยขายได้ +

+
+
+ + {/* Mapping Progress */} +
+
+

สถานะการผูก Master SKU

+ +
+
+
+

+ {kpi?.mappedSkus || 0} / {kpi?.totalSkus || 0} +

+ + {kpi?.mappedPercent?.toFixed(1)}% + +
+ {/* Progress bar */} +
+
+
+

+ ยังไม่ผูก: {kpi?.unmappedSkus || 0} รายการ +

+
+
+ + {/* Total Sold Units */} +
+
+

จำนวนชิ้นที่ขายได้รวม

+ +
+
+

+ {fmtNum(kpi?.totalSoldQty || 0)} ชิ้น +

+

+ ยอดขายรวมสะสมจากทุกออเดอร์ +

+
+
+ + {/* Total Revenue */} +
+
+

ยอดขายรวม (Gross)

+ +
+
+

+ {fmtBaht(kpi?.totalRevenue || 0)} +

+

+ ยอดเงินที่ได้รับจากลูกค้า +

+
+
+ + {/* Est. Gross Profit (From Linked BOM) */} +
+
+

กำไรขั้นต้นประเมิน (BOM)

+ +
+
+

+ {fmtBaht(kpi?.totalEstProfit || 0)} +

+

+ Margin รวม: {kpi?.grossProfitMargin?.toFixed(1)}% +

+
+
+
+ + {/* ── Filter Bar ────────────────────────────────────────────────────────── */} +
+
+ {/* Channel Tabs */} +
+ + + + +
+ + {/* Shop Selector */} +
+ ร้านค้า: + +
+ + {/* Status Filter */} +
+ + + +
+
+ + {/* Secondary: Search & Sort */} +
+
+ + { + setSearch(e.target.value); + setPage(1); + }} + className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-black/40 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/40" + /> +
+ +
+
+ เรียงตาม: + +
+
+
+
+ + {/* ── Marketplace Products Table ────────────────────────────────────────── */} +
+
+ + + + + + + + + + + + + + + + + {loading ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((item: any) => { + const hasMapping = !!item.masterVariantId && !!item.masterVariant; + const profit = item.profitability; + + return ( + + {/* Platform & Shop */} + + + {/* Product Name & Variation */} + + + {/* Seller SKU */} + + + {/* Sold Qty */} + + + {/* Total Revenue */} + + + {/* Avg Price */} + + + {/* Master Core Catalog Mapping */} + + + {/* BOM Cost */} + + + {/* Estimated Profit */} + + + {/* Actions */} + + + ); + }) + )} + +
ช่องทาง / ร้านค้าชื่อสินค้า & ตัวเลือก (Marketplace)Seller SKUจำนวนที่ขายยอดขายรวม (฿)ราคาเฉลี่ยสินค้า Master (Core Catalog)ต้นทุน BOM (฿)กำไรประเมินจัดการ
+ + กำลังโหลดข้อมูลสินค้า... +
+

ไม่พบรายการสินค้าตามเงื่อนไขที่เลือก

+ +
+
+ {item.channel === 'tiktok' ? ( + + TikTok + + ) : item.channel === 'shopee' ? ( + + Shopee + + ) : ( + + Lazada + + )} +

{item.shop}

+
+
+

+ {item.productName} +

+ {item.variation && ( + + ตัวเลือก: {item.variation} + + )} +
+ {item.marketplaceSku ? ( + + {item.marketplaceSku} + + ) : ( + - + )} + + {fmtNum(item.totalSoldQty)} ชิ้น + + {fmtBaht(item.totalRevenue)} + + {fmtBaht(item.avgPrice)} + + {hasMapping ? ( +
+
+ + + {item.masterVariant?.productName} + +
+
+ SKU: {item.masterVariant?.sku || '-'} + {item.masterVariant?.option1 && • {item.masterVariant.option1}} +
+
+ ) : ( + + ยังไม่ผูก SKU + + )} +
+ {hasMapping && profit?.unitBomCost > 0 ? ( +
+ {fmtBaht(profit.unitBomCost)}/ชิ้น +

รวม {fmtBaht(profit.totalCost)}

+
+ ) : hasMapping ? ( + ยังไม่ตั้ง BOM + ) : ( + - + )} +
+ {hasMapping && profit ? ( +
+ {fmtBaht(profit.profit)} +

{profit.marginPercent.toFixed(1)}% margin

+
+ ) : ( + - + )} +
+ +
+
+ + {/* Pagination Footer */} + {pagination && pagination.totalPages > 1 && ( +
+

+ แสดงหน้าที่ {pagination.page} จาก{' '} + {pagination.totalPages} (ทั้งหมด {fmtNum(pagination.total)} รายการ) +

+
+ + +
+
+ )} +
+ + {/* ── Mapping Modal ────────────────────────────────────────────────────── */} + {mappingModalItem && ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+

ผูกสินค้า Marketplace เข้า Master Catalog

+

เลือกสินค้า/ขนาดจากระบบหลักที่ตรงกับรายการนี้

+
+
+ +
+ + {/* Target Marketplace Item Summary */} +
+
+ สินค้าบน {mappingModalItem.channel.toUpperCase()} ({mappingModalItem.shop}): + {mappingModalItem.marketplaceSku && ( + + SKU: {mappingModalItem.marketplaceSku} + + )} +
+

{mappingModalItem.productName}

+ {mappingModalItem.variation && ( +

ตัวเลือก: {mappingModalItem.variation}

+ )} +
+ ขายแล้ว: {fmtNum(mappingModalItem.totalSoldQty)} ชิ้น + ยอดขาย: {fmtBaht(mappingModalItem.totalRevenue)} + ราคาเฉลี่ย: {fmtBaht(mappingModalItem.avgPrice)} +
+
+ + {/* Search & Variants List */} +
+
+ + setVariantSearch(e.target.value)} + className="w-full pl-9 pr-4 py-2 rounded-xl bg-black/40 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/40" + autoFocus + /> +
+ +
+ {filteredMasterVariants.length === 0 ? ( +
+ ไม่พบรายการสินค้า Master ในระบบที่ตรงกับคำค้นหา +
+ ) : ( + filteredMasterVariants.map((variant) => { + const isSelected = selectedVariantId === variant.id; + return ( +
setSelectedVariantId(variant.id)} + className={`p-3 rounded-xl border transition-all cursor-pointer flex items-center justify-between gap-3 ${ + isSelected + ? 'bg-amber-500/20 border-amber-500/40 shadow-sm' + : 'bg-white/[0.02] border-white/5 hover:bg-white/5 hover:border-white/10' + }`} + > +
+

{variant.productName}

+
+ SKU: {variant.sku || '-'} + {variant.optionsStr && • {variant.optionsStr}} + {variant.categoryName && • {variant.categoryName}} +
+
+ +
+

{fmtBaht(variant.price)}

+

+ ต้นทุน BOM: {fmtBaht(variant.bomCost)} +

+
+
+ ); + }) + )} +
+
+ + {/* Modal Footer */} +
+ {mappingModalItem.masterVariantId ? ( + + ) :
} + +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/apps/backend/prisma/migrations/20260906094000_add_marketplace_product/migration.sql b/apps/backend/prisma/migrations/20260906094000_add_marketplace_product/migration.sql new file mode 100644 index 0000000..a5c312a --- /dev/null +++ b/apps/backend/prisma/migrations/20260906094000_add_marketplace_product/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0329069..b7efe90 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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]) +} + diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 14f27bf..85e6d0e 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -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" }); diff --git a/apps/backend/src/routes/businessAnalytics.ts b/apps/backend/src/routes/businessAnalytics.ts new file mode 100644 index 0000000..e664468 --- /dev/null +++ b/apps/backend/src/routes/businessAnalytics.ts @@ -0,0 +1,1393 @@ +import { Elysia, t } from "elysia"; +import prisma from "../lib/prisma"; +import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces"; + +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 }; +} + +function extractLazadaAddress(order: any): { province: string; district: string } { + const reg = String(order.shippingRegion || "").trim(); + const city = String(order.shippingCity || "").trim(); + const postcode = String(order.shippingPostCode || "").trim(); + + // Try matching region or city first as province name + let matchedProv = findProvince(reg) || findProvince(city); + + // Fallback to 5-digit/2-digit Postcode lookup + if (!matchedProv && postcode) { + matchedProv = findProvinceByPostcode(postcode); + } + + const provinceName = matchedProv ? matchedProv.nameTh : (reg || city); + const districtName = cleanDistrictName(city) || cleanDistrictName(reg) || (postcode ? `รหัส ปณ. ${postcode}` : "ไม่ระบุอำเภอ"); + return { province: provinceName, district: districtName }; +} + +// Helper to filter out / include cancelled orders +function buildStatusFilter(statusFilter: string = "completed") { + // 'completed' (default, excludes cancelled) | 'all' (includes all) | 'cancelled' (only cancelled) + if (statusFilter === "all") { + return { + tiktok: {}, + shopee: {}, + lazada: {}, + }; + } + if (statusFilter === "cancelled") { + return { + tiktok: { status: { contains: "ยกเลิก" } }, + shopee: { status: { contains: "ยกเลิก" } }, + lazada: { status: { in: ["canceled", "cancelled", "ยกเลิกแล้ว", "Package Returned", "Lost by 3PL"] } }, + }; + } + // Default 'completed': exclude cancelled + return { + tiktok: { status: { not: { contains: "ยกเลิก" } } }, + shopee: { status: { not: { contains: "ยกเลิก" } } }, + lazada: { status: { notIn: ["canceled", "cancelled", "ยกเลิกแล้ว", "Package Returned", "Lost by 3PL"] } }, + }; +} + +export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" }) + // ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ────────── + .get("/sales-map", async ({ query }) => { + const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada' + const statusFilter = (query.statusFilter as string) || "completed"; // 'completed' | 'all' | 'cancelled' + const sf = buildStatusFilter(statusFilter); + + const tiktokShop = query.tiktokShop as string | undefined; + const shopeeShop = query.shopeeShop as string | undefined; + const lazadaShop = query.lazadaShop as string | undefined; + const dateFrom = query.dateFrom as string | undefined; + const dateTo = query.dateTo as string | undefined; + + // 1. Fetch TikTok Orders if applicable + let tiktokOrders: any[] = []; + if (channel === "all" || channel === "tiktok") { + const where: any = { ...sf.tiktok }; + if (tiktokShop && tiktokShop !== "all") { + where.shop = tiktokShop; + } + if (dateFrom || dateTo) { + where.createdTime = {}; + if (dateFrom) where.createdTime.gte = dateFrom; + if (dateTo) where.createdTime.lte = dateTo + " 23:59"; + } + tiktokOrders = await prisma.tiktokOrder.findMany({ + where, + select: { + orderId: true, + shop: true, + province: true, + district: true, + orderAmount: true, + createdTime: true, + }, + }); + } + + // 2. Fetch Shopee Orders if applicable + let shopeeOrders: any[] = []; + if (channel === "all" || channel === "shopee") { + const where: any = { ...sf.shopee }; + if (shopeeShop && shopeeShop !== "all") { + where.shop = shopeeShop; + } + if (dateFrom || dateTo) { + where.orderCreatedAt = {}; + if (dateFrom) where.orderCreatedAt.gte = dateFrom; + if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; + } + shopeeOrders = await prisma.shopeeOrder.findMany({ + where, + include: { + items: { + take: 1, + select: { rawData: true }, + }, + }, + }); + } + + // 3. Fetch Lazada Orders if applicable + let lazadaOrders: any[] = []; + if (channel === "all" || channel === "lazada") { + const where: any = { ...sf.lazada }; + if (lazadaShop && lazadaShop !== "all") { + where.shop = lazadaShop; + } + if (dateFrom || dateTo) { + where.createTime = {}; + if (dateFrom) where.createTime.gte = dateFrom; + if (dateTo) where.createTime.lte = dateTo + " 23:59"; + } + lazadaOrders = await prisma.lazadaOrder.findMany({ + where, + select: { + orderNumber: true, + shop: true, + shippingRegion: true, + shippingCity: true, + shippingPostCode: true, + orderAmount: true, + createTime: true, + }, + }); + } + + // Initialize 77 provinces + const provSalesMap = new Map(); + + 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, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + + let totalRevenue = 0; + let totalOrders = 0; + let totalTiktokRevenue = 0; + let totalTiktokOrders = 0; + let totalShopeeRevenue = 0; + let totalShopeeOrders = 0; + let totalLazadaRevenue = 0; + let totalLazadaOrders = 0; + let unmappedOrders = 0; + let unmappedRevenue = 0; + + // Aggregate TikTok + for (const o of tiktokOrders) { + const amount = o.orderAmount || 0; + totalRevenue += amount; + totalOrders += 1; + totalTiktokRevenue += amount; + totalTiktokOrders += 1; + + const matchedProv = findProvince(o.province); + if (matchedProv && provSalesMap.has(matchedProv.nameTh)) { + const p = provSalesMap.get(matchedProv.nameTh)!; + p.revenue += amount; + p.orders += 1; + p.tiktokRevenue += amount; + p.tiktokOrders += 1; + } else { + unmappedOrders += 1; + unmappedRevenue += amount; + } + } + + // Aggregate Shopee + for (const o of shopeeOrders) { + const amount = o.totalAmount || 0; + totalRevenue += amount; + totalOrders += 1; + totalShopeeRevenue += amount; + totalShopeeOrders += 1; + + const { province } = extractShopeeAddress(o); + const matchedProv = findProvince(province); + if (matchedProv && provSalesMap.has(matchedProv.nameTh)) { + const p = provSalesMap.get(matchedProv.nameTh)!; + p.revenue += amount; + p.orders += 1; + p.shopeeRevenue += amount; + p.shopeeOrders += 1; + } else { + unmappedOrders += 1; + unmappedRevenue += amount; + } + } + + // Aggregate Lazada + for (const o of lazadaOrders) { + const amount = o.orderAmount || 0; + totalRevenue += amount; + totalOrders += 1; + totalLazadaRevenue += amount; + totalLazadaOrders += 1; + + const { province } = extractLazadaAddress(o); + const matchedProv = findProvince(province); + if (matchedProv && provSalesMap.has(matchedProv.nameTh)) { + const p = provSalesMap.get(matchedProv.nameTh)!; + p.revenue += amount; + p.orders += 1; + p.lazadaRevenue += amount; + p.lazadaOrders += 1; + } else { + unmappedOrders += 1; + unmappedRevenue += amount; + } + } + + const provinceList = Array.from(provSalesMap.values()).map(p => ({ + ...p, + tiktokShare: p.revenue > 0 ? (p.tiktokRevenue / p.revenue) * 100 : 0, + shopeeShare: p.revenue > 0 ? (p.shopeeRevenue / p.revenue) * 100 : 0, + lazadaShare: p.revenue > 0 ? (p.lazadaRevenue / p.revenue) * 100 : 0, + aov: p.orders > 0 ? p.revenue / p.orders : 0, + })); + + const activeProvinces = provinceList.filter(p => p.orders > 0); + const coverageCount = activeProvinces.length; + + // Top 20 provinces by total revenue + const topProvinces = [...provinceList] + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 20) + .map((p, idx) => ({ + rank: idx + 1, + ...p, + })); + + // Region Breakdown + const regionMap = new Map(); + + for (const p of provinceList) { + if (!regionMap.has(p.region)) { + regionMap.set(p.region, { + region: p.region, + regionTh: p.regionTh, + revenue: 0, + orders: 0, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + const r = regionMap.get(p.region)!; + r.revenue += p.revenue; + r.orders += p.orders; + r.tiktokRevenue += p.tiktokRevenue; + r.tiktokOrders += p.tiktokOrders; + r.shopeeRevenue += p.shopeeRevenue; + r.shopeeOrders += p.shopeeOrders; + r.lazadaRevenue += p.lazadaRevenue; + r.lazadaOrders += p.lazadaOrders; + } + + const regionBreakdown = Array.from(regionMap.values()) + .map(r => ({ + ...r, + percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0, + tiktokShare: r.revenue > 0 ? (r.tiktokRevenue / r.revenue) * 100 : 0, + shopeeShare: r.revenue > 0 ? (r.shopeeRevenue / r.revenue) * 100 : 0, + lazadaShare: r.revenue > 0 ? (r.lazadaRevenue / r.revenue) * 100 : 0, + })) + .sort((a, b) => b.revenue - a.revenue); + + const topRegion = regionBreakdown[0] || { + region: 'Central', + regionTh: 'ภาคกลาง', + revenue: 0, + orders: 0, + tiktokRevenue: 0, + shopeeRevenue: 0, + lazadaRevenue: 0, + }; + + return { + kpi: { + totalRevenue, + totalOrders, + totalTiktokRevenue, + totalTiktokOrders, + tiktokSharePercent: totalRevenue > 0 ? (totalTiktokRevenue / totalRevenue) * 100 : 0, + totalShopeeRevenue, + totalShopeeOrders, + shopeeSharePercent: totalRevenue > 0 ? (totalShopeeRevenue / totalRevenue) * 100 : 0, + totalLazadaRevenue, + totalLazadaOrders, + lazadaSharePercent: totalRevenue > 0 ? (totalLazadaRevenue / totalRevenue) * 100 : 0, + coverageCount, + coverageTotal: 77, + coveragePercent: (coverageCount / 77) * 100, + aov: totalOrders > 0 ? totalRevenue / totalOrders : 0, + tiktokAov: totalTiktokOrders > 0 ? totalTiktokRevenue / totalTiktokOrders : 0, + shopeeAov: totalShopeeOrders > 0 ? totalShopeeRevenue / totalShopeeOrders : 0, + lazadaAov: totalLazadaOrders > 0 ? totalLazadaRevenue / totalLazadaOrders : 0, + topRegion: { + name: topRegion.regionTh, + orders: topRegion.orders, + revenue: topRegion.revenue, + tiktokRevenue: topRegion.tiktokRevenue, + shopeeRevenue: topRegion.shopeeRevenue, + lazadaRevenue: topRegion.lazadaRevenue, + }, + unmappedOrders, + unmappedRevenue, + }, + provinces: provinceList, + topProvinces, + regionBreakdown, + }; + }) + + // ── GET District Level Details & Orders (Omnichannel: TT + SP + LAZ) ─────── + .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 channel = (query.channel as string) || "all"; + const statusFilter = (query.statusFilter as string) || "completed"; + const sf = buildStatusFilter(statusFilter); + + const tiktokShop = query.tiktokShop as string | undefined; + const shopeeShop = query.shopeeShop as string | undefined; + const lazadaShop = query.lazadaShop as string | undefined; + const dateFrom = query.dateFrom as string | undefined; + const dateTo = query.dateTo as string | undefined; + + // 1. Query TikTok Orders + let tiktokMatched: any[] = []; + if (channel === "all" || channel === "tiktok") { + const where: any = { ...sf.tiktok }; + if (tiktokShop && tiktokShop !== "all") { + where.shop = tiktokShop; + } + if (dateFrom || dateTo) { + where.createdTime = {}; + if (dateFrom) where.createdTime.gte = dateFrom; + if (dateTo) where.createdTime.lte = dateTo + " 23:59"; + } + + const allTt = await prisma.tiktokOrder.findMany({ + where, + include: { items: true }, + orderBy: { createdTime: "desc" }, + }); + + tiktokMatched = allTt.filter(o => { + const p = findProvince(o.province); + return p ? p.nameTh === provNameTh : (o.province?.includes(provNameTh) || provNameTh.includes(o.province || '')); + }); + } + + // 2. Query Shopee Orders + let shopeeMatched: any[] = []; + if (channel === "all" || channel === "shopee") { + const where: any = { ...sf.shopee }; + if (shopeeShop && shopeeShop !== "all") { + where.shop = shopeeShop; + } + if (dateFrom || dateTo) { + where.orderCreatedAt = {}; + if (dateFrom) where.orderCreatedAt.gte = dateFrom; + if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; + } + + const allSp = await prisma.shopeeOrder.findMany({ + where, + include: { items: true }, + orderBy: { orderCreatedAt: "desc" }, + }); + + shopeeMatched = allSp.filter(o => { + const { province } = extractShopeeAddress(o); + const p = findProvince(province); + return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || '')); + }); + } + + // 3. Query Lazada Orders + let lazadaMatched: any[] = []; + if (channel === "all" || channel === "lazada") { + const where: any = { ...sf.lazada }; + if (lazadaShop && lazadaShop !== "all") { + where.shop = lazadaShop; + } + if (dateFrom || dateTo) { + where.createTime = {}; + if (dateFrom) where.createTime.gte = dateFrom; + if (dateTo) where.createTime.lte = dateTo + " 23:59"; + } + + const allLaz = await prisma.lazadaOrder.findMany({ + where, + include: { items: true }, + orderBy: { createTime: "desc" }, + }); + + lazadaMatched = allLaz.filter(o => { + const { province } = extractLazadaAddress(o); + const p = findProvince(province); + return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || '')); + }); + } + + // Aggregate by District + const districtMap = new Map(); + + let provRevenue = 0; + let provTiktokRevenue = 0; + let provShopeeRevenue = 0; + let provLazadaRevenue = 0; + + for (const o of tiktokMatched) { + const amount = o.orderAmount || 0; + provRevenue += amount; + provTiktokRevenue += amount; + + const dName = cleanDistrictName(o.district) || "ไม่ระบุอำเภอ"; + if (!districtMap.has(dName)) { + districtMap.set(dName, { + districtName: dName, + revenue: 0, + orders: 0, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + const d = districtMap.get(dName)!; + d.revenue += amount; + d.orders += 1; + d.tiktokRevenue += amount; + d.tiktokOrders += 1; + } + + for (const o of shopeeMatched) { + const amount = o.totalAmount || 0; + provRevenue += amount; + provShopeeRevenue += amount; + + const { district } = extractShopeeAddress(o); + const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ"; + if (!districtMap.has(dName)) { + districtMap.set(dName, { + districtName: dName, + revenue: 0, + orders: 0, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + const d = districtMap.get(dName)!; + d.revenue += amount; + d.orders += 1; + d.shopeeRevenue += amount; + d.shopeeOrders += 1; + } + + for (const o of lazadaMatched) { + const amount = o.orderAmount || 0; + provRevenue += amount; + provLazadaRevenue += amount; + + const { district } = extractLazadaAddress(o); + const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ"; + if (!districtMap.has(dName)) { + districtMap.set(dName, { + districtName: dName, + revenue: 0, + orders: 0, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + const d = districtMap.get(dName)!; + d.revenue += amount; + d.orders += 1; + d.lazadaRevenue += amount; + d.lazadaOrders += 1; + } + + const districts = Array.from(districtMap.values()).sort((a, b) => b.revenue - a.revenue); + + // Normalize unified orders list + const unifiedOrders = [ + ...tiktokMatched.map(o => ({ + orderId: o.orderId, + channel: "tiktok" as const, + shop: o.shop, + status: o.status, + subStatus: o.subStatus, + buyerUsername: o.buyerUsername, + createdTime: o.createdTime, + orderAmount: o.orderAmount, + shippingFee: o.shippingFee, + province: o.province, + district: cleanDistrictName(o.district) || o.district || "ไม่ระบุอำเภอ", + itemsCount: o.items.reduce((s: number, i: any) => s + i.quantity, 0), + items: o.items.map((i: any) => ({ + productName: i.productName, + variation: i.variation, + sellerSku: i.sellerSku, + quantity: i.quantity, + subtotalAfterDiscount: i.subtotalAfterDiscount, + unitOriginalPrice: i.unitOriginalPrice, + })), + })), + ...shopeeMatched.map(o => { + const { district, province } = extractShopeeAddress(o); + return { + orderId: o.orderId, + channel: "shopee" as const, + shop: o.shop, + status: o.status, + subStatus: null, + buyerUsername: o.buyerUsername, + createdTime: o.orderCreatedAt, + orderAmount: o.totalAmount, + shippingFee: o.shippingFee, + province: province || "ไม่ระบุจังหวัด", + district: cleanDistrictName(district) || district || "ไม่ระบุอำเภอ", + itemsCount: o.items.reduce((s: number, i: any) => s + i.quantity, 0), + items: o.items.map((i: any) => ({ + productName: i.productName, + variation: i.variantName, + sellerSku: i.skuRef || i.parentSku, + quantity: i.quantity, + subtotalAfterDiscount: i.totalPayment, + unitOriginalPrice: i.originalPrice || i.dealPrice, + })), + }; + }), + ...lazadaMatched.map(o => { + const { district, province } = extractLazadaAddress(o); + return { + orderId: o.orderNumber, + channel: "lazada" as const, + shop: o.shop, + status: o.status, + subStatus: null, + buyerUsername: o.customerName || "ลูกค้า Lazada", + createdTime: o.createTime, + orderAmount: o.orderAmount, + shippingFee: o.shippingFee, + province: province || "ไม่ระบุจังหวัด", + district: cleanDistrictName(district) || district || "ไม่ระบุอำเภอ", + itemsCount: o.items.length, + items: o.items.map((i: any) => ({ + productName: i.itemName, + variation: i.variation, + sellerSku: i.sellerSku || i.lazadaSku, + quantity: 1, + subtotalAfterDiscount: i.paidPrice, + unitOriginalPrice: i.unitPrice || i.paidPrice, + })), + }; + }), + ].sort((a, b) => String(b.createdTime || "").localeCompare(String(a.createdTime || ""))); + + return { + province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: "Central", regionTh: "ภาคกลาง", code: "" }, + summary: { + totalRevenue: provRevenue, + totalOrders: unifiedOrders.length, + tiktokRevenue: provTiktokRevenue, + tiktokOrders: tiktokMatched.length, + shopeeRevenue: provShopeeRevenue, + shopeeOrders: shopeeMatched.length, + lazadaRevenue: provLazadaRevenue, + lazadaOrders: lazadaMatched.length, + districtCount: districts.length, + }, + districts, + orders: unifiedOrders, + }; + }) + + // ── 3. GET Master Products & Variants List (For Selection Dropdown) ───────── + .get("/products-list", async () => { + const products = await prisma.product.findMany({ + include: { + category: { select: { id: true, name: true } }, + brand: { select: { id: true, name: true } }, + variants: { + include: { + marketplaceProducts: { + select: { + id: true, + channel: true, + shop: true, + productName: true, + variation: true, + marketplaceSku: true, + totalSoldQty: true, + totalRevenue: true, + }, + }, + }, + }, + }, + orderBy: { name: "asc" }, + }); + + const formatted = products.map((p) => { + let totalMappedMarketplaceCount = 0; + let totalSoldUnits = 0; + let totalRevenue = 0; + + const variants = p.variants.map((v) => { + const mappedCount = v.marketplaceProducts.length; + totalMappedMarketplaceCount += mappedCount; + const vSold = v.marketplaceProducts.reduce((s, mp) => s + mp.totalSoldQty, 0); + const vRev = v.marketplaceProducts.reduce((s, mp) => s + mp.totalRevenue, 0); + totalSoldUnits += vSold; + totalRevenue += vRev; + + const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); + return { + id: v.id, + sku: v.sku || "", + barcode: v.barcode || "", + price: v.price || 0, + stock: v.stock || 0, + optionsStr, + displayName: `${optionsStr || "Default"} ${v.sku ? `[${v.sku}]` : ""}`.trim(), + mappedMarketplaceCount: mappedCount, + soldUnits: vSold, + revenue: vRev, + }; + }); + + return { + id: p.id, + name: p.name || "Unnamed Product", + category: p.category?.name || "", + brand: p.brand?.name || "", + totalMappedMarketplaceCount, + totalSoldUnits, + totalRevenue, + variants, + }; + }); + + return { products: formatted }; + }) + + // ── 4. GET Product Geographical Sales Map (77 Provinces) ──────────────────── + .get("/sales-map-products", async ({ query }) => { + const productId = query.productId ? parseInt(query.productId as string, 10) : undefined; + const variantId = query.variantId ? parseInt(query.variantId as string, 10) : undefined; + const channel = (query.channel as string) || "all"; + const statusFilter = (query.statusFilter as string) || "completed"; + const sf = buildStatusFilter(statusFilter); + const dateFrom = query.dateFrom as string | undefined; + const dateTo = query.dateTo as string | undefined; + const metric = (query.metric as string) || "revenue"; + + // 1. Resolve master variants to filter + let targetVariantIds: number[] = []; + let productInfo: any = null; + let variantInfo: any = null; + + if (variantId) { + targetVariantIds = [variantId]; + const v = await prisma.productVariant.findUnique({ + where: { id: variantId }, + include: { product: { include: { category: true } } }, + }); + if (v) { + const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); + variantInfo = { + id: v.id, + sku: v.sku, + optionsStr, + productName: v.product?.name, + categoryName: v.product?.category?.name, + }; + productInfo = { + id: v.product?.id, + name: v.product?.name, + }; + } + } else if (productId) { + const p = await prisma.product.findUnique({ + where: { id: productId }, + include: { variants: true, category: true }, + }); + if (p) { + targetVariantIds = p.variants.map((v) => v.id); + productInfo = { + id: p.id, + name: p.name, + categoryName: p.category?.name, + variantsCount: p.variants.length, + }; + } + } + + // 2. Fetch linked MarketplaceProducts + const mappedMp = await prisma.marketplaceProduct.findMany({ + where: { + ...(targetVariantIds.length > 0 ? { masterVariantId: { in: targetVariantIds } } : {}), + ...(channel !== "all" ? { channel } : {}), + }, + }); + + const tiktokMps = mappedMp.filter((m) => m.channel === "tiktok"); + const shopeeMps = mappedMp.filter((m) => m.channel === "shopee"); + const lazadaMps = mappedMp.filter((m) => m.channel === "lazada"); + + const tiktokKeys = new Set(tiktokMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const tiktokSkus = new Set(tiktokMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const tiktokNames = new Set(tiktokMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const shopeeKeys = new Set(shopeeMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const shopeeSkus = new Set(shopeeMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const shopeeNames = new Set(shopeeMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const lazadaKeys = new Set(lazadaMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const lazadaSkus = new Set(lazadaMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const lazadaNames = new Set(lazadaMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + // 3. Query Orders with Line Items + let matchedLineItems: Array<{ + channel: "tiktok" | "shopee" | "lazada"; + shop: string; + orderId: string; + province: string; + district: string; + quantity: number; + revenue: number; + createdTime: string | null; + }> = []; + + // A. TikTok Items + if ((channel === "all" || channel === "tiktok") && (tiktokKeys.size > 0 || tiktokSkus.size > 0 || tiktokNames.size > 0)) { + const where: any = { ...sf.tiktok }; + if (dateFrom || dateTo) { + where.createdTime = {}; + if (dateFrom) where.createdTime.gte = dateFrom; + if (dateTo) where.createdTime.lte = dateTo + " 23:59"; + } + + const tiktokOrders = await prisma.tiktokOrder.findMany({ + where, + select: { + orderId: true, + shop: true, + province: true, + district: true, + createdTime: true, + items: true, + }, + }); + + for (const o of tiktokOrders) { + const prov = o.province || ""; + const dist = o.district || ""; + for (const it of o.items) { + const sku = it.sellerSku || it.skuId || ""; + const name = it.productName || ""; + const variation = it.variation || ""; + const shop = o.shop || "hom-aroma"; + const key = `${shop}:::${name}:::${variation}:::${sku}`; + + const isMatch = + tiktokKeys.has(key) || + (sku && tiktokSkus.has(sku.trim().toLowerCase())) || + (name && tiktokNames.has(name.trim().toLowerCase()) && (!variation || tiktokMps.some((m) => m.variation === variation))); + + if (isMatch) { + const qty = it.quantity || 1; + const rev = it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : it.unitOriginalPrice * qty; + matchedLineItems.push({ + channel: "tiktok", + shop, + orderId: o.orderId, + province: prov, + district: dist, + quantity: qty, + revenue: rev, + createdTime: o.createdTime, + }); + } + } + } + } + + // B. Shopee Items + if ((channel === "all" || channel === "shopee") && (shopeeKeys.size > 0 || shopeeSkus.size > 0 || shopeeNames.size > 0)) { + const where: any = { ...sf.shopee }; + if (dateFrom || dateTo) { + where.orderCreatedAt = {}; + if (dateFrom) where.orderCreatedAt.gte = dateFrom; + if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; + } + + const shopeeOrders = await prisma.shopeeOrder.findMany({ + where, + select: { + orderId: true, + shop: true, + orderCreatedAt: true, + items: true, + }, + }); + + for (const o of shopeeOrders) { + const { province, district } = extractShopeeAddress(o); + for (const it of o.items) { + const sku = it.skuRef || it.parentSku || ""; + const name = it.productName || ""; + const variation = it.variantName || ""; + const shop = o.shop || "hom-aroma"; + const key = `${shop}:::${name}:::${variation}:::${sku}`; + + const isMatch = + shopeeKeys.has(key) || + (sku && shopeeSkus.has(sku.trim().toLowerCase())) || + (name && shopeeNames.has(name.trim().toLowerCase()) && (!variation || shopeeMps.some((m) => m.variation === variation))); + + if (isMatch) { + const qty = it.quantity || 1; + const rev = it.totalPayment > 0 ? it.totalPayment : it.dealPrice * qty; + matchedLineItems.push({ + channel: "shopee", + shop, + orderId: o.orderId, + province, + district, + quantity: qty, + revenue: rev, + createdTime: o.orderCreatedAt, + }); + } + } + } + } + + // C. Lazada Items + if ((channel === "all" || channel === "lazada") && (lazadaKeys.size > 0 || lazadaSkus.size > 0 || lazadaNames.size > 0)) { + const where: any = { ...sf.lazada }; + if (dateFrom || dateTo) { + where.createTime = {}; + if (dateFrom) where.createTime.gte = dateFrom; + if (dateTo) where.createTime.lte = dateTo + " 23:59"; + } + + const lazadaOrders = await prisma.lazadaOrder.findMany({ + where, + select: { + orderNumber: true, + shop: true, + createTime: true, + shippingRegion: true, + shippingCity: true, + shippingPostCode: true, + items: true, + }, + }); + + for (const o of lazadaOrders) { + const { province, district } = extractLazadaAddress(o); + for (const it of o.items) { + const sku = it.sellerSku || it.lazadaSku || ""; + const name = it.itemName || ""; + const variation = it.variation || ""; + const shop = o.shop || "hom-aroma"; + const key = `${shop}:::${name}:::${variation}:::${sku}`; + + const isMatch = + lazadaKeys.has(key) || + (sku && lazadaSkus.has(sku.trim().toLowerCase())) || + (name && lazadaNames.has(name.trim().toLowerCase()) && (!variation || lazadaMps.some((m) => m.variation === variation))); + + if (isMatch) { + const qty = 1; + const rev = it.paidPrice > 0 ? it.paidPrice : it.unitPrice; + matchedLineItems.push({ + channel: "lazada", + shop, + orderId: o.orderNumber, + province, + district, + quantity: qty, + revenue: rev, + createdTime: o.createTime, + }); + } + } + } + } + + // 4. Map & Aggregate by 77 Provinces + const provMap: Record = {}; + + for (const p of THAILAND_PROVINCES) { + provMap[p.nameTh] = { + code: p.code, + nameTh: p.nameTh, + nameEn: p.nameEn, + region: p.region, + regionTh: p.regionTh, + revenue: 0, + unitsSold: 0, + ordersCount: 0, + tiktokRevenue: 0, + shopeeRevenue: 0, + lazadaRevenue: 0, + tiktokUnits: 0, + shopeeUnits: 0, + lazadaUnits: 0, + }; + } + + let totalRevenue = 0; + let totalUnitsSold = 0; + let totalTiktokRevenue = 0; + let totalShopeeRevenue = 0; + let totalLazadaRevenue = 0; + let totalTiktokUnits = 0; + let totalShopeeUnits = 0; + let totalLazadaUnits = 0; + const uniqueOrderIds = new Set(); + + for (const it of matchedLineItems) { + const provMatch = findProvince(it.province); + const provNameTh = provMatch ? provMatch.nameTh : "กรุงเทพมหานคร"; + + if (provMap[provNameTh]) { + provMap[provNameTh].revenue += it.revenue; + provMap[provNameTh].unitsSold += it.quantity; + provMap[provNameTh].ordersCount += 1; + + if (it.channel === "tiktok") { + provMap[provNameTh].tiktokRevenue += it.revenue; + provMap[provNameTh].tiktokUnits += it.quantity; + totalTiktokRevenue += it.revenue; + totalTiktokUnits += it.quantity; + } else if (it.channel === "shopee") { + provMap[provNameTh].shopeeRevenue += it.revenue; + provMap[provNameTh].shopeeUnits += it.quantity; + totalShopeeRevenue += it.revenue; + totalShopeeUnits += it.quantity; + } else if (it.channel === "lazada") { + provMap[provNameTh].lazadaRevenue += it.revenue; + provMap[provNameTh].lazadaUnits += it.quantity; + totalLazadaRevenue += it.revenue; + totalLazadaUnits += it.quantity; + } + + totalRevenue += it.revenue; + totalUnitsSold += it.quantity; + uniqueOrderIds.add(it.orderId); + } + } + + const provinces = Object.values(provMap).map((p: any) => { + const pRev = p.revenue; + const ttShare = pRev > 0 ? (p.tiktokRevenue / pRev) * 100 : 0; + const spShare = pRev > 0 ? (p.shopeeRevenue / pRev) * 100 : 0; + const lzShare = pRev > 0 ? (p.lazadaRevenue / pRev) * 100 : 0; + + return { + ...p, + tiktokShare: ttShare, + shopeeShare: spShare, + lazadaShare: lzShare, + aov: p.ordersCount > 0 ? p.revenue / p.ordersCount : 0, + }; + }); + + const coveredProvinces = provinces.filter((p) => p.revenue > 0); + const coverageCount = coveredProvinces.length; + const coveragePercent = (coverageCount / 77) * 100; + + const sortedByMetric = [...provinces].sort((a, b) => { + return metric === "quantity" ? b.unitsSold - a.unitsSold : b.revenue - a.revenue; + }); + + const topProvinces = sortedByMetric.slice(0, 20).map((p, idx) => ({ + ...p, + rank: idx + 1, + })); + + const topProvince = + topProvinces.length > 0 && (topProvinces[0].revenue > 0 || topProvinces[0].unitsSold > 0) ? topProvinces[0] : null; + + // Region breakdown + const regionsMap: Record = {}; + + for (const p of provinces) { + if (!regionsMap[p.region]) { + regionsMap[p.region] = { + region: p.region, + regionTh: p.regionTh, + revenue: 0, + unitsSold: 0, + tiktokRevenue: 0, + shopeeRevenue: 0, + lazadaRevenue: 0, + orders: 0, + }; + } + regionsMap[p.region].revenue += p.revenue; + regionsMap[p.region].unitsSold += p.unitsSold; + regionsMap[p.region].tiktokRevenue += p.tiktokRevenue; + regionsMap[p.region].shopeeRevenue += p.shopeeRevenue; + regionsMap[p.region].lazadaRevenue += p.lazadaRevenue; + regionsMap[p.region].orders += p.ordersCount; + } + + const regionBreakdown = Object.values(regionsMap) + .map((r: any) => ({ + ...r, + percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0, + })) + .sort((a: any, b: any) => b.revenue - a.revenue); + + return { + product: productInfo, + variant: variantInfo, + mappedMarketplaceCount: mappedMp.length, + kpi: { + totalRevenue, + totalUnitsSold, + totalOrders: uniqueOrderIds.size, + aov: uniqueOrderIds.size > 0 ? totalRevenue / uniqueOrderIds.size : 0, + coverageCount, + coveragePercent, + topProvince: topProvince + ? { name: topProvince.nameTh, revenue: topProvince.revenue, unitsSold: topProvince.unitsSold } + : null, + topRegion: regionBreakdown.length > 0 ? { name: regionBreakdown[0].regionTh, revenue: regionBreakdown[0].revenue } : null, + totalTiktokRevenue, + totalShopeeRevenue, + totalLazadaRevenue, + totalTiktokUnits, + totalShopeeUnits, + totalLazadaUnits, + tiktokSharePercent: totalRevenue > 0 ? (totalTiktokRevenue / totalRevenue) * 100 : 0, + shopeeSharePercent: totalRevenue > 0 ? (totalShopeeRevenue / totalRevenue) * 100 : 0, + lazadaSharePercent: totalRevenue > 0 ? (totalLazadaRevenue / totalRevenue) * 100 : 0, + }, + provinces, + topProvinces, + regionBreakdown, + }; + }) + + // ── 5. GET Product District Level Sales Map & Orders ─────────────────────── + .get("/sales-map-products/district", async ({ query }) => { + const provinceName = (query.province as string) || "กรุงเทพมหานคร"; + const productId = query.productId ? parseInt(query.productId as string, 10) : undefined; + const variantId = query.variantId ? parseInt(query.variantId as string, 10) : undefined; + const channel = (query.channel as string) || "all"; + const statusFilter = (query.statusFilter as string) || "completed"; + const sf = buildStatusFilter(statusFilter); + + const dateFrom = query.dateFrom as string | undefined; + const dateTo = query.dateTo as string | undefined; + + let targetVariantIds: number[] = []; + if (variantId) { + targetVariantIds = [variantId]; + } else if (productId) { + const p = await prisma.product.findUnique({ + where: { id: productId }, + include: { variants: true }, + }); + if (p) targetVariantIds = p.variants.map((v) => v.id); + } + + const mappedMp = await prisma.marketplaceProduct.findMany({ + where: { + ...(targetVariantIds.length > 0 ? { masterVariantId: { in: targetVariantIds } } : {}), + ...(channel !== "all" ? { channel } : {}), + }, + }); + + const tiktokMps = mappedMp.filter((m) => m.channel === "tiktok"); + const shopeeMps = mappedMp.filter((m) => m.channel === "shopee"); + const lazadaMps = mappedMp.filter((m) => m.channel === "lazada"); + + const tiktokKeys = new Set(tiktokMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const tiktokSkus = new Set(tiktokMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const tiktokNames = new Set(tiktokMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const shopeeKeys = new Set(shopeeMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const shopeeSkus = new Set(shopeeMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const shopeeNames = new Set(shopeeMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const lazadaKeys = new Set(lazadaMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const lazadaSkus = new Set(lazadaMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const lazadaNames = new Set(lazadaMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const targetProv = findProvince(provinceName); + const provNameTh = targetProv ? targetProv.nameTh : provinceName; + + const districtMap: Record = {}; + const matchedOrders: any[] = []; + + // A. TikTok + if (channel === "all" || channel === "tiktok") { + const where: any = { ...sf.tiktok }; + if (dateFrom || dateTo) { + where.createdTime = {}; + if (dateFrom) where.createdTime.gte = dateFrom; + if (dateTo) where.createdTime.lte = dateTo + " 23:59"; + } + + const ttOrders = await prisma.tiktokOrder.findMany({ + where, + include: { items: true }, + }); + + for (const o of ttOrders) { + const p = findProvince(o.province || ""); + if (p && p.nameTh === provNameTh) { + const matchingItems = o.items.filter((it) => { + const sku = it.sellerSku || it.skuId || ""; + const name = it.productName || ""; + const variation = it.variation || ""; + const key = `${o.shop}:::${name}:::${variation}:::${sku}`; + return tiktokKeys.has(key) || (sku && tiktokSkus.has(sku.trim().toLowerCase())) || (name && tiktokNames.has(name.trim().toLowerCase())); + }); + + if (matchingItems.length > 0) { + const itemRev = matchingItems.reduce( + (s, it) => s + (it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : it.unitOriginalPrice * it.quantity), + 0 + ); + const itemQty = matchingItems.reduce((s, it) => s + it.quantity, 0); + const dName = cleanDistrictName(o.district) || o.district || "ไม่ระบุอำเภอ"; + + if (!districtMap[dName]) { + districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; + } + districtMap[dName].revenue += itemRev; + districtMap[dName].unitsSold += itemQty; + districtMap[dName].orders += 1; + districtMap[dName].tiktokRevenue += itemRev; + + matchedOrders.push({ + orderId: o.orderId, + channel: "tiktok", + shop: o.shop, + buyerUsername: o.buyerUsername || "ลูกค้า TikTok", + createdTime: o.createdTime, + orderAmount: itemRev, + shippingFee: o.shippingFee, + province: provNameTh, + district: dName, + items: matchingItems.map((it) => ({ + productName: it.productName, + variation: it.variation, + sellerSku: it.sellerSku || it.skuId, + quantity: it.quantity, + subtotalAfterDiscount: it.subtotalAfterDiscount || it.unitOriginalPrice * it.quantity, + })), + }); + } + } + } + } + + // B. Shopee + if (channel === "all" || channel === "shopee") { + const where: any = { ...sf.shopee }; + if (dateFrom || dateTo) { + where.orderCreatedAt = {}; + if (dateFrom) where.orderCreatedAt.gte = dateFrom; + if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; + } + + const spOrders = await prisma.shopeeOrder.findMany({ + where, + include: { items: true }, + }); + + for (const o of spOrders) { + const { province, district } = extractShopeeAddress(o); + const p = findProvince(province); + if (p && p.nameTh === provNameTh) { + const matchingItems = o.items.filter((it) => { + const sku = it.skuRef || it.parentSku || ""; + const name = it.productName || ""; + const variation = it.variantName || ""; + const key = `${o.shop}:::${name}:::${variation}:::${sku}`; + return shopeeKeys.has(key) || (sku && shopeeSkus.has(sku.trim().toLowerCase())) || (name && shopeeNames.has(name.trim().toLowerCase())); + }); + + if (matchingItems.length > 0) { + const itemRev = matchingItems.reduce( + (s, it) => s + (it.totalPayment > 0 ? it.totalPayment : it.dealPrice * it.quantity), + 0 + ); + const itemQty = matchingItems.reduce((s, it) => s + it.quantity, 0); + const dName = cleanDistrictName(district) || district || "ไม่ระบุอำเภอ"; + + if (!districtMap[dName]) { + districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; + } + districtMap[dName].revenue += itemRev; + districtMap[dName].unitsSold += itemQty; + districtMap[dName].orders += 1; + districtMap[dName].shopeeRevenue += itemRev; + + matchedOrders.push({ + orderId: o.orderId, + channel: "shopee", + shop: o.shop, + buyerUsername: o.buyerUsername || "ลูกค้า Shopee", + createdTime: o.orderCreatedAt, + orderAmount: itemRev, + shippingFee: o.shippingFee, + province: provNameTh, + district: dName, + items: matchingItems.map((it) => ({ + productName: it.productName, + variation: it.variantName, + sellerSku: it.skuRef || it.parentSku, + quantity: it.quantity, + subtotalAfterDiscount: it.totalPayment || it.dealPrice * it.quantity, + })), + }); + } + } + } + } + + // C. Lazada + if (channel === "all" || channel === "lazada") { + const where: any = { ...sf.lazada }; + if (dateFrom || dateTo) { + where.createTime = {}; + if (dateFrom) where.createTime.gte = dateFrom; + if (dateTo) where.createTime.lte = dateTo + " 23:59"; + } + + const lzOrders = await prisma.lazadaOrder.findMany({ + where, + include: { items: true }, + }); + + for (const o of lzOrders) { + const { province, district } = extractLazadaAddress(o); + const p = findProvince(province); + if (p && p.nameTh === provNameTh) { + const matchingItems = o.items.filter((it) => { + const sku = it.sellerSku || it.lazadaSku || ""; + const name = it.itemName || ""; + const variation = it.variation || ""; + const key = `${o.shop}:::${name}:::${variation}:::${sku}`; + return lazadaKeys.has(key) || (sku && lazadaSkus.has(sku.trim().toLowerCase())) || (name && lazadaNames.has(name.trim().toLowerCase())); + }); + + if (matchingItems.length > 0) { + const itemRev = matchingItems.reduce((s, it) => s + (it.paidPrice > 0 ? it.paidPrice : it.unitPrice), 0); + const itemQty = matchingItems.length; + const dName = cleanDistrictName(district) || district || "ไม่ระบุอำเภอ"; + + if (!districtMap[dName]) { + districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; + } + districtMap[dName].revenue += itemRev; + districtMap[dName].unitsSold += itemQty; + districtMap[dName].orders += 1; + districtMap[dName].lazadaRevenue += itemRev; + + matchedOrders.push({ + orderId: o.orderNumber, + channel: "lazada", + shop: o.shop, + buyerUsername: o.customerName || "ลูกค้า Lazada", + createdTime: o.createTime, + orderAmount: itemRev, + shippingFee: o.shippingFee, + province: provNameTh, + district: dName, + items: matchingItems.map((it) => ({ + productName: it.itemName, + variation: it.variation, + sellerSku: it.sellerSku || it.lazadaSku, + quantity: 1, + subtotalAfterDiscount: it.paidPrice || it.unitPrice, + })), + }); + } + } + } + } + + const districts = Object.values(districtMap).sort((a: any, b: any) => b.revenue - a.revenue); + matchedOrders.sort((a, b) => String(b.createdTime || "").localeCompare(String(a.createdTime || ""))); + + return { + province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: "Central", regionTh: "ภาคกลาง", code: "" }, + summary: { + totalRevenue: districts.reduce((s: number, d: any) => s + d.revenue, 0), + totalUnitsSold: districts.reduce((s: number, d: any) => s + d.unitsSold, 0), + totalOrders: matchedOrders.length, + districtCount: districts.length, + }, + districts, + orders: matchedOrders, + }; + }); + diff --git a/apps/backend/src/routes/shopee.ts b/apps/backend/src/routes/shopee.ts index 7585a68..1895d6a 100644 --- a/apps/backend/src/routes/shopee.ts +++ b/apps/backend/src/routes/shopee.ts @@ -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(); + + 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(); + 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(); + 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, + })), + }; + }), + }; }); diff --git a/apps/backend/src/routes/tiktokOrder.ts b/apps/backend/src/routes/tiktokOrder.ts index e6b6796..3f273d4 100644 --- a/apps/backend/src/routes/tiktokOrder.ts +++ b/apps/backend/src/routes/tiktokOrder.ts @@ -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(); + for (const s of settlements) { + settlementMap.set(s.orderId, s); + } + + // Index MarketplaceProducts + const mpExactMap = new Map(); // shop:::productName:::variation:::sku + const mpSkuMap = new Map(); // shop:::sku + const mpNameVarMap = new Map(); // shop:::productName:::variation + const variantSkuMap = new Map(); // 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(); + const monthMap = new Map(); + 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, }, }; }) diff --git a/apps/backend/src/routes/tools.ts b/apps/backend/src/routes/tools.ts new file mode 100644 index 0000000..33e55d4 --- /dev/null +++ b/apps/backend/src/routes/tools.ts @@ -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(); + 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(); + 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(); + + 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(); + 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, + }; + }); diff --git a/apps/backend/src/utils/thaiProvinces.ts b/apps/backend/src/utils/thaiProvinces.ts index a0d1b8f..c294d39 100644 --- a/apps/backend/src/utils/thaiProvinces.ts +++ b/apps/backend/src/utils/thaiProvinces.ts @@ -144,3 +144,110 @@ export function cleanDistrictName(input: string | null | undefined): string { .replace(/^(อำเภอ|อ\.|เขต|แขวง|ตำบล|ต\.)\s*/, '') .trim(); } + +const POSTCODE_EXCEPTIONS: Record = { + '10540': 'สมุทรปราการ', + '10270': 'สมุทรปราการ', + '10280': 'สมุทรปราการ', + '10560': 'สมุทรปราการ', + '13170': 'พระนครศรีอยุธยา', + '13180': 'พระนครศรีอยุธยา', +}; + +const POSTCODE_PREFIX_TO_PROVINCE_NAME: Record = { + '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; +} diff --git a/income_20260906105930(UTC+7).xlsx b/income_20260906105930(UTC+7).xlsx new file mode 100644 index 0000000000000000000000000000000000000000..16411f53aa623f69bcf491ec80ab5f999010ff3d GIT binary patch literal 67014 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