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:
1 parent
fdbbaea021
commit
c78a898af7
21 files changed
+4336
-2
No files matched your search
@@ -36,6 +36,7 @@ import { brandKnowledgeRoute } from "./routes/brandKnowledge";
|
||||
import { categorySectionsRoute } from "./routes/categorySections";
|
||||
import { llmPromptsRoute } from "./routes/llmPrompts";
|
||||
import { frameSequencesRoute, getCandidateSequenceDirs } from "./routes/frame-sequences";
|
||||
import { profitabilityRoute } from "./routes/profitability";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||
@@ -158,7 +159,7 @@ const app = new Elysia()
|
||||
return file;
|
||||
})
|
||||
.group("/api", (app) =>
|
||||
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute).use(frameSequencesRoute)
|
||||
app.use(authRoute).use(categoriesRoute).use(categorySectionsRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute).use(crmRoute).use(brandKnowledgeRoute).use(llmPromptsRoute).use(frameSequencesRoute).use(profitabilityRoute)
|
||||
)
|
||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
|
||||
export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Cost & Profitability Matrix
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
.get(
|
||||
"/matrix",
|
||||
async ({ query }) => {
|
||||
const search = (query.search || "").trim();
|
||||
const category = (query.category || "all").trim();
|
||||
const marginFilter = (query.margin || "all").trim(); // "all" | "high" (>70%) | "mid" (55-70%) | "low" (<55%)
|
||||
const sortBy = query.sortBy || "margin_desc";
|
||||
const page = query.page ? parseInt(query.page) : 1;
|
||||
const limit = query.limit ? parseInt(query.limit) : 50;
|
||||
|
||||
// 1. Fetch all active variants with Product, Category, and BOM
|
||||
const variants = await prisma.productVariant.findMany({
|
||||
where: {
|
||||
product: { isDeleted: false },
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: { select: { id: true, name: true } },
|
||||
brand: { select: { id: true, name: true } },
|
||||
translations: { select: { langCode: true, name: true } },
|
||||
},
|
||||
},
|
||||
bomItems: {
|
||||
include: {
|
||||
rawMaterial: {
|
||||
select: { id: true, name: true, unit: true, pricePerUnit: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ product: { name: "asc" } },
|
||||
{ sku: "asc" },
|
||||
],
|
||||
});
|
||||
|
||||
// 2. Map and compute profitability metrics for each variant
|
||||
const mapped = variants.map((v) => {
|
||||
const thName = v.product.translations?.find((t) => t.langCode === "th")?.name;
|
||||
const productName = v.product.name || thName || `Product #${v.product.id}`;
|
||||
const categoryName = v.product.category?.name || "Uncategorized";
|
||||
const brandName = v.product.brand?.name || "HomAroma";
|
||||
const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
|
||||
const retailPrice = v.price || 0;
|
||||
|
||||
let liquidCost = 0;
|
||||
let packagingCost = 0;
|
||||
|
||||
const bomItemsFormatted = (v.bomItems || []).map((b) => {
|
||||
const rm = b.rawMaterial;
|
||||
const unitPrice = rm?.pricePerUnit || 0;
|
||||
const cost = Math.round(b.quantity * unitPrice * 100) / 100;
|
||||
const isLiquid =
|
||||
rm?.unit === "ml" ||
|
||||
rm?.name.includes("หัวน้ำหอม") ||
|
||||
rm?.name.includes("DPG") ||
|
||||
rm?.name.includes("DPM") ||
|
||||
rm?.name.includes("น้ำหอม");
|
||||
|
||||
if (isLiquid) {
|
||||
liquidCost += cost;
|
||||
} else {
|
||||
packagingCost += cost;
|
||||
}
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
rawMaterialId: rm?.id,
|
||||
name: rm?.name || "Unknown Material",
|
||||
unit: rm?.unit || "piece",
|
||||
unitPrice,
|
||||
quantity: b.quantity,
|
||||
totalCost: cost,
|
||||
isLiquid,
|
||||
};
|
||||
});
|
||||
|
||||
liquidCost = Math.round(liquidCost * 100) / 100;
|
||||
packagingCost = Math.round(packagingCost * 100) / 100;
|
||||
const totalBomCost = Math.round((liquidCost + packagingCost) * 100) / 100;
|
||||
const grossProfit = Math.round((retailPrice - totalBomCost) * 100) / 100;
|
||||
const grossMargin = retailPrice > 0 ? Math.round(((retailPrice - totalBomCost) / retailPrice) * 1000) / 10 : 0;
|
||||
|
||||
// Max discount % before margin drops below 40% safety threshold
|
||||
const minAcceptablePrice = totalBomCost / (1 - 0.40);
|
||||
const maxDiscountPercent = retailPrice > minAcceptablePrice
|
||||
? Math.round(((retailPrice - minAcceptablePrice) / retailPrice) * 100)
|
||||
: 0;
|
||||
|
||||
let marginHealth: "high" | "mid" | "low" = "mid";
|
||||
if (grossMargin >= 70) marginHealth = "high";
|
||||
else if (grossMargin < 55) marginHealth = "low";
|
||||
|
||||
return {
|
||||
id: v.id,
|
||||
sku: v.sku || `VAR-${v.id}`,
|
||||
productId: v.product.id,
|
||||
productName,
|
||||
categoryName,
|
||||
brandName,
|
||||
optionsStr,
|
||||
retailPrice,
|
||||
liquidCost,
|
||||
packagingCost,
|
||||
totalBomCost,
|
||||
grossProfit,
|
||||
grossMargin,
|
||||
marginHealth,
|
||||
maxDiscountPercent,
|
||||
bomCount: bomItemsFormatted.length,
|
||||
bomItems: bomItemsFormatted,
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Compute KPI Summary across the entire catalog (before pagination)
|
||||
const totalVariants = mapped.length;
|
||||
const configuredBomVariants = mapped.filter((m) => m.bomCount > 0).length;
|
||||
const totalCatalogRevenueAtFullList = mapped.reduce((s, m) => s + m.retailPrice, 0);
|
||||
const totalCatalogBomCost = mapped.reduce((s, m) => s + m.totalBomCost, 0);
|
||||
const avgGrossMargin =
|
||||
totalCatalogRevenueAtFullList > 0
|
||||
? Math.round(((totalCatalogRevenueAtFullList - totalCatalogBomCost) / totalCatalogRevenueAtFullList) * 1000) / 10
|
||||
: 0;
|
||||
|
||||
const highMarginCount = mapped.filter((m) => m.marginHealth === "high").length;
|
||||
const midMarginCount = mapped.filter((m) => m.marginHealth === "mid").length;
|
||||
const lowMarginCount = mapped.filter((m) => m.marginHealth === "low").length;
|
||||
|
||||
// Category breakdown
|
||||
const categoryMap = new Map<string, { count: number; totalRev: number; totalCost: number }>();
|
||||
for (const item of mapped) {
|
||||
let entry = categoryMap.get(item.categoryName);
|
||||
if (!entry) {
|
||||
entry = { count: 0, totalRev: 0, totalCost: 0 };
|
||||
categoryMap.set(item.categoryName, entry);
|
||||
}
|
||||
entry.count++;
|
||||
entry.totalRev += item.retailPrice;
|
||||
entry.totalCost += item.totalBomCost;
|
||||
}
|
||||
|
||||
const categoryBreakdown = Array.from(categoryMap.entries()).map(([name, stat]) => ({
|
||||
categoryName: name,
|
||||
variantsCount: stat.count,
|
||||
avgPrice: stat.count > 0 ? Math.round(stat.totalRev / stat.count) : 0,
|
||||
avgCost: stat.count > 0 ? Math.round((stat.totalCost / stat.count) * 100) / 100 : 0,
|
||||
avgMargin: stat.totalRev > 0 ? Math.round(((stat.totalRev - stat.totalCost) / stat.totalRev) * 1000) / 10 : 0,
|
||||
}));
|
||||
|
||||
// 4. Apply Filters
|
||||
let filtered = mapped;
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(m) =>
|
||||
m.productName.toLowerCase().includes(s) ||
|
||||
m.sku.toLowerCase().includes(s) ||
|
||||
m.optionsStr.toLowerCase().includes(s) ||
|
||||
m.categoryName.toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
if (category !== "all") {
|
||||
filtered = filtered.filter((m) => m.categoryName.toLowerCase() === category.toLowerCase());
|
||||
}
|
||||
|
||||
if (marginFilter !== "all") {
|
||||
filtered = filtered.filter((m) => m.marginHealth === marginFilter);
|
||||
}
|
||||
|
||||
// 5. Apply Sorting
|
||||
filtered.sort((a, b) => {
|
||||
if (sortBy === "margin_desc") return b.grossMargin - a.grossMargin;
|
||||
if (sortBy === "margin_asc") return a.grossMargin - b.grossMargin;
|
||||
if (sortBy === "profit_desc") return b.grossProfit - a.grossProfit;
|
||||
if (sortBy === "profit_asc") return a.grossProfit - b.grossProfit;
|
||||
if (sortBy === "cost_desc") return b.totalBomCost - a.totalBomCost;
|
||||
if (sortBy === "cost_asc") return a.totalBomCost - b.totalBomCost;
|
||||
if (sortBy === "price_desc") return b.retailPrice - a.retailPrice;
|
||||
if (sortBy === "price_asc") return a.retailPrice - b.retailPrice;
|
||||
if (sortBy === "name_asc") return a.productName.localeCompare(b.productName);
|
||||
return 0;
|
||||
});
|
||||
|
||||
// 6. Paginate
|
||||
const totalFiltered = filtered.length;
|
||||
const paginated = filtered.slice((page - 1) * limit, page * limit);
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
totalVariants,
|
||||
configuredBomVariants,
|
||||
avgGrossMargin,
|
||||
totalCatalogRevenueAtFullList,
|
||||
totalCatalogBomCost,
|
||||
highMarginCount,
|
||||
midMarginCount,
|
||||
lowMarginCount,
|
||||
categoryBreakdown,
|
||||
},
|
||||
pagination: {
|
||||
total: totalFiltered,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(totalFiltered / limit) || 1,
|
||||
},
|
||||
items: paginated,
|
||||
};
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
search: t.Optional(t.String()),
|
||||
category: t.Optional(t.String()),
|
||||
margin: t.Optional(t.String()),
|
||||
sortBy: t.Optional(t.String()),
|
||||
page: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Omnichannel Net Margin & Contribution Matrix
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
.get("/omnichannel", async ({ query }) => {
|
||||
const search = (query.search || "").trim().toLowerCase();
|
||||
const channel = (query.channel || "all").trim().toLowerCase();
|
||||
|
||||
// 1. Fetch mapped marketplace products with master variants & BOM items
|
||||
const mpProducts = await prisma.marketplaceProduct.findMany({
|
||||
where: {
|
||||
masterVariantId: { not: null },
|
||||
...(channel !== "all" ? { channel } : {}),
|
||||
},
|
||||
include: {
|
||||
masterVariant: {
|
||||
include: {
|
||||
product: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
bomItems: {
|
||||
include: { rawMaterial: { select: { pricePerUnit: true, name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { totalRevenue: "desc" },
|
||||
});
|
||||
|
||||
// Marketplace estimated fee rates
|
||||
const CHANNEL_FEES: Record<string, { commissionPercent: number; servicePercent: number; paymentPercent: number }> = {
|
||||
shopee: { commissionPercent: 8.0, servicePercent: 5.0, paymentPercent: 3.21 }, // ~16.21% total standard Shopee
|
||||
lazada: { commissionPercent: 6.0, servicePercent: 4.0, paymentPercent: 3.21 }, // ~13.21%
|
||||
tiktok: { commissionPercent: 6.0, servicePercent: 4.5, paymentPercent: 3.0 }, // ~13.5%
|
||||
};
|
||||
|
||||
let totalGrossRevenue = 0;
|
||||
let totalEstNetRevenue = 0;
|
||||
let totalBOMCost = 0;
|
||||
let totalEstNetProfit = 0;
|
||||
|
||||
const channelStats: Record<string, { soldQty: number; revenue: number; bomCost: number; netProfit: number }> = {
|
||||
shopee: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
||||
lazada: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
||||
tiktok: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
||||
};
|
||||
|
||||
const mappedItems = mpProducts.map((item) => {
|
||||
const v = item.masterVariant!;
|
||||
const unitBomCost = (v.bomItems || []).reduce(
|
||||
(sum, b) => sum + b.quantity * (b.rawMaterial?.pricePerUnit || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const itemBomCost = Math.round(unitBomCost * item.totalSoldQty * 100) / 100;
|
||||
const ch = item.channel.toLowerCase();
|
||||
const feeConfig = CHANNEL_FEES[ch] || { commissionPercent: 7.0, servicePercent: 4.0, paymentPercent: 3.0 };
|
||||
const totalFeePercent = feeConfig.commissionPercent + feeConfig.servicePercent + feeConfig.paymentPercent;
|
||||
|
||||
const estPlatformFee = Math.round(item.totalRevenue * (totalFeePercent / 100) * 100) / 100;
|
||||
const netRevenue = Math.round((item.totalRevenue - estPlatformFee) * 100) / 100;
|
||||
const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100;
|
||||
const netMarginPercent = item.totalRevenue > 0 ? Math.round((netProfit / item.totalRevenue) * 1000) / 10 : 0;
|
||||
|
||||
// Direct comparison: what if sold directly on website/POS at retail price?
|
||||
const directPrice = v.price || item.avgPrice || 0;
|
||||
const directGrossProfit = directPrice - unitBomCost;
|
||||
const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0;
|
||||
|
||||
// Accumulate
|
||||
totalGrossRevenue += item.totalRevenue;
|
||||
totalEstNetRevenue += netRevenue;
|
||||
totalBOMCost += itemBomCost;
|
||||
totalEstNetProfit += netProfit;
|
||||
|
||||
if (channelStats[ch]) {
|
||||
channelStats[ch].soldQty += item.totalSoldQty;
|
||||
channelStats[ch].revenue += item.totalRevenue;
|
||||
channelStats[ch].bomCost += itemBomCost;
|
||||
channelStats[ch].netProfit += netProfit;
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
channel: item.channel,
|
||||
shop: item.shop,
|
||||
marketplaceSku: item.marketplaceSku,
|
||||
marketplaceProductName: item.productName,
|
||||
variation: item.variation,
|
||||
avgSellingPrice: item.avgPrice,
|
||||
totalSoldQty: item.totalSoldQty,
|
||||
totalRevenue: item.totalRevenue,
|
||||
masterVariantId: v.id,
|
||||
masterSku: v.sku,
|
||||
productName: v.product?.name || "Product",
|
||||
categoryName: v.product?.category?.name || "Uncategorized",
|
||||
unitBomCost: Math.round(unitBomCost * 100) / 100,
|
||||
totalBomCost: itemBomCost,
|
||||
totalFeePercent,
|
||||
estPlatformFee,
|
||||
netRevenue,
|
||||
netProfit,
|
||||
netMarginPercent,
|
||||
directPrice,
|
||||
directMarginPercent,
|
||||
};
|
||||
});
|
||||
|
||||
let filtered = mappedItems;
|
||||
if (search) {
|
||||
filtered = filtered.filter(
|
||||
(m) =>
|
||||
m.marketplaceProductName.toLowerCase().includes(search) ||
|
||||
m.productName.toLowerCase().includes(search) ||
|
||||
m.masterSku?.toLowerCase().includes(search) ||
|
||||
m.variation.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
const overallNetMargin =
|
||||
totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0;
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
totalMappedProducts: mpProducts.length,
|
||||
totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100,
|
||||
totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100,
|
||||
totalBOMCost: Math.round(totalBOMCost * 100) / 100,
|
||||
totalEstNetProfit: Math.round(totalEstNetProfit * 100) / 100,
|
||||
overallNetMargin,
|
||||
channelStats,
|
||||
},
|
||||
items: filtered,
|
||||
};
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. MRP & Material Requirement Planning (Reverse BOM Explosion)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
.get("/mrp", async ({ query }) => {
|
||||
// 1. Fetch all raw materials with unit and price
|
||||
const rawMaterials = await prisma.rawMaterial.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
bomItems: {
|
||||
include: {
|
||||
productVariant: {
|
||||
include: {
|
||||
product: { select: { id: true, name: true, category: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Fetch sales quantities per Master Variant across Marketplace Products & Orders
|
||||
const mpProducts = await prisma.marketplaceProduct.findMany({
|
||||
where: { masterVariantId: { not: null } },
|
||||
select: {
|
||||
masterVariantId: true,
|
||||
totalSoldQty: true,
|
||||
totalRevenue: true,
|
||||
},
|
||||
});
|
||||
|
||||
const variantSoldMap = new Map<number, number>();
|
||||
for (const mp of mpProducts) {
|
||||
if (mp.masterVariantId) {
|
||||
const current = variantSoldMap.get(mp.masterVariantId) || 0;
|
||||
variantSoldMap.set(mp.masterVariantId, current + mp.totalSoldQty);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Reverse Explosion: For each Raw Material, calculate total consumption
|
||||
let totalMaterialCostConsumed = 0;
|
||||
const materialUsageList = rawMaterials.map((rm) => {
|
||||
let totalConsumedQty = 0;
|
||||
const associatedProductsMap = new Map<string, { qty: number; variantCount: number }>();
|
||||
|
||||
for (const b of rm.bomItems) {
|
||||
const vId = b.productVariantId;
|
||||
const unitsSold = variantSoldMap.get(vId) || 0;
|
||||
const materialUsedForVariant = unitsSold * b.quantity;
|
||||
totalConsumedQty += materialUsedForVariant;
|
||||
|
||||
const prodName = b.productVariant.product?.name || "Product";
|
||||
const entry = associatedProductsMap.get(prodName) || { qty: 0, variantCount: 0 };
|
||||
entry.qty += materialUsedForVariant;
|
||||
entry.variantCount++;
|
||||
associatedProductsMap.set(prodName, entry);
|
||||
}
|
||||
|
||||
const totalCost = Math.round(totalConsumedQty * rm.pricePerUnit * 100) / 100;
|
||||
totalMaterialCostConsumed += totalCost;
|
||||
|
||||
const topProducts = Array.from(associatedProductsMap.entries())
|
||||
.map(([name, stat]) => ({ name, qty: Math.round(stat.qty * 10) / 10 }))
|
||||
.sort((a, b) => b.qty - a.qty)
|
||||
.slice(0, 3);
|
||||
|
||||
const isLiquid =
|
||||
rm.unit === "ml" ||
|
||||
rm.name.includes("หัวน้ำหอม") ||
|
||||
rm.name.includes("DPG") ||
|
||||
rm.name.includes("DPM");
|
||||
|
||||
return {
|
||||
id: rm.id,
|
||||
name: rm.name,
|
||||
unit: rm.unit,
|
||||
pricePerUnit: rm.pricePerUnit,
|
||||
isLiquid,
|
||||
linkedVariantsCount: rm.bomItems.length,
|
||||
totalConsumedQty: Math.round(totalConsumedQty * 100) / 100,
|
||||
totalConsumedCost: totalCost,
|
||||
topProducts,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by consumption cost descending (high-burn materials first)
|
||||
materialUsageList.sort((a, b) => b.totalConsumedCost - a.totalConsumedCost);
|
||||
|
||||
const fragranceMaterials = materialUsageList.filter((m) => m.name.includes("หัวน้ำหอม"));
|
||||
const packagingMaterials = materialUsageList.filter((m) => !m.isLiquid);
|
||||
const solventMaterials = materialUsageList.filter((m) => m.name.includes("DPG") || m.name.includes("DPM"));
|
||||
|
||||
return {
|
||||
kpis: {
|
||||
totalRawMaterials: rawMaterials.length,
|
||||
totalMaterialCostConsumed: Math.round(totalMaterialCostConsumed * 100) / 100,
|
||||
topFragrancesByCost: fragranceMaterials.slice(0, 5),
|
||||
topPackagingByCost: packagingMaterials.slice(0, 5),
|
||||
},
|
||||
categories: {
|
||||
fragrances: fragranceMaterials,
|
||||
solvents: solventMaterials,
|
||||
packaging: packagingMaterials,
|
||||
},
|
||||
allMaterials: materialUsageList,
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user