From d724a565f5d6cc55c3334bd5378b0875bc76422b Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sat, 1 Aug 2026 21:28:15 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A3=8E=E9=99=A9=E4=B8=8E=E5=86=85=E6=8E=A7?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=BC=98=E5=8C=96:=20=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E8=B4=A6=E5=8D=95=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86=E9=A1=B5?= =?UTF-8?q?/=E7=AD=9B=E9=80=89/=E6=94=B6=E9=93=B6=E5=91=98=E6=90=9C?= =?UTF-8?q?=E7=B4=A2/=E5=90=88=E8=AE=A1,=20=E6=94=B6=E9=93=B6=E5=91=98TOP1?= =?UTF-8?q?5=E6=8C=89=E5=BC=82=E5=B8=B8=E8=B4=A6=E5=8D=95=E6=95=B0?= =?UTF-8?q?=E6=8E=92=E5=BA=8F,=20=E5=8F=8C=E5=87=BB=E8=B7=B3=E8=BD=AC?= =?UTF-8?q?=E6=98=8E=E7=BB=86,=20MonthPicker=E9=9D=A0=E5=8F=B3,=20?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E5=BA=95=E9=83=A8=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/CollapsibleSection.tsx | 5 +- client/src/components/DataTable.tsx | 6 +- client/src/components/FilterableTable.tsx | 129 ++++- client/src/components/Layout.tsx | 2 +- .../cost-analysis/AdjustmentTab.tsx | 170 +++--- .../src/components/cost-analysis/BomTab.tsx | 159 +++--- .../components/cost-analysis/ExploreTab.tsx | 4 +- .../components/cost-analysis/MaterialTab.tsx | 100 ++-- .../components/cost-analysis/OverviewTab.tsx | 49 +- .../components/cost-analysis/PackagingTab.tsx | 79 ++- .../cost-analysis/ProfitabilityTab.tsx | 108 ++-- .../components/cost-analysis/QualityTab.tsx | 113 ++-- .../src/components/cost-analysis/StoreTab.tsx | 94 ++-- .../components/cost-analysis/SupplyTab.tsx | 143 +++-- .../smart-scheduling/AttendanceAlertTab.tsx | 142 ++--- .../EfficiencyBenchmarkTab.tsx | 113 ++-- .../smart-scheduling/EmployeeAnalysisTab.tsx | 166 +++--- .../smart-scheduling/OverallAnalysisTab.tsx | 6 +- .../SchedulingSuggestionTab.tsx | 36 +- .../smart-scheduling/StaffingForecastTab.tsx | 6 +- .../smart-scheduling/StaffingMatchTab.tsx | 7 +- .../smart-scheduling/TrafficHeatmapTab.tsx | 133 ++--- .../components/store-expense/BreakEvenTab.tsx | 38 +- .../store-expense/ContributionTab.tsx | 38 +- .../components/store-expense/DeliveryTab.tsx | 38 +- .../store-expense/EfficiencyTab.tsx | 38 +- .../store-expense/FixedVariableTab.tsx | 38 +- .../store-expense/LossDiagnosisTab.tsx | 43 +- .../components/store-expense/RentRiskTab.tsx | 37 +- .../store-expense/StoreEvaluationTab.tsx | 43 +- .../store-expense/StoreRankingTab.tsx | 38 +- client/src/pages/BankPage.tsx | 247 ++++----- client/src/pages/BomPenetrationPage.tsx | 492 ++++++++++-------- client/src/pages/CentralKitchenPage.tsx | 358 +++++-------- client/src/pages/CostAnalysisPage.tsx | 31 +- client/src/pages/CostPage.tsx | 194 ++++--- client/src/pages/DashboardPage.tsx | 34 +- .../pages/DistributionReconciliationPage.tsx | 280 ++++------ client/src/pages/IndicatorsPage.tsx | 22 +- client/src/pages/MemberPage.tsx | 68 +-- client/src/pages/MonthlyReviewPage.tsx | 97 +++- client/src/pages/OntologyPage.tsx | 31 +- client/src/pages/PlatformPage.tsx | 110 ++-- client/src/pages/ProductionPlanPage.tsx | 19 +- client/src/pages/RegionalPage.tsx | 126 +++-- client/src/pages/RevenuePage.tsx | 32 +- client/src/pages/RiskPage.tsx | 255 +++++---- client/src/pages/SKUPage.tsx | 77 ++- client/src/pages/SiteSelectionPage.tsx | 195 +++---- client/src/pages/SituationalAwarenessPage.tsx | 1 - client/src/pages/SmartSchedulingPage.tsx | 27 +- client/src/pages/StoreDetailPage.tsx | 133 +++-- client/src/pages/StorePage.tsx | 24 +- client/src/pages/TasksPage.tsx | 30 +- client/src/pages/TimePage.tsx | 33 +- server/src/routes/cost-analysis.ts | 158 ++++-- server/src/routes/data.ts | 413 ++++++++++++--- server/src/routes/situational-awareness.ts | 4 +- server/src/routes/smart-scheduling.ts | 25 +- server/src/routes/tasks.ts | 78 ++- 60 files changed, 3154 insertions(+), 2561 deletions(-) diff --git a/client/src/components/CollapsibleSection.tsx b/client/src/components/CollapsibleSection.tsx index 49f0cd1..d137fb4 100644 --- a/client/src/components/CollapsibleSection.tsx +++ b/client/src/components/CollapsibleSection.tsx @@ -6,17 +6,18 @@ interface CollapsibleSectionProps { subtitle?: string defaultOpen?: boolean headerRight?: ReactNode + onToggle?: (open: boolean) => void children: ReactNode } -export function CollapsibleSection({ title, subtitle, defaultOpen = true, headerRight, children }: CollapsibleSectionProps) { +export function CollapsibleSection({ title, subtitle, defaultOpen = true, headerRight, onToggle, children }: CollapsibleSectionProps) { const [open, setOpen] = useState(defaultOpen) return (
- )} -
- ))} - - - ))} - {bomTree.length === 0 && ( -
该产品无配方数据
- )} - - )} - - {/* 产品选择器 */} - setProductFilter(e.target.value)} - className="rounded border px-3 py-1 text-sm" - /> - } - > -
- - - - - - - - - - - - - - - - {filteredProducts.map((p: any, i: number) => ( - - - - - - - - - - - - ))} - -
产品编码产品名称品类入库量理论成本实际成本材料数多级BOM
{p.product_code}{p.product_name}{p.category_minor}{formatNumber(p.inbound_quantity)}{formatCurrency(p.theoretical_cost)}{formatCurrency(p.actual_cost)}{p.material_count} - {p.has_multi_level_bom ? : '-'} - - -
-
+ + {p.product_name} }, + { key: 'category_minor', label: '品类' }, + { key: 'inbound_quantity', label: '入库量', align: 'right', render: (p) => formatNumber(p.inbound_quantity) }, + { key: 'theoretical_cost', label: '理论成本', align: 'right', render: (p) => formatCurrency(p.theoretical_cost) }, + { key: 'actual_cost', label: '实际成本', align: 'right', render: (p) => formatCurrency(p.actual_cost) }, + { key: 'material_count', label: '材料数', align: 'right' }, + { key: 'has_multi_level_bom', label: '多级BOM', align: 'center', render: (p) => p.has_multi_level_bom ? : '-' }, + { key: 'action', label: '', render: (p) => }, + ]} + onRowClick={(p) => openProductModal(p.product_code)} + /> {/* 半成品依赖链 */} -
- - - - - - - - - - - - - - {multiLevelChains.map((c: any, i: number) => ( - - - - - - - - - - ))} - -
成品编码成品名称成品配方半成品编码半成品名称半成品配方
{c.finished_code}{c.finished_name}{c.finished_recipe}{c.sub_product_code}{c.sub_product_finished_name || c.sub_product_name}{c.sub_product_recipe || '-'}
+
+ + 展示成品与半成品之间的多级BOM依赖关系,可快速定位半成品成本波动影响的成品范围
+ {c.finished_name} }, + { key: 'finished_recipe', label: '成品配方' }, + { key: 'arrow', label: '', render: () => }, + { key: 'sub_product_code', label: '半成品编码' }, + { key: 'sub_product_finished_name', label: '半成品名称', render: (c) => {c.sub_product_finished_name || c.sub_product_name} }, + { key: 'sub_product_recipe', label: '半成品配方', render: (c) => c.sub_product_recipe || '-' }, + ]} + /> + + {/* BOM树弹窗 */} + {selectedProduct && ( +
+
e.stopPropagation()}> +
+
+ +

+ BOM树穿透 - {productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct} +

+ 共{bomTree.length}个节点,最大{maxLevel}层 +
+ +
+
+ {productLoading ? ( + + ) : bomTree.length === 0 ? ( +
该产品无配方数据
+ ) : ( +
+ + + + + + + + + + + + + + + {renderTreeRows(bomTreeData, 0)} + +
物料名称编码单位理论量领用量理论金额领用金额差异金额
+
+ )} +
+
+
+ )}
) } diff --git a/client/src/pages/CentralKitchenPage.tsx b/client/src/pages/CentralKitchenPage.tsx index 7b2b22e..80704a7 100644 --- a/client/src/pages/CentralKitchenPage.tsx +++ b/client/src/pages/CentralKitchenPage.tsx @@ -5,6 +5,7 @@ import api from '@/lib/api' import { MetricCard } from '@/components/MetricCard' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' +import { FilterableTable } from '@/components/FilterableTable' import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils' import { ChefHat, TrendingDown, TrendingUp, AlertTriangle, Package, Factory } from 'lucide-react' import { MonthPicker } from '@/components/MonthPicker' @@ -24,7 +25,6 @@ function varianceText(pct: number): string { export function CentralKitchenPage() { const [month, setMonth] = useState('2026-04') - const [productFilter, setProductFilter] = useState('') const { data, isLoading } = useQuery({ queryKey: ['central-kitchen-dashboard', month], @@ -39,13 +39,6 @@ export function CentralKitchenPage() { const { summary, reconciliation, products, categoryCost, recipeEfficiency, mfgPool, yieldAnalysis } = data - const filteredProducts = products.filter((p: any) => - !productFilter || - p.product_name?.includes(productFilter) || - p.product_code?.includes(productFilter) || - p.category_minor?.includes(productFilter) - ) - // 成本对账瀑布数据 const waterfallData = [ { name: '理论成本', value: reconciliation.theoretical_cost, type: 'base' }, @@ -76,9 +69,11 @@ export function CentralKitchenPage() { return (
{/* 页面标题 */} -
- -

中央厨房成本驾驶舱

+
+
+ +

中央厨房成本驾驶舱

+
@@ -135,37 +130,29 @@ export function CentralKitchenPage() { -
- - - - - - - - - - - - - - - {categoryCost.map((c: any, i: number) => ( - - - - - - - - - - - ))} - -
品类产品数入库量理论成本实际材料制造费用全成本效率差异
{c.category_minor}{c.product_count}{formatNumber(c.total_qty)}{formatCurrency(c.theoretical_cost)}{formatCurrency(c.material_actual_cost)}{formatCurrency(c.allocated_mfg_cost)}{formatCurrency(c.full_cost)} - {varianceText(c.efficiency_var_pct)} -
+
+ formatNumber(c.total_qty) }, + { key: 'theoretical_cost', label: '理论成本', align: 'right', render: (c) => formatCurrency(c.theoretical_cost) }, + { key: 'material_actual_cost', label: '实际材料', align: 'right', render: (c) => formatCurrency(c.material_actual_cost) }, + { key: 'allocated_mfg_cost', label: '制造费用', align: 'right', render: (c) => formatCurrency(c.allocated_mfg_cost) }, + { key: 'full_cost', label: '全成本', align: 'right', render: (c) => {formatCurrency(c.full_cost)} }, + { key: 'efficiency_var_pct', label: '效率差异', align: 'right', render: (c) => {varianceText(c.efficiency_var_pct)} }, + ]} + />
@@ -176,7 +163,19 @@ export function CentralKitchenPage() { `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} label={{ value: '理论成本', position: 'insideBottom', offset: -5, fontSize: 11 }} /> `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} label={{ value: '实际成本', angle: -90, position: 'insideLeft', fontSize: 11 }} /> - name === 'x' || name === 'y' ? formatCurrency(v) : v} labelFormatter={() => ''} /> + { + if (!active || !payload?.length) return null + const p = payload[0].payload + return ( +
+
{p.name}
+
品类: {p.category}
+
理论成本: {formatCurrency(p.x)}
+
实际成本: {formatCurrency(p.y)}
+
偏差率: {p.z.toFixed(2)}%
+
+ ) + }} /> {scatterData.map((entry: any, idx: number) => ( setProductFilter(e.target.value)} - className="rounded border px-3 py-1 text-sm" - /> - } + subtitle={`${products.length}个产品`} > -
- - - - - - - - - - - - - - - - - - - - - {filteredProducts.map((p: any, i: number) => ( - - - - - - - - - - - - - - - - - ))} - -
编码产品名品类入库量理论成本标准成本实际材料制造费用全成本单位成本效率差异标准差异入库价值产品毛利
{p.product_code}{p.product_name}{p.category_minor}{formatNumber(p.inbound_quantity)}{formatCurrency(p.theoretical_cost)}{formatCurrency(p.standard_cost)}{formatCurrency(p.material_actual_cost)}{formatCurrency(p.allocated_manufacturing_cost)}{formatCurrency(p.full_manufacturing_cost)}{formatNumber(p.full_unit_cost)} - {varianceText(p.efficiency_variance_pct)} - - {varianceText(p.standard_variance_pct)} - {formatCurrency(p.inbound_value)}= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(p.product_margin)} -
-
+ {p.product_name} }, + { key: 'category_minor', label: '品类' }, + { key: 'inbound_quantity', label: '入库量', align: 'right', render: (p) => formatNumber(p.inbound_quantity) }, + { key: 'theoretical_cost', label: '理论成本', align: 'right', render: (p) => formatCurrency(p.theoretical_cost) }, + { key: 'standard_cost', label: '标准成本', align: 'right', render: (p) => formatCurrency(p.standard_cost) }, + { key: 'material_actual_cost', label: '实际材料', align: 'right', render: (p) => formatCurrency(p.material_actual_cost) }, + { key: 'allocated_manufacturing_cost', label: '制造费用', align: 'right', render: (p) => formatCurrency(p.allocated_manufacturing_cost) }, + { key: 'full_manufacturing_cost', label: '全成本', align: 'right', render: (p) => {formatCurrency(p.full_manufacturing_cost)} }, + { key: 'full_unit_cost', label: '单位成本', align: 'right', render: (p) => formatNumber(p.full_unit_cost) }, + { key: 'efficiency_variance_pct', label: '效率差异', align: 'right', render: (p) => {varianceText(p.efficiency_variance_pct)} }, + { key: 'standard_variance_pct', label: '标准差异', align: 'right', render: (p) => {varianceText(p.standard_variance_pct)} }, + { key: 'inbound_value', label: '入库价值', align: 'right', render: (p) => formatCurrency(p.inbound_value) }, + { key: 'product_margin', label: '产品毛利', align: 'right', render: (p) => = 0 ? 'text-green-600' : 'text-red-600'}>{formatCurrency(p.product_margin)} }, + ]} + /> {/* 配方效率分析 */} -
- - - - - - - - - - - - - - - - - - {recipeEfficiency.map((r: any, i: number) => ( - - - - - - - - - - - - - - ))} - -
配方物料规格理论用量实际用量理论金额实际金额金额差异率实际出成率标准出成率出成差异
{r.recipe_name}{r.item_name}{r.specification}{formatNumber(r.theoretical_qty)}{formatNumber(r.actual_qty)}{formatCurrency(r.theoretical_amt)}{formatCurrency(r.actual_amt)} - {varianceText(r.variance_pct)} - {r.avg_actual_yield ? `${(r.avg_actual_yield * 100).toFixed(2)}%` : '-'}{r.avg_recipe_yield ? `${(r.avg_recipe_yield * 100).toFixed(2)}%` : '-'} 0.03 ? 'text-green-600' : ''}`}> - {r.yield_diff ? `${(r.yield_diff * 100).toFixed(2)}pct` : '-'} -
-
+ {r.recipe_name} }, + { key: 'item_name', label: '物料' }, + { key: 'specification', label: '规格' }, + { key: 'theoretical_qty', label: '理论用量', align: 'right', render: (r) => formatNumber(r.theoretical_qty) }, + { key: 'actual_qty', label: '实际用量', align: 'right', render: (r) => formatNumber(r.actual_qty) }, + { key: 'theoretical_amt', label: '理论金额', align: 'right', render: (r) => formatCurrency(r.theoretical_amt) }, + { key: 'actual_amt', label: '实际金额', align: 'right', render: (r) => formatCurrency(r.actual_amt) }, + { key: 'variance_pct', label: '金额差异率', align: 'right', render: (r) => {varianceText(r.variance_pct)} }, + { key: 'avg_actual_yield', label: '实际出成率', align: 'right', render: (r) => r.avg_actual_yield ? `${(r.avg_actual_yield * 100).toFixed(2)}%` : '-' }, + { key: 'avg_recipe_yield', label: '标准出成率', align: 'right', render: (r) => r.avg_recipe_yield ? `${(r.avg_recipe_yield * 100).toFixed(2)}%` : '-' }, + { key: 'yield_diff', label: '出成差异', align: 'right', render: (r) => 0.03 ? 'text-green-600' : ''}`}>{r.yield_diff ? `${(r.yield_diff * 100).toFixed(2)}pct` : '-'} }, + ]} + />
{/* 出成率分析 */} -
- - - - - - - - - - - - - - - - {yieldAnalysis.map((y: any, i: number) => ( - - - - - - - - - - - - ))} - -
配方产品名理论入库量实际入库量达成率预期偏差率入库金额退库量退库率
{y.recipe_name}{y.product_name}{formatNumber(y.theoretical_inbound_qty)}{formatNumber(y.actual_inbound_qty)}= 0.95 ? 'text-green-600' : y.avg_achievement_rate >= 0.9 ? 'text-yellow-600' : 'text-red-600'}`}> - {y.avg_achievement_rate ? `${(y.avg_achievement_rate * 100).toFixed(2)}%` : '-'} - 0.03 ? 'text-green-600' : ''}`}> - {y.avg_expected_var_rate ? `${(y.avg_expected_var_rate * 100).toFixed(2)}%` : '-'} - {formatCurrency(y.total_inbound_amt)}{formatNumber(y.total_return_qty)} 1 ? 'text-red-600' : ''}`}> - {y.return_rate ? `${y.return_rate.toFixed(2)}%` : '-'} -
-
+ {y.recipe_name} }, + { key: 'product_name', label: '产品名' }, + { key: 'theoretical_inbound_qty', label: '理论入库量', align: 'right', render: (y) => formatNumber(y.theoretical_inbound_qty) }, + { key: 'actual_inbound_qty', label: '实际入库量', align: 'right', render: (y) => formatNumber(y.actual_inbound_qty) }, + { key: 'avg_achievement_rate', label: '达成率', align: 'right', render: (y) => = 0.95 ? 'text-green-600' : y.avg_achievement_rate >= 0.9 ? 'text-yellow-600' : 'text-red-600'}>{y.avg_achievement_rate ? `${(y.avg_achievement_rate * 100).toFixed(2)}%` : '-'} }, + { key: 'avg_expected_var_rate', label: '预期偏差率', align: 'right', render: (y) => 0.03 ? 'text-green-600' : ''}`}>{y.avg_expected_var_rate ? `${(y.avg_expected_var_rate * 100).toFixed(2)}%` : '-'} }, + { key: 'total_inbound_amt', label: '入库金额', align: 'right', render: (y) => formatCurrency(y.total_inbound_amt) }, + { key: 'total_return_qty', label: '退库量', align: 'right', render: (y) => formatNumber(y.total_return_qty) }, + { key: 'return_rate', label: '退库率', align: 'right', render: (y) => 1 ? 'text-red-600' : ''}>{y.return_rate ? `${y.return_rate.toFixed(2)}%` : '-'} }, + ]} + />
{/* 制造费用池 */} -
- - - - - - - - - - - - - - - - - {mfgPool.map((m: any, i: number) => ( - - - - - - - - - - - - - ))} - -
费用类型费用子类来源原始金额分摊比例分摊金额分摊方法计入重建临时备注
{m.cost_type}{m.cost_subtype}{m.source_type}{formatCurrency(m.source_amount)}{m.share_pct ? `${(m.share_pct * 100).toFixed(2)}%` : '-'}{formatCurrency(m.allocated_amount)}{m.allocation_method}{m.include_in_rebuilt_cost ? '✅' : '❌'} - {m.is_provisional && } - {m.note}
-
+ {m.cost_type} }, + { key: 'cost_subtype', label: '费用子类' }, + { key: 'source_type', label: '来源' }, + { key: 'source_amount', label: '原始金额', align: 'right', render: (m) => formatCurrency(m.source_amount) }, + { key: 'share_pct', label: '分摊比例', align: 'right', render: (m) => m.share_pct ? `${(m.share_pct * 100).toFixed(2)}%` : '-' }, + { key: 'allocated_amount', label: '分摊金额', align: 'right', render: (m) => {formatCurrency(m.allocated_amount)} }, + { key: 'allocation_method', label: '分摊方法' }, + { key: 'include_in_rebuilt_cost', label: '计入重建', align: 'center', render: (m) => m.include_in_rebuilt_cost ? '✅' : '❌' }, + { key: 'is_provisional', label: '临时', align: 'center', render: (m) => m.is_provisional ? : '' }, + { key: 'note', label: '备注' }, + ]} + />
当前制造费用分摊为临时方法(按人数比例),待电表、面积或工时数据后替换为更精确的分摊基准。 diff --git a/client/src/pages/CostAnalysisPage.tsx b/client/src/pages/CostAnalysisPage.tsx index 7259eda..2138823 100644 --- a/client/src/pages/CostAnalysisPage.tsx +++ b/client/src/pages/CostAnalysisPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { Tabs } from '@/components/Tabs' +import { MonthPicker } from '@/components/MonthPicker' import { OverviewTab } from '@/components/cost-analysis/OverviewTab' import { ProfitabilityTab } from '@/components/cost-analysis/ProfitabilityTab' import { MaterialTab } from '@/components/cost-analysis/MaterialTab' @@ -26,25 +27,29 @@ const TABS = [ export function CostAnalysisPage() { const [activeTab, setActiveTab] = useState('overview') + const [month, setMonth] = useState('2026-04') return (
-
-

菜品成本

-

基于BOM与成本报表的多维度运营分析

+
+
+

菜品成本

+

基于BOM与成本报表的多维度运营分析

+
+
- {activeTab === 'overview' && } - {activeTab === 'profitability' && } - {activeTab === 'material' && } - {activeTab === 'bom' && } - {activeTab === 'supply' && } - {activeTab === 'packaging' && } - {activeTab === 'quality' && } - {activeTab === 'store' && } - {activeTab === 'explore' && } - {activeTab === 'adjustment' && } + {activeTab === 'overview' && } + {activeTab === 'profitability' && } + {activeTab === 'material' && } + {activeTab === 'bom' && } + {activeTab === 'supply' && } + {activeTab === 'packaging' && } + {activeTab === 'quality' && } + {activeTab === 'store' && } + {activeTab === 'explore' && } + {activeTab === 'adjustment' && }
) diff --git a/client/src/pages/CostPage.tsx b/client/src/pages/CostPage.tsx index dc8b140..846417e 100644 --- a/client/src/pages/CostPage.tsx +++ b/client/src/pages/CostPage.tsx @@ -1,22 +1,15 @@ import { useQuery } from '@tanstack/react-query' import api from '@/lib/api' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { MetricCard } from '@/components/MetricCard' -import { Badge } from '@/components/Badge' -import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' +import { formatCurrency, formatPercent } from '@/lib/utils' import { useState, useMemo } from 'react' -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter } from 'recharts' +import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ReferenceLine } from 'recharts' import { MonthPicker } from '@/components/MonthPicker' -const PAGE_SIZE = 15 - export function CostPage() { - const [costPage, setCostPage] = useState(1) - const [invPage, setInvPage] = useState(1) - const [catPage, setCatPage] = useState(1) const [month, setMonth] = useState('2026-04') const { data: costData, isLoading: costLoading } = useQuery({ @@ -24,21 +17,26 @@ export function CostPage() { queryFn: () => api.get('/cost/comparison', { params: { month } }), }) + const [invOpen, setInvOpen] = useState(false) + const [catOpen, setCatOpen] = useState(false) + const { data: invData, isLoading: invLoading } = useQuery({ queryKey: ['cost/inventory', month], queryFn: () => api.get('/cost/inventory', { params: { month } }), + enabled: invOpen, }) const { data: catData, isLoading: catLoading } = useQuery({ queryKey: ['cost/category-benchmark', month], queryFn: () => api.get('/cost/category-benchmark', { params: { month } }), + enabled: catOpen, }) const costRows = (costData as any)?.data || [] const invRows = (invData as any)?.data || [] const catRows = (catData as any)?.data || [] - const pageLoading = costLoading || invLoading || catLoading + const pageLoading = costLoading const validCostRows = costRows.filter((r: any) => !r.variance_level?.includes('口径异常')) const abnormalRows = costRows.filter((r: any) => r.variance_level?.includes('口径异常')) @@ -50,11 +48,6 @@ export function CostPage() { ? validCostRows.reduce((s: number, r: any) => s + Number(r.actual_food_cost_rate_pct || 0), 0) / validCostRows.length : 0 const highVarianceCount = validCostRows.filter((r: any) => Number(r.variance_to_theoretical_pct || 0) > 20).length - const abnormalInvCount = invRows.filter((r: any) => Number(r.estimated_inventory_days) > 7 || Number(r.negative_item_lines) > 0).length - - const pagedCost = useMemo(() => costRows.slice((costPage - 1) * PAGE_SIZE, costPage * PAGE_SIZE), [costRows, costPage]) - const pagedInv = useMemo(() => invRows.slice((invPage - 1) * PAGE_SIZE, invPage * PAGE_SIZE), [invRows, invPage]) - const pagedCat = useMemo(() => catRows.slice((catPage - 1) * PAGE_SIZE, catPage * PAGE_SIZE), [catRows, catPage]) const scatterData = validCostRows.map((r: any) => ({ name: r.store_name, @@ -63,6 +56,17 @@ export function CostPage() { variance: Number(r.variance_to_theoretical_pct || 0), })) + const scatterDomain = useMemo(() => { + if (scatterData.length === 0) return { x: [0, 40], y: [0, 50] } + const xs = scatterData.map((d: any) => d.theoretical) + const ys = scatterData.map((d: any) => d.actual) + const xMin = Math.floor(Math.min(...xs) - 1) + const xMax = Math.ceil(Math.max(...xs) + 1) + const yMin = Math.floor(Math.min(...ys) - 1) + const yMax = Math.ceil(Math.max(...ys) + 1) + return { x: [xMin, xMax], y: [yMin, yMax] } + }, [scatterData]) + if (pageLoading) { return } @@ -101,8 +105,9 @@ export function CostPage() { - - + + + { @@ -127,78 +132,99 @@ export function CostPage() { {/* 成本对比表 */} - -
- formatPercent(r.theoretical_cost_rate_pct) }, - { key: 'actual_food_cost_rate_pct', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) }, - { key: 'variance_to_theoretical_pct', label: '偏差', align: 'right', render: (r) => { - const v = Number(r.variance_to_theoretical_pct || 0) - return 20 ? 'font-medium text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}% - }}, - { key: 'variance_level', label: '偏差等级', render: (r) => ( - {r.variance_level || '-'} - )}, - { key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) }, - ]} - data={pagedCost} - /> -
+ formatPercent(r.theoretical_cost_rate_pct) }, + { key: 'actual_food_cost_rate_pct', label: '实际成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) }, + { key: 'variance_to_theoretical_pct', label: '偏差', align: 'right', render: (r) => { + const v = Number(r.variance_to_theoretical_pct || 0) + return 20 ? 'font-medium text-red-600' : v > 10 ? 'text-yellow-600' : 'text-green-600'}>{v > 0 ? '+' : ''}{v.toFixed(1)}% + }}, + { key: 'variance_level', label: '偏差等级', render: (r) => ( + {r.variance_level || '-'} + )}, + { key: 'actual_food_cost', label: '实际食材成本', align: 'right', render: (r) => formatCurrency(r.actual_food_cost) }, + ]} + />
{/* 库存效率 */} - - -
- { - const d = Number(r.estimated_inventory_days || 0) - return 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}天 - }}, - { key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) }, - { key: 'negative_item_lines', label: '负耗用行数', align: 'center', render: (r) => { - const n = Number(r.negative_item_lines || 0) - return n > 0 ? {n} : 0 - }}, - { key: 'abnormal_item_lines', label: '异常货品行', align: 'center', render: (r) => { - const n = Number(r.abnormal_item_lines || 0) - return n > 0 ? {n} : 0 - }}, - ]} - data={pagedInv} - /> -
+ + {invLoading ? : <> + { + const d = Number(r.estimated_inventory_days || 0) + return 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}天 + }}, + { key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) }, + { key: 'negative_item_lines', label: '负耗用行数', align: 'center', render: (r) => { + const n = Number(r.negative_item_lines || 0) + return n > 0 ? {n} : 0 + }}, + ]} + /> + } {/* 原料分类差异 */} - - -
- formatCurrency(r.actual_category_cost) }, - { key: 'cost_per_10k_sales', label: '每万元成本', align: 'right', render: (r) => formatCurrency(r.cost_per_10k_sales) }, - { key: 'peer_median_cost_per_10k', label: '同业中位', align: 'right', render: (r) => formatCurrency(r.peer_median_cost_per_10k) }, - { key: 'excess_vs_peer_per_10k', label: '超出同业', align: 'right', render: (r) => { - const v = Number(r.excess_vs_peer_per_10k || 0) - return v > 0 ? +{formatCurrency(v)} : {formatCurrency(v)} - }}, - ]} - data={pagedCat} - /> -
+ + {catLoading ? : <> + formatCurrency(r.actual_category_cost) }, + { key: 'cost_per_10k_sales', label: '每万元成本', align: 'right', render: (r) => formatCurrency(r.cost_per_10k_sales) }, + { key: 'peer_median_cost_per_10k', label: '同业中位', align: 'right', render: (r) => formatCurrency(r.peer_median_cost_per_10k) }, + { key: 'excess_vs_peer_per_10k', label: '超出同业', align: 'right', render: (r) => { + const v = Number(r.excess_vs_peer_per_10k || 0) + return v > 0 ? +{formatCurrency(v)} : {formatCurrency(v)} + }}, + ]} + /> + }
) diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 90f22fc..ad377dd 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -4,21 +4,18 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai import api from '@/lib/api' import { MetricCard } from '@/components/MetricCard' import { Badge } from '@/components/Badge' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { MonthPicker } from '@/components/MonthPicker' import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils' -import { useState, useMemo } from 'react' +import { useState } from 'react' import { TrendingUp, TrendingDown, Minus } from 'lucide-react' const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' } -const PAGE_SIZE = 10 export function DashboardPage() { const navigate = useNavigate() - const [priorityPage, setPriorityPage] = useState(1) const [month, setMonth] = useState('2026-04') const { data: overview, isLoading: odLoading } = useQuery({ @@ -52,13 +49,13 @@ export function DashboardPage() { }) const { data: platformData } = useQuery({ - queryKey: ['platform/economics'], - queryFn: () => api.get('/platform/economics'), + queryKey: ['platform/economics', month], + queryFn: () => api.get('/platform/economics', { params: { month } }), }) const { data: alertsData } = useQuery({ - queryKey: ['sa-alerts'], - queryFn: () => api.get('/situational-awareness/alerts'), + queryKey: ['sa-alerts', month], + queryFn: () => api.get('/situational-awareness/alerts', { params: { month } }), }) const { data: expenseData } = useQuery({ @@ -169,7 +166,6 @@ export function DashboardPage() { }, {}) const p0p1Stores = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1')) - const pagedP0P1 = useMemo(() => p0p1Stores.slice((priorityPage - 1) * PAGE_SIZE, priorityPage * PAGE_SIZE), [p0p1Stores, priorityPage]) // 闭环健康度综合得分 const loopScore = lh ? ( @@ -500,9 +496,18 @@ export function DashboardPage() {
} > - -
- navigate(`/stores/${r.store_code}`)} columns={[ { key: 'store_name', label: '门店' }, { key: 'action_priority', label: '优先级', render: (r) => }, @@ -510,10 +515,7 @@ export function DashboardPage() { { key: 'problem_combination', label: '问题组合' }, { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, ]} - data={pagedP0P1} - onRowClick={(r) => navigate(`/stores/${r.store_code}`)} /> -
) diff --git a/client/src/pages/DistributionReconciliationPage.tsx b/client/src/pages/DistributionReconciliationPage.tsx index 8d65c16..3dfcb40 100644 --- a/client/src/pages/DistributionReconciliationPage.tsx +++ b/client/src/pages/DistributionReconciliationPage.tsx @@ -5,6 +5,7 @@ import api from '@/lib/api' import { MetricCard } from '@/components/MetricCard' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' +import { FilterableTable } from '@/components/FilterableTable' import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils' import { Truck, AlertTriangle, PackageSearch, BarChart3 } from 'lucide-react' import { MonthPicker } from '@/components/MonthPicker' @@ -27,7 +28,6 @@ function varianceText(pct: number): string { export function DistributionReconciliationPage() { const [month, setMonth] = useState('2026-04') - const [storeFilter, setStoreFilter] = useState('') const { data, isLoading } = useQuery({ queryKey: ['distribution-reconciliation', month], @@ -42,12 +42,6 @@ export function DistributionReconciliationPage() { const { summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation } = data - const filteredStores = storeReconciliation.filter((s: any) => - !storeFilter || - s.store_code?.includes(storeFilter) || - s.store_name?.includes(storeFilter) - ) - // 倒挤公式瀑布数据 const waterfallData = [ { name: '期初库存', value: summary.total_opening_amt, type: 'base' }, @@ -79,9 +73,11 @@ export function DistributionReconciliationPage() { return (
{/* 页面标题 */} -
- -

配送—倒挤成本对账

+
+
+ +

配送—倒挤成本对账

+
@@ -137,41 +133,30 @@ export function DistributionReconciliationPage() { -
- - - - - - - - - - - - - - - - {categoryReconciliation.map((c: any, i: number) => ( - - - - - - - - - - - - ))} - -
品类品项数配送量配送金额不含税成本耗用金额期末库存差异金额差异率
{c.minor_category}{c.item_count}{formatNumber(c.dist_qty)}{formatCurrency(c.dist_amt)}{formatCurrency(c.dist_cost_excl_tax)}{formatCurrency(c.consumption_amt)}{formatCurrency(c.ending_amt)}= 0 ? 'text-yellow-600' : 'text-red-600'}`}> - {formatCurrency(c.variance_amt)} - - {varianceText(c.variance_pct)} -
+
+ formatNumber(c.dist_qty) }, + { key: 'dist_amt', label: '配送金额', align: 'right', render: (c) => formatCurrency(c.dist_amt) }, + { key: 'dist_cost_excl_tax', label: '不含税成本', align: 'right', render: (c) => formatCurrency(c.dist_cost_excl_tax) }, + { key: 'consumption_amt', label: '耗用金额', align: 'right', render: (c) => formatCurrency(c.consumption_amt) }, + { key: 'ending_amt', label: '期末库存', align: 'right', render: (c) => formatCurrency(c.ending_amt) }, + { key: 'variance_amt', label: '差异金额', align: 'right', render: (c) => = 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(c.variance_amt)} }, + { key: 'variance_pct', label: '差异率', align: 'right', render: (c) => {varianceText(c.variance_pct)} }, + ]} + />
@@ -182,7 +167,17 @@ export function DistributionReconciliationPage() { `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} label={{ value: '配送金额', position: 'insideBottom', offset: -5, fontSize: 11 }} /> `${v.toFixed(0)}%`} tick={{ fontSize: 11 }} label={{ value: '差异率%', angle: -90, position: 'insideLeft', fontSize: 11 }} /> - name === 'x' ? formatCurrency(v) : `${v.toFixed(2)}%`} labelFormatter={() => ''} /> + { + if (!active || !payload?.length) return null + const p = payload[0].payload + return ( +
+
{p.name} ({p.store_code})
+
配送金额: {formatCurrency(p.x)}
+
差异率: {p.y.toFixed(2)}%
+
+ ) + }} /> {storeScatterData.map((entry: any, idx: number) => ( setStoreFilter(e.target.value)} - className="rounded border px-3 py-1 text-sm" - /> - } + subtitle={`${storeReconciliation.length}家门店`} > -
- - - - - - - - - - - - - - - - - - {filteredStores.map((s: any, i: number) => ( - - - - - - - - - - - - - - ))} - -
门店编码门店名称配送金额不含税成本期初库存实际耗用期末库存倒挤应耗用差异金额差异率负库存
{s.store_code}{s.store_name || '-'}{formatCurrency(s.dist_amt)}{formatCurrency(s.dist_cost_excl_tax)}{formatCurrency(s.opening_amt)}{formatCurrency(s.consumption_amt)}{formatCurrency(s.ending_amt)}{formatCurrency(s.reverse_consumption_amt)}= 0 ? 'text-yellow-600' : 'text-red-600'}`}> - {formatCurrency(s.variance_amt)} - - {varianceText(s.variance_pct)} - 0 ? 'text-red-600 font-medium' : ''}`}> - {s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'} -
-
+ s.store_name || '-' }, + { key: 'dist_amt', label: '配送金额', align: 'right', render: (s) => formatCurrency(s.dist_amt) }, + { key: 'dist_cost_excl_tax', label: '不含税成本', align: 'right', render: (s) => formatCurrency(s.dist_cost_excl_tax) }, + { key: 'opening_amt', label: '期初库存', align: 'right', render: (s) => formatCurrency(s.opening_amt) }, + { key: 'consumption_amt', label: '实际耗用', align: 'right', render: (s) => formatCurrency(s.consumption_amt) }, + { key: 'ending_amt', label: '期末库存', align: 'right', render: (s) => formatCurrency(s.ending_amt) }, + { key: 'reverse_consumption_amt', label: '倒挤应耗用', align: 'right', render: (s) => formatCurrency(s.reverse_consumption_amt) }, + { key: 'variance_amt', label: '差异金额', align: 'right', render: (s) => = 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(s.variance_amt)} }, + { key: 'variance_pct', label: '差异率', align: 'right', render: (s) => {varianceText(s.variance_pct)} }, + { key: 'neg_inventory_count', label: '负库存', align: 'right', render: (s) => 0 ? 'text-red-600 font-medium' : ''}>{s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'} }, + ]} + /> {/* Top差异品项 */} -
- - - - - - - - - - - - - - - - - - - {topVariances.map((v: any, i: number) => ( - - - - - - - - - - - - - - - ))} - -
门店品项编码品项名称品类配送量配送金额期初实际耗用期末倒挤应耗用差异金额差异率
{v.store_code}{v.item_code}{v.item_name}{v.minor_category}{formatNumber(v.dist_qty)}{formatCurrency(v.dist_amt)}{formatCurrency(v.opening_amt)}{formatCurrency(v.consumption_amt)}{formatCurrency(v.ending_amt)}{formatCurrency(v.reverse_consumption_amt)}= 0 ? 'text-yellow-600' : 'text-red-600'}`}> - {formatCurrency(v.variance_amt)} - - {varianceText(v.variance_pct)} -
-
+ formatNumber(v.dist_qty) }, + { key: 'dist_amt', label: '配送金额', align: 'right', render: (v) => formatCurrency(v.dist_amt) }, + { key: 'opening_amt', label: '期初', align: 'right', render: (v) => formatCurrency(v.opening_amt) }, + { key: 'consumption_amt', label: '实际耗用', align: 'right', render: (v) => formatCurrency(v.consumption_amt) }, + { key: 'ending_amt', label: '期末', align: 'right', render: (v) => formatCurrency(v.ending_amt) }, + { key: 'reverse_consumption_amt', label: '倒挤应耗用', align: 'right', render: (v) => formatCurrency(v.reverse_consumption_amt) }, + { key: 'variance_amt', label: '差异金额', align: 'right', render: (v) => = 0 ? 'text-yellow-600' : 'text-red-600'}`}>{formatCurrency(v.variance_amt)} }, + { key: 'variance_pct', label: '差异率', align: 'right', render: (v) => {varianceText(v.variance_pct)} }, + ]} + />
{/* 未匹配品项 */} -
- - - - - - - - - - - - - {unmatchedItems.map((u: any, i: number) => ( - - - - - - - - - ))} - -
品项编码品项名称品类配送量配送金额涉及门店数
{u.item_code}{u.item_name}{u.minor_category}{formatNumber(u.dist_qty)}{formatCurrency(u.dist_amt)}{u.store_count}
-
+ formatNumber(u.dist_qty) }, + { key: 'dist_amt', label: '配送金额', align: 'right', render: (u) => formatCurrency(u.dist_amt) }, + { key: 'store_count', label: '涉及门店数', align: 'right' }, + ]} + />
这些品项在配送系统有发货记录,但在库存系统中无对应的耗用数据,可能存在编码不一致、未入库或数据缺失等问题。 diff --git a/client/src/pages/IndicatorsPage.tsx b/client/src/pages/IndicatorsPage.tsx index 89a6772..cc9b757 100644 --- a/client/src/pages/IndicatorsPage.tsx +++ b/client/src/pages/IndicatorsPage.tsx @@ -1,26 +1,29 @@ import { useQuery } from '@tanstack/react-query' import api from '@/lib/api' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' -import { useState, useMemo } from 'react' - -const PAGE_SIZE = 10 +import { FilterableTable } from '@/components/FilterableTable' export function IndicatorsPage() { - const [page, setPage] = useState(1) const { data } = useQuery({ queryKey: ['indicators'], queryFn: () => api.get('/tasks/indicators'), }) const indicators = (data as any)?.data || [] - const pagedIndicators = useMemo(() => indicators.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [indicators, page]) return (

