- 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
213 lines
8.3 KiB
TypeScript
213 lines
8.3 KiB
TypeScript
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);
|