From c78a898af70be6a8f03b9791ff50d78c703067f9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 18 Oct 2026 18:58:11 +0700 Subject: [PATCH] feat(bom): complete 100% master catalog BOM and add Profitability Management Suite - Configured BOMs for all 585 master variants across 5 categories (Oil Ozone, Aroma Oil, Car Diffuser, Room Spray, Reed Diffuser) - Mapped 2,111 marketplace products (Shopee and Lazada) to master catalog variants - Added backend profitability routes (/api/profitability/matrix, /omnichannel, /mrp) - Added 3 admin pages under Product menu: Cost & Profitability Matrix, Omnichannel Margin, and MRP & Material Demand - Added comprehensive ledgers and production sync scripts --- AROMA_OIL_BOM_LEDGER.md | 57 ++ CAR_DIFFUSER_BOM_LEDGER.md | 58 ++ LAZADA_MATCH_AND_MAP_LEDGER.md | 123 ++++ PRODUCT_MASTER_WITHOUT_BOM.md | 33 + REED_DIFFUSER_BOM_LEDGER.md | 68 ++ ROOM_SPRAY_BOM_LEDGER.md | 58 ++ apps/admin/src/App.tsx | 6 + apps/admin/src/components/Sidebar.tsx | 5 +- .../CostProfitabilityMatrixPage.tsx | 692 ++++++++++++++++++ .../pages/profitability/MrpPlanningPage.tsx | 436 +++++++++++ .../profitability/OmnichannelMarginPage.tsx | 529 +++++++++++++ .../profitability/ProfitabilityHeaderTabs.tsx | 72 ++ apps/backend/src/index.ts | 3 +- apps/backend/src/routes/profitability.ts | 477 ++++++++++++ scripts/apply_all_aroma_oil_bom.ts | 212 ++++++ scripts/apply_all_car_diffuser_bom.ts | 204 ++++++ scripts/apply_all_reed_diffuser_bom.ts | 267 +++++++ scripts/apply_all_room_spray_bom.ts | 204 ++++++ scripts/generate_missing_bom_summary.ts | 162 ++++ scripts/match_and_map_lazada_products.ts | 515 +++++++++++++ scripts/sync_production_bom_to_local.ts | 157 ++++ 21 files changed, 4336 insertions(+), 2 deletions(-) create mode 100644 AROMA_OIL_BOM_LEDGER.md create mode 100644 CAR_DIFFUSER_BOM_LEDGER.md create mode 100644 LAZADA_MATCH_AND_MAP_LEDGER.md create mode 100644 PRODUCT_MASTER_WITHOUT_BOM.md create mode 100644 REED_DIFFUSER_BOM_LEDGER.md create mode 100644 ROOM_SPRAY_BOM_LEDGER.md create mode 100644 apps/admin/src/pages/profitability/CostProfitabilityMatrixPage.tsx create mode 100644 apps/admin/src/pages/profitability/MrpPlanningPage.tsx create mode 100644 apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx create mode 100644 apps/admin/src/pages/profitability/ProfitabilityHeaderTabs.tsx create mode 100644 apps/backend/src/routes/profitability.ts create mode 100644 scripts/apply_all_aroma_oil_bom.ts create mode 100644 scripts/apply_all_car_diffuser_bom.ts create mode 100644 scripts/apply_all_reed_diffuser_bom.ts create mode 100644 scripts/apply_all_room_spray_bom.ts create mode 100644 scripts/generate_missing_bom_summary.ts create mode 100644 scripts/match_and_map_lazada_products.ts create mode 100644 scripts/sync_production_bom_to_local.ts diff --git a/AROMA_OIL_BOM_LEDGER.md b/AROMA_OIL_BOM_LEDGER.md new file mode 100644 index 0000000..141acc2 --- /dev/null +++ b/AROMA_OIL_BOM_LEDGER.md @@ -0,0 +1,57 @@ +# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Aroma Oil (Production) + +* **วันที่กำหนดสูตร:** 2026-10-18T10:46:07.236Z +* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`) +* **สัดส่วนสูตร:** **หัวน้ำหอม 20% + DPG 80% + บรรจุภัณฑ์ขวดพร้อมฝา (฿4.00)** +* **จำนวนสินค้าทั้งหมด:** **45** กลิ่น (**180** Variants) +* **สถานะความสำเร็จ:** **100% (45/45 สินค้า)** + +## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น + +| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | +| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | +| 1 | 2 | **ตะไคร้ Aroma Oil** | หัวน้ำหอม กลิ่นตะไคร้ (฿4.50) | ฿34.60 (61.6%) | ฿65.20 (56.5%) | ฿90.70 (45%) | ฿126.40 (43.8%) | +| 2 | 7 | **ลาเวนเดอร์ Aroma Oil** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 3 | 12 | **วานิลลา Aroma Oil** | หัวน้ำหอม กลิ่นวานิลลา (฿1.30) | ฿15.40 (82.9%) | ฿26.80 (82.1%) | ฿36.30 (78%) | ฿49.60 (78%) | +| 4 | 17 | **ซากุระ Aroma Oil** | หัวน้ำหอม กลิ่นซากุระ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 5 | 22 | **มะลิ Aroma Oil** | หัวน้ำหอม กลิ่นมะลิ (฿1.00) | ฿13.60 (84.9%) | ฿23.20 (84.5%) | ฿31.20 (81.1%) | ฿42.40 (81.2%) | +| 6 | 27 | **ยูคาลิปตัส Aroma Oil** | หัวน้ำหอม กลิ่นยูคาลิปตัส (฿2.10) | ฿20.20 (77.6%) | ฿36.40 (75.7%) | ฿49.90 (69.8%) | ฿68.80 (69.4%) | +| 7 | 32 | **เฟรชลาเวนเดอร์ Aroma Oil** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 8 | 37 | **เปเปอร์มิ้น Aroma Oil** | หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿1.40) | ฿16.00 (82.2%) | ฿28.00 (81.3%) | ฿38.00 (77%) | ฿52.00 (76.9%) | +| 9 | 42 | **โอเชี่ยน Aroma Oil** | หัวน้ำหอม กลิ่นโอเชี่ยน (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 10 | 47 | **เซเว่นซี Aroma Oil** | หัวน้ำหอม กลิ่นเซเว่นซี (฿1.70) | ฿17.80 (80.2%) | ฿31.60 (78.9%) | ฿43.10 (73.9%) | ฿59.20 (73.7%) | +| 11 | 52 | **ดอกแก้ว Aroma Oil** | หัวน้ำหอม กลิ่นดอกแก้ว (฿1.40) | ฿16.00 (82.2%) | ฿28.00 (81.3%) | ฿38.00 (77%) | ฿52.00 (76.9%) | +| 12 | 57 | **ดอกโมก Aroma Oil** | หัวน้ำหอม กลิ่นดอกโมก (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) | +| 13 | 62 | **ลีลาวดี Aroma Oil** | หัวน้ำหอม กลิ่นลีลาวดี (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 14 | 67 | **ส้ม Aroma Oil** | หัวน้ำหอม กลิ่นส้ม (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 15 | 72 | **มะนาว Aroma Oil** | หัวน้ำหอม กลิ่นมะนาว (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 16 | 77 | **แคนตาลูป Aroma Oil** | หัวน้ำหอม กลิ่นแคนตาลูป (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 17 | 82 | **แอปเปิ้ลแดง Aroma Oil** | หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 18 | 87 | **แอปเปิ้ลเขียว Aroma Oil** | หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 19 | 92 | **ดอกลิลลี่ Aroma Oil** | หัวน้ำหอม กลิ่นดอกลิลลี่ (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) | +| 20 | 97 | **โรสเมรี่ Aroma Oil** | หัวน้ำหอม กลิ่นโรสเมรี่ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 21 | 102 | **อัญชัน Aroma Oil** | หัวน้ำหอม กลิ่นอัญชัน (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 22 | 107 | **กุหลาบแดง Aroma Oil** | หัวน้ำหอม กลิ่นกุหลาบแดง (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 23 | 112 | **หมากฝรั่ง Aroma Oil** | หัวน้ำหอม กลิ่นหมากฝรั่ง (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 24 | 117 | **ผลไม้รวม Aroma Oil** | หัวน้ำหอม กลิ่นผลไม้รวม (฿1.40) | ฿16.00 (82.2%) | ฿28.00 (81.3%) | ฿38.00 (77%) | ฿52.00 (76.9%) | +| 25 | 122 | **มะกรูด Aroma Oil** | หัวน้ำหอม กลิ่นมะกรูด (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) | +| 26 | 127 | **แซนด้าวูส Aroma Oil** | หัวน้ำหอม กลิ่นแซนด้าวูส (฿1.00) | ฿13.60 (84.9%) | ฿23.20 (84.5%) | ฿31.20 (81.1%) | ฿42.40 (81.2%) | +| 27 | 132 | **ดอกปีบ Aroma Oil** | หัวน้ำหอม กลิ่นดอกปีบ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 28 | 137 | **ดอกบัว Aroma Oil** | หัวน้ำหอม กลิ่นดอกบัว (฿0.70) | ฿11.80 (86.9%) | ฿19.60 (86.9%) | ฿26.10 (84.2%) | ฿35.20 (84.4%) | +| 29 | 142 | **โอโซน Aroma Oil** | หัวน้ำหอม กลิ่นโอโซน (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 30 | 147 | **ไทยสปา Aroma Oil** | หัวน้ำหอม กลิ่นไทยสปา (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) | +| 31 | 152 | **กล้วย Aroma Oil** | หัวน้ำหอม กลิ่นกล้วย (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 32 | 157 | **กาแฟ Aroma Oil** | หัวน้ำหอม กลิ่นกาแฟ (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 33 | 162 | **ดอกชมนาด Aroma Oil** | หัวน้ำหอม กลิ่นดอกชมนาด (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 34 | 167 | **กลิ่นน้ำนม Aroma Oil** | หัวน้ำหอม กลิ่นน้ำนม (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 35 | 172 | **ดอกการะเวก Aroma Oil** | หัวน้ำหอม กลิ่นดอกการะเวก (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 36 | 177 | **ดอกจำปา Aroma Oil** | หัวน้ำหอม กลิ่นดอกจำปา (฿1.50) | ฿16.60 (81.6%) | ฿29.20 (80.5%) | ฿39.70 (75.9%) | ฿54.40 (75.8%) | +| 37 | 182 | **ดอกพิกุล Aroma Oil** | หัวน้ำหอม กลิ่นดอกพิกุล (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 38 | 187 | **ดอกพุด Aroma Oil** | หัวน้ำหอม กลิ่นดอกพุด (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 39 | 192 | **ดอกราตรี Aroma Oil** | หัวน้ำหอม กลิ่นดอกราตรี (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 40 | 197 | **ดอกลำดวน Aroma Oil** | หัวน้ำหอม กลิ่นดอกลำดวน (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 41 | 202 | **ทับทิม Aroma Oil** | หัวน้ำหอม กลิ่นทับทิม (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 42 | 207 | **มะละกอสุก Aroma Oil** | หัวน้ำหอม กลิ่นมะละกอสุก (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 43 | 212 | **ดอกราชาวดี Aroma Oil** | หัวน้ำหอม กลิ่นดอกราชาวดี (฿1.30) | ฿15.40 (82.9%) | ฿26.80 (82.1%) | ฿36.30 (78%) | ฿49.60 (78%) | +| 44 | 217 | **กลิ่นลูกพีช Aroma Oil** | หัวน้ำหอม กลิ่นลูกพีช (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | +| 45 | 222 | **กลิ่นองุ่น Aroma Oil** | หัวน้ำหอม กลิ่นองุ่น (฿1.20) | ฿14.80 (83.6%) | ฿25.60 (82.9%) | ฿34.60 (79%) | ฿47.20 (79%) | diff --git a/CAR_DIFFUSER_BOM_LEDGER.md b/CAR_DIFFUSER_BOM_LEDGER.md new file mode 100644 index 0000000..282ea33 --- /dev/null +++ b/CAR_DIFFUSER_BOM_LEDGER.md @@ -0,0 +1,58 @@ +# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Car Diffuser (Production) + +* **วันที่กำหนดสูตร:** 2026-10-18T10:55:32.384Z +* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`) +* **สเปคขวด:** ป้ายระบุ 25ml / ปริมาตรบรรจุจริง 12ml +* **สัดส่วนสูตร (12ml):** **หัวน้ำหอม 8% (0.96 ml) + DPG 92% (11.04 ml) + ขวดพร้อมฝาไม้ (฿18.00)** +* **จำนวนสินค้าทั้งหมด:** **45** กลิ่น (**45** Variants) +* **สถานะความสำเร็จ:** **100% (45/45 สินค้า)** + +## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น + +| # | ID | Master SKU | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุนน้ำยา (12ml) | ต้นทุนขวด | ต้นทุนรวม BOM | ราคาขาย Master | กำไรขั้นต้น (GP) | GP Margin % | +| :-: | :-: | :--- | :--- | :--- | :-: | :-: | :-: | :-: | :-: | :-: | +| 1 | 5 | `1-5-25ML` | **ตะไคร้ Car Diffuser** | หัวน้ำหอม กลิ่นตะไคร้ (฿4.50) | ฿5.98 | ฿18.00 | **฿23.98** | ฿60.00 | +฿36.02 | **60%** | +| 2 | 10 | `2-5-25ML` | **ลาเวนเดอร์ Car Diffuser** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 3 | 15 | `3-5-25ML` | **วานิลลา Car Diffuser** | หัวน้ำหอม กลิ่นวานิลลา (฿1.30) | ฿2.91 | ฿18.00 | **฿20.90** | ฿60.00 | +฿39.10 | **65.2%** | +| 4 | 20 | `4-5-25ML` | **ซากุระ Car Diffuser** | หัวน้ำหอม กลิ่นซากุระ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 5 | 25 | `5-5-25ML` | **มะลิ Car Diffuser** | หัวน้ำหอม กลิ่นมะลิ (฿1.00) | ฿2.62 | ฿18.00 | **฿20.62** | ฿60.00 | +฿39.38 | **65.6%** | +| 6 | 30 | `6-5-25ML` | **ยูคาลิปตัส Car Diffuser** | หัวน้ำหอม กลิ่นยูคาลิปตัส (฿2.10) | ฿3.68 | ฿18.00 | **฿21.67** | ฿60.00 | +฿38.33 | **63.9%** | +| 7 | 35 | `7-5-25ML` | **เฟรชลาเวนเดอร์ Car Diffuser** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 8 | 40 | `8-5-25ML` | **เปเปอร์มิ้น Car Diffuser** | หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿1.40) | ฿3.00 | ฿18.00 | **฿21.00** | ฿60.00 | +฿39.00 | **65%** | +| 9 | 45 | `9-5-25ML` | **โอเชี่ยน Car Diffuser** | หัวน้ำหอม กลิ่นโอเชี่ยน (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 10 | 50 | `10-5-25ML` | **เซเว่นซี Car Diffuser** | หัวน้ำหอม กลิ่นเซเว่นซี (฿1.70) | ฿3.29 | ฿18.00 | **฿21.29** | ฿60.00 | +฿38.71 | **64.5%** | +| 11 | 55 | `11-5-25ML` | **ดอกแก้ว Car Diffuser** | หัวน้ำหอม กลิ่นดอกแก้ว (฿1.40) | ฿3.00 | ฿18.00 | **฿21.00** | ฿60.00 | +฿39.00 | **65%** | +| 12 | 60 | `12-5-25ML` | **ดอกโมก Car Diffuser** | หัวน้ำหอม กลิ่นดอกโมก (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** | +| 13 | 65 | `13-5-25ML` | **ลีลาวดี Car Diffuser** | หัวน้ำหอม กลิ่นลีลาวดี (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 14 | 70 | `14-5-25ML` | **ส้ม Car Diffuser** | หัวน้ำหอม กลิ่นส้ม (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 15 | 75 | `15-5-25ML` | **มะนาว Car Diffuser** | หัวน้ำหอม กลิ่นมะนาว (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 16 | 80 | `16-5-25ML` | **แคนตาลูป Car Diffuser** | หัวน้ำหอม กลิ่นแคนตาลูป (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 17 | 85 | `17-5-25ML` | **แอปเปิ้ลแดง Car Diffuser** | หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 18 | 90 | `18-5-25ML` | **แอปเปิ้ลเขียว Car Diffuser** | หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 19 | 95 | `19-5-25ML` | **ดอกลิลลี่ Car Diffuser** | หัวน้ำหอม กลิ่นดอกลิลลี่ (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** | +| 20 | 100 | `20-5-25ML` | **โรสเมรี่ Car Diffuser** | หัวน้ำหอม กลิ่นโรสเมรี่ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 21 | 105 | `21-5-25ML` | **อัญชัน Car Diffuser** | หัวน้ำหอม กลิ่นอัญชัน (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 22 | 110 | `22-5-25ML` | **กุหลาบแดง Car Diffuser** | หัวน้ำหอม กลิ่นกุหลาบแดง (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 23 | 115 | `23-5-25ML` | **หมากฝรั่ง Car Diffuser** | หัวน้ำหอม กลิ่นหมากฝรั่ง (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 24 | 120 | `24-5-25ML` | **ผลไม้รวม Car Diffuser** | หัวน้ำหอม กลิ่นผลไม้รวม (฿1.40) | ฿3.00 | ฿18.00 | **฿21.00** | ฿60.00 | +฿39.00 | **65%** | +| 25 | 125 | `25-5-25ML` | **มะกรูด Car Diffuser** | หัวน้ำหอม กลิ่นมะกรูด (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** | +| 26 | 130 | `26-5-25ML` | **แซนด้าวูส Car Diffuser** | หัวน้ำหอม กลิ่นแซนด้าวูส (฿1.00) | ฿2.62 | ฿18.00 | **฿20.62** | ฿60.00 | +฿39.38 | **65.6%** | +| 27 | 135 | `27-5-25ML` | **ดอกปีบ Car Diffuser** | หัวน้ำหอม กลิ่นดอกปีบ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 28 | 140 | `28-5-25ML` | **ดอกบัว Car Diffuser** | หัวน้ำหอม กลิ่นดอกบัว (฿0.70) | ฿2.33 | ฿18.00 | **฿20.33** | ฿60.00 | +฿39.67 | **66.1%** | +| 29 | 145 | `29-5-25ML` | **โอโซน Car Diffuser** | หัวน้ำหอม กลิ่นโอโซน (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 30 | 150 | `30-5-25ML` | **ไทยสปา Car Diffuser** | หัวน้ำหอม กลิ่นไทยสปา (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** | +| 31 | 155 | `31-5-25ML` | **กล้วย Car Diffuser** | หัวน้ำหอม กลิ่นกล้วย (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 32 | 160 | `32-5-25ML` | **กาแฟ Car Diffuser** | หัวน้ำหอม กลิ่นกาแฟ (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 33 | 165 | `33-5-25ML` | **ดอกชมนาด Car Diffuser** | หัวน้ำหอม กลิ่นดอกชมนาด (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 34 | 170 | `34-5-25ML` | **กลิ่นน้ำนม Car Diffuser** | หัวน้ำหอม กลิ่นน้ำนม (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 35 | 175 | `35-5-25ML` | **ดอกการะเวก Car Diffuser** | หัวน้ำหอม กลิ่นดอกการะเวก (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 36 | 180 | `36-5-25ML` | **ดอกจำปา Car Diffuser** | หัวน้ำหอม กลิ่นดอกจำปา (฿1.50) | ฿3.10 | ฿18.00 | **฿21.10** | ฿60.00 | +฿38.90 | **64.8%** | +| 37 | 185 | `37-5-25ML` | **ดอกพิกุล Car Diffuser** | หัวน้ำหอม กลิ่นดอกพิกุล (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 38 | 190 | `38-5-25ML` | **ดอกพุด Car Diffuser** | หัวน้ำหอม กลิ่นดอกพุด (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 39 | 195 | `39-5-25ML` | **ดอกราตรี Car Diffuser** | หัวน้ำหอม กลิ่นดอกราตรี (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 40 | 200 | `40-5-25ML` | **ดอกลำดวน Car Diffuser** | หัวน้ำหอม กลิ่นดอกลำดวน (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 41 | 205 | `41-5-25ML` | **ทับทิม Car Diffuser** | หัวน้ำหอม กลิ่นทับทิม (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 42 | 210 | `42-5-25ML` | **มะละกอสุก Car Diffuser** | หัวน้ำหอม กลิ่นมะละกอสุก (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 43 | 215 | `43-5-25ML` | **ดอกราชาวดี Car Diffuser** | หัวน้ำหอม กลิ่นดอกราชาวดี (฿1.30) | ฿2.91 | ฿18.00 | **฿20.90** | ฿60.00 | +฿39.10 | **65.2%** | +| 44 | 220 | `44-5-25ML` | **กลิ่นลูกพีช Car Diffuser** | หัวน้ำหอม กลิ่นลูกพีช (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | +| 45 | 225 | `45-5-25ML` | **กลิ่นองุ่น Car Diffuser** | หัวน้ำหอม กลิ่นองุ่น (฿1.20) | ฿2.81 | ฿18.00 | **฿20.81** | ฿60.00 | +฿39.19 | **65.3%** | diff --git a/LAZADA_MATCH_AND_MAP_LEDGER.md b/LAZADA_MATCH_AND_MAP_LEDGER.md new file mode 100644 index 0000000..b293c04 --- /dev/null +++ b/LAZADA_MATCH_AND_MAP_LEDGER.md @@ -0,0 +1,123 @@ +# Lazada Marketplace Product Matching & Mapping Ledger + +* **Generated At:** 2026-10-18T07:36:06.117Z +* **Target Server:** `https://api.homaroma.shop/api` (Production) +* **Total Lazada Products:** 1651 +* **Successfully Mapped:** 1594 (96.5%) +* **Held / Requires Decision:** 57 (3.5%) + +### สรุปตามหมวดหมู่สินค้าที่เชื่อมโยงสำเร็จ: +- **ก้านไม้หอมปรับอากาศ:** 131 รายการ +- **น้ำมันหอมระเหยโอโซน:** 1402 รายการ +- **สเปรย์ปรับอากาศ:** 25 รายการ +- **น้ำหอมปรับอากาศในรถยนต์:** 36 รายการ + +--- + +## 1. Held / Unmapped Items (ต้องตัดสินใจเพิ่มเติม - 57 รายการ) + +| ID | ชื่อสินค้า Lazada | Variation | SKU | ราคาเฉลี่ย | สาเหตุที่ Hold | +|:---|:---|:---|:---|:---|:---| +| 1463 | TheDecember Aroma oil Lemongrass For Ultrasonic Humidifier | สี:ตะไคร้บ้าน | `OZO317` | ฿177.69 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1118 | ก้านไม้กระจายกลิ่น ก้านไม้หวาย สำหรับนำกลิ่น ฟอกสีขาว ไม้กระจายกลิ่นน้ำมันหอม แบ่งขาย สูง 8 นิ้ว ทางร้านมีขนาดเดียว | ก้านไม้หวาย:ขนาด 8 นิ้ว 15 ก้าน | `reed15` | ฿36.71 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2265 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกปีบ สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:ดอกปีบ | `3769652085-1652073968741-0` | ฿83.67 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1342 | ก้านไม้กระจายกลิ่น ก้านไม้หวาย สำหรับนำกลิ่น ฟอกสีขาว ไม้กระจายกลิ่นน้ำมันหอม แบ่งขาย สูง 8 นิ้ว ทางร้านมีขนาดเดียว | Variation3:ขนาด 8 นิ้ว 15 ก้าน | `reed15` | ฿50.78 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1546 | ราคาโปร 99 บาท iRelaxShop Aroma Room Spray สเปรย์น้ำหอมปรับอากาศ น้ำหอมปรับอากาศ กลิ่น ตะไคร้บ้าน Lemongrass | Variation3:ดอกแก้ว | `3889863236-1654920750614-10` | ฿100.80 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1142 | TheDecember น้ำมันหอมระเหย กลิ่นโอเชี่ยน สำหรับเครื่องพ่น น้ำหอมสำหรับเครื่องฟอกอากาศ เครื่องพ่นอโรม่า ออยโอโซน | โทนสี:โอเชี่ยน | `101054522-1618206132775-1` | ฿211.55 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1466 | TheDecember Aroma oil Butterfly Pea For Ultrasonic Humidifier | สี:สีขาว | `OZO309` | ฿179.28 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2309 | กลิ่นวนิลา ออยโอโซน น้ำมันอโรม่า เหมาะกับทุกโอกาส เพื่มความหอมในบ้าน มีให้เลือกหลายขนาด | กลิ่น:วนิลา | `3769545730-1652073961460-0` | ฿70.72 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1089 | TheDecember Aroma oil Lemongrass For Ultrasonic Humidifier | Color:ตะไคร้บ้าน | `OZO317` | ฿308.40 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2124 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นแคนตาลูป​ สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:แคนตาลูป​ | `3769564587-1652073965359-0` | ฿71.84 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1191 | TheDecember น้ำมันหอมระเหย สำหรับเครื่องพ่น กลิ่นตะไคร้บ้าน น้ำหอมสำหรับเครื่องฟอกอากาศ เครื่องพ่นอโรม่า ออยโอโซน | โทนสี:ตะไคร้บ้าน | `OZO317` | ฿280.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1454 | TheDecember Aroma oil Cantaloupe For Ultrasonic Humidifier Oil Ozone | สี:แคนตาลูป | `101054485-1618207184796-1` | ฿269.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1231 | ก้านไม้กระจายกลิ่น ก้านไม้หวาย สำหรับนำกลิ่น ฟอกสีขาว ไม้กระจายกลิ่นน้ำมันหอม แบ่งขาย สูง 8 นิ้ว ทางร้านมีขนาดเดียว | Variation3:ขนาด 8 นิ้ว 10 ก้าน | `reed10` | ฿29.59 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1508 | iRelaxShop น้ำมันหอมระเหย ชาเขียว Green Tea Aromatherapy Oil ขนาด 30 ML อโรม่า ออย Aroma Oil | Color Family:Clear | `ATPO-Greentea-30` | ฿79.99 | ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น ชาเขียว หรือไม่มีระบุกลิ่น) | +| 1144 | ก้านไม้กระจายกลิ่น ก้านไม้หวาย สำหรับนำกลิ่น ฟอกสีขาว ไม้กระจายกลิ่นน้ำมันหอม แบ่งขาย สูง 8 นิ้ว ทางร้านมีขนาดเดียว | ก้านไม้หวาย:ขนาด 8 นิ้ว 10 ก้าน | `reed10` | ฿23.84 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1330 | TheDecember Aroma oil Cantaloupe For Ultrasonic Humidifier Oil Ozone | Color Family:แคนตาลูป | `101054485-1618207184796-1` | ฿219.10 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1514 | TheDecember Aroma oil Lemongrass For Ultrasonic Humidifier | Color Family:ตะไคร้บ้าน | `OZO317` | ฿198.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2204 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกบัว สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:ดอกบัว | `3769533755-1652073969038-0` | ฿86.85 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1743 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:เปเปอร์มิ้น, ขนาด:10 ม.ล | `802108757-1653090692783-0` | ฿33.77 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1275 | TheDecember Aroma oil Rose For Ultrasonic Humidifier Oil Ozone | สี:กุหลาบชมพู | `101054621-1618205446197-1` | ฿155.43 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2180 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นแคนตาลูป​ สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | Variation3:แคนตาลูป​ | `3769564587-1652073965359-0` | ฿75.59 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1755 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:โรสเมรี่, ขนาด:10 ม.ล | `802108757-1653090692783-55` | ฿31.50 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1439 | ราคาโปร 99 บาท iRelaxShop Aroma Room Spray สเปรย์น้ำหอมปรับอากาศ น้ำหอมปรับอากาศ กลิ่น ตะไคร้บ้าน Lemongrass | Variation3:แซนด้าวูส | `3889863236-1654920750614-25` | ฿111.60 | พบหมวด 'สเปรย์ปรับอากาศ', กลิ่น 'แซนด้าวูส', ขนาด '25ml' แต่ไม่มี Master Variant ขนาดนี้ในแคตตาล็อก | +| 1749 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ไทยสปา, ขนาด:10 ม.ล | `802108757-1653090692783-15` | ฿32.70 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 2301 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกแก้ว สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:ดอกแก้ว | `3769539783-1652073963920-0` | ฿97.10 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1784 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ส้ม, ขนาด:10 ม.ล | `802108757-1653090692783-65` | ฿32.33 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1753 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ตะไคร้, ขนาด:10 ม.ล | `802108757-1653090692783-10` | ฿31.75 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 2431 | กลิ่นดอกบัว ออยโอโซน น้ำมันอโรม่า น้ำหอมปรับอากาศ สบายอารมณ์ อย่าลืมใช้คูปอง | กลิ่น:ดอกบัว | `3769533755-1652073969038-0` | ฿95.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 948 | Aroma Essential Oil for Humidifier Diffuser and Home Fragrance | ขนาด (Size):(Small) เล็ก 35 ml, กลิ่น (Scent):มะลิ Jasmine | `248993355-1739954808457-9` | ฿92.20 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2289 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นหมากฝรั่ง สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:หมากฝรั่ง | `3769520871-1652073967598-0` | ฿92.10 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2385 | กลิ่นไทยสปา ออยโอโซน น้ำมันอโรม่า เพิ่มความหอม ในรถ ขายดีที่สุด ไม่มีแอลกอฮอล์ | fragrance_family:ไทยสปา | `3769552716-1652073969601-0` | ฿89.12 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1487 | ราคาโปร 99 บาท iRelaxShop Aroma Room Spray สเปรย์น้ำหอมปรับอากาศ น้ำหอมปรับอากาศ กลิ่น ตะไคร้บ้าน Lemongrass | เลือกกลิ่น:ดอกแก้ว | `3889863236-1654920750614-10` | ฿88.08 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1883 | น้ำมันหอมระเหย ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | โทนสี:โอเชี่ยน | `802288500-1598587105589-14` | ฿79.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1882 | น้ำมันหอมระเหย ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | โทนสี:ไม้หอม | `802288500-1598587105589-19` | ฿79.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1881 | น้ำมันหอมระเหย ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | โทนสี:ส้ม | `802288500-1598587105589-20` | ฿79.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1904 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น ยูคาลิปตัส eucalyptus สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | สี:มะกรูด | `802288500-1598587105589-29` | ฿77.96 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2276 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกปีบ สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | Variation3:ดอกปีบ | `3769652085-1652073968741-0` | ฿74.60 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1886 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น ยูคาลิปตัส eucalyptus สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | Color Family:แอปเปิ้ลเขียว | `802288500-1598587105589-7` | ฿73.47 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1885 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น ยูคาลิปตัส eucalyptus สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | Color Family:วานิลลา | `802288500-1598587105589-16` | ฿73.47 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1874 | น้ำมันหอมระเหย ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | โทนสี:มะกรูด | `802288500-1598587105589-29` | ฿73.47 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1884 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น ยูคาลิปตัส eucalyptus สำหรับ เครื่องพ่นไอน้ำ Oil Ozone | Color Family:ดอกมะลิ | `802288500-1598587105589-8` | ฿73.47 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2298 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นไทยสปา สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | กลิ่น:ไทยสปา | `3769552716-1652073969601-0` | ฿72.54 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 2260 | น้ำหอมเครื่องพ่นไอน้ำ น้ำมันหอมระเหย กลิ่นดอกบัว สำหรับเตาอโรม่า เครื่องพ่นไอน้ำ | Variation3:ดอกบัว | `3769533755-1652073969038-0` | ฿71.52 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1703 | iRelax Fragrant Oil Ozone Essential Aromatherapy Oil Aroma Scent Lavender For Aroma Stoves Benefits of Aromatherapy Create Home Atmosphere | กลิ่น:แคนตาลูป | `2874895750-1675057400797-11` | ฿70.22 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1702 | iRelax Fragrant Oil Ozone Essential Aromatherapy Oil Aroma Scent Lavender For Aroma Stoves Benefits of Aromatherapy Create Home Atmosphere | กลิ่น:ซากุระ | `2874895750-1675057400797-10` | ฿70.22 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1751 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ลาเวนเดอร์, ขนาด:10 ม.ล | `802108757-1653090692783-45` | ฿31.50 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1765 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ยูคาลิปตัส, ขนาด:10 ม.ล | `802108757-1653090692783-5` | ฿30.21 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1781 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:เปเปอร์มิ้น, Variation1:10 ม.ล | `802108757-1653090692783-0` | ฿28.81 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1782 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:ยูคาลิปตัส, Variation1:10 ม.ล | `802108757-1653090692783-5` | ฿28.81 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1752 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ดอกโมก, ขนาด:10 ม.ล | `802108757-1653090692783-30` | ฿28.80 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 2432 | กลิ่นดอกแก้ว ออยโอโซน น้ำมันอโรม่า ลดกลิ่นอับ ในรถ ในบ้าน กดติดตามร้าน ได้ส่วนลด น้ำหอมปรับอากาศ | fragrance_family:ดอกแก้ว | `3769539783-1652073963920-0` | ฿55.96 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU | +| 1801 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:ตะไคร้, Variation1:10 ม.ล | `802108757-1653090692783-10` | ฿36.55 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1802 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:ไทยสปา, Variation1:10 ม.ล | `802108757-1653090692783-15` | ฿36.55 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1803 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:โอเชี่ยน, Variation1:10 ม.ล | `802108757-1653090692783-20` | ฿36.55 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1750 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:ดอกซากุระ, ขนาด:10 ม.ล | `802108757-1653090692783-50` | ฿31.50 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1764 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | กลิ่น:โอเชี่ยน, ขนาด:10 ม.ล | `802108757-1653090692783-20` | ฿26.08 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | +| 1817 | ออย โอโซน น้ำมันหอม น้ำมันหอมระเหย อโรม่า ออย กลิ่น เปเปอร์​มิ้น​ Peppermint สำหรับ เครื่องพ่นไอน้ำ Oil Ozone ขนาด 85 ml | Variation3:ส้ม, Variation1:10 ม.ล | `802108757-1653090692783-65` | ฿24.75 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) | + +## 2. Sample of Successfully Mapped Items (ตัวอย่างที่เชื่อมโยงสำเร็จ) + +| ID | Lazada SKU | หมวดหมู่ | กลิ่น | ขนาด | Master SKU | Master Variant | BOM Status | +|:---|:---|:---|:---|:---|:---|:---|:---| +| 1155 | `LMG120ml` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 120ml | `1-3-120ML` | ตะไคร้ Reed Diffuser (120ml) [1-3-120ML] | รอสร้าง BOM | +| 1060 | `101054933-1618197462982-1` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว | +| 1205 | `LMG120ml` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 120ml | `1-3-120ML` | ตะไคร้ Reed Diffuser (120ml) [1-3-120ML] | รอสร้าง BOM | +| 1149 | `lemongrass120ml` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว | +| 1383 | `101055114-1653027088508-43` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว | +| 1996 | `120-วานิลลา vanilla-120 ม.ล` | น้ำมันหอมระเหยโอโซน | วานิลลา | 120ml | `3-1-120ML` | วานิลลา Oil Ozone (120ml) [3-1-120ML] | ✓ มี BOM แล้ว | +| 1360 | `101055114-1653027088508-35` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว | +| 1354 | `101055114-1653027088508-31` | น้ำมันหอมระเหยโอโซน | มะลิ | 120ml | `5-1-120ML` | มะลิ Oil Ozone (120ml) [5-1-120ML] | ✓ มี BOM แล้ว | +| 1225 | `LVN120` | ก้านไม้หอมปรับอากาศ | ลาเวนเดอร์ | 120ml | `2-3-120ML` | ลาเวนเดอร์ Reed Diffuser (120ml) [2-3-120ML] | รอสร้าง BOM | +| 1194 | `มะกรูดก้านไม้-120` | ก้านไม้หอมปรับอากาศ | มะกรูด | 120ml | `25-3-120ML` | มะกรูด Reed Diffuser (120ml) [25-3-120ML] | รอสร้าง BOM | +| 1035 | `LMG120ml` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 120ml | `1-3-120ML` | ตะไคร้ Reed Diffuser (120ml) [1-3-120ML] | รอสร้าง BOM | +| 1218 | `lemongrass120ml` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว | +| 1221 | `LMG60` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 60ml | `1-3-60ML` | ตะไคร้ Reed Diffuser (60ml) [1-3-60ML] | รอสร้าง BOM | +| 1039 | `120-ซากุระ-120 ml` | น้ำมันหอมระเหยโอโซน | ซากุระ | 120ml | `4-1-120ML` | ซากุระ Oil Ozone (120ml) [4-1-120ML] | ✓ มี BOM แล้ว | +| 1040 | `120-ตะไคร้บ้าน-120 ml` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว | +| 1163 | `Sandalwood` | ก้านไม้หอมปรับอากาศ | แซนด้าวูส | 120ml | `26-3-120ML` | แซนด้าวูส Reed Diffuser (120ml) [26-3-120ML] | รอสร้าง BOM | +| 1397 | `101055114-1653027088508-23` | น้ำมันหอมระเหยโอโซน | เปเปอร์มิ้น | 120ml | `8-1-120ML` | เปเปอร์มิ้น Oil Ozone (120ml) [8-1-120ML] | ✓ มี BOM แล้ว | +| 1936 | `30-ลาเวนเดอร์ Lavender-30 ม.ล` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 30ml | `2-1-30ML` | ลาเวนเดอร์ Oil Ozone (30ml) [2-1-30ML] | ✓ มี BOM แล้ว | +| 1173 | `LVN120` | ก้านไม้หอมปรับอากาศ | ลาเวนเดอร์ | 120ml | `2-3-120ML` | ลาเวนเดอร์ Reed Diffuser (120ml) [2-3-120ML] | รอสร้าง BOM | +| 1049 | `LMG120ml` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 120ml | `1-3-120ML` | ตะไคร้ Reed Diffuser (120ml) [1-3-120ML] | รอสร้าง BOM | +| 1877 | `802308238-1618112605393-1` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 85ml | `1-1-85ML` | ตะไคร้ Oil Ozone (85ml) [1-1-85ML] | ✓ มี BOM แล้ว | +| 1043 | `101054933-1618197462982-1` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว | +| 1941 | `120-ลาเวนเดอร์ Lavender-120 ม.ล` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว | +| 1361 | `101055114-1653027088508-63` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว | +| 1222 | `EUC120` | ก้านไม้หอมปรับอากาศ | ยูคาลิปตัส | 120ml | `6-3-120ML` | ยูคาลิปตัส Reed Diffuser (120ml) [6-3-120ML] | รอสร้าง BOM | +| 1935 | `30-มะลิ Jasmine-30 ม.ล` | น้ำมันหอมระเหยโอโซน | มะลิ | 30ml | `5-1-30ML` | มะลิ Oil Ozone (30ml) [5-1-30ML] | ✓ มี BOM แล้ว | +| 1948 | `30-ตะไคร้ Lemongrass-30 ม.ล` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 30ml | `1-1-30ML` | ตะไคร้ Oil Ozone (30ml) [1-1-30ML] | ✓ มี BOM แล้ว | +| 1746 | `SKU003` | น้ำมันหอมระเหยโอโซน | มะลิ | 85ml | `5-1-85ML` | มะลิ Oil Ozone (85ml) [5-1-85ML] | ✓ มี BOM แล้ว | +| 1386 | `101055114-1653027088508-40` | น้ำมันหอมระเหยโอโซน | ลาเวนเดอร์ | 30ml | `2-1-30ML` | ลาเวนเดอร์ Oil Ozone (30ml) [2-1-30ML] | ✓ มี BOM แล้ว | +| 1334 | `101055114-1653027088508-35` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว | +| 1988 | `30-ตะไคร้ Lemongrass-30 ม.ล` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 30ml | `1-1-30ML` | ตะไคร้ Oil Ozone (30ml) [1-1-30ML] | ✓ มี BOM แล้ว | +| 1303 | `VAN120` | ก้านไม้หอมปรับอากาศ | วานิลลา | 120ml | `3-3-120ML` | วานิลลา Reed Diffuser (120ml) [3-3-120ML] | รอสร้าง BOM | +| 1154 | `LMG60` | ก้านไม้หอมปรับอากาศ | ตะไคร้ | 60ml | `1-3-60ML` | ตะไคร้ Reed Diffuser (60ml) [1-3-60ML] | รอสร้าง BOM | +| 1376 | `101055114-1653027088508-19` | น้ำมันหอมระเหยโอโซน | ซากุระ | 120ml | `4-1-120ML` | ซากุระ Oil Ozone (120ml) [4-1-120ML] | ✓ มี BOM แล้ว | +| 1162 | `PEP120` | ก้านไม้หอมปรับอากาศ | เปเปอร์มิ้น | 120ml | `8-3-120ML` | เปเปอร์มิ้น Reed Diffuser (120ml) [8-3-120ML] | รอสร้าง BOM | +| 1979 | `120-ซากุระ Sakura-120 ม.ล` | น้ำมันหอมระเหยโอโซน | ซากุระ | 120ml | `4-1-120ML` | ซากุระ Oil Ozone (120ml) [4-1-120ML] | ✓ มี BOM แล้ว | +| 1973 | `120-ไทยสปา-120 ม.ล` | น้ำมันหอมระเหยโอโซน | ไทยสปา | 120ml | `30-1-120ML` | ไทยสปา Oil Ozone (120ml) [30-1-120ML] | ✓ มี BOM แล้ว | +| 1390 | `101055114-1653027088508-55` | น้ำมันหอมระเหยโอโซน | ส้ม | 120ml | `14-1-120ML` | ส้ม Oil Ozone (120ml) [14-1-120ML] | ✓ มี BOM แล้ว | +| 1965 | `120-ตะไคร้ Lemongrass-120 ม.ล` | น้ำมันหอมระเหยโอโซน | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว | +| 1719 | `802108757-1653090692783-4` | น้ำมันหอมระเหยโอโซน | เปเปอร์มิ้น | 120ml | `8-1-120ML` | เปเปอร์มิ้น Oil Ozone (120ml) [8-1-120ML] | ✓ มี BOM แล้ว | +| ... | ... | ... | ... | ... | ... | *(แสดง 40 จากทั้งหมด 1594 รายการ)* | ... | diff --git a/PRODUCT_MASTER_WITHOUT_BOM.md b/PRODUCT_MASTER_WITHOUT_BOM.md new file mode 100644 index 0000000..fa2db60 --- /dev/null +++ b/PRODUCT_MASTER_WITHOUT_BOM.md @@ -0,0 +1,33 @@ +# สรุปรายการสินค้า Product Master ที่ยังไม่มีสูตร BOM (Bill of Materials) + +* **วันที่จัดทำรายงาน:** 2026-10-18T11:26:21.423Z +* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`) +* **จำนวน Product Master Variant ทั้งหมด:** **585** รายการ +* **มีสูตร BOM แล้ว:** **585** รายการ (100.0%) +* **ยังไม่มีสูตร BOM:** **0** รายการ (0.0%) + +> **ข้อสังเกต:** สินค้าในหมวด **"น้ำมันหอมระเหยโอโซน (Oil Ozone)"** ทั้งหมด 180 รายการ (45 กลิ่น x 4 ขนาด) มีสูตร BOM ครบถ้วน 100% แล้ว ส่วนสินค้าที่ยังไม่มีสูตร BOM มี 4 หมวดหมู่อยู่ด้านล่างนี้ + +## 1. ภาพรวมสถานะ BOM แยกตามหมวดหมู่ (Category Overview) + +| หมวดหมู่ (Category) | จำนวนทั้งหมด | มี BOM แล้ว | ยังไม่มี BOM | สถานะความพร้อม | +|:---|:---:|:---:|:---:|:---:| +| **น้ำมันหอมระเหยอโรม่า** | 180 | 180 | **0** | ✅ ครบ 100% | +| **น้ำหอมปรับอากาศในรถยนต์** | 45 | 45 | **0** | ✅ ครบ 100% | +| **น้ำมันหอมระเหยโอโซน** | 180 | 180 | **0** | ✅ ครบ 100% | +| **ก้านไม้หอมปรับอากาศ** | 135 | 135 | **0** | ✅ ครบ 100% | +| **สเปรย์ปรับอากาศ** | 45 | 45 | **0** | ✅ ครบ 100% | +| **รวมทั้งหมด** | **585** | **585** | **0** | **100.0%** | + +--- + +## 2. รายละเอียดสินค้าที่ยังไม่มีสูตร BOM แยกตามหมวดหมู่ + +--- + +## 3. คำแนะนำในการสร้าง BOM ลำดับถัดไป (Next Steps & Recommendations) + +1. **ก้านไม้หอมปรับอากาศ (Reed Diffuser - 135 รายการ):** แนะนำทำเป็นลำดับแรก เนื่องจากมียอดขายและการผูกบน Marketplace (Shopee/Lazada) มากที่สุด วัตถุดิบหลักประกอบด้วย: น้ำหอม Base Reed Diffuser, ก้านไม้หวาย (Diffuser Sticks), ขวดแก้ว, ฝาจุก +2. **น้ำหอมปรับอากาศในรถยนต์ (Car Diffuser - 45 รายการ):** มีขนาดเดียวคือ 25ml ประกอบด้วย: หัวเชื้อน้ำหอม, ขวดแขวนฝาไม้, เชือกแขวน, จุกพลาสติกกันรั่ว +3. **สเปรย์ปรับอากาศ (Room Spray - 45 รายการ):** มีขนาดเดียวคือ 85ml ประกอบด้วย: เบสสเปรย์ (Alcohol/Water Solution), หัวเชื้อน้ำหอม, ขวดพลาสติก 85ml, หัวฉีดสเปรย์ +4. **น้ำมันหอมระเหยอโรม่า (Aroma Oil - 179 รายการ):** สูตรจะใกล้เคียงกับ Oil Ozone แต่ใช้ตัวทำละลายต่างกัน (Aroma Oil ใช้สำหรับเตาอโรม่า/Burner ไม่ต้องใช้น้ำมันละลายน้ำ) diff --git a/REED_DIFFUSER_BOM_LEDGER.md b/REED_DIFFUSER_BOM_LEDGER.md new file mode 100644 index 0000000..891b326 --- /dev/null +++ b/REED_DIFFUSER_BOM_LEDGER.md @@ -0,0 +1,68 @@ +# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้าก้านไม้หอมปรับอากาศ (Reed Diffuser) + +* **วันที่กำหนดสูตร:** 2026-10-18T11:26:11.539Z +* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`) +* **วัตถุดิบและสัดส่วนสูตร:** + - **สูตรน้ำยา:** หัวน้ำหอม 8% + DPM 92% (ราคา DPM: ฿0.10/ml) + - **ขวดพร้อมฝา Oilozone:** 60ml (ID 11: ฿4.00), 85ml (ID 12: ฿4.00), 120ml (ID 9: ฿4.00) + - **ก้านไม้กระจายกลิ่น (1 set):** (ID 61: ฿20.00/set) +* **จำนวนสินค้าทั้งหมด:** **45** กลิ่น (**135** Variants) +* **สถานะความสำเร็จ:** **100% (45/45 สินค้า)** + +## 📋 สรุปต้นทุนมาตรฐานเทียบกลิ่นยอดนิยม + +| ขนาดบรรจุ | สัดส่วนน้ำยา | Fixed Base Cost (DPM + ขวด + ก้านไม้) | ต้นทุน BOM (กลิ่นมาตรฐาน ฿1.20) | ราคาขาย Master | Gross Margin % | +| :-: | :--- | :-: | :-: | :-: | :-: | +| **60 ml** | หัวน้ำหอม 4.8ml + DPM 55.2ml (฿5.52) | ฿29.52 | **฿35.28** | ฿120.00 | **70.6%** | +| **85 ml** | หัวน้ำหอม 6.8ml + DPM 78.2ml (฿7.82) | ฿31.82 | **฿40.00** | ฿160.00 | **75.0%** | +| **120 ml** | หัวน้ำหอม 9.6ml + DPM 110.4ml (฿11.04) | ฿35.04 | **฿46.56** | ฿190.00 | **75.5%** | + +## 📋 ตารางรายละเอียดต้นทุน BOM ครบทั้ง 45 กลิ่น + +| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 60ml (GP%) | ต้นทุน 85ml (GP%) | ต้นทุน 120ml (GP%) | +| :-: | :-: | :--- | :--- | :---: | :---: | :---: | +| 1 | 3 | **ตะไคร้ Reed Diffuser** | หัวน้ำหอม กลิ่นตะไคร้ (฿4.50) | ฿51.12 (57.4%) | ฿62.42 (61%) | ฿78.24 (58.8%) | +| 2 | 8 | **ลาเวนเดอร์ Reed Diffuser** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 3 | 13 | **วานิลลา Reed Diffuser** | หัวน้ำหอม กลิ่นวานิลลา (฿1.30) | ฿35.76 (70.2%) | ฿40.66 (74.6%) | ฿47.52 (75%) | +| 4 | 18 | **ซากุระ Reed Diffuser** | หัวน้ำหอม กลิ่นซากุระ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 5 | 23 | **มะลิ Reed Diffuser** | หัวน้ำหอม กลิ่นมะลิ (฿1.00) | ฿34.32 (71.4%) | ฿38.62 (75.9%) | ฿44.64 (76.5%) | +| 6 | 28 | **ยูคาลิปตัส Reed Diffuser** | หัวน้ำหอม กลิ่นยูคาลิปตัส (฿2.10) | ฿39.60 (67%) | ฿46.10 (71.2%) | ฿55.20 (70.9%) | +| 7 | 33 | **เฟรชลาเวนเดอร์ Reed Diffuser** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 8 | 38 | **เปเปอร์มิ้น Reed Diffuser** | หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿1.40) | ฿36.24 (69.8%) | ฿41.34 (74.2%) | ฿48.48 (74.5%) | +| 9 | 43 | **โอเชี่ยน Reed Diffuser** | หัวน้ำหอม กลิ่นโอเชี่ยน (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 10 | 48 | **เซเว่นซี Reed Diffuser** | หัวน้ำหอม กลิ่นเซเว่นซี (฿1.70) | ฿37.68 (68.6%) | ฿43.38 (72.9%) | ฿51.36 (73%) | +| 11 | 53 | **ดอกแก้ว Reed Diffuser** | หัวน้ำหอม กลิ่นดอกแก้ว (฿1.40) | ฿36.24 (69.8%) | ฿41.34 (74.2%) | ฿48.48 (74.5%) | +| 12 | 58 | **ดอกโมก Reed Diffuser** | หัวน้ำหอม กลิ่นดอกโมก (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) | +| 13 | 63 | **ลีลาวดี Reed Diffuser** | หัวน้ำหอม กลิ่นลีลาวดี (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 14 | 68 | **ส้ม Reed Diffuser** | หัวน้ำหอม กลิ่นส้ม (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 15 | 73 | **มะนาว Reed Diffuser** | หัวน้ำหอม กลิ่นมะนาว (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 16 | 78 | **แคนตาลูป Reed Diffuser** | หัวน้ำหอม กลิ่นแคนตาลูป (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 17 | 83 | **แอปเปิ้ลแดง Reed Diffuser** | หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 18 | 88 | **แอปเปิ้ลเขียว Reed Diffuser** | หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 19 | 93 | **ดอกลิลลี่ Reed Diffuser** | หัวน้ำหอม กลิ่นดอกลิลลี่ (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) | +| 20 | 98 | **โรสเมรี่ Reed Diffuser** | หัวน้ำหอม กลิ่นโรสเมรี่ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 21 | 103 | **อัญชัน Reed Diffuser** | หัวน้ำหอม กลิ่นอัญชัน (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 22 | 108 | **กุหลาบแดง Reed Diffuser** | หัวน้ำหอม กลิ่นกุหลาบแดง (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 23 | 113 | **หมากฝรั่ง Reed Diffuser** | หัวน้ำหอม กลิ่นหมากฝรั่ง (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 24 | 118 | **ผลไม้รวม Reed Diffuser** | หัวน้ำหอม กลิ่นผลไม้รวม (฿1.40) | ฿36.24 (69.8%) | ฿41.34 (74.2%) | ฿48.48 (74.5%) | +| 25 | 123 | **มะกรูด Reed Diffuser** | หัวน้ำหอม กลิ่นมะกรูด (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) | +| 26 | 128 | **แซนด้าวูส Reed Diffuser** | หัวน้ำหอม กลิ่นแซนด้าวูส (฿1.00) | ฿34.32 (71.4%) | ฿38.62 (75.9%) | ฿44.64 (76.5%) | +| 27 | 133 | **ดอกปีบ Reed Diffuser** | หัวน้ำหอม กลิ่นดอกปีบ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 28 | 138 | **ดอกบัว Reed Diffuser** | หัวน้ำหอม กลิ่นดอกบัว (฿0.70) | ฿32.88 (72.6%) | ฿36.58 (77.1%) | ฿41.76 (78%) | +| 29 | 143 | **โอโซน Reed Diffuser** | หัวน้ำหอม กลิ่นโอโซน (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 30 | 148 | **ไทยสปา Reed Diffuser** | หัวน้ำหอม กลิ่นไทยสปา (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) | +| 31 | 153 | **กล้วย Reed Diffuser** | หัวน้ำหอม กลิ่นกล้วย (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 32 | 158 | **กาแฟ Reed Diffuser** | หัวน้ำหอม กลิ่นกาแฟ (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 33 | 163 | **ดอกชมนาด Reed Diffuser** | หัวน้ำหอม กลิ่นดอกชมนาด (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 34 | 168 | **กลิ่นน้ำนม Reed Diffuser** | หัวน้ำหอม กลิ่นน้ำนม (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 35 | 173 | **ดอกการะเวก Reed Diffuser** | หัวน้ำหอม กลิ่นดอกการะเวก (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 36 | 178 | **ดอกจำปา Reed Diffuser** | หัวน้ำหอม กลิ่นดอกจำปา (฿1.50) | ฿36.72 (69.4%) | ฿42.02 (73.7%) | ฿49.44 (74%) | +| 37 | 183 | **ดอกพิกุล Reed Diffuser** | หัวน้ำหอม กลิ่นดอกพิกุล (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 38 | 188 | **ดอกพุด Reed Diffuser** | หัวน้ำหอม กลิ่นดอกพุด (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 39 | 193 | **ดอกราตรี Reed Diffuser** | หัวน้ำหอม กลิ่นดอกราตรี (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 40 | 198 | **ดอกลำดวน Reed Diffuser** | หัวน้ำหอม กลิ่นดอกลำดวน (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 41 | 203 | **ทับทิม Reed Diffuser** | หัวน้ำหอม กลิ่นทับทิม (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 42 | 208 | **มะละกอสุก Reed Diffuser** | หัวน้ำหอม กลิ่นมะละกอสุก (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 43 | 213 | **ดอกราชาวดี Reed Diffuser** | หัวน้ำหอม กลิ่นดอกราชาวดี (฿1.30) | ฿35.76 (70.2%) | ฿40.66 (74.6%) | ฿47.52 (75%) | +| 44 | 218 | **กลิ่นลูกพีช Reed Diffuser** | หัวน้ำหอม กลิ่นลูกพีช (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | +| 45 | 223 | **กลิ่นองุ่น Reed Diffuser** | หัวน้ำหอม กลิ่นองุ่น (฿1.20) | ฿35.28 (70.6%) | ฿39.98 (75%) | ฿46.56 (75.5%) | diff --git a/ROOM_SPRAY_BOM_LEDGER.md b/ROOM_SPRAY_BOM_LEDGER.md new file mode 100644 index 0000000..85989bb --- /dev/null +++ b/ROOM_SPRAY_BOM_LEDGER.md @@ -0,0 +1,58 @@ +# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Room Spray (Production) + +* **วันที่กำหนดสูตร:** 2026-10-18T11:02:00.809Z +* **ระบบอ้างอิง:** Production Database (`https://api.homaroma.shop/api`) +* **ขนาดบรรจุ:** 85 ml (ขวดแก้วหัว Spray) +* **สัดส่วนสูตร:** **หัวน้ำหอม 8% (6.8 ml) + DPM 92% (78.2 ml) + ขวดแก้วหัว Spray ขนาด 85 ml (฿25.00)** +* **จำนวนสินค้าทั้งหมด:** **45** กลิ่น (**45** Variants) +* **สถานะความสำเร็จ:** **100% (45/45 สินค้า)** + +## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น + +| # | ID | Master SKU | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุนน้ำยา (85ml) | ต้นทุนขวดแก้ว | ต้นทุนรวม BOM | ราคาขาย Master | กำไรขั้นต้น (GP) | GP Margin % | +| :-: | :-: | :--- | :--- | :--- | :-: | :-: | :-: | :-: | :-: | :-: | +| 1 | 4 | `1-4-85ML` | **ตะไคร้ Room Spray** | หัวน้ำหอม กลิ่นตะไคร้ (฿4.50) | ฿38.42 | ฿25.00 | **฿63.42** | ฿150.00 | +฿86.58 | **57.7%** | +| 2 | 9 | `2-4-85ML` | **ลาเวนเดอร์ Room Spray** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 3 | 14 | `3-4-85ML` | **วานิลลา Room Spray** | หัวน้ำหอม กลิ่นวานิลลา (฿1.30) | ฿16.66 | ฿25.00 | **฿41.66** | ฿150.00 | +฿108.34 | **72.2%** | +| 4 | 19 | `4-4-85ML` | **ซากุระ Room Spray** | หัวน้ำหอม กลิ่นซากุระ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 5 | 24 | `5-4-85ML` | **มะลิ Room Spray** | หัวน้ำหอม กลิ่นมะลิ (฿1.00) | ฿14.62 | ฿25.00 | **฿39.62** | ฿150.00 | +฿110.38 | **73.6%** | +| 6 | 29 | `6-4-85ML` | **ยูคาลิปตัส Room Spray** | หัวน้ำหอม กลิ่นยูคาลิปตัส (฿2.10) | ฿22.10 | ฿25.00 | **฿47.10** | ฿150.00 | +฿102.90 | **68.6%** | +| 7 | 34 | `7-4-85ML` | **เฟรชลาเวนเดอร์ Room Spray** | หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 8 | 39 | `8-4-85ML` | **เปเปอร์มิ้น Room Spray** | หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿1.40) | ฿17.34 | ฿25.00 | **฿42.34** | ฿150.00 | +฿107.66 | **71.8%** | +| 9 | 44 | `9-4-85ML` | **โอเชี่ยน Room Spray** | หัวน้ำหอม กลิ่นโอเชี่ยน (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 10 | 49 | `10-4-85ML` | **เซเว่นซี Room Spray** | หัวน้ำหอม กลิ่นเซเว่นซี (฿1.70) | ฿19.38 | ฿25.00 | **฿44.38** | ฿150.00 | +฿105.62 | **70.4%** | +| 11 | 54 | `11-4-85ML` | **ดอกแก้ว Room Spray** | หัวน้ำหอม กลิ่นดอกแก้ว (฿1.40) | ฿17.34 | ฿25.00 | **฿42.34** | ฿150.00 | +฿107.66 | **71.8%** | +| 12 | 59 | `12-4-85ML` | **ดอกโมก Room Spray** | หัวน้ำหอม กลิ่นดอกโมก (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** | +| 13 | 64 | `13-4-85ML` | **ลีลาวดี Room Spray** | หัวน้ำหอม กลิ่นลีลาวดี (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 14 | 69 | `14-4-85ML` | **ส้ม Room Spray** | หัวน้ำหอม กลิ่นส้ม (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 15 | 74 | `15-4-85ML` | **มะนาว Room Spray** | หัวน้ำหอม กลิ่นมะนาว (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 16 | 79 | `16-4-85ML` | **แคนตาลูป Room Spray** | หัวน้ำหอม กลิ่นแคนตาลูป (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 17 | 84 | `17-4-85ML` | **แอปเปิ้ลแดง Room Spray** | หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 18 | 89 | `18-4-85ML` | **แอปเปิ้ลเขียว Room Spray** | หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 19 | 94 | `19-4-85ML` | **ดอกลิลลี่ Room Spray** | หัวน้ำหอม กลิ่นดอกลิลลี่ (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** | +| 20 | 99 | `20-4-85ML` | **โรสเมรี่ Room Spray** | หัวน้ำหอม กลิ่นโรสเมรี่ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 21 | 104 | `21-4-85ML` | **อัญชัน Room Spray** | หัวน้ำหอม กลิ่นอัญชัน (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 22 | 109 | `22-4-85ML` | **กุหลาบแดง Room Spray** | หัวน้ำหอม กลิ่นกุหลาบแดง (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 23 | 114 | `23-4-85ML` | **หมากฝรั่ง Room Spray** | หัวน้ำหอม กลิ่นหมากฝรั่ง (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 24 | 119 | `24-4-85ML` | **ผลไม้รวม Room Spray** | หัวน้ำหอม กลิ่นผลไม้รวม (฿1.40) | ฿17.34 | ฿25.00 | **฿42.34** | ฿150.00 | +฿107.66 | **71.8%** | +| 25 | 124 | `25-4-85ML` | **มะกรูด Room Spray** | หัวน้ำหอม กลิ่นมะกรูด (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** | +| 26 | 129 | `26-4-85ML` | **แซนด้าวูส Room Spray** | หัวน้ำหอม กลิ่นแซนด้าวูส (฿1.00) | ฿14.62 | ฿25.00 | **฿39.62** | ฿150.00 | +฿110.38 | **73.6%** | +| 27 | 134 | `27-4-85ML` | **ดอกปีบ Room Spray** | หัวน้ำหอม กลิ่นดอกปีบ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 28 | 139 | `28-4-85ML` | **ดอกบัว Room Spray** | หัวน้ำหอม กลิ่นดอกบัว (฿0.70) | ฿12.58 | ฿25.00 | **฿37.58** | ฿150.00 | +฿112.42 | **74.9%** | +| 29 | 144 | `29-4-85ML` | **โอโซน Room Spray** | หัวน้ำหอม กลิ่นโอโซน (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 30 | 149 | `30-4-85ML` | **ไทยสปา Room Spray** | หัวน้ำหอม กลิ่นไทยสปา (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** | +| 31 | 154 | `31-4-85ML` | **กล้วย Room Spray** | หัวน้ำหอม กลิ่นกล้วย (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 32 | 159 | `32-4-85ML` | **กาแฟ Room Spray** | หัวน้ำหอม กลิ่นกาแฟ (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 33 | 164 | `33-4-85ML` | **ดอกชมนาด Room Spray** | หัวน้ำหอม กลิ่นดอกชมนาด (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 34 | 169 | `34-4-85ML` | **กลิ่นน้ำนม Room Spray** | หัวน้ำหอม กลิ่นน้ำนม (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 35 | 174 | `35-4-85ML` | **ดอกการะเวก Room Spray** | หัวน้ำหอม กลิ่นดอกการะเวก (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 36 | 179 | `36-4-85ML` | **ดอกจำปา Room Spray** | หัวน้ำหอม กลิ่นดอกจำปา (฿1.50) | ฿18.02 | ฿25.00 | **฿43.02** | ฿150.00 | +฿106.98 | **71.3%** | +| 37 | 184 | `37-4-85ML` | **ดอกพิกุล Room Spray** | หัวน้ำหอม กลิ่นดอกพิกุล (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 38 | 189 | `38-4-85ML` | **ดอกพุด Room Spray** | หัวน้ำหอม กลิ่นดอกพุด (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 39 | 194 | `39-4-85ML` | **ดอกราตรี Room Spray** | หัวน้ำหอม กลิ่นดอกราตรี (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 40 | 199 | `40-4-85ML` | **ดอกลำดวน Room Spray** | หัวน้ำหอม กลิ่นดอกลำดวน (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 41 | 204 | `41-4-85ML` | **ทับทิม Room Spray** | หัวน้ำหอม กลิ่นทับทิม (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 42 | 209 | `42-4-85ML` | **มะละกอสุก Room Spray** | หัวน้ำหอม กลิ่นมะละกอสุก (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 43 | 214 | `43-4-85ML` | **ดอกราชาวดี Room Spray** | หัวน้ำหอม กลิ่นดอกราชาวดี (฿1.30) | ฿16.66 | ฿25.00 | **฿41.66** | ฿150.00 | +฿108.34 | **72.2%** | +| 44 | 219 | `44-4-85ML` | **กลิ่นลูกพีช Room Spray** | หัวน้ำหอม กลิ่นลูกพีช (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | +| 45 | 224 | `45-4-85ML` | **กลิ่นองุ่น Room Spray** | หัวน้ำหอม กลิ่นองุ่น (฿1.20) | ฿15.98 | ฿25.00 | **฿40.98** | ฿150.00 | +฿109.02 | **72.7%** | diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 5c8925c..954c771 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -79,6 +79,9 @@ import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage' import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage' import PosPage from './pages/PosPage' import UserLlmPromptsPage from './pages/UserLlmPromptsPage' +import CostProfitabilityMatrixPage from './pages/profitability/CostProfitabilityMatrixPage' +import OmnichannelMarginPage from './pages/profitability/OmnichannelMarginPage' +import MrpPlanningPage from './pages/profitability/MrpPlanningPage' import { StoreSettingsProvider } from './contexts/StoreSettingsContext' function App() { @@ -126,6 +129,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index a3eec06..e2bd740 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -56,7 +56,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { const navigate = useNavigate() const location = useLocation() - const isInProduct = ['/categories', '/brands', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p)) + const isInProduct = ['/categories', '/brands', '/products', '/product-attributes', '/raw-materials', '/profitability', '/omnichannel-margin', '/mrp-planning'].some(p => location.pathname.startsWith(p)) const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p)) const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer') const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') && !isRepeatCustomers @@ -97,6 +97,9 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { { icon: Package, label: t('nav.products', 'Products'), to: '/products' }, { icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' }, { icon: PackageOpen, label: t('nav.rawMaterials', 'Raw Materials'), to: '/raw-materials' }, + { icon: Calculator, label: t('nav.profitability', 'Cost & Profitability'), to: '/profitability' }, + { icon: Layers, label: t('nav.omnichannelMargin', 'Omnichannel Margin'), to: '/omnichannel-margin' }, + { icon: BrainCircuit, label: t('nav.mrpPlanning', 'MRP & Material Demand'), to: '/mrp-planning' }, ] const SALES_MENU = [ diff --git a/apps/admin/src/pages/profitability/CostProfitabilityMatrixPage.tsx b/apps/admin/src/pages/profitability/CostProfitabilityMatrixPage.tsx new file mode 100644 index 0000000..a8257eb --- /dev/null +++ b/apps/admin/src/pages/profitability/CostProfitabilityMatrixPage.tsx @@ -0,0 +1,692 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Search, + Download, + TrendingUp, + Percent, + Coins, + Package, + Layers, + ChevronDown, + ChevronUp, + RefreshCw, + SlidersHorizontal, +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; +import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs'; + +function fmtMoney(val: number | null | undefined): string { + if (val == null || isNaN(val)) return '฿0.00'; + return ( + '฿' + + new Intl.NumberFormat('th-TH', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(val) + ); +} + +function fmtPercent(val: number | null | undefined): string { + if (val == null || isNaN(val)) return '0.0%'; + return `${val.toFixed(1)}%`; +} + +interface BomComponent { + id: number; + rawMaterialId: number; + name: string; + unit: string; + unitPrice: number; + quantity: number; + totalCost: number; + isLiquid: boolean; +} + +interface VariantProfitItem { + id: number; + sku: string; + productId: number; + productName: string; + categoryName: string; + brandName: string; + optionsStr: string; + retailPrice: number; + liquidCost: number; + packagingCost: number; + totalBomCost: number; + grossProfit: number; + grossMargin: number; + marginHealth: 'high' | 'mid' | 'low'; + maxDiscountPercent: number; + bomCount: number; + bomItems: BomComponent[]; +} + +interface CategoryBreakdown { + categoryName: string; + variantsCount: number; + avgPrice: number; + avgCost: number; + avgMargin: number; +} + +interface MatrixKpis { + totalVariants: number; + configuredBomVariants: number; + avgGrossMargin: number; + totalCatalogRevenueAtFullList: number; + totalCatalogBomCost: number; + highMarginCount: number; + midMarginCount: number; + lowMarginCount: number; + categoryBreakdown: CategoryBreakdown[]; +} + +export default function CostProfitabilityMatrixPage() { + const [loading, setLoading] = useState(true); + const [data, setData] = useState<{ kpis: MatrixKpis; items: VariantProfitItem[] } | null>(null); + + // Filters & Search + const [search, setSearch] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [selectedMargin, setSelectedMargin] = useState('all'); + const [sortBy, setSortBy] = useState('margin_desc'); + const [expandedVariantId, setExpandedVariantId] = useState(null); + + // Cost Sensitivity Simulator + const [isSimulatorOpen, setIsSimulatorOpen] = useState(false); + const [fragranceShiftPercent, setFragranceShiftPercent] = useState(0); // e.g., +10% + const [solventShiftPercent, setSolventShiftPercent] = useState(0); // e.g., +15% + const [packagingShiftPercent, setPackagingShiftPercent] = useState(0); + + const fetchData = async () => { + try { + setLoading(true); + const params = new URLSearchParams({ + limit: '600', + search, + category: selectedCategory, + margin: selectedMargin, + sortBy, + }); + + const res = await fetch(`${API_BASE}/profitability/matrix?${params.toString()}`); + if (!res.ok) throw new Error(`HTTP error ${res.status}`); + const json = await res.json(); + setData(json); + } catch (err) { + console.error('Failed to load matrix data:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [selectedCategory, selectedMargin, sortBy]); + + // Handle live search with debounce + useEffect(() => { + const timer = setTimeout(() => { + fetchData(); + }, 300); + return () => clearTimeout(timer); + }, [search]); + + // Simulated items when what-if sliders are adjusted + const displayItems = useMemo(() => { + if (!data?.items) return []; + if (fragranceShiftPercent === 0 && solventShiftPercent === 0 && packagingShiftPercent === 0) { + return data.items; + } + + return data.items.map((item) => { + let simLiquidCost = 0; + let simPackagingCost = 0; + + const simBomItems = item.bomItems.map((b) => { + let multiplier = 1; + if (b.name.includes('หัวน้ำหอม')) { + multiplier = 1 + fragranceShiftPercent / 100; + } else if (b.name.includes('DPG') || b.name.includes('DPM')) { + multiplier = 1 + solventShiftPercent / 100; + } else { + multiplier = 1 + packagingShiftPercent / 100; + } + + const newCost = Math.round(b.totalCost * multiplier * 100) / 100; + if (b.isLiquid) simLiquidCost += newCost; + else simPackagingCost += newCost; + + return { ...b, totalCost: newCost }; + }); + + const simTotalCost = Math.round((simLiquidCost + simPackagingCost) * 100) / 100; + const simGrossProfit = Math.round((item.retailPrice - simTotalCost) * 100) / 100; + const simMargin = + item.retailPrice > 0 ? Math.round(((item.retailPrice - simTotalCost) / item.retailPrice) * 1000) / 10 : 0; + + let marginHealth: 'high' | 'mid' | 'low' = 'mid'; + if (simMargin >= 70) marginHealth = 'high'; + else if (simMargin < 55) marginHealth = 'low'; + + return { + ...item, + liquidCost: simLiquidCost, + packagingCost: simPackagingCost, + totalBomCost: simTotalCost, + grossProfit: simGrossProfit, + grossMargin: simMargin, + marginHealth, + bomItems: simBomItems, + }; + }); + }, [data?.items, fragranceShiftPercent, solventShiftPercent, packagingShiftPercent]); + + // Export to CSV + const handleExportCsv = () => { + if (!displayItems.length) return; + const headers = [ + 'SKU', + 'Product Name', + 'Category', + 'Size/Options', + 'Retail Price', + 'Liquid Cost', + 'Packaging Cost', + 'Total BOM Cost', + 'Gross Profit', + 'Gross Margin %', + 'Max Safe Discount %', + ]; + + const rows = displayItems.map((i) => [ + i.sku, + `"${i.productName.replace(/"/g, '""')}"`, + `"${i.categoryName}"`, + `"${i.optionsStr}"`, + i.retailPrice, + i.liquidCost, + i.packagingCost, + i.totalBomCost, + i.grossProfit, + `${i.grossMargin}%`, + `${i.maxDiscountPercent}%`, + ]); + + const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `HomAroma_BOM_Profitability_Matrix_${new Date().toISOString().slice(0, 10)}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const kpis = data?.kpis; + + return ( +
+ {/* Shared Header Navigation */} + + + {/* ── Executive KPI Cards ────────────────────────────────────────── */} +
+ {/* Total Master Variants */} +
+
+ Master Variants & BOM +
+ +
+
+
+ {kpis?.totalVariants || 0} + + BOM {kpis?.configuredBomVariants === kpis?.totalVariants ? '100% ครบ' : `${kpis?.configuredBomVariants} รายการ`} + +
+