指标字典

- - {r.business_definition} }, @@ -31,7 +34,6 @@ export function IndicatorsPage() { { key: 'red_threshold', label: '红线', align: 'right' }, { key: 'version_date', label: '版本日期', render: (r) => r.version_date?.substring(0, 10) }, ]} - data={pagedIndicators} />
) diff --git a/client/src/pages/MemberPage.tsx b/client/src/pages/MemberPage.tsx index 12e9fa4..231f321 100644 --- a/client/src/pages/MemberPage.tsx +++ b/client/src/pages/MemberPage.tsx @@ -1,24 +1,20 @@ import { useQuery } from '@tanstack/react-query' import api from '@/lib/api' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { MetricCard } from '@/components/MetricCard' import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' -import { useState, useMemo } from 'react' +import { useState } from 'react' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts' import { MonthPicker } from '@/components/MonthPicker' -const PAGE_SIZE = 15 - export function MemberPage() { - const [repeatPage, setRepeatPage] = useState(1) const [month, setMonth] = useState('2026-04') const { data: comparisonData, isLoading: cmpLoading } = useQuery({ - queryKey: ['member/comparison'], - queryFn: () => api.get('/member/comparison'), + queryKey: ['member/comparison', month], + queryFn: () => api.get('/member/comparison', { params: { month } }), }) const { data: repeatData, isLoading: repeatLoading } = useQuery({ @@ -60,8 +56,6 @@ export function MemberPage() { ] const PIE_COLORS = ['#3b82f6', '#e5e7eb'] - const pagedRepeat = useMemo(() => repeatRows.slice((repeatPage - 1) * PAGE_SIZE, repeatPage * PAGE_SIZE), [repeatRows, repeatPage]) - if (pageLoading) { return } @@ -120,7 +114,16 @@ export function MemberPage() { {/* 会员对比明细 */} - formatNumber(r.bill_count) }, @@ -129,29 +132,36 @@ export function MemberPage() { { key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) }, { key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) }, ]} - data={cmpRows} /> {/* 门店复购率 */} - -
- formatNumber(r.identified_members) }, - { key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) }, - { key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => { - const v = Number(r.repeat_rate_pct || 0) - return = 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}% - }}, - { key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) }, - { key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` }, - ]} - data={pagedRepeat} - /> -
+ formatNumber(r.identified_members) }, + { key: 'repeat_members', label: '复购会员数', align: 'right', render: (r) => formatNumber(r.repeat_members) }, + { key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => { + const v = Number(r.repeat_rate_pct || 0) + return = 50 ? 'font-medium text-green-600' : v >= 30 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}% + }}, + { key: 'avg_orders', label: '人均消费次数', align: 'right', render: (r) => Number(r.avg_orders || 0).toFixed(2) }, + { key: 'repeat_revenue_share_pct', label: '复购收入占比', align: 'right', render: (r) => `${Number(r.repeat_revenue_share_pct || 0).toFixed(1)}%` }, + ]} + />
) diff --git a/client/src/pages/MonthlyReviewPage.tsx b/client/src/pages/MonthlyReviewPage.tsx index 93f8ef0..1a2e4e5 100644 --- a/client/src/pages/MonthlyReviewPage.tsx +++ b/client/src/pages/MonthlyReviewPage.tsx @@ -1,16 +1,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '@/lib/api' import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, CartesianGrid, XAxis, YAxis, Legend } from 'recharts' -import { DataTable } from '@/components/DataTable' +import { FilterableTable } from '@/components/FilterableTable' import { Badge } from '@/components/Badge' import { MetricCard } from '@/components/MetricCard' -import { Pagination } from '@/components/Pagination' -import { useState, useMemo } from 'react' +import { useState } from 'react' import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' import { MonthPicker } from '@/components/MonthPicker' const REVIEW_COLORS = { '达标': '#22c55e', '改善中': '#eab308', '未改善': '#ef4444' } -const PAGE_SIZE = 10 type ReviewTab = 'summary' | 'grade-change' | 'completion' | 'activity' | 'sku' | 'practice' | 'effectiveness' @@ -25,8 +23,7 @@ const REVIEW_TABS: { key: ReviewTab; label: string }[] = [ ] export function MonthlyReviewPage() { - const [month, setMonth] = useState('2026-05') - const [page, setPage] = useState(1) + const [month, setMonth] = useState('2026-04') const [tab, setTab] = useState('summary') const { data } = useQuery({ @@ -35,8 +32,8 @@ export function MonthlyReviewPage() { }) const { data: gradeData } = useQuery({ - queryKey: ['grade-change'], - queryFn: () => api.get('/tasks/grade-change'), + queryKey: ['grade-change', month], + queryFn: () => api.get('/tasks/grade-change', { params: { month } }), enabled: tab === 'grade-change', }) @@ -47,14 +44,14 @@ export function MonthlyReviewPage() { }) const { data: activityData } = useQuery({ - queryKey: ['monthly-review/activity-list'], - queryFn: () => api.get('/tasks/monthly-review/activity-list'), + queryKey: ['monthly-review/activity-list', month], + queryFn: () => api.get('/tasks/monthly-review/activity-list', { params: { month } }), enabled: tab === 'activity', }) const { data: skuGovData } = useQuery({ - queryKey: ['monthly-review/sku-governance'], - queryFn: () => api.get('/tasks/monthly-review/sku-governance'), + queryKey: ['monthly-review/sku-governance', month], + queryFn: () => api.get('/tasks/monthly-review/sku-governance', { params: { month } }), enabled: tab === 'sku', }) @@ -67,7 +64,6 @@ export function MonthlyReviewPage() { const rd = (data as any)?.data const summary = rd?.summary || { total: 0, passed: 0, improving: 0, failed: 0 } const details = rd?.details || [] - const pagedDetails = useMemo(() => details.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), [details, page]) const gradeRows = (gradeData as any)?.data || [] const completionRows = (completionData as any)?.data || [] @@ -124,9 +120,19 @@ export function MonthlyReviewPage() {

验收明细

- -
- }, ]} - data={pagedDetails} /> -
)} @@ -147,7 +151,17 @@ export function MonthlyReviewPage() {

门店P0/P1/P2升降级面板

{gradeRows.length === 0 ?

暂无升降级数据

: ( - r.change_month?.substring(0, 7) }, { key: 'store_name', label: '门店' }, @@ -159,7 +173,6 @@ export function MonthlyReviewPage() { } }, { key: 'reason', label: '原因', render: (r) => {r.reason} }, ]} - data={gradeRows} /> )}
@@ -183,7 +196,14 @@ export function MonthlyReviewPage() {
))}
- }, { key: 'total', label: '总数', align: 'right' }, @@ -193,7 +213,6 @@ export function MonthlyReviewPage() { { key: 'failed', label: '未改善', align: 'right', render: (r) => {r.failed} }, { key: 'completion_rate', label: '完成率', align: 'right', render: (r) => `${r.completion_rate}%` }, ]} - data={completionRows} /> )} @@ -206,7 +225,16 @@ export function MonthlyReviewPage() {

平台活动停/改/留清单

按活动贡献毛利排序,建议保留高毛利活动、停改低毛利活动

{activityRows.length === 0 ?

暂无数据

: ( - formatNumber(r.bill_count) }, @@ -223,7 +251,6 @@ export function MonthlyReviewPage() { return } }, ]} - data={activityRows} /> )}
@@ -246,7 +273,15 @@ export function MonthlyReviewPage() {

长尾SKU治理清单 (C类)

{skuGov?.longtail?.length > 0 ? ( - formatCurrency(r.received_amount) }, { key: 'revenue_share_pct', label: '营收占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` }, ]} - data={skuGov.longtail.slice(0, 20)} /> ) :

