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),
|