Compare commits

..
Author SHA1 Message Date
Your Name cf4546422d feat(website): implement PDPA cookie consent banner and script manager
- Add cookieConsentStore using Zustand with localStorage persistence and skipHydration
- Implement responsive CookieBanner with Quiet Luxury design and PDPA compliance
- Implement CookiePreferencesModal with category toggles (Necessary, Analytics, Marketing)
- Implement ScriptManager for Google Consent Mode v2, GA4, Meta Pixel, and TikTok Pixel orchestration
- Add Cookie Preferences trigger in Footer support column
- Add multilingual translation keys for Thai, English, Japanese, and Chinese
2026-10-19 05:45:36 +07:00
Your Name 9a18463bda feat(profitability): set default period to this_month on omnichannel margin page 2026-10-18 22:00:42 +07:00
adminandYour Name dbc6c513db feat(profitability): hybrid actual fees for Shopee/TikTok with conservative 30% fallback (#13)
Co-authored-by: Your Name <you@example.com>
Reviewed-on: #13
2026-10-18 14:53:31 +00:00
Your Name b8bb1d4617 feat(profitability): add date range filtering and 30-day MRP inventory forecast 2026-10-18 19:10:22 +07:00
Your Name c78a898af7 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
2026-10-18 18:58:11 +07:00
Your Name fdbbaea021 x 2026-10-18 14:25:29 +07:00
Your Name f49776f9cb x 2026-10-18 13:55:18 +07:00
Your Name 4fcdd8a816 feat(mcp): add BOM management tools (list_products_bom_status, get_product_bom, set_product_bom) 2026-10-18 12:25:35 +07:00
Your Name b030c08f68 feat(raw-materials): add inline price editing with keyboard shortcuts and partial update support 2026-10-18 11:56:38 +07:00
Your Name a5584d0d46 fix(raw-materials): remove invalid sku field from product select and resolve localized name 2026-10-18 11:44:51 +07:00
adminandYour Name a0abfbf306 feat(bom): add BOM JSON import modal with auto-matching and live cost preview (#12)
Co-authored-by: Your Name <you@example.com>
Reviewed-on: #12
2026-10-18 04:24:17 +00:00
antigravity-bot BotandYour Name d8f6d8139f feat(bom): add Get Prompt modal for LLM and fix product name resolution (#11)
### Summary of Changes
- Added 'Get Prompt' button and ProductBomPromptModal dialog on /products/:id/bom.
- Formats product ID, variants, and raw material inventory into a structured prompt for LLMs.
- Fixed backend bug in GET /products/:id?lang=all that wiped product.name when no translations existed.
- Added multi-level fallback in frontend to ensure product name never shows 'Unknown Product'.

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #11
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 03:55:15 +00:00
antigravity-bot BotandYour Name a647c9682d feat(raw-materials): add bulk JSON import with upsert and full-page edit routes (#10)
Fixes #9

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #10
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 03:30:19 +00:00
antigravity-bot Bot b13f37cf7e Merge pull request 'docs: audit and tidy legacy agent workspaces and historical records' (#8) from docs/historical-agents into master 2026-10-18 02:51:35 +00:00
Your Name 1ce711d960 docs: audit and tidy legacy agent workspaces and historical records 2026-10-18 09:50:58 +07:00
antigravity-bot Bot 809f420fe4 Merge pull request 'docs: label tenant examples and LLM prompt guides' (#7) from docs/tenant-prompts into master 2026-10-18 02:49:34 +00:00
Your Name cbd816592b docs: label tenant examples and LLM prompt guides 2026-10-18 09:48:54 +07:00
antigravity-bot Bot 408f07e27a Merge pull request 'docs: add scope labels to deployment runbooks and postmortem checklists' (#6) from docs/deployment-runbooks into master 2026-10-18 02:44:40 +00:00
Your Name 468b690ac3 docs: add scope labels to deployment runbooks and postmortem checklists 2026-10-18 09:42:04 +07:00
antigravity-bot BotandYour Name 18c12a6a14 docs: modernize platform capability and architecture guides to DBK Platform (#5)
Fixes #1

### Summary of Changes
- Modernized architecture documents to establish DBK Platform as the canonical multi-tenant commerce platform identity, treating HomAroma as the reference tenant.
- Updated frontend comparison to accurately reflect apps/spa (React 19 + Vite) vs apps/website (Next.js 15 SSR) instead of legacy names.
- Cleaned typos in PRISMA7_GUIDE and added cross-references to docs/PLATFORM_OVERVIEW.md.
- Updated docs/DOCUMENTATION_MIGRATION.md completion tracking.

---------

Co-authored-by: Your Name <you@example.com>
Reviewed-on: #5
Co-authored-by: antigravity-bot <bot@kittisak.com>
2026-10-18 01:47:59 +00:00
Your Name ce252dc061 feat(tools): add projects support to gitea helper CLI 2026-10-18 08:18:41 +07:00
Your Name 8a8438096c Test Add Gitea 2026-10-18 08:13:44 +07:00
Your Name 765cdd1fa9 x 2026-10-18 08:01:36 +07:00
88 changed files with 14203 additions and 345 deletions

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# Original User Request
> **Historical context:** This is an unedited record of an earlier development request for the Split-Screen Editorial Carousel feature. The current platform identity is DBK Platform.
## Initial Request — 2026-06-28T10:50:33Z
Implement a Split-Screen Editorial Carousel by extending the existing `EDITORIAL_CAROUSEL` schema and updating the backend, admin, spa, and website apps.
+32
View File
@@ -0,0 +1,32 @@
# Agent System & Workspaces Directory
> **Agent Infrastructure & Historical Provenance:** This directory houses both active multi-agent skills and historical subagent run workspaces. The platform identity is **DBK Platform**, with **HomAroma** as the reference tenant.
---
## 1. Active Skills (`.agents/skills/`)
The `.agents/skills/` directory contains active domain skill definitions loaded on demand by Antigravity and orchestrator agents. Each skill contains a `SKILL.md` specifying role responsibilities, routing logic, and verification rules:
- **`orchestrator`**: Central project planning, task routing, delegation, and verification.
- **`frontend-lead`**: React 19 SPA & Tailwind CSS development.
- **`frontend-code-auditor`**: Review gate for bug risks, contracts, and regressions.
- **`nextjs-seo-architect`**: Next.js 15 SSR, metadata, canonical URLs, and performance.
- **`backend-mcp-lead`**: Elysia API endpoints, server architecture, and MCP tools.
- **`database-architect`**: Prisma schema, PostgreSQL migrations, and data safety.
- **`cms-architect`**: Dynamic page builder payloads and headless CMS schemas.
- **`cms-content-manager`**: Content management and MCP blackbox testing.
- **`product-manager`**: Product scope, roadmap, and business impact analysis.
- **`ux-ui-designer`**: Design system, interaction models, and responsive layouts.
- **`qa-product-auditor`**: User flows, regression checks, and release readiness.
- **`brand-strategist`**: SEO copywriting, brand voice, and positioning.
- **`theme-compliance-auditor`**: CMS dynamic theme token compliance (avoid hardcoded colors).
*Note: Changes to `.agents/skills/` directly affect agent behavior and should follow the operational rules defined in [`AGENTS.md`](../AGENTS.md).*
---
## 2. Historical Subagent Workspaces
Directories matching `auditor_*`, `worker_*`, `explorer_*`, `reviewer_*`, `challenger_*`, `teamwork_preview_*`, `sentinel`, and `sub_orch_*` are historical workspaces and artifact directories generated during previous autonomous multi-agent sprint sessions.
- **Status:** Historical records preserved for provenance, auditability, and debugging.
- **Usage:** Do not edit active platform rules within historical folders. Refer to [`AGENTS.md`](../AGENTS.md) and [`docs/PLATFORM_OVERVIEW.md`](../docs/PLATFORM_OVERVIEW.md) for current standards.
+2 -2
View File
@@ -1,6 +1,6 @@
# DBK Platform Admin Section Guide
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant.
> **Platform scope:** This guide describes shared CMS administration capabilities. Product and content examples are tenant-specific; HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
This guide explains the dynamic page blocks available in the Page Builder and what each block contains.
@@ -138,7 +138,7 @@ This guide explains the dynamic page blocks available in the Page Builder and wh
### `SCENT_NOTES_BREAKDOWN`
- Purpose: Fragrance layer breakdown
- Purpose: Fragrance layer breakdown (reference tenant vertical block; pattern for multi-tier attribute breakdowns)
- Contains:
- `title` localized string optional
- `description` localized string optional
+1 -1
View File
@@ -1,6 +1,6 @@
# Architecture Decisions — DBK Platform
> **Platform scope:** HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource.
> **Platform scope:** DBK Platform architecture decisions. HomAroma references in this ADR are reference-tenant context unless a decision explicitly names an operational resource. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
> บันทึกการตัดสินใจเชิงสถาปัตยกรรม (ADR) จากการประชุมทีม
> อัปเดตล่าสุด: 2026-07-12
+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%) |
+2 -2
View File
@@ -1,7 +1,7 @@
# แผนการพัฒนาและคู่มือสร้างแผนที่ Malaysia & Vietnam Map
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration must be enabled and validated per tenant.
สำหรับเมนู Business Analytics บนระบบ HomAroma Admin
> **Platform capability plan:** Geographic coverage, data availability, and analytics configuration are platform capabilities enabled per tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
สำหรับเมนู Business Analytics บนระบบ DBK Platform Admin (Multi-tenant & Multi-region)
เอกสารนี้รวบรวมรายละเอียด แหล่งข้อมูล Open-source Repositories สำหรับดาวน์โหลดไฟล์ GeoJSON สถาปัตยกรรมทางเทคนิค และแผนขั้นตอนการนำไปพัฒนาต่อยอดให้เหมือนกับ **Omnichannel Sales Map (แผนที่ประเทศไทย)** เดิม
+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%** |
+3 -3
View File
@@ -1,8 +1,8 @@
# Custom Block Flow Guide
# Custom Block Flow Guide — DBK Platform
> **Platform scope:** This guide describes shared CMS block workflows. Content examples belong to their named tenant.
> **Platform scope:** This guide describes shared CMS block workflows for DBK Platform. Content examples (e.g. `BakeryCategories`) belong to their named tenant. HomAroma is the reference tenant. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ HomAroma Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่างจริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
เอกสารนี้อธิบาย Flow การสร้าง Custom Block สำหรับ DBK Platform Page Builder โดยใช้ `BakeryCategories` เป็นตัวอย่าง tenant จริง เพื่อให้เพิ่ม block ถัดไปได้โดยไม่ต้อง hardcode section type ใหม่ทุกครั้ง
## แนวคิดหลัก
+1 -1
View File
@@ -1,6 +1,6 @@
# DDC Deployment Runbook
> **Tenant operational runbook:** This document records DDC deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
> **Tenant operational runbook:** This document records DDC deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้เป็น runbook เฉพาะลูกค้า `ddc` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/pkh-*`
+1 -1
View File
@@ -1,6 +1,6 @@
# คู่มือการติดตั้งโปรเจกต์ใหม่ (New Project Deployment Guide)
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it.
> **DBK Platform operational guide:** Substitute tenant-specific domains, paths, credentials, and process names. Any `homaroma` identifier shown elsewhere in the repository is a legacy or reference-tenant identifier unless this guide explicitly selects it. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้รวบรวมขั้นตอนการสร้างสภาพแวดล้อม (Environment) และการตั้งค่าระบบสำหรับลูกค้ารายใหม่ (เช่น โปรเจกต์ `pkh`) โดยอ้างอิงจากโครงสร้างระบบปัจจุบันบนเซิร์ฟเวอร์ `kitizone.com`
+1 -1
View File
@@ -1,6 +1,6 @@
# HuaHin Deployment Runbook
> **Tenant operational runbook:** This document records HuaHin deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
> **Tenant operational runbook:** This document records HuaHin deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้เป็น runbook เฉพาะลูกค้า `huahin` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*`
+1 -1
View File
@@ -1,6 +1,6 @@
# Kittisak Deployment Runbook
> **Tenant operational runbook:** This document records Kittisak deployment configuration. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding.
> **Tenant operational runbook:** This document records Kittisak deployment configuration for DBK Platform. Preserve its hostnames, paths, and process identifiers as operational facts; they are not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้เป็น runbook เฉพาะลูกค้า `kittisak` โดยอิงจาก `DEPLOYMENT_GUIDE.md` และไฟล์ตัวอย่างใน `serverinfo/ddc-*` / `serverinfo/huahin-*`
+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 รายการ)* | ... |
+61
View File
@@ -0,0 +1,61 @@
# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)
> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)
> **อัปเดตล่าสุด:** 18/10/2569 13:05:32
> **จำนวนสินค้าทั้งหมด:** 45 รายการ
## 📦 สรุปสถานะภาพรวม
- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** 45 / 45 รายการ (100%)
- **ยังไม่ได้ตั้งค่า (MISSING):** 0 รายการ
- **ตั้งค่าบางส่วน (PARTIAL):** 0 รายการ
- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** 45 / 45 รายการ
## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)
| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |
| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |
| 1 | **#1** | [ตะไคร้ Oil Ozone](https://admin.homaroma.shop/products/1/bom) | [ID: 41] หัวน้ำหอม กลิ่นตะไคร้ (฿4.5) | ฿18.94 (72.6%) | ฿33.88 (66.1%) | ฿46.33 (61.4%) | ฿63.76 (57.5%) | ✅ พร้อมขาย |
| 2 | **#6** | [ลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/6/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 3 | **#11** | [วานิลลา Oil Ozone](https://admin.homaroma.shop/products/11/bom) | [ID: 53] หัวน้ำหอม กลิ่นวานิลลา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 4 | **#16** | [ซากุระ Oil Ozone](https://admin.homaroma.shop/products/16/bom) | [ID: 27] หัวน้ำหอม กลิ่นซากุระ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 5 | **#21** | [มะลิ Oil Ozone](https://admin.homaroma.shop/products/21/bom) | [ID: 48] หัวน้ำหอม กลิ่นมะลิ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 6 | **#26** | [ยูคาลิปตัส Oil Ozone](https://admin.homaroma.shop/products/26/bom) | [ID: 49] หัวน้ำหอม กลิ่นยูคาลิปตัส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 7 | **#31** | [เฟรชลาเวนเดอร์ Oil Ozone](https://admin.homaroma.shop/products/31/bom) | [ID: 15] หัวน้ำหอม กลิ่นเฟรชลาเวนเดอร์ (฿1.2) | ฿11.02 (84%) | ฿18.04 (82%) | ฿23.89 (80.1%) | ฿32.08 (78.6%) | ✅ พร้อมขาย |
| 8 | **#36** | [เปเปอร์มิ้น Oil Ozone](https://admin.homaroma.shop/products/36/bom) | [ID: 14] หัวน้ำหอม กลิ่นเปเปอร์มิ้น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 9 | **#41** | [โอเชี่ยน Oil Ozone](https://admin.homaroma.shop/products/41/bom) | [ID: 21] หัวน้ำหอม กลิ่นโอเชี่ยน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 10 | **#46** | [เซเว่นซี Oil Ozone](https://admin.homaroma.shop/products/46/bom) | [ID: 13] หัวน้ำหอม กลิ่นเซเว่นซี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 11 | **#51** | [ดอกแก้ว Oil Ozone](https://admin.homaroma.shop/products/51/bom) | [ID: 28] หัวน้ำหอม กลิ่นดอกแก้ว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 12 | **#56** | [ดอกโมก Oil Ozone](https://admin.homaroma.shop/products/56/bom) | [ID: 29] หัวน้ำหอม กลิ่นดอกโมก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 13 | **#61** | [ลีลาวดี Oil Ozone](https://admin.homaroma.shop/products/61/bom) | [ID: 51] หัวน้ำหอม กลิ่นลีลาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 14 | **#66** | [ส้ม Oil Ozone](https://admin.homaroma.shop/products/66/bom) | [ID: 54] หัวน้ำหอม กลิ่นส้ม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 15 | **#71** | [มะนาว Oil Ozone](https://admin.homaroma.shop/products/71/bom) | [ID: 46] หัวน้ำหอม กลิ่นมะนาว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 16 | **#76** | [แคนตาลูป Oil Ozone](https://admin.homaroma.shop/products/76/bom) | [ID: 16] หัวน้ำหอม กลิ่นแคนตาลูป (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 17 | **#81** | [แอปเปิ้ลแดง Oil Ozone](https://admin.homaroma.shop/products/81/bom) | [ID: 19] หัวน้ำหอม กลิ่นแอปเปิ้ลแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 18 | **#86** | [แอปเปิ้ลเขียว Oil Ozone](https://admin.homaroma.shop/products/86/bom) | [ID: 18] หัวน้ำหอม กลิ่นแอปเปิ้ลเขียว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 19 | **#91** | [ดอกลิลลี่ Oil Ozone](https://admin.homaroma.shop/products/91/bom) | [ID: 40] หัวน้ำหอม กลิ่นดอกลิลลี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 20 | **#96** | [โรสเมรี่ Oil Ozone](https://admin.homaroma.shop/products/96/bom) | [ID: 20] หัวน้ำหอม กลิ่นโรสเมรี่ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 21 | **#101** | [อัญชัน Oil Ozone](https://admin.homaroma.shop/products/101/bom) | [ID: 57] หัวน้ำหอม กลิ่นอัญชัน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 22 | **#106** | [กุหลาบแดง Oil Ozone](https://admin.homaroma.shop/products/106/bom) | [ID: 26] หัวน้ำหอม กลิ่นกุหลาบแดง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 23 | **#111** | [หมากฝรั่ง Oil Ozone](https://admin.homaroma.shop/products/111/bom) | [ID: 55] หัวน้ำหอม กลิ่นหมากฝรั่ง (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 24 | **#116** | [ผลไม้รวม Oil Ozone](https://admin.homaroma.shop/products/116/bom) | [ID: 44] หัวน้ำหอม กลิ่นผลไม้รวม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 25 | **#121** | [มะกรูด Oil Ozone](https://admin.homaroma.shop/products/121/bom) | [ID: 45] หัวน้ำหอม กลิ่นมะกรูด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 26 | **#126** | [แซนด้าวูส Oil Ozone](https://admin.homaroma.shop/products/126/bom) | [ID: 17] หัวน้ำหอม กลิ่นแซนด้าวูส (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 27 | **#131** | [ดอกปีบ Oil Ozone](https://admin.homaroma.shop/products/131/bom) | [ID: 34] หัวน้ำหอม กลิ่นดอกปีบ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 28 | **#136** | [ดอกบัว Oil Ozone](https://admin.homaroma.shop/products/136/bom) | [ID: 33] หัวน้ำหอม กลิ่นดอกบัว (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 29 | **#141** | [โอโซน Oil Ozone](https://admin.homaroma.shop/products/141/bom) | [ID: 22] หัวน้ำหอม กลิ่นโอโซน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 30 | **#146** | [ไทยสปา Oil Ozone](https://admin.homaroma.shop/products/146/bom) | [ID: 23] หัวน้ำหอม กลิ่นไทยสปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 31 | **#151** | [กล้วย Oil Ozone](https://admin.homaroma.shop/products/151/bom) | [ID: 24] หัวน้ำหอม กลิ่นกล้วย (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 32 | **#156** | [กาแฟ Oil Ozone](https://admin.homaroma.shop/products/156/bom) | [ID: 25] หัวน้ำหอม กลิ่นกาแฟ (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 33 | **#161** | [ดอกชมนาด Oil Ozone](https://admin.homaroma.shop/products/161/bom) | [ID: 32] หัวน้ำหอม กลิ่นดอกชมนาด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 34 | **#166** | [กลิ่นน้ำนม Oil Ozone](https://admin.homaroma.shop/products/166/bom) | [ID: 43] หัวน้ำหอม กลิ่นน้ำนม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 35 | **#171** | [ดอกการะเวก Oil Ozone](https://admin.homaroma.shop/products/171/bom) | [ID: 30] หัวน้ำหอม กลิ่นดอกการะเวก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 36 | **#176** | [ดอกจำปา Oil Ozone](https://admin.homaroma.shop/products/176/bom) | [ID: 31] หัวน้ำหอม กลิ่นดอกจำปา (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 37 | **#181** | [ดอกพิกุล Oil Ozone](https://admin.homaroma.shop/products/181/bom) | [ID: 35] หัวน้ำหอม กลิ่นดอกพิกุล (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 38 | **#186** | [ดอกพุด Oil Ozone](https://admin.homaroma.shop/products/186/bom) | [ID: 36] หัวน้ำหอม กลิ่นดอกพุด (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 39 | **#191** | [ดอกราตรี Oil Ozone](https://admin.homaroma.shop/products/191/bom) | [ID: 38] หัวน้ำหอม กลิ่นดอกราตรี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 40 | **#196** | [ดอกลำดวน Oil Ozone](https://admin.homaroma.shop/products/196/bom) | [ID: 39] หัวน้ำหอม กลิ่นดอกลำดวน (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 41 | **#201** | [ทับทิม Oil Ozone](https://admin.homaroma.shop/products/201/bom) | [ID: 42] หัวน้ำหอม กลิ่นทับทิม (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 42 | **#206** | [มะละกอสุก Oil Ozone](https://admin.homaroma.shop/products/206/bom) | [ID: 47] หัวน้ำหอม กลิ่นมะละกอสุก (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 43 | **#211** | [ดอกราชาวดี Oil Ozone](https://admin.homaroma.shop/products/211/bom) | [ID: 37] หัวน้ำหอม กลิ่นดอกราชาวดี (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 44 | **#216** | [กลิ่นลูกพีช Oil Ozone](https://admin.homaroma.shop/products/216/bom) | [ID: 52] หัวน้ำหอม กลิ่นลูกพีช (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
| 45 | **#221** | [กลิ่นองุ่น Oil Ozone](https://admin.homaroma.shop/products/221/bom) | [ID: 56] หัวน้ำหอม กลิ่นองุ่น (฿3) | ฿15.34 (77.8%) | ฿26.68 (73.3%) | ฿36.13 (69.9%) | ฿49.36 (67.1%) | ✅ พร้อมขาย |
+4 -4
View File
@@ -1,14 +1,14 @@
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed (ไม่งงอีกต่อไป)
# Prisma 7 — คู่มือการเชื่อมต่อและทำ Seed — DBK Platform
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
> **Platform engineering guide:** Any HomAroma seed data in this guide is reference-tenant data. Validate the target tenant database and seed strategy before use.
xx
> **สำคัญมาก:** Prisma 7 เปลี่ยนวิธีการเชื่อมต่อฐานข้อมูลอย่างพื้นฐาน
> ถ้าเคยใช้ Prisma 4, 5, หรือ 6 มาก่อน — ของเก่าพวกนั้น **ใช้ไม่ได้อีกแล้ว**
---
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 6fdss → Prisma 7
## 1. สิ่งที่เปลี่ยนไปจาก Prisma 5/6 → Prisma 7
| เรื่อง | Prisma 5/6 (เก่า) | Prisma 7 (ใหม่) |
|---|---|---|
+1 -1
View File
@@ -2,7 +2,7 @@
> **DBK Platform tenant example:** Schema fields are shared platform contracts. The HomAroma brand, aroma products, categories, and copy below are reference-tenant sample data only.
ระบบนำเข้าสินค้าของ HomAroma รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
ระบบนำเข้าสินค้าของ DBK Platform (ตัวอย่างข้อมูลแคตตาล็อกสินค้าอ้างอิงจาก Reference Tenant: HomAroma) รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products`
+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%** |
+84
View File
@@ -0,0 +1,84 @@
# Shopee Marketplace Product Matching & Mapping Ledger
* **Generated At:** 2026-10-18T07:17:25.420Z
* **Target Server:** `https://api.homaroma.shop/api` (Production)
* **Total Shopee Products:** 253
* **Successfully Mapped:** 217 (85.8%)
* **Held / Requires Decision:** 36 (14.2%)
## 1. Held / Unmapped Items (ต้องตัดสินใจเพิ่มเติม)
| ID | ชื่อสินค้า Shopee | Variation | ราคาเฉลี่ย | สาเหตุที่ Hold |
|:---|:---|:---|:---|:---|
| 619 | น้ำมันหอมระเหย สำหรับ เครื่องพ่นไอน้ำ เครื่องพ่นอโรม่า ขนาด 30 ml | เมลอน ญี่ปุ่น | ฿77.13 | ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น เมลอน หรือกลิ่นที่ยังไม่มีในระบบ) |
| 649 | น้ำมันหอมระเหย สำหรับ เครื่องพ่นไอน้ำ เครื่องพ่นอโรม่า ขนาด 30 ml | เมลอน ญี่ปุ่น,30 ml | ฿89.00 | ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น เมลอน หรือกลิ่นที่ยังไม่มีในระบบ) |
| 651 | น้ำมันหอมระเหย สำหรับ เครื่องพ่นไอน้ำ เครื่องพ่นอโรม่า ขนาด 30 ml | เมลอน ญี่ปุ่น,120 ml | ฿190.00 | ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น เมลอน หรือกลิ่นที่ยังไม่มีในระบบ) |
| 804 | น้ำมันหอมระเหย กลิ่นดอกโมก สำหรับเครื่องพ่นไอน้ำ ช่วยปรับอากาศ | - | ฿180.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้าหรือ Variation |
| 664 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ไทยสปา | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 698 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ซากุระ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 767 | HOM AROMA กลิ่นลาเวนเดอร์ lavender น้ำมันหอมระหย ออยโอโซน สำหรับเครื่องพ่น เครื่องพ่นไอน้ำ | - | ฿88.00 | ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้าหรือ Variation |
| 692 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ยูคาลิปตัส | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 687 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | แคนตาลูป | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 730 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ลาเวนเดอร์ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 705 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | แซนด้าวูด | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 704 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | เฟรชลาเวนเดอร์ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 691 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | โอเชี่ยน | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 693 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | โรสเมรี่ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 686 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | เมล่อนญี่ปุ่น | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 761 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | แอปเปิ้ลแดง | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 741 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | เปเปอร์มิ้นต์ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 760 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | มะกรูด | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 756 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | มะลิ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 755 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ตะไตร้ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 759 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | เซเว่นซี | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 754 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | กุหลาบชมพู | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 753 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกปีบ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 752 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | มะนาว | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 751 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ส้ม | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 758 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกแก้ว | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 757 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกอัญชัน | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 750 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ลิลลี่ | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 749 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | วนิลลา | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 747 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | กุหลาบแดง | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 748 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ผลไม้รวม | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 746 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | แอปเปิ้ลเขียว | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 745 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | หมากฝรั่ง | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 744 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกโมก | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 743 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ลีลาวดี | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
| 742 | oil ozone ขนาด 10 ml น้ำมันหอมระเหย น้ำมันใส่เครื่องพ่นไอน้ำ ออยโอโซน aroma oil อโรม่าออย | ดอกบัว | ฿25.00 | ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml) |
## 2. Sample of Successfully Mapped Items (ตัวอย่างที่เชื่อมโยงสำเร็จ)
| ID | Shopee SKU | กลิ่น | ขนาด | Master SKU | Master Variant | มี BOM หรือยัง |
|:---|:---|:---|:---|:---|:---|:---|
| 563 | M1015 | เปเปอร์มิ้น | 120ml | `8-1-120ML` | เปเปอร์มิ้น Oil Ozone (120ml) [8-1-120ML] | ✓ มี BOM แล้ว |
| 575 | M1002 | ตะไคร้ | 120ml | `1-1-120ML` | ตะไคร้ Oil Ozone (120ml) [1-1-120ML] | ✓ มี BOM แล้ว |
| 601 | 30-ml- | ลาเวนเดอร์ | 30ml | `2-1-30ML` | ลาเวนเดอร์ Oil Ozone (30ml) [2-1-30ML] | ✓ มี BOM แล้ว |
| 589 | 30-ml- | ยูคาลิปตัส | 30ml | `6-1-30ML` | ยูคาลิปตัส Oil Ozone (30ml) [6-1-30ML] | ✓ มี BOM แล้ว |
| 572 | M1015 | เปเปอร์มิ้น | 30ml | `8-1-30ML` | เปเปอร์มิ้น Oil Ozone (30ml) [8-1-30ML] | ✓ มี BOM แล้ว |
| 591 | 30-ml- | โอเชี่ยน | 30ml | `9-1-30ML` | โอเชี่ยน Oil Ozone (30ml) [9-1-30ML] | ✓ มี BOM แล้ว |
| 608 | 30-ml- | มะลิ | 30ml | `5-1-30ML` | มะลิ Oil Ozone (30ml) [5-1-30ML] | ✓ มี BOM แล้ว |
| 627 | M1001 | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว |
| 590 | 30-ml- | ซากุระ | 30ml | `4-1-30ML` | ซากุระ Oil Ozone (30ml) [4-1-30ML] | ✓ มี BOM แล้ว |
| 559 | M1005 | โอโซน | 120ml | `29-1-120ML` | โอโซน Oil Ozone (120ml) [29-1-120ML] | ✓ มี BOM แล้ว |
| 611 | 30-ml- | ตะไคร้ | 30ml | `1-1-30ML` | ตะไคร้ Oil Ozone (30ml) [1-1-30ML] | ✓ มี BOM แล้ว |
| 557 | M1015 | เปเปอร์มิ้น | 60ml | `8-1-60ML` | เปเปอร์มิ้น Oil Ozone (60ml) [8-1-60ML] | ✓ มี BOM แล้ว |
| 604 | 30-ml- | เปเปอร์มิ้น | 30ml | `8-1-30ML` | เปเปอร์มิ้น Oil Ozone (30ml) [8-1-30ML] | ✓ มี BOM แล้ว |
| 607 | 30-ml- | ลาเวนเดอร์ | 30ml | `2-1-30ML` | ลาเวนเดอร์ Oil Ozone (30ml) [2-1-30ML] | ✓ มี BOM แล้ว |
| 606 | 30-ml- | กุหลาบแดง | 30ml | `22-1-30ML` | กุหลาบแดง Oil Ozone (30ml) [22-1-30ML] | ✓ มี BOM แล้ว |
| 602 | 30-ml- | ดอกโมก | 30ml | `12-1-30ML` | ดอกโมก Oil Ozone (30ml) [12-1-30ML] | ✓ มี BOM แล้ว |
| 636 | M1015 | เปเปอร์มิ้น | 85ml | `8-1-85ML` | เปเปอร์มิ้น Oil Ozone (85ml) [8-1-85ML] | ✓ มี BOM แล้ว |
| 612 | 30-ml- | ผลไม้รวม | 30ml | `24-1-30ML` | ผลไม้รวม Oil Ozone (30ml) [24-1-30ML] | ✓ มี BOM แล้ว |
| 620 | 30-ml- | แคนตาลูป | 30ml | `16-1-30ML` | แคนตาลูป Oil Ozone (30ml) [16-1-30ML] | ✓ มี BOM แล้ว |
| 603 | M1004 | มะลิ | 120ml | `5-1-120ML` | มะลิ Oil Ozone (120ml) [5-1-120ML] | ✓ มี BOM แล้ว |
| 249 | M1001 | ลาเวนเดอร์ | 120ml | `2-1-120ML` | ลาเวนเดอร์ Oil Ozone (120ml) [2-1-120ML] | ✓ มี BOM แล้ว |
| 615 | 30-ml- | วานิลลา | 30ml | `3-1-30ML` | วานิลลา Oil Ozone (30ml) [3-1-30ML] | ✓ มี BOM แล้ว |
| 558 | M1003 | โอโซน | 120ml | `29-1-120ML` | โอโซน Oil Ozone (120ml) [29-1-120ML] | ✓ มี BOM แล้ว |
| 616 | 30-ml- | ดอกแก้ว | 30ml | `11-1-30ML` | ดอกแก้ว Oil Ozone (30ml) [11-1-30ML] | ✓ มี BOM แล้ว |
| 678 | 1652052234 | โอโซน | 120ml | `29-1-120ML` | โอโซน Oil Ozone (120ml) [29-1-120ML] | ✓ มี BOM แล้ว |
| 625 | 30-ml- | ดอกราตรี | 30ml | `39-1-30ML` | ดอกราตรี Oil Ozone (30ml) [39-1-30ML] | ✓ มี BOM แล้ว |
| 626 | 30-ml- | กลิ่นองุ่น | 30ml | `45-1-30ML` | กลิ่นองุ่น Oil Ozone (30ml) [45-1-30ML] | ✓ มี BOM แล้ว |
| 734 | 1652058083 | โอโซน | 60ml | `29-1-60ML` | โอโซน Oil Ozone (60ml) [29-1-60ML] | ✓ มี BOM แล้ว |
| 622 | 30-ml- | แอปเปิ้ลเขียว | 30ml | `18-1-30ML` | แอปเปิ้ลเขียว Oil Ozone (30ml) [18-1-30ML] | ✓ มี BOM แล้ว |
| 610 | 30-ml- | มะนาว | 30ml | `15-1-30ML` | มะนาว Oil Ozone (30ml) [15-1-30ML] | ✓ มี BOM แล้ว |
| ... | ... | ... | ... | ... | *(แสดง 30 จากทั้งหมด 217 รายการ)* | ... |
+1 -1
View File
@@ -1,6 +1,6 @@
# สรุปปัญหา ข้อผิดพลาด และ Checklist การสร้าง Tenant / Subdomain ใหม่
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding.
> **Historical operational record:** This postmortem preserves the facts of a 2026 tenant-provisioning incident. Existing HomAroma identifiers are legacy infrastructure references, not DBK Platform branding. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
(Tenant Provisioning Post-Mortem & Standard Operating Procedure)
บันทึกข้อผิดพลาดจากการสร้าง Showcase Subdomains (`wellness`, `fashion`, `beauty`, `moto`) บน `kittisak.com` เมื่อวันที่ 24 กันยายน 2026 เพื่อใช้เป็น Checklist ป้องกันการทำผิดพลาดซ้ำในอนาคต
+9
View File
@@ -14,6 +14,7 @@ import PageBuilderPage from './pages/PageBuilderPage'
import PageReorderPage from './pages/PageReorderPage'
import CmsMockupPage from './pages/CmsMockupPage'
import RawMaterialsPage from './pages/RawMaterialsPage'
import RawMaterialDetailPage from './pages/RawMaterialDetailPage'
import LoginPage from './pages/LoginPage'
import InstallSeedPage from './pages/InstallSeedPage'
import ProtectedRoute from './components/ProtectedRoute'
@@ -78,6 +79,9 @@ import MarketplaceCrmPage from './pages/crm/MarketplaceCrmPage'
import MarketplaceCustomerDetailPage from './pages/crm/MarketplaceCustomerDetailPage'
import PosPage from './pages/PosPage'
import UserLlmPromptsPage from './pages/UserLlmPromptsPage'
import CostProfitabilityMatrixPage from './pages/profitability/CostProfitabilityMatrixPage'
import OmnichannelMarginPage from './pages/profitability/OmnichannelMarginPage'
import MrpPlanningPage from './pages/profitability/MrpPlanningPage'
import { StoreSettingsProvider } from './contexts/StoreSettingsContext'
function App() {
@@ -123,6 +127,11 @@ function App() {
<Route path="/promotions" element={<PromotionsPage />} />
<Route path="/coupons" element={<CouponsPage />} />
<Route path="/raw-materials" element={<RawMaterialsPage />} />
<Route path="/raw-materials/new" 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/brand-intelligence" element={<BrandIntelligencePage />} />
<Route path="/brand-intelligence" element={<BrandIntelligencePage />} />
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types';
import { X, Upload, Loader2, Image as ImageIcon, Video } from 'lucide-react';
@@ -105,7 +106,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
if (!isOpen) return null;
return (
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-night-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-5xl h-[85vh] flex flex-col overflow-hidden animate-fade-in relative">
{/* Header */}
@@ -196,6 +197,7 @@ export default function MediaLibraryModal({ isOpen, onClose, onSelect, mediaType
)}
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import {
Sparkles,
Copy,
@@ -35,6 +36,8 @@ const GUIDE_OVERVIEW_MARKDOWN = `# คู่มือการใช้ AI/LLM
### 1. Root Items (เมนูแถวบนสุด - Level 1)
- **label**: ออบเจกต์ข้อความรองรับ 8 ภาษา \`{ th, en, ja, zh, vi, ko, lo, it }\`
- **url**: ลิงก์ปลายทาง เช่น \`/category/reed-diffuser\` หรือ \`/collections/all\`
- **layout**: (ไม่บังคับ) \`'columns'\` (Mega Menu คอลัมน์) | \`'tabbed'\` (Tabbed Catalog แท็บซ้าย) | \`'visual-cards'\` (การ์ดรูปภาพหรูหรา 4 ใบ) | \`'classic'\` (Dropdown แนวตั้งเรียบหรู กะทัดรัด)
- **layoutConfig**: (ไม่บังคับ) ออบเจกต์ตั้งค่าเพิ่มเติม \`{ columnCount: 2 | 3 | 4, showFeatured: boolean }\`
- **rootImageUrl**: URL รูปภาพหลักฝั่งซ้ายสุดของเมกะเมนู (อัตราส่วน 1:1 จัตุรัส ขนาดแนะนำ 800x800)
- **columns**: อาร์เรย์ของคอลัมน์เมนูย่อย (Level 2)
- **featured**: การ์ดสินค้า/ไฮไลต์โปรโมตพิเศษที่แสดงฝั่งขวาสุดของเมกะเมนู
@@ -304,7 +307,7 @@ export default function MenuJsonGuideModal({
}
}
return (
return createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
{/* Header */}
@@ -491,6 +494,7 @@ export default function MenuJsonGuideModal({
</div>
</div>
</div>
</div>
</div>,
document.body
)
}
@@ -0,0 +1,390 @@
import { useState, useMemo } from 'react';
import {
X,
FileJson,
Upload,
CheckCircle2,
AlertCircle,
Layers,
Sparkles
} from 'lucide-react';
interface ProductBomImportModalProps {
isOpen: boolean;
onClose: () => void;
variants: any[];
rawMaterials: any[];
onApply: (updatedVariants: any[]) => void;
}
export default function ProductBomImportModal({
isOpen,
onClose,
variants,
rawMaterials,
onApply,
}: ProductBomImportModalProps) {
const [jsonInput, setJsonInput] = useState('');
// Real-time Parser & Matcher
const parsedResult = useMemo(() => {
if (!jsonInput.trim()) return null;
try {
const parsed = JSON.parse(jsonInput);
if (!Array.isArray(parsed)) {
return {
valid: false,
error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]',
matchedVariants: [],
unmatchedCount: 0,
};
}
if (parsed.length === 0) {
return {
valid: false,
error: 'JSON Array ว่างเปล่า ไม่มีข้อมูลสูตร',
matchedVariants: [],
unmatchedCount: 0,
};
}
const matched: any[] = [];
let unmatchedCount = 0;
for (const entry of parsed) {
// 1. Match Variant by ID or SKU or Option
const variant = variants.find((v) => {
if (entry.variantId && Number(v.id) === Number(entry.variantId)) return true;
if (entry.sku && v.sku && String(v.sku).trim().toLowerCase() === String(entry.sku).trim().toLowerCase()) return true;
if (entry.option1 && v.option1 && String(v.option1).trim().toLowerCase() === String(entry.option1).trim().toLowerCase()) return true;
return false;
});
if (!variant) {
unmatchedCount++;
continue;
}
// 2. Match BOM items to RawMaterials
const rawItems = Array.isArray(entry.bomItems) ? entry.bomItems : [];
const resolvedBomItems: any[] = [];
let totalCost = 0;
for (const b of rawItems) {
const qty = Number(b.quantity) || 0;
if (qty <= 0) continue;
// Find raw material by ID or Name
const rm = rawMaterials.find((m) => {
if (b.rawMaterialId && Number(m.id) === Number(b.rawMaterialId)) return true;
if (b.name && String(m.name).trim().toLowerCase() === String(b.name).trim().toLowerCase()) return true;
return false;
});
if (rm) {
const lineCost = (Number(rm.pricePerUnit) || 0) * qty;
totalCost += lineCost;
resolvedBomItems.push({
rawMaterialId: rm.id,
name: rm.name,
unit: rm.unit,
quantity: qty,
pricePerUnit: rm.pricePerUnit,
lineCost,
});
} else {
// Unresolved material ID or name
resolvedBomItems.push({
rawMaterialId: Number(b.rawMaterialId) || 0,
name: b.name || `Material #${b.rawMaterialId}`,
unit: 'unit',
quantity: qty,
pricePerUnit: 0,
lineCost: 0,
unknown: true,
});
}
}
const price = Number(variant.price) || 0;
const profit = price - totalCost;
const margin = price > 0 ? (profit / price) * 100 : 0;
matched.push({
targetVariant: variant,
resolvedBomItems,
totalCost,
price,
profit,
margin,
});
}
if (matched.length === 0) {
return {
valid: false,
error: 'ไม่พบ Variant ในระบบที่ตรงกับ ID หรือ SKU ใน JSON ที่วางมา',
matchedVariants: [],
unmatchedCount,
};
}
return {
valid: true,
error: null,
matchedVariants: matched,
unmatchedCount,
};
} catch (err: any) {
return {
valid: false,
error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`,
matchedVariants: [],
unmatchedCount: 0,
};
}
}, [jsonInput, variants, rawMaterials]);
if (!isOpen) return null;
const handleApply = () => {
if (!parsedResult || !parsedResult.valid) return;
// Deep copy current variants
const newVariants = JSON.parse(JSON.stringify(variants));
for (const match of parsedResult.matchedVariants) {
const idx = newVariants.findIndex((v: any) => v.id === match.targetVariant.id);
if (idx !== -1) {
// Map to exact BOM structure expected by ProductBomPage
newVariants[idx].bomItems = match.resolvedBomItems.map((b: any) => ({
rawMaterialId: b.rawMaterialId,
quantity: b.quantity,
}));
}
}
onApply(newVariants);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Modal Container */}
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
<FileJson className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">Import BOM from LLM JSON</h2>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-bold">
Auto-Match & Calculate
</span>
</div>
<p className="text-xs text-slate-400">
วาง JSON ที่ได้รับจาก LLM ระบบจะจับคู่ Variant และคำนวณต้นทุนให้ก่อนกรอกลงตาราง
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-6 flex-1">
{/* Textarea Input */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<label className="font-bold text-slate-300 flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
วางข้อความ JSON ที่ได้จาก AI (Ctrl+V):
</label>
{jsonInput && (
<button
type="button"
onClick={() => setJsonInput('')}
className="text-rose-400 hover:text-rose-300 font-medium"
>
ล้างข้อความ
</button>
)}
</div>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
rows={8}
placeholder={`[\n {\n "variantId": 3206,\n "sku": "4-1-30ML",\n "bomItems": [\n { "rawMaterialId": 4, "quantity": 2.4 },\n { "rawMaterialId": 91, "quantity": 27.6 }\n ]\n }\n]`}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y leading-relaxed"
/>
</div>
{/* Validation Status Banner */}
{parsedResult && (
<div
className={`p-4 rounded-2xl border text-xs flex items-center justify-between ${
parsedResult.valid
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
}`}
>
<div className="flex items-center gap-2.5">
{parsedResult.valid ? (
<CheckCircle2 className="w-5 h-5 shrink-0 text-emerald-400" />
) : (
<AlertCircle className="w-5 h-5 shrink-0 text-rose-400" />
)}
<span>
{parsedResult.valid ? (
<>
ตรวจพบสูตร BOM ที่ตรงกับสินค้า <strong>{parsedResult.matchedVariants.length}</strong> ตัวเลือก
{parsedResult.unmatchedCount > 0 && ` (ข้ามที่ไม่ตรง ${parsedResult.unmatchedCount} รายการ)`}
</>
) : (
parsedResult.error
)}
</span>
</div>
{parsedResult.valid && (
<span className="text-[11px] px-2 py-0.5 rounded-md bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 font-medium">
พร้อมนำเข้า
</span>
)}
</div>
)}
{/* Live Preview Cards */}
{parsedResult?.valid && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="font-bold text-white flex items-center gap-1.5">
<Layers className="w-4 h-4 text-cyan-400" />
พรีวิวการจัดสูตรและคำนวณต้นทุน (BOM Preview):
</span>
<span>จำนวน {parsedResult.matchedVariants.length} Variants</span>
</div>
<div className="space-y-3 max-h-[280px] overflow-y-auto pr-1">
{parsedResult.matchedVariants.map((item: any, idx: number) => {
const v = item.targetVariant;
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Default';
return (
<div
key={v.id || idx}
className="p-4 rounded-2xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-white/5 pb-2.5">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-lg bg-cyan-500/10 text-cyan-400 text-xs font-mono font-bold flex items-center justify-center">
#{v.id}
</span>
<span className="text-sm font-bold text-white">{label}</span>
{v.sku && (
<span className="text-[11px] font-mono text-slate-500 bg-white/5 px-2 py-0.5 rounded-md">
SKU: {v.sku}
</span>
)}
</div>
<div className="flex items-center gap-4 text-xs font-mono">
<span className="text-slate-400">
ราคาขาย: <strong className="text-white">฿{item.price.toLocaleString()}</strong>
</span>
<span className="text-slate-400">
ต้นทุน BOM: <strong className="text-amber-400">฿{item.totalCost.toFixed(2)}</strong>
</span>
<span className="text-slate-400">
กำไร:{' '}
<strong className={item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
฿{item.profit.toFixed(2)} ({item.margin.toFixed(1)}%)
</strong>
</span>
</div>
</div>
{/* Ingredients Breakdown */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2">
{item.resolvedBomItems.map((b: any, bIdx: number) => (
<div
key={bIdx}
className={`p-2.5 rounded-xl border text-xs flex items-center justify-between ${
b.unknown
? 'bg-amber-950/20 border-amber-500/30 text-amber-300'
: 'bg-night-950/60 border-white/5 text-slate-300'
}`}
>
<div className="space-y-0.5 min-w-0 pr-2">
<p className="font-medium truncate text-white text-[11px]">
{b.name}
</p>
<p className="text-[10px] text-slate-500">
ID: #{b.rawMaterialId}
</p>
</div>
<div className="text-right shrink-0">
<span className="font-mono font-bold text-cyan-400">
{b.quantity} {b.unit}
</span>
<p className="text-[10px] text-slate-500 font-mono">
฿{b.lineCost.toFixed(2)}
</p>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
<p className="text-xs text-slate-500">
เมื่อกด Apply ข้อมูลจะถูกกรอกลงตารางบนหน้าเว็บทันทีเพื่อให้คุณตรวจสอบก่อนกดบันทึก
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
ยกเลิก
</button>
<button
type="button"
onClick={handleApply}
disabled={!parsedResult?.valid}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Upload className="w-4 h-4" />
<span>กรอกลงตาราง ({parsedResult?.matchedVariants?.length || 0} รายการ)</span>
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,332 @@
import { useState, useMemo } from 'react';
import {
X,
Copy,
Check,
Bot,
Sparkles,
FileCode,
Package,
Layers,
Code2
} from 'lucide-react';
interface ProductBomPromptModalProps {
isOpen: boolean;
onClose: () => void;
productId: number | string;
productName: string;
variants: any[];
rawMaterials: any[];
}
export default function ProductBomPromptModal({
isOpen,
onClose,
productId,
productName,
variants,
rawMaterials,
}: ProductBomPromptModalProps) {
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState<'prompt' | 'context' | 'schema'>('prompt');
// Generate full prompt text for LLM
const generatedPrompt = useMemo(() => {
const variantLines = variants.map((v) => {
const opts = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ') || 'Base Variant';
return ` - Variant ID: ${v.id} | SKU: ${v.sku || '-'} | ตัวเลือก: "${opts}" | ราคาขาย: ฿${Number(v.price || 0).toLocaleString()}`;
}).join('\n');
const rawMaterialLines = rawMaterials.map((rm) => {
return ` - [ID: ${rm.id}] "${rm.name}" | หน่วย: ${rm.unit} | ต้นทุน: ฿${Number(rm.pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
}).join('\n');
return `คุณคือ Senior Manufacturing & Bill of Materials (BOM) Specialist ของระบบ E-Commerce (DBK Platform)
[บริบทข้อมูลสินค้า]:
- รหัสสินค้า (Product ID): ${productId}
- ชื่อสินค้า: "${productName}"
[รายการตัวเลือกสินค้า (Variants ที่ต้องกำหนดสูตร BOM)]:
${variantLines}
[คลังรายการวัตถุดิบในระบบ (Raw Materials ที่สามารถนำมาผูกสูตรได้)]:
${rawMaterialLines}
[ภารกิจของคุณ]:
กรุณาจัดทำสูตรการผลิต (BOM Items) ให้กับแต่ละ Variant ของสินค้า รหัส #${productId} ตามคำสั่ง/สัดส่วนที่ระบุ
และส่งคืนผลลัพธ์เป็น **Valid JSON Array** ล้วนๆ ตามข้อกำหนดดังต่อไปนี้เท่านั้น:
[ข้อกำหนดผลลัพธ์ JSON Format ที่ต้องส่งกลับ]:
\`\`\`json
[
{
"variantId": <ตัวเลข ID ของ Variant>,
"sku": "<รหัส SKU>",
"bomItems": [
{
"rawMaterialId": <ตัวเลข ID ของวัตถุดิบจากคลังด้านบน>,
"name": "<ชื่อวัตถุดิบ>",
"quantity": <จำนวนปริมาณตัวเลข (ห้ามเป็น string)>
}
]
}
]
\`\`\`
*(คำสั่ง/สูตรเฉพาะ: ระบุสัดส่วนการผสม วัตถุดิบบรรจุภัณฑ์ และปริมาณที่ต้องการต่อ 1 ชิ้น)*`;
}, [productId, productName, variants, rawMaterials]);
// Context JSON
const contextJson = useMemo(() => {
return JSON.stringify(
{
product: {
id: Number(productId),
name: productName,
variantsCount: variants.length,
variants: variants.map((v) => ({
id: v.id,
sku: v.sku,
option1: v.option1,
option2: v.option2,
option3: v.option3,
price: v.price,
currentBomCount: v.bomItems?.length || 0,
})),
},
rawMaterialsCount: rawMaterials.length,
rawMaterials: rawMaterials.map((rm) => ({
id: rm.id,
name: rm.name,
unit: rm.unit,
pricePerUnit: rm.pricePerUnit,
})),
},
null,
2
);
}, [productId, productName, variants, rawMaterials]);
// Target schema sample
const schemaSample = useMemo(() => {
const firstVariant = variants[0];
const sampleItem = rawMaterials[0];
return JSON.stringify(
[
{
variantId: firstVariant ? firstVariant.id : 1,
sku: firstVariant?.sku || 'SAMPLE-SKU',
bomItems: [
{
rawMaterialId: sampleItem ? sampleItem.id : 1,
name: sampleItem?.name || 'ตัวอย่างวัตถุดิบ',
quantity: 1,
},
],
},
],
null,
2
);
}, [variants, rawMaterials]);
if (!isOpen) return null;
const handleCopy = async (textToCopy: string) => {
try {
await navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error(e);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
onClick={onClose}
/>
{/* Modal Container */}
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[92vh] animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
<Bot className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">LLM Prompt Generator for BOM</h2>
<span className="text-xs px-2.5 py-0.5 rounded-full bg-purple-500/10 text-purple-300 border border-purple-500/20 font-mono font-bold">
Product #{productId}
</span>
</div>
<p className="text-xs text-slate-400">
สร้างชุดคำสั่ง Prompt พร้อมรหัสสินค้า, Variants และรายการวัตถุดิบทั้งหมดสำหรับส่งให้ LLM
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick Stats Bar */}
<div className="grid grid-cols-3 gap-4 px-6 py-3 bg-night-950/30 border-b border-white/5 text-xs">
<div className="flex items-center gap-2 text-slate-400">
<Package className="w-4 h-4 text-cyan-400" />
<span>สินค้า: <strong className="text-white">{productName}</strong></span>
</div>
<div className="flex items-center gap-2 text-slate-400">
<Layers className="w-4 h-4 text-purple-400" />
<span>Variants: <strong className="text-white">{variants.length} ตัวเลือก</strong></span>
</div>
<div className="flex items-center gap-2 text-slate-400">
<Code2 className="w-4 h-4 text-emerald-400" />
<span>วัตถุดิบในคลัง: <strong className="text-white">{rawMaterials.length} รายการ</strong></span>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
<button
onClick={() => setActiveTab('prompt')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'prompt'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Sparkles className="w-4 h-4" />
<span>ชุดคำสั่ง Prompt สำหรับ LLM</span>
</button>
<button
onClick={() => setActiveTab('context')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'context'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<FileCode className="w-4 h-4" />
<span>ข้อมูลดิบ (Context JSON)</span>
</button>
<button
onClick={() => setActiveTab('schema')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'schema'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Code2 className="w-4 h-4" />
<span>Target Output Format</span>
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-4 flex-1">
{activeTab === 'prompt' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400">
คัดลอกข้อความด้านล่างนี้ไปวางใน <strong>ChatGPT, Claude หรือ Gemini</strong>:
</span>
<button
type="button"
onClick={() => handleCopy(generatedPrompt)}
className="px-4 py-2 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-bold flex items-center gap-2 transition-all shadow-sm"
>
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span>{copied ? 'คัดลอก Prompt สำเร็จ!' : 'คัดลอก Prompt ทั้งหมด'}</span>
</button>
</div>
<textarea
readOnly
value={generatedPrompt}
rows={16}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-200 focus:outline-none resize-y leading-relaxed"
/>
</div>
)}
{activeTab === 'context' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400">
โครงสร้างข้อมูล Product Master, Variants และ Raw Materials ในรูปแบบ JSON:
</span>
<button
type="button"
onClick={() => handleCopy(contextJson)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 text-xs font-bold flex items-center gap-2 transition-all"
>
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
<span>{copied ? 'คัดลอกแล้ว' : 'คัดลอก Context JSON'}</span>
</button>
</div>
<textarea
readOnly
value={contextJson}
rows={16}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-slate-300 focus:outline-none resize-y leading-relaxed"
/>
</div>
)}
{activeTab === 'schema' && (
<div className="space-y-4">
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-slate-300 space-y-1">
<p className="font-bold text-cyan-300">💡 โครงสร้างผลลัพธ์ที่ออกแบบไว้สำหรับรองรับการ Copy วาง (Paste to Import):</p>
<p className="text-slate-400">
เมื่อ LLM ได้รับข้อมูลและส่งกลับมาในรูปแบบนี้ คุณจะสามารถนำข้อความ JSON มาวางเพื่อบันทึก BOM เข้าสู่ Variant ทั้งหมดได้ทันที
</p>
</div>
<textarea
readOnly
value={schemaSample}
rows={12}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-5 text-emerald-400 focus:outline-none resize-y leading-relaxed"
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-between">
<p className="text-xs text-slate-500">
ระบบจัดเตรียมข้อมูลอัตโนมัติจากฐานข้อมูลจริงของสินค้า #{productId}
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
ปิดหน้าต่าง
</button>
<button
type="button"
onClick={() => handleCopy(generatedPrompt)}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 shadow-lg shadow-purple-500/20 bg-purple-600 hover:bg-purple-500 text-white"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
<span>{copied ? 'คัดลอกสำเร็จแล้ว' : 'คัดลอก Prompt ไปใช้งาน'}</span>
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import {
Sparkles,
Copy,
@@ -613,7 +614,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
}
}
return (
return createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-3 sm:p-5 animate-fade-in">
<div className="w-full max-w-5xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col max-h-[92vh] overflow-hidden animate-zoom-in">
@@ -792,6 +793,7 @@ export default function ProductJsonGuideModal({ isOpen, onClose, onOpenImport }:
</div>
</div>
</div>
</div>,
document.body
)
}
@@ -0,0 +1,419 @@
import { useState, useMemo, useRef, type ChangeEvent } from 'react';
import {
X,
Copy,
Check,
Download,
Upload,
FileJson,
AlertCircle,
CheckCircle2,
Loader2,
Sparkles,
} from 'lucide-react';
import { API_BASE, type RawMaterial } from '@homaroma/types';
interface RawMaterialBulkJsonModalProps {
isOpen: boolean;
onClose: () => void;
materials: RawMaterial[];
onSuccess: () => void;
}
const SAMPLE_TEMPLATE = [
{
name: "ขวดแก้วสีชา 100ml (Amber Glass Bottle)",
unit: "piece",
pricePerUnit: 18.50
},
{
name: "ก้านไม้ไฟเบอร์สีดำ 6 ก้าน (Black Fiber Reeds)",
unit: "pack",
pricePerUnit: 7.00
},
{
name: "น้ำมันหอมระเหย ลาเวนเดอร์บริสุทธิ์ (Pure Lavender Oil)",
unit: "kg",
pricePerUnit: 2400.00
},
{
name: "เบสกระจายความหอม MMB (Diffuser Base)",
unit: "liter",
pricePerUnit: 350.00
}
];
export default function RawMaterialBulkJsonModal({
isOpen,
onClose,
materials,
onSuccess,
}: RawMaterialBulkJsonModalProps) {
const [activeTab, setActiveTab] = useState<'import' | 'export'>('import');
const [copiedTemplate, setCopiedTemplate] = useState(false);
const [copiedExport, setCopiedExport] = useState(false);
const [importJsonText, setImportJsonText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [resultMessage, setResultMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Parse & validate input text in real time
const parsedPreview = useMemo(() => {
if (!importJsonText.trim()) return null;
try {
const parsed = JSON.parse(importJsonText);
if (!Array.isArray(parsed)) {
return { valid: false, error: 'ข้อมูลต้องเป็น JSON Array [ { ... }, { ... } ]', count: 0, items: [] };
}
if (parsed.length === 0) {
return { valid: false, error: 'Array ว่างเปล่า ไม่มีรายการวัตถุดิบ', count: 0, items: [] };
}
const invalidItem = parsed.find((item: any) => !item || typeof item !== 'object' || !item.name);
if (invalidItem) {
return { valid: false, error: 'พบรายการที่ไม่มีฟิลด์ "name" หรือข้อมูลไม่สมบูรณ์', count: 0, items: [] };
}
return { valid: true, error: null, count: parsed.length, items: parsed };
} catch (err: any) {
return { valid: false, error: `รูปแบบ JSON ไม่ถูกต้อง: ${err.message}`, count: 0, items: [] };
}
}, [importJsonText]);
// Export JSON string
const exportJsonString = useMemo(() => {
const cleanList = materials.map((m) => ({
name: m.name,
unit: m.unit,
pricePerUnit: m.pricePerUnit,
}));
return JSON.stringify(cleanList, null, 2);
}, [materials]);
if (!isOpen) return null;
const handleCopyTemplate = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(SAMPLE_TEMPLATE, null, 2));
setCopiedTemplate(true);
setTimeout(() => setCopiedTemplate(false), 2000);
} catch (e) {
console.error(e);
}
};
const handleCopyExport = async () => {
try {
await navigator.clipboard.writeText(exportJsonString);
setCopiedExport(true);
setTimeout(() => setCopiedExport(false), 2000);
} catch (e) {
console.error(e);
}
};
const handleDownloadExport = () => {
const blob = new Blob([exportJsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `raw-materials-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setImportJsonText(content);
setResultMessage(null);
};
reader.readAsText(file);
e.target.value = '';
};
const handleImportSubmit = async () => {
if (!parsedPreview || !parsedPreview.valid) return;
setSubmitting(true);
setResultMessage(null);
try {
const res = await fetch(`${API_BASE}/raw-materials/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsedPreview.items),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'นำเข้าข้อมูลไม่สำเร็จ');
}
setResultMessage({
type: 'success',
text: `นำเข้าและปรับปรุงข้อมูล (Upsert) สำเร็จ ${data.count || parsedPreview.count} รายการ`,
});
setImportJsonText('');
setTimeout(() => {
onSuccess();
onClose();
}, 1200);
} catch (err: any) {
setResultMessage({
type: 'error',
text: err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์',
});
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm transition-opacity"
onClick={() => !submitting && onClose()}
/>
{/* Modal Container */}
<div className="relative w-full max-w-3xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden max-h-[90vh] animate-zoom-in">
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-night-950/40">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400">
<FileJson className="w-5 h-5" />
</div>
<div>
<h2 className="text-xl font-bold text-white flex items-center gap-2">
Raw Materials JSON Tool
<span className="text-xs px-2 py-0.5 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20">
Bulk Upsert
</span>
</h2>
<p className="text-xs text-slate-400">
นำเข้าและส่งออกข้อมูลวัตถุดิบ รองรับการอัปเดตข้อมูลเดิมอัตโนมัติเมื่อชื่อซ้ำ
</p>
</div>
</div>
<button
onClick={onClose}
disabled={submitting}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-white/5 px-6 pt-2 bg-night-950/20">
<button
onClick={() => { setActiveTab('import'); setResultMessage(null); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'import'
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Upload className="w-4 h-4" />
<span>นำเข้า JSON (Bulk Import)</span>
</button>
<button
onClick={() => { setActiveTab('export'); setResultMessage(null); }}
className={`flex items-center gap-2 px-4 py-3 text-sm font-semibold border-b-2 transition-all ${
activeTab === 'export'
? 'border-cyan-500 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-white'
}`}
>
<Download className="w-4 h-4" />
<span>ส่งออก JSON (Export {materials.length} รายการ)</span>
</button>
</div>
{/* Content Body */}
<div className="p-6 overflow-y-auto space-y-6 flex-1">
{activeTab === 'import' ? (
<div className="space-y-5">
{/* Guidance Notice */}
<div className="p-4 rounded-2xl bg-cyan-950/20 border border-cyan-500/20 text-xs text-cyan-200/90 flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-1.5 font-bold text-cyan-300">
<Sparkles className="w-3.5 h-3.5" />
กลไกอัจฉริยะ (Upsert Automation)
</div>
<p className="text-slate-300 leading-relaxed">
วาง JSON Array รายการวัตถุดิบที่ต้องการ หากพบ <strong>name</strong> ที่มีอยู่แล้วในระบบ
ระบบจะ <strong>Update</strong> หน่วยนับและราคาต้นทุนให้ทันที หากยังไม่มีจะสร้างรายการใหม่
</p>
</div>
<button
type="button"
onClick={handleCopyTemplate}
className="shrink-0 px-3 py-1.5 rounded-lg bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 font-medium flex items-center gap-1.5 transition-all text-xs"
>
{copiedTemplate ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedTemplate ? 'คัดลอกแล้ว' : 'คัดลอก Template'}</span>
</button>
</div>
{/* JSON Input Area */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<label className="font-bold text-slate-300">วาง JSON Payload:</label>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".json"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-cyan-400 hover:text-cyan-300 flex items-center gap-1 font-medium transition-colors"
>
<Upload className="w-3.5 h-3.5" />
<span>อัปโหลดไฟล์ .json</span>
</button>
{importJsonText && (
<button
type="button"
onClick={() => { setImportJsonText(''); setResultMessage(null); }}
className="text-rose-400 hover:text-rose-300 ml-2 font-medium"
>
ล้างข้อมูล
</button>
)}
</div>
</div>
<textarea
value={importJsonText}
onChange={(e) => {
setImportJsonText(e.target.value);
setResultMessage(null);
}}
rows={9}
placeholder={`[\n {\n "name": "ขวดแก้ว 100ml",\n "unit": "piece",\n "pricePerUnit": 18.5\n }\n]`}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-200 placeholder-slate-600 focus:outline-none focus:border-cyan-500/50 transition-all resize-y"
/>
</div>
{/* Live Preview / Validation Status */}
{parsedPreview && (
<div
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
parsedPreview.valid
? 'bg-emerald-950/30 border-emerald-500/30 text-emerald-300'
: 'bg-rose-950/30 border-rose-500/30 text-rose-300'
}`}
>
{parsedPreview.valid ? (
<>
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>
JSON ถูกต้อง — ตรวจพบวัตถุดิบทั้งหมด <strong>{parsedPreview.count}</strong> รายการ พร้อมนำเข้าสู่ระบบ
</span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{parsedPreview.error}</span>
</>
)}
</div>
)}
{/* Server Result Message */}
{resultMessage && (
<div
className={`p-3.5 rounded-xl border text-xs flex items-center gap-2.5 ${
resultMessage.type === 'success'
? 'bg-emerald-950/40 border-emerald-500/40 text-emerald-300'
: 'bg-rose-950/40 border-rose-500/40 text-rose-300'
}`}
>
{resultMessage.type === 'success' ? (
<CheckCircle2 className="w-4 h-4 shrink-0" />
) : (
<AlertCircle className="w-4 h-4 shrink-0" />
)}
<span>{resultMessage.text}</span>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>วัตถุดิบทั้งหมดในระบบ ({materials.length} รายการ):</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopyExport}
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium flex items-center gap-1.5 transition-all"
>
{copiedExport ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedExport ? 'คัดลอกแล้ว' : 'คัดลอก JSON'}</span>
</button>
<button
type="button"
onClick={handleDownloadExport}
className="btn-primary text-xs px-3 py-1.5 flex items-center gap-1.5"
>
<Download className="w-3.5 h-3.5" />
<span>ดาวน์โหลด .json</span>
</button>
</div>
</div>
<textarea
readOnly
value={exportJsonString}
rows={12}
className="w-full font-mono text-xs bg-night-950/80 border border-white/10 rounded-2xl p-4 text-slate-300 focus:outline-none resize-y"
/>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/5 bg-night-950/40 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-5 py-2.5 rounded-xl text-sm font-medium text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
ยกเลิก
</button>
{activeTab === 'import' && (
<button
type="button"
onClick={handleImportSubmit}
disabled={submitting || !parsedPreview?.valid}
className="btn-primary text-sm px-6 py-2.5 flex items-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed shadow-lg shadow-cyan-500/10"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>กำลังบันทึกข้อมูล...</span>
</>
) : (
<>
<Upload className="w-4 h-4" />
<span>ยืนยันการนำเข้า ({parsedPreview?.count || 0} รายการ)</span>
</>
)}
</button>
)}
</div>
</div>
</div>
);
}
+4 -1
View File
@@ -56,7 +56,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const navigate = useNavigate()
const location = useLocation()
const isInProduct = ['/categories', '/brands', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
const isInProduct = ['/categories', '/brands', '/products', '/product-attributes', '/raw-materials', '/profitability', '/omnichannel-margin', '/mrp-planning'].some(p => location.pathname.startsWith(p))
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
const isRepeatCustomers = location.pathname.startsWith('/business-analytics/repeat-customer')
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') && !isRepeatCustomers
@@ -97,6 +97,9 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
{ icon: Package, label: t('nav.products', 'Products'), to: '/products' },
{ icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' },
{ icon: PackageOpen, label: t('nav.rawMaterials', 'Raw Materials'), to: '/raw-materials' },
{ icon: Calculator, label: t('nav.profitability', 'Cost & Profitability'), to: '/profitability' },
{ icon: Layers, label: t('nav.omnichannelMargin', 'Omnichannel Margin'), to: '/omnichannel-margin' },
{ icon: BrainCircuit, label: t('nav.mrpPlanning', 'MRP & Material Demand'), to: '/mrp-planning' },
]
const SALES_MENU = [
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
X,
@@ -852,7 +853,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
return matchesCategory && matchesSearch;
});
return (
return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md p-4 sm:p-6 animate-fade-in">
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-6xl flex flex-col h-[90vh] max-h-[850px] overflow-hidden text-slate-200">
{/* Modal Header */}
@@ -978,6 +979,7 @@ export default function AddSectionModal({ isOpen, onClose, onSelectBlock }: AddS
)}
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Search, Loader2 } from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { getAdminDisplayString } from '../../utils/localization';
@@ -63,7 +64,7 @@ export default function ProductPickerModal({
onClose();
};
return (
return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-night-900/80 backdrop-blur-sm" onClick={onClose}></div>
<div className="relative bg-slate-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col h-[80vh]">
@@ -160,6 +161,7 @@ export default function ProductPickerModal({
</div>
</div>
</div>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,495 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
X,
Receipt,
DollarSign,
Wallet,
BadgePercent,
Coins,
Package,
Truck,
ExternalLink,
CheckCircle2,
AlertTriangle,
Copy,
Check,
TrendingUp,
} from 'lucide-react';
import { TIKTOK_SHOPS } from '../../pages/TiktokOrderPage';
function fmt(val: number | null | undefined) {
if (val == null) return '0.00';
return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
}
interface TiktokFeeBreakdownModalProps {
order: any;
onClose: () => void;
}
export function TiktokFeeBreakdownModal({ order, onClose }: TiktokFeeBreakdownModalProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
// Keyboard navigation & body scroll lock
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
// Prevent background page from scrolling
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = originalOverflow;
};
}, [onClose]);
if (!order || typeof document === 'undefined') return null;
const settlement = order.settlement;
const isSettled = order.isSettled || !!settlement;
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
// Financial figures (Settlement fees are usually stored as negative numbers from TikTok reports)
const grossRevenue = settlement?.grossRevenue ?? settlement?.subtotalAfterSellerDiscount ?? order.orderAmount ?? 0;
const sellerDiscount = Math.abs(settlement?.sellerDiscount ?? 0);
const subtotalBeforeDiscount = settlement?.subtotalBeforeDiscount ?? (grossRevenue + sellerDiscount);
const commissionFee = Math.abs(settlement?.commissionFee ?? order.commissionFee ?? 0);
const affiliateCommission = Math.abs(settlement?.affiliateCommission ?? order.affiliateFee ?? 0);
const affiliatePartnerFee = Math.abs(settlement?.affiliatePartnerFee ?? 0);
const totalAffiliate = affiliateCommission + affiliatePartnerFee;
const orderFee = Math.abs(settlement?.orderFee ?? 0);
const growthSupportFee = Math.abs(settlement?.growthSupportFee ?? 0);
const shippingFeeActual = Math.abs(settlement?.shippingFeeActual ?? 0);
const platformShippingDiscount = Math.abs(settlement?.platformShippingDiscount ?? 0);
const netShippingToSeller = Math.max(0, shippingFeeActual - platformShippingDiscount);
// Total deductions
const rawTotalFee = settlement?.totalFee != null
? Math.abs(settlement.totalFee)
: (commissionFee + totalAffiliate + orderFee + growthSupportFee + netShippingToSeller);
// Net payout
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (grossRevenue - commissionFee - totalAffiliate);
// BOM & Net Profit
const totalBomCost = order.orderTotalBomCost || 0;
const hasMissingBom = order.hasIncompleteBom || (order.missingBomCount ?? 0) > 0;
const realNetProfit = netSettlement - totalBomCost;
const marginOnSales = grossRevenue > 0 ? (realNetProfit / grossRevenue) * 100 : 0;
const markupOnCost = totalBomCost > 0 ? (realNetProfit / totalBomCost) * 100 : 0;
const realNetMargin = marginOnSales;
const handleCopyOrderId = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(order.orderId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-5 md:p-6 bg-black/80 backdrop-blur-md animate-fade-in"
onClick={onClose}
>
<div
className="bg-slate-900 border border-white/15 rounded-3xl w-full max-w-5xl xl:max-w-6xl max-h-[92vh] flex flex-col shadow-2xl shadow-black/90 overflow-hidden text-white"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500/20 to-cyan-500/20 border border-violet-500/30 flex items-center justify-center shadow-lg shadow-violet-500/10 shrink-0">
<Receipt className="w-5 h-5 text-violet-400" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-white text-base">
{t('tiktokOrders.feeModalTitle', 'TikTok Fee & Settlement Breakdown')}
</h3>
{isSettled ? (
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 inline-flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
{t('tiktokOrders.settlementCompleteBadge', 'โอนเงินแล้ว')}
</span>
) : (
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30 inline-flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{t('tiktokOrders.settlementPendingBadge', 'รอ Settlement')}
</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-400 mt-0.5 flex-wrap">
<span className="font-mono text-rose-400 font-bold">#{order.orderId}</span>
<button
type="button"
onClick={handleCopyOrderId}
className="text-slate-400 hover:text-white p-0.5 rounded transition-colors inline-flex items-center gap-1 text-[11px]"
title={t('tiktokOrders.copyOrderId', 'คัดลอก Order ID')}
>
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
{copied && <span className="text-[10px] text-emerald-400 font-sans">{t('tiktokOrders.copied', 'คัดลอกแล้ว')}</span>}
</button>
<span>•</span>
<span className={`font-semibold ${shopCfg.color}`}>{shopCfg.label}</span>
{order.buyerUsername && (
<>
<span>•</span>
<span className="text-slate-300">ผู้ซื้อ: {order.buyerUsername}</span>
</>
)}
{order.createdTime && (
<>
<span>•</span>
<span className="text-slate-400 font-mono">{order.createdTime.slice(0, 16)}</span>
</>
)}
</div>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all ml-2"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Scrollable Content */}
<div className="p-6 space-y-5 overflow-y-auto">
{/* Pending Settlement Notice if not settled */}
{!settlement && (
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-start gap-3 text-amber-300 text-xs">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-0.5">
<p className="font-bold text-amber-200">
{t('tiktokOrders.settlementPendingAlert', 'คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement')}
</p>
<p className="text-amber-300/80 leading-relaxed text-[11px]">
{t(
'tiktokOrders.settlementPendingDesc',
'ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center'
)}
</p>
</div>
</div>
)}
{/* 5 Financial Summary Cards (Compact Banner) */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
{/* 1. Revenue */}
<div className="p-3.5 rounded-2xl bg-white/5 border border-white/10 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-slate-400 text-[11px]">
<span className="truncate">{t('tiktokOrders.orderRevenueLabel', 'ยอดขายสินค้า')}</span>
<DollarSign className="w-3.5 h-3.5 text-slate-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-white mt-1">฿{fmt(grossRevenue)}</p>
<span className="text-[10px] text-slate-500 truncate" title={sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดร้านค้าแล้ว'}>
{sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดแล้ว'}
</span>
</div>
{/* 2. TikTok Deductions */}
<div className="p-3.5 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-rose-300 text-[11px]">
<span className="truncate">{t('tiktokOrders.tiktokDeductionsLabel', 'TikTok หักรวม')}</span>
<BadgePercent className="w-3.5 h-3.5 text-rose-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-rose-400 mt-1">-฿{fmt(rawTotalFee)}</p>
<span className="text-[10px] text-rose-400/80 truncate">
{grossRevenue > 0 ? `${((rawTotalFee / grossRevenue) * 100).toFixed(1)}% ของยอดขาย` : '-'}
</span>
</div>
{/* 3. Net Payout */}
<div className="p-3.5 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-cyan-300 text-[11px]">
<span className="truncate">{t('tiktokOrders.netPayoutLabel', 'ยอดโอนสุทธิ')}</span>
<Wallet className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-cyan-400 mt-1">฿{fmt(netSettlement)}</p>
<span className="text-[10px] text-cyan-400/80 truncate">
{settlement?.orderSettledTime ? `โอนเมื่อ ${settlement.orderSettledTime.slice(0, 10)}` : 'รอสรุปยอดโอน'}
</span>
</div>
{/* 4. Product Cost (BOM) */}
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex flex-col justify-between min-h-[96px]">
<div className="flex items-center justify-between text-amber-300 text-[11px]">
<span className="truncate">{t('tiktokOrders.cardBomCost', 'ต้นทุนสินค้า (BOM)')}</span>
<Coins className="w-3.5 h-3.5 text-amber-400 shrink-0" />
</div>
<p className="text-lg font-bold font-mono text-amber-400 mt-1">฿{fmt(totalBomCost)}</p>
<span className="text-[10px] text-amber-400/80 truncate">
{hasMissingBom
? '⚠️ มีสินค้ายังไม่ใส่ BOM'
: grossRevenue > 0
? `${((totalBomCost / grossRevenue) * 100).toFixed(1)}% ของยอดขาย`
: 'BOM ครบ 100%'}
</span>
</div>
{/* 5. Net Profit (with Margin % on sales and Markup % on cost) */}
<div className={`p-3.5 rounded-2xl border flex flex-col justify-between min-h-[96px] ${
realNetProfit >= 0 ? 'bg-emerald-500/10 border-emerald-500/25' : 'bg-rose-500/10 border-rose-500/25'
}`}>
<div className="flex items-center justify-between text-[11px]">
<span className={`truncate font-semibold ${realNetProfit >= 0 ? 'text-emerald-300' : 'text-rose-300'}`}>
{t('tiktokOrders.cardNetProfit', 'กำไรสุทธิ')}
</span>
<TrendingUp className={`w-3.5 h-3.5 shrink-0 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`} />
</div>
<p className={`text-lg font-bold font-mono mt-1 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
฿{fmt(realNetProfit)}
</p>
<div className="flex items-center gap-1.5 text-[10px] font-mono leading-tight flex-wrap">
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
กำไร/ขาย {marginOnSales.toFixed(1)}%
</span>
<span className="text-slate-500">•</span>
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
กำไร/ทุน {markupOnCost >= 0 ? '+' : ''}{markupOnCost.toFixed(1)}%
</span>
</div>
</div>
</div>
{/* 2-Column Wide Landscape Grid Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 items-start">
{/* Left Column (7 Cols): TikTok Platform Fees Breakdown */}
<div className="lg:col-span-7 p-4 sm:p-5 rounded-2xl bg-black/30 border border-white/10 space-y-3.5">
<div className="flex items-center justify-between pb-2.5 border-b border-white/10">
<div className="flex items-center gap-2">
<BadgePercent className="w-4 h-4 text-violet-400" />
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-300">
{t('tiktokOrders.feeBreakdownTitle', 'Breakdown รายการหักค่าใช้จ่ายของ TikTok')}
</h4>
</div>
<span className="text-xs font-mono font-bold text-rose-400">-฿{fmt(rawTotalFee)}</span>
</div>
<div className="space-y-2.5 text-xs">
{/* Commission Fee */}
<div className="flex items-center justify-between p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-violet-400 shrink-0" />
<div>
<p className="font-bold text-violet-200">
{t('tiktokOrders.tiktokCommissionFee', 'ค่าคอมมิชชั่น TikTok Shop (Commission Fee)')}
</p>
<p className="text-[10px] text-violet-300/70">
{t('tiktokOrders.tiktokCommissionDesc', 'หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม')}
</p>
</div>
</div>
<span className="font-mono font-bold text-violet-400 text-sm">
{commissionFee > 0 ? `-฿${fmt(commissionFee)}` : '฿0.00'}
</span>
</div>
{/* Affiliate Creator Commission */}
<div className="flex items-center justify-between p-3 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
<div className="flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-full bg-cyan-400 shrink-0" />
<div>
<p className="font-bold text-cyan-200">
{t('tiktokOrders.affiliateCommissionFee', 'ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)')}
</p>
<p className="text-[10px] text-cyan-300/70">
{t('tiktokOrders.affiliateCommissionDesc', 'จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์')}
</p>
</div>
</div>
<span className="font-mono font-bold text-cyan-400 text-sm">
{affiliateCommission > 0 ? `-฿${fmt(affiliateCommission)}` : '฿0.00'}
</span>
</div>
{/* Affiliate Partner Fee (TAP) */}
{(affiliatePartnerFee > 0 || settlement) && (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-cyan-400/60" />
<span>{t('tiktokOrders.affiliatePartnerFee', 'ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)')}</span>
</div>
<span className="font-mono text-cyan-300 font-semibold">
{affiliatePartnerFee > 0 ? `-฿${fmt(affiliatePartnerFee)}` : '฿0.00'}
</span>
</div>
)}
{/* Order / Transaction Fee */}
{(orderFee > 0 || settlement) && (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-amber-400" />
<span>{t('tiktokOrders.orderFee', 'ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)')}</span>
</div>
<span className="font-mono text-amber-400 font-semibold">
{orderFee > 0 ? `-฿${fmt(orderFee)}` : '฿0.00'}
</span>
</div>
)}
{/* Growth Support Fee */}
{growthSupportFee > 0 && (
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-blue-400" />
<span>{t('tiktokOrders.growthSupportFee', 'ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)')}</span>
</div>
<span className="font-mono text-blue-400 font-semibold">
-฿{fmt(growthSupportFee)}
</span>
</div>
)}
{/* Shipping Breakdown */}
{(shippingFeeActual > 0 || platformShippingDiscount > 0) && (
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1">
<div className="flex items-center justify-between text-slate-300 text-xs">
<div className="flex items-center gap-2">
<Truck className="w-3.5 h-3.5 text-blue-400" />
<span>{t('tiktokOrders.shippingBreakdown', 'ค่าจัดส่งขนส่ง')}</span>
</div>
<span className="font-mono font-semibold text-slate-200">
{netShippingToSeller > 0 ? `-฿${fmt(netShippingToSeller)}` : t('tiktokOrders.shippingFreeForSeller', 'ร้านค้าไม่เสียค่าส่ง')}
</span>
</div>
<div className="flex items-center justify-between text-[11px] pl-5 text-slate-400">
<span>
{t('tiktokOrders.shippingBreakdownDetail', {
actual: fmt(shippingFeeActual),
subsidy: fmt(platformShippingDiscount),
defaultValue: `ค่าส่งจริง ฿${fmt(shippingFeeActual)} | TikTok อุดหนุน -฿${fmt(platformShippingDiscount)}`
})}
</span>
</div>
</div>
)}
</div>
</div>
{/* Right Column (5 Cols): BOM Profitability & Order Items */}
<div className="lg:col-span-5 space-y-4">
{/* BOM Profitability Analysis Card */}
<div
className={`p-4 sm:p-5 rounded-2xl border space-y-3 ${
hasMissingBom ? 'bg-rose-500/10 border-rose-500/25' : 'bg-emerald-500/10 border-emerald-500/25'
}`}
>
<div className="flex items-center justify-between pb-2 border-b border-white/10">
<div className="flex items-center gap-2">
<Coins className={`w-4 h-4 ${hasMissingBom ? 'text-rose-400' : 'text-emerald-400'}`} />
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-200">
{t('tiktokOrders.bomProfitAnalysis', 'วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM')}
</h4>
</div>
{hasMissingBom ? (
<span className="text-[10px] font-bold text-rose-300 px-2 py-0.5 rounded-full bg-rose-500/20 border border-rose-500/30">
{t('tiktokOrders.missingBomCount', { count: order.missingBomCount })}
</span>
) : (
<span className="text-[10px] font-bold text-emerald-300 px-2 py-0.5 rounded-full bg-emerald-500/20 border border-emerald-500/30">
{t('tiktokOrders.bomComplete100', 'BOM ครบ 100%')}
</span>
)}
</div>
<div className="space-y-2 text-xs">
<div className="flex justify-between items-center text-slate-300">
<span>{t('tiktokOrders.netPayoutLabel', 'ยอดเงินที่ TikTok โอนให้')}</span>
<span className="font-mono font-bold text-white">฿{fmt(netSettlement)}</span>
</div>
<div className="flex justify-between items-center text-amber-300">
<span>{t('tiktokOrders.totalBomCostLabel', 'หัก ต้นทุนสินค้าตามสูตร BOM')}</span>
<span className="font-mono font-bold">
{totalBomCost > 0 ? `-฿${fmt(totalBomCost)}` : '฿0.00'}
</span>
</div>
<div className="pt-2.5 border-t border-white/10 flex justify-between items-center">
<div>
<p className="font-bold text-white text-sm">
{t('tiktokOrders.realNetProfitLabel', 'กำไรสุทธิแท้จริง (Real Net Profit)')}
</p>
<p className="text-[10px] text-slate-400">
{t('tiktokOrders.realNetMarginLabel', 'คิดเป็นอัตรากำไร')}:{' '}
<span className="font-mono font-bold text-slate-200">{realNetMargin.toFixed(1)}%</span>
</p>
</div>
<div className="text-right">
<span className={`text-2xl font-bold font-mono ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
฿{fmt(realNetProfit)}
</span>
</div>
</div>
</div>
</div>
{/* Order Items Snippet */}
{order.items && order.items.length > 0 && (
<div className="p-4 rounded-2xl bg-black/20 border border-white/10 space-y-2.5">
<div className="flex items-center justify-between text-xs font-bold text-slate-400 pb-1 border-b border-white/5">
<div className="flex items-center gap-1.5">
<Package className="w-3.5 h-3.5 text-slate-400" />
<span>
{t('tiktokOrders.orderItemsTitle', 'รายการสินค้าในออเดอร์นี้')} ({order.items.length})
</span>
</div>
</div>
<div className="divide-y divide-white/5 max-h-48 overflow-y-auto pr-1">
{order.items.map((item: any, idx: number) => (
<div key={idx} className="py-2 flex items-center justify-between gap-3 text-xs">
<div className="flex-1 min-w-0">
<p className="text-slate-200 font-medium truncate">{item.productName}</p>
<p className="text-[11px] text-slate-400 font-mono">
{item.variation ? `[${item.variation}] ` : ''}จำนวน: {item.quantity} ชิ้น
{item.masterSku ? ` • Master: ${item.masterSku}` : ''}
</p>
</div>
<div className="text-right shrink-0 font-mono">
<p className="text-white font-bold">
฿{fmt(item.subtotalAfterDiscount || item.unitOriginalPrice * (item.quantity || 1))}
</p>
<p className="text-[10px] text-amber-400">BOM: ฿{fmt(item.totalBomCost || 0)}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/[0.02]">
<Link
to={`/tiktok-order/${encodeURIComponent(order.orderId)}`}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-500/15 hover:bg-rose-500/25 border border-rose-500/30 text-rose-300 font-bold text-xs transition-colors"
>
<span>{t('tiktokOrders.viewFullOrderDetail', 'เปิดดูคำสั่งซื้อฉบับเต็ม')}</span>
<ExternalLink className="w-3.5 h-3.5" />
</Link>
<button
onClick={onClose}
className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors"
>
{t('tiktokOrders.closeModal', 'ปิด')}
</button>
</div>
</div>
</div>,
document.body
);
}
+4 -4
View File
@@ -116,18 +116,18 @@
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
to { opacity: 1; transform: none; }
}
@keyframes zoomIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
to { opacity: 1; transform: none; }
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out forwards;
animation: fadeIn 0.4s ease-out;
}
.animate-zoom-in {
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation: zoomIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@@ -454,6 +454,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "Net Profit",
"thActions": "Actions",
"viewFeeBreakdown": "Click to view TikTok fee breakdown",
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
"feeModalSub": "Detailed breakdown of TikTok Shop commission, affiliate fees, and net payout",
"settlementPendingAlert": "This order does not have a TikTok Settlement statement yet",
"settlementPendingDesc": "Commissions and fees below are preliminary estimates from order data until a Settlement XLSX file is imported from TikTok Seller Center.",
"settlementCompleteBadge": "Settled & Transferred",
"settlementPendingBadge": "Pending Settlement",
"orderRevenueLabel": "Product Revenue",
"tiktokDeductionsLabel": "Total TikTok Deductions",
"netPayoutLabel": "Net Payout",
"cardBomCost": "Product Cost (BOM)",
"cardNetProfit": "Net Profit",
"profitOnSales": "Profit/Sales",
"profitOnCost": "Profit/Cost",
"feeBreakdownTitle": "TikTok Platform Fee Deductions Breakdown",
"tiktokCommissionFee": "TikTok Shop Commission Fee",
"tiktokCommissionDesc": "Platform category commission rate deduction",
"affiliateCommissionFee": "Creator Affiliate Commission Fee",
"affiliateCommissionDesc": "Commission paid to creators/affiliates",
"affiliatePartnerFee": "Affiliate Partner Fee (TAP)",
"orderFee": "Order / Transaction Fee",
"growthSupportFee": "Growth Support Fee",
"shippingBreakdown": "Shipping & Logistics Fees",
"shippingBreakdownDetail": "Actual Shipping ฿{{actual}} | Platform Subsidy -฿{{subsidy}}",
"shippingFreeForSeller": "Seller shipping covered",
"sellerDiscountLabel": "Seller Discount",
"bomProfitAnalysis": "Real Net Profit Analysis (After BOM Cost)",
"totalBomCostLabel": "Deduct: BOM Material Cost",
"realNetProfitLabel": "Real Net Profit",
"realNetMarginLabel": "Net Margin",
"orderItemsTitle": "Items in this Order",
"viewFullOrderDetail": "View Full Order Detail",
"closeModal": "Close",
"copyOrderId": "Copy Order ID",
"copied": "Copied",
"processingData": "Processing data...",
"noOrders": "No data available",
"settledTransferred": "Settled ฿{{amount}}",
@@ -450,6 +450,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "純利益",
"thActions": "操作",
"viewFeeBreakdown": "クリックしてTikTok手数料明細を表示",
"feeModalTitle": "TikTok 手数料・精算内訳 (Breakdown)",
"feeModalSub": "TikTok Shop手数料、アフィリエイト報酬、純利益の詳細内訳",
"settlementPendingAlert": "この注文にはまだTikTok精算明細データがありません",
"settlementPendingDesc": "以下の手数料と数値は注文データに基づく概算です。TikTokセラーセンターから精算XLSXがインポートされると確定値に更新されます。",
"settlementCompleteBadge": "振込完了 (Settled)",
"settlementPendingBadge": "精算待ち",
"orderRevenueLabel": "商品売上高",
"tiktokDeductionsLabel": "TikTok 控除合計",
"netPayoutLabel": "振込受取額 (Net Payout)",
"cardBomCost": "商品原価 (BOM)",
"cardNetProfit": "純利益",
"profitOnSales": "売上利益率",
"profitOnCost": "原価利益率",
"feeBreakdownTitle": "TikTok プラットフォーム控除の内訳",
"tiktokCommissionFee": "TikTok Shop プラットフォーム手数料 (Commission)",
"tiktokCommissionDesc": "カテゴリ別プラットフォーム手数料率による控除",
"affiliateCommissionFee": "クリエイターアフィリエイト報酬 (Affiliate)",
"affiliateCommissionDesc": "紹介・ライブ配信クリエイターへの支払報酬",
"affiliatePartnerFee": "パートナー手数料 (TAP)",
"orderFee": "注文・決済処理手数料 (Order Fee)",
"growthSupportFee": "成長支援手数料 (Growth Support)",
"shippingBreakdown": "配送・物流手数料内訳",
"shippingBreakdownDetail": "実送料 ฿{{actual}} | プラットフォーム補助 -฿{{subsidy}}",
"shippingFreeForSeller": "ショップ負担なし",
"sellerDiscountLabel": "ショップ割引",
"bomProfitAnalysis": "BOM原価控除後の実質純利益分析",
"totalBomCostLabel": "控除:BOM原料および包材原価",
"realNetProfitLabel": "実質純利益 (Real Net Profit)",
"realNetMarginLabel": "純利益率",
"orderItemsTitle": "注文内商品一覧",
"viewFullOrderDetail": "注文詳細画面を開く",
"closeModal": "閉じる",
"copyOrderId": "注文番号をコピー",
"copied": "コピー完了",
"processingData": "データを処理中...",
"noOrders": "データがありません",
"settledTransferred": "振込済 ฿{{amount}}",
@@ -454,6 +454,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "กำไรสุทธิ",
"thActions": "การกระทำ",
"viewFeeBreakdown": "คลิกดู Breakdown ค่าธรรมเนียม TikTok",
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
"feeModalSub": "รายละเอียดค่าธรรมเนียม TikTok Shop, Affiliate และกำไรสุทธิ",
"settlementPendingAlert": "คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement",
"settlementPendingDesc": "ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center",
"settlementCompleteBadge": "โอนเงินแล้ว",
"settlementPendingBadge": "รอ Settlement",
"orderRevenueLabel": "ยอดขายสินค้า",
"tiktokDeductionsLabel": "TikTok หักรวม",
"netPayoutLabel": "ยอดโอนสุทธิ",
"cardBomCost": "ต้นทุนสินค้า (BOM)",
"cardNetProfit": "กำไรสุทธิ",
"profitOnSales": "กำไร/ยอดขาย",
"profitOnCost": "กำไร/ทุน",
"feeBreakdownTitle": "Breakdown รายการหักค่าใช้จ่ายของ TikTok",
"tiktokCommissionFee": "ค่าคอมมิชชั่น TikTok Shop (Commission Fee)",
"tiktokCommissionDesc": "หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม",
"affiliateCommissionFee": "ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)",
"affiliateCommissionDesc": "จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์",
"affiliatePartnerFee": "ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)",
"orderFee": "ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)",
"growthSupportFee": "ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)",
"shippingBreakdown": "ค่าจัดส่งขนส่ง",
"shippingBreakdownDetail": "ค่าส่งจริง ฿{{actual}} | TikTok อุดหนุน -฿{{subsidy}}",
"shippingFreeForSeller": "ร้านค้าไม่เสียค่าส่ง",
"sellerDiscountLabel": "ส่วนลดจากร้านค้า",
"bomProfitAnalysis": "วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM",
"totalBomCostLabel": "หัก ต้นทุนสินค้าตามสูตร BOM",
"realNetProfitLabel": "กำไรสุทธิแท้จริง (Real Net Profit)",
"realNetMarginLabel": "คิดเป็นอัตรากำไร",
"orderItemsTitle": "รายการสินค้าในออเดอร์นี้",
"viewFullOrderDetail": "เปิดดูคำสั่งซื้อฉบับเต็ม",
"closeModal": "ปิด",
"copyOrderId": "คัดลอก Order ID",
"copied": "คัดลอกแล้ว",
"processingData": "กำลังประมวลผลข้อมูล...",
"noOrders": "ยังไม่มีข้อมูล",
"settledTransferred": "โอนแล้ว ฿{{amount}}",
@@ -450,6 +450,41 @@
"thAffiliate": "Affiliate (฿)",
"thNetProfit": "净利润",
"thActions": "操作",
"viewFeeBreakdown": "点击查看 TikTok 费用明细",
"feeModalTitle": "TikTok 费用与结算明细",
"feeModalSub": "TikTok Shop 佣金、达人佣金与净利润详细拆解",
"settlementPendingAlert": "该订单暂无 TikTok 结算对账单数据",
"settlementPendingDesc": "以下佣金与费用为基于订单数据的预估,待从 TikTok 卖家中心导入结算 XLSX 文件后更新。",
"settlementCompleteBadge": "已完成打款",
"settlementPendingBadge": "等待结算",
"orderRevenueLabel": "商品销售额",
"tiktokDeductionsLabel": "TikTok 扣款合计",
"netPayoutLabel": "最终打款金额",
"cardBomCost": "商品成本 (BOM)",
"cardNetProfit": "净利润",
"profitOnSales": "利润率(销售)",
"profitOnCost": "利润率(成本)",
"feeBreakdownTitle": "TikTok 平台扣款明细",
"tiktokCommissionFee": "TikTok Shop 平台佣金 (Commission Fee)",
"tiktokCommissionDesc": "按平台品类佣金率扣除",
"affiliateCommissionFee": "达人/分销佣金 (Affiliate Commission)",
"affiliateCommissionDesc": "支付给带货达人的佣金",
"affiliatePartnerFee": "机构/合作伙伴佣金 (TAP)",
"orderFee": "订单/交易手续费 (Order Fee)",
"growthSupportFee": "成长支持费 (Growth Support Fee)",
"shippingBreakdown": "物流运费明细",
"shippingBreakdownDetail": "实际运费 ฿{{actual}} | 平台补贴 -฿{{subsidy}}",
"shippingFreeForSeller": "商家免运费",
"sellerDiscountLabel": "商家折扣",
"bomProfitAnalysis": "扣除 BOM 成本后真实净利润分析",
"totalBomCostLabel": "扣除:BOM 原料及包装成本",
"realNetProfitLabel": "真实净利润 (Real Net Profit)",
"realNetMarginLabel": "净利润率",
"orderItemsTitle": "订单内商品列表",
"viewFullOrderDetail": "查看订单完整详情",
"closeModal": "关闭",
"copyOrderId": "复制订单号",
"copied": "已复制",
"processingData": "正在处理数据...",
"noOrders": "暂无数据",
"settledTransferred": "已打款 ฿{{amount}}",
+58 -4
View File
@@ -11,9 +11,13 @@ import {
ArrowLeft,
Coins,
Layers,
Calculator
Calculator,
Bot,
FileJson
} from 'lucide-react'
import { API_BASE, type Product } from '@homaroma/types'
import ProductBomPromptModal from '../components/ProductBomPromptModal'
import ProductBomImportModal from '../components/ProductBomImportModal'
function fmtMoney(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '฿0.00'
@@ -38,6 +42,14 @@ export default function ProductBomPage() {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [isPromptModalOpen, setIsPromptModalOpen] = useState(false)
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
const handleApplyBomJson = (updatedVariants: any[]) => {
setVariants(updatedVariants)
setSuccess(true)
setTimeout(() => setSuccess(false), 3500)
}
useEffect(() => {
const fetchData = async () => {
@@ -143,9 +155,14 @@ export default function ProductBomPage() {
}
const productName =
typeof product.name === 'string'
? product.name
: (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
(typeof product.name === 'string' && product.name.trim())
? product.name.trim()
: (product.name as any)?.th ||
(product.name as any)?.en ||
(typeof product.name === 'object' && Object.values(product.name || {}).find((v) => typeof v === 'string' && (v as string).trim()) as string) ||
((product as any).brand?.name
? `${(product as any).brand?.name} ${(product as any).category?.name || ''}`.trim()
: `Product #${product.id}`)
// Summary Metrics Across All Variants
const totalVariantsCount = variants.length
@@ -177,6 +194,24 @@ export default function ProductBomPage() {
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setIsPromptModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 text-purple-300 border border-purple-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
title="สร้าง Prompt สำหรับส่งให้ LLM คิดและตอบกลับเป็นสูตร BOM"
>
<Bot className="w-4 h-4 text-purple-400" />
<span>Get Prompt</span>
</button>
<button
type="button"
onClick={() => setIsImportModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-sm font-bold flex items-center gap-2 transition-all shadow-sm"
title="วาง JSON จาก LLM เพื่อกรอกสูตร BOM ลงในตารางอัตโนมัติ"
>
<FileJson className="w-4 h-4 text-cyan-400" />
<span>Import BOM JSON</span>
</button>
<button
onClick={() => navigate('/products')}
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
@@ -534,6 +569,25 @@ export default function ProductBomPage() {
})
)}
</div>
{/* LLM Prompt Generator Modal */}
<ProductBomPromptModal
isOpen={isPromptModalOpen}
onClose={() => setIsPromptModalOpen(false)}
productId={id || ''}
productName={productName}
variants={variants}
rawMaterials={rawMaterials}
/>
{/* LLM JSON Import Modal */}
<ProductBomImportModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
variants={variants}
rawMaterials={rawMaterials}
onApply={handleApplyBomJson}
/>
</div>
)
}
@@ -0,0 +1,431 @@
import { useState, useEffect, type FormEvent } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
Save,
Trash2,
Loader2,
PackageOpen,
Boxes,
ExternalLink,
Layers,
Sparkles,
CheckCircle2,
AlertCircle
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
const COMMON_UNITS = [
{ label: 'ชิ้น (piece)', value: 'piece' },
{ label: 'กก. (kg)', value: 'kg' },
{ label: 'กรัม (g)', value: 'g' },
{ label: 'ลิตร (liter)', value: 'liter' },
{ label: 'มล. (ml)', value: 'ml' },
{ label: 'แพ็ค (pack)', value: 'pack' },
{ label: 'กล่อง (box)', value: 'box' },
{ label: 'หลอด (tube)', value: 'tube' },
];
export default function RawMaterialDetailPage() {
const { id } = useParams<{ id: string }>();
const isNew = !id || id === 'new';
const navigate = useNavigate();
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [toast, setToast] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Form Fields
const [name, setName] = useState('');
const [unit, setUnit] = useState('piece');
const [pricePerUnit, setPricePerUnit] = useState<number>(0);
const [materialData, setMaterialData] = useState<any>(null);
useEffect(() => {
if (isNew) return;
setLoading(true);
fetch(`${API_BASE}/raw-materials/${id}`)
.then(async (res) => {
if (!res.ok) throw new Error('ไม่พบข้อมูลวัตถุดิบ');
return await res.json();
})
.then((data) => {
setMaterialData(data);
setName(data.name || '');
setUnit(data.unit || 'piece');
setPricePerUnit(Number(data.pricePerUnit) || 0);
})
.catch((err) => {
console.error(err);
setToast({ type: 'error', text: err.message || 'เกิดข้อผิดพลาดในการโหลดข้อมูล' });
})
.finally(() => setLoading(false));
}, [id, isNew]);
const showToast = (type: 'success' | 'error', text: string) => {
setToast({ type, text });
setTimeout(() => setToast(null), 3000);
};
const handleSave = async (e?: FormEvent) => {
if (e) e.preventDefault();
if (!name.trim()) {
showToast('error', 'กรุณาระบุชื่อวัตถุดิบ');
return;
}
if (!unit.trim()) {
showToast('error', 'กรุณาระบุหน่วยนับ');
return;
}
if (pricePerUnit < 0) {
showToast('error', 'ราคาต้นทุนต่อหน่วยต้องไม่ติดลบ');
return;
}
setSaving(true);
try {
const payload = {
name: name.trim(),
unit: unit.trim(),
pricePerUnit: Number(pricePerUnit) || 0,
};
const url = isNew ? `${API_BASE}/raw-materials` : `${API_BASE}/raw-materials/${id}`;
const method = isNew ? 'POST' : 'PUT';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.message || 'บันทึกข้อมูลไม่สำเร็จ');
}
showToast('success', isNew ? 'สร้างวัตถุดิบใหม่สำเร็จ' : 'บันทึกการแก้ไขสำเร็จ');
if (isNew && data.id) {
setTimeout(() => {
navigate(`/raw-materials/${data.id}`, { replace: true });
}, 800);
}
} catch (err: any) {
showToast('error', err.message || 'เกิดข้อผิดพลาดในการบันทึก');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
setDeleting(true);
try {
const res = await fetch(`${API_BASE}/raw-materials/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
}
navigate('/raw-materials', { replace: true });
} catch (err: any) {
showToast('error', err.message || 'เกิดข้อผิดพลาดในการลบ');
setDeleting(false);
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
<p className="text-slate-400 text-sm">กำลังโหลดข้อมูลวัตถุดิบ...</p>
</div>
);
}
const bomItems = materialData?.bomItems || [];
return (
<div className="space-y-8 animate-fade-in max-w-5xl mx-auto pb-16">
{/* Toast Notification */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 p-4 rounded-2xl border shadow-2xl flex items-center gap-3 animate-slide-up ${
toast.type === 'success'
? 'bg-emerald-950/90 border-emerald-500/40 text-emerald-200'
: 'bg-rose-950/90 border-rose-500/40 text-rose-200'
}`}
>
{toast.type === 'success' ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
) : (
<AlertCircle className="w-5 h-5 text-rose-400" />
)}
<span className="text-sm font-medium">{toast.text}</span>
</div>
)}
{/* Top Breadcrumb & Navigation */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Link to="/raw-materials" className="hover:text-white transition-colors flex items-center gap-1">
<PackageOpen className="w-3.5 h-3.5" />
<span>Raw Materials</span>
</Link>
<span>/</span>
<span className="text-cyan-400 font-medium">
{isNew ? 'เพิ่มวัตถุดิบใหม่' : name || `ID #${id}`}
</span>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate('/raw-materials')}
className="p-2.5 rounded-2xl bg-white/5 border border-white/10 hover:bg-white/10 text-slate-300 hover:text-white transition-all shadow-sm"
title="ย้อนกลับไปหน้ารายการ"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl md:text-3xl font-bold text-white flex items-center gap-2.5">
{isNew ? 'เพิ่มวัตถุดิบใหม่' : `แก้ไขวัตถุดิบ`}
{!isNew && (
<span className="text-xs px-2.5 py-1 rounded-full bg-cyan-500/10 text-cyan-300 border border-cyan-500/20 font-mono">
ID: #{id}
</span>
)}
</h1>
<p className="text-xs md:text-sm text-slate-400">
{isNew
? 'สร้างรายการวัตถุดิบสำหรับใช้คำนวณต้นทุนสินค้าและผูกสูตรผลิต (BOM)'
: 'กำหนดข้อมูลราคาต้นทุนและหน่วยนับสำหรับใช้ในสูตรการผลิตสินค้า'}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{!isNew && (
<button
type="button"
onClick={handleDelete}
disabled={deleting || saving}
className="px-4 py-2.5 rounded-xl border border-rose-500/30 text-rose-400 hover:bg-rose-500/10 hover:border-rose-500/50 font-medium text-sm flex items-center gap-2 transition-all disabled:opacity-50"
>
{deleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
<span>ลบวัตถุดิบ</span>
</button>
)}
<button
type="button"
onClick={() => handleSave()}
disabled={saving}
className="btn-primary px-6 py-2.5 flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
<span>{saving ? 'กำลังบันทึก...' : 'บันทึกข้อมูล'}</span>
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column: Form Details (2 cols) */}
<div className="lg:col-span-2 space-y-6">
<form onSubmit={handleSave} className="admin-card space-y-6 border-white/10 shadow-2xl p-6 md:p-8">
<div className="flex items-center gap-2 pb-4 border-b border-white/5">
<Sparkles className="w-4 h-4 text-cyan-400" />
<h2 className="text-base font-bold text-white uppercase tracking-wider text-xs">
ข้อมูลวัตถุดิบ (Material Information)
</h2>
</div>
{/* Material Name */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
<span>ชื่อวัตถุดิบ (Material Name) <span className="text-rose-400">*</span></span>
<span className="text-[10px] text-slate-500 font-normal">ห้ามซ้ำกับรายการอื่นในระบบ</span>
</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="เช่น ขวดแก้ว 100ml, น้ำมันหอมระเหย ลาเวนเดอร์, ก้านไม้ไฟเบอร์"
className="admin-input w-full text-sm"
/>
</div>
{/* Unit Selection */}
<div className="space-y-3">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider">
หน่วยนับ (Unit of Measurement) <span className="text-rose-400">*</span>
</label>
<input
type="text"
required
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder="เช่น piece, kg, liter, pack, ml"
className="admin-input w-full text-sm"
/>
{/* Quick Preset Unit Chips */}
<div className="space-y-1.5">
<span className="text-[11px] text-slate-400">เลือกหน่วยที่ใช้บ่อย:</span>
<div className="flex flex-wrap gap-2">
{COMMON_UNITS.map((u) => (
<button
key={u.value}
type="button"
onClick={() => setUnit(u.value)}
className={`text-xs px-2.5 py-1 rounded-lg border transition-all ${
unit === u.value
? 'bg-cyan-500/20 border-cyan-500/40 text-cyan-300 font-bold'
: 'bg-white/5 border-white/5 text-slate-400 hover:text-white hover:bg-white/10'
}`}
>
{u.label}
</button>
))}
</div>
</div>
</div>
{/* Cost Price per Unit */}
<div className="space-y-2">
<label className="text-xs font-bold text-slate-300 uppercase tracking-wider flex items-center justify-between">
<span>ต้นทุนต่อหน่วย (Cost per Unit) <span className="text-rose-400">*</span></span>
<span className="text-[10px] text-slate-500 font-normal">ใช้ในการคำนวณสูตร BOM สินค้า</span>
</label>
<div className="relative">
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 font-bold text-sm">
฿
</span>
<input
type="number"
step="0.01"
min="0"
required
value={pricePerUnit === 0 ? '' : pricePerUnit}
onChange={(e) => setPricePerUnit(parseFloat(e.target.value) || 0)}
placeholder="0.00"
className="admin-input w-full pl-9 font-mono text-emerald-400 font-bold text-base"
/>
</div>
<p className="text-[11px] text-slate-500">
ระบุราคาต้นทุนเฉลี่ยต่อ 1 {unit || 'หน่วย'} เช่น 18.50 บาท / ขวด
</p>
</div>
</form>
</div>
{/* Right Column: BOM Linked Products & Summary (1 col) */}
<div className="space-y-6">
{/* Summary Card */}
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
<div className="flex items-center gap-2 pb-3 border-b border-white/5">
<Layers className="w-4 h-4 text-cyan-400" />
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
สรุปภาพรวม (Overview)
</h3>
</div>
<div className="space-y-3 text-xs">
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">สถานะ:</span>
<span className="font-semibold text-emerald-400">พร้อมใช้งาน</span>
</div>
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">หน่วยนับ:</span>
<span className="font-mono font-bold text-white">{unit || '-'}</span>
</div>
<div className="flex justify-between py-1.5 border-b border-white/5 text-slate-300">
<span className="text-slate-400">ต้นทุน / หน่วย:</span>
<span className="font-mono font-bold text-emerald-400">
฿{Number(pricePerUnit || 0).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</span>
</div>
{!isNew && materialData?.updatedAt && (
<div className="flex justify-between py-1.5 text-slate-300">
<span className="text-slate-400">อัปเดตล่าสุด:</span>
<span className="text-slate-400">
{new Date(materialData.updatedAt).toLocaleDateString('th-TH')}
</span>
</div>
)}
</div>
</div>
{/* BOM Usage Section */}
{!isNew && (
<div className="admin-card border-white/10 shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between pb-3 border-b border-white/5">
<div className="flex items-center gap-2">
<Boxes className="w-4 h-4 text-cyan-400" />
<h3 className="text-xs font-bold text-white uppercase tracking-wider">
สูตรสินค้าที่ใช้วัตถุดิบนี้ ({bomItems.length})
</h3>
</div>
<Link
to="/products/bom"
className="text-[11px] text-cyan-400 hover:text-cyan-300 flex items-center gap-1 transition-colors"
>
<span>จัดการ BOM</span>
<ExternalLink className="w-3 h-3" />
</Link>
</div>
{bomItems.length === 0 ? (
<div className="py-6 text-center space-y-2">
<Boxes className="w-8 h-8 text-slate-700 mx-auto" />
<p className="text-xs text-slate-400 italic">
ยังไม่มีสูตรสินค้า (BOM) ที่ผูกกับวัตถุดิบนี้
</p>
<p className="text-[11px] text-slate-500">
สามารถไปผูกวัตถุดิบเข้ากับ Variant สินค้าได้ที่เมนู "BOM สูตรผลิต"
</p>
</div>
) : (
<div className="space-y-2.5 max-h-[300px] overflow-y-auto pr-1">
{bomItems.map((item: any, idx: number) => {
const product = item.productVariant?.product;
const variant = item.productVariant;
const variantLabel = [variant?.option1, variant?.option2, variant?.option3]
.filter(Boolean)
.join(' / ');
return (
<div
key={item.id || idx}
className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1 hover:border-white/10 transition-all"
>
<p className="text-xs font-bold text-white line-clamp-1">
{product?.name || `Product #${variant?.productId}`}
</p>
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span>{variantLabel ? `ตัวเลือก: ${variantLabel}` : 'Base Variant'}</span>
<span className="font-mono text-cyan-400 font-bold">
{item.quantity} {unit}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
+339 -171
View File
@@ -1,227 +1,395 @@
import { useState, useEffect } from 'react'
import { Plus, Edit3, Trash2, Loader2, PackageOpen } from 'lucide-react'
import { API_BASE, type RawMaterial } from '@homaroma/types'
import { useState, useEffect, useMemo } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
Plus,
Edit3,
Trash2,
PackageOpen,
Search,
FileJson,
Boxes,
Layers,
Sparkles,
Check,
X,
Loader2,
Pencil,
} from 'lucide-react';
import { API_BASE, type RawMaterial } from '@homaroma/types';
import RawMaterialBulkJsonModal from '../components/RawMaterialBulkJsonModal';
export default function RawMaterialsPage() {
const [materials, setMaterials] = useState<RawMaterial[]>([])
const [loading, setLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [name, setName] = useState('')
const [unit, setUnit] = useState('')
const [pricePerUnit, setPricePerUnit] = useState<number>(0)
const [saving, setSaving] = useState(false)
const navigate = useNavigate();
const [materials, setMaterials] = useState<RawMaterial[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [isBulkModalOpen, setIsBulkModalOpen] = useState(false);
// Inline Price Editing State
const [editingId, setEditingId] = useState<number | null>(null);
const [editingPrice, setEditingPrice] = useState<string>('');
const [savingId, setSavingId] = useState<number | null>(null);
const startEditing = (item: RawMaterial) => {
setEditingId(item.id);
setEditingPrice(String(item.pricePerUnit ?? ''));
};
const cancelEditing = () => {
setEditingId(null);
setEditingPrice('');
};
const handleSavePrice = async (item: RawMaterial) => {
const val = parseFloat(editingPrice);
if (isNaN(val) || val < 0) {
alert('กรุณาระบุราคาต้นทุนให้ถูกต้อง (ต้องเป็นตัวเลขมากกว่าหรือเท่ากับ 0)');
return;
}
setSavingId(item.id);
try {
const res = await fetch(`${API_BASE}/raw-materials/${item.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pricePerUnit: val,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.message || 'บันทึกราคาไม่สำเร็จ');
}
setMaterials((prev) =>
prev.map((m) => (m.id === item.id ? { ...m, pricePerUnit: val } : m))
);
setEditingId(null);
setEditingPrice('');
} catch (err: any) {
console.error(err);
alert(err.message || 'เกิดข้อผิดพลาดในการบันทึกราคา');
} finally {
setSavingId(null);
}
};
const fetchMaterials = async () => {
setLoading(true)
setLoading(true);
try {
const res = await fetch(`${API_BASE}/raw-materials`)
const res = await fetch(`${API_BASE}/raw-materials`);
if (res.ok) {
setMaterials(await res.json())
setMaterials(await res.json());
}
} catch (err) {
console.error('Failed to fetch raw materials', err)
console.error('Failed to fetch raw materials', err);
} finally {
setLoading(false)
setLoading(false);
}
}
};
useEffect(() => {
fetchMaterials()
}, [])
fetchMaterials();
}, []);
const handleOpenModal = (material?: RawMaterial) => {
if (material) {
setEditingId(material.id)
setName(material.name)
setUnit(material.unit)
setPricePerUnit(material.pricePerUnit)
} else {
setEditingId(null)
setName('')
setUnit('')
setPricePerUnit(0)
}
setIsModalOpen(true)
}
const handleDelete = async (id: number, name: string) => {
if (!confirm(`คุณแน่ใจหรือไม่ว่าต้องการลบวัตถุดิบ "${name}"?`)) return;
const handleSave = async () => {
if (!name || !unit || pricePerUnit <= 0) return alert('Please fill all fields')
setSaving(true)
try {
const payload = { name, unit, pricePerUnit }
const url = editingId
? `${API_BASE}/raw-materials/${editingId}`
: `${API_BASE}/raw-materials`
const res = await fetch(url, {
method: editingId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' });
if (res.ok) {
setIsModalOpen(false)
fetchMaterials()
fetchMaterials();
} else {
alert('Failed to save raw material')
const data = await res.json();
alert(data.message || 'ไม่สามารถลบวัตถุดิบนี้ได้');
}
} catch (err) {
console.error(err)
} finally {
setSaving(false)
console.error(err);
}
}
};
const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this raw material?')) return
try {
const res = await fetch(`${API_BASE}/raw-materials/${id}`, { method: 'DELETE' })
if (res.ok) {
fetchMaterials()
}
} catch (err) {
console.error(err)
}
}
// Filtered materials by search query
const filteredMaterials = useMemo(() => {
if (!searchQuery.trim()) return materials;
const q = searchQuery.toLowerCase().trim();
return materials.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.unit.toLowerCase().includes(q) ||
String(m.id).includes(q)
);
}, [materials, searchQuery]);
// Aggregate metrics
const metrics = useMemo(() => {
const totalCount = materials.length;
const avgCost = totalCount > 0
? materials.reduce((sum, m) => sum + (Number(m.pricePerUnit) || 0), 0) / totalCount
: 0;
const units = new Set(materials.map((m) => m.unit.toLowerCase())).size;
return { totalCount, avgCost, units };
}, [materials]);
return (
<div className="space-y-8 animate-fade-in">
<div className="space-y-8 animate-fade-in pb-12">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1">
<h1 className="text-3xl font-bold">Raw Materials (วัตถุดิบ)</h1>
<p className="text-slate-500 text-sm">Manage inventory and costs for BOM calculation.</p>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">Raw Materials (วัตถุดิบ)</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">
{materials.length} รายการ
</span>
</div>
<p className="text-slate-400 text-sm">
จัดการรายการวัตถุดิบ หน่วยนับ และต้นทุนสำหรับผูกสูตรผลิตสินค้า (BOM)
</p>
</div>
<div className="flex items-center gap-3">
{/* Bulk JSON Import / Export Button */}
<button
type="button"
onClick={() => setIsBulkModalOpen(true)}
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 font-medium text-sm flex items-center gap-2 transition-all shadow-sm"
>
<FileJson className="w-4 h-4 text-cyan-400" />
<span>Import / Export JSON</span>
</button>
{/* Add Material (Navigates to Full Page Route) */}
<button
type="button"
onClick={() => navigate('/raw-materials/new')}
className="btn-primary flex items-center gap-2 text-sm shadow-lg shadow-cyan-500/10"
>
<Plus className="w-4 h-4" />
<span>เพิ่มวัตถุดิบ</span>
</button>
</div>
<button onClick={() => handleOpenModal()} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Material
</button>
</div>
{/* Metrics Row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
<PackageOpen className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">วัตถุดิบทั้งหมด</p>
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.totalCount} <span className="text-xs font-normal text-slate-400">รายการ</span></p>
</div>
</div>
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 shrink-0">
<Sparkles className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ต้นทุนเฉลี่ย / หน่วย</p>
<p className="text-2xl font-bold text-emerald-400 font-mono mt-0.5">
฿{metrics.avgCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
</div>
<div className="admin-card p-5 border-white/5 bg-night-900/60 flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400 shrink-0">
<Layers className="w-6 h-6" />
</div>
<div>
<p className="text-xs font-bold text-slate-400 uppercase tracking-wider">ประเภทหน่วยนับ</p>
<p className="text-2xl font-bold text-white font-mono mt-0.5">{metrics.units} <span className="text-xs font-normal text-slate-400">แบบ</span></p>
</div>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="ค้นหาตามชื่อ หรือ หน่วยนับ..."
className="admin-input w-full pl-10 text-xs"
/>
</div>
<div className="text-xs text-slate-400">
แสดง <strong>{filteredMaterials.length}</strong> จากทั้งหมด {materials.length} รายการ
</div>
</div>
{/* Data Table */}
<div className="admin-card !p-0 overflow-hidden border-white/10 shadow-2xl">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-white/[0.02] border-b border-white/5">
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Material Name</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Unit</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Cost Per Unit</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">Actions</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 w-16">ID</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ชื่อวัตถุดิบ (Material Name)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">หน่วยนับ (Unit)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">ต้นทุนต่อหน่วย (Cost)</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">การจัดการ</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.03]">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
Array.from({ length: 4 }).map((_, i) => (
<tr key={i}>
<td colSpan={4} className="px-6 py-8">
<td colSpan={5} className="px-6 py-8">
<div className="h-6 w-full bg-white/5 animate-pulse rounded-lg" />
</td>
</tr>
))
) : materials.length === 0 ? (
) : filteredMaterials.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-20 text-center space-y-3">
<td colSpan={5} className="px-6 py-20 text-center space-y-3">
<PackageOpen className="w-10 h-10 text-slate-700 mx-auto" />
<p className="text-slate-500 font-medium italic">No raw materials found.</p>
</td>
</tr>
) : materials.map((item) => (
<tr key={item.id} className="hover:bg-white/[0.01] transition-colors">
<td className="px-6 py-4">
<p className="text-sm font-bold text-white">{item.name}</p>
</td>
<td className="px-6 py-4">
<span className="inline-block px-2 py-1 rounded-md bg-night-950 border border-white/5 text-slate-400 text-[10px] uppercase font-bold tracking-wider">
{item.unit}
</span>
</td>
<td className="px-6 py-4">
<p className="text-sm font-bold text-emerald-400">฿{item.pricePerUnit.toLocaleString(undefined, { minimumFractionDigits: 2 })}</p>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button onClick={() => handleOpenModal(item)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all">
<Edit3 className="w-4 h-4" />
<p className="text-slate-400 font-medium">
{searchQuery ? 'ไม่พบวัตถุดิบที่ตรงกับคำค้นหา' : 'ยังไม่มีข้อมูลวัตถุดิบในระบบ'}
</p>
<div className="flex items-center justify-center gap-3 pt-2">
<button
type="button"
onClick={() => setIsBulkModalOpen(true)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-medium border border-white/10 transition-colors"
>
นำเข้าผ่าน JSON
</button>
<button onClick={() => handleDelete(item.id)} className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all">
<Trash2 className="w-4 h-4" />
<button
type="button"
onClick={() => navigate('/raw-materials/new')}
className="btn-primary text-xs px-4 py-2"
>
เพิ่มวัตถุดิบใหม่
</button>
</div>
</td>
</tr>
))}
) : (
filteredMaterials.map((item) => (
<tr key={item.id} className="hover:bg-white/[0.015] transition-colors group">
<td className="px-6 py-4 text-xs font-mono text-slate-500">
#{item.id}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center text-cyan-400 shrink-0">
<Boxes className="w-4 h-4" />
</div>
<Link
to={`/raw-materials/${item.id}`}
className="text-sm font-bold text-white hover:text-cyan-400 transition-colors"
>
{item.name}
</Link>
</div>
</td>
<td className="px-6 py-4">
<span className="inline-block px-2.5 py-1 rounded-md bg-night-950 border border-white/5 text-slate-300 text-xs font-mono font-medium">
{item.unit}
</span>
</td>
<td className="px-6 py-3">
{editingId === item.id ? (
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-xs text-slate-500 font-mono font-bold">
฿
</span>
<input
type="number"
step="0.01"
min="0"
autoFocus
value={editingPrice}
onChange={(e) => setEditingPrice(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSavePrice(item);
if (e.key === 'Escape') cancelEditing();
}}
disabled={savingId === item.id}
className="w-28 pl-6 pr-2 py-1 text-sm font-bold font-mono text-emerald-400 bg-black/80 border border-cyan-500/60 rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500/40 shadow-inner"
placeholder="0.00"
/>
</div>
<button
type="button"
disabled={savingId === item.id}
onClick={() => handleSavePrice(item)}
className="p-1.5 rounded-lg bg-emerald-500/20 hover:bg-emerald-500/30 text-emerald-300 border border-emerald-500/40 transition-all disabled:opacity-50"
title="บันทึกราคา (Enter)"
>
{savingId === item.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Check className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"
disabled={savingId === item.id}
onClick={cancelEditing}
className="p-1.5 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 border border-white/10 transition-all disabled:opacity-50"
title="ยกเลิก (Esc)"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<div
onClick={() => startEditing(item)}
className="group/price inline-flex items-center gap-2 cursor-pointer px-2.5 py-1 -ml-2.5 rounded-lg hover:bg-white/5 border border-transparent hover:border-white/10 transition-all"
title="คลิกที่นี่เพื่อแก้ไขราคาต่อหน่วย (Inline Edit)"
>
<p className="text-sm font-bold text-emerald-400 font-mono">
฿{Number(item.pricePerUnit).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
<Pencil className="w-3 h-3 text-slate-500 opacity-0 group-hover/price:opacity-100 transition-opacity" />
</div>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{/* Edit Button navigates to Full Page Route */}
<button
type="button"
onClick={() => navigate(`/raw-materials/${item.id}`)}
className="px-3 py-1.5 rounded-lg bg-white/5 hover:bg-cyan-500/10 text-slate-400 hover:text-cyan-400 border border-white/5 hover:border-cyan-500/20 text-xs font-medium transition-all flex items-center gap-1.5"
title="แก้ไขรายละเอียด"
>
<Edit3 className="w-3.5 h-3.5" />
<span>แก้ไข</span>
</button>
<button
type="button"
onClick={() => handleDelete(item.id, item.name)}
className="p-1.5 rounded-lg hover:bg-rose-500/10 text-slate-500 hover:text-rose-400 transition-all"
title="ลบรายการ"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-night-950/80 backdrop-blur-sm" onClick={() => !saving && setIsModalOpen(false)} />
<div className="relative w-full max-w-md bg-night-900 border border-white/10 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-zoom-in p-6 space-y-6">
<h3 className="text-xl font-bold">{editingId ? 'Edit Material' : 'Add New Material'}</h3>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Material Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="admin-input w-full"
placeholder="e.g. Alcohol, Glass Bottle 50ml"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Unit</label>
<input
type="text"
value={unit}
onChange={e => setUnit(e.target.value)}
className="admin-input w-full"
placeholder="e.g. kg, liter, piece"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-slate-400 uppercase tracking-wider">Price per Unit (฿)</label>
<input
type="number"
value={pricePerUnit || ''}
onChange={e => setPricePerUnit(Number(e.target.value))}
className="admin-input w-full"
placeholder="0.00"
step="0.01"
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-white/5">
<button
onClick={() => setIsModalOpen(false)}
disabled={saving}
className="px-4 py-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/5 font-medium transition-all"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="btn-primary px-6 py-2 flex items-center gap-2"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? 'Saving...' : 'Save Material'}
</button>
</div>
</div>
</div>
)}
{/* Bulk JSON Import / Export Modal */}
<RawMaterialBulkJsonModal
isOpen={isBulkModalOpen}
onClose={() => setIsBulkModalOpen(false)}
materials={materials}
onSuccess={fetchMaterials}
/>
</div>
)
);
}
+33 -8
View File
@@ -21,6 +21,7 @@ import {
import { API_BASE } from '@homaroma/types';
import { TIKTOK_SHOPS } from './TiktokOrderPage';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
function authHeaders() {
return {
@@ -66,6 +67,7 @@ export default function TiktokOrderDetailPage() {
const [order, setOrder] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showFeeModal, setShowFeeModal] = useState(false);
useEffect(() => {
if (!orderId) return;
@@ -410,19 +412,35 @@ export default function TiktokOrderDetailPage() {
</span>
</div>
<div className="flex justify-between items-center text-violet-300">
<span className="text-slate-400">{t('tiktokOrderDetail.commissionSummary')}</span>
<span className="font-mono font-semibold">
<button
type="button"
onClick={() => setShowFeeModal(true)}
className="w-full flex justify-between items-center text-violet-300 hover:bg-violet-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
{t('tiktokOrderDetail.commissionSummary')}
<Receipt className="w-3 h-3 text-violet-400/50 group-hover:text-violet-300 transition-colors" />
</span>
<span className="font-mono font-semibold text-xs text-violet-400 group-hover:underline">
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
</span>
</div>
</button>
<div className="flex justify-between items-center text-cyan-300">
<span className="text-slate-400">{t('tiktokOrderDetail.affiliateSummary')}</span>
<span className="font-mono font-semibold">
<button
type="button"
onClick={() => setShowFeeModal(true)}
className="w-full flex justify-between items-center text-cyan-300 hover:bg-cyan-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
{t('tiktokOrderDetail.affiliateSummary')}
<Receipt className="w-3 h-3 text-cyan-400/50 group-hover:text-cyan-300 transition-colors" />
</span>
<span className="font-mono font-semibold text-xs text-cyan-400 group-hover:underline">
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
</span>
</div>
</button>
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
<div>
@@ -527,6 +545,13 @@ export default function TiktokOrderDetailPage() {
</div>
</div>
</div>
{showFeeModal && (
<TiktokFeeBreakdownModal
order={order}
onClose={() => setShowFeeModal(false)}
/>
)}
</div>
);
}
+61 -13
View File
@@ -7,10 +7,11 @@ import {
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
BadgePercent, Wallet, Receipt
BadgePercent, Wallet, Receipt, Info
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
// ── Shop Config ───────────────────────────────────────────────────────────────
export const TIKTOK_SHOPS = [
@@ -164,6 +165,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [bomFilter, setBomFilter] = useState('all');
const [selectedFeeOrder, setSelectedFeeOrder] = useState<any | null>(null);
useEffect(() => {
const timer = setTimeout(() => {
@@ -396,8 +398,24 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
<th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400">{t('tiktokOrders.thCommission')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th>
<th
className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400 cursor-help"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="inline-flex items-center gap-1 justify-end">
{t('tiktokOrders.thCommission')}
<Info className="w-3 h-3 text-violet-400/50" />
</span>
</th>
<th
className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400 cursor-help"
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
>
<span className="inline-flex items-center gap-1 justify-end">
{t('tiktokOrders.thAffiliate')}
<Info className="w-3 h-3 text-cyan-400/50" />
</span>
</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
<th className="px-4 py-3.5 text-center" />
</tr>
@@ -507,20 +525,42 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
{/* Commission (฿) */}
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
{order.commissionFee > 0 ? (
<span className="text-violet-400 font-bold">-฿{fmt(order.commissionFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px]">-</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedFeeOrder(order);
}}
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-violet-500/15 border border-transparent hover:border-violet-500/30 transition-all cursor-pointer"
>
{order.commissionFee > 0 ? (
<span className="text-violet-400 font-bold group-hover/btn:underline">-฿{fmt(order.commissionFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
)}
<Receipt className="w-3 h-3 text-violet-400/40 group-hover/btn:text-violet-300 transition-colors" />
</button>
</td>
{/* Affiliate (฿) */}
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
{order.affiliateFee > 0 ? (
<span className="text-cyan-400 font-bold">-฿{fmt(order.affiliateFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px]">-</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedFeeOrder(order);
}}
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-cyan-500/15 border border-transparent hover:border-cyan-500/30 transition-all cursor-pointer"
>
{order.affiliateFee > 0 ? (
<span className="text-cyan-400 font-bold group-hover/btn:underline">-฿{fmt(order.affiliateFee)}</span>
) : (
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
)}
<Receipt className="w-3 h-3 text-cyan-400/40 group-hover/btn:text-cyan-300 transition-colors" />
</button>
</td>
{/* Profit */}
@@ -553,6 +593,14 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
</tbody>
</table>
</div>
{/* Fee Breakdown Modal */}
{selectedFeeOrder && (
<TiktokFeeBreakdownModal
order={selectedFeeOrder}
onClose={() => setSelectedFeeOrder(null)}
/>
)}
</div>
);
}
@@ -1,9 +1,11 @@
import { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { API_BASE, type Menu } from '@homaroma/types'
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X } from 'lucide-react'
import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil, Sparkles, Code2, X, LayoutGrid, ListFilter, Layers, Image as ImageIcon } from 'lucide-react'
import { ICON_REGISTRY } from '../../utils/iconRegistry'
import FocalPointPicker from '../../components/page-builder/FocalPointPicker'
import MenuJsonGuideModal from '../../components/MenuJsonGuideModal'
import MediaLibraryModal from '../../components/MediaLibraryModal'
const IconSelect = ({ value, onChange }: { value?: string, onChange: (val: string) => void }) => {
return (
@@ -30,7 +32,7 @@ type MenuEditorPageProps = {
description: string;
}
const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
const FeaturedCardEditorFields = ({ featured, onChange, onOpenMediaPicker }: any) => {
const [activeTab, setActiveTab] = useState<'content' | 'media'>('content');
const [activeLang, setActiveLang] = useState('th');
@@ -54,8 +56,28 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
['ctaLabel', 'CTA label', 'View details'],
];
const isEnabled = featured.enabled !== false;
return (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 rounded-xl bg-white/5 border border-white/5">
<div>
<div className="text-xs font-bold text-white">Enable Featured Card</div>
<div className="text-[10px] text-slate-400">เปิดหรือปิดการแสดงการ์ดโปรโมตใน Mega Menu</div>
</div>
<button
type="button"
onClick={() => updateFeatured('enabled', !isEnabled)}
className={`px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
isEnabled
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
{isEnabled ? 'Enabled (เปิด)' : 'Disabled (ปิด)'}
</button>
</div>
<div className="grid grid-cols-2 rounded-xl bg-white/5 p-1">
{[
['content', 'Content'],
@@ -136,22 +158,59 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
</div>
<div className="space-y-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="aspect-[4/3] overflow-hidden rounded-xl border border-white/10 bg-white/5 flex items-center justify-center">
<div
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
className="aspect-[4/3] relative overflow-hidden rounded-xl border-2 border-dashed border-white/10 hover:border-cyan-glow/50 bg-night-950/40 flex items-center justify-center cursor-pointer group transition-all"
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
>
{featured.imageUrl ? (
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover" />
<>
<img src={featured.imageUrl} alt="Featured preview" className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300" />
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center gap-1.5 backdrop-blur-xs">
<ImageIcon className="w-6 h-6 text-cyan-glow" />
<span className="text-xs font-bold text-white">เปลี่ยนรูปจากคลังภาพ</span>
</div>
</>
) : (
<span className="px-4 text-center text-xs text-slate-600">No featured image</span>
<div className="flex flex-col items-center gap-2 text-slate-500 group-hover:text-cyan-glow transition-colors py-4">
<ImageIcon className="w-8 h-8" />
<span className="text-xs font-bold">เลือกรูปจากคลังภาพ (Images Lib)</span>
<span className="text-[10px] text-slate-600">คลิกที่นี่เพื่อเปิดคลังสื่อของระบบ</span>
</div>
)}
</div>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
<input
className="admin-input w-full text-xs py-1 px-2"
value={featured.imageUrl || ''}
placeholder="https://cdn.example.com/product.webp"
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
/>
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-500">Featured Image URL</label>
{onOpenMediaPicker && (
<button
type="button"
onClick={() => onOpenMediaPicker((url: string) => updateFeatured('imageUrl', url))}
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
>
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
</button>
)}
</div>
<div className="flex gap-2">
<input
className="admin-input w-full text-xs py-1 px-2"
value={featured.imageUrl || ''}
placeholder="https://cdn.example.com/product.webp"
onChange={e => updateFeatured('imageUrl', e.target.value || null)}
/>
{featured.imageUrl && (
<button
type="button"
onClick={() => updateFeatured('imageUrl', null)}
className="px-2.5 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
title="ลบรูปภาพ"
>
Clear
</button>
)}
</div>
</div>
{featured.imageUrl && (
<div className="pt-2">
@@ -188,7 +247,7 @@ const FeaturedCardEditorFields = ({ featured, onChange }: any) => {
);
}
const FeaturedCardSidePanel = ({ node, onChange }: any) => {
const FeaturedCardSidePanel = ({ node, onChange, onOpenMediaPicker }: any) => {
if (!node) {
return (
<div className="admin-card p-6 space-y-3">
@@ -223,12 +282,13 @@ const FeaturedCardSidePanel = ({ node, onChange }: any) => {
<FeaturedCardEditorFields
featured={featured}
onChange={(newFeatured: any) => onChange({ ...node, featured: newFeatured })}
onOpenMediaPicker={onOpenMediaPicker}
/>
</div>
);
}
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected }: any) => {
const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured, isFeaturedSelected, onOpenMediaPicker }: any) => {
const childrenKey = type === 'root' ? 'columns' : 'links';
const labelKey = type === 'column' ? 'title' : 'label';
const hasUrl = type !== 'column';
@@ -300,21 +360,56 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
{/* Image URL field */}
<div className="flex items-start gap-3">
{/* Preview thumbnail */}
<div className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 bg-white/5 flex items-center justify-center">
<div
onClick={() => onOpenMediaPicker && onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
className="w-14 h-14 shrink-0 rounded-lg overflow-hidden border border-white/10 hover:border-cyan-glow/50 bg-white/5 flex items-center justify-center cursor-pointer group relative transition-colors"
title="คลิกเพื่อเลือกรูปจากคลังภาพ (Media Library)"
>
{imageValue ? (
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
<>
<img src={imageValue} alt="preview" className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-night-950/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<ImageIcon className="w-4 h-4 text-cyan-glow" />
</div>
</>
) : (
<span className="text-[9px] text-slate-600 text-center leading-tight px-1">No Image</span>
<div className="flex flex-col items-center justify-center text-slate-600 group-hover:text-cyan-glow transition-colors">
<ImageIcon className="w-4 h-4 mb-0.5" />
<span className="text-[8px] text-center leading-tight">Library</span>
</div>
)}
</div>
<div className="flex-1 space-y-1">
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
<input
className="admin-input w-full text-xs py-1 px-2"
value={imageValue}
placeholder="https://cdn.example.com/image.webp"
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
/>
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-500">{imageFieldLabel}</label>
{onOpenMediaPicker && (
<button
type="button"
onClick={() => onOpenMediaPicker((url: string) => onChange({ ...node, [imageFieldKey]: url }))}
className="text-[10px] font-bold text-cyan-glow hover:underline flex items-center gap-1 cursor-pointer"
>
<ImageIcon className="w-3 h-3" /> เลือกจากคลังภาพ
</button>
)}
</div>
<div className="flex gap-2">
<input
className="admin-input w-full text-xs py-1 px-2"
value={imageValue}
placeholder="https://cdn.example.com/image.webp"
onChange={e => onChange({ ...node, [imageFieldKey]: e.target.value || null })}
/>
{imageValue && (
<button
type="button"
onClick={() => onChange({ ...node, [imageFieldKey]: null })}
className="px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/10 rounded border border-white/10 shrink-0 cursor-pointer"
title="ลบรูปภาพ"
>
Clear
</button>
)}
</div>
{type !== 'root' && (
<IconSelect
value={node.icon}
@@ -325,30 +420,193 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
</div>
{type === 'root' && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
<Star className="w-5 h-5" />
<div className="space-y-3">
{/* Layout Mode Selector */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
<div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
Sub-Menu Layout Preset
</label>
<span className="text-[10px] text-cyan-glow font-medium">
{node.layout === 'classic'
? 'Classic Dropdown'
: node.layout === 'tabbed'
? 'Tabbed Hierarchy'
: node.layout === 'visual-cards'
? 'Visual Cards'
: `Mega Menu (${node.layoutConfig?.columnCount || 2} Cols)`}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<button
type="button"
onClick={() => onChange({ ...node, layout: 'columns' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
(!node.layout || node.layout === 'columns')
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<LayoutGrid className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Column Grid</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">2-4 คอลัมน์</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'tabbed' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'tabbed'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<Layers className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Tabbed Catalog</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แท็บซ้าย + ลิงก์ขวา</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'visual-cards' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'visual-cards'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<Sparkles className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Visual Cards</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">การ์ดรูปถ่ายหรูหรา</div>
</div>
</button>
<button
type="button"
onClick={() => onChange({ ...node, layout: 'classic' })}
className={`flex items-center gap-2 p-2.5 rounded-lg border text-left transition-colors ${
node.layout === 'classic'
? 'border-cyan-500/50 bg-cyan-500/10 text-white shadow-sm'
: 'border-white/5 bg-night-900/40 text-slate-400 hover:border-white/20'
}`}
>
<ListFilter className="w-4 h-4 shrink-0 text-cyan-glow" />
<div className="min-w-0">
<div className="text-xs font-bold leading-tight truncate">Classic Dropdown</div>
<div className="text-[10px] text-slate-500 leading-tight mt-0.5 truncate">แนวตั้งมินิมอล</div>
</div>
</button>
</div>
{/* Submenu Layout Modifier / Description */}
{(!node.layout || node.layout === 'columns') && (
<div className="pt-3 border-t border-white/5 space-y-3">
{/* Column Count */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">จำนวนคอลัมน์ (Columns)</span>
<div className="flex rounded-lg bg-black/40 p-1 border border-white/5">
{[2, 3, 4].map((count) => {
const currentCols = node.layoutConfig?.columnCount || 2;
return (
<button
key={count}
type="button"
onClick={() => onChange({
...node,
layoutConfig: { ...(node.layoutConfig || {}), columnCount: count }
})}
className={`px-3 py-1 text-xs font-bold rounded-md transition-colors ${
currentCols === count
? 'bg-cyan-glow text-night-900'
: 'text-slate-400 hover:text-white'
}`}
>
{count} Cols
</button>
);
})}
</div>
</div>
{/* Toggle Featured Card */}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-300">แสดง Featured Card</span>
<button
type="button"
onClick={() => {
const isCurrentlyShown = node.layoutConfig?.showFeatured !== false && featured.enabled !== false;
const nextState = !isCurrentlyShown;
onChange({
...node,
layoutConfig: { ...(node.layoutConfig || {}), showFeatured: nextState },
featured: { ...featured, enabled: nextState }
});
}}
className={`flex items-center gap-1.5 px-3 py-1 text-xs font-bold rounded-lg border transition-colors ${
(node.layoutConfig?.showFeatured !== false && featured.enabled !== false)
? 'border-amber-400/40 bg-amber-400/20 text-amber-300'
: 'border-white/10 bg-white/5 text-slate-400 hover:border-white/20'
}`}
>
{(node.layoutConfig?.showFeatured !== false && featured.enabled !== false) ? 'ON (เปิด)' : 'OFF (ปิด)'}
</button>
</div>
</div>
<div className="min-w-0">
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
<p className="truncate text-[11px] text-slate-500">
{featured.enabled === false ? 'Disabled' : featuredTitle}
</p>
)}
{node.layout === 'tabbed' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
💡 <strong className="text-slate-200">Tabbed Hierarchy:</strong> แต่ละ Column จะทำหน้าที่เป็นแท็บหมวดหมู่ฝั่งซ้าย เมื่อเอาเมาส์ Hover แท็บใด รายการ Links ย่อยจะปรากฏฝั่งขวาทันที เหมาะสำหรับร้านค้าที่มีหมวดหมู่แคตตาล็อกลึก (เช่น อะไหล่ยนต์, เครื่องใช้ไฟฟ้า, ไอที)
</div>
)}
{node.layout === 'visual-cards' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
✨ <strong className="text-slate-200">Visual Image Cards:</strong> แสดงเป็นการ์ดรูปถ่าย 4 ใบแนวนอน โดยดึงรูปภาพจาก Column Icon URL หรือ Hover Image ของลิงก์แรก เหมาะสำหรับสินค้าที่เน้นความงามและดีไซน์ (เช่น ก้านไม้หอม, เทียนหอม, คอลเลกชันแฟชั่น)
</div>
)}
{node.layout === 'classic' && (
<div className="pt-3 border-t border-white/5 text-xs text-slate-400 leading-relaxed bg-white/2 p-2.5 rounded-lg">
📄 <strong className="text-slate-200">Classic Dropdown:</strong> แสดงเป็นกล่องแนวตั้งกะทัดรัดใต้ปุ่มเมนูบน Navbar เหมาะสำหรับเมนูข้อมูลทั่วไปที่มี 3-7 ลิงก์ (เช่น เกี่ยวกับเรา, ศูนย์บริการ, ข่าวสาร)
</div>
)}
</div>
{/* Featured Card editor trigger (only shown when in columns mode and featured card is enabled) */}
{(!node.layout || node.layout === 'columns') && (node.layoutConfig?.showFeatured !== false && featured.enabled !== false) && (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-amber-400/10 text-amber-300">
<Star className="w-5 h-5" />
</div>
<div className="min-w-0">
<h3 className="text-xs font-bold uppercase tracking-widest text-amber-200">Featured card</h3>
<p className="truncate text-[11px] text-slate-500">
{featuredTitle}
</p>
</div>
</div>
<button
type="button"
onClick={onEditFeatured}
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
}`}
>
<Pencil className="w-3.5 h-3.5" />
{isFeaturedSelected ? 'Editing' : 'Edit'}
</button>
</div>
</div>
<button
type="button"
onClick={onEditFeatured}
className={`flex shrink-0 items-center gap-2 rounded-lg px-3 py-2 text-xs font-bold transition-colors ${
isFeaturedSelected ? 'bg-amber-300 text-night-900' : 'bg-amber-400/10 text-amber-200 hover:bg-amber-400/20'
}`}
>
<Pencil className="w-3.5 h-3.5" />
{isFeaturedSelected ? 'Editing' : 'Edit'}
</button>
</div>
)}
</div>
)}
</div>
@@ -383,6 +641,7 @@ const TreeNodeEditor = ({ node, type, depth, onChange, onRemove, onEditFeatured,
depth={depth + 1}
onChange={(newChild: any) => handleUpdateChild(i, newChild)}
onRemove={() => handleRemoveChild(i)}
onOpenMediaPicker={onOpenMediaPicker}
/>
))}
</div>
@@ -406,6 +665,13 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
const [pastedJsonText, setPastedJsonText] = useState('')
const [pasteError, setPasteError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [mediaSelectCallback, setMediaSelectCallback] = useState<((url: string) => void) | null>(null)
const handleOpenMediaPicker = (onSelectUrl: (url: string) => void) => {
setMediaSelectCallback(() => onSelectUrl)
setIsMediaModalOpen(true)
}
useEffect(() => {
Promise.all([
@@ -708,6 +974,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
}}
onEditFeatured={() => setSelectedFeaturedIndex(idx)}
isFeaturedSelected={safeFeaturedIndex === idx}
onOpenMediaPicker={handleOpenMediaPicker}
/>
))
)}
@@ -829,6 +1096,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
<FeaturedCardSidePanel
node={selectedFeaturedNode}
onChange={handleUpdateFeaturedNode}
onOpenMediaPicker={handleOpenMediaPicker}
/>
) : (
<div className="admin-card p-6 space-y-4">
@@ -884,7 +1152,7 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
)}
{/* Paste JSON Modal */}
{showPasteModal && (
{showPasteModal && createPortal(
<div className="fixed inset-0 z-50 bg-night-950/85 backdrop-blur-md flex items-center justify-center p-4 animate-fade-in">
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl p-6 space-y-4 animate-zoom-in">
<div className="flex items-center justify-between border-b border-white/10 pb-3">
@@ -932,7 +1200,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* LLM Mega Menu Guide Modal */}
@@ -942,6 +1211,23 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
onOpenPasteJson={() => setShowPasteModal(true)}
onOpenImportFile={() => fileInputRef.current?.click()}
/>
{/* Media Library Modal */}
<MediaLibraryModal
isOpen={isMediaModalOpen}
onClose={() => {
setIsMediaModalOpen(false)
setMediaSelectCallback(null)
}}
onSelect={(url) => {
if (mediaSelectCallback) {
mediaSelectCallback(url)
}
setIsMediaModalOpen(false)
setMediaSelectCallback(null)
}}
mediaType="image"
/>
</div>
)
}
@@ -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,110 @@
import { useState } from 'react';
import { Calendar, Clock, ChevronDown } from 'lucide-react';
export interface DateFilterState {
period: string; // 'all' | '7d' | '30d' | '90d' | 'this_month' | 'last_month' | 'custom'
startDate?: string;
endDate?: string;
}
interface Props {
value: DateFilterState;
onChange: (val: DateFilterState) => void;
label?: string;
}
export default function DateRangeSelector({ value, onChange }: Props) {
const [isCustomOpen, setIsCustomOpen] = useState(value.period === 'custom');
const [customStart, setCustomStart] = useState(value.startDate || '');
const [customEnd, setCustomEnd] = useState(value.endDate || '');
const presets = [
{ id: 'all', label: 'ทั้งหมด (All Time)' },
{ id: '7d', label: '7 วันล่าสุด' },
{ id: '30d', label: '30 วันล่าสุด' },
{ id: '90d', label: '90 วันล่าสุด' },
{ id: 'this_month', label: 'เดือนนี้' },
{ id: 'last_month', label: 'เดือนที่แล้ว' },
];
const handleSelectPreset = (id: string) => {
setIsCustomOpen(false);
onChange({ period: id });
};
const handleApplyCustom = () => {
if (!customStart) return;
onChange({
period: 'custom',
startDate: customStart,
endDate: customEnd,
});
};
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5 p-1 rounded-xl bg-slate-800/80 border border-white/10 text-xs">
<Clock className="w-3.5 h-3.5 text-cyan-400 ml-2" />
<span className="text-[11px] font-bold text-slate-400 mr-1 hidden sm:inline">ช่วงเวลา:</span>
{presets.map((p) => {
const isActive = value.period === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => handleSelectPreset(p.id)}
className={`px-2.5 py-1.5 rounded-lg font-semibold transition-all ${
isActive
? 'bg-cyan-500 text-white shadow-sm'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
}`}
>
{p.label}
</button>
);
})}
<button
type="button"
onClick={() => setIsCustomOpen(!isCustomOpen)}
className={`px-2.5 py-1.5 rounded-lg font-semibold flex items-center gap-1 transition-all ${
value.period === 'custom' || isCustomOpen
? 'bg-purple-500 text-white shadow-sm'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
}`}
>
<Calendar className="w-3.5 h-3.5" />
<span>กำหนดเอง</span>
<ChevronDown className="w-3 h-3" />
</button>
</div>
{/* Custom Date Range Popover / Inline */}
{isCustomOpen && (
<div className="flex items-center gap-2 p-1.5 rounded-xl bg-slate-900 border border-purple-500/40 text-xs shadow-xl animate-fade-in">
<input
type="date"
value={customStart}
onChange={(e) => setCustomStart(e.target.value)}
className="px-2 py-1 rounded-lg bg-slate-800 border border-white/10 text-white focus:outline-none focus:border-purple-500"
/>
<span className="text-slate-400">ถึง</span>
<input
type="date"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
className="px-2 py-1 rounded-lg bg-slate-800 border border-white/10 text-white focus:outline-none focus:border-purple-500"
/>
<button
type="button"
onClick={handleApplyCustom}
disabled={!customStart}
className="px-3 py-1 rounded-lg bg-purple-500 hover:bg-purple-600 text-white font-bold transition-all disabled:opacity-50"
>
ใช้ตัวกรอง
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,473 @@
import { useState, useEffect, useMemo } from 'react';
import {
Search,
Download,
Package,
RefreshCw,
Box,
Flame,
Calendar,
TrendingUp,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
import DateRangeSelector, { type DateFilterState } from './DateRangeSelector';
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;
dailyBurnRate: number;
forecast30DaysQty: number;
forecast30DaysCost: number;
topProducts: TopProductUsage[];
}
interface PeriodInfo {
period: string;
label: string;
startDate: string | null;
endDate: string | null;
daysCount: number;
}
interface MrpKpis {
totalRawMaterials: number;
totalMaterialCostConsumed: number;
topFragrancesByCost: RawMaterialUsageItem[];
topPackagingByCost: RawMaterialUsageItem[];
}
export default function MrpPlanningPage() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<{
periodInfo: PeriodInfo;
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 [dateFilter, setDateFilter] = useState<DateFilterState>({ period: '30d' }); // Default 30d for MRP
const [sortBy, setSortBy] = useState('cost_desc');
const fetchData = async () => {
try {
setLoading(true);
const params = new URLSearchParams({
period: dateFilter.period,
...(dateFilter.startDate ? { startDate: dateFilter.startDate } : {}),
...(dateFilter.endDate ? { endDate: dateFilter.endDate } : {}),
});
const res = await fetch(`${API_BASE}/profitability/mrp?${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 MRP data:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [dateFilter]);
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 === 'forecast_desc') return b.forecast30DaysCost - a.forecast30DaysCost;
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]);
// Total forecast procurement budget
const totalForecastBudget = useMemo(() => {
if (!data?.allMaterials) return 0;
return data.allMaterials.reduce((sum, m) => sum + m.forecast30DaysCost, 0);
}, [data?.allMaterials]);
const handleExportCsv = () => {
if (!filteredItems.length) return;
const headers = [
'Raw Material ID',
'Material Name',
'Unit',
'Unit Cost (THB)',
'Type',
'Linked BOM Formulas',
`Consumed Qty (${data?.periodInfo?.label || 'Period'})`,
'Consumed Cost (THB)',
'Daily Burn Rate',
'30-Day Forecast Reorder Qty',
'30-Day Forecast Budget (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.dailyBurnRate,
i.forecast30DaysQty,
i.forecast30DaysCost,
`"${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_${dateFilter.period}_${new Date().toISOString().slice(0, 10)}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const kpis = data?.kpis;
const periodInfo = data?.periodInfo;
return (
<div className="space-y-6">
{/* Shared Header Navigation */}
<ProfitabilityHeaderTabs
title="MRP & Material Demand Planning"
subtitle="Reverse BOM Explosion: คำนวณปริมาณการใช้วัตถุดิบจริงย้อนกลับตามช่วงเวลา เพื่อหาอัตราการใช้ต่อวัน (Daily Burn Rate) และพยากรณ์การสั่งซื้อล่วงหน้า 30 วัน"
/>
{/* ── Filter Bar: Date Range + Search ────────────────────────────── */}
<div className="space-y-3">
<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">
{/* Date Range Selector */}
<DateRangeSelector value={dateFilter} onChange={setDateFilter} />
{/* Active Period Indicator */}
{periodInfo && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-800 border border-cyan-500/30 text-xs font-semibold text-cyan-400">
<Calendar className="w-4 h-4 text-cyan-400" />
<span>ช่วงวิเคราะห์สต็อก: {periodInfo.label}</span>
{periodInfo.daysCount && (
<span className="text-[10px] text-slate-400">({periodInfo.daysCount} วัน)</span>
)}
</div>
)}
</div>
</div>
{/* ── 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>
{/* Material Cost Consumed in Period */}
<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">Consumed Cost ({periodInfo?.label || 'Period'})</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>
{/* 30-Day Forecast Procurement Budget */}
<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">30-Day Forecast Budget</span>
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
<TrendingUp className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-extrabold text-purple-400">{fmtMoney(totalForecastBudget)}</div>
<p className="text-[11px] text-slate-400 mt-2">งบประมาณสั่งซื้อวัตถุดิบ 30 วันข้างหน้า (อิง Run-rate)</p>
</div>
{/* Top Packaging Burn */}
<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">Top Volume Driver</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 Sub-Tabs & Search ──────────────────────────────────── */}
<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="forecast_desc">งบพยากรณ์ 30 วันสูงสุด (Forecast Budget)</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-right">ยอดใช้ในรอบ ({periodInfo?.label || 'Period'})</th>
<th className="py-4 px-4 text-right bg-white/5">มูลค่าใช้จริง</th>
<th className="py-4 px-4 text-right">Daily Burn Rate</th>
<th className="py-4 px-4 text-right bg-purple-500/10 text-purple-300">
พยากรณ์สั่งซื้อ 30 วัน
</th>
<th className="py-4 px-4 text-right bg-purple-500/10 text-purple-300">
งบสั่งซื้อ 30 วัน
</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={9} className="py-12 text-center text-slate-400">
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
กำลังประมวลผลการคำนวณวัตถุดิบและพยากรณ์สต็อกล่วงหน้า...
</td>
</tr>
) : filteredItems.length === 0 ? (
<tr>
<td colSpan={9} className="py-12 text-center text-slate-400">
ไม่พบรายการวัตถุดิบที่มียอดใช้ในช่วงเวลาที่เลือก
</td>
</tr>
) : (
filteredItems.map((item) => {
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} • {item.linkedVariantsCount} สูตร
</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-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-right font-mono text-cyan-400 font-semibold">
{fmtNum(item.dailyBurnRate)} {item.unit}/วัน
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-purple-300 bg-purple-500/5">
{fmtNum(item.forecast30DaysQty)} {item.unit}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-purple-200 bg-purple-500/5">
{fmtMoney(item.forecast30DaysCost)}
</td>
<td className="py-3.5 px-4 max-w-[240px]">
{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,657 @@
import { useState, useEffect, useMemo } from 'react';
import {
Search,
Download,
ShoppingBag,
TrendingUp,
Percent,
Coins,
RefreshCw,
Calendar,
Receipt,
ShieldCheck,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
import DateRangeSelector, { type DateFilterState } from './DateRangeSelector';
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;
platformFee: number;
effectiveFeePercent: number;
feeSource: 'actual' | 'hybrid' | 'fallback_30pct';
netRevenue: number;
netProfit: number;
netMarginPercent: number;
directPrice: number;
directMarginPercent: number;
}
interface ChannelStat {
soldQty: number;
revenue: number;
platformFee: number;
bomCost: number;
netProfit: number;
}
interface PeriodInfo {
period: string;
label: string;
startDate: string | null;
endDate: string | null;
daysCount: number;
}
interface OmnichannelKpis {
totalMappedProducts: number;
totalUnitsSold: number;
totalGrossRevenue: number;
totalPlatformFee: number;
totalEstNetRevenue: number;
totalBOMCost: number;
totalEstNetProfit: number;
overallNetMargin: number;
effectiveFeeRate: number;
actualFeeAmount: number;
fallbackFeeAmount: number;
actualOrdersCount: number;
fallbackOrdersCount: number;
channelStats: Record<string, ChannelStat>;
}
export default function OmnichannelMarginPage() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<{
periodInfo: PeriodInfo;
feePolicy?: { mode: string; fallbackPercent: number; description: string };
kpis: OmnichannelKpis;
items: OmnichannelItem[];
} | null>(null);
// Filters
const [search, setSearch] = useState('');
const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all');
const [dateFilter, setDateFilter] = useState<DateFilterState>({ period: 'this_month' });
const [sortBy, setSortBy] = useState('revenue_desc');
const fetchData = async () => {
try {
setLoading(true);
const params = new URLSearchParams({
channel: channelFilter,
search,
period: dateFilter.period,
...(dateFilter.startDate ? { startDate: dateFilter.startDate } : {}),
...(dateFilter.endDate ? { endDate: dateFilter.endDate } : {}),
});
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, dateFilter]);
// 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',
'Platform Fee (THB)',
'Effective Fee %',
'Fee Source',
'Unit BOM Cost',
'Total BOM Cost',
'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.platformFee,
`${i.effectiveFeePercent}%`,
i.feeSource,
i.unitBomCost,
i.totalBomCost,
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_${dateFilter.period}_${new Date().toISOString().slice(0, 10)}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const kpis = data?.kpis;
const periodInfo = data?.periodInfo;
return (
<div className="space-y-6">
{/* Shared Header Navigation */}
<ProfitabilityHeaderTabs
title="Omnichannel Net Margin & Contribution"
subtitle="วิเคราะห์กำไรสุทธิตามช่องทางขาย (Shopee, Lazada, TikTok, Direct) หลังหักค่าธรรมเนียม Platform และต้นทุนผลิต BOM จริง"
/>
{/* ── Filter Bar: Date Range + Controls ───────────────────────────── */}
<div className="space-y-3">
<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">
{/* Date Range Selector */}
<DateRangeSelector value={dateFilter} onChange={setDateFilter} />
{/* Active Period Badge */}
{periodInfo && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-800 border border-cyan-500/30 text-xs font-semibold text-cyan-400">
<Calendar className="w-4 h-4 text-cyan-400" />
<span>ช่วงข้อมูล: {periodInfo.label}</span>
{periodInfo.daysCount && (
<span className="text-[10px] text-slate-400">({periodInfo.daysCount} วัน)</span>
)}
</div>
)}
</div>
{/* Secondary 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>
</div>
{/* ── Fee Policy Notice Banner ─────────────────────────────────── */}
<div className="flex flex-wrap items-center justify-between gap-3 p-4 rounded-2xl bg-slate-900/90 border border-white/10 shadow-lg backdrop-blur-sm text-xs text-slate-300">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400">
<ShieldCheck className="w-5 h-5 shrink-0" />
</div>
<div>
<span className="font-bold text-white">Hybrid Fee Policy (Actual + 30% Fallback):</span>{' '}
<span className="text-slate-400">
Shopee ดึงค่าธรรมเนียมจริงจากรายงานคำสั่งซื้อ, TikTok คำนวณจากยอดโอนจริง (Settlement)
สำหรับออเดอร์ที่ยังไม่มีข้อมูลค่าธรรมเนียม จะใช้ตัวคูณปลอดภัย{' '}
<strong className="text-amber-400">30% Fallback</strong>
</span>
</div>
</div>
<div className="flex items-center gap-2 text-[11px] font-mono shrink-0">
<span className="px-2.5 py-1 rounded-lg bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 font-bold">
Actual: {fmtNum(kpis?.actualOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.actualFeeAmount)})
</span>
<span className="px-2.5 py-1 rounded-lg bg-amber-500/15 text-amber-300 border border-amber-500/30 font-bold">
30% Fallback: {fmtNum(kpis?.fallbackOrdersCount)} ออเดอร์ ({fmtMoney(kpis?.fallbackFeeAmount)})
</span>
</div>
</div>
{/* ── Executive KPI Cards (5 Cards) ──────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 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</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?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} SKU)
</p>
</div>
{/* Total Platform Fee */}
<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">Platform Deductions</span>
<div className="p-2 rounded-xl bg-amber-500/10 text-amber-400">
<Receipt className="w-5 h-5" />
</div>
</div>
<div className="text-2xl font-extrabold text-amber-400">{fmtMoney(kpis?.totalPlatformFee)}</div>
<p className="text-[11px] text-slate-400 mt-2 font-mono">
เฉลี่ยหัก <strong className="text-amber-300 font-bold">~{kpis?.effectiveFeeRate ?? 30}%</strong> ของยอดขาย
</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</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">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">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.shopee?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<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">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.lazada?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<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">Platform Fee:</span>
<span className="text-xs font-mono text-amber-400">
{fmtMoney(kpis.channelStats.tiktok?.platformFee)}
</span>
</div>
<div className="flex items-baseline justify-between mt-1 pt-1 border-t border-white/5">
<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>
)}
{/* ── 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">ขายได้ ({periodInfo?.label || 'All'})</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 (%) &amp; ที่มา</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" />
กำลังประมวลผลข้อมูลกำไรสุทธิสำหรับช่วงเวลา {periodInfo?.label}...
</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">
<div className="font-mono text-slate-200">
~{item.effectiveFeePercent}%
</div>
<div className="text-[10px] mt-0.5">
{item.feeSource === 'actual' ? (
<span className="px-1.5 py-0.5 rounded font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
Actual
</span>
) : item.feeSource === 'hybrid' ? (
<span className="px-1.5 py-0.5 rounded font-bold bg-indigo-500/20 text-indigo-400 border border-indigo-500/30">
Hybrid
</span>
) : (
<span className="px-1.5 py-0.5 rounded font-medium bg-amber-500/15 text-amber-300 border border-amber-500/30">
30% Fallback
</span>
)}
</div>
</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 { llmPromptsRoute } from "./routes/llmPrompts";
import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences";
import { profitabilityRoute } from "./routes/profitability";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -158,7 +159,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute).use(frameSequencesRoute)
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute).use(frameSequencesRoute).use(profitabilityRoute)
)
.listen({ port: PORT, hostname: "0.0.0.0" });
+349 -3
View File
@@ -431,6 +431,95 @@ export const productsRoute = new Elysia({ prefix: "/products" })
},
};
})
.get("/bom-status", async ({ query }) => {
const search = query.search?.trim();
const filterStatus = query.status?.trim() || "all";
const limit = query.limit ? parseInt(query.limit) : 50;
const where: any = { isDeleted: false };
if (search) {
where.OR = [
{ name: { contains: search, mode: "insensitive" } },
{ translations: { some: { name: { contains: search, mode: "insensitive" } } } },
{ brand: { name: { contains: search, mode: "insensitive" } } },
{ variants: { some: { sku: { contains: search, mode: "insensitive" } } } },
];
}
const products = await prisma.product.findMany({
where,
select: {
id: true,
name: true,
brand: { select: { id: true, name: true } },
category: { select: { id: true, name: true } },
translations: { select: { langCode: true, name: true } },
variants: {
select: {
id: true,
sku: true,
option1: true,
option2: true,
option3: true,
price: true,
bomItems: {
select: {
id: true,
rawMaterialId: true,
quantity: true,
rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } },
},
},
},
},
},
take: limit,
orderBy: { id: "asc" },
});
const mapped = products.map((p) => {
const thName = p.translations.find((t) => t.langCode === "th")?.name;
const productName = p.name || thName || p.translations[0]?.name || `Product #${p.id}`;
const totalVariants = p.variants.length;
const configuredVariants = p.variants.filter((v) => v.bomItems && v.bomItems.length > 0).length;
let bomStatus = "MISSING";
if (configuredVariants === totalVariants && totalVariants > 0) {
bomStatus = "CONFIGURED";
} else if (configuredVariants > 0) {
bomStatus = "PARTIAL";
}
return {
id: p.id,
name: productName,
brand: p.brand?.name || null,
category: p.category?.name || null,
totalVariants,
configuredVariants,
bomStatus,
variants: p.variants.map((v) => ({
id: v.id,
sku: v.sku,
options: [v.option1, v.option2, v.option3].filter(Boolean).join(" / "),
price: v.price,
hasBom: v.bomItems.length > 0,
bomItemCount: v.bomItems.length,
totalBomCost: Math.round(v.bomItems.reduce((acc, b) => acc + (b.rawMaterial?.pricePerUnit || 0) * b.quantity, 0) * 100) / 100,
})),
};
});
const filtered = filterStatus === "all"
? mapped
: mapped.filter((p) => p.bomStatus.toLowerCase() === filterStatus.toLowerCase());
return {
success: true,
count: filtered.length,
products: filtered,
};
})
.get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
@@ -471,9 +560,9 @@ export const productsRoute = new Elysia({ prefix: "/products" })
if (!product || (!includeDeleted && product.isDeleted)) return status(404, { message: "Product not found" });
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
const nameObj: any = product.name ? { th: product.name } : {};
const descObj: any = product.description ? { th: product.description } : {};
const detailsObj: any = product.details ? { th: product.details } : {};
product.translations.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
@@ -499,6 +588,263 @@ export const productsRoute = new Elysia({ prefix: "/products" })
};
}
})
.get("/:id/bom", async ({ params, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const [product, rawMaterials] = await Promise.all([
prisma.product.findUnique({
where: { id },
include: {
brand: true,
category: true,
translations: true,
variants: {
include: {
bomItems: {
include: {
rawMaterial: true,
},
},
},
orderBy: { price: "asc" },
},
},
}),
prisma.rawMaterial.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, unit: true, pricePerUnit: true },
}),
]);
if (!product || product.isDeleted) return status(404, { message: "Product not found" });
const thName = product.translations.find((t) => t.langCode === "th")?.name;
const productName = product.name || thName || product.translations[0]?.name || `Product #${product.id}`;
const variants = product.variants.map((v) => {
const items = (v.bomItems || []).map((b) => {
const rm = b.rawMaterial;
const lineCost = (rm?.pricePerUnit || 0) * b.quantity;
return {
id: b.id,
rawMaterialId: b.rawMaterialId,
name: rm?.name || `Material #${b.rawMaterialId}`,
unit: rm?.unit || "piece",
pricePerUnit: rm?.pricePerUnit || 0,
quantity: b.quantity,
lineCost: Math.round(lineCost * 100) / 100,
};
});
const totalBomCost = items.reduce((acc, i) => acc + i.lineCost, 0);
const retailPrice = Number(v.price) || 0;
const profit = retailPrice - totalBomCost;
const marginPercent = retailPrice > 0 ? (profit / retailPrice) * 100 : 0;
const optionLabel = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
return {
id: v.id,
sku: v.sku,
optionLabel: optionLabel || "Standard",
price: retailPrice,
stock: v.stock,
bomItems: items,
totalBomCost: Math.round(totalBomCost * 100) / 100,
estimatedProfit: Math.round(profit * 100) / 100,
marginPercent: Math.round(marginPercent * 10) / 10,
};
});
const totalVariants = variants.length;
const configuredVariants = variants.filter((v) => v.bomItems.length > 0).length;
return {
success: true,
productId: product.id,
productName,
brand: product.brand?.name || null,
category: product.category?.name || null,
bomStatus:
configuredVariants === totalVariants && totalVariants > 0
? "CONFIGURED"
: configuredVariants > 0
? "PARTIAL"
: "MISSING",
summary: {
totalVariants,
configuredVariants,
},
variants,
availableRawMaterials: rawMaterials,
};
})
.put("/:id/bom", async ({ params, body, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const payload = body as any;
const incomingVariants = Array.isArray(payload) ? payload : (payload.variants || []);
if (!Array.isArray(incomingVariants) || incomingVariants.length === 0) {
return status(400, { message: "variants array is required" });
}
const [product, rawMaterials] = await Promise.all([
prisma.product.findUnique({
where: { id },
include: {
brand: true,
translations: true,
variants: true,
},
}),
prisma.rawMaterial.findMany(),
]);
if (!product || product.isDeleted) return status(404, { message: "Product not found" });
const rawMaterialById = new Map<number, (typeof rawMaterials)[0]>();
const rawMaterialByName = new Map<string, (typeof rawMaterials)[0]>();
for (const rm of rawMaterials) {
rawMaterialById.set(rm.id, rm);
rawMaterialByName.set(rm.name.trim().toLowerCase(), rm);
}
const plan: Array<{
targetVariant: (typeof product.variants)[0];
bomItemsToCreate: Array<{ rawMaterialId: number; quantity: number }>;
}> = [];
const unresolvedVariants: any[] = [];
const unresolvedMaterials: any[] = [];
for (const iv of incomingVariants) {
const vId = Number(iv.variantId || iv.id);
const vSku = String(iv.sku || "").trim().toLowerCase();
const vOption = String(iv.option1 || iv.option || iv.name || "").trim().toLowerCase();
let targetVariant = product.variants.find((v) => v.id === vId);
if (!targetVariant && vSku) {
targetVariant = product.variants.find((v) => (v.sku || "").trim().toLowerCase() === vSku);
}
if (!targetVariant && vOption) {
targetVariant = product.variants.find((v) => (v.option1 || "").trim().toLowerCase() === vOption);
}
if (!targetVariant) {
unresolvedVariants.push(iv);
continue;
}
const bomItemsToCreate: Array<{ rawMaterialId: number; quantity: number }> = [];
const items = Array.isArray(iv.bomItems) ? iv.bomItems : [];
for (const item of items) {
const rmId = Number(item.rawMaterialId || item.id);
const rmName = String(item.name || item.rawMaterialName || "").trim().toLowerCase();
const qty = Number(item.quantity) || 0;
if (qty <= 0) continue;
let matchedRm = rawMaterialById.get(rmId);
if (!matchedRm && rmName) {
matchedRm = rawMaterialByName.get(rmName);
if (!matchedRm) {
for (const rm of rawMaterials) {
if (rm.name.toLowerCase().includes(rmName) || rmName.includes(rm.name.toLowerCase())) {
matchedRm = rm;
break;
}
}
}
}
if (!matchedRm) {
unresolvedMaterials.push({ variantSku: targetVariant.sku, item });
continue;
}
bomItemsToCreate.push({
rawMaterialId: matchedRm.id,
quantity: qty,
});
}
plan.push({ targetVariant, bomItemsToCreate });
}
if (plan.length === 0) {
return status(400, {
message: "No matching variants could be resolved from payload",
unresolvedVariants,
});
}
await prisma.$transaction(async (tx) => {
for (const p of plan) {
await tx.bomItem.deleteMany({
where: { productVariantId: p.targetVariant.id },
});
if (p.bomItemsToCreate.length > 0) {
await tx.bomItem.createMany({
data: p.bomItemsToCreate.map((b) => ({
productVariantId: p.targetVariant.id,
rawMaterialId: b.rawMaterialId,
quantity: b.quantity,
})),
});
}
}
});
const updatedVariants = await prisma.productVariant.findMany({
where: { productId: id },
include: {
bomItems: {
include: { rawMaterial: true },
},
},
orderBy: { price: "asc" },
});
const report = updatedVariants.map((v) => {
const items = v.bomItems.map((b) => ({
rawMaterialId: b.rawMaterialId,
name: b.rawMaterial.name,
unit: b.rawMaterial.unit,
pricePerUnit: b.rawMaterial.pricePerUnit,
quantity: b.quantity,
lineCost: Math.round(b.rawMaterial.pricePerUnit * b.quantity * 100) / 100,
}));
const totalBomCost = items.reduce((sum, i) => sum + i.lineCost, 0);
const profit = (v.price || 0) - totalBomCost;
const margin = (v.price || 0) > 0 ? (profit / (v.price || 0)) * 100 : 0;
return {
variantId: v.id,
sku: v.sku,
option: [v.option1, v.option2, v.option3].filter(Boolean).join(" / "),
retailPrice: v.price,
totalBomCost: Math.round(totalBomCost * 100) / 100,
estimatedProfit: Math.round(profit * 100) / 100,
marginPercent: Math.round(margin * 10) / 10,
bomItems: items,
};
});
const thName = product.translations.find((t) => t.langCode === "th")?.name;
const productName = product.name || thName || product.translations[0]?.name || `Product #${product.id}`;
return {
success: true,
message: `Successfully updated BOM for ${plan.length} variants on product #${id} (${productName})`,
productId: id,
productName,
variantsUpdated: plan.length,
variants: report,
unresolvedMaterials: unresolvedMaterials.length > 0 ? unresolvedMaterials : undefined,
};
})
.get("/:id/detail-sections", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
+934
View File
@@ -0,0 +1,934 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
// ── Date Range Helper ────────────────────────────────────────────────────────
interface DateRangeInfo {
start: Date | null;
end: Date | null;
label: string;
daysCount: number;
period: string;
}
function resolveDateRange(period?: string, startStr?: string, endStr?: string): DateRangeInfo {
const p = (period || "all").trim().toLowerCase();
const now = new Date();
if (p === "7d") {
const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return { period: p, start, end: now, label: "7 วันล่าสุด (Last 7 Days)", daysCount: 7 };
}
if (p === "30d") {
const start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
return { period: p, start, end: now, label: "30 วันล่าสุด (Last 30 Days)", daysCount: 30 };
}
if (p === "60d") {
const start = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000);
return { period: p, start, end: now, label: "60 วันล่าสุด (Last 60 Days)", daysCount: 60 };
}
if (p === "90d") {
const start = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
return { period: p, start, end: now, label: "90 วันล่าสุด (Last 90 Days)", daysCount: 90 };
}
if (p === "this_month") {
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const days = Math.ceil((now.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) || 1;
return { period: p, start, end: now, label: "เดือนนี้ (This Month)", daysCount: days };
}
if (p === "last_month") {
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const end = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
const days = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) || 30;
return { period: p, start, end, label: "เดือนที่แล้ว (Last Month)", daysCount: days };
}
if (p === "custom" && startStr) {
const start = new Date(startStr);
const end = endStr ? new Date(`${endStr}T23:59:59.999Z`) : now;
const days = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)));
return { period: p, start, end, label: `${startStr} ถึง ${endStr || "ปัจจุบัน"}`, daysCount: days };
}
// Default: All Time
return { period: "all", start: null, end: null, label: "ทั้งหมด (All Time)", daysCount: 365 };
}
function parseOrderDate(dateStr: string | Date | null | undefined): Date | null {
if (!dateStr) return null;
if (dateStr instanceof Date) return dateStr;
const cleaned = String(dateStr).trim().replace(/\//g, "-");
const d = new Date(cleaned);
return isNaN(d.getTime()) ? null : d;
}
const toNum = (val: any): number => {
if (val === null || val === undefined || val === "") return 0;
const s = String(val).replace(/[^0-9.\-]/g, "");
const n = parseFloat(s);
return isNaN(n) ? 0 : n;
};
const DEFAULT_FALLBACK_FEE_PERCENT = 30.0; // 30% conservative platform deduction fallback
export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
// ─────────────────────────────────────────────────────────────────────────────
// 1. Cost & Profitability Matrix (Catalog-Level Baseline)
// ─────────────────────────────────────────────────────────────────────────────
.get(
"/matrix",
async ({ query }) => {
const search = (query.search || "").trim();
const category = (query.category || "all").trim();
const marginFilter = (query.margin || "all").trim();
const sortBy = query.sortBy || "margin_desc";
const page = query.page ? parseInt(query.page) : 1;
const limit = query.limit ? parseInt(query.limit) : 50;
// 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" },
],
});
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;
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,
};
});
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;
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,
}));
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);
}
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;
});
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 (Supports Date Range Filtering)
// ─────────────────────────────────────────────────────────────────────────────
.get("/omnichannel", async ({ query }) => {
const search = (query.search || "").trim().toLowerCase();
const channel = (query.channel || "all").trim().toLowerCase();
const dateRange = resolveDateRange(query.period || "this_month", query.startDate, query.endDate);
// 1. Fetch mapped marketplace products with master variants & BOM
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" },
});
// Look up helper: map marketplace item keys to marketplaceProduct.id
const mpLookup = new Map<string, number>();
for (const mp of mpProducts) {
const key = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`;
mpLookup.set(key, mp.id);
if (mp.marketplaceSku) {
mpLookup.set(`${mp.channel}:::${mp.marketplaceSku}`, mp.id);
}
}
// 2. Dynamic Order Aggregator (Actual Fee Extraction with 30% Fallback)
interface DynamicSalesEntry {
soldQty: number;
revenue: number;
actualFeeSum: number;
fallbackFeeSum: number;
actualCount: number;
fallbackCount: number;
}
const dynamicSalesMap = new Map<number, DynamicSalesEntry>();
const start = dateRange.start;
const end = dateRange.end || new Date();
// A. TikTok Orders
if (channel === "all" || channel === "tiktok") {
const ttOrders = await prisma.tiktokOrder.findMany({
where: { createdTime: { not: null } },
select: {
orderId: true,
createdTime: true,
shop: true,
orderAmount: true,
commissionFee: true,
affiliateFee: true,
netSettlement: true,
isSettled: true,
items: {
select: {
sellerSku: true,
skuId: true,
productName: true,
variation: true,
quantity: true,
subtotalAfterDiscount: true,
unitOriginalPrice: true,
},
},
},
});
for (const order of ttOrders) {
const orderDate = parseOrderDate(order.createdTime);
if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
const orderAmount = order.orderAmount || 0;
// Check actual deduction from settlement or commission
const hasSettlement = order.isSettled && order.netSettlement > 0;
const actualOrderFee = hasSettlement
? Math.max(0, orderAmount - order.netSettlement)
: ((order.commissionFee || 0) + (order.affiliateFee || 0));
const isActual = actualOrderFee > 0;
for (const item of order.items) {
const sku = item.sellerSku || item.skuId || "";
const key1 = `tiktok:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variation || ""}:::${sku}`;
const key2 = `tiktok:::${sku}`;
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
if (!mpId) continue;
const qty = item.quantity || 1;
const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
const cur = dynamicSalesMap.get(mpId) || {
soldQty: 0,
revenue: 0,
actualFeeSum: 0,
fallbackFeeSum: 0,
actualCount: 0,
fallbackCount: 0,
};
cur.soldQty += qty;
cur.revenue += rev;
if (isActual) {
// Proportional share of actual order deduction
const share = orderAmount > 0 ? (rev / orderAmount) : 1;
cur.actualFeeSum += actualOrderFee * share;
cur.actualCount += 1;
} else {
// Fallback 30%
cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100);
cur.fallbackCount += 1;
}
dynamicSalesMap.set(mpId, cur);
}
}
}
// B. Shopee Orders
if (channel === "all" || channel === "shopee") {
const spOrders = await prisma.shopeeOrder.findMany({
where: { orderCreatedAt: { not: null } },
select: {
orderId: true,
orderCreatedAt: true,
shop: true,
totalAmount: true,
items: {
select: {
parentSku: true,
skuRef: true,
productName: true,
variantName: true,
quantity: true,
totalPayment: true,
dealPrice: true,
rawData: true,
},
},
},
});
for (const order of spOrders) {
const orderDate = parseOrderDate(order.orderCreatedAt);
if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
// Shopee Order-level actual fee from rawData (first item has order-level fees)
const firstRaw = (order.items[0]?.rawData as any) || {};
const comm = toNum(firstRaw["ค่าคอมมิชชั่น"]);
const service = toNum(firstRaw["ค่าบริการ"]);
const tx = toNum(firstRaw["Transaction Fee"]);
const voucher = toNum(firstRaw["โค้ดส่วนลดชำระโดยผู้ขาย"]);
const orderActualFee = comm + service + tx + voucher;
const isActual = orderActualFee > 0;
const orderTotalRev = order.totalAmount || order.items.reduce((sum, it) => sum + (it.totalPayment > 0 ? it.totalPayment : (it.dealPrice * it.quantity)), 0);
for (const item of order.items) {
const sku = item.skuRef || item.parentSku || "";
const key1 = `shopee:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variantName || ""}:::${sku}`;
const key2 = `shopee:::${sku}`;
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
if (!mpId) continue;
const qty = item.quantity || 1;
const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty);
const cur = dynamicSalesMap.get(mpId) || {
soldQty: 0,
revenue: 0,
actualFeeSum: 0,
fallbackFeeSum: 0,
actualCount: 0,
fallbackCount: 0,
};
cur.soldQty += qty;
cur.revenue += rev;
if (isActual) {
const share = orderTotalRev > 0 ? (rev / orderTotalRev) : 1;
cur.actualFeeSum += orderActualFee * share;
cur.actualCount += 1;
} else {
// Fallback 30%
cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100);
cur.fallbackCount += 1;
}
dynamicSalesMap.set(mpId, cur);
}
}
}
// C. Lazada Orders
if (channel === "all" || channel === "lazada") {
const lzOrders = await prisma.lazadaOrder.findMany({
where: { createTime: { not: null } },
select: {
createTime: true,
shop: true,
items: {
select: {
sellerSku: true,
lazadaSku: true,
itemName: true,
variation: true,
paidPrice: true,
unitPrice: true,
},
},
},
});
for (const order of lzOrders) {
const orderDate = parseOrderDate(order.createTime);
if (start && (!orderDate || orderDate < start || orderDate > end)) continue;
for (const item of order.items) {
const sku = item.sellerSku || item.lazadaSku || "";
const key1 = `lazada:::${order.shop || "hom-aroma"}:::${item.itemName}:::${item.variation || ""}:::${sku}`;
const key2 = `lazada:::${sku}`;
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
if (!mpId) continue;
const qty = 1;
const rev = item.paidPrice > 0 ? item.paidPrice : item.unitPrice;
const cur = dynamicSalesMap.get(mpId) || {
soldQty: 0,
revenue: 0,
actualFeeSum: 0,
fallbackFeeSum: 0,
actualCount: 0,
fallbackCount: 0,
};
cur.soldQty += qty;
cur.revenue += rev;
// Lazada has no settlement import yet -> 30% fallback
cur.fallbackFeeSum += rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100);
cur.fallbackCount += 1;
dynamicSalesMap.set(mpId, cur);
}
}
}
// 3. Map Items & Accumulate KPIs
let totalGrossRevenue = 0;
let totalEstNetRevenue = 0;
let totalBOMCost = 0;
let totalEstNetProfit = 0;
let totalUnitsSold = 0;
let totalPlatformFee = 0;
let totalActualFeeSum = 0;
let totalFallbackFeeSum = 0;
let totalActualOrdersCount = 0;
let totalFallbackOrdersCount = 0;
const channelStats: Record<string, { soldQty: number; revenue: number; platformFee: number; bomCost: number; netProfit: number }> = {
shopee: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
lazada: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
tiktok: { soldQty: 0, revenue: 0, platformFee: 0, bomCost: 0, netProfit: 0 },
};
const mappedItems: any[] = [];
for (const item of mpProducts) {
let soldQty = 0;
let rev = 0;
let platformFee = 0;
let feeSource: "actual" | "hybrid" | "fallback_30pct" = "fallback_30pct";
let actualFeePart = 0;
let fallbackFeePart = 0;
const dyn = dynamicSalesMap.get(item.id);
if (dyn && dyn.soldQty > 0) {
soldQty = dyn.soldQty;
rev = dyn.revenue;
actualFeePart = dyn.actualFeeSum;
fallbackFeePart = dyn.fallbackFeeSum;
platformFee = Math.round((dyn.actualFeeSum + dyn.fallbackFeeSum) * 100) / 100;
if (dyn.actualCount > 0 && dyn.fallbackCount === 0) {
feeSource = "actual";
} else if (dyn.actualCount > 0 && dyn.fallbackCount > 0) {
feeSource = "hybrid";
} else {
feeSource = "fallback_30pct";
}
totalActualOrdersCount += dyn.actualCount;
totalFallbackOrdersCount += dyn.fallbackCount;
} else if (!start) {
// All-time fallback from MarketplaceProduct aggregate if no order details
if (item.totalSoldQty === 0 && item.totalRevenue === 0) continue;
soldQty = item.totalSoldQty;
rev = item.totalRevenue;
platformFee = Math.round(rev * (DEFAULT_FALLBACK_FEE_PERCENT / 100) * 100) / 100;
fallbackFeePart = platformFee;
feeSource = "fallback_30pct";
totalFallbackOrdersCount += 1;
} else {
// Date range filtered and no sales in this period
continue;
}
const v = item.masterVariant!;
const unitBomCost = (v.bomItems || []).reduce(
(sum, b) => sum + b.quantity * (b.rawMaterial?.pricePerUnit || 0),
0
);
const itemBomCost = Math.round(unitBomCost * soldQty * 100) / 100;
const ch = item.channel.toLowerCase();
const netRevenue = Math.round((rev - platformFee) * 100) / 100;
const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100;
const netMarginPercent = rev > 0 ? Math.round((netProfit / rev) * 1000) / 10 : 0;
const effectiveFeePercent = rev > 0 ? Math.round((platformFee / rev) * 1000) / 10 : DEFAULT_FALLBACK_FEE_PERCENT;
const avgSellingPrice = soldQty > 0 ? Math.round((rev / soldQty) * 100) / 100 : item.avgPrice;
const directPrice = v.price || avgSellingPrice || 0;
const directGrossProfit = directPrice - unitBomCost;
const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0;
totalGrossRevenue += rev;
totalPlatformFee += platformFee;
totalEstNetRevenue += netRevenue;
totalBOMCost += itemBomCost;
totalEstNetProfit += netProfit;
totalUnitsSold += soldQty;
totalActualFeeSum += actualFeePart;
totalFallbackFeeSum += fallbackFeePart;
if (channelStats[ch]) {
channelStats[ch].soldQty += soldQty;
channelStats[ch].revenue += rev;
channelStats[ch].platformFee += platformFee;
channelStats[ch].bomCost += itemBomCost;
channelStats[ch].netProfit += netProfit;
}
mappedItems.push({
id: item.id,
channel: item.channel,
shop: item.shop,
marketplaceSku: item.marketplaceSku,
marketplaceProductName: item.productName,
variation: item.variation,
avgSellingPrice,
totalSoldQty: soldQty,
totalRevenue: rev,
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,
platformFee,
effectiveFeePercent,
feeSource, // "actual" | "hybrid" | "fallback_30pct"
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;
const effectiveFeeRate =
totalGrossRevenue > 0 ? Math.round((totalPlatformFee / totalGrossRevenue) * 1000) / 10 : DEFAULT_FALLBACK_FEE_PERCENT;
return {
periodInfo: {
period: dateRange.period,
label: dateRange.label,
startDate: dateRange.start ? dateRange.start.toISOString().slice(0, 10) : null,
endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null,
daysCount: dateRange.daysCount,
},
feePolicy: {
mode: "hybrid_actual_with_30pct_fallback",
fallbackPercent: DEFAULT_FALLBACK_FEE_PERCENT,
description: "Shopee uses actual fees from report rawData. TikTok uses actual net settlement & fees. Any unmapped or non-settled order falls back to 30% platform deduction.",
},
kpis: {
totalMappedProducts: mappedItems.length,
totalUnitsSold,
totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100,
totalPlatformFee: Math.round(totalPlatformFee * 100) / 100,
totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100,
totalBOMCost: Math.round(totalBOMCost * 100) / 100,
totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100,
overallNetMargin,
effectiveFeeRate,
actualFeeAmount: Math.round(totalActualFeeSum * 100) / 100,
fallbackFeeAmount: Math.round(totalFallbackFeeSum * 100) / 100,
actualOrdersCount: totalActualOrdersCount,
fallbackOrdersCount: totalFallbackOrdersCount,
channelStats,
},
items: filtered,
};
})
// ─────────────────────────────────────────────────────────────────────────────
// 3. MRP & Material Requirement Planning (Supports Date Range & Forecasting)
// ─────────────────────────────────────────────────────────────────────────────
.get("/mrp", async ({ query }) => {
const dateRange = resolveDateRange(query.period, query.startDate, query.endDate);
// 1. Fetch raw materials
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
const variantSoldMap = new Map<number, number>();
if (!dateRange.start) {
// All Time: Aggregate from MarketplaceProduct
const mpProducts = await prisma.marketplaceProduct.findMany({
where: { masterVariantId: { not: null } },
select: { masterVariantId: true, totalSoldQty: true },
});
for (const mp of mpProducts) {
if (mp.masterVariantId) {
const current = variantSoldMap.get(mp.masterVariantId) || 0;
variantSoldMap.set(mp.masterVariantId, current + mp.totalSoldQty);
}
}
} else {
// Date Range Filter: Aggregate from actual Order items
const start = dateRange.start;
const end = dateRange.end || new Date();
// Look up helper: maps sellerSku / SKU to masterVariantId
const variants = await prisma.productVariant.findMany({
select: { id: true, sku: true, barcode: true },
});
const skuMap = new Map<string, number>();
for (const v of variants) {
if (v.sku) skuMap.set(v.sku.trim().toLowerCase(), v.id);
if (v.barcode) skuMap.set(v.barcode.trim().toLowerCase(), v.id);
}
const mps = await prisma.marketplaceProduct.findMany({
where: { masterVariantId: { not: null } },
select: { channel: true, shop: true, productName: true, variation: true, marketplaceSku: true, masterVariantId: true },
});
const mpKeyMap = new Map<string, number>();
for (const mp of mps) {
const k = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`;
mpKeyMap.set(k, mp.masterVariantId!);
if (mp.marketplaceSku) mpKeyMap.set(`${mp.channel}:::${mp.marketplaceSku.trim().toLowerCase()}`, mp.masterVariantId!);
}
// Scan TikTok Orders
const ttOrders = await prisma.tiktokOrder.findMany({
where: { createdTime: { not: null } },
select: {
createdTime: true,
shop: true,
items: { select: { sellerSku: true, skuId: true, productName: true, variation: true, quantity: true } },
},
});
for (const o of ttOrders) {
const d = parseOrderDate(o.createdTime);
if (!d || d < start || d > end) continue;
for (const it of o.items) {
const sku = (it.sellerSku || it.skuId || "").trim().toLowerCase();
const k = `tiktok:::${o.shop || "hom-aroma"}:::${it.productName}:::${it.variation || ""}:::${sku}`;
const vId = mpKeyMap.get(k) || (sku ? mpKeyMap.get(`tiktok:::${sku}`) || skuMap.get(sku) : null);
if (vId) {
variantSoldMap.set(vId, (variantSoldMap.get(vId) || 0) + (it.quantity || 1));
}
}
}
// Scan Shopee Orders
const spOrders = await prisma.shopeeOrder.findMany({
where: { orderCreatedAt: { not: null } },
select: {
orderCreatedAt: true,
shop: true,
items: { select: { parentSku: true, skuRef: true, productName: true, variantName: true, quantity: true } },
},
});
for (const o of spOrders) {
const d = parseOrderDate(o.orderCreatedAt);
if (!d || d < start || d > end) continue;
for (const it of o.items) {
const sku = (it.skuRef || it.parentSku || "").trim().toLowerCase();
const k = `shopee:::${o.shop || "hom-aroma"}:::${it.productName}:::${it.variantName || ""}:::${sku}`;
const vId = mpKeyMap.get(k) || (sku ? mpKeyMap.get(`shopee:::${sku}`) || skuMap.get(sku) : null);
if (vId) {
variantSoldMap.set(vId, (variantSoldMap.get(vId) || 0) + (it.quantity || 1));
}
}
}
// Scan Lazada Orders
const lzOrders = await prisma.lazadaOrder.findMany({
where: { createTime: { not: null } },
select: {
createTime: true,
shop: true,
items: { select: { sellerSku: true, lazadaSku: true, itemName: true, variation: true } },
},
});
for (const o of lzOrders) {
const d = parseOrderDate(o.createTime);
if (!d || d < start || d > end) continue;
for (const it of o.items) {
const sku = (it.sellerSku || it.lazadaSku || "").trim().toLowerCase();
const k = `lazada:::${o.shop || "hom-aroma"}:::${it.itemName}:::${it.variation || ""}:::${sku}`;
const vId = mpKeyMap.get(k) || (sku ? mpKeyMap.get(`lazada:::${sku}`) || skuMap.get(sku) : null);
if (vId) {
variantSoldMap.set(vId, (variantSoldMap.get(vId) || 0) + 1);
}
}
}
}
// 3. Reverse Explosion: For each Raw Material, compute consumption and 30-day forecast
let totalMaterialCostConsumed = 0;
const daysCount = dateRange.daysCount || 30;
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;
// Forecast Metrics
const dailyBurnRate = Math.round((totalConsumedQty / daysCount) * 100) / 100;
const forecast30DaysQty = Math.round(dailyBurnRate * 30 * 10) / 10;
const forecast30DaysCost = Math.round(forecast30DaysQty * rm.pricePerUnit * 100) / 100;
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,
dailyBurnRate,
forecast30DaysQty,
forecast30DaysCost,
topProducts,
};
});
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 {
periodInfo: {
period: dateRange.period,
label: dateRange.label,
startDate: dateRange.start ? dateRange.start.toISOString().slice(0, 10) : null,
endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null,
daysCount,
},
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,
};
});
+112 -7
View File
@@ -14,10 +14,105 @@ export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
const rawMaterial = await prisma.rawMaterial.findUnique({
where: { id },
include: {
bomItems: {
include: {
productVariant: {
include: {
product: {
select: {
id: true,
name: true,
translations: {
select: { langCode: true, name: true }
}
}
}
}
}
}
}
}
});
if (!rawMaterial) return status(404, { message: "Raw material not found" });
return rawMaterial;
const result = {
...rawMaterial,
bomItems: rawMaterial.bomItems.map((item) => {
const prod = item.productVariant?.product;
if (!prod) return item;
const thName = prod.translations?.find((t) => t.langCode === "th")?.name;
const fallbackName = prod.translations?.[0]?.name;
const resolvedName = prod.name || thName || fallbackName || `Product #${prod.id}`;
return {
...item,
productVariant: {
...item.productVariant,
product: {
...prod,
name: resolvedName,
}
}
};
})
};
return result;
})
.post("/bulk", async ({ body, status }) => {
try {
const items = Array.isArray(body) ? body : [body];
if (items.length === 0) {
return status(400, { message: "Payload must be a non-empty array" });
}
const results = await prisma.$transaction(async (tx) => {
const records = [];
for (const item of items) {
const name = String(item.name || "").trim();
if (!name) continue;
const unit = String(item.unit || "piece").trim() || "piece";
const rawPrice = item.pricePerUnit;
const pricePerUnit = typeof rawPrice === "string" ? (parseFloat(rawPrice) || 0) : Number(rawPrice) || 0;
const record = await tx.rawMaterial.upsert({
where: { name },
create: {
name,
unit,
pricePerUnit,
},
update: {
unit,
pricePerUnit,
},
});
records.push(record);
}
return records;
});
return status(200, {
success: true,
count: results.length,
items: results,
});
} catch (err: any) {
console.error("Bulk raw material import failed:", err);
return status(500, { message: "Failed to bulk import raw materials", error: err.message });
}
}, {
body: t.Array(
t.Object({
name: t.String(),
unit: t.Optional(t.String()),
pricePerUnit: t.Union([t.Number(), t.String()]),
})
),
detail: { tags: ["raw-materials"] }
})
.post("/", async ({ body, status }) => {
try {
@@ -42,20 +137,30 @@ export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
if (isNaN(id)) return status(400, { message: "Invalid ID" });
try {
const updateData: any = {};
if (body.name !== undefined) updateData.name = String(body.name).trim();
if (body.unit !== undefined) updateData.unit = String(body.unit).trim();
if (body.pricePerUnit !== undefined) {
updateData.pricePerUnit =
typeof body.pricePerUnit === "string"
? parseFloat(body.pricePerUnit) || 0
: Number(body.pricePerUnit) || 0;
}
const rawMaterial = await prisma.rawMaterial.update({
where: { id },
data: body,
data: updateData,
});
return rawMaterial;
} catch (err) {
} catch (err: any) {
console.error(err);
return status(500, { message: "Failed to update raw material" });
return status(500, { message: "Failed to update raw material", error: err.message });
}
}, {
body: t.Object({
name: t.String(),
unit: t.String(),
pricePerUnit: t.Number(),
name: t.Optional(t.String()),
unit: t.Optional(t.String()),
pricePerUnit: t.Optional(t.Union([t.Number(), t.String()])),
}),
detail: { tags: ["raw-materials"] }
})
+27
View File
@@ -469,6 +469,33 @@ export const toolsRoute = new Elysia({ prefix: "/tools" })
}
)
// ── 4.1 POST Bulk Link / Unlink Master Variants ──────────────────────────────
.post(
"/marketplace-products/bulk-map",
async ({ body }: { body: { mappings: Array<{ marketplaceProductId: number; masterVariantId: number | null }> } }) => {
const { mappings } = body;
await prisma.$transaction(
mappings.map((m) =>
prisma.marketplaceProduct.update({
where: { id: m.marketplaceProductId },
data: { masterVariantId: m.masterVariantId },
})
)
);
return { success: true, count: mappings.length };
},
{
body: t.Object({
mappings: t.Array(
t.Object({
marketplaceProductId: t.Number(),
masterVariantId: t.Nullable(t.Number()),
})
),
}),
}
)
// ── 5. POST Bulk Auto Match Unmapped Items ──────────────────────────────────
.post("/marketplace-products/bulk-auto-match", async () => {
const masterVariants = await prisma.productVariant.findMany({
+1 -1
View File
@@ -51,7 +51,7 @@ test("settings writes send only supported fields and simple product fields are a
try {
client = await connectMcp(`http://localhost:${backend.port}/api`);
const tools = (await client.listTools()).tools;
expect(tools).toHaveLength(113);
expect(tools).toHaveLength(116);
expect(tools.find((item) => item.name === "get_footer_layout")?.annotations?.readOnlyHint).toBe(true);
expect(tools.find((item) => item.name === "delete_category_section")?.annotations?.destructiveHint).toBe(true);
const footerLayout = await client.callTool({ name: "get_footer_layout", arguments: {} });
+168
View File
@@ -371,6 +371,123 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["id"]
}
},
{
name: "list_products_bom_status",
description: "List products and their BOM (Bill of Materials) setup status (whether variants have formulas defined). Useful for finding products needing BOM configuration.",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Search by product name, brand, or SKU" },
status: { type: "string", enum: ["all", "missing", "configured", "partial"], description: "Filter by BOM status. Default is 'all'." },
limit: { type: "number", description: "Maximum products to return. Default 20." }
}
}
},
{
name: "get_product_bom",
description: "Get full BOM formula details, current material costs, gross profit, margin %, and available raw materials for a product. Use this to inspect a product's variants and determine required materials.",
inputSchema: {
type: "object",
properties: {
productId: { type: "number", description: "Product ID" }
},
required: ["productId"]
}
},
{
name: "set_product_bom",
description: "Set or update the BOM (Bill of Materials) formula for a product's variants. Matches variants by variantId or sku, and raw materials by rawMaterialId or name. Replaces the variant's BOM items and recalculates cost/margin.",
inputSchema: {
type: "object",
properties: {
productId: { type: "number", description: "Product ID" },
variants: {
type: "array",
description: "Array of variant BOM formulas to apply",
items: {
type: "object",
properties: {
variantId: { type: "number", description: "Variant ID (optional if sku provided)" },
sku: { type: "string", description: "Variant SKU (optional if variantId provided)" },
bomItems: {
type: "array",
description: "List of raw material ingredients/packaging for this variant",
items: {
type: "object",
properties: {
rawMaterialId: { type: "number", description: "Raw material ID (optional if name provided)" },
name: { type: "string", description: "Raw material name (optional if rawMaterialId provided)" },
quantity: { type: "number", description: "Quantity of raw material used per unit" }
},
required: ["quantity"]
}
}
},
required: ["bomItems"]
}
}
},
required: ["productId", "variants"]
}
},
{
name: "list_marketplace_products",
description: "List marketplace products (TikTok, Shopee, Lazada) extracted from orders. Allows filtering by status ('unmapped', 'mapped', 'all'), channel ('tiktok', 'shopee', 'lazada', 'all'), search text, and pagination. Returns sales metrics and current masterVariant mapping.",
inputSchema: {
type: "object",
properties: {
channel: { type: "string", enum: ["tiktok", "shopee", "lazada", "all"], description: "Marketplace channel. Default is 'all'." },
status: { type: "string", enum: ["unmapped", "mapped", "all"], description: "Mapping status. Default is 'unmapped'." },
search: { type: "string", description: "Search by product name, variation, or SKU" },
page: { type: "number", description: "Page number. Default 1." },
limit: { type: "number", description: "Items per page. Default 50." }
}
}
},
{
name: "get_master_product_variants",
description: "List all master product variants from the core catalog for mapping, including product name, options (size/scent), SKU, barcode, and unit BOM cost.",
inputSchema: {
type: "object",
properties: {
search: { type: "string", description: "Search by product name, variant option, or SKU" }
}
}
},
{
name: "map_marketplace_product",
description: "Link or unlink a single marketplace product (TikTok/Shopee/Lazada) to a master product variant (ProductVariant) to enable BOM cost and profitability calculation. Pass masterVariantId=null to unlink.",
inputSchema: {
type: "object",
properties: {
marketplaceProductId: { type: "number", description: "Marketplace product ID" },
masterVariantId: { type: ["number", "null"], description: "Master variant ID, or null to unlink" }
},
required: ["marketplaceProductId", "masterVariantId"]
}
},
{
name: "bulk_map_marketplace_products",
description: "Link multiple marketplace products to master catalog variants in a single batch operation.",
inputSchema: {
type: "object",
properties: {
mappings: {
type: "array",
description: "Array of mappings to apply",
items: {
type: "object",
properties: {
marketplaceProductId: { type: "number", description: "Marketplace product ID" },
masterVariantId: { type: ["number", "null"], description: "Master variant ID, or null to unlink" }
},
required: ["marketplaceProductId", "masterVariantId"]
}
}
},
required: ["mappings"]
}
},
{
name: "update_category_translation",
description: "Add or update translations for a specific category. Do NOT use this tool for products.",
@@ -1449,6 +1566,57 @@ async function handleToolRequest(request: CallToolRequest) {
const res = await api.get(`/products/${params.id}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "list_products_bom_status": {
const query = new URLSearchParams();
if (params.search) query.set("search", String(params.search));
if (params.status) query.set("status", String(params.status));
query.set("limit", String(params.limit ?? 20));
const res = await api.get(`/products/bom-status?${query.toString()}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_product_bom": {
const res = await api.get(`/products/${params.productId}/bom`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "set_product_bom": {
const res = await api.put(`/products/${params.productId}/bom`, { variants: params.variants });
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "list_marketplace_products": {
const query = new URLSearchParams();
if (params.channel) query.set("channel", String(params.channel));
if (params.status) query.set("status", String(params.status));
if (params.search) query.set("search", String(params.search));
if (params.page) query.set("page", String(params.page));
query.set("limit", String(params.limit ?? 50));
const res = await api.get(`/tools/marketplace-products?${query.toString()}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_master_product_variants": {
const res = await api.get(`/tools/marketplace-products/master-variants`);
let variants = res.data.variants || [];
if (params.search) {
const words = String(params.search).toLowerCase().split(/\s+/).filter(Boolean);
variants = variants.filter((v: any) => {
const target = `${v.displayName} ${v.sku} ${v.productName} ${v.optionsStr}`.toLowerCase();
return words.every((w: string) => target.includes(w));
});
}
return { content: [{ type: "text", text: JSON.stringify({ variants }, null, 2) }] };
}
case "map_marketplace_product": {
const res = await api.post(`/tools/marketplace-products/map`, {
marketplaceProductId: params.marketplaceProductId,
masterVariantId: params.masterVariantId,
});
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "bulk_map_marketplace_products": {
const res = await api.post(`/tools/marketplace-products/bulk-map`, {
mappings: params.mappings,
});
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "create_product": {
const res = await api.post(`/products`, params);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
+116
View File
@@ -0,0 +1,116 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { join } from "node:path";
async function main() {
console.log("Connecting to MCP server...");
const transport = new StdioClientTransport({
command: process.execPath,
args: [join(import.meta.dir, "index.ts")],
cwd: import.meta.dir,
env: {
...process.env,
MCP_BACKEND_URL: "http://localhost:3051/api",
},
stderr: "pipe",
});
const client = new Client({ name: "bom-test-client", version: "1.0.0" });
await client.connect(transport);
console.log("Connected to MCP server!");
// 1. List tools and verify BOM tools are registered
const tools = (await client.listTools()).tools;
console.log(`Total MCP tools registered: ${tools.length}`);
const bomTools = ["list_products_bom_status", "get_product_bom", "set_product_bom"];
for (const name of bomTools) {
const found = tools.find((t) => t.name === name);
if (!found) {
throw new Error(`Tool ${name} not found!`);
}
console.log(`✓ Found tool: ${name}`);
}
// 2. Call list_products_bom_status
console.log("\nCalling list_products_bom_status for search='ซากุระ'...");
const statusRes = await client.callTool({
name: "list_products_bom_status",
arguments: { search: "ซากุระ", limit: 5 },
});
console.log("Status result:", (statusRes.content as any)[0].text.slice(0, 300) + "...");
// 3. Call get_product_bom for Product #250
console.log("\nCalling get_product_bom for productId=250...");
const getBomRes = await client.callTool({
name: "get_product_bom",
arguments: { productId: 250 },
});
const bomData = JSON.parse((getBomRes.content as any)[0].text);
console.log(`Product Name: ${bomData.productName}`);
console.log(`Variants count: ${bomData.variants.length}`);
console.log(`Available Raw Materials count: ${bomData.availableRawMaterials.length}`);
// 4. Call set_product_bom for Product #250
console.log("\nCalling set_product_bom for Product #250 with 4 variants...");
const setBomRes = await client.callTool({
name: "set_product_bom",
arguments: {
productId: 250,
variants: [
{
variantId: 3206,
sku: "4-1-30ML",
bomItems: [
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 2.4 },
{ rawMaterialId: 91, name: "DPG", quantity: 27.6 },
{ rawMaterialId: 92, name: "ขวดพร้อมฝา Oilozone 30ml", quantity: 1 },
],
},
{
variantId: 3207,
sku: "4-1-60ML",
bomItems: [
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 4.8 },
{ rawMaterialId: 91, name: "DPG", quantity: 55.2 },
{ rawMaterialId: 93, name: "ขวดพร้อมฝา Oilozone 60ml", quantity: 1 },
],
},
{
variantId: 3208,
sku: "4-1-85ML",
bomItems: [
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 6.8 },
{ rawMaterialId: 91, name: "DPG", quantity: 78.2 },
{ rawMaterialId: 94, name: "ขวดพร้อมฝา Oilozone 85ml", quantity: 1 },
],
},
{
variantId: 3209,
sku: "4-1-120ML",
bomItems: [
{ rawMaterialId: 4, name: "หัวน้ำหอม กลิ่นซากุระ", quantity: 9.6 },
{ rawMaterialId: 91, name: "DPG", quantity: 110.4 },
{ rawMaterialId: 95, name: "ขวดพร้อมฝา Oilozone 120ml", quantity: 1 },
],
},
],
},
});
const setResult = JSON.parse((setBomRes.content as any)[0].text);
console.log("\nSet BOM result:");
console.log(`Success: ${setResult.success}`);
console.log(`Message: ${setResult.message}`);
for (const v of setResult.variants) {
console.log(` - Variant: ${v.option} (SKU: ${v.sku}) | Retail: ฿${v.retailPrice} | BOM Cost: ฿${v.totalBomCost} | Margin: ${v.marginPercent}%`);
}
await client.close();
console.log("\nAll BOM MCP tests passed successfully!");
}
main().catch((err) => {
console.error("Test failed:", err);
process.exit(1);
});
+314 -5
View File
@@ -60,6 +60,7 @@ function MenuImage({ src, alt, placeholder, className, style }: { src?: string |
export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const [openId, setOpenId] = useState<string | null>(null);
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null);
const [activeTabByMenu, setActiveTabByMenu] = useState<Record<string, number>>({});
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const location = useLocation();
const { i18n } = useTranslation();
@@ -110,10 +111,17 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const isOpen = openId === uniqueId;
const hasColumns = item.columns && item.columns.length > 0;
const visibleColumns = item.columns?.slice(0, 2) ?? [];
const layout = item.layout || 'columns';
const isClassic = layout === 'classic';
const isTabbed = layout === 'tabbed';
const isVisualCards = layout === 'visual-cards';
const configuredCols = item.layoutConfig?.columnCount || (item.columns?.length ? Math.min(Math.max(item.columns.length, 2), 4) : 2);
const visibleColumns = item.columns?.slice(0, configuredCols) ?? [];
const featuredLink = getFirstImageLink(item.columns);
const featuredConfig = item.featured;
const showFeatured = featuredConfig?.enabled !== false;
const showFeatured = (item.layoutConfig?.showFeatured !== false) && (featuredConfig?.enabled !== false) && !isClassic && !isVisualCards && !isTabbed && (
!!(featuredConfig?.imageUrl || featuredConfig?.title || featuredConfig?.sectionTitle || featuredLink?.imageUrl)
);
const configuredImage = featuredConfig?.imageUrl || null;
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl;
const focalPoint = featuredConfig?.focalPoint;
@@ -129,6 +137,28 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#';
const featuredCta = getLocalizedText(featuredConfig?.ctaLabel, lang) || 'View details';
const getMegaContainerClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'w-[min(100vw-2rem,1260px)]';
if (cols === 3) return 'w-[min(100vw-2rem,1120px)]';
return 'w-[min(100vw-2rem,980px)]';
}
if (cols >= 4) return 'w-[min(100vw-2rem,1180px)]';
if (cols === 3) return 'w-[min(100vw-2rem,960px)]';
return 'w-[min(100vw-2rem,720px)]';
};
const getMegaGridColsClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'grid-cols-[1fr_1fr_1fr_1fr_260px]';
if (cols === 3) return 'grid-cols-[1fr_1fr_1fr_270px]';
return 'grid-cols-[1fr_1fr_270px]';
}
if (cols >= 4) return 'grid-cols-4';
if (cols === 3) return 'grid-cols-3';
return 'grid-cols-2';
};
return (
<div
key={uniqueId}
@@ -159,9 +189,288 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
)}
</Link>
{hasColumns && (
{hasColumns && isClassic && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,980px)] -translate-x-1/2 transition-all duration-200 ${
className={`absolute left-0 top-full z-50 pt-2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="min-w-[230px] max-w-xs cursor-default overflow-hidden rounded-xl border border-pop-black/10 bg-pop-bg/95 p-2 shadow-2xl backdrop-blur-xl">
{item.columns?.map((col, colIdx) => (
<div key={colIdx} className={colIdx > 0 ? 'mt-2 border-t border-pop-black/10 pt-2' : ''}>
{getLocalizedText(col.title, lang) && item.columns && item.columns.length > 1 && (
<h5 className="px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-pop-black/50">
{getLocalizedText(col.title, lang)}
</h5>
)}
<ul className="space-y-0.5">
{col.links.map((link, linkIdx) => (
<li key={linkIdx}>
<Link
to={link.url || '#'}
onClick={handleLinkClick}
className="group flex items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold normal-case tracking-normal text-pop-black transition-colors hover:bg-pop-card hover:text-pop-black"
>
<span className="flex items-center gap-2.5">
{link.icon && (
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-4 w-4 shrink-0 text-pop-black/70 transition-colors group-hover:text-pop-black"
strokeWidth={1.5}
/>
)}
<span>{getLocalizedText(link.label, lang)}</span>
</span>
{link.links?.length ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-pop-black/30 transition-colors group-hover:text-pop-black" />
) : null}
</Link>
{link.links && link.links.length > 0 && (
<ul className="ml-4 space-y-0.5 border-l border-pop-black/10 pl-2 my-1">
{link.links.map((child, childIdx) => (
<li key={childIdx}>
<Link
to={child.url || '#'}
onClick={handleLinkClick}
className="block rounded-md px-2 py-1 text-[11px] font-normal normal-case tracking-normal text-pop-black/60 transition-colors hover:bg-pop-card hover:text-pop-black"
>
{getLocalizedText(child.label, lang)}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{hasColumns && isTabbed && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
style={{ top: 'var(--navbar-bottom, 56px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-xl border border-pop-black/10 bg-pop-bg/95 shadow-2xl backdrop-blur-xl">
<div className="flex min-h-[380px] max-h-[500px]">
{/* Left Category Tabs Sidebar */}
<div className="w-64 shrink-0 border-r border-pop-black/10 bg-pop-card/30 p-3 space-y-1 overflow-y-auto">
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-pop-black/40">
{lang === 'th' ? 'หมวดหมู่หลัก' : 'Categories'}
</div>
{item.columns?.map((column, colIdx) => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0;
const isSelected = activeIdx === colIdx;
return (
<button
key={colIdx}
type="button"
onMouseEnter={() => {
setActiveTabByMenu(prev => ({ ...prev, [uniqueId]: colIdx }));
setHoveredLinkImage(null);
}}
className={`flex w-full items-center justify-between rounded-lg px-3.5 py-2.5 text-left transition-all ${
isSelected
? 'bg-pop-primary/10 text-pop-primary font-bold shadow-sm'
: 'text-pop-black/80 hover:bg-pop-card hover:text-pop-black font-medium'
}`}
>
<span className="flex items-center gap-2.5 min-w-0">
<DynamicIcon
iconName={column.icon}
defaultIcon={Sparkles}
className={`h-4 w-4 shrink-0 ${isSelected ? 'text-pop-primary' : 'text-pop-black/50'}`}
strokeWidth={1.5}
/>
<span className="truncate text-xs normal-case">{getLocalizedText(column.title, lang)}</span>
</span>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSelected ? 'text-pop-primary translate-x-0.5' : 'text-pop-black/20'}`} />
</button>
);
})}
</div>
{/* Right Content Panel for Selected Tab */}
{(() => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0;
const activeCol = item.columns?.[activeIdx] || item.columns?.[0];
if (!activeCol) return null;
return (
<div className="flex-1 p-6 flex flex-col justify-between overflow-y-auto">
<div>
<div className="flex items-center justify-between border-b border-pop-black/10 pb-3 mb-4">
<div className="flex items-center gap-2.5">
<DynamicIcon
iconName={activeCol.icon}
defaultIcon={Sparkles}
className="h-5 w-5 text-pop-primary"
strokeWidth={1.5}
/>
<h3 className="text-sm font-bold normal-case tracking-normal text-pop-black">
{getLocalizedText(activeCol.title, lang)}
</h3>
</div>
<Link
to={activeCol.links[0]?.url || item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 text-xs font-semibold normal-case text-pop-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูรายการทั้งหมด' : 'View all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{activeCol.links.map((link, lIdx) => (
<div
key={lIdx}
className="rounded-lg p-2.5 hover:bg-pop-card transition-colors group"
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
onMouseLeave={() => setHoveredLinkImage(null)}
>
<Link
to={link.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-2.5"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-pop-black/10 bg-pop-bg text-pop-primary group-hover:border-pop-primary/40">
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-3.5 w-3.5"
strokeWidth={1.35}
/>
</span>
<span className="truncate text-xs font-semibold normal-case text-pop-black group-hover:text-pop-primary transition-colors">
{getLocalizedText(link.label, lang)}
</span>
</Link>
{link.links && link.links.length > 0 && (
<div className="ml-9 mt-1.5 flex flex-wrap gap-1.5">
{link.links.slice(0, 3).map((child, cIdx) => (
<Link
key={cIdx}
to={child.url || '#'}
onClick={handleLinkClick}
className="text-[10px] text-pop-black/60 hover:text-pop-primary transition-colors bg-pop-card px-1.5 py-0.5 rounded border border-pop-black/5"
>
{getLocalizedText(child.label, lang)}
</Link>
))}
</div>
)}
</div>
))}
</div>
</div>
<div className="border-t border-pop-black/10 pt-4 mt-6 flex items-center justify-between text-xs text-pop-black/50">
<span>{item.columns?.length || 0} {lang === 'th' ? 'หมวดหมู่ย่อย' : 'categories'}</span>
<Link
to={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 font-semibold text-pop-black hover:text-pop-primary transition-colors"
>
<span>{getLocalizedText(item.label, lang)}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
);
})()}
</div>
</div>
</div>
)}
{hasColumns && isVisualCards && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
style={{ top: 'var(--navbar-bottom, 56px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-2xl border border-pop-black/10 bg-pop-bg/95 p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-pop-black/40">
{getLocalizedText(item.label, lang)}
</h4>
<Link
to={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1 text-xs font-semibold text-pop-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูทั้งหมด' : 'Explore all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{item.columns?.slice(0, 4).map((column, colIdx) => {
const cardImg = column.iconUrl || column.links?.[0]?.imageUrl || configuredImage || item.rootImageUrl;
const cardHref = column.links?.[0]?.url || item.url || '#';
const cardTitle = getLocalizedText(column.title, lang);
const firstSubLink = column.links?.[0];
const subText = firstSubLink ? getLocalizedText(firstSubLink.label, lang) : null;
return (
<Link
key={colIdx}
to={cardHref}
onClick={handleLinkClick}
className="group relative block overflow-hidden rounded-xl border border-pop-black/10 bg-pop-card transition-all hover:border-pop-primary/50 hover:shadow-lg"
>
<div className="relative aspect-[4/5] w-full overflow-hidden bg-pop-card/50">
<MenuImage
src={cardImg}
alt={cardTitle}
placeholder={cardTitle}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4 space-y-1">
<span className="block text-sm font-bold text-white normal-case leading-snug drop-shadow-sm">
{cardTitle}
</span>
{subText && (
<span className="flex items-center justify-between text-[11px] font-normal text-white/80 normal-case">
<span className="truncate">{subText}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-white/80 transition-transform group-hover:translate-x-1" />
</span>
)}
</div>
</div>
</Link>
);
})}
</div>
</div>
</div>
)}
{hasColumns && !isClassic && !isTabbed && !isVisualCards && (
<div
className={`fixed left-1/2 z-50 ${getMegaContainerClass(visibleColumns.length, showFeatured)} -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto translate-y-0' : 'opacity-0 pointer-events-none -translate-y-1'
}`}
style={{ top: 'var(--navbar-bottom, 56px)' }}
@@ -172,7 +481,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-pop-black/10 bg-pop-bg/95" />
</div>
<div className="w-full overflow-hidden rounded-lg border border-pop-black/10 bg-pop-bg/95 shadow-2xl cursor-default backdrop-blur-xl">
<div className={`grid gap-0 px-8 py-8 ${showFeatured ? 'grid-cols-[1fr_1fr_270px]' : 'grid-cols-2'}`}>
<div className={`grid gap-0 px-8 py-8 ${getMegaGridColsClass(visibleColumns.length, showFeatured)}`}>
{visibleColumns.map((col, idx) => (
<div
key={idx}
+10
View File
@@ -29,11 +29,21 @@ export interface MenuFeaturedCard {
ctaLabel?: Record<string, string> | string;
}
export type SubmenuLayoutType = 'columns' | 'classic' | 'tabbed' | 'visual-cards';
export interface SubmenuLayoutConfig {
columnCount?: 2 | 3 | 4;
showFeatured?: boolean;
itemStyle?: 'icon' | 'thumbnail' | 'text-only';
}
export interface MegaMenuPayload {
id?: string;
nodeId?: string;
label: Record<string, string> | string;
url?: string;
layout?: SubmenuLayoutType;
layoutConfig?: SubmenuLayoutConfig;
columns?: MenuColumn[];
rootImageUrl?: string | null; // รูปหลักฝั่งซ้ายสุด (1:1)
featured?: MenuFeaturedCard;
@@ -7,7 +7,11 @@ import i18n from '../lib/i18n';
import { useCartStore } from '../store/cartStore';
import { useWishlistStore } from '../store/wishlistStore';
import { useAuthStore } from '../store/authStore';
import { useCookieConsentStore } from '../store/cookieConsentStore';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '../lib/locales';
import CookieBanner from './cookie/CookieBanner';
import CookiePreferencesModal from './cookie/CookiePreferencesModal';
import ScriptManager from './cookie/ScriptManager';
export default function ClientProviders({ children }: { children: ReactNode }) {
useEffect(() => {
@@ -25,13 +29,17 @@ export default function ClientProviders({ children }: { children: ReactNode }) {
void useCartStore.persist.rehydrate()
void useWishlistStore.persist.rehydrate()
void useAuthStore.persist.rehydrate()
void useCookieConsentStore.persist.rehydrate()
}, [])
return (
<>
<ScriptManager />
<ScrollToTop />
{children}
<CartDrawer />
<CookieBanner />
<CookiePreferencesModal />
</>
);
}
+8
View File
@@ -15,6 +15,7 @@ import {
import { useTranslation } from 'react-i18next'
import { resolveLocalized } from '../lib/localization'
import { useLang } from '../hooks/useLang'
import { useCookieConsentStore } from '../store/cookieConsentStore'
interface FooterProps {
categories?: Category[]
@@ -154,6 +155,13 @@ export default function Footer({
</h4>
<div className="site-footer-column-links flex flex-col space-y-2 text-muted-text">
{supportMenu.map((item) => <Link key={item.id} href={item.url} className="ui-footer-link">{translateMenuLabel(item)}</Link>)}
<button
type="button"
onClick={() => useCookieConsentStore.getState().openPreferences()}
className="text-left ui-footer-link hover:underline transition-colors cursor-pointer text-xs"
>
{t('cookie.settings', 'การตั้งค่าคุกกี้')}
</button>
</div>
<div className="pt-4">
<p className="text-[10px] uppercase tracking-widest text-muted-text">
@@ -0,0 +1,78 @@
"use client";
import { useCookieConsentStore } from '../../store/cookieConsentStore';
import { useTranslation } from 'react-i18next';
import { Cookie, Settings2, ShieldCheck } from 'lucide-react';
export default function CookieBanner() {
const { t } = useTranslation();
const { record, isHydrated, acceptAll, rejectAll, openPreferences } = useCookieConsentStore();
// Do not render on server or before rehydration, or if user has already made a choice
if (!isHydrated || record.hasResponded) {
return null;
}
return (
<aside
aria-label={t('cookie.bannerTitle', 'การตั้งค่าคุกกี้และความเป็นส่วนตัว')}
className="fixed bottom-0 inset-x-0 z-50 p-4 sm:p-6 pointer-events-none animate-in fade-in slide-in-from-bottom-6 duration-500"
>
<div className="max-w-5xl mx-auto pointer-events-auto bg-surface-card/95 backdrop-blur-md border border-border-color shadow-2xl rounded-2xl p-5 sm:p-6 text-text-main transition-all">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-5">
{/* Icon & Description */}
<div className="flex items-start gap-3.5 flex-1 pr-0 md:pr-4">
<div className="p-2.5 rounded-xl bg-brand-cool/60 text-brand-primary shrink-0 mt-0.5">
<Cookie className="w-5 h-5" />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm sm:text-base text-text-main tracking-wide">
{t('cookie.bannerTitle', 'เราให้ความสำคัญกับความเป็นส่วนตัวของคุณ')}
</h3>
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-brand-cool/50 text-muted-text">
<ShieldCheck className="w-3 h-3 text-brand-primary" />
PDPA Compliant
</span>
</div>
<p className="text-xs sm:text-sm text-muted-text leading-relaxed">
{t(
'cookie.bannerDesc',
'เราใช้คุกกี้เพื่อเพิ่มประสิทธิภาพ ประสบการณ์ที่ดีในการใช้งานเว็บไซต์ และเพื่อการวิเคราะห์ปรับปรุงบริการ ท่านสามารถเลือกตั้งค่าความยินยอมตามประเภทคุกกี้ได้'
)}
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap sm:flex-nowrap items-center gap-2.5 w-full md:w-auto shrink-0">
<button
type="button"
onClick={openPreferences}
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs sm:text-sm font-medium rounded-lg text-muted-text hover:text-text-main hover:bg-surface-muted transition-colors border border-transparent"
>
<Settings2 className="w-3.5 h-3.5" />
<span>{t('cookie.btnCustomize', 'ตั้งค่าคุกกี้')}</span>
</button>
<button
type="button"
onClick={rejectAll}
className="flex-1 sm:flex-none px-4 py-2 text-xs sm:text-sm font-medium rounded-lg border border-border-color text-text-main hover:bg-surface-muted hover:border-text-main/20 transition-all text-center"
>
{t('cookie.btnRejectAll', 'ปฏิเสธทั้งหมด')}
</button>
<button
type="button"
onClick={acceptAll}
className="w-full sm:w-auto px-5 py-2 text-xs sm:text-sm font-semibold rounded-lg bg-brand-primary text-white hover:bg-brand-primary-hover shadow-sm hover:shadow transition-all text-center tracking-wide"
>
{t('cookie.btnAcceptAll', 'ยอมรับทั้งหมด')}
</button>
</div>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,225 @@
"use client";
import { useState, useEffect } from 'react';
import { useCookieConsentStore, type CookiePreferences } from '../../store/cookieConsentStore';
import { useTranslation } from 'react-i18next';
import { X, ShieldCheck, BarChart3, Target, Lock, Check } from 'lucide-react';
export default function CookiePreferencesModal() {
const { t } = useTranslation();
const {
record,
isPreferencesOpen,
closePreferences,
savePreferences,
acceptAll,
rejectAll,
} = useCookieConsentStore();
// Local state for interactive toggling inside the modal
const [localPrefs, setLocalPrefs] = useState<CookiePreferences>({
necessary: true,
analytics: false,
marketing: false,
});
// Sync local state when modal opens
useEffect(() => {
if (isPreferencesOpen) {
setLocalPrefs({
necessary: true,
analytics: Boolean(record.preferences.analytics),
marketing: Boolean(record.preferences.marketing),
});
}
}, [isPreferencesOpen, record.preferences]);
// Handle ESC key to close
useEffect(() => {
if (!isPreferencesOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closePreferences();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isPreferencesOpen, closePreferences]);
if (!isPreferencesOpen) return null;
const handleSave = () => {
savePreferences(localPrefs);
};
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="cookie-modal-title"
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/50 backdrop-blur-sm animate-in fade-in duration-200"
>
<div
className="w-full max-w-2xl bg-surface-card rounded-2xl shadow-2xl border border-border-color overflow-hidden flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-color bg-surface-nav-rest">
<div className="flex items-center gap-2.5">
<div className="p-2 rounded-lg bg-brand-cool/70 text-brand-primary">
<ShieldCheck className="w-5 h-5" />
</div>
<div>
<h2 id="cookie-modal-title" className="text-base sm:text-lg font-bold text-text-main">
{t('cookie.prefTitle', 'ศูนย์การตั้งค่าความเป็นส่วนตัว (Cookie Preferences)')}
</h2>
<p className="text-xs text-muted-text">
{t('cookie.prefSubtitle', 'จัดการความยินยอมในการจัดเก็บและใช้งานคุกกี้ตาม พ.ร.บ. คุ้มครองข้อมูลส่วนบุคคล (PDPA)')}
</p>
</div>
</div>
<button
type="button"
onClick={closePreferences}
className="p-2 text-muted-text hover:text-text-main rounded-lg hover:bg-surface-muted transition-colors"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body / Cookie Categories */}
<div className="p-6 space-y-4 overflow-y-auto divide-y divide-border-color/60">
{/* 1. Necessary Cookies */}
<div className="pt-2 first:pt-0">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 rounded-md bg-surface-muted text-muted-text mt-0.5">
<Lock className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-text-main">
{t('cookie.catNecessaryTitle', 'คุกกี้ที่จำเป็นอย่างยิ่ง (Strictly Necessary)')}
</h4>
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200">
{t('cookie.alwaysActive', 'เปิดใช้งานเสมอ')}
</span>
</div>
<p className="text-xs text-muted-text mt-1 leading-relaxed">
{t(
'cookie.catNecessaryDesc',
'คุกกี้เหล่านี้มีความจำเป็นต่อการทำงานพื้นฐานของเว็บไซต์ เช่น การเข้าสู่ระบบ ความปลอดภัย การจดจำสินค้าในตะกร้า และการตั้งค่าภาษา เว็บไซต์ไม่สามารถทำงานได้อย่างถูกต้องหากไม่มีคุกกี้เหล่านี้'
)}
</p>
</div>
</div>
<div className="relative inline-flex items-center cursor-not-allowed opacity-80 shrink-0">
<input
type="checkbox"
checked={true}
disabled
className="sr-only peer"
/>
<div className="w-11 h-6 bg-brand-primary rounded-full peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"></div>
</div>
</div>
</div>
{/* 2. Analytics Cookies */}
<div className="pt-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 rounded-md bg-brand-cool/50 text-brand-primary mt-0.5">
<BarChart3 className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-semibold text-text-main">
{t('cookie.catAnalyticsTitle', 'คุกกี้เพื่อการวิเคราะห์และวัดผล (Analytics & Performance)')}
</h4>
<p className="text-xs text-muted-text mt-1 leading-relaxed">
{t(
'cookie.catAnalyticsDesc',
'ช่วยให้เราทราบสถิติการเข้าชม พฤติกรรมหน้าเว็บยอดนิยม และเส้นทางการใช้งานของผู้ใช้ เพื่อนำข้อมูลไปปรับปรุงประสิทธิภาพและบริการ (เช่น Google Analytics 4) ข้อมูลจะถูกจัดเก็บแบบไม่ระบุตัวตนโดยตรง'
)}
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0">
<input
type="checkbox"
checked={localPrefs.analytics}
onChange={(e) =>
setLocalPrefs((prev) => ({ ...prev, analytics: e.target.checked }))
}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-border-color peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-primary"></div>
</label>
</div>
</div>
{/* 3. Marketing Cookies */}
<div className="pt-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 rounded-md bg-brand-cool/50 text-brand-primary mt-0.5">
<Target className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-semibold text-text-main">
{t('cookie.catMarketingTitle', 'คุกกี้เพื่อการตลาดและโฆษณา (Marketing & Targeting)')}
</h4>
<p className="text-xs text-muted-text mt-1 leading-relaxed">
{t(
'cookie.catMarketingDesc',
'ใช้เพื่อนำเสนอโปรโมชัน โฆษณา และเนื้อหาที่สอดคล้องกับความสนใจของท่านผ่านแพลตฟอร์มพันธมิตร (เช่น Meta Pixel, TikTok Pixel, Google Ads) รวมถึงวัดประสิทธิผลของแคมเปญ'
)}
</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0">
<input
type="checkbox"
checked={localPrefs.marketing}
onChange={(e) =>
setLocalPrefs((prev) => ({ ...prev, marketing: e.target.checked }))
}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-border-color peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-brand-primary"></div>
</label>
</div>
</div>
</div>
{/* Footer / Buttons */}
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-3 p-4 sm:p-5 border-t border-border-color bg-surface-nav-rest">
<button
type="button"
onClick={rejectAll}
className="w-full sm:w-auto px-4 py-2 text-xs sm:text-sm font-medium rounded-lg text-muted-text hover:text-text-main hover:bg-surface-muted transition-colors text-center"
>
{t('cookie.btnRejectAll', 'ปฏิเสธทั้งหมด')}
</button>
<div className="flex items-center gap-2.5 w-full sm:w-auto">
<button
type="button"
onClick={handleSave}
className="flex-1 sm:flex-none inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs sm:text-sm font-medium rounded-lg border border-border-color text-text-main hover:bg-surface-muted hover:border-text-main/20 transition-all text-center"
>
<Check className="w-4 h-4 text-brand-primary" />
<span>{t('cookie.btnSavePreferences', 'บันทึกการตั้งค่า')}</span>
</button>
<button
type="button"
onClick={acceptAll}
className="flex-1 sm:flex-none px-5 py-2 text-xs sm:text-sm font-semibold rounded-lg bg-brand-primary text-white hover:bg-brand-primary-hover shadow-sm transition-all text-center"
>
{t('cookie.btnAcceptAll', 'ยอมรับทั้งหมด')}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,170 @@
"use client";
import { useEffect, useRef } from 'react';
import { useCookieConsentStore } from '../../store/cookieConsentStore';
declare global {
interface Window {
dataLayer?: any[];
gtag?: (...args: any[]) => void;
fbq?: any;
_fbq?: any;
ttq?: any;
}
}
export default function ScriptManager() {
const { record, isHydrated } = useCookieConsentStore();
const consentInitializedRef = useRef(false);
const gaInjectedRef = useRef(false);
const metaInjectedRef = useRef(false);
const tiktokInjectedRef = useRef(false);
// 1. Initialize Google Consent Mode v2 Defaults
useEffect(() => {
if (typeof window === 'undefined' || consentInitializedRef.current) return;
consentInitializedRef.current = true;
window.dataLayer = window.dataLayer || [];
if (!window.gtag) {
window.gtag = function () {
window.dataLayer?.push(arguments);
};
}
// Default to 'denied' as required by GDPR/PDPA prior to user choice
window.gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
});
}, []);
// 2. Sync consent updates whenever state changes
useEffect(() => {
if (!isHydrated || typeof window === 'undefined') return;
const { preferences, hasResponded } = record;
if (window.gtag) {
window.gtag('consent', 'update', {
analytics_storage: hasResponded && preferences.analytics ? 'granted' : 'denied',
ad_storage: hasResponded && preferences.marketing ? 'granted' : 'denied',
ad_user_data: hasResponded && preferences.marketing ? 'granted' : 'denied',
ad_personalization: hasResponded && preferences.marketing ? 'granted' : 'denied',
});
}
// Dispatch custom browser event for any other custom tags/scripts
window.dispatchEvent(
new CustomEvent('homaroma:consent-change', {
detail: {
hasResponded,
preferences,
},
})
);
// 3. Inject GA4 dynamically if enabled and configured
const gaId = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID;
if (gaId && hasResponded && preferences.analytics && !gaInjectedRef.current) {
gaInjectedRef.current = true;
const script = document.createElement('script');
script.src = `https://www.googletagmanager.com/gtag/js?id=${gaId}`;
script.async = true;
document.head.appendChild(script);
window.gtag?.('js', new Date());
window.gtag?.('config', gaId, {
anonymize_ip: true,
});
}
// 4. Inject Meta Pixel dynamically if enabled and configured
const metaPixelId = process.env.NEXT_PUBLIC_META_PIXEL_ID;
if (metaPixelId && hasResponded && preferences.marketing && !metaInjectedRef.current) {
metaInjectedRef.current = true;
(function (f: any, b: any, e: any, v: any, n?: any, t?: any, s?: any) {
if (f.fbq) return;
n = f.fbq = function () {
n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments);
};
if (!f._fbq) f._fbq = n;
n.push = n;
n.loaded = true;
n.version = '2.0';
n.queue = [];
t = b.createElement(e);
t.async = true;
t.src = v;
s = b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t, s);
})(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js');
window.fbq('init', metaPixelId);
window.fbq('track', 'PageView');
}
// 5. Inject TikTok Pixel dynamically if enabled and configured
const tiktokPixelId = process.env.NEXT_PUBLIC_TIKTOK_PIXEL_ID;
if (tiktokPixelId && hasResponded && preferences.marketing && !tiktokInjectedRef.current) {
tiktokInjectedRef.current = true;
(function (w: any, d: any, t: any) {
w.TiktokAnalyticsObject = t;
const ttq = (w[t] = w[t] || []);
ttq.methods = [
'page',
'track',
'identify',
'instances',
'debug',
'on',
'off',
'once',
'ready',
'alias',
'group',
'enableCookie',
'disableCookie',
];
ttq.setAndDefer = function (t: any, e: any) {
t[e] = function () {
t.push([e].concat(Array.prototype.slice.call(arguments, 0)));
};
};
for (let i = 0; i < ttq.methods.length; i++) {
ttq.setAndDefer(ttq, ttq.methods[i]);
}
ttq.instance = function (t: any) {
const e = ttq._i[t] || [];
for (let n = 0; n < ttq.methods.length; n++) {
ttq.setAndDefer(e, ttq.methods[n]);
}
return e;
};
ttq.load = function (e: any, n: any) {
const i = 'https://analytics.tiktok.com/i18n/pixel/events.js';
ttq._i = ttq._i || {};
ttq._i[e] = [];
ttq._i[e]._u = i;
ttq._t = ttq._t || {};
ttq._t[e] = +new Date();
ttq._o = ttq._o || {};
ttq._o[e] = n || {};
const o = d.createElement('script');
o.type = 'text/javascript';
o.async = true;
o.src = `${i}?sdkid=${e}&lib=${t}`;
const a = d.getElementsByTagName('script')[0];
a.parentNode.insertBefore(o, a);
};
})(window, document, 'ttq');
window.ttq?.load(tiktokPixelId);
window.ttq?.page();
}
}, [record, isHydrated]);
return null;
}
@@ -84,6 +84,7 @@ function MenuImage({
export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuProps) {
const [openId, setOpenId] = useState<string | null>(null)
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null)
const [activeTabByMenu, setActiveTabByMenu] = useState<Record<string, number>>({})
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Automatically close mega menu when route changes
@@ -128,10 +129,17 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
const hasColumns = !!item.columns?.length
const isActive = item.url ? currentPath === item.url : false
const visibleColumns = item.columns?.slice(0, 2) ?? []
const layout = item.layout || 'columns'
const isClassic = layout === 'classic'
const isTabbed = layout === 'tabbed'
const isVisualCards = layout === 'visual-cards'
const configuredCols = item.layoutConfig?.columnCount || (item.columns?.length ? Math.min(Math.max(item.columns.length, 2), 4) : 2)
const visibleColumns = item.columns?.slice(0, configuredCols) ?? []
const featuredLink = getFirstImageLink(item.columns)
const featuredConfig = item.featured
const showFeatured = featuredConfig?.enabled !== false
const showFeatured = (item.layoutConfig?.showFeatured !== false) && (featuredConfig?.enabled !== false) && !isClassic && !isVisualCards && !isTabbed && (
!!(featuredConfig?.imageUrl || featuredConfig?.title || featuredConfig?.sectionTitle || featuredLink?.imageUrl)
)
const configuredImage = featuredConfig?.imageUrl || null
const featuredImage = hoveredLinkImage ?? configuredImage ?? featuredLink?.imageUrl ?? item.rootImageUrl
const focalPoint = featuredConfig?.focalPoint
@@ -147,6 +155,28 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
const featuredHref = featuredConfig?.url || featuredLink?.url || item.url || '#'
const featuredCta = getLocalizedMenuText(featuredConfig?.ctaLabel, lang) || 'View details'
const getMegaContainerClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'w-[min(100vw-2rem,1260px)]'
if (cols === 3) return 'w-[min(100vw-2rem,1120px)]'
return 'w-[min(100vw-2rem,980px)]'
}
if (cols >= 4) return 'w-[min(100vw-2rem,1180px)]'
if (cols === 3) return 'w-[min(100vw-2rem,960px)]'
return 'w-[min(100vw-2rem,720px)]'
}
const getMegaGridColsClass = (cols: number, hasFeatured: boolean) => {
if (hasFeatured) {
if (cols >= 4) return 'grid-cols-[1fr_1fr_1fr_1fr_260px]'
if (cols === 3) return 'grid-cols-[1fr_1fr_1fr_270px]'
return 'grid-cols-[1fr_1fr_270px]'
}
if (cols >= 4) return 'grid-cols-4'
if (cols === 3) return 'grid-cols-3'
return 'grid-cols-2'
}
return (
<div
key={uniqueId}
@@ -176,9 +206,288 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
)}
</Link>
{hasColumns && (
{hasColumns && isClassic && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,980px)] -translate-x-1/2 transition-all duration-200 ${
className={`absolute left-0 top-full z-50 pt-2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="min-w-[230px] max-w-xs cursor-default overflow-hidden rounded-xl border border-line-primary/15 bg-surface-card/95 p-2 shadow-card backdrop-blur-xl">
{item.columns?.map((column, columnIndex) => (
<div key={columnIndex} className={columnIndex > 0 ? 'mt-2 border-t border-line-primary/10 pt-2' : ''}>
{getLocalizedMenuText(column.title, lang) && item.columns && item.columns.length > 1 && (
<h5 className="px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-muted-text">
{getLocalizedMenuText(column.title, lang)}
</h5>
)}
<ul className="space-y-0.5">
{column.links.map((link, linkIndex) => (
<li key={linkIndex}>
<Link
href={link.url || '#'}
onClick={handleLinkClick}
className="group flex items-center justify-between rounded-lg px-3 py-2 text-xs font-semibold normal-case tracking-normal text-text-main transition-colors hover:bg-surface-muted hover:text-brand-primary"
>
<span className="flex items-center gap-2.5">
{link.icon && (
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-4 w-4 shrink-0 text-brand-primary/80 transition-colors group-hover:text-brand-primary"
strokeWidth={1.5}
/>
)}
<span>{getLocalizedMenuText(link.label, lang)}</span>
</span>
{link.links?.length ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-text-main/30 transition-colors group-hover:text-brand-primary" />
) : null}
</Link>
{link.links && link.links.length > 0 && (
<ul className="ml-4 space-y-0.5 border-l border-line-primary/15 pl-2 my-1">
{link.links.map((child, childIdx) => (
<li key={childIdx}>
<Link
href={child.url || '#'}
onClick={handleLinkClick}
className="block rounded-md px-2 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-text transition-colors hover:bg-surface-muted hover:text-brand-primary"
>
{getLocalizedMenuText(child.label, lang)}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{hasColumns && isTabbed && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
style={{ top: 'var(--navbar-bottom, 64px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-xl border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl">
<div className="flex min-h-[380px] max-h-[500px]">
{/* Left Category Tabs Sidebar */}
<div className="w-64 shrink-0 border-r border-line-primary/15 bg-surface-page/50 p-3 space-y-1 overflow-y-auto">
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-muted-text">
{lang === 'th' ? 'หมวดหมู่หลัก' : 'Categories'}
</div>
{item.columns?.map((column, colIdx) => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0
const isSelected = activeIdx === colIdx
return (
<button
key={colIdx}
type="button"
onMouseEnter={() => {
setActiveTabByMenu(prev => ({ ...prev, [uniqueId]: colIdx }))
setHoveredLinkImage(null)
}}
className={`flex w-full items-center justify-between rounded-lg px-3.5 py-2.5 text-left transition-all ${
isSelected
? 'bg-brand-primary/10 text-brand-primary font-bold shadow-sm'
: 'text-text-main/80 hover:bg-surface-muted hover:text-text-main font-medium'
}`}
>
<span className="flex items-center gap-2.5 min-w-0">
<DynamicIcon
iconName={column.icon}
defaultIcon={Sparkles}
className={`h-4 w-4 shrink-0 ${isSelected ? 'text-brand-primary' : 'text-brand-primary/60'}`}
strokeWidth={1.5}
/>
<span className="truncate text-xs normal-case">{getLocalizedMenuText(column.title, lang)}</span>
</span>
<ChevronRight className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSelected ? 'text-brand-primary translate-x-0.5' : 'text-text-main/20'}`} />
</button>
)
})}
</div>
{/* Right Content Panel for Selected Tab */}
{(() => {
const activeIdx = activeTabByMenu[uniqueId] ?? 0
const activeCol = item.columns?.[activeIdx] || item.columns?.[0]
if (!activeCol) return null
return (
<div className="flex-1 p-6 flex flex-col justify-between overflow-y-auto">
<div>
<div className="flex items-center justify-between border-b border-line-primary/10 pb-3 mb-4">
<div className="flex items-center gap-2.5">
<DynamicIcon
iconName={activeCol.icon}
defaultIcon={Sparkles}
className="h-5 w-5 text-brand-primary"
strokeWidth={1.5}
/>
<h3 className="text-sm font-bold normal-case tracking-normal text-text-main">
{getLocalizedMenuText(activeCol.title, lang)}
</h3>
</div>
<Link
href={activeCol.links[0]?.url || item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 text-xs font-semibold normal-case text-brand-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูรายการทั้งหมด' : 'View all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{activeCol.links.map((link, lIdx) => (
<div
key={lIdx}
className="rounded-lg p-2.5 hover:bg-surface-muted transition-colors group"
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
onMouseLeave={() => setHoveredLinkImage(null)}
>
<Link
href={link.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-2.5"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-line-primary/20 bg-surface-page text-brand-primary group-hover:border-brand-primary/40">
<DynamicIcon
iconName={link.icon}
defaultIcon={Box}
className="h-3.5 w-3.5"
strokeWidth={1.35}
/>
</span>
<span className="truncate text-xs font-semibold normal-case text-text-main group-hover:text-brand-primary transition-colors">
{getLocalizedMenuText(link.label, lang)}
</span>
</Link>
{link.links && link.links.length > 0 && (
<div className="ml-9 mt-1.5 flex flex-wrap gap-1.5">
{link.links.slice(0, 3).map((child, cIdx) => (
<Link
key={cIdx}
href={child.url || '#'}
onClick={handleLinkClick}
className="text-[10px] text-muted-text hover:text-brand-primary transition-colors bg-surface-page/60 px-1.5 py-0.5 rounded border border-line-primary/10"
>
{getLocalizedMenuText(child.label, lang)}
</Link>
))}
</div>
)}
</div>
))}
</div>
</div>
<div className="border-t border-line-primary/10 pt-4 mt-6 flex items-center justify-between text-xs text-muted-text">
<span>{item.columns?.length || 0} {lang === 'th' ? 'หมวดหมู่ย่อย' : 'categories'}</span>
<Link
href={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1.5 font-semibold text-text-main hover:text-brand-primary transition-colors"
>
<span>{getLocalizedMenuText(item.label, lang)}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</div>
)
})()}
</div>
</div>
</div>
)}
{hasColumns && isVisualCards && (
<div
className={`fixed left-1/2 z-50 w-[min(100vw-2rem,1040px)] -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
style={{ top: 'var(--navbar-bottom, 64px)' }}
onMouseEnter={() => handleOpen(uniqueId)}
onMouseLeave={handleClose}
>
<div className="mx-auto h-3 w-20 overflow-hidden">
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-2xl border border-line-primary/15 bg-surface-card/95 p-6 shadow-card backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-text">
{getLocalizedMenuText(item.label, lang)}
</h4>
<Link
href={item.url || '#'}
onClick={handleLinkClick}
className="flex items-center gap-1 text-xs font-semibold text-brand-primary hover:underline"
>
<span>{lang === 'th' ? 'ดูทั้งหมด' : 'Explore all'}</span>
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{item.columns?.slice(0, 4).map((column, colIdx) => {
const cardImg = column.iconUrl || column.links?.[0]?.imageUrl || configuredImage || item.rootImageUrl
const cardHref = column.links?.[0]?.url || item.url || '#'
const cardTitle = getLocalizedMenuText(column.title, lang)
const firstSubLink = column.links?.[0]
const subText = firstSubLink ? getLocalizedMenuText(firstSubLink.label, lang) : null
return (
<Link
key={colIdx}
href={cardHref}
onClick={handleLinkClick}
className="group relative block overflow-hidden rounded-xl border border-line-primary/20 bg-surface-page transition-all hover:border-brand-primary/50 hover:shadow-lg"
>
<div className="relative aspect-[4/5] w-full overflow-hidden bg-surface-muted">
<MenuImage
src={cardImg}
alt={cardTitle}
placeholder={cardTitle}
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-4 space-y-1">
<span className="block text-sm font-bold text-white normal-case leading-snug drop-shadow-sm">
{cardTitle}
</span>
{subText && (
<span className="flex items-center justify-between text-[11px] font-normal text-white/80 normal-case">
<span className="truncate">{subText}</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0 text-white/80 transition-transform group-hover:translate-x-1" />
</span>
)}
</div>
</div>
</Link>
)
})}
</div>
</div>
</div>
)}
{hasColumns && !isClassic && !isTabbed && !isVisualCards && (
<div
className={`fixed left-1/2 z-50 ${getMegaContainerClass(visibleColumns.length, showFeatured)} -translate-x-1/2 transition-all duration-200 ${
isOpen ? 'pointer-events-auto translate-y-0 opacity-100' : 'pointer-events-none -translate-y-1 opacity-0'
}`}
style={{ top: 'var(--navbar-bottom, 64px)' }}
@@ -189,7 +498,7 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
<div className="mx-auto mt-2 h-5 w-5 rotate-45 rounded-sm border-l border-t border-line-primary/15 bg-surface-card/95" />
</div>
<div className="w-full cursor-default overflow-hidden rounded-lg border border-line-primary/15 bg-surface-card/95 shadow-card backdrop-blur-xl">
<div className={`grid gap-0 px-8 py-8 ${showFeatured ? 'grid-cols-[1fr_1fr_270px]' : 'grid-cols-2'}`}>
<div className={`grid gap-0 px-8 py-8 ${getMegaGridColsClass(visibleColumns.length, showFeatured)}`}>
{visibleColumns.map((column, columnIndex) => (
<div
key={columnIndex}
@@ -26,11 +26,21 @@ export interface MenuFeaturedCard {
ctaLabel?: Record<string, string> | string
}
export type SubmenuLayoutType = 'columns' | 'classic' | 'tabbed' | 'visual-cards'
export interface SubmenuLayoutConfig {
columnCount?: 2 | 3 | 4
showFeatured?: boolean
itemStyle?: 'icon' | 'thumbnail' | 'text-only'
}
export interface MegaMenuPayload {
id?: string
nodeId?: string
label: Record<string, string> | string
url?: string
layout?: SubmenuLayoutType
layoutConfig?: SubmenuLayoutConfig
columns?: MenuColumn[]
rootImageUrl?: string | null // รูปหลักฝั่งซ้ายสุด (1:1)
featured?: MenuFeaturedCard
+7 -7
View File
@@ -11,13 +11,13 @@ export type TranslationSchema = typeof thTranslation
const translations: Record<string, TranslationSchema> = {
th: thTranslation,
en: enTranslation as TranslationSchema,
ja: jaTranslation as TranslationSchema,
zh: zhTranslation as TranslationSchema,
vi: viTranslation as TranslationSchema,
ko: koTranslation as TranslationSchema,
lo: loTranslation as TranslationSchema,
it: itTranslation as TranslationSchema,
en: enTranslation as unknown as TranslationSchema,
ja: jaTranslation as unknown as TranslationSchema,
zh: zhTranslation as unknown as TranslationSchema,
vi: viTranslation as unknown as TranslationSchema,
ko: koTranslation as unknown as TranslationSchema,
lo: loTranslation as unknown as TranslationSchema,
it: itTranslation as unknown as TranslationSchema,
}
export function getServerTranslations(lang = 'th'): TranslationSchema {
@@ -216,6 +216,24 @@
"notFound": "Unable to verify order details",
"networkError": "Connection error. Please try again."
}
},
"cookie": {
"settings": "Cookie Preferences",
"bannerTitle": "We value your privacy",
"bannerDesc": "We use cookies to improve your browsing experience, provide personalized content, and analyze our website traffic in accordance with privacy laws. You can choose to accept all cookies or manage your preferences.",
"btnCustomize": "Preferences",
"btnRejectAll": "Reject Non-Essential",
"btnAcceptAll": "Accept All",
"btnSavePreferences": "Save Preferences",
"prefTitle": "Privacy & Cookie Preferences",
"prefSubtitle": "Manage your consent for cookie storage and data processing under privacy regulations (PDPA & GDPR)",
"alwaysActive": "Always Active",
"catNecessaryTitle": "Strictly Necessary Cookies",
"catNecessaryDesc": "These cookies are essential for the website to function properly, enabling basic functions like navigation, cart checkout, and access to secure areas. The website cannot function properly without these cookies.",
"catAnalyticsTitle": "Analytics & Performance Cookies",
"catAnalyticsDesc": "These cookies allow us to count visits and traffic sources so we can measure and improve the performance of our site (e.g., Google Analytics 4). All information is aggregated and anonymous.",
"catMarketingTitle": "Marketing & Targeting Cookies",
"catMarketingDesc": "These cookies may be set through our site by our advertising partners (e.g., Meta Pixel, TikTok Pixel, Google Ads) to build a profile of your interests and show you relevant adverts on other sites."
}
}
@@ -237,6 +237,24 @@
"notFound": "注文情報を確認できませんでした",
"networkError": "接続エラーが発生しました。もう一度お試しください。"
}
},
"cookie": {
"settings": "クッキー設定",
"bannerTitle": "お客様のプライバシーを尊重します",
"bannerDesc": "当ウェブサイトでは、利便性の向上、利用状況の分析、パーソナライズされた広告の提供のためにクッキーを使用しています。同意設定はいつでも変更できます。",
"btnCustomize": "設定",
"btnRejectAll": "必須以外を拒否",
"btnAcceptAll": "すべて同意",
"btnSavePreferences": "設定を保存",
"prefTitle": "プライバシーおよびクッキー設定",
"prefSubtitle": "個人情報保護法(PDPA/GDPR)に基づくクッキーの保存およびデータ利用の同意管理",
"alwaysActive": "常に有効",
"catNecessaryTitle": "必須クッキー (Strictly Necessary)",
"catNecessaryDesc": "ウェブサイトの基本的な機能(ログイン、セキュリティ、カート、言語設定など)に不可欠なクッキーです。このクッキーを無効にすることはできません。",
"catAnalyticsTitle": "分析・パフォーマンスクッキー (Analytics)",
"catAnalyticsDesc": "訪問者数やトラフィックソースを計測し、サイトのパフォーマンスを向上させるためのクッキーです(Google Analyticsなど)。",
"catMarketingTitle": "マーケティング・ターゲティングクッキー (Marketing)",
"catMarketingDesc": "お客様の興味関心に基づいた関連性の高い広告やプロモーションを配信するために使用されます(Meta Pixel、TikTok Pixelなど)。"
}
}
@@ -216,6 +216,24 @@
"notFound": "ไม่สามารถยืนยันข้อมูลคำสั่งซื้อได้",
"networkError": "เกิดข้อผิดพลาดในการเชื่อมต่อ กรุณาลองใหม่อีกครั้ง"
}
},
"cookie": {
"settings": "การตั้งค่าคุกกี้",
"bannerTitle": "เราให้ความสำคัญกับความเป็นส่วนตัวของคุณ",
"bannerDesc": "เราใช้คุกกี้เพื่อเพิ่มประสิทธิภาพ ประสบการณ์ที่ดีในการใช้งานเว็บไซต์ และเพื่อการวิเคราะห์ปรับปรุงบริการ ท่านสามารถเลือกตั้งค่าความยินยอมตามประเภทคุกกี้ได้",
"btnCustomize": "ตั้งค่าคุกกี้",
"btnRejectAll": "ปฏิเสธทั้งหมด",
"btnAcceptAll": "ยอมรับทั้งหมด",
"btnSavePreferences": "บันทึกการตั้งค่า",
"prefTitle": "ศูนย์การตั้งค่าความเป็นส่วนตัว",
"prefSubtitle": "จัดการความยินยอมในการจัดเก็บและใช้งานคุกกี้ตาม พ.ร.บ. คุ้มครองข้อมูลส่วนบุคคล (PDPA)",
"alwaysActive": "เปิดใช้งานเสมอ",
"catNecessaryTitle": "คุกกี้ที่จำเป็นอย่างยิ่ง (Strictly Necessary)",
"catNecessaryDesc": "คุกกี้เหล่านี้มีความจำเป็นต่อการทำงานพื้นฐานของเว็บไซต์ เช่น การเข้าสู่ระบบ ความปลอดภัย การจดจำสินค้าในตะกร้า และการตั้งค่าภาษา เว็บไซต์ไม่สามารถทำงานได้อย่างถูกต้องหากไม่มีคุกกี้เหล่านี้",
"catAnalyticsTitle": "คุกกี้เพื่อการวิเคราะห์และวัดผล (Analytics & Performance)",
"catAnalyticsDesc": "ช่วยให้เราทราบสถิติการเข้าชม พฤติกรรมหน้าเว็บยอดนิยม และเส้นทางการใช้งานของผู้ใช้ เพื่อนำข้อมูลไปปรับปรุงประสิทธิภาพและบริการ (เช่น Google Analytics 4) ข้อมูลจะถูกจัดเก็บแบบไม่ระบุตัวตนโดยตรง",
"catMarketingTitle": "คุกกี้เพื่อการตลาดและโฆษณา (Marketing & Targeting)",
"catMarketingDesc": "ใช้เพื่อนำเสนอโปรโมชัน โฆษณา และเนื้อหาที่สอดคล้องกับความสนใจของท่านผ่านแพลตฟอร์มพันธมิตร (เช่น Meta Pixel, TikTok Pixel, Google Ads) รวมถึงวัดประสิทธิผลของแคมเปญ"
}
}
@@ -237,6 +237,24 @@
"notFound": "无法验证订单信息",
"networkError": "网络连接错误,请稍后重试。"
}
},
"cookie": {
"settings": "Cookie 设置",
"bannerTitle": "我们重视您的隐私",
"bannerDesc": "我们使用 Cookie 来改善您的浏览体验、提供个性化内容以及根据隐私法规分析网站流量。您可以选择接受所有 Cookie 或自定义您的偏好设置。",
"btnCustomize": "偏好设置",
"btnRejectAll": "拒绝非必要",
"btnAcceptAll": "接受全部",
"btnSavePreferences": "保存设置",
"prefTitle": "隐私与 Cookie 偏好中心",
"prefSubtitle": "根据个人数据保护法规 (PDPA/GDPR) 管理 Cookie 存储及数据使用授权",
"alwaysActive": "始终启用",
"catNecessaryTitle": "必要 Cookie (Strictly Necessary)",
"catNecessaryDesc": "这些 Cookie 是网站运行所必需的,用于支持登录、安全性、购物车和语言设置等基本功能。网站无法在缺少此类 Cookie 的情况下正常运行。",
"catAnalyticsTitle": "分析与性能 Cookie (Analytics)",
"catAnalyticsDesc": "帮助我们统计访问量和流量来源,以评估并改善网站性能(例如 Google Analytics 4)。数据为匿名统计。",
"catMarketingTitle": "营销与广告 Cookie (Marketing)",
"catMarketingDesc": "由广告合作伙伴设置(例如 Meta Pixel、TikTok Pixel、Google Ads),用于构建您的兴趣画像并向您推送相关广告。"
}
}
@@ -0,0 +1,124 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const CURRENT_CONSENT_VERSION = '1.0';
export interface CookiePreferences {
necessary: boolean;
analytics: boolean;
marketing: boolean;
}
export interface CookieConsentRecord {
hasResponded: boolean;
version: string;
timestamp: string | null;
preferences: CookiePreferences;
}
interface CookieConsentState {
record: CookieConsentRecord;
isPreferencesOpen: boolean;
isHydrated: boolean;
// Actions
setHydrated: (hydrated: boolean) => void;
openPreferences: () => void;
closePreferences: () => void;
acceptAll: () => void;
rejectAll: () => void;
savePreferences: (preferences: Partial<CookiePreferences>) => void;
resetConsent: () => void;
}
const DEFAULT_RECORD: CookieConsentRecord = {
hasResponded: false,
version: CURRENT_CONSENT_VERSION,
timestamp: null,
preferences: {
necessary: true,
analytics: false,
marketing: false,
},
};
export const useCookieConsentStore = create<CookieConsentState>()(
persist(
(set) => ({
record: DEFAULT_RECORD,
isPreferencesOpen: false,
isHydrated: false,
setHydrated: (hydrated: boolean) => set({ isHydrated: hydrated }),
openPreferences: () => set({ isPreferencesOpen: true }),
closePreferences: () => set({ isPreferencesOpen: false }),
acceptAll: () => {
const now = new Date().toISOString();
set({
record: {
hasResponded: true,
version: CURRENT_CONSENT_VERSION,
timestamp: now,
preferences: {
necessary: true,
analytics: true,
marketing: true,
},
},
isPreferencesOpen: false,
});
},
rejectAll: () => {
const now = new Date().toISOString();
set({
record: {
hasResponded: true,
version: CURRENT_CONSENT_VERSION,
timestamp: now,
preferences: {
necessary: true,
analytics: false,
marketing: false,
},
},
isPreferencesOpen: false,
});
},
savePreferences: (userPrefs: Partial<CookiePreferences>) => {
const now = new Date().toISOString();
set((state) => ({
record: {
hasResponded: true,
version: CURRENT_CONSENT_VERSION,
timestamp: now,
preferences: {
necessary: true,
analytics: Boolean(userPrefs.analytics),
marketing: Boolean(userPrefs.marketing),
},
},
isPreferencesOpen: false,
}));
},
resetConsent: () => {
set({
record: DEFAULT_RECORD,
isPreferencesOpen: false,
});
},
}),
{
name: 'homaroma-cookie-consent',
partialize: (state) => ({ record: state.record }),
skipHydration: true,
onRehydrateStorage: () => (state) => {
state?.setHydrated(true);
},
}
)
);
+34
View File
@@ -13,6 +13,40 @@ See [Platform Overview](PLATFORM_OVERVIEW.md) for terminology.
- `PROJECT_SYSTEM_SUMMARY.md`
- `PROJECT_MANAGEMENT_PERSPECTIVE.md`
- `LLM_MCP_GUIDE.md`
- `ARCHITECTURE_DECISIONS.md`
- `CUSTOM_BLOCK_FLOW_GUIDE.md`
- `ADMIN_MCP_SECTION_GUIDE.md`
- `PRISMA7_GUIDE.md`
- `mcp_update.md`
- `implementation_plan.md`
- `frontend-comparison.md`
- `BUSINESS_ANALYTICS_MAPS_PLAN.md`
- `goal.section.md`
- `DEPLOYMENT_GUIDE.md`
- `DDC_DEPLOYMENT_RUNBOOK.md`
- `HUAHIN_DEPLOYMENT_RUNBOOK.md`
- `KITTISAK_DEPLOYMENT_RUNBOOK.md`
- `TENANT_PROVISIONING_POSTMORTEM_CHECKLIST.md`
- `PRODUCT_JSON_IMPORT_SCHEMA.md`
- `LLM_PRODUCT_JSON_GENERATOR_GUIDE.md`
- `LLM_PROMPT_1_WELLNESS_LUXURY.md`
- `LLM_PROMPT_2_BEAUTY_MULTIBRAND.md`
- `LLM_PROMPT_3_FASHION_LIFESTYLE.md`
- `LLM_PROMPT_4_MOTO_PARTS.md`
- `TEST_INFRA.md`
- `docs/notifications/order-confirmation.md`
- `docs/notifications/shipping-tracking.md`
- `docs/policies/privacy-policy.md`
- `docs/policies/return-policy.md`
- `docs/policies/shipping-policy.md`
- `docs/tenants/kittisak.com/README.md`
- `docs/tenants/kittisak.com/brand-identity.md`
- `docs/tenants/kittisak.com/integrations.md`
- `docs/tenants/kittisak.com/user-flows.md`
- `ORIGINAL_REQUEST.md`
- `RESUME_PORTFOLIO_MARKETING_DEV.md`
- `.agents/README.md`
- `.agents/ORIGINAL_REQUEST.md`
These documents now introduce DBK Platform before mentioning HomAroma and explain
the platform/tenant boundary.
+31 -39
View File
@@ -1,79 +1,71 @@
# สรุปความแตกต่างและเป้าหมายการใช้งาน: `apps/ssr` vs `apps/website`
# สรุปความแตกต่างและเป้าหมายการใช้งาน: `apps/spa` vs `apps/website` — DBK Platform
> **Platform architecture note:** Any HomAroma-specific routes, data, or branding cited below are reference-tenant examples.
> **Platform architecture note:** DBK Platform integrates two complementary frontend applications. HomAroma is the reference tenant; tenant-specific routes or branding cited below are reference examples. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
เอกสารนี้สรุปความแตกต่างเชิงสถาปัตยกรรม (Architecture) และจุดประสงค์การใช้งานระหว่าง Frontend ทั้งสองระบบในโปรเจกต์ HomAroma เพื่อให้ทีมพัฒนาเข้าใจว่าควรเลือกใช้หรือพัฒนาต่อยอดที่โปรเจกต์ใด
เอกสารนี้สรุปความแตกต่างเชิงสถาปัตยกรรม (Architecture) และจุดประสงค์การใช้งานระหว่าง Frontend ทั้งสองระบบใน DBK Platform เพื่อให้ทีมพัฒนาเข้าใจว่าควรเลือกใช้หรือพัฒนาต่อยอดที่โปรเจกต์ใด
## ตารางเปรียบเทียบ (Comparison Table)
| คุณสมบัติ (Feature) | `apps/website` (SPA) | `apps/ssr` (Next.js) |
| คุณสมบัติ (Feature) | `apps/spa` (Mobile / Social Commerce) | `apps/website` (Main Public Website) |
| :--- | :--- | :--- |
| **สถาปัตยกรรมพื้นฐาน** | Single Page Application (SPA) | Server-Side Rendering (SSR) |
| **เครื่องมือ / Framework**| React 19 + Vite | Next.js 15 (App Router) + React 19 |
| **เครื่องมือ / Framework**| React 19 + Vite + Tailwind CSS | Next.js 15 (App Router) + React 19 + Tailwind CSS |
| **การประมวลผล (Rendering)**| Client-side Rendering (ประมวลผลที่เบราว์เซอร์) | Server-side Rendering (ประมวลผลที่เซิร์ฟเวอร์ก่อนส่งให้ผู้ใช้) |
| **เป้าหมายหลัก (Use Case)** | **LINE OA / LIFF App**, Web App ที่เน้นความลื่นไหล | **SEO**, การค้นหาผ่าน Google, หน้าเว็บสาธารณะ |
| **จุดเด่น** | ทำงานลื่นไหลเหมือนแอปมือถือ (ไม่กระตุกตอนเปลี่ยนหน้า), เหมาะกับ Webview ในแอปพลิเคชัน | ทำ SEO ได้ดีมาก Bot สามารถอ่านข้อมูลสินค้าได้ทันที, โหลดหน้าแรกได้ไว |
| **จุดด้อย** | ทำ SEO ได้ยาก (Bot ของ Google อาจจะอ่านข้อมูลไม่ครบเพราะต้องรอ JavaScript ทำงาน) | ต้องใช้ทรัพยากรฝั่ง Server ในการรัน (Node.js Process), ซับซ้อนกว่าในการตั้งค่า |
| **รูปแบบการ Deploy** | **Static Files** (HTML/CSS/JS) เสิร์ฟผ่าน Nginx โดยตรง ไม่ต้องมี Node.js รันเป็นพื้นหลัง | **Node.js Server** ต้องใช้ PM2 รัน `next start` และใช้ Nginx ทำ Reverse Proxy |
| **ตำแหน่งไฟล์บน Server**| `/home/ti/projects/MyNginxMain/nginx/html/homaroma.shop/website` | `/home/ti/projects/homaroma-ssr` |
| **คำสั่ง Build** | `bun run build` (ได้โฟลเดอร์ `dist`) | `bun run build` (ได้โฟลเดอร์ `.next`) |
| **Service ที่ทำงาน**| ไม่มี (Nginx จัดการทั้งหมด) | **PM2** ชื่อ process: `homaroma-ssr` รันบนพอร์ต `3001` |
| **การจัดการ Routing** | `react-router-dom` (ประมวลผลที่ฝั่ง Client ทั้งหมด) | Next.js App Router (มีระบบ File-based routing) |
| **การจัดการ State** | Zustand | React Context / Zustand / Server Components |
| **ความเหมาะสมของระบบ Login**| เหมาะสมกับ **LINE LIFF Login** และ Token-based auth เพราะมี Context แบบ SPA | เหมาะกับ Cookie-based Auth (NextAuth/Auth.js) หรือ Session |
| **เป้าหมายหลัก (Use Case)** | **LINE OA / LIFF App**, Mobile-first commerce, Retention | **SEO**, การค้นหาผ่าน Google, หน้าเว็บแบรนด์สาธารณะ |
| **จุดเด่น** | ทำงานลื่นไหลเหมือนแอปมือถือ (ไม่กระตุกตอนเปลี่ยนหน้า), เหมาะกับ Webview ใน LINE | ทำ SEO ได้ดีมาก Bot ของ Search Engine อ่านข้อมูลสินค้าได้ทันที, โหลดหน้าแรกได้ไว |
| **จุดด้อย** | ทำ SEO ได้ยาก (Bot ของ Google อาจจะอ่านข้อมูลไม่ครบเพราะต้องรอ JavaScript) | ต้องใช้ทรัพยากรฝั่ง Server ในการรัน (Node.js Process), ซับซ้อนกว่าในการตั้งค่า |
| **รูปแบบการ Deploy** | **Static Files** (HTML/CSS/JS) เสิร์ฟผ่าน Nginx โดยตรง | **Node.js Server** รันด้วย PM2 / Bun และใช้ Nginx ทำ Reverse Proxy |
| **คำสั่ง Build** | `cd apps/spa && bun run build` (ได้โฟลเดอร์ `dist`) | `cd apps/website && bun run build` (ได้โฟลเดอร์ `.next`) |
| **การจัดการ Routing** | `react-router-dom` (ประมวลผลที่ฝั่ง Client ทั้งหมด) | Next.js App Router (File-based routing with Server Components) |
| **การจัดการ State** | Zustand | React Context / Zustand / Server State |
| **ความเหมาะสมของระบบ Auth**| เหมาะสมกับ **LINE LIFF Login** และ Token-based auth เพราะมี Context แบบ SPA | เหมาะกับ Cookie-based Auth / Server Session |
---
## สรุปแนวทางการพัฒนา (Development Guidelines)
### 1. เลือกใช้ `apps/website` เมื่อ:
- คุณต้องการพัฒนาฟีเจอร์ที่ให้ลูกค้าเปิดใช้งานผ่าน **LINE Official Account (LIFF Menu)**
### 1. เลือกใช้ `apps/spa` เมื่อ:
- คุณต้องการพัฒนาฟีเจอร์ที่ให้ลูกค้าเปิดใช้งานผ่าน **LINE Official Account (LIFF Menu)** หรือ Social Commerce
- เน้นประสบการณ์ผู้ใช้แบบ **App-like UX** ที่ต้องการความรวดเร็วในการกดเปลี่ยนหน้า เช่น ระบบตะกร้าสินค้าแบบ Popup หรือ Dashboard ส่วนตัวลูกค้า
- ไม่สนใจเรื่อง SEO (เช่น หน้าจัดการโปรไฟล์ลูกค้าส่วนตัว, หน้า Checkout)
- ไม่เน้นเรื่อง Public Search SEO (เช่น หน้าจัดการโปรไฟล์ลูกค้าส่วนตัว, หน้าสะสมแต้ม, หน้า Checkout)
### 2. เลือกใช้ `apps/ssr` เมื่อ:
### 2. เลือกใช้ `apps/website` เมื่อ:
- เป็นหน้าเพจที่ต้องการดึงดูดลูกค้าใหม่ (Acquisition) ผ่าน Search Engine (Google)
- หน้าสินค้า (Product Details), หมวดหมู่สินค้า (Category), หรือบทความ (Blog) ที่คุณอยากให้แชร์ลิงก์แล้วขึ้นภาพ Preview (Open Graph) ที่สวยงามบน Facebook/LINE
- เป็นหน้าหลัก (Landing Page) ที่เน้นความน่าเชื่อถือและต้องการโหลดให้เร็วที่สุดตั้งแต่ครั้งแรกที่เปิด
- หน้าสินค้า (Product Details), หมวดหมู่สินค้า (Category), หรือบทความ (Blog) ที่ต้องการแชร์ลิงก์แล้วมี Rich Snippets / Open Graph สวยงามบน Facebook/LINE/X
- เป็นหน้าหลัก (Landing Page) ที่เน้นความน่าเชื่อถือและต้องการโหลดให้เร็วที่สุดตั้งแต่ครั้งแรกที่เปิด (Core Web Vitals)
---
## ข้อมูลเชิงลึกด้านการ Build & Deploy (Server Config)
อ้างอิงจากการตั้งค่าในสคริปต์ `serverinfo/post-receive` บน Server ปัจจุบัน
### 📱 1. `apps/website` (Single Page Application)
### 📱 1. `apps/spa` (Single Page Application)
ระบบนี้ถูกคอมไพล์เป็นไฟล์ Static (HTML, CSS, JS) ซึ่งไม่มีการทำงานของ Server-side รันอยู่เบื้องหลัง
- **คำสั่ง Build:** `cd apps/website && bun run build`
- **คำสั่ง Build:** `cd apps/spa && bun run build`
- **สิ่งที่ได้จากการ Build:** โฟลเดอร์ `dist/`
- **ตำแหน่งไฟล์ปลายทาง (Target Directory):**
`/home/ti/projects/MyNginxMain/nginx/html/homaroma.shop/website`
- **การจัดการ Environment:** ต้องประกาศตัวแปรแวดล้อมก่อนสั่ง Build เสมอ (เช่น `export VITE_LIFF_ID=...`) เพื่อให้ Vite ฝังค่าเข้าไปในไฟล์โค้ด
- **การตั้งค่า Nginx ที่จำเป็น (Routing Configuration):**
เพื่อให้ระบบ React Router ทำงานได้โดยไม่เกิด 404 Error Nginx ต้องถูกตั้งค่าให้โยน Path ที่ไม่รู้จักกลับไปหา `index.html` เสมอ
```nginx
location / {
root /usr/share/nginx/html/homaroma.shop/website;
root /usr/share/nginx/html/tenant/spa;
index index.html;
try_files $uri $uri/ /index.html; # <--- บังคับให้ SPA จัดการ Route เอง
}
```
### 🌍 2. `apps/ssr` (Next.js Application)
ระบบนี้ต้องการ Node.js Server (รันผ่าน PM2) เพื่อประมวลผลข้อมูลที่ฝั่งเซิร์ฟเวอร์ทุกครั้งที่มีคนเปิดหน้าเว็บ (หรือประมวลผลไว้ล่วงหน้า)
- **คำสั่ง Build:** `cd apps/ssr && bun run build`
- **สิ่งที่ได้จากการ Build:** โฟลเดอร์ `.next/` และไฟล์ Source Code
- **ตำแหน่งไฟล์ปลายทาง (Target Directory):**
`/home/ti/projects/homaroma-ssr`
### 🌍 2. `apps/website` (Next.js Application)
ระบบนี้ต้องการ Server Runtime (Node.js หรือ Bun) เพื่อประมวลผลข้อมูลที่ฝั่งเซิร์ฟเวอร์
- **คำสั่ง Build:** `cd apps/website && bun run build`
- **สิ่งที่ได้จากการ Build:** โฟลเดอร์ `.next/`
- **การจัดการ Service (PM2):**
ต้องสั่งรันให้แอปทำงานอยู่เบื้องหลังเสมอด้วยคำสั่ง:
รันแอปเป็น background process เช่น:
```bash
pm2 start /home/ti/projects/homaroma-ssr/node_modules/.bin/next --name homaroma-ssr -- start -p 3001
pm2 start npm --name "website-ssr" -- start
```
- **การตั้งค่า Nginx ที่จำเป็น (Reverse Proxy):**
Nginx จะไม่ได้เสิร์ฟไฟล์ตรงๆ เหมือน SPA แต่ทำหน้าที่เป็นประตูส่ง Request ต่อไปให้ PM2 (ที่พอร์ต 3001)
Nginx ทำหน้าที่เป็น Reverse Proxy ส่ง Request ต่อไปให้ Next.js:
```nginx
location / {
proxy_pass http://127.0.0.1:3001;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@@ -83,4 +75,4 @@
```
---
*Generated by Antigravity AI on 2026-05-01*
*Updated for DBK Platform architecture standards*
+1 -1
View File
@@ -1,6 +1,6 @@
# ระบบ CMS Pages และ Section Block ของ DBK Platform
> **Platform scope:** HomAroma content blocks are reference-tenant examples, not a restriction on tenant verticals.
> **Platform scope:** HomAroma content blocks are reference-tenant examples, not a restriction on tenant verticals. See [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md).
## การทำงานของระบบ CMS Pages และ Section Block
File diff suppressed because it is too large. Load diff
+3
View File
@@ -143,6 +143,9 @@ export interface RawMaterial {
name: string
unit: string
pricePerUnit: number
bomItems?: BomItem[]
createdAt?: string
updatedAt?: string
}
export interface BomItem {
+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);
+157
View File
@@ -0,0 +1,157 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface RawMaterialRef {
id: number;
name: string;
pricePerUnit: number;
unit: string;
}
interface VariantPlan {
id: number;
sku: string;
option: string;
price: number;
hasBom: boolean;
totalBomCost: number;
}
interface ProductPlan {
index: number;
productId: number;
productName: string;
scentName: string;
category: string | null;
bomStatus: string;
configuredVariants: number;
totalVariants: number;
variants: VariantPlan[];
matchedFragrance: RawMaterialRef | null;
packagingReady: boolean;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const BOTTLE_MAP: Record<number, number> = {
30: 10, // ขวดพร้อมฝา Oilozone 30ml
60: 11, // ขวดพร้อมฝา Oilozone 60ml
85: 12, // ขวดพร้อมฝา Oilozone 85ml
120: 9, // ขวดพร้อมฝา Oilozone 120ml
};
const FORMULA_MAP: Record<number, { fragQty: number; dpgQty: number }> = {
30: { fragQty: 2.4, dpgQty: 27.6 },
60: { fragQty: 4.8, dpgQty: 55.2 },
85: { fragQty: 6.8, dpgQty: 78.2 },
120: { fragQty: 9.6, dpgQty: 110.4 },
};
function detectSize(variant: VariantPlan): number {
const opt = (variant.option || "").toLowerCase();
const sku = (variant.sku || "").toLowerCase();
if (opt.includes("120") || sku.includes("120ml")) return 120;
if (opt.includes("85") || sku.includes("85ml")) return 85;
if (opt.includes("60") || sku.includes("60ml")) return 60;
if (opt.includes("30") || sku.includes("30ml")) return 30;
throw new Error(`Cannot detect size for variant ${variant.id} (${variant.sku} / ${variant.option})`);
}
async function main() {
const jsonPath = path.resolve(process.cwd(), "oil_ozone_products.json");
if (!fs.existsSync(jsonPath)) {
throw new Error(`File not found: ${jsonPath}`);
}
const products: ProductPlan[] = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
console.log(`Loaded ${products.length} products from ${jsonPath}`);
let successCount = 0;
let skippedCount = 0;
let failedCount = 0;
for (const product of products) {
console.log(`\n--------------------------------------------------`);
console.log(`[${product.index}/${products.length}] Product #${product.productId}: ${product.productName}`);
if (!product.matchedFragrance) {
console.error(`❌ Skipped: No matching fragrance found for scent '${product.scentName}'`);
failedCount++;
continue;
}
if (product.bomStatus === "CONFIGURED" && product.configuredVariants === product.totalVariants) {
console.log(`⚡ Already configured (${product.configuredVariants}/${product.totalVariants} variants). Skipping.`);
skippedCount++;
continue;
}
// Build variants payload
const variantsPayload = product.variants.map((v) => {
const size = detectSize(v);
const formula = FORMULA_MAP[size];
const bottleId = BOTTLE_MAP[size];
return {
variantId: v.id,
sku: v.sku,
bomItems: [
{
rawMaterialId: product.matchedFragrance!.id,
name: product.matchedFragrance!.name,
quantity: formula.fragQty,
},
{
rawMaterialId: 2, // DPG
name: "DPG",
quantity: formula.dpgQty,
},
{
rawMaterialId: bottleId,
quantity: 1,
},
],
};
});
console.log(`Applying BOM for ${variantsPayload.length} variants with fragrance [ID ${product.matchedFragrance.id}] ${product.matchedFragrance.name}...`);
try {
const res = await fetch(`${PROD_API}/products/${product.productId}/bom`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ variants: variantsPayload }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`HTTP ${res.status}: ${errText}`);
}
const resData = (await res.json()) as any;
console.log(`✓ BOM saved successfully for #${product.productId}! Updated variants: ${resData.updatedVariants ?? variantsPayload.length}`);
successCount++;
} catch (err: any) {
console.error(`❌ Failed to update BOM for #${product.productId}: ${err.message}`);
failedCount++;
}
// Rate-limit pause
await sleep(200);
}
console.log(`\n==================================================`);
console.log(`BOM Application Summary:`);
console.log(` - Newly configured: ${successCount}`);
console.log(` - Already configured (skipped): ${skippedCount}`);
console.log(` - Failed: ${failedCount}`);
console.log(`==================================================\n`);
}
main().catch(console.error);
+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);
+169
View File
@@ -0,0 +1,169 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface RawMaterial {
id: number;
name: string;
unit: string;
pricePerUnit: number;
}
interface Variant {
id: number;
sku: string;
options: string;
price: number;
hasBom: boolean;
bomItemCount: number;
totalBomCost: number;
}
interface Product {
id: number;
name: string;
brand: string | null;
category: string | null;
totalVariants: number;
configuredVariants: number;
bomStatus: string;
variants: Variant[];
}
async function main() {
console.log("1. Fetching all products from production...");
const statusRes = await fetch(`${PROD_API}/products/bom-status?limit=250`);
if (!statusRes.ok) throw new Error(`Failed to fetch products: ${statusRes.statusText}`);
const statusData = (await statusRes.json()) as { products: Product[] };
// Filter only Oil Ozone products
const allProducts = statusData.products || [];
const oilOzoneProducts = allProducts.filter(
(p) =>
p.name.includes("Oil Ozone") ||
(p.category && p.category.includes("โอโซน")) ||
p.variants.some((v) => v.sku && v.sku.includes("-1-"))
);
console.log(`Found ${oilOzoneProducts.length} Oil Ozone products.`);
// 2. Fetch all raw materials from production
console.log("2. Fetching available raw materials from production...");
const rmRes = await fetch(`${PROD_API}/raw-materials`);
if (!rmRes.ok) throw new Error(`Failed to fetch raw materials: ${rmRes.statusText}`);
const rawMaterials = (await rmRes.json()) as RawMaterial[];
console.log(`Found ${rawMaterials.length} raw materials on production.`);
// Identify common raw materials
const dpg = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpg");
const b30 = rawMaterials.find((r) => r.name.includes("Oilozone 30ml") || r.name.includes("ขวดพร้อมฝา Oilozone 30ml"));
const b60 = rawMaterials.find((r) => r.name.includes("Oilozone 60ml") || r.name.includes("ขวดพร้อมฝา Oilozone 60ml"));
const b85 = rawMaterials.find((r) => r.name.includes("Oilozone 85ml") || r.name.includes("ขวดพร้อมฝา Oilozone 85ml"));
const b120 = rawMaterials.find((r) => r.name.includes("Oilozone 120ml") || r.name.includes("ขวดพร้อมฝา Oilozone 120ml"));
// Build match plan for each product
const productPlan = oilOzoneProducts.map((p, idx) => {
// Extract scent name e.g. "ซากุระ Oil Ozone" -> "ซากุระ"
const scentName = p.name.replace(/Oil Ozone/gi, "").trim();
// Look for matching fragrance oil in raw materials
const matchedFragrance = rawMaterials.find((rm) => {
const rmName = rm.name.trim();
return (
rmName === `หัวน้ำหอม กลิ่น${scentName}` ||
rmName === `หัวน้ำหอม ${scentName}` ||
rmName.includes(`กลิ่น${scentName}`) ||
(rmName.includes(scentName) && rmName.includes("หัวน้ำหอม"))
);
});
return {
index: idx + 1,
productId: p.id,
productName: p.name,
scentName,
category: p.category,
bomStatus: p.bomStatus,
configuredVariants: p.configuredVariants,
totalVariants: p.totalVariants,
variants: p.variants.map((v) => ({
id: v.id,
sku: v.sku,
option: v.options,
price: v.price,
hasBom: v.hasBom,
totalBomCost: v.totalBomCost,
marginPercent: v.price > 0 && v.totalBomCost > 0
? Number((((v.price - v.totalBomCost) / v.price) * 100).toFixed(1))
: null,
})),
matchedFragrance: matchedFragrance
? {
id: matchedFragrance.id,
name: matchedFragrance.name,
pricePerUnit: matchedFragrance.pricePerUnit,
unit: matchedFragrance.unit,
}
: null,
packagingReady: !!(dpg && b30 && b60 && b85 && b120),
};
});
// 3. Save JSON to local root folder
const jsonPath = path.resolve(process.cwd(), "oil_ozone_products.json");
fs.writeFileSync(jsonPath, JSON.stringify(productPlan, null, 2), "utf-8");
console.log(`\n✓ Saved JSON data to: ${jsonPath}`);
// 4. Generate Markdown Ledger
let md = `# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)\n\n`;
md += `> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)\n`;
md += `> **อัปเดตล่าสุด:** ${new Date().toLocaleString("th-TH")}\n`;
md += `> **จำนวนสินค้าทั้งหมด:** ${productPlan.length} รายการ\n\n`;
md += `## 📦 สรุปสถานะภาพรวม\n`;
const configured = productPlan.filter((p) => p.bomStatus === "CONFIGURED").length;
const missing = productPlan.filter((p) => p.bomStatus === "MISSING").length;
const partial = productPlan.filter((p) => p.bomStatus === "PARTIAL").length;
const fragranceFound = productPlan.filter((p) => p.matchedFragrance !== null).length;
md += `- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** ${configured} / ${productPlan.length} รายการ (100%)\n`;
md += `- **ยังไม่ได้ตั้งค่า (MISSING):** ${missing} รายการ\n`;
md += `- **ตั้งค่าบางส่วน (PARTIAL):** ${partial} รายการ\n`;
md += `- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** ${fragranceFound} / ${productPlan.length} รายการ\n\n`;
md += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)\n\n`;
md += `| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |\n`;
md += `| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |\n`;
for (const p of productPlan) {
const statusBadge =
p.bomStatus === "CONFIGURED"
? "✅ พร้อมขาย"
: p.bomStatus === "PARTIAL"
? "⚠️ บางส่วน"
: "❌ ขาด BOM";
const fragText = p.matchedFragrance
? `[ID: ${p.matchedFragrance.id}] ${p.matchedFragrance.name} (฿${p.matchedFragrance.pricePerUnit})`
: `<span style="color:red">⚠️ ไม่พบหัวน้ำหอม</span>`;
const v30 = p.variants.find((v) => (v.option || "").includes("30") || (v.sku || "").includes("30ML"));
const v60 = p.variants.find((v) => (v.option || "").includes("60") || (v.sku || "").includes("60ML"));
const v85 = p.variants.find((v) => (v.option || "").includes("85") || (v.sku || "").includes("85ML"));
const v120 = p.variants.find((v) => (v.option || "").includes("120") || (v.sku || "").includes("120ML"));
const formatCost = (v?: (typeof p.variants)[0]) => {
if (!v || !v.hasBom) return "-";
return `฿${v.totalBomCost.toFixed(2)} (${v.marginPercent}%)`;
};
md += `| ${p.index} | **#${p.productId}** | [${p.productName}](https://admin.homaroma.shop/products/${p.productId}/bom) | ${fragText} | ${formatCost(v30)} | ${formatCost(v60)} | ${formatCost(v85)} | ${formatCost(v120)} | ${statusBadge} |\n`;
}
const mdPath = path.resolve(process.cwd(), "OIL_OZONE_BOM_LEDGER.md");
fs.writeFileSync(mdPath, md, "utf-8");
console.log(`✓ Saved Markdown ledger to: ${mdPath}`);
}
main().catch(console.error);
+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);
+384
View File
@@ -0,0 +1,384 @@
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 "Oil Ozone")
const SCENT_ALIASES: Record<string, string> = {
// Peppermint
"เปปเปอร์มินต์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้นท์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้น": "เปเปอร์มิ้น",
"เปเปอร์มินต์": "เปเปอร์มิ้น",
"เปเปอร์​มิ้น​": "เปเปอร์มิ้น",
"เปเปอร์มิ้น": "เปเปอร์มิ้น",
"pepermint": "เปเปอร์มิ้น",
"peppermint": "เปเปอร์มิ้น",
// Lemongrass
"ตะไคร้บ้าน": "ตะไคร้",
"ตะไคร้": "ตะไคร้",
"ตะไตร้": "ตะไคร้",
"lemongrass": "ตะไคร้",
// Lavender
"ลา​เวนเดอร์​": "ลาเวนเดอร์",
"ลาเวนเดอร์": "ลาเวนเดอร์",
"lavender": "ลาเวนเดอร์",
"เฟรซ ลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"เฟรชลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"เฟรช ลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"fresh lavender": "เฟรชลาเวนเดอร์",
// Sandalwood
"แซนดัลวูด": "แซนด้าวูส",
"แซนด้าวูด": "แซนด้าวูส",
"แซนดัลวู้ด": "แซนด้าวูส",
"แซนด้าวูส": "แซนด้าวูส",
"sandalwood": "แซนด้าวูส",
// Rose
"กุหลาบแดง": "กุหลาบแดง",
"กุหลาบ​แดง": "กุหลาบแดง",
"กุหลาบ": "กุหลาบแดง",
"red rose": "กุหลาบแดง",
// Jasmine
"มะลิซ้อน": "มะลิ",
"ดอกมะลิ": "มะลิ",
"มะลิ": "มะลิ",
"jasmine": "มะลิ",
// Fruits
"ลูกพีช": "กลิ่นลูกพีช",
"พีช": "กลิ่นลูกพีช",
"peach": "กลิ่นลูกพีช",
"องุ่น": "กลิ่นองุ่น",
"grape": "กลิ่นองุ่น",
"น้ำนม": "กลิ่นน้ำนม",
"นม": "กลิ่นน้ำนม",
"milk": "กลิ่นน้ำนม",
"แคนตาลูป​": "แคนตาลูป",
"แคนตาลูป": "แคนตาลูป",
"cantaloupe": "แคนตาลูป",
"ส้ม": "ส้ม",
"orange": "ส้ม",
"มะกรูด": "มะกรูด",
"bergamot": "มะกรูด",
"ผลไม้รวม": "ผลไม้รวม",
"mixed fruit": "ผลไม้รวม",
"แอปเปิ้ล​เขียว": "แอปเปิ้ลเขียว",
"แอปเปิ้ลเขียว": "แอปเปิ้ลเขียว",
"green apple": "แอปเปิ้ลเขียว",
"แอปเปื้ลแดง": "แอปเปิ้ลแดง",
"แอปเปิ้ลแดง": "แอปเปิ้ลแดง",
"red apple": "แอปเปิ้ลแดง",
"มะนาว": "มะนาว",
"lemon": "มะนาว",
"lime": "มะนาว",
"ทับทิม": "ทับทิม",
"pomegranate": "ทับทิม",
"มะละกอสุก": "มะละกอสุก",
"มะละกอ": "มะละกอสุก",
"papaya": "มะละกอสุก",
"กล้วย": "กล้วย",
"banana": "กล้วย",
// Ocean / Ozone
"โอเชี่ยน": "โอเชี่ยน",
"มหาสมุทร": "โอเชี่ยน",
"ocean": "โอเชี่ยน",
"โอโซน": "โอโซน",
"ozone": "โอโซน",
// Thai Flowers
"ดอกราตรี": "ดอกราตรี",
"ราตรี": "ดอกราตรี",
"การเวก": "ดอกการะเวก",
"ดอกการะเวก": "ดอกการะเวก",
"ดอกอัญชัน": "อัญชัน",
"อัญชัน": "อัญชัน",
"butterfly pea": "อัญชัน",
"ดอกบัวหลวง": "ดอกบัว",
"ดอกบัว": "ดอกบัว",
"บัว": "ดอกบัว",
"lotus": "ดอกบัว",
"ดอกปีบ": "ดอกปีบ",
"ปีบ": "ดอกปีบ",
"ดอกโมก": "ดอกโมก",
"โมก": "ดอกโมก",
"ดอกแก้ว": "ดอกแก้ว",
"แก้ว": "ดอกแก้ว",
"ดอกจำปา": "ดอกจำปา",
"จำปา": "ดอกจำปา",
"ดอกพิกุล": "ดอกพิกุล",
"พิกุล": "ดอกพิกุล",
"ดอกพุด": "ดอกพุด",
"พุด": "ดอกพุด",
"ดอกชมนาด": "ดอกชมนาด",
"ชมนาด": "ดอกชมนาด",
"ดอกราชาวดี": "ดอกราชาวดี",
"ราชาวดี": "ดอกราชาวดี",
"ดอกลำดวน": "ดอกลำดวน",
"ลำดวน": "ดอกลำดวน",
"ลีลาวดี": "ลีลาวดี",
"frangipani": "ลีลาวดี",
"ลิลลี่": "ดอกลิลลี่",
"ดอกลิลลี่": "ดอกลิลลี่",
"lilly": "ดอกลิลลี่",
"lily": "ดอกลิลลี่",
// Herbs & Others
"ยูคาลิปตัส": "ยูคาลิปตัส",
"ยูคา": "ยูคาลิปตัส",
"eucalyptus": "ยูคาลิปตัส",
"วนิลา": "วานิลลา",
"วนิลลา": "วานิลลา",
"วานิลลา": "วานิลลา",
"vanilla": "วานิลลา",
"ซากุระ": "ซากุระ",
"sakura": "ซากุระ",
"เซเว่นซี": "เซเว่นซี",
"seven sea": "เซเว่นซี",
"7 sea": "เซเว่นซี",
"หมากฝรั่ง": "หมากฝรั่ง",
"bubble gum": "หมากฝรั่ง",
"ไทยสปา": "ไทยสปา",
"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 === "120ml") return "120ml";
if (s.includes("85ml") || s === "85ml") return "85ml";
if (s.includes("60ml") || s === "60ml") return "60ml";
if (s.includes("30ml") || s === "30ml") return "30ml";
if (s.includes("25ml") || s === "25ml") return "25ml";
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 (lower.includes(alias.toLowerCase())) {
return canonical;
}
}
return null;
}
async function main() {
console.log("== Shopee 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.`);
// Lookup map: `${canonicalScent}:::${normalizedSize}` -> MasterVariant
const masterLookup = new Map<string, MasterVariant>();
for (const v of variants) {
const cleanName = v.productName.replace(/Oil Ozone/gi, "").trim();
const size = v.optionsStr;
const key = `${cleanName}:::${size}`.toLowerCase();
masterLookup.set(key, v);
}
// 3. Fetch Shopee Marketplace Products
console.log("Fetching Shopee Marketplace Products...");
const pRes = await fetch(`${PROD_API}/tools/marketplace-products?channel=shopee&status=all&limit=400`);
const pData = (await pRes.json()) as { items: MarketplaceProduct[]; total: number };
const items = pData.items || [];
console.log(`Loaded ${items.length} Shopee products.`);
const matchedMappings: { marketplaceProductId: number; masterVariantId: number; meta: any }[] = [];
const heldItems: { id: number; name: string; variation: string; price: number; reason: string; detectedScent?: string | null; detectedSize?: string | null }[] = [];
for (const item of items) {
// If it's a 10ml product, hold it (Master Catalog does not support 10ml)
if (item.productName.toLowerCase().includes("10 ml") || (item.variation || "").includes("10 ml")) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
price: item.avgPrice,
reason: "ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml)",
detectedSize: "10ml",
});
continue;
}
const vStr = item.variation || "";
const pStr = item.productName || "";
let scent: string | null = null;
let size: string | null = null;
// Handle comma-separated variation e.g. 'ตะไคร้บ้าน,120 ml'
if (vStr.includes(",")) {
const parts = vStr.split(",");
for (const part of parts) {
if (!size) size = cleanSize(part);
if (!scent) scent = findScent(part);
}
}
if (!size) size = cleanSize(vStr);
if (!scent) scent = findScent(vStr);
if (!size) size = cleanSize(pStr);
if (!scent) scent = findScent(pStr);
if (!size) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
price: item.avgPrice,
reason: "ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้าหรือ Variation",
detectedScent: scent,
detectedSize: null,
});
continue;
}
if (!scent) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
price: item.avgPrice,
reason: "ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น เมลอน หรือกลิ่นที่ยังไม่มีในระบบ)",
detectedScent: null,
detectedSize: size,
});
continue;
}
const key = `${scent}:::${size}`.toLowerCase();
const master = masterLookup.get(key);
if (!master) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
price: item.avgPrice,
reason: `พบกลิ่น '${scent}' ขนาด '${size}' แต่ไม่มี Master Variant ขนาดนี้ในแคตตาล็อก`,
detectedScent: scent,
detectedSize: size,
});
continue;
}
matchedMappings.push({
marketplaceProductId: item.id,
masterVariantId: master.id,
meta: {
shopeeSku: item.marketplaceSku,
shopeeName: item.productName,
shopeeVar: item.variation,
masterSku: master.sku,
masterName: master.displayName,
scent,
size,
hasBom: master.bomCost > 0,
},
});
}
console.log(`\n========================================`);
console.log(`Total Shopee 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(`========================================\n`);
// 4. Execute Bulk Mapping on Production
if (matchedMappings.length > 0) {
console.log(`Applying ${matchedMappings.length} bulk mappings on production...`);
const bulkPayload = {
mappings: matchedMappings.map((m) => ({
marketplaceProductId: m.marketplaceProductId,
masterVariantId: m.masterVariantId,
})),
};
const mapRes = await fetch(`${PROD_API}/tools/marketplace-products/bulk-map`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bulkPayload),
});
const mapResult = await mapRes.json();
console.log("Bulk Map API Response:", mapResult);
}
// 5. Generate Markdown Report
let report = `# Shopee Marketplace Product Matching & Mapping Ledger\n\n`;
report += `* **Generated At:** ${new Date().toISOString()}\n`;
report += `* **Target Server:** \`${PROD_API}\` (Production)\n`;
report += `* **Total Shopee 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 += `## 1. Held / Unmapped Items (ต้องตัดสินใจเพิ่มเติม)\n\n`;
report += `| ID | ชื่อสินค้า Shopee | Variation | ราคาเฉลี่ย | สาเหตุที่ Hold |\n`;
report += `|:---|:---|:---|:---|:---|\n`;
for (const h of heldItems) {
report += `| ${h.id} | ${h.name.replace(/\|/g, "-")} | ${h.variation || "-"} | ฿${h.price.toFixed(2)} | ${h.reason} |\n`;
}
report += `\n## 2. Sample of Successfully Mapped Items (ตัวอย่างที่เชื่อมโยงสำเร็จ)\n\n`;
report += `| ID | Shopee SKU | กลิ่น | ขนาด | Master SKU | Master Variant | มี BOM หรือยัง |\n`;
report += `|:---|:---|:---|:---|:---|:---|:---|\n`;
for (const m of matchedMappings.slice(0, 30)) {
report += `| ${m.marketplaceProductId} | ${m.meta.shopeeSku || "-"} | ${m.meta.scent} | ${m.meta.size} | \`${m.meta.masterSku}\` | ${m.meta.masterName} | ${m.meta.hasBom ? "✓ มี BOM แล้ว" : "รอสร้าง BOM"} |\n`;
}
if (matchedMappings.length > 30) {
report += `| ... | ... | ... | ... | ... | *(แสดง 30 จากทั้งหมด ${matchedMappings.length} รายการ)* | ... |\n`;
}
const outPath = path.resolve(process.cwd(), "SHOPEE_MATCH_AND_MAP_LEDGER.md");
fs.writeFileSync(outPath, report, "utf8");
console.log(`\nSaved ledger report to: ${outPath}`);
}
main().catch(console.error);
+299
View File
@@ -0,0 +1,299 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface MasterVariant {
id: number;
sku: string;
barcode: string;
price: number;
stock: number;
optionsStr: string;
productName: string;
categoryName: string;
brandName: string;
bomCost: number;
displayName: string;
}
interface MarketplaceProduct {
id: number;
channel: string;
shop: string;
marketplaceSku: string;
productName: string;
variation: string;
avgPrice: number;
totalSoldQty: number;
totalRevenue: number;
firstSoldAt: string;
lastSoldAt: string;
masterVariantId: number | null;
}
// Scents standard dictionary & aliases
const SCENT_ALIASES: Record<string, string> = {
"เปปเปอร์มินต์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้นท์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้น": "เปเปอร์มิ้น",
"เปเปอร์มินต์": "เปเปอร์มิ้น",
"ตะไคร้บ้าน": "ตะไคร้",
"เฟรช ลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"เฟรชลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"แซนดัลวูด": "แซนด้าวูส",
"แซนด้าวูด": "แซนด้าวูส",
"แซนดัลวู้ด": "แซนด้าวูส",
"กุหลาบ": "กุหลาบแดง",
"มะลิซ้อน": "มะลิ",
"ลูกพีช": "กลิ่นลูกพีช",
"พีช": "กลิ่นลูกพีช",
"องุ่น": "กลิ่นองุ่น",
"น้ำนม": "กลิ่นน้ำนม",
"นม": "กลิ่นน้ำนม",
"มหาสมุทร": "โอเชี่ยน",
"ราตรี": "ดอกราตรี",
"การเวก": "ดอกการะเวก",
"ดอกอัญชัน": "อัญชัน",
"ดอกบัวหลวง": "ดอกบัว",
"ไม้หอม": "แซนด้าวูส",
};
// Size extractor helper
function extractSize(variation: string, productName: string): string | null {
const v = (variation || "").toLowerCase();
const p = (productName || "").toLowerCase();
// Variation has highest priority for size
if (v.includes("120") || v.includes("120ml")) return "120ml";
if (v.includes("85") || v.includes("85ml")) return "85ml";
if (v.includes("60") || v.includes("60ml")) return "60ml";
if (v.includes("30") || v.includes("30ml")) return "30ml";
// Fallback to product name
if (p.includes("120") || p.includes("120ml") || p.includes("ขวดใหญ่")) return "120ml";
if (p.includes("85") || p.includes("85ml")) return "85ml";
if (p.includes("60") || p.includes("60ml")) return "60ml";
if (p.includes("30") || p.includes("30ml") || p.includes("ขวดเล็ก")) return "30ml";
return null;
}
// Scent extractor helper
function extractScent(variation: string, productName: string, masterScents: string[]): string | null {
const cleanVar = (variation || "")
.replace(/\d+\s*ml/gi, "")
.replace(/,/g, "")
.replace(/^กลิ่น\s*/, "")
.trim();
// 1. Check variation aliases first
for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) {
if (cleanVar.includes(alias) || cleanVar === alias) {
return canonical;
}
}
// 2. Check direct master scents in variation
for (const scent of masterScents) {
if (cleanVar.includes(scent) || cleanVar === scent) {
return scent;
}
}
// 3. Fallback to product name aliases
for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) {
if (productName.includes(alias)) {
return canonical;
}
}
// 4. Fallback to direct master scents in product name
for (const scent of masterScents) {
if (productName.includes(`กลิ่น ${scent}`) || productName.includes(`กลิ่น${scent}`) || productName.includes(scent)) {
return scent;
}
}
return null;
}
async function main() {
console.log("1. Fetching master variants from production...");
const mvRes = await fetch(`${PROD_API}/tools/marketplace-products/master-variants`);
const mvData = (await mvRes.json()) as { variants: MasterVariant[] };
const masterVariants = mvData.variants || [];
console.log(`Loaded ${masterVariants.length} master variants.`);
// Filter only Oil Ozone master variants
const ozoneVariants = masterVariants.filter(
(v) => (v.categoryName && v.categoryName.includes("โอโซน")) || v.productName.includes("Oil Ozone")
);
console.log(`Found ${ozoneVariants.length} Oil Ozone master variants.`);
// Collect distinct scents
const masterScents = Array.from(
new Set(ozoneVariants.map((v) => v.productName.replace(/Oil Ozone/gi, "").trim()))
).filter(Boolean);
console.log(`Detected ${masterScents.length} distinct Oil Ozone scents.`);
// 2. Fetch unmapped TikTok products
console.log("\n2. Fetching unmapped TikTok products from production...");
const mpRes = await fetch(`${PROD_API}/tools/marketplace-products?channel=tiktok&status=unmapped&limit=350`);
const mpData = (await mpRes.json()) as { items: MarketplaceProduct[]; pagination: any };
const unmappedItems = mpData.items || [];
console.log(`Found ${unmappedItems.length} unmapped TikTok items (total in DB: ${mpData.pagination?.total}).`);
// 3. Test matching
const matches: Array<{
item: MarketplaceProduct;
matchedVariant: MasterVariant;
scent: string;
size: string;
confidence: "HIGH" | "MEDIUM";
}> = [];
const unmatchable: MarketplaceProduct[] = [];
for (const item of unmappedItems) {
const text = `${item.productName} ${item.variation || ""}`;
const isOilOzone =
text.includes("โอโซน") ||
text.includes("oil ozone") ||
text.includes("ออยโอโซน") ||
text.includes("น้ำมันหอมระเหย") ||
text.includes("น้ำหอมอโรม่า") ||
text.includes("น้ำมันอโรม่า") ||
text.includes("น้ำมันหอมไอน้ำ") ||
item.marketplaceSku.includes("OZO") ||
item.marketplaceSku.includes("OIL_") ||
item.marketplaceSku === "30-ALL";
if (!isOilOzone) {
unmatchable.push(item);
continue;
}
const size = extractSize(item.variation, item.productName);
const scent = extractScent(item.variation, item.productName, masterScents);
if (scent && size) {
// Find matching variant
const matched = ozoneVariants.find((v) => {
const vScent = v.productName.replace(/Oil Ozone/gi, "").trim();
const vSize = (v.optionsStr || "").toLowerCase();
return (
vScent === scent &&
(vSize.includes(size.toLowerCase()) || v.sku.toLowerCase().includes(size.toLowerCase()))
);
});
if (matched) {
matches.push({
item,
matchedVariant: matched,
scent,
size,
confidence: "HIGH",
});
} else {
unmatchable.push(item);
}
} else {
unmatchable.push(item);
}
}
console.log(`\n==================================================`);
console.log(`Matching Simulation Results:`);
console.log(` - Successfully matched: ${matches.length} items (${((matches.length / unmappedItems.length) * 100).toFixed(1)}%)`);
console.log(` - Unmatched / Non-ozone: ${unmatchable.length} items`);
console.log(`==================================================\n`);
console.log("Sample 15 matches:");
matches.slice(0, 15).forEach((m, idx) => {
console.log(
`[${idx + 1}] MP #${m.item.id} (${m.item.marketplaceSku || 'NO_SKU'}) | '${m.item.productName.slice(0, 35)}...' | Var: '${m.item.variation}'`
);
console.log(
` -> MATCHED: [Variant #${m.matchedVariant.id} | SKU: ${m.matchedVariant.sku}] ${m.matchedVariant.displayName} (BOM: ฿${m.matchedVariant.bomCost.toFixed(2)})`
);
});
// Save report to scratch file
const reportPath = path.resolve(process.cwd(), "scratch_tiktok_matches.json");
fs.writeFileSync(
reportPath,
JSON.stringify(
{
matchedCount: matches.length,
unmatchedCount: unmatchable.length,
matches: matches.map((m) => ({
marketplaceProductId: m.item.id,
marketplaceSku: m.item.marketplaceSku,
marketplaceName: m.item.productName,
marketplaceVariation: m.item.variation,
matchedVariantId: m.matchedVariant.id,
matchedVariantSku: m.matchedVariant.sku,
matchedVariantName: m.matchedVariant.displayName,
unitBomCost: m.matchedVariant.bomCost,
soldQty: m.item.totalSoldQty,
revenue: m.item.totalRevenue,
})),
unmatched: unmatchable.map((u) => ({
id: u.id,
sku: u.marketplaceSku,
name: u.productName,
variation: u.variation,
revenue: u.totalRevenue,
})),
},
null,
2
),
"utf-8"
);
console.log(`\nSaved match report to: ${reportPath}`);
// 4. Apply mappings if --apply flag is passed
const shouldApply = process.argv.includes("--apply");
if (!shouldApply) {
console.log(`\n💡 To apply all ${matches.length} mappings to production, run:`);
console.log(` bun scripts/match_and_map_tiktok_products.ts --apply\n`);
return;
}
console.log(`\n🚀 Applying ${matches.length} mappings to production...`);
let applied = 0;
let failed = 0;
const BATCH = 15;
for (let i = 0; i < matches.length; i += BATCH) {
const chunk = matches.slice(i, i + BATCH);
await Promise.all(
chunk.map(async (m) => {
try {
const res = await fetch(`${PROD_API}/tools/marketplace-products/map`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
marketplaceProductId: m.item.id,
masterVariantId: m.matchedVariant.id,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
applied++;
} catch (err: any) {
console.error(`Failed to map MP #${m.item.id}:`, err.message);
failed++;
}
})
);
process.stdout.write(`\rProgress: ${applied}/${matches.length} applied (${failed} failed)...`);
await new Promise((r) => setTimeout(r, 100));
}
console.log(`\n\n🎉 Finished! Successfully mapped: ${applied} | Failed: ${failed}`);
}
main().catch(console.error);
+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());
+210
View File
@@ -0,0 +1,210 @@
/**
* Gitea Automation CLI & API Client for DBK Platform / HomAroma
* Usage: bun tools/gitea.ts <command> [args]
*/
const GITEA_URL = process.env.GITEA_URL || 'https://git.kittisak.com';
const GITEA_API = process.env.GITEA_API_URL || `${GITEA_URL}/api/v1`;
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
const OWNER = process.env.GITEA_OWNER || 'admin';
const REPO = process.env.GITEA_REPO || 'homaroma';
if (!GITEA_TOKEN) {
console.error('Error: GITEA_TOKEN is not set. Please check .env.local');
process.exit(1);
}
const headers = {
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
async function api(path: string, options: RequestInit = {}) {
const url = `${GITEA_API}${path.startsWith('/') ? path : `/${path}`}`;
const res = await fetch(url, {
...options,
headers: { ...headers, ...(options.headers || {}) },
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(`Gitea API Error (${res.status} ${res.statusText}): ${errorText}`);
}
const contentType = res.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await res.json();
}
return await res.text();
}
async function main() {
const [,, cmd, ...args] = process.argv;
try {
switch (cmd) {
case 'whoami': {
const user = await api('/user');
console.log(`Logged in as: ${user.username} (${user.email}) [Admin: ${user.is_admin}]`);
break;
}
case 'repo': {
const repo = await api(`/repos/${OWNER}/${REPO}`);
console.log(`Repository: ${repo.full_name}`);
console.log(`URL: ${repo.html_url}`);
console.log(`Default Branch: ${repo.default_branch}`);
console.log(`Open Issues: ${repo.open_issues_count} | Open PRs: ${repo.open_pr_counter}`);
break;
}
case 'issues': {
const sub = args[0] || 'list';
if (sub === 'list') {
const state = args[1] || 'open';
const issues = await api(`/repos/${OWNER}/${REPO}/issues?state=${state}`);
if (issues.length === 0) {
console.log(`No ${state} issues found.`);
} else {
console.log(`Found ${issues.length} ${state} issues:`);
for (const iss of issues) {
console.log(` #${iss.number} [${iss.state.toUpperCase()}] ${iss.title} (${iss.html_url})`);
}
}
} else if (sub === 'create') {
const title = args[1];
const body = args[2] || '';
if (!title) {
console.error('Usage: bun tools/gitea.ts issues create <title> [body]');
process.exit(1);
}
const issue = await api(`/repos/${OWNER}/${REPO}/issues`, {
method: 'POST',
body: JSON.stringify({ title, body }),
});
console.log(`Created Issue #${issue.number}: ${issue.title}`);
console.log(`URL: ${issue.html_url}`);
}
break;
}
case 'prs': {
const sub = args[0] || 'list';
if (sub === 'list') {
const state = args[1] || 'open';
const prs = await api(`/repos/${OWNER}/${REPO}/pulls?state=${state}`);
if (prs.length === 0) {
console.log(`No ${state} pull requests found.`);
} else {
console.log(`Found ${prs.length} ${state} pull requests:`);
for (const pr of prs) {
console.log(` #${pr.number} [${pr.state.toUpperCase()}] ${pr.title} (${pr.head.ref} -> ${pr.base.ref})`);
console.log(` URL: ${pr.html_url}`);
}
}
} else if (sub === 'create') {
const [, head, base, title, ...bodyParts] = args;
const body = bodyParts.join(' ') || '';
if (!head || !base || !title) {
console.error('Usage: bun tools/gitea.ts prs create <head_branch> <base_branch> <title> [body]');
process.exit(1);
}
const pr = await api(`/repos/${OWNER}/${REPO}/pulls`, {
method: 'POST',
body: JSON.stringify({ head, base, title, body }),
});
console.log(`Created Pull Request #${pr.number}: ${pr.title}`);
console.log(`URL: ${pr.html_url}`);
} else if (sub === 'merge') {
const index = args[1];
const method = args[2] || 'merge'; // merge, rebase, rebase-merge, squash
if (!index) {
console.error('Usage: bun tools/gitea.ts prs merge <pr_number> [method]');
process.exit(1);
}
await api(`/repos/${OWNER}/${REPO}/pulls/${index}/merge`, {
method: 'POST',
body: JSON.stringify({ Do: method }),
});
console.log(`Merged Pull Request #${index} using method: ${method}`);
}
break;
}
case 'releases': {
const sub = args[0] || 'list';
if (sub === 'list') {
const releases = await api(`/repos/${OWNER}/${REPO}/releases`);
if (releases.length === 0) {
console.log('No releases found.');
} else {
for (const rel of releases) {
console.log(` ${rel.tag_name} - ${rel.name} (${rel.html_url})`);
}
}
} else if (sub === 'create') {
const tag_name = args[1];
const name = args[2] || tag_name;
const body = args[3] || '';
if (!tag_name) {
console.error('Usage: bun tools/gitea.ts releases create <tag_name> [title] [notes]');
process.exit(1);
}
const rel = await api(`/repos/${OWNER}/${REPO}/releases`, {
method: 'POST',
body: JSON.stringify({ tag_name, name, body }),
});
console.log(`Created Release ${rel.tag_name}: ${rel.name}`);
console.log(`URL: ${rel.html_url}`);
}
break;
}
case 'projects': {
const sub = args[0] || 'list';
if (sub === 'list') {
const projects = await api(`/repos/${OWNER}/${REPO}/projects`);
if (projects.length === 0) {
console.log('No projects found.');
} else {
console.log(`Found ${projects.length} projects:`);
for (const p of projects) {
console.log(` #${p.id} ${p.title}`);
}
}
} else if (sub === 'create') {
const title = args[1];
const description = args.slice(2).join(' ') || '';
if (!title) {
console.error('Usage: bun tools/gitea.ts projects create <title> [description]');
process.exit(1);
}
const project = await api(`/repos/${OWNER}/${REPO}/projects`, {
method: 'POST',
body: JSON.stringify({ title, description }),
});
console.log(`Created Project #${project.id}: ${project.title}`);
}
break;
}
default: {
console.log(`Gitea Helper CLI for ${OWNER}/${REPO}`);
console.log('Available commands:');
console.log(' bun tools/gitea.ts whoami');
console.log(' bun tools/gitea.ts repo');
console.log(' bun tools/gitea.ts projects [list|create]');
console.log(' bun tools/gitea.ts issues [list|create]');
console.log(' bun tools/gitea.ts prs [list|create]');
console.log(' bun tools/gitea.ts releases [list|create]');
break;
}
}
} catch (err: any) {
console.error(err.message || err);
process.exit(1);
}
}
main();