diff --git a/client/src/components/StoreMap.tsx b/client/src/components/StoreMap.tsx index e9decba..f3b2264 100644 --- a/client/src/components/StoreMap.tsx +++ b/client/src/components/StoreMap.tsx @@ -47,11 +47,16 @@ interface StoreMapProps { function FitBounds({ points }: { points: [number, number][] }) { const map = useMap() useEffect(() => { - if (points.length === 0) return - if (points.length === 1) { - map.setView(points[0], 13) + // 只适配北京区域内的点,避免外地门店导致地图缩放过远 + const beijingPoints = points.filter(([lat, lng]) => + lat >= 39.4 && lat <= 41.1 && lng >= 115.4 && lng <= 117.4 + ) + const targetPoints = beijingPoints.length > 0 ? beijingPoints : points + if (targetPoints.length === 0) return + if (targetPoints.length === 1) { + map.setView(targetPoints[0], 13) } else { - const bounds = L.latLngBounds(points) + const bounds = L.latLngBounds(targetPoints) map.fitBounds(bounds, { padding: [30, 30] }) } }, [points, map]) diff --git a/client/src/components/cost-analysis/AdjustmentTab.tsx b/client/src/components/cost-analysis/AdjustmentTab.tsx index adbb2df..f136190 100644 --- a/client/src/components/cost-analysis/AdjustmentTab.tsx +++ b/client/src/components/cost-analysis/AdjustmentTab.tsx @@ -292,7 +292,7 @@ export function AdjustmentTab({ month }: { month: string }) { - + [v, '日销量(账单数)']} /> diff --git a/client/src/components/cost-analysis/BomTab.tsx b/client/src/components/cost-analysis/BomTab.tsx index 364c65b..1b89217 100644 --- a/client/src/components/cost-analysis/BomTab.tsx +++ b/client/src/components/cost-analysis/BomTab.tsx @@ -77,7 +77,7 @@ export function BomTab({ month }: { month: string }) { - + [v, 'SKU数']} /> diff --git a/client/src/components/cost-analysis/MaterialTab.tsx b/client/src/components/cost-analysis/MaterialTab.tsx index 6b1e7fc..29fb1b6 100644 --- a/client/src/components/cost-analysis/MaterialTab.tsx +++ b/client/src/components/cost-analysis/MaterialTab.tsx @@ -5,7 +5,7 @@ import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils' import { useState } from 'react' -import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts' +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Legend } from 'recharts' const PIE_COLORS = ['#22c55e', '#3b82f6', '#eab308', '#f97316', '#ef4444', '#8b5cf6', '#a3a3a3'] export function MaterialTab({ month }: { month: string }) { @@ -119,7 +119,8 @@ export function MaterialTab({ month }: { month: string }) { `${e.cnt}`}> {distData.map((_: any, i: number) => )} - + [v, '明细数']} /> +
diff --git a/client/src/components/cost-analysis/OverviewTab.tsx b/client/src/components/cost-analysis/OverviewTab.tsx index 9f0ee17..251b1cd 100644 --- a/client/src/components/cost-analysis/OverviewTab.tsx +++ b/client/src/components/cost-analysis/OverviewTab.tsx @@ -5,7 +5,7 @@ import { MetricCard } from '@/components/MetricCard' import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { formatCurrency, formatPercent } from '@/lib/utils' -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts' const PIE_COLORS = ['#ef4444', '#f97316', '#eab308', '#3b82f6', '#22c55e', '#a3a3a3', '#8b5cf6'] @@ -100,7 +100,8 @@ export function OverviewTab({ month }: { month: string }) { `${e.deviation_band}: ${e.cnt}`}> {devs.map((_: any, i: number) => )} - + [v, '菜品数']} /> +
diff --git a/client/src/components/cost-analysis/StoreTab.tsx b/client/src/components/cost-analysis/StoreTab.tsx index 5c90151..77954b9 100644 --- a/client/src/components/cost-analysis/StoreTab.tsx +++ b/client/src/components/cost-analysis/StoreTab.tsx @@ -6,7 +6,7 @@ import { FilterableTable } from '@/components/FilterableTable' import { LoadingSpinner } from '@/components/LoadingSpinner' import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils' import { useState } from 'react' -import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts' +import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts' const PAGE_SIZE = 15 const LEVEL_COLORS: Record = { @@ -62,7 +62,8 @@ export function StoreTab({ month }: { month: string }) { `${e.name}: ${e.value}`}> {pieData.map((d, i) => )} - + [v, '门店数']} /> + diff --git a/client/src/components/store-expense/ExpenseOverviewTab.tsx b/client/src/components/store-expense/ExpenseOverviewTab.tsx index d4306a6..4985feb 100644 --- a/client/src/components/store-expense/ExpenseOverviewTab.tsx +++ b/client/src/components/store-expense/ExpenseOverviewTab.tsx @@ -32,10 +32,10 @@ export function ExpenseOverviewTab({ month }: { month: string }) { - e.name.length > 6 ? e.name.slice(0, 6) + '…' : e.name}> + {pieData.map((_: any, i: number) => )} - formatCurrency(v)} /> + [formatCurrency(v), name]} /> diff --git a/client/src/pages/BankPage.tsx b/client/src/pages/BankPage.tsx index a4375ea..c8a4ceb 100644 --- a/client/src/pages/BankPage.tsx +++ b/client/src/pages/BankPage.tsx @@ -371,7 +371,7 @@ export function BankPage() {
- `${e.name}: ${e.value}家`}> + {`${e.name}: ${e.value}家`}}> {riskPie.map((entry: any, i: number) => )} [`${v}家`, name]} /> @@ -405,7 +405,7 @@ export function BankPage() {
- `${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}> + {`${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}}> {channelPie.map((entry: any, i: number) => )} [formatCurrency(v), CHANNEL_LABELS[name] || name]} /> diff --git a/client/src/pages/BossPage.tsx b/client/src/pages/BossPage.tsx index 2b99dcc..dc1e5a1 100644 --- a/client/src/pages/BossPage.tsx +++ b/client/src/pages/BossPage.tsx @@ -417,7 +417,7 @@ export function BossPage() { {!structLoading && pieData.length > 0 && ( - `${e.name}`}> + {e.name}}> {pieData.map((_: any, i: number) => )} [formatCurrency(v), '金额']} /> diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index ad377dd..a682964 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -376,15 +376,15 @@ export function DashboardPage() { `${name} ${value}家 (${pct}%)`} + labelLine={false} label={({ name, value, pct }: any) => {`${name} ${value}家 (${pct}%)`}} > {riskPieData.map((entry) => ( ))} - + [`${v}家`, '门店数']} /> @@ -417,8 +417,8 @@ export function DashboardPage() { v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> - formatCurrency(v)} /> - + [formatCurrency(v), '金额']} /> + {waterfallData.map((entry, idx) => )} @@ -432,7 +432,7 @@ export function DashboardPage() { - + [`${v}%`, '成本率']} /> diff --git a/client/src/pages/DistributionReconciliationPage.tsx b/client/src/pages/DistributionReconciliationPage.tsx index 3dfcb40..8364026 100644 --- a/client/src/pages/DistributionReconciliationPage.tsx +++ b/client/src/pages/DistributionReconciliationPage.tsx @@ -104,8 +104,8 @@ export function DistributionReconciliationPage() { `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} /> - formatCurrency(v)} /> - + [formatCurrency(v), '金额']} /> + {waterfallData.map((entry, idx) => ( - `${name}: ${formatNumber(value)}`}> + {`${name}: ${formatNumber(value)}`}}> {pieData.map((_, i) => )} - formatNumber(v)} /> + [formatNumber(v), '数量']} /> diff --git a/client/src/pages/MonthlyReviewPage.tsx b/client/src/pages/MonthlyReviewPage.tsx index 1a2e4e5..b7cd9ed 100644 --- a/client/src/pages/MonthlyReviewPage.tsx +++ b/client/src/pages/MonthlyReviewPage.tsx @@ -108,11 +108,12 @@ export function MonthlyReviewPage() { {pieData.length > 0 ? ( - `${name}: ${value}`}> + {`${name}: ${value}`}}> {pieData.map((d) => )} - + [v, '任务数']} /> + ) :
暂无验收数据
} diff --git a/client/src/pages/ProductionPlanPage.tsx b/client/src/pages/ProductionPlanPage.tsx index d621b64..1408f3a 100644 --- a/client/src/pages/ProductionPlanPage.tsx +++ b/client/src/pages/ProductionPlanPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie } from 'recharts' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie, Legend } from 'recharts' import api from '@/lib/api' import { MetricCard } from '@/components/MetricCard' import { LoadingSpinner } from '@/components/LoadingSpinner' @@ -100,12 +100,13 @@ export function ProductionPlanPage() { - `${entry.name}: ${entry.value}`}> + {`${entry.name}: ${entry.value}`}}> {coverageData.map((entry, idx) => ( ))} - + [v, '菜品数']} /> + @@ -164,10 +165,17 @@ export function ProductionPlanPage() { {/* 生产与配送协同 */} - +
- 完工配送率 = 配送/完工 | 配送消耗率 = 消耗/配送 | 库存积压率 = 期末/消耗 + + 完工入库 → 配送量(中央厨房出库) → 门店消耗 → 期末库存(门店) | 其他仓库消耗/期末单独列出 + +
+
+ 数据逻辑:完工入库(生产) → 配送量(中央厨房出库给门店) → 门店消耗(盘点倒挤) → 门店期末库存
+ 中央厨房出库:盘点倒挤中DC=2的消耗量,应≈配送量 | 其他仓库:非门店非中央厨房的仓库消耗与期末库存
+ 完工配送率 = 配送/完工 | 配送消耗率 = 门店消耗/配送 | 库存积压率 = 门店期末/门店消耗
@@ -188,8 +196,10 @@ export function ProductionPlanPage() { sortOptions={[ { key: 'inbound_qty', label: '完工入库' }, { key: 'dist_qty', label: '配送量' }, + { key: 'ck_consumption_qty', label: '中央厨房出库' }, + { key: 'other_wh_consumption_qty', label: '其他仓库消耗' }, { key: 'consumption_qty', label: '门店消耗' }, - { key: 'ending_qty', label: '期末库存' }, + { key: 'ending_qty', label: '门店期末库存' }, { key: 'actual_cost', label: '实际成本' }, ]} defaultSort="actual_cost" @@ -198,8 +208,12 @@ export function ProductionPlanPage() { { key: 'product_name', label: '产品名称' }, { key: 'inbound_qty', label: '完工入库', align: 'right', render: (r: any) => formatNumber(r.inbound_qty) }, { key: 'dist_qty', label: '配送量', align: 'right', render: (r: any) => formatNumber(r.dist_qty) }, + { key: 'ck_consumption_qty', label: '中央厨房出库', align: 'right', render: (r: any) => formatNumber(r.ck_consumption_qty) }, + { key: 'ck_ending_qty', label: '中央厨房期末', align: 'right', render: (r: any) => formatNumber(r.ck_ending_qty) }, + { key: 'other_wh_consumption_qty', label: '其他仓库消耗', align: 'right', render: (r: any) => formatNumber(r.other_wh_consumption_qty) }, + { key: 'other_wh_ending_qty', label: '其他仓库期末', align: 'right', render: (r: any) => formatNumber(r.other_wh_ending_qty) }, { key: 'consumption_qty', label: '门店消耗', align: 'right', render: (r: any) => formatNumber(r.consumption_qty) }, - { key: 'ending_qty', label: '期末库存', align: 'right', render: (r: any) => formatNumber(r.ending_qty) }, + { key: 'ending_qty', label: '门店期末库存', align: 'right', render: (r: any) => formatNumber(r.ending_qty) }, { key: 'completion_distribution_rate', label: '完工配送率', align: 'right', render: (r: any) => ( {rateText(r.completion_distribution_rate)} ) }, diff --git a/client/src/pages/RevenuePage.tsx b/client/src/pages/RevenuePage.tsx index a1abc38..fa494a2 100644 --- a/client/src/pages/RevenuePage.tsx +++ b/client/src/pages/RevenuePage.tsx @@ -194,7 +194,7 @@ export function RevenuePage() { {channelPie.length > 0 && ( - `${channelLabels[e.name] || e.name}`}> + {channelLabels[e.name] || e.name}}> {channelPie.map((entry: any, i: number) => )} [formatCurrency(v), channelLabels[name] || name]} /> @@ -225,7 +225,7 @@ export function RevenuePage() {
- `${e.name}`}> + {e.name}}> {mealPie.map((entry: any, i: number) => )} [formatCurrency(v), name]} /> diff --git a/client/src/pages/SKUPage.tsx b/client/src/pages/SKUPage.tsx index 8162049..26270d2 100644 --- a/client/src/pages/SKUPage.tsx +++ b/client/src/pages/SKUPage.tsx @@ -46,8 +46,16 @@ export function SKUPage() { const totalSku = (skuData as any)?.data?.length || 0 const totalReceived = Object.values(abcSummary).reduce((s: number, v: any) => s + v.received, 0) const cClassCount = abcSummary['C-长尾']?.sku_count || 0 - 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 lowValueSkuRows = ((skuData as any)?.data || []).filter((r: any) => Number(r.received_amount) < 1000).sort((a: any, b: any) => Number(a.received_amount) - Number(b.received_amount)) + const singleStoreSkuRows = ((skuData as any)?.data || []).filter((r: any) => Number(r.store_count) <= 1).sort((a: any, b: any) => Number(a.received_amount) - Number(b.received_amount)) + const mergeCandidateRows = ((skuData as any)?.data || []).filter((r: any) => { + const name = (r.dish_name || '').toLowerCase() + return name.includes('套餐') || name.includes('赠') || name.includes('餐具') || name.includes('打包') || name.includes('纸巾') || name.includes('调料包') + }).sort((a: any, b: any) => Number(a.received_amount) - Number(b.received_amount)) + const lowValueSku = lowValueSkuRows.length + const singleStoreSku = singleStoreSkuRows.length + + const [detailView, setDetailView] = useState(null) const pageLoading = skuLoading || catLoading @@ -84,7 +92,7 @@ export function SKUPage() { data={abcPieData} cx="50%" cy="50%" outerRadius={80} dataKey="value" - label={({ name, value, share }: any) => `${name}: ${value}个 (${Number(share).toFixed(1)}%)`} + labelLine={false} > {abcPieData.map((entry) => ( @@ -102,7 +110,7 @@ export function SKUPage() { v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} /> - formatCurrency(v)} /> + [formatCurrency(v), '实收']} /> @@ -147,18 +155,127 @@ export function SKUPage() { {/* 长尾治理建议 */}
-
-

首轮停用候选:约 {lowValueSku} 个SKU

-

月实收不足¥1,000的SKU,停用前需确认是否有区域特色或套餐组件

+
setDetailView(detailView === 'lowValue' ? null : 'lowValue')} + > +
+
+

首轮停用候选:约 {lowValueSku} 个SKU

+

月实收不足¥1,000的SKU,停用前需确认是否有区域特色或套餐组件

+
+ {detailView === 'lowValue' ? '收起' : '查看明细 →'} +
-
-

单店专属SKU:{singleStoreSku} 个

-

仅1家门店销售,评估是否为区域特色或可推广

+ {detailView === 'lowValue' && ( + ( + {r.abc_class} + )}, + { 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) }, + ]} + /> + )} + +
setDetailView(detailView === 'singleStore' ? null : 'singleStore')} + > +
+
+

单店专属SKU:{singleStoreSku} 个

+

仅1家门店销售,评估是否为区域特色或可推广

+
+ {detailView === 'singleStore' ? '收起' : '查看明细 →'} +
-
-

合并/归档候选:约300个套餐/赠品/餐具类

-

转为技术SKU或合并为统一编码,目标有效SKU池800-900个

+ {detailView === 'singleStore' && ( + ( + {r.abc_class} + )}, + { 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) }, + ]} + /> + )} + +
setDetailView(detailView === 'merge' ? null : 'merge')} + > +
+
+

合并/归档候选:{mergeCandidateRows.length} 个套餐/赠品/餐具类

+

转为技术SKU或合并为统一编码,目标有效SKU池800-900个

+
+ {detailView === 'merge' ? '收起' : '查看明细 →'} +
+ {detailView === 'merge' && ( + ( + {r.abc_class} + )}, + { 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) }, + ]} + /> + )}
diff --git a/client/src/pages/SituationalAwarenessPage.tsx b/client/src/pages/SituationalAwarenessPage.tsx index 86eb9e8..d634f7f 100644 --- a/client/src/pages/SituationalAwarenessPage.tsx +++ b/client/src/pages/SituationalAwarenessPage.tsx @@ -151,7 +151,7 @@ function HealthScoreTab({ navigate, month }: { navigate: (path: string) => void; - + [v, '门店数']} /> {scoreBuckets.map((b, i) => )} @@ -256,8 +256,8 @@ function HealthScoreDetail({ store, onClose, navigate }: { store: any; onClose: - - + + [v, '得分']} />
diff --git a/client/src/pages/StoreDetailPage.tsx b/client/src/pages/StoreDetailPage.tsx index 6c00522..9838557 100644 --- a/client/src/pages/StoreDetailPage.tsx +++ b/client/src/pages/StoreDetailPage.tsx @@ -272,8 +272,8 @@ export function StoreDetailPage() { - = 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fill={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fillOpacity={0.3} /> - + = 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fill={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fillOpacity={0.3} /> + [v, '得分']} />
@@ -393,10 +393,11 @@ export function StoreDetailPage() {

门店品类占比

- `${name} ${value}%`}> + {`${name} ${value}%`}}> {pieData.map((d, i) => )} - + [`${v}%`, '占比']} /> +
@@ -407,7 +408,7 @@ export function StoreDetailPage() { - + [`${v}%`, '占比']} /> diff --git a/client/src/pages/StorePage.tsx b/client/src/pages/StorePage.tsx index 51b9f34..7db9f36 100644 --- a/client/src/pages/StorePage.tsx +++ b/client/src/pages/StorePage.tsx @@ -14,7 +14,7 @@ const SCORE_DIMENSIONS = [ { key: 'score_revenue', label: '营收规模', max: 20 }, { key: 'score_cost', label: '成本控制', max: 20 }, { key: 'score_margin', label: '毛利率', max: 15 }, - { key: 'score_risk', label: '风险等级', max: 10 }, + { key: 'score_risk', label: '风险控制', max: 10 }, { key: 'score_repeat', label: '复购率', max: 10 }, { key: 'score_member', label: '会员占比', max: 10 }, { key: 'score_task', label: '任务完成', max: 10 }, @@ -46,7 +46,7 @@ export function StorePage() { }) 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({ + const { data: priorityData, isLoading: priorityLoading } = useQuery({ queryKey: ['stores-priority-detail', month], queryFn: () => api.get('/stores/priority', { params: { month } }), }) @@ -71,23 +71,23 @@ export function StorePage() { queryFn: () => api.get('/tasks/followup', { params: { month } }), }) - const { data: skuData } = useQuery({ + const { data: skuData, isLoading: skuLoading } = useQuery({ queryKey: ['sku-attach', storeCode], queryFn: () => api.get('/sku/attach', { params: { month } }), }) - const { data: abcData } = useQuery({ + const { data: abcData, isLoading: abcLoading } = useQuery({ queryKey: ['sku-abc'], queryFn: () => api.get('/sku/abc', { params: { month } }), }) // StoreDetailPage queries - const { data: storeDetailData } = useQuery({ + const { data: storeDetailData, isLoading: storeDetailLoading } = useQuery({ queryKey: ['store-detail', storeCode, month], queryFn: () => api.get(`/stores/${storeCode}`, { params: { month } }), }) - const { data: healthData } = useQuery({ + const { data: healthData, isLoading: healthLoading } = useQuery({ queryKey: ['store-health', storeCode, month], queryFn: () => api.get('/situational-awareness/health-score', { params: { month } }), }) @@ -217,7 +217,7 @@ export function StorePage() { return 'bg-green-100 text-green-700 border-green-300' } - const pageLoading = storesLoading || cardLoading || tasksLoading || dailyLoading || followupLoading + const pageLoading = storesLoading || priorityLoading || cardLoading || tasksLoading || dailyLoading || followupLoading || skuLoading || abcLoading || storeDetailLoading || healthLoading if (pageLoading) { return @@ -267,7 +267,7 @@ export function StorePage() { {/* 经营概览 */}
-

最新经营概览({month})

+

日均经营概览({month})

{anomalyCount > 0 && ( {anomalyCount} 项异常 )} @@ -510,10 +510,10 @@ export function StorePage() { {/* ========== 门店详情区 ========== */} {sc && Number(sc.received) > 0 && ( <> - {/* 核心指标 */} + {/* 核心指标 - 月度汇总 */}
- - + + @@ -611,8 +611,8 @@ export function StorePage() { - = 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fill={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fillOpacity={0.3} /> - + = 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fill={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fillOpacity={0.3} /> + [v, '得分']} />
@@ -718,10 +718,11 @@ export function StorePage() {

门店品类占比

- `${name} ${value}%`}> + {`${name} ${value}%`}}> {pieData.map((d, i) => )} - + [`${v}%`, '占比']} /> +
@@ -732,7 +733,7 @@ export function StorePage() { - + [`${v}%`, '占比']} /> diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts index da4dfb5..8a74eb9 100644 --- a/server/src/routes/data.ts +++ b/server/src/routes/data.ts @@ -1597,6 +1597,13 @@ router.get('/central-kitchen/dashboard', async (req: AuthRequest, res) => { router.get('/distribution/reconciliation', async (req: AuthRequest, res) => { try { const month = parseMonth(req) + + // 检查配送物化视图是否包含当前月份数据 + const distMvCheck = await query(`SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1`, [month]) + if (distMvCheck.rows.length === 0) { + await query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_distribution_monthly`) + } + const [summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation] = await Promise.all([ // 汇总 query(` @@ -1605,9 +1612,8 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => { round(sum(d.total_quantity)::numeric,2) AS dist_qty, round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt, round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax - 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 + FROM mv_distribution_monthly d + WHERE d.report_month = $1::date GROUP BY d.store_code, d.item_code ), inv AS ( @@ -1658,9 +1664,8 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => { round(sum(d.total_quantity)::numeric,2) AS dist_qty, round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt, round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax - 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 + FROM mv_distribution_monthly d + WHERE d.report_month = $1::date GROUP BY d.store_code ), inv AS ( @@ -1674,8 +1679,8 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => { GROUP BY f.store_code ), store_names AS ( - SELECT DISTINCT store_code, store_name FROM distribution_detail_records - WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month') + SELECT DISTINCT store_code, store_name FROM mv_distribution_monthly + WHERE report_month = $1::date ) SELECT COALESCE(d.store_code, i.store_code) AS store_code, sn.store_name, @@ -1699,9 +1704,8 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => { SELECT d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category, round(sum(d.total_quantity)::numeric,2) AS dist_qty, round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt - 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 + FROM mv_distribution_monthly d + WHERE d.report_month = $1::date GROUP BY d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category ), inv AS ( @@ -1733,9 +1737,8 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => { round(sum(d.total_quantity)::numeric,2) AS dist_qty, round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt, count(DISTINCT d.store_code) AS store_count - 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 + FROM mv_distribution_monthly d + WHERE d.report_month = $1::date GROUP BY d.item_code, d.item_name, d.unit, d.major_category, d.minor_category ), inv_items AS ( @@ -1757,16 +1760,14 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => { round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt, round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax, count(DISTINCT d.item_code) AS item_count - 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 + FROM mv_distribution_monthly d + WHERE d.report_month = $1::date GROUP BY COALESCE(NULLIF(d.minor_category,''),'未分类') ), item_cat AS ( SELECT DISTINCT item_code, COALESCE(NULLIF(minor_category,''),'未分类') AS minor_category - FROM distribution_detail_records - WHERE business_date >= $1::date AND business_date < ($1::date + INTERVAL '1 month') - AND item_code IS NOT NULL + FROM mv_distribution_monthly + WHERE report_month = $1::date ), inv AS ( SELECT ic.minor_category, @@ -2080,6 +2081,14 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dish_sales_monthly`) } + // 检查配送物化视图是否包含当前月份数据 + const distMvCheck = await client.query(` + SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1 + `, [month]) + if (distMvCheck.rows.length === 0) { + await client.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY mv_distribution_monthly`) + } + // 从物化视图创建临时表(索引扫描,毫秒级) await client.query(` CREATE TEMP TABLE IF NOT EXISTS tmp_sales_agg AS @@ -2159,9 +2168,8 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { LEFT JOIN analytics.dim_material m ON b.material_code = m.material_code LEFT JOIN ( 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 + FROM mv_distribution_monthly d + WHERE d.report_month = $1::date 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 @@ -2279,12 +2287,33 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { SELECT d.item_code, d.item_name, round(sum(d.total_quantity)::numeric,4) AS dist_qty, round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt - 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 + FROM mv_distribution_monthly d + WHERE d.report_month = $1::date AND d.distribution_center_code = '2' GROUP BY d.item_code, d.item_name ), + ck_inventory AS ( + SELECT f.material_code, + round(sum(f.consumption_quantity)::numeric,4) AS consumption_qty, + round(sum(f.ending_quantity)::numeric,4) AS ending_qty + FROM analytics.fact_inventory_snapshot f + WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month') + AND f.store_code = '2' + GROUP BY f.material_code + ), + other_wh_inventory AS ( + SELECT f.material_code, + round(sum(f.consumption_quantity)::numeric,4) AS consumption_qty, + round(sum(f.ending_quantity)::numeric,4) AS ending_qty + FROM analytics.fact_inventory_snapshot f + WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month') + AND f.store_code IN ( + SELECT cost_unit_code FROM inventory_store_mapping + WHERE sales_store_code IS NULL OR sales_store_code = '' + ) + AND f.store_code != '2' + GROUP BY f.material_code + ), store_consumption AS ( SELECT f.material_code, round(sum(f.consumption_quantity)::numeric,4) AS consumption_qty, @@ -2293,6 +2322,10 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { round(sum(f.ending_amount)::numeric,2) AS ending_amt FROM analytics.fact_inventory_snapshot f WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month') + AND f.store_code NOT IN ( + SELECT cost_unit_code FROM inventory_store_mapping + WHERE sales_store_code IS NULL OR sales_store_code = '' + ) GROUP BY f.material_code ) SELECT COALESCE(p.product_code, dist.item_code) AS product_code, @@ -2302,6 +2335,10 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { COALESCE(p.theoretical_cost, 0) AS theoretical_cost, COALESCE(dist.dist_qty, 0) AS dist_qty, COALESCE(dist.dist_amt, 0) AS dist_amt, + COALESCE(ck.consumption_qty, 0) AS ck_consumption_qty, + COALESCE(ck.ending_qty, 0) AS ck_ending_qty, + COALESCE(owh.consumption_qty, 0) AS other_wh_consumption_qty, + COALESCE(owh.ending_qty, 0) AS other_wh_ending_qty, COALESCE(sc.consumption_qty, 0) AS consumption_qty, COALESCE(sc.consumption_amt, 0) AS consumption_amt, COALESCE(sc.ending_qty, 0) AS ending_qty, @@ -2319,6 +2356,8 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { ELSE NULL END AS inventory_accumulation_rate FROM ck_production p FULL OUTER JOIN ck_distribution dist ON p.product_code = dist.item_code + FULL OUTER JOIN ck_inventory ck ON COALESCE(p.product_code, dist.item_code) = ck.material_code + FULL OUTER JOIN other_wh_inventory owh ON COALESCE(p.product_code, dist.item_code) = owh.material_code FULL OUTER JOIN store_consumption sc ON COALESCE(p.product_code, dist.item_code) = sc.material_code ORDER BY COALESCE(p.actual_cost, 0) DESC LIMIT 50 @@ -2384,6 +2423,10 @@ router.get('/sales-driven/production-plan', async (req: AuthRequest, res) => { theoretical_cost: parseNum(row.theoretical_cost), dist_qty: parseNum(row.dist_qty), dist_amt: parseNum(row.dist_amt), + ck_consumption_qty: parseNum(row.ck_consumption_qty), + ck_ending_qty: parseNum(row.ck_ending_qty), + other_wh_consumption_qty: parseNum(row.other_wh_consumption_qty), + other_wh_ending_qty: parseNum(row.other_wh_ending_qty), consumption_qty: parseNum(row.consumption_qty), consumption_amt: parseNum(row.consumption_amt), ending_qty: parseNum(row.ending_qty),