feat(bom): complete 100% master catalog BOM and add Profitability Management Suite

- Configured BOMs for all 585 master variants across 5 categories (Oil Ozone, Aroma Oil, Car Diffuser, Room Spray, Reed Diffuser)
- Mapped 2,111 marketplace products (Shopee and Lazada) to master catalog variants
- Added backend profitability routes (/api/profitability/matrix, /omnichannel, /mrp)
- Added 3 admin pages under Product menu: Cost & Profitability Matrix, Omnichannel Margin, and MRP & Material Demand
- Added comprehensive ledgers and production sync scripts
This commit is contained in:
Your Name committed 2026-10-18 18:58:11 +07:00
1 parent fdbbaea021
commit c78a898af7
21 files changed
+4336 -2

No files matched your search

+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);