diff --git a/OIL_OZONE_BOM_LEDGER.md b/OIL_OZONE_BOM_LEDGER.md new file mode 100644 index 0000000..680f78b --- /dev/null +++ b/OIL_OZONE_BOM_LEDGER.md @@ -0,0 +1,61 @@ +# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production) + +> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml) +> **อัปเดตล่าสุด:** 18/10/2569 13:05:32 +> **จำนวนสินค้าทั้งหมด:** 45 รายการ + +## 📦 สรุปสถานะภาพรวม +- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** 45 / 45 รายการ (100%) +- **ยังไม่ได้ตั้งค่า (MISSING):** 0 รายการ +- **ตั้งค่าบางส่วน (PARTIAL):** 0 รายการ +- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** 45 / 45 รายการ + +## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) + +| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ | +| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: | +| 1 | **#1** | [ตะไคร้ Oil Ozone](https://admin.homaroma.shop/products/1/bom) | [ID: 41] หัวน้ำหอม กลิ่นตะไคร้ (฿4.5) | ฿18.94 (72.6%) | ฿33.88 (66.1%) | ฿46.33 (61.4%) | ฿63.76 (57.5%) | ✅ พร้อมขาย | +| 2 | **#6** | [ลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/6/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย | +| 3 | **#11** | [วานิลลา Oil Ozone](https://admin.homaroma.shop/products/11/bom) | [ID: 53] หัวน้ำหอม กลิ่นวานิลลา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 4 | **#16** | [ซากุระ Oil Ozone](https://admin.homaroma.shop/products/16/bom) | [ID: 27] หัวน้ำหอม กลิ่นซากุระ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย | +| 5 | **#21** | [มะลิ Oil Ozone](https://admin.homaroma.shop/products/21/bom) | [ID: 48] หัวน้ำหอม กลิ่นมะลิ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย | +| 6 | **#26** | [ยูคาลิปตัส Oil Ozone](https://admin.homaroma.shop/products/26/bom) | [ID: 49] หัวน้ำหอม กลิ่นยูคาลิปตัส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 7 | **#31** | [เฟรชลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/31/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย | +| 8 | **#36** | [เปเปอร์มิ้น Oil Ozone](https://admin.homaroma.shop/products/36/bom) | [ID: 14] หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 9 | **#41** | [โอเชี่ยน Oil Ozone](https://admin.homaroma.shop/products/41/bom) | [ID: 21] หัวน้ำหอม กลิ่นโอเชี่ยน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 10 | **#46** | [เซเว่นซี Oil Ozone](https://admin.homaroma.shop/products/46/bom) | [ID: 13] หัวน้ำหอม กลิ่นเซเว่นซี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 11 | **#51** | [ดอกแก้ว Oil Ozone](https://admin.homaroma.shop/products/51/bom) | [ID: 28] หัวน้ำหอม กลิ่นดอกแก้ว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 12 | **#56** | [ดอกโมก Oil Ozone](https://admin.homaroma.shop/products/56/bom) | [ID: 29] หัวน้ำหอม กลิ่นดอกโมก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 13 | **#61** | [ลีลาวดี Oil Ozone](https://admin.homaroma.shop/products/61/bom) | [ID: 51] หัวน้ำหอม กลิ่นลีลาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 14 | **#66** | [ส้ม Oil Ozone](https://admin.homaroma.shop/products/66/bom) | [ID: 54] หัวน้ำหอม กลิ่นส้ม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 15 | **#71** | [มะนาว Oil Ozone](https://admin.homaroma.shop/products/71/bom) | [ID: 46] หัวน้ำหอม กลิ่นมะนาว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 16 | **#76** | [แคนตาลูป Oil Ozone](https://admin.homaroma.shop/products/76/bom) | [ID: 16] หัวน้ำหอม กลิ่นแคนตาลูป (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 17 | **#81** | [แอปเปิ้ลแดง Oil Ozone](https://admin.homaroma.shop/products/81/bom) | [ID: 19] หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 18 | **#86** | [แอปเปิ้ลเขียว Oil Ozone](https://admin.homaroma.shop/products/86/bom) | [ID: 18] หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 19 | **#91** | [ดอกลิลลี่ Oil Ozone](https://admin.homaroma.shop/products/91/bom) | [ID: 40] หัวน้ำหอม กลิ่นดอกลิลลี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 20 | **#96** | [โรสเมรี่ Oil Ozone](https://admin.homaroma.shop/products/96/bom) | [ID: 20] หัวน้ำหอม กลิ่นโรสเมรี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 21 | **#101** | [อัญชัน Oil Ozone](https://admin.homaroma.shop/products/101/bom) | [ID: 57] หัวน้ำหอม กลิ่นอัญชัน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 22 | **#106** | [กุหลาบแดง Oil Ozone](https://admin.homaroma.shop/products/106/bom) | [ID: 26] หัวน้ำหอม กลิ่นกุหลาบแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 23 | **#111** | [หมากฝรั่ง Oil Ozone](https://admin.homaroma.shop/products/111/bom) | [ID: 55] หัวน้ำหอม กลิ่นหมากฝรั่ง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 24 | **#116** | [ผลไม้รวม Oil Ozone](https://admin.homaroma.shop/products/116/bom) | [ID: 44] หัวน้ำหอม กลิ่นผลไม้รวม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 25 | **#121** | [มะกรูด Oil Ozone](https://admin.homaroma.shop/products/121/bom) | [ID: 45] หัวน้ำหอม กลิ่นมะกรูด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 26 | **#126** | [แซนด้าวูส Oil Ozone](https://admin.homaroma.shop/products/126/bom) | [ID: 17] หัวน้ำหอม กลิ่นแซนด้าวูส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 27 | **#131** | [ดอกปีบ Oil Ozone](https://admin.homaroma.shop/products/131/bom) | [ID: 34] หัวน้ำหอม กลิ่นดอกปีบ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 28 | **#136** | [ดอกบัว Oil Ozone](https://admin.homaroma.shop/products/136/bom) | [ID: 33] หัวน้ำหอม กลิ่นดอกบัว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 29 | **#141** | [โอโซน Oil Ozone](https://admin.homaroma.shop/products/141/bom) | [ID: 22] หัวน้ำหอม กลิ่นโอโซน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 30 | **#146** | [ไทยสปา Oil Ozone](https://admin.homaroma.shop/products/146/bom) | [ID: 23] หัวน้ำหอม กลิ่นไทยสปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 31 | **#151** | [กล้วย Oil Ozone](https://admin.homaroma.shop/products/151/bom) | [ID: 24] หัวน้ำหอม กลิ่นกล้วย (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 32 | **#156** | [กาแฟ Oil Ozone](https://admin.homaroma.shop/products/156/bom) | [ID: 25] หัวน้ำหอม กลิ่นกาแฟ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 33 | **#161** | [ดอกชมนาด Oil Ozone](https://admin.homaroma.shop/products/161/bom) | [ID: 32] หัวน้ำหอม กลิ่นดอกชมนาด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 34 | **#166** | [กลิ่นน้ำนม Oil Ozone](https://admin.homaroma.shop/products/166/bom) | [ID: 43] หัวน้ำหอม กลิ่นน้ำนม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 35 | **#171** | [ดอกการะเวก Oil Ozone](https://admin.homaroma.shop/products/171/bom) | [ID: 30] หัวน้ำหอม กลิ่นดอกการะเวก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 36 | **#176** | [ดอกจำปา Oil Ozone](https://admin.homaroma.shop/products/176/bom) | [ID: 31] หัวน้ำหอม กลิ่นดอกจำปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 37 | **#181** | [ดอกพิกุล Oil Ozone](https://admin.homaroma.shop/products/181/bom) | [ID: 35] หัวน้ำหอม กลิ่นดอกพิกุล (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 38 | **#186** | [ดอกพุด Oil Ozone](https://admin.homaroma.shop/products/186/bom) | [ID: 36] หัวน้ำหอม กลิ่นดอกพุด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 39 | **#191** | [ดอกราตรี Oil Ozone](https://admin.homaroma.shop/products/191/bom) | [ID: 38] หัวน้ำหอม กลิ่นดอกราตรี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 40 | **#196** | [ดอกลำดวน Oil Ozone](https://admin.homaroma.shop/products/196/bom) | [ID: 39] หัวน้ำหอม กลิ่นดอกลำดวน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 41 | **#201** | [ทับทิม Oil Ozone](https://admin.homaroma.shop/products/201/bom) | [ID: 42] หัวน้ำหอม กลิ่นทับทิม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 42 | **#206** | [มะละกอสุก Oil Ozone](https://admin.homaroma.shop/products/206/bom) | [ID: 47] หัวน้ำหอม กลิ่นมะละกอสุก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 43 | **#211** | [ดอกราชาวดี Oil Ozone](https://admin.homaroma.shop/products/211/bom) | [ID: 37] หัวน้ำหอม กลิ่นดอกราชาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 44 | **#216** | [กลิ่นลูกพีช Oil Ozone](https://admin.homaroma.shop/products/216/bom) | [ID: 52] หัวน้ำหอม กลิ่นลูกพีช (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | +| 45 | **#221** | [กลิ่นองุ่น Oil Ozone](https://admin.homaroma.shop/products/221/bom) | [ID: 56] หัวน้ำหอม กลิ่นองุ่น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย | diff --git a/apps/admin/src/components/tiktok-order/TiktokFeeBreakdownModal.tsx b/apps/admin/src/components/tiktok-order/TiktokFeeBreakdownModal.tsx new file mode 100644 index 0000000..2ac25f1 --- /dev/null +++ b/apps/admin/src/components/tiktok-order/TiktokFeeBreakdownModal.tsx @@ -0,0 +1,495 @@ +import React, { useState, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + X, + Receipt, + DollarSign, + Wallet, + BadgePercent, + Coins, + Package, + Truck, + ExternalLink, + CheckCircle2, + AlertTriangle, + Copy, + Check, + TrendingUp, +} from 'lucide-react'; +import { TIKTOK_SHOPS } from '../../pages/TiktokOrderPage'; + +function fmt(val: number | null | undefined) { + if (val == null) return '0.00'; + return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val); +} + +interface TiktokFeeBreakdownModalProps { + order: any; + onClose: () => void; +} + +export function TiktokFeeBreakdownModal({ order, onClose }: TiktokFeeBreakdownModalProps) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + // Keyboard navigation & body scroll lock + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + + // Prevent background page from scrolling + const originalOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = originalOverflow; + }; + }, [onClose]); + + if (!order || typeof document === 'undefined') return null; + + const settlement = order.settlement; + const isSettled = order.isSettled || !!settlement; + const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0]; + + // Financial figures (Settlement fees are usually stored as negative numbers from TikTok reports) + const grossRevenue = settlement?.grossRevenue ?? settlement?.subtotalAfterSellerDiscount ?? order.orderAmount ?? 0; + const sellerDiscount = Math.abs(settlement?.sellerDiscount ?? 0); + const subtotalBeforeDiscount = settlement?.subtotalBeforeDiscount ?? (grossRevenue + sellerDiscount); + + const commissionFee = Math.abs(settlement?.commissionFee ?? order.commissionFee ?? 0); + const affiliateCommission = Math.abs(settlement?.affiliateCommission ?? order.affiliateFee ?? 0); + const affiliatePartnerFee = Math.abs(settlement?.affiliatePartnerFee ?? 0); + const totalAffiliate = affiliateCommission + affiliatePartnerFee; + const orderFee = Math.abs(settlement?.orderFee ?? 0); + const growthSupportFee = Math.abs(settlement?.growthSupportFee ?? 0); + const shippingFeeActual = Math.abs(settlement?.shippingFeeActual ?? 0); + const platformShippingDiscount = Math.abs(settlement?.platformShippingDiscount ?? 0); + const netShippingToSeller = Math.max(0, shippingFeeActual - platformShippingDiscount); + + // Total deductions + const rawTotalFee = settlement?.totalFee != null + ? Math.abs(settlement.totalFee) + : (commissionFee + totalAffiliate + orderFee + growthSupportFee + netShippingToSeller); + + // Net payout + const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (grossRevenue - commissionFee - totalAffiliate); + + // BOM & Net Profit + const totalBomCost = order.orderTotalBomCost || 0; + const hasMissingBom = order.hasIncompleteBom || (order.missingBomCount ?? 0) > 0; + const realNetProfit = netSettlement - totalBomCost; + const marginOnSales = grossRevenue > 0 ? (realNetProfit / grossRevenue) * 100 : 0; + const markupOnCost = totalBomCost > 0 ? (realNetProfit / totalBomCost) * 100 : 0; + const realNetMargin = marginOnSales; + + const handleCopyOrderId = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(order.orderId); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return createPortal( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+
+

