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
This commit is contained in:
1 parent
fdbbaea021
commit
c78a898af7
21 files changed
+4336
-2
No files matched your search
@@ -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%) |
|
||||||
@@ -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%** |
|
||||||
@@ -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 รายการ)* | ... |
|
||||||
@@ -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 ไม่ต้องใช้น้ำมันละลายน้ำ)
|
||||||
@@ -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%) |
|
||||||
@@ -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%** |
|
||||||
@@ -79,6 +79,9 @@ import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
|
|||||||
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
|
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
|
||||||
import PosPage from './pages/PosPage'
|
import PosPage from './pages/PosPage'
|
||||||
import UserLlmPromptsPage from './pages/UserLlmPromptsPage'
|
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'
|
import { StoreSettingsProvider } from './contexts/StoreSettingsContext'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -126,6 +129,9 @@ function App() {
|
|||||||
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
<Route path="/raw-materials" element={<RawMaterialsPage />} />
|
||||||
<Route path="/raw-materials/new" element={<RawMaterialDetailPage />} />
|
<Route path="/raw-materials/new" element={<RawMaterialDetailPage />} />
|
||||||
<Route path="/raw-materials/:id" element={<RawMaterialDetailPage />} />
|
<Route path="/raw-materials/:id" element={<RawMaterialDetailPage />} />
|
||||||
|
<Route path="/profitability" element={<CostProfitabilityMatrixPage />} />
|
||||||
|
<Route path="/omnichannel-margin" element={<OmnichannelMarginPage />} />
|
||||||
|
<Route path="/mrp-planning" element={<MrpPlanningPage />} />
|
||||||
<Route path="/settings" element={<StoreSettingsPage />} />
|
<Route path="/settings" element={<StoreSettingsPage />} />
|
||||||
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
|
<Route path="/settings/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||||
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
|
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
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 isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
|
||||||
const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
|
const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
|
||||||
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') && !isRepeatCustomers
|
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: Package, label: t('nav.products', 'Products'), to: '/products' },
|
||||||
{ icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' },
|
{ icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' },
|
||||||
{ icon: PackageOpen, label: t('nav.rawMaterials', 'Raw Materials'), to: '/raw-materials' },
|
{ 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 = [
|
const SALES_MENU = [
|
||||||
|
|||||||
@@ -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<number | null>(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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Shared Header Navigation */}
|
||||||
|
<ProfitabilityHeaderTabs
|
||||||
|
title="Cost & Profitability Matrix"
|
||||||
|
subtitle="ตารางวิเคราะห์โครงสร้างต้นทุนวัตถุดิบ (BOM), ราคาขายปลีก และอัตรากำไรขั้นต้น (Gross Profit & Margin) ครบทั้งแคตตาล็อก"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Total Master Variants */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm relative overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Master Variants & BOM</span>
|
||||||
|
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-extrabold text-white">{kpis?.totalVariants || 0}</span>
|
||||||
|
<span className="text-xs font-semibold text-emerald-400">
|
||||||
|
BOM {kpis?.configuredBomVariants === kpis?.totalVariants ? '100% ครบ' : `${kpis?.configuredBomVariants} รายการ`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">ครอบคลุมทั้ง 5 กลุ่มสินค้าหลักในระบบ</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Average Gross Margin */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm relative overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Average Catalog Margin</span>
|
||||||
|
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||||
|
<Percent className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-extrabold text-emerald-400">
|
||||||
|
{kpis ? fmtPercent(kpis.avgGrossMargin) : '0.0%'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-slate-400">Gross Margin</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-slate-800 h-1.5 rounded-full mt-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${Math.min(100, kpis?.avgGrossMargin || 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Catalog Value vs BOM Cost */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">List Value vs BOM Cost</span>
|
||||||
|
<div className="p-2 rounded-xl bg-blue-500/10 text-blue-400">
|
||||||
|
<Coins className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-bold text-white flex items-center justify-between">
|
||||||
|
<span className="text-slate-400 text-xs">Retail:</span>
|
||||||
|
<span>{fmtMoney(kpis?.totalCatalogRevenueAtFullList)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-bold text-rose-300 flex items-center justify-between mt-1">
|
||||||
|
<span className="text-slate-400 text-xs">Total BOM:</span>
|
||||||
|
<span>{fmtMoney(kpis?.totalCatalogBomCost)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Margin Health Breakdown */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Margin Distribution</span>
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-xs font-bold flex-1 text-center">
|
||||||
|
🟢 {kpis?.highMarginCount || 0} >70%
|
||||||
|
</span>
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-amber-500/15 text-amber-400 border border-amber-500/30 text-xs font-bold flex-1 text-center">
|
||||||
|
🟡 {kpis?.midMarginCount || 0} 55-70%
|
||||||
|
</span>
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-rose-500/15 text-rose-400 border border-rose-500/30 text-xs font-bold flex-1 text-center">
|
||||||
|
🔴 {kpis?.lowMarginCount || 0} <55%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Category Quick Summary Pill Bar ────────────────────────────── */}
|
||||||
|
{kpis?.categoryBreakdown && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
|
{kpis.categoryBreakdown.map((cat) => {
|
||||||
|
const isSelected = selectedCategory.toLowerCase() === cat.categoryName.toLowerCase();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cat.categoryName}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedCategory(isSelected ? 'all' : cat.categoryName)}
|
||||||
|
className={`p-3.5 rounded-2xl border text-left transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-cyan-500/15 border-cyan-500/50 shadow-md shadow-cyan-500/10'
|
||||||
|
: 'bg-slate-900/80 border-white/10 hover:border-white/20 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs font-bold text-slate-300 truncate">{cat.categoryName}</span>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-white/10 text-slate-300 font-mono">
|
||||||
|
{cat.variantsCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-2">
|
||||||
|
<span className="text-xs text-slate-400">Avg Cost ฿{cat.avgCost}</span>
|
||||||
|
<span className="text-sm font-extrabold text-emerald-400">{cat.avgMargin}%</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── What-If Cost Sensitivity Simulator Accordion ───────────────── */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-lg backdrop-blur-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsSimulatorOpen(!isSimulatorOpen)}
|
||||||
|
className="w-full px-5 py-4 flex items-center justify-between hover:bg-white/5 transition-all text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400 border border-purple-500/20">
|
||||||
|
<SlidersHorizontal className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm md:text-base font-bold text-white flex items-center gap-2">
|
||||||
|
What-If Cost Sensitivity Simulator (จำลองต้นทุนวัตถุดิบผันผวน)
|
||||||
|
{(fragranceShiftPercent !== 0 || solventShiftPercent !== 0 || packagingShiftPercent !== 0) && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold bg-purple-500 text-white animate-pulse">
|
||||||
|
Active Sim
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
ทดลองปรับราคาน้ำหอม, ตัวทำละลาย (DPG/DPM) หรือบรรจุภัณฑ์ เพื่อดูผลกระทบต่อ Gross Margin ทั้งแคตตาล็อกทันที
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
{isSimulatorOpen ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isSimulatorOpen && (
|
||||||
|
<div className="p-5 border-t border-white/10 bg-slate-950/60 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Fragrance Shift */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs font-semibold">
|
||||||
|
<span className="text-slate-300">หัวน้ำหอม (Fragrance Oil)</span>
|
||||||
|
<span className={fragranceShiftPercent > 0 ? 'text-rose-400' : fragranceShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}>
|
||||||
|
{fragranceShiftPercent > 0 ? `+${fragranceShiftPercent}%` : `${fragranceShiftPercent}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-30"
|
||||||
|
max="50"
|
||||||
|
step="5"
|
||||||
|
value={fragranceShiftPercent}
|
||||||
|
onChange={(e) => setFragranceShiftPercent(parseInt(e.target.value))}
|
||||||
|
className="w-full accent-purple-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500">
|
||||||
|
<span>-30%</span>
|
||||||
|
<span>0% (ปัจจุบัน)</span>
|
||||||
|
<span>+50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Solvent Shift */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs font-semibold">
|
||||||
|
<span className="text-slate-300">ตัวทำละลาย (DPG / DPM)</span>
|
||||||
|
<span className={solventShiftPercent > 0 ? 'text-rose-400' : solventShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}>
|
||||||
|
{solventShiftPercent > 0 ? `+${solventShiftPercent}%` : `${solventShiftPercent}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-30"
|
||||||
|
max="50"
|
||||||
|
step="5"
|
||||||
|
value={solventShiftPercent}
|
||||||
|
onChange={(e) => setSolventShiftPercent(parseInt(e.target.value))}
|
||||||
|
className="w-full accent-cyan-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500">
|
||||||
|
<span>-30%</span>
|
||||||
|
<span>0% (ปัจจุบัน)</span>
|
||||||
|
<span>+50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Packaging Shift */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs font-semibold">
|
||||||
|
<span className="text-slate-300">บรรจุภัณฑ์ (ขวด / ก้าน / ฝา / สเปรย์)</span>
|
||||||
|
<span className={packagingShiftPercent > 0 ? 'text-rose-400' : packagingShiftPercent < 0 ? 'text-emerald-400' : 'text-slate-400'}>
|
||||||
|
{packagingShiftPercent > 0 ? `+${packagingShiftPercent}%` : `${packagingShiftPercent}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="-30"
|
||||||
|
max="50"
|
||||||
|
step="5"
|
||||||
|
value={packagingShiftPercent}
|
||||||
|
onChange={(e) => setPackagingShiftPercent(parseInt(e.target.value))}
|
||||||
|
className="w-full accent-blue-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500">
|
||||||
|
<span>-30%</span>
|
||||||
|
<span>0% (ปัจจุบัน)</span>
|
||||||
|
<span>+50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-white/5">
|
||||||
|
<span className="text-xs text-slate-400 italic">
|
||||||
|
* ค่าที่ปรับเปลี่ยนนี้เป็นเพียงการจำลองผลแบบเรียลไทม์บนหน้าจอ ไม่มีการบันทึกทับฐานข้อมูลจริง
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFragranceShiftPercent(0);
|
||||||
|
setSolventShiftPercent(0);
|
||||||
|
setPackagingShiftPercent(0);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-xs font-semibold text-slate-300 transition-all flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
รีเซ็ตค่าจำลอง
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filter Bar & Actions ────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[240px]">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdowns */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Category Dropdown */}
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="all">ทุกหมวดหมู่ (All Categories)</option>
|
||||||
|
{kpis?.categoryBreakdown?.map((c) => (
|
||||||
|
<option key={c.categoryName} value={c.categoryName}>
|
||||||
|
{c.categoryName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Margin Health Filter */}
|
||||||
|
<select
|
||||||
|
value={selectedMargin}
|
||||||
|
onChange={(e) => setSelectedMargin(e.target.value)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="all">ทุกระดับมาร์จิ้น (All Margins)</option>
|
||||||
|
<option value="high">🟢 High Margin (>= 70%)</option>
|
||||||
|
<option value="mid">🟡 Mid Margin (55% - 70%)</option>
|
||||||
|
<option value="low">🔴 Low Margin (< 55%)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="margin_desc">มาร์จิ้นสูงสุด (Margin % สูงสุด)</option>
|
||||||
|
<option value="margin_asc">มาร์จิ้นต่ำสุด (Margin % ต่ำสุด)</option>
|
||||||
|
<option value="profit_desc">กำไรบาทสูงสุด (GP บาทสูงสุด)</option>
|
||||||
|
<option value="profit_asc">กำไรบาทต่ำสุด (GP บาทต่ำสุด)</option>
|
||||||
|
<option value="cost_desc">ต้นทุน BOM สูงสุด</option>
|
||||||
|
<option value="cost_asc">ต้นทุน BOM ต่ำสุด</option>
|
||||||
|
<option value="price_desc">ราคาขายสูงสุด</option>
|
||||||
|
<option value="price_asc">ราคาขายต่ำสุด</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Export CSV */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs md:text-sm font-bold flex items-center gap-2 transition-all"
|
||||||
|
title="ดาวน์โหลดข้อมูลเป็นไฟล์ Excel / CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main Data Table ─────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
<th className="py-4 px-4 w-10"></th>
|
||||||
|
<th className="py-4 px-4">สินค้า & Variant</th>
|
||||||
|
<th className="py-4 px-4">หมวดหมู่</th>
|
||||||
|
<th className="py-4 px-4 text-center">ขนาด</th>
|
||||||
|
<th className="py-4 px-4 text-right">ราคาขายปลีก</th>
|
||||||
|
<th className="py-4 px-4 text-right">ค่าน้ำยา (Liquid)</th>
|
||||||
|
<th className="py-4 px-4 text-right">ค่าแพ็กเกจ (Pack)</th>
|
||||||
|
<th className="py-4 px-4 text-right bg-white/5">ต้นทุนรวม (COGS)</th>
|
||||||
|
<th className="py-4 px-4 text-right">กำไร (GP ฿)</th>
|
||||||
|
<th className="py-4 px-4 text-right">Gross Margin %</th>
|
||||||
|
<th className="py-4 px-4 text-center">Max Safe Disc.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||||
|
กำลังคำนวณและประมวลผลข้อมูล BOM แคตตาล็อก...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : displayItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
|
ไม่พบรายการสินค้าที่ตรงกับเงื่อนไขการค้นหา
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
<tr
|
||||||
|
onClick={() => setExpandedVariantId(isExpanded ? null : item.id)}
|
||||||
|
className={`hover:bg-white/5 transition-all cursor-pointer ${
|
||||||
|
isExpanded ? 'bg-white/5' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="py-3.5 px-4 text-slate-400 text-center">
|
||||||
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="font-bold text-white leading-tight">{item.productName}</div>
|
||||||
|
<div className="text-[11px] font-mono text-cyan-400 mt-0.5">{item.sku}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-slate-300">{item.categoryName}</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-white/10 text-slate-200 text-xs font-mono font-medium">
|
||||||
|
{item.optionsStr || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-bold text-white">{fmtMoney(item.retailPrice)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right text-slate-300 font-mono">{fmtMoney(item.liquidCost)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right text-slate-300 font-mono">{fmtMoney(item.packagingCost)}</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-rose-300 bg-white/5">
|
||||||
|
{fmtMoney(item.totalBomCost)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-bold text-emerald-400 font-mono">
|
||||||
|
+{fmtMoney(item.grossProfit)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right">
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-bold border inline-block min-w-[65px] text-center font-mono ${badgeClass}`}
|
||||||
|
>
|
||||||
|
{fmtPercent(item.grossMargin)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 text-xs font-bold font-mono"
|
||||||
|
title="ลดราคาได้สูงสุดเท่านี้ก่อนที่มาร์จิ้นจะตกต่ำกว่า 40%"
|
||||||
|
>
|
||||||
|
ลดได้ {item.maxDiscountPercent}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expandable Recipe Row */}
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="bg-slate-950/60 border-y border-cyan-500/20">
|
||||||
|
<td colSpan={11} className="py-4 px-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-cyan-400 flex items-center gap-1.5">
|
||||||
|
<Layers className="w-3.5 h-3.5" />
|
||||||
|
แจกแจงโครงสร้างสูตร BOM สำหรับ {item.productName} ({item.optionsStr})
|
||||||
|
</h4>
|
||||||
|
<span className="text-xs text-slate-400 font-mono">
|
||||||
|
วัตถุดิบทั้งหมด {item.bomItems.length} รายการ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2.5">
|
||||||
|
{item.bomItems.map((bi) => (
|
||||||
|
<div
|
||||||
|
key={bi.id}
|
||||||
|
className="p-2.5 rounded-xl bg-slate-900 border border-white/10 flex items-center justify-between text-xs"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-200">{bi.name}</p>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5 font-mono">
|
||||||
|
{bi.quantity} {bi.unit} @ {fmtMoney(bi.unitPrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold text-white font-mono">{fmtMoney(bi.totalCost)}</p>
|
||||||
|
<span
|
||||||
|
className={`text-[9px] px-1.5 py-0.5 rounded font-semibold ${
|
||||||
|
bi.isLiquid
|
||||||
|
? 'bg-purple-500/10 text-purple-400'
|
||||||
|
: 'bg-blue-500/10 text-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{bi.isLiquid ? 'Liquid' : 'Pack'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Shared Header Navigation */}
|
||||||
|
<ProfitabilityHeaderTabs
|
||||||
|
title="MRP & Material Demand Planning"
|
||||||
|
subtitle="Reverse BOM Explosion: คำนวณปริมาณการใช้วัตถุดิบจริงย้อนกลับจากประวัติคำสั่งซื้อ เพื่อบริหารสต็อกและวางแผนการจัดซื้อ"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Total Raw Materials */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Active Raw Materials</span>
|
||||||
|
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||||
|
<Box className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-extrabold text-white">{kpis?.totalRawMaterials || 0} รายการ</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">
|
||||||
|
หัวน้ำหอม 45 กลิ่น + ตัวทำละลาย DPG/DPM + บรรจุภัณฑ์
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Material Cost Consumed */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Raw Material Burned</span>
|
||||||
|
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
|
||||||
|
<Flame className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalMaterialCostConsumed)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">มูลค่ายอดใช้วัตถุดิบรวมจากการผลิตขายจริง</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Fragrance Burn Driver */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">#1 Cost Driver (Fragrance)</span>
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||||
|
<Droplets className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{kpis?.topFragrancesByCost?.[0] ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white truncate">{kpis.topFragrancesByCost[0].name}</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1 text-xs">
|
||||||
|
<span className="text-slate-400 font-mono">
|
||||||
|
{fmtNum(kpis.topFragrancesByCost[0].totalConsumedQty)} ml
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-purple-400 font-mono">
|
||||||
|
{fmtMoney(kpis.topFragrancesByCost[0].totalConsumedCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-500">-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Packaging Burn Driver */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">#1 Packaging Volume</span>
|
||||||
|
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||||
|
<Package className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{kpis?.topPackagingByCost?.[0] ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold text-white truncate">{kpis.topPackagingByCost[0].name}</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1 text-xs">
|
||||||
|
<span className="text-slate-400 font-mono">
|
||||||
|
{fmtNum(kpis.topPackagingByCost[0].totalConsumedQty)} {kpis.topPackagingByCost[0].unit}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtMoney(kpis.topPackagingByCost[0].totalConsumedCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-slate-500">-</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Category Tabs & Filter Bar ─────────────────────────────────── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-3 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Sub Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'bg-cyan-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทั้งหมด ({data?.allMaterials?.length || 0})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('fragrances')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'fragrances'
|
||||||
|
? 'bg-purple-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🌸 หัวน้ำหอม ({data?.categories.fragrances?.length || 0})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('solvents')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'solvents'
|
||||||
|
? 'bg-blue-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🧪 ตัวทำละลาย DPG/DPM ({data?.categories.solvents?.length || 0})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('packaging')}
|
||||||
|
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'packaging'
|
||||||
|
? 'bg-emerald-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
📦 บรรจุภัณฑ์ ขวด/ก้าน/ฝา ({data?.categories.packaging?.length || 0})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative min-w-[200px]">
|
||||||
|
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-slate-800 border border-white/10 text-xs text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="cost_desc">มูลค่าที่ใช้สูงสุด (Consumed Cost)</option>
|
||||||
|
<option value="qty_desc">ปริมาณที่ใช้สูงสุด (Volume Qty)</option>
|
||||||
|
<option value="price_desc">ราคาต่อหน่วยสูงสุด</option>
|
||||||
|
<option value="name_asc">ชื่อ ก-ฮ (A-Z)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Export CSV */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs font-bold flex items-center gap-1.5 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main MRP Table ─────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
<th className="py-4 px-4">ชื่อวัตถุดิบ (Raw Material)</th>
|
||||||
|
<th className="py-4 px-4 text-center">ประเภท</th>
|
||||||
|
<th className="py-4 px-4 text-right">ราคาต่อหน่วย</th>
|
||||||
|
<th className="py-4 px-4 text-center">ผูกในสูตร (Variants)</th>
|
||||||
|
<th className="py-4 px-4 text-right">ปริมาณที่ผลิตไปแล้ว</th>
|
||||||
|
<th className="py-4 px-4 text-right bg-white/5">มูลค่าที่ใช้ไป (Total Value)</th>
|
||||||
|
<th className="py-4 px-4 text-center">Burn Velocity</th>
|
||||||
|
<th className="py-4 px-4">สินค้าหลักที่ใช้วัตถุดิบนี้ (Top Consumers)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="py-12 text-center text-slate-400">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||||
|
กำลังประมวลผลข้อมูลการใช้วัตถุดิบย้อนกลับ (Reverse Explosion)...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : filteredItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="py-12 text-center text-slate-400">
|
||||||
|
ไม่พบรายการวัตถุดิบที่ตรงกับเงื่อนไข
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<tr key={item.id} className="hover:bg-white/5 transition-all">
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="font-bold text-white">{item.name}</div>
|
||||||
|
<div className="text-[11px] text-slate-400 font-mono">ID: #{item.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-md text-[10px] font-bold ${
|
||||||
|
item.isLiquid
|
||||||
|
? 'bg-purple-500/10 text-purple-400 border border-purple-500/20'
|
||||||
|
: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.isLiquid ? 'Liquid' : 'Packaging'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-white">
|
||||||
|
{fmtMoney(item.pricePerUnit)} / {item.unit}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center font-mono font-semibold text-slate-300">
|
||||||
|
{item.linkedVariantsCount} สูตร
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||||||
|
{fmtNum(item.totalConsumedQty)} {item.unit}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-rose-300 bg-white/5">
|
||||||
|
{fmtMoney(item.totalConsumedCost)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span className={`px-2.5 py-1 rounded-full text-[11px] font-bold inline-block ${burnBadge}`}>
|
||||||
|
{burnLabel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 max-w-[280px]">
|
||||||
|
{item.topProducts.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{item.topProducts.map((p, pIdx) => (
|
||||||
|
<div key={pIdx} className="text-[11px] text-slate-300 flex items-center justify-between">
|
||||||
|
<span className="truncate pr-2">{p.name}</span>
|
||||||
|
<span className="text-cyan-400 font-mono shrink-0">
|
||||||
|
{fmtNum(p.qty)} {item.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-500">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, ChannelStat>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Shared Header Navigation */}
|
||||||
|
<ProfitabilityHeaderTabs
|
||||||
|
title="Omnichannel Net Margin & Contribution"
|
||||||
|
subtitle="วิเคราะห์กำไรสุทธิตามช่องทางขาย (Shopee, Lazada, TikTok, Direct) หลังหักค่าธรรมเนียม Platform และต้นทุนผลิต BOM จริง"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Total Gross Revenue */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue (Omnichannel)</span>
|
||||||
|
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||||
|
<Coins className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2 font-mono">
|
||||||
|
{fmtNum(kpis?.totalMappedProducts)} สินค้า Mapped เข้า Master Catalog
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Product BOM Cost */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Product BOM (COGS)</span>
|
||||||
|
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">
|
||||||
|
ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Est Net Profit */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Est. Net Profit (หลังหัก Fee)</span>
|
||||||
|
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||||
|
<TrendingUp className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-emerald-400">{fmtMoney(kpis?.totalEstNetProfit)}</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">
|
||||||
|
หักทั้งค่าธรรมเนียม Platform และต้นทุน BOM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overall Net Margin % */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Overall Net Margin</span>
|
||||||
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||||
|
<Percent className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-extrabold text-purple-400">{fmtPercent(kpis?.overallNetMargin)}</div>
|
||||||
|
<div className="w-full bg-slate-800 h-1.5 rounded-full mt-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-indigo-400 h-full rounded-full"
|
||||||
|
style={{ width: `${Math.min(100, kpis?.overallNetMargin || 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Channel Breakdown Cards ────────────────────────────────────── */}
|
||||||
|
{kpis?.channelStats && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Shopee */}
|
||||||
|
<div
|
||||||
|
onClick={() => 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-orange-500/20 text-orange-400 text-xs font-extrabold">
|
||||||
|
SHOPEE
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">Hom Aroma</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||||
|
{fmtNum(kpis.channelStats.shopee?.soldQty)} ชิ้น
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-3">
|
||||||
|
<span className="text-xs text-slate-400">Revenue:</span>
|
||||||
|
<span className="text-base font-bold text-white font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.shopee?.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1">
|
||||||
|
<span className="text-xs text-slate-400">Net Profit:</span>
|
||||||
|
<span className="text-base font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.shopee?.netProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lazada */}
|
||||||
|
<div
|
||||||
|
onClick={() => 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-blue-500/20 text-blue-400 text-xs font-extrabold">
|
||||||
|
LAZADA
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">Hom Aroma</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||||
|
{fmtNum(kpis.channelStats.lazada?.soldQty)} ชิ้น
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-3">
|
||||||
|
<span className="text-xs text-slate-400">Revenue:</span>
|
||||||
|
<span className="text-base font-bold text-white font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.lazada?.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1">
|
||||||
|
<span className="text-xs text-slate-400">Net Profit:</span>
|
||||||
|
<span className="text-base font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.lazada?.netProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TikTok Shop */}
|
||||||
|
<div
|
||||||
|
onClick={() => 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2.5 py-1 rounded-lg bg-pink-500/20 text-pink-400 text-xs font-extrabold">
|
||||||
|
TIKTOK SHOP
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">Hom Aroma</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-300 font-mono">
|
||||||
|
{fmtNum(kpis.channelStats.tiktok?.soldQty)} ชิ้น
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-3">
|
||||||
|
<span className="text-xs text-slate-400">Revenue:</span>
|
||||||
|
<span className="text-base font-bold text-white font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.tiktok?.revenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between mt-1">
|
||||||
|
<span className="text-xs text-slate-400">Net Profit:</span>
|
||||||
|
<span className="text-base font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtMoney(kpis.channelStats.tiktok?.netProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Filter Bar ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[240px]">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdowns */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Channel Filter */}
|
||||||
|
<select
|
||||||
|
value={channelFilter}
|
||||||
|
onChange={(e) => setChannelFilter(e.target.value as any)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="all">ทุกช่องทาง (All Channels)</option>
|
||||||
|
<option value="shopee">Shopee</option>
|
||||||
|
<option value="lazada">Lazada</option>
|
||||||
|
<option value="tiktok">TikTok Shop</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="revenue_desc">ยอดขายรวมสูงสุด (Revenue)</option>
|
||||||
|
<option value="profit_desc">กำไรสุทธิรวมสูงสุด (Net Profit)</option>
|
||||||
|
<option value="margin_desc">อัตรากำไรสุทธิสูงสุด (Net Margin %)</option>
|
||||||
|
<option value="margin_asc">อัตรากำไรสุทธิต่ำสุด (Risk Margin %)</option>
|
||||||
|
<option value="qty_desc">จำนวนชิ้นที่ขายได้สูงสุด (Units Sold)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Export CSV */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs md:text-sm font-bold flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main Omnichannel Table ─────────────────────────────────────── */}
|
||||||
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-950/80 border-b border-white/10 text-[11px] font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
<th className="py-4 px-4">Channel</th>
|
||||||
|
<th className="py-4 px-4">Marketplace Listing</th>
|
||||||
|
<th className="py-4 px-4">Linked Master Variant</th>
|
||||||
|
<th className="py-4 px-4 text-center">ขายได้</th>
|
||||||
|
<th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th>
|
||||||
|
<th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th>
|
||||||
|
<th className="py-4 px-4 text-right">หัก Fee (%)</th>
|
||||||
|
<th className="py-4 px-4 text-right">Net Rev / Unit</th>
|
||||||
|
<th className="py-4 px-4 text-right bg-white/5">Net Profit / Unit</th>
|
||||||
|
<th className="py-4 px-4 text-right">Net Margin %</th>
|
||||||
|
<th className="py-4 px-4 text-center">Direct vs Channel</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||||
|
กำลังประมวลผลข้อมูลกำไรสุทธิของ Marketplace...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : sortedItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
|
ไม่พบรายการสินค้า Marketplace ที่ตรงกับเงื่อนไข
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<tr key={item.id} className="hover:bg-white/5 transition-all">
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<span className={`px-2 py-1 rounded-md text-[10px] font-extrabold uppercase border ${chBadge}`}>
|
||||||
|
{item.channel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 max-w-[280px]">
|
||||||
|
<div className="font-bold text-white truncate" title={item.marketplaceProductName}>
|
||||||
|
{item.marketplaceProductName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="font-mono text-cyan-400">{item.variation || 'Standard'}</span>
|
||||||
|
{item.marketplaceSku && <span>• {item.marketplaceSku}</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="font-semibold text-slate-200">{item.productName}</div>
|
||||||
|
<div className="text-[11px] font-mono text-slate-400">{item.masterSku}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center font-mono font-bold text-white">
|
||||||
|
{fmtNum(item.totalSoldQty)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-white">
|
||||||
|
{fmtMoney(item.avgSellingPrice)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-rose-300">
|
||||||
|
{fmtMoney(item.unitBomCost)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-slate-400">
|
||||||
|
~{item.totalFeePercent}%
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono text-slate-200">
|
||||||
|
{fmtMoney(unitNetRevenue)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-emerald-400 bg-white/5">
|
||||||
|
+{fmtMoney(unitNetProfit)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right">
|
||||||
|
<span
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-bold border inline-block min-w-[65px] text-center font-mono ${
|
||||||
|
item.netMarginPercent >= 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)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-center">
|
||||||
|
<span className="text-[11px] text-slate-400" title="ขายตรงหน้าร้านจะได้มาร์จิ้นสูงกว่านี้">
|
||||||
|
Direct: <strong className="text-cyan-400">{item.directMarginPercent}%</strong>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
{/* Title & Badge */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase bg-cyan-500/10 text-cyan-400 border border-cyan-500/30 flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3 h-3 text-cyan-400" />
|
||||||
|
Profit & Intelligence Suite
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">{title}</h1>
|
||||||
|
<p className="text-xs md:text-sm text-slate-400 mt-1">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modern Navigation Tabs */}
|
||||||
|
<div className="flex flex-wrap gap-2 p-1.5 bg-slate-900/80 backdrop-blur-md rounded-2xl border border-white/10 shadow-lg">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={tab.to}
|
||||||
|
to={tab.to}
|
||||||
|
end
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`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'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 shrink-0" />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import { brandKnowledgeRoute } from "./routes/brandKnowledge";
|
|||||||
import { categorySectionsRoute } from "./routes/categorySections";
|
import { categorySectionsRoute } from "./routes/categorySections";
|
||||||
import { llmPromptsRoute } from "./routes/llmPrompts";
|
import { llmPromptsRoute } from "./routes/llmPrompts";
|
||||||
import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences";
|
import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences";
|
||||||
|
import { profitabilityRoute } from "./routes/profitability";
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||||
@@ -158,7 +159,7 @@ const app = new Elysia()
|
|||||||
return file;
|
return file;
|
||||||
})
|
})
|
||||||
.group("/api", (app) =>
|
.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" });
|
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, { count: number; totalRev: number; totalCost: number }>();
|
||||||
|
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<string, { commissionPercent: number; servicePercent: number; paymentPercent: number }> = {
|
||||||
|
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<string, { soldQty: number; revenue: number; bomCost: number; netProfit: number }> = {
|
||||||
|
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<number, number>();
|
||||||
|
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<string, { qty: number; variantCount: number }>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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<number, number> = {
|
||||||
|
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<number, { fragQty: number; dpgQty: number }> = {
|
||||||
|
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<number, { cost: number; price: number; margin: number }>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
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<number, { cost: number; price: number; margin: number }> = {};
|
||||||
|
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);
|
||||||
@@ -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);
|
||||||
@@ -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<number, number> = {
|
||||||
|
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<number, { fragQty: number; dpmQty: number }> = {
|
||||||
|
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<number, {
|
||||||
|
variantId: number;
|
||||||
|
sku: string;
|
||||||
|
retailPrice: number;
|
||||||
|
fragranceCost: number;
|
||||||
|
dpmCost: number;
|
||||||
|
bottleCost: number;
|
||||||
|
sticksCost: number;
|
||||||
|
totalBomCost: number;
|
||||||
|
profit: number;
|
||||||
|
marginPercent: number;
|
||||||
|
}>;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
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<number, any> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -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);
|
||||||
@@ -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<number, { count: number; soldQty: number; revenue: number }>();
|
||||||
|
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<string, { total: number; withBom: number; withoutBom: number; missingList: MasterVariant[] }> = {};
|
||||||
|
|
||||||
|
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<string, number> = {};
|
||||||
|
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);
|
||||||
@@ -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<string, string> = {
|
||||||
|
// 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<string, MasterVariant>();
|
||||||
|
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<string, number> = {};
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -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<string, number>();
|
||||||
|
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());
|
||||||
Reference in new issue
Block a user