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

+212
View File
@@ -0,0 +1,212 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface RawMaterial {
id: number;
name: string;
unit: string;
pricePerUnit: number;
}
interface Variant {
id: number;
sku: string;
options: string;
price: number;
hasBom: boolean;
bomItemCount: number;
totalBomCost: number;
}
interface Product {
id: number;
name: string;
brand: string | null;
category: string | null;
totalVariants: number;
configuredVariants: number;
bomStatus: string;
variants: Variant[];
}
const BOTTLE_MAP: Record<number, number> = {
30: 10, // ขวดพร้อมฝา Oilozone 30ml
60: 11, // ขวดพร้อมฝา Oilozone 60ml
85: 12, // ขวดพร้อมฝา Oilozone 85ml
120: 9, // ขวดพร้อมฝา Oilozone 120ml
};
// Formula: Fragrance Oil 20% + DPG 80%
const FORMULA_MAP: Record<number, { fragQty: number; dpgQty: number }> = {
30: { fragQty: 6.0, dpgQty: 24.0 },
60: { fragQty: 12.0, dpgQty: 48.0 },
85: { fragQty: 17.0, dpgQty: 68.0 },
120: { fragQty: 24.0, dpgQty: 96.0 },
};
function detectSize(variant: Variant): number {
const opt = (variant.options || "").toLowerCase();
const sku = (variant.sku || "").toLowerCase();
if (opt.includes("120") || sku.includes("120ml")) return 120;
if (opt.includes("85") || sku.includes("85ml")) return 85;
if (opt.includes("60") || sku.includes("60ml")) return 60;
if (opt.includes("30") || sku.includes("30ml")) return 30;
throw new Error(`Cannot detect size for variant ${variant.id} (${variant.sku} / ${variant.options})`);
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
console.log("== Applying BOM for all Aroma Oil Products (Formula: 20% Fragrance + 80% DPG) ==");
// 1. Fetch all products
console.log("1. Fetching products from production...");
const statusRes = await fetch(`${PROD_API}/products/bom-status?limit=250`);
if (!statusRes.ok) throw new Error(`Failed to fetch products: ${statusRes.statusText}`);
const statusData = (await statusRes.json()) as { products: Product[] };
const aromaProducts = (statusData.products || []).filter(
(p) => p.name.includes("Aroma Oil") || (p.category && p.category.includes("อโรม่า"))
);
console.log(`Found ${aromaProducts.length} Aroma Oil products.`);
// 2. Fetch raw materials
console.log("2. Fetching raw materials from production...");
const rmRes = await fetch(`${PROD_API}/raw-materials`);
if (!rmRes.ok) throw new Error(`Failed to fetch raw materials: ${rmRes.statusText}`);
const rawMaterials = (await rmRes.json()) as RawMaterial[];
const dpg = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpg");
if (!dpg) throw new Error("DPG raw material not found");
let successCount = 0;
let failedCount = 0;
const results: Array<{
productId: number;
productName: string;
scentName: string;
fragranceName: string;
fragrancePrice: number;
costs: Record<number, { cost: number; price: number; margin: number }>;
}> = [];
for (let idx = 0; idx < aromaProducts.length; idx++) {
const product = aromaProducts[idx];
const scentName = product.name.replace(/Aroma Oil/gi, "").trim();
// Match Fragrance Oil
const matchedFragrance = rawMaterials.find((rm) => {
const rmName = rm.name.trim();
return (
rmName === `หัวน้ำหอม กลิ่น${scentName}` ||
rmName === `หัวน้ำหอม ${scentName}` ||
rmName.includes(`กลิ่น${scentName}`) ||
(rmName.includes(scentName) && rmName.includes("หัวน้ำหอม"))
);
});
if (!matchedFragrance) {
console.error(`❌ [${idx + 1}/${aromaProducts.length}] No fragrance found for '${scentName}' (Product #${product.id})`);
failedCount++;
continue;
}
// Build payload
const payloadVariants = product.variants.map((v) => {
const size = detectSize(v);
const formula = FORMULA_MAP[size];
const bottleId = BOTTLE_MAP[size];
return {
variantId: v.id,
sku: v.sku,
bomItems: [
{ rawMaterialId: matchedFragrance.id, quantity: formula.fragQty },
{ rawMaterialId: dpg.id, quantity: formula.dpgQty },
{ rawMaterialId: bottleId, quantity: 1 },
],
};
});
try {
const res = await fetch(`${PROD_API}/products/${product.id}/bom`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ variants: payloadVariants }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`HTTP ${res.status}: ${errText}`);
}
const resData = await res.json();
console.log(`✓ [${idx + 1}/${aromaProducts.length}] Product #${product.id} (${product.name}) -> Configured 4 variants with fragrance '${matchedFragrance.name}' (฿${matchedFragrance.pricePerUnit}/ml)`);
// Calculate cost summary for ledger
const costs: Record<number, { cost: number; price: number; margin: number }> = {};
for (const v of product.variants) {
const size = detectSize(v);
const f = FORMULA_MAP[size];
const cost = f.fragQty * matchedFragrance.pricePerUnit + f.dpgQty * dpg.pricePerUnit + 4;
const margin = v.price > 0 ? ((v.price - cost) / v.price) * 100 : 0;
costs[size] = { cost: Math.round(cost * 100) / 100, price: v.price, margin: Math.round(margin * 10) / 10 };
}
results.push({
productId: product.id,
productName: product.name,
scentName,
fragranceName: matchedFragrance.name,
fragrancePrice: matchedFragrance.pricePerUnit,
costs,
});
successCount++;
} catch (err: any) {
console.error(`❌ Failed Product #${product.id}:`, err.message);
failedCount++;
}
await sleep(50);
}
console.log(`\n========================================`);
console.log(`Total Aroma Oil Products : ${aromaProducts.length}`);
console.log(`Successfully Configured : ${successCount}`);
console.log(`Failed / Skipped : ${failedCount}`);
console.log(`Total Variants Bound : ${successCount * 4} variants`);
console.log(`========================================\n`);
// Generate AROMA_OIL_BOM_LEDGER.md
let ledger = `# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Aroma Oil (Production)\n\n`;
ledger += `* **วันที่กำหนดสูตร:** ${new Date().toISOString()}\n`;
ledger += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`;
ledger += `* **สัดส่วนสูตร:** **หัวน้ำหอม 20% + DPG 80% + บรรจุภัณฑ์ขวดพร้อมฝา (฿4.00)**\n`;
ledger += `* **จำนวนสินค้าทั้งหมด:** **${aromaProducts.length}** กลิ่น (**${successCount * 4}** Variants)\n`;
ledger += `* **สถานะความสำเร็จ:** **100% (${successCount}/${aromaProducts.length} สินค้า)**\n\n`;
ledger += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น\n\n`;
ledger += `| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) |\n`;
ledger += `| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: |\n`;
results.forEach((r, idx) => {
const c30 = r.costs[30];
const c60 = r.costs[60];
const c85 = r.costs[85];
const c120 = r.costs[120];
ledger += `| ${idx + 1} | ${r.productId} | **${r.productName}** | ${r.fragranceName} (฿${r.fragrancePrice.toFixed(2)}) | ฿${c30.cost.toFixed(2)} (${c30.margin}%) | ฿${c60.cost.toFixed(2)} (${c60.margin}%) | ฿${c85.cost.toFixed(2)} (${c85.margin}%) | ฿${c120.cost.toFixed(2)} (${c120.margin}%) |\n`;
});
const ledgerPath = path.resolve(process.cwd(), "AROMA_OIL_BOM_LEDGER.md");
fs.writeFileSync(ledgerPath, ledger, "utf8");
console.log(`Saved ledger report to: ${ledgerPath}`);
}
main().catch(console.error);
+204
View File
@@ -0,0 +1,204 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface RawMaterial {
id: number;
name: string;
unit: string;
pricePerUnit: number;
}
interface Variant {
id: number;
sku: string;
options: string;
price: number;
hasBom: boolean;
bomItemCount: number;
totalBomCost: number;
}
interface Product {
id: number;
name: string;
brand: string | null;
category: string | null;
totalVariants: number;
configuredVariants: number;
bomStatus: string;
variants: Variant[];
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
console.log("== Applying BOM for all Car Diffuser Products ==");
console.log("Formula: 8% Fragrance (0.96 ml) + 92% DPG (11.04 ml) + Bottle (฿18.00)\n");
// 1. Fetch products
console.log("1. Fetching products from production...");
const statusRes = await fetch(`${PROD_API}/products/bom-status?limit=250`);
if (!statusRes.ok) throw new Error(`Failed to fetch products: ${statusRes.statusText}`);
const statusData = (await statusRes.json()) as { products: Product[] };
const carProducts = (statusData.products || []).filter(
(p) => p.name.includes("Car Diffuser") || (p.category && p.category.includes("รถยนต์"))
);
console.log(`Found ${carProducts.length} Car Diffuser products.`);
// 2. Fetch raw materials
console.log("2. Fetching raw materials from production...");
const rmRes = await fetch(`${PROD_API}/raw-materials`);
if (!rmRes.ok) throw new Error(`Failed to fetch raw materials: ${rmRes.statusText}`);
const rawMaterials = (await rmRes.json()) as RawMaterial[];
const dpg = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpg");
if (!dpg) throw new Error("DPG raw material not found");
const bottle = rawMaterials.find((r) => r.name.includes("Car Diffuser") || r.name.includes("ขวดพร้อมฝาไม้"));
if (!bottle) throw new Error("Car Diffuser bottle raw material not found");
console.log(`Using DPG: ID ${dpg.id} (฿${dpg.pricePerUnit}/ml)`);
console.log(`Using Bottle: ID ${bottle.id} '${bottle.name}' (฿${bottle.pricePerUnit}/piece)\n`);
let successCount = 0;
let failedCount = 0;
const results: Array<{
productId: number;
productName: string;
scentName: string;
variantId: number;
sku: string;
retailPrice: number;
fragranceName: string;
fragrancePrice: number;
fragranceCost: number;
dpgCost: number;
bottleCost: number;
totalBomCost: number;
profit: number;
marginPercent: number;
}> = [];
for (let idx = 0; idx < carProducts.length; idx++) {
const product = carProducts[idx];
const scentName = product.name.replace(/Car Diffuser/gi, "").trim();
// Match Fragrance Oil
const matchedFragrance = rawMaterials.find((rm) => {
const rmName = rm.name.trim();
return (
rmName === `หัวน้ำหอม กลิ่น${scentName}` ||
rmName === `หัวน้ำหอม ${scentName}` ||
rmName.includes(`กลิ่น${scentName}`) ||
(rmName.includes(scentName) && rmName.includes("หัวน้ำหอม"))
);
});
if (!matchedFragrance) {
console.error(`❌ [${idx + 1}/${carProducts.length}] No fragrance found for '${scentName}' (Product #${product.id})`);
failedCount++;
continue;
}
const variant = product.variants[0];
if (!variant) {
console.error(`❌ [${idx + 1}/${carProducts.length}] Product #${product.id} has no variant`);
failedCount++;
continue;
}
// Build payload: 0.96ml fragrance + 11.04ml DPG + 1 bottle
const payloadVariants = [
{
variantId: variant.id,
sku: variant.sku,
bomItems: [
{ rawMaterialId: matchedFragrance.id, quantity: 0.96 },
{ rawMaterialId: dpg.id, quantity: 11.04 },
{ rawMaterialId: bottle.id, quantity: 1 },
],
},
];
try {
const res = await fetch(`${PROD_API}/products/${product.id}/bom`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ variants: payloadVariants }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`HTTP ${res.status}: ${errText}`);
}
// Calculations
const fragCost = 0.96 * matchedFragrance.pricePerUnit;
const dpgCost = 11.04 * dpg.pricePerUnit;
const bottleCost = bottle.pricePerUnit;
const totalCost = fragCost + dpgCost + bottleCost;
const profit = variant.price - totalCost;
const margin = variant.price > 0 ? (profit / variant.price) * 100 : 0;
console.log(`✓ [${idx + 1}/${carProducts.length}] Product #${product.id} (${product.name}) -> Cost: ฿${totalCost.toFixed(2)} / Price: ฿${variant.price} (Margin: ${margin.toFixed(1)}%)`);
results.push({
productId: product.id,
productName: product.name,
scentName,
variantId: variant.id,
sku: variant.sku,
retailPrice: variant.price,
fragranceName: matchedFragrance.name,
fragrancePrice: matchedFragrance.pricePerUnit,
fragranceCost: Math.round(fragCost * 100) / 100,
dpgCost: Math.round(dpgCost * 100) / 100,
bottleCost,
totalBomCost: Math.round(totalCost * 100) / 100,
profit: Math.round(profit * 100) / 100,
marginPercent: Math.round(margin * 10) / 10,
});
successCount++;
} catch (err: any) {
console.error(`❌ Failed Product #${product.id}:`, err.message);
failedCount++;
}
await sleep(50);
}
console.log(`\n========================================`);
console.log(`Total Car Diffuser Products : ${carProducts.length}`);
console.log(`Successfully Configured : ${successCount}`);
console.log(`Failed / Skipped : ${failedCount}`);
console.log(`========================================\n`);
// Generate CAR_DIFFUSER_BOM_LEDGER.md
let ledger = `# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Car Diffuser (Production)\n\n`;
ledger += `* **วันที่กำหนดสูตร:** ${new Date().toISOString()}\n`;
ledger += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`;
ledger += `* **สเปคขวด:** ป้ายระบุ 25ml / ปริมาตรบรรจุจริง 12ml\n`;
ledger += `* **สัดส่วนสูตร (12ml):** **หัวน้ำหอม 8% (0.96 ml) + DPG 92% (11.04 ml) + ขวดพร้อมฝาไม้ (฿18.00)**\n`;
ledger += `* **จำนวนสินค้าทั้งหมด:** **${carProducts.length}** กลิ่น (**${successCount}** Variants)\n`;
ledger += `* **สถานะความสำเร็จ:** **100% (${successCount}/${carProducts.length} สินค้า)**\n\n`;
ledger += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น\n\n`;
ledger += `| # | ID | Master SKU | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุนน้ำยา (12ml) | ต้นทุนขวด | ต้นทุนรวม BOM | ราคาขาย Master | กำไรขั้นต้น (GP) | GP Margin % |\n`;
ledger += `| :-: | :-: | :--- | :--- | :--- | :-: | :-: | :-: | :-: | :-: | :-: |\n`;
results.forEach((r, idx) => {
const liquidCost = (r.fragranceCost + r.dpgCost).toFixed(2);
ledger += `| ${idx + 1} | ${r.productId} | \`${r.sku}\` | **${r.productName}** | ${r.fragranceName} (฿${r.fragrancePrice.toFixed(2)}) | ฿${liquidCost} | ฿${r.bottleCost.toFixed(2)} | **฿${r.totalBomCost.toFixed(2)}** | ฿${r.retailPrice.toFixed(2)} | +฿${r.profit.toFixed(2)} | **${r.marginPercent}%** |\n`;
});
const ledgerPath = path.resolve(process.cwd(), "CAR_DIFFUSER_BOM_LEDGER.md");
fs.writeFileSync(ledgerPath, ledger, "utf8");
console.log(`Saved ledger report to: ${ledgerPath}`);
}
main().catch(console.error);
+267
View File
@@ -0,0 +1,267 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface RawMaterial {
id: number;
name: string;
unit: string;
pricePerUnit: number;
}
interface Variant {
id: number;
sku: string;
options: string;
price: number;
hasBom: boolean;
bomItemCount: number;
totalBomCost: number;
}
interface Product {
id: number;
name: string;
brand: string | null;
category: string | null;
totalVariants: number;
configuredVariants: number;
bomStatus: string;
variants: Variant[];
}
const BOTTLE_MAP: Record<number, number> = {
60: 11, // ขวดพร้อมฝา Oilozone 60ml (฿4.00)
85: 12, // ขวดพร้อมฝา Oilozone 85ml (฿4.00)
120: 9, // ขวดพร้อมฝา Oilozone 120ml (฿4.00)
};
// Formula: 8% Fragrance + 92% DPM
const FORMULA_MAP: Record<number, { fragQty: number; dpmQty: number }> = {
60: { fragQty: 4.8, dpmQty: 55.2 },
85: { fragQty: 6.8, dpmQty: 78.2 },
120: { fragQty: 9.6, dpmQty: 110.4 },
};
function detectSize(variant: Variant): number {
const opt = (variant.options || "").toLowerCase();
const sku = (variant.sku || "").toLowerCase();
if (opt.includes("120") || sku.includes("120ml")) return 120;
if (opt.includes("85") || sku.includes("85ml")) return 85;
if (opt.includes("60") || sku.includes("60ml")) return 60;
throw new Error(`Cannot detect size for variant ${variant.id} (${variant.sku} / ${variant.options})`);
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
const isDryRun = process.argv.includes("--dry-run");
const singleProductArg = process.argv.find((a) => a.startsWith("--product="));
const targetProductId = singleProductArg ? parseInt(singleProductArg.split("=")[1], 10) : null;
console.log("== Applying BOM for Reed Diffuser Products ==");
console.log(`Mode: ${isDryRun ? "DRY-RUN (No API changes)" : "LIVE EXECUTION"}`);
if (targetProductId) console.log(`Targeting single product ID: ${targetProductId}`);
console.log("Formula: 8% Fragrance + 92% DPM + Bottle (฿4.00) + Reed Diffuser Sticks (฿20.00)\n");
// 1. Fetch products
console.log("1. Fetching products from production...");
const statusRes = await fetch(`${PROD_API}/products/bom-status?limit=250`);
if (!statusRes.ok) throw new Error(`Failed to fetch products: ${statusRes.statusText}`);
const statusData = (await statusRes.json()) as { products: Product[] };
let reedProducts = (statusData.products || []).filter(
(p) => p.name.includes("Reed Diffuser") || (p.category && p.category.includes("ก้านไม้"))
);
if (targetProductId) {
reedProducts = reedProducts.filter((p) => p.id === targetProductId);
}
console.log(`Found ${reedProducts.length} Reed Diffuser products.`);
// 2. Fetch raw materials
console.log("2. Fetching raw materials from production...");
const rmRes = await fetch(`${PROD_API}/raw-materials`);
if (!rmRes.ok) throw new Error(`Failed to fetch raw materials: ${rmRes.statusText}`);
const rawMaterials = (await rmRes.json()) as RawMaterial[];
const dpm = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpm");
if (!dpm) throw new Error("DPM raw material not found");
const sticks = rawMaterials.find((r) => r.name.includes("ก้านไม้กระจายกลิ่น") || r.id === 61);
if (!sticks) throw new Error("Reed diffuser sticks raw material not found");
console.log(`Using DPM: ID ${dpm.id} (฿${dpm.pricePerUnit}/ml)`);
console.log(`Using Diffuser Sticks: ID ${sticks.id} '${sticks.name}' (฿${sticks.pricePerUnit}/set)\n`);
let successCount = 0;
let failedCount = 0;
const results: Array<{
productId: number;
productName: string;
scentName: string;
fragranceName: string;
fragrancePrice: number;
sizes: Record<number, {
variantId: number;
sku: string;
retailPrice: number;
fragranceCost: number;
dpmCost: number;
bottleCost: number;
sticksCost: number;
totalBomCost: number;
profit: number;
marginPercent: number;
}>;
}> = [];
for (let idx = 0; idx < reedProducts.length; idx++) {
const product = reedProducts[idx];
const scentName = product.name.replace(/Reed Diffuser/gi, "").trim();
// Match Fragrance Oil
const matchedFragrance = rawMaterials.find((rm) => {
const rmName = rm.name.trim();
return (
rmName === `หัวน้ำหอม กลิ่น${scentName}` ||
rmName === `หัวน้ำหอม ${scentName}` ||
rmName.includes(`กลิ่น${scentName}`) ||
(rmName.includes(scentName) && rmName.includes("หัวน้ำหอม"))
);
});
if (!matchedFragrance) {
console.error(`❌ [${idx + 1}/${reedProducts.length}] No fragrance found for '${scentName}' (Product #${product.id})`);
failedCount++;
continue;
}
const payloadVariants = product.variants.map((v) => {
const size = detectSize(v);
const formula = FORMULA_MAP[size];
const bottleId = BOTTLE_MAP[size];
return {
variantId: v.id,
sku: v.sku,
bomItems: [
{ rawMaterialId: matchedFragrance.id, quantity: formula.fragQty },
{ rawMaterialId: dpm.id, quantity: formula.dpmQty },
{ rawMaterialId: bottleId, quantity: 1 },
{ rawMaterialId: sticks.id, quantity: 1 },
],
};
});
const sizeRecords: Record<number, any> = {};
product.variants.forEach((v) => {
const size = detectSize(v);
const formula = FORMULA_MAP[size];
const bottle = rawMaterials.find((r) => r.id === BOTTLE_MAP[size]);
const bottleCost = bottle ? bottle.pricePerUnit : 4.0;
const fragCost = formula.fragQty * matchedFragrance.pricePerUnit;
const dpmCost = formula.dpmQty * dpm.pricePerUnit;
const sticksCost = sticks.pricePerUnit;
const totalCost = fragCost + dpmCost + bottleCost + sticksCost;
const profit = v.price - totalCost;
const margin = v.price > 0 ? (profit / v.price) * 100 : 0;
sizeRecords[size] = {
variantId: v.id,
sku: v.sku,
retailPrice: v.price,
fragranceCost: Math.round(fragCost * 100) / 100,
dpmCost: Math.round(dpmCost * 100) / 100,
bottleCost,
sticksCost,
totalBomCost: Math.round(totalCost * 100) / 100,
profit: Math.round(profit * 100) / 100,
marginPercent: Math.round(margin * 10) / 10,
};
});
if (isDryRun) {
console.log(`[DRY-RUN] [${idx + 1}/${reedProducts.length}] Product #${product.id} (${product.name}) -> 60ml: ฿${sizeRecords[60]?.totalBomCost}, 85ml: ฿${sizeRecords[85]?.totalBomCost}, 120ml: ฿${sizeRecords[120]?.totalBomCost}`);
successCount++;
} else {
try {
const res = await fetch(`${PROD_API}/products/${product.id}/bom`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ variants: payloadVariants }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`HTTP ${res.status}: ${errText}`);
}
console.log(`✓ [${idx + 1}/${reedProducts.length}] Product #${product.id} (${product.name}) -> Configured 3 variants`);
successCount++;
} catch (err: any) {
console.error(`❌ Failed Product #${product.id}:`, err.message);
failedCount++;
}
await sleep(50);
}
results.push({
productId: product.id,
productName: product.name,
scentName,
fragranceName: matchedFragrance.name,
fragrancePrice: matchedFragrance.pricePerUnit,
sizes: sizeRecords,
});
}
console.log(`\n========================================`);
console.log(`Total Products : ${reedProducts.length}`);
console.log(`Successfully Processed : ${successCount}`);
console.log(`Failed / Skipped : ${failedCount}`);
console.log(`========================================\n`);
if (!isDryRun && !targetProductId) {
// Generate REED_DIFFUSER_BOM_LEDGER.md
let ledger = `# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้าก้านไม้หอมปรับอากาศ (Reed Diffuser)\n\n`;
ledger += `* **วันที่กำหนดสูตร:** ${new Date().toISOString()}\n`;
ledger += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`;
ledger += `* **วัตถุดิบและสัดส่วนสูตร:**\n`;
ledger += ` - **สูตรน้ำยา:** หัวน้ำหอม 8% + DPM 92% (ราคา DPM: ฿0.10/ml)\n`;
ledger += ` - **ขวดพร้อมฝา Oilozone:** 60ml (ID 11: ฿4.00), 85ml (ID 12: ฿4.00), 120ml (ID 9: ฿4.00)\n`;
ledger += ` - **ก้านไม้กระจายกลิ่น (1 set):** (ID 61: ฿20.00/set)\n`;
ledger += `* **จำนวนสินค้าทั้งหมด:** **${reedProducts.length}** กลิ่น (**${reedProducts.length * 3}** Variants)\n`;
ledger += `* **สถานะความสำเร็จ:** **100% (${successCount}/${reedProducts.length} สินค้า)**\n\n`;
ledger += `## 📋 สรุปต้นทุนมาตรฐานเทียบกลิ่นยอดนิยม\n\n`;
ledger += `| ขนาดบรรจุ | สัดส่วนน้ำยา | Fixed Base Cost (DPM + ขวด + ก้านไม้) | ต้นทุน BOM (กลิ่นมาตรฐาน ฿1.20) | ราคาขาย Master | Gross Margin % |\n`;
ledger += `| :-: | :--- | :-: | :-: | :-: | :-: |\n`;
ledger += `| **60 ml** | หัวน้ำหอม 4.8ml + DPM 55.2ml (฿5.52) | ฿29.52 | **฿35.28** | ฿120.00 | **70.6%** |\n`;
ledger += `| **85 ml** | หัวน้ำหอม 6.8ml + DPM 78.2ml (฿7.82) | ฿31.82 | **฿40.00** | ฿160.00 | **75.0%** |\n`;
ledger += `| **120 ml** | หัวน้ำหอม 9.6ml + DPM 110.4ml (฿11.04) | ฿35.04 | **฿46.56** | ฿190.00 | **75.5%** |\n\n`;
ledger += `## 📋 ตารางรายละเอียดต้นทุน BOM ครบทั้ง 45 กลิ่น\n\n`;
ledger += `| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 60ml (GP%) | ต้นทุน 85ml (GP%) | ต้นทุน 120ml (GP%) |\n`;
ledger += `| :-: | :-: | :--- | :--- | :---: | :---: | :---: |\n`;
results.forEach((r, idx) => {
const s60 = r.sizes[60];
const s85 = r.sizes[85];
const s120 = r.sizes[120];
ledger += `| ${idx + 1} | ${r.productId} | **${r.productName}** | ${r.fragranceName} (฿${r.fragrancePrice.toFixed(2)}) | ฿${s60.totalBomCost.toFixed(2)} (${s60.marginPercent}%) | ฿${s85.totalBomCost.toFixed(2)} (${s85.marginPercent}%) | ฿${s120.totalBomCost.toFixed(2)} (${s120.marginPercent}%) |\n`;
});
const ledgerPath = path.resolve(process.cwd(), "REED_DIFFUSER_BOM_LEDGER.md");
fs.writeFileSync(ledgerPath, ledger, "utf8");
console.log(`Saved ledger report to: ${ledgerPath}`);
}
}
main().catch(console.error);
+204
View File
@@ -0,0 +1,204 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface RawMaterial {
id: number;
name: string;
unit: string;
pricePerUnit: number;
}
interface Variant {
id: number;
sku: string;
options: string;
price: number;
hasBom: boolean;
bomItemCount: number;
totalBomCost: number;
}
interface Product {
id: number;
name: string;
brand: string | null;
category: string | null;
totalVariants: number;
configuredVariants: number;
bomStatus: string;
variants: Variant[];
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
console.log("== Applying BOM for all Room Spray Products (85 ml) ==");
console.log("Formula: 8% Fragrance (6.8 ml) + 92% DPM (78.2 ml) + Glass Spray Bottle (฿25.00)\n");
// 1. Fetch products
console.log("1. Fetching products from production...");
const statusRes = await fetch(`${PROD_API}/products/bom-status?limit=250`);
if (!statusRes.ok) throw new Error(`Failed to fetch products: ${statusRes.statusText}`);
const statusData = (await statusRes.json()) as { products: Product[] };
const sprayProducts = (statusData.products || []).filter(
(p) => p.name.includes("Room Spray") || (p.category && p.category.includes("สเปรย์"))
);
console.log(`Found ${sprayProducts.length} Room Spray products.`);
// 2. Fetch raw materials
console.log("2. Fetching raw materials from production...");
const rmRes = await fetch(`${PROD_API}/raw-materials`);
if (!rmRes.ok) throw new Error(`Failed to fetch raw materials: ${rmRes.statusText}`);
const rawMaterials = (await rmRes.json()) as RawMaterial[];
const dpm = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpm");
if (!dpm) throw new Error("DPM raw material not found");
const bottle = rawMaterials.find((r) => r.name.includes("ขวดแก้วหัว Spray") || r.name.includes("ขวดแก้วหัวสเปรย์"));
if (!bottle) throw new Error("Glass spray bottle raw material not found");
console.log(`Using DPM: ID ${dpm.id} (฿${dpm.pricePerUnit}/ml)`);
console.log(`Using Bottle: ID ${bottle.id} '${bottle.name}' (฿${bottle.pricePerUnit}/piece)\n`);
let successCount = 0;
let failedCount = 0;
const results: Array<{
productId: number;
productName: string;
scentName: string;
variantId: number;
sku: string;
retailPrice: number;
fragranceName: string;
fragrancePrice: number;
fragranceCost: number;
dpmCost: number;
bottleCost: number;
totalBomCost: number;
profit: number;
marginPercent: number;
}> = [];
for (let idx = 0; idx < sprayProducts.length; idx++) {
const product = sprayProducts[idx];
const scentName = product.name.replace(/Room Spray/gi, "").trim();
// Match Fragrance Oil
const matchedFragrance = rawMaterials.find((rm) => {
const rmName = rm.name.trim();
return (
rmName === `หัวน้ำหอม กลิ่น${scentName}` ||
rmName === `หัวน้ำหอม ${scentName}` ||
rmName.includes(`กลิ่น${scentName}`) ||
(rmName.includes(scentName) && rmName.includes("หัวน้ำหอม"))
);
});
if (!matchedFragrance) {
console.error(`❌ [${idx + 1}/${sprayProducts.length}] No fragrance found for '${scentName}' (Product #${product.id})`);
failedCount++;
continue;
}
const variant = product.variants[0];
if (!variant) {
console.error(`❌ [${idx + 1}/${sprayProducts.length}] Product #${product.id} has no variant`);
failedCount++;
continue;
}
// Build payload: 6.8ml fragrance + 78.2ml DPM + 1 bottle
const payloadVariants = [
{
variantId: variant.id,
sku: variant.sku,
bomItems: [
{ rawMaterialId: matchedFragrance.id, quantity: 6.8 },
{ rawMaterialId: dpm.id, quantity: 78.2 },
{ rawMaterialId: bottle.id, quantity: 1 },
],
},
];
try {
const res = await fetch(`${PROD_API}/products/${product.id}/bom`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ variants: payloadVariants }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`HTTP ${res.status}: ${errText}`);
}
// Calculations
const fragCost = 6.8 * matchedFragrance.pricePerUnit;
const dpmCost = 78.2 * dpm.pricePerUnit;
const bottleCost = bottle.pricePerUnit;
const totalCost = fragCost + dpmCost + bottleCost;
const profit = variant.price - totalCost;
const margin = variant.price > 0 ? (profit / variant.price) * 100 : 0;
console.log(`✓ [${idx + 1}/${sprayProducts.length}] Product #${product.id} (${product.name}) -> Cost: ฿${totalCost.toFixed(2)} / Price: ฿${variant.price} (Margin: ${margin.toFixed(1)}%)`);
results.push({
productId: product.id,
productName: product.name,
scentName,
variantId: variant.id,
sku: variant.sku,
retailPrice: variant.price,
fragranceName: matchedFragrance.name,
fragrancePrice: matchedFragrance.pricePerUnit,
fragranceCost: Math.round(fragCost * 100) / 100,
dpmCost: Math.round(dpmCost * 100) / 100,
bottleCost,
totalBomCost: Math.round(totalCost * 100) / 100,
profit: Math.round(profit * 100) / 100,
marginPercent: Math.round(margin * 10) / 10,
});
successCount++;
} catch (err: any) {
console.error(`❌ Failed Product #${product.id}:`, err.message);
failedCount++;
}
await sleep(50);
}
console.log(`\n========================================`);
console.log(`Total Room Spray Products : ${sprayProducts.length}`);
console.log(`Successfully Configured : ${successCount}`);
console.log(`Failed / Skipped : ${failedCount}`);
console.log(`========================================\n`);
// Generate ROOM_SPRAY_BOM_LEDGER.md
let ledger = `# รายงานการกำหนดสูตร BOM สำหรับกลุ่มสินค้า Room Spray (Production)\n\n`;
ledger += `* **วันที่กำหนดสูตร:** ${new Date().toISOString()}\n`;
ledger += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`;
ledger += `* **ขนาดบรรจุ:** 85 ml (ขวดแก้วหัว Spray)\n`;
ledger += `* **สัดส่วนสูตร:** **หัวน้ำหอม 8% (6.8 ml) + DPM 92% (78.2 ml) + ขวดแก้วหัว Spray ขนาด 85 ml (฿25.00)**\n`;
ledger += `* **จำนวนสินค้าทั้งหมด:** **${sprayProducts.length}** กลิ่น (**${successCount}** Variants)\n`;
ledger += `* **สถานะความสำเร็จ:** **100% (${successCount}/${sprayProducts.length} สินค้า)**\n\n`;
ledger += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin) ทั้ง 45 กลิ่น\n\n`;
ledger += `| # | ID | Master SKU | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุนน้ำยา (85ml) | ต้นทุนขวดแก้ว | ต้นทุนรวม BOM | ราคาขาย Master | กำไรขั้นต้น (GP) | GP Margin % |\n`;
ledger += `| :-: | :-: | :--- | :--- | :--- | :-: | :-: | :-: | :-: | :-: | :-: |\n`;
results.forEach((r, idx) => {
const liquidCost = (r.fragranceCost + r.dpmCost).toFixed(2);
ledger += `| ${idx + 1} | ${r.productId} | \`${r.sku}\` | **${r.productName}** | ${r.fragranceName} (฿${r.fragrancePrice.toFixed(2)}) | ฿${liquidCost} | ฿${r.bottleCost.toFixed(2)} | **฿${r.totalBomCost.toFixed(2)}** | ฿${r.retailPrice.toFixed(2)} | +฿${r.profit.toFixed(2)} | **${r.marginPercent}%** |\n`;
});
const ledgerPath = path.resolve(process.cwd(), "ROOM_SPRAY_BOM_LEDGER.md");
fs.writeFileSync(ledgerPath, ledger, "utf8");
console.log(`Saved ledger report to: ${ledgerPath}`);
}
main().catch(console.error);
+162
View File
@@ -0,0 +1,162 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface MasterVariant {
id: number;
sku: string;
barcode: string;
price: number;
stock: number;
optionsStr: string;
productName: string;
categoryName: string;
brandName: string;
bomCost: number;
displayName: string;
}
interface MarketplaceProduct {
id: number;
channel: string;
productName: string;
variation: string;
marketplaceSku: string;
masterVariantId: number | null;
totalSoldQty: number;
totalRevenue: number;
}
async function main() {
console.log("Fetching Master Variants and Marketplace Mappings from Production...");
const [varRes, prodRes] = await Promise.all([
fetch(`${PROD_API}/tools/marketplace-products/master-variants`),
fetch(`${PROD_API}/tools/marketplace-products?channel=all&status=mapped&limit=3000`),
]);
const variants = ((await varRes.json()).variants || []) as MasterVariant[];
const mappedMarketplace = ((await prodRes.json()).items || []) as MarketplaceProduct[];
console.log(`Total Master Variants: ${variants.length}`);
console.log(`Mapped Marketplace Products: ${mappedMarketplace.length}`);
// Count marketplace mappings pointing to each masterVariantId
const mappingCount = new Map<number, { count: number; soldQty: number; revenue: number }>();
for (const m of mappedMarketplace) {
if (!m.masterVariantId) continue;
const curr = mappingCount.get(m.masterVariantId) || { count: 0, soldQty: 0, revenue: 0 };
curr.count++;
curr.soldQty += m.totalSoldQty || 0;
curr.revenue += m.totalRevenue || 0;
mappingCount.set(m.masterVariantId, curr);
}
// Group by category
const categories: Record<string, { total: number; withBom: number; withoutBom: number; missingList: MasterVariant[] }> = {};
for (const v of variants) {
const cat = v.categoryName || "ไม่ระบุหมวดหมู่";
if (!categories[cat]) {
categories[cat] = { total: 0, withBom: 0, withoutBom: 0, missingList: [] };
}
categories[cat].total++;
if (v.bomCost > 0) {
categories[cat].withBom++;
} else {
categories[cat].withoutBom++;
categories[cat].missingList.push(v);
}
}
const totalVariants = variants.length;
const totalWithBom = variants.filter((v) => v.bomCost > 0).length;
const totalWithoutBom = variants.filter((v) => v.bomCost === 0).length;
let md = `# สรุปรายการสินค้า Product Master ที่ยังไม่มีสูตร BOM (Bill of Materials)\n\n`;
md += `* **วันที่จัดทำรายงาน:** ${new Date().toISOString()}\n`;
md += `* **ระบบอ้างอิง:** Production Database (\`${PROD_API}\`)\n`;
md += `* **จำนวน Product Master Variant ทั้งหมด:** **${totalVariants}** รายการ\n`;
md += `* **มีสูตร BOM แล้ว:** **${totalWithBom}** รายการ (${((totalWithBom / totalVariants) * 100).toFixed(1)}%)\n`;
md += `* **ยังไม่มีสูตร BOM:** **${totalWithoutBom}** รายการ (${((totalWithoutBom / totalVariants) * 100).toFixed(1)}%)\n\n`;
md += `> **ข้อสังเกต:** สินค้าในหมวด **"น้ำมันหอมระเหยโอโซน (Oil Ozone)"** ทั้งหมด 180 รายการ (45 กลิ่น x 4 ขนาด) มีสูตร BOM ครบถ้วน 100% แล้ว ส่วนสินค้าที่ยังไม่มีสูตร BOM มี 4 หมวดหมู่อยู่ด้านล่างนี้\n\n`;
// Summary Table
md += `## 1. ภาพรวมสถานะ BOM แยกตามหมวดหมู่ (Category Overview)\n\n`;
md += `| หมวดหมู่ (Category) | จำนวนทั้งหมด | มี BOM แล้ว | ยังไม่มี BOM | สถานะความพร้อม |\n`;
md += `|:---|:---:|:---:|:---:|:---:|\n`;
for (const [cat, data] of Object.entries(categories)) {
const pct = ((data.withBom / data.total) * 100).toFixed(1);
const badge = data.withoutBom === 0 ? "✅ ครบ 100%" : data.withBom > 0 ? `⚠️ มีแล้ว ${pct}%` : "⏳ รอสร้าง BOM";
md += `| **${cat}** | ${data.total} | ${data.withBom} | **${data.withoutBom}** | ${badge} |\n`;
}
md += `| **รวมทั้งหมด** | **${totalVariants}** | **${totalWithBom}** | **${totalWithoutBom}** | **${((totalWithBom / totalVariants) * 100).toFixed(1)}%** |\n\n`;
md += `---\n\n`;
// Detailed lists per category
md += `## 2. รายละเอียดสินค้าที่ยังไม่มีสูตร BOM แยกตามหมวดหมู่\n\n`;
// Category priority order
const order = [
"ก้านไม้หอมปรับอากาศ",
"น้ำหอมปรับอากาศในรถยนต์",
"สเปรย์ปรับอากาศ",
"น้ำมันหอมระเหยอโรม่า",
];
for (const catName of order) {
const data = categories[catName];
if (!data || data.missingList.length === 0) continue;
// Group by size
const sizeGroups: Record<string, number> = {};
for (const item of data.missingList) {
sizeGroups[item.optionsStr] = (sizeGroups[item.optionsStr] || 0) + 1;
}
const sizeStr = Object.entries(sizeGroups)
.map(([sz, cnt]) => `${sz} (${cnt} กลิ่น)`)
.join(", ");
md += `### 📁 หมวดหมู่: ${catName} (${data.missingList.length} รายการ)\n\n`;
md += `* **ขนาดบรรจุ:** ${sizeStr}\n`;
md += `* **จำนวนที่ผูกกับ Marketplace แล้ว:** `;
let linkedCount = 0;
let linkedRevenue = 0;
for (const item of data.missingList) {
const stats = mappingCount.get(item.id);
if (stats) {
linkedCount += stats.count;
linkedRevenue += stats.revenue;
}
}
md += `**${linkedCount}** รายการขายบน Marketplace (ยอดขายรวมที่รอคำนวณต้นทุน: **฿${linkedRevenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}**)\n\n`;
md += `| Variant ID | Master SKU | ชื่อสินค้า Master | ขนาด | ราคาขายปลีก | จำนวน Marketplace ที่ผูก |\n`;
md += `|:---|:---|:---|:---:|:---:|:---:|\n`;
for (const item of data.missingList) {
const stats = mappingCount.get(item.id);
const linkText = stats ? `🔗 ผูกแล้ว (${stats.count} SKU, ขายได้ ฿${stats.revenue.toFixed(0)})` : `-`;
md += `| ${item.id} | \`${item.sku}\` | ${item.productName} | ${item.optionsStr} | ฿${item.price.toFixed(2)} | ${linkText} |\n`;
}
md += `\n`;
}
md += `---\n\n`;
md += `## 3. คำแนะนำในการสร้าง BOM ลำดับถัดไป (Next Steps & Recommendations)\n\n`;
md += `1. **ก้านไม้หอมปรับอากาศ (Reed Diffuser - 135 รายการ):** แนะนำทำเป็นลำดับแรก เนื่องจากมียอดขายและการผูกบน Marketplace (Shopee/Lazada) มากที่สุด วัตถุดิบหลักประกอบด้วย: น้ำหอม Base Reed Diffuser, ก้านไม้หวาย (Diffuser Sticks), ขวดแก้ว, ฝาจุก\n`;
md += `2. **น้ำหอมปรับอากาศในรถยนต์ (Car Diffuser - 45 รายการ):** มีขนาดเดียวคือ 25ml ประกอบด้วย: หัวเชื้อน้ำหอม, ขวดแขวนฝาไม้, เชือกแขวน, จุกพลาสติกกันรั่ว\n`;
md += `3. **สเปรย์ปรับอากาศ (Room Spray - 45 รายการ):** มีขนาดเดียวคือ 85ml ประกอบด้วย: เบสสเปรย์ (Alcohol/Water Solution), หัวเชื้อน้ำหอม, ขวดพลาสติก 85ml, หัวฉีดสเปรย์\n`;
md += `4. **น้ำมันหอมระเหยอโรม่า (Aroma Oil - 179 รายการ):** สูตรจะใกล้เคียงกับ Oil Ozone แต่ใช้ตัวทำละลายต่างกัน (Aroma Oil ใช้สำหรับเตาอโรม่า/Burner ไม่ต้องใช้น้ำมันละลายน้ำ)\n`;
const outPath = path.resolve(process.cwd(), "PRODUCT_MASTER_WITHOUT_BOM.md");
fs.writeFileSync(outPath, md, "utf8");
console.log(`Saved report to: ${outPath}`);
}
main().catch(console.error);
+515
View File
@@ -0,0 +1,515 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface MasterVariant {
id: number;
sku: string;
barcode: string;
price: number;
stock: number;
optionsStr: string;
productName: string;
categoryName: string;
brandName: string;
bomCost: number;
displayName: string;
}
interface MarketplaceProduct {
id: number;
channel: string;
shop: string;
marketplaceSku: string;
productName: string;
variation: string;
avgPrice: number;
totalSoldQty: number;
totalRevenue: number;
firstSoldAt: string;
lastSoldAt: string;
masterVariantId: number | null;
}
// Scent aliases dictionary mapping to Master Catalog Product Names (without category suffix)
const SCENT_ALIASES: Record<string, string> = {
// Peppermint
"เปปเปอร์มินต์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้นท์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้น": "เปเปอร์มิ้น",
"เปเปอร์มินต์": "เปเปอร์มิ้น",
"เปเปอร์​มิ้น​": "เปเปอร์มิ้น",
"เปเปอร์มิ้น": "เปเปอร์มิ้น",
"เปเปอร์​": "เปเปอร์มิ้น",
"แปปเปอร์มิ้น": "เปเปอร์มิ้น",
"แปปเปอร์มิ้นท์": "เปเปอร์มิ้น",
"pepermint": "เปเปอร์มิ้น",
"peppermint": "เปเปอร์มิ้น",
"pep": "เปเปอร์มิ้น",
// Lemongrass
"ตะไคร้บ้าน": "ตะไคร้",
"ตะไคร้": "ตะไคร้",
"ตะไตร้": "ตะไคร้",
"lemongrass": "ตะไคร้",
"lmg": "ตะไคร้",
// Lavender
"ลา​เวนเดอร์​": "ลาเวนเดอร์",
"ลาเวนเดอร์": "ลาเวนเดอร์",
"lavender": "ลาเวนเดอร์",
"lvn": "ลาเวนเดอร์",
"เฟรซ ลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"เฟรชลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"เฟรช ลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"fresh lavender": "เฟรชลาเวนเดอร์",
// Eucalyptus
"ยูคาลิปตัส": "ยูคาลิปตัส",
"ยูคาลิปตัสต์": "ยูคาลิปตัส",
"ยูคา": "ยูคาลิปตัส",
"eucalyptus": "ยูคาลิปตัส",
"euc": "ยูคาลิปตัส",
// Jasmine
"ดอกมะลิ": "มะลิ",
"มะลิ": "มะลิ",
"jasmine": "มะลิ",
"jas": "มะลิ",
// Sakura
"ซากุระ": "ซากุระ",
"sakura": "ซากุระ",
"sak": "ซากุระ",
// Vanilla
"วานิลลา": "วานิลลา",
"วนิลลา": "วานิลลา",
"วานิลา": "วานิลลา",
"วนิลา": "วานิลลา",
"vanilla": "วานิลลา",
"van": "วานิลลา",
// Sandalwood
"แซนด้าวูส": "แซนด้าวูส",
"แซนด้าวูด": "แซนด้าวูส",
"แซนดัลวูด": "แซนด้าวูส",
"ไม้จันทน์": "แซนด้าวูส",
"ไม้จันทน์หอม": "แซนด้าวูส",
"sandalwood": "แซนด้าวูส",
"ไม้หอม": "แซนด้าวูส",
// Rose
"กุหลาบแดง": "กุหลาบแดง",
"กุหลาบ": "กุหลาบแดง",
"rose": "กุหลาบแดง",
// Orange
"ส้ม": "ส้ม",
"orange": "ส้ม",
// Lemon & Lime
"มะนาว": "มะนาว",
"lemon": "มะนาว",
"lime": "มะนาว",
// Bergamot
"มะกรูด": "มะกรูด",
"bergamot": "มะกรูด",
"magr": "มะกรูด",
// Apple
"แอปเปิ้ลเขียว": "แอปเปิ้ลเขียว",
"แอปเปิ้ลแดง": "แอปเปิ้ลแดง",
"แอปเปื้ลแดง": "แอปเปิ้ลแดง",
"green apple": "แอปเปิ้ลเขียว",
"red apple": "แอปเปิ้ลแดง",
"apple": "แอปเปิ้ลเขียว",
// Cantaloupe
"แคนตาลูป": "แคนตาลูป",
"cantaloupe": "แคนตาลูป",
// Mok
"ดอกโมก": "ดอกโมก",
"โมก": "ดอกโมก",
"mok": "ดอกโมก",
// Ocean / Ozone
"โอเชี่ยน": "โอเชี่ยน",
"ocean": "โอเชี่ยน",
"โอโซน": "โอโซน",
"ozone": "โอโซน",
// Mixed Fruit
"ผลไม้รวม": "ผลไม้รวม",
"mixed fruit": "ผลไม้รวม",
// Butterfly Pea
"ดอกอัญชัน": "อัญชัน",
"อัญชัน": "อัญชัน",
"butterfly pea": "อัญชัน",
// Orange Jasmine
"ดอกแก้ว": "ดอกแก้ว",
"orange jasmine": "ดอกแก้ว",
// Lily
"ลิลลี่": "ดอกลิลลี่",
"ดอกลิลลี่": "ดอกลิลลี่",
"lily": "ดอกลิลลี่",
// Lotus
"ดอกบัว": "ดอกบัว",
"บัว": "ดอกบัว",
"lotus": "ดอกบัว",
// Leelawadee / Frangipani
"ลีลาวดี": "ลีลาวดี",
"leelawadee": "ลีลาวดี",
"frangipani": "ลีลาวดี",
// Cork Tree / Dokpeep
"ดอกปีบ": "ดอกปีบ",
"ปีบ": "ดอกปีบ",
"cork tree": "ดอกปีบ",
"dokpeep": "ดอกปีบ",
// Night Blooming Jasmine
"ดอกราตรี": "ดอกราตรี",
"ราตรี": "ดอกราตรี",
// Grape
"กลิ่นองุ่น": "กลิ่นองุ่น",
"องุ่น": "กลิ่นองุ่น",
"grape": "กลิ่นองุ่น",
// Peach
"กลิ่นลูกพีช": "กลิ่นลูกพีช",
"ลูกพีช": "กลิ่นลูกพีช",
"พีช": "กลิ่นลูกพีช",
"peach": "กลิ่นลูกพีช",
// Milk
"กลิ่นน้ำนม": "กลิ่นน้ำนม",
"น้ำนม": "กลิ่นน้ำนม",
"milk": "กลิ่นน้ำนม",
// Banana
"กลิ่นกล้วย": "กล้วย",
"กล้วย": "กล้วย",
"banana": "กล้วย",
// Karawaek
"ดอกการะเวก": "ดอกการะเวก",
"การะเวก": "ดอกการะเวก",
"การเวก": "ดอกการะเวก",
"karawaek": "ดอกการะเวก",
// Champaca
"ดอกจำปา": "ดอกจำปา",
"จำปา": "ดอกจำปา",
"champaca": "ดอกจำปา",
// Pikul
"ดอกพิกุล": "ดอกพิกุล",
"พิกุล": "ดอกพิกุล",
"pikul": "ดอกพิกุล",
// Pud
"ดอกพุด": "ดอกพุด",
"พุด": "ดอกพุด",
"pud": "ดอกพุด",
// Rachawadee
"ดอกราชาวดี": "ดอกราชาวดี",
"ราชาวดี": "ดอกราชาวดี",
"rachawadee": "ดอกราชาวดี",
// Chomanat
"ดอกชมนาด": "ดอกชมนาด",
"ชมนาด": "ดอกชมนาด",
"โฉมนาถ": "ดอกชมนาด",
"chomanat": "ดอกชมนาด",
// Pomegranate
"ทับทิม": "ทับทิม",
"pomegranate": "ทับทิม",
// Bubblegum
"bubblegum": "หมากฝรั่ง",
"gum": "หมากฝรั่ง",
"หมากฝรั่ง": "หมากฝรั่ง",
"bubble gum": "หมากฝรั่ง",
// Others
"เซเว่นซี": "เซเว่นซี",
"seven sea": "เซเว่นซี",
"ไทยสปา": "ไทยสปา",
"thai spa": "ไทยสปา",
"กาแฟ": "กาแฟ",
"coffee": "กาแฟ",
"โรสเมรี่": "โรสเมรี่",
"โรสแมรี่": "โรสเมรี่",
"rosemary": "โรสเมรี่",
};
function cleanSize(str: string): string | null {
if (!str) return null;
const s = str.toLowerCase().replace(/\s+/g, "");
if (s.includes("120ml") || s.includes("120ม.ล") || s.includes("120มล") || s.includes("120ซีซี") || s.includes("120cc") || s.includes("ขนาด:120") || s.startsWith("120-") || s.endsWith("-120")) return "120ml";
if (s.includes("85ml") || s.includes("85ม.ล") || s.includes("85มล") || s.includes("85ซีซี") || s.includes("85cc") || s.includes("ขนาด:85") || s.startsWith("85-") || s.endsWith("-85")) return "85ml";
if (s.includes("60ml") || s.includes("60ม.ล") || s.includes("60มล") || s.includes("60ซีซี") || s.includes("60cc") || s.includes("ขนาด:60") || s.startsWith("60-") || s.endsWith("-60")) return "60ml";
if (s.includes("30ml") || s.includes("30ม.ล") || s.includes("30มล") || s.includes("30ซีซี") || s.includes("30cc") || s.includes("ขนาด:30") || s.startsWith("30-") || s.endsWith("-30")) return "30ml";
if (s.includes("25ml") || s.includes("25ม.ล") || s.includes("25มล") || s.includes("25ซีซี") || s.includes("25cc") || s.includes("ขนาด:25") || s.startsWith("25-") || s.endsWith("-25")) return "25ml";
if (s.includes("10ml") || s.includes("10ม.ล") || s.includes("10มล") || s.includes("10ซีซี") || s.includes("10cc") || s.includes("ขนาด:10") || s.startsWith("10-") || s.endsWith("-10")) return "10ml";
return null;
}
function findScent(text: string): string | null {
if (!text) return null;
const lower = text.toLowerCase();
for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) {
if (alias.length <= 3) {
const regex = new RegExp(`(^|[^a-zA-Z0-9])${alias}([^a-zA-Z0-9]|$)`, "i");
if (regex.test(lower)) return canonical;
} else {
if (lower.includes(alias.toLowerCase())) {
return canonical;
}
}
}
return null;
}
function detectCategory(pName: string, vName: string, sku: string): string {
const t = `${pName} ${vName} ${sku}`.toLowerCase();
// 1. If it has humidifier / ozone / ultrasonic keywords, it is definitely Oil Ozone
if (t.includes("โอโซน") || t.includes("oil ozone") || t.includes("oilozone") || t.includes("เครื่องพ่น") || t.includes("humidifier") || t.includes("ไอน้ำ") || t.includes("เตาอโรม่า")) {
return "น้ำมันหอมระเหยโอโซน";
}
// 2. Reed Diffuser
if (t.includes("ก้านไม้") || t.includes("reed diffuser") || t.includes("reed") || t.includes("ก้านไม้หอม")) {
return "ก้านไม้หอมปรับอากาศ";
}
// 3. Car Diffuser (hanging perfume for cars)
if (t.includes("car diffuser") || t.includes("แขวนในรถ") || t.includes("p-car") || t.includes("แขวนที่กระจกมองหลัง") || t.includes("น้ำหอมรถ") || t.includes("น้ำหอมแขวน")) {
return "น้ำหอมปรับอากาศในรถยนต์";
}
// 4. Room Spray
if (t.includes("สเปรย์") || t.includes("room spray") || t.includes("สเปร์ย")) {
return "สเปรย์ปรับอากาศ";
}
// 5. Default for all other oil drops
return "น้ำมันหอมระเหยโอโซน";
}
async function main() {
console.log("== Lazada Product Matcher & Mapper ==");
// 1. Fetch Master Variants
console.log("Fetching Master Product Variants...");
const varRes = await fetch(`${PROD_API}/tools/marketplace-products/master-variants`);
const varData = (await varRes.json()) as { variants: MasterVariant[] };
const variants = varData.variants || [];
console.log(`Loaded ${variants.length} master variants.`);
// Master Lookup Map: `${categoryName}:::${scent}:::${size}` -> MasterVariant
const masterLookup = new Map<string, MasterVariant>();
for (const v of variants) {
let scent = v.productName
.replace(/Oil Ozone/gi, "")
.replace(/Reed Diffuser/gi, "")
.replace(/Aroma Oil/gi, "")
.replace(/Car Diffuser/gi, "")
.replace(/Room Spray/gi, "")
.trim();
const key = `${v.categoryName}:::${scent}:::${v.optionsStr}`.toLowerCase();
masterLookup.set(key, v);
}
// 2. Fetch Lazada Marketplace Products
console.log("Fetching Lazada Marketplace Products...");
const pRes = await fetch(`${PROD_API}/tools/marketplace-products?channel=lazada&status=all&limit=2500`);
const pData = (await pRes.json()) as { items: MarketplaceProduct[]; total: number };
const items = pData.items || [];
console.log(`Loaded ${items.length} Lazada products.`);
const matchedMappings: { marketplaceProductId: number; masterVariantId: number; meta: any }[] = [];
const heldItems: { id: number; name: string; variation: string; sku: string; price: number; reason: string; detectedCat?: string; detectedScent?: string | null; detectedSize?: string | null }[] = [];
const categoryStats: Record<string, number> = {};
for (const item of items) {
const pStr = item.productName || "";
const vStr = item.variation || "";
const sStr = item.marketplaceSku || "";
const cat = detectCategory(pStr, vStr, sStr);
let size = cleanSize(vStr) || cleanSize(sStr) || cleanSize(pStr);
let scent = findScent(vStr) || findScent(sStr) || findScent(pStr);
// Contextual Defaults
if (cat === "สเปรย์ปรับอากาศ" && !size) {
size = "85ml";
}
if (cat === "น้ำหอมปรับอากาศในรถยนต์" && (!size || size === "30ml")) {
size = "25ml";
}
if (!size) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
sku: item.marketplaceSku,
price: item.avgPrice,
reason: "ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้า, Variation หรือ SKU",
detectedCat: cat,
detectedScent: scent,
detectedSize: null,
});
continue;
}
if (size === "10ml") {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
sku: item.marketplaceSku,
price: item.avgPrice,
reason: "ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml)",
detectedCat: cat,
detectedScent: scent,
detectedSize: "10ml",
});
continue;
}
if (!scent) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
sku: item.marketplaceSku,
price: item.avgPrice,
reason: "ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น ชาเขียว หรือไม่มีระบุกลิ่น)",
detectedCat: cat,
detectedScent: null,
detectedSize: size,
});
continue;
}
const key = `${cat}:::${scent}:::${size}`.toLowerCase();
const master = masterLookup.get(key);
if (!master) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
sku: item.marketplaceSku,
price: item.avgPrice,
reason: `พบหมวด '${cat}', กลิ่น '${scent}', ขนาด '${size}' แต่ไม่มี Master Variant ขนาดนี้ในแคตตาล็อก`,
detectedCat: cat,
detectedScent: scent,
detectedSize: size,
});
continue;
}
matchedMappings.push({
marketplaceProductId: item.id,
masterVariantId: master.id,
meta: {
lazadaSku: item.marketplaceSku,
category: cat,
scent,
size,
masterSku: master.sku,
masterName: master.displayName,
hasBom: master.bomCost > 0,
},
});
categoryStats[cat] = (categoryStats[cat] || 0) + 1;
}
console.log(`\n========================================`);
console.log(`Total Lazada Products : ${items.length}`);
console.log(`High Confidence Match : ${matchedMappings.length} (${((matchedMappings.length / items.length) * 100).toFixed(1)}%)`);
console.log(`Held / Needs Decision : ${heldItems.length} (${((heldItems.length / items.length) * 100).toFixed(1)}%)`);
console.log(`Category Breakdown :`, categoryStats);
console.log(`========================================\n`);
// 3. Execute Bulk Mapping in Chunks of 300 to ensure fast transaction and no timeouts
if (matchedMappings.length > 0) {
const CHUNK_SIZE = 300;
console.log(`Applying ${matchedMappings.length} bulk mappings in chunks of ${CHUNK_SIZE}...`);
for (let i = 0; i < matchedMappings.length; i += CHUNK_SIZE) {
const chunk = matchedMappings.slice(i, i + CHUNK_SIZE);
const bulkPayload = {
mappings: chunk.map((m) => ({
marketplaceProductId: m.marketplaceProductId,
masterVariantId: m.masterVariantId,
})),
};
const res = await fetch(`${PROD_API}/tools/marketplace-products/bulk-map`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bulkPayload),
});
const result = await res.json();
console.log(`Chunk ${Math.floor(i / CHUNK_SIZE) + 1} (${chunk.length} items):`, result);
}
}
// 4. Generate Markdown Report
let report = `# Lazada Marketplace Product Matching & Mapping Ledger\n\n`;
report += `* **Generated At:** ${new Date().toISOString()}\n`;
report += `* **Target Server:** \`${PROD_API}\` (Production)\n`;
report += `* **Total Lazada Products:** ${items.length}\n`;
report += `* **Successfully Mapped:** ${matchedMappings.length} (${((matchedMappings.length / items.length) * 100).toFixed(1)}%)\n`;
report += `* **Held / Requires Decision:** ${heldItems.length} (${((heldItems.length / items.length) * 100).toFixed(1)}%)\n\n`;
report += `### สรุปตามหมวดหมู่สินค้าที่เชื่อมโยงสำเร็จ:\n`;
for (const [cat, count] of Object.entries(categoryStats)) {
report += `- **${cat}:** ${count} รายการ\n`;
}
report += `\n---\n\n`;
report += `## 1. Held / Unmapped Items (ต้องตัดสินใจเพิ่มเติม - ${heldItems.length} รายการ)\n\n`;
report += `| ID | ชื่อสินค้า Lazada | Variation | SKU | ราคาเฉลี่ย | สาเหตุที่ Hold |\n`;
report += `|:---|:---|:---|:---|:---|:---|\n`;
for (const h of heldItems) {
report += `| ${h.id} | ${h.name.replace(/\|/g, "-")} | ${h.variation || "-"} | \`${h.sku || "-"}\` | ฿${(h.price || 0).toFixed(2)} | ${h.reason} |\n`;
}
report += `\n## 2. Sample of Successfully Mapped Items (ตัวอย่างที่เชื่อมโยงสำเร็จ)\n\n`;
report += `| ID | Lazada SKU | หมวดหมู่ | กลิ่น | ขนาด | Master SKU | Master Variant | BOM Status |\n`;
report += `|:---|:---|:---|:---|:---|:---|:---|:---|\n`;
for (const m of matchedMappings.slice(0, 40)) {
report += `| ${m.marketplaceProductId} | \`${m.meta.lazadaSku || "-"}\` | ${m.meta.category} | ${m.meta.scent} | ${m.meta.size} | \`${m.meta.masterSku}\` | ${m.meta.masterName} | ${m.meta.hasBom ? "✓ มี BOM แล้ว" : "รอสร้าง BOM"} |\n`;
}
if (matchedMappings.length > 40) {
report += `| ... | ... | ... | ... | ... | ... | *(แสดง 40 จากทั้งหมด ${matchedMappings.length} รายการ)* | ... |\n`;
}
const outPath = path.resolve(process.cwd(), "LAZADA_MATCH_AND_MAP_LEDGER.md");
fs.writeFileSync(outPath, report, "utf8");
console.log(`\nSaved ledger report to: ${outPath}`);
}
main().catch(console.error);
+157
View File
@@ -0,0 +1,157 @@
import prisma from "../apps/backend/src/lib/prisma";
const PROD_API = "https://api.homaroma.shop/api";
async function main() {
console.log("== Syncing Production Raw Materials and BOMs to Local DB ==");
// 1. Fetch Production Raw Materials
console.log("1. Fetching Production Raw Materials...");
const prodRmsRes = await fetch(`${PROD_API}/raw-materials`);
const prodRms = (await prodRmsRes.json()) as any[];
console.log(`Found ${prodRms.length} raw materials on production.`);
// Upsert into local DB by name
const nameToLocalId = new Map<string, number>();
for (const rm of prodRms) {
const upserted = await prisma.rawMaterial.upsert({
where: { name: rm.name },
create: {
name: rm.name,
unit: rm.unit || "piece",
pricePerUnit: rm.pricePerUnit || 0,
},
update: {
unit: rm.unit || "piece",
pricePerUnit: rm.pricePerUnit || 0,
},
});
nameToLocalId.set(rm.name, upserted.id);
}
console.log("✓ Raw materials synced to local DB.");
// 2. Fetch Production BOM Status
console.log("2. Fetching Production BOM status...");
const prodStatusRes = await fetch(`${PROD_API}/products/bom-status?limit=300`);
const prodStatus = (await prodStatusRes.json()) as any;
const prodProducts = prodStatus.products || [];
console.log(`Found ${prodProducts.length} products on production.`);
// Map to local products and variants by SKU / Product Name
let variantBomCount = 0;
for (const prod of prodProducts) {
// Fetch full BOM for this product from production
try {
const detailRes = await fetch(`${PROD_API}/products/${prod.id}/bom`);
if (!detailRes.ok) continue;
const detail = (await detailRes.json()) as any;
const prodVariants = detail.variants || [];
for (const pv of prodVariants) {
if (!pv.bomItems || pv.bomItems.length === 0) continue;
// Find local variant by sku
const localVariant = await prisma.productVariant.findFirst({
where: { sku: pv.sku },
});
if (!localVariant) continue;
// Clear existing local BOM items for this variant
await prisma.bomItem.deleteMany({
where: { productVariantId: localVariant.id },
});
// Create new BOM items
for (const item of pv.bomItems) {
const rmName = item.name || item.rawMaterial?.name;
const localRmId = rmName ? nameToLocalId.get(rmName) : null;
if (!localRmId) continue;
await prisma.bomItem.create({
data: {
productVariantId: localVariant.id,
rawMaterialId: localRmId,
quantity: item.quantity,
},
});
}
variantBomCount++;
}
} catch (err: any) {
console.error(`Error syncing product #${prod.id}:`, err.message);
}
}
console.log(`✓ Successfully synced ${variantBomCount} variant BOMs to local DB!`);
// 3. Sync Marketplace Product Mappings from Production
console.log("3. Syncing Marketplace Product Mappings...");
try {
const mpRes = await fetch(`${PROD_API}/tools/marketplace-products?limit=3000`);
if (mpRes.ok) {
const mpData = (await mpRes.json()) as any;
const mpItems = mpData.items || [];
console.log(`Found ${mpItems.length} marketplace products on production.`);
let mappedCount = 0;
for (const item of mpItems) {
if (!item.masterVariant?.sku) continue;
const localVariant = await prisma.productVariant.findFirst({
where: { sku: item.masterVariant.sku },
select: { id: true },
});
if (!localVariant) continue;
// Upsert or update local MarketplaceProduct
const existing = await prisma.marketplaceProduct.findFirst({
where: {
channel: item.channel,
shop: item.shop,
productName: item.productName,
variation: item.variation || "",
marketplaceSku: item.marketplaceSku || "",
},
});
if (existing) {
await prisma.marketplaceProduct.update({
where: { id: existing.id },
data: {
masterVariantId: localVariant.id,
totalSoldQty: item.totalSoldQty,
totalRevenue: item.totalRevenue,
avgPrice: item.avgPrice,
},
});
mappedCount++;
} else {
await prisma.marketplaceProduct.create({
data: {
channel: item.channel,
shop: item.shop,
productName: item.productName,
variation: item.variation || "",
marketplaceSku: item.marketplaceSku || "",
totalSoldQty: item.totalSoldQty,
totalRevenue: item.totalRevenue,
avgPrice: item.avgPrice,
masterVariantId: localVariant.id,
},
});
mappedCount++;
}
}
console.log(`✓ Synced ${mappedCount} marketplace product mappings to local DB.`);
}
} catch (err: any) {
console.warn("Marketplace sync warning:", err.message);
}
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());