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