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:
Your Name committed 2026-10-18 18:58:11 +07:00
1 parent fdbbaea021
commit c78a898af7
21 files changed
+4336 -2

No files matched your search

+57
View File
@@ -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%) |
+58
View File
@@ -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%** |
+123
View File
@@ -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 รายการ)* | ... |
+33
View File
@@ -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 ไม่ต้องใช้น้ำมันละลายน้ำ)
+68
View File
@@ -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%) |
+58
View File
@@ -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%** |
+6
View File
@@ -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 />} />
+4 -1
View File
@@ -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} &gt;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} &lt;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 (&gt;= 70%)</option>
<option value="mid">🟡 Mid Margin (55% - 70%)</option>
<option value="low">🔴 Low Margin (&lt; 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>
);
}
+2 -1
View File
@@ -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" });
+477
View File
@@ -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,
};
});
+212
View File
@@ -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);
+204
View File
@@ -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);
+267
View File
@@ -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);
+204
View File
@@ -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);
+162
View File
@@ -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);
+515
View File
@@ -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);
+157
View File
@@ -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());