From b8bb1d4617d445c49317ba3ccd0613feb05781db Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 18 Oct 2026 19:10:22 +0700 Subject: [PATCH] feat(profitability): add date range filtering and 30-day MRP inventory forecast --- .../pages/profitability/DateRangeSelector.tsx | 110 +++++ .../pages/profitability/MrpPlanningPage.tsx | 163 ++++--- .../profitability/OmnichannelMarginPage.tsx | 165 ++++--- apps/backend/src/routes/profitability.ts | 431 +++++++++++++++--- 4 files changed, 690 insertions(+), 179 deletions(-) create mode 100644 apps/admin/src/pages/profitability/DateRangeSelector.tsx diff --git a/apps/admin/src/pages/profitability/DateRangeSelector.tsx b/apps/admin/src/pages/profitability/DateRangeSelector.tsx new file mode 100644 index 0000000..2fcdccd --- /dev/null +++ b/apps/admin/src/pages/profitability/DateRangeSelector.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { Calendar, Clock, ChevronDown } from 'lucide-react'; + +export interface DateFilterState { + period: string; // 'all' | '7d' | '30d' | '90d' | 'this_month' | 'last_month' | 'custom' + startDate?: string; + endDate?: string; +} + +interface Props { + value: DateFilterState; + onChange: (val: DateFilterState) => void; + label?: string; +} + +export default function DateRangeSelector({ value, onChange }: Props) { + const [isCustomOpen, setIsCustomOpen] = useState(value.period === 'custom'); + const [customStart, setCustomStart] = useState(value.startDate || ''); + const [customEnd, setCustomEnd] = useState(value.endDate || ''); + + const presets = [ + { id: 'all', label: 'ทั้งหมด (All Time)' }, + { id: '7d', label: '7 วันล่าสุด' }, + { id: '30d', label: '30 วันล่าสุด' }, + { id: '90d', label: '90 วันล่าสุด' }, + { id: 'this_month', label: 'เดือนนี้' }, + { id: 'last_month', label: 'เดือนที่แล้ว' }, + ]; + + const handleSelectPreset = (id: string) => { + setIsCustomOpen(false); + onChange({ period: id }); + }; + + const handleApplyCustom = () => { + if (!customStart) return; + onChange({ + period: 'custom', + startDate: customStart, + endDate: customEnd, + }); + }; + + return ( +
+
+ + ช่วงเวลา: + {presets.map((p) => { + const isActive = value.period === p.id; + return ( + + ); + })} + + +
+ + {/* Custom Date Range Popover / Inline */} + {isCustomOpen && ( +
+ setCustomStart(e.target.value)} + className="px-2 py-1 rounded-lg bg-slate-800 border border-white/10 text-white focus:outline-none focus:border-purple-500" + /> + ถึง + setCustomEnd(e.target.value)} + className="px-2 py-1 rounded-lg bg-slate-800 border border-white/10 text-white focus:outline-none focus:border-purple-500" + /> + +
+ )} +
+ ); +} diff --git a/apps/admin/src/pages/profitability/MrpPlanningPage.tsx b/apps/admin/src/pages/profitability/MrpPlanningPage.tsx index ce6dde8..2123110 100644 --- a/apps/admin/src/pages/profitability/MrpPlanningPage.tsx +++ b/apps/admin/src/pages/profitability/MrpPlanningPage.tsx @@ -4,12 +4,14 @@ import { Download, Package, RefreshCw, - Droplets, Box, Flame, + Calendar, + TrendingUp, } from 'lucide-react'; import { API_BASE } from '@homaroma/types'; import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs'; +import DateRangeSelector, { type DateFilterState } from './DateRangeSelector'; function fmtMoney(val: number | null | undefined): string { if (val == null || isNaN(val)) return '฿0.00'; @@ -41,9 +43,20 @@ interface RawMaterialUsageItem { linkedVariantsCount: number; totalConsumedQty: number; totalConsumedCost: number; + dailyBurnRate: number; + forecast30DaysQty: number; + forecast30DaysCost: number; topProducts: TopProductUsage[]; } +interface PeriodInfo { + period: string; + label: string; + startDate: string | null; + endDate: string | null; + daysCount: number; +} + interface MrpKpis { totalRawMaterials: number; totalMaterialCostConsumed: number; @@ -54,6 +67,7 @@ interface MrpKpis { export default function MrpPlanningPage() { const [loading, setLoading] = useState(true); const [data, setData] = useState<{ + periodInfo: PeriodInfo; kpis: MrpKpis; categories: { fragrances: RawMaterialUsageItem[]; @@ -66,12 +80,19 @@ export default function MrpPlanningPage() { // Filters const [search, setSearch] = useState(''); const [activeTab, setActiveTab] = useState<'all' | 'fragrances' | 'solvents' | 'packaging'>('all'); + const [dateFilter, setDateFilter] = useState({ period: '30d' }); // Default 30d for MRP const [sortBy, setSortBy] = useState('cost_desc'); const fetchData = async () => { try { setLoading(true); - const res = await fetch(`${API_BASE}/profitability/mrp`); + const params = new URLSearchParams({ + period: dateFilter.period, + ...(dateFilter.startDate ? { startDate: dateFilter.startDate } : {}), + ...(dateFilter.endDate ? { endDate: dateFilter.endDate } : {}), + }); + + const res = await fetch(`${API_BASE}/profitability/mrp?${params.toString()}`); if (!res.ok) throw new Error(`HTTP error ${res.status}`); const json = await res.json(); setData(json); @@ -84,7 +105,7 @@ export default function MrpPlanningPage() { useEffect(() => { fetchData(); - }, []); + }, [dateFilter]); const filteredItems = useMemo(() => { if (!data) return []; @@ -103,6 +124,7 @@ export default function MrpPlanningPage() { const sorted = [...list]; sorted.sort((a, b) => { if (sortBy === 'cost_desc') return b.totalConsumedCost - a.totalConsumedCost; + if (sortBy === 'forecast_desc') return b.forecast30DaysCost - a.forecast30DaysCost; if (sortBy === 'qty_desc') return b.totalConsumedQty - a.totalConsumedQty; if (sortBy === 'name_asc') return a.name.localeCompare(b.name); if (sortBy === 'price_desc') return b.pricePerUnit - a.pricePerUnit; @@ -112,6 +134,12 @@ export default function MrpPlanningPage() { return sorted; }, [data, activeTab, search, sortBy]); + // Total forecast procurement budget + const totalForecastBudget = useMemo(() => { + if (!data?.allMaterials) return 0; + return data.allMaterials.reduce((sum, m) => sum + m.forecast30DaysCost, 0); + }, [data?.allMaterials]); + const handleExportCsv = () => { if (!filteredItems.length) return; const headers = [ @@ -121,8 +149,11 @@ export default function MrpPlanningPage() { 'Unit Cost (THB)', 'Type', 'Linked BOM Formulas', - 'Total Consumed Qty', - 'Total Consumed Cost (THB)', + `Consumed Qty (${data?.periodInfo?.label || 'Period'})`, + 'Consumed Cost (THB)', + 'Daily Burn Rate', + '30-Day Forecast Reorder Qty', + '30-Day Forecast Budget (THB)', 'Top Consumer Products', ]; @@ -135,6 +166,9 @@ export default function MrpPlanningPage() { i.linkedVariantsCount, i.totalConsumedQty, i.totalConsumedCost, + i.dailyBurnRate, + i.forecast30DaysQty, + i.forecast30DaysCost, `"${i.topProducts.map((p) => `${p.name} (${p.qty} ${i.unit})`).join('; ')}"`, ]); @@ -143,22 +177,42 @@ export default function MrpPlanningPage() { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.setAttribute('download', `HomAroma_MRP_Material_Demand_${new Date().toISOString().slice(0, 10)}.csv`); + link.setAttribute('download', `HomAroma_MRP_Material_Demand_${dateFilter.period}_${new Date().toISOString().slice(0, 10)}.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const kpis = data?.kpis; + const periodInfo = data?.periodInfo; return (
{/* Shared Header Navigation */} + {/* ── Filter Bar: Date Range + Search ────────────────────────────── */} +
+
+ {/* Date Range Selector */} + + + {/* Active Period Indicator */} + {periodInfo && ( +
+ + ช่วงวิเคราะห์สต็อก: {periodInfo.label} + {periodInfo.daysCount && ( + ({periodInfo.daysCount} วัน) + )} +
+ )} +
+
+ {/* ── Executive KPI Cards ────────────────────────────────────────── */}
{/* Total Raw Materials */} @@ -175,47 +229,34 @@ export default function MrpPlanningPage() {

- {/* Total Material Cost Consumed */} + {/* Material Cost Consumed in Period */}
- Total Raw Material Burned + Consumed Cost ({periodInfo?.label || 'Period'})
{fmtMoney(kpis?.totalMaterialCostConsumed)}
-

มูลค่ายอดใช้วัตถุดิบรวมจากการผลิตขายจริง

+

มูลค่ายอดใช้วัตถุดิบจริงในช่วงเวลาที่เลือก

- {/* Top Fragrance Burn Driver */} + {/* 30-Day Forecast Procurement Budget */}
-
- #1 Cost Driver (Fragrance) +
+ 30-Day Forecast Budget
- +
- {kpis?.topFragrancesByCost?.[0] ? ( -
-
{kpis.topFragrancesByCost[0].name}
-
- - {fmtNum(kpis.topFragrancesByCost[0].totalConsumedQty)} ml - - - {fmtMoney(kpis.topFragrancesByCost[0].totalConsumedCost)} - -
-
- ) : ( -
-
- )} +
{fmtMoney(totalForecastBudget)}
+

งบประมาณสั่งซื้อวัตถุดิบ 30 วันข้างหน้า (อิง Run-rate)

- {/* Top Packaging Burn Driver */} + {/* Top Packaging Burn */}
- #1 Packaging Volume + Top Volume Driver
@@ -238,7 +279,7 @@ export default function MrpPlanningPage() {
- {/* ── Category Tabs & Filter Bar ─────────────────────────────────── */} + {/* ── Category Sub-Tabs & Search ──────────────────────────────────── */}
{/* Sub Tabs */} @@ -310,6 +351,7 @@ export default function MrpPlanningPage() { className="px-3 py-1.5 rounded-xl bg-slate-800 border border-white/10 text-xs text-white focus:outline-none focus:border-cyan-500" > + @@ -337,47 +379,41 @@ export default function MrpPlanningPage() { ชื่อวัตถุดิบ (Raw Material) ประเภท ราคาต่อหน่วย - ผูกในสูตร (Variants) - ปริมาณที่ผลิตไปแล้ว - มูลค่าที่ใช้ไป (Total Value) - Burn Velocity - สินค้าหลักที่ใช้วัตถุดิบนี้ (Top Consumers) + ยอดใช้ในรอบ ({periodInfo?.label || 'Period'}) + มูลค่าใช้จริง + Daily Burn Rate + + พยากรณ์สั่งซื้อ 30 วัน + + + งบสั่งซื้อ 30 วัน + + สินค้าหลักที่ใช้ (Top Consumers) {loading ? ( - + - กำลังประมวลผลข้อมูลการใช้วัตถุดิบย้อนกลับ (Reverse Explosion)... + กำลังประมวลผลการคำนวณวัตถุดิบและพยากรณ์สต็อกล่วงหน้า... ) : filteredItems.length === 0 ? ( - - ไม่พบรายการวัตถุดิบที่ตรงกับเงื่อนไข + + ไม่พบรายการวัตถุดิบที่มียอดใช้ในช่วงเวลาที่เลือก ) : ( filteredItems.map((item) => { - let burnBadge = 'bg-slate-800 text-slate-400'; - let burnLabel = 'Low Burn'; - if (item.totalConsumedCost >= 20000) { - burnBadge = 'bg-rose-500/20 text-rose-400 border border-rose-500/30'; - burnLabel = '🔥 Very High'; - } else if (item.totalConsumedCost >= 5000) { - burnBadge = 'bg-amber-500/20 text-amber-400 border border-amber-500/30'; - burnLabel = '⚡ High Burn'; - } else if (item.totalConsumedCost > 0) { - burnBadge = 'bg-blue-500/20 text-blue-400 border border-blue-500/30'; - burnLabel = '💧 Moderate'; - } - return (
{item.name}
-
ID: #{item.id}
+
+ ID: #{item.id} • {item.linkedVariantsCount} สูตร +
{fmtMoney(item.pricePerUnit)} / {item.unit} - - {item.linkedVariantsCount} สูตร - {fmtNum(item.totalConsumedQty)} {item.unit} {fmtMoney(item.totalConsumedCost)} - - - {burnLabel} - + + {fmtNum(item.dailyBurnRate)} {item.unit}/วัน - + + {fmtNum(item.forecast30DaysQty)} {item.unit} + + + {fmtMoney(item.forecast30DaysCost)} + + {item.topProducts.length > 0 ? (
{item.topProducts.map((p, pIdx) => ( diff --git a/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx b/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx index 2b36444..31d8933 100644 --- a/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx +++ b/apps/admin/src/pages/profitability/OmnichannelMarginPage.tsx @@ -7,9 +7,11 @@ import { Percent, Coins, RefreshCw, + Calendar, } from 'lucide-react'; import { API_BASE } from '@homaroma/types'; import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs'; +import DateRangeSelector, { type DateFilterState } from './DateRangeSelector'; function fmtMoney(val: number | null | undefined): string { if (val == null || isNaN(val)) return '฿0.00'; @@ -64,8 +66,17 @@ interface ChannelStat { netProfit: number; } +interface PeriodInfo { + period: string; + label: string; + startDate: string | null; + endDate: string | null; + daysCount: number; +} + interface OmnichannelKpis { totalMappedProducts: number; + totalUnitsSold: number; totalGrossRevenue: number; totalEstNetRevenue: number; totalBOMCost: number; @@ -76,11 +87,16 @@ interface OmnichannelKpis { export default function OmnichannelMarginPage() { const [loading, setLoading] = useState(true); - const [data, setData] = useState<{ kpis: OmnichannelKpis; items: OmnichannelItem[] } | null>(null); + const [data, setData] = useState<{ + periodInfo: PeriodInfo; + kpis: OmnichannelKpis; + items: OmnichannelItem[]; + } | null>(null); // Filters const [search, setSearch] = useState(''); const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all'); + const [dateFilter, setDateFilter] = useState({ period: 'all' }); const [sortBy, setSortBy] = useState('revenue_desc'); const fetchData = async () => { @@ -89,6 +105,9 @@ export default function OmnichannelMarginPage() { const params = new URLSearchParams({ channel: channelFilter, search, + period: dateFilter.period, + ...(dateFilter.startDate ? { startDate: dateFilter.startDate } : {}), + ...(dateFilter.endDate ? { endDate: dateFilter.endDate } : {}), }); const res = await fetch(`${API_BASE}/profitability/omnichannel?${params.toString()}`); @@ -104,7 +123,7 @@ export default function OmnichannelMarginPage() { useEffect(() => { fetchData(); - }, [channelFilter]); + }, [channelFilter, dateFilter]); // Live search debounce useEffect(() => { @@ -180,13 +199,14 @@ export default function OmnichannelMarginPage() { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.setAttribute('download', `HomAroma_Omnichannel_Net_Margin_${new Date().toISOString().slice(0, 10)}.csv`); + link.setAttribute('download', `HomAroma_Omnichannel_Net_Margin_${dateFilter.period}_${new Date().toISOString().slice(0, 10)}.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const kpis = data?.kpis; + const periodInfo = data?.periodInfo; return (
@@ -196,19 +216,91 @@ export default function OmnichannelMarginPage() { subtitle="วิเคราะห์กำไรสุทธิตามช่องทางขาย (Shopee, Lazada, TikTok, Direct) หลังหักค่าธรรมเนียม Platform และต้นทุนผลิต BOM จริง" /> + {/* ── Filter Bar: Date Range + Controls ───────────────────────────── */} +
+
+ {/* Date Range Selector */} + + + {/* Active Period Badge */} + {periodInfo && ( +
+ + ช่วงข้อมูล: {periodInfo.label} + {periodInfo.daysCount && ( + ({periodInfo.daysCount} วัน) + )} +
+ )} +
+ + {/* Secondary Filter Bar */} +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="ค้นหาชื่อสินค้าบน Marketplace, Variation หรือ Master SKU..." + className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500" + /> +
+ + {/* Dropdowns */} +
+ {/* Channel Filter */} + + + {/* Sort By */} + + + {/* Export CSV */} + +
+
+
+ {/* ── Executive KPI Cards ────────────────────────────────────────── */}
{/* Total Gross Revenue */}
- Gross Revenue (Omnichannel) + Gross Revenue ({periodInfo?.label || 'All'})
{fmtMoney(kpis?.totalGrossRevenue)}

- {fmtNum(kpis?.totalMappedProducts)} สินค้า Mapped เข้า Master Catalog + {fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} รายการ SKU)

@@ -222,7 +314,7 @@ export default function OmnichannelMarginPage() {
{fmtMoney(kpis?.totalBOMCost)}

- ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้ + ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้ในช่วงเวลานี้

@@ -243,7 +335,7 @@ export default function OmnichannelMarginPage() { {/* Overall Net Margin % */}
- Overall Net Margin + Net Margin % ({periodInfo?.label || 'All'})
@@ -365,59 +457,6 @@ export default function OmnichannelMarginPage() {
)} - {/* ── Filter Bar ─────────────────────────────────────────────────── */} -
- {/* Search */} -
- - setSearch(e.target.value)} - placeholder="ค้นหาชื่อสินค้าบน Marketplace, Variation หรือ Master SKU..." - className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-sm text-white placeholder-slate-400 focus:outline-none focus:border-cyan-500" - /> -
- - {/* Dropdowns */} -
- {/* Channel Filter */} - - - {/* Sort By */} - - - {/* Export CSV */} - -
-
- {/* ── Main Omnichannel Table ─────────────────────────────────────── */}
@@ -427,7 +466,7 @@ export default function OmnichannelMarginPage() { Channel Marketplace Listing Linked Master Variant - ขายได้ + ขายได้ ({periodInfo?.label || 'All'}) ราคาขายเฉลี่ย ต้นทุน BOM (Unit) หัก Fee (%) @@ -442,13 +481,13 @@ export default function OmnichannelMarginPage() { - กำลังประมวลผลข้อมูลกำไรสุทธิของ Marketplace... + กำลังประมวลผลข้อมูลกำไรสุทธิสำหรับช่วงเวลา {periodInfo?.label}... ) : sortedItems.length === 0 ? ( - ไม่พบรายการสินค้า Marketplace ที่ตรงกับเงื่อนไข + ไม่พบรายการสินค้า Marketplace ที่มียอดขายในช่วงเวลาที่เลือก ) : ( diff --git a/apps/backend/src/routes/profitability.ts b/apps/backend/src/routes/profitability.ts index 6153927..9f81829 100644 --- a/apps/backend/src/routes/profitability.ts +++ b/apps/backend/src/routes/profitability.ts @@ -1,26 +1,83 @@ 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 + // 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(); // "all" | "high" (>70%) | "mid" (55-70%) | "low" (<55%) + 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; - // 1. Fetch all active variants with Product, Category, and BOM + // Fetch all active variants with Product, Category, and BOM const variants = await prisma.productVariant.findMany({ - where: { - product: { isDeleted: false }, - }, + where: { product: { isDeleted: false } }, include: { product: { select: { @@ -45,7 +102,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) ], }); - // 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}`; @@ -92,7 +148,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) 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) @@ -123,7 +178,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) }; }); - // 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); @@ -137,7 +191,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) const midMarginCount = mapped.filter((m) => m.marginHealth === "mid").length; const lowMarginCount = mapped.filter((m) => m.marginHealth === "low").length; - // Category breakdown const categoryMap = new Map(); for (const item of mapped) { let entry = categoryMap.get(item.categoryName); @@ -158,7 +211,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) 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(); @@ -179,7 +231,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) 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; @@ -193,7 +244,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) return 0; }); - // 6. Paginate const totalFiltered = filtered.length; const paginated = filtered.slice((page - 1) * limit, page * limit); @@ -231,13 +281,14 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) ) // ───────────────────────────────────────────────────────────────────────────── - // 2. Omnichannel Net Margin & Contribution Matrix + // 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 items + // 1. Fetch mapped marketplace products with master variants & BOM const mpProducts = await prisma.marketplaceProduct.findMany({ where: { masterVariantId: { not: null }, @@ -262,17 +313,162 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) orderBy: { totalRevenue: "desc" }, }); - // Marketplace estimated fee rates const CHANNEL_FEES: Record = { - shopee: { commissionPercent: 8.0, servicePercent: 5.0, paymentPercent: 3.21 }, // ~16.21% total standard Shopee + 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 | 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(); + 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 = { shopee: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, @@ -280,51 +476,64 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) tiktok: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 }, }; - const mappedItems = mpProducts.map((item) => { + 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 * item.totalSoldQty * 100) / 100; + 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(item.totalRevenue * (totalFeePercent / 100) * 100) / 100; - const netRevenue = Math.round((item.totalRevenue - estPlatformFee) * 100) / 100; + 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 = item.totalRevenue > 0 ? Math.round((netProfit / item.totalRevenue) * 1000) / 10 : 0; + const netMarginPercent = rev > 0 ? Math.round((netProfit / rev) * 1000) / 10 : 0; - // Direct comparison: what if sold directly on website/POS at retail price? - const directPrice = v.price || item.avgPrice || 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; - // Accumulate - totalGrossRevenue += item.totalRevenue; + totalGrossRevenue += rev; totalEstNetRevenue += netRevenue; totalBOMCost += itemBomCost; totalEstNetProfit += netProfit; + totalUnitsSold += soldQty; if (channelStats[ch]) { - channelStats[ch].soldQty += item.totalSoldQty; - channelStats[ch].revenue += item.totalRevenue; + channelStats[ch].soldQty += soldQty; + channelStats[ch].revenue += rev; channelStats[ch].bomCost += itemBomCost; channelStats[ch].netProfit += netProfit; } - return { + mappedItems.push({ 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, + avgSellingPrice, + totalSoldQty: soldQty, + totalRevenue: rev, masterVariantId: v.id, masterSku: v.sku, productName: v.product?.name || "Product", @@ -338,8 +547,8 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) netMarginPercent, directPrice, directMarginPercent, - }; - }); + }); + } let filtered = mappedItems; if (search) { @@ -356,8 +565,16 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) 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: mpProducts.length, + totalMappedProducts: mappedItems.length, + totalUnitsSold, totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100, totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100, totalBOMCost: Math.round(totalBOMCost * 100) / 100, @@ -370,10 +587,12 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) }) // ───────────────────────────────────────────────────────────────────────────── - // 3. MRP & Material Requirement Planning (Reverse BOM Explosion) + // 3. MRP & Material Requirement Planning (Supports Date Range & Forecasting) // ───────────────────────────────────────────────────────────────────────────── .get("/mrp", async ({ query }) => { - // 1. Fetch all raw materials with unit and price + const dateRange = resolveDateRange(query.period, query.startDate, query.endDate); + + // 1. Fetch raw materials const rawMaterials = await prisma.rawMaterial.findMany({ orderBy: { name: "asc" }, include: { @@ -389,26 +608,118 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) }, }); - // 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, - }, - }); - + // 2. Fetch sales quantities per Master Variant const variantSoldMap = new Map(); - for (const mp of mpProducts) { - if (mp.masterVariantId) { - const current = variantSoldMap.get(mp.masterVariantId) || 0; - variantSoldMap.set(mp.masterVariantId, current + mp.totalSoldQty); + + 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(); + 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(); + 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, calculate total consumption + // 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(); @@ -429,6 +740,11 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) 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) @@ -449,11 +765,13 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) linkedVariantsCount: rm.bomItems.length, totalConsumedQty: Math.round(totalConsumedQty * 100) / 100, totalConsumedCost: totalCost, + dailyBurnRate, + forecast30DaysQty, + forecast30DaysCost, 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("หัวน้ำหอม")); @@ -461,6 +779,13 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" }) 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,