暂无长尾SKU数据

}
@@ -276,7 +310,15 @@ export function MonthlyReviewPage() {

指标有效性评价

评估上月哪些指标真正驱动了改善,哪些没有区分力

{effectivenessRows.length === 0 ?

暂无数据

: ( - } }, ]} - data={effectivenessRows} /> )}
diff --git a/client/src/pages/OntologyPage.tsx b/client/src/pages/OntologyPage.tsx index 0152e43..8d46ac1 100644 --- a/client/src/pages/OntologyPage.tsx +++ b/client/src/pages/OntologyPage.tsx @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query' import api from '@/lib/api' import { LoadingSpinner } from '@/components/LoadingSpinner' -import { DataTable } from '@/components/DataTable' +import { FilterableTable } from '@/components/FilterableTable' import { useState } from 'react' import { Database, Table, Layers, Settings, ChevronRight, Box, ArrowRight } from 'lucide-react' import { cn } from '@/lib/utils' @@ -189,7 +189,12 @@ export function OntologyPage() {

{labelOf(selectedTable)} {selectedTable}

- ({ key: c.column_name, label: c.column_name })) || []} + defaultSort={dim?.columns?.[0]?.column_name || ''} + defaultOrder="asc" + pageSize={20} columns={dim?.columns?.map((c: any) => ({ key: c.column_name, label: c.column_name, @@ -201,7 +206,6 @@ export function OntologyPage() { return {String(val)} } })) || []} - data={dim?.rows || []} />
字段定义:
@@ -294,9 +298,12 @@ export function OntologyPage() {

{labelOf(selectedTable)} {selectedTable}

- ({ key: k, label: k }))} + ({ key: k, label: k }))} + defaultSort={Object.keys(enumRows[0] || {})[0] || ''} + defaultOrder="asc" + columns={Object.keys(enumRows[0] || {}).map(k => ({ key: k, label: k }))} />
@@ -316,7 +323,18 @@ export function OntologyPage() { ))}
- m.metric_category === metricFilter)} + sortOptions={[ + { key: 'indicator_name', label: '指标名称' }, + { key: 'metric_category', label: '分类' }, + { key: 'owner', label: '负责人' }, + { key: 'update_frequency', label: '频率' }, + ]} + defaultSort="indicator_name" + defaultOrder="asc" + filterKey="metric_category" + filterLabel="全部分类" columns={[ { key: 'metric_code', label: '指标编码', render: (r: any) => {r.metric_code || '—'} }, { key: 'indicator_name', label: '指标名称', render: (r: any) => {r.indicator_name} }, @@ -331,7 +349,6 @@ export function OntologyPage() { { key: 'update_frequency', label: '频率', align: 'center' as const }, { key: 'version', label: '版本', render: (r: any) => {r.version || '—'} }, ]} - data={metricFilter === '全部' ? metrics : metrics.filter((m: any) => m.metric_category === metricFilter)} />
diff --git a/client/src/pages/PlatformPage.tsx b/client/src/pages/PlatformPage.tsx index bee1b1a..4228f26 100644 --- a/client/src/pages/PlatformPage.tsx +++ b/client/src/pages/PlatformPage.tsx @@ -1,20 +1,15 @@ import { useQuery } from '@tanstack/react-query' import api from '@/lib/api' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { MetricCard } from '@/components/MetricCard' import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' -import { useState, useMemo } from 'react' +import { useState } from 'react' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts' import { MonthPicker } from '@/components/MonthPicker' -const PAGE_SIZE = 15 - export function PlatformPage() { - const [platformPage, setPlatformPage] = useState(1) - const [marketingPage, setMarketingPage] = useState(1) const [month, setMonth] = useState('2026-04') const { data: platformData, isLoading: platformLoading } = useQuery({ @@ -23,8 +18,8 @@ export function PlatformPage() { }) const { data: marketingData, isLoading: marketingLoading } = useQuery({ - queryKey: ['marketing/plans'], - queryFn: () => api.get('/marketing/plans'), + queryKey: ['marketing/plans', month], + queryFn: () => api.get('/marketing/plans', { params: { month } }), }) const platformRows = (platformData as any)?.data || [] @@ -56,9 +51,6 @@ export function PlatformPage() { { name: '京东', 实收: totalJd, 折扣: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_discount || 0), 0), 佣金: storesWithPlatform.reduce((s: number, r: any) => s + Number(r.jd_commission || 0), 0) }, ] - const pagedPlatform = useMemo(() => storesWithPlatform.slice((platformPage - 1) * PAGE_SIZE, platformPage * PAGE_SIZE), [storesWithPlatform, platformPage]) - const pagedMarketing = useMemo(() => marketingRows.slice((marketingPage - 1) * PAGE_SIZE, marketingPage * PAGE_SIZE), [marketingRows, marketingPage]) - if (pageLoading) { return } @@ -101,51 +93,63 @@ export function PlatformPage() { {/* 门店平台经济性 */} - -
- formatCurrency(r.meituan_received) }, - { key: 'meituan_cost_rate_pct', label: '美团成本率', align: 'right', render: (r) => { - const v = Number(r.meituan_cost_rate_pct || 0) - return v > 0 ? = 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}% : - - }}, - { key: 'taobao_received', label: '淘宝实收', align: 'right', render: (r) => formatCurrency(r.taobao_received) }, - { key: 'taobao_cost_rate_pct', label: '淘宝成本率', align: 'right', render: (r) => { - const v = Number(r.taobao_cost_rate_pct || 0) - return v > 0 ? = 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}% : - - }}, - { key: 'jd_received', label: '京东实收', align: 'right', render: (r) => formatCurrency(r.jd_received) }, - { key: 'jd_cost_rate_pct', label: '京东成本率', align: 'right', render: (r) => { - const v = Number(r.jd_cost_rate_pct || 0) - return v > 0 ? = 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}% : - - }}, - ]} - data={pagedPlatform} - /> -
+ formatCurrency(r.meituan_received) }, + { key: 'meituan_cost_rate_pct', label: '美团成本率', align: 'right', render: (r) => { + const v = Number(r.meituan_cost_rate_pct || 0) + return v > 0 ? = 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}% : - + }}, + { key: 'taobao_received', label: '淘宝实收', align: 'right', render: (r) => formatCurrency(r.taobao_received) }, + { key: 'taobao_cost_rate_pct', label: '淘宝成本率', align: 'right', render: (r) => { + const v = Number(r.taobao_cost_rate_pct || 0) + return v > 0 ? = 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}% : - + }}, + { key: 'jd_received', label: '京东实收', align: 'right', render: (r) => formatCurrency(r.jd_received) }, + { key: 'jd_cost_rate_pct', label: '京东成本率', align: 'right', render: (r) => { + const v = Number(r.jd_cost_rate_pct || 0) + return v > 0 ? = 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}% : - + }}, + ]} + />
{/* 营销方案ROI */} - -
- formatNumber(r.bill_count) }, - { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, - { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, - { key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => { - const v = Number(r.discount_rate_pct || 0) - return = 25 ? 'font-medium text-red-600' : v >= 15 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}% - }}, - { key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) }, - ]} - data={pagedMarketing} - /> -
+ formatNumber(r.bill_count) }, + { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, + { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, + { key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => { + const v = Number(r.discount_rate_pct || 0) + return = 25 ? 'font-medium text-red-600' : v >= 15 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}% + }}, + { key: 'theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) }, + ]} + />
) diff --git a/client/src/pages/ProductionPlanPage.tsx b/client/src/pages/ProductionPlanPage.tsx index 2242a6f..d621b64 100644 --- a/client/src/pages/ProductionPlanPage.tsx +++ b/client/src/pages/ProductionPlanPage.tsx @@ -68,9 +68,11 @@ export function ProductionPlanPage() { return (
{/* 页面标题 */} -
- -

销量驱动生产与要货计划

+
+
+ +

销量驱动生产与要货计划

+
@@ -79,12 +81,15 @@ export function ProductionPlanPage() { 预测逻辑: SKU销量 × 门店BOM = 基础原料需求 | 生产计划: 各门店要货合计 + 中央厨房安全库存 | 要货建议: 需求量 - 当前库存 + 安全库存
- {/* 核心指标 */} -
+ {/* 核心指标 - 第一行:菜品与BOM */} +
= 50 ? 'good' : 'warn'} /> +
+ {/* 核心指标 - 第二行:销售额与生产计划 */} +
p.demand_qty > 0).length} unit="种" status="warn" description="有销量需求的半成品数" /> @@ -213,6 +218,10 @@ export function ProductionPlanPage() { {/* 门店要货建议 */} +
+ + 建议要货量 = 原料需求量 - 期末库存量,为门店级宏观参考;实际要货请按「原料需求」表逐项确认,注意单位差异 +
(null) const [weeklyText, setWeeklyText] = useState('') const { data: riskData, isLoading: riskLoading } = useQuery({ - queryKey: ['stores/risk'], - queryFn: () => api.get('/stores/risk'), + queryKey: ['stores/risk', month], + queryFn: () => api.get('/stores/risk', { params: { month } }), }) const { data: weeklyData, isLoading: weeklyLoading } = useQuery({ - queryKey: ['tasks/weekly-check'], + queryKey: ['tasks/weekly-check', month], queryFn: () => api.get('/tasks/weekly-check', { params: { month } }), }) const { data: priorityData, isLoading: priorityLoading } = useQuery({ - queryKey: ['stores/priority'], - queryFn: () => api.get('/stores/priority'), + queryKey: ['stores/priority', month], + queryFn: () => api.get('/stores/priority', { params: { month } }), }) const { data: benchmarkData } = useQuery({ - queryKey: ['benchmark/composite'], - queryFn: () => api.get('/benchmark/composite'), + queryKey: ['benchmark/composite', month], + queryFn: () => api.get('/benchmark/composite', { params: { month } }), }) const { data: regionData } = useQuery({ - queryKey: ['region/summary'], - queryFn: () => api.get('/region/summary'), + queryKey: ['region/summary', month], + queryFn: () => api.get('/region/summary', { params: { month } }), }) const riskRows = ((riskData as any)?.data || []).filter((r: any) => !riskFilter || r.risk_level === riskFilter) @@ -58,10 +52,6 @@ export function RegionalPage() { const p0p1 = priorityRows.filter((s: any) => s.action_priority?.startsWith('P0') || s.action_priority?.startsWith('P1')) const needCheck = weeklyRows.filter((w: any) => parseInt(w.consecutive_no_improve_weeks) >= 2) - const pagedRiskRows = useMemo(() => riskRows.slice((riskPage - 1) * PAGE_SIZE, riskPage * PAGE_SIZE), [riskRows, riskPage]) - const pagedNeedCheck = useMemo(() => needCheck.slice((checkPage - 1) * PAGE_SIZE, checkPage * PAGE_SIZE), [needCheck, checkPage]) - const pagedBenchmark = useMemo(() => benchmarkRows.slice((benchPage - 1) * PAGE_SIZE, benchPage * PAGE_SIZE), [benchmarkRows, benchPage]) - const pageLoading = riskLoading || weeklyLoading || priorityLoading const weeklyMutation = useMutation({ @@ -96,7 +86,16 @@ export function RegionalPage() { {regionRows.length > 0 && (

区域经营汇总

- 0 ? 'font-bold text-yellow-600' : ''}>{r.yellow_count} }, { key: 'green_count', label: '绿', align: 'center', render: (r) => {r.green_count} }, ]} - data={regionRows} />
)} @@ -130,9 +128,20 @@ export function RegionalPage() { {/* 门店红黄绿列表 */}

门店风险分级 ({riskRows.length})

- -
- navigate(`/stores/${r.store_code}`)} columns={[ { key: 'store_name', label: '门店' }, { key: 'risk_level', label: '风险', render: (r) => }, @@ -141,19 +150,22 @@ export function RegionalPage() { { key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) }, { key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => formatPercent(r.anomaly_rate_pct) }, ]} - data={pagedRiskRows} - onRowClick={(r) => navigate(`/stores/${r.store_code}`)} /> -
{/* 需到店检查门店 + 周检录入 */} {needCheck.length > 0 && (

需到店检查门店(连续2周未改善)({needCheck.length})

- -
- navigate(`/tasks/${r.task_id}`)} columns={[ { key: 'store_name', label: '门店' }, { key: 'problem_indicator', label: '问题指标' }, @@ -163,10 +175,7 @@ export function RegionalPage() { ) }, ]} - data={pagedNeedCheck} - onRowClick={(r) => navigate(`/tasks/${r.task_id}`)} /> -
)} @@ -174,25 +183,32 @@ export function RegionalPage() { {benchmarkRows.length > 0 && (

同类门店基准对比

- -
- { - const v = Number(r.benchmark_score || 0) - return = 80 ? 'text-green-600' : v >= 60 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)} - } }, - { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, - { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, - { key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) }, - { key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) }, - { key: 'member_bill_share_pct', label: '会员占比', align: 'right', render: (r) => formatPercent(r.member_bill_share_pct) }, - ]} - data={pagedBenchmark} - onRowClick={(r) => navigate(`/stores/${r.store_code}`)} - /> -
+ navigate(`/stores/${r.store_code}`)} + columns={[ + { key: 'store_name', label: '门店' }, + { key: 'benchmark_score', label: '标杆得分', align: 'right', render: (r) => { + const v = Number(r.benchmark_score || 0) + return = 80 ? 'text-green-600' : v >= 60 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)} + } }, + { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, + { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, + { key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => formatPercent(r.discount_rate_pct) }, + { key: 'theoretical_margin_pct', label: '毛利率', align: 'right', render: (r) => formatPercent(r.theoretical_margin_pct) }, + { key: 'member_bill_share_pct', label: '会员占比', align: 'right', render: (r) => formatPercent(r.member_bill_share_pct) }, + ]} + />
)} diff --git a/client/src/pages/RevenuePage.tsx b/client/src/pages/RevenuePage.tsx index 639aeeb..db5e681 100644 --- a/client/src/pages/RevenuePage.tsx +++ b/client/src/pages/RevenuePage.tsx @@ -7,6 +7,8 @@ import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils' import { BarChart3, TrendingDown, Users, Receipt } from 'lucide-react' +import { MonthPicker } from '@/components/MonthPicker' +import { useState } from 'react' const CHANNEL_COLORS: Record = { wechat: '#07c160', @@ -23,22 +25,23 @@ const CHANNEL_COLORS: Record = { export function RevenuePage() { const navigate = useNavigate() + const [month, setMonth] = useState('2026-04') const { data: dailyData, isLoading: dailyLoading } = useQuery({ - queryKey: ['revenue/daily-summary'], - queryFn: () => api.get('/revenue/daily-summary'), + queryKey: ['revenue/daily-summary', month], + queryFn: () => api.get('/revenue/daily-summary', { params: { month } }), }) const { data: channelData, isLoading: channelLoading } = useQuery({ - queryKey: ['revenue/channel'], - queryFn: () => api.get('/revenue/channel'), + queryKey: ['revenue/channel', month], + queryFn: () => api.get('/revenue/channel', { params: { month } }), }) const { data: mealPeriodData, isLoading: mealLoading } = useQuery({ - queryKey: ['revenue/meal-period'], - queryFn: () => api.get('/revenue/meal-period'), + queryKey: ['revenue/meal-period', month], + queryFn: () => api.get('/revenue/meal-period', { params: { month } }), }) const { data: storeRankingData, isLoading: rankLoading } = useQuery({ - queryKey: ['revenue/store-ranking'], - queryFn: () => api.get('/revenue/store-ranking'), + queryKey: ['revenue/store-ranking', month], + queryFn: () => api.get('/revenue/store-ranking', { params: { month } }), }) const pageLoading = dailyLoading || channelLoading || mealLoading || rankLoading @@ -138,12 +141,15 @@ export function RevenuePage() { return (
{/* 标题 */} -
- -
-

