feat: 配送物化视图优化查询性能 + 饼图统一处理 + SKU长尾治理明细 + 地图初始聚焦北京
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -292,7 +292,7 @@ export function AdjustmentTab({ month }: { month: string }) {
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="day" tick={{ fontSize: 9 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [v, '日销量(账单数)']} />
|
||||
<Legend />
|
||||
<Line dataKey="bills" name="日销量(账单数)" stroke="#3b82f6" />
|
||||
</LineChart>
|
||||
|
||||
@@ -77,7 +77,7 @@ export function BomTab({ month }: { month: string }) {
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="band" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [v, 'SKU数']} />
|
||||
<Bar dataKey="cnt" name="SKU数" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -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 }) {
|
||||
<Pie data={distData} dataKey="cnt" nameKey="loss_band" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.cnt}`}>
|
||||
{distData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [v, '明细数']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -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 }) {
|
||||
<Pie data={devs} dataKey="cnt" nameKey="deviation_band" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.deviation_band}: ${e.cnt}`}>
|
||||
{devs.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [v, '菜品数']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -62,7 +62,8 @@ export function StoreTab({ month }: { month: string }) {
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.name}: ${e.value}`}>
|
||||
{pieData.map((d, i) => <Cell key={i} fill={LEVEL_COLORS[d.name]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [v, '门店数']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -32,10 +32,10 @@ export function ExpenseOverviewTab({ month }: { month: string }) {
|
||||
<CollapsibleSection title="费用结构分析" subtitle="2026年4月各费用科目占比">
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={120} label={(e: any) => e.name.length > 6 ? e.name.slice(0, 6) + '…' : e.name}>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={120} labelLine={false}>
|
||||
{pieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), name]} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -371,7 +371,7 @@ export function BankPage() {
|
||||
<div>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={riskPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(e: any) => `${e.name}: ${e.value}家`}>
|
||||
<Pie data={riskPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} labelLine={false} label={(e: any) => <tspan fontSize={11}>{`${e.name}: ${e.value}家`}</tspan>}>
|
||||
{riskPie.map((entry: any, i: number) => <Cell key={i} fill={entry.fill} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, name: any) => [`${v}家`, name]} />
|
||||
@@ -405,7 +405,7 @@ export function BankPage() {
|
||||
<div className="mt-4">
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label={(e: any) => `${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}>
|
||||
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={60} labelLine={false} label={(e: any) => <tspan fontSize={11}>{`${CHANNEL_LABELS[e.name] || e.name} ${totalChannel > 0 ? (e.value / totalChannel * 100).toFixed(1) : 0}%`}</tspan>}>
|
||||
{channelPie.map((entry: any, i: number) => <Cell key={i} fill={CHANNEL_COLORS[entry.name] || '#999'} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), CHANNEL_LABELS[name] || name]} />
|
||||
|
||||
@@ -417,7 +417,7 @@ export function BossPage() {
|
||||
{!structLoading && pieData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label={(e: any) => `${e.name}`}>
|
||||
<Pie data={pieData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} labelLine={false} label={(e: any) => <tspan fontSize={11}>{e.name}</tspan>}>
|
||||
{pieData.map((_: any, i: number) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any) => [formatCurrency(v), '金额']} />
|
||||
|
||||
@@ -376,15 +376,15 @@ export function DashboardPage() {
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={riskPieData}
|
||||
cx="50%" cy="50%" outerRadius={80}
|
||||
cx="50%" cy="50%" outerRadius={70}
|
||||
dataKey="value"
|
||||
label={({ name, value, pct }: any) => `${name} ${value}家 (${pct}%)`}
|
||||
labelLine={false} label={({ name, value, pct }: any) => <tspan fontSize={11}>{`${name} ${value}家 (${pct}%)`}</tspan>}
|
||||
>
|
||||
{riskPieData.map((entry) => (
|
||||
<Cell key={entry.name} fill={RISK_COLORS[entry.name as keyof typeof RISK_COLORS] || '#gray'} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [`${v}家`, '门店数']} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -417,8 +417,8 @@ export function DashboardPage() {
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
<Tooltip formatter={(v: any) => [formatCurrency(v), '金额']} />
|
||||
<Bar dataKey="value" name="金额" radius={[4, 4, 0, 0]}>
|
||||
{waterfallData.map((entry, idx) => <Cell key={idx} fill={entry.fill} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
@@ -432,7 +432,7 @@ export function DashboardPage() {
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-30} textAnchor="middle" height={50} />
|
||||
<YAxis unit="%" tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [`${v}%`, '成本率']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
<Bar dataKey="meituan" name="美团" fill="#f97316" />
|
||||
<Bar dataKey="eleme" name="饿了么" fill="#3b82f6" />
|
||||
|
||||
@@ -104,8 +104,8 @@ export function DistributionReconciliationPage() {
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
<Tooltip formatter={(v: any) => [formatCurrency(v), '金额']} />
|
||||
<Bar dataKey="value" name="金额" radius={[4, 4, 0, 0]}>
|
||||
{waterfallData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={
|
||||
entry.type === 'add' ? '#52c41a' :
|
||||
|
||||
@@ -101,11 +101,11 @@ export function MemberPage() {
|
||||
<CollapsibleSection title="会员账单占比" subtitle="会员与非会员账单分布">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
|
||||
label={({ name, value, }: any) => `${name}: ${formatNumber(value)}`}>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
|
||||
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${formatNumber(value)}`}</tspan>}>
|
||||
{pieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i]} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any) => formatNumber(v)} />
|
||||
<Tooltip formatter={(v: any) => [formatNumber(v), '数量']} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -108,11 +108,12 @@ export function MonthlyReviewPage() {
|
||||
{pieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={80} dataKey="value"
|
||||
label={({ name, value }: any) => `${name}: ${value}`}>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
|
||||
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
|
||||
{pieData.map((d) => <Cell key={d.name} fill={REVIEW_COLORS[d.name as keyof typeof REVIEW_COLORS]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [v, '任务数']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : <div className="py-20 text-center text-muted-foreground">暂无验收数据</div>}
|
||||
|
||||
@@ -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() {
|
||||
<CollapsibleSection title="BOM覆盖率" subtitle="有BOM的菜品占比">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie data={coverageData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={(entry: any) => `${entry.name}: ${entry.value}`}>
|
||||
<Pie data={coverageData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} labelLine={false} label={(entry: any) => <tspan fontSize={11}>{`${entry.name}: ${entry.value}`}</tspan>}>
|
||||
{coverageData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [v, '菜品数']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
@@ -164,10 +165,17 @@ export function ProductionPlanPage() {
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 生产与配送协同 */}
|
||||
<CollapsibleSection title="生产与配送协同" subtitle="完工入库 → 配送 → 门店消耗 → 期末库存">
|
||||
<CollapsibleSection title="生产与配送协同" subtitle="完工入库 → 中央厨房出库(配送) → 门店消耗 → 期末库存">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Truck className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm text-muted-foreground">完工配送率 = 配送/完工 | 配送消耗率 = 消耗/配送 | 库存积压率 = 期末/消耗</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
完工入库 → 配送量(中央厨房出库) → 门店消耗 → 期末库存(门店) | 其他仓库消耗/期末单独列出
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3 rounded-lg border bg-blue-50/40 p-3 text-xs text-blue-800">
|
||||
<strong>数据逻辑:</strong>完工入库(生产) → 配送量(中央厨房出库给门店) → 门店消耗(盘点倒挤) → 门店期末库存<br />
|
||||
<strong>中央厨房出库:</strong>盘点倒挤中DC=2的消耗量,应≈配送量 | <strong>其他仓库:</strong>非门店非中央厨房的仓库消耗与期末库存<br />
|
||||
<strong>完工配送率</strong> = 配送/完工 | <strong>配送消耗率</strong> = 门店消耗/配送 | <strong>库存积压率</strong> = 门店期末/门店消耗
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={coordChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
@@ -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) => (
|
||||
<span className={rateColorClass(r.completion_distribution_rate)}>{rateText(r.completion_distribution_rate)}</span>
|
||||
) },
|
||||
|
||||
@@ -194,7 +194,7 @@ export function RevenuePage() {
|
||||
{channelPie.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label={(e: any) => `${channelLabels[e.name] || e.name}`}>
|
||||
<Pie data={channelPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} labelLine={false} label={(e: any) => <tspan fontSize={11}>{channelLabels[e.name] || e.name}</tspan>}>
|
||||
{channelPie.map((entry: any, i: number) => <Cell key={i} fill={CHANNEL_COLORS[entry.name] || '#999'} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), channelLabels[name] || name]} />
|
||||
@@ -225,7 +225,7 @@ export function RevenuePage() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie data={mealPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label={(e: any) => `${e.name}`}>
|
||||
<Pie data={mealPie} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} labelLine={false} label={(e: any) => <tspan fontSize={11}>{e.name}</tspan>}>
|
||||
{mealPie.map((entry: any, i: number) => <Cell key={i} fill={entry.fill} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: any, name: any) => [formatCurrency(v), name]} />
|
||||
|
||||
+130
-13
@@ -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<string | null>(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) => (
|
||||
<Cell key={entry.name} fill={ABC_COLORS[entry.name as keyof typeof ABC_COLORS] || '#gray'} />
|
||||
@@ -102,7 +110,7 @@ export function SKUPage() {
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} />
|
||||
<YAxis type="category" dataKey="category_name" tick={{ fontSize: 10 }} width={80} />
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Tooltip formatter={(v: any) => [formatCurrency(v), '实收']} />
|
||||
<Bar dataKey="amount" fill="#3b82f6" name="实收" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -147,18 +155,127 @@ export function SKUPage() {
|
||||
{/* 长尾治理建议 */}
|
||||
<CollapsibleSection title="长尾治理建议" subtitle="基于SKU分布的治理方向" defaultOpen={false}>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-md border border-red-200 bg-red-50/50 p-3">
|
||||
<p className="font-medium text-red-700">首轮停用候选:约 {lowValueSku} 个SKU</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">月实收不足¥1,000的SKU,停用前需确认是否有区域特色或套餐组件</p>
|
||||
<div
|
||||
className={`rounded-md border p-3 cursor-pointer transition-colors ${detailView === 'lowValue' ? 'border-red-400 bg-red-50' : 'border-red-200 bg-red-50/50 hover:bg-red-50'}`}
|
||||
onClick={() => setDetailView(detailView === 'lowValue' ? null : 'lowValue')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-red-700">首轮停用候选:约 {lowValueSku} 个SKU</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">月实收不足¥1,000的SKU,停用前需确认是否有区域特色或套餐组件</p>
|
||||
</div>
|
||||
<span className="text-xs text-red-600">{detailView === 'lowValue' ? '收起' : '查看明细 →'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 p-3">
|
||||
<p className="font-medium text-yellow-700">单店专属SKU:{singleStoreSku} 个</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">仅1家门店销售,评估是否为区域特色或可推广</p>
|
||||
{detailView === 'lowValue' && (
|
||||
<FilterableTable
|
||||
data={lowValueSkuRows}
|
||||
sortOptions={[
|
||||
{ key: 'received_amount', label: '实收' },
|
||||
{ key: 'bill_count', label: '账单数' },
|
||||
{ key: 'store_count', label: '覆盖门店' },
|
||||
]}
|
||||
defaultSort="received_amount"
|
||||
defaultOrder="asc"
|
||||
filterKey="category_level1"
|
||||
filterLabel="全部品类"
|
||||
columns={[
|
||||
{ key: 'dish_name', label: '菜品名称' },
|
||||
{ key: 'category_level1', label: '一级品类' },
|
||||
{ key: 'abc_class', label: 'ABC', render: (r) => (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
|
||||
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>{r.abc_class}</span>
|
||||
)},
|
||||
{ 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) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`rounded-md border p-3 cursor-pointer transition-colors ${detailView === 'singleStore' ? 'border-yellow-400 bg-yellow-50' : 'border-yellow-200 bg-yellow-50/50 hover:bg-yellow-50'}`}
|
||||
onClick={() => setDetailView(detailView === 'singleStore' ? null : 'singleStore')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-yellow-700">单店专属SKU:{singleStoreSku} 个</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">仅1家门店销售,评估是否为区域特色或可推广</p>
|
||||
</div>
|
||||
<span className="text-xs text-yellow-600">{detailView === 'singleStore' ? '收起' : '查看明细 →'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50/50 p-3">
|
||||
<p className="font-medium text-blue-700">合并/归档候选:约300个套餐/赠品/餐具类</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">转为技术SKU或合并为统一编码,目标有效SKU池800-900个</p>
|
||||
{detailView === 'singleStore' && (
|
||||
<FilterableTable
|
||||
data={singleStoreSkuRows}
|
||||
sortOptions={[
|
||||
{ key: 'received_amount', label: '实收' },
|
||||
{ key: 'bill_count', label: '账单数' },
|
||||
]}
|
||||
defaultSort="received_amount"
|
||||
defaultOrder="asc"
|
||||
filterKey="category_level1"
|
||||
filterLabel="全部品类"
|
||||
columns={[
|
||||
{ key: 'dish_name', label: '菜品名称' },
|
||||
{ key: 'category_level1', label: '一级品类' },
|
||||
{ key: 'abc_class', label: 'ABC', render: (r) => (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
|
||||
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>{r.abc_class}</span>
|
||||
)},
|
||||
{ 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) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`rounded-md border p-3 cursor-pointer transition-colors ${detailView === 'merge' ? 'border-blue-400 bg-blue-50' : 'border-blue-200 bg-blue-50/50 hover:bg-blue-50'}`}
|
||||
onClick={() => setDetailView(detailView === 'merge' ? null : 'merge')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-blue-700">合并/归档候选:{mergeCandidateRows.length} 个套餐/赠品/餐具类</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">转为技术SKU或合并为统一编码,目标有效SKU池800-900个</p>
|
||||
</div>
|
||||
<span className="text-xs text-blue-600">{detailView === 'merge' ? '收起' : '查看明细 →'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{detailView === 'merge' && (
|
||||
<FilterableTable
|
||||
data={mergeCandidateRows}
|
||||
sortOptions={[
|
||||
{ key: 'received_amount', label: '实收' },
|
||||
{ key: 'bill_count', label: '账单数' },
|
||||
{ key: 'store_count', label: '覆盖门店' },
|
||||
]}
|
||||
defaultSort="received_amount"
|
||||
defaultOrder="asc"
|
||||
filterKey="category_level1"
|
||||
filterLabel="全部品类"
|
||||
columns={[
|
||||
{ key: 'dish_name', label: '菜品名称' },
|
||||
{ key: 'category_level1', label: '一级品类' },
|
||||
{ key: 'abc_class', label: 'ABC', render: (r) => (
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
r.abc_class === 'A-核心' ? 'bg-green-100 text-green-700' :
|
||||
r.abc_class === 'B-成长' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>{r.abc_class}</span>
|
||||
)},
|
||||
{ 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) },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,7 @@ function HealthScoreTab({ navigate, month }: { navigate: (path: string) => void;
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [v, '门店数']} />
|
||||
<Bar dataKey="count" name="门店数" radius={[4, 4, 0, 0]}>
|
||||
{scoreBuckets.map((b, i) => <Cell key={i} fill={b.color} />)}
|
||||
</Bar>
|
||||
@@ -256,8 +256,8 @@ function HealthScoreDetail({ store, onClose, navigate }: { store: any; onClose:
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 8 }} />
|
||||
<Radar dataKey="score" stroke={healthColor} fill={healthColor} fillOpacity={0.3} />
|
||||
<Tooltip />
|
||||
<Radar dataKey="score" name="得分" stroke={healthColor} fill={healthColor} fillOpacity={0.3} />
|
||||
<Tooltip formatter={(v: any) => [v, '得分']} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -272,8 +272,8 @@ export function StoreDetailPage() {
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 8 }} />
|
||||
<Radar dataKey="score" stroke={Number(health.health_score) >= 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} />
|
||||
<Tooltip />
|
||||
<Radar dataKey="score" name="得分" stroke={Number(health.health_score) >= 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} />
|
||||
<Tooltip formatter={(v: any) => [v, '得分']} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="space-y-2">
|
||||
@@ -393,10 +393,11 @@ export function StoreDetailPage() {
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">门店品类占比</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={70} dataKey="value" label={({ name, value }: any) => `${name} ${value}%`}>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={60} dataKey="value" nameKey="name" labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name} ${value}%`}</tspan>}>
|
||||
{pieData.map((d, i) => <Cell key={i} fill={d.fill} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [`${v}%`, '占比']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -407,7 +408,7 @@ export function StoreDetailPage() {
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
|
||||
<YAxis unit="%" tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [`${v}%`, '占比']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
<Bar dataKey="门店占比" fill="#3b82f6" />
|
||||
<Bar dataKey="公司占比" fill="#e2e8f0" />
|
||||
|
||||
@@ -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 <LoadingSpinner text="加载店长工作台..." />
|
||||
@@ -267,7 +267,7 @@ export function StorePage() {
|
||||
{/* 经营概览 */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-bold">最新经营概览({month})</h2>
|
||||
<h2 className="text-sm font-bold">日均经营概览({month})</h2>
|
||||
{anomalyCount > 0 && (
|
||||
<span className="rounded-md bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">{anomalyCount} 项异常</span>
|
||||
)}
|
||||
@@ -510,10 +510,10 @@ export function StorePage() {
|
||||
{/* ========== 门店详情区 ========== */}
|
||||
{sc && Number(sc.received) > 0 && (
|
||||
<>
|
||||
{/* 核心指标 */}
|
||||
{/* 核心指标 - 月度汇总 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
|
||||
<MetricCard title="实收" value={sc.received} format="currency" />
|
||||
<MetricCard title="账单数" value={sc.bill_count} format="number" />
|
||||
<MetricCard title="月度实收" value={sc.received} format="currency" />
|
||||
<MetricCard title="月度账单数" value={sc.bill_count} format="number" />
|
||||
<MetricCard title="客单价" value={sc.avg_bill_value} format="currency" />
|
||||
<MetricCard title="优惠率" value={sc.discount_rate_pct} format="percent" />
|
||||
<MetricCard title="理论毛利率" value={sc.theoretical_margin_pct} format="percent" />
|
||||
@@ -611,8 +611,8 @@ export function StorePage() {
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 8 }} />
|
||||
<Radar dataKey="score" stroke={Number(health.health_score) >= 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} />
|
||||
<Tooltip />
|
||||
<Radar dataKey="score" name="得分" stroke={Number(health.health_score) >= 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} />
|
||||
<Tooltip formatter={(v: any) => [v, '得分']} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="space-y-2">
|
||||
@@ -718,10 +718,11 @@ export function StorePage() {
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground">门店品类占比</p>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={70} dataKey="value" label={({ name, value }: any) => `${name} ${value}%`}>
|
||||
<Pie data={pieData} cx="50%" cy="50%" outerRadius={60} dataKey="value" nameKey="name" labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name} ${value}%`}</tspan>}>
|
||||
{pieData.map((d, i) => <Cell key={i} fill={d.fill} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [`${v}%`, '占比']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -732,7 +733,7 @@ export function StorePage() {
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
|
||||
<YAxis unit="%" tick={{ fontSize: 10 }} />
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(v: any) => [`${v}%`, '占比']} />
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
<Bar dataKey="门店占比" fill="#3b82f6" />
|
||||
<Bar dataKey="公司占比" fill="#e2e8f0" />
|
||||
|
||||
+69
-26
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user