+ {t('tiktokOrders.feeModalTitle', 'TikTok Fee & Settlement Breakdown')} +

+ {isSettled ? ( + + + {t('tiktokOrders.settlementCompleteBadge', 'โอนเงินแล้ว')} + + ) : ( + + + {t('tiktokOrders.settlementPendingBadge', 'รอ Settlement')} + + )} +
+
+ #{order.orderId} + + • + {shopCfg.label} + {order.buyerUsername && ( + <> + • + ผู้ซื้อ: {order.buyerUsername} + + )} + {order.createdTime && ( + <> + • + {order.createdTime.slice(0, 16)} + + )} +
+
+
+ +
+ + {/* Scrollable Content */} +
+ {/* Pending Settlement Notice if not settled */} + {!settlement && ( +
+ +
+

+ {t('tiktokOrders.settlementPendingAlert', 'คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement')} +

+

+ {t( + 'tiktokOrders.settlementPendingDesc', + 'ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center' + )} +

+
+
+ )} + + {/* 5 Financial Summary Cards (Compact Banner) */} +
+ {/* 1. Revenue */} +
+
+ {t('tiktokOrders.orderRevenueLabel', 'ยอดขายสินค้า')} + +
+

฿{fmt(grossRevenue)}

+ 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดร้านค้าแล้ว'}> + {sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดแล้ว'} + +
+ + {/* 2. TikTok Deductions */} +
+
+ {t('tiktokOrders.tiktokDeductionsLabel', 'TikTok หักรวม')} + +
+

-฿{fmt(rawTotalFee)}

+ + {grossRevenue > 0 ? `${((rawTotalFee / grossRevenue) * 100).toFixed(1)}% ของยอดขาย` : '-'} + +
+ + {/* 3. Net Payout */} +
+
+ {t('tiktokOrders.netPayoutLabel', 'ยอดโอนสุทธิ')} + +
+

฿{fmt(netSettlement)}

+ + {settlement?.orderSettledTime ? `โอนเมื่อ ${settlement.orderSettledTime.slice(0, 10)}` : 'รอสรุปยอดโอน'} + +
+ + {/* 4. Product Cost (BOM) */} +
+
+ {t('tiktokOrders.cardBomCost', 'ต้นทุนสินค้า (BOM)')} + +
+

฿{fmt(totalBomCost)}