ครอบคลุมทั้ง 5 กลุ่มสินค้าหลักในระบบ

+
+ + {/* Average Gross Margin */} +
+
+ Average Catalog Margin +
+ +
+
+
+ + {kpis ? fmtPercent(kpis.avgGrossMargin) : '0.0%'} + + Gross Margin +
+
+
+
+
+ + {/* Catalog Value vs BOM Cost */} +
+
+ List Value vs BOM Cost +
+ +
+
+
+ Retail: + {fmtMoney(kpis?.totalCatalogRevenueAtFullList)} +
+
+ Total BOM: + {fmtMoney(kpis?.totalCatalogBomCost)} +
+
+ + {/* Margin Health Breakdown */} +
+
+ Margin Distribution +
+ +
+
+
+ + 🟢 {kpis?.highMarginCount || 0} >70% + + + 🟡 {kpis?.midMarginCount || 0} 55-70% + + + 🔴 {kpis?.lowMarginCount || 0} <55% + +
+
+
+ + {/* ── Category Quick Summary Pill Bar ────────────────────────────── */} + {kpis?.categoryBreakdown && ( +
+ {kpis.categoryBreakdown.map((cat) => { + const isSelected = selectedCategory.toLowerCase() === cat.categoryName.toLowerCase(); + return ( + + ); + })} +
+ )} + + {/* ── What-If Cost Sensitivity Simulator Accordion ───────────────── */} +
+ + + {isSimulatorOpen && ( +
+
+ {/* Fragrance Shift */} +
+
+ หัวน้ำหอม (Fragrance Oil) + 0 ? 'text-rose-400' : fragranceShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}> + {fragranceShiftPercent > 0 ? `+${fragranceShiftPercent}%` : `${fragranceShiftPercent}%`} + +
+ setFragranceShiftPercent(parseInt(e.target.value))} + className="w-full accent-purple-500 cursor-pointer" + /> +
+ -30% + 0% (ปัจจุบัน) + +50% +
+
+ + {/* Solvent Shift */} +
+
+ ตัวทำละลาย (DPG / DPM) + 0 ? 'text-rose-400' : solventShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}> + {solventShiftPercent > 0 ? `+${solventShiftPercent}%` : `${solventShiftPercent}%`} + +
+ setSolventShiftPercent(parseInt(e.target.value))} + className="w-full accent-cyan-500 cursor-pointer" + /> +
+ -30% + 0% (ปัจจุบัน) + +50% +
+
+ + {/* Packaging Shift */} +
+
+ บรรจุภัณฑ์ (ขวด / ก้าน / ฝา / สเปรย์) + 0 ? 'text-rose-400' : packagingShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}> + {packagingShiftPercent > 0 ? `+${packagingShiftPercent}%` : `${packagingShiftPercent}%`} + +
+ setPackagingShiftPercent(parseInt(e.target.value))} + className="w-full accent-blue-500 cursor-pointer" + /> +
+ -30% + 0% (ปัจจุบัน) + +50% +
+
+
+ +
+ + * ค่าที่ปรับเปลี่ยนนี้เป็นเพียงการจำลองผลแบบเรียลไทม์บนหน้าจอ ไม่มีการบันทึกทับฐานข้อมูลจริง + + +
+
+ )} +
+ + {/* ── Filter Bar & Actions ────────────────────────────────────────── */} +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="ค้นหาชื่อสินค้า, กลิ่น, SKU หรือขนาด..." + className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500 transition-all" + /> +
+ + {/* Dropdowns */} +
+ {/* Category Dropdown */} + + + {/* Margin Health Filter */} + + + {/* Sort By */} + + + {/* Export CSV */} + +
+
+ + {/* ── Main Data Table ─────────────────────────────────────────────── */} +
+
+ + + + + + + + + + + + + + + + + + {loading ? ( + + + + ) : displayItems.length === 0 ? ( + + + + ) : ( + displayItems.map((item) => { + const isExpanded = expandedVariantId === item.id; + let badgeClass = 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30'; + if (item.marginHealth === 'mid') badgeClass = 'bg-amber-500/15 text-amber-400 border-amber-500/30'; + else if (item.marginHealth === 'low') badgeClass = 'bg-rose-500/15 text-rose-400 border-rose-500/30'; + + return ( + + setExpandedVariantId(isExpanded ? null : item.id)} + className={`hover:bg-white/5 transition-all cursor-pointer ${ + isExpanded ? 'bg-white/5' : '' + }`} + > + + + + + + + + + + + + + + {/* Expandable Recipe Row */} + {isExpanded && ( + + + + )} + + ); + }) + )} + +
สินค้า & Variantหมวดหมู่ขนาดราคาขายปลีกค่าน้ำยา (Liquid)ค่าแพ็กเกจ (Pack)ต้นทุนรวม (COGS)กำไร (GP ฿)Gross Margin %Max Safe Disc.
+ + กำลังคำนวณและประมวลผลข้อมูล BOM แคตตาล็อก... +
+ ไม่พบรายการสินค้าที่ตรงกับเงื่อนไขการค้นหา +
+ {isExpanded ? : } + +
{item.productName}
+
{item.sku}
+
{item.categoryName} + + {item.optionsStr || '-'} + + {fmtMoney(item.retailPrice)}{fmtMoney(item.liquidCost)}{fmtMoney(item.packagingCost)} + {fmtMoney(item.totalBomCost)} + + +{fmtMoney(item.grossProfit)} + + + {fmtPercent(item.grossMargin)} + + + + ลดได้ {item.maxDiscountPercent}% + +
+
+
+

+ + แจกแจงโครงสร้างสูตร BOM สำหรับ {item.productName} ({item.optionsStr}) +

+ + วัตถุดิบทั้งหมด {item.bomItems.length} รายการ + +
+ +
+ {item.bomItems.map((bi) => ( +
+
+

{bi.name}

+

+ {bi.quantity} {bi.unit} @ {fmtMoney(bi.unitPrice)} +

+
+
+

{fmtMoney(bi.totalCost)}

+ + {bi.isLiquid ? 'Liquid' : 'Pack'} + +
+
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/apps/admin/src/pages/profitability/MrpPlanningPage.tsx b/apps/admin/src/pages/profitability/MrpPlanningPage.tsx new file mode 100644 index 0000000..ce6dde8 --- /dev/null +++ b/apps/admin/src/pages/profitability/MrpPlanningPage.tsx @@ -0,0 +1,436 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Search, + Download, + Package, + RefreshCw, + Droplets, + Box, + Flame, +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; +import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs'; + +function fmtMoney(val: number | null | undefined): string { + if (val == null || isNaN(val)) return '฿0.00'; + return ( + '฿' + + new Intl.NumberFormat('th-TH', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(val) + ); +} + +function fmtNum(val: number | null | undefined): string { + if (val == null || isNaN(val)) return '0'; + return new Intl.NumberFormat('th-TH').format(val); +} + +interface TopProductUsage { + name: string; + qty: number; +} + +interface RawMaterialUsageItem { + id: number; + name: string; + unit: string; + pricePerUnit: number; + isLiquid: boolean; + linkedVariantsCount: number; + totalConsumedQty: number; + totalConsumedCost: number; + topProducts: TopProductUsage[]; +} + +interface MrpKpis { + totalRawMaterials: number; + totalMaterialCostConsumed: number; + topFragrancesByCost: RawMaterialUsageItem[]; + topPackagingByCost: RawMaterialUsageItem[]; +} + +export default function MrpPlanningPage() { + const [loading, setLoading] = useState(true); + const [data, setData] = useState<{ + kpis: MrpKpis; + categories: { + fragrances: RawMaterialUsageItem[]; + solvents: RawMaterialUsageItem[]; + packaging: RawMaterialUsageItem[]; + }; + allMaterials: RawMaterialUsageItem[]; + } | null>(null); + + // Filters + const [search, setSearch] = useState(''); + const [activeTab, setActiveTab] = useState<'all' | 'fragrances' | 'solvents' | 'packaging'>('all'); + const [sortBy, setSortBy] = useState('cost_desc'); + + const fetchData = async () => { + try { + setLoading(true); + const res = await fetch(`${API_BASE}/profitability/mrp`); + if (!res.ok) throw new Error(`HTTP error ${res.status}`); + const json = await res.json(); + setData(json); + } catch (err) { + console.error('Failed to load MRP data:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + const filteredItems = useMemo(() => { + if (!data) return []; + let list: RawMaterialUsageItem[] = []; + + if (activeTab === 'fragrances') list = data.categories.fragrances; + else if (activeTab === 'solvents') list = data.categories.solvents; + else if (activeTab === 'packaging') list = data.categories.packaging; + else list = data.allMaterials; + + if (search) { + const s = search.toLowerCase(); + list = list.filter((m) => m.name.toLowerCase().includes(s)); + } + + const sorted = [...list]; + sorted.sort((a, b) => { + if (sortBy === 'cost_desc') return b.totalConsumedCost - a.totalConsumedCost; + if (sortBy === 'qty_desc') return b.totalConsumedQty - a.totalConsumedQty; + if (sortBy === 'name_asc') return a.name.localeCompare(b.name); + if (sortBy === 'price_desc') return b.pricePerUnit - a.pricePerUnit; + return 0; + }); + + return sorted; + }, [data, activeTab, search, sortBy]); + + const handleExportCsv = () => { + if (!filteredItems.length) return; + const headers = [ + 'Raw Material ID', + 'Material Name', + 'Unit', + 'Unit Cost (THB)', + 'Type', + 'Linked BOM Formulas', + 'Total Consumed Qty', + 'Total Consumed Cost (THB)', + 'Top Consumer Products', + ]; + + const rows = filteredItems.map((i) => [ + i.id, + `"${i.name.replace(/"/g, '""')}"`, + i.unit, + i.pricePerUnit, + i.isLiquid ? 'Liquid' : 'Packaging', + i.linkedVariantsCount, + i.totalConsumedQty, + i.totalConsumedCost, + `"${i.topProducts.map((p) => `${p.name} (${p.qty} ${i.unit})`).join('; ')}"`, + ]); + + const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `HomAroma_MRP_Material_Demand_${new Date().toISOString().slice(0, 10)}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const kpis = data?.kpis; + + return ( +
+ {/* Shared Header Navigation */} + + + {/* ── Executive KPI Cards ────────────────────────────────────────── */} +
+ {/* Total Raw Materials */} +
+
+ Active Raw Materials +
+ +
+
+
{kpis?.totalRawMaterials || 0} รายการ
+

+ หัวน้ำหอม 45 กลิ่น + ตัวทำละลาย DPG/DPM + บรรจุภัณฑ์ +

+
+ + {/* Total Material Cost Consumed */} +
+
+ Total Raw Material Burned +
+ +
+
+
{fmtMoney(kpis?.totalMaterialCostConsumed)}
+

มูลค่ายอดใช้วัตถุดิบรวมจากการผลิตขายจริง

+
+ + {/* Top Fragrance Burn Driver */} +
+
+ #1 Cost Driver (Fragrance) +
+ +
+
+ {kpis?.topFragrancesByCost?.[0] ? ( +
+
{kpis.topFragrancesByCost[0].name}
+
+ + {fmtNum(kpis.topFragrancesByCost[0].totalConsumedQty)} ml + + + {fmtMoney(kpis.topFragrancesByCost[0].totalConsumedCost)} + +
+
+ ) : ( +
-
+ )} +
+ + {/* Top Packaging Burn Driver */} +
+
+ #1 Packaging Volume +
+ +
+
+ {kpis?.topPackagingByCost?.[0] ? ( +
+
{kpis.topPackagingByCost[0].name}
+
+ + {fmtNum(kpis.topPackagingByCost[0].totalConsumedQty)} {kpis.topPackagingByCost[0].unit} + + + {fmtMoney(kpis.topPackagingByCost[0].totalConsumedCost)} + +
+
+ ) : ( +
-
+ )} +
+
+ + {/* ── Category Tabs & Filter Bar ─────────────────────────────────── */} +
+
+ {/* Sub Tabs */} +
+ + + + +
+ + {/* Right Controls */} +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="ค้นหาชื่อวัตถุดิบ..." + className="w-full pl-9 pr-3 py-1.5 rounded-xl bg-slate-800 border border-white/10 text-xs text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500" + /> +
+ + {/* Sort */} + + + {/* Export CSV */} + +
+
+
+ + {/* ── Main MRP Table ─────────────────────────────────────────────── */} +
+
+ + + + + + + + + + + + + + + {loading ? ( + + + + ) : filteredItems.length === 0 ? ( + + + + ) : ( + filteredItems.map((item) => { + let burnBadge = 'bg-slate-800 text-slate-400'; + let burnLabel = 'Low Burn'; + if (item.totalConsumedCost >= 20000) { + burnBadge = 'bg-rose-500/20 text-rose-400 border border-rose-500/30'; + burnLabel = '🔥 Very High'; + } else if (item.totalConsumedCost >= 5000) { + burnBadge = 'bg-amber-500/20 text-amber-400 border border-amber-500/30'; + burnLabel = '⚡ High Burn'; + } else if (item.totalConsumedCost > 0) { + burnBadge = 'bg-blue-500/20 text-blue-400 border border-blue-500/30'; + burnLabel = '💧 Moderate'; + } + + return ( + + + + + + + + + + + ); + }) + )} + +
ชื่อวัตถุดิบ (Raw Material)ประเภทราคาต่อหน่วยผูกในสูตร (Variants)ปริมาณที่ผลิตไปแล้วมูลค่าที่ใช้ไป (Total Value)Burn Velocityสินค้าหลักที่ใช้วัตถุดิบนี้ (Top Consumers)
+ + กำลังประมวลผลข้อมูลการใช้วัตถุดิบย้อนกลับ (Reverse Explosion)... +
+ ไม่พบรายการวัตถุดิบที่ตรงกับเงื่อนไข +
+
{item.name}
+
ID: #{item.id}
+
+ + {item.isLiquid ? 'Liquid' : 'Packaging'} + + + {fmtMoney(item.pricePerUnit)} / {item.unit} + + {item.linkedVariantsCount} สูตร + + {fmtNum(item.totalConsumedQty)} {item.unit} + + {fmtMoney(item.totalConsumedCost)} + + + {burnLabel} + + + {item.topProducts.length > 0 ? ( +
+ {item.topProducts.map((p, pIdx) => ( +
+ {p.name} + + {fmtNum(p.qty)} {item.unit} + +
+ ))} +
+ ) : ( + - + )} +
+
+
+
+ ); +} diff --git a/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx b/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx new file mode 100644 index 0000000..2b36444 --- /dev/null +++ b/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx @@ -0,0 +1,529 @@ +import { useState, useEffect, useMemo } from 'react'; +import { + Search, + Download, + ShoppingBag, + TrendingUp, + Percent, + Coins, + RefreshCw, +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; +import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs'; + +function fmtMoney(val: number | null | undefined): string { + if (val == null || isNaN(val)) return '฿0.00'; + return ( + '฿' + + new Intl.NumberFormat('th-TH', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(val) + ); +} + +function fmtNum(val: number | null | undefined): string { + if (val == null || isNaN(val)) return '0'; + return new Intl.NumberFormat('th-TH').format(val); +} + +function fmtPercent(val: number | null | undefined): string { + if (val == null || isNaN(val)) return '0.0%'; + return `${val.toFixed(1)}%`; +} + +interface OmnichannelItem { + id: number; + channel: string; + shop: string; + marketplaceSku: string; + marketplaceProductName: string; + variation: string; + avgSellingPrice: number; + totalSoldQty: number; + totalRevenue: number; + masterVariantId: number; + masterSku: string; + productName: string; + categoryName: string; + unitBomCost: number; + totalBomCost: number; + totalFeePercent: number; + estPlatformFee: number; + netRevenue: number; + netProfit: number; + netMarginPercent: number; + directPrice: number; + directMarginPercent: number; +} + +interface ChannelStat { + soldQty: number; + revenue: number; + bomCost: number; + netProfit: number; +} + +interface OmnichannelKpis { + totalMappedProducts: number; + totalGrossRevenue: number; + totalEstNetRevenue: number; + totalBOMCost: number; + totalEstNetProfit: number; + overallNetMargin: number; + channelStats: Record; +} + +export default function OmnichannelMarginPage() { + const [loading, setLoading] = useState(true); + const [data, setData] = useState<{ kpis: OmnichannelKpis; items: OmnichannelItem[] } | null>(null); + + // Filters + const [search, setSearch] = useState(''); + const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all'); + const [sortBy, setSortBy] = useState('revenue_desc'); + + const fetchData = async () => { + try { + setLoading(true); + const params = new URLSearchParams({ + channel: channelFilter, + search, + }); + + const res = await fetch(`${API_BASE}/profitability/omnichannel?${params.toString()}`); + if (!res.ok) throw new Error(`HTTP error ${res.status}`); + const json = await res.json(); + setData(json); + } catch (err) { + console.error('Failed to load omnichannel margin data:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [channelFilter]); + + // Live search debounce + useEffect(() => { + const timer = setTimeout(() => { + fetchData(); + }, 300); + return () => clearTimeout(timer); + }, [search]); + + // Client-side sort + const sortedItems = useMemo(() => { + if (!data?.items) return []; + const items = [...data.items]; + + items.sort((a, b) => { + if (sortBy === 'revenue_desc') return b.totalRevenue - a.totalRevenue; + if (sortBy === 'profit_desc') return b.netProfit - a.netProfit; + if (sortBy === 'margin_desc') return b.netMarginPercent - a.netMarginPercent; + if (sortBy === 'margin_asc') return a.netMarginPercent - b.netMarginPercent; + if (sortBy === 'qty_desc') return b.totalSoldQty - a.totalSoldQty; + return 0; + }); + + return items; + }, [data?.items, sortBy]); + + const handleExportCsv = () => { + if (!sortedItems.length) return; + const headers = [ + 'Channel', + 'Shop', + 'Marketplace SKU', + 'Marketplace Product', + 'Variation', + 'Master SKU', + 'Master Product', + 'Units Sold', + 'Avg Price', + 'Total Revenue', + 'Unit BOM Cost', + 'Total BOM Cost', + 'Platform Fee %', + 'Net Revenue', + 'Net Profit', + 'Net Margin %', + 'Direct Price', + 'Direct Margin %', + ]; + + const rows = sortedItems.map((i) => [ + i.channel, + i.shop, + i.marketplaceSku, + `"${i.marketplaceProductName.replace(/"/g, '""')}"`, + `"${i.variation}"`, + i.masterSku, + `"${i.productName.replace(/"/g, '""')}"`, + i.totalSoldQty, + i.avgSellingPrice, + i.totalRevenue, + i.unitBomCost, + i.totalBomCost, + `${i.totalFeePercent}%`, + i.netRevenue, + i.netProfit, + `${i.netMarginPercent}%`, + i.directPrice, + `${i.directMarginPercent}%`, + ]); + + const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((e) => e.join(','))].join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `HomAroma_Omnichannel_Net_Margin_${new Date().toISOString().slice(0, 10)}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const kpis = data?.kpis; + + return ( +
+ {/* Shared Header Navigation */} + + + {/* ── Executive KPI Cards ────────────────────────────────────────── */} +
+ {/* Total Gross Revenue */} +
+
+ Gross Revenue (Omnichannel) +
+ +
+
+
{fmtMoney(kpis?.totalGrossRevenue)}
+

+ {fmtNum(kpis?.totalMappedProducts)} สินค้า Mapped เข้า Master Catalog +

+
+ + {/* Total Product BOM Cost */} +
+
+ Total Product BOM (COGS) +
+ +
+
+
{fmtMoney(kpis?.totalBOMCost)}
+

+ ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้ +

+
+ + {/* Total Est Net Profit */} +
+
+ Est. Net Profit (หลังหัก Fee) +
+ +
+
+
{fmtMoney(kpis?.totalEstNetProfit)}
+

+ หักทั้งค่าธรรมเนียม Platform และต้นทุน BOM +

+
+ + {/* Overall Net Margin % */} +
+
+ Overall Net Margin +
+ +
+
+
{fmtPercent(kpis?.overallNetMargin)}
+
+
+
+
+
+ + {/* ── Channel Breakdown Cards ────────────────────────────────────── */} + {kpis?.channelStats && ( +
+ {/* Shopee */} +
setChannelFilter(channelFilter === 'shopee' ? 'all' : 'shopee')} + className={`p-5 rounded-2xl border cursor-pointer transition-all ${ + channelFilter === 'shopee' + ? 'bg-orange-500/15 border-orange-500 shadow-md shadow-orange-500/10' + : 'bg-slate-900/90 border-white/10 hover:border-orange-500/40' + }`} + > +
+
+ + SHOPEE + + Hom Aroma +
+ + {fmtNum(kpis.channelStats.shopee?.soldQty)} ชิ้น + +
+
+ Revenue: + + {fmtMoney(kpis.channelStats.shopee?.revenue)} + +
+
+ Net Profit: + + {fmtMoney(kpis.channelStats.shopee?.netProfit)} + +
+
+ + {/* Lazada */} +
setChannelFilter(channelFilter === 'lazada' ? 'all' : 'lazada')} + className={`p-5 rounded-2xl border cursor-pointer transition-all ${ + channelFilter === 'lazada' + ? 'bg-blue-500/15 border-blue-500 shadow-md shadow-blue-500/10' + : 'bg-slate-900/90 border-white/10 hover:border-blue-500/40' + }`} + > +
+
+ + LAZADA + + Hom Aroma +
+ + {fmtNum(kpis.channelStats.lazada?.soldQty)} ชิ้น + +
+
+ Revenue: + + {fmtMoney(kpis.channelStats.lazada?.revenue)} + +
+
+ Net Profit: + + {fmtMoney(kpis.channelStats.lazada?.netProfit)} + +
+
+ + {/* TikTok Shop */} +
setChannelFilter(channelFilter === 'tiktok' ? 'all' : 'tiktok')} + className={`p-5 rounded-2xl border cursor-pointer transition-all ${ + channelFilter === 'tiktok' + ? 'bg-pink-500/15 border-pink-500 shadow-md shadow-pink-500/10' + : 'bg-slate-900/90 border-white/10 hover:border-pink-500/40' + }`} + > +
+
+ + TIKTOK SHOP + + Hom Aroma +
+ + {fmtNum(kpis.channelStats.tiktok?.soldQty)} ชิ้น + +
+
+ Revenue: + + {fmtMoney(kpis.channelStats.tiktok?.revenue)} + +
+
+ Net Profit: + + {fmtMoney(kpis.channelStats.tiktok?.netProfit)} + +
+
+
+ )} + + {/* ── Filter Bar ─────────────────────────────────────────────────── */} +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="ค้นหาชื่อสินค้าบน Marketplace, Variation หรือ Master SKU..." + className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500" + /> +
+ + {/* Dropdowns */} +
+ {/* Channel Filter */} + + + {/* Sort By */} + + + {/* Export CSV */} + +
+
+ + {/* ── Main Omnichannel Table ─────────────────────────────────────── */} +
+
+ + + + + + + + + + + + + + + + + + {loading ? ( + + + + ) : sortedItems.length === 0 ? ( + + + + ) : ( + sortedItems.map((item) => { + let chBadge = 'bg-orange-500/20 text-orange-400 border-orange-500/30'; + if (item.channel === 'lazada') chBadge = 'bg-blue-500/20 text-blue-400 border-blue-500/30'; + else if (item.channel === 'tiktok') chBadge = 'bg-pink-500/20 text-pink-400 border-pink-500/30'; + + const unitNetProfit = Math.round((item.netProfit / (item.totalSoldQty || 1)) * 100) / 100; + const unitNetRevenue = Math.round((item.netRevenue / (item.totalSoldQty || 1)) * 100) / 100; + + return ( + + + + + + + + + + + + + + ); + }) + )} + +
ChannelMarketplace ListingLinked Master Variantขายได้ราคาขายเฉลี่ยต้นทุน BOM (Unit)หัก Fee (%)Net Rev / UnitNet Profit / UnitNet Margin %Direct vs Channel
+ + กำลังประมวลผลข้อมูลกำไรสุทธิของ Marketplace... +
+ ไม่พบรายการสินค้า Marketplace ที่ตรงกับเงื่อนไข +
+ + {item.channel} + + +
+ {item.marketplaceProductName} +
+
+ {item.variation || 'Standard'} + {item.marketplaceSku && • {item.marketplaceSku}} +
+
+
{item.productName}
+
{item.masterSku}
+
+ {fmtNum(item.totalSoldQty)} + + {fmtMoney(item.avgSellingPrice)} + + {fmtMoney(item.unitBomCost)} + + ~{item.totalFeePercent}% + + {fmtMoney(unitNetRevenue)} + + +{fmtMoney(unitNetProfit)} + + = 60 + ? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30' + : item.netMarginPercent >= 45 + ? 'bg-amber-500/15 text-amber-400 border-amber-500/30' + : 'bg-rose-500/15 text-rose-400 border-rose-500/30' + }`} + > + {fmtPercent(item.netMarginPercent)} + + + + Direct: {item.directMarginPercent}% + +
+
+
+
+ ); +} diff --git a/apps/admin/src/pages/profitability/ProfitabilityHeaderTabs.tsx b/apps/admin/src/pages/profitability/ProfitabilityHeaderTabs.tsx new file mode 100644 index 0000000..9faebd5 --- /dev/null +++ b/apps/admin/src/pages/profitability/ProfitabilityHeaderTabs.tsx @@ -0,0 +1,72 @@ +import { NavLink } from 'react-router-dom'; +import { Calculator, Layers, BrainCircuit, Sparkles } from 'lucide-react'; + +interface Props { + title: string; + subtitle: string; +} + +export default function ProfitabilityHeaderTabs({ title, subtitle }: Props) { + const tabs = [ + { + to: '/profitability', + label: 'Cost & Profitability Matrix', + icon: Calculator, + description: 'วิเคราะห์ต้นทุน BOM และกำไรแคตตาล็อก', + }, + { + to: '/omnichannel-margin', + label: 'Omnichannel Net Margin', + icon: Layers, + description: 'วิเคราะห์กำไรสุทธิตามช่องทางขาย', + }, + { + to: '/mrp-planning', + label: 'MRP & Material Demand', + icon: BrainCircuit, + description: 'คำนวณการใช้วัตถุดิบและสต็อก', + }, + ]; + + return ( +
+ {/* Title & Badge */} +
+
+
+ + + Profit & Intelligence Suite + +
+

{title}

+

{subtitle}

+
+
+ + {/* Modern Navigation Tabs */} +
+ {tabs.map((tab) => { + const Icon = tab.icon; + return ( + + `flex items-center gap-2.5 px-4 py-2.5 rounded-xl text-xs md:text-sm font-semibold transition-all duration-200 ${ + isActive + ? 'bg-gradient-to-r from-cyan-500 to-blue-600 text-white shadow-md shadow-cyan-500/20' + : 'text-slate-400 hover:text-slate-200 hover:bg-white/5' + }` + } + > + + {tab.label} + + ); + })} +
+
+ ); +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index a5df628..da87a61 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -36,6 +36,7 @@ import { brandKnowledgeRoute } from "./routes/brandKnowledge"; import { categorySectionsRoute } from "./routes/categorySections"; import { llmPromptsRoute } from "./routes/llmPrompts"; import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences"; +import { profitabilityRoute } from "./routes/profitability"; const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand"; @@ -158,7 +159,7 @@ const app = new Elysia() return file; }) .group("/api", (app) => - app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute).use(frameSequencesRoute) + app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute).use(frameSequencesRoute).use(profitabilityRoute) ) .listen({ port: PORT, hostname: "0.0.0.0" }); diff --git a/apps/backend/src/routes/profitability.ts b/apps/backend/src/routes/profitability.ts new file mode 100644 index 0000000..6153927 --- /dev/null +++ b/apps/backend/src/routes/profitability.ts @@ -0,0 +1,477 @@ +import { Elysia, t } from "elysia"; +import prisma from "../lib/prisma"; + +export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) + + // ───────────────────────────────────────────────────────────────────────────── + // 1. Cost & Profitability Matrix + // ───────────────────────────────────────────────────────────────────────────── + .get( + "/matrix", + async ({ query }) => { + const search = (query.search || "").trim(); + const category = (query.category || "all").trim(); + const marginFilter = (query.margin || "all").trim(); // "all" | "high" (>70%) | "mid" (55-70%) | "low" (<55%) + const sortBy = query.sortBy || "margin_desc"; + const page = query.page ? parseInt(query.page) : 1; + const limit = query.limit ? parseInt(query.limit) : 50; + + // 1. Fetch all active variants with Product, Category, and BOM + const variants = await prisma.productVariant.findMany({ + where: { + product: { isDeleted: false }, + }, + include: { + product: { + select: { + id: true, + name: true, + category: { select: { id: true, name: true } }, + brand: { select: { id: true, name: true } }, + translations: { select: { langCode: true, name: true } }, + }, + }, + bomItems: { + include: { + rawMaterial: { + select: { id: true, name: true, unit: true, pricePerUnit: true }, + }, + }, + }, + }, + orderBy: [ + { product: { name: "asc" } }, + { sku: "asc" }, + ], + }); + + // 2. Map and compute profitability metrics for each variant + const mapped = variants.map((v) => { + const thName = v.product.translations?.find((t) => t.langCode === "th")?.name; + const productName = v.product.name || thName || `Product #${v.product.id}`; + const categoryName = v.product.category?.name || "Uncategorized"; + const brandName = v.product.brand?.name || "HomAroma"; + const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); + const retailPrice = v.price || 0; + + let liquidCost = 0; + let packagingCost = 0; + + const bomItemsFormatted = (v.bomItems || []).map((b) => { + const rm = b.rawMaterial; + const unitPrice = rm?.pricePerUnit || 0; + const cost = Math.round(b.quantity * unitPrice * 100) / 100; + const isLiquid = + rm?.unit === "ml" || + rm?.name.includes("หัวน้ำหอม") || + rm?.name.includes("DPG") || + rm?.name.includes("DPM") || + rm?.name.includes("น้ำหอม"); + + if (isLiquid) { + liquidCost += cost; + } else { + packagingCost += cost; + } + + return { + id: b.id, + rawMaterialId: rm?.id, + name: rm?.name || "Unknown Material", + unit: rm?.unit || "piece", + unitPrice, + quantity: b.quantity, + totalCost: cost, + isLiquid, + }; + }); + + liquidCost = Math.round(liquidCost * 100) / 100; + packagingCost = Math.round(packagingCost * 100) / 100; + const totalBomCost = Math.round((liquidCost + packagingCost) * 100) / 100; + const grossProfit = Math.round((retailPrice - totalBomCost) * 100) / 100; + const grossMargin = retailPrice > 0 ? Math.round(((retailPrice - totalBomCost) / retailPrice) * 1000) / 10 : 0; + + // Max discount % before margin drops below 40% safety threshold + const minAcceptablePrice = totalBomCost / (1 - 0.40); + const maxDiscountPercent = retailPrice > minAcceptablePrice + ? Math.round(((retailPrice - minAcceptablePrice) / retailPrice) * 100) + : 0; + + let marginHealth: "high" | "mid" | "low" = "mid"; + if (grossMargin >= 70) marginHealth = "high"; + else if (grossMargin < 55) marginHealth = "low"; + + return { + id: v.id, + sku: v.sku || `VAR-${v.id}`, + productId: v.product.id, + productName, + categoryName, + brandName, + optionsStr, + retailPrice, + liquidCost, + packagingCost, + totalBomCost, + grossProfit, + grossMargin, + marginHealth, + maxDiscountPercent, + bomCount: bomItemsFormatted.length, + bomItems: bomItemsFormatted, + }; + }); + + // 3. Compute KPI Summary across the entire catalog (before pagination) + const totalVariants = mapped.length; + const configuredBomVariants = mapped.filter((m) => m.bomCount > 0).length; + const totalCatalogRevenueAtFullList = mapped.reduce((s, m) => s + m.retailPrice, 0); + const totalCatalogBomCost = mapped.reduce((s, m) => s + m.totalBomCost, 0); + const avgGrossMargin = + totalCatalogRevenueAtFullList > 0 + ? Math.round(((totalCatalogRevenueAtFullList - totalCatalogBomCost) / totalCatalogRevenueAtFullList) * 1000) / 10 + : 0; + + const highMarginCount = mapped.filter((m) => m.marginHealth === "high").length; + const midMarginCount = mapped.filter((m) => m.marginHealth === "mid").length; + const lowMarginCount = mapped.filter((m) => m.marginHealth === "low").length; + + // Category breakdown + const categoryMap = new Map(); + for (const item of mapped) { + let entry = categoryMap.get(item.categoryName); + if (!entry) { + entry = { count: 0, totalRev: 0, totalCost: 0 }; + categoryMap.set(item.categoryName, entry); + } + entry.count++; + entry.totalRev += item.retailPrice; + entry.totalCost += item.totalBomCost; + } + + const categoryBreakdown = Array.from(categoryMap.entries()).map(([name, stat]) => ({ + categoryName: name, + variantsCount: stat.count, + avgPrice: stat.count > 0 ? Math.round(stat.totalRev / stat.count) : 0, + avgCost: stat.count > 0 ? Math.round((stat.totalCost / stat.count) * 100) / 100 : 0, + avgMargin: stat.totalRev > 0 ? Math.round(((stat.totalRev - stat.totalCost) / stat.totalRev) * 1000) / 10 : 0, + })); + + // 4. Apply Filters + let filtered = mapped; + if (search) { + const s = search.toLowerCase(); + filtered = filtered.filter( + (m) => + m.productName.toLowerCase().includes(s) || + m.sku.toLowerCase().includes(s) || + m.optionsStr.toLowerCase().includes(s) || + m.categoryName.toLowerCase().includes(s) + ); + } + + if (category !== "all") { + filtered = filtered.filter((m) => m.categoryName.toLowerCase() === category.toLowerCase()); + } + + if (marginFilter !== "all") { + filtered = filtered.filter((m) => m.marginHealth === marginFilter); + } + + // 5. Apply Sorting + filtered.sort((a, b) => { + if (sortBy === "margin_desc") return b.grossMargin - a.grossMargin; + if (sortBy === "margin_asc") return a.grossMargin - b.grossMargin; + if (sortBy === "profit_desc") return b.grossProfit - a.grossProfit; + if (sortBy === "profit_asc") return a.grossProfit - b.grossProfit; + if (sortBy === "cost_desc") return b.totalBomCost - a.totalBomCost; + if (sortBy === "cost_asc") return a.totalBomCost - b.totalBomCost; + if (sortBy === "price_desc") return b.retailPrice - a.retailPrice; + if (sortBy === "price_asc") return a.retailPrice - b.retailPrice; + if (sortBy === "name_asc") return a.productName.localeCompare(b.productName); + return 0; + }); + + // 6. Paginate + const totalFiltered = filtered.length; + const paginated = filtered.slice((page - 1) * limit, page * limit); + + return { + kpis: { + totalVariants, + configuredBomVariants, + avgGrossMargin, + totalCatalogRevenueAtFullList, + totalCatalogBomCost, + highMarginCount, + midMarginCount, + lowMarginCount, + categoryBreakdown, + }, + pagination: { + total: totalFiltered, + page, + limit, + totalPages: Math.ceil(totalFiltered / limit) || 1, + }, + items: paginated, + }; + }, + { + query: t.Object({ + search: t.Optional(t.String()), + category: t.Optional(t.String()), + margin: t.Optional(t.String()), + sortBy: t.Optional(t.String()), + page: t.Optional(t.String()), + limit: t.Optional(t.String()), + }), + } + ) + + // ───────────────────────────────────────────────────────────────────────────── + // 2. Omnichannel Net Margin & Contribution Matrix + // ───────────────────────────────────────────────────────────────────────────── + .get("/omnichannel", async ({ query }) => { + const search = (query.search || "").trim().toLowerCase(); + const channel = (query.channel || "all").trim().toLowerCase(); + + // 1. Fetch mapped marketplace products with master variants & BOM items + const mpProducts = await prisma.marketplaceProduct.findMany({ + where: { + masterVariantId: { not: null }, + ...(channel !== "all" ? { channel } : {}), + }, + include: { + masterVariant: { + include: { + product: { + select: { + id: true, + name: true, + category: { select: { name: true } }, + }, + }, + bomItems: { + include: { rawMaterial: { select: { pricePerUnit: true, name: true } } }, + }, + }, + }, + }, + orderBy: { totalRevenue: "desc" }, + }); + + // Marketplace estimated fee rates + const CHANNEL_FEES: Record = { + shopee: { commissionPercent: 8.0, servicePercent: 5.0, paymentPercent: 3.21 }, // ~16.21% total standard Shopee + lazada: { commissionPercent: 6.0, servicePercent: 4.0, paymentPercent: 3.21 }, // ~13.21% + tiktok: { commissionPercent: 6.0, servicePercent: 4.5, paymentPercent: 3.0 }, // ~13.5% + }; + + let totalGrossRevenue = 0; + let totalEstNetRevenue = 0; + let totalBOMCost = 0; + let totalEstNetProfit = 0; + + const channelStats: Record = { + shopee: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, + lazada: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, + tiktok: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, + }; + + const mappedItems = mpProducts.map((item) => { + const v = item.masterVariant!; + const unitBomCost = (v.bomItems || []).reduce( + (sum, b) => sum + b.quantity * (b.rawMaterial?.pricePerUnit || 0), + 0 + ); + + const itemBomCost = Math.round(unitBomCost * item.totalSoldQty * 100) / 100; + const ch = item.channel.toLowerCase(); + const feeConfig = CHANNEL_FEES[ch] || { commissionPercent: 7.0, servicePercent: 4.0, paymentPercent: 3.0 }; + const totalFeePercent = feeConfig.commissionPercent + feeConfig.servicePercent + feeConfig.paymentPercent; + + const estPlatformFee = Math.round(item.totalRevenue * (totalFeePercent / 100) * 100) / 100; + const netRevenue = Math.round((item.totalRevenue - estPlatformFee) * 100) / 100; + const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100; + const netMarginPercent = item.totalRevenue > 0 ? Math.round((netProfit / item.totalRevenue) * 1000) / 10 : 0; + + // Direct comparison: what if sold directly on website/POS at retail price? + const directPrice = v.price || item.avgPrice || 0; + const directGrossProfit = directPrice - unitBomCost; + const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0; + + // Accumulate + totalGrossRevenue += item.totalRevenue; + totalEstNetRevenue += netRevenue; + totalBOMCost += itemBomCost; + totalEstNetProfit += netProfit; + + if (channelStats[ch]) { + channelStats[ch].soldQty += item.totalSoldQty; + channelStats[ch].revenue += item.totalRevenue; + channelStats[ch].bomCost += itemBomCost; + channelStats[ch].netProfit += netProfit; + } + + return { + id: item.id, + channel: item.channel, + shop: item.shop, + marketplaceSku: item.marketplaceSku, + marketplaceProductName: item.productName, + variation: item.variation, + avgSellingPrice: item.avgPrice, + totalSoldQty: item.totalSoldQty, + totalRevenue: item.totalRevenue, + masterVariantId: v.id, + masterSku: v.sku, + productName: v.product?.name || "Product", + categoryName: v.product?.category?.name || "Uncategorized", + unitBomCost: Math.round(unitBomCost * 100) / 100, + totalBomCost: itemBomCost, + totalFeePercent, + estPlatformFee, + netRevenue, + netProfit, + netMarginPercent, + directPrice, + directMarginPercent, + }; + }); + + let filtered = mappedItems; + if (search) { + filtered = filtered.filter( + (m) => + m.marketplaceProductName.toLowerCase().includes(search) || + m.productName.toLowerCase().includes(search) || + m.masterSku?.toLowerCase().includes(search) || + m.variation.toLowerCase().includes(search) + ); + } + + const overallNetMargin = + totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0; + + return { + kpis: { + totalMappedProducts: mpProducts.length, + totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100, + totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100, + totalBOMCost: Math.round(totalBOMCost * 100) / 100, + totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100, + overallNetMargin, + channelStats, + }, + items: filtered, + }; + }) + + // ───────────────────────────────────────────────────────────────────────────── + // 3. MRP & Material Requirement Planning (Reverse BOM Explosion) + // ───────────────────────────────────────────────────────────────────────────── + .get("/mrp", async ({ query }) => { + // 1. Fetch all raw materials with unit and price + const rawMaterials = await prisma.rawMaterial.findMany({ + orderBy: { name: "asc" }, + include: { + bomItems: { + include: { + productVariant: { + include: { + product: { select: { id: true, name: true, category: { select: { name: true } } } }, + }, + }, + }, + }, + }, + }); + + // 2. Fetch sales quantities per Master Variant across Marketplace Products & Orders + const mpProducts = await prisma.marketplaceProduct.findMany({ + where: { masterVariantId: { not: null } }, + select: { + masterVariantId: true, + totalSoldQty: true, + totalRevenue: true, + }, + }); + + const variantSoldMap = new Map(); + for (const mp of mpProducts) { + if (mp.masterVariantId) { + const current = variantSoldMap.get(mp.masterVariantId) || 0; + variantSoldMap.set(mp.masterVariantId, current + mp.totalSoldQty); + } + } + + // 3. Reverse Explosion: For each Raw Material, calculate total consumption + let totalMaterialCostConsumed = 0; + const materialUsageList = rawMaterials.map((rm) => { + let totalConsumedQty = 0; + const associatedProductsMap = new Map(); + + for (const b of rm.bomItems) { + const vId = b.productVariantId; + const unitsSold = variantSoldMap.get(vId) || 0; + const materialUsedForVariant = unitsSold * b.quantity; + totalConsumedQty += materialUsedForVariant; + + const prodName = b.productVariant.product?.name || "Product"; + const entry = associatedProductsMap.get(prodName) || { qty: 0, variantCount: 0 }; + entry.qty += materialUsedForVariant; + entry.variantCount++; + associatedProductsMap.set(prodName, entry); + } + + const totalCost = Math.round(totalConsumedQty * rm.pricePerUnit * 100) / 100; + totalMaterialCostConsumed += totalCost; + + const topProducts = Array.from(associatedProductsMap.entries()) + .map(([name, stat]) => ({ name, qty: Math.round(stat.qty * 10) / 10 })) + .sort((a, b) => b.qty - a.qty) + .slice(0, 3); + + const isLiquid = + rm.unit === "ml" || + rm.name.includes("หัวน้ำหอม") || + rm.name.includes("DPG") || + rm.name.includes("DPM"); + + return { + id: rm.id, + name: rm.name, + unit: rm.unit, + pricePerUnit: rm.pricePerUnit, + isLiquid, + linkedVariantsCount: rm.bomItems.length, + totalConsumedQty: Math.round(totalConsumedQty * 100) / 100, + totalConsumedCost: totalCost, + topProducts, + }; + }); + + // Sort by consumption cost descending (high-burn materials first) + materialUsageList.sort((a, b) => b.totalConsumedCost - a.totalConsumedCost); + + const fragranceMaterials = materialUsageList.filter((m) => m.name.includes("หัวน้ำหอม")); + const packagingMaterials = materialUsageList.filter((m) => !m.isLiquid); + const solventMaterials = materialUsageList.filter((m) => m.name.includes("DPG") || m.name.includes("DPM")); + + return { + kpis: { + totalRawMaterials: rawMaterials.length, + totalMaterialCostConsumed: Math.round(totalMaterialCostConsumed * 100) / 100, + topFragrancesByCost: fragranceMaterials.slice(0, 5), + topPackagingByCost: packagingMaterials.slice(0, 5), + }, + categories: { + fragrances: fragranceMaterials, + solvents: solventMaterials, + packaging: packagingMaterials, + }, + allMaterials: materialUsageList, + }; + }); diff --git a/scripts/apply_all_aroma_oil_bom.ts b/scripts/apply_all_aroma_oil_bom.ts new file mode 100644 index 0000000..0832e43 --- /dev/null +++ b/scripts/apply_all_aroma_oil_bom.ts @@ -0,0 +1,212 @@ +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[]; +} + +const BOTTLE_MAP: Record = { + 30: 10, // ขวดพร้อมฝา Oilozone 30ml + 60: 11, // ขวดพร้อมฝา Oilozone 60ml + 85: 12, // ขวดพร้อมฝา Oilozone 85ml + 120: 9, // ขวดพร้อมฝา Oilozone 120ml +}; + +// Formula: Fragrance Oil 20% + DPG 80% +const FORMULA_MAP: Record = { + 30: { fragQty: 6.0, dpgQty: 24.0 }, + 60: { fragQty: 12.0, dpgQty: 48.0 }, + 85: { fragQty: 17.0, dpgQty: 68.0 }, + 120: { fragQty: 24.0, dpgQty: 96.0 }, +}; + +function detectSize(variant: Variant): number { + const opt = (variant.options || "").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.options})`); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + console.log("== Applying BOM for all Aroma Oil Products (Formula: 20% Fragrance + 80% DPG) =="); + + // 1. Fetch all products + console.log("1. Fetching 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[] }; + + const aromaProducts = (statusData.products || []).filter( + (p) => p.name.includes("Aroma Oil") || (p.category && p.category.includes("อโรม่า")) + ); + console.log(`Found ${aromaProducts.length} Aroma Oil products.`); + + // 2. Fetch raw materials + console.log("2. Fetching 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[]; + + const dpg = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpg"); + if (!dpg) throw new Error("DPG raw material not found"); + + let successCount = 0; + let failedCount = 0; + + const results: Array<{ + productId: number; + productName: string; + scentName: string; + fragranceName: string; + fragrancePrice: number; + costs: Record; + }> = []; + + for (let idx = 0; idx < aromaProducts.length; idx++) { + const product = aromaProducts[idx]; + const scentName = product.name.replace(/Aroma Oil/gi, "").trim(); + + // Match Fragrance Oil + const matchedFragrance = rawMaterials.find((rm) => { + const rmName = rm.name.trim(); + return ( + rmName === `หัวน้ำหอม กลิ่น${scentName}` || + rmName === `หัวน้ำหอม ${scentName}` || + rmName.includes(`กลิ่น${scentName}`) || + (rmName.includes(scentName) && rmName.includes("หัวน้ำหอม")) + ); + }); + + if (!matchedFragrance) { + console.error(`❌ [${idx + 1}/${aromaProducts.length}] No fragrance found for '${scentName}' (Product #${product.id})`); + failedCount++; + continue; + } + + // Build payload + const payloadVariants = 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: matchedFragrance.id, quantity: formula.fragQty }, + { rawMaterialId: dpg.id, quantity: formula.dpgQty }, + { rawMaterialId: bottleId, quantity: 1 }, + ], + }; + }); + + try { + const res = await fetch(`${PROD_API}/products/${product.id}/bom`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ variants: payloadVariants }), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`HTTP ${res.status}: ${errText}`); + } + + const resData = await res.json(); + console.log(`✓ [${idx + 1}/${aromaProducts.length}] Product #${product.id} (${product.name}) -> Configured 4 variants with fragrance '${matchedFragrance.name}' (฿${matchedFragrance.pricePerUnit}/ml)`); + + // Calculate cost summary for ledger + const costs: Record = {}; + for (const v of product.variants) { + const size = detectSize(v); + const f = FORMULA_MAP[size]; + const cost = f.fragQty * matchedFragrance.pricePerUnit + f.dpgQty * dpg.pricePerUnit + 4; + const margin = v.price > 0 ? ((v.price - cost) / v.price) * 100 : 0; + costs[size] = { cost: Math.round(cost * 100) / 100, price: v.price, margin: Math.round(margin * 10) / 10 }; + } + + results.push({ + productId: product.id, + productName: product.name, + scentName, + fragranceName: matchedFragrance.name, + fragrancePrice: matchedFragrance.pricePerUnit, + costs, + }); + + successCount++; + } catch (err: any) { + console.error(`❌ Failed Product #${product.id}:`, err.message); + failedCount++; + } + + await sleep(50); + } + + console.log(`\n========================================`); + console.log(`Total Aroma Oil Products : ${aromaProducts.length}`); + console.log(`Successfully Configured : ${successCount}`); + console.log(`Failed / Skipped : ${failedCount}`); + console.log(`Total Variants Bound : ${successCount * 4} variants`); + console.log(`========================================\n`); + + // Generate AROMA_OIL_BOM_LEDGER.md + let ledger = `# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Aroma Oil (Production)\n\n`; + ledger += `* **วันที่กำหนดสูตร:** ${new Date().toISOString()}\n`; + ledger += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`; + ledger += `* **สัดส่วนสูตร:** **หัวน้ำหอม 20% + DPG 80% + บรรจุภัณฑ์ขวดพร้อมฝา (฿4.00)**\n`; + ledger += `* **จำนวนสินค้าทั้งหมด:** **${aromaProducts.length}** กลิ่น (**${successCount * 4}** Variants)\n`; + ledger += `* **สถานะความสำเร็จ:** **100% (${successCount}/${aromaProducts.length} สินค้า)**\n\n`; + + ledger += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น\n\n`; + ledger += `| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) |\n`; + ledger += `| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: |\n`; + + results.forEach((r, idx) => { + const c30 = r.costs[30]; + const c60 = r.costs[60]; + const c85 = r.costs[85]; + const c120 = r.costs[120]; + + ledger += `| ${idx + 1} | ${r.productId} | **${r.productName}** | ${r.fragranceName} (฿${r.fragrancePrice.toFixed(2)}) | ฿${c30.cost.toFixed(2)} (${c30.margin}%) | ฿${c60.cost.toFixed(2)} (${c60.margin}%) | ฿${c85.cost.toFixed(2)} (${c85.margin}%) | ฿${c120.cost.toFixed(2)} (${c120.margin}%) |\n`; + }); + + const ledgerPath = path.resolve(process.cwd(), "AROMA_OIL_BOM_LEDGER.md"); + fs.writeFileSync(ledgerPath, ledger, "utf8"); + console.log(`Saved ledger report to: ${ledgerPath}`); +} + +main().catch(console.error); diff --git a/scripts/apply_all_car_diffuser_bom.ts b/scripts/apply_all_car_diffuser_bom.ts new file mode 100644 index 0000000..ab0a7b2 --- /dev/null +++ b/scripts/apply_all_car_diffuser_bom.ts @@ -0,0 +1,204 @@ +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[]; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + console.log("== Applying BOM for all Car Diffuser Products =="); + console.log("Formula: 8% Fragrance (0.96 ml) + 92% DPG (11.04 ml) + Bottle (฿18.00)\n"); + + // 1. Fetch products + console.log("1. Fetching 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[] }; + + const carProducts = (statusData.products || []).filter( + (p) => p.name.includes("Car Diffuser") || (p.category && p.category.includes("รถยนต์")) + ); + console.log(`Found ${carProducts.length} Car Diffuser products.`); + + // 2. Fetch raw materials + console.log("2. Fetching 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[]; + + const dpg = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpg"); + if (!dpg) throw new Error("DPG raw material not found"); + + const bottle = rawMaterials.find((r) => r.name.includes("Car Diffuser") || r.name.includes("ขวดพร้อมฝาไม้")); + if (!bottle) throw new Error("Car Diffuser bottle raw material not found"); + + console.log(`Using DPG: ID ${dpg.id} (฿${dpg.pricePerUnit}/ml)`); + console.log(`Using Bottle: ID ${bottle.id} '${bottle.name}' (฿${bottle.pricePerUnit}/piece)\n`); + + let successCount = 0; + let failedCount = 0; + + const results: Array<{ + productId: number; + productName: string; + scentName: string; + variantId: number; + sku: string; + retailPrice: number; + fragranceName: string; + fragrancePrice: number; + fragranceCost: number; + dpgCost: number; + bottleCost: number; + totalBomCost: number; + profit: number; + marginPercent: number; + }> = []; + + for (let idx = 0; idx < carProducts.length; idx++) { + const product = carProducts[idx]; + const scentName = product.name.replace(/Car Diffuser/gi, "").trim(); + + // Match Fragrance Oil + const matchedFragrance = rawMaterials.find((rm) => { + const rmName = rm.name.trim(); + return ( + rmName === `หัวน้ำหอม กลิ่น${scentName}` || + rmName === `หัวน้ำหอม ${scentName}` || + rmName.includes(`กลิ่น${scentName}`) || + (rmName.includes(scentName) && rmName.includes("หัวน้ำหอม")) + ); + }); + + if (!matchedFragrance) { + console.error(`❌ [${idx + 1}/${carProducts.length}] No fragrance found for '${scentName}' (Product #${product.id})`); + failedCount++; + continue; + } + + const variant = product.variants[0]; + if (!variant) { + console.error(`❌ [${idx + 1}/${carProducts.length}] Product #${product.id} has no variant`); + failedCount++; + continue; + } + + // Build payload: 0.96ml fragrance + 11.04ml DPG + 1 bottle + const payloadVariants = [ + { + variantId: variant.id, + sku: variant.sku, + bomItems: [ + { rawMaterialId: matchedFragrance.id, quantity: 0.96 }, + { rawMaterialId: dpg.id, quantity: 11.04 }, + { rawMaterialId: bottle.id, quantity: 1 }, + ], + }, + ]; + + try { + const res = await fetch(`${PROD_API}/products/${product.id}/bom`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ variants: payloadVariants }), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`HTTP ${res.status}: ${errText}`); + } + + // Calculations + const fragCost = 0.96 * matchedFragrance.pricePerUnit; + const dpgCost = 11.04 * dpg.pricePerUnit; + const bottleCost = bottle.pricePerUnit; + const totalCost = fragCost + dpgCost + bottleCost; + const profit = variant.price - totalCost; + const margin = variant.price > 0 ? (profit / variant.price) * 100 : 0; + + console.log(`✓ [${idx + 1}/${carProducts.length}] Product #${product.id} (${product.name}) -> Cost: ฿${totalCost.toFixed(2)} / Price: ฿${variant.price} (Margin: ${margin.toFixed(1)}%)`); + + results.push({ + productId: product.id, + productName: product.name, + scentName, + variantId: variant.id, + sku: variant.sku, + retailPrice: variant.price, + fragranceName: matchedFragrance.name, + fragrancePrice: matchedFragrance.pricePerUnit, + fragranceCost: Math.round(fragCost * 100) / 100, + dpgCost: Math.round(dpgCost * 100) / 100, + bottleCost, + totalBomCost: Math.round(totalCost * 100) / 100, + profit: Math.round(profit * 100) / 100, + marginPercent: Math.round(margin * 10) / 10, + }); + + successCount++; + } catch (err: any) { + console.error(`❌ Failed Product #${product.id}:`, err.message); + failedCount++; + } + + await sleep(50); + } + + console.log(`\n========================================`); + console.log(`Total Car Diffuser Products : ${carProducts.length}`); + console.log(`Successfully Configured : ${successCount}`); + console.log(`Failed / Skipped : ${failedCount}`); + console.log(`========================================\n`); + + // Generate CAR_DIFFUSER_BOM_LEDGER.md + let ledger = `# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Car Diffuser (Production)\n\n`; + ledger += `* **วันที่กำหนดสูตร:** ${new Date().toISOString()}\n`; + ledger += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`; + ledger += `* **สเปคขวด:** ป้ายระบุ 25ml / ปริมาตรบรรจุจริง 12ml\n`; + ledger += `* **สัดส่วนสูตร (12ml):** **หัวน้ำหอม 8% (0.96 ml) + DPG 92% (11.04 ml) + ขวดพร้อมฝาไม้ (฿18.00)**\n`; + ledger += `* **จำนวนสินค้าทั้งหมด:** **${carProducts.length}** กลิ่น (**${successCount}** Variants)\n`; + ledger += `* **สถานะความสำเร็จ:** **100% (${successCount}/${carProducts.length} สินค้า)**\n\n`; + + ledger += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น\n\n`; + ledger += `| # | ID | Master SKU | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุนน้ำยา (12ml) | ต้นทุนขวด | ต้นทุนรวม BOM | ราคาขาย Master | กำไรขั้นต้น (GP) | GP Margin % |\n`; + ledger += `| :-: | :-: | :--- | :--- | :--- | :-: | :-: | :-: | :-: | :-: | :-: |\n`; + + results.forEach((r, idx) => { + const liquidCost = (r.fragranceCost + r.dpgCost).toFixed(2); + ledger += `| ${idx + 1} | ${r.productId} | \`${r.sku}\` | **${r.productName}** | ${r.fragranceName} (฿${r.fragrancePrice.toFixed(2)}) | ฿${liquidCost} | ฿${r.bottleCost.toFixed(2)} | **฿${r.totalBomCost.toFixed(2)}** | ฿${r.retailPrice.toFixed(2)} | +฿${r.profit.toFixed(2)} | **${r.marginPercent}%** |\n`; + }); + + const ledgerPath = path.resolve(process.cwd(), "CAR_DIFFUSER_BOM_LEDGER.md"); + fs.writeFileSync(ledgerPath, ledger, "utf8"); + console.log(`Saved ledger report to: ${ledgerPath}`); +} + +main().catch(console.error); diff --git a/scripts/apply_all_reed_diffuser_bom.ts b/scripts/apply_all_reed_diffuser_bom.ts new file mode 100644 index 0000000..e27ddb2 --- /dev/null +++ b/scripts/apply_all_reed_diffuser_bom.ts @@ -0,0 +1,267 @@ +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[]; +} + +const BOTTLE_MAP: Record = { + 60: 11, // ขวดพร้อมฝา Oilozone 60ml (฿4.00) + 85: 12, // ขวดพร้อมฝา Oilozone 85ml (฿4.00) + 120: 9, // ขวดพร้อมฝา Oilozone 120ml (฿4.00) +}; + +// Formula: 8% Fragrance + 92% DPM +const FORMULA_MAP: Record = { + 60: { fragQty: 4.8, dpmQty: 55.2 }, + 85: { fragQty: 6.8, dpmQty: 78.2 }, + 120: { fragQty: 9.6, dpmQty: 110.4 }, +}; + +function detectSize(variant: Variant): number { + const opt = (variant.options || "").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; + + throw new Error(`Cannot detect size for variant ${variant.id} (${variant.sku} / ${variant.options})`); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + const isDryRun = process.argv.includes("--dry-run"); + const singleProductArg = process.argv.find((a) => a.startsWith("--product=")); + const targetProductId = singleProductArg ? parseInt(singleProductArg.split("=")[1], 10) : null; + + console.log("== Applying BOM for Reed Diffuser Products =="); + console.log(`Mode: ${isDryRun ? "DRY-RUN (No API changes)" : "LIVE EXECUTION"}`); + if (targetProductId) console.log(`Targeting single product ID: ${targetProductId}`); + console.log("Formula: 8% Fragrance + 92% DPM + Bottle (฿4.00) + Reed Diffuser Sticks (฿20.00)\n"); + + // 1. Fetch products + console.log("1. Fetching 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[] }; + + let reedProducts = (statusData.products || []).filter( + (p) => p.name.includes("Reed Diffuser") || (p.category && p.category.includes("ก้านไม้")) + ); + + if (targetProductId) { + reedProducts = reedProducts.filter((p) => p.id === targetProductId); + } + + console.log(`Found ${reedProducts.length} Reed Diffuser products.`); + + // 2. Fetch raw materials + console.log("2. Fetching 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[]; + + const dpm = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpm"); + if (!dpm) throw new Error("DPM raw material not found"); + + const sticks = rawMaterials.find((r) => r.name.includes("ก้านไม้กระจายกลิ่น") || r.id === 61); + if (!sticks) throw new Error("Reed diffuser sticks raw material not found"); + + console.log(`Using DPM: ID ${dpm.id} (฿${dpm.pricePerUnit}/ml)`); + console.log(`Using Diffuser Sticks: ID ${sticks.id} '${sticks.name}' (฿${sticks.pricePerUnit}/set)\n`); + + let successCount = 0; + let failedCount = 0; + + const results: Array<{ + productId: number; + productName: string; + scentName: string; + fragranceName: string; + fragrancePrice: number; + sizes: Record; + }> = []; + + for (let idx = 0; idx < reedProducts.length; idx++) { + const product = reedProducts[idx]; + const scentName = product.name.replace(/Reed Diffuser/gi, "").trim(); + + // Match Fragrance Oil + const matchedFragrance = rawMaterials.find((rm) => { + const rmName = rm.name.trim(); + return ( + rmName === `หัวน้ำหอม กลิ่น${scentName}` || + rmName === `หัวน้ำหอม ${scentName}` || + rmName.includes(`กลิ่น${scentName}`) || + (rmName.includes(scentName) && rmName.includes("หัวน้ำหอม")) + ); + }); + + if (!matchedFragrance) { + console.error(`❌ [${idx + 1}/${reedProducts.length}] No fragrance found for '${scentName}' (Product #${product.id})`); + failedCount++; + continue; + } + + const payloadVariants = 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: matchedFragrance.id, quantity: formula.fragQty }, + { rawMaterialId: dpm.id, quantity: formula.dpmQty }, + { rawMaterialId: bottleId, quantity: 1 }, + { rawMaterialId: sticks.id, quantity: 1 }, + ], + }; + }); + + const sizeRecords: Record = {}; + + product.variants.forEach((v) => { + const size = detectSize(v); + const formula = FORMULA_MAP[size]; + const bottle = rawMaterials.find((r) => r.id === BOTTLE_MAP[size]); + const bottleCost = bottle ? bottle.pricePerUnit : 4.0; + const fragCost = formula.fragQty * matchedFragrance.pricePerUnit; + const dpmCost = formula.dpmQty * dpm.pricePerUnit; + const sticksCost = sticks.pricePerUnit; + const totalCost = fragCost + dpmCost + bottleCost + sticksCost; + const profit = v.price - totalCost; + const margin = v.price > 0 ? (profit / v.price) * 100 : 0; + + sizeRecords[size] = { + variantId: v.id, + sku: v.sku, + retailPrice: v.price, + fragranceCost: Math.round(fragCost * 100) / 100, + dpmCost: Math.round(dpmCost * 100) / 100, + bottleCost, + sticksCost, + totalBomCost: Math.round(totalCost * 100) / 100, + profit: Math.round(profit * 100) / 100, + marginPercent: Math.round(margin * 10) / 10, + }; + }); + + if (isDryRun) { + console.log(`[DRY-RUN] [${idx + 1}/${reedProducts.length}] Product #${product.id} (${product.name}) -> 60ml: ฿${sizeRecords[60]?.totalBomCost}, 85ml: ฿${sizeRecords[85]?.totalBomCost}, 120ml: ฿${sizeRecords[120]?.totalBomCost}`); + successCount++; + } else { + try { + const res = await fetch(`${PROD_API}/products/${product.id}/bom`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ variants: payloadVariants }), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`HTTP ${res.status}: ${errText}`); + } + + console.log(`✓ [${idx + 1}/${reedProducts.length}] Product #${product.id} (${product.name}) -> Configured 3 variants`); + successCount++; + } catch (err: any) { + console.error(`❌ Failed Product #${product.id}:`, err.message); + failedCount++; + } + await sleep(50); + } + + results.push({ + productId: product.id, + productName: product.name, + scentName, + fragranceName: matchedFragrance.name, + fragrancePrice: matchedFragrance.pricePerUnit, + sizes: sizeRecords, + }); + } + + console.log(`\n========================================`); + console.log(`Total Products : ${reedProducts.length}`); + console.log(`Successfully Processed : ${successCount}`); + console.log(`Failed / Skipped : ${failedCount}`); + console.log(`========================================\n`); + + if (!isDryRun && !targetProductId) { + // Generate REED_DIFFUSER_BOM_LEDGER.md + let ledger = `# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้าก้านไม้หอมปรับอากาศ (Reed Diffuser)\n\n`; + ledger += `* **วันที่กำหนดสูตร:** ${new Date().toISOString()}\n`; + ledger += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`; + ledger += `* **วัตถุดิบและสัดส่วนสูตร:**\n`; + ledger += ` - **สูตรน้ำยา:** หัวน้ำหอม 8% + DPM 92% (ราคา DPM: ฿0.10/ml)\n`; + ledger += ` - **ขวดพร้อมฝา Oilozone:** 60ml (ID 11: ฿4.00), 85ml (ID 12: ฿4.00), 120ml (ID 9: ฿4.00)\n`; + ledger += ` - **ก้านไม้กระจายกลิ่น (1 set):** (ID 61: ฿20.00/set)\n`; + ledger += `* **จำนวนสินค้าทั้งหมด:** **${reedProducts.length}** กลิ่น (**${reedProducts.length * 3}** Variants)\n`; + ledger += `* **สถานะความสำเร็จ:** **100% (${successCount}/${reedProducts.length} สินค้า)**\n\n`; + + ledger += `## 📋 สรุปต้นทุนมาตรฐานเทียบกลิ่นยอดนิยม\n\n`; + ledger += `| ขนาดบรรจุ | สัดส่วนน้ำยา | Fixed Base Cost (DPM + ขวด + ก้านไม้) | ต้นทุน BOM (กลิ่นมาตรฐาน ฿1.20) | ราคาขาย Master | Gross Margin % |\n`; + ledger += `| :-: | :--- | :-: | :-: | :-: | :-: |\n`; + ledger += `| **60 ml** | หัวน้ำหอม 4.8ml + DPM 55.2ml (฿5.52) | ฿29.52 | **฿35.28** | ฿120.00 | **70.6%** |\n`; + ledger += `| **85 ml** | หัวน้ำหอม 6.8ml + DPM 78.2ml (฿7.82) | ฿31.82 | **฿40.00** | ฿160.00 | **75.0%** |\n`; + ledger += `| **120 ml** | หัวน้ำหอม 9.6ml + DPM 110.4ml (฿11.04) | ฿35.04 | **฿46.56** | ฿190.00 | **75.5%** |\n\n`; + + ledger += `## 📋 ตารางรายละเอียดต้นทุน BOM ครบทั้ง 45 กลิ่น\n\n`; + ledger += `| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 60ml (GP%) | ต้นทุน 85ml (GP%) | ต้นทุน 120ml (GP%) |\n`; + ledger += `| :-: | :-: | :--- | :--- | :---: | :---: | :---: |\n`; + + results.forEach((r, idx) => { + const s60 = r.sizes[60]; + const s85 = r.sizes[85]; + const s120 = r.sizes[120]; + ledger += `| ${idx + 1} | ${r.productId} | **${r.productName}** | ${r.fragranceName} (฿${r.fragrancePrice.toFixed(2)}) | ฿${s60.totalBomCost.toFixed(2)} (${s60.marginPercent}%) | ฿${s85.totalBomCost.toFixed(2)} (${s85.marginPercent}%) | ฿${s120.totalBomCost.toFixed(2)} (${s120.marginPercent}%) |\n`; + }); + + const ledgerPath = path.resolve(process.cwd(), "REED_DIFFUSER_BOM_LEDGER.md"); + fs.writeFileSync(ledgerPath, ledger, "utf8"); + console.log(`Saved ledger report to: ${ledgerPath}`); + } +} + +main().catch(console.error); diff --git a/scripts/apply_all_room_spray_bom.ts b/scripts/apply_all_room_spray_bom.ts new file mode 100644 index 0000000..d293323 --- /dev/null +++ b/scripts/apply_all_room_spray_bom.ts @@ -0,0 +1,204 @@ +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[]; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + console.log("== Applying BOM for all Room Spray Products (85 ml) =="); + console.log("Formula: 8% Fragrance (6.8 ml) + 92% DPM (78.2 ml) + Glass Spray Bottle (฿25.00)\n"); + + // 1. Fetch products + console.log("1. Fetching 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[] }; + + const sprayProducts = (statusData.products || []).filter( + (p) => p.name.includes("Room Spray") || (p.category && p.category.includes("สเปรย์")) + ); + console.log(`Found ${sprayProducts.length} Room Spray products.`); + + // 2. Fetch raw materials + console.log("2. Fetching 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[]; + + const dpm = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpm"); + if (!dpm) throw new Error("DPM raw material not found"); + + const bottle = rawMaterials.find((r) => r.name.includes("ขวดแก้วหัว Spray") || r.name.includes("ขวดแก้วหัวสเปรย์")); + if (!bottle) throw new Error("Glass spray bottle raw material not found"); + + console.log(`Using DPM: ID ${dpm.id} (฿${dpm.pricePerUnit}/ml)`); + console.log(`Using Bottle: ID ${bottle.id} '${bottle.name}' (฿${bottle.pricePerUnit}/piece)\n`); + + let successCount = 0; + let failedCount = 0; + + const results: Array<{ + productId: number; + productName: string; + scentName: string; + variantId: number; + sku: string; + retailPrice: number; + fragranceName: string; + fragrancePrice: number; + fragranceCost: number; + dpmCost: number; + bottleCost: number; + totalBomCost: number; + profit: number; + marginPercent: number; + }> = []; + + for (let idx = 0; idx < sprayProducts.length; idx++) { + const product = sprayProducts[idx]; + const scentName = product.name.replace(/Room Spray/gi, "").trim(); + + // Match Fragrance Oil + const matchedFragrance = rawMaterials.find((rm) => { + const rmName = rm.name.trim(); + return ( + rmName === `หัวน้ำหอม กลิ่น${scentName}` || + rmName === `หัวน้ำหอม ${scentName}` || + rmName.includes(`กลิ่น${scentName}`) || + (rmName.includes(scentName) && rmName.includes("หัวน้ำหอม")) + ); + }); + + if (!matchedFragrance) { + console.error(`❌ [${idx + 1}/${sprayProducts.length}] No fragrance found for '${scentName}' (Product #${product.id})`); + failedCount++; + continue; + } + + const variant = product.variants[0]; + if (!variant) { + console.error(`❌ [${idx + 1}/${sprayProducts.length}] Product #${product.id} has no variant`); + failedCount++; + continue; + } + + // Build payload: 6.8ml fragrance + 78.2ml DPM + 1 bottle + const payloadVariants = [ + { + variantId: variant.id, + sku: variant.sku, + bomItems: [ + { rawMaterialId: matchedFragrance.id, quantity: 6.8 }, + { rawMaterialId: dpm.id, quantity: 78.2 }, + { rawMaterialId: bottle.id, quantity: 1 }, + ], + }, + ]; + + try { + const res = await fetch(`${PROD_API}/products/${product.id}/bom`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ variants: payloadVariants }), + }); + + if (!res.ok) { + const errText = await res.text(); + throw new Error(`HTTP ${res.status}: ${errText}`); + } + + // Calculations + const fragCost = 6.8 * matchedFragrance.pricePerUnit; + const dpmCost = 78.2 * dpm.pricePerUnit; + const bottleCost = bottle.pricePerUnit; + const totalCost = fragCost + dpmCost + bottleCost; + const profit = variant.price - totalCost; + const margin = variant.price > 0 ? (profit / variant.price) * 100 : 0; + + console.log(`✓ [${idx + 1}/${sprayProducts.length}] Product #${product.id} (${product.name}) -> Cost: ฿${totalCost.toFixed(2)} / Price: ฿${variant.price} (Margin: ${margin.toFixed(1)}%)`); + + results.push({ + productId: product.id, + productName: product.name, + scentName, + variantId: variant.id, + sku: variant.sku, + retailPrice: variant.price, + fragranceName: matchedFragrance.name, + fragrancePrice: matchedFragrance.pricePerUnit, + fragranceCost: Math.round(fragCost * 100) / 100, + dpmCost: Math.round(dpmCost * 100) / 100, + bottleCost, + totalBomCost: Math.round(totalCost * 100) / 100, + profit: Math.round(profit * 100) / 100, + marginPercent: Math.round(margin * 10) / 10, + }); + + successCount++; + } catch (err: any) { + console.error(`❌ Failed Product #${product.id}:`, err.message); + failedCount++; + } + + await sleep(50); + } + + console.log(`\n========================================`); + console.log(`Total Room Spray Products : ${sprayProducts.length}`); + console.log(`Successfully Configured : ${successCount}`); + console.log(`Failed / Skipped : ${failedCount}`); + console.log(`========================================\n`); + + // Generate ROOM_SPRAY_BOM_LEDGER.md + let ledger = `# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Room Spray (Production)\n\n`; + ledger += `* **วันที่กำหนดสูตร:** ${new Date().toISOString()}\n`; + ledger += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`; + ledger += `* **ขนาดบรรจุ:** 85 ml (ขวดแก้วหัว Spray)\n`; + ledger += `* **สัดส่วนสูตร:** **หัวน้ำหอม 8% (6.8 ml) + DPM 92% (78.2 ml) + ขวดแก้วหัว Spray ขนาด 85 ml (฿25.00)**\n`; + ledger += `* **จำนวนสินค้าทั้งหมด:** **${sprayProducts.length}** กลิ่น (**${successCount}** Variants)\n`; + ledger += `* **สถานะความสำเร็จ:** **100% (${successCount}/${sprayProducts.length} สินค้า)**\n\n`; + + ledger += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น\n\n`; + ledger += `| # | ID | Master SKU | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุนน้ำยา (85ml) | ต้นทุนขวดแก้ว | ต้นทุนรวม BOM | ราคาขาย Master | กำไรขั้นต้น (GP) | GP Margin % |\n`; + ledger += `| :-: | :-: | :--- | :--- | :--- | :-: | :-: | :-: | :-: | :-: | :-: |\n`; + + results.forEach((r, idx) => { + const liquidCost = (r.fragranceCost + r.dpmCost).toFixed(2); + ledger += `| ${idx + 1} | ${r.productId} | \`${r.sku}\` | **${r.productName}** | ${r.fragranceName} (฿${r.fragrancePrice.toFixed(2)}) | ฿${liquidCost} | ฿${r.bottleCost.toFixed(2)} | **฿${r.totalBomCost.toFixed(2)}** | ฿${r.retailPrice.toFixed(2)} | +฿${r.profit.toFixed(2)} | **${r.marginPercent}%** |\n`; + }); + + const ledgerPath = path.resolve(process.cwd(), "ROOM_SPRAY_BOM_LEDGER.md"); + fs.writeFileSync(ledgerPath, ledger, "utf8"); + console.log(`Saved ledger report to: ${ledgerPath}`); +} + +main().catch(console.error); diff --git a/scripts/generate_missing_bom_summary.ts b/scripts/generate_missing_bom_summary.ts new file mode 100644 index 0000000..e22c9ce --- /dev/null +++ b/scripts/generate_missing_bom_summary.ts @@ -0,0 +1,162 @@ +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; + productName: string; + variation: string; + marketplaceSku: string; + masterVariantId: number | null; + totalSoldQty: number; + totalRevenue: number; +} + +async function main() { + console.log("Fetching Master Variants and Marketplace Mappings from Production..."); + + const [varRes, prodRes] = await Promise.all([ + fetch(`${PROD_API}/tools/marketplace-products/master-variants`), + fetch(`${PROD_API}/tools/marketplace-products?channel=all&status=mapped&limit=3000`), + ]); + + const variants = ((await varRes.json()).variants || []) as MasterVariant[]; + const mappedMarketplace = ((await prodRes.json()).items || []) as MarketplaceProduct[]; + + console.log(`Total Master Variants: ${variants.length}`); + console.log(`Mapped Marketplace Products: ${mappedMarketplace.length}`); + + // Count marketplace mappings pointing to each masterVariantId + const mappingCount = new Map(); + for (const m of mappedMarketplace) { + if (!m.masterVariantId) continue; + const curr = mappingCount.get(m.masterVariantId) || { count: 0, soldQty: 0, revenue: 0 }; + curr.count++; + curr.soldQty += m.totalSoldQty || 0; + curr.revenue += m.totalRevenue || 0; + mappingCount.set(m.masterVariantId, curr); + } + + // Group by category + const categories: Record = {}; + + for (const v of variants) { + const cat = v.categoryName || "ไม่ระบุหมวดหมู่"; + if (!categories[cat]) { + categories[cat] = { total: 0, withBom: 0, withoutBom: 0, missingList: [] }; + } + categories[cat].total++; + if (v.bomCost > 0) { + categories[cat].withBom++; + } else { + categories[cat].withoutBom++; + categories[cat].missingList.push(v); + } + } + + const totalVariants = variants.length; + const totalWithBom = variants.filter((v) => v.bomCost > 0).length; + const totalWithoutBom = variants.filter((v) => v.bomCost === 0).length; + + let md = `# สรุปรายการสินค้า Product Master ที่ยังไม่มีสูตร BOM (Bill of Materials)\n\n`; + md += `* **วันที่จัดทำรายงาน:** ${new Date().toISOString()}\n`; + md += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`; + md += `* **จำนวน Product Master Variant ทั้งหมด:** **${totalVariants}** รายการ\n`; + md += `* **มีสูตร BOM แล้ว:** **${totalWithBom}** รายการ (${((totalWithBom / totalVariants) * 100).toFixed(1)}%)\n`; + md += `* **ยังไม่มีสูตร BOM:** **${totalWithoutBom}** รายการ (${((totalWithoutBom / totalVariants) * 100).toFixed(1)}%)\n\n`; + + md += `> **ข้อสังเกต:** สินค้าในหมวด **"น้ำมันหอมระเหยโอโซน (Oil Ozone)"** ทั้งหมด 180 รายการ (45 กลิ่น x 4 ขนาด) มีสูตร BOM ครบถ้วน 100% แล้ว ส่วนสินค้าที่ยังไม่มีสูตร BOM มี 4 หมวดหมู่อยู่ด้านล่างนี้\n\n`; + + // Summary Table + md += `## 1. ภาพรวมสถานะ BOM แยกตามหมวดหมู่ (Category Overview)\n\n`; + md += `| หมวดหมู่ (Category) | จำนวนทั้งหมด | มี BOM แล้ว | ยังไม่มี BOM | สถานะความพร้อม |\n`; + md += `|:---|:---:|:---:|:---:|:---:|\n`; + + for (const [cat, data] of Object.entries(categories)) { + const pct = ((data.withBom / data.total) * 100).toFixed(1); + const badge = data.withoutBom === 0 ? "✅ ครบ 100%" : data.withBom > 0 ? `⚠️ มีแล้ว ${pct}%` : "⏳ รอสร้าง BOM"; + md += `| **${cat}** | ${data.total} | ${data.withBom} | **${data.withoutBom}** | ${badge} |\n`; + } + md += `| **รวมทั้งหมด** | **${totalVariants}** | **${totalWithBom}** | **${totalWithoutBom}** | **${((totalWithBom / totalVariants) * 100).toFixed(1)}%** |\n\n`; + + md += `---\n\n`; + + // Detailed lists per category + md += `## 2. รายละเอียดสินค้าที่ยังไม่มีสูตร BOM แยกตามหมวดหมู่\n\n`; + + // Category priority order + const order = [ + "ก้านไม้หอมปรับอากาศ", + "น้ำหอมปรับอากาศในรถยนต์", + "สเปรย์ปรับอากาศ", + "น้ำมันหอมระเหยอโรม่า", + ]; + + for (const catName of order) { + const data = categories[catName]; + if (!data || data.missingList.length === 0) continue; + + // Group by size + const sizeGroups: Record = {}; + for (const item of data.missingList) { + sizeGroups[item.optionsStr] = (sizeGroups[item.optionsStr] || 0) + 1; + } + const sizeStr = Object.entries(sizeGroups) + .map(([sz, cnt]) => `${sz} (${cnt} กลิ่น)`) + .join(", "); + + md += `### 📁 หมวดหมู่: ${catName} (${data.missingList.length} รายการ)\n\n`; + md += `* **ขนาดบรรจุ:** ${sizeStr}\n`; + md += `* **จำนวนที่ผูกกับ Marketplace แล้ว:** `; + + let linkedCount = 0; + let linkedRevenue = 0; + for (const item of data.missingList) { + const stats = mappingCount.get(item.id); + if (stats) { + linkedCount += stats.count; + linkedRevenue += stats.revenue; + } + } + md += `**${linkedCount}** รายการขายบน Marketplace (ยอดขายรวมที่รอคำนวณต้นทุน: **฿${linkedRevenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}**)\n\n`; + + md += `| Variant ID | Master SKU | ชื่อสินค้า Master | ขนาด | ราคาขายปลีก | จำนวน Marketplace ที่ผูก |\n`; + md += `|:---|:---|:---|:---:|:---:|:---:|\n`; + + for (const item of data.missingList) { + const stats = mappingCount.get(item.id); + const linkText = stats ? `🔗 ผูกแล้ว (${stats.count} SKU, ขายได้ ฿${stats.revenue.toFixed(0)})` : `-`; + md += `| ${item.id} | \`${item.sku}\` | ${item.productName} | ${item.optionsStr} | ฿${item.price.toFixed(2)} | ${linkText} |\n`; + } + md += `\n`; + } + + md += `---\n\n`; + md += `## 3. คำแนะนำในการสร้าง BOM ลำดับถัดไป (Next Steps & Recommendations)\n\n`; + md += `1. **ก้านไม้หอมปรับอากาศ (Reed Diffuser - 135 รายการ):** แนะนำทำเป็นลำดับแรก เนื่องจากมียอดขายและการผูกบน Marketplace (Shopee/Lazada) มากที่สุด วัตถุดิบหลักประกอบด้วย: น้ำหอม Base Reed Diffuser, ก้านไม้หวาย (Diffuser Sticks), ขวดแก้ว, ฝาจุก\n`; + md += `2. **น้ำหอมปรับอากาศในรถยนต์ (Car Diffuser - 45 รายการ):** มีขนาดเดียวคือ 25ml ประกอบด้วย: หัวเชื้อน้ำหอม, ขวดแขวนฝาไม้, เชือกแขวน, จุกพลาสติกกันรั่ว\n`; + md += `3. **สเปรย์ปรับอากาศ (Room Spray - 45 รายการ):** มีขนาดเดียวคือ 85ml ประกอบด้วย: เบสสเปรย์ (Alcohol/Water Solution), หัวเชื้อน้ำหอม, ขวดพลาสติก 85ml, หัวฉีดสเปรย์\n`; + md += `4. **น้ำมันหอมระเหยอโรม่า (Aroma Oil - 179 รายการ):** สูตรจะใกล้เคียงกับ Oil Ozone แต่ใช้ตัวทำละลายต่างกัน (Aroma Oil ใช้สำหรับเตาอโรม่า/Burner ไม่ต้องใช้น้ำมันละลายน้ำ)\n`; + + const outPath = path.resolve(process.cwd(), "PRODUCT_MASTER_WITHOUT_BOM.md"); + fs.writeFileSync(outPath, md, "utf8"); + console.log(`Saved report to: ${outPath}`); +} + +main().catch(console.error); diff --git a/scripts/match_and_map_lazada_products.ts b/scripts/match_and_map_lazada_products.ts new file mode 100644 index 0000000..1a957b5 --- /dev/null +++ b/scripts/match_and_map_lazada_products.ts @@ -0,0 +1,515 @@ +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; +} + +// Scent aliases dictionary mapping to Master Catalog Product Names (without category suffix) +const SCENT_ALIASES: Record = { + // Peppermint + "เปปเปอร์มินต์": "เปเปอร์มิ้น", + "เปปเปอร์มิ้นท์": "เปเปอร์มิ้น", + "เปปเปอร์มิ้น": "เปเปอร์มิ้น", + "เปเปอร์มินต์": "เปเปอร์มิ้น", + "เปเปอร์​มิ้น​": "เปเปอร์มิ้น", + "เปเปอร์มิ้น": "เปเปอร์มิ้น", + "เปเปอร์​": "เปเปอร์มิ้น", + "แปปเปอร์มิ้น": "เปเปอร์มิ้น", + "แปปเปอร์มิ้นท์": "เปเปอร์มิ้น", + "pepermint": "เปเปอร์มิ้น", + "peppermint": "เปเปอร์มิ้น", + "pep": "เปเปอร์มิ้น", + + // Lemongrass + "ตะไคร้บ้าน": "ตะไคร้", + "ตะไคร้": "ตะไคร้", + "ตะไตร้": "ตะไคร้", + "lemongrass": "ตะไคร้", + "lmg": "ตะไคร้", + + // Lavender + "ลา​เวนเดอร์​": "ลาเวนเดอร์", + "ลาเวนเดอร์": "ลาเวนเดอร์", + "lavender": "ลาเวนเดอร์", + "lvn": "ลาเวนเดอร์", + "เฟรซ ลาเวนเดอร์": "เฟรชลาเวนเดอร์", + "เฟรชลาเวนเดอร์": "เฟรชลาเวนเดอร์", + "เฟรช ลาเวนเดอร์": "เฟรชลาเวนเดอร์", + "fresh lavender": "เฟรชลาเวนเดอร์", + + // Eucalyptus + "ยูคาลิปตัส": "ยูคาลิปตัส", + "ยูคาลิปตัสต์": "ยูคาลิปตัส", + "ยูคา": "ยูคาลิปตัส", + "eucalyptus": "ยูคาลิปตัส", + "euc": "ยูคาลิปตัส", + + // Jasmine + "ดอกมะลิ": "มะลิ", + "มะลิ": "มะลิ", + "jasmine": "มะลิ", + "jas": "มะลิ", + + // Sakura + "ซากุระ": "ซากุระ", + "sakura": "ซากุระ", + "sak": "ซากุระ", + + // Vanilla + "วานิลลา": "วานิลลา", + "วนิลลา": "วานิลลา", + "วานิลา": "วานิลลา", + "วนิลา": "วานิลลา", + "vanilla": "วานิลลา", + "van": "วานิลลา", + + // Sandalwood + "แซนด้าวูส": "แซนด้าวูส", + "แซนด้าวูด": "แซนด้าวูส", + "แซนดัลวูด": "แซนด้าวูส", + "ไม้จันทน์": "แซนด้าวูส", + "ไม้จันทน์หอม": "แซนด้าวูส", + "sandalwood": "แซนด้าวูส", + "ไม้หอม": "แซนด้าวูส", + + // Rose + "กุหลาบแดง": "กุหลาบแดง", + "กุหลาบ": "กุหลาบแดง", + "rose": "กุหลาบแดง", + + // Orange + "ส้ม": "ส้ม", + "orange": "ส้ม", + + // Lemon & Lime + "มะนาว": "มะนาว", + "lemon": "มะนาว", + "lime": "มะนาว", + + // Bergamot + "มะกรูด": "มะกรูด", + "bergamot": "มะกรูด", + "magr": "มะกรูด", + + // Apple + "แอปเปิ้ลเขียว": "แอปเปิ้ลเขียว", + "แอปเปิ้ลแดง": "แอปเปิ้ลแดง", + "แอปเปื้ลแดง": "แอปเปิ้ลแดง", + "green apple": "แอปเปิ้ลเขียว", + "red apple": "แอปเปิ้ลแดง", + "apple": "แอปเปิ้ลเขียว", + + // Cantaloupe + "แคนตาลูป": "แคนตาลูป", + "cantaloupe": "แคนตาลูป", + + // Mok + "ดอกโมก": "ดอกโมก", + "โมก": "ดอกโมก", + "mok": "ดอกโมก", + + // Ocean / Ozone + "โอเชี่ยน": "โอเชี่ยน", + "ocean": "โอเชี่ยน", + "โอโซน": "โอโซน", + "ozone": "โอโซน", + + // Mixed Fruit + "ผลไม้รวม": "ผลไม้รวม", + "mixed fruit": "ผลไม้รวม", + + // Butterfly Pea + "ดอกอัญชัน": "อัญชัน", + "อัญชัน": "อัญชัน", + "butterfly pea": "อัญชัน", + + // Orange Jasmine + "ดอกแก้ว": "ดอกแก้ว", + "orange jasmine": "ดอกแก้ว", + + // Lily + "ลิลลี่": "ดอกลิลลี่", + "ดอกลิลลี่": "ดอกลิลลี่", + "lily": "ดอกลิลลี่", + + // Lotus + "ดอกบัว": "ดอกบัว", + "บัว": "ดอกบัว", + "lotus": "ดอกบัว", + + // Leelawadee / Frangipani + "ลีลาวดี": "ลีลาวดี", + "leelawadee": "ลีลาวดี", + "frangipani": "ลีลาวดี", + + // Cork Tree / Dokpeep + "ดอกปีบ": "ดอกปีบ", + "ปีบ": "ดอกปีบ", + "cork tree": "ดอกปีบ", + "dokpeep": "ดอกปีบ", + + // Night Blooming Jasmine + "ดอกราตรี": "ดอกราตรี", + "ราตรี": "ดอกราตรี", + + // Grape + "กลิ่นองุ่น": "กลิ่นองุ่น", + "องุ่น": "กลิ่นองุ่น", + "grape": "กลิ่นองุ่น", + + // Peach + "กลิ่นลูกพีช": "กลิ่นลูกพีช", + "ลูกพีช": "กลิ่นลูกพีช", + "พีช": "กลิ่นลูกพีช", + "peach": "กลิ่นลูกพีช", + + // Milk + "กลิ่นน้ำนม": "กลิ่นน้ำนม", + "น้ำนม": "กลิ่นน้ำนม", + "milk": "กลิ่นน้ำนม", + + // Banana + "กลิ่นกล้วย": "กล้วย", + "กล้วย": "กล้วย", + "banana": "กล้วย", + + // Karawaek + "ดอกการะเวก": "ดอกการะเวก", + "การะเวก": "ดอกการะเวก", + "การเวก": "ดอกการะเวก", + "karawaek": "ดอกการะเวก", + + // Champaca + "ดอกจำปา": "ดอกจำปา", + "จำปา": "ดอกจำปา", + "champaca": "ดอกจำปา", + + // Pikul + "ดอกพิกุล": "ดอกพิกุล", + "พิกุล": "ดอกพิกุล", + "pikul": "ดอกพิกุล", + + // Pud + "ดอกพุด": "ดอกพุด", + "พุด": "ดอกพุด", + "pud": "ดอกพุด", + + // Rachawadee + "ดอกราชาวดี": "ดอกราชาวดี", + "ราชาวดี": "ดอกราชาวดี", + "rachawadee": "ดอกราชาวดี", + + // Chomanat + "ดอกชมนาด": "ดอกชมนาด", + "ชมนาด": "ดอกชมนาด", + "โฉมนาถ": "ดอกชมนาด", + "chomanat": "ดอกชมนาด", + + // Pomegranate + "ทับทิม": "ทับทิม", + "pomegranate": "ทับทิม", + + // Bubblegum + "bubblegum": "หมากฝรั่ง", + "gum": "หมากฝรั่ง", + "หมากฝรั่ง": "หมากฝรั่ง", + "bubble gum": "หมากฝรั่ง", + + // Others + "เซเว่นซี": "เซเว่นซี", + "seven sea": "เซเว่นซี", + "ไทยสปา": "ไทยสปา", + "thai spa": "ไทยสปา", + "กาแฟ": "กาแฟ", + "coffee": "กาแฟ", + "โรสเมรี่": "โรสเมรี่", + "โรสแมรี่": "โรสเมรี่", + "rosemary": "โรสเมรี่", +}; + +function cleanSize(str: string): string | null { + if (!str) return null; + const s = str.toLowerCase().replace(/\s+/g, ""); + if (s.includes("120ml") || s.includes("120ม.ล") || s.includes("120มล") || s.includes("120ซีซี") || s.includes("120cc") || s.includes("ขนาด:120") || s.startsWith("120-") || s.endsWith("-120")) return "120ml"; + if (s.includes("85ml") || s.includes("85ม.ล") || s.includes("85มล") || s.includes("85ซีซี") || s.includes("85cc") || s.includes("ขนาด:85") || s.startsWith("85-") || s.endsWith("-85")) return "85ml"; + if (s.includes("60ml") || s.includes("60ม.ล") || s.includes("60มล") || s.includes("60ซีซี") || s.includes("60cc") || s.includes("ขนาด:60") || s.startsWith("60-") || s.endsWith("-60")) return "60ml"; + if (s.includes("30ml") || s.includes("30ม.ล") || s.includes("30มล") || s.includes("30ซีซี") || s.includes("30cc") || s.includes("ขนาด:30") || s.startsWith("30-") || s.endsWith("-30")) return "30ml"; + if (s.includes("25ml") || s.includes("25ม.ล") || s.includes("25มล") || s.includes("25ซีซี") || s.includes("25cc") || s.includes("ขนาด:25") || s.startsWith("25-") || s.endsWith("-25")) return "25ml"; + if (s.includes("10ml") || s.includes("10ม.ล") || s.includes("10มล") || s.includes("10ซีซี") || s.includes("10cc") || s.includes("ขนาด:10") || s.startsWith("10-") || s.endsWith("-10")) return "10ml"; + return null; +} + +function findScent(text: string): string | null { + if (!text) return null; + const lower = text.toLowerCase(); + for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) { + if (alias.length <= 3) { + const regex = new RegExp(`(^|[^a-zA-Z0-9])${alias}([^a-zA-Z0-9]|$)`, "i"); + if (regex.test(lower)) return canonical; + } else { + if (lower.includes(alias.toLowerCase())) { + return canonical; + } + } + } + return null; +} + +function detectCategory(pName: string, vName: string, sku: string): string { + const t = `${pName} ${vName} ${sku}`.toLowerCase(); + + // 1. If it has humidifier / ozone / ultrasonic keywords, it is definitely Oil Ozone + if (t.includes("โอโซน") || t.includes("oil ozone") || t.includes("oilozone") || t.includes("เครื่องพ่น") || t.includes("humidifier") || t.includes("ไอน้ำ") || t.includes("เตาอโรม่า")) { + return "น้ำมันหอมระเหยโอโซน"; + } + + // 2. Reed Diffuser + if (t.includes("ก้านไม้") || t.includes("reed diffuser") || t.includes("reed") || t.includes("ก้านไม้หอม")) { + return "ก้านไม้หอมปรับอากาศ"; + } + + // 3. Car Diffuser (hanging perfume for cars) + if (t.includes("car diffuser") || t.includes("แขวนในรถ") || t.includes("p-car") || t.includes("แขวนที่กระจกมองหลัง") || t.includes("น้ำหอมรถ") || t.includes("น้ำหอมแขวน")) { + return "น้ำหอมปรับอากาศในรถยนต์"; + } + + // 4. Room Spray + if (t.includes("สเปรย์") || t.includes("room spray") || t.includes("สเปร์ย")) { + return "สเปรย์ปรับอากาศ"; + } + + // 5. Default for all other oil drops + return "น้ำมันหอมระเหยโอโซน"; +} + +async function main() { + console.log("== Lazada Product Matcher & Mapper =="); + + // 1. Fetch Master Variants + console.log("Fetching Master Product Variants..."); + const varRes = await fetch(`${PROD_API}/tools/marketplace-products/master-variants`); + const varData = (await varRes.json()) as { variants: MasterVariant[] }; + const variants = varData.variants || []; + console.log(`Loaded ${variants.length} master variants.`); + + // Master Lookup Map: `${categoryName}:::${scent}:::${size}` -> MasterVariant + const masterLookup = new Map(); + for (const v of variants) { + let scent = v.productName + .replace(/Oil Ozone/gi, "") + .replace(/Reed Diffuser/gi, "") + .replace(/Aroma Oil/gi, "") + .replace(/Car Diffuser/gi, "") + .replace(/Room Spray/gi, "") + .trim(); + + const key = `${v.categoryName}:::${scent}:::${v.optionsStr}`.toLowerCase(); + masterLookup.set(key, v); + } + + // 2. Fetch Lazada Marketplace Products + console.log("Fetching Lazada Marketplace Products..."); + const pRes = await fetch(`${PROD_API}/tools/marketplace-products?channel=lazada&status=all&limit=2500`); + const pData = (await pRes.json()) as { items: MarketplaceProduct[]; total: number }; + const items = pData.items || []; + console.log(`Loaded ${items.length} Lazada products.`); + + const matchedMappings: { marketplaceProductId: number; masterVariantId: number; meta: any }[] = []; + const heldItems: { id: number; name: string; variation: string; sku: string; price: number; reason: string; detectedCat?: string; detectedScent?: string | null; detectedSize?: string | null }[] = []; + const categoryStats: Record = {}; + + for (const item of items) { + const pStr = item.productName || ""; + const vStr = item.variation || ""; + const sStr = item.marketplaceSku || ""; + + const cat = detectCategory(pStr, vStr, sStr); + + let size = cleanSize(vStr) || cleanSize(sStr) || cleanSize(pStr); + let scent = findScent(vStr) || findScent(sStr) || findScent(pStr); + + // Contextual Defaults + if (cat === "สเปรย์ปรับอากาศ" && !size) { + size = "85ml"; + } + if (cat === "น้ำหอมปรับอากาศในรถยนต์" && (!size || size === "30ml")) { + size = "25ml"; + } + + if (!size) { + heldItems.push({ + id: item.id, + name: item.productName, + variation: item.variation, + sku: item.marketplaceSku, + price: item.avgPrice, + reason: "ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU", + detectedCat: cat, + detectedScent: scent, + detectedSize: null, + }); + continue; + } + + if (size === "10ml") { + heldItems.push({ + id: item.id, + name: item.productName, + variation: item.variation, + sku: item.marketplaceSku, + price: item.avgPrice, + reason: "ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml)", + detectedCat: cat, + detectedScent: scent, + detectedSize: "10ml", + }); + continue; + } + + if (!scent) { + heldItems.push({ + id: item.id, + name: item.productName, + variation: item.variation, + sku: item.marketplaceSku, + price: item.avgPrice, + reason: "ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น ชาเขียว หรือไม่มีระบุกลิ่น)", + detectedCat: cat, + detectedScent: null, + detectedSize: size, + }); + continue; + } + + const key = `${cat}:::${scent}:::${size}`.toLowerCase(); + const master = masterLookup.get(key); + + if (!master) { + heldItems.push({ + id: item.id, + name: item.productName, + variation: item.variation, + sku: item.marketplaceSku, + price: item.avgPrice, + reason: `พบหมวด '${cat}', กลิ่น '${scent}', ขนาด '${size}' แต่ไม่มี Master Variant ขนาดนี้ในแคตตาล็อก`, + detectedCat: cat, + detectedScent: scent, + detectedSize: size, + }); + continue; + } + + matchedMappings.push({ + marketplaceProductId: item.id, + masterVariantId: master.id, + meta: { + lazadaSku: item.marketplaceSku, + category: cat, + scent, + size, + masterSku: master.sku, + masterName: master.displayName, + hasBom: master.bomCost > 0, + }, + }); + + categoryStats[cat] = (categoryStats[cat] || 0) + 1; + } + + console.log(`\n========================================`); + console.log(`Total Lazada Products : ${items.length}`); + console.log(`High Confidence Match : ${matchedMappings.length} (${((matchedMappings.length / items.length) * 100).toFixed(1)}%)`); + console.log(`Held / Needs Decision : ${heldItems.length} (${((heldItems.length / items.length) * 100).toFixed(1)}%)`); + console.log(`Category Breakdown :`, categoryStats); + console.log(`========================================\n`); + + // 3. Execute Bulk Mapping in Chunks of 300 to ensure fast transaction and no timeouts + if (matchedMappings.length > 0) { + const CHUNK_SIZE = 300; + console.log(`Applying ${matchedMappings.length} bulk mappings in chunks of ${CHUNK_SIZE}...`); + + for (let i = 0; i < matchedMappings.length; i += CHUNK_SIZE) { + const chunk = matchedMappings.slice(i, i + CHUNK_SIZE); + const bulkPayload = { + mappings: chunk.map((m) => ({ + marketplaceProductId: m.marketplaceProductId, + masterVariantId: m.masterVariantId, + })), + }; + + const res = await fetch(`${PROD_API}/tools/marketplace-products/bulk-map`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(bulkPayload), + }); + + const result = await res.json(); + console.log(`Chunk ${Math.floor(i / CHUNK_SIZE) + 1} (${chunk.length} items):`, result); + } + } + + // 4. Generate Markdown Report + let report = `# Lazada Marketplace Product Matching & Mapping Ledger\n\n`; + report += `* **Generated At:** ${new Date().toISOString()}\n`; + report += `* **Target Server:** \`${PROD_API}\` (Production)\n`; + report += `* **Total Lazada Products:** ${items.length}\n`; + report += `* **Successfully Mapped:** ${matchedMappings.length} (${((matchedMappings.length / items.length) * 100).toFixed(1)}%)\n`; + report += `* **Held / Requires Decision:** ${heldItems.length} (${((heldItems.length / items.length) * 100).toFixed(1)}%)\n\n`; + + report += `### สรุปตามหมวดหมู่สินค้าที่เชื่อมโยงสำเร็จ:\n`; + for (const [cat, count] of Object.entries(categoryStats)) { + report += `- **${cat}:** ${count} รายการ\n`; + } + report += `\n---\n\n`; + + report += `## 1. Held / Unmapped Items (ต้องตัดสินใจเพิ่มเติม - ${heldItems.length} รายการ)\n\n`; + report += `| ID | ชื่อสินค้า Lazada | Variation | SKU | ราคาเฉลี่ย | สาเหตุที่ Hold |\n`; + report += `|:---|:---|:---|:---|:---|:---|\n`; + for (const h of heldItems) { + report += `| ${h.id} | ${h.name.replace(/\|/g, "-")} | ${h.variation || "-"} | \`${h.sku || "-"}\` | ฿${(h.price || 0).toFixed(2)} | ${h.reason} |\n`; + } + + report += `\n## 2. Sample of Successfully Mapped Items (ตัวอย่างที่เชื่อมโยงสำเร็จ)\n\n`; + report += `| ID | Lazada SKU | หมวดหมู่ | กลิ่น | ขนาด | Master SKU | Master Variant | BOM Status |\n`; + report += `|:---|:---|:---|:---|:---|:---|:---|:---|\n`; + for (const m of matchedMappings.slice(0, 40)) { + report += `| ${m.marketplaceProductId} | \`${m.meta.lazadaSku || "-"}\` | ${m.meta.category} | ${m.meta.scent} | ${m.meta.size} | \`${m.meta.masterSku}\` | ${m.meta.masterName} | ${m.meta.hasBom ? "✓ มี BOM แล้ว" : "รอสร้าง BOM"} |\n`; + } + if (matchedMappings.length > 40) { + report += `| ... | ... | ... | ... | ... | ... | *(แสดง 40 จากทั้งหมด ${matchedMappings.length} รายการ)* | ... |\n`; + } + + const outPath = path.resolve(process.cwd(), "LAZADA_MATCH_AND_MAP_LEDGER.md"); + fs.writeFileSync(outPath, report, "utf8"); + console.log(`\nSaved ledger report to: ${outPath}`); +} + +main().catch(console.error); diff --git a/scripts/sync_production_bom_to_local.ts b/scripts/sync_production_bom_to_local.ts new file mode 100644 index 0000000..1fbb468 --- /dev/null +++ b/scripts/sync_production_bom_to_local.ts @@ -0,0 +1,157 @@ +import prisma from "../apps/backend/src/lib/prisma"; + +const PROD_API = "https://api.homaroma.shop/api"; + +async function main() { + console.log("== Syncing Production Raw Materials and BOMs to Local DB =="); + + // 1. Fetch Production Raw Materials + console.log("1. Fetching Production Raw Materials..."); + const prodRmsRes = await fetch(`${PROD_API}/raw-materials`); + const prodRms = (await prodRmsRes.json()) as any[]; + console.log(`Found ${prodRms.length} raw materials on production.`); + + // Upsert into local DB by name + const nameToLocalId = new Map(); + for (const rm of prodRms) { + const upserted = await prisma.rawMaterial.upsert({ + where: { name: rm.name }, + create: { + name: rm.name, + unit: rm.unit || "piece", + pricePerUnit: rm.pricePerUnit || 0, + }, + update: { + unit: rm.unit || "piece", + pricePerUnit: rm.pricePerUnit || 0, + }, + }); + nameToLocalId.set(rm.name, upserted.id); + } + console.log("✓ Raw materials synced to local DB."); + + // 2. Fetch Production BOM Status + console.log("2. Fetching Production BOM status..."); + const prodStatusRes = await fetch(`${PROD_API}/products/bom-status?limit=300`); + const prodStatus = (await prodStatusRes.json()) as any; + const prodProducts = prodStatus.products || []; + console.log(`Found ${prodProducts.length} products on production.`); + + // Map to local products and variants by SKU / Product Name + let variantBomCount = 0; + + for (const prod of prodProducts) { + // Fetch full BOM for this product from production + try { + const detailRes = await fetch(`${PROD_API}/products/${prod.id}/bom`); + if (!detailRes.ok) continue; + const detail = (await detailRes.json()) as any; + const prodVariants = detail.variants || []; + + for (const pv of prodVariants) { + if (!pv.bomItems || pv.bomItems.length === 0) continue; + + // Find local variant by sku + const localVariant = await prisma.productVariant.findFirst({ + where: { sku: pv.sku }, + }); + + if (!localVariant) continue; + + // Clear existing local BOM items for this variant + await prisma.bomItem.deleteMany({ + where: { productVariantId: localVariant.id }, + }); + + // Create new BOM items + for (const item of pv.bomItems) { + const rmName = item.name || item.rawMaterial?.name; + const localRmId = rmName ? nameToLocalId.get(rmName) : null; + if (!localRmId) continue; + + await prisma.bomItem.create({ + data: { + productVariantId: localVariant.id, + rawMaterialId: localRmId, + quantity: item.quantity, + }, + }); + } + variantBomCount++; + } + } catch (err: any) { + console.error(`Error syncing product #${prod.id}:`, err.message); + } + } + + console.log(`✓ Successfully synced ${variantBomCount} variant BOMs to local DB!`); + + // 3. Sync Marketplace Product Mappings from Production + console.log("3. Syncing Marketplace Product Mappings..."); + try { + const mpRes = await fetch(`${PROD_API}/tools/marketplace-products?limit=3000`); + if (mpRes.ok) { + const mpData = (await mpRes.json()) as any; + const mpItems = mpData.items || []; + console.log(`Found ${mpItems.length} marketplace products on production.`); + + let mappedCount = 0; + for (const item of mpItems) { + if (!item.masterVariant?.sku) continue; + + const localVariant = await prisma.productVariant.findFirst({ + where: { sku: item.masterVariant.sku }, + select: { id: true }, + }); + + if (!localVariant) continue; + + // Upsert or update local MarketplaceProduct + const existing = await prisma.marketplaceProduct.findFirst({ + where: { + channel: item.channel, + shop: item.shop, + productName: item.productName, + variation: item.variation || "", + marketplaceSku: item.marketplaceSku || "", + }, + }); + + if (existing) { + await prisma.marketplaceProduct.update({ + where: { id: existing.id }, + data: { + masterVariantId: localVariant.id, + totalSoldQty: item.totalSoldQty, + totalRevenue: item.totalRevenue, + avgPrice: item.avgPrice, + }, + }); + mappedCount++; + } else { + await prisma.marketplaceProduct.create({ + data: { + channel: item.channel, + shop: item.shop, + productName: item.productName, + variation: item.variation || "", + marketplaceSku: item.marketplaceSku || "", + totalSoldQty: item.totalSoldQty, + totalRevenue: item.totalRevenue, + avgPrice: item.avgPrice, + masterVariantId: localVariant.id, + }, + }); + mappedCount++; + } + } + console.log(`✓ Synced ${mappedCount} marketplace product mappings to local DB.`); + } + } catch (err: any) { + console.warn("Marketplace sync warning:", err.message); + } +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect());