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:
Your Name committed 2026-10-18 18:58:11 +07:00
1 parent fdbbaea021
commit c78a898af7
21 files changed
+4336 -2

No files matched your search

+157
View File
@@ -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());