feat(profitability): add date range filtering and 30-day MRP inventory forecast
This commit is contained in:
1 parent
c78a898af7
commit
b8bb1d4617
4 files changed
+683
-172
No files matched your search
@@ -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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 p-1 rounded-xl bg-slate-800/80 border border-white/10 text-xs">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-cyan-400 ml-2" />
|
||||||
|
<span className="text-[11px] font-bold text-slate-400 mr-1 hidden sm:inline">ช่วงเวลา:</span>
|
||||||
|
{presets.map((p) => {
|
||||||
|
const isActive = value.period === p.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectPreset(p.id)}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg font-semibold transition-all ${
|
||||||
|
isActive
|
||||||
|
? 'bg-cyan-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCustomOpen(!isCustomOpen)}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg font-semibold flex items-center gap-1 transition-all ${
|
||||||
|
value.period === 'custom' || isCustomOpen
|
||||||
|
? 'bg-purple-500 text-white shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
<span>กำหนดเอง</span>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Date Range Popover / Inline */}
|
||||||
|
{isCustomOpen && (
|
||||||
|
<div className="flex items-center gap-2 p-1.5 rounded-xl bg-slate-900 border border-purple-500/40 text-xs shadow-xl animate-fade-in">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customStart}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400">ถึง</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={customEnd}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApplyCustom}
|
||||||
|
disabled={!customStart}
|
||||||
|
className="px-3 py-1 rounded-lg bg-purple-500 hover:bg-purple-600 text-white font-bold transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
ใช้ตัวกรอง
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Package,
|
Package,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Droplets,
|
|
||||||
Box,
|
Box,
|
||||||
Flame,
|
Flame,
|
||||||
|
Calendar,
|
||||||
|
TrendingUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
||||||
|
import DateRangeSelector, { type DateFilterState } from './DateRangeSelector';
|
||||||
|
|
||||||
function fmtMoney(val: number | null | undefined): string {
|
function fmtMoney(val: number | null | undefined): string {
|
||||||
if (val == null || isNaN(val)) return '฿0.00';
|
if (val == null || isNaN(val)) return '฿0.00';
|
||||||
@@ -41,9 +43,20 @@ interface RawMaterialUsageItem {
|
|||||||
linkedVariantsCount: number;
|
linkedVariantsCount: number;
|
||||||
totalConsumedQty: number;
|
totalConsumedQty: number;
|
||||||
totalConsumedCost: number;
|
totalConsumedCost: number;
|
||||||
|
dailyBurnRate: number;
|
||||||
|
forecast30DaysQty: number;
|
||||||
|
forecast30DaysCost: number;
|
||||||
topProducts: TopProductUsage[];
|
topProducts: TopProductUsage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PeriodInfo {
|
||||||
|
period: string;
|
||||||
|
label: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
daysCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface MrpKpis {
|
interface MrpKpis {
|
||||||
totalRawMaterials: number;
|
totalRawMaterials: number;
|
||||||
totalMaterialCostConsumed: number;
|
totalMaterialCostConsumed: number;
|
||||||
@@ -54,6 +67,7 @@ interface MrpKpis {
|
|||||||
export default function MrpPlanningPage() {
|
export default function MrpPlanningPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [data, setData] = useState<{
|
const [data, setData] = useState<{
|
||||||
|
periodInfo: PeriodInfo;
|
||||||
kpis: MrpKpis;
|
kpis: MrpKpis;
|
||||||
categories: {
|
categories: {
|
||||||
fragrances: RawMaterialUsageItem[];
|
fragrances: RawMaterialUsageItem[];
|
||||||
@@ -66,12 +80,19 @@ export default function MrpPlanningPage() {
|
|||||||
// Filters
|
// Filters
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<'all' | 'fragrances' | 'solvents' | 'packaging'>('all');
|
const [activeTab, setActiveTab] = useState<'all' | 'fragrances' | 'solvents' | 'packaging'>('all');
|
||||||
|
const [dateFilter, setDateFilter] = useState<DateFilterState>({ period: '30d' }); // Default 30d for MRP
|
||||||
const [sortBy, setSortBy] = useState('cost_desc');
|
const [sortBy, setSortBy] = useState('cost_desc');
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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}`);
|
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setData(json);
|
setData(json);
|
||||||
@@ -84,7 +105,7 @@ export default function MrpPlanningPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, [dateFilter]);
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
@@ -103,6 +124,7 @@ export default function MrpPlanningPage() {
|
|||||||
const sorted = [...list];
|
const sorted = [...list];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
if (sortBy === 'cost_desc') return b.totalConsumedCost - a.totalConsumedCost;
|
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 === 'qty_desc') return b.totalConsumedQty - a.totalConsumedQty;
|
||||||
if (sortBy === 'name_asc') return a.name.localeCompare(b.name);
|
if (sortBy === 'name_asc') return a.name.localeCompare(b.name);
|
||||||
if (sortBy === 'price_desc') return b.pricePerUnit - a.pricePerUnit;
|
if (sortBy === 'price_desc') return b.pricePerUnit - a.pricePerUnit;
|
||||||
@@ -112,6 +134,12 @@ export default function MrpPlanningPage() {
|
|||||||
return sorted;
|
return sorted;
|
||||||
}, [data, activeTab, search, sortBy]);
|
}, [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 = () => {
|
const handleExportCsv = () => {
|
||||||
if (!filteredItems.length) return;
|
if (!filteredItems.length) return;
|
||||||
const headers = [
|
const headers = [
|
||||||
@@ -121,8 +149,11 @@ export default function MrpPlanningPage() {
|
|||||||
'Unit Cost (THB)',
|
'Unit Cost (THB)',
|
||||||
'Type',
|
'Type',
|
||||||
'Linked BOM Formulas',
|
'Linked BOM Formulas',
|
||||||
'Total Consumed Qty',
|
`Consumed Qty (${data?.periodInfo?.label || 'Period'})`,
|
||||||
'Total Consumed Cost (THB)',
|
'Consumed Cost (THB)',
|
||||||
|
'Daily Burn Rate',
|
||||||
|
'30-Day Forecast Reorder Qty',
|
||||||
|
'30-Day Forecast Budget (THB)',
|
||||||
'Top Consumer Products',
|
'Top Consumer Products',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -135,6 +166,9 @@ export default function MrpPlanningPage() {
|
|||||||
i.linkedVariantsCount,
|
i.linkedVariantsCount,
|
||||||
i.totalConsumedQty,
|
i.totalConsumedQty,
|
||||||
i.totalConsumedCost,
|
i.totalConsumedCost,
|
||||||
|
i.dailyBurnRate,
|
||||||
|
i.forecast30DaysQty,
|
||||||
|
i.forecast30DaysCost,
|
||||||
`"${i.topProducts.map((p) => `${p.name} (${p.qty} ${i.unit})`).join('; ')}"`,
|
`"${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 url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
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);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
};
|
};
|
||||||
|
|
||||||
const kpis = data?.kpis;
|
const kpis = data?.kpis;
|
||||||
|
const periodInfo = data?.periodInfo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Shared Header Navigation */}
|
{/* Shared Header Navigation */}
|
||||||
<ProfitabilityHeaderTabs
|
<ProfitabilityHeaderTabs
|
||||||
title="MRP & Material Demand Planning"
|
title="MRP & Material Demand Planning"
|
||||||
subtitle="Reverse BOM Explosion: คำนวณปริมาณการใช้วัตถุดิบจริงย้อนกลับจากประวัติคำสั่งซื้อ เพื่อบริหารสต็อกและวางแผนการจัดซื้อ"
|
subtitle="Reverse BOM Explosion: คำนวณปริมาณการใช้วัตถุดิบจริงย้อนกลับตามช่วงเวลา เพื่อหาอัตราการใช้ต่อวัน (Daily Burn Rate) และพยากรณ์การสั่งซื้อล่วงหน้า 30 วัน"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Filter Bar: Date Range + Search ────────────────────────────── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Date Range Selector */}
|
||||||
|
<DateRangeSelector value={dateFilter} onChange={setDateFilter} />
|
||||||
|
|
||||||
|
{/* Active Period Indicator */}
|
||||||
|
{periodInfo && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-800 border border-cyan-500/30 text-xs font-semibold text-cyan-400">
|
||||||
|
<Calendar className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>ช่วงวิเคราะห์สต็อก: {periodInfo.label}</span>
|
||||||
|
{periodInfo.daysCount && (
|
||||||
|
<span className="text-[10px] text-slate-400">({periodInfo.daysCount} วัน)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* Total Raw Materials */}
|
{/* Total Raw Materials */}
|
||||||
@@ -175,47 +229,34 @@ export default function MrpPlanningPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Total Material Cost Consumed */}
|
{/* Material Cost Consumed in Period */}
|
||||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Total Raw Material Burned</span>
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Consumed Cost ({periodInfo?.label || 'Period'})</span>
|
||||||
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
|
<div className="p-2 rounded-xl bg-rose-500/10 text-rose-400">
|
||||||
<Flame className="w-5 h-5" />
|
<Flame className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalMaterialCostConsumed)}</div>
|
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalMaterialCostConsumed)}</div>
|
||||||
<p className="text-[11px] text-slate-400 mt-2">มูลค่ายอดใช้วัตถุดิบรวมจากการผลิตขายจริง</p>
|
<p className="text-[11px] text-slate-400 mt-2">มูลค่ายอดใช้วัตถุดิบจริงในช่วงเวลาที่เลือก</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Fragrance Burn Driver */}
|
{/* 30-Day Forecast Procurement Budget */}
|
||||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">#1 Cost Driver (Fragrance)</span>
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">30-Day Forecast Budget</span>
|
||||||
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||||
<Droplets className="w-5 h-5" />
|
<TrendingUp className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{kpis?.topFragrancesByCost?.[0] ? (
|
<div className="text-2xl font-extrabold text-purple-400">{fmtMoney(totalForecastBudget)}</div>
|
||||||
<div>
|
<p className="text-[11px] text-slate-400 mt-2">งบประมาณสั่งซื้อวัตถุดิบ 30 วันข้างหน้า (อิง Run-rate)</p>
|
||||||
<div className="text-sm font-bold text-white truncate">{kpis.topFragrancesByCost[0].name}</div>
|
|
||||||
<div className="flex items-baseline justify-between mt-1 text-xs">
|
|
||||||
<span className="text-slate-400 font-mono">
|
|
||||||
{fmtNum(kpis.topFragrancesByCost[0].totalConsumedQty)} ml
|
|
||||||
</span>
|
|
||||||
<span className="font-bold text-purple-400 font-mono">
|
|
||||||
{fmtMoney(kpis.topFragrancesByCost[0].totalConsumedCost)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-slate-500">-</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Packaging Burn Driver */}
|
{/* Top Packaging Burn */}
|
||||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">#1 Packaging Volume</span>
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Top Volume Driver</span>
|
||||||
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
<div className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
||||||
<Package className="w-5 h-5" />
|
<Package className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +279,7 @@ export default function MrpPlanningPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Category Tabs & Filter Bar ─────────────────────────────────── */}
|
{/* ── Category Sub-Tabs & Search ──────────────────────────────────── */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-3 shadow-lg backdrop-blur-sm">
|
<div className="flex flex-wrap items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-3 shadow-lg backdrop-blur-sm">
|
||||||
{/* Sub Tabs */}
|
{/* 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"
|
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"
|
||||||
>
|
>
|
||||||
<option value="cost_desc">มูลค่าที่ใช้สูงสุด (Consumed Cost)</option>
|
<option value="cost_desc">มูลค่าที่ใช้สูงสุด (Consumed Cost)</option>
|
||||||
|
<option value="forecast_desc">งบพยากรณ์ 30 วันสูงสุด (Forecast Budget)</option>
|
||||||
<option value="qty_desc">ปริมาณที่ใช้สูงสุด (Volume Qty)</option>
|
<option value="qty_desc">ปริมาณที่ใช้สูงสุด (Volume Qty)</option>
|
||||||
<option value="price_desc">ราคาต่อหน่วยสูงสุด</option>
|
<option value="price_desc">ราคาต่อหน่วยสูงสุด</option>
|
||||||
<option value="name_asc">ชื่อ ก-ฮ (A-Z)</option>
|
<option value="name_asc">ชื่อ ก-ฮ (A-Z)</option>
|
||||||
@@ -337,47 +379,41 @@ export default function MrpPlanningPage() {
|
|||||||
<th className="py-4 px-4">ชื่อวัตถุดิบ (Raw Material)</th>
|
<th className="py-4 px-4">ชื่อวัตถุดิบ (Raw Material)</th>
|
||||||
<th className="py-4 px-4 text-center">ประเภท</th>
|
<th className="py-4 px-4 text-center">ประเภท</th>
|
||||||
<th className="py-4 px-4 text-right">ราคาต่อหน่วย</th>
|
<th className="py-4 px-4 text-right">ราคาต่อหน่วย</th>
|
||||||
<th className="py-4 px-4 text-center">ผูกในสูตร (Variants)</th>
|
<th className="py-4 px-4 text-right">ยอดใช้ในรอบ ({periodInfo?.label || 'Period'})</th>
|
||||||
<th className="py-4 px-4 text-right">ปริมาณที่ผลิตไปแล้ว</th>
|
<th className="py-4 px-4 text-right bg-white/5">มูลค่าใช้จริง</th>
|
||||||
<th className="py-4 px-4 text-right bg-white/5">มูลค่าที่ใช้ไป (Total Value)</th>
|
<th className="py-4 px-4 text-right">Daily Burn Rate</th>
|
||||||
<th className="py-4 px-4 text-center">Burn Velocity</th>
|
<th className="py-4 px-4 text-right bg-purple-500/10 text-purple-300">
|
||||||
<th className="py-4 px-4">สินค้าหลักที่ใช้วัตถุดิบนี้ (Top Consumers)</th>
|
พยากรณ์สั่งซื้อ 30 วัน
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-4 text-right bg-purple-500/10 text-purple-300">
|
||||||
|
งบสั่งซื้อ 30 วัน
|
||||||
|
</th>
|
||||||
|
<th className="py-4 px-4">สินค้าหลักที่ใช้ (Top Consumers)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
<tbody className="divide-y divide-white/5 text-xs md:text-sm">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-400">
|
<td colSpan={9} className="py-12 text-center text-slate-400">
|
||||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||||
กำลังประมวลผลข้อมูลการใช้วัตถุดิบย้อนกลับ (Reverse Explosion)...
|
กำลังประมวลผลการคำนวณวัตถุดิบและพยากรณ์สต็อกล่วงหน้า...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredItems.length === 0 ? (
|
) : filteredItems.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="py-12 text-center text-slate-400">
|
<td colSpan={9} className="py-12 text-center text-slate-400">
|
||||||
ไม่พบรายการวัตถุดิบที่ตรงกับเงื่อนไข
|
ไม่พบรายการวัตถุดิบที่มียอดใช้ในช่วงเวลาที่เลือก
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredItems.map((item) => {
|
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 (
|
return (
|
||||||
<tr key={item.id} className="hover:bg-white/5 transition-all">
|
<tr key={item.id} className="hover:bg-white/5 transition-all">
|
||||||
<td className="py-3.5 px-4">
|
<td className="py-3.5 px-4">
|
||||||
<div className="font-bold text-white">{item.name}</div>
|
<div className="font-bold text-white">{item.name}</div>
|
||||||
<div className="text-[11px] text-slate-400 font-mono">ID: #{item.id}</div>
|
<div className="text-[11px] text-slate-400 font-mono">
|
||||||
|
ID: #{item.id} • {item.linkedVariantsCount} สูตร
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-center">
|
<td className="py-3.5 px-4 text-center">
|
||||||
<span
|
<span
|
||||||
@@ -393,21 +429,22 @@ export default function MrpPlanningPage() {
|
|||||||
<td className="py-3.5 px-4 text-right font-mono text-white">
|
<td className="py-3.5 px-4 text-right font-mono text-white">
|
||||||
{fmtMoney(item.pricePerUnit)} / {item.unit}
|
{fmtMoney(item.pricePerUnit)} / {item.unit}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-center font-mono font-semibold text-slate-300">
|
|
||||||
{item.linkedVariantsCount} สูตร
|
|
||||||
</td>
|
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
|
||||||
{fmtNum(item.totalConsumedQty)} {item.unit}
|
{fmtNum(item.totalConsumedQty)} {item.unit}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-right font-mono font-bold text-rose-300 bg-white/5">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-rose-300 bg-white/5">
|
||||||
{fmtMoney(item.totalConsumedCost)}
|
{fmtMoney(item.totalConsumedCost)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-center">
|
<td className="py-3.5 px-4 text-right font-mono text-cyan-400 font-semibold">
|
||||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-bold inline-block ${burnBadge}`}>
|
{fmtNum(item.dailyBurnRate)} {item.unit}/วัน
|
||||||
{burnLabel}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 max-w-[280px]">
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-purple-300 bg-purple-500/5">
|
||||||
|
{fmtNum(item.forecast30DaysQty)} {item.unit}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 text-right font-mono font-bold text-purple-200 bg-purple-500/5">
|
||||||
|
{fmtMoney(item.forecast30DaysCost)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3.5 px-4 max-w-[240px]">
|
||||||
{item.topProducts.length > 0 ? (
|
{item.topProducts.length > 0 ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{item.topProducts.map((p, pIdx) => (
|
{item.topProducts.map((p, pIdx) => (
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import {
|
|||||||
Percent,
|
Percent,
|
||||||
Coins,
|
Coins,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Calendar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { API_BASE } from '@homaroma/types';
|
import { API_BASE } from '@homaroma/types';
|
||||||
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
import ProfitabilityHeaderTabs from './ProfitabilityHeaderTabs';
|
||||||
|
import DateRangeSelector, { type DateFilterState } from './DateRangeSelector';
|
||||||
|
|
||||||
function fmtMoney(val: number | null | undefined): string {
|
function fmtMoney(val: number | null | undefined): string {
|
||||||
if (val == null || isNaN(val)) return '฿0.00';
|
if (val == null || isNaN(val)) return '฿0.00';
|
||||||
@@ -64,8 +66,17 @@ interface ChannelStat {
|
|||||||
netProfit: number;
|
netProfit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PeriodInfo {
|
||||||
|
period: string;
|
||||||
|
label: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
daysCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface OmnichannelKpis {
|
interface OmnichannelKpis {
|
||||||
totalMappedProducts: number;
|
totalMappedProducts: number;
|
||||||
|
totalUnitsSold: number;
|
||||||
totalGrossRevenue: number;
|
totalGrossRevenue: number;
|
||||||
totalEstNetRevenue: number;
|
totalEstNetRevenue: number;
|
||||||
totalBOMCost: number;
|
totalBOMCost: number;
|
||||||
@@ -76,11 +87,16 @@ interface OmnichannelKpis {
|
|||||||
|
|
||||||
export default function OmnichannelMarginPage() {
|
export default function OmnichannelMarginPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
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
|
// Filters
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all');
|
const [channelFilter, setChannelFilter] = useState<'all' | 'shopee' | 'lazada' | 'tiktok'>('all');
|
||||||
|
const [dateFilter, setDateFilter] = useState<DateFilterState>({ period: 'all' });
|
||||||
const [sortBy, setSortBy] = useState('revenue_desc');
|
const [sortBy, setSortBy] = useState('revenue_desc');
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -89,6 +105,9 @@ export default function OmnichannelMarginPage() {
|
|||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
channel: channelFilter,
|
channel: channelFilter,
|
||||||
search,
|
search,
|
||||||
|
period: dateFilter.period,
|
||||||
|
...(dateFilter.startDate ? { startDate: dateFilter.startDate } : {}),
|
||||||
|
...(dateFilter.endDate ? { endDate: dateFilter.endDate } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/profitability/omnichannel?${params.toString()}`);
|
const res = await fetch(`${API_BASE}/profitability/omnichannel?${params.toString()}`);
|
||||||
@@ -104,7 +123,7 @@ export default function OmnichannelMarginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [channelFilter]);
|
}, [channelFilter, dateFilter]);
|
||||||
|
|
||||||
// Live search debounce
|
// Live search debounce
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,13 +199,14 @@ export default function OmnichannelMarginPage() {
|
|||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
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);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
};
|
};
|
||||||
|
|
||||||
const kpis = data?.kpis;
|
const kpis = data?.kpis;
|
||||||
|
const periodInfo = data?.periodInfo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -196,19 +216,91 @@ export default function OmnichannelMarginPage() {
|
|||||||
subtitle="วิเคราะห์กำไรสุทธิตามช่องทางขาย (Shopee, Lazada, TikTok, Direct) หลังหักค่าธรรมเนียม Platform และต้นทุนผลิต BOM จริง"
|
subtitle="วิเคราะห์กำไรสุทธิตามช่องทางขาย (Shopee, Lazada, TikTok, Direct) หลังหักค่าธรรมเนียม Platform และต้นทุนผลิต BOM จริง"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── Filter Bar: Date Range + Controls ───────────────────────────── */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Date Range Selector */}
|
||||||
|
<DateRangeSelector value={dateFilter} onChange={setDateFilter} />
|
||||||
|
|
||||||
|
{/* Active Period Badge */}
|
||||||
|
{periodInfo && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-800 border border-cyan-500/30 text-xs font-semibold text-cyan-400">
|
||||||
|
<Calendar className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>ช่วงข้อมูล: {periodInfo.label}</span>
|
||||||
|
{periodInfo.daysCount && (
|
||||||
|
<span className="text-[10px] text-slate-400">({periodInfo.daysCount} วัน)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary Filter Bar */}
|
||||||
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-[240px]">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdowns */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Channel Filter */}
|
||||||
|
<select
|
||||||
|
value={channelFilter}
|
||||||
|
onChange={(e) => setChannelFilter(e.target.value as any)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="all">ทุกช่องทาง (All Channels)</option>
|
||||||
|
<option value="shopee">Shopee</option>
|
||||||
|
<option value="lazada">Lazada</option>
|
||||||
|
<option value="tiktok">TikTok Shop</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
||||||
|
>
|
||||||
|
<option value="revenue_desc">ยอดขายรวมสูงสุด (Revenue)</option>
|
||||||
|
<option value="profit_desc">กำไรสุทธิรวมสูงสุด (Net Profit)</option>
|
||||||
|
<option value="margin_desc">อัตรากำไรสุทธิสูงสุด (Net Margin %)</option>
|
||||||
|
<option value="margin_asc">อัตรากำไรสุทธิต่ำสุด (Risk Margin %)</option>
|
||||||
|
<option value="qty_desc">จำนวนชิ้นที่ขายได้สูงสุด (Units Sold)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Export CSV */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs md:text-sm font-bold flex items-center gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Export CSV</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
{/* ── Executive KPI Cards ────────────────────────────────────────── */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* Total Gross Revenue */}
|
{/* Total Gross Revenue */}
|
||||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue (Omnichannel)</span>
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Gross Revenue ({periodInfo?.label || 'All'})</span>
|
||||||
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
|
||||||
<Coins className="w-5 h-5" />
|
<Coins className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div>
|
<div className="text-2xl font-extrabold text-white">{fmtMoney(kpis?.totalGrossRevenue)}</div>
|
||||||
<p className="text-[11px] text-slate-400 mt-2 font-mono">
|
<p className="text-[11px] text-slate-400 mt-2 font-mono">
|
||||||
{fmtNum(kpis?.totalMappedProducts)} สินค้า Mapped เข้า Master Catalog
|
{fmtNum(kpis?.totalUnitsSold)} ชิ้น ({fmtNum(kpis?.totalMappedProducts)} รายการ SKU)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -222,7 +314,7 @@ export default function OmnichannelMarginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div>
|
<div className="text-2xl font-extrabold text-rose-400">{fmtMoney(kpis?.totalBOMCost)}</div>
|
||||||
<p className="text-[11px] text-slate-400 mt-2">
|
<p className="text-[11px] text-slate-400 mt-2">
|
||||||
ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้
|
ต้นทุนวัตถุดิบจริงสะสมของสินค้าที่ขายได้ในช่วงเวลานี้
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -243,7 +335,7 @@ export default function OmnichannelMarginPage() {
|
|||||||
{/* Overall Net Margin % */}
|
{/* Overall Net Margin % */}
|
||||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl p-5 shadow-lg backdrop-blur-sm">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Overall Net Margin</span>
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Net Margin % ({periodInfo?.label || 'All'})</span>
|
||||||
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
<div className="p-2 rounded-xl bg-purple-500/10 text-purple-400">
|
||||||
<Percent className="w-5 h-5" />
|
<Percent className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
@@ -365,59 +457,6 @@ export default function OmnichannelMarginPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Filter Bar ─────────────────────────────────────────────────── */}
|
|
||||||
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/90 border border-white/10 rounded-2xl p-4 shadow-lg backdrop-blur-sm">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative flex-1 min-w-[240px]">
|
|
||||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dropdowns */}
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{/* Channel Filter */}
|
|
||||||
<select
|
|
||||||
value={channelFilter}
|
|
||||||
onChange={(e) => setChannelFilter(e.target.value as any)}
|
|
||||||
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
|
||||||
>
|
|
||||||
<option value="all">ทุกช่องทาง (All Channels)</option>
|
|
||||||
<option value="shopee">Shopee</option>
|
|
||||||
<option value="lazada">Lazada</option>
|
|
||||||
<option value="tiktok">TikTok Shop</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Sort By */}
|
|
||||||
<select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => setSortBy(e.target.value)}
|
|
||||||
className="px-3 py-2.5 rounded-xl bg-slate-800/80 border border-white/10 text-xs md:text-sm text-white focus:outline-none focus:border-cyan-500"
|
|
||||||
>
|
|
||||||
<option value="revenue_desc">ยอดขายรวมสูงสุด (Revenue)</option>
|
|
||||||
<option value="profit_desc">กำไรสุทธิรวมสูงสุด (Net Profit)</option>
|
|
||||||
<option value="margin_desc">อัตรากำไรสุทธิสูงสุด (Net Margin %)</option>
|
|
||||||
<option value="margin_asc">อัตรากำไรสุทธิต่ำสุด (Risk Margin %)</option>
|
|
||||||
<option value="qty_desc">จำนวนชิ้นที่ขายได้สูงสุด (Units Sold)</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Export CSV */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleExportCsv}
|
|
||||||
className="px-4 py-2.5 rounded-xl bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs md:text-sm font-bold flex items-center gap-2 transition-all"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
<span>Export CSV</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Main Omnichannel Table ─────────────────────────────────────── */}
|
{/* ── Main Omnichannel Table ─────────────────────────────────────── */}
|
||||||
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
<div className="bg-slate-900/90 border border-white/10 rounded-2xl overflow-hidden shadow-xl backdrop-blur-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -427,7 +466,7 @@ export default function OmnichannelMarginPage() {
|
|||||||
<th className="py-4 px-4">Channel</th>
|
<th className="py-4 px-4">Channel</th>
|
||||||
<th className="py-4 px-4">Marketplace Listing</th>
|
<th className="py-4 px-4">Marketplace Listing</th>
|
||||||
<th className="py-4 px-4">Linked Master Variant</th>
|
<th className="py-4 px-4">Linked Master Variant</th>
|
||||||
<th className="py-4 px-4 text-center">ขายได้</th>
|
<th className="py-4 px-4 text-center">ขายได้ ({periodInfo?.label || 'All'})</th>
|
||||||
<th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th>
|
<th className="py-4 px-4 text-right">ราคาขายเฉลี่ย</th>
|
||||||
<th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th>
|
<th className="py-4 px-4 text-right">ต้นทุน BOM (Unit)</th>
|
||||||
<th className="py-4 px-4 text-right">หัก Fee (%)</th>
|
<th className="py-4 px-4 text-right">หัก Fee (%)</th>
|
||||||
@@ -442,13 +481,13 @@ export default function OmnichannelMarginPage() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={11} className="py-12 text-center text-slate-400">
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto text-cyan-400 mb-2" />
|
||||||
กำลังประมวลผลข้อมูลกำไรสุทธิของ Marketplace...
|
กำลังประมวลผลข้อมูลกำไรสุทธิสำหรับช่วงเวลา {periodInfo?.label}...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedItems.length === 0 ? (
|
) : sortedItems.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={11} className="py-12 text-center text-slate-400">
|
<td colSpan={11} className="py-12 text-center text-slate-400">
|
||||||
ไม่พบรายการสินค้า Marketplace ที่ตรงกับเงื่อนไข
|
ไม่พบรายการสินค้า Marketplace ที่มียอดขายในช่วงเวลาที่เลือก
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,26 +1,83 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import prisma from "../lib/prisma";
|
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" })
|
export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// 1. Cost & Profitability Matrix
|
// 1. Cost & Profitability Matrix (Catalog-Level Baseline)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
.get(
|
.get(
|
||||||
"/matrix",
|
"/matrix",
|
||||||
async ({ query }) => {
|
async ({ query }) => {
|
||||||
const search = (query.search || "").trim();
|
const search = (query.search || "").trim();
|
||||||
const category = (query.category || "all").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 sortBy = query.sortBy || "margin_desc";
|
||||||
const page = query.page ? parseInt(query.page) : 1;
|
const page = query.page ? parseInt(query.page) : 1;
|
||||||
const limit = query.limit ? parseInt(query.limit) : 50;
|
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({
|
const variants = await prisma.productVariant.findMany({
|
||||||
where: {
|
where: { product: { isDeleted: false } },
|
||||||
product: { isDeleted: false },
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
product: {
|
product: {
|
||||||
select: {
|
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 mapped = variants.map((v) => {
|
||||||
const thName = v.product.translations?.find((t) => t.langCode === "th")?.name;
|
const thName = v.product.translations?.find((t) => t.langCode === "th")?.name;
|
||||||
const productName = v.product.name || thName || `Product #${v.product.id}`;
|
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 grossProfit = Math.round((retailPrice - totalBomCost) * 100) / 100;
|
||||||
const grossMargin = retailPrice > 0 ? Math.round(((retailPrice - totalBomCost) / retailPrice) * 1000) / 10 : 0;
|
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 minAcceptablePrice = totalBomCost / (1 - 0.40);
|
||||||
const maxDiscountPercent = retailPrice > minAcceptablePrice
|
const maxDiscountPercent = retailPrice > minAcceptablePrice
|
||||||
? Math.round(((retailPrice - minAcceptablePrice) / retailPrice) * 100)
|
? 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 totalVariants = mapped.length;
|
||||||
const configuredBomVariants = mapped.filter((m) => m.bomCount > 0).length;
|
const configuredBomVariants = mapped.filter((m) => m.bomCount > 0).length;
|
||||||
const totalCatalogRevenueAtFullList = mapped.reduce((s, m) => s + m.retailPrice, 0);
|
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 midMarginCount = mapped.filter((m) => m.marginHealth === "mid").length;
|
||||||
const lowMarginCount = mapped.filter((m) => m.marginHealth === "low").length;
|
const lowMarginCount = mapped.filter((m) => m.marginHealth === "low").length;
|
||||||
|
|
||||||
// Category breakdown
|
|
||||||
const categoryMap = new Map<string, { count: number; totalRev: number; totalCost: number }>();
|
const categoryMap = new Map<string, { count: number; totalRev: number; totalCost: number }>();
|
||||||
for (const item of mapped) {
|
for (const item of mapped) {
|
||||||
let entry = categoryMap.get(item.categoryName);
|
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,
|
avgMargin: stat.totalRev > 0 ? Math.round(((stat.totalRev - stat.totalCost) / stat.totalRev) * 1000) / 10 : 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 4. Apply Filters
|
|
||||||
let filtered = mapped;
|
let filtered = mapped;
|
||||||
if (search) {
|
if (search) {
|
||||||
const s = search.toLowerCase();
|
const s = search.toLowerCase();
|
||||||
@@ -179,7 +231,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|||||||
filtered = filtered.filter((m) => m.marginHealth === marginFilter);
|
filtered = filtered.filter((m) => m.marginHealth === marginFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Apply Sorting
|
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
if (sortBy === "margin_desc") return b.grossMargin - a.grossMargin;
|
if (sortBy === "margin_desc") return b.grossMargin - a.grossMargin;
|
||||||
if (sortBy === "margin_asc") return a.grossMargin - b.grossMargin;
|
if (sortBy === "margin_asc") return a.grossMargin - b.grossMargin;
|
||||||
@@ -193,7 +244,6 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Paginate
|
|
||||||
const totalFiltered = filtered.length;
|
const totalFiltered = filtered.length;
|
||||||
const paginated = filtered.slice((page - 1) * limit, page * limit);
|
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 }) => {
|
.get("/omnichannel", async ({ query }) => {
|
||||||
const search = (query.search || "").trim().toLowerCase();
|
const search = (query.search || "").trim().toLowerCase();
|
||||||
const channel = (query.channel || "all").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({
|
const mpProducts = await prisma.marketplaceProduct.findMany({
|
||||||
where: {
|
where: {
|
||||||
masterVariantId: { not: null },
|
masterVariantId: { not: null },
|
||||||
@@ -262,17 +313,162 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|||||||
orderBy: { totalRevenue: "desc" },
|
orderBy: { totalRevenue: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Marketplace estimated fee rates
|
|
||||||
const CHANNEL_FEES: Record<string, { commissionPercent: number; servicePercent: number; paymentPercent: number }> = {
|
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
|
shopee: { commissionPercent: 8.0, servicePercent: 5.0, paymentPercent: 3.21 }, // ~16.21%
|
||||||
lazada: { commissionPercent: 6.0, servicePercent: 4.0, paymentPercent: 3.21 }, // ~13.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%
|
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 totalGrossRevenue = 0;
|
||||||
let totalEstNetRevenue = 0;
|
let totalEstNetRevenue = 0;
|
||||||
let totalBOMCost = 0;
|
let totalBOMCost = 0;
|
||||||
let totalEstNetProfit = 0;
|
let totalEstNetProfit = 0;
|
||||||
|
let totalUnitsSold = 0;
|
||||||
|
|
||||||
const channelStats: Record<string, { soldQty: number; revenue: number; bomCost: number; netProfit: number }> = {
|
const channelStats: Record<string, { soldQty: number; revenue: number; bomCost: number; netProfit: number }> = {
|
||||||
shopee: { soldQty: 0, revenue: 0, bomCost: 0, netProfit: 0 },
|
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 },
|
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 v = item.masterVariant!;
|
||||||
const unitBomCost = (v.bomItems || []).reduce(
|
const unitBomCost = (v.bomItems || []).reduce(
|
||||||
(sum, b) => sum + b.quantity * (b.rawMaterial?.pricePerUnit || 0),
|
(sum, b) => sum + b.quantity * (b.rawMaterial?.pricePerUnit || 0),
|
||||||
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 ch = item.channel.toLowerCase();
|
||||||
const feeConfig = CHANNEL_FEES[ch] || { commissionPercent: 7.0, servicePercent: 4.0, paymentPercent: 3.0 };
|
const feeConfig = CHANNEL_FEES[ch] || { commissionPercent: 7.0, servicePercent: 4.0, paymentPercent: 3.0 };
|
||||||
const totalFeePercent = feeConfig.commissionPercent + feeConfig.servicePercent + feeConfig.paymentPercent;
|
const totalFeePercent = feeConfig.commissionPercent + feeConfig.servicePercent + feeConfig.paymentPercent;
|
||||||
|
|
||||||
const estPlatformFee = Math.round(item.totalRevenue * (totalFeePercent / 100) * 100) / 100;
|
const estPlatformFee = Math.round(rev * (totalFeePercent / 100) * 100) / 100;
|
||||||
const netRevenue = Math.round((item.totalRevenue - estPlatformFee) * 100) / 100;
|
const netRevenue = Math.round((rev - estPlatformFee) * 100) / 100;
|
||||||
const netProfit = Math.round((netRevenue - itemBomCost) * 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 avgSellingPrice = soldQty > 0 ? Math.round((rev / soldQty) * 100) / 100 : item.avgPrice;
|
||||||
const directPrice = v.price || item.avgPrice || 0;
|
const directPrice = v.price || avgSellingPrice || 0;
|
||||||
const directGrossProfit = directPrice - unitBomCost;
|
const directGrossProfit = directPrice - unitBomCost;
|
||||||
const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0;
|
const directMarginPercent = directPrice > 0 ? Math.round((directGrossProfit / directPrice) * 1000) / 10 : 0;
|
||||||
|
|
||||||
// Accumulate
|
totalGrossRevenue += rev;
|
||||||
totalGrossRevenue += item.totalRevenue;
|
|
||||||
totalEstNetRevenue += netRevenue;
|
totalEstNetRevenue += netRevenue;
|
||||||
totalBOMCost += itemBomCost;
|
totalBOMCost += itemBomCost;
|
||||||
totalEstNetProfit += netProfit;
|
totalEstNetProfit += netProfit;
|
||||||
|
totalUnitsSold += soldQty;
|
||||||
|
|
||||||
if (channelStats[ch]) {
|
if (channelStats[ch]) {
|
||||||
channelStats[ch].soldQty += item.totalSoldQty;
|
channelStats[ch].soldQty += soldQty;
|
||||||
channelStats[ch].revenue += item.totalRevenue;
|
channelStats[ch].revenue += rev;
|
||||||
channelStats[ch].bomCost += itemBomCost;
|
channelStats[ch].bomCost += itemBomCost;
|
||||||
channelStats[ch].netProfit += netProfit;
|
channelStats[ch].netProfit += netProfit;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
mappedItems.push({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
channel: item.channel,
|
channel: item.channel,
|
||||||
shop: item.shop,
|
shop: item.shop,
|
||||||
marketplaceSku: item.marketplaceSku,
|
marketplaceSku: item.marketplaceSku,
|
||||||
marketplaceProductName: item.productName,
|
marketplaceProductName: item.productName,
|
||||||
variation: item.variation,
|
variation: item.variation,
|
||||||
avgSellingPrice: item.avgPrice,
|
avgSellingPrice,
|
||||||
totalSoldQty: item.totalSoldQty,
|
totalSoldQty: soldQty,
|
||||||
totalRevenue: item.totalRevenue,
|
totalRevenue: rev,
|
||||||
masterVariantId: v.id,
|
masterVariantId: v.id,
|
||||||
masterSku: v.sku,
|
masterSku: v.sku,
|
||||||
productName: v.product?.name || "Product",
|
productName: v.product?.name || "Product",
|
||||||
@@ -338,8 +547,8 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|||||||
netMarginPercent,
|
netMarginPercent,
|
||||||
directPrice,
|
directPrice,
|
||||||
directMarginPercent,
|
directMarginPercent,
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let filtered = mappedItems;
|
let filtered = mappedItems;
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -356,8 +565,16 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|||||||
totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0;
|
totalGrossRevenue > 0 ? Math.round((totalEstNetProfit / totalGrossRevenue) * 1000) / 10 : 0;
|
||||||
|
|
||||||
return {
|
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: {
|
kpis: {
|
||||||
totalMappedProducts: mpProducts.length,
|
totalMappedProducts: mappedItems.length,
|
||||||
|
totalUnitsSold,
|
||||||
totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100,
|
totalGrossRevenue: Math.round(totalGrossRevenue * 100) / 100,
|
||||||
totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100,
|
totalEstNetRevenue: Math.round(totalEstNetRevenue * 100) / 100,
|
||||||
totalBOMCost: Math.round(totalBOMCost * 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 }) => {
|
.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({
|
const rawMaterials = await prisma.rawMaterial.findMany({
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
include: {
|
include: {
|
||||||
@@ -389,26 +608,118 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Fetch sales quantities per Master Variant across Marketplace Products & Orders
|
// 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({
|
const mpProducts = await prisma.marketplaceProduct.findMany({
|
||||||
where: { masterVariantId: { not: null } },
|
where: { masterVariantId: { not: null } },
|
||||||
select: {
|
select: { masterVariantId: true, totalSoldQty: true },
|
||||||
masterVariantId: true,
|
|
||||||
totalSoldQty: true,
|
|
||||||
totalRevenue: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const variantSoldMap = new Map<number, number>();
|
|
||||||
for (const mp of mpProducts) {
|
for (const mp of mpProducts) {
|
||||||
if (mp.masterVariantId) {
|
if (mp.masterVariantId) {
|
||||||
const current = variantSoldMap.get(mp.masterVariantId) || 0;
|
const current = variantSoldMap.get(mp.masterVariantId) || 0;
|
||||||
variantSoldMap.set(mp.masterVariantId, current + mp.totalSoldQty);
|
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();
|
||||||
|
|
||||||
// 3. Reverse Explosion: For each Raw Material, calculate total consumption
|
// 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;
|
let totalMaterialCostConsumed = 0;
|
||||||
|
const daysCount = dateRange.daysCount || 30;
|
||||||
|
|
||||||
const materialUsageList = rawMaterials.map((rm) => {
|
const materialUsageList = rawMaterials.map((rm) => {
|
||||||
let totalConsumedQty = 0;
|
let totalConsumedQty = 0;
|
||||||
const associatedProductsMap = new Map<string, { qty: number; variantCount: number }>();
|
const associatedProductsMap = new Map<string, { qty: number; variantCount: number }>();
|
||||||
@@ -429,6 +740,11 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|||||||
const totalCost = Math.round(totalConsumedQty * rm.pricePerUnit * 100) / 100;
|
const totalCost = Math.round(totalConsumedQty * rm.pricePerUnit * 100) / 100;
|
||||||
totalMaterialCostConsumed += totalCost;
|
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())
|
const topProducts = Array.from(associatedProductsMap.entries())
|
||||||
.map(([name, stat]) => ({ name, qty: Math.round(stat.qty * 10) / 10 }))
|
.map(([name, stat]) => ({ name, qty: Math.round(stat.qty * 10) / 10 }))
|
||||||
.sort((a, b) => b.qty - a.qty)
|
.sort((a, b) => b.qty - a.qty)
|
||||||
@@ -449,11 +765,13 @@ export const profitabilityRoute = new Elysia({ prefix: "/profitability" })
|
|||||||
linkedVariantsCount: rm.bomItems.length,
|
linkedVariantsCount: rm.bomItems.length,
|
||||||
totalConsumedQty: Math.round(totalConsumedQty * 100) / 100,
|
totalConsumedQty: Math.round(totalConsumedQty * 100) / 100,
|
||||||
totalConsumedCost: totalCost,
|
totalConsumedCost: totalCost,
|
||||||
|
dailyBurnRate,
|
||||||
|
forecast30DaysQty,
|
||||||
|
forecast30DaysCost,
|
||||||
topProducts,
|
topProducts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by consumption cost descending (high-burn materials first)
|
|
||||||
materialUsageList.sort((a, b) => b.totalConsumedCost - a.totalConsumedCost);
|
materialUsageList.sort((a, b) => b.totalConsumedCost - a.totalConsumedCost);
|
||||||
|
|
||||||
const fragranceMaterials = materialUsageList.filter((m) => m.name.includes("หัวน้ำหอม"));
|
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"));
|
const solventMaterials = materialUsageList.filter((m) => m.name.includes("DPG") || m.name.includes("DPM"));
|
||||||
|
|
||||||
return {
|
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: {
|
kpis: {
|
||||||
totalRawMaterials: rawMaterials.length,
|
totalRawMaterials: rawMaterials.length,
|
||||||
totalMaterialCostConsumed: Math.round(totalMaterialCostConsumed * 100) / 100,
|
totalMaterialCostConsumed: Math.round(totalMaterialCostConsumed * 100) / 100,
|
||||||
|
|||||||
Reference in new issue
Block a user