x
This commit is contained in:
1 parent
4fcdd8a816
commit
f49776f9cb
14 files changed
+4012
-21
No files matched your search
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user