+ + {hasMissingBom + ? '⚠️ มีสินค้ายังไม่ใส่ BOM' + : grossRevenue > 0 + ? `${((totalBomCost / grossRevenue) * 100).toFixed(1)}% ของยอดขาย` + : 'BOM ครบ 100%'} + +
+ + {/* 5. Net Profit (with Margin % on sales and Markup % on cost) */} +
= 0 ? 'bg-emerald-500/10 border-emerald-500/25' : 'bg-rose-500/10 border-rose-500/25' + }`}> +
+ = 0 ? 'text-emerald-300' : 'text-rose-300'}`}> + {t('tiktokOrders.cardNetProfit', 'กำไรสุทธิ')} + + = 0 ? 'text-emerald-400' : 'text-rose-400'}`} /> +
+

= 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + ฿{fmt(realNetProfit)} +

+
+ = 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}> + กำไร/ขาย {marginOnSales.toFixed(1)}% + + • + = 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}> + กำไร/ทุน {markupOnCost >= 0 ? '+' : ''}{markupOnCost.toFixed(1)}% + +
+
+
+ + {/* 2-Column Wide Landscape Grid Layout */} +
+ {/* Left Column (7 Cols): TikTok Platform Fees Breakdown */} +
+
+
+ +

+ {t('tiktokOrders.feeBreakdownTitle', 'Breakdown รายการหักค่าใช้จ่ายของ TikTok')} +

+
+ -฿{fmt(rawTotalFee)} +
+ +
+ {/* Commission Fee */} +
+
+ +
+

+ {t('tiktokOrders.tiktokCommissionFee', 'ค่าคอมมิชชั่น TikTok Shop (Commission Fee)')} +

+

+ {t('tiktokOrders.tiktokCommissionDesc', 'หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม')} +

+
+
+ + {commissionFee > 0 ? `-฿${fmt(commissionFee)}` : '฿0.00'} + +
+ + {/* Affiliate Creator Commission */} +
+
+ +
+

+ {t('tiktokOrders.affiliateCommissionFee', 'ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)')} +

+

+ {t('tiktokOrders.affiliateCommissionDesc', 'จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์')} +

+
+
+ + {affiliateCommission > 0 ? `-฿${fmt(affiliateCommission)}` : '฿0.00'} + +
+ + {/* Affiliate Partner Fee (TAP) */} + {(affiliatePartnerFee > 0 || settlement) && ( +
+
+ + {t('tiktokOrders.affiliatePartnerFee', 'ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)')} +
+ + {affiliatePartnerFee > 0 ? `-฿${fmt(affiliatePartnerFee)}` : '฿0.00'} + +
+ )} + + {/* Order / Transaction Fee */} + {(orderFee > 0 || settlement) && ( +
+
+ + {t('tiktokOrders.orderFee', 'ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)')} +
+ + {orderFee > 0 ? `-฿${fmt(orderFee)}` : '฿0.00'} + +
+ )} + + {/* Growth Support Fee */} + {growthSupportFee > 0 && ( +
+
+ + {t('tiktokOrders.growthSupportFee', 'ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)')} +
+ + -฿{fmt(growthSupportFee)} + +
+ )} + + {/* Shipping Breakdown */} + {(shippingFeeActual > 0 || platformShippingDiscount > 0) && ( +
+
+
+ + {t('tiktokOrders.shippingBreakdown', 'ค่าจัดส่งขนส่ง')} +
+ + {netShippingToSeller > 0 ? `-฿${fmt(netShippingToSeller)}` : t('tiktokOrders.shippingFreeForSeller', 'ร้านค้าไม่เสียค่าส่ง')} + +
+
+ + {t('tiktokOrders.shippingBreakdownDetail', { + actual: fmt(shippingFeeActual), + subsidy: fmt(platformShippingDiscount), + defaultValue: `ค่าส่งจริง ฿${fmt(shippingFeeActual)} | TikTok อุดหนุน -฿${fmt(platformShippingDiscount)}` + })} + +
+
+ )} +
+
+ + {/* Right Column (5 Cols): BOM Profitability & Order Items */} +
+ {/* BOM Profitability Analysis Card */} +
+
+
+ +

+ {t('tiktokOrders.bomProfitAnalysis', 'วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM')} +

+
+ {hasMissingBom ? ( + + {t('tiktokOrders.missingBomCount', { count: order.missingBomCount })} + + ) : ( + + {t('tiktokOrders.bomComplete100', 'BOM ครบ 100%')} + + )} +
+ +
+
+ {t('tiktokOrders.netPayoutLabel', 'ยอดเงินที่ TikTok โอนให้')} + ฿{fmt(netSettlement)} +
+ +
+ {t('tiktokOrders.totalBomCostLabel', 'หัก ต้นทุนสินค้าตามสูตร BOM')} + + {totalBomCost > 0 ? `-฿${fmt(totalBomCost)}` : '฿0.00'} + +
+ +
+
+

+ {t('tiktokOrders.realNetProfitLabel', 'กำไรสุทธิแท้จริง (Real Net Profit)')} +

+

+ {t('tiktokOrders.realNetMarginLabel', 'คิดเป็นอัตรากำไร')}:{' '} + {realNetMargin.toFixed(1)}% +

+
+
+ = 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + ฿{fmt(realNetProfit)} + +
+
+
+
+ + {/* Order Items Snippet */} + {order.items && order.items.length > 0 && ( +
+
+
+ + + {t('tiktokOrders.orderItemsTitle', 'รายการสินค้าในออเดอร์นี้')} ({order.items.length}) + +
+
+
+ {order.items.map((item: any, idx: number) => ( +
+
+

{item.productName}

+

+ {item.variation ? `[${item.variation}] ` : ''}จำนวน: {item.quantity} ชิ้น + {item.masterSku ? ` • Master: ${item.masterSku}` : ''} +

+
+
+

+ ฿{fmt(item.subtotalAfterDiscount || item.unitOriginalPrice * (item.quantity || 1))} +

+

BOM: ฿{fmt(item.totalBomCost || 0)}

+
+
+ ))} +
+
+ )} +
+
+
+ + {/* Footer Actions */} +
+ + {t('tiktokOrders.viewFullOrderDetail', 'เปิดดูคำสั่งซื้อฉบับเต็ม')} + + + +
+
+
, + document.body + ); +} diff --git a/apps/admin/src/locales/en/translation.json b/apps/admin/src/locales/en/translation.json index 334d4d4..c45be95 100644 --- a/apps/admin/src/locales/en/translation.json +++ b/apps/admin/src/locales/en/translation.json @@ -454,6 +454,41 @@ "thAffiliate": "Affiliate (฿)", "thNetProfit": "Net Profit", "thActions": "Actions", + "viewFeeBreakdown": "Click to view TikTok fee breakdown", + "feeModalTitle": "TikTok Fee & Settlement Breakdown", + "feeModalSub": "Detailed breakdown of TikTok Shop commission, affiliate fees, and net payout", + "settlementPendingAlert": "This order does not have a TikTok Settlement statement yet", + "settlementPendingDesc": "Commissions and fees below are preliminary estimates from order data until a Settlement XLSX file is imported from TikTok Seller Center.", + "settlementCompleteBadge": "Settled & Transferred", + "settlementPendingBadge": "Pending Settlement", + "orderRevenueLabel": "Product Revenue", + "tiktokDeductionsLabel": "Total TikTok Deductions", + "netPayoutLabel": "Net Payout", + "cardBomCost": "Product Cost (BOM)", + "cardNetProfit": "Net Profit", + "profitOnSales": "Profit/Sales", + "profitOnCost": "Profit/Cost", + "feeBreakdownTitle": "TikTok Platform Fee Deductions Breakdown", + "tiktokCommissionFee": "TikTok Shop Commission Fee", + "tiktokCommissionDesc": "Platform category commission rate deduction", + "affiliateCommissionFee": "Creator Affiliate Commission Fee", + "affiliateCommissionDesc": "Commission paid to creators/affiliates", + "affiliatePartnerFee": "Affiliate Partner Fee (TAP)", + "orderFee": "Order / Transaction Fee", + "growthSupportFee": "Growth Support Fee", + "shippingBreakdown": "Shipping & Logistics Fees", + "shippingBreakdownDetail": "Actual Shipping ฿{{actual}} | Platform Subsidy -฿{{subsidy}}", + "shippingFreeForSeller": "Seller shipping covered", + "sellerDiscountLabel": "Seller Discount", + "bomProfitAnalysis": "Real Net Profit Analysis (After BOM Cost)", + "totalBomCostLabel": "Deduct: BOM Material Cost", + "realNetProfitLabel": "Real Net Profit", + "realNetMarginLabel": "Net Margin", + "orderItemsTitle": "Items in this Order", + "viewFullOrderDetail": "View Full Order Detail", + "closeModal": "Close", + "copyOrderId": "Copy Order ID", + "copied": "Copied", "processingData": "Processing data...", "noOrders": "No data available", "settledTransferred": "Settled ฿{{amount}}", diff --git a/apps/admin/src/locales/ja/translation.json b/apps/admin/src/locales/ja/translation.json index 2018b5d..8ca8e65 100644 --- a/apps/admin/src/locales/ja/translation.json +++ b/apps/admin/src/locales/ja/translation.json @@ -450,6 +450,41 @@ "thAffiliate": "Affiliate (฿)", "thNetProfit": "純利益", "thActions": "操作", + "viewFeeBreakdown": "クリックしてTikTok手数料明細を表示", + "feeModalTitle": "TikTok 手数料・精算内訳 (Breakdown)", + "feeModalSub": "TikTok Shop手数料、アフィリエイト報酬、純利益の詳細内訳", + "settlementPendingAlert": "この注文にはまだTikTok精算明細データがありません", + "settlementPendingDesc": "以下の手数料と数値は注文データに基づく概算です。TikTokセラーセンターから精算XLSXがインポートされると確定値に更新されます。", + "settlementCompleteBadge": "振込完了 (Settled)", + "settlementPendingBadge": "精算待ち", + "orderRevenueLabel": "商品売上高", + "tiktokDeductionsLabel": "TikTok 控除合計", + "netPayoutLabel": "振込受取額 (Net Payout)", + "cardBomCost": "商品原価 (BOM)", + "cardNetProfit": "純利益", + "profitOnSales": "売上利益率", + "profitOnCost": "原価利益率", + "feeBreakdownTitle": "TikTok プラットフォーム控除の内訳", + "tiktokCommissionFee": "TikTok Shop プラットフォーム手数料 (Commission)", + "tiktokCommissionDesc": "カテゴリ別プラットフォーム手数料率による控除", + "affiliateCommissionFee": "クリエイターアフィリエイト報酬 (Affiliate)", + "affiliateCommissionDesc": "紹介・ライブ配信クリエイターへの支払報酬", + "affiliatePartnerFee": "パートナー手数料 (TAP)", + "orderFee": "注文・決済処理手数料 (Order Fee)", + "growthSupportFee": "成長支援手数料 (Growth Support)", + "shippingBreakdown": "配送・物流手数料内訳", + "shippingBreakdownDetail": "実送料 ฿{{actual}} | プラットフォーム補助 -฿{{subsidy}}", + "shippingFreeForSeller": "ショップ負担なし", + "sellerDiscountLabel": "ショップ割引", + "bomProfitAnalysis": "BOM原価控除後の実質純利益分析", + "totalBomCostLabel": "控除:BOM原料および包材原価", + "realNetProfitLabel": "実質純利益 (Real Net Profit)", + "realNetMarginLabel": "純利益率", + "orderItemsTitle": "注文内商品一覧", + "viewFullOrderDetail": "注文詳細画面を開く", + "closeModal": "閉じる", + "copyOrderId": "注文番号をコピー", + "copied": "コピー完了", "processingData": "データを処理中...", "noOrders": "データがありません", "settledTransferred": "振込済 ฿{{amount}}", diff --git a/apps/admin/src/locales/th/translation.json b/apps/admin/src/locales/th/translation.json index d490d56..8032029 100644 --- a/apps/admin/src/locales/th/translation.json +++ b/apps/admin/src/locales/th/translation.json @@ -454,6 +454,41 @@ "thAffiliate": "Affiliate (฿)", "thNetProfit": "กำไรสุทธิ", "thActions": "การกระทำ", + "viewFeeBreakdown": "คลิกดู Breakdown ค่าธรรมเนียม TikTok", + "feeModalTitle": "TikTok Fee & Settlement Breakdown", + "feeModalSub": "รายละเอียดค่าธรรมเนียม TikTok Shop, Affiliate และกำไรสุทธิ", + "settlementPendingAlert": "คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement", + "settlementPendingDesc": "ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center", + "settlementCompleteBadge": "โอนเงินแล้ว", + "settlementPendingBadge": "รอ Settlement", + "orderRevenueLabel": "ยอดขายสินค้า", + "tiktokDeductionsLabel": "TikTok หักรวม", + "netPayoutLabel": "ยอดโอนสุทธิ", + "cardBomCost": "ต้นทุนสินค้า (BOM)", + "cardNetProfit": "กำไรสุทธิ", + "profitOnSales": "กำไร/ยอดขาย", + "profitOnCost": "กำไร/ทุน", + "feeBreakdownTitle": "Breakdown รายการหักค่าใช้จ่ายของ TikTok", + "tiktokCommissionFee": "ค่าคอมมิชชั่น TikTok Shop (Commission Fee)", + "tiktokCommissionDesc": "หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม", + "affiliateCommissionFee": "ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)", + "affiliateCommissionDesc": "จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์", + "affiliatePartnerFee": "ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)", + "orderFee": "ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)", + "growthSupportFee": "ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)", + "shippingBreakdown": "ค่าจัดส่งขนส่ง", + "shippingBreakdownDetail": "ค่าส่งจริง ฿{{actual}} | TikTok อุดหนุน -฿{{subsidy}}", + "shippingFreeForSeller": "ร้านค้าไม่เสียค่าส่ง", + "sellerDiscountLabel": "ส่วนลดจากร้านค้า", + "bomProfitAnalysis": "วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM", + "totalBomCostLabel": "หัก ต้นทุนสินค้าตามสูตร BOM", + "realNetProfitLabel": "กำไรสุทธิแท้จริง (Real Net Profit)", + "realNetMarginLabel": "คิดเป็นอัตรากำไร", + "orderItemsTitle": "รายการสินค้าในออเดอร์นี้", + "viewFullOrderDetail": "เปิดดูคำสั่งซื้อฉบับเต็ม", + "closeModal": "ปิด", + "copyOrderId": "คัดลอก Order ID", + "copied": "คัดลอกแล้ว", "processingData": "กำลังประมวลผลข้อมูล...", "noOrders": "ยังไม่มีข้อมูล", "settledTransferred": "โอนแล้ว ฿{{amount}}", diff --git a/apps/admin/src/locales/zh/translation.json b/apps/admin/src/locales/zh/translation.json index db9a450..2e47216 100644 --- a/apps/admin/src/locales/zh/translation.json +++ b/apps/admin/src/locales/zh/translation.json @@ -450,6 +450,41 @@ "thAffiliate": "Affiliate (฿)", "thNetProfit": "净利润", "thActions": "操作", + "viewFeeBreakdown": "点击查看 TikTok 费用明细", + "feeModalTitle": "TikTok 费用与结算明细", + "feeModalSub": "TikTok Shop 佣金、达人佣金与净利润详细拆解", + "settlementPendingAlert": "该订单暂无 TikTok 结算对账单数据", + "settlementPendingDesc": "以下佣金与费用为基于订单数据的预估,待从 TikTok 卖家中心导入结算 XLSX 文件后更新。", + "settlementCompleteBadge": "已完成打款", + "settlementPendingBadge": "等待结算", + "orderRevenueLabel": "商品销售额", + "tiktokDeductionsLabel": "TikTok 扣款合计", + "netPayoutLabel": "最终打款金额", + "cardBomCost": "商品成本 (BOM)", + "cardNetProfit": "净利润", + "profitOnSales": "利润率(销售)", + "profitOnCost": "利润率(成本)", + "feeBreakdownTitle": "TikTok 平台扣款明细", + "tiktokCommissionFee": "TikTok Shop 平台佣金 (Commission Fee)", + "tiktokCommissionDesc": "按平台品类佣金率扣除", + "affiliateCommissionFee": "达人/分销佣金 (Affiliate Commission)", + "affiliateCommissionDesc": "支付给带货达人的佣金", + "affiliatePartnerFee": "机构/合作伙伴佣金 (TAP)", + "orderFee": "订单/交易手续费 (Order Fee)", + "growthSupportFee": "成长支持费 (Growth Support Fee)", + "shippingBreakdown": "物流运费明细", + "shippingBreakdownDetail": "实际运费 ฿{{actual}} | 平台补贴 -฿{{subsidy}}", + "shippingFreeForSeller": "商家免运费", + "sellerDiscountLabel": "商家折扣", + "bomProfitAnalysis": "扣除 BOM 成本后真实净利润分析", + "totalBomCostLabel": "扣除:BOM 原料及包装成本", + "realNetProfitLabel": "真实净利润 (Real Net Profit)", + "realNetMarginLabel": "净利润率", + "orderItemsTitle": "订单内商品列表", + "viewFullOrderDetail": "查看订单完整详情", + "closeModal": "关闭", + "copyOrderId": "复制订单号", + "copied": "已复制", "processingData": "正在处理数据...", "noOrders": "暂无数据", "settledTransferred": "已打款 ฿{{amount}}", diff --git a/apps/admin/src/pages/TiktokOrderDetailPage.tsx b/apps/admin/src/pages/TiktokOrderDetailPage.tsx index 4894127..61ab91c 100644 --- a/apps/admin/src/pages/TiktokOrderDetailPage.tsx +++ b/apps/admin/src/pages/TiktokOrderDetailPage.tsx @@ -21,6 +21,7 @@ import { import { API_BASE } from '@homaroma/types'; import { TIKTOK_SHOPS } from './TiktokOrderPage'; import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils'; +import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal'; function authHeaders() { return { @@ -66,6 +67,7 @@ export default function TiktokOrderDetailPage() { const [order, setOrder] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [showFeeModal, setShowFeeModal] = useState(false); useEffect(() => { if (!orderId) return; @@ -410,19 +412,35 @@ export default function TiktokOrderDetailPage() { -
- {t('tiktokOrderDetail.commissionSummary')} - +
+ -
- {t('tiktokOrderDetail.affiliateSummary')} - +
+
@@ -527,6 +545,13 @@ export default function TiktokOrderDetailPage() {
+ + {showFeeModal && ( + setShowFeeModal(false)} + /> + )} ); } diff --git a/apps/admin/src/pages/TiktokOrderPage.tsx b/apps/admin/src/pages/TiktokOrderPage.tsx index 1e52a77..f22fb8d 100644 --- a/apps/admin/src/pages/TiktokOrderPage.tsx +++ b/apps/admin/src/pages/TiktokOrderPage.tsx @@ -7,10 +7,11 @@ import { ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2, FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw, - BadgePercent, Wallet, Receipt + BadgePercent, Wallet, Receipt, Info } from 'lucide-react'; import { API_BASE } from '@homaroma/types'; import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils'; +import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal'; // ── Shop Config ─────────────────────────────────────────────────────────────── export const TIKTOK_SHOPS = [ @@ -164,6 +165,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [bomFilter, setBomFilter] = useState('all'); + const [selectedFeeOrder, setSelectedFeeOrder] = useState(null); useEffect(() => { const timer = setTimeout(() => { @@ -396,8 +398,24 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) {t('tiktokOrders.thSkusBom')} {t('tiktokOrders.thOrderAmount')} {t('tiktokOrders.thBomCost')} - {t('tiktokOrders.thCommission')} - {t('tiktokOrders.thAffiliate')} + + + {t('tiktokOrders.thCommission')} + + + + + + {t('tiktokOrders.thAffiliate')} + + + {t('tiktokOrders.thNetProfit')} @@ -507,20 +525,42 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) {/* Commission (฿) */} - {order.commissionFee > 0 ? ( - -฿{fmt(order.commissionFee)} - ) : ( - - - )} + {/* Affiliate (฿) */} - {order.affiliateFee > 0 ? ( - -฿{fmt(order.affiliateFee)} - ) : ( - - - )} + {/* Profit */} @@ -553,6 +593,14 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) + + {/* Fee Breakdown Modal */} + {selectedFeeOrder && ( + setSelectedFeeOrder(null)} + /> + )} ); } diff --git a/apps/backend/src/routes/tools.ts b/apps/backend/src/routes/tools.ts index b594e88..552d2c7 100644 --- a/apps/backend/src/routes/tools.ts +++ b/apps/backend/src/routes/tools.ts @@ -469,6 +469,33 @@ export const toolsRoute = new Elysia({ prefix: "/tools" }) } ) + // ── 4.1 POST Bulk Link / Unlink Master Variants ────────────────────────────── + .post( + "/marketplace-products/bulk-map", + async ({ body }: { body: { mappings: Array<{ marketplaceProductId: number; masterVariantId: number | null }> } }) => { + const { mappings } = body; + await prisma.$transaction( + mappings.map((m) => + prisma.marketplaceProduct.update({ + where: { id: m.marketplaceProductId }, + data: { masterVariantId: m.masterVariantId }, + }) + ) + ); + return { success: true, count: mappings.length }; + }, + { + body: t.Object({ + mappings: t.Array( + 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({ diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 623d5bb..588c7b2 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -430,6 +430,64 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { required: ["productId", "variants"] } }, + { + name: "list_marketplace_products", + description: "List marketplace products (TikTok, Shopee, Lazada) extracted from orders. Allows filtering by status ('unmapped', 'mapped', 'all'), channel ('tiktok', 'shopee', 'lazada', 'all'), search text, and pagination. Returns sales metrics and current masterVariant mapping.", + inputSchema: { + type: "object", + properties: { + channel: { type: "string", enum: ["tiktok", "shopee", "lazada", "all"], description: "Marketplace channel. Default is 'all'." }, + status: { type: "string", enum: ["unmapped", "mapped", "all"], description: "Mapping status. Default is 'unmapped'." }, + search: { type: "string", description: "Search by product name, variation, or SKU" }, + page: { type: "number", description: "Page number. Default 1." }, + limit: { type: "number", description: "Items per page. Default 50." } + } + } + }, + { + name: "get_master_product_variants", + description: "List all master product variants from the core catalog for mapping, including product name, options (size/scent), SKU, barcode, and unit BOM cost.", + inputSchema: { + type: "object", + properties: { + search: { type: "string", description: "Search by product name, variant option, or SKU" } + } + } + }, + { + name: "map_marketplace_product", + description: "Link or unlink a single marketplace product (TikTok/Shopee/Lazada) to a master product variant (ProductVariant) to enable BOM cost and profitability calculation. Pass masterVariantId=null to unlink.", + inputSchema: { + type: "object", + properties: { + marketplaceProductId: { type: "number", description: "Marketplace product ID" }, + masterVariantId: { type: ["number", "null"], description: "Master variant ID, or null to unlink" } + }, + required: ["marketplaceProductId", "masterVariantId"] + } + }, + { + name: "bulk_map_marketplace_products", + description: "Link multiple marketplace products to master catalog variants in a single batch operation.", + inputSchema: { + type: "object", + properties: { + mappings: { + type: "array", + description: "Array of mappings to apply", + items: { + type: "object", + properties: { + marketplaceProductId: { type: "number", description: "Marketplace product ID" }, + masterVariantId: { type: ["number", "null"], description: "Master variant ID, or null to unlink" } + }, + required: ["marketplaceProductId", "masterVariantId"] + } + } + }, + required: ["mappings"] + } + }, { name: "update_category_translation", description: "Add or update translations for a specific category. Do NOT use this tool for products.", @@ -1524,6 +1582,41 @@ async function handleToolRequest(request: CallToolRequest) { const res = await api.put(`/products/${params.productId}/bom`, { variants: params.variants }); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "list_marketplace_products": { + const query = new URLSearchParams(); + if (params.channel) query.set("channel", String(params.channel)); + if (params.status) query.set("status", String(params.status)); + if (params.search) query.set("search", String(params.search)); + if (params.page) query.set("page", String(params.page)); + query.set("limit", String(params.limit ?? 50)); + const res = await api.get(`/tools/marketplace-products?${query.toString()}`); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + case "get_master_product_variants": { + const res = await api.get(`/tools/marketplace-products/master-variants`); + let variants = res.data.variants || []; + if (params.search) { + const words = String(params.search).toLowerCase().split(/\s+/).filter(Boolean); + variants = variants.filter((v: any) => { + const target = `${v.displayName} ${v.sku} ${v.productName} ${v.optionsStr}`.toLowerCase(); + return words.every((w: string) => target.includes(w)); + }); + } + return { content: [{ type: "text", text: JSON.stringify({ variants }, null, 2) }] }; + } + case "map_marketplace_product": { + const res = await api.post(`/tools/marketplace-products/map`, { + marketplaceProductId: params.marketplaceProductId, + masterVariantId: params.masterVariantId, + }); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + case "bulk_map_marketplace_products": { + const res = await api.post(`/tools/marketplace-products/bulk-map`, { + mappings: params.mappings, + }); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } case "create_product": { const res = await api.post(`/products`, params); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; diff --git a/oil_ozone_products.json b/oil_ozone_products.json new file mode 100644 index 0000000..021f9d5 --- /dev/null +++ b/oil_ozone_products.json @@ -0,0 +1,2477 @@ +[ + { + "index": 1, + "productId": 1, + "productName": "ตะไคร้ Oil Ozone", + "scentName": "ตะไคร้", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 698, + "sku": "1-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 18.94, + "marginPercent": 72.6 + }, + { + "id": 699, + "sku": "1-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 33.88, + "marginPercent": 66.1 + }, + { + "id": 700, + "sku": "1-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 46.33, + "marginPercent": 61.4 + }, + { + "id": 701, + "sku": "1-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 63.76, + "marginPercent": 57.5 + } + ], + "matchedFragrance": { + "id": 41, + "name": "หัวน้ำหอม กลิ่นตะไคร้", + "pricePerUnit": 4.5, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 2, + "productId": 6, + "productName": "ลาเวนเดอร์ Oil Ozone", + "scentName": "ลาเวนเดอร์", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 746, + "sku": "2-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 11.02, + "marginPercent": 84 + }, + { + "id": 747, + "sku": "2-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 18.04, + "marginPercent": 82 + }, + { + "id": 748, + "sku": "2-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 23.89, + "marginPercent": 80.1 + }, + { + "id": 749, + "sku": "2-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 32.08, + "marginPercent": 78.6 + } + ], + "matchedFragrance": { + "id": 15, + "name": "หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์", + "pricePerUnit": 1.2, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 3, + "productId": 11, + "productName": "วานิลลา Oil Ozone", + "scentName": "วานิลลา", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 754, + "sku": "3-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 755, + "sku": "3-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 756, + "sku": "3-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 757, + "sku": "3-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 53, + "name": "หัวน้ำหอม กลิ่นวานิลลา", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 4, + "productId": 16, + "productName": "ซากุระ Oil Ozone", + "scentName": "ซากุระ", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 690, + "sku": "4-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 11.02, + "marginPercent": 84 + }, + { + "id": 691, + "sku": "4-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 18.04, + "marginPercent": 82 + }, + { + "id": 692, + "sku": "4-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 23.89, + "marginPercent": 80.1 + }, + { + "id": 693, + "sku": "4-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 32.08, + "marginPercent": 78.6 + } + ], + "matchedFragrance": { + "id": 27, + "name": "หัวน้ำหอม กลิ่นซากุระ", + "pricePerUnit": 1.2, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 5, + "productId": 21, + "productName": "มะลิ Oil Ozone", + "scentName": "มะลิ", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 731, + "sku": "5-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 18.04, + "marginPercent": 82 + }, + { + "id": 732, + "sku": "5-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 23.89, + "marginPercent": 80.1 + }, + { + "id": 733, + "sku": "5-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 32.08, + "marginPercent": 78.6 + }, + { + "id": 730, + "sku": "5-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 11.02, + "marginPercent": 84 + } + ], + "matchedFragrance": { + "id": 48, + "name": "หัวน้ำหอม กลิ่นมะลิ", + "pricePerUnit": 1.2, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 6, + "productId": 26, + "productName": "ยูคาลิปตัส Oil Ozone", + "scentName": "ยูคาลิปตัส", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 738, + "sku": "6-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 739, + "sku": "6-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 740, + "sku": "6-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 741, + "sku": "6-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 49, + "name": "หัวน้ำหอม กลิ่นยูคาลิปตัส", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 7, + "productId": 31, + "productName": "เฟรชลาเวนเดอร์ Oil Ozone", + "scentName": "เฟรชลาเวนเดอร์", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 714, + "sku": "7-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 11.02, + "marginPercent": 84 + }, + { + "id": 715, + "sku": "7-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 18.04, + "marginPercent": 82 + }, + { + "id": 716, + "sku": "7-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 23.89, + "marginPercent": 80.1 + }, + { + "id": 717, + "sku": "7-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 32.08, + "marginPercent": 78.6 + } + ], + "matchedFragrance": { + "id": 15, + "name": "หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์", + "pricePerUnit": 1.2, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 8, + "productId": 36, + "productName": "เปเปอร์มิ้น Oil Ozone", + "scentName": "เปเปอร์มิ้น", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 706, + "sku": "8-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 707, + "sku": "8-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 708, + "sku": "8-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 709, + "sku": "8-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 14, + "name": "หัวน้ำหอม กลิ่นเปเปอร์มิ้น", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 9, + "productId": 41, + "productName": "โอเชี่ยน Oil Ozone", + "scentName": "โอเชี่ยน", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 105, + "sku": "9-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 106, + "sku": "9-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 107, + "sku": "9-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 108, + "sku": "9-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 21, + "name": "หัวน้ำหอม กลิ่นโอเชี่ยน", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 10, + "productId": 46, + "productName": "เซเว่นซี Oil Ozone", + "scentName": "เซเว่นซี", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 1012, + "sku": "10-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 1013, + "sku": "10-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 1014, + "sku": "10-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 1015, + "sku": "10-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 13, + "name": "หัวน้ำหอม กลิ่นเซเว่นซี", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 11, + "productId": 51, + "productName": "ดอกแก้ว Oil Ozone", + "scentName": "ดอกแก้ว", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 758, + "sku": "11-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 759, + "sku": "11-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 760, + "sku": "11-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 761, + "sku": "11-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 28, + "name": "หัวน้ำหอม กลิ่นดอกแก้ว", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 12, + "productId": 56, + "productName": "ดอกโมก Oil Ozone", + "scentName": "ดอกโมก", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 766, + "sku": "12-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 767, + "sku": "12-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 768, + "sku": "12-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 769, + "sku": "12-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 29, + "name": "หัวน้ำหอม กลิ่นดอกโมก", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 13, + "productId": 61, + "productName": "ลีลาวดี Oil Ozone", + "scentName": "ลีลาวดี", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 774, + "sku": "13-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 775, + "sku": "13-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 776, + "sku": "13-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 777, + "sku": "13-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 51, + "name": "หัวน้ำหอม กลิ่นลีลาวดี", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 14, + "productId": 66, + "productName": "ส้ม Oil Ozone", + "scentName": "ส้ม", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 610, + "sku": "14-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 611, + "sku": "14-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 612, + "sku": "14-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 613, + "sku": "14-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 54, + "name": "หัวน้ำหอม กลิ่นส้ม", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 15, + "productId": 71, + "productName": "มะนาว Oil Ozone", + "scentName": "มะนาว", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 682, + "sku": "15-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 683, + "sku": "15-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 684, + "sku": "15-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 685, + "sku": "15-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 46, + "name": "หัวน้ำหอม กลิ่นมะนาว", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 16, + "productId": 76, + "productName": "แคนตาลูป Oil Ozone", + "scentName": "แคนตาลูป", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 594, + "sku": "16-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 595, + "sku": "16-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 596, + "sku": "16-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 597, + "sku": "16-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 16, + "name": "หัวน้ำหอม กลิ่นแคนตาลูป", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 17, + "productId": 81, + "productName": "แอปเปิ้ลแดง Oil Ozone", + "scentName": "แอปเปิ้ลแดง", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 1020, + "sku": "17-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 1021, + "sku": "17-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 1022, + "sku": "17-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 1023, + "sku": "17-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 19, + "name": "หัวน้ำหอม กลิ่นแอปเปิ้ลแดง", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 18, + "productId": 86, + "productName": "แอปเปิ้ลเขียว Oil Ozone", + "scentName": "แอปเปิ้ลเขียว", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 618, + "sku": "18-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 619, + "sku": "18-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 620, + "sku": "18-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 621, + "sku": "18-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 18, + "name": "หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 19, + "productId": 91, + "productName": "ดอกลิลลี่ Oil Ozone", + "scentName": "ดอกลิลลี่", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 666, + "sku": "19-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 667, + "sku": "19-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 668, + "sku": "19-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 669, + "sku": "19-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 40, + "name": "หัวน้ำหอม กลิ่นดอกลิลลี่", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 20, + "productId": 96, + "productName": "โรสเมรี่ Oil Ozone", + "scentName": "โรสเมรี่", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 602, + "sku": "20-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 603, + "sku": "20-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 604, + "sku": "20-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 605, + "sku": "20-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 20, + "name": "หัวน้ำหอม กลิ่นโรสเมรี่", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 21, + "productId": 101, + "productName": "อัญชัน Oil Ozone", + "scentName": "อัญชัน", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 790, + "sku": "21-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 791, + "sku": "21-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 792, + "sku": "21-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 793, + "sku": "21-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 57, + "name": "หัวน้ำหอม กลิ่นอัญชัน", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 22, + "productId": 106, + "productName": "กุหลาบแดง Oil Ozone", + "scentName": "กุหลาบแดง", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 626, + "sku": "22-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 627, + "sku": "22-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 628, + "sku": "22-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 629, + "sku": "22-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 26, + "name": "หัวน้ำหอม กลิ่นกุหลาบแดง", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 23, + "productId": 111, + "productName": "หมากฝรั่ง Oil Ozone", + "scentName": "หมากฝรั่ง", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 1024, + "sku": "23-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 1025, + "sku": "23-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 1026, + "sku": "23-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 1027, + "sku": "23-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 55, + "name": "หัวน้ำหอม กลิ่นหมากฝรั่ง", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 24, + "productId": 116, + "productName": "ผลไม้รวม Oil Ozone", + "scentName": "ผลไม้รวม", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 782, + "sku": "24-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 783, + "sku": "24-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 784, + "sku": "24-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 785, + "sku": "24-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 44, + "name": "หัวน้ำหอม กลิ่นผลไม้รวม", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 25, + "productId": 121, + "productName": "มะกรูด Oil Ozone", + "scentName": "มะกรูด", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 650, + "sku": "25-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 651, + "sku": "25-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 652, + "sku": "25-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 653, + "sku": "25-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 45, + "name": "หัวน้ำหอม กลิ่นมะกรูด", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 26, + "productId": 126, + "productName": "แซนด้าวูส Oil Ozone", + "scentName": "แซนด้าวูส", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 814, + "sku": "26-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 815, + "sku": "26-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 816, + "sku": "26-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 817, + "sku": "26-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 17, + "name": "หัวน้ำหอม กลิ่นแซนด้าวูส", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 27, + "productId": 131, + "productName": "ดอกปีบ Oil Ozone", + "scentName": "ดอกปีบ", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 634, + "sku": "27-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 635, + "sku": "27-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 636, + "sku": "27-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 637, + "sku": "27-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 34, + "name": "หัวน้ำหอม กลิ่นดอกปีบ", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 28, + "productId": 136, + "productName": "ดอกบัว Oil Ozone", + "scentName": "ดอกบัว", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 674, + "sku": "28-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 675, + "sku": "28-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 676, + "sku": "28-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 677, + "sku": "28-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 33, + "name": "หัวน้ำหอม กลิ่นดอกบัว", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 29, + "productId": 141, + "productName": "โอโซน Oil Ozone", + "scentName": "โอโซน", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 658, + "sku": "29-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 659, + "sku": "29-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 660, + "sku": "29-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 661, + "sku": "29-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 22, + "name": "หัวน้ำหอม กลิ่นโอโซน", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 30, + "productId": 146, + "productName": "ไทยสปา Oil Ozone", + "scentName": "ไทยสปา", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 642, + "sku": "30-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 643, + "sku": "30-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 644, + "sku": "30-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 645, + "sku": "30-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 23, + "name": "หัวน้ำหอม กลิ่นไทยสปา", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 31, + "productId": 151, + "productName": "กล้วย Oil Ozone", + "scentName": "กล้วย", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 912, + "sku": "31-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 913, + "sku": "31-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 914, + "sku": "31-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 915, + "sku": "31-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 24, + "name": "หัวน้ำหอม กลิ่นกล้วย", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 32, + "productId": 156, + "productName": "กาแฟ Oil Ozone", + "scentName": "กาแฟ", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 948, + "sku": "32-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 949, + "sku": "32-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 950, + "sku": "32-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 951, + "sku": "32-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 25, + "name": "หัวน้ำหอม กลิ่นกาแฟ", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 33, + "productId": 161, + "productName": "ดอกชมนาด Oil Ozone", + "scentName": "ดอกชมนาด", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 960, + "sku": "33-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 961, + "sku": "33-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 962, + "sku": "33-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 963, + "sku": "33-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 32, + "name": "หัวน้ำหอม กลิ่นดอกชมนาด", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 34, + "productId": 166, + "productName": "กลิ่นน้ำนม Oil Ozone", + "scentName": "กลิ่นน้ำนม", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 920, + "sku": "34-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 921, + "sku": "34-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 922, + "sku": "34-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 923, + "sku": "34-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 43, + "name": "หัวน้ำหอม กลิ่นน้ำนม", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 35, + "productId": 171, + "productName": "ดอกการะเวก Oil Ozone", + "scentName": "ดอกการะเวก", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 936, + "sku": "35-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 937, + "sku": "35-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 938, + "sku": "35-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 939, + "sku": "35-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 30, + "name": "หัวน้ำหอม กลิ่นดอกการะเวก", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 36, + "productId": 176, + "productName": "ดอกจำปา Oil Ozone", + "scentName": "ดอกจำปา", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 928, + "sku": "36-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 929, + "sku": "36-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 930, + "sku": "36-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 931, + "sku": "36-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 31, + "name": "หัวน้ำหอม กลิ่นดอกจำปา", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 37, + "productId": 181, + "productName": "ดอกพิกุล Oil Ozone", + "scentName": "ดอกพิกุล", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 469, + "sku": "37-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 470, + "sku": "37-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 471, + "sku": "37-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 472, + "sku": "37-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 35, + "name": "หัวน้ำหอม กลิ่นดอกพิกุล", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 38, + "productId": 186, + "productName": "ดอกพุด Oil Ozone", + "scentName": "ดอกพุด", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 482, + "sku": "38-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 483, + "sku": "38-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 484, + "sku": "38-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 485, + "sku": "38-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 36, + "name": "หัวน้ำหอม กลิ่นดอกพุด", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 39, + "productId": 191, + "productName": "ดอกราตรี Oil Ozone", + "scentName": "ดอกราตรี", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 495, + "sku": "39-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 496, + "sku": "39-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 497, + "sku": "39-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 498, + "sku": "39-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 38, + "name": "หัวน้ำหอม กลิ่นดอกราตรี", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 40, + "productId": 196, + "productName": "ดอกลำดวน Oil Ozone", + "scentName": "ดอกลำดวน", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 508, + "sku": "40-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 509, + "sku": "40-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 510, + "sku": "40-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 511, + "sku": "40-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 39, + "name": "หัวน้ำหอม กลิ่นดอกลำดวน", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 41, + "productId": 201, + "productName": "ทับทิม Oil Ozone", + "scentName": "ทับทิม", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 980, + "sku": "41-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 981, + "sku": "41-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 982, + "sku": "41-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 983, + "sku": "41-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 42, + "name": "หัวน้ำหอม กลิ่นทับทิม", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 42, + "productId": 206, + "productName": "มะละกอสุก Oil Ozone", + "scentName": "มะละกอสุก", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 988, + "sku": "42-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 989, + "sku": "42-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 990, + "sku": "42-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 991, + "sku": "42-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 47, + "name": "หัวน้ำหอม กลิ่นมะละกอสุก", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 43, + "productId": 211, + "productName": "ดอกราชาวดี Oil Ozone", + "scentName": "ดอกราชาวดี", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 1004, + "sku": "43-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 1005, + "sku": "43-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 1006, + "sku": "43-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 1007, + "sku": "43-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 37, + "name": "หัวน้ำหอม กลิ่นดอกราชาวดี", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 44, + "productId": 216, + "productName": "กลิ่นลูกพีช Oil Ozone", + "scentName": "กลิ่นลูกพีช", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 972, + "sku": "44-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 973, + "sku": "44-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 974, + "sku": "44-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 975, + "sku": "44-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 52, + "name": "หัวน้ำหอม กลิ่นลูกพีช", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + }, + { + "index": 45, + "productId": 221, + "productName": "กลิ่นองุ่น Oil Ozone", + "scentName": "กลิ่นองุ่น", + "category": "น้ำมันหอมระเหยโอโซน", + "bomStatus": "CONFIGURED", + "configuredVariants": 4, + "totalVariants": 4, + "variants": [ + { + "id": 996, + "sku": "45-1-30ML", + "option": "30ml", + "price": 69, + "hasBom": true, + "totalBomCost": 15.34, + "marginPercent": 77.8 + }, + { + "id": 997, + "sku": "45-1-60ML", + "option": "60ml", + "price": 100, + "hasBom": true, + "totalBomCost": 26.68, + "marginPercent": 73.3 + }, + { + "id": 998, + "sku": "45-1-85ML", + "option": "85ml", + "price": 120, + "hasBom": true, + "totalBomCost": 36.13, + "marginPercent": 69.9 + }, + { + "id": 999, + "sku": "45-1-120ML", + "option": "120ml", + "price": 150, + "hasBom": true, + "totalBomCost": 49.36, + "marginPercent": 67.1 + } + ], + "matchedFragrance": { + "id": 56, + "name": "หัวน้ำหอม กลิ่นองุ่น", + "pricePerUnit": 3, + "unit": "ml" + }, + "packagingReady": true + } +] \ No newline at end of file diff --git a/scripts/apply_all_oil_ozone_bom.ts b/scripts/apply_all_oil_ozone_bom.ts new file mode 100644 index 0000000..d0e75c0 --- /dev/null +++ b/scripts/apply_all_oil_ozone_bom.ts @@ -0,0 +1,157 @@ +import * as fs from "fs"; +import * as path from "path"; + +const PROD_API = "https://api.homaroma.shop/api"; + +interface RawMaterialRef { + id: number; + name: string; + pricePerUnit: number; + unit: string; +} + +interface VariantPlan { + id: number; + sku: string; + option: string; + price: number; + hasBom: boolean; + totalBomCost: number; +} + +interface ProductPlan { + index: number; + productId: number; + productName: string; + scentName: string; + category: string | null; + bomStatus: string; + configuredVariants: number; + totalVariants: number; + variants: VariantPlan[]; + matchedFragrance: RawMaterialRef | null; + packagingReady: boolean; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const BOTTLE_MAP: Record = { + 30: 10, // ขวดพร้อมฝา Oilozone 30ml + 60: 11, // ขวดพร้อมฝา Oilozone 60ml + 85: 12, // ขวดพร้อมฝา Oilozone 85ml + 120: 9, // ขวดพร้อมฝา Oilozone 120ml +}; + +const FORMULA_MAP: Record = { + 30: { fragQty: 2.4, dpgQty: 27.6 }, + 60: { fragQty: 4.8, dpgQty: 55.2 }, + 85: { fragQty: 6.8, dpgQty: 78.2 }, + 120: { fragQty: 9.6, dpgQty: 110.4 }, +}; + +function detectSize(variant: VariantPlan): number { + const opt = (variant.option || "").toLowerCase(); + const sku = (variant.sku || "").toLowerCase(); + + if (opt.includes("120") || sku.includes("120ml")) return 120; + if (opt.includes("85") || sku.includes("85ml")) return 85; + if (opt.includes("60") || sku.includes("60ml")) return 60; + if (opt.includes("30") || sku.includes("30ml")) return 30; + + throw new Error(`Cannot detect size for variant ${variant.id} (${variant.sku} / ${variant.option})`); +} + +async function main() { + const jsonPath = path.resolve(process.cwd(), "oil_ozone_products.json"); + if (!fs.existsSync(jsonPath)) { + throw new Error(`File not found: ${jsonPath}`); + } + + const products: ProductPlan[] = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); + console.log(`Loaded ${products.length} products from ${jsonPath}`); + + let successCount = 0; + let skippedCount = 0; + let failedCount = 0; + + for (const product of products) { + console.log(`\n--------------------------------------------------`); + console.log(`[${product.index}/${products.length}] Product #${product.productId}: ${product.productName}`); + + if (!product.matchedFragrance) { + console.error(`❌ Skipped: No matching fragrance found for scent '${product.scentName}'`); + failedCount++; + continue; + } + + if (product.bomStatus === "CONFIGURED" && product.configuredVariants === product.totalVariants) { + console.log(`⚡ Already configured (${product.configuredVariants}/${product.totalVariants} variants). Skipping.`); + skippedCount++; + continue; + } + + // Build variants payload + const variantsPayload = product.variants.map((v) => { + const size = detectSize(v); + const formula = FORMULA_MAP[size]; + const bottleId = BOTTLE_MAP[size]; + + return { + variantId: v.id, + sku: v.sku, + bomItems: [ + { + rawMaterialId: product.matchedFragrance!.id, + name: product.matchedFragrance!.name, + quantity: formula.fragQty, + }, + { + rawMaterialId: 2, // DPG + name: "DPG", + quantity: formula.dpgQty, + }, + { + rawMaterialId: bottleId, + quantity: 1, + }, + ], + }; + }); + + console.log(`Applying BOM for ${variantsPayload.length} variants with fragrance [ID ${product.matchedFragrance.id}] ${product.matchedFragrance.name}...`); + + try { + const res = await fetch(`${PROD_API}/products/${product.productId}/bom`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ variants: variantsPayload }), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`HTTP ${res.status}: ${errText}`); + } + + const resData = (await res.json()) as any; + console.log(`✓ BOM saved successfully for #${product.productId}! Updated variants: ${resData.updatedVariants ?? variantsPayload.length}`); + successCount++; + } catch (err: any) { + console.error(`❌ Failed to update BOM for #${product.productId}: ${err.message}`); + failedCount++; + } + + // Rate-limit pause + await sleep(200); + } + + console.log(`\n==================================================`); + console.log(`BOM Application Summary:`); + console.log(` - Newly configured: ${successCount}`); + console.log(` - Already configured (skipped): ${skippedCount}`); + console.log(` - Failed: ${failedCount}`); + console.log(`==================================================\n`); +} + +main().catch(console.error); diff --git a/scripts/fetch_and_list_oil_ozone.ts b/scripts/fetch_and_list_oil_ozone.ts new file mode 100644 index 0000000..872cd90 --- /dev/null +++ b/scripts/fetch_and_list_oil_ozone.ts @@ -0,0 +1,169 @@ +import * as fs from "fs"; +import * as path from "path"; + +const PROD_API = "https://api.homaroma.shop/api"; + +interface RawMaterial { + id: number; + name: string; + unit: string; + pricePerUnit: number; +} + +interface Variant { + id: number; + sku: string; + options: string; + price: number; + hasBom: boolean; + bomItemCount: number; + totalBomCost: number; +} + +interface Product { + id: number; + name: string; + brand: string | null; + category: string | null; + totalVariants: number; + configuredVariants: number; + bomStatus: string; + variants: Variant[]; +} + +async function main() { + console.log("1. Fetching all products from production..."); + const statusRes = await fetch(`${PROD_API}/products/bom-status?limit=250`); + if (!statusRes.ok) throw new Error(`Failed to fetch products: ${statusRes.statusText}`); + const statusData = (await statusRes.json()) as { products: Product[] }; + + // Filter only Oil Ozone products + const allProducts = statusData.products || []; + const oilOzoneProducts = allProducts.filter( + (p) => + p.name.includes("Oil Ozone") || + (p.category && p.category.includes("โอโซน")) || + p.variants.some((v) => v.sku && v.sku.includes("-1-")) + ); + + console.log(`Found ${oilOzoneProducts.length} Oil Ozone products.`); + + // 2. Fetch all raw materials from production + console.log("2. Fetching available raw materials from production..."); + const rmRes = await fetch(`${PROD_API}/raw-materials`); + if (!rmRes.ok) throw new Error(`Failed to fetch raw materials: ${rmRes.statusText}`); + const rawMaterials = (await rmRes.json()) as RawMaterial[]; + console.log(`Found ${rawMaterials.length} raw materials on production.`); + + // Identify common raw materials + const dpg = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpg"); + const b30 = rawMaterials.find((r) => r.name.includes("Oilozone 30ml") || r.name.includes("ขวดพร้อมฝา Oilozone 30ml")); + const b60 = rawMaterials.find((r) => r.name.includes("Oilozone 60ml") || r.name.includes("ขวดพร้อมฝา Oilozone 60ml")); + const b85 = rawMaterials.find((r) => r.name.includes("Oilozone 85ml") || r.name.includes("ขวดพร้อมฝา Oilozone 85ml")); + const b120 = rawMaterials.find((r) => r.name.includes("Oilozone 120ml") || r.name.includes("ขวดพร้อมฝา Oilozone 120ml")); + + // Build match plan for each product + const productPlan = oilOzoneProducts.map((p, idx) => { + // Extract scent name e.g. "ซากุระ Oil Ozone" -> "ซากุระ" + const scentName = p.name.replace(/Oil Ozone/gi, "").trim(); + + // Look for matching fragrance oil in raw materials + const matchedFragrance = rawMaterials.find((rm) => { + const rmName = rm.name.trim(); + return ( + rmName === `หัวน้ำหอม กลิ่น${scentName}` || + rmName === `หัวน้ำหอม ${scentName}` || + rmName.includes(`กลิ่น${scentName}`) || + (rmName.includes(scentName) && rmName.includes("หัวน้ำหอม")) + ); + }); + + return { + index: idx + 1, + productId: p.id, + productName: p.name, + scentName, + category: p.category, + bomStatus: p.bomStatus, + configuredVariants: p.configuredVariants, + totalVariants: p.totalVariants, + variants: p.variants.map((v) => ({ + id: v.id, + sku: v.sku, + option: v.options, + price: v.price, + hasBom: v.hasBom, + totalBomCost: v.totalBomCost, + marginPercent: v.price > 0 && v.totalBomCost > 0 + ? Number((((v.price - v.totalBomCost) / v.price) * 100).toFixed(1)) + : null, + })), + matchedFragrance: matchedFragrance + ? { + id: matchedFragrance.id, + name: matchedFragrance.name, + pricePerUnit: matchedFragrance.pricePerUnit, + unit: matchedFragrance.unit, + } + : null, + packagingReady: !!(dpg && b30 && b60 && b85 && b120), + }; + }); + + // 3. Save JSON to local root folder + const jsonPath = path.resolve(process.cwd(), "oil_ozone_products.json"); + fs.writeFileSync(jsonPath, JSON.stringify(productPlan, null, 2), "utf-8"); + console.log(`\n✓ Saved JSON data to: ${jsonPath}`); + + // 4. Generate Markdown Ledger + let md = `# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)\n\n`; + md += `> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)\n`; + md += `> **อัปเดตล่าสุด:** ${new Date().toLocaleString("th-TH")}\n`; + md += `> **จำนวนสินค้าทั้งหมด:** ${productPlan.length} รายการ\n\n`; + + md += `## 📦 สรุปสถานะภาพรวม\n`; + const configured = productPlan.filter((p) => p.bomStatus === "CONFIGURED").length; + const missing = productPlan.filter((p) => p.bomStatus === "MISSING").length; + const partial = productPlan.filter((p) => p.bomStatus === "PARTIAL").length; + const fragranceFound = productPlan.filter((p) => p.matchedFragrance !== null).length; + + md += `- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** ${configured} / ${productPlan.length} รายการ (100%)\n`; + md += `- **ยังไม่ได้ตั้งค่า (MISSING):** ${missing} รายการ\n`; + md += `- **ตั้งค่าบางส่วน (PARTIAL):** ${partial} รายการ\n`; + md += `- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** ${fragranceFound} / ${productPlan.length} รายการ\n\n`; + + md += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)\n\n`; + md += `| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |\n`; + md += `| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |\n`; + + for (const p of productPlan) { + const statusBadge = + p.bomStatus === "CONFIGURED" + ? "✅ พร้อมขาย" + : p.bomStatus === "PARTIAL" + ? "⚠️ บางส่วน" + : "❌ ขาด BOM"; + + const fragText = p.matchedFragrance + ? `[ID: ${p.matchedFragrance.id}] ${p.matchedFragrance.name} (฿${p.matchedFragrance.pricePerUnit})` + : `⚠️ ไม่พบหัวน้ำหอม`; + + const v30 = p.variants.find((v) => (v.option || "").includes("30") || (v.sku || "").includes("30ML")); + const v60 = p.variants.find((v) => (v.option || "").includes("60") || (v.sku || "").includes("60ML")); + const v85 = p.variants.find((v) => (v.option || "").includes("85") || (v.sku || "").includes("85ML")); + const v120 = p.variants.find((v) => (v.option || "").includes("120") || (v.sku || "").includes("120ML")); + + const formatCost = (v?: (typeof p.variants)[0]) => { + if (!v || !v.hasBom) return "-"; + return `฿${v.totalBomCost.toFixed(2)} (${v.marginPercent}%)`; + }; + + md += `| ${p.index} | **#${p.productId}** | [${p.productName}](https://admin.homaroma.shop/products/${p.productId}/bom) | ${fragText} | ${formatCost(v30)} | ${formatCost(v60)} | ${formatCost(v85)} | ${formatCost(v120)} | ${statusBadge} |\n`; + } + + const mdPath = path.resolve(process.cwd(), "OIL_OZONE_BOM_LEDGER.md"); + fs.writeFileSync(mdPath, md, "utf-8"); + console.log(`✓ Saved Markdown ledger to: ${mdPath}`); +} + +main().catch(console.error); diff --git a/scripts/match_and_map_tiktok_products.ts b/scripts/match_and_map_tiktok_products.ts new file mode 100644 index 0000000..1ae94ed --- /dev/null +++ b/scripts/match_and_map_tiktok_products.ts @@ -0,0 +1,299 @@ +import * as fs from "fs"; +import * as path from "path"; + +const PROD_API = "https://api.homaroma.shop/api"; + +interface MasterVariant { + id: number; + sku: string; + barcode: string; + price: number; + stock: number; + optionsStr: string; + productName: string; + categoryName: string; + brandName: string; + bomCost: number; + displayName: string; +} + +interface MarketplaceProduct { + id: number; + channel: string; + shop: string; + marketplaceSku: string; + productName: string; + variation: string; + avgPrice: number; + totalSoldQty: number; + totalRevenue: number; + firstSoldAt: string; + lastSoldAt: string; + masterVariantId: number | null; +} + +// Scents standard dictionary & aliases +const SCENT_ALIASES: Record = { + "เปปเปอร์มินต์": "เปเปอร์มิ้น", + "เปปเปอร์มิ้นท์": "เปเปอร์มิ้น", + "เปปเปอร์มิ้น": "เปเปอร์มิ้น", + "เปเปอร์มินต์": "เปเปอร์มิ้น", + "ตะไคร้บ้าน": "ตะไคร้", + "เฟรช ลาเวนเดอร์": "เฟรชลาเวนเดอร์", + "เฟรชลาเวนเดอร์": "เฟรชลาเวนเดอร์", + "แซนดัลวูด": "แซนด้าวูส", + "แซนด้าวูด": "แซนด้าวูส", + "แซนดัลวู้ด": "แซนด้าวูส", + "กุหลาบ": "กุหลาบแดง", + "มะลิซ้อน": "มะลิ", + "ลูกพีช": "กลิ่นลูกพีช", + "พีช": "กลิ่นลูกพีช", + "องุ่น": "กลิ่นองุ่น", + "น้ำนม": "กลิ่นน้ำนม", + "นม": "กลิ่นน้ำนม", + "มหาสมุทร": "โอเชี่ยน", + "ราตรี": "ดอกราตรี", + "การเวก": "ดอกการะเวก", + "ดอกอัญชัน": "อัญชัน", + "ดอกบัวหลวง": "ดอกบัว", + "ไม้หอม": "แซนด้าวูส", +}; + +// Size extractor helper +function extractSize(variation: string, productName: string): string | null { + const v = (variation || "").toLowerCase(); + const p = (productName || "").toLowerCase(); + + // Variation has highest priority for size + if (v.includes("120") || v.includes("120ml")) return "120ml"; + if (v.includes("85") || v.includes("85ml")) return "85ml"; + if (v.includes("60") || v.includes("60ml")) return "60ml"; + if (v.includes("30") || v.includes("30ml")) return "30ml"; + + // Fallback to product name + if (p.includes("120") || p.includes("120ml") || p.includes("ขวดใหญ่")) return "120ml"; + if (p.includes("85") || p.includes("85ml")) return "85ml"; + if (p.includes("60") || p.includes("60ml")) return "60ml"; + if (p.includes("30") || p.includes("30ml") || p.includes("ขวดเล็ก")) return "30ml"; + + return null; +} + +// Scent extractor helper +function extractScent(variation: string, productName: string, masterScents: string[]): string | null { + const cleanVar = (variation || "") + .replace(/\d+\s*ml/gi, "") + .replace(/,/g, "") + .replace(/^กลิ่น\s*/, "") + .trim(); + + // 1. Check variation aliases first + for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) { + if (cleanVar.includes(alias) || cleanVar === alias) { + return canonical; + } + } + + // 2. Check direct master scents in variation + for (const scent of masterScents) { + if (cleanVar.includes(scent) || cleanVar === scent) { + return scent; + } + } + + // 3. Fallback to product name aliases + for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) { + if (productName.includes(alias)) { + return canonical; + } + } + + // 4. Fallback to direct master scents in product name + for (const scent of masterScents) { + if (productName.includes(`กลิ่น ${scent}`) || productName.includes(`กลิ่น${scent}`) || productName.includes(scent)) { + return scent; + } + } + + return null; +} + +async function main() { + console.log("1. Fetching master variants from production..."); + const mvRes = await fetch(`${PROD_API}/tools/marketplace-products/master-variants`); + const mvData = (await mvRes.json()) as { variants: MasterVariant[] }; + const masterVariants = mvData.variants || []; + console.log(`Loaded ${masterVariants.length} master variants.`); + + // Filter only Oil Ozone master variants + const ozoneVariants = masterVariants.filter( + (v) => (v.categoryName && v.categoryName.includes("โอโซน")) || v.productName.includes("Oil Ozone") + ); + console.log(`Found ${ozoneVariants.length} Oil Ozone master variants.`); + + // Collect distinct scents + const masterScents = Array.from( + new Set(ozoneVariants.map((v) => v.productName.replace(/Oil Ozone/gi, "").trim())) + ).filter(Boolean); + console.log(`Detected ${masterScents.length} distinct Oil Ozone scents.`); + + // 2. Fetch unmapped TikTok products + console.log("\n2. Fetching unmapped TikTok products from production..."); + const mpRes = await fetch(`${PROD_API}/tools/marketplace-products?channel=tiktok&status=unmapped&limit=350`); + const mpData = (await mpRes.json()) as { items: MarketplaceProduct[]; pagination: any }; + const unmappedItems = mpData.items || []; + console.log(`Found ${unmappedItems.length} unmapped TikTok items (total in DB: ${mpData.pagination?.total}).`); + + // 3. Test matching + const matches: Array<{ + item: MarketplaceProduct; + matchedVariant: MasterVariant; + scent: string; + size: string; + confidence: "HIGH" | "MEDIUM"; + }> = []; + + const unmatchable: MarketplaceProduct[] = []; + + for (const item of unmappedItems) { + const text = `${item.productName} ${item.variation || ""}`; + const isOilOzone = + text.includes("โอโซน") || + text.includes("oil ozone") || + text.includes("ออยโอโซน") || + text.includes("น้ำมันหอมระเหย") || + text.includes("น้ำหอมอโรม่า") || + text.includes("น้ำมันอโรม่า") || + text.includes("น้ำมันหอมไอน้ำ") || + item.marketplaceSku.includes("OZO") || + item.marketplaceSku.includes("OIL_") || + item.marketplaceSku === "30-ALL"; + + if (!isOilOzone) { + unmatchable.push(item); + continue; + } + + const size = extractSize(item.variation, item.productName); + const scent = extractScent(item.variation, item.productName, masterScents); + + if (scent && size) { + // Find matching variant + const matched = ozoneVariants.find((v) => { + const vScent = v.productName.replace(/Oil Ozone/gi, "").trim(); + const vSize = (v.optionsStr || "").toLowerCase(); + return ( + vScent === scent && + (vSize.includes(size.toLowerCase()) || v.sku.toLowerCase().includes(size.toLowerCase())) + ); + }); + + if (matched) { + matches.push({ + item, + matchedVariant: matched, + scent, + size, + confidence: "HIGH", + }); + } else { + unmatchable.push(item); + } + } else { + unmatchable.push(item); + } + } + + console.log(`\n==================================================`); + console.log(`Matching Simulation Results:`); + console.log(` - Successfully matched: ${matches.length} items (${((matches.length / unmappedItems.length) * 100).toFixed(1)}%)`); + console.log(` - Unmatched / Non-ozone: ${unmatchable.length} items`); + console.log(`==================================================\n`); + + console.log("Sample 15 matches:"); + matches.slice(0, 15).forEach((m, idx) => { + console.log( + `[${idx + 1}] MP #${m.item.id} (${m.item.marketplaceSku || 'NO_SKU'}) | '${m.item.productName.slice(0, 35)}...' | Var: '${m.item.variation}'` + ); + console.log( + ` -> MATCHED: [Variant #${m.matchedVariant.id} | SKU: ${m.matchedVariant.sku}] ${m.matchedVariant.displayName} (BOM: ฿${m.matchedVariant.bomCost.toFixed(2)})` + ); + }); + + // Save report to scratch file + const reportPath = path.resolve(process.cwd(), "scratch_tiktok_matches.json"); + fs.writeFileSync( + reportPath, + JSON.stringify( + { + matchedCount: matches.length, + unmatchedCount: unmatchable.length, + matches: matches.map((m) => ({ + marketplaceProductId: m.item.id, + marketplaceSku: m.item.marketplaceSku, + marketplaceName: m.item.productName, + marketplaceVariation: m.item.variation, + matchedVariantId: m.matchedVariant.id, + matchedVariantSku: m.matchedVariant.sku, + matchedVariantName: m.matchedVariant.displayName, + unitBomCost: m.matchedVariant.bomCost, + soldQty: m.item.totalSoldQty, + revenue: m.item.totalRevenue, + })), + unmatched: unmatchable.map((u) => ({ + id: u.id, + sku: u.marketplaceSku, + name: u.productName, + variation: u.variation, + revenue: u.totalRevenue, + })), + }, + null, + 2 + ), + "utf-8" + ); + console.log(`\nSaved match report to: ${reportPath}`); + + // 4. Apply mappings if --apply flag is passed + const shouldApply = process.argv.includes("--apply"); + if (!shouldApply) { + console.log(`\n💡 To apply all ${matches.length} mappings to production, run:`); + console.log(` bun scripts/match_and_map_tiktok_products.ts --apply\n`); + return; + } + + console.log(`\n🚀 Applying ${matches.length} mappings to production...`); + let applied = 0; + let failed = 0; + + const BATCH = 15; + for (let i = 0; i < matches.length; i += BATCH) { + const chunk = matches.slice(i, i + BATCH); + await Promise.all( + chunk.map(async (m) => { + try { + const res = await fetch(`${PROD_API}/tools/marketplace-products/map`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + marketplaceProductId: m.item.id, + masterVariantId: m.matchedVariant.id, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + applied++; + } catch (err: any) { + console.error(`Failed to map MP #${m.item.id}:`, err.message); + failed++; + } + }) + ); + process.stdout.write(`\rProgress: ${applied}/${matches.length} applied (${failed} failed)...`); + await new Promise((r) => setTimeout(r, 100)); + } + + console.log(`\n\n🎉 Finished! Successfully mapped: ${applied} | Failed: ${failed}`); +} + +main().catch(console.error);