营收分析

-

收入结构 · 渠道分布 · 时段分析 · 门店排名 · 2026年4月

+
+
+ +
+

营收分析

+

收入结构 · 渠道分布 · 时段分析 · 门店排名

+
+
{/* 核心指标 */} diff --git a/client/src/pages/RiskPage.tsx b/client/src/pages/RiskPage.tsx index ef03dfe..2b6488e 100644 --- a/client/src/pages/RiskPage.tsx +++ b/client/src/pages/RiskPage.tsx @@ -1,65 +1,88 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, keepPreviousData } from '@tanstack/react-query' import api from '@/lib/api' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { MetricCard } from '@/components/MetricCard' import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' -import { useState, useMemo } from 'react' +import { useState, useEffect } from 'react' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import { MonthPicker } from '@/components/MonthPicker' -const PAGE_SIZE = 15 +const ANOMALY_PAGE_SIZE = 20 + +function SectionLoading({ text }: { text: string }) { + return ( +
+
+ {text} +
+ ) +} export function RiskPage() { - const [anomalyPage, setAnomalyPage] = useState(1) - const [cashierPage, setCashierPage] = useState(1) - const [zeroPage, setZeroPage] = useState(1) const [riskTab, setRiskTab] = useState<'anomaly' | 'zero' | 'cashier'>('anomaly') + const [month, setMonth] = useState('2026-04') + const [anomalyPage, setAnomalyPage] = useState(1) + const [anomalyStore, setAnomalyStore] = useState('') + const [anomalyReason, setAnomalyReason] = useState('') + const [anomalyCashier, setAnomalyCashier] = useState('') + const [debouncedCashier, setDebouncedCashier] = useState('') - const { data: anomalyData, isLoading: anomalyLoading } = useQuery({ - queryKey: ['risk/anomaly'], - queryFn: () => api.get('/risk/anomaly'), + useEffect(() => { + const t = setTimeout(() => setDebouncedCashier(anomalyCashier), 400) + return () => clearTimeout(t) + }, [anomalyCashier]) + + const { data: anomalyData, isLoading: anomalyLoading, isFetching: anomalyFetching } = useQuery({ + queryKey: ['risk/anomaly', month, anomalyPage, anomalyStore, anomalyReason, debouncedCashier], + queryFn: () => api.get('/risk/anomaly', { params: { month, page: anomalyPage, page_size: ANOMALY_PAGE_SIZE, ...(anomalyStore ? { store: anomalyStore } : {}), ...(anomalyReason ? { reason: anomalyReason } : {}), ...(debouncedCashier ? { cashier: debouncedCashier } : {}) } }), + placeholderData: keepPreviousData, }) - const { data: zeroData, isLoading: zeroLoading } = useQuery({ - queryKey: ['risk/zero-received'], - queryFn: () => api.get('/risk/zero-received'), + const { data: zeroData, isLoading: zeroLoading, isFetching: zeroFetching } = useQuery({ + queryKey: ['risk/zero-received', month], + queryFn: () => api.get('/risk/zero-received', { params: { month } }), + staleTime: 5 * 60 * 1000, + placeholderData: keepPreviousData, }) - const { data: cashierData, isLoading: cashierLoading } = useQuery({ - queryKey: ['risk/cashier'], - queryFn: () => api.get('/risk/cashier'), + const { data: cashierData, isLoading: cashierLoading, isFetching: cashierFetching } = useQuery({ + queryKey: ['risk/cashier', month], + queryFn: () => api.get('/risk/cashier', { params: { month } }), + staleTime: 5 * 60 * 1000, + placeholderData: keepPreviousData, }) const anomalyRows = (anomalyData as any)?.data || [] + const anomalyMeta = (anomalyData as any)?.meta || { total: 0 } const zeroRows = (zeroData as any)?.data || [] const cashierRows = (cashierData as any)?.data || [] + const storeNames: string[] = Array.from(new Set(cashierRows.map((r: any) => r.store_name as string).filter(Boolean))).sort() - const pageLoading = anomalyLoading || zeroLoading || cashierLoading + const initialLoading = !anomalyData && !zeroData && !cashierData - const totalAnomaly = anomalyRows.length + const totalAnomaly = anomalyMeta.total || anomalyRows.length const totalZero = zeroRows.length const highRiskCashiers = cashierRows.filter((r: any) => Number(r.anomaly_rate_pct || 0) >= 10).length const avgAnomalyRate = cashierRows.length > 0 ? cashierRows.reduce((s: number, r: any) => s + Number(r.anomaly_rate_pct || 0), 0) / cashierRows.length : 0 - const pagedAnomaly = useMemo(() => anomalyRows.slice((anomalyPage - 1) * PAGE_SIZE, anomalyPage * PAGE_SIZE), [anomalyRows, anomalyPage]) - const pagedZero = useMemo(() => zeroRows.slice((zeroPage - 1) * PAGE_SIZE, zeroPage * PAGE_SIZE), [zeroRows, zeroPage]) - const pagedCashier = useMemo(() => cashierRows.slice((cashierPage - 1) * PAGE_SIZE, cashierPage * PAGE_SIZE), [cashierRows, cashierPage]) + const cashierChartData = [...cashierRows].sort((a: any, b: any) => Number(b.anomaly_bills || 0) - Number(a.anomaly_bills || 0)).slice(0, 15).map((r: any) => ({ ...r, anomaly_bills: Number(r.anomaly_bills || 0) })) - const cashierChartData = cashierRows.slice(0, 15) - - if (pageLoading) { + if (initialLoading) { return } return (
-
-

风险与内控管理

-

异常账单 · 零实收 · 收银风险 · 2026年4月

+
+
+

风险与内控管理

+

异常账单 · 零实收 · 收银风险

+
+
{/* 概览指标 */} @@ -73,16 +96,20 @@ export function RiskPage() { {/* 收银员风险图 */} - - - + + {cashierFetching ? ( + + ) : ( + + - - - `${Number(v).toFixed(2)}%`} /> - + + + formatNumber(Number(v))} /> + + )} {/* 风险明细表 — Tab切换 */} @@ -100,67 +127,119 @@ export function RiskPage() {
{riskTab === 'anomaly' && ( + anomalyFetching ? : <> - -
- formatCurrency(r.consumption) }, - { key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) }, - { key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) }, - { key: 'anomaly_reason', label: '异常原因' }, - { key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) }, - ]} - data={pagedAnomaly} - /> -
+
+ 筛选合计: + 消费额 {formatCurrency(anomalyMeta.sum_consumption)} + 优惠 {formatCurrency(anomalyMeta.sum_discount)} + 应收 {formatCurrency(Number(anomalyMeta.sum_consumption || 0) - Number(anomalyMeta.sum_discount || 0))} + 实收 {formatCurrency(anomalyMeta.sum_received)} + | 共 {totalAnomaly} 条 +
+ { setAnomalyStore(v); setAnomalyPage(1) }} + filterKey="store_name" + filterLabel="全部门店" + filterOptions={storeNames} + statusFilterKey="anomaly_reason" + statusFilterLabel="全部异常原因" + statusOptions={[ + { value: '有消费无实收', label: '有消费无实收' }, + { value: '优惠大于消费', label: '优惠大于消费' }, + { value: '消费-优惠与实收不平', label: '消费-优惠与实收不平' }, + ]} + statusFilterValue={anomalyReason} + onStatusFilterChange={(v) => { setAnomalyReason(v); setAnomalyPage(1) }} + searchPlaceholder="搜索收银员..." + serverSearchValue={anomalyCashier} + onServerSearchChange={(v) => { setAnomalyCashier(v); setAnomalyPage(1) }} + sortOptions={[ + { key: 'consumption', label: '消费额' }, + { key: 'discount_total', label: '优惠' }, + { key: 'received_total', label: '实收' }, + ]} + defaultSort="consumption" + defaultOrder="desc" + columns={[ + { key: 'store_name', label: '门店' }, + { key: 'bill_no', label: '账单号' }, + { key: 'meal_period', label: '餐段' }, + { key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) }, + { key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) }, + { key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) }, + { key: 'anomaly_reason', label: '异常原因' }, + { key: 'cashier', label: '收银员' }, + { key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) }, + ]} + /> )} {riskTab === 'zero' && ( - <> - -
- formatCurrency(r.consumption) }, - { key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) }, - { key: 'zero_received_type', label: '零实收类型' }, - { key: 'cashier', label: '收银员' }, - { key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) }, - ]} - data={pagedZero} - /> -
- + zeroFetching ? : + formatCurrency(r.consumption) }, + { key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) }, + { key: 'zero_received_type', label: '零实收类型' }, + { key: 'cashier', label: '收银员' }, + { key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) }, + ]} + /> )} {riskTab === 'cashier' && ( - <> - -
- formatNumber(r.bill_count) }, - { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, - { key: 'anomaly_bills', label: '异常账单数', align: 'right', render: (r) => formatNumber(r.anomaly_bills) }, - { key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => { - const v = Number(r.anomaly_rate_pct || 0) - return = 10 ? 'font-medium text-red-600' : v >= 5 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(2)}% - }}, - { key: 'anomaly_consumption', label: '异常消费额', align: 'right', render: (r) => formatCurrency(r.anomaly_consumption) }, - ]} - data={pagedCashier} - /> -
- + cashierFetching ? : + { + setAnomalyCashier(r.cashier) + setDebouncedCashier(r.cashier) + setAnomalyPage(1) + setRiskTab('anomaly') + }} + sortOptions={[ + { key: 'anomaly_rate_pct', label: '异常率' }, + { key: 'bill_count', label: '账单数' }, + { key: 'received', label: '实收' }, + { key: 'anomaly_bills', label: '异常账单数' }, + { key: 'cashier', label: '收银员' }, + ]} + defaultSort="anomaly_bills" + defaultOrder="desc" + columns={[ + { key: 'cashier', label: '收银员' }, + { key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) }, + { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, + { key: 'anomaly_bills', label: '异常账单数', align: 'right', render: (r) => formatNumber(r.anomaly_bills) }, + { key: 'anomaly_rate_pct', label: '异常率', align: 'right', render: (r) => { + const v = Number(r.anomaly_rate_pct || 0) + return = 10 ? 'font-medium text-red-600' : v >= 5 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(2)}% + }}, + { key: 'anomaly_consumption', label: '异常消费额', align: 'right', render: (r) => formatCurrency(r.anomaly_consumption) }, + ]} + /> )}
diff --git a/client/src/pages/SKUPage.tsx b/client/src/pages/SKUPage.tsx index f132c21..8162049 100644 --- a/client/src/pages/SKUPage.tsx +++ b/client/src/pages/SKUPage.tsx @@ -1,21 +1,17 @@ import { useQuery } from '@tanstack/react-query' import api from '@/lib/api' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { MetricCard } from '@/components/MetricCard' import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' -import { useState, useMemo } from 'react' +import { useState } from 'react' import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts' import { MonthPicker } from '@/components/MonthPicker' const ABC_COLORS = { 'A-核心': '#22c55e', 'B-成长': '#3b82f6', 'C-长尾': '#ef4444' } -const PAGE_SIZE = 15 export function SKUPage() { - const [abcFilter, setAbcFilter] = useState('') - const [skuPage, setSkuPage] = useState(1) const [month, setMonth] = useState('2026-04') const { data: skuData, isLoading: skuLoading } = useQuery({ @@ -28,7 +24,7 @@ export function SKUPage() { queryFn: () => api.get('/sku/category'), }) - const skuRows = ((skuData as any)?.data || []).filter((r: any) => !abcFilter || r.abc_class === abcFilter) + const skuRows = ((skuData as any)?.data || []) const catRows = (catData as any)?.data || [] const abcSummary = (skuData as any)?.data?.reduce((acc: any, r: any) => { @@ -53,8 +49,6 @@ export function SKUPage() { const lowValueSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.received_amount) < 1000).length const singleStoreSku = ((skuData as any)?.data || []).filter((r: any) => Number(r.store_count) <= 1).length - const pagedSku = useMemo(() => skuRows.slice((skuPage - 1) * PAGE_SIZE, skuPage * PAGE_SIZE), [skuRows, skuPage]) - const pageLoading = skuLoading || catLoading if (pageLoading) { @@ -117,40 +111,37 @@ export function SKUPage() { {/* SKU明细表 */} -
- {['', 'A-核心', 'B-成长', 'C-长尾'].map((f) => ( - - ))} -
- -
- ( - {r.abc_class} - )}, - { key: 'sales_quadrant', label: '象限' }, - { key: 'store_count', label: '覆盖门店', align: 'center' }, - { key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) }, - { key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) }, - { key: 'revenue_share_pct', label: '占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` }, - { key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => `${Number(r.discount_rate_pct).toFixed(1)}%` }, - ]} - data={pagedSku} - /> -
+ ( + {r.abc_class} + )}, + { key: 'sales_quadrant', label: '象限' }, + { key: 'store_count', label: '覆盖门店', align: 'center' }, + { key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) }, + { key: 'received_amount', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_amount) }, + { key: 'revenue_share_pct', label: '占比', align: 'right', render: (r) => `${Number(r.revenue_share_pct).toFixed(2)}%` }, + { key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => `${Number(r.discount_rate_pct).toFixed(1)}%` }, + ]} + />
{/* 长尾治理建议 */} diff --git a/client/src/pages/SiteSelectionPage.tsx b/client/src/pages/SiteSelectionPage.tsx index ec31a76..ce313dd 100644 --- a/client/src/pages/SiteSelectionPage.tsx +++ b/client/src/pages/SiteSelectionPage.tsx @@ -1,18 +1,16 @@ import { useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import api from '@/lib/api' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { CollapsibleSection } from '@/components/CollapsibleSection' import { MetricCard } from '@/components/MetricCard' import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' -import { useState, useMemo } from 'react' +import { useState } from 'react' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, ReferenceLine, Cell } from 'recharts' import { StoreMap } from '@/components/StoreMap' import { MonthPicker } from '@/components/MonthPicker' -const PAGE_SIZE = 15 const SCENE_COLORS: Record = { '办公园区': '#3b82f6', '商场商业体': '#a855f7', '社区居民': '#22c55e', '街边综合': '#dc2626', '交通枢纽': '#f97316', '校园档口': '#06b6d4', '特殊业态': '#6b7280', } @@ -31,9 +29,6 @@ export function SiteSelectionPage() { const navigate = useNavigate() const [tab, setTab] = useState('benchmark') const [sceneFilter, setSceneFilter] = useState('') - const [replPage, setReplPage] = useState(1) - const [overlapPage, setOverlapPage] = useState(1) - const [profilePage, setProfilePage] = useState(1) const [month, setMonth] = useState('2026-04') const { data: profileData, isLoading: profileLoading } = useQuery({ @@ -67,10 +62,6 @@ export function SiteSelectionPage() { const overlapRows = (overlapData as any)?.data || [] const districtRows = (districtData as any)?.data || [] - const pagedRepl = useMemo(() => replRows.slice((replPage - 1) * PAGE_SIZE, replPage * PAGE_SIZE), [replRows, replPage]) - const pagedOverlap = useMemo(() => overlapRows.slice((overlapPage - 1) * PAGE_SIZE, overlapPage * PAGE_SIZE), [overlapRows, overlapPage]) - const pagedProfile = useMemo(() => profileRows.slice((profilePage - 1) * PAGE_SIZE, profilePage * PAGE_SIZE), [profileRows, profilePage]) - const pageLoading = profileLoading || segLoading || replLoading || overlapLoading || districtLoading if (pageLoading) { @@ -178,7 +169,18 @@ export function SiteSelectionPage() { {tab === 'benchmark' && (
- formatCurrency(r.avg_bill_value) }, { key: 'avg_repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.avg_repeat_rate_pct) }, ]} - data={segRows} /> -
- {['', '办公园区', '商场商业体', '社区居民', '街边综合'].map((f) => ( - - ))} -
- -
- {r.site_scene} }, - { key: 'area_band', label: '面积段' }, - { key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}㎡` : '-' }, - { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, - { key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) }, - { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, - { key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.repeat_rate_pct) }, - { key: 'actual_food_cost_rate_pct', label: '成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) }, - ]} - data={pagedProfile} - onRowClick={(r) => navigate(`/stores/${r.store_code}`)} - /> -
+ navigate(`/stores/${r.store_code}`)} + columns={[ + { key: 'store_name', label: '门店' }, + { key: 'site_scene', label: '场景', render: (r) => {r.site_scene} }, + { key: 'area_band', label: '面积段' }, + { key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}㎡` : '-' }, + { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, + { key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) }, + { key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) }, + { key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.repeat_rate_pct) }, + { key: 'actual_food_cost_rate_pct', label: '成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) }, + ]} + />
)} @@ -232,32 +230,39 @@ export function SiteSelectionPage() { {/* 复制评分 */} {tab === 'replication' && ( - -
- r.area_sqm ? `${r.area_sqm}㎡` : '-' }, - { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, - { key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) }, - { key: 'nearest_store_name', label: '最近门店' }, - { key: 'nearest_distance_km', label: '距离(km)', align: 'right', render: (r) => `${r.nearest_distance_km}km` }, - { key: 'site_replication_score', label: '复制评分', align: 'right', render: (r) => { - const v = Number(r.site_replication_score || 0) - return = 75 ? 'font-bold text-green-600' : v >= 60 ? 'text-blue-600' : v < 40 ? 'text-red-600' : ''}>{v.toFixed(2)} - } }, - { key: 'replication_recommendation', label: '复制建议', render: (r) => { - const rec = r.replication_recommendation - const cls = rec === '优先提炼选址原型' ? 'bg-green-100 text-green-700' : rec === '不宜作为选址标杆' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700' - return {rec} - } }, - { key: 'spatial_recommendation', label: '空间建议', render: (r) => {r.spatial_recommendation} }, - ]} - data={pagedRepl} - onRowClick={(r) => navigate(`/stores/${r.store_code}`)} - /> -
+ navigate(`/stores/${r.store_code}`)} + columns={[ + { key: 'store_name', label: '门店' }, + { key: 'site_scene', label: '场景' }, + { key: 'area_sqm', label: '面积', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}㎡` : '-' }, + { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) }, + { key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) }, + { key: 'nearest_store_name', label: '最近门店' }, + { key: 'nearest_distance_km', label: '距离(km)', align: 'right', render: (r) => `${r.nearest_distance_km}km` }, + { key: 'site_replication_score', label: '复制评分', align: 'right', render: (r) => { + const v = Number(r.site_replication_score || 0) + return = 75 ? 'font-bold text-green-600' : v >= 60 ? 'text-blue-600' : v < 40 ? 'text-red-600' : ''}>{v.toFixed(2)} + } }, + { key: 'replication_recommendation', label: '复制建议', render: (r) => { + const rec = r.replication_recommendation + const cls = rec === '优先提炼选址原型' ? 'bg-green-100 text-green-700' : rec === '不宜作为选址标杆' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700' + return {rec} + } }, + { key: 'spatial_recommendation', label: '空间建议', render: (r) => {r.spatial_recommendation} }, + ]} + />
)} @@ -270,24 +275,30 @@ export function SiteSelectionPage() { r.distance_km >= 2).length} format="number" description="不能仅用直线距离判断" />
- -
- `${Number(r.distance_km).toFixed(2)}km` }, - { key: 'received_a', label: 'A实收', align: 'right', render: (r) => formatCurrency(r.received_a) }, - { key: 'received_b', label: 'B实收', align: 'right', render: (r) => formatCurrency(r.received_b) }, - { key: 'overlap_risk', label: '风险等级', render: (r) => { - const risk = r.overlap_risk - const color = RISK_COLORS[risk] || '#999' - return {risk} - } }, - ]} - data={pagedOverlap} - /> -
+ `${Number(r.distance_km).toFixed(2)}km` }, + { key: 'received_a', label: 'A实收', align: 'right', render: (r) => formatCurrency(r.received_a) }, + { key: 'received_b', label: 'B实收', align: 'right', render: (r) => formatCurrency(r.received_b) }, + { key: 'overlap_risk', label: '风险等级', render: (r) => { + const risk = r.overlap_risk + const color = RISK_COLORS[risk] || '#999' + return {risk} + } }, + ]} + />
)} @@ -308,7 +319,16 @@ export function SiteSelectionPage() { - r.p0_count > 0 ? {r.p0_count} : '0' }, { key: 'p1_count', label: 'P1', align: 'center', render: (r) => r.p1_count > 0 ? {r.p1_count} : '0' }, ]} - data={districtRows} />
diff --git a/client/src/pages/SituationalAwarenessPage.tsx b/client/src/pages/SituationalAwarenessPage.tsx index 00b075e..41a26a0 100644 --- a/client/src/pages/SituationalAwarenessPage.tsx +++ b/client/src/pages/SituationalAwarenessPage.tsx @@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom' import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, Legend, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis } from 'recharts' import api from '@/lib/api' import { CollapsibleSection } from '@/components/CollapsibleSection' -import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' diff --git a/client/src/pages/SmartSchedulingPage.tsx b/client/src/pages/SmartSchedulingPage.tsx index fd4ba54..225e5fb 100644 --- a/client/src/pages/SmartSchedulingPage.tsx +++ b/client/src/pages/SmartSchedulingPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { Tabs } from '@/components/Tabs' +import { MonthPicker } from '@/components/MonthPicker' import { OverallAnalysisTab } from '@/components/smart-scheduling/OverallAnalysisTab' import { StaffingForecastTab } from '@/components/smart-scheduling/StaffingForecastTab' import { TrafficHeatmapTab } from '@/components/smart-scheduling/TrafficHeatmapTab' @@ -22,25 +23,29 @@ const TABS = [ export function SmartSchedulingPage() { const [activeTab, setActiveTab] = useState('overall') + const [month, setMonth] = useState('2026-04') return (
-
-

智能排班分析

-

2026年4月 · 客流规律 × 排班匹配 × 人效优化 · 103家门店

+
+
+

智能排班分析

+

客流规律 × 排班匹配 × 人效优化 · 103家门店

+
+
- {activeTab === 'overall' && } - {activeTab === 'forecast' && } - {activeTab === 'traffic' && } - {activeTab === 'match' && } - {activeTab === 'efficiency' && } - {activeTab === 'suggestion' && } - {activeTab === 'alert' && } - {activeTab === 'employee' && } + {activeTab === 'overall' && } + {activeTab === 'forecast' && } + {activeTab === 'traffic' && } + {activeTab === 'match' && } + {activeTab === 'efficiency' && } + {activeTab === 'suggestion' && } + {activeTab === 'alert' && } + {activeTab === 'employee' && }
) diff --git a/client/src/pages/StoreDetailPage.tsx b/client/src/pages/StoreDetailPage.tsx index b566cb3..6c00522 100644 --- a/client/src/pages/StoreDetailPage.tsx +++ b/client/src/pages/StoreDetailPage.tsx @@ -4,8 +4,6 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai import api from '@/lib/api' import { MetricCard } from '@/components/MetricCard' import { Badge } from '@/components/Badge' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { formatCurrency, formatPercent, formatNumber, cn } from '@/lib/utils' @@ -40,12 +38,11 @@ export function StoreDetailPage() { const { code } = useParams() const navigate = useNavigate() const [tab, setTab] = useState('overview') - const [anomalyPage, setAnomalyPage] = useState(1) - const [month, setMonth] = useState('2026-05') + const [month, setMonth] = useState('2026-04') const { data: storeData } = useQuery({ - queryKey: ['store', code], - queryFn: () => api.get(`/stores/${code}`), + queryKey: ['store', code, month], + queryFn: () => api.get(`/stores/${code}`, { params: { month } }), }) const { data: healthData } = useQuery({ @@ -54,8 +51,8 @@ export function StoreDetailPage() { }) const { data: dailyData } = useQuery({ - queryKey: ['store', code, 'daily'], - queryFn: () => api.get(`/stores/${code}/daily`), + queryKey: ['store', code, 'daily', month], + queryFn: () => api.get(`/stores/${code}/daily`, { params: { month } }), }) const { data: tasksData } = useQuery({ @@ -65,32 +62,32 @@ export function StoreDetailPage() { }) const { data: mealData } = useQuery({ - queryKey: ['store', code, 'meal-period'], - queryFn: () => api.get(`/stores/${code}/meal-period`), + queryKey: ['store', code, 'meal-period', month], + queryFn: () => api.get(`/stores/${code}/meal-period`, { params: { month } }), enabled: tab === 'meal', }) const { data: catData } = useQuery({ - queryKey: ['store', code, 'category-mix'], - queryFn: () => api.get(`/stores/${code}/category-mix`), + queryKey: ['store', code, 'category-mix', month], + queryFn: () => api.get(`/stores/${code}/category-mix`, { params: { month } }), enabled: tab === 'category', }) const { data: costData } = useQuery({ - queryKey: ['store', code, 'cost'], - queryFn: () => api.get(`/stores/${code}/cost`), + queryKey: ['store', code, 'cost', month], + queryFn: () => api.get(`/stores/${code}/cost`, { params: { month } }), enabled: tab === 'cost', }) const { data: memberData } = useQuery({ - queryKey: ['store', code, 'member'], - queryFn: () => api.get(`/stores/${code}/member`), + queryKey: ['store', code, 'member', month], + queryFn: () => api.get(`/stores/${code}/member`, { params: { month } }), enabled: tab === 'member', }) const { data: anomalyData } = useQuery({ - queryKey: ['store', code, 'anomalies'], - queryFn: () => api.get(`/stores/${code}/anomalies`, { params: { page: anomalyPage, page_size: 10 } }), + queryKey: ['store', code, 'anomalies', month], + queryFn: () => api.get(`/stores/${code}/anomalies`, { params: { month, page_size: 200 } }), enabled: tab === 'anomaly', }) @@ -104,7 +101,7 @@ export function StoreDetailPage() { const cost = (costData as any)?.data const member = (memberData as any)?.data const anomalyRows = ((anomalyData as any)?.data) || [] - const anomalyTotal = (anomalyData as any)?.meta?.total || 0 + const anomalyTotal = (anomalyData as any)?.meta?.total || anomalyRows.length if (!sd) return const sc = sd.scorecard @@ -153,22 +150,24 @@ export function StoreDetailPage() { 返回 -
-

{sc.store_name}

- {health && ( - <> - = 75 ? 'text-green-600' : Number(health.health_score) >= 55 ? 'text-yellow-600' : 'text-red-600')}> - {health.health_score} - - {health.health_status} - - )} - {risk?.risk_level && } - {quadrant && {quadrant}} +
+
+

{sc.store_name}

+ {health && ( + <> + = 75 ? 'text-green-600' : Number(health.health_score) >= 55 ? 'text-yellow-600' : 'text-red-600')}> + {health.health_score} + + {health.health_status} + + )} + {risk?.risk_level && } + {quadrant && {quadrant}} +
@@ -345,7 +344,15 @@ export function StoreDetailPage() {
- formatNumber(r.bill_count) }, @@ -354,7 +361,6 @@ export function StoreDetailPage() { { key: 'network_avg_bill', label: '全网均价', align: 'right', render: (r) => formatCurrency(r.network_avg_bill) }, { key: 'vs_network_pct', label: 'vs全网', align: 'right', render: (r) => {Number(r.vs_network_pct).toFixed(1)}% }, ]} - data={mealRows} />
@@ -500,23 +506,27 @@ export function StoreDetailPage() { {tab === 'anomaly' && (

异常账单明细 ({anomalyTotal})

- {anomalyTotal > 0 && } -
- {anomalyRows.length === 0 ?

暂无异常账单

: ( - formatCurrency(r.consumption) }, - { key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) }, - { key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) }, - { key: 'anomaly_reason', label: '异常原因' }, - { key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) }, - ]} - data={anomalyRows} - /> - )} -
+ {anomalyRows.length === 0 ?

暂无异常账单

: ( + formatCurrency(r.consumption) }, + { key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) }, + { key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) }, + { key: 'anomaly_reason', label: '异常原因' }, + { key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) }, + ]} + /> + )}
)} @@ -524,7 +534,18 @@ export function StoreDetailPage() { {tab === 'tasks' && (

门店任务 ({tasks.length})

- navigate(`/tasks/${r.task_id}`)} columns={[ { key: 'problem_indicator', label: '问题指标' }, { key: 'priority', label: '优先级', render: (r) => }, @@ -532,8 +553,6 @@ export function StoreDetailPage() { { key: 'status', label: '状态', render: (r) => }, { key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) }, ]} - data={tasks} - onRowClick={(r) => navigate(`/tasks/${r.task_id}`)} />
)} diff --git a/client/src/pages/StorePage.tsx b/client/src/pages/StorePage.tsx index fe40dce..bb94547 100644 --- a/client/src/pages/StorePage.tsx +++ b/client/src/pages/StorePage.tsx @@ -12,37 +12,37 @@ export function StorePage() { const [executeText, setExecuteText] = useState('') const [activeTaskId, setActiveTaskId] = useState(null) const [storeCode, setStoreCode] = useState('1111') - const [month, setMonth] = useState('2026-05') + const [month, setMonth] = useState('2026-04') const { data: storesData, isLoading: storesLoading } = useQuery({ - queryKey: ['stores-list'], - queryFn: () => api.get('/stores/risk'), + queryKey: ['stores-list', month], + queryFn: () => api.get('/stores/risk', { params: { month } }), }) const stores = ((storesData as any)?.data || []).map((s: any) => ({ code: s.store_code, name: s.store_name, risk: s.risk_level, issue: s.primary_issue })) const { data: priorityData } = useQuery({ - queryKey: ['stores-priority-detail'], - queryFn: () => api.get('/stores/priority'), + queryKey: ['stores-priority-detail', month], + queryFn: () => api.get('/stores/priority', { params: { month } }), }) const { data: cardData, isLoading: cardLoading } = useQuery({ - queryKey: ['store', storeCode, 'daily-card'], - queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`), + queryKey: ['store', storeCode, 'daily-card', month], + queryFn: () => api.get(`/tasks/stores/${storeCode}/daily-card`, { params: { month } }), }) const { data: tasksData, isLoading: tasksLoading } = useQuery({ - queryKey: ['store', storeCode, 'tasks'], + queryKey: ['store', storeCode, 'tasks', month], queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month, page_size: 50 } }), }) const { data: dailyData, isLoading: dailyLoading } = useQuery({ - queryKey: ['store', storeCode, 'daily'], + queryKey: ['store', storeCode, 'daily', month], queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month } }), }) const { data: followupData, isLoading: followupLoading } = useQuery({ - queryKey: ['store', storeCode, 'followup'], - queryFn: () => api.get('/tasks/followup'), + queryKey: ['store', storeCode, 'followup', month], + queryFn: () => api.get('/tasks/followup', { params: { month } }), }) const { data: skuData } = useQuery({ @@ -153,7 +153,7 @@ export function StorePage() { {/* 经营概览 */}
-

最新经营概览(5月1日)

+

最新经营概览({month})

{anomalyCount > 0 && ( {anomalyCount} 项异常 )} diff --git a/client/src/pages/TasksPage.tsx b/client/src/pages/TasksPage.tsx index 35eb603..7f38191 100644 --- a/client/src/pages/TasksPage.tsx +++ b/client/src/pages/TasksPage.tsx @@ -2,20 +2,16 @@ import { useQuery } from '@tanstack/react-query' import { useNavigate } from 'react-router-dom' import api from '@/lib/api' import { Badge } from '@/components/Badge' -import { DataTable } from '@/components/DataTable' -import { Pagination } from '@/components/Pagination' +import { FilterableTable } from '@/components/FilterableTable' import { formatCurrency, formatPercent } from '@/lib/utils' import { MonthPicker } from '@/components/MonthPicker' import { useState, useMemo } from 'react' -const PAGE_SIZE = 5 - export function TasksPage() { const navigate = useNavigate() - const [month, setMonth] = useState('2026-05') + const [month, setMonth] = useState('2026-04') const [priority, setPriority] = useState('') const [status, setStatus] = useState('') - const [page, setPage] = useState(1) const { data } = useQuery({ queryKey: ['tasks', month, priority, status], @@ -41,7 +37,6 @@ export function TasksPage() { ), [tasks]) const totalGroups = groups.length - const pagedGroups = groups.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) return (
@@ -80,12 +75,9 @@ export function TasksPage() { ))}
- {/* 分页 */} - - {/* 任务列表 — 按门店分组 */}
- {pagedGroups.map((group: any) => ( + {groups.map((group: any) => (
{/* 门店组标题 */}
{group.tasks.length} 项任务
{/* 组内任务表 */} - navigate(`/tasks/${r.task_id}`)} columns={[ { key: 'problem_indicator', label: '问题指标' }, { key: 'action_required', label: '行动要求', render: (r) => {r.action_required} }, @@ -105,9 +108,6 @@ export function TasksPage() { { key: 'deadline', label: '截止日', render: (r) => r.deadline?.substring(0, 10) }, { key: 'status', label: '状态', render: (r) => }, ]} - data={group.tasks} - onRowClick={(r) => navigate(`/tasks/${r.task_id}`)} - className="rounded-none border-0" />
))} diff --git a/client/src/pages/TimePage.tsx b/client/src/pages/TimePage.tsx index 98243a7..913fa7c 100644 --- a/client/src/pages/TimePage.tsx +++ b/client/src/pages/TimePage.tsx @@ -5,6 +5,8 @@ import { CollapsibleSection } from '@/components/CollapsibleSection' import { MetricCard } from '@/components/MetricCard' import { formatCurrency, formatNumber } from '@/lib/utils' import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Area, AreaChart } from 'recharts' +import { MonthPicker } from '@/components/MonthPicker' +import { useState } from 'react' const CHANNEL_COLORS: Record = { cash: '#22c55e', @@ -38,19 +40,21 @@ const CHANNEL_LABELS: Record = { const channelLabel = (k: string) => CHANNEL_LABELS[k] || k export function TimePage() { + const [month, setMonth] = useState('2026-04') + const { data: weekdayData, isLoading: weekdayLoading } = useQuery({ - queryKey: ['time/weekday'], - queryFn: () => api.get('/time/weekday'), + queryKey: ['time/weekday', month], + queryFn: () => api.get('/time/weekday', { params: { month } }), }) const { data: hourlyData, isLoading: hourlyLoading } = useQuery({ - queryKey: ['time/hourly'], - queryFn: () => api.get('/time/hourly'), + queryKey: ['time/hourly', month], + queryFn: () => api.get('/time/hourly', { params: { month } }), }) const { data: channelData, isLoading: channelLoading } = useQuery({ - queryKey: ['channel'], - queryFn: () => api.get('/channel'), + queryKey: ['channel', month], + queryFn: () => api.get('/channel', { params: { month } }), }) const weekdayRows = (weekdayData as any)?.data || [] @@ -90,17 +94,20 @@ export function TimePage() { return (
-
-

时间维度分析

-

星期规律 · 小时分布 · 渠道趋势 · 2026年4月

+
+
+

时间维度分析

+

星期规律 · 小时分布 · 渠道趋势

+
+
{/* 概览指标 */}
- - - + + +
@@ -117,7 +124,7 @@ export function TimePage() { - + diff --git a/server/src/routes/cost-analysis.ts b/server/src/routes/cost-analysis.ts index efd2bb7..dd6610f 100644 --- a/server/src/routes/cost-analysis.ts +++ b/server/src/routes/cost-analysis.ts @@ -10,6 +10,7 @@ const router = Router() // 成本总览指标 router.get('/overview', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` SELECT count(*) AS total_dishes, @@ -22,7 +23,8 @@ router.get('/overview', async (req: AuthRequest, res) => { round(sum(actual_cost)::numeric, 2) AS total_actual_cost, round(sum(cost_variance_amount)::numeric, 2) AS total_variance FROM public.dish_cost_analysis_summary - `) + WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) + `, [month]) sendSuccess(res, result.rows[0]) } catch (err: any) { sendError(res, err.message) @@ -32,6 +34,7 @@ router.get('/overview', async (req: AuthRequest, res) => { // 品类成本对比 router.get('/category-comparison', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` SELECT category_level1, count(*) AS dishes, @@ -43,8 +46,9 @@ router.get('/category-comparison', async (req: AuthRequest, res) => { round(sum(cost_variance_amount)::numeric, 2) AS total_variance FROM public.dish_cost_analysis_summary WHERE category_level1 IS NOT NULL + AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY category_level1 ORDER BY total_sales DESC - `) + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -54,6 +58,7 @@ router.get('/category-comparison', async (req: AuthRequest, res) => { // 毛利率偏差分布 router.get('/margin-deviation', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` SELECT CASE @@ -66,8 +71,9 @@ router.get('/margin-deviation', async (req: AuthRequest, res) => { count(*) AS cnt FROM public.dish_cost_analysis_summary WHERE theoretical_margin_rate_pct IS NOT NULL AND actual_margin_rate_pct IS NOT NULL + AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY deviation_band ORDER BY cnt DESC - `) + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -79,6 +85,7 @@ router.get('/variance-top', async (req: AuthRequest, res) => { try { const limit = parseInt((req.query.limit as string) || '50') const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' + const month = parseMonth(req) const result = await query(` SELECT dish_name, dish_code, category_level1, round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin, @@ -93,8 +100,9 @@ router.get('/variance-top', async (req: AuthRequest, res) => { ELSE '正常' END AS cost_tier FROM public.dish_cost_analysis_summary + WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1) ORDER BY cost_variance_amount ${order} LIMIT $1 - `, [limit]) + `, [limit, month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -106,24 +114,26 @@ router.get('/variance-top', async (req: AuthRequest, res) => { // 菜单工程矩阵 router.get('/menu-engineering', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` + WITH imp AS (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) SELECT dish_name, dish_code, category_level1, round(sales_quantity::numeric, 2) AS sales_quantity, round(sales_amount::numeric, 2) AS sales_amount, round(actual_margin_rate_pct::numeric, 2) AS actual_margin, CASE WHEN actual_margin_rate_pct < 0 THEN '数据异常' - WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary) - AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '明星盈利品' - WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary) - AND actual_margin_rate_pct < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '高销低利品' - WHEN sales_quantity < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary) - AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct > 0) THEN '低销高利品' + WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id) + AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '明星盈利品' + WHEN sales_quantity >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id) + AND actual_margin_rate_pct < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '高销低利品' + WHEN sales_quantity < (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY sales_quantity) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id) + AND actual_margin_rate_pct >= (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY actual_margin_rate_pct) FROM public.dish_cost_analysis_summary s, imp WHERE s.import_id = imp.import_id AND actual_margin_rate_pct > 0) THEN '低销高利品' ELSE '低销低利品' END AS menu_type - FROM public.dish_cost_analysis_summary - WHERE actual_margin_rate_pct IS NOT NULL - `) + FROM public.dish_cost_analysis_summary s, imp + WHERE s.import_id = imp.import_id AND actual_margin_rate_pct IS NOT NULL + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -141,8 +151,9 @@ router.get('/profitability', async (req: AuthRequest, res) => { const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC' const filter = (req.query.filter as string) || '' - let where = 'WHERE actual_margin_rate_pct IS NOT NULL' - const params: any[] = [] + const month = parseMonth(req) + let where = 'WHERE actual_margin_rate_pct IS NOT NULL AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)' + const params: any[] = [month] if (category) { params.push(category); where += ` AND category_level1 = $${params.length}` } if (filter === 'profitable') { where += ` AND actual_margin_rate_pct > 0` } else if (filter === 'loss') { where += ` AND actual_margin_rate_pct <= 0` } @@ -177,6 +188,7 @@ router.get('/profitability', async (req: AuthRequest, res) => { router.get('/pricing', async (req: AuthRequest, res) => { try { const threshold = parseFloat((req.query.threshold as string) || '50') + const month = parseMonth(req) const result = await query(` SELECT dish_name, dish_code, category_level1, round(price::numeric, 2) AS price, @@ -187,8 +199,9 @@ router.get('/pricing', async (req: AuthRequest, res) => { round(sales_amount::numeric, 2) AS sales_amount FROM public.dish_cost_analysis_summary WHERE theoretical_margin_rate_pct < $1 AND sales_amount > 1000 + AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1) ORDER BY theoretical_margin_rate_pct ASC - `, [threshold]) + `, [threshold, month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -202,6 +215,7 @@ router.get('/material-variance', async (req: AuthRequest, res) => { try { const dishCode = req.query.dish_code as string if (!dishCode) return sendError(res, 'dish_code is required') + const month = parseMonth(req) const result = await query(` SELECT m.material_name, m.material_unit, @@ -221,8 +235,9 @@ router.get('/material-variance', async (req: AuthRequest, res) => { FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.dish_code = $1 + AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1) ORDER BY m.loss_amount ASC - `, [dishCode]) + `, [dishCode, month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -232,6 +247,7 @@ router.get('/material-variance', async (req: AuthRequest, res) => { // 损耗率分布 router.get('/loss-distribution', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` SELECT CASE @@ -244,9 +260,11 @@ router.get('/loss-distribution', async (req: AuthRequest, res) => { ELSE '正向(>0%)' END AS loss_band, count(*) AS cnt - FROM public.dish_cost_analysis_material_detail + FROM public.dish_cost_analysis_material_detail m + JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id + WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY loss_band ORDER BY cnt DESC - `) + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -258,6 +276,7 @@ router.get('/material-loss-top', async (req: AuthRequest, res) => { try { const limit = parseInt((req.query.limit as string) || '50') const order = (req.query.order as string) === 'rate' ? 'avg_loss_rate' : 'total_loss_qty' + const month = parseMonth(req) const result = await query(` SELECT material_name, material_type, count(DISTINCT m.summary_id) AS dish_count, @@ -266,10 +285,12 @@ router.get('/material-loss-top', async (req: AuthRequest, res) => { round(max(loss_quantity_rate_pct)::numeric, 2) AS max_loss_rate, round(sum(loss_amount)::numeric, 2) AS total_loss_amount FROM public.dish_cost_analysis_material_detail m + JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE m.loss_quantity < 0 + AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $2::date ORDER BY import_id DESC LIMIT 1) GROUP BY material_name, material_type ORDER BY ${order} ASC LIMIT $1 - `, [limit]) + `, [limit, month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -279,15 +300,18 @@ router.get('/material-loss-top', async (req: AuthRequest, res) => { // 物料类型损耗对比 router.get('/material-type-loss', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` SELECT material_type, count(*) AS cnt, round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate, round(sum(loss_amount)::numeric, 2) AS total_loss_amount, count(DISTINCT material_name) AS unique_materials - FROM public.dish_cost_analysis_material_detail + FROM public.dish_cost_analysis_material_detail m + JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id + WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY material_type ORDER BY total_loss_amount ASC - `) + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -582,15 +606,18 @@ router.get('/material-demand', async (req: AuthRequest, res) => { // 包装总览 router.get('/packaging-overview', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` SELECT count(DISTINCT material_name) AS packaging_types, round(sum(theoretical_amount)::numeric, 2) AS total_cost, round(sum(loss_quantity)::numeric, 2) AS total_loss_qty, count(*) FILTER (WHERE loss_quantity_rate_pct < -20) AS high_loss_count - FROM public.dish_cost_analysis_material_detail + FROM public.dish_cost_analysis_material_detail m + JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE material_name ~* '餐盒|餐具|打包|纸巾|餐盒|调味包|餐盒|碗|袋|杯|盒' - `) + AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) + `, [month]) sendSuccess(res, result.rows[0]) } catch (err: any) { sendError(res, err.message) @@ -612,30 +639,35 @@ router.get('/packaging-detail', async (req: AuthRequest, res) => { const validSorts = ['total_cost', 'total_qty', 'loss_qty', 'avg_loss_rate', 'dish_count', 'material_name'] const sortCol = validSorts.includes(sort) ? sort : 'total_cost' + const month = parseMonth(req) const countResult = await query(` - SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail + SELECT count(DISTINCT material_name) FROM public.dish_cost_analysis_material_detail m + JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒' - `) + AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) + `, [month]) const total = countResult.rows[0].count const result = await query(` SELECT material_name, - count(DISTINCT summary_id) AS dish_count, - round(sum(theoretical_quantity)::numeric, 2) AS total_qty, - round(sum(theoretical_amount)::numeric, 2) AS total_cost, - round(sum(loss_quantity)::numeric, 2) AS loss_qty, - round((sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate, + count(DISTINCT m.summary_id) AS dish_count, + round(sum(m.theoretical_quantity)::numeric, 2) AS total_qty, + round(sum(m.theoretical_amount)::numeric, 2) AS total_cost, + round(sum(m.loss_quantity)::numeric, 2) AS loss_qty, + round((sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100)::numeric, 2) AS avg_loss_rate, CASE - WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -20 THEN '核查' - WHEN sum(loss_quantity) / nullif(sum(theoretical_quantity), 0) * 100 < -5 THEN '关注' + WHEN sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100 < -20 THEN '核查' + WHEN sum(m.loss_quantity) / nullif(sum(m.theoretical_quantity), 0) * 100 < -5 THEN '关注' ELSE '正常' END AS status - FROM public.dish_cost_analysis_material_detail + FROM public.dish_cost_analysis_material_detail m + JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE material_name ~* '餐盒|餐具|打包|纸巾|碗|袋|杯|盒' + AND s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) GROUP BY material_name ${having} - ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2 - `, [pageSize, offset]) + ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3 + `, [month, pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) @@ -647,19 +679,20 @@ router.get('/packaging-detail', async (req: AuthRequest, res) => { // 数据质量总览 router.get('/data-quality', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` SELECT (SELECT count(*) FROM analytics.dim_sku) AS total_sku, (SELECT count(DISTINCT sku_code) FROM analytics.fact_recipe_bom) AS sku_with_bom, round((SELECT count(DISTINCT sku_code)::numeric FROM analytics.fact_recipe_bom) / nullif((SELECT count(*) FROM analytics.dim_sku), 0) * 100, 2) AS bom_coverage, - (SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = true) AS matched_dishes, - (SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary) AS total_dishes, + (SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)) AS matched_dishes, + (SELECT count(*) FROM public.dish_cost_analysis_summary WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)) AS total_dishes, (SELECT count(*) FROM analytics.fact_recipe_bom WHERE standard_net_quantity = 0) AS zero_actual_bom, - (SELECT count(*) FROM public.dish_cost_analysis_summary WHERE actual_margin_rate_pct < 0) AS negative_margin_count, - (SELECT count(*) FROM public.dish_cost_analysis_summary WHERE theoretical_margin_rate_pct < 0) AS negative_theo_margin_count, - (SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = true) AS matched_materials, - (SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail) AS total_materials - `) + (SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND s.actual_margin_rate_pct < 0) AS negative_margin_count, + (SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND s.theoretical_margin_rate_pct < 0) AS negative_theo_margin_count, + (SELECT count(*) FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name)) AS matched_materials, + (SELECT count(*) FROM public.dish_cost_analysis_material_detail m JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)) AS total_materials + `, [month]) sendSuccess(res, result.rows[0]) } catch (err: any) { sendError(res, err.message) @@ -676,16 +709,17 @@ router.get('/unmatched-dishes', async (req: AuthRequest, res) => { const validSorts = ['sales_amount', 'dish_name', 'dish_code', 'category_level1'] const sortCol = validSorts.includes(sort) ? sort : 'sales_amount' - const countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_summary WHERE matched_to_dish_sales_by_name = false`) + const month = parseMonth(req) + const countResult = await query(`SELECT count(*) FROM public.dish_cost_analysis_summary s WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name)`, [month]) const total = countResult.rows[0].count const result = await query(` SELECT dish_name, dish_code, category_level1, round(sales_amount::numeric, 2) AS sales_amount - FROM analytics.v_dish_cost_analysis_latest_summary - WHERE matched_to_dish_sales_by_name = false - ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2 - `, [pageSize, offset]) + FROM public.dish_cost_analysis_summary s + WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) AND NOT EXISTS (SELECT 1 FROM public.dish_sales_details d WHERE d.dish_name = s.dish_name) + ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3 + `, [month, pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) @@ -702,18 +736,30 @@ router.get('/unmatched-materials', async (req: AuthRequest, res) => { const validSorts = ['dish_count', 'loss_amount', 'material_name', 'material_type'] const sortCol = validSorts.includes(sort) ? sort : 'dish_count' - const countResult = await query(`SELECT count(*) FROM analytics.v_dish_cost_analysis_latest_material_detail WHERE matched_to_inventory_by_name = false`) + const month = parseMonth(req) + const countResult = await query(` + SELECT count(*) FROM ( + SELECT material_name, material_type + FROM public.dish_cost_analysis_material_detail m + JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id + WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) + AND NOT EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name) + GROUP BY material_name, material_type + ) t + `, [month]) const total = countResult.rows[0].count const result = await query(` SELECT material_name, material_type, - count(DISTINCT summary_id) AS dish_count, + count(DISTINCT m.summary_id) AS dish_count, round(sum(loss_amount)::numeric, 2) AS loss_amount - FROM analytics.v_dish_cost_analysis_latest_material_detail - WHERE matched_to_inventory_by_name = false + FROM public.dish_cost_analysis_material_detail m + JOIN public.dish_cost_analysis_summary s ON s.summary_id = m.summary_id + WHERE s.import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) + AND NOT EXISTS (SELECT 1 FROM public.inventory_cost_records i WHERE i.material_name = m.material_name) GROUP BY material_name, material_type - ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2 - `, [pageSize, offset]) + ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3 + `, [month, pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) @@ -808,6 +854,7 @@ router.get('/store-ranking', async (req: AuthRequest, res) => { // 散点图数据 router.get('/scatter', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` SELECT dish_name, dish_code, category_level1, round(sales_quantity::numeric, 2) AS x, @@ -815,7 +862,8 @@ router.get('/scatter', async (req: AuthRequest, res) => { round(sales_amount::numeric, 2) AS size FROM public.dish_cost_analysis_summary WHERE cost_variance_amount IS NOT NULL AND sales_quantity IS NOT NULL - `) + AND import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1) + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts index d0ad454..32ea2c2 100644 --- a/server/src/routes/data.ts +++ b/server/src/routes/data.ts @@ -80,10 +80,12 @@ router.get('/stores/risk', async (req: AuthRequest, res) => { router.get('/stores/priority', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` SELECT store_code, store_name, action_priority, problem_count, problem_combination, received, scale_tier, business_type - FROM analytics.cache_store_priority + FROM analytics.mv_store_action_priority_deep_monthly + WHERE month_start = $1 ORDER BY CASE action_priority WHEN 'P0-修复数据口径' THEN 1 WHEN 'P0-综合专项整改' THEN 2 @@ -92,7 +94,7 @@ router.get('/stores/priority', async (req: AuthRequest, res) => { WHEN '标杆候选' THEN 5 ELSE 6 END, received DESC - `) + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -101,7 +103,8 @@ router.get('/stores/priority', async (req: AuthRequest, res) => { router.get('/stores/quadrant', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.v_store_benchmark ORDER BY management_quadrant, avg_daily_received DESC`) + const month = parseMonth(req) + const result = await query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $1 ORDER BY benchmark_score DESC`, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -111,16 +114,43 @@ router.get('/stores/quadrant', async (req: AuthRequest, res) => { router.get('/stores/:code', async (req: AuthRequest, res) => { try { const code = req.params.code + const month = parseMonth(req) const [scorecard, risk, platform, benchmark, action] = await Promise.all([ - query(`SELECT * FROM analytics.mv_store_scorecard WHERE store_code = $1`, [code]), - query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND store_code = $2`, [parseMonth(req), code]), - query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, parseMonth(req)]), - query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $2 AND store_code = $1`, [code, parseMonth(req)]), - query(`SELECT * FROM analytics.mv_store_action_priority_deep_monthly WHERE month_start = $1 AND store_code = $2`, [parseMonth(req), code]), + query(` + SELECT store_code, store_name, + count(*) AS bill_count, + count(DISTINCT closed_at::date) AS active_days, + sum(received_total) AS received, + round(sum(received_total) / NULLIF(count(DISTINCT closed_at::date), 0), 2) AS avg_daily_received, + round(sum(received_total) / count(*), 2) AS avg_bill_value, + round(sum(received_total) / NULLIF(sum(guest_count), 0), 2) AS avg_guest_value, + round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct, + round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct, + round(count(*) FILTER (WHERE member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct + FROM analytics.fact_bill + WHERE store_code = $1 + AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') + GROUP BY store_code, store_name + `, [code, month]), + query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]), + query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]), + query(`SELECT * FROM analytics.mv_store_benchmark_composite_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]), + query(`SELECT * FROM analytics.mv_store_action_priority_deep_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]), ]) if (scorecard.rows.length === 0) { - return sendError(res, 'Store not found', 404) + const storeInfo = await query(`SELECT store_code, store_name FROM analytics.dim_store WHERE store_code = $1`, [code]) + if (storeInfo.rows.length === 0) { + return sendError(res, 'Store not found', 404) + } + const si = storeInfo.rows[0] as any + return sendSuccess(res, { + scorecard: { store_code: si.store_code, store_name: si.store_name, bill_count: 0, active_days: 0, received: 0, avg_daily_received: 0, avg_bill_value: 0, avg_guest_value: 0, discount_rate_pct: 0, theoretical_margin_pct: 0, member_bill_share_pct: 0 }, + risk: null, + platform: null, + benchmark: null, + action: { management_quadrant: '问题门店', risk_level: null }, + }) } const sc = scorecard.rows[0] as any @@ -159,7 +189,7 @@ router.get('/stores/:code/daily', async (req: AuthRequest, res) => { round(sum(received_total), 2) AS received, round(sum(received_total) / count(*), 2) AS avg_bill_value, round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct - FROM analytics.bill_fact + FROM analytics.fact_bill WHERE store_code = $1 AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') @@ -176,7 +206,7 @@ router.get('/stores/:code/daily', async (req: AuthRequest, res) => { router.get('/cost/comparison', async (req: AuthRequest, res) => { try { const month = parseMonth(req) - const result = await query(`SELECT * FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ORDER BY variance_to_theoretical_pct DESC NULLS LAST`, [month]) + const result = await query(`SELECT store_code, store_name, bill_count, sales_consumption, sales_received, theoretical_cost, theoretical_profit, actual_food_cost, actual_total_cost, packaging_cost, operating_supply_cost, repair_equipment_cost, new_retail_cost, ending_inventory_amount, negative_item_lines, negative_consumption_amount, food_cost_variance, theoretical_cost_rate_pct, actual_food_cost_rate_pct, variance_to_theoretical_pct, cost_rate_gap_pct, comparison_status, variance_level FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 ORDER BY variance_to_theoretical_pct DESC NULLS LAST`, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -186,7 +216,35 @@ router.get('/cost/comparison', async (req: AuthRequest, res) => { router.get('/cost/category-benchmark', async (req: AuthRequest, res) => { try { const month = parseMonth(req) - const result = await query(`SELECT * FROM analytics.mv_inventory_cost_classified_monthly WHERE month_start = $1 ORDER BY sales_store_code`, [month]) + const result = await query(` + WITH store_cat AS ( + SELECT sales_store_name AS store_name, finance_category, + round(sum(consumption_amount)::numeric, 2) AS actual_category_cost + FROM analytics.mv_inventory_cost_classified_monthly WHERE month_start = $1 + GROUP BY sales_store_name, finance_category + ), + store_sales AS ( + SELECT store_name, max(sales_received) AS sales_received + FROM analytics.mv_store_theoretical_actual_cost_monthly WHERE month_start = $1 + GROUP BY store_name + ), + cat_sales AS ( + SELECT sc.store_name, sc.finance_category, sc.actual_category_cost, + CASE WHEN ss.sales_received > 0 THEN round(sc.actual_category_cost / ss.sales_received * 10000, 2) ELSE NULL END AS cost_per_10k_sales + FROM store_cat sc JOIN store_sales ss ON sc.store_name = ss.store_name + ), + peer AS ( + SELECT finance_category, percentile_cont(0.5) WITHIN GROUP (ORDER BY cost_per_10k_sales) AS peer_median + FROM cat_sales WHERE cost_per_10k_sales IS NOT NULL GROUP BY finance_category + ) + SELECT cs.store_name, cs.finance_category, cs.actual_category_cost, + cs.cost_per_10k_sales, + round(pm.peer_median::numeric, 2) AS peer_median_cost_per_10k, + CASE WHEN cs.cost_per_10k_sales IS NOT NULL AND pm.peer_median IS NOT NULL + THEN round((cs.cost_per_10k_sales - pm.peer_median)::numeric, 2) ELSE NULL END AS excess_vs_peer_per_10k + FROM cat_sales cs LEFT JOIN peer pm ON cs.finance_category = pm.finance_category + ORDER BY cs.store_name + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -196,7 +254,15 @@ router.get('/cost/category-benchmark', async (req: AuthRequest, res) => { router.get('/cost/inventory', async (req: AuthRequest, res) => { try { const month = parseMonth(req) - const result = await query(`SELECT * FROM analytics.mv_store_area_efficiency_monthly WHERE month_start = $1 ORDER BY estimated_inventory_days DESC NULLS LAST`, [month]) + const result = await query(` + SELECT a.store_code, a.store_name, a.business_type, a.scale_tier, a.estimated_inventory_days, + t.ending_inventory_amount, t.negative_item_lines + FROM analytics.mv_store_area_efficiency_monthly a + LEFT JOIN analytics.mv_store_theoretical_actual_cost_monthly t + ON a.store_code = t.store_code AND a.month_start = t.month_start + WHERE a.month_start = $1 + ORDER BY a.estimated_inventory_days DESC NULLS LAST + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -215,7 +281,21 @@ router.get('/platform/economics', async (req: AuthRequest, res) => { router.get('/member/comparison', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.v_member_comparison`) + const month = parseMonth(req) + const result = await query(` + SELECT + CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END AS customer_type, + count(*) AS bill_count, + round(sum(received_total), 2) AS received, + round(avg(received_total), 2) AS avg_bill_value, + round(avg(discount_total), 2) AS avg_discount, + round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct, + round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct + FROM analytics.fact_bill + WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') + GROUP BY CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END + ORDER BY customer_type + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -225,7 +305,7 @@ router.get('/member/comparison', async (req: AuthRequest, res) => { router.get('/member/repeat', async (req: AuthRequest, res) => { try { const month = parseMonth(req) - const result = await query(`SELECT * FROM analytics.mv_store_repeat_summary_monthly WHERE month_start = $1::date ORDER BY repeat_rate_pct DESC NULLS LAST`, [month]) + const result = await query(`SELECT * FROM analytics.v_store_repeat_summary_monthly WHERE month_start = $1::date ORDER BY repeat_rate_pct DESC NULLS LAST`, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -261,12 +341,57 @@ router.get('/sku/attach', async (req: AuthRequest, res) => { } }) +const RECEIVED_COLS = `COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)` + router.get('/risk/anomaly', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) - const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills`) - const result = await query(`SELECT * FROM analytics.v_anomaly_bills ORDER BY closed_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset]) - sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize }) + const month = parseMonth(req) + const storeName = req.query.store as string + const reason = req.query.reason as string + const cashier = req.query.cashier as string + + const conditions: string[] = [ + `c175 IS NOT NULL AND c175 != ''`, + `c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')`, + `((c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) OR (c068::numeric > c009::numeric) OR (abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05))`, + ] + const params: any[] = [month] + let paramIdx = 2 + if (storeName) { + conditions.push(`c003 = $${paramIdx}`) + params.push(storeName) + paramIdx++ + } + if (reason) { + if (reason === '有消费无实收') conditions.push(`c009::numeric > 0 AND (${RECEIVED_COLS}) = 0`) + else if (reason === '优惠大于消费') conditions.push(`c068::numeric > c009::numeric`) + else if (reason === '消费-优惠与实收不平') conditions.push(`abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 AND NOT (c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AND NOT (c068::numeric > c009::numeric)`) + } + if (cashier) { + conditions.push(`c191 ILIKE $${paramIdx}`) + params.push(`%${cashier}%`) + paramIdx++ + } + const whereClause = conditions.join(' AND ') + + const countResult = await query(`SELECT count(*) AS total, round(sum(c009::numeric),2) AS sum_consumption, round(sum(COALESCE(c068::numeric,0)),2) AS sum_discount, round(sum(${RECEIVED_COLS}),2) AS sum_received FROM bill_records WHERE ${whereClause}`, params) + const result = await query(` + SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period, + c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total, + (${RECEIVED_COLS}) AS received_total, + c191 AS cashier, c176 AS closed_at, + CASE + WHEN c009::numeric > 0 AND (${RECEIVED_COLS}) = 0 THEN '有消费无实收' + WHEN c068::numeric > c009::numeric THEN '优惠大于消费' + WHEN abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 THEN '消费-优惠与实收不平' + END AS anomaly_reason + FROM bill_records + WHERE ${whereClause} + ORDER BY c176 DESC + LIMIT $${paramIdx} OFFSET $${paramIdx + 1} + `, [...params, pageSize, offset]) + sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize, sum_consumption: countResult.rows[0].sum_consumption, sum_discount: countResult.rows[0].sum_discount, sum_received: countResult.rows[0].sum_received }) } catch (err: any) { sendError(res, err.message) } @@ -274,14 +399,29 @@ router.get('/risk/anomaly', async (req: AuthRequest, res) => { router.get('/risk/zero-received', async (req: AuthRequest, res) => { try { - const storeCode = req.query.store_code as string - let sql = `SELECT * FROM analytics.v_zero_received_detail` - const params: any[] = [] - if (storeCode) { - sql += ` WHERE store_code = $1` - params.push(storeCode) + const month = parseMonth(req) + const storeName = req.query.store as string + let sql = ` + SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period, + c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total, + 0 AS received_total, + c191 AS cashier, c176 AS closed_at, + CASE + WHEN c068::numeric >= c009::numeric AND c009::numeric > 0 THEN '全额优惠' + WHEN c009::numeric = 0 OR c009 IS NULL OR c009 = '' THEN '零消费零实收' + ELSE '有消费无实收' + END AS zero_received_type + FROM bill_records + WHERE c175 IS NOT NULL AND c175 != '' + AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month') + AND c009::numeric > 0 AND (${RECEIVED_COLS}) = 0 + ` + const params: any[] = [month] + if (storeName) { + sql += ` AND c003 = $2` + params.push(storeName) } - sql += ` ORDER BY closed_at DESC LIMIT 200` + sql += ` ORDER BY c009::numeric DESC LIMIT 200` const result = await query(sql, params) sendSuccess(res, result.rows) } catch (err: any) { @@ -291,7 +431,21 @@ router.get('/risk/zero-received', async (req: AuthRequest, res) => { router.get('/risk/cashier', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.v_cashier_risk ORDER BY anomaly_rate_pct DESC NULLS LAST`) + const month = parseMonth(req) + const result = await query(` + SELECT c003 AS store_name, c191 AS cashier, + count(*) AS bill_count, + round(sum(${RECEIVED_COLS})::numeric, 2) AS received, + count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AS anomaly_bills, + round(count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric / count(*) * 100, 2) AS anomaly_rate_pct, + round(sum(c009::numeric) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric, 2) AS anomaly_consumption + FROM bill_records + WHERE c175 IS NOT NULL AND c175 != '' + AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month') + AND c191 IS NOT NULL AND c191 != '' + GROUP BY c003, c191 + ORDER BY anomaly_rate_pct DESC NULLS LAST + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -300,7 +454,21 @@ router.get('/risk/cashier', async (req: AuthRequest, res) => { router.get('/marketing/plans', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.v_marketing_plan_summary ORDER BY received DESC`) + const month = parseMonth(req) + const result = await query(` + SELECT store_code, store_name, + count(*) AS bill_count, + sum(received_total) AS received, + sum(discount_total) AS discount_amount, + round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct, + round(sum(third_party_discount) / NULLIF(sum(received_total), 0) * 100, 2) AS third_party_rate_pct, + count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '') AS plan_bills, + round(count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '')::numeric / count(*) * 100, 2) AS plan_coverage_pct + FROM analytics.fact_bill + WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') + GROUP BY store_code, store_name + ORDER BY received DESC + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -319,7 +487,21 @@ router.get('/benchmark/composite', async (req: AuthRequest, res) => { router.get('/time/weekday', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.v_weekday_summary ORDER BY weekday_no`) + const month = parseMonth(req) + const result = await query(` + SELECT EXTRACT(isodow FROM closed_at)::int AS weekday_no, + CASE EXTRACT(isodow FROM closed_at)::int + WHEN 1 THEN '周一' WHEN 2 THEN '周二' WHEN 3 THEN '周三' + WHEN 4 THEN '周四' WHEN 5 THEN '周五' WHEN 6 THEN '周六' ELSE '周日' + END AS weekday, + count(*) AS bill_count, + sum(received_total) AS received, + round(sum(received_total) / count(*), 2) AS avg_bill_value, + round(avg(duration_minutes), 2) AS avg_duration_minutes + FROM analytics.fact_bill + WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') + GROUP BY 1, 2 ORDER BY 1 + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -328,7 +510,18 @@ router.get('/time/weekday', async (req: AuthRequest, res) => { router.get('/time/hourly', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.v_hourly_summary ORDER BY closing_hour`) + const month = parseMonth(req) + const result = await query(` + SELECT EXTRACT(hour FROM closed_at)::int AS closing_hour, + count(*) AS bill_count, + sum(received_total) AS received, + round(sum(received_total) / count(*), 2) AS avg_bill_value, + round(avg(duration_minutes), 2) AS avg_duration_minutes + FROM analytics.fact_bill + WHERE closed_at IS NOT NULL + AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') + GROUP BY 1 ORDER BY 1 + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -337,7 +530,23 @@ router.get('/time/hourly', async (req: AuthRequest, res) => { router.get('/channel', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.v_channel_daily ORDER BY business_date`) + const month = parseMonth(req) + const result = await query(` + SELECT closed_at::date AS business_date, + sum(cash_received) AS cash, + sum(alipay_received) AS alipay, + sum(wechat_received) AS wechat, + sum(meituan_received) AS meituan, + sum(unionpay_received) AS unionpay, + sum(douyin_received) AS douyin, + sum(credit_received) AS credit, + sum(jd_delivery_received) AS jd_delivery, + sum(meituan_delivery_received) AS meituan_delivery, + sum(taobao_delivery_received) AS taobao_delivery + FROM analytics.fact_bill + WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') + GROUP BY 1 ORDER BY 1 + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -358,7 +567,7 @@ router.get('/data-quality', async (req: AuthRequest, res) => { count(DISTINCT store_code) AS store_count, min(closed_at)::text AS min_date, max(closed_at)::text AS max_date - FROM analytics.bill_fact + FROM analytics.fact_bill `) const dishStats = await query(` SELECT @@ -388,7 +597,39 @@ router.get('/data-quality', async (req: AuthRequest, res) => { router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.v_store_meal_opportunity WHERE store_code = $1 ORDER BY bill_count DESC`, [req.params.code]) + const month = parseMonth(req) + const code = req.params.code + const result = await query(` + WITH network AS ( + SELECT meal_period, + count(*) AS network_bills, + (sum(received_total) / NULLIF(count(*), 0)) AS network_avg_bill + FROM analytics.fact_bill + WHERE meal_period IS NOT NULL + AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') + GROUP BY meal_period + ), store_meal AS ( + SELECT store_code, store_name, meal_period, + count(*) AS bill_count, + sum(received_total) AS received, + (sum(received_total) / NULLIF(count(*), 0)) AS avg_bill + FROM analytics.fact_bill + WHERE meal_period IS NOT NULL + AND store_code = $2 + AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') + GROUP BY store_code, store_name, meal_period + ) + SELECT s.store_code, s.store_name, s.meal_period, + s.bill_count, + round(s.received, 2) AS received, + round(s.avg_bill, 2) AS avg_bill, + round(n.network_avg_bill, 2) AS network_avg_bill, + round(GREATEST(n.network_avg_bill - s.avg_bill, 0) * s.bill_count, 2) AS avg_bill_uplift_scenario, + round((s.avg_bill / n.network_avg_bill - 1) * 100, 2) AS vs_network_pct + FROM store_meal s + JOIN network n ON s.meal_period = n.meal_period + ORDER BY s.bill_count DESC + `, [month, code]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) @@ -469,16 +710,38 @@ router.get('/stores/:code/member', async (req: AuthRequest, res) => { router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) - const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills WHERE store_code = $1`, [req.params.code]) - const result = await query(`SELECT * FROM analytics.v_anomaly_bills WHERE store_code = $1 ORDER BY closed_at DESC LIMIT $2 OFFSET $3`, [req.params.code, pageSize, offset]) + const month = parseMonth(req) + const code = req.params.code + const countResult = await query(`SELECT count(*) AS total FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month')`, [code, month]) + const result = await query(`SELECT * FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') ORDER BY closed_at DESC LIMIT $3 OFFSET $4`, [code, month, pageSize, offset]) sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize }) } catch (err: any) { sendError(res, err.message) } }) // 区域汇总 -router.get('/region/summary', async (req, res) => { +router.get('/region/summary', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.mv_region_summary ORDER BY total_received DESC`) + const month = parseMonth(req) + const result = await query(` + SELECT d.region, + count(DISTINCT b.store_code) AS store_count, + COALESCE(sum(b.received_total), 0) AS total_received, + count(*) AS total_bill_count, + round(avg(b.received_total), 2) AS avg_bill_value, + round(sum(b.discount_total) / NULLIF(sum(b.consumption), 0) * 100, 2) AS avg_discount_rate, + round(sum(b.theoretical_profit) / NULLIF(sum(b.received_total), 0) * 100, 2) AS avg_margin_rate, + 0 AS avg_anomaly_rate, + count(DISTINCT CASE WHEN r.risk_level = '红色' THEN b.store_code END) AS red_count, + count(DISTINCT CASE WHEN r.risk_level = '黄色' THEN b.store_code END) AS yellow_count, + count(DISTINCT CASE WHEN r.risk_level = '绿色' THEN b.store_code END) AS green_count + FROM analytics.fact_bill b + JOIN analytics.dim_store d ON d.store_code = b.store_code + LEFT JOIN analytics.mv_store_risk_rating_monthly r ON r.store_code = b.store_code AND r.month_start = $1::date + WHERE d.region IS NOT NULL AND d.region <> '' + AND b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month') + GROUP BY d.region + ORDER BY total_received DESC + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) @@ -1112,10 +1375,12 @@ router.get('/bank/report', async (req: AuthRequest, res) => { const stdDev = Math.sqrt(variance) const cv = meanRev > 0 ? stdDev / meanRev : 0 + const f = (v: any) => v === null || v === undefined ? 0 : parseFloat(v) + const i = (v: any) => v === null || v === undefined ? 0 : parseInt(v) sendSuccess(res, { - overview: { ...ov, received: parseFloat(ov?.received), bill_count: parseInt(ov?.bill_count), avg_bill_value: parseFloat(ov?.avg_bill_value), discount_rate_pct: parseFloat(ov?.discount_rate_pct), theoretical_margin_pct: parseFloat(ov?.theoretical_margin_pct), member_bills: parseInt(ov?.member_bills), member_share_pct: parseFloat(ov?.member_share_pct) }, + overview: { ...ov, received: f(ov?.received), bill_count: i(ov?.bill_count), avg_bill_value: f(ov?.avg_bill_value), discount_rate_pct: f(ov?.discount_rate_pct), theoretical_margin_pct: f(ov?.theoretical_margin_pct), member_bills: i(ov?.member_bills), member_share_pct: f(ov?.member_share_pct) }, daily: dailyRows, - waterfall: { ...wf, received: parseFloat(wf?.received), food_cost: parseFloat(wf?.food_cost), wage: parseFloat(wf?.wage), rent: parseFloat(wf?.rent), utility: parseFloat(wf?.utility), dorm: parseFloat(wf?.dorm), commission: parseFloat(wf?.commission), other_expense: parseFloat(wf?.other_expense), total_expense: parseFloat(wf?.total_expense), store_contribution: parseFloat(wf?.store_contribution) }, + waterfall: { ...wf, received: f(wf?.received), food_cost: f(wf?.food_cost), wage: f(wf?.wage), rent: f(wf?.rent), utility: f(wf?.utility), dorm: f(wf?.dorm), commission: f(wf?.commission), other_expense: f(wf?.other_expense), total_expense: f(wf?.total_expense), store_contribution: f(wf?.store_contribution) }, risk: riskRows, channel: channelTotals, stores: storeRows, @@ -1739,8 +2004,10 @@ router.get('/central-kitchen/bom-penetration', async (req: AuthRequest, res) => JOIN recipe_agg ra ON pc.recipe_name = ra.recipe_name LEFT JOIN central_kitchen_processing_cost pc2 ON ra.material_code = pc2.product_code AND pc2.report_month = $1::date WHERE bt.is_finished_product AND bt.level < 5 + ${productCode ? '' : 'AND bt.level < 1'} ) - SELECT * FROM bom_tree ORDER BY root_product_code, level, theoretical_amt DESC + SELECT * FROM bom_tree + ORDER BY root_product_code, level, theoretical_amt DESC ${productCode ? '' : 'LIMIT 200'} `, productCode ? [month, productCode] : [month]), @@ -1846,29 +2113,34 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { const client = await pool.connect() try { - // 创建临时表存储聚合销量,避免重复全表扫描550万行 + // 检查物化视图是否包含当前月份数据,如缺失则刷新 + const mvCheck = await client.query(` + SELECT 1 FROM mv_dish_sales_monthly WHERE month = $1::date LIMIT 1 + `, [month]) + if (mvCheck.rows.length === 0) { + // 物化视图中没有该月数据,刷新物化视图 + await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dish_sales_monthly`) + } + + // 从物化视图创建临时表(索引扫描,毫秒级) await client.query(` CREATE TEMP TABLE IF NOT EXISTS tmp_sales_agg AS - SELECT d.store_code, d.store_name, sk.sku_code, d.dish_name, - round(sum(d.sales_quantity)::numeric,4) AS qty, - round(sum(d.gross_amount)::numeric,2) AS amt, - round(avg(d.unit_price)::numeric,2) AS avg_unit_price - FROM dish_sales_details d - JOIN analytics.dim_sku sk ON d.dish_name = sk.standard_name - WHERE d.ordered_at >= $1::date AND d.ordered_at < ($1::date + INTERVAL '1 month') - ${storeCode ? 'AND d.store_code = $2' : ''} - GROUP BY d.store_code, d.store_name, sk.sku_code, d.dish_name + SELECT store_code, store_name, sku_code, dish_name, qty, amt, avg_unit_price + FROM mv_dish_sales_monthly + WHERE month = $1::date + ${storeCode ? 'AND store_code = $2' : ''} `, storeCode ? [month, storeCode] : [month]) await client.query(`CREATE INDEX IF NOT EXISTS idx_tmp_sales_sku ON tmp_sales_agg(sku_code)`) await client.query(`CREATE INDEX IF NOT EXISTS idx_tmp_sales_store ON tmp_sales_agg(store_code)`) - // 总销量(从临时表获取匹配菜品的汇总,从原始表获取全部菜品数) + // 总销量(全部从临时表获取,无需再扫描原表) const totalSales = await client.query(` SELECT - (SELECT count(*) FROM (SELECT DISTINCT dish_name FROM dish_sales_details WHERE ordered_at >= $1::date AND ordered_at < ($1::date + INTERVAL '1 month')) t) AS total_dish_count, - (SELECT round(sum(gross_amount)::numeric,2) FROM dish_sales_details WHERE ordered_at >= $1::date AND ordered_at < ($1::date + INTERVAL '1 month')) AS total_sales_amt - `, [month]) + count(DISTINCT dish_name) AS total_dish_count, + round(sum(amt)::numeric,2) AS total_sales_amt + FROM tmp_sales_agg + `) const [summaryResult, skuSalesResult, materialDemandResult, storeDemandResult, ckProductionPlanResult, productionCoordResult] = await Promise.all([ // 汇总(使用临时表) @@ -1916,7 +2188,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { LIMIT 50 `), - // 原料需求(使用临时表 × BOM展开) + // 原料需求(使用临时表 × BOM展开,价格通过名称匹配配送记录) client.query(` WITH demand AS ( SELECT s.store_code, b.material_code, @@ -1928,11 +2200,12 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code LEFT JOIN analytics.dim_material m ON b.material_code = m.material_code LEFT JOIN ( - SELECT item_code AS material_code, round(avg(unit_price_excl_tax)::numeric,4) AS unit_price - FROM central_kitchen_recipe_consumption - WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month') - GROUP BY item_code - ) ic ON b.material_code = ic.material_code + SELECT d.item_name, round(sum(d.outbound_total_amount)::numeric / NULLIF(sum(d.total_quantity), 0), 4) AS unit_price + FROM distribution_detail_records d + WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month') + AND NOT d.is_return AND d.item_name IS NOT NULL + GROUP BY d.item_name + ) ic ON m.material_name = ic.item_name GROUP BY s.store_code, b.material_code, m.material_name, b.unit ) SELECT @@ -1981,7 +2254,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { ORDER BY sd.total_amt DESC `, [month]), - // 中央厨房生产计划(使用临时表) + // 中央厨房生产计划(使用临时表,通过名称匹配实际入库数据) client.query(` WITH ck_demand AS ( SELECT b.material_code AS ck_product_code, @@ -1992,10 +2265,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { FROM tmp_sales_agg s JOIN analytics.fact_recipe_bom b ON s.sku_code = b.sku_code JOIN analytics.dim_material m ON b.material_code = m.material_code - WHERE b.material_code IN ( - SELECT product_code FROM central_kitchen_processing_cost WHERE report_month = $1::date - ) - OR m.material_name IN ( + WHERE m.material_name IN ( SELECT product_name FROM central_kitchen_processing_cost WHERE report_month = $1::date ) GROUP BY b.material_code, m.material_name, b.unit @@ -2009,23 +2279,22 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { GROUP BY product_code, product_name ), ck_combined AS ( - SELECT COALESCE(d.ck_product_code, a.product_code) AS product_code, - COALESCE(d.ck_product_name, a.product_name) AS product_name, + SELECT d.ck_product_code AS product_code, + d.ck_product_name AS product_name, COALESCE(d.unit, '') AS unit, - COALESCE(d.demand_qty, 0) AS demand_qty, - COALESCE(d.store_count, 0) AS store_count, + d.demand_qty, + d.store_count, COALESCE(a.actual_inbound_qty, 0) AS actual_inbound_qty, COALESCE(a.actual_cost, 0) AS actual_cost FROM ck_demand d - FULL OUTER JOIN ck_actual a ON d.ck_product_code = a.product_code + LEFT JOIN ck_actual a ON d.ck_product_name = a.product_name UNION ALL SELECT a.product_code, a.product_name, '', 0, 0, a.actual_inbound_qty, a.actual_cost FROM ck_actual a - WHERE a.product_code NOT IN (SELECT ck_product_code FROM ck_demand WHERE ck_product_code IS NOT NULL) - AND a.product_name NOT IN (SELECT ck_product_name FROM ck_demand WHERE ck_product_name IS NOT NULL) + WHERE a.product_name NOT IN (SELECT ck_product_name FROM ck_demand) ) SELECT product_code, product_name, unit, demand_qty, store_count, actual_inbound_qty, actual_cost, @@ -2055,7 +2324,7 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { FROM distribution_detail_records d WHERE d.business_date >= $1::date AND d.business_date < ($1::date + INTERVAL '1 month') AND NOT d.is_return AND d.item_code IS NOT NULL - AND d.distribution_center_code = '3' + AND d.distribution_center_code = '2' GROUP BY d.item_code, d.item_name ), store_consumption AS ( diff --git a/server/src/routes/situational-awareness.ts b/server/src/routes/situational-awareness.ts index 992ec18..1d4a262 100644 --- a/server/src/routes/situational-awareness.ts +++ b/server/src/routes/situational-awareness.ts @@ -113,8 +113,8 @@ router.get('/alerts', async (req: AuthRequest, res) => { SELECT closed_at::date AS business_date, count(*) AS bill_count, sum(received_total) AS received - FROM analytics.bill_fact - WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.bill_fact) + FROM analytics.fact_bill + WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.fact_bill) AND closed_at IS NOT NULL GROUP BY closed_at::date ORDER BY business_date diff --git a/server/src/routes/smart-scheduling.ts b/server/src/routes/smart-scheduling.ts index e3e9ef4..2f2b022 100644 --- a/server/src/routes/smart-scheduling.ts +++ b/server/src/routes/smart-scheduling.ts @@ -152,7 +152,7 @@ router.get('/dow-traffic', async (req: AuthRequest, res) => { const storeName = req.query.store as string const month = parseMonth(req) const params: any[] = [month] - let where = `WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= $1 AND c175 < $1::date + interval '1 month'` + let where = `WHERE c175 IS NOT NULL AND c175 != '' AND c175::timestamp >= $1::date AND c175::timestamp < $1::date + interval '1 month'` if (storeName) { params.push(storeName) where += ` AND c003 = $${params.length}` @@ -455,7 +455,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => { count(*) AS bills FROM bill_records WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != '' - AND c175 >= $4 AND c175 < $4::date + interval '1 month' + AND c175::timestamp >= $4::date AND c175::timestamp < $4::date + interval '1 month' GROUP BY hour, day_type, bill_date ), hourly_stats AS ( @@ -721,12 +721,18 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => { params.push(storeName) where += ` AND org_level5 = $${params.length}` } - params.push(month) - const monthParam = `$${params.length}` + // 将 2026-04 转为 2026年4月 格式匹配 salary_period + const [yr, mo] = month.split('-') + const periodLabel = `${parseInt(yr)}年${parseInt(mo)}月` + params.push(periodLabel) + where += ` AND salary_period = $${params.length}` const countResult = await query(`SELECT count(*) FROM salary_detail_records ${where}`, params) const total = countResult.rows[0].count + params.push(month) + const monthParam = `$${params.length}` + params.push(pageSize, offset) const result = await query(` SELECT employee_code, @@ -767,6 +773,9 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => { // 岗位薪资对比 router.get('/position-salary-compare', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) + const [yr, mo] = month.split('-') + const periodLabel = `${parseInt(yr)}年${parseInt(mo)}月` const result = await query(` SELECT position, count(*) AS emp_count, @@ -781,10 +790,11 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => { FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND position IS NOT NULL AND position != '' + AND salary_period = $1 GROUP BY position HAVING count(*) >= 5 ORDER BY avg_gross DESC - `) + `, [periodLabel]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -795,6 +805,8 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => { router.get('/turnover-stats', async (req: AuthRequest, res) => { try { const month = parseMonth(req) + const [yr, mo] = month.split('-') + const periodLabel = `${parseInt(yr)}年${parseInt(mo)}月` const result = await query(` SELECT org_level5 AS store_name, count(*) AS total_emp, @@ -804,9 +816,10 @@ router.get('/turnover-stats', async (req: AuthRequest, res) => { round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' + AND salary_period = $2 GROUP BY org_level5 ORDER BY turnover_rate DESC NULLS LAST - `, [month]) + `, [month, periodLabel]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 104fbb8..7cef8e5 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -123,8 +123,15 @@ router.get('/monthly-review', async (req: AuthRequest, res) => { router.get('/followup', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.mv_store_monthly_followup ORDER BY priority, store_code`) - sendSuccess(res, result.rows) + const month = req.query.month as string | undefined + if (month) { + const monthDate = month + '-01' + const result = await query(`SELECT * FROM analytics.mv_store_monthly_followup WHERE plan_month = $1::date ORDER BY priority, store_code`, [monthDate]) + sendSuccess(res, result.rows) + } else { + const result = await query(`SELECT * FROM analytics.mv_store_monthly_followup ORDER BY priority, store_code`) + sendSuccess(res, result.rows) + } } catch (err: any) { sendError(res, err.message) } @@ -132,7 +139,8 @@ router.get('/followup', async (req: AuthRequest, res) => { router.get('/grade-change', async (req: AuthRequest, res) => { try { - const result = await query(`SELECT * FROM analytics.store_grade_change ORDER BY change_month DESC, store_code`) + const month = parseMonth(req) + const result = await query(`SELECT * FROM analytics.store_grade_change WHERE change_month = $1::date ORDER BY change_month DESC, store_code`, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) @@ -278,7 +286,49 @@ router.post('/notifications/dispatch', async (req: AuthRequest, res) => { router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => { try { const code = req.params.code - const result = await query(`SELECT * FROM analytics.v_store_daily_card WHERE store_code = $1 ORDER BY module`, [code]) + const month = req.query.month as string | undefined + let dateFilter: string + let params: any[] = [code] + if (month) { + const monthDate = month + '-01' + dateFilter = `closed_at::date = (SELECT max(closed_at)::date FROM analytics.fact_bill WHERE closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') AND closed_at IS NOT NULL)` + params.push(monthDate) + } else { + dateFilter = `closed_at::date = (SELECT max(closed_at)::date FROM analytics.fact_bill WHERE closed_at IS NOT NULL)` + } + const result = await query(` + WITH target_day AS ( + SELECT max(closed_at)::date AS business_date + FROM analytics.fact_bill + WHERE ${dateFilter.replace('closed_at', 'fact_bill.closed_at')} + ), + today_stats AS ( + SELECT bf.store_code, bf.store_name, + round(sum(bf.received_total), 2) AS received, + count(*) AS bill_count, + round(sum(bf.received_total) / count(*), 2) AS avg_bill + FROM analytics.fact_bill bf, target_day td + WHERE bf.store_code = $1 AND bf.closed_at::date = td.business_date + GROUP BY bf.store_code, bf.store_name + ), + week_ago_stats AS ( + SELECT bf.store_code, + round(sum(bf.received_total), 2) AS received, + count(*) AS bill_count, + round(sum(bf.received_total) / count(*), 2) AS avg_bill + FROM analytics.fact_bill bf, target_day td + WHERE bf.store_code = $1 AND bf.closed_at::date = td.business_date - interval '7 days' + GROUP BY bf.store_code + ) + SELECT t.store_code, t.store_name, '收入' AS module, + jsonb_build_array( + jsonb_build_object('metric', '实收', 'value', t.received, 'baseline', COALESCE(w.received, 0), 'is_anomaly', t.received < (COALESCE(w.received, 0) * 0.8)), + jsonb_build_object('metric', '账单数', 'value', t.bill_count, 'baseline', COALESCE(w.bill_count, 0), 'is_anomaly', t.bill_count::numeric < (COALESCE(w.bill_count, 0) * 0.8)), + jsonb_build_object('metric', '客单价', 'value', t.avg_bill, 'baseline', COALESCE(w.avg_bill, 0), 'is_anomaly', t.avg_bill < (COALESCE(w.avg_bill, 0) * 0.9)) + ) AS anomalies + FROM today_stats t + LEFT JOIN week_ago_stats w ON t.store_code = w.store_code + `, params) const tasks = await query(` SELECT t.* FROM analytics.store_task t WHERE t.store_code = $1 AND t.status IN ('待启动', '进行中') @@ -601,14 +651,28 @@ router.get('/monthly-review/completion', async (req: AuthRequest, res) => { router.get('/monthly-review/activity-list', async (req: AuthRequest, res) => { try { + const month = parseMonth(req) const result = await query(` - SELECT marketing_plan, bill_count, consumption, discounts, received, + SELECT marketing_plan, bill_count, consumption, discounts, received, avg_bill_value, discount_rate_pct, theoretical_margin_pct, ROUND(discounts * 100.0 / NULLIF(consumption, 0), 2) as discount_cost_rate, ROUND(received - discounts, 2) as net_contribution - FROM analytics.v_marketing_plan_summary + FROM ( + SELECT marketing_plan, + count(*) AS bill_count, + sum(consumption) AS consumption, + sum(discount_total) AS discounts, + sum(received_total) AS received, + round(avg(received_total), 2) AS avg_bill_value, + round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct, + round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct + FROM analytics.fact_bill + WHERE marketing_plan IS NOT NULL + AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month') + GROUP BY marketing_plan + ) t ORDER BY received DESC - `) + `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } })