803 lines
32 KiB
TypeScript
803 lines
32 KiB
TypeScript
import { Elysia, t } from "elysia";
|
|
import prisma from "../lib/prisma";
|
|
|
|
// ── Date Range Helper ────────────────────────────────────────────────────────
|
|
interface DateRangeInfo {
|
|
start: Date | null;
|
|
end: Date | null;
|
|
label: string;
|
|
daysCount: number;
|
|
period: string;
|
|
}
|
|
|
|
function resolveDateRange(period?: string, startStr?: string, endStr?: string): DateRangeInfo {
|
|
const p = (period || "all").trim().toLowerCase();
|
|
const now = new Date();
|
|
|
|
if (p === "7d") {
|
|
const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
return { period: p, start, end: now, label: "7 วันล่าสุด (Last 7 Days)", daysCount: 7 };
|
|
}
|
|
if (p === "30d") {
|
|
const start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
return { period: p, start, end: now, label: "30 วันล่าสุด (Last 30 Days)", daysCount: 30 };
|
|
}
|
|
if (p === "60d") {
|
|
const start = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000);
|
|
return { period: p, start, end: now, label: "60 วันล่าสุด (Last 60 Days)", daysCount: 60 };
|
|
}
|
|
if (p === "90d") {
|
|
const start = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
|
return { period: p, start, end: now, label: "90 วันล่าสุด (Last 90 Days)", daysCount: 90 };
|
|
}
|
|
if (p === "this_month") {
|
|
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
const days = Math.ceil((now.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) || 1;
|
|
return { period: p, start, end: now, label: "เดือนนี้ (This Month)", daysCount: days };
|
|
}
|
|
if (p === "last_month") {
|
|
const start = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
const end = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999);
|
|
const days = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) || 30;
|
|
return { period: p, start, end, label: "เดือนที่แล้ว (Last Month)", daysCount: days };
|
|
}
|
|
if (p === "custom" && startStr) {
|
|
const start = new Date(startStr);
|
|
const end = endStr ? new Date(`${endStr}T23:59:59.999Z`) : now;
|
|
const days = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)));
|
|
return { period: p, start, end, label: `${startStr} ถึง ${endStr || "ปัจจุบัน"}`, daysCount: days };
|
|
}
|
|
|
|
// Default: All Time
|
|
return { period: "all", start: null, end: null, label: "ทั้งหมด (All Time)", daysCount: 365 };
|
|
}
|
|
|
|
function parseOrderDate(dateStr: string | Date | null | undefined): Date | null {
|
|
if (!dateStr) return null;
|
|
if (dateStr instanceof Date) return dateStr;
|
|
const cleaned = String(dateStr).trim().replace(/\//g, "-");
|
|
const d = new Date(cleaned);
|
|
return isNaN(d.getTime()) ? null : d;
|
|
}
|
|
|
|
export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 1. Cost & Profitability Matrix (Catalog-Level Baseline)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
.get(
|
|
"/matrix",
|
|
async ({ query }) => {
|
|
const search = (query.search || "").trim();
|
|
const category = (query.category || "all").trim();
|
|
const marginFilter = (query.margin || "all").trim();
|
|
const sortBy = query.sortBy || "margin_desc";
|
|
const page = query.page ? parseInt(query.page) : 1;
|
|
const limit = query.limit ? parseInt(query.limit) : 50;
|
|
|
|
// 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" },
|
|
],
|
|
});
|
|
|
|
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;
|
|
|
|
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,
|
|
};
|
|
});
|
|
|
|
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;
|
|
|
|
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,
|
|
}));
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
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 (Supports Date Range Filtering)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
.get("/omnichannel", async ({ query }) => {
|
|
const search = (query.search || "").trim().toLowerCase();
|
|
const channel = (query.channel || "all").trim().toLowerCase();
|
|
const dateRange = resolveDateRange(query.period, query.startDate, query.endDate);
|
|
|
|
// 1. Fetch mapped marketplace products with master variants & BOM
|
|
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" },
|
|
});
|
|
|
|
const CHANNEL_FEES: Record<string, { commissionPercent: number; servicePercent: number; paymentPercent: number }> = {
|
|
shopee: { commissionPercent: 8.0, servicePercent: 5.0, paymentPercent: 3.21 }, // ~16.21%
|
|
lazada: { commissionPercent: 6.0, servicePercent: 4.0, paymentPercent: 3.21 }, // ~13.21%
|
|
tiktok: { commissionPercent: 6.0, servicePercent: 4.5, paymentPercent: 3.0 }, // ~13.5%
|
|
};
|
|
|
|
// 2. If a specific Date Range is selected, calculate dynamic sales from Orders
|
|
let dynamicSalesMap: Map<number, { soldQty: number; revenue: number }> | null = null;
|
|
|
|
if (dateRange.start) {
|
|
dynamicSalesMap = new Map();
|
|
const start = dateRange.start;
|
|
const end = dateRange.end || new Date();
|
|
|
|
// Look up helper: map marketplace item keys to marketplaceProduct.id
|
|
const mpLookup = new Map<string, number>();
|
|
for (const mp of mpProducts) {
|
|
const key = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`;
|
|
mpLookup.set(key, mp.id);
|
|
if (mp.marketplaceSku) {
|
|
mpLookup.set(`${mp.channel}:::${mp.marketplaceSku}`, mp.id);
|
|
}
|
|
}
|
|
|
|
// A. TikTok Orders in Range
|
|
if (channel === "all" || channel === "tiktok") {
|
|
const ttOrders = await prisma.tiktokOrder.findMany({
|
|
where: { createdTime: { not: null } },
|
|
select: {
|
|
createdTime: true,
|
|
shop: true,
|
|
items: {
|
|
select: {
|
|
sellerSku: true,
|
|
skuId: true,
|
|
productName: true,
|
|
variation: true,
|
|
quantity: true,
|
|
subtotalAfterDiscount: true,
|
|
unitOriginalPrice: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
for (const order of ttOrders) {
|
|
const orderDate = parseOrderDate(order.createdTime);
|
|
if (!orderDate || orderDate < start || orderDate > end) continue;
|
|
|
|
for (const item of order.items) {
|
|
const sku = item.sellerSku || item.skuId || "";
|
|
const key1 = `tiktok:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variation || ""}:::${sku}`;
|
|
const key2 = `tiktok:::${sku}`;
|
|
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
|
if (!mpId) continue;
|
|
|
|
const qty = item.quantity || 1;
|
|
const rev = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
|
|
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
|
|
cur.soldQty += qty;
|
|
cur.revenue += rev;
|
|
dynamicSalesMap.set(mpId, cur);
|
|
}
|
|
}
|
|
}
|
|
|
|
// B. Shopee Orders in Range
|
|
if (channel === "all" || channel === "shopee") {
|
|
const spOrders = await prisma.shopeeOrder.findMany({
|
|
where: { orderCreatedAt: { not: null } },
|
|
select: {
|
|
orderCreatedAt: true,
|
|
shop: true,
|
|
items: {
|
|
select: {
|
|
parentSku: true,
|
|
skuRef: true,
|
|
productName: true,
|
|
variantName: true,
|
|
quantity: true,
|
|
totalPayment: true,
|
|
dealPrice: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
for (const order of spOrders) {
|
|
const orderDate = parseOrderDate(order.orderCreatedAt);
|
|
if (!orderDate || orderDate < start || orderDate > end) continue;
|
|
|
|
for (const item of order.items) {
|
|
const sku = item.skuRef || item.parentSku || "";
|
|
const key1 = `shopee:::${order.shop || "hom-aroma"}:::${item.productName}:::${item.variantName || ""}:::${sku}`;
|
|
const key2 = `shopee:::${sku}`;
|
|
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
|
if (!mpId) continue;
|
|
|
|
const qty = item.quantity || 1;
|
|
const rev = item.totalPayment > 0 ? item.totalPayment : (item.dealPrice * qty);
|
|
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
|
|
cur.soldQty += qty;
|
|
cur.revenue += rev;
|
|
dynamicSalesMap.set(mpId, cur);
|
|
}
|
|
}
|
|
}
|
|
|
|
// C. Lazada Orders in Range
|
|
if (channel === "all" || channel === "lazada") {
|
|
const lzOrders = await prisma.lazadaOrder.findMany({
|
|
where: { createTime: { not: null } },
|
|
select: {
|
|
createTime: true,
|
|
shop: true,
|
|
items: {
|
|
select: {
|
|
sellerSku: true,
|
|
lazadaSku: true,
|
|
itemName: true,
|
|
variation: true,
|
|
paidPrice: true,
|
|
unitPrice: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
for (const order of lzOrders) {
|
|
const orderDate = parseOrderDate(order.createTime);
|
|
if (!orderDate || orderDate < start || orderDate > end) continue;
|
|
|
|
for (const item of order.items) {
|
|
const sku = item.sellerSku || item.lazadaSku || "";
|
|
const key1 = `lazada:::${order.shop || "hom-aroma"}:::${item.itemName}:::${item.variation || ""}:::${sku}`;
|
|
const key2 = `lazada:::${sku}`;
|
|
const mpId = mpLookup.get(key1) || (sku ? mpLookup.get(key2) : null);
|
|
if (!mpId) continue;
|
|
|
|
const qty = 1;
|
|
const rev = item.paidPrice > 0 ? item.paidPrice : item.unitPrice;
|
|
const cur = dynamicSalesMap.get(mpId) || { soldQty: 0, revenue: 0 };
|
|
cur.soldQty += qty;
|
|
cur.revenue += rev;
|
|
dynamicSalesMap.set(mpId, cur);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Map Items & Accumulate KPIs
|
|
let totalGrossRevenue = 0;
|
|
let totalEstNetRevenue = 0;
|
|
let totalBOMCost = 0;
|
|
let totalEstNetProfit = 0;
|
|
let totalUnitsSold = 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: any[] = [];
|
|
|
|
for (const item of mpProducts) {
|
|
let soldQty = item.totalSoldQty;
|
|
let rev = item.totalRevenue;
|
|
|
|
// If dynamic date range active, use filtered order numbers
|
|
if (dynamicSalesMap) {
|
|
const dyn = dynamicSalesMap.get(item.id);
|
|
if (!dyn || dyn.soldQty === 0) continue; // Skip items with no sales in this date range
|
|
soldQty = dyn.soldQty;
|
|
rev = dyn.revenue;
|
|
}
|
|
|
|
const v = item.masterVariant!;
|
|
const unitBomCost = (v.bomItems || []).reduce(
|
|
(sum, b) => sum + b.quantity * (b.rawMaterial?.pricePerUnit || 0),
|
|
0
|
|
);
|
|
|
|
const itemBomCost = Math.round(unitBomCost * soldQty * 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(rev * (totalFeePercent / 100) * 100) / 100;
|
|
const netRevenue = Math.round((rev - estPlatformFee) * 100) / 100;
|
|
const netProfit = Math.round((netRevenue - itemBomCost) * 100) / 100;
|
|
const netMarginPercent = rev > 0 ? Math.round((netProfit / rev) * 1000) / 10 : 0;
|
|
|
|
const avgSellingPrice = soldQty > 0 ? Math.round((rev / soldQty) * 100) / 100 : item.avgPrice;
|
|
const directPrice = v.price || avgSellingPrice || 0;
|
|
const directGrossProfit = directPrice - unitBomCost;
|
|
const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0;
|
|
|
|
totalGrossRevenue += rev;
|
|
totalEstNetRevenue += netRevenue;
|
|
totalBOMCost += itemBomCost;
|
|
totalEstNetProfit += netProfit;
|
|
totalUnitsSold += soldQty;
|
|
|
|
if (channelStats[ch]) {
|
|
channelStats[ch].soldQty += soldQty;
|
|
channelStats[ch].revenue += rev;
|
|
channelStats[ch].bomCost += itemBomCost;
|
|
channelStats[ch].netProfit += netProfit;
|
|
}
|
|
|
|
mappedItems.push({
|
|
id: item.id,
|
|
channel: item.channel,
|
|
shop: item.shop,
|
|
marketplaceSku: item.marketplaceSku,
|
|
marketplaceProductName: item.productName,
|
|
variation: item.variation,
|
|
avgSellingPrice,
|
|
totalSoldQty: soldQty,
|
|
totalRevenue: rev,
|
|
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 {
|
|
periodInfo: {
|
|
period: dateRange.period,
|
|
label: dateRange.label,
|
|
startDate: dateRange.start ? dateRange.start.toISOString().slice(0, 10) : null,
|
|
endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null,
|
|
daysCount: dateRange.daysCount,
|
|
},
|
|
kpis: {
|
|
totalMappedProducts: mappedItems.length,
|
|
totalUnitsSold,
|
|
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 (Supports Date Range & Forecasting)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
.get("/mrp", async ({ query }) => {
|
|
const dateRange = resolveDateRange(query.period, query.startDate, query.endDate);
|
|
|
|
// 1. Fetch raw materials
|
|
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
|
|
const variantSoldMap = new Map<number, number>();
|
|
|
|
if (!dateRange.start) {
|
|
// All Time: Aggregate from MarketplaceProduct
|
|
const mpProducts = await prisma.marketplaceProduct.findMany({
|
|
where: { masterVariantId: { not: null } },
|
|
select: { masterVariantId: true, totalSoldQty: true },
|
|
});
|
|
for (const mp of mpProducts) {
|
|
if (mp.masterVariantId) {
|
|
const current = variantSoldMap.get(mp.masterVariantId) || 0;
|
|
variantSoldMap.set(mp.masterVariantId, current + mp.totalSoldQty);
|
|
}
|
|
}
|
|
} else {
|
|
// Date Range Filter: Aggregate from actual Order items
|
|
const start = dateRange.start;
|
|
const end = dateRange.end || new Date();
|
|
|
|
// Look up helper: maps sellerSku / SKU to masterVariantId
|
|
const variants = await prisma.productVariant.findMany({
|
|
select: { id: true, sku: true, barcode: true },
|
|
});
|
|
const skuMap = new Map<string, number>();
|
|
for (const v of variants) {
|
|
if (v.sku) skuMap.set(v.sku.trim().toLowerCase(), v.id);
|
|
if (v.barcode) skuMap.set(v.barcode.trim().toLowerCase(), v.id);
|
|
}
|
|
|
|
const mps = await prisma.marketplaceProduct.findMany({
|
|
where: { masterVariantId: { not: null } },
|
|
select: { channel: true, shop: true, productName: true, variation: true, marketplaceSku: true, masterVariantId: true },
|
|
});
|
|
const mpKeyMap = new Map<string, number>();
|
|
for (const mp of mps) {
|
|
const k = `${mp.channel}:::${mp.shop}:::${mp.productName}:::${mp.variation}:::${mp.marketplaceSku}`;
|
|
mpKeyMap.set(k, mp.masterVariantId!);
|
|
if (mp.marketplaceSku) mpKeyMap.set(`${mp.channel}:::${mp.marketplaceSku.trim().toLowerCase()}`, mp.masterVariantId!);
|
|
}
|
|
|
|
// Scan TikTok Orders
|
|
const ttOrders = await prisma.tiktokOrder.findMany({
|
|
where: { createdTime: { not: null } },
|
|
select: {
|
|
createdTime: true,
|
|
shop: true,
|
|
items: { select: { sellerSku: true, skuId: true, productName: true, variation: true, quantity: true } },
|
|
},
|
|
});
|
|
for (const o of ttOrders) {
|
|
const d = parseOrderDate(o.createdTime);
|
|
if (!d || d < start || d > end) continue;
|
|
for (const it of o.items) {
|
|
const sku = (it.sellerSku || it.skuId || "").trim().toLowerCase();
|
|
const k = `tiktok:::${o.shop || "hom-aroma"}:::${it.productName}:::${it.variation || ""}:::${sku}`;
|
|
const vId = mpKeyMap.get(k) || (sku ? mpKeyMap.get(`tiktok:::${sku}`) || skuMap.get(sku) : null);
|
|
if (vId) {
|
|
variantSoldMap.set(vId, (variantSoldMap.get(vId) || 0) + (it.quantity || 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scan Shopee Orders
|
|
const spOrders = await prisma.shopeeOrder.findMany({
|
|
where: { orderCreatedAt: { not: null } },
|
|
select: {
|
|
orderCreatedAt: true,
|
|
shop: true,
|
|
items: { select: { parentSku: true, skuRef: true, productName: true, variantName: true, quantity: true } },
|
|
},
|
|
});
|
|
for (const o of spOrders) {
|
|
const d = parseOrderDate(o.orderCreatedAt);
|
|
if (!d || d < start || d > end) continue;
|
|
for (const it of o.items) {
|
|
const sku = (it.skuRef || it.parentSku || "").trim().toLowerCase();
|
|
const k = `shopee:::${o.shop || "hom-aroma"}:::${it.productName}:::${it.variantName || ""}:::${sku}`;
|
|
const vId = mpKeyMap.get(k) || (sku ? mpKeyMap.get(`shopee:::${sku}`) || skuMap.get(sku) : null);
|
|
if (vId) {
|
|
variantSoldMap.set(vId, (variantSoldMap.get(vId) || 0) + (it.quantity || 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scan Lazada Orders
|
|
const lzOrders = await prisma.lazadaOrder.findMany({
|
|
where: { createTime: { not: null } },
|
|
select: {
|
|
createTime: true,
|
|
shop: true,
|
|
items: { select: { sellerSku: true, lazadaSku: true, itemName: true, variation: true } },
|
|
},
|
|
});
|
|
for (const o of lzOrders) {
|
|
const d = parseOrderDate(o.createTime);
|
|
if (!d || d < start || d > end) continue;
|
|
for (const it of o.items) {
|
|
const sku = (it.sellerSku || it.lazadaSku || "").trim().toLowerCase();
|
|
const k = `lazada:::${o.shop || "hom-aroma"}:::${it.itemName}:::${it.variation || ""}:::${sku}`;
|
|
const vId = mpKeyMap.get(k) || (sku ? mpKeyMap.get(`lazada:::${sku}`) || skuMap.get(sku) : null);
|
|
if (vId) {
|
|
variantSoldMap.set(vId, (variantSoldMap.get(vId) || 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Reverse Explosion: For each Raw Material, compute consumption and 30-day forecast
|
|
let totalMaterialCostConsumed = 0;
|
|
const daysCount = dateRange.daysCount || 30;
|
|
|
|
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;
|
|
|
|
// Forecast Metrics
|
|
const dailyBurnRate = Math.round((totalConsumedQty / daysCount) * 100) / 100;
|
|
const forecast30DaysQty = Math.round(dailyBurnRate * 30 * 10) / 10;
|
|
const forecast30DaysCost = Math.round(forecast30DaysQty * rm.pricePerUnit * 100) / 100;
|
|
|
|
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,
|
|
dailyBurnRate,
|
|
forecast30DaysQty,
|
|
forecast30DaysCost,
|
|
topProducts,
|
|
};
|
|
});
|
|
|
|
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 {
|
|
periodInfo: {
|
|
period: dateRange.period,
|
|
label: dateRange.label,
|
|
startDate: dateRange.start ? dateRange.start.toISOString().slice(0, 10) : null,
|
|
endDate: dateRange.end ? dateRange.end.toISOString().slice(0, 10) : null,
|
|
daysCount,
|
|
},
|
|
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,
|
|
};
|
|
});
|