feat(bom): complete 100% master catalog BOM and add Profitability Management Suite
- Configured BOMs for all 585 master variants across 5 categories (Oil Ozone, Aroma Oil, Car Diffuser, Room Spray, Reed Diffuser) - Mapped 2,111 marketplace products (Shopee and Lazada) to master catalog variants - Added backend profitability routes (/api/profitability/matrix, /omnichannel, /mrp) - Added 3 admin pages under Product menu: Cost & Profitability Matrix, Omnichannel Margin, and MRP & Material Demand - Added comprehensive ledgers and production sync scripts
This commit is contained in:
1 parent
fdbbaea021
commit
c78a898af7
21 files changed
+4336
-2
No files matched your search
@@ -0,0 +1,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);
|
||||
Reference in new issue
Block a user