This commit is contained in:
Your Name committed 2026-10-18 14:25:29 +07:00
1 parent f49776f9cb
commit fdbbaea021
2 files changed
+468

No files matched your search

+384
View File
@@ -0,0 +1,384 @@
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 "Oil Ozone")
const SCENT_ALIASES: Record<string, string> = {
// Peppermint
"เปปเปอร์มินต์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้นท์": "เปเปอร์มิ้น",
"เปปเปอร์มิ้น": "เปเปอร์มิ้น",
"เปเปอร์มินต์": "เปเปอร์มิ้น",
"เปเปอร์​มิ้น​": "เปเปอร์มิ้น",
"เปเปอร์มิ้น": "เปเปอร์มิ้น",
"pepermint": "เปเปอร์มิ้น",
"peppermint": "เปเปอร์มิ้น",
// Lemongrass
"ตะไคร้บ้าน": "ตะไคร้",
"ตะไคร้": "ตะไคร้",
"ตะไตร้": "ตะไคร้",
"lemongrass": "ตะไคร้",
// Lavender
"ลา​เวนเดอร์​": "ลาเวนเดอร์",
"ลาเวนเดอร์": "ลาเวนเดอร์",
"lavender": "ลาเวนเดอร์",
"เฟรซ ลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"เฟรชลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"เฟรช ลาเวนเดอร์": "เฟรชลาเวนเดอร์",
"fresh lavender": "เฟรชลาเวนเดอร์",
// Sandalwood
"แซนดัลวูด": "แซนด้าวูส",
"แซนด้าวูด": "แซนด้าวูส",
"แซนดัลวู้ด": "แซนด้าวูส",
"แซนด้าวูส": "แซนด้าวูส",
"sandalwood": "แซนด้าวูส",
// Rose
"กุหลาบแดง": "กุหลาบแดง",
"กุหลาบ​แดง": "กุหลาบแดง",
"กุหลาบ": "กุหลาบแดง",
"red rose": "กุหลาบแดง",
// Jasmine
"มะลิซ้อน": "มะลิ",
"ดอกมะลิ": "มะลิ",
"มะลิ": "มะลิ",
"jasmine": "มะลิ",
// Fruits
"ลูกพีช": "กลิ่นลูกพีช",
"พีช": "กลิ่นลูกพีช",
"peach": "กลิ่นลูกพีช",
"องุ่น": "กลิ่นองุ่น",
"grape": "กลิ่นองุ่น",
"น้ำนม": "กลิ่นน้ำนม",
"นม": "กลิ่นน้ำนม",
"milk": "กลิ่นน้ำนม",
"แคนตาลูป​": "แคนตาลูป",
"แคนตาลูป": "แคนตาลูป",
"cantaloupe": "แคนตาลูป",
"ส้ม": "ส้ม",
"orange": "ส้ม",
"มะกรูด": "มะกรูด",
"bergamot": "มะกรูด",
"ผลไม้รวม": "ผลไม้รวม",
"mixed fruit": "ผลไม้รวม",
"แอปเปิ้ล​เขียว": "แอปเปิ้ลเขียว",
"แอปเปิ้ลเขียว": "แอปเปิ้ลเขียว",
"green apple": "แอปเปิ้ลเขียว",
"แอปเปื้ลแดง": "แอปเปิ้ลแดง",
"แอปเปิ้ลแดง": "แอปเปิ้ลแดง",
"red apple": "แอปเปิ้ลแดง",
"มะนาว": "มะนาว",
"lemon": "มะนาว",
"lime": "มะนาว",
"ทับทิม": "ทับทิม",
"pomegranate": "ทับทิม",
"มะละกอสุก": "มะละกอสุก",
"มะละกอ": "มะละกอสุก",
"papaya": "มะละกอสุก",
"กล้วย": "กล้วย",
"banana": "กล้วย",
// Ocean / Ozone
"โอเชี่ยน": "โอเชี่ยน",
"มหาสมุทร": "โอเชี่ยน",
"ocean": "โอเชี่ยน",
"โอโซน": "โอโซน",
"ozone": "โอโซน",
// Thai Flowers
"ดอกราตรี": "ดอกราตรี",
"ราตรี": "ดอกราตรี",
"การเวก": "ดอกการะเวก",
"ดอกการะเวก": "ดอกการะเวก",
"ดอกอัญชัน": "อัญชัน",
"อัญชัน": "อัญชัน",
"butterfly pea": "อัญชัน",
"ดอกบัวหลวง": "ดอกบัว",
"ดอกบัว": "ดอกบัว",
"บัว": "ดอกบัว",
"lotus": "ดอกบัว",
"ดอกปีบ": "ดอกปีบ",
"ปีบ": "ดอกปีบ",
"ดอกโมก": "ดอกโมก",
"โมก": "ดอกโมก",
"ดอกแก้ว": "ดอกแก้ว",
"แก้ว": "ดอกแก้ว",
"ดอกจำปา": "ดอกจำปา",
"จำปา": "ดอกจำปา",
"ดอกพิกุล": "ดอกพิกุล",
"พิกุล": "ดอกพิกุล",
"ดอกพุด": "ดอกพุด",
"พุด": "ดอกพุด",
"ดอกชมนาด": "ดอกชมนาด",
"ชมนาด": "ดอกชมนาด",
"ดอกราชาวดี": "ดอกราชาวดี",
"ราชาวดี": "ดอกราชาวดี",
"ดอกลำดวน": "ดอกลำดวน",
"ลำดวน": "ดอกลำดวน",
"ลีลาวดี": "ลีลาวดี",
"frangipani": "ลีลาวดี",
"ลิลลี่": "ดอกลิลลี่",
"ดอกลิลลี่": "ดอกลิลลี่",
"lilly": "ดอกลิลลี่",
"lily": "ดอกลิลลี่",
// Herbs & Others
"ยูคาลิปตัส": "ยูคาลิปตัส",
"ยูคา": "ยูคาลิปตัส",
"eucalyptus": "ยูคาลิปตัส",
"วนิลา": "วานิลลา",
"วนิลลา": "วานิลลา",
"วานิลลา": "วานิลลา",
"vanilla": "วานิลลา",
"ซากุระ": "ซากุระ",
"sakura": "ซากุระ",
"เซเว่นซี": "เซเว่นซี",
"seven sea": "เซเว่นซี",
"7 sea": "เซเว่นซี",
"หมากฝรั่ง": "หมากฝรั่ง",
"bubble gum": "หมากฝรั่ง",
"ไทยสปา": "ไทยสปา",
"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 === "120ml") return "120ml";
if (s.includes("85ml") || s === "85ml") return "85ml";
if (s.includes("60ml") || s === "60ml") return "60ml";
if (s.includes("30ml") || s === "30ml") return "30ml";
if (s.includes("25ml") || s === "25ml") return "25ml";
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 (lower.includes(alias.toLowerCase())) {
return canonical;
}
}
return null;
}
async function main() {
console.log("== Shopee 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.`);
// Lookup map: `${canonicalScent}:::${normalizedSize}` -> MasterVariant
const masterLookup = new Map<string, MasterVariant>();
for (const v of variants) {
const cleanName = v.productName.replace(/Oil Ozone/gi, "").trim();
const size = v.optionsStr;
const key = `${cleanName}:::${size}`.toLowerCase();
masterLookup.set(key, v);
}
// 3. Fetch Shopee Marketplace Products
console.log("Fetching Shopee Marketplace Products...");
const pRes = await fetch(`${PROD_API}/tools/marketplace-products?channel=shopee&status=all&limit=400`);
const pData = (await pRes.json()) as { items: MarketplaceProduct[]; total: number };
const items = pData.items || [];
console.log(`Loaded ${items.length} Shopee products.`);
const matchedMappings: { marketplaceProductId: number; masterVariantId: number; meta: any }[] = [];
const heldItems: { id: number; name: string; variation: string; price: number; reason: string; detectedScent?: string | null; detectedSize?: string | null }[] = [];
for (const item of items) {
// If it's a 10ml product, hold it (Master Catalog does not support 10ml)
if (item.productName.toLowerCase().includes("10 ml") || (item.variation || "").includes("10 ml")) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
price: item.avgPrice,
reason: "ขนาด 10 ml (Master Catalog ปัจจุบันยังไม่มีขนาด 10ml)",
detectedSize: "10ml",
});
continue;
}
const vStr = item.variation || "";
const pStr = item.productName || "";
let scent: string | null = null;
let size: string | null = null;
// Handle comma-separated variation e.g. 'ตะไคร้บ้าน,120 ml'
if (vStr.includes(",")) {
const parts = vStr.split(",");
for (const part of parts) {
if (!size) size = cleanSize(part);
if (!scent) scent = findScent(part);
}
}
if (!size) size = cleanSize(vStr);
if (!scent) scent = findScent(vStr);
if (!size) size = cleanSize(pStr);
if (!scent) scent = findScent(pStr);
if (!size) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
price: item.avgPrice,
reason: "ไม่สามารถระบุขนาดบรรจุ (Size) ได้ชัดเจนจากชื่อสินค้าหรือ Variation",
detectedScent: scent,
detectedSize: null,
});
continue;
}
if (!scent) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
price: item.avgPrice,
reason: "ไม่พบชื่อกลิ่นที่ตรงกับ Master Catalog (เช่น เมลอน หรือกลิ่นที่ยังไม่มีในระบบ)",
detectedScent: null,
detectedSize: size,
});
continue;
}
const key = `${scent}:::${size}`.toLowerCase();
const master = masterLookup.get(key);
if (!master) {
heldItems.push({
id: item.id,
name: item.productName,
variation: item.variation,
price: item.avgPrice,
reason: `พบกลิ่น '${scent}' ขนาด '${size}' แต่ไม่มี Master Variant ขนาดนี้ในแคตตาล็อก`,
detectedScent: scent,
detectedSize: size,
});
continue;
}
matchedMappings.push({
marketplaceProductId: item.id,
masterVariantId: master.id,
meta: {
shopeeSku: item.marketplaceSku,
shopeeName: item.productName,
shopeeVar: item.variation,
masterSku: master.sku,
masterName: master.displayName,
scent,
size,
hasBom: master.bomCost > 0,
},
});
}
console.log(`\n========================================`);
console.log(`Total Shopee 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(`========================================\n`);
// 4. Execute Bulk Mapping on Production
if (matchedMappings.length > 0) {
console.log(`Applying ${matchedMappings.length} bulk mappings on production...`);
const bulkPayload = {
mappings: matchedMappings.map((m) => ({
marketplaceProductId: m.marketplaceProductId,
masterVariantId: m.masterVariantId,
})),
};
const mapRes = await fetch(`${PROD_API}/tools/marketplace-products/bulk-map`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bulkPayload),
});
const mapResult = await mapRes.json();
console.log("Bulk Map API Response:", mapResult);
}
// 5. Generate Markdown Report
let report = `# Shopee Marketplace Product Matching & Mapping Ledger\n\n`;
report += `* **Generated At:** ${new Date().toISOString()}\n`;
report += `* **Target Server:** \`${PROD_API}\` (Production)\n`;
report += `* **Total Shopee 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 += `## 1. Held / Unmapped Items (ต้องตัดสินใจเพิ่มเติม)\n\n`;
report += `| ID | ชื่อสินค้า Shopee | Variation | ราคาเฉลี่ย | สาเหตุที่ Hold |\n`;
report += `|:---|:---|:---|:---|:---|\n`;
for (const h of heldItems) {
report += `| ${h.id} | ${h.name.replace(/\|/g, "-")} | ${h.variation || "-"} | ฿${h.price.toFixed(2)} | ${h.reason} |\n`;
}
report += `\n## 2. Sample of Successfully Mapped Items (ตัวอย่างที่เชื่อมโยงสำเร็จ)\n\n`;
report += `| ID | Shopee SKU | กลิ่น | ขนาด | Master SKU | Master Variant | มี BOM หรือยัง |\n`;
report += `|:---|:---|:---|:---|:---|:---|:---|\n`;
for (const m of matchedMappings.slice(0, 30)) {
report += `| ${m.marketplaceProductId} | ${m.meta.shopeeSku || "-"} | ${m.meta.scent} | ${m.meta.size} | \`${m.meta.masterSku}\` | ${m.meta.masterName} | ${m.meta.hasBom ? "✓ มี BOM แล้ว" : "รอสร้าง BOM"} |\n`;
}
if (matchedMappings.length > 30) {
report += `| ... | ... | ... | ... | ... | *(แสดง 30 จากทั้งหมด ${matchedMappings.length} รายการ)* | ... |\n`;
}
const outPath = path.resolve(process.cwd(), "SHOPEE_MATCH_AND_MAP_LEDGER.md");
fs.writeFileSync(outPath, report, "utf8");
console.log(`\nSaved ledger report to: ${outPath}`);
}
main().catch(console.error);