This commit is contained in:
Your Name committed 2026-10-18 13:55:18 +07:00
1 parent 4fcdd8a816
commit f49776f9cb
14 files changed
+4012 -21

No files matched your search

+157
View File
@@ -0,0 +1,157 @@
import * as fs from "fs";
import * as path from "path";
const PROD_API = "https://api.homaroma.shop/api";
interface RawMaterialRef {
id: number;
name: string;
pricePerUnit: number;
unit: string;
}
interface VariantPlan {
id: number;
sku: string;
option: string;
price: number;
hasBom: boolean;
totalBomCost: number;
}
interface ProductPlan {
index: number;
productId: number;
productName: string;
scentName: string;
category: string | null;
bomStatus: string;
configuredVariants: number;
totalVariants: number;
variants: VariantPlan[];
matchedFragrance: RawMaterialRef | null;
packagingReady: boolean;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const BOTTLE_MAP: Record<number, number> = {
30: 10, // ขวดพร้อมฝา Oilozone 30ml
60: 11, // ขวดพร้อมฝา Oilozone 60ml
85: 12, // ขวดพร้อมฝา Oilozone 85ml
120: 9, // ขวดพร้อมฝา Oilozone 120ml
};
const FORMULA_MAP: Record<number, { fragQty: number; dpgQty: number }> = {
30: { fragQty: 2.4, dpgQty: 27.6 },
60: { fragQty: 4.8, dpgQty: 55.2 },
85: { fragQty: 6.8, dpgQty: 78.2 },
120: { fragQty: 9.6, dpgQty: 110.4 },
};
function detectSize(variant: VariantPlan): number {
const opt = (variant.option || "").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.option})`);
}
async function main() {
const jsonPath = path.resolve(process.cwd(), "oil_ozone_products.json");
if (!fs.existsSync(jsonPath)) {
throw new Error(`File not found: ${jsonPath}`);
}
const products: ProductPlan[] = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
console.log(`Loaded ${products.length} products from ${jsonPath}`);
let successCount = 0;
let skippedCount = 0;
let failedCount = 0;
for (const product of products) {
console.log(`\n--------------------------------------------------`);
console.log(`[${product.index}/${products.length}] Product #${product.productId}: ${product.productName}`);
if (!product.matchedFragrance) {
console.error(`❌ Skipped: No matching fragrance found for scent '${product.scentName}'`);
failedCount++;
continue;
}
if (product.bomStatus === "CONFIGURED" && product.configuredVariants === product.totalVariants) {
console.log(`⚡ Already configured (${product.configuredVariants}/${product.totalVariants} variants). Skipping.`);
skippedCount++;
continue;
}
// Build variants payload
const variantsPayload = 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: product.matchedFragrance!.id,
name: product.matchedFragrance!.name,
quantity: formula.fragQty,
},
{
rawMaterialId: 2, // DPG
name: "DPG",
quantity: formula.dpgQty,
},
{
rawMaterialId: bottleId,
quantity: 1,
},
],
};
});
console.log(`Applying BOM for ${variantsPayload.length} variants with fragrance [ID ${product.matchedFragrance.id}] ${product.matchedFragrance.name}...`);
try {
const res = await fetch(`${PROD_API}/products/${product.productId}/bom`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ variants: variantsPayload }),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`HTTP ${res.status}: ${errText}`);
}
const resData = (await res.json()) as any;
console.log(`✓ BOM saved successfully for #${product.productId}! Updated variants: ${resData.updatedVariants ?? variantsPayload.length}`);
successCount++;
} catch (err: any) {
console.error(`❌ Failed to update BOM for #${product.productId}: ${err.message}`);
failedCount++;
}
// Rate-limit pause
await sleep(200);
}
console.log(`\n==================================================`);
console.log(`BOM Application Summary:`);
console.log(` - Newly configured: ${successCount}`);
console.log(` - Already configured (skipped): ${skippedCount}`);
console.log(` - Failed: ${failedCount}`);
console.log(`==================================================\n`);
}
main().catch(console.error);
+169
View File
@@ -0,0 +1,169 @@
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[];
}
async function main() {
console.log("1. Fetching all 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[] };
// Filter only Oil Ozone products
const allProducts = statusData.products || [];
const oilOzoneProducts = allProducts.filter(
(p) =>
p.name.includes("Oil Ozone") ||
(p.category && p.category.includes("โอโซน")) ||
p.variants.some((v) => v.sku && v.sku.includes("-1-"))
);
console.log(`Found ${oilOzoneProducts.length} Oil Ozone products.`);
// 2. Fetch all raw materials from production
console.log("2. Fetching available 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[];
console.log(`Found ${rawMaterials.length} raw materials on production.`);
// Identify common raw materials
const dpg = rawMaterials.find((r) => r.name.trim().toLowerCase() === "dpg");
const b30 = rawMaterials.find((r) => r.name.includes("Oilozone 30ml") || r.name.includes("ขวดพร้อมฝา Oilozone 30ml"));
const b60 = rawMaterials.find((r) => r.name.includes("Oilozone 60ml") || r.name.includes("ขวดพร้อมฝา Oilozone 60ml"));
const b85 = rawMaterials.find((r) => r.name.includes("Oilozone 85ml") || r.name.includes("ขวดพร้อมฝา Oilozone 85ml"));
const b120 = rawMaterials.find((r) => r.name.includes("Oilozone 120ml") || r.name.includes("ขวดพร้อมฝา Oilozone 120ml"));
// Build match plan for each product
const productPlan = oilOzoneProducts.map((p, idx) => {
// Extract scent name e.g. "ซากุระ Oil Ozone" -> "ซากุระ"
const scentName = p.name.replace(/Oil Ozone/gi, "").trim();
// Look for matching fragrance oil in raw materials
const matchedFragrance = rawMaterials.find((rm) => {
const rmName = rm.name.trim();
return (
rmName === `หัวน้ำหอม กลิ่น${scentName}` ||
rmName === `หัวน้ำหอม ${scentName}` ||
rmName.includes(`กลิ่น${scentName}`) ||
(rmName.includes(scentName) && rmName.includes("หัวน้ำหอม"))
);
});
return {
index: idx + 1,
productId: p.id,
productName: p.name,
scentName,
category: p.category,
bomStatus: p.bomStatus,
configuredVariants: p.configuredVariants,
totalVariants: p.totalVariants,
variants: p.variants.map((v) => ({
id: v.id,
sku: v.sku,
option: v.options,
price: v.price,
hasBom: v.hasBom,
totalBomCost: v.totalBomCost,
marginPercent: v.price > 0 && v.totalBomCost > 0
? Number((((v.price - v.totalBomCost) / v.price) * 100).toFixed(1))
: null,
})),
matchedFragrance: matchedFragrance
? {
id: matchedFragrance.id,
name: matchedFragrance.name,
pricePerUnit: matchedFragrance.pricePerUnit,
unit: matchedFragrance.unit,
}
: null,
packagingReady: !!(dpg && b30 && b60 && b85 && b120),
};
});
// 3. Save JSON to local root folder
const jsonPath = path.resolve(process.cwd(), "oil_ozone_products.json");
fs.writeFileSync(jsonPath, JSON.stringify(productPlan, null, 2), "utf-8");
console.log(`\n✓ Saved JSON data to: ${jsonPath}`);
// 4. Generate Markdown Ledger
let md = `# รายการสินค้ากลุ่ม Oil Ozone สำหรับกำหนดสูตร BOM (Production)\n\n`;
md += `> **เป้าหมาย:** กำหนดสูตร BOM อัตราส่วน หัวน้ำหอม 8% + DPG 92% + ขวดและฝา 4 บาท (ทุกขนาด: 30ml, 60ml, 85ml, 120ml)\n`;
md += `> **อัปเดตล่าสุด:** ${new Date().toLocaleString("th-TH")}\n`;
md += `> **จำนวนสินค้าทั้งหมด:** ${productPlan.length} รายการ\n\n`;
md += `## 📦 สรุปสถานะภาพรวม\n`;
const configured = productPlan.filter((p) => p.bomStatus === "CONFIGURED").length;
const missing = productPlan.filter((p) => p.bomStatus === "MISSING").length;
const partial = productPlan.filter((p) => p.bomStatus === "PARTIAL").length;
const fragranceFound = productPlan.filter((p) => p.matchedFragrance !== null).length;
md += `- **ตั้งค่าเสร็จแล้ว (CONFIGURED):** ${configured} / ${productPlan.length} รายการ (100%)\n`;
md += `- **ยังไม่ได้ตั้งค่า (MISSING):** ${missing} รายการ\n`;
md += `- **ตั้งค่าบางส่วน (PARTIAL):** ${partial} รายการ\n`;
md += `- **พบวัตถุดิบหัวน้ำหอมตรงกลิ่น:** ${fragranceFound} / ${productPlan.length} รายการ\n\n`;
md += `## 📋 ตารางรายละเอียดต้นทุน BOM และอัตรากำไร (Margin)\n\n`;
md += `| # | ID | ชื่อสินค้า | หัวน้ำหอม (ราคา/ml) | ต้นทุน 30ml (กำไร%) | ต้นทุน 60ml (กำไร%) | ต้นทุน 85ml (กำไร%) | ต้นทุน 120ml (กำไร%) | สถานะ |\n`;
md += `| :-: | :-: | :--- | :--- | :-: | :-: | :-: | :-: | :-: |\n`;
for (const p of productPlan) {
const statusBadge =
p.bomStatus === "CONFIGURED"
? "✅ พร้อมขาย"
: p.bomStatus === "PARTIAL"
? "⚠️ บางส่วน"
: "❌ ขาด BOM";
const fragText = p.matchedFragrance
? `[ID: ${p.matchedFragrance.id}] ${p.matchedFragrance.name} (฿${p.matchedFragrance.pricePerUnit})`
: `<span style="color:red">⚠️ ไม่พบหัวน้ำหอม</span>`;
const v30 = p.variants.find((v) => (v.option || "").includes("30") || (v.sku || "").includes("30ML"));
const v60 = p.variants.find((v) => (v.option || "").includes("60") || (v.sku || "").includes("60ML"));
const v85 = p.variants.find((v) => (v.option || "").includes("85") || (v.sku || "").includes("85ML"));
const v120 = p.variants.find((v) => (v.option || "").includes("120") || (v.sku || "").includes("120ML"));
const formatCost = (v?: (typeof p.variants)[0]) => {
if (!v || !v.hasBom) return "-";
return `฿${v.totalBomCost.toFixed(2)} (${v.marginPercent}%)`;
};
md += `| ${p.index} | **#${p.productId}** | [${p.productName}](https://admin.homaroma.shop/products/${p.productId}/bom) | ${fragText} | ${formatCost(v30)} | ${formatCost(v60)} | ${formatCost(v85)} | ${formatCost(v120)} | ${statusBadge} |\n`;
}
const mdPath = path.resolve(process.cwd(), "OIL_OZONE_BOM_LEDGER.md");
fs.writeFileSync(mdPath, md, "utf-8");
console.log(`✓ Saved Markdown ledger to: ${mdPath}`);
}
main().catch(console.error);
+299
View File
@@ -0,0 +1,299 @@
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;
}
// Scents standard dictionary & aliases
const SCENT_ALIASES: Record<string, string> = {
"เปปเปอร์มินต์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้นท์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้น": "เปเปอร์มิ้น",
"เปเปอร์มินต์": "เปเปอร์มิ้น",
"ตะไคร้บ้าน": "ตะไคร้",
"เฟรช ลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"เฟรชลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"แซนดัลวูด": "แซนด้าวูส",
"แซนด้าวูด": "แซนด้าวูส",
"แซนดัลวู้ด": "แซนด้าวูส",
"กุหลาบ": "กุหลาบแดง",
"มะลิซ้อน": "มะลิ",
"ลูกพีช": "กลิ่นลูกพีช",
"พีช": "กลิ่นลูกพีช",
"องุ่น": "กลิ่นองุ่น",
"น้ำนม": "กลิ่นน้ำนม",
"นม": "กลิ่นน้ำนม",
"มหาสมุทร": "โอเชี่ยน",
"ราตรี": "ดอกราตรี",
"การเวก": "ดอกการะเวก",
"ดอกอัญชัน": "อัญชัน",
"ดอกบัวหลวง": "ดอกบัว",
"ไม้หอม": "แซนด้าวูส",
};
// Size extractor helper
function extractSize(variation: string, productName: string): string | null {
const v = (variation || "").toLowerCase();
const p = (productName || "").toLowerCase();
// Variation has highest priority for size
if (v.includes("120") || v.includes("120ml")) return "120ml";
if (v.includes("85") || v.includes("85ml")) return "85ml";
if (v.includes("60") || v.includes("60ml")) return "60ml";
if (v.includes("30") || v.includes("30ml")) return "30ml";
// Fallback to product name
if (p.includes("120") || p.includes("120ml") || p.includes("ขวดใหญ่")) return "120ml";
if (p.includes("85") || p.includes("85ml")) return "85ml";
if (p.includes("60") || p.includes("60ml")) return "60ml";
if (p.includes("30") || p.includes("30ml") || p.includes("ขวดเล็ก")) return "30ml";
return null;
}
// Scent extractor helper
function extractScent(variation: string, productName: string, masterScents: string[]): string | null {
const cleanVar = (variation || "")
.replace(/\d+\s*ml/gi, "")
.replace(/,/g, "")
.replace(/^กลิ่น\s*/, "")
.trim();
// 1. Check variation aliases first
for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) {
if (cleanVar.includes(alias) || cleanVar === alias) {
return canonical;
}
}
// 2. Check direct master scents in variation
for (const scent of masterScents) {
if (cleanVar.includes(scent) || cleanVar === scent) {
return scent;
}
}
// 3. Fallback to product name aliases
for (const [alias, canonical] of Object.entries(SCENT_ALIASES)) {
if (productName.includes(alias)) {
return canonical;
}
}
// 4. Fallback to direct master scents in product name
for (const scent of masterScents) {
if (productName.includes(`กลิ่น ${scent}`) || productName.includes(`กลิ่น${scent}`) || productName.includes(scent)) {
return scent;
}
}
return null;
}
async function main() {
console.log("1. Fetching master variants from production...");
const mvRes = await fetch(`${PROD_API}/tools/marketplace-products/master-variants`);
const mvData = (await mvRes.json()) as { variants: MasterVariant[] };
const masterVariants = mvData.variants || [];
console.log(`Loaded ${masterVariants.length} master variants.`);
// Filter only Oil Ozone master variants
const ozoneVariants = masterVariants.filter(
(v) => (v.categoryName && v.categoryName.includes("โอโซน")) || v.productName.includes("Oil Ozone")
);
console.log(`Found ${ozoneVariants.length} Oil Ozone master variants.`);
// Collect distinct scents
const masterScents = Array.from(
new Set(ozoneVariants.map((v) => v.productName.replace(/Oil Ozone/gi, "").trim()))
).filter(Boolean);
console.log(`Detected ${masterScents.length} distinct Oil Ozone scents.`);
// 2. Fetch unmapped TikTok products
console.log("\n2. Fetching unmapped TikTok products from production...");
const mpRes = await fetch(`${PROD_API}/tools/marketplace-products?channel=tiktok&status=unmapped&limit=350`);
const mpData = (await mpRes.json()) as { items: MarketplaceProduct[]; pagination: any };
const unmappedItems = mpData.items || [];
console.log(`Found ${unmappedItems.length} unmapped TikTok items (total in DB: ${mpData.pagination?.total}).`);
// 3. Test matching
const matches: Array<{
item: MarketplaceProduct;
matchedVariant: MasterVariant;
scent: string;
size: string;
confidence: "HIGH" | "MEDIUM";
}> = [];
const unmatchable: MarketplaceProduct[] = [];
for (const item of unmappedItems) {
const text = `${item.productName} ${item.variation || ""}`;
const isOilOzone =
text.includes("โอโซน") ||
text.includes("oil ozone") ||
text.includes("ออยโอโซน") ||
text.includes("น้ำมันหอมระเหย") ||
text.includes("น้ำหอมอโรม่า") ||
text.includes("น้ำมันอโรม่า") ||
text.includes("น้ำมันหอมไอน้ำ") ||
item.marketplaceSku.includes("OZO") ||
item.marketplaceSku.includes("OIL_") ||
item.marketplaceSku === "30-ALL";
if (!isOilOzone) {
unmatchable.push(item);
continue;
}
const size = extractSize(item.variation, item.productName);
const scent = extractScent(item.variation, item.productName, masterScents);
if (scent && size) {
// Find matching variant
const matched = ozoneVariants.find((v) => {
const vScent = v.productName.replace(/Oil Ozone/gi, "").trim();
const vSize = (v.optionsStr || "").toLowerCase();
return (
vScent === scent &&
(vSize.includes(size.toLowerCase()) || v.sku.toLowerCase().includes(size.toLowerCase()))
);
});
if (matched) {
matches.push({
item,
matchedVariant: matched,
scent,
size,
confidence: "HIGH",
});
} else {
unmatchable.push(item);
}
} else {
unmatchable.push(item);
}
}
console.log(`\n==================================================`);
console.log(`Matching Simulation Results:`);
console.log(` - Successfully matched: ${matches.length} items (${((matches.length / unmappedItems.length) * 100).toFixed(1)}%)`);
console.log(` - Unmatched / Non-ozone: ${unmatchable.length} items`);
console.log(`==================================================\n`);
console.log("Sample 15 matches:");
matches.slice(0, 15).forEach((m, idx) => {
console.log(
`[${idx + 1}] MP #${m.item.id} (${m.item.marketplaceSku || 'NO_SKU'}) | '${m.item.productName.slice(0, 35)}...' | Var: '${m.item.variation}'`
);
console.log(
` -> MATCHED: [Variant #${m.matchedVariant.id} | SKU: ${m.matchedVariant.sku}] ${m.matchedVariant.displayName} (BOM: ฿${m.matchedVariant.bomCost.toFixed(2)})`
);
});
// Save report to scratch file
const reportPath = path.resolve(process.cwd(), "scratch_tiktok_matches.json");
fs.writeFileSync(
reportPath,
JSON.stringify(
{
matchedCount: matches.length,
unmatchedCount: unmatchable.length,
matches: matches.map((m) => ({
marketplaceProductId: m.item.id,
marketplaceSku: m.item.marketplaceSku,
marketplaceName: m.item.productName,
marketplaceVariation: m.item.variation,
matchedVariantId: m.matchedVariant.id,
matchedVariantSku: m.matchedVariant.sku,
matchedVariantName: m.matchedVariant.displayName,
unitBomCost: m.matchedVariant.bomCost,
soldQty: m.item.totalSoldQty,
revenue: m.item.totalRevenue,
})),
unmatched: unmatchable.map((u) => ({
id: u.id,
sku: u.marketplaceSku,
name: u.productName,
variation: u.variation,
revenue: u.totalRevenue,
})),
},
null,
2
),
"utf-8"
);
console.log(`\nSaved match report to: ${reportPath}`);
// 4. Apply mappings if --apply flag is passed
const shouldApply = process.argv.includes("--apply");
if (!shouldApply) {
console.log(`\n💡 To apply all ${matches.length} mappings to production, run:`);
console.log(` bun scripts/match_and_map_tiktok_products.ts --apply\n`);
return;
}
console.log(`\n🚀 Applying ${matches.length} mappings to production...`);
let applied = 0;
let failed = 0;
const BATCH = 15;
for (let i = 0; i < matches.length; i += BATCH) {
const chunk = matches.slice(i, i + BATCH);
await Promise.all(
chunk.map(async (m) => {
try {
const res = await fetch(`${PROD_API}/tools/marketplace-products/map`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
marketplaceProductId: m.item.id,
masterVariantId: m.matchedVariant.id,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
applied++;
} catch (err: any) {
console.error(`Failed to map MP #${m.item.id}:`, err.message);
failed++;
}
})
);
process.stdout.write(`\rProgress: ${applied}/${matches.length} applied (${failed} failed)...`);
await new Promise((r) => setTimeout(r, 100));
}
console.log(`\n\n🎉 Finished! Successfully mapped: ${applied} | Failed: ${failed}`);
}
main().catch(console.error);