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,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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
Reference in new issue
Block a user