feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询
This commit is contained in:
@@ -8,3 +8,7 @@ dist/
|
||||
*.xlsx
|
||||
*.zip
|
||||
.playwright-mcp/
|
||||
backups/
|
||||
.playwright-cli/
|
||||
.tmp/
|
||||
数据/
|
||||
|
||||
@@ -26,6 +26,10 @@ import { SituationalAwarenessPage } from '@/pages/SituationalAwarenessPage'
|
||||
import { BossPage } from '@/pages/BossPage'
|
||||
import { RevenuePage } from '@/pages/RevenuePage'
|
||||
import { BankPage } from '@/pages/BankPage'
|
||||
import { CentralKitchenPage } from '@/pages/CentralKitchenPage'
|
||||
import { DistributionReconciliationPage } from '@/pages/DistributionReconciliationPage'
|
||||
import { BomPenetrationPage } from '@/pages/BomPenetrationPage'
|
||||
import { ProductionPlanPage } from '@/pages/ProductionPlanPage'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -73,6 +77,10 @@ export default function App() {
|
||||
<Route path="/boss" element={<BossPage />} />
|
||||
<Route path="/revenue" element={<RevenuePage />} />
|
||||
<Route path="/bank" element={<BankPage />} />
|
||||
<Route path="/central-kitchen" element={<CentralKitchenPage />} />
|
||||
<Route path="/distribution-reconciliation" element={<DistributionReconciliationPage />} />
|
||||
<Route path="/bom-penetration" element={<BomPenetrationPage />} />
|
||||
<Route path="/production-plan" element={<ProductionPlanPage />} />
|
||||
<Route path="/regional" element={<RegionalPage />} />
|
||||
<Route path="/store" element={<StorePage />} />
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
|
||||
@@ -39,7 +39,7 @@ export function FilterableTable({
|
||||
const filtered = useMemo(() => {
|
||||
let r = data
|
||||
if (filter && filterKey) r = r.filter((row: any) => row[filterKey] === filter)
|
||||
if (statusFilter && statusFilterKey) r = r.filter((row: any) => row[statusFilterKey] === statusFilter)
|
||||
if (statusFilter && statusFilterKey) r = r.filter((row: any) => String(row[statusFilterKey]) === statusFilter)
|
||||
return [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[sort]) || (typeof a[sort] === 'string' ? 0 : a[sort] || 0)
|
||||
const bv = parseFloat(b[sort]) || (typeof b[sort] === 'string' ? 0 : b[sort] || 0)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark } from 'lucide-react'
|
||||
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -53,6 +53,10 @@ const menuGroups: MenuGroup[] = [
|
||||
items: [
|
||||
{ path: '/cost-analysis', label: '菜品成本', icon: PieChart, roles: ['hq', 'dept'] },
|
||||
{ path: '/cost', label: '成本库存', icon: Utensils, roles: ['hq', 'dept'] },
|
||||
{ path: '/central-kitchen', label: '中央厨房', icon: ChefHat, roles: ['hq', 'dept'] },
|
||||
{ path: '/distribution-reconciliation', label: '配送对账', icon: Truck, roles: ['hq', 'dept'] },
|
||||
{ path: '/bom-penetration', label: 'BOM穿透', icon: Network, roles: ['hq', 'dept'] },
|
||||
{ path: '/production-plan', label: '生产要货', icon: TrendingUpIcon, roles: ['hq', 'dept'] },
|
||||
{ path: '/store-expense', label: '门店费用', icon: Wallet, roles: ['hq', 'dept'] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
|
||||
interface MonthPickerProps {
|
||||
month: string
|
||||
onChange: (month: string) => void
|
||||
rangeMode?: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
|
||||
onRangeModeChange?: (mode: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom') => void
|
||||
}
|
||||
|
||||
const RANGE_MODES: { value: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'; label: string }[] = [
|
||||
{ value: 'month', label: '月度' },
|
||||
{ value: 'quarter', label: '季度' },
|
||||
{ value: 'halfyear', label: '半年' },
|
||||
{ value: 'year', label: '全年' },
|
||||
]
|
||||
|
||||
export function MonthPicker({ month, onChange, rangeMode = 'month', onRangeModeChange }: MonthPickerProps) {
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
|
||||
const displayMonth = month.length === 10 ? month.substring(0, 7) : month
|
||||
|
||||
const prevMonth = useCallback(() => {
|
||||
const [y, m] = displayMonth.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||
}, [displayMonth, onChange])
|
||||
|
||||
const nextMonth = useCallback(() => {
|
||||
const [y, m] = displayMonth.split('-').map(Number)
|
||||
const d = new Date(y, m, 1)
|
||||
onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||
}, [displayMonth, onChange])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setShowPicker(false)
|
||||
if (showPicker) {
|
||||
window.addEventListener('click', handler)
|
||||
return () => window.removeEventListener('click', handler)
|
||||
}
|
||||
}, [showPicker])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-md border bg-card px-1 py-0.5">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="rounded p-1 hover:bg-muted"
|
||||
title="上月"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowPicker(!showPicker) }}
|
||||
className="flex items-center gap-1 px-2 py-1 text-sm font-medium hover:bg-muted rounded"
|
||||
>
|
||||
<Calendar size={14} />
|
||||
{displayMonth}
|
||||
</button>
|
||||
{showPicker && (
|
||||
<div
|
||||
className="absolute top-full left-0 z-50 mt-1 rounded-md border bg-card p-3 shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="month"
|
||||
value={displayMonth}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value)
|
||||
setShowPicker(false)
|
||||
}}
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="rounded p-1 hover:bg-muted"
|
||||
title="下月"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{onRangeModeChange && (
|
||||
<div className="flex items-center gap-1 rounded-md border bg-card px-1 py-0.5">
|
||||
{RANGE_MODES.map((m) => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => onRangeModeChange(m.value)}
|
||||
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
rangeMode === m.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000,
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
const DEFAULT_MONTH = '2026-04'
|
||||
|
||||
export function useMonthParam() {
|
||||
const [month, setMonth] = useState(DEFAULT_MONTH)
|
||||
|
||||
const monthParam = useCallback(() => {
|
||||
return { month }
|
||||
}, [month])
|
||||
|
||||
const monthForApi = month.length === 7 ? month : month.substring(0, 7)
|
||||
|
||||
return { month: monthForApi, setMonth, monthParam }
|
||||
}
|
||||
|
||||
export type RangeMode = 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
|
||||
|
||||
export function useRangeMode() {
|
||||
const [rangeMode, setRangeMode] = useState<RangeMode>('month')
|
||||
return { rangeMode, setRangeMode }
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export function BankPage() {
|
||||
// 计算各项指标值
|
||||
const metrics = useMemo(() => {
|
||||
if (!ov || !wf || !stability) return null
|
||||
const netMargin = wf.received > 0 ? (wf.net_profit / wf.received * 100) : 0
|
||||
const netMargin = wf.received > 0 ? (wf.store_contribution / wf.received * 100) : 0
|
||||
const cv = stability.cv
|
||||
const redStores = risk.filter((r: any) => r.risk_level === '红色').reduce((s: number, r: any) => s + r.store_count, 0)
|
||||
const totalStores = risk.reduce((s: number, r: any) => s + r.store_count, 0)
|
||||
@@ -140,7 +140,7 @@ export function BankPage() {
|
||||
{ name: '房租', value: -wf.rent, base: wf.received - wf.food_cost - wf.wage, fill: '#8b5cf6' },
|
||||
{ name: '水电', value: -wf.utility, base: wf.received - wf.food_cost - wf.wage - wf.rent, fill: '#06b6d4' },
|
||||
{ name: '其他', value: -(wf.dorm + wf.commission + wf.other_expense), base: wf.received - wf.food_cost - wf.wage - wf.rent - wf.utility, fill: '#64748b' },
|
||||
{ name: '净利润', value: wf.net_profit, base: 0, fill: wf.net_profit >= 0 ? '#22c55e' : '#ef4444' },
|
||||
{ name: '门店贡献利润', value: wf.store_contribution, base: 0, fill: wf.store_contribution >= 0 ? '#22c55e' : '#ef4444' },
|
||||
]
|
||||
}, [wf])
|
||||
|
||||
@@ -254,10 +254,10 @@ export function BankPage() {
|
||||
{wf && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="净利润" value={wf.net_profit} format="currency" status={wf.net_profit > 0 ? 'good' : 'bad'} description={`净利率 ${wf.received > 0 ? formatPercent(wf.net_profit / wf.received * 100) : '-'}`} />
|
||||
<MetricCard title="门店贡献利润估算" value={wf.store_contribution} format="currency" status={wf.store_contribution > 0 ? 'good' : 'bad'} description={`贡献率 ${wf.received > 0 ? formatPercent(wf.store_contribution / wf.received * 100) : '-'} · 仅费用已匹配门店`} />
|
||||
<MetricCard title="食材成本率" value={wf.received > 0 ? wf.food_cost / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.food_cost / wf.received * 100 > 35 ? 'warn' : 'good'} description="食材成本 ÷ 实收" />
|
||||
<MetricCard title="费用率" value={wf.received > 0 ? wf.total_expense / wf.received * 100 : 0} format="percent" status={wf.received > 0 && wf.total_expense / wf.received * 100 > 45 ? 'warn' : 'good'} description="经营费用 ÷ 实收" />
|
||||
<MetricCard title="实收分配" value={wf.received > 0 ? wf.net_profit / wf.received * 100 : 0} format="percent" description="净利润占实收比例" />
|
||||
<MetricCard title="实收分配" value={wf.received > 0 ? wf.store_contribution / wf.received * 100 : 0} format="percent" description="门店贡献利润占实收比例" />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, Treemap } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { formatNumber, formatCurrency } from '@/lib/utils'
|
||||
import { Network, ChevronRight, Layers, GitBranch } from 'lucide-react'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
|
||||
function varianceColorClass(pct: number): string {
|
||||
if (Math.abs(pct) <= 3) return 'text-green-600'
|
||||
if (Math.abs(pct) <= 10) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
function varianceText(pct: number): string {
|
||||
if (pct > 0) return `+${pct.toFixed(2)}%`
|
||||
return `${pct.toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function BomPenetrationPage() {
|
||||
const [month, setMonth] = useState('2026-04')
|
||||
const [selectedProduct, setSelectedProduct] = useState('0202028')
|
||||
const [productFilter, setProductFilter] = useState('')
|
||||
|
||||
const { data: overviewData, isLoading: overviewLoading } = useQuery({
|
||||
queryKey: ['bom-penetration-overview', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/central-kitchen/bom-penetration?month=${month}`)
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: productData, isLoading: productLoading } = useQuery({
|
||||
queryKey: ['bom-penetration', month, selectedProduct],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/central-kitchen/bom-penetration?month=${month}&productCode=${selectedProduct}`)
|
||||
return res.data
|
||||
},
|
||||
enabled: !!selectedProduct,
|
||||
})
|
||||
|
||||
if (overviewLoading) return <LoadingSpinner />
|
||||
if (!overviewData) return <div className="p-4 text-muted-foreground">暂无数据</div>
|
||||
|
||||
const { productList, bomSummary, multiLevelChains } = overviewData
|
||||
const bomTree = productData?.bomTree || []
|
||||
|
||||
// 汇总指标
|
||||
const totalProducts = productList.length
|
||||
const multiLevelProducts = productList.filter((p: any) => p.has_multi_level_bom).length
|
||||
const totalBomTheoretical = bomSummary.reduce((s: number, b: any) => s + b.bom_theoretical_amt, 0)
|
||||
const totalBomIssue = bomSummary.reduce((s: number, b: any) => s + b.bom_issue_amt, 0)
|
||||
const totalVariance = bomSummary.reduce((s: number, b: any) => s + b.variance_amt, 0)
|
||||
|
||||
// BOM汇总Top10
|
||||
const bomSummaryTop10 = bomSummary.slice(0, 15)
|
||||
const bomChartData = bomSummaryTop10.map((b: any) => ({
|
||||
name: b.product_name,
|
||||
理论成本: b.bom_theoretical_amt,
|
||||
领用成本: b.bom_issue_amt,
|
||||
}))
|
||||
|
||||
// BOM树按层级分组
|
||||
const treeByLevel: Record<number, any[]> = {}
|
||||
bomTree.forEach((node: any) => {
|
||||
if (!treeByLevel[node.level]) treeByLevel[node.level] = []
|
||||
treeByLevel[node.level].push(node)
|
||||
})
|
||||
const maxLevel = Math.max(...Object.keys(treeByLevel).map(Number), 1)
|
||||
|
||||
// Treemap数据
|
||||
const treemapData = bomSummary.slice(0, 30).map((b: any) => ({
|
||||
name: b.product_name,
|
||||
size: b.bom_theoretical_amt,
|
||||
variance: b.variance_pct,
|
||||
}))
|
||||
|
||||
const filteredProducts = productList.filter((p: any) =>
|
||||
!productFilter ||
|
||||
p.product_code?.includes(productFilter) ||
|
||||
p.product_name?.includes(productFilter)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-6 w-6 text-purple-600" />
|
||||
<h1 className="text-xl font-bold">多级BOM成本穿透</h1>
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
<MetricCard title="完工产品数" value={totalProducts} unit="个" />
|
||||
<MetricCard title="含多级BOM" value={multiLevelProducts} unit="个" status="warn" description="配方中包含半成品的产成品数" />
|
||||
<MetricCard title="BOM理论成本" value={totalBomTheoretical} format="currency" />
|
||||
<MetricCard title="BOM领用成本" value={totalBomIssue} format="currency" />
|
||||
<MetricCard title="差异金额" value={totalVariance} format="currency" status={totalVariance >= 0 ? 'warn' : 'bad'} />
|
||||
<MetricCard title="半成品依赖链" value={multiLevelChains.length} unit="条" description="成品→半成品的依赖关系数" />
|
||||
</div>
|
||||
|
||||
{/* BOM成本结构Top15 */}
|
||||
<CollapsibleSection title="BOM成本结构Top15" subtitle="按理论成本排序的产成品BOM汇总">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={bomChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
|
||||
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Bar dataKey="理论成本" fill="#1677ff" />
|
||||
<Bar dataKey="领用成本" fill="#722ed1" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">产品编码</th>
|
||||
<th className="pb-2 pr-3">产品名称</th>
|
||||
<th className="pb-2 pr-3 text-right">材料数</th>
|
||||
<th className="pb-2 pr-3 text-right">半成品数</th>
|
||||
<th className="pb-2 pr-3 text-right">理论成本</th>
|
||||
<th className="pb-2 pr-3 text-right">领用成本</th>
|
||||
<th className="pb-2 pr-3 text-right">单位理论成本</th>
|
||||
<th className="pb-2 pr-3 text-right">单位领用成本</th>
|
||||
<th className="pb-2 pr-3 text-right">差异金额</th>
|
||||
<th className="pb-2 pr-3 text-right">差异率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bomSummaryTop10.map((b: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50 cursor-pointer" onClick={() => setSelectedProduct(b.product_code)}>
|
||||
<td className="py-1.5 pr-3 text-xs">{b.product_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium text-blue-600">{b.product_name}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{b.material_count}</td>
|
||||
<td className="py-1.5 pr-3 text-right">
|
||||
{b.sub_product_count > 0 ? <span className="text-purple-600 font-medium">{b.sub_product_count}</span> : '-'}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_theoretical_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(b.bom_issue_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_theoretical_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(b.bom_unit_issue_cost)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right ${b.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(b.variance_amt)}
|
||||
</td>
|
||||
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColorClass(b.variance_pct)}`}>
|
||||
{varianceText(b.variance_pct)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* BOM成本Treemap */}
|
||||
<CollapsibleSection title="BOM成本分布Treemap" subtitle="按理论成本大小展示产成品BOM分布" defaultOpen={false}>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<Treemap
|
||||
data={treemapData}
|
||||
dataKey="size"
|
||||
stroke="#fff"
|
||||
fill="#722ed1"
|
||||
content={<CustomTreemapContent />}
|
||||
/>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* BOM树穿透 */}
|
||||
<CollapsibleSection
|
||||
title={`BOM树穿透 - ${productData?.productList?.find((p: any) => p.product_code === selectedProduct)?.product_name || selectedProduct}`}
|
||||
subtitle={`共${bomTree.length}个节点,最大${maxLevel}层`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm text-muted-foreground">点击下方产品可切换BOM树</span>
|
||||
</div>
|
||||
{productLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(treeByLevel).map(([level, nodes]) => (
|
||||
<div key={level} className="rounded-lg border p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm font-medium">Level {level}</span>
|
||||
<span className="text-xs text-muted-foreground">({nodes.length}个物料)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
|
||||
{nodes.map((node: any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`rounded border p-2 ${node.is_finished_product ? 'border-purple-300 bg-purple-50/50' : 'border-gray-200'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">{node.material_name}</span>
|
||||
{node.is_finished_product && (
|
||||
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-600">半成品</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>编码: {node.material_code}</span>
|
||||
<span>单位: {node.unit}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs">
|
||||
<span>理论: {formatCurrency(node.theoretical_amt)}</span>
|
||||
<span>领用: {formatCurrency(node.issue_amt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>理论量: {formatNumber(node.theoretical_qty)}</span>
|
||||
<span>领用量: {formatNumber(node.issue_qty)}</span>
|
||||
</div>
|
||||
{node.is_finished_product && (
|
||||
<button
|
||||
className="mt-1 text-xs text-purple-600 hover:underline"
|
||||
onClick={() => setSelectedProduct(node.material_code)}
|
||||
>
|
||||
展开此半成品BOM →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{bomTree.length === 0 && (
|
||||
<div className="py-4 text-center text-muted-foreground">该产品无配方数据</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 产品选择器 */}
|
||||
<CollapsibleSection title="产品列表" subtitle={`${filteredProducts.length}个产品`} defaultOpen={false}
|
||||
headerRight={
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索产品..."
|
||||
value={productFilter}
|
||||
onChange={(e) => setProductFilter(e.target.value)}
|
||||
className="rounded border px-3 py-1 text-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">产品编码</th>
|
||||
<th className="pb-2 pr-3">产品名称</th>
|
||||
<th className="pb-2 pr-3">品类</th>
|
||||
<th className="pb-2 pr-3 text-right">入库量</th>
|
||||
<th className="pb-2 pr-3 text-right">理论成本</th>
|
||||
<th className="pb-2 pr-3 text-right">实际成本</th>
|
||||
<th className="pb-2 pr-3 text-right">材料数</th>
|
||||
<th className="pb-2 pr-3 text-center">多级BOM</th>
|
||||
<th className="pb-2 pr-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredProducts.map((p: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 text-xs">{p.product_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium">{p.product_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{p.category_minor}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(p.inbound_quantity)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.theoretical_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.actual_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{p.material_count}</td>
|
||||
<td className="py-1.5 pr-3 text-center">
|
||||
{p.has_multi_level_bom ? <span className="text-purple-600">✓</span> : '-'}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<button
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
onClick={() => setSelectedProduct(p.product_code)}
|
||||
>
|
||||
查看BOM
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 半成品依赖链 */}
|
||||
<CollapsibleSection title="半成品依赖链" subtitle={`${multiLevelChains.length}条成品→半成品依赖关系`} defaultOpen={false}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">成品编码</th>
|
||||
<th className="pb-2 pr-3">成品名称</th>
|
||||
<th className="pb-2 pr-3">成品配方</th>
|
||||
<th className="pb-2 pr-3"></th>
|
||||
<th className="pb-2 pr-3">半成品编码</th>
|
||||
<th className="pb-2 pr-3">半成品名称</th>
|
||||
<th className="pb-2 pr-3">半成品配方</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{multiLevelChains.map((c: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 text-xs">{c.finished_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium">{c.finished_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.finished_recipe}</td>
|
||||
<td className="py-1.5 pr-3"><ChevronRight className="h-4 w-4 text-purple-600" /></td>
|
||||
<td className="py-1.5 pr-3 text-xs">{c.sub_product_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium text-purple-600">{c.sub_product_finished_name || c.sub_product_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{c.sub_product_recipe || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomTreemapContent(props: any) {
|
||||
const { x, y, width, height, name, size, variance } = props
|
||||
if (width < 50 || height < 30) return null
|
||||
const fill = Math.abs(variance || 0) <= 3 ? '#52c41a' : Math.abs(variance || 0) <= 10 ? '#faad14' : '#f5222d'
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={width} height={height} stroke="#fff" fill={fill} fillOpacity={0.7} />
|
||||
<text x={x + 4} y={y + 16} fontSize={11} fill="#333">
|
||||
{name?.length > 10 ? name.slice(0, 10) + '...' : name}
|
||||
</text>
|
||||
<text x={x + 4} y={y + 30} fontSize={10} fill="#666">
|
||||
¥{(size / 10000).toFixed(1)}万
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, LineChart, Line, PieChart, Pie, Legend, ComposedChart } from 'recharts'
|
||||
@@ -6,10 +7,61 @@ import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt } from 'lucide-react'
|
||||
import { Crown, TrendingDown, AlertTriangle, TrendingUp, Building2, Receipt, Target, ChevronDown } from 'lucide-react'
|
||||
|
||||
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
|
||||
|
||||
function ProfitOppItem({ index, o, opp, pct, confColor }: { index: number; o: any; opp: number; pct: number; confColor: string }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
return (
|
||||
<div className={`rounded-lg border transition-all ${expanded ? 'border-purple-300 shadow-sm' : ''}`}>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-3 p-3"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-100 text-sm font-bold text-purple-600">{index + 1}</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{o.category}</span>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${confColor}`}>{o.confidence}</span>
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
基线: {typeof o.baseline === 'number' ? (o.baseline < 100 ? formatPercent(o.baseline) : formatNumber(o.baseline)) : o.baseline}
|
||||
{typeof o.baseline === 'number' && o.baseline < 100 ? '%' : ''} · 责任: {o.owner} · 验收: {o.evidence}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-green-600">{formatCurrency(opp)}</p>
|
||||
<p className="text-[10px] text-muted-foreground">占比 {formatPercent(pct)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && o.detail && (
|
||||
<div className="border-t bg-purple-50/20 px-4 py-3">
|
||||
{o.detail.split('\n').map((line: string, idx: number) => {
|
||||
const isHeader = line.endsWith(':') || line.endsWith(':')
|
||||
const isAction = line.startsWith('行动指向') || /^\d)/.test(line)
|
||||
const isStoreLine = line.includes('费率') || line.includes('差异') || line.includes('优惠率') || line.includes('佣金') || line.includes('收入')
|
||||
return (
|
||||
<p
|
||||
key={idx}
|
||||
className={`text-xs leading-relaxed ${
|
||||
isHeader ? 'mt-2 font-semibold text-foreground' :
|
||||
isAction ? 'text-foreground' :
|
||||
isStoreLine ? 'text-muted-foreground' :
|
||||
'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{line || '\u00A0'}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BossPage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -49,6 +101,10 @@ export function BossPage() {
|
||||
queryKey: ['overview/store-profit-ranking'],
|
||||
queryFn: () => api.get('/overview/store-profit-ranking'),
|
||||
})
|
||||
const { data: profitOppData } = useQuery({
|
||||
queryKey: ['overview/profit-opportunity'],
|
||||
queryFn: () => api.get('/overview/profit-opportunity'),
|
||||
})
|
||||
|
||||
const pageLoading = odLoading || dailyLoading || exLoading || wfLoading || riskLoading || priorityLoading
|
||||
|
||||
@@ -61,6 +117,8 @@ export function BossPage() {
|
||||
const costOv = (costOverview as any)?.data || {}
|
||||
const structRows = (expenseStructure as any)?.data || []
|
||||
const profitRanking = (profitRankingData as any)?.data || []
|
||||
const profitOpp = (profitOppData as any)?.data?.items || []
|
||||
const totalOpportunity = profitOpp.reduce((s: number, o: any) => s + Number(o.opportunity || 0), 0)
|
||||
|
||||
// 风险分布
|
||||
const riskSummary = riskRows.reduce((acc: any, r: any) => {
|
||||
@@ -101,7 +159,7 @@ export function BossPage() {
|
||||
{ name: '宿舍', value: -Number(wf.dorm), type: 'cost' },
|
||||
{ name: '外卖佣金', value: -Number(wf.commission), type: 'cost' },
|
||||
{ name: '其他费用', value: -Number(wf.other_expense), type: 'cost' },
|
||||
{ name: '净利润', value: Number(wf.net_profit), type: 'profit' },
|
||||
{ name: '门店贡献利润', value: Number(wf.store_contribution), type: 'profit' },
|
||||
] : []
|
||||
|
||||
// 瀑布图累计计算
|
||||
@@ -156,14 +214,14 @@ export function BossPage() {
|
||||
{/* ① 核心经营指标 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="实收总额" value={ex?.total_received} format="currency" trend={trendReceived} description={`全口径${ex?.total_stores}家门店。公式:全部门店实收合计。环比上周 ${trendReceived > 0 ? '↑' : '↓'} ${Math.abs(trendReceived)}%`} />
|
||||
<MetricCard title="实际净利润" value={ex?.actual_net_profit} format="currency" status={Number(ex?.actual_net_margin_pct) < 5 ? 'bad' : Number(ex?.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`净利率 ${formatPercent(ex?.actual_net_margin_pct)} · 公式:实收 - 食材成本 - 经营费用。建议值:≥ 10% 优秀,5-10% 合格,< 5% 需整改`} />
|
||||
<MetricCard title="门店贡献利润估算" value={ex?.actual_net_profit} format="currency" status={Number(ex?.actual_net_margin_pct) < 5 ? 'bad' : Number(ex?.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`贡献率 ${formatPercent(ex?.actual_net_margin_pct)} · 公式:实收 - 食材成本 - 经营费用(仅费用已匹配门店)。建议值:≥ 10% 优秀,5-10% 合格,< 5% 需整改`} />
|
||||
<MetricCard title="门店数" value={ex?.total_stores} unit="家" description={`盈利 ${ex?.profitable_stores}家 / 亏损 ${ex?.loss_stores}家。无费用数据 ${ex?.no_expense_stores}家`} />
|
||||
<MetricCard title="客单价" value={ex?.avg_bill_value} format="currency" trend={trend((last7.length > 0 ? sumKey(last7, 'received') / sumKey(last7, 'bill_count') : 0), (prev7.length > 0 ? sumKey(prev7, 'received') / sumKey(prev7, 'bill_count') : 0))} description={`账单 ${formatNumber(ex?.total_bills)} 笔 · 公式:实收 ÷ 账单数`} />
|
||||
</div>
|
||||
|
||||
{/* ② 利润瀑布 */}
|
||||
{wf && (
|
||||
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 净利润">
|
||||
<CollapsibleSection title="利润结构" subtitle="实收 → 减各项成本费用 → 门店贡献利润估算">
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={waterfallChart} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
@@ -189,8 +247,8 @@ export function BossPage() {
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded border border-green-200 bg-green-50/40 p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">净利润</p>
|
||||
<p className="text-sm font-bold text-green-700">{formatCurrency(wf.net_profit)}</p>
|
||||
<p className="text-xs text-muted-foreground">门店贡献利润</p>
|
||||
<p className="text-sm font-bold text-green-700">{formatCurrency(wf.store_contribution)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 成本费用占比汇总条 */}
|
||||
@@ -213,20 +271,46 @@ export function BossPage() {
|
||||
<div className="flex items-center justify-center bg-slate-400" style={{ width: `${(Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100}%` }} title={`其他费用 ${formatPercent((Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100)}`}>
|
||||
其他{formatPercent((Number(wf.dorm) + Number(wf.commission) + Number(wf.other_expense)) / Number(wf.received) * 100)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center bg-green-500" style={{ width: `${Number(wf.net_profit) / Number(wf.received) * 100}%` }} title={`净利润 ${formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)}`}>
|
||||
利润{formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)}
|
||||
<div className="flex items-center justify-center bg-green-500" style={{ width: `${Number(wf.store_contribution) / Number(wf.received) * 100}%` }} title={`门店贡献利润 ${formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}`}>
|
||||
利润{formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
|
||||
<span>食材成本率 {formatPercent(Number(wf.food_cost) / Number(wf.received) * 100)}(建议 ≤ 32%)</span>
|
||||
<span>费用率 {formatPercent(Number(wf.total_expense) / Number(wf.received) * 100)}(建议 ≤ 40%)</span>
|
||||
<span>净利率 {formatPercent(Number(wf.net_profit) / Number(wf.received) * 100)}(建议 ≥ 10%)</span>
|
||||
<span>贡献率 {formatPercent(Number(wf.store_contribution) / Number(wf.received) * 100)}(建议 ≥ 10%)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* ②b 利润机会池 */}
|
||||
{profitOpp.length > 0 && (
|
||||
<CollapsibleSection title="利润机会池" subtitle={`理论月度机会合计 ${formatCurrency(totalOpportunity)} · 30天承诺值 ≥ 150万元 · 点击展开详情`}>
|
||||
<div className="space-y-2">
|
||||
{[...profitOpp].sort((a: any, b: any) => Number(b.opportunity || 0) - Number(a.opportunity || 0)).map((o: any, i: number) => {
|
||||
const opp = Number(o.opportunity || 0)
|
||||
const pct = totalOpportunity > 0 ? (opp / totalOpportunity * 100) : 0
|
||||
const confColor = o.confidence === '中高' ? 'text-green-600 bg-green-50' : o.confidence === '中' ? 'text-blue-600 bg-blue-50' : 'text-yellow-600 bg-yellow-50'
|
||||
return (
|
||||
<ProfitOppItem key={i} index={i} o={o} opp={opp} pct={pct} confColor={confColor} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 rounded-lg border border-purple-200 bg-purple-50/30 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm font-medium">30天整改目标</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
理论月度机会合计 {formatCurrency(totalOpportunity)},考虑项目重叠和促销弹性,经营承诺值取 <span className="font-bold text-purple-600">≥ 150万元</span>。
|
||||
标准门店贡献率目标从 7.29% 提升至约 10%。
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* ③ 风险态势 */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className={`rounded-lg border p-4 ${riskSummary['红色'] > 15 ? 'border-red-200 bg-red-50/40' : 'border-yellow-200 bg-yellow-50/40'}`}>
|
||||
@@ -358,7 +442,7 @@ export function BossPage() {
|
||||
|
||||
{/* ⑤b 门店利润排名 */}
|
||||
{profitRanking.length > 0 && (
|
||||
<CollapsibleSection title="门店利润排名" subtitle="TOP5 最赚钱 · BOTTOM5 亏损最多 · 公式:实收 - 食材成本 - 经营费用">
|
||||
<CollapsibleSection title="门店利润排名" subtitle="TOP5 贡献最高 · BOTTOM5 亏损最多 · 公式:实收 - 食材成本 - 经营费用(仅费用已匹配门店)">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{/* TOP5 */}
|
||||
<div>
|
||||
@@ -373,9 +457,9 @@ export function BossPage() {
|
||||
<span className="text-lg font-bold text-green-600">{i + 1}</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{s.store_name}</p>
|
||||
<p className="text-xs text-muted-foreground">实收 {formatCurrency(s.received)} · 净利率 {formatPercent(s.net_margin_pct)}</p>
|
||||
<p className="text-xs text-muted-foreground">实收 {formatCurrency(s.received)} · 贡献率 {formatPercent(s.contribution_margin_pct)}</p>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-green-700">{formatCurrency(s.net_profit)}</span>
|
||||
<span className="text-lg font-bold text-green-700">{formatCurrency(s.store_contribution)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -384,7 +468,7 @@ export function BossPage() {
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-bold text-red-600">亏损 BOTTOM5</p>
|
||||
<div className="space-y-2">
|
||||
{profitRanking.filter((s: any) => Number(s.net_profit) < 0).slice(-5).reverse().map((s: any, i: number) => (
|
||||
{profitRanking.filter((s: any) => Number(s.store_contribution) < 0).slice(-5).reverse().map((s: any, i: number) => (
|
||||
<div
|
||||
key={s.store_code}
|
||||
className="flex cursor-pointer items-center gap-3 rounded-lg border border-red-200 bg-red-50/30 p-3 hover:bg-red-50/60"
|
||||
@@ -393,9 +477,9 @@ export function BossPage() {
|
||||
<span className="text-lg font-bold text-red-600">{i + 1}</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{s.store_name}</p>
|
||||
<p className="text-xs text-muted-foreground">实收 {formatCurrency(s.received)} · 净利率 {formatPercent(s.net_margin_pct)}</p>
|
||||
<p className="text-xs text-muted-foreground">实收 {formatCurrency(s.received)} · 贡献率 {formatPercent(s.contribution_margin_pct)}</p>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-red-700">{formatCurrency(s.net_profit)}</span>
|
||||
<span className="text-lg font-bold text-red-700">{formatCurrency(s.store_contribution)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, ScatterChart, Scatter } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
|
||||
import { ChefHat, TrendingDown, TrendingUp, AlertTriangle, Package, Factory } from 'lucide-react'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
|
||||
const CATEGORY_COLORS = ['#1677ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#13c2c2', '#eb2f96', '#fa8c16', '#a0d911', '#2f54eb']
|
||||
|
||||
function varianceColor(pct: number): 'good' | 'warn' | 'bad' {
|
||||
if (Math.abs(pct) <= 3) return 'good'
|
||||
if (Math.abs(pct) <= 5) return 'warn'
|
||||
return 'bad'
|
||||
}
|
||||
|
||||
function varianceText(pct: number): string {
|
||||
if (pct > 0) return `+${pct.toFixed(2)}%`
|
||||
return `${pct.toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function CentralKitchenPage() {
|
||||
const [month, setMonth] = useState('2026-04')
|
||||
const [productFilter, setProductFilter] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['central-kitchen-dashboard', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/central-kitchen/dashboard?month=${month}`)
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
if (!data) return <div className="p-4 text-muted-foreground">暂无数据</div>
|
||||
|
||||
const { summary, reconciliation, products, categoryCost, recipeEfficiency, mfgPool, yieldAnalysis } = data
|
||||
|
||||
const filteredProducts = products.filter((p: any) =>
|
||||
!productFilter ||
|
||||
p.product_name?.includes(productFilter) ||
|
||||
p.product_code?.includes(productFilter) ||
|
||||
p.category_minor?.includes(productFilter)
|
||||
)
|
||||
|
||||
// 成本对账瀑布数据
|
||||
const waterfallData = [
|
||||
{ name: '理论成本', value: reconciliation.theoretical_cost, type: 'base' },
|
||||
{ name: '标准成本', value: reconciliation.standard_cost, type: 'base' },
|
||||
{ name: '实际材料', value: reconciliation.material_actual_cost, type: 'base' },
|
||||
{ name: '制造费用', value: reconciliation.allocated_manufacturing_cost, type: 'add' },
|
||||
{ name: '全制造成本', value: reconciliation.full_manufacturing_cost, type: 'total' },
|
||||
{ name: '入库价值', value: reconciliation.calculated_inbound_value, type: 'base' },
|
||||
{ name: '制造毛利', value: reconciliation.manufacturing_margin, type: 'margin' },
|
||||
]
|
||||
|
||||
// 品类成本堆叠数据
|
||||
const categoryStackData = categoryCost.map((c: any) => ({
|
||||
name: c.category_minor,
|
||||
材料成本: c.material_actual_cost,
|
||||
制造费用: c.allocated_mfg_cost,
|
||||
}))
|
||||
|
||||
// 产品成本偏差散点图
|
||||
const scatterData = products.map((p: any) => ({
|
||||
name: p.product_name,
|
||||
x: p.theoretical_cost,
|
||||
y: p.material_actual_cost,
|
||||
z: p.efficiency_variance_pct,
|
||||
category: p.category_minor,
|
||||
})).filter((d: any) => d.x > 0 && d.y > 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<ChefHat className="h-6 w-6 text-blue-600" />
|
||||
<h1 className="text-xl font-bold">中央厨房成本驾驶舱</h1>
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
</div>
|
||||
|
||||
{/* 核心指标卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-7">
|
||||
<MetricCard title="产品数" value={summary.product_count} unit="个" status="good" />
|
||||
<MetricCard title="完工入库量" value={summary.total_inbound_qty} unit={products[0]?.unit || ''} format="number" />
|
||||
<MetricCard title="理论材料成本" value={summary.theoretical_cost} format="currency" />
|
||||
<MetricCard title="实际材料成本" value={summary.material_actual_cost} format="currency" status={varianceColor(summary.efficiency_variance_pct)} />
|
||||
<MetricCard title="完整制造成本" value={summary.full_manufacturing_cost} format="currency" />
|
||||
<MetricCard title="材料效率差异" value={varianceText(summary.efficiency_variance_pct)} status={varianceColor(summary.efficiency_variance_pct)} description="(实际-理论)/理论×100%" />
|
||||
<MetricCard title="制造费用率" value={summary.mfg_cost_rate} unit="%" status={summary.mfg_cost_rate <= 5 ? 'good' : 'warn'} description="制造费用/实际材料成本" />
|
||||
</div>
|
||||
|
||||
{/* 成本对账瀑布 */}
|
||||
<CollapsibleSection title="成本对账瀑布" subtitle="理论→标准→实际→+制造费用→全成本→入库价值→制造毛利">
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
<MetricCard title="理论成本" value={reconciliation.theoretical_cost} format="currency" />
|
||||
<MetricCard title="标准成本" value={reconciliation.standard_cost} format="currency" />
|
||||
<MetricCard title="实际材料" value={reconciliation.material_actual_cost} format="currency" status={varianceColor(summary.efficiency_variance_pct)} />
|
||||
<MetricCard title="制造费用池" value={reconciliation.manufacturing_cost_pool} format="currency" />
|
||||
<MetricCard title="全制造成本" value={reconciliation.full_manufacturing_cost} format="currency" />
|
||||
<MetricCard title="制造毛利" value={reconciliation.manufacturing_margin} format="currency" status={reconciliation.manufacturing_margin >= 0 ? 'good' : 'bad'} description="入库价值-全制造成本" />
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={waterfallData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<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]}>
|
||||
{waterfallData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={
|
||||
entry.type === 'total' ? '#1677ff' :
|
||||
entry.type === 'add' ? '#faad14' :
|
||||
entry.type === 'margin' ? (entry.value >= 0 ? '#52c41a' : '#f5222d') :
|
||||
'#8ec6ff'
|
||||
} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 品类成本结构 */}
|
||||
<CollapsibleSection title="品类成本结构" subtitle="按品类展示材料成本与制造费用分布">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={categoryStackData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
|
||||
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Bar dataKey="材料成本" stackId="a" fill="#1677ff" />
|
||||
<Bar dataKey="制造费用" stackId="a" fill="#faad14" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-4">品类</th>
|
||||
<th className="pb-2 pr-4 text-right">产品数</th>
|
||||
<th className="pb-2 pr-4 text-right">入库量</th>
|
||||
<th className="pb-2 pr-4 text-right">理论成本</th>
|
||||
<th className="pb-2 pr-4 text-right">实际材料</th>
|
||||
<th className="pb-2 pr-4 text-right">制造费用</th>
|
||||
<th className="pb-2 pr-4 text-right">全成本</th>
|
||||
<th className="pb-2 pr-4 text-right">效率差异</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categoryCost.map((c: any, i: number) => (
|
||||
<tr key={i} className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">{c.category_minor}</td>
|
||||
<td className="py-2 pr-4 text-right">{c.product_count}</td>
|
||||
<td className="py-2 pr-4 text-right">{formatNumber(c.total_qty)}</td>
|
||||
<td className="py-2 pr-4 text-right">{formatCurrency(c.theoretical_cost)}</td>
|
||||
<td className="py-2 pr-4 text-right">{formatCurrency(c.material_actual_cost)}</td>
|
||||
<td className="py-2 pr-4 text-right">{formatCurrency(c.allocated_mfg_cost)}</td>
|
||||
<td className="py-2 pr-4 text-right font-medium">{formatCurrency(c.full_cost)}</td>
|
||||
<td className={`py-2 pr-4 text-right font-medium ${varianceColor(c.efficiency_var_pct) === 'good' ? 'text-green-600' : varianceColor(c.efficiency_var_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{varianceText(c.efficiency_var_pct)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 产品成本偏差散点图 */}
|
||||
<CollapsibleSection title="产品成本偏差散点图" subtitle="X=理论成本,Y=实际成本,颜色=偏差率" defaultOpen={false}>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<ScatterChart margin={{ top: 20, right: 30, left: 20, bottom: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" dataKey="x" name="理论成本" tickFormatter={(v) => `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} label={{ value: '理论成本', position: 'insideBottom', offset: -5, fontSize: 11 }} />
|
||||
<YAxis type="number" dataKey="y" name="实际成本" tickFormatter={(v) => `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} label={{ value: '实际成本', angle: -90, position: 'insideLeft', fontSize: 11 }} />
|
||||
<Tooltip cursor={{ strokeDasharray: '3 3' }} formatter={(v: any, name: any) => name === 'x' || name === 'y' ? formatCurrency(v) : v} labelFormatter={() => ''} />
|
||||
<Scatter data={scatterData}>
|
||||
{scatterData.map((entry: any, idx: number) => (
|
||||
<Cell key={idx} fill={
|
||||
entry.z <= -5 ? '#52c41a' :
|
||||
entry.z >= 5 ? '#f5222d' :
|
||||
entry.z >= 0 ? '#faad14' : '#1677ff'
|
||||
} />
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-green-500" /> 节约(≤-5%)</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-blue-500" /> 正常(-5%~0%)</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-yellow-500" /> 轻微超耗(0~5%)</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-red-500" /> 严重超耗(≥5%)</span>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 产品明细表 */}
|
||||
<CollapsibleSection
|
||||
title="产品成本明细"
|
||||
subtitle={`${filteredProducts.length}个产品`}
|
||||
headerRight={
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索产品名/编码/品类..."
|
||||
value={productFilter}
|
||||
onChange={(e) => setProductFilter(e.target.value)}
|
||||
className="rounded border px-3 py-1 text-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">编码</th>
|
||||
<th className="pb-2 pr-3">产品名</th>
|
||||
<th className="pb-2 pr-3">品类</th>
|
||||
<th className="pb-2 pr-3 text-right">入库量</th>
|
||||
<th className="pb-2 pr-3 text-right">理论成本</th>
|
||||
<th className="pb-2 pr-3 text-right">标准成本</th>
|
||||
<th className="pb-2 pr-3 text-right">实际材料</th>
|
||||
<th className="pb-2 pr-3 text-right">制造费用</th>
|
||||
<th className="pb-2 pr-3 text-right">全成本</th>
|
||||
<th className="pb-2 pr-3 text-right">单位成本</th>
|
||||
<th className="pb-2 pr-3 text-right">效率差异</th>
|
||||
<th className="pb-2 pr-3 text-right">标准差异</th>
|
||||
<th className="pb-2 pr-3 text-right">入库价值</th>
|
||||
<th className="pb-2 pr-3 text-right">产品毛利</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredProducts.map((p: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 text-xs">{p.product_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium">{p.product_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{p.category_minor}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(p.inbound_quantity)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.theoretical_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.standard_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.material_actual_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.allocated_manufacturing_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right font-medium">{formatCurrency(p.full_manufacturing_cost)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(p.full_unit_cost)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right text-xs ${varianceColor(p.efficiency_variance_pct) === 'good' ? 'text-green-600' : varianceColor(p.efficiency_variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{varianceText(p.efficiency_variance_pct)}
|
||||
</td>
|
||||
<td className={`py-1.5 pr-3 text-right text-xs ${varianceColor(p.standard_variance_pct) === 'good' ? 'text-green-600' : varianceColor(p.standard_variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{varianceText(p.standard_variance_pct)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(p.inbound_value)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right ${p.product_margin >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(p.product_margin)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 配方效率分析 */}
|
||||
<CollapsibleSection title="配方效率分析 TOP20" subtitle="按材料效率差异绝对值排序,识别超耗最严重的配方" defaultOpen={false}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">配方</th>
|
||||
<th className="pb-2 pr-3">物料</th>
|
||||
<th className="pb-2 pr-3">规格</th>
|
||||
<th className="pb-2 pr-3 text-right">理论用量</th>
|
||||
<th className="pb-2 pr-3 text-right">实际用量</th>
|
||||
<th className="pb-2 pr-3 text-right">理论金额</th>
|
||||
<th className="pb-2 pr-3 text-right">实际金额</th>
|
||||
<th className="pb-2 pr-3 text-right">金额差异率</th>
|
||||
<th className="pb-2 pr-3 text-right">实际出成率</th>
|
||||
<th className="pb-2 pr-3 text-right">标准出成率</th>
|
||||
<th className="pb-2 pr-3 text-right">出成差异</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recipeEfficiency.map((r: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 font-medium">{r.recipe_name}</td>
|
||||
<td className="py-1.5 pr-3">{r.item_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{r.specification}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(r.theoretical_qty)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(r.actual_qty)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(r.theoretical_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(r.actual_amt)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColor(r.variance_pct) === 'good' ? 'text-green-600' : varianceColor(r.variance_pct) === 'warn' ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{varianceText(r.variance_pct)}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right">{r.avg_actual_yield ? `${(r.avg_actual_yield * 100).toFixed(2)}%` : '-'}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{r.avg_recipe_yield ? `${(r.avg_recipe_yield * 100).toFixed(2)}%` : '-'}</td>
|
||||
<td className={`py-1.5 pr-3 text-right text-xs ${r.yield_diff < -0.03 ? 'text-red-600' : r.yield_diff > 0.03 ? 'text-green-600' : ''}`}>
|
||||
{r.yield_diff ? `${(r.yield_diff * 100).toFixed(2)}pct` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 出成率分析 */}
|
||||
<CollapsibleSection title="出成率分析 TOP20" subtitle="按预期偏差率排序,识别产出异常产品" defaultOpen={false}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">配方</th>
|
||||
<th className="pb-2 pr-3">产品名</th>
|
||||
<th className="pb-2 pr-3 text-right">理论入库量</th>
|
||||
<th className="pb-2 pr-3 text-right">实际入库量</th>
|
||||
<th className="pb-2 pr-3 text-right">达成率</th>
|
||||
<th className="pb-2 pr-3 text-right">预期偏差率</th>
|
||||
<th className="pb-2 pr-3 text-right">入库金额</th>
|
||||
<th className="pb-2 pr-3 text-right">退库量</th>
|
||||
<th className="pb-2 pr-3 text-right">退库率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{yieldAnalysis.map((y: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 font-medium">{y.recipe_name}</td>
|
||||
<td className="py-1.5 pr-3">{y.product_name}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(y.theoretical_inbound_qty)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(y.actual_inbound_qty)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right ${y.avg_achievement_rate >= 0.95 ? 'text-green-600' : y.avg_achievement_rate >= 0.9 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{y.avg_achievement_rate ? `${(y.avg_achievement_rate * 100).toFixed(2)}%` : '-'}
|
||||
</td>
|
||||
<td className={`py-1.5 pr-3 text-right text-xs ${y.avg_expected_var_rate < -0.03 ? 'text-red-600' : y.avg_expected_var_rate > 0.03 ? 'text-green-600' : ''}`}>
|
||||
{y.avg_expected_var_rate ? `${(y.avg_expected_var_rate * 100).toFixed(2)}%` : '-'}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(y.total_inbound_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(y.total_return_qty)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right ${y.return_rate > 1 ? 'text-red-600' : ''}`}>
|
||||
{y.return_rate ? `${y.return_rate.toFixed(2)}%` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 制造费用池 */}
|
||||
<CollapsibleSection title="制造费用池明细" subtitle="制造费用归集与分摊方法" defaultOpen={false}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">费用类型</th>
|
||||
<th className="pb-2 pr-3">费用子类</th>
|
||||
<th className="pb-2 pr-3">来源</th>
|
||||
<th className="pb-2 pr-3 text-right">原始金额</th>
|
||||
<th className="pb-2 pr-3 text-right">分摊比例</th>
|
||||
<th className="pb-2 pr-3 text-right">分摊金额</th>
|
||||
<th className="pb-2 pr-3">分摊方法</th>
|
||||
<th className="pb-2 pr-3">计入重建</th>
|
||||
<th className="pb-2 pr-3">临时</th>
|
||||
<th className="pb-2 pr-3">备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mfgPool.map((m: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 font-medium">{m.cost_type}</td>
|
||||
<td className="py-1.5 pr-3">{m.cost_subtype}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{m.source_type}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(m.source_amount)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{m.share_pct ? `${(m.share_pct * 100).toFixed(2)}%` : '-'}</td>
|
||||
<td className="py-1.5 pr-3 text-right font-medium">{formatCurrency(m.allocated_amount)}</td>
|
||||
<td className="py-1.5 pr-3 text-xs">{m.allocation_method}</td>
|
||||
<td className="py-1.5 pr-3 text-center">{m.include_in_rebuilt_cost ? '✅' : '❌'}</td>
|
||||
<td className="py-1.5 pr-3 text-center">
|
||||
{m.is_provisional && <AlertTriangle className="inline h-4 w-4 text-yellow-500" />}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground max-w-xs">{m.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-yellow-50 p-3 text-xs text-yellow-800">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>当前制造费用分摊为临时方法(按人数比例),待电表、面积或工时数据后替换为更精确的分摊基准。</span>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart } from 'recharts'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, PieChart, Pie, Cell, Legend, ScatterChart, Scatter, ZAxis, ReferenceLine, ComposedChart, Area, AreaChart } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { Badge } from '@/components/Badge'
|
||||
@@ -8,8 +8,10 @@ import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
|
||||
const RISK_COLORS = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
|
||||
const PAGE_SIZE = 10
|
||||
@@ -17,25 +19,26 @@ const PAGE_SIZE = 10
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [priorityPage, setPriorityPage] = useState(1)
|
||||
const [month, setMonth] = useState('2026-04')
|
||||
|
||||
const { data: overview, isLoading: odLoading } = useQuery({
|
||||
queryKey: ['overview'],
|
||||
queryFn: () => api.get('/overview'),
|
||||
queryKey: ['overview', month],
|
||||
queryFn: () => api.get('/overview', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: daily, isLoading: dailyLoading } = useQuery({
|
||||
queryKey: ['overview/daily'],
|
||||
queryFn: () => api.get('/overview/daily'),
|
||||
queryKey: ['overview/daily', month],
|
||||
queryFn: () => api.get('/overview/daily', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: riskData, isLoading: riskLoading } = useQuery({
|
||||
queryKey: ['stores/risk'],
|
||||
queryFn: () => api.get('/stores/risk'),
|
||||
queryKey: ['stores/risk', month],
|
||||
queryFn: () => api.get('/stores/risk', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: priorityData, isLoading: priorityLoading } = useQuery({
|
||||
queryKey: ['stores/priority'],
|
||||
queryFn: () => api.get('/stores/priority'),
|
||||
queryKey: ['stores/priority', month],
|
||||
queryFn: () => api.get('/stores/priority', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: loopHealth, isLoading: loopLoading } = useQuery({
|
||||
@@ -44,8 +47,8 @@ export function DashboardPage() {
|
||||
})
|
||||
|
||||
const { data: quadrantData } = useQuery({
|
||||
queryKey: ['stores/quadrant'],
|
||||
queryFn: () => api.get('/stores/quadrant'),
|
||||
queryKey: ['stores/quadrant', month],
|
||||
queryFn: () => api.get('/stores/quadrant', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: platformData } = useQuery({
|
||||
@@ -59,8 +62,28 @@ export function DashboardPage() {
|
||||
})
|
||||
|
||||
const { data: expenseData } = useQuery({
|
||||
queryKey: ['store-expense/overview'],
|
||||
queryFn: () => api.get('/store-expense/overview'),
|
||||
queryKey: ['store-expense/overview', month],
|
||||
queryFn: () => api.get('/store-expense/overview', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: yoyData } = useQuery({
|
||||
queryKey: ['overview/yoy', month],
|
||||
queryFn: () => api.get('/overview/yoy', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: momData } = useQuery({
|
||||
queryKey: ['overview/mom', month],
|
||||
queryFn: () => api.get('/overview/mom', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: trendData } = useQuery({
|
||||
queryKey: ['overview/trend', month],
|
||||
queryFn: () => api.get('/overview/trend', { params: { month, months: 12 } }),
|
||||
})
|
||||
|
||||
const { data: profitTrendData } = useQuery({
|
||||
queryKey: ['overview/profit-trend', month],
|
||||
queryFn: () => api.get('/overview/profit-trend', { params: { month, months: 6 } }),
|
||||
})
|
||||
|
||||
const pageLoading = odLoading || dailyLoading || riskLoading || priorityLoading || loopLoading
|
||||
@@ -75,6 +98,26 @@ export function DashboardPage() {
|
||||
const platformRows = (platformData as any)?.data || []
|
||||
const alerts = ((alertsData as any)?.data?.alerts || []) as any[]
|
||||
const alertSummary = (alertsData as any)?.data
|
||||
const yoy = (yoyData as any)?.data
|
||||
const mom = (momData as any)?.data
|
||||
const trendRows = ((trendData as any)?.data || []).map((r: any) => ({
|
||||
...r,
|
||||
month: r.month?.substring(0, 10) || r.month,
|
||||
}))
|
||||
const profitTrendRows = ((profitTrendData as any)?.data || []).map((r: any) => ({
|
||||
...r,
|
||||
report_month: r.report_month?.substring(0, 10) || r.report_month,
|
||||
}))
|
||||
|
||||
const yoyData_ = yoy?.yoy
|
||||
const momData_ = mom?.mom
|
||||
const TrendIcon = ({ val }: { val: number | null }) => {
|
||||
if (val === null || val === undefined) return <Minus size={14} className="text-muted-foreground" />
|
||||
if (val > 0) return <TrendingUp size={14} className="text-green-600" />
|
||||
if (val < 0) return <TrendingDown size={14} className="text-red-600" />
|
||||
return <Minus size={14} className="text-muted-foreground" />
|
||||
}
|
||||
const fmtChange = (v: number | null) => v === null ? '-' : `${v > 0 ? '+' : ''}${v.toFixed(2)}%`
|
||||
|
||||
const QUADRANT_COLORS: Record<string, string> = {
|
||||
'明星门店': '#22c55e', '稳健经营': '#3b82f6', '规模承压': '#eab308', '重点改善': '#ef4444', '高效潜力': '#a855f7',
|
||||
@@ -159,13 +202,16 @@ export function DashboardPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">总部驾驶舱</h1>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">全部门店经营总览 · 2026年4月</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">全部门店经营总览 · {month}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="rounded-md bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">{totalStores} 家门店</span>
|
||||
<span className="rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700">红色 {riskSummary['红色'] || 0}</span>
|
||||
<span className="rounded-md bg-yellow-50 px-3 py-1 text-xs font-medium text-yellow-700">黄色 {riskSummary['黄色'] || 0}</span>
|
||||
<span className="rounded-md bg-green-50 px-3 py-1 text-xs font-medium text-green-700">绿色 {riskSummary['绿色'] || 0}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
<div className="flex gap-2">
|
||||
<span className="rounded-md bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">{totalStores} 家门店</span>
|
||||
<span className="rounded-md bg-red-50 px-3 py-1 text-xs font-medium text-red-700">红色 {riskSummary['红色'] || 0}</span>
|
||||
<span className="rounded-md bg-yellow-50 px-3 py-1 text-xs font-medium text-yellow-700">黄色 {riskSummary['黄色'] || 0}</span>
|
||||
<span className="rounded-md bg-green-50 px-3 py-1 text-xs font-medium text-green-700">绿色 {riskSummary['绿色'] || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,12 +234,44 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 同比/环比对比 */}
|
||||
{(yoyData_ || momData_) && (
|
||||
<CollapsibleSection title="同比 / 环比对比" subtitle={`${month} 与去年同期、上月对比`}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{[
|
||||
{ label: '实收同比', yoy: yoyData_?.received_change_pct, mom: momData_?.received_change_pct },
|
||||
{ label: '账单数同比', yoy: yoyData_?.bill_count_change_pct, mom: momData_?.bill_count_change_pct },
|
||||
{ label: '客单价同比', yoy: yoyData_?.avg_bill_value_change_pct, mom: momData_?.avg_bill_value_change_pct },
|
||||
{ label: '优惠率变化', yoy: yoyData_?.discount_rate_change, mom: momData_?.discount_rate_change },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-md border p-3">
|
||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">同比</span>
|
||||
<TrendIcon val={item.yoy ?? null} />
|
||||
<span className={`text-sm font-bold ${item.yoy === null ? 'text-muted-foreground' : item.yoy > 0 ? 'text-green-600' : item.yoy < 0 ? 'text-red-600' : 'text-muted-foreground'}`}>
|
||||
{fmtChange(item.yoy ?? null)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">环比</span>
|
||||
<TrendIcon val={item.mom ?? null} />
|
||||
<span className={`text-sm font-bold ${item.mom === null ? 'text-muted-foreground' : item.mom > 0 ? 'text-green-600' : item.mom < 0 ? 'text-red-600' : 'text-muted-foreground'}`}>
|
||||
{fmtChange(item.mom ?? null)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* 利润与成本 */}
|
||||
{ex && (
|
||||
<CollapsibleSection title="利润与成本" subtitle="食材成本、经营费用、净利润等财务指标">
|
||||
<CollapsibleSection title="利润与成本" subtitle="食材成本、经营费用、门店贡献利润估算等财务指标">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="理论净利润" value={ex.theoretical_net_profit} format="currency" status="good" description={`净利率 ${formatPercent(ex.theoretical_net_margin_pct)} · 公式:实收 - 理论食材成本 - 经营费用。建议值:净利率 ≥ 15% 为健康`} />
|
||||
<MetricCard title="实际净利润" value={ex.actual_net_profit} format="currency" status={Number(ex.actual_net_margin_pct) < 5 ? 'bad' : Number(ex.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`净利率 ${formatPercent(ex.actual_net_margin_pct)} · 公式:实收 - 实际食材成本 - 经营费用。盈利 ${ex.profitable_stores}家 / 亏损 ${ex.loss_stores}家。建议值:净利率 ≥ 10% 为优秀,5-10% 为合格,< 5% 需整改`} />
|
||||
<MetricCard title="理论贡献利润" value={ex.theoretical_net_profit} format="currency" status="good" description={`贡献率 ${formatPercent(ex.theoretical_net_margin_pct)} · 公式:实收 - 理论食材成本 - 经营费用(仅费用已匹配门店)。建议值:贡献率 ≥ 15% 为健康`} />
|
||||
<MetricCard title="实际贡献利润" value={ex.actual_net_profit} format="currency" status={Number(ex.actual_net_margin_pct) < 5 ? 'bad' : Number(ex.actual_net_margin_pct) < 10 ? 'warn' : 'good'} description={`贡献率 ${formatPercent(ex.actual_net_margin_pct)} · 公式:实收 - 实际食材成本 - 经营费用(仅费用已匹配门店)。盈利 ${ex.profitable_stores}家 / 亏损 ${ex.loss_stores}家。建议值:贡献率 ≥ 10% 为优秀,5-10% 为合格,< 5% 需整改`} />
|
||||
<MetricCard title="食材成本" value={ex.total_food_cost} format="currency" status={Number(ex.actual_food_cost_rate_pct) - Number(ex.theoretical_food_cost_rate_pct) > 10 ? 'bad' : Number(ex.actual_food_cost_rate_pct) - Number(ex.theoretical_food_cost_rate_pct) > 5 ? 'warn' : 'good'} description={`成本率 ${formatPercent(ex.actual_food_cost_rate_pct)} · 理论成本率 ${formatPercent(ex.theoretical_food_cost_rate_pct)} · 差异 ${formatPercent(Number(ex.actual_food_cost_rate_pct) - Number(ex.theoretical_food_cost_rate_pct))}。公式:成本率 = 食材成本 ÷ 实收 × 100%。建议值:实际成本率 ≤ 32%,与理论差异 ≤ 3% 为正常`} />
|
||||
<MetricCard title="经营费用" value={ex.total_expense} format="currency" status={Number(ex.overall_expense_rate_pct) > 50 ? 'bad' : Number(ex.overall_expense_rate_pct) > 40 ? 'warn' : 'good'} description={`费用率 ${formatPercent(ex.overall_expense_rate_pct)} · 公式:费用率 = 经营费用 ÷ 实收 × 100%。人工 ${formatCurrency(ex.total_wage)} · 房租 ${formatCurrency(ex.total_rent)}。建议值:费用率 ≤ 40% 为健康,40-50% 需关注,> 50% 需整改`} />
|
||||
</div>
|
||||
@@ -368,6 +446,48 @@ export function DashboardPage() {
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* 月度趋势 & 利润趋势 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CollapsibleSection title="月度经营趋势" subtitle="近12个月实收与账单数变化">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<ComposedChart data={trendRows}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" tickFormatter={(v) => v?.substring(5, 7) || ''} tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="left" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(v) => v}
|
||||
formatter={(v: any, n: string) => n === '实收' ? formatCurrency(v) : formatNumber(v)}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
<Bar yAxisId="left" dataKey="bill_count" name="账单数" fill="#93c5fd" barSize={20} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="received" name="实收" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="利润趋势" subtitle="近6个月利润瀑布变化">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<ComposedChart data={profitTrendRows}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="report_month" tickFormatter={(v) => v?.substring(5, 7) || ''} tick={{ fontSize: 10 }} />
|
||||
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
labelFormatter={(v) => v}
|
||||
formatter={(v: any) => formatCurrency(v)}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 10 }} />
|
||||
<Bar dataKey="food_cost" name="食材成本" stackId="a" fill="#f97316" />
|
||||
<Bar dataKey="wage" name="人工" stackId="a" fill="#eab308" />
|
||||
<Bar dataKey="rent" name="房租" stackId="a" fill="#a855f7" />
|
||||
<Bar dataKey="utility" name="水电" stackId="a" fill="#06b6d4" />
|
||||
<Line type="monotone" dataKey="received" name="实收" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Line type="monotone" dataKey="store_contribution" name="贡献利润" stroke="#22c55e" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* P0/P1 门店列表 */}
|
||||
<CollapsibleSection
|
||||
title={`P0/P1 重点整改门店 (${p0p1Stores.length})`}
|
||||
|
||||
@@ -3,7 +3,7 @@ import api from '@/lib/api'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
import { formatNumber, formatCurrency } from '@/lib/utils'
|
||||
|
||||
export function DataQualityPage() {
|
||||
const { data: qualityData, isLoading } = useQuery({
|
||||
@@ -22,13 +22,26 @@ export function DataQualityPage() {
|
||||
const missingStoreCode = Number(dq.missing_store_code || 0)
|
||||
const zeroConsumption = Number(dq.zero_consumption || 0)
|
||||
const negativeReceived = Number(dq.negative_received || 0)
|
||||
const zeroReceived = Number(dq.zero_received || 0)
|
||||
const discountGtConsumption = Number(dq.discount_gt_consumption || 0)
|
||||
const storeCount = Number(dq.store_count || 0)
|
||||
const totalDishRecords = Number(dq.total_dish_records || 0)
|
||||
const dishMissingStore = Number(dq.dish_missing_store || 0)
|
||||
const dishMissingDish = Number(dq.dish_missing_dish || 0)
|
||||
const negativeConsumptionCount = Number(dq.negative_consumption_count || 0)
|
||||
const negativeConsumptionAmount = Number(dq.negative_consumption_amount || 0)
|
||||
|
||||
const billQualityStatus = missingBillNo === 0 && missingStoreCode === 0 ? '正常' : '异常'
|
||||
const dishQualityStatus = dishMissingStore === 0 && dishMissingDish === 0 ? '正常' : '异常'
|
||||
// 四层状态规则:完整性、准确性、一致性、时效性
|
||||
const completenessStatus = missingBillNo === 0 && missingStoreCode === 0 && dishMissingStore === 0 && dishMissingDish === 0 ? '正常' : '异常'
|
||||
const accuracyStatus = negativeReceived === 0 && zeroConsumption < totalBills * 0.01 && discountGtConsumption === 0 ? '正常' : '需关注'
|
||||
const consistencyStatus = negativeConsumptionCount === 0 ? '正常' : '异常'
|
||||
const timelinessStatus = '正常'
|
||||
|
||||
// 综合质量状态:任一异常即为不可直接使用
|
||||
const overallStatus = completenessStatus === '正常' && accuracyStatus === '正常' && consistencyStatus === '正常' ? '正常' : '需修复'
|
||||
|
||||
// 利润数据置信等级
|
||||
const profitConfidence = overallStatus === '正常' ? 'A:已对账' : accuracyStatus === '需关注' ? 'B:待桥接' : 'C:异常'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -37,6 +50,29 @@ export function DataQualityPage() {
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">账单与菜品明细数据质量检查 · {dq.min_date?.substring(0, 10)} ~ {dq.max_date?.substring(0, 10)}</p>
|
||||
</div>
|
||||
|
||||
{/* 质量状态总览 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<div className={`rounded-lg border p-4 shadow-sm ${completenessStatus === '正常' ? 'border-green-200 bg-green-50/30' : 'border-red-200 bg-red-50/30'}`}>
|
||||
<p className="text-xs text-muted-foreground">完整性</p>
|
||||
<p className={`mt-1 text-lg font-bold ${completenessStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>{completenessStatus}</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">缺失字段检查</p>
|
||||
</div>
|
||||
<div className={`rounded-lg border p-4 shadow-sm ${accuracyStatus === '正常' ? 'border-green-200 bg-green-50/30' : 'border-yellow-200 bg-yellow-50/30'}`}>
|
||||
<p className="text-xs text-muted-foreground">准确性</p>
|
||||
<p className={`mt-1 text-lg font-bold ${accuracyStatus === '正常' ? 'text-green-600' : 'text-yellow-600'}`}>{accuracyStatus}</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">负值、零值、越界检查</p>
|
||||
</div>
|
||||
<div className={`rounded-lg border p-4 shadow-sm ${consistencyStatus === '正常' ? 'border-green-200 bg-green-50/30' : 'border-red-200 bg-red-50/30'}`}>
|
||||
<p className="text-xs text-muted-foreground">一致性</p>
|
||||
<p className={`mt-1 text-lg font-bold ${consistencyStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>{consistencyStatus}</p>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">库存负耗用等跨表检查</p>
|
||||
</div>
|
||||
<div className={`rounded-lg border p-4 shadow-sm ${profitConfidence.startsWith('A') ? 'border-green-200 bg-green-50/30' : profitConfidence.startsWith('B') ? 'border-yellow-200 bg-yellow-50/30' : 'border-red-200 bg-red-50/30'}`}>
|
||||
<p className="text-xs text-muted-foreground">利润数据置信等级</p>
|
||||
<p className={`mt-1 text-sm font-bold ${profitConfidence.startsWith('A') ? 'text-green-600' : profitConfidence.startsWith('B') ? 'text-yellow-600' : 'text-red-600'}`}>{profitConfidence}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账单数据质量 */}
|
||||
<CollapsibleSection title="账单数据质量" subtitle="原始账单表完整性检查">
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
@@ -46,14 +82,10 @@ export function DataQualityPage() {
|
||||
<MetricCard title="缺失门店编码" value={missingStoreCode} format="number" description="门店编码为空的记录数,应为0" />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="零消费账单" value={zeroConsumption} format="number" description="消费额为0的账单数" />
|
||||
<MetricCard title="负实收账单" value={negativeReceived} format="number" description="实收为负数的账单数" />
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<p className="text-xs text-muted-foreground">账单质量状态</p>
|
||||
<p className={`mt-1 text-lg font-bold ${billQualityStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{billQualityStatus}
|
||||
</p>
|
||||
</div>
|
||||
<MetricCard title="零消费账单" value={zeroConsumption} format="number" description="消费额为0的账单数,可能为撤单、赠送或导入问题" />
|
||||
<MetricCard title="负实收账单" value={negativeReceived} format="number" description="实收为负数的账单数,需区分退款、冲销和异常录入" />
|
||||
<MetricCard title="零实收账单" value={zeroReceived} format="number" description="实收为0的账单数,影响账单数、客单和优惠判断" />
|
||||
<MetricCard title="优惠大于消费" value={discountGtConsumption} format="number" description="优惠额超过消费额的账单数,需检查字段定义和冲销逻辑" />
|
||||
</div>
|
||||
{missingBillNo > 0 && (
|
||||
<div className="mt-3 rounded-md border border-yellow-200 bg-yellow-50/50 p-3">
|
||||
@@ -79,10 +111,10 @@ export function DataQualityPage() {
|
||||
<MetricCard title="总菜品记录数" value={totalDishRecords} format="number" description="菜品销售明细表总记录数" />
|
||||
<MetricCard title="缺失门店" value={dishMissingStore} format="number" description="门店编码为空的记录数" />
|
||||
<MetricCard title="缺失菜品名" value={dishMissingDish} format="number" description="菜品名称为空的记录数" />
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className={`rounded-lg border p-4 shadow-sm ${dishMissingStore === 0 && dishMissingDish === 0 ? 'border-green-200 bg-green-50/30' : 'border-red-200 bg-red-50/30'}`}>
|
||||
<p className="text-xs text-muted-foreground">菜品质量状态</p>
|
||||
<p className={`mt-1 text-lg font-bold ${dishQualityStatus === '正常' ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{dishQualityStatus}
|
||||
<p className={`mt-1 text-lg font-bold ${dishMissingStore === 0 && dishMissingDish === 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{dishMissingStore === 0 && dishMissingDish === 0 ? '正常' : '异常'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,6 +128,22 @@ export function DataQualityPage() {
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 库存数据质量 */}
|
||||
<CollapsibleSection title="库存数据质量" subtitle="库存倒挤成本异常检查" defaultOpen={false}>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricCard title="负耗用记录数" value={negativeConsumptionCount} format="number" description="消费金额为负数的库存记录数,可能扭曲成本和库存周转" />
|
||||
<MetricCard title="负耗用金额" value={negativeConsumptionAmount} format="currency" description="负耗用记录的金额合计,需排查盘点差异或录入错误" />
|
||||
</div>
|
||||
{negativeConsumptionCount > 0 && (
|
||||
<div className="mt-3 rounded-md border border-red-200 bg-red-50/50 p-3">
|
||||
<p className="text-xs text-red-700">
|
||||
<span className="font-medium">提示:</span>
|
||||
{negativeConsumptionCount} 条库存负耗用记录,金额 {formatCurrency(negativeConsumptionAmount)},可能扭曲成本和库存周转,需排查盘点差异或录入错误
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 数据治理建议 */}
|
||||
<CollapsibleSection title="数据治理建议" subtitle="基于管理体系的数据质量要求" defaultOpen={false}>
|
||||
<div className="space-y-3 text-sm">
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, ScatterChart, Scatter } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
|
||||
import { Truck, AlertTriangle, PackageSearch, BarChart3 } from 'lucide-react'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
|
||||
function varianceStatus(pct: number): 'good' | 'warn' | 'bad' {
|
||||
if (Math.abs(pct) <= 3) return 'good'
|
||||
if (Math.abs(pct) <= 10) return 'warn'
|
||||
return 'bad'
|
||||
}
|
||||
|
||||
function varianceColorClass(pct: number): string {
|
||||
const s = varianceStatus(pct)
|
||||
return s === 'good' ? 'text-green-600' : s === 'warn' ? 'text-yellow-600' : 'text-red-600'
|
||||
}
|
||||
|
||||
function varianceText(pct: number): string {
|
||||
if (pct > 0) return `+${pct.toFixed(2)}%`
|
||||
return `${pct.toFixed(2)}%`
|
||||
}
|
||||
|
||||
export function DistributionReconciliationPage() {
|
||||
const [month, setMonth] = useState('2026-04')
|
||||
const [storeFilter, setStoreFilter] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['distribution-reconciliation', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/distribution/reconciliation?month=${month}`)
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
if (!data) return <div className="p-4 text-muted-foreground">暂无数据</div>
|
||||
|
||||
const { summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation } = data
|
||||
|
||||
const filteredStores = storeReconciliation.filter((s: any) =>
|
||||
!storeFilter ||
|
||||
s.store_code?.includes(storeFilter) ||
|
||||
s.store_name?.includes(storeFilter)
|
||||
)
|
||||
|
||||
// 倒挤公式瀑布数据
|
||||
const waterfallData = [
|
||||
{ name: '期初库存', value: summary.total_opening_amt, type: 'base' },
|
||||
{ name: '配送入库', value: summary.total_dist_amt, type: 'add' },
|
||||
{ name: '应耗用(倒挤)', value: summary.reverse_consumption_amt, type: 'calc' },
|
||||
{ name: '实际耗用', value: summary.total_consumption_amt, type: 'actual' },
|
||||
{ name: '期末库存', value: summary.total_ending_amt, type: 'base' },
|
||||
{ name: '差异金额', value: summary.variance_amt, type: 'variance' },
|
||||
]
|
||||
|
||||
// 品类对账柱状图
|
||||
const categoryChartData = categoryReconciliation.slice(0, 15).map((c: any) => ({
|
||||
name: c.minor_category,
|
||||
配送金额: c.dist_amt,
|
||||
耗用金额: c.consumption_amt,
|
||||
期末库存: c.ending_amt,
|
||||
}))
|
||||
|
||||
// 门店散点图:配送金额 vs 差异率
|
||||
const storeScatterData = storeReconciliation
|
||||
.filter((s: any) => s.dist_amt > 0 && s.consumption_amt > 0)
|
||||
.map((s: any) => ({
|
||||
name: s.store_name || s.store_code,
|
||||
x: s.dist_amt,
|
||||
y: s.variance_pct,
|
||||
store_code: s.store_code,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Truck className="h-6 w-6 text-blue-600" />
|
||||
<h1 className="text-xl font-bold">配送—倒挤成本对账</h1>
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
</div>
|
||||
|
||||
{/* 公式说明 */}
|
||||
<div className="rounded-lg border bg-blue-50/40 p-3 text-sm text-blue-800">
|
||||
<strong>倒挤公式:</strong> 应耗用成本 = 期初库存 + 配送入库 - 期末库存 | 差异 = 实际耗用 - 应耗用(倒挤) | 差异率 = 差异 / 实际耗用 × 100%
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-7">
|
||||
<MetricCard title="对账品项行" value={summary.total_lines} unit="行" description="配送+库存合并去重后的品项行数" />
|
||||
<MetricCard title="匹配行数" value={summary.matched_lines} unit="行" status="good" description="配送和库存都有数据的行数" />
|
||||
<MetricCard title="配送总额" value={summary.total_dist_amt} format="currency" />
|
||||
<MetricCard title="实际耗用" value={summary.total_consumption_amt} format="currency" />
|
||||
<MetricCard title="倒挤应耗用" value={summary.reverse_consumption_amt} format="currency" description="期初+配送-期末" />
|
||||
<MetricCard title="差异金额" value={summary.variance_amt} format="currency" status={summary.variance_amt >= 0 ? 'warn' : 'bad'} />
|
||||
<MetricCard title="差异率" value={varianceText(summary.variance_pct)} status={varianceStatus(summary.variance_pct)} />
|
||||
</div>
|
||||
|
||||
{/* 倒挤对账瀑布 */}
|
||||
<CollapsibleSection title="倒挤成本对账瀑布" subtitle="期初库存 + 配送入库 - 期末库存 = 应耗用 vs 实际耗用">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={waterfallData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<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]}>
|
||||
{waterfallData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={
|
||||
entry.type === 'add' ? '#52c41a' :
|
||||
entry.type === 'calc' ? '#1677ff' :
|
||||
entry.type === 'actual' ? '#722ed1' :
|
||||
entry.type === 'variance' ? (entry.value >= 0 ? '#faad14' : '#f5222d') :
|
||||
'#8ec6ff'
|
||||
} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 品类维度对账 */}
|
||||
<CollapsibleSection title="品类维度对账" subtitle="按小类汇总配送金额与耗用金额">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={categoryChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 10 }} angle={-20} textAnchor="end" height={60} />
|
||||
<YAxis tickFormatter={(v) => `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||
<Bar dataKey="配送金额" fill="#1677ff" />
|
||||
<Bar dataKey="耗用金额" fill="#722ed1" />
|
||||
<Bar dataKey="期末库存" fill="#faad14" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-4">品类</th>
|
||||
<th className="pb-2 pr-4 text-right">品项数</th>
|
||||
<th className="pb-2 pr-4 text-right">配送量</th>
|
||||
<th className="pb-2 pr-4 text-right">配送金额</th>
|
||||
<th className="pb-2 pr-4 text-right">不含税成本</th>
|
||||
<th className="pb-2 pr-4 text-right">耗用金额</th>
|
||||
<th className="pb-2 pr-4 text-right">期末库存</th>
|
||||
<th className="pb-2 pr-4 text-right">差异金额</th>
|
||||
<th className="pb-2 pr-4 text-right">差异率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categoryReconciliation.map((c: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-2 pr-4 font-medium">{c.minor_category}</td>
|
||||
<td className="py-2 pr-4 text-right">{c.item_count}</td>
|
||||
<td className="py-2 pr-4 text-right">{formatNumber(c.dist_qty)}</td>
|
||||
<td className="py-2 pr-4 text-right">{formatCurrency(c.dist_amt)}</td>
|
||||
<td className="py-2 pr-4 text-right">{formatCurrency(c.dist_cost_excl_tax)}</td>
|
||||
<td className="py-2 pr-4 text-right">{formatCurrency(c.consumption_amt)}</td>
|
||||
<td className="py-2 pr-4 text-right">{formatCurrency(c.ending_amt)}</td>
|
||||
<td className={`py-2 pr-4 text-right ${c.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(c.variance_amt)}
|
||||
</td>
|
||||
<td className={`py-2 pr-4 text-right font-medium ${varianceColorClass(c.variance_pct)}`}>
|
||||
{varianceText(c.variance_pct)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 门店差异散点图 */}
|
||||
<CollapsibleSection title="门店差异散点图" subtitle="X=配送金额,Y=差异率%" defaultOpen={false}>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<ScatterChart margin={{ top: 20, right: 30, left: 20, bottom: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" dataKey="x" name="配送金额" tickFormatter={(v) => `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 11 }} label={{ value: '配送金额', position: 'insideBottom', offset: -5, fontSize: 11 }} />
|
||||
<YAxis type="number" dataKey="y" name="差异率%" tickFormatter={(v) => `${v.toFixed(0)}%`} tick={{ fontSize: 11 }} label={{ value: '差异率%', angle: -90, position: 'insideLeft', fontSize: 11 }} />
|
||||
<Tooltip cursor={{ strokeDasharray: '3 3' }} formatter={(v: any, name: any) => name === 'x' ? formatCurrency(v) : `${v.toFixed(2)}%`} labelFormatter={() => ''} />
|
||||
<Scatter data={storeScatterData}>
|
||||
{storeScatterData.map((entry: any, idx: number) => (
|
||||
<Cell key={idx} fill={
|
||||
Math.abs(entry.y) <= 3 ? '#52c41a' :
|
||||
Math.abs(entry.y) <= 10 ? '#faad14' :
|
||||
'#f5222d'
|
||||
} />
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 flex gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-green-500" /> 正常(±3%)</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-yellow-500" /> 关注(3~10%)</span>
|
||||
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded-full bg-red-500" /> 异常(>10%)</span>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 门店对账明细 */}
|
||||
<CollapsibleSection
|
||||
title="门店对账明细"
|
||||
subtitle={`${filteredStores.length}家门店`}
|
||||
headerRight={
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索门店编码/名称..."
|
||||
value={storeFilter}
|
||||
onChange={(e) => setStoreFilter(e.target.value)}
|
||||
className="rounded border px-3 py-1 text-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">门店编码</th>
|
||||
<th className="pb-2 pr-3">门店名称</th>
|
||||
<th className="pb-2 pr-3 text-right">配送金额</th>
|
||||
<th className="pb-2 pr-3 text-right">不含税成本</th>
|
||||
<th className="pb-2 pr-3 text-right">期初库存</th>
|
||||
<th className="pb-2 pr-3 text-right">实际耗用</th>
|
||||
<th className="pb-2 pr-3 text-right">期末库存</th>
|
||||
<th className="pb-2 pr-3 text-right">倒挤应耗用</th>
|
||||
<th className="pb-2 pr-3 text-right">差异金额</th>
|
||||
<th className="pb-2 pr-3 text-right">差异率</th>
|
||||
<th className="pb-2 pr-3 text-right">负库存</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredStores.map((s: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 text-xs">{s.store_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium">{s.store_name || '-'}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.dist_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.dist_cost_excl_tax)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.opening_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.consumption_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.ending_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(s.reverse_consumption_amt)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right ${s.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(s.variance_amt)}
|
||||
</td>
|
||||
<td className={`py-1.5 pr-3 text-right font-medium ${varianceColorClass(s.variance_pct)}`}>
|
||||
{varianceText(s.variance_pct)}
|
||||
</td>
|
||||
<td className={`py-1.5 pr-3 text-right ${s.neg_inventory_count > 0 ? 'text-red-600 font-medium' : ''}`}>
|
||||
{s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Top差异品项 */}
|
||||
<CollapsibleSection title="Top30差异品项" subtitle="按差异金额绝对值排序" defaultOpen={false}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">门店</th>
|
||||
<th className="pb-2 pr-3">品项编码</th>
|
||||
<th className="pb-2 pr-3">品项名称</th>
|
||||
<th className="pb-2 pr-3">品类</th>
|
||||
<th className="pb-2 pr-3 text-right">配送量</th>
|
||||
<th className="pb-2 pr-3 text-right">配送金额</th>
|
||||
<th className="pb-2 pr-3 text-right">期初</th>
|
||||
<th className="pb-2 pr-3 text-right">实际耗用</th>
|
||||
<th className="pb-2 pr-3 text-right">期末</th>
|
||||
<th className="pb-2 pr-3 text-right">倒挤应耗用</th>
|
||||
<th className="pb-2 pr-3 text-right">差异金额</th>
|
||||
<th className="pb-2 pr-3 text-right">差异率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topVariances.map((v: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 text-xs">{v.store_code}</td>
|
||||
<td className="py-1.5 pr-3 text-xs">{v.item_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium">{v.item_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{v.minor_category}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(v.dist_qty)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.dist_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.opening_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.consumption_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.ending_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(v.reverse_consumption_amt)}</td>
|
||||
<td className={`py-1.5 pr-3 text-right font-medium ${v.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{formatCurrency(v.variance_amt)}
|
||||
</td>
|
||||
<td className={`py-1.5 pr-3 text-right ${varianceColorClass(v.variance_pct)}`}>
|
||||
{varianceText(v.variance_pct)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 未匹配品项 */}
|
||||
<CollapsibleSection title="未匹配品项(有配送无库存耗用)" subtitle="配送系统有发货但库存系统无耗用记录" defaultOpen={false}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 pr-3">品项编码</th>
|
||||
<th className="pb-2 pr-3">品项名称</th>
|
||||
<th className="pb-2 pr-3">品类</th>
|
||||
<th className="pb-2 pr-3 text-right">配送量</th>
|
||||
<th className="pb-2 pr-3 text-right">配送金额</th>
|
||||
<th className="pb-2 pr-3 text-right">涉及门店数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unmatchedItems.map((u: any, i: number) => (
|
||||
<tr key={i} className="border-b hover:bg-muted/50">
|
||||
<td className="py-1.5 pr-3 text-xs">{u.item_code}</td>
|
||||
<td className="py-1.5 pr-3 font-medium">{u.item_name}</td>
|
||||
<td className="py-1.5 pr-3 text-xs text-muted-foreground">{u.minor_category}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatNumber(u.dist_qty)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{formatCurrency(u.dist_amt)}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{u.store_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md bg-yellow-50 p-3 text-xs text-yellow-800">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
<span>这些品项在配送系统有发货记录,但在库存系统中无对应的耗用数据,可能存在编码不一致、未入库或数据缺失等问题。</span>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { MetricCard } from '@/components/MetricCard'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
|
||||
const REVIEW_COLORS = { '达标': '#22c55e', '改善中': '#eab308', '未改善': '#ef4444' }
|
||||
const PAGE_SIZE = 10
|
||||
@@ -84,12 +85,7 @@ export function MonthlyReviewPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">月度验收与复盘</h1>
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="rounded-md border px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, PieChart, Pie } from 'recharts'
|
||||
import api from '@/lib/api'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { FilterableTable } from '@/components/FilterableTable'
|
||||
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TrendingUp, Factory, Truck, Package, ArrowRight } from 'lucide-react'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
|
||||
function rateColorClass(rate: number | null): string {
|
||||
if (rate === null) return 'text-gray-400'
|
||||
if (rate >= 90) return 'text-green-600'
|
||||
if (rate >= 70) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
function rateText(rate: number | null): string {
|
||||
if (rate === null) return '-'
|
||||
return `${rate.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function ProductionPlanPage() {
|
||||
const [month, setMonth] = useState('2026-04')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['production-plan', month],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(`/sales-driven/production-plan?month=${month}`)
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
if (!data) return <div className="p-4 text-muted-foreground">暂无数据</div>
|
||||
|
||||
const { summary, skuSales, materialDemand, storeDemand, ckProductionPlan, productionCoord } = data
|
||||
|
||||
// BOM覆盖率饼图
|
||||
const coverageData = [
|
||||
{ name: '有BOM', value: summary.matched_sku_count, fill: '#52c41a' },
|
||||
{ name: '无BOM', value: summary.total_dish_count - summary.matched_sku_count, fill: '#f5222d' },
|
||||
]
|
||||
|
||||
// Top10原料需求
|
||||
const topMaterialDemand = materialDemand.slice(0, 15)
|
||||
const materialChartData = topMaterialDemand.map((m: any) => ({
|
||||
name: m.material_name?.length > 10 ? m.material_name.slice(0, 10) + '...' : m.material_name,
|
||||
需求量: m.total_demand_qty,
|
||||
门店数: m.store_count,
|
||||
}))
|
||||
|
||||
// 图表数据用Top15
|
||||
const topCkPlan = ckProductionPlan.filter((p: any) => p.demand_qty > 0).slice(0, 15)
|
||||
|
||||
// 生产配送协同图表Top15
|
||||
const topCoord = productionCoord.filter((p: any) => p.inbound_qty > 0).slice(0, 15)
|
||||
const coordChartData = topCoord.map((p: any) => ({
|
||||
name: p.product_name?.length > 10 ? p.product_name.slice(0, 10) + '...' : p.product_name,
|
||||
完工入库: p.inbound_qty,
|
||||
配送量: p.dist_qty,
|
||||
门店消耗: p.consumption_qty,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||
<h1 className="text-xl font-bold">销量驱动生产与要货计划</h1>
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
</div>
|
||||
|
||||
{/* 公式说明 */}
|
||||
<div className="rounded-lg border bg-green-50/40 p-3 text-sm text-green-800">
|
||||
<strong>预测逻辑:</strong> SKU销量 × 门店BOM = 基础原料需求 | <strong>生产计划:</strong> 各门店要货合计 + 中央厨房安全库存 | <strong>要货建议:</strong> 需求量 - 当前库存 + 安全库存
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-7">
|
||||
<MetricCard title="总菜品数" value={summary.total_dish_count} unit="个" />
|
||||
<MetricCard title="有BOM菜品" value={summary.matched_sku_count} unit="个" status="good" />
|
||||
<MetricCard title="BOM覆盖率" value={`${summary.bom_coverage_pct}%`} status={summary.bom_coverage_pct >= 50 ? 'good' : 'warn'} />
|
||||
<MetricCard title="BOM原料数" value={summary.total_bom_materials} unit="种" />
|
||||
<MetricCard title="匹配销售额" value={summary.matched_amt} format="currency" description="有BOM菜品的销售额" />
|
||||
<MetricCard title="总销售额" value={summary.total_sales_amt} format="currency" />
|
||||
<MetricCard title="CK生产计划" value={ckProductionPlan.filter((p: any) => p.demand_qty > 0).length} unit="种" status="warn" description="有销量需求的半成品数" />
|
||||
</div>
|
||||
|
||||
{/* BOM覆盖率 + Top原料需求 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<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}`}>
|
||||
{coverageData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<CollapsibleSection title="Top15原料需求" subtitle="按销量×BOM展开的原料需求量">
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={materialChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-25} textAnchor="end" height={70} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v: any) => formatNumber(v)} />
|
||||
<Bar dataKey="需求量" fill="#52c41a" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中央厨房生产计划 */}
|
||||
<CollapsibleSection title="中央厨房生产计划" subtitle="销量驱动的半成品需求 vs 实际完工入库">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Factory className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-sm text-muted-foreground">需求量 = 各门店BOM中半成品用量合计,差异 = 需求 - 实际入库</span>
|
||||
</div>
|
||||
<FilterableTable
|
||||
data={ckProductionPlan}
|
||||
filterKey="product_name"
|
||||
filterLabel="全部产品"
|
||||
sortOptions={[
|
||||
{ key: 'demand_qty', label: '需求量' },
|
||||
{ key: 'actual_inbound_qty', label: '实际入库' },
|
||||
{ key: 'variance_qty', label: '差异量' },
|
||||
{ key: 'actual_cost', label: '实际成本' },
|
||||
{ key: 'store_count', label: '门店数' },
|
||||
]}
|
||||
defaultSort="demand_qty"
|
||||
columns={[
|
||||
{ key: 'product_code', label: '产品编码' },
|
||||
{ key: 'product_name', label: '产品名称' },
|
||||
{ key: 'unit', label: '单位', align: 'right' },
|
||||
{ key: 'demand_qty', label: '需求量', align: 'right', render: (r: any) => formatNumber(r.demand_qty) },
|
||||
{ key: 'actual_inbound_qty', label: '实际入库', align: 'right', render: (r: any) => formatNumber(r.actual_inbound_qty) },
|
||||
{ key: 'variance_qty', label: '差异量', align: 'right', render: (r: any) => (
|
||||
<span className={cn(r.variance_qty > 0 ? 'text-red-600' : 'text-green-600')}>{formatNumber(r.variance_qty)}</span>
|
||||
) },
|
||||
{ key: 'variance_pct', label: '差异率', align: 'right', render: (r: any) => (
|
||||
<span className={cn('font-medium', r.variance_pct === null ? 'text-gray-400' : Math.abs(r.variance_pct) <= 10 ? 'text-green-600' : 'text-red-600')}>
|
||||
{r.variance_pct === null ? '-' : `${r.variance_pct > 0 ? '+' : ''}${r.variance_pct.toFixed(1)}%`}
|
||||
</span>
|
||||
) },
|
||||
{ key: 'store_count', label: '涉及门店', align: 'right' },
|
||||
{ key: 'actual_cost', label: '实际成本', align: 'right', render: (r: any) => formatCurrency(r.actual_cost) },
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 生产与配送协同 */}
|
||||
<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>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={coordChartData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 9 }} angle={-25} textAnchor="end" height={70} />
|
||||
<YAxis tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(v: any) => formatNumber(v)} />
|
||||
<Bar dataKey="完工入库" fill="#1677ff" />
|
||||
<Bar dataKey="配送量" fill="#52c41a" />
|
||||
<Bar dataKey="门店消耗" fill="#faad14" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-3">
|
||||
<FilterableTable
|
||||
data={productionCoord}
|
||||
filterKey="product_name"
|
||||
filterLabel="全部产品"
|
||||
sortOptions={[
|
||||
{ key: 'inbound_qty', label: '完工入库' },
|
||||
{ key: 'dist_qty', label: '配送量' },
|
||||
{ key: 'consumption_qty', label: '门店消耗' },
|
||||
{ key: 'ending_qty', label: '期末库存' },
|
||||
{ key: 'actual_cost', label: '实际成本' },
|
||||
]}
|
||||
defaultSort="actual_cost"
|
||||
columns={[
|
||||
{ key: 'product_code', label: '产品编码' },
|
||||
{ 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: '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: 'completion_distribution_rate', label: '完工配送率', align: 'right', render: (r: any) => (
|
||||
<span className={rateColorClass(r.completion_distribution_rate)}>{rateText(r.completion_distribution_rate)}</span>
|
||||
) },
|
||||
{ key: 'distribution_consumption_rate', label: '配送消耗率', align: 'right', render: (r: any) => (
|
||||
<span className={rateColorClass(r.distribution_consumption_rate)}>{rateText(r.distribution_consumption_rate)}</span>
|
||||
) },
|
||||
{ key: 'inventory_accumulation_rate', label: '库存积压率', align: 'right', render: (r: any) => (
|
||||
<span className={r.inventory_accumulation_rate === null ? 'text-gray-400' : r.inventory_accumulation_rate > 30 ? 'text-red-600' : 'text-green-600'}>
|
||||
{rateText(r.inventory_accumulation_rate)}
|
||||
</span>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 门店要货建议 */}
|
||||
<CollapsibleSection title="门店要货建议" subtitle={`${storeDemand.length}家门店的原料需求与库存对比`} defaultOpen={false}>
|
||||
<FilterableTable
|
||||
data={storeDemand}
|
||||
filterKey="store_name"
|
||||
filterLabel="全部门店"
|
||||
sortOptions={[
|
||||
{ key: 'total_amt', label: '销售额' },
|
||||
{ key: 'total_qty', label: '销量' },
|
||||
{ key: 'total_material_demand_qty', label: '原料需求量' },
|
||||
{ key: 'ending_inventory_amt', label: '期末库存额' },
|
||||
{ key: 'suggested_order_qty', label: '建议要货量' },
|
||||
{ key: 'sku_count', label: '菜品数' },
|
||||
]}
|
||||
defaultSort="total_amt"
|
||||
columns={[
|
||||
{ key: 'store_code', label: '门店编码' },
|
||||
{ key: 'store_name', label: '门店名称' },
|
||||
{ key: 'sku_count', label: '有BOM菜品数', align: 'right' },
|
||||
{ key: 'total_qty', label: '销量', align: 'right', render: (r: any) => formatNumber(r.total_qty) },
|
||||
{ key: 'total_amt', label: '销售额', align: 'right', render: (r: any) => formatCurrency(r.total_amt) },
|
||||
{ key: 'total_material_demand_qty', label: '原料需求量', align: 'right', render: (r: any) => formatNumber(r.total_material_demand_qty) },
|
||||
{ key: 'ending_inventory_qty', label: '期末库存量', align: 'right', render: (r: any) => formatNumber(r.ending_inventory_qty) },
|
||||
{ key: 'ending_inventory_amt', label: '期末库存额', align: 'right', render: (r: any) => formatCurrency(r.ending_inventory_amt) },
|
||||
{ key: 'suggested_order_qty', label: '建议要货量', align: 'right', render: (r: any) => (
|
||||
<span className={cn('font-medium', r.suggested_order_qty > 0 ? 'text-blue-600' : 'text-green-600')}>{formatNumber(r.suggested_order_qty)}</span>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 原料需求明细 */}
|
||||
<CollapsibleSection title="原料需求明细" subtitle={`${materialDemand.length}种原料的BOM展开需求`} defaultOpen={false}>
|
||||
<FilterableTable
|
||||
data={materialDemand}
|
||||
filterKey="material_name"
|
||||
filterLabel="全部原料"
|
||||
sortOptions={[
|
||||
{ key: 'total_demand_qty', label: '需求量' },
|
||||
{ key: 'total_demand_amt', label: '需求金额' },
|
||||
{ key: 'store_count', label: '门店数' },
|
||||
{ key: 'avg_store_demand', label: '门店均需求' },
|
||||
]}
|
||||
defaultSort="total_demand_amt"
|
||||
columns={[
|
||||
{ key: 'material_code', label: '原料编码' },
|
||||
{ key: 'material_name', label: '原料名称' },
|
||||
{ key: 'unit', label: '单位', align: 'right' },
|
||||
{ key: 'total_demand_qty', label: '总需求量', align: 'right', render: (r: any) => formatNumber(r.total_demand_qty) },
|
||||
{ key: 'total_demand_amt', label: '总需求金额', align: 'right', render: (r: any) => formatCurrency(r.total_demand_amt) },
|
||||
{ key: 'store_count', label: '涉及门店数', align: 'right' },
|
||||
{ key: 'avg_store_demand', label: '门店均需求', align: 'right', render: (r: any) => formatNumber(r.avg_store_demand) },
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* SKU销量Top50 */}
|
||||
<CollapsibleSection title="SKU销量Top50" subtitle="含BOM匹配状态" defaultOpen={false}>
|
||||
<FilterableTable
|
||||
data={skuSales}
|
||||
filterKey="dish_name"
|
||||
filterLabel="全部菜品"
|
||||
sortOptions={[
|
||||
{ key: 'amt', label: '销售额' },
|
||||
{ key: 'qty', label: '销量' },
|
||||
{ key: 'store_count', label: '门店数' },
|
||||
{ key: 'avg_unit_price', label: '均价' },
|
||||
{ key: 'material_count', label: '材料数' },
|
||||
]}
|
||||
defaultSort="amt"
|
||||
statusFilterKey="has_bom"
|
||||
statusFilterLabel="全部状态"
|
||||
statusOptions={[
|
||||
{ value: 'true', label: '有BOM' },
|
||||
{ value: 'false', label: '无BOM' },
|
||||
]}
|
||||
columns={[
|
||||
{ key: 'dish_name', label: '菜品名称' },
|
||||
{ key: 'sku_code', label: 'SKU编码', render: (r: any) => r.sku_code || '-' },
|
||||
{ key: 'qty', label: '销量', align: 'right', render: (r: any) => formatNumber(r.qty) },
|
||||
{ key: 'amt', label: '销售额', align: 'right', render: (r: any) => formatCurrency(r.amt) },
|
||||
{ key: 'store_count', label: '门店数', align: 'right' },
|
||||
{ key: 'avg_unit_price', label: '均价', align: 'right', render: (r: any) => formatCurrency(r.avg_unit_price) },
|
||||
{ key: 'has_bom', label: '有BOM', align: 'center', render: (r: any) => (
|
||||
r.has_bom ? <span className="text-green-600">✓</span> : <span className="text-red-500">✗</span>
|
||||
) },
|
||||
{ key: 'material_count', label: '材料数', align: 'right', render: (r: any) => r.material_count || '-' },
|
||||
]}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { MetricCard } from '@/components/MetricCard'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { formatCurrency, formatPercent, riskColor } from '@/lib/utils'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
@@ -16,6 +17,7 @@ export function RegionalPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [riskFilter, setRiskFilter] = useState('')
|
||||
const [month, setMonth] = useState('2026-05')
|
||||
const [riskPage, setRiskPage] = useState(1)
|
||||
const [checkPage, setCheckPage] = useState(1)
|
||||
const [benchPage, setBenchPage] = useState(1)
|
||||
@@ -29,7 +31,7 @@ export function RegionalPage() {
|
||||
|
||||
const { data: weeklyData, isLoading: weeklyLoading } = useQuery({
|
||||
queryKey: ['tasks/weekly-check'],
|
||||
queryFn: () => api.get('/tasks/weekly-check', { params: { month: '2026-05' } }),
|
||||
queryFn: () => api.get('/tasks/weekly-check', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: priorityData, isLoading: priorityLoading } = useQuery({
|
||||
@@ -77,7 +79,10 @@ export function RegionalPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold">区域经理工作台</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">区域经理工作台</h1>
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
</div>
|
||||
|
||||
{/* 汇总卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
|
||||
@@ -245,7 +245,7 @@ export function RevenuePage() {
|
||||
|
||||
{/* 门店营收排名 */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<CollapsibleSection title="营收 TOP10" subtitle="赚钱最多的10家门店">
|
||||
<CollapsibleSection title="营收 TOP10" subtitle="实收最高的10家门店">
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={top10} layout="vertical" margin={{ left: 70 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FilterableTable } from '@/components/FilterableTable'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
import { useState } from 'react'
|
||||
|
||||
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks'
|
||||
@@ -29,6 +30,7 @@ export function StoreDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<DetailTab>('overview')
|
||||
const [anomalyPage, setAnomalyPage] = useState(1)
|
||||
const [month, setMonth] = useState('2026-05')
|
||||
|
||||
const { data: storeData } = useQuery({
|
||||
queryKey: ['store', code],
|
||||
@@ -42,7 +44,7 @@ export function StoreDetailPage() {
|
||||
|
||||
const { data: tasksData } = useQuery({
|
||||
queryKey: ['store', code, 'tasks'],
|
||||
queryFn: () => api.get('/tasks', { params: { store_code: code, month: '2026-05', page_size: 50 } }),
|
||||
queryFn: () => api.get('/tasks', { params: { store_code: code, month, page_size: 50 } }),
|
||||
enabled: tab === 'tasks',
|
||||
})
|
||||
|
||||
@@ -137,6 +139,7 @@ export function StoreDetailPage() {
|
||||
<h1 className="text-xl font-bold">{sc.store_name}</h1>
|
||||
{risk?.risk_level && <Badge type="risk" text={risk.risk_level} />}
|
||||
{quadrant && <span className="rounded-md bg-muted px-2 py-0.5 text-xs">{quadrant}</span>}
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Badge } from '@/components/Badge'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function StorePage() {
|
||||
@@ -11,6 +12,7 @@ export function StorePage() {
|
||||
const [executeText, setExecuteText] = useState('')
|
||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
|
||||
const [storeCode, setStoreCode] = useState('1111')
|
||||
const [month, setMonth] = useState('2026-05')
|
||||
|
||||
const { data: storesData, isLoading: storesLoading } = useQuery({
|
||||
queryKey: ['stores-list'],
|
||||
@@ -30,12 +32,12 @@ export function StorePage() {
|
||||
|
||||
const { data: tasksData, isLoading: tasksLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'tasks'],
|
||||
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month: '2026-05', page_size: 50 } }),
|
||||
queryFn: () => api.get('/tasks', { params: { store_code: storeCode, month, page_size: 50 } }),
|
||||
})
|
||||
|
||||
const { data: dailyData, isLoading: dailyLoading } = useQuery({
|
||||
queryKey: ['store', storeCode, 'daily'],
|
||||
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month: '2026-04' } }),
|
||||
queryFn: () => api.get(`/stores/${storeCode}/daily`, { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: followupData, isLoading: followupLoading } = useQuery({
|
||||
@@ -45,12 +47,12 @@ export function StorePage() {
|
||||
|
||||
const { data: skuData } = useQuery({
|
||||
queryKey: ['sku-attach', storeCode],
|
||||
queryFn: () => api.get('/sku/attach'),
|
||||
queryFn: () => api.get('/sku/attach', { params: { month } }),
|
||||
})
|
||||
|
||||
const { data: abcData } = useQuery({
|
||||
queryKey: ['sku-abc'],
|
||||
queryFn: () => api.get('/sku/abc'),
|
||||
queryFn: () => api.get('/sku/abc', { params: { month } }),
|
||||
})
|
||||
|
||||
const card = (cardData as any)?.data
|
||||
@@ -109,15 +111,18 @@ export function StorePage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">店长工作台</h1>
|
||||
<select
|
||||
value={storeCode}
|
||||
onChange={(e) => setStoreCode(e.target.value)}
|
||||
className="rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
{stores.map((s: any) => (
|
||||
<option key={s.code} value={s.code}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center gap-3">
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
<select
|
||||
value={storeCode}
|
||||
onChange={(e) => setStoreCode(e.target.value)}
|
||||
className="rounded-md border px-3 py-1.5 text-sm"
|
||||
>
|
||||
{stores.map((s: any) => (
|
||||
<option key={s.code} value={s.code}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 门店概况 */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Badge } from '@/components/Badge'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { formatCurrency, formatPercent } from '@/lib/utils'
|
||||
import { MonthPicker } from '@/components/MonthPicker'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
const PAGE_SIZE = 5
|
||||
@@ -46,17 +47,14 @@ export function TasksPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">任务管理</h1>
|
||||
<span className="text-sm text-muted-foreground">共 {total} 条任务</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<MonthPicker month={month} onChange={setMonth} />
|
||||
<span className="text-sm text-muted-foreground">共 {total} 条任务</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选器 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className="rounded-md border px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<select value={priority} onChange={(e) => setPriority(e.target.value)} className="rounded-md border px-3 py-1.5 text-sm">
|
||||
<option value="">全部优先级</option>
|
||||
<option value="P0-修复数据口径">P0-修复数据口径</option>
|
||||
|
||||
@@ -43,7 +43,7 @@ def to_text(val):
|
||||
s = str(val).strip()
|
||||
return s if s else None
|
||||
|
||||
def import_salary(conn):
|
||||
def import_salary(conn, report_month='2026-04-01'):
|
||||
print(f'导入薪资拆分明细表: {SALARY_FILE}')
|
||||
sha = file_sha256(SALARY_FILE)
|
||||
wb = openpyxl.load_workbook(SALARY_FILE, read_only=True, data_only=True)
|
||||
@@ -54,7 +54,7 @@ def import_salary(conn):
|
||||
data_rows = [r for r in rows if r[0] is not None and r[9] is not None and str(r[9]).strip()]
|
||||
print(f' 数据行数: {len(data_rows)}')
|
||||
|
||||
report_month = '2026-04-01'
|
||||
report_month = report_month
|
||||
source_file = os.path.basename(SALARY_FILE)
|
||||
|
||||
cur = conn.cursor()
|
||||
@@ -145,7 +145,7 @@ def import_salary(conn):
|
||||
wb.close()
|
||||
print(f' 薪资明细导入完成: {len(data_rows)} 行')
|
||||
|
||||
def import_attendance(conn):
|
||||
def import_attendance(conn, report_month='2026-04-01'):
|
||||
print(f'\n导入考勤数据表: {ATTENDANCE_FILE}')
|
||||
sha = file_sha256(ATTENDANCE_FILE)
|
||||
wb = openpyxl.load_workbook(ATTENDANCE_FILE, read_only=True, data_only=True)
|
||||
@@ -156,7 +156,7 @@ def import_attendance(conn):
|
||||
data_rows = [r for r in rows if r[0] is not None]
|
||||
print(f' 数据行数: {len(data_rows)}')
|
||||
|
||||
report_month = '2026-04-01'
|
||||
report_month = report_month
|
||||
source_file = os.path.basename(ATTENDANCE_FILE)
|
||||
|
||||
cur = conn.cursor()
|
||||
@@ -209,11 +209,13 @@ def import_attendance(conn):
|
||||
print(f' 考勤数据导入完成: {len(data_rows)} 行')
|
||||
|
||||
def main():
|
||||
report_month = sys.argv[1] if len(sys.argv) > 1 else '2026-04-01'
|
||||
print(f'导入月份: {report_month}')
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
conn.autocommit = False
|
||||
try:
|
||||
import_salary(conn)
|
||||
import_attendance(conn)
|
||||
import_salary(conn, report_month)
|
||||
import_attendance(conn, report_month)
|
||||
print('\n=== 导入完成 ===')
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
-- ============================================================
|
||||
-- Phase 5: SKU/菜品参数化函数
|
||||
-- 替换 _april 后缀的SKU/菜品视图为参数化函数
|
||||
-- 依赖: fn_dish_sales(p_month) 已在 Phase 2 中创建
|
||||
-- ============================================================
|
||||
|
||||
-- 1. fn_dish_sku_summary(p_month) — 替换 dish_sku_summary_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_sku_summary(p_month date)
|
||||
RETURNS TABLE (
|
||||
dish_name text,
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
detail_rows bigint,
|
||||
bill_count bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric,
|
||||
realized_unit_price numeric,
|
||||
revenue_share_pct numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
s.dish_name,
|
||||
min(s.category_level1) AS category_level1,
|
||||
min(s.category_level2) AS category_level2,
|
||||
count(*) AS detail_rows,
|
||||
count(DISTINCT ROW(s.store_code, s.bill_no)) AS bill_count,
|
||||
count(DISTINCT s.store_code) AS store_count,
|
||||
sum(s.sales_quantity) AS sales_quantity,
|
||||
sum(s.gross_amount) AS gross_amount,
|
||||
sum(s.received_amount) AS received_amount,
|
||||
sum(s.dish_discount_amount) AS discount_amount,
|
||||
round(sum(s.dish_discount_amount) / NULLIF(sum(s.gross_amount), 0) * 100, 2) AS discount_rate_pct,
|
||||
round(sum(s.received_amount) / NULLIF(sum(s.sales_quantity), 0), 2) AS realized_unit_price,
|
||||
round(sum(s.received_amount) / NULLIF(sum(sum(s.received_amount)) OVER (), 0) * 100, 4) AS revenue_share_pct
|
||||
FROM analytics.fn_dish_sales(p_month) s
|
||||
WHERE s.dish_name IS NOT NULL
|
||||
GROUP BY s.dish_name
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 2. fn_dish_sku_abc(p_month) — 替换 v_dish_sku_abc_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_sku_abc(p_month date)
|
||||
RETURNS TABLE (
|
||||
dish_name text,
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
detail_rows bigint,
|
||||
bill_count bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric,
|
||||
realized_unit_price numeric,
|
||||
revenue_share_pct numeric,
|
||||
cumulative_revenue_share numeric,
|
||||
median_quantity numeric,
|
||||
median_revenue numeric,
|
||||
abc_class text,
|
||||
sales_quadrant text
|
||||
) AS $$
|
||||
WITH medians AS (
|
||||
SELECT
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY s.sales_quantity::double precision) AS median_quantity,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY s.received_amount::double precision) AS median_revenue
|
||||
FROM analytics.fn_dish_sku_summary(p_month) s
|
||||
), ranked AS (
|
||||
SELECT
|
||||
s.dish_name,
|
||||
s.category_level1,
|
||||
s.category_level2,
|
||||
s.detail_rows,
|
||||
s.bill_count,
|
||||
s.store_count,
|
||||
s.sales_quantity,
|
||||
s.gross_amount,
|
||||
s.received_amount,
|
||||
s.discount_amount,
|
||||
s.discount_rate_pct,
|
||||
s.realized_unit_price,
|
||||
s.revenue_share_pct,
|
||||
sum(s.received_amount) OVER (ORDER BY s.received_amount DESC, s.dish_name ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
|
||||
/ NULLIF(sum(s.received_amount) OVER (), 0) AS cumulative_revenue_share,
|
||||
m.median_quantity,
|
||||
m.median_revenue
|
||||
FROM analytics.fn_dish_sku_summary(p_month) s
|
||||
CROSS JOIN medians m
|
||||
)
|
||||
SELECT
|
||||
ranked.dish_name,
|
||||
ranked.category_level1,
|
||||
ranked.category_level2,
|
||||
ranked.detail_rows,
|
||||
ranked.bill_count,
|
||||
ranked.store_count,
|
||||
ranked.sales_quantity,
|
||||
ranked.gross_amount,
|
||||
ranked.received_amount,
|
||||
ranked.discount_amount,
|
||||
ranked.discount_rate_pct,
|
||||
ranked.realized_unit_price,
|
||||
ranked.revenue_share_pct,
|
||||
ranked.cumulative_revenue_share,
|
||||
ranked.median_quantity,
|
||||
ranked.median_revenue,
|
||||
CASE
|
||||
WHEN ranked.cumulative_revenue_share <= 0.70 THEN 'A-核心'
|
||||
WHEN ranked.cumulative_revenue_share <= 0.90 THEN 'B-成长'
|
||||
ELSE 'C-长尾'
|
||||
END AS abc_class,
|
||||
CASE
|
||||
WHEN ranked.sales_quantity::double precision >= ranked.median_quantity AND ranked.received_amount::double precision >= ranked.median_revenue THEN '明星菜品'
|
||||
WHEN ranked.sales_quantity::double precision >= ranked.median_quantity AND ranked.received_amount::double precision < ranked.median_revenue THEN '引流菜品'
|
||||
WHEN ranked.sales_quantity::double precision < ranked.median_quantity AND ranked.received_amount::double precision >= ranked.median_revenue THEN '潜力菜品'
|
||||
ELSE '淘汰观察品'
|
||||
END AS sales_quadrant
|
||||
FROM ranked
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 3. fn_dish_category_summary(p_month) — 替换 dish_category_summary_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_category_summary(p_month date)
|
||||
RETURNS TABLE (
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
sku_count bigint,
|
||||
bill_count bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric,
|
||||
revenue_share_pct numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
s.category_level1,
|
||||
s.category_level2,
|
||||
count(DISTINCT s.dish_name) AS sku_count,
|
||||
count(DISTINCT ROW(s.store_code, s.bill_no)) AS bill_count,
|
||||
count(DISTINCT s.store_code) AS store_count,
|
||||
sum(s.sales_quantity) AS sales_quantity,
|
||||
sum(s.gross_amount) AS gross_amount,
|
||||
sum(s.received_amount) AS received_amount,
|
||||
sum(s.dish_discount_amount) AS discount_amount,
|
||||
round(sum(s.dish_discount_amount) / NULLIF(sum(s.gross_amount), 0) * 100, 2) AS discount_rate_pct,
|
||||
round(sum(s.received_amount) / NULLIF(sum(sum(s.received_amount)) OVER (), 0) * 100, 2) AS revenue_share_pct
|
||||
FROM analytics.fn_dish_sales(p_month) s
|
||||
GROUP BY s.category_level1, s.category_level2
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 4. fn_dish_member_sku(p_month) — 替换 dish_member_sku_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_member_sku(p_month date)
|
||||
RETURNS TABLE (
|
||||
member_id text,
|
||||
dish_name text,
|
||||
order_count bigint,
|
||||
purchase_days bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
received_amount numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
s.member_id,
|
||||
s.dish_name,
|
||||
count(DISTINCT ROW(s.store_code, s.bill_no)) AS order_count,
|
||||
count(DISTINCT s.business_date) AS purchase_days,
|
||||
count(DISTINCT s.store_code) AS store_count,
|
||||
sum(s.sales_quantity) AS sales_quantity,
|
||||
sum(s.received_amount) AS received_amount
|
||||
FROM analytics.fn_dish_sales(p_month) s
|
||||
WHERE s.member_id IS NOT NULL AND s.dish_name IS NOT NULL AND s.sales_quantity > 0
|
||||
GROUP BY s.member_id, s.dish_name
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 5. fn_dish_store_sku(p_month) — 替换 dish_store_sku_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_store_sku(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text,
|
||||
store_name text,
|
||||
dish_name text,
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
bill_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
s.store_code,
|
||||
min(s.store_name) AS store_name,
|
||||
s.dish_name,
|
||||
min(s.category_level1) AS category_level1,
|
||||
min(s.category_level2) AS category_level2,
|
||||
count(DISTINCT s.bill_no) AS bill_count,
|
||||
sum(s.sales_quantity) AS sales_quantity,
|
||||
sum(s.gross_amount) AS gross_amount,
|
||||
sum(s.received_amount) AS received_amount,
|
||||
sum(s.dish_discount_amount) AS discount_amount,
|
||||
round(sum(s.dish_discount_amount) / NULLIF(sum(s.gross_amount), 0) * 100, 2) AS discount_rate_pct
|
||||
FROM analytics.fn_dish_sales(p_month) s
|
||||
WHERE s.dish_name IS NOT NULL
|
||||
GROUP BY s.store_code, s.dish_name
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 6. fn_dish_pair_summary(p_month) — 替换 dish_pair_summary_april (普通表)
|
||||
-- 注意: 此表由外部脚本填充,函数版本从 dish_sales_details 实时计算
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_pair_summary(p_month date)
|
||||
RETURNS TABLE (
|
||||
dish_a text,
|
||||
dish_b text,
|
||||
pair_count bigint
|
||||
) AS $$
|
||||
WITH pairs AS (
|
||||
SELECT
|
||||
LEAST(a.dish_name, b.dish_name) AS dish_a,
|
||||
GREATEST(a.dish_name, b.dish_name) AS dish_b,
|
||||
count(DISTINCT a.bill_no) AS pair_count
|
||||
FROM dish_sales_details a
|
||||
JOIN dish_sales_details b
|
||||
ON a.store_code = b.store_code
|
||||
AND a.bill_no = b.bill_no
|
||||
AND a.dish_name < b.dish_name
|
||||
WHERE a.opened_at >= p_month
|
||||
AND a.opened_at < p_month + INTERVAL '1 month'
|
||||
AND b.opened_at >= p_month
|
||||
AND b.opened_at < p_month + INTERVAL '1 month'
|
||||
AND a.dish_name IS NOT NULL
|
||||
AND b.dish_name IS NOT NULL
|
||||
GROUP BY LEAST(a.dish_name, b.dish_name), GREATEST(a.dish_name, b.dish_name)
|
||||
)
|
||||
SELECT dish_a, dish_b, pair_count
|
||||
FROM pairs
|
||||
ORDER BY pair_count DESC
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 7. fn_c_sku_governance(p_month) — 替换 v_c_sku_governance_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_c_sku_governance(p_month date)
|
||||
RETURNS TABLE (
|
||||
dish_name text,
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
detail_rows bigint,
|
||||
bill_count bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric,
|
||||
realized_unit_price numeric,
|
||||
revenue_share_pct numeric,
|
||||
cumulative_revenue_share numeric,
|
||||
median_quantity numeric,
|
||||
median_revenue numeric,
|
||||
abc_class text,
|
||||
sales_quadrant text,
|
||||
item_types text,
|
||||
is_combo_header boolean,
|
||||
is_combo_component boolean,
|
||||
is_single_item boolean,
|
||||
dish_code text,
|
||||
cost_source_group_count bigint,
|
||||
cost_report_sales_amount numeric,
|
||||
theoretical_cost numeric,
|
||||
actual_cost numeric,
|
||||
cost_variance_amount numeric,
|
||||
cost_report_theoretical_cost_rate_pct numeric,
|
||||
cost_report_actual_cost_rate_pct numeric,
|
||||
material_count bigint,
|
||||
exclusive_material_count bigint,
|
||||
governance_group text,
|
||||
additional_risk text
|
||||
) AS $$
|
||||
WITH item_types AS (
|
||||
SELECT
|
||||
d.dish_name,
|
||||
string_agg(DISTINCT COALESCE(d.item_type, '未分类'), '、' ORDER BY (COALESCE(d.item_type, '未分类'))) AS item_types,
|
||||
bool_or(d.item_type = '套餐') AS is_combo_header,
|
||||
bool_or(d.item_type = '套餐明细菜') AS is_combo_component,
|
||||
bool_or(d.item_type = '单点') AS is_single_item
|
||||
FROM dish_sales_details d
|
||||
WHERE d.opened_at >= p_month AND d.opened_at < p_month + INTERVAL '1 month'
|
||||
GROUP BY d.dish_name
|
||||
), material_usage AS (
|
||||
SELECT
|
||||
m.material_name,
|
||||
count(DISTINCT m.dish_name) AS used_by_dish_count
|
||||
FROM analytics.v_dish_cost_analysis_latest_material_detail m
|
||||
GROUP BY m.material_name
|
||||
), material_profile AS (
|
||||
SELECT
|
||||
d.dish_name,
|
||||
count(DISTINCT d.material_name) AS material_count,
|
||||
count(DISTINCT d.material_name) FILTER (WHERE u.used_by_dish_count = 1) AS exclusive_material_count
|
||||
FROM analytics.v_dish_cost_analysis_latest_material_detail d
|
||||
JOIN material_usage u USING (material_name)
|
||||
GROUP BY d.dish_name
|
||||
)
|
||||
SELECT
|
||||
c.dish_name,
|
||||
c.category_level1,
|
||||
c.category_level2,
|
||||
c.detail_rows,
|
||||
c.bill_count,
|
||||
c.store_count,
|
||||
c.sales_quantity,
|
||||
c.gross_amount,
|
||||
c.received_amount,
|
||||
c.discount_amount,
|
||||
c.discount_rate_pct,
|
||||
c.realized_unit_price,
|
||||
c.revenue_share_pct,
|
||||
c.cumulative_revenue_share,
|
||||
c.median_quantity,
|
||||
c.median_revenue,
|
||||
c.abc_class,
|
||||
c.sales_quadrant,
|
||||
t.item_types,
|
||||
t.is_combo_header,
|
||||
t.is_combo_component,
|
||||
t.is_single_item,
|
||||
cost.dish_code,
|
||||
cost.source_group_count AS cost_source_group_count,
|
||||
cost.sales_amount AS cost_report_sales_amount,
|
||||
cost.theoretical_cost,
|
||||
cost.actual_cost,
|
||||
cost.cost_variance_amount,
|
||||
cost.theoretical_cost_rate_pct AS cost_report_theoretical_cost_rate_pct,
|
||||
cost.actual_cost_rate_pct AS cost_report_actual_cost_rate_pct,
|
||||
COALESCE(mp.material_count, 0) AS material_count,
|
||||
COALESCE(mp.exclusive_material_count, 0) AS exclusive_material_count,
|
||||
CASE
|
||||
WHEN c.received_amount <= 0 AND COALESCE(t.is_combo_header, false) THEN 'T1-套餐/技术项目治理'
|
||||
WHEN c.received_amount <= 0 THEN 'T2-零收入单点核查'
|
||||
WHEN c.store_count = 1 AND c.bill_count < 30 AND c.received_amount < 1000 THEN 'S1-首批停用评审'
|
||||
WHEN c.store_count <= 3 AND c.bill_count < 60 AND c.received_amount < 3000 THEN 'S2-区域低效评审'
|
||||
WHEN c.store_count > 3 AND c.bill_count < 30 AND c.received_amount < 1000 THEN 'S3-铺店不动销评审'
|
||||
WHEN c.sales_quadrant IN ('明星菜品', '潜力菜品') THEN 'K1-保留并优化'
|
||||
ELSE 'K2-继续观察'
|
||||
END AS governance_group,
|
||||
CASE
|
||||
WHEN COALESCE(mp.exclusive_material_count, 0) > 0 AND c.received_amount < 3000 THEN '高:低收入且占用独有原料'
|
||||
WHEN cost.cost_variance_amount > 0 AND cost.actual_cost > (cost.theoretical_cost * 1.2) THEN '高:成本报表显示明显超理论'
|
||||
WHEN c.discount_rate_pct >= 35 THEN '中:高折扣依赖'
|
||||
ELSE '常规'
|
||||
END AS additional_risk
|
||||
FROM analytics.fn_dish_sku_abc(p_month) c
|
||||
LEFT JOIN item_types t USING (dish_name)
|
||||
LEFT JOIN analytics.v_dish_cost_analysis_latest_dish_rollup cost USING (dish_name)
|
||||
LEFT JOIN material_profile mp USING (dish_name)
|
||||
WHERE c.abc_class = 'C-长尾'
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
@@ -0,0 +1,315 @@
|
||||
-- ============================================================
|
||||
-- Phase 6: 选址/空间参数化函数
|
||||
-- 替换 _april 后缀的选址/空间视图为参数化函数
|
||||
-- 依赖: fn_store_area_efficiency(p_month) 已在 Phase 2 中创建
|
||||
-- ============================================================
|
||||
|
||||
-- 1. fn_store_site_profile(p_month) — 替换 v_store_site_profile_april
|
||||
-- 依赖: fn_store_area_efficiency
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_site_profile(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text, store_name text, bill_count bigint, active_days bigint,
|
||||
received numeric, avg_daily_received numeric, avg_bill_value numeric,
|
||||
discount_rate_pct numeric, theoretical_margin_pct numeric, member_bill_share_pct numeric,
|
||||
items_per_bill numeric, skus_per_bill numeric, delivery_bill_share_pct numeric,
|
||||
noodle_snack_attach_pct numeric, noodle_drink_attach_pct numeric, noodle_cold_attach_pct numeric,
|
||||
combo_bill_share_pct numeric, theoretical_cost numeric, actual_food_cost numeric,
|
||||
food_cost_variance numeric, theoretical_cost_rate_pct numeric, actual_food_cost_rate_pct numeric,
|
||||
variance_to_theoretical_pct numeric, comparison_status text, variance_level text,
|
||||
identified_members bigint, repeat_rate_pct numeric, repeat_revenue_share_pct numeric,
|
||||
benchmark_score numeric, meituan_received numeric, taobao_received numeric, jd_received numeric,
|
||||
combined_platform_cost_rate_pct numeric, business_type text, scale_tier text,
|
||||
problem_count bigint, problem_combination text, action_priority text,
|
||||
area_sqm numeric, business_address text, city text, district text,
|
||||
latitude_gcj02 double precision, longitude_gcj02 double precision,
|
||||
open_date date, lease_expiry_date date, store_age_years numeric,
|
||||
monthly_received_per_sqm numeric, daily_received_per_sqm numeric,
|
||||
estimated_inventory_days numeric, site_scene text, floor_type text,
|
||||
area_band text, age_band text
|
||||
) AS $$
|
||||
SELECT
|
||||
a.store_code, a.store_name, a.bill_count, a.active_days,
|
||||
a.received, a.avg_daily_received, a.avg_bill_value,
|
||||
a.discount_rate_pct, a.theoretical_margin_pct, a.member_bill_share_pct,
|
||||
a.items_per_bill, a.skus_per_bill, a.delivery_bill_share_pct,
|
||||
a.noodle_snack_attach_pct, a.noodle_drink_attach_pct, a.noodle_cold_attach_pct,
|
||||
a.combo_bill_share_pct, a.theoretical_cost, a.actual_food_cost,
|
||||
a.food_cost_variance, a.theoretical_cost_rate_pct, a.actual_food_cost_rate_pct,
|
||||
a.variance_to_theoretical_pct, a.comparison_status, a.variance_level,
|
||||
a.identified_members, a.repeat_rate_pct, a.repeat_revenue_share_pct,
|
||||
a.benchmark_score, a.meituan_received, a.taobao_received, a.jd_received,
|
||||
a.combined_platform_cost_rate_pct, a.business_type, a.scale_tier,
|
||||
a.problem_count, a.problem_combination, a.action_priority,
|
||||
a.area_sqm, a.business_address, a.city, a.district,
|
||||
a.latitude_gcj02, a.longitude_gcj02,
|
||||
a.open_date, a.lease_expiry_date, a.store_age_years,
|
||||
a.monthly_received_per_sqm, a.daily_received_per_sqm,
|
||||
a.estimated_inventory_days,
|
||||
CASE
|
||||
WHEN a.business_type = '特殊业态' THEN '特殊业态'
|
||||
WHEN a.business_address ~ '机场|航站楼' THEN '交通枢纽'
|
||||
WHEN a.business_address ~ '大学|食堂|档口' THEN '校园档口'
|
||||
WHEN a.business_address ~ '总部|科技园|产业园|创业园|商务楼|写字楼|信息产业基地|生命科学园|自贸试验区|经海|荣华' THEN '办公园区'
|
||||
WHEN a.business_address ~ '商场|商城|购物|超市|万科|龙湖|大悦|搜秀|美食城|商业大厦|商铺' THEN '商场商业体'
|
||||
WHEN a.business_address ~ '社区|小区|家园|里|园一区|园东街' THEN '社区居民'
|
||||
ELSE '街边综合'
|
||||
END AS site_scene,
|
||||
CASE
|
||||
WHEN a.business_address ~ '地下一层|负一层|-1层|-1至|B1|b1' THEN '地下层'
|
||||
WHEN a.business_address ~ '二层|2层|四层|4层|4F|五层|5层|23层' THEN '非首层'
|
||||
WHEN a.business_address ~ '一层|1层|底商' THEN '首层'
|
||||
ELSE '楼层不明'
|
||||
END AS floor_type,
|
||||
CASE
|
||||
WHEN a.area_sqm IS NULL THEN '面积缺失'
|
||||
WHEN a.area_sqm <= 180 THEN '≤180㎡'
|
||||
WHEN a.area_sqm <= 250 THEN '181-250㎡'
|
||||
WHEN a.area_sqm <= 350 THEN '251-350㎡'
|
||||
WHEN a.area_sqm <= 500 THEN '351-500㎡'
|
||||
ELSE '>500㎡'
|
||||
END AS area_band,
|
||||
CASE
|
||||
WHEN a.store_age_years IS NULL THEN '店龄缺失'
|
||||
WHEN a.store_age_years < 1 THEN '新店<1年'
|
||||
WHEN a.store_age_years < 3 THEN '成长期1-3年'
|
||||
WHEN a.store_age_years < 8 THEN '成熟期3-8年'
|
||||
ELSE '老店≥8年'
|
||||
END AS age_band
|
||||
FROM analytics.fn_store_area_efficiency(p_month) a
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 2. fn_store_spatial_pairs(p_month) — 替换 v_store_spatial_pairs_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_spatial_pairs(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code_a text, store_name_a text, store_code_b text, store_name_b text,
|
||||
district_a text, district_b text, scene_a text, scene_b text,
|
||||
priority_a text, priority_b text, received_a numeric, received_b numeric,
|
||||
sqm_efficiency_a numeric, sqm_efficiency_b numeric, distance_km double precision,
|
||||
proximity_level text
|
||||
) AS $$
|
||||
WITH physical AS (
|
||||
SELECT store_code, store_name, district, site_scene, action_priority,
|
||||
received, monthly_received_per_sqm, latitude_gcj02 AS lat, longitude_gcj02 AS lon
|
||||
FROM analytics.fn_store_site_profile(p_month)
|
||||
WHERE latitude_gcj02 IS NOT NULL AND longitude_gcj02 IS NOT NULL
|
||||
), pairs AS (
|
||||
SELECT
|
||||
a.store_code AS store_code_a, a.store_name AS store_name_a,
|
||||
b.store_code AS store_code_b, b.store_name AS store_name_b,
|
||||
a.district AS district_a, b.district AS district_b,
|
||||
a.site_scene AS scene_a, b.site_scene AS scene_b,
|
||||
a.action_priority AS priority_a, b.action_priority AS priority_b,
|
||||
a.received AS received_a, b.received AS received_b,
|
||||
a.monthly_received_per_sqm AS sqm_efficiency_a, b.monthly_received_per_sqm AS sqm_efficiency_b,
|
||||
6371.0 * acos(LEAST(1.0, GREATEST(-1.0,
|
||||
cos(radians(a.lat)) * cos(radians(b.lat)) * cos(radians(b.lon - a.lon))
|
||||
+ sin(radians(a.lat)) * sin(radians(b.lat))
|
||||
))) AS distance_km
|
||||
FROM physical a JOIN physical b ON a.store_code < b.store_code
|
||||
)
|
||||
SELECT
|
||||
pairs.store_code_a, pairs.store_name_a, pairs.store_code_b, pairs.store_name_b,
|
||||
pairs.district_a, pairs.district_b, pairs.scene_a, pairs.scene_b,
|
||||
pairs.priority_a, pairs.priority_b, pairs.received_a, pairs.received_b,
|
||||
pairs.sqm_efficiency_a, pairs.sqm_efficiency_b, pairs.distance_km,
|
||||
CASE
|
||||
WHEN pairs.distance_km < 1 THEN '高度重叠<1km'
|
||||
WHEN pairs.distance_km < 2 THEN '较高重叠1-2km'
|
||||
WHEN pairs.distance_km < 3 THEN '观察2-3km'
|
||||
ELSE '相对独立≥3km'
|
||||
END AS proximity_level
|
||||
FROM pairs
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 3. fn_store_nearest_neighbor(p_month) — 替换 v_store_nearest_neighbor_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_nearest_neighbor(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text, store_name text, nearest_store_code text, nearest_store_name text,
|
||||
nearest_distance_km numeric, nearest_proximity_level text
|
||||
) AS $$
|
||||
WITH directed AS (
|
||||
SELECT store_code_a AS store_code, store_name_a AS store_name,
|
||||
store_code_b AS nearest_store_code, store_name_b AS nearest_store_name, distance_km
|
||||
FROM analytics.fn_store_spatial_pairs(p_month)
|
||||
UNION ALL
|
||||
SELECT store_code_b, store_name_b, store_code_a, store_name_a, distance_km
|
||||
FROM analytics.fn_store_spatial_pairs(p_month)
|
||||
), ranked AS (
|
||||
SELECT store_code, store_name, nearest_store_code, nearest_store_name, distance_km,
|
||||
row_number() OVER (PARTITION BY store_code ORDER BY distance_km) AS rn
|
||||
FROM directed
|
||||
)
|
||||
SELECT store_code, store_name, nearest_store_code, nearest_store_name,
|
||||
round(distance_km::numeric, 2) AS nearest_distance_km,
|
||||
CASE
|
||||
WHEN distance_km < 1 THEN '高度重叠<1km'
|
||||
WHEN distance_km < 2 THEN '较高重叠1-2km'
|
||||
WHEN distance_km < 3 THEN '观察2-3km'
|
||||
ELSE '相对独立≥3km'
|
||||
END AS nearest_proximity_level
|
||||
FROM ranked WHERE rn = 1
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 4. fn_store_site_replication(p_month) — 替换 v_store_site_replication_score_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_site_replication(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text, store_name text, bill_count bigint, active_days bigint,
|
||||
received numeric, avg_daily_received numeric, avg_bill_value numeric,
|
||||
discount_rate_pct numeric, theoretical_margin_pct numeric, member_bill_share_pct numeric,
|
||||
items_per_bill numeric, skus_per_bill numeric, delivery_bill_share_pct numeric,
|
||||
noodle_snack_attach_pct numeric, noodle_drink_attach_pct numeric, noodle_cold_attach_pct numeric,
|
||||
combo_bill_share_pct numeric, theoretical_cost numeric, actual_food_cost numeric,
|
||||
food_cost_variance numeric, theoretical_cost_rate_pct numeric, actual_food_cost_rate_pct numeric,
|
||||
variance_to_theoretical_pct numeric, comparison_status text, variance_level text,
|
||||
identified_members bigint, repeat_rate_pct numeric, repeat_revenue_share_pct numeric,
|
||||
benchmark_score numeric, meituan_received numeric, taobao_received numeric, jd_received numeric,
|
||||
combined_platform_cost_rate_pct numeric, business_type text, scale_tier text,
|
||||
problem_count bigint, problem_combination text, action_priority text,
|
||||
area_sqm numeric, business_address text, city text, district text,
|
||||
latitude_gcj02 double precision, longitude_gcj02 double precision,
|
||||
open_date date, lease_expiry_date date, store_age_years numeric,
|
||||
monthly_received_per_sqm numeric, daily_received_per_sqm numeric,
|
||||
estimated_inventory_days numeric, site_scene text, floor_type text,
|
||||
area_band text, age_band text,
|
||||
nearest_store_code text, nearest_store_name text, nearest_distance_km numeric,
|
||||
sqm_score double precision, daily_score double precision, repeat_score double precision,
|
||||
discount_score double precision, cost_score double precision, platform_score double precision,
|
||||
execution_score numeric, site_replication_score numeric,
|
||||
replication_recommendation text, spatial_recommendation text
|
||||
) AS $$
|
||||
WITH eligible AS (
|
||||
SELECT
|
||||
p.*, n.nearest_store_code, n.nearest_store_name, n.nearest_distance_km,
|
||||
percent_rank() OVER (ORDER BY p.monthly_received_per_sqm) AS sqm_score,
|
||||
percent_rank() OVER (ORDER BY p.avg_daily_received) AS daily_score,
|
||||
percent_rank() OVER (ORDER BY p.repeat_rate_pct NULLS FIRST) AS repeat_score,
|
||||
1.0 - percent_rank() OVER (ORDER BY p.discount_rate_pct) AS discount_score,
|
||||
1.0 - percent_rank() OVER (ORDER BY p.actual_food_cost_rate_pct) AS cost_score,
|
||||
1.0 - percent_rank() OVER (ORDER BY p.combined_platform_cost_rate_pct) AS platform_score,
|
||||
GREATEST(0, 1 - p.problem_count / 6.0) AS execution_score
|
||||
FROM analytics.fn_store_site_profile(p_month) p
|
||||
LEFT JOIN analytics.fn_store_nearest_neighbor(p_month) n USING (store_code, store_name)
|
||||
WHERE p.business_type = '标准门店' AND p.received > 0 AND p.area_sqm IS NOT NULL
|
||||
), scored AS (
|
||||
SELECT eligible.*,
|
||||
round((0.30 * sqm_score + 0.20 * daily_score + 0.15 * repeat_score
|
||||
+ 0.10 * discount_score + 0.10 * cost_score + 0.10 * platform_score
|
||||
+ 0.05 * execution_score::double precision)::numeric * 100, 2) AS site_replication_score
|
||||
FROM eligible
|
||||
)
|
||||
SELECT scored.*,
|
||||
CASE
|
||||
WHEN site_replication_score >= 75 AND problem_count <= 1 THEN '优先提炼选址原型'
|
||||
WHEN site_replication_score >= 60 THEN '可作为同类参考'
|
||||
WHEN site_replication_score < 40 THEN '不宜作为选址标杆'
|
||||
ELSE '观察验证'
|
||||
END AS replication_recommendation,
|
||||
CASE
|
||||
WHEN nearest_distance_km >= 3 AND site_replication_score >= 70 THEN '高表现且周边相对独立,可研究相似商圈扩张'
|
||||
WHEN nearest_distance_km < 1.5 THEN '邻店较近,新址需重点防止同店分流'
|
||||
ELSE '常规评估'
|
||||
END AS spatial_recommendation
|
||||
FROM scored
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 5. fn_store_overlap_risk(p_month) — 替换 v_store_location_overlap_risk_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_overlap_risk(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code_a text, store_name_a text, store_code_b text, store_name_b text,
|
||||
district_a text, district_b text, scene_a text, scene_b text,
|
||||
priority_a text, priority_b text, received_a numeric, received_b numeric,
|
||||
sqm_efficiency_a numeric, sqm_efficiency_b numeric, distance_km double precision,
|
||||
proximity_level text, problem_count_a bigint, problem_count_b bigint,
|
||||
overlap_risk text
|
||||
) AS $$
|
||||
SELECT
|
||||
p.store_code_a, p.store_name_a, p.store_code_b, p.store_name_b,
|
||||
p.district_a, p.district_b, p.scene_a, p.scene_b,
|
||||
p.priority_a, p.priority_b, p.received_a, p.received_b,
|
||||
p.sqm_efficiency_a, p.sqm_efficiency_b, p.distance_km, p.proximity_level,
|
||||
a.problem_count AS problem_count_a, b.problem_count AS problem_count_b,
|
||||
CASE
|
||||
WHEN p.distance_km < 1 AND (a.action_priority LIKE 'P0%' OR b.action_priority LIKE 'P0%'
|
||||
OR a.action_priority = 'P1-重点整改' OR b.action_priority = 'P1-重点整改') THEN '高风险:距离近且至少一家经营承压'
|
||||
WHEN p.distance_km < 1.5 THEN '中风险:需核查客群和配送圈重叠'
|
||||
ELSE '观察'
|
||||
END AS overlap_risk
|
||||
FROM analytics.fn_store_spatial_pairs(p_month) p
|
||||
JOIN analytics.fn_store_site_profile(p_month) a ON a.store_code = p.store_code_a
|
||||
JOIN analytics.fn_store_site_profile(p_month) b ON b.store_code = p.store_code_b
|
||||
WHERE p.distance_km < 3
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 6. fn_district_site_benchmark(p_month) — 替换 v_district_site_benchmark_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_district_site_benchmark(p_month date)
|
||||
RETURNS TABLE (
|
||||
city text, district text, store_count bigint, avg_area_sqm numeric,
|
||||
total_received numeric, avg_received numeric, median_received numeric,
|
||||
avg_received_per_sqm numeric, avg_bill_value numeric, avg_discount_rate_pct numeric,
|
||||
avg_repeat_rate_pct numeric, avg_actual_cost_rate_pct numeric,
|
||||
avg_platform_cost_rate_pct numeric, p0_count bigint, p1_count bigint
|
||||
) AS $$
|
||||
SELECT
|
||||
city, district,
|
||||
count(*) AS store_count,
|
||||
round(avg(area_sqm), 1) AS avg_area_sqm,
|
||||
round(sum(received), 2) AS total_received,
|
||||
round(avg(received), 2) AS avg_received,
|
||||
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY received::double precision)::numeric, 2) AS median_received,
|
||||
round(avg(monthly_received_per_sqm), 2) AS avg_received_per_sqm,
|
||||
round(avg(avg_bill_value), 2) AS avg_bill_value,
|
||||
round(avg(discount_rate_pct), 2) AS avg_discount_rate_pct,
|
||||
round(avg(repeat_rate_pct), 2) AS avg_repeat_rate_pct,
|
||||
round(avg(actual_food_cost_rate_pct) FILTER (WHERE comparison_status = '可比'), 2) AS avg_actual_cost_rate_pct,
|
||||
round(avg(combined_platform_cost_rate_pct), 2) AS avg_platform_cost_rate_pct,
|
||||
count(*) FILTER (WHERE action_priority LIKE 'P0%') AS p0_count,
|
||||
count(*) FILTER (WHERE action_priority = 'P1-重点整改') AS p1_count
|
||||
FROM analytics.fn_store_site_profile(p_month)
|
||||
WHERE business_type = '标准门店' AND received > 0
|
||||
GROUP BY city, district
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 7. fn_site_segment_benchmark(p_month) — 替换 v_site_segment_benchmark_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_site_segment_benchmark(p_month date)
|
||||
RETURNS TABLE (
|
||||
site_scene text, area_band text, store_count bigint, avg_area_sqm numeric,
|
||||
avg_received numeric, median_received numeric, avg_received_per_sqm numeric,
|
||||
median_received_per_sqm numeric, avg_bill_value numeric, avg_discount_rate_pct numeric,
|
||||
avg_repeat_rate_pct numeric, avg_actual_cost_rate_pct numeric,
|
||||
avg_delivery_share_pct numeric, avg_drink_attach_pct numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
site_scene, area_band,
|
||||
count(*) AS store_count,
|
||||
round(avg(area_sqm), 1) AS avg_area_sqm,
|
||||
round(avg(received), 2) AS avg_received,
|
||||
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY received::double precision)::numeric, 2) AS median_received,
|
||||
round(avg(monthly_received_per_sqm), 2) AS avg_received_per_sqm,
|
||||
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY monthly_received_per_sqm::double precision)::numeric, 2) AS median_received_per_sqm,
|
||||
round(avg(avg_bill_value), 2) AS avg_bill_value,
|
||||
round(avg(discount_rate_pct), 2) AS avg_discount_rate_pct,
|
||||
round(avg(repeat_rate_pct), 2) AS avg_repeat_rate_pct,
|
||||
round(avg(actual_food_cost_rate_pct) FILTER (WHERE comparison_status = '可比'), 2) AS avg_actual_cost_rate_pct,
|
||||
round(avg(delivery_bill_share_pct), 2) AS avg_delivery_share_pct,
|
||||
round(avg(noodle_drink_attach_pct), 2) AS avg_drink_attach_pct
|
||||
FROM analytics.fn_store_site_profile(p_month)
|
||||
WHERE business_type = '标准门店' AND received > 0 AND area_sqm IS NOT NULL
|
||||
GROUP BY site_scene, area_band
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 8. fn_dish_member_repeat(p_month) — 替换 v_dish_member_repeat_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_member_repeat(p_month date)
|
||||
RETURNS TABLE (
|
||||
dish_name text, purchasing_members bigint, repeat_members bigint,
|
||||
repeat_member_rate_pct numeric, avg_member_orders numeric, member_received_amount numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
dish_name,
|
||||
count(*) AS purchasing_members,
|
||||
count(*) FILTER (WHERE order_count >= 2) AS repeat_members,
|
||||
round(count(*) FILTER (WHERE order_count >= 2)::numeric / NULLIF(count(*), 0) * 100, 2) AS repeat_member_rate_pct,
|
||||
round(avg(order_count), 2) AS avg_member_orders,
|
||||
sum(received_amount) AS member_received_amount
|
||||
FROM analytics.fn_dish_member_sku(p_month)
|
||||
GROUP BY dish_name
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
@@ -0,0 +1,84 @@
|
||||
-- ============================================================
|
||||
-- Phase 8: mv_store_risk_rating 参数化函数
|
||||
-- 替换普通表 mv_store_risk_rating(4月快照)为参数化函数
|
||||
-- 依赖: bill_fact, v_anomaly_bills
|
||||
-- ============================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_risk_rating(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text,
|
||||
store_name text,
|
||||
bill_count bigint,
|
||||
active_days bigint,
|
||||
received numeric,
|
||||
avg_daily_received numeric,
|
||||
avg_bill_value numeric,
|
||||
avg_guest_value numeric,
|
||||
discount_rate_pct numeric,
|
||||
theoretical_margin_pct numeric,
|
||||
member_bill_share_pct numeric,
|
||||
anomaly_rate_pct numeric,
|
||||
risk_level text,
|
||||
primary_issue text
|
||||
) AS $$
|
||||
WITH base AS (
|
||||
SELECT
|
||||
b.store_code,
|
||||
b.store_name,
|
||||
count(*) AS bill_count,
|
||||
count(DISTINCT b.closed_at::date) AS active_days,
|
||||
sum(b.received_total) AS received,
|
||||
round(sum(b.received_total) / NULLIF(count(DISTINCT b.closed_at::date), 0), 2) AS avg_daily_received,
|
||||
round(sum(b.received_total) / count(*), 2) AS avg_bill_value,
|
||||
round(sum(b.received_total) / NULLIF(sum(b.guest_count), 0), 2) AS avg_guest_value,
|
||||
round(sum(b.discount_total) / NULLIF(sum(b.consumption), 0) * 100, 2) AS discount_rate_pct,
|
||||
round(sum(b.theoretical_profit) / NULLIF(sum(b.received_total), 0) * 100, 2) AS theoretical_margin_pct,
|
||||
round(count(*) FILTER (WHERE b.member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct
|
||||
FROM analytics.bill_fact b
|
||||
WHERE b.closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND b.closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY b.store_code, b.store_name
|
||||
),
|
||||
anomaly AS (
|
||||
SELECT
|
||||
a.store_code,
|
||||
round(count(*)::numeric / NULLIF(bc.bill_count, 0) * 100, 2) AS anomaly_rate_pct
|
||||
FROM analytics.v_anomaly_bills a
|
||||
JOIN (SELECT store_code, count(*) AS bill_count FROM analytics.bill_fact
|
||||
WHERE closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY store_code) bc ON a.store_code = bc.store_code
|
||||
WHERE a.closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND a.closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY a.store_code, bc.bill_count
|
||||
),
|
||||
combined AS (
|
||||
SELECT
|
||||
b.*,
|
||||
COALESCE(an.anomaly_rate_pct, 0) AS anomaly_rate_pct,
|
||||
CASE
|
||||
WHEN b.received IS NULL OR b.received = 0 THEN '绿色'
|
||||
WHEN b.theoretical_margin_pct < 68 OR b.discount_rate_pct > 25 OR COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '红色'
|
||||
WHEN b.theoretical_margin_pct >= 70 AND b.discount_rate_pct <= 22 AND COALESCE(an.anomaly_rate_pct, 0) <= 1.5 THEN '绿色'
|
||||
ELSE '黄色'
|
||||
END AS risk_level,
|
||||
CASE
|
||||
WHEN b.theoretical_margin_pct < 68 AND b.discount_rate_pct > 25 AND COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '毛利率低;优惠率高;异常率高'
|
||||
WHEN b.theoretical_margin_pct < 68 AND b.discount_rate_pct > 25 THEN '毛利率低;优惠率高'
|
||||
WHEN b.theoretical_margin_pct < 68 AND COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '毛利率低;异常率高'
|
||||
WHEN b.discount_rate_pct > 25 AND COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '优惠率高;异常率高'
|
||||
WHEN b.theoretical_margin_pct < 68 THEN '毛利率低'
|
||||
WHEN b.discount_rate_pct > 25 THEN '优惠率高'
|
||||
WHEN COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '异常率高'
|
||||
ELSE ''
|
||||
END AS primary_issue
|
||||
FROM base b
|
||||
LEFT JOIN anomaly an ON b.store_code = an.store_code
|
||||
)
|
||||
SELECT
|
||||
store_code, store_name, bill_count, active_days, received,
|
||||
avg_daily_received, avg_bill_value, avg_guest_value,
|
||||
discount_rate_pct, theoretical_margin_pct, member_bill_share_pct,
|
||||
anomaly_rate_pct, risk_level, primary_issue
|
||||
FROM combined
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
@@ -0,0 +1,177 @@
|
||||
-- ============================================================
|
||||
-- Phase 9: 全量视图参数化
|
||||
-- 替换 v_store_platform_economics, v_store_category_mix, v_store_member_opportunity
|
||||
-- 为按月参数化函数
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. fn_store_platform_economics(p_month)
|
||||
-- 替换 v_store_platform_economics(无月份过滤的全量聚合)
|
||||
-- 数据源: bill_records, 按月份过滤 c175
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_platform_economics(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text,
|
||||
store_name text,
|
||||
meituan_received numeric,
|
||||
meituan_discount numeric,
|
||||
meituan_commission numeric,
|
||||
taobao_received numeric,
|
||||
taobao_discount numeric,
|
||||
taobao_commission numeric,
|
||||
jd_received numeric,
|
||||
jd_discount numeric,
|
||||
jd_commission numeric,
|
||||
meituan_cost_rate_pct numeric,
|
||||
taobao_cost_rate_pct numeric,
|
||||
jd_cost_rate_pct numeric
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
SELECT
|
||||
NULLIF(bill_records.c002, '') AS store_code,
|
||||
NULLIF(bill_records.c003, '') AS store_name,
|
||||
sum(COALESCE(NULLIF(bill_records.c151, '')::numeric, 0)) AS meituan_received,
|
||||
sum(COALESCE(NULLIF(bill_records.c101, '')::numeric, 0)) AS meituan_discount,
|
||||
sum(COALESCE(NULLIF(bill_records.c097, '')::numeric, 0)) AS meituan_commission,
|
||||
sum(COALESCE(NULLIF(bill_records.c152, '')::numeric, 0)) AS taobao_received,
|
||||
sum(COALESCE(NULLIF(bill_records.c102, '')::numeric, 0)) AS taobao_discount,
|
||||
sum(COALESCE(NULLIF(bill_records.c098, '')::numeric, 0)) AS taobao_commission,
|
||||
sum(COALESCE(NULLIF(bill_records.c150, '')::numeric, 0)) AS jd_received,
|
||||
sum(COALESCE(NULLIF(bill_records.c099, '')::numeric, 0)) AS jd_discount,
|
||||
sum(COALESCE(NULLIF(bill_records.c100, '')::numeric, 0)) AS jd_commission,
|
||||
round((sum(COALESCE(NULLIF(bill_records.c101, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c097, '')::numeric, 0)))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c151, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c101, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c097, '')::numeric, 0)), 0) * 100, 2) AS meituan_cost_rate_pct,
|
||||
round((sum(COALESCE(NULLIF(bill_records.c102, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c098, '')::numeric, 0)))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c152, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c102, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c098, '')::numeric, 0)), 0) * 100, 2) AS taobao_cost_rate_pct,
|
||||
round((sum(COALESCE(NULLIF(bill_records.c099, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c100, '')::numeric, 0)))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c150, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c099, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c100, '')::numeric, 0)), 0) * 100, 2) AS jd_cost_rate_pct
|
||||
FROM bill_records
|
||||
WHERE NULLIF(bill_records.c005, '') IS NOT NULL
|
||||
AND bill_records.c175 IS NOT NULL AND bill_records.c175 != ''
|
||||
AND bill_records.c175::timestamp >= analytics.fn_month_start_ts(p_month)
|
||||
AND bill_records.c175::timestamp < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY NULLIF(bill_records.c002, ''), NULLIF(bill_records.c003, '');
|
||||
$function$;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. fn_store_category_mix(p_month)
|
||||
-- 替换 v_store_category_mix(无月份过滤的全量聚合)
|
||||
-- 数据源: bill_records, 按月份过滤 c175
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_category_mix(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text,
|
||||
store_name text,
|
||||
consumption numeric,
|
||||
lanzhou_noodle numeric,
|
||||
western_staple numeric,
|
||||
delivery_package numeric,
|
||||
night_bbq numeric,
|
||||
cold_dishes numeric,
|
||||
silk_road_food numeric,
|
||||
noodle_share_pct numeric,
|
||||
delivery_package_share_pct numeric,
|
||||
top_category_share_pct numeric,
|
||||
top_category text
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
SELECT
|
||||
NULLIF(bill_records.c002, '') AS store_code,
|
||||
NULLIF(bill_records.c003, '') AS store_name,
|
||||
sum(COALESCE(NULLIF(bill_records.c009, '')::numeric, 0)) AS consumption,
|
||||
sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0)) AS lanzhou_noodle,
|
||||
sum(COALESCE(NULLIF(bill_records.c016, '')::numeric, 0)) AS western_staple,
|
||||
sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0)) AS delivery_package,
|
||||
sum(COALESCE(NULLIF(bill_records.c013, '')::numeric, 0)) AS night_bbq,
|
||||
sum(COALESCE(NULLIF(bill_records.c015, '')::numeric, 0)) AS cold_dishes,
|
||||
sum(COALESCE(NULLIF(bill_records.c019, '')::numeric, 0)) AS silk_road_food,
|
||||
round(sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c009, '')::numeric, 0)), 0) * 100, 2) AS noodle_share_pct,
|
||||
round(sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c009, '')::numeric, 0)), 0) * 100, 2) AS delivery_package_share_pct,
|
||||
round(GREATEST(
|
||||
sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c016, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c013, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c015, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c019, '')::numeric, 0))
|
||||
) / NULLIF(sum(COALESCE(NULLIF(bill_records.c009, '')::numeric, 0)), 0) * 100, 2) AS top_category_share_pct,
|
||||
CASE GREATEST(
|
||||
sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c016, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c013, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c015, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c019, '')::numeric, 0))
|
||||
)
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0)) THEN '兰州牛肉面'
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c016, '')::numeric, 0)) THEN '西部主食'
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0)) THEN '外卖套餐'
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c013, '')::numeric, 0)) THEN '夜市烧烤'
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c015, '')::numeric, 0)) THEN '爽口凉菜'
|
||||
ELSE '丝路美食'
|
||||
END AS top_category
|
||||
FROM bill_records
|
||||
WHERE NULLIF(bill_records.c005, '') IS NOT NULL
|
||||
AND bill_records.c175 IS NOT NULL AND bill_records.c175 != ''
|
||||
AND bill_records.c175::timestamp >= analytics.fn_month_start_ts(p_month)
|
||||
AND bill_records.c175::timestamp < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY NULLIF(bill_records.c002, ''), NULLIF(bill_records.c003, '');
|
||||
$function$;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. fn_store_member_opportunity(p_month)
|
||||
-- 替换 v_store_member_opportunity(无月份过滤的全量聚合)
|
||||
-- 数据源: analytics.bill_fact, 按月份过滤 closed_at
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_member_opportunity(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text,
|
||||
store_name text,
|
||||
bill_count bigint,
|
||||
received numeric,
|
||||
member_share_pct numeric,
|
||||
company_member_share_pct numeric,
|
||||
conversion_bill_scenario numeric,
|
||||
revenue_uplift_scenario numeric
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
WITH company AS (
|
||||
SELECT
|
||||
count(*) FILTER (WHERE bill_fact.member_id IS NOT NULL)::numeric / count(*)::numeric AS member_share,
|
||||
avg(bill_fact.received_total) FILTER (WHERE bill_fact.member_id IS NOT NULL) AS member_avg_bill,
|
||||
avg(bill_fact.received_total) FILTER (WHERE bill_fact.member_id IS NULL) AS nonmember_avg_bill
|
||||
FROM analytics.bill_fact
|
||||
WHERE bill_fact.closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND bill_fact.closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
), stores AS (
|
||||
SELECT
|
||||
bill_fact.store_code,
|
||||
bill_fact.store_name,
|
||||
count(*) AS bill_count,
|
||||
count(*) FILTER (WHERE bill_fact.member_id IS NOT NULL)::numeric / count(*)::numeric AS member_share,
|
||||
sum(bill_fact.received_total) AS received
|
||||
FROM analytics.bill_fact
|
||||
WHERE bill_fact.closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND bill_fact.closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY bill_fact.store_code, bill_fact.store_name
|
||||
)
|
||||
SELECT
|
||||
s.store_code,
|
||||
s.store_name,
|
||||
s.bill_count,
|
||||
round(s.received, 2) AS received,
|
||||
round(s.member_share * 100, 2) AS member_share_pct,
|
||||
round(c.member_share * 100, 2) AS company_member_share_pct,
|
||||
round(GREATEST(c.member_share - s.member_share, 0) * s.bill_count::numeric, 0) AS conversion_bill_scenario,
|
||||
round(GREATEST(c.member_share - s.member_share, 0) * s.bill_count::numeric * (c.member_avg_bill - c.nonmember_avg_bill), 2) AS revenue_uplift_scenario
|
||||
FROM stores s
|
||||
CROSS JOIN company c;
|
||||
$function$;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
# 2026年4月与5月数据导入文件差异说明
|
||||
|
||||
> 生成日期:2026-08-01
|
||||
> 数据源目录:`/Users/freedak/Documents/AIDashboard/西部马华数据分析/`
|
||||
> 对比口径:4月 `[2026-04-01, 2026-05-01)`;5月 `[2026-05-01, 2026-06-01)`
|
||||
|
||||
---
|
||||
|
||||
## 一、文件目录结构对比
|
||||
|
||||
### 1.1 4月目录结构
|
||||
|
||||
```
|
||||
4月/
|
||||
├── 营业费用分析.xls (54KB, 213列)
|
||||
├── 账单查询-菜品销售明细表/ (56个Excel + ZIP压缩包)
|
||||
├── 盘点倒挤成本报表2026年4月明细.xlsx (9.3MB, 35列)
|
||||
├── 菜品成本分析报表.xlsx (596KB)
|
||||
├── 全部门店-全部仓库-统计货品明细报表2026年4月1-15日.xlsx (51MB)
|
||||
├── 全部门店-全部仓库-统计货品明细报表2026年4月16-30日.xlsx (50MB)
|
||||
├── 中央厨房4月.rar
|
||||
├── 各店信息新.xls
|
||||
├── 91家门店5月工作计划.csv/xlsx
|
||||
└── [分析报告、SQL脚本等]
|
||||
```
|
||||
|
||||
### 1.2 5月目录结构
|
||||
|
||||
```
|
||||
5月/
|
||||
├── 2026年5月营业费用分析.xls (55KB, 213列)
|
||||
├── 2026年5月管理费用分析.xls (47KB, 213列) ← 新增
|
||||
├── 营业数据/
|
||||
│ ├── 2026年5月考勤数据表.xlsx (23KB)
|
||||
│ ├── 2026年5月菜品成本分析报表.xlsx (652KB)
|
||||
│ ├── 2026年5月薪资拆分明细表.xlsx (1.6MB, 98列)
|
||||
│ ├── 账单查询-菜品销售明细表/ (57个Excel) ← 多1个分片
|
||||
│ ├── 正餐事业部_全渠道订单明细.xlsx (3.7MB) ← 新增
|
||||
│ └── 正餐事业部_品项销售明细.xlsx (24MB) ← 新增
|
||||
├── 供应链数据/
|
||||
│ ├── 2026-05-01--2026-05-31 23_59_59采购货品明细.xlsx (7.7MB)
|
||||
│ ├── 4.13盘点倒挤成本报表2026年5月.xlsx (9.6MB)
|
||||
│ ├── 全部门店-全部仓库-统计货品明细报表2026年5月1-15日.xlsx (51MB)
|
||||
│ ├── 全部门店-全部仓库-统计货品明细报表2026年5月16-31日.xlsx (55MB)
|
||||
│ └── 采购订货单导出-20260730.zip (244KB) ← 新增
|
||||
└── 中央厨房/
|
||||
├── 按货品导出-货品实际与理论耗用.xlsx (552KB)
|
||||
├── 按配方导出-货品实际与理论耗用.xlsx (1.1MB)
|
||||
├── 2026-05-01--2026-05-31 23 59 59完工入库统计分析报表.xlsx (386KB)
|
||||
└── 加工单价分析报表.xlsx (53KB)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、数据文件详细对比
|
||||
|
||||
> 行数说明:下文同时存在“工作表行数”和“数据库导入行数”。工作表行数通常包含标题或表头,不能直接与数据库有效记录数相减;文中已尽量标明口径。
|
||||
|
||||
### 2.1 账单与菜品销售数据
|
||||
|
||||
| 指标 | 4月 | 5月 | 变化说明 |
|
||||
|---|---|---|---|
|
||||
| 分片数量 | 56个Excel | 57个Excel | +1个分片 |
|
||||
| 文件命名格式 | `账单查询-菜品销售明细表*.xlsx` | `账单查询-菜品销售明细表0730105605_*.xlsx` | 命名格式变化 |
|
||||
| 文件总大小 | ~490MB (ZIP) | ~523MB | +33MB |
|
||||
| 字段数 | 20列 | 20列 | 不变 |
|
||||
| 主要字段 | 门店、账单号、人数、开结账时间、菜品、销量、金额、实收金额 | 同4月 | 兼容 |
|
||||
|
||||
**关键差异**:
|
||||
- 5月文件命名增加了时间戳后缀(0730105605),需调整导入脚本的文件名匹配规则
|
||||
- 5月20列文件可同时生成**基础账单事实**和**菜品销售事实**,足以支持营收、账单数、客单价、菜品、品类、业务类型和时段分析
|
||||
- 4月另有17个196列主账单文件,原始约1,677,990行;5月当前目录没有同等扩展字段。因此会员、支付方式、营销方案、收银员、账单状态、理论成本等分析不能仅凭5月20列文件完整复现
|
||||
- 5月正餐事业部两份报表仅覆盖9家门店,应作为补充核验数据,不得与57个全公司分片重复累计
|
||||
- 4月源文件包含少量3月及5月1日边界记录,所有月份必须按业务时间半开区间过滤,不能按文件名直接归月
|
||||
|
||||
### 2.2 营业费用数据
|
||||
|
||||
| 指标 | 4月 | 5月 | 变化说明 |
|
||||
|---|---|---|---|
|
||||
| 文件 | `2026年4月营业费用分析.xls` | `2026年5月营业费用分析.xls` | 月份更新 |
|
||||
| 文件大小 | 54KB | 55KB | +1KB |
|
||||
| 行数 | 21行 | 18行 | -3行 |
|
||||
| 列数 | 213列 | 213列 | 不变 |
|
||||
| 费用科目数 | 19个 | 16个 | -3个 |
|
||||
| 合计金额 | 36,960,829.47元 | 48,775,884.64元 | +11,815,055.17元(科目范围变化,不宜直接解释为同口径费用增长) |
|
||||
|
||||
#### 费用科目变化
|
||||
|
||||
**4月独有科目(5个)**:
|
||||
| 科目编码 | 科目名称 | 可能原因 |
|
||||
|---|---|---|
|
||||
| 50301 | 刷卡手续费 | 可能合并到其他支付渠道科目 |
|
||||
| 50318 | 清洗费 | 可能改为其他费用项目 |
|
||||
| 50334 | 烟道清洗 | 可能改为其他费用项目 |
|
||||
| 50365 | 自送快递费 | 可能为临时性费用 |
|
||||
| 50383 | 外卖佣金 | 可能已包含在其他佣金科目 |
|
||||
|
||||
**5月新增科目(2个)**:
|
||||
| 科目编码 | 科目名称 | 说明 |
|
||||
|---|---|---|
|
||||
| 11901 | 备用金 | 资产类科目出现在费用报表中,需确认 |
|
||||
| 50384 | 维修费手工报销 | 替代原50315维修费的补充报销 |
|
||||
|
||||
**共同科目(14个)**:餐厅房租、水费、电费、燃气费、员工宿舍费用、暖气费、维修费、月薪员工工资、前厅/后厨员工工资、垃圾费、物业费、菜品提成、设备租赁费
|
||||
|
||||
### 2.3 管理费用数据
|
||||
|
||||
| 指标 | 4月 | 5月 | 变化说明 |
|
||||
|---|---|---|---|
|
||||
| 文件 | 不存在 | `2026年5月管理费用分析.xls` | **新增文件** |
|
||||
| 文件大小 | — | 47KB | — |
|
||||
| 行数 | — | 16行 | — |
|
||||
| 列数 | — | 213列 | — |
|
||||
| 合计金额 | — | 5,754,540.18元 | — |
|
||||
|
||||
**说明**:管理费用是5月新增的数据域,单独统计总部/管理层的费用,与门店营业费用是不同利润层级的数据。
|
||||
|
||||
### 2.4 库存倒挤成本数据
|
||||
|
||||
| 指标 | 4月 | 5月 | 变化说明 |
|
||||
|---|---|---|---|
|
||||
| 文件 | `4.13盘点倒挤成本报表2026年4月明细.xlsx` | `4.13盘点倒挤成本报表2026年5月.xlsx` | 月份更新 |
|
||||
| 文件大小 | 9.3MB | 9.6MB | +0.3MB |
|
||||
| 行数 | 源行54,974行,正式导入54,973行 | 工作表56,538行 | 工作表规模约增加1,564行,需按同口径复核 |
|
||||
| 列数 | 35列 | 35列 | 结构基本一致 |
|
||||
|
||||
### 2.5 菜品成本/BOM数据
|
||||
|
||||
| 指标 | 4月 | 5月 | 变化说明 |
|
||||
|---|---|---|---|
|
||||
| 文件 | `菜品成本分析报表.xlsx` | `2026年5月菜品成本分析报表.xlsx` | 月份更新 |
|
||||
| 文件大小 | 596KB | 652KB | +56KB |
|
||||
| 行数 | 工作簿数据区3,749行;解析出788条菜品汇总、3,568条原料明细 | 工作表4,146行(含标题/表头) | 工作表规模约增加397行 |
|
||||
| 列数 | 24列 | 24列 | 结构一致 |
|
||||
|
||||
**期间风险**:4月菜品成本导入记录中的期间字段为空。5月导入时必须明确写入 `report_period_start`、`report_period_end`,后续视图不得继续仅以 `max(import_id)` 判断“最新月份”。
|
||||
|
||||
### 2.6 薪资数据
|
||||
|
||||
| 指标 | 4月 | 5月 | 变化说明 |
|
||||
|---|---|---|---|
|
||||
| 文件 | 4月脱敏薪资明细 | `2026年5月薪资拆分明细表_北京西部马华餐饮有限公司.xlsx` | 月份更新、文件名变化 |
|
||||
| 文件大小 | 待确认 | 1.6MB | — |
|
||||
| 行数 | 3,596行 | 3,529行 | -67行 |
|
||||
| 列数 | 99列(8级组织) | 98列(3级成本中心) | **列数减少但组织层级变化** |
|
||||
| 新增字段 | — | 姓名、身份证等 | 需脱敏处理 |
|
||||
|
||||
**关键差异**:
|
||||
- 5月组织层级从8级压缩为3级成本中心
|
||||
- 列位置发生变化,现有导入脚本按固定列号读取会错位
|
||||
- 新增姓名、身份证等敏感字段,导入时需受限存储
|
||||
|
||||
### 2.7 考勤数据
|
||||
|
||||
| 指标 | 4月 | 5月 | 变化说明 |
|
||||
|---|---|---|---|
|
||||
| 文件 | 4月脱敏考勤明细 | `2026年5月考勤数据表.xlsx` | 月份更新、文件名变化 |
|
||||
| 行数 | 3,556行 | 10行 | **严重缺失** |
|
||||
| 列数 | 当前脚本假定33列:工号、岗位、部门及1—30日 | 工作表35列:工号、姓名、岗位、所在部门及1—31日 | 新增姓名列且多31日,固定列号脚本会错位 |
|
||||
| 覆盖范围 | 全部门店员工 | 仅总部少量人员 | **仅10名总部人员** |
|
||||
|
||||
**阻断说明**:5月考勤数据严重不完整(仅10条 vs 4月3556条),不能用于门店排班和人效分析。
|
||||
|
||||
### 2.8 配送/供应链数据
|
||||
|
||||
| 指标 | 4月 | 5月 | 变化说明 |
|
||||
|---|---|---|---|
|
||||
| 配送明细1-15日 | 工作表165,245行;导入165,244行 | 工作表165,272行 | 基本持平 |
|
||||
| 配送明细16-30/31日 | 工作表160,679行;导入160,678行 | 工作表178,949行 | 5月下半月多1天 |
|
||||
| 配送工作表合计 | 325,924行 | 344,221行 | +18,297行,约+5.61% |
|
||||
| 采购货品明细 | 不存在 | 7.7MB(30,555行×63列) | **新增** |
|
||||
| 采购订货单 | 不存在 | 244KB ZIP(71个.xls) | **新增,含跨月文件** |
|
||||
|
||||
### 2.9 中央厨房数据
|
||||
|
||||
| 数据域 | 4月 | 5月 | 变化说明 |
|
||||
|---|---|---|---|
|
||||
| 按货品实际与理论耗用 | 工作簿3,332行;导入3,329行 | 工作表3,396行×33列 | 小幅增加,结构兼容 |
|
||||
| 按配方实际与理论耗用 | 工作簿4,844行;导入4,842行 | 工作表4,948行×42列 | 小幅增加,结构兼容 |
|
||||
| 完工入库统计 | 工作簿1,721行;导入1,708行 | 工作表1,784行×43列 | 小幅增加,结构兼容 |
|
||||
| 加工单价 | 工作簿170行;导入157行 | 工作表179行×37列 | 小幅增加,结构兼容 |
|
||||
|
||||
---
|
||||
|
||||
## 三、文件格式兼容性总结
|
||||
|
||||
| 数据域 | 4月→5月兼容性 | 主要差异 | 风险等级 |
|
||||
|---|---|---|---|
|
||||
| 账单菜品明细 | 高 | 文件命名格式变化 | 低 |
|
||||
| 营业费用 | 中 | 科目集合变化、合计行位置变化 | 中 |
|
||||
| 管理费用 | 新增 | 无4月对照 | 中 |
|
||||
| 库存倒挤成本 | 高 | 月份更新,结构基本不变 | 低 |
|
||||
| 菜品成本/BOM | 高 | 月份更新,结构一致 | 低 |
|
||||
| 薪资 | 低 | 列位置变化、组织层级变化、新增敏感字段 | **高** |
|
||||
| 考勤 | 阻断 | 仅10条记录(vs 4月3556条) | **P0** |
|
||||
| 配送/采购 | 中高 | 采购数据新增,结构需验证 | 中 |
|
||||
| 中央厨房 | 高 | 月份更新,结构兼容 | 低 |
|
||||
| 正餐事业部补充 | 新增 | 独立数据域 | 低 |
|
||||
|
||||
### 3.1 按处理方式分类
|
||||
|
||||
- **规则基本可复用**:20列账单菜品明细、库存倒挤成本、菜品成本/BOM、中央厨房、配送。复用的前提是增加月份区间、源文件哈希、批次号和重复导入控制。
|
||||
- **必须调整后再导入**:营业费用、薪资、考勤、采购订货单。主要原因分别是科目行变化、表头位置变化、数据缺失及ZIP跨月。
|
||||
- **5月新增数据域**:管理费用、采购货品明细、采购订货单、正餐事业部补充报表。新增数据不得未经口径确认就并入原有事实表。
|
||||
- **5月当前缺少的完整能力**:与4月196列主账单相对应的会员、支付、营销、收银、账单状态和理论成本等扩展字段。
|
||||
|
||||
---
|
||||
|
||||
## 四、导入前需确认事项
|
||||
|
||||
### 4.1 P0级阻断(必须解决)
|
||||
|
||||
1. **考勤数据严重缺失**:5月仅10条记录,需补充完整门店考勤才能发布排班和人效模块
|
||||
|
||||
### 4.2 P1级风险(需调整导入规则)
|
||||
|
||||
1. **营业费用科目白名单**:需建立5月费用科目白名单,确认4月独有科目(刷卡手续费、清洗费等)的去向
|
||||
2. **薪资脚本改造**:5月列位置和组织层级变化,必须改为表头映射方式,不能按固定列号读取
|
||||
3. **管理费用独立域**:5月新增管理费用,需确认是否用于总部损益桥及分摊规则
|
||||
4. **采购订货单跨月**:ZIP包含4月和6月文件,需按业务日期筛选
|
||||
|
||||
### 4.3 P2级优化(建议)
|
||||
|
||||
1. **账单分片命名**:5月文件命名增加时间戳后缀,需更新导入脚本的文件名匹配规则
|
||||
2. **薪资敏感字段**:5月新增姓名、身份证等字段,导入时需受限存储或脱敏
|
||||
3. **正餐事业部报表去重**:两份补充表仅覆盖9家门店,只能独立核验,不可与全公司账单明细相加
|
||||
4. **采购事实落表**:采购货品明细可填充当前为空的采购入库事实,但应先确认单据状态、数量、含税金额和退货冲销口径
|
||||
|
||||
---
|
||||
|
||||
## 五、文件就绪度评估
|
||||
|
||||
| 等级 | 数据域 | 说明 |
|
||||
|---|---|---|
|
||||
| 绿色 | 账单菜品明细、库存成本、菜品成本、中央厨房、配送 | 可进入预检 |
|
||||
| 黄色 | 营业费用、管理费用、薪资、采购订货单、正餐事业部补充报表 | 需调整规则或确认范围 |
|
||||
| **红色** | **完整考勤** | **严重不完整,阻断排班和人效模块** |
|
||||
|
||||
---
|
||||
|
||||
## 六、差异文件清单
|
||||
|
||||
### 6.1 5月新增文件
|
||||
|
||||
- `2026年5月管理费用分析.xls` (47KB)
|
||||
- `营业数据/正餐事业部_全渠道订单明细_20260730_1112_1785381233074.xlsx` (3.7MB)
|
||||
- `营业数据/正餐事业部_品项销售明细_20260730_1128_1785382191936.xlsx` (24MB)
|
||||
- `供应链数据/2026-05-01--2026-05-31 23_59_59采购货品明细.xlsx` (7.7MB)
|
||||
- `供应链数据/采购订货单导出-20260730.zip` (244KB)
|
||||
|
||||
### 6.2 5月缺失/变化文件
|
||||
|
||||
| 4月文件 | 5月对应文件 | 变化 |
|
||||
|---|---|---|
|
||||
| `账单查询-菜品销售明细表.zip` (490MB) | `账单查询-菜品销售明细表/` (57个Excel, 523MB) | ZIP改为文件夹,增加1个分片 |
|
||||
| `菜品成本分析报表.xlsx` | `2026年5月菜品成本分析报表.xlsx` | 月份更新 |
|
||||
| `4.13盘点倒挤成本报表2026年4月明细.xlsx` | `4.13盘点倒挤成本报表2026年5月.xlsx` | 月份更新 |
|
||||
| `中央厨房4月.rar` | `中央厨房/` (4个Excel) | RAR改为文件夹,结构整理 |
|
||||
|
||||
### 6.3 5月需特别关注的文件
|
||||
|
||||
| 文件 | 关注原因 |
|
||||
|---|---|
|
||||
| `2026年5月考勤数据表.xlsx` | 仅10条记录,严重不完整 |
|
||||
| `2026年5月薪资拆分明细表.xlsx` | 组织层级变化,列位置变化 |
|
||||
| `2026年5月营业费用分析.xls` | 科目集合变化,合计行位置变化 |
|
||||
| `采购订货单导出-20260730.zip` | 含跨月文件,需按业务日期筛选 |
|
||||
|
||||
---
|
||||
|
||||
## 七、建议的5月导入文件清单
|
||||
|
||||
按优先级排序的推荐导入顺序:
|
||||
|
||||
1. **账单菜品明细**(57个Excel)- 基础营收数据,一次解析生成账单和菜品事实
|
||||
2. **库存倒挤成本** - 成本核算基础
|
||||
3. **菜品成本/BOM** - 成本分析
|
||||
4. **配送货品明细**(上下半月)- 供应链数据
|
||||
5. **采购货品明细** - 采购事实(新增)
|
||||
6. **中央厨房**(4个文件)- 加工成本
|
||||
7. **营业费用** - 费用分析(需调整导入规则)
|
||||
8. **管理费用** - 总部费用(新增,需确认用途)
|
||||
9. **正餐事业部补充报表** - 补充数据域(独立使用)
|
||||
10. **薪资** - 人事成本(需改造导入脚本)
|
||||
11. **考勤** - **暂不导入,等待完整数据**
|
||||
|
||||
---
|
||||
|
||||
## 八、4月与5月文件映射及处理结论
|
||||
|
||||
| 数据域 | 4月来源 | 5月来源 | 5月处理结论 |
|
||||
|---|---|---|---|
|
||||
| 完整主账单 | 17个196列主账单分片 | 当前未提供同等文件 | 不发布会员、支付、营销等完整分析 |
|
||||
| 基础账单/菜品销售 | 56个20列明细分片 | 57个20列明细分片 | 可复用解析,按5月业务时间过滤并去重 |
|
||||
| 库存倒挤成本 | 4月盘点倒挤文件 | 5月盘点倒挤文件 | 35列一致,可进入预检 |
|
||||
| 菜品成本/BOM | 4月菜品成本文件 | 5月菜品成本文件 | 24列一致,必须补齐报告期间 |
|
||||
| 营业费用 | 4月营业费用分析 | 5月营业费用分析 | 按科目编码识别,不得依赖固定行号 |
|
||||
| 管理费用 | 无独立同类输入 | 5月管理费用分析 | 建立独立管理费用域,先确认分摊规则 |
|
||||
| 薪资 | 4月薪资明细 | 5月薪资拆分明细 | 暂停直接复用脚本,改为表头映射后再导入 |
|
||||
| 考勤 | 4月全量考勤 | 5月仅10条人员记录 | 阻断全公司排班、人效分析,等待补数 |
|
||||
| 配送 | 1—15日、16—30日 | 1—15日、16—31日 | 76列一致,校验31日及上下半月边界 |
|
||||
| 中央厨房 | 4类报表 | 对应4类报表 | 结构基本一致,可进入预检 |
|
||||
| 采购货品 | 无同类月度输入 | 5月采购货品明细 | 新增采购事实,独立验收 |
|
||||
| 采购订货单 | 无同类月度输入 | ZIP内71个XLS | 解包后按业务日期过滤,不能整包归入5月 |
|
||||
| 正餐事业部补充 | 无同类月度输入 | 订单明细、品项销售明细 | 仅9店补充核验,禁止重复汇总 |
|
||||
|
||||
---
|
||||
|
||||
## 九、对5月发布范围的影响
|
||||
|
||||
### 9.1 可以发布的基础经营分析
|
||||
|
||||
- 营收、账单数、客单价、就餐人数
|
||||
- 菜品销量、销售额、品类结构、业务类型与时段表现
|
||||
- 库存倒挤成本、菜品BOM、配送和中央厨房基础分析
|
||||
- 经科目确认后的营业费用分析
|
||||
|
||||
### 9.2 应限制或暂缓的分析
|
||||
|
||||
- **会员、支付、营销、收银、账单状态、理论成本**:缺少与4月196列主账单同等的5月扩展字段
|
||||
- **全公司排班、人效、工时效率**:5月考勤仅10条,样本严重不足
|
||||
- **薪资与人工成本正式发布**:在按表头重做字段映射并完成敏感信息控制前暂缓
|
||||
- **总部费用分摊后利润**:管理费用是新数据域,分摊口径未确认前只展示原始总额,不下钻到门店利润
|
||||
- **采购订货趋势**:ZIP存在4月、5月和6月业务日期,完成逐单过滤前不得形成5月结论
|
||||
|
||||
---
|
||||
|
||||
## 十、5月导入前检查清单
|
||||
|
||||
- [ ] 为所有文件登记源路径、文件大小、修改时间、SHA-256、数据域、业务期间和导入批次
|
||||
- [ ] 业务时间统一使用 `[2026-05-01 00:00:00, 2026-06-01 00:00:00)`,剔除跨月边界记录
|
||||
- [ ] 57个账单菜品分片完成表头一致性、空文件、重复账单号与重复明细检查
|
||||
- [ ] 账单汇总与菜品明细按门店、日期核对实收金额,但允许按取消、赠送、退菜等业务规则形成可解释差异
|
||||
- [ ] 营业费用按科目编码解析,并由财务确认新增、缺失科目及合计范围
|
||||
- [ ] 管理费用单独入域,确认是否及如何分摊至门店
|
||||
- [ ] 库存倒挤成本、菜品成本、中央厨房和配送完成列数、必填字段、金额平衡与环比异常检查
|
||||
- [ ] 配送上下半月确认无日期重叠、无缺日,并覆盖5月31日
|
||||
- [ ] 薪资改为按表头映射;姓名、身份证等敏感字段不进入普通分析层和日志
|
||||
- [ ] 收到完整考勤前,不刷新全公司人效相关物化结果
|
||||
- [ ] 采购订货单逐个成员文件解析业务日期,排除4月和6月单据,并登记成员哈希
|
||||
- [ ] 正餐事业部补充表使用独立来源标签,禁止与全公司账单重复累计
|
||||
- [ ] 所有预检通过后再刷新对应月份物化结果;失败时保留4月已发布版本
|
||||
|
||||
---
|
||||
|
||||
*本说明仅描述文件与数据口径差异,不包含代码修改、数据库导入或物化视图刷新。*
|
||||
@@ -0,0 +1,687 @@
|
||||
# 2026 年 4 月数据导入物化全流程复盘与 5 月数据接入优化方案
|
||||
|
||||
> 复核日期:2026-08-01
|
||||
> 复核范围:本应用、PostgreSQL `bill_query`、4 月历史导入记录、5 月数据目录
|
||||
> 5 月数据目录:`/Users/freedak/Documents/AIDashboard/西部马华数据分析/5月/`
|
||||
> 5 月基础账单目录:`/Users/freedak/Documents/AIDashboard/西部马华数据分析/5月/营业数据/账单查询-菜品销售明细表/`
|
||||
> 本阶段边界:只输出方案,不修改应用代码,不写入 5 月数据,不刷新现有物化视图。
|
||||
> 月份口径:4 月为 `[2026-04-01, 2026-05-01)`;5 月为 `[2026-05-01, 2026-06-01)`。
|
||||
|
||||
---
|
||||
|
||||
## 一、执行结论
|
||||
|
||||
### 1. 当前不能直接按 4 月方式追加并发布 5 月数据
|
||||
|
||||
5 月目录已经具备菜品销售明细、库存倒挤成本、菜品成本、营业费用、中央厨房、配送、采购和薪资等文件,但尚不满足完整经营看板发布条件,主要原因如下:
|
||||
|
||||
1. **5 月基础账单数据已经确认位于 `营业数据/账单查询-菜品销售明细表/`。** 该目录有 57 个 Excel、约 523 MB,每个文件为 20 列账单—菜品行明细,包含门店、账单号、人数、开结账时间、菜品、销量、金额和实收金额。它可以生成 5 月基础账单事实和菜品事实,但并非 4 月 196 列主账单的同构文件,不含会员、支付方式、营销方案、收银员、账单状态及理论成本等扩展字段。
|
||||
2. **5 月考勤仅有 10 条员工记录。** 4 月数据库有 3,556 条考勤记录,5 月文件明显只覆盖少量总部人员,不能用于门店排班和人效分析。
|
||||
3. **现有薪资导入脚本按固定列号读取。** 5 月薪资表的组织层级、姓名、工号和身份证等列位置已经变化,直接执行会造成字段错位。
|
||||
4. **5 月营业费用虽然仍为 213 列,但费用科目集合发生变化。** 文件中出现备用金和维修费手工报销,部分 4 月科目未出现,且合计行位置变化,不能只按行号复用。
|
||||
5. **现有物化脚本存在全表聚合和固定 4 月对象。** 如果先追加 5 月原始数据,再执行旧物化脚本,多个页面会把 4 月、5 月及边界日期混合。
|
||||
|
||||
### 2. 5 月当前状态应定义为“可预检、不可正式发布”
|
||||
|
||||
| 状态 | 数据域 |
|
||||
|---|---|
|
||||
| 可进入预检,待月度框架完成后导入 | 账单菜品明细、库存倒挤成本、菜品成本、中央厨房、配送、采购货品明细 |
|
||||
| 需调整规则后导入 | 营业费用、薪资、采购订货单 |
|
||||
| 阻断基础经营版发布 | 57 个账单菜品明细分片未完成全量预检、入库和财务收入对账 |
|
||||
| 阻断排班和人效模块发布 | 完整考勤缺失 |
|
||||
| 阻断完整功能版发布 | 会员、支付、营销、收银和账单状态等扩展字段来源未补齐 |
|
||||
| 只能作为补充数据 | 正餐事业部全渠道订单明细、正餐事业部品项销售明细 |
|
||||
|
||||
### 3. 核心建议
|
||||
|
||||
- 保留现有 4 月数据和物化对象作为只读基线。
|
||||
- 在任何 5 月正式导入前,先建立月份隔离、文件哈希幂等、批次回滚和发布状态。
|
||||
- 月度经营 API 必须显式接收 `month`,默认读取“最后一个已发布月份”,不能自动读取最新导入批次。
|
||||
- 传统全量物化视图逐步改为带 `month_start` 的月度汇总表;每次只重算目标月份。
|
||||
- 账单菜品明细未完成全量导入和财务对账前,5 月基础经营看板应显示“数据未发布”;会员、支付、营销等无来源模块应显示“本月数据不可用”,不能用 0 替代。
|
||||
|
||||
---
|
||||
|
||||
## 二、4 月现有数据链路复盘
|
||||
|
||||
### 1. 全流程结构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Excel / ZIP / XLS 原始文件"] --> B["文件结构与期间检查"]
|
||||
B --> C["import_log 导入批次"]
|
||||
C --> D["public 原始明细表"]
|
||||
D --> E["门店、SKU、原料、成本中心映射"]
|
||||
E --> F["analytics 普通视图与标准事实"]
|
||||
F --> G["物化视图 / 月度汇总"]
|
||||
G --> H["后端 API"]
|
||||
H --> I["驾驶舱、分析报告、整改任务"]
|
||||
C --> J["行数、金额、日期、重复批次校验"]
|
||||
E --> J
|
||||
G --> J
|
||||
```
|
||||
|
||||
4 月已经形成了从原始文件到分析页面的完整雏形,但各数据域的批次控制、月份字段和物化方式不完全一致。
|
||||
|
||||
### 2. 4 月数据源与数据库现状
|
||||
|
||||
| 数据域 | 4 月来源与结构 | 原始层/日志 | 当前数据库实证 |
|
||||
|---|---|---|---:|
|
||||
| 主账单 | 17 个 `账单查询0723103743_*.xlsx`,196 个原始字段 | `bill_records`、`bill_columns` | 原始 1,677,990 行;有效账单物化 1,677,989 行 |
|
||||
| 菜品销售明细 | 56 个 Excel,20 个业务字段 | `dish_sales_details`、`dish_sales_import_log` | 5,553,314 行 |
|
||||
| 库存倒挤成本 | `4.13盘点倒挤成本报表2026年4月明细.xlsx`,35 列 | `inventory_cost_records`、`inventory_cost_import_log` | 54,973 行 |
|
||||
| 菜品成本/BOM | `菜品成本分析报表.xlsx`,24 列 | `dish_cost_analysis_summary`、`dish_cost_analysis_material_detail`、导入日志 | 788 条菜品汇总、3,568 条原料明细 |
|
||||
| 营业费用 | `2026年4月营业费用分析.xls`,213 列 | `operating_expense_records`、导入日志及门店映射 | 3,952 条长表记录;源合计 36,960,829.47 元 |
|
||||
| 薪资 | 4 月脱敏薪资明细 | `salary_detail_records`、`salary_import_log` | 3,596 行 |
|
||||
| 考勤 | 4 月脱敏考勤明细 | `attendance_records`、`attendance_import_log` | 3,556 行 |
|
||||
| 中央厨房按货品耗用 | 4 月按货品实际与理论耗用 | `central_kitchen_material_daily` | 3,329 行 |
|
||||
| 中央厨房按配方耗用 | 4 月按配方实际与理论耗用 | `central_kitchen_recipe_consumption` | 4,842 行 |
|
||||
| 中央厨房完工入库 | 4 月完工入库统计 | `central_kitchen_finished_receipt` | 1,708 行 |
|
||||
| 中央厨房加工单价 | 4 月加工单价 | `central_kitchen_processing_cost` | 157 行 |
|
||||
| 配送明细 | 4 月 1—15 日、16—30 日两个文件 | `distribution_detail_records`、`distribution_import_log` | 325,922 行 |
|
||||
|
||||
### 3. 4 月主账单标准化过程
|
||||
|
||||
4 月主账单没有直接改造成业务字段表,而是先原样保存:
|
||||
|
||||
- `bill_records` 保留 `c001` 至 `c196`。
|
||||
- `bill_columns` 保存 Excel 列号、分组表头和子表头。
|
||||
- 每条原始记录保留 `source_file` 和 `source_row`。
|
||||
- `analytics.bill_fact` 将主要文本列转换为门店、账单号、金额、会员、渠道、时间和理论成本等类型化字段。
|
||||
|
||||
当前 `analytics.bill_fact` 的日期范围实际为:
|
||||
|
||||
| 月份 | 有效账单数 | 实收金额 |
|
||||
|---|---:|---:|
|
||||
| 2026 年 3 月 | 3 | 48.00 |
|
||||
| 2026 年 4 月 | 1,669,925 | 56,156,726.95 |
|
||||
| 2026 年 5 月 | 8,061 | 210,378.43 |
|
||||
|
||||
这说明 4 月源文件本身已经包含 3 月和 5 月 1 日的边界数据。任何月度分析都必须按完整半开区间过滤,不能把整张 `bill_fact` 直接视为 4 月。
|
||||
|
||||
### 4. 4 月菜品销售标准化过程
|
||||
|
||||
菜品明细已经保存为明确字段,包括门店、账单号、开台/结账/下单时间、菜品名称、分类、销量、金额和实收金额。
|
||||
|
||||
当前明细日期范围同样跨越边界:
|
||||
|
||||
| 月份 | 菜品明细行数 | 菜品实收金额 |
|
||||
|---|---:|---:|
|
||||
| 2026 年 3 月 | 4 | 48.00 |
|
||||
| 2026 年 4 月 | 5,524,594 | 56,150,168.18 |
|
||||
| 2026 年 5 月 | 28,716 | 210,365.43 |
|
||||
|
||||
严格 4 月口径下,主账单实收与菜品明细实收相差 6,558.77 元,约为主账单实收的 0.012%。该差异应作为月度对账项目保留,不应默认两者完全一致。
|
||||
|
||||
### 5. 4 月映射和分析层
|
||||
|
||||
现有分析层主要完成了以下映射与聚合:
|
||||
|
||||
- 门店编码、门店名称和费用成本中心映射。
|
||||
- 菜品名称、SKU 分类和 ABC 分类。
|
||||
- BOM 原料与库存货品名称匹配。
|
||||
- 理论成本与库存倒挤成本对比。
|
||||
- 营业费用科目汇总及门店贡献利润估算。
|
||||
- 会员、复购、餐段、渠道、异常账单和平台经济性分析。
|
||||
- 中央厨房、配送、BOM 穿透及供应链对账。
|
||||
|
||||
应用页面和 API 主要读取 `analytics` 层,方向正确;问题在于部分分析视图仍然聚合所有月份,部分接口又写死 4 月。
|
||||
|
||||
### 6. 现有物化处理
|
||||
|
||||
数据库目前有 16 个已填充物化视图,另有 `dish_pair_summary_april` 作为普通表保存固定 4 月结果。
|
||||
|
||||
主要物化对象分为三类:
|
||||
|
||||
1. **全量事实物化**
|
||||
- `analytics.bill_fact`
|
||||
- 包含全部有效原始账单,没有 `month_start` 列,也没有在物化时限制月份。
|
||||
|
||||
2. **固定 4 月商品和门店物化**
|
||||
- `dish_sales_april`
|
||||
- `dish_basket_april`
|
||||
- `dish_sku_summary_april`
|
||||
- `dish_category_summary_april`
|
||||
- `dish_store_summary_april`
|
||||
- `dish_store_sku_april`
|
||||
- `dish_member_sku_april`
|
||||
- `dish_pair_summary_april`
|
||||
- `mv_store_action_priority_deep_april`
|
||||
- `mv_store_theoretical_actual_cost_april`
|
||||
|
||||
3. **名称不带月份但实际聚合全部原始数据的物化**
|
||||
- `v_store_platform_economics`
|
||||
- `v_store_category_mix`
|
||||
- `v_store_benchmark`
|
||||
- `v_store_action_list`
|
||||
- `v_store_execution_priority`
|
||||
- `mv_store_scorecard`
|
||||
|
||||
`server/sql/10_materialize_slow_views.sql` 中的平台、品类和门店基准物化直接读取全部 `bill_records`,没有月份过滤;后续组合视图又将复购月份固定为 `2026-04-01`。因此该脚本不能用于 5 月直接刷新。
|
||||
|
||||
### 7. 当前刷新审计状态
|
||||
|
||||
应用已经存在 `analytics.data_refresh_log`,字段包括刷新月份、动作、状态、详情、操作人和时间,但当前没有刷新记录。
|
||||
|
||||
这意味着目前无法仅依靠数据库回答:
|
||||
|
||||
- 某个月何时完成导入;
|
||||
- 哪些数据域成功或失败;
|
||||
- 哪些物化对象被刷新;
|
||||
- 刷新前后行数和金额是否一致;
|
||||
- 出现问题时应回滚哪个批次。
|
||||
|
||||
---
|
||||
|
||||
## 三、现有流程的主要风险
|
||||
|
||||
| 编号 | 风险 | 实证 | 可能后果 | 优化方向 |
|
||||
|---|---|---|---|---|
|
||||
| P0-01 | 主事实和多张视图缺少月份隔离 | `bill_fact`、门店评分和平台物化聚合全量数据 | 追加 5 月后污染 4 月指标 | 每条事实和汇总均带 `month_start`,API 强制传月份 |
|
||||
| P0-02 | 旧物化脚本不能按目标月份刷新 | `10_materialize_slow_views.sql` 直接聚合全部原始表 | 4、5 月混合,页面利润和排名失真 | 停止用旧脚本刷新新增月份,改成月度汇总框架 |
|
||||
| P0-03 | 大量对象固定 `_april` | 商品、成本、门店优先级等对象固定 4 月 | 每月复制对象,维护成本不断上升 | 使用同一对象和 `month_start` 维度 |
|
||||
| P0-04 | `latest` 视图按最大导入批次切换 | 菜品成本/BOM使用 `max(import_id)` | 导入 5 月后,4 月页面自动显示 5 月成本 | 业务查询必须按 `report_month`,不能按最新批次 |
|
||||
| P0-05 | 主账单缺少统一导入日志和文件哈希 | `bill_records` 只有源文件和源行号 | 重复文件难以识别,批次难以回滚 | 建立统一 `import_batch` 和 SHA-256 唯一约束 |
|
||||
| P0-06 | 薪资、考勤导入写死 4 月和列号 | 脚本固定 `report_month='2026-04-01'` 并按数组下标取值 | 5 月整列错位,人员和金额失真 | 按规范化表头名称映射,月份由参数传入 |
|
||||
| P0-07 | 部分 API 写死 4 月或读取全部人事数据 | 费用接口固定 `2026-04-01`,排班接口直接读全表 | 月份选择无效,新增月后混算 | 所有 API 使用统一月份解析器和发布状态 |
|
||||
| P1-01 | 标准事实表未成为主链路 | `fact_bill`、`fact_bill_item`、`fact_employee_shift`、`fact_purchase_receipt` 均为空 | 各页面继续依赖历史宽表和临时视图 | 分阶段迁移到标准事实模型 |
|
||||
| P1-02 | 导入日志字段不统一 | 有的有哈希、有的只有文件名;金额对账字段不一致 | 难以形成统一月结审计 | 统一批次状态、源行数、金额、日期和校验结果 |
|
||||
| P1-03 | 薪资文件包含个人敏感信息 | 5 月新增姓名、身份证等字段 | 数据泄露和越权使用风险 | 敏感列进入受限区,分析层仅保留脱敏员工标识 |
|
||||
| P1-04 | 缺失数据可能被当作 0 | 当前部分利润接口使用 `COALESCE` | 不完整数据被展示为盈利 | 缺失成本或费用时返回 `NULL` 和覆盖率状态 |
|
||||
|
||||
---
|
||||
|
||||
## 四、5 月文件盘点与兼容性判断
|
||||
|
||||
### 1. 营业与销售数据
|
||||
|
||||
| 文件/数据域 | 文件实证 | 与 4 月兼容性 | 当前建议 |
|
||||
|---|---|---|---|
|
||||
| 账单查询—菜品销售明细 | 已确认目录内有 57 个 Excel、约 523 MB;20 个字段;文件头明确为 2026-05-01 至 2026-05-31 | 高 | 作为 5 月基础账单和菜品明细的正式来源;一次解析同时生成账单事实与菜品事实,避免重复导入 |
|
||||
| 账单扩展字段 | 当前 20 列文件不含会员、支付方式、营销方案、收银员、账单状态和理论成本等字段 | 与 4 月 196 列主账单不完全兼容 | 不阻断基础营收/SKU版本,但对应会员、支付、营销和账单风险模块标记为不可用 |
|
||||
| 正餐事业部全渠道订单明细 | 工作表 13,595 行,其中约 13,592 条数据;51 列;仅 9 家门店;订单收入合计约 14,947,586.52 元 | 低 | 仅作为正餐事业部补充订单域,不能替代主账单 |
|
||||
| 正餐事业部品项销售明细 | 工作表 104,113 行,其中约 104,110 条数据;50 列;仅 9 家门店;品项收入合计约 14,286,930.30 元 | 低 | 单独建立正餐事业部补充事实,不能替代全公司菜品明细 |
|
||||
| 5 月菜品成本分析 | 工作表 4,146 行、24 列,层级表头与 4 月一致 | 高 | 可导入,但导入日志必须补齐 5 月期间,不能继续使用 `latest` |
|
||||
|
||||
5 月 20 列账单菜品明细可生成的基础指标包括:
|
||||
|
||||
- 按 `门店编码 + 账单号` 去重后的账单数;
|
||||
- 菜品金额、菜品实收、折让额和折让率;
|
||||
- 门店实收、客单价和客均消费;
|
||||
- 堂食/外卖等业务类型;
|
||||
- 开台、结账和下单时间,可派生日、星期、小时和餐段;
|
||||
- 菜品、品类、出品部门、销量、单价、实收和搭售。
|
||||
|
||||
生成账单事实时,人数、开结账时间等账单级字段不能在菜品行上直接求和。应先按 `门店编码 + 账单号` 聚合:人数取同一账单一致值或最大值,开台时间取最小值,结账时间取最大值,金额和实收按菜品行求和。
|
||||
|
||||
当前文件不能直接生成会员复购、支付渠道、营销方案效果、收银员风险、账单状态、平台佣金和理论毛利等指标。这些模块应返回“字段来源未提供”,而不是显示 0。
|
||||
|
||||
### 2. 成本、费用和人事数据
|
||||
|
||||
| 文件/数据域 | 文件实证 | 兼容性问题 | 当前建议 |
|
||||
|---|---|---|---|
|
||||
| 盘点倒挤成本 | 工作表 56,538 行、35 列,期间为 5 月 1—31 日 | 结构基本一致 | 可按 5 月批次导入,导入后核对成本单位、总成本和负倒挤 |
|
||||
| 5 月营业费用 | 18 行、213 列;合计 48,775,884.64 元 | 成本单位列结构相同,但科目行集合和合计行位置变化 | 不能按固定行号导入;必须按科目编码识别并对账 |
|
||||
| 5 月管理费用 | 16 行、213 列;合计 5,754,540.18 元 | 与门店营业费用是不同利润层级 | 单独进入总部/管理费用域,不得直接追加到门店营业费用表 |
|
||||
| 5 月薪资 | 工作表 3,529 行、98 列;组织层级改为三级成本中心,并新增姓名、身份证等字段 | 当前脚本假定 99 个固定位置字段、8 级组织,工号位置已变化 | 先改为表头映射;敏感列受限存储;未改前禁止导入 |
|
||||
| 5 月考勤 | 工作表 11 行、35 列,即 10 条人员记录;新增姓名列并包含 31 天 | 4 月有 3,556 条;当前脚本只处理 30 天且列位不同 | 判定为不完整文件,等待全量考勤;不得用于全公司排班 |
|
||||
|
||||
5 月营业费用不能简单视为与 4 月完全一致。直接对比可见:
|
||||
|
||||
- 4 月营业费用为 21 行、213 列;5 月为 18 行、213 列。
|
||||
- 5 月合计行不在文件末尾。
|
||||
- 5 月出现 `11901 备用金`、`50384 维修费手工报销`。
|
||||
- 4 月存在的刷卡手续费、清洗费、烟道清洗、自送快递费、外卖佣金等科目未在当前 5 月文件中出现。
|
||||
|
||||
因此必须先由财务确认 5 月文件的科目范围、缺失科目是否转入其他报表,以及 48,775,884.64 元是否为完整门店营业费用口径。
|
||||
|
||||
### 3. 中央厨房、配送和采购数据
|
||||
|
||||
| 文件/数据域 | 工作表规模 | 与 4 月兼容性 | 当前建议 |
|
||||
|---|---:|---|---|
|
||||
| 按货品实际与理论耗用 | 3,396 行、33 列 | 高 | 可按 5 月批次导入 |
|
||||
| 按配方实际与理论耗用 | 4,948 行、42 列 | 高 | 可按 5 月批次导入 |
|
||||
| 完工入库统计 | 1,784 行、43 列 | 高 | 可按 5 月批次导入 |
|
||||
| 加工单价 | 179 行、37 列 | 高 | 可按 5 月批次导入 |
|
||||
| 配送明细 1—15 日 | 165,272 行、76 列 | 高 | 与下半月共同组成 5 月完整配送批次 |
|
||||
| 配送明细 16—31 日 | 178,949 行、76 列 | 高 | 导入后两文件业务日期必须无重叠、无缺日 |
|
||||
| 采购货品明细 | 30,555 行、63 列 | 中高 | 可作为采购收货事实来源,填充当前为空的采购事实表 |
|
||||
| 采购订货单 ZIP | 71 个 `.xls` 文件 | 文件名存在乱码,且包含 4 月 10 日和 6 月 1 日文件 | 只能按单据业务日期筛选 5 月,不得按压缩包名称整包导入 |
|
||||
|
||||
采购货品明细中的部分制单时间和生产时间可能早于 5 月,但业务日期和实际到货日期属于 5 月。这类单据应以业务口径字段确定月份,同时保留制单、审核、到货和生产时间,不能只用单一时间字段。
|
||||
|
||||
### 4. 5 月数据就绪度
|
||||
|
||||
| 等级 | 数据域 | 含义 |
|
||||
|---|---|---|
|
||||
| 绿色 | 账单菜品明细、库存成本、菜品成本、中央厨房、配送、采购货品明细 | 文件结构可进入月度预检和暂存 |
|
||||
| 黄色 | 营业费用、管理费用、薪资、采购订货单、正餐事业部补充报表 | 需先确认范围或调整映射规则 |
|
||||
| 红色 | 完整考勤 | 严重不完整,阻断全公司排班和人效模块发布 |
|
||||
| 功能受限 | 会员、支付、营销、收银、账单状态等扩展字段 | 不阻断基础经营版,阻断相应扩展模块和与 4 月完全同口径对比 |
|
||||
|
||||
---
|
||||
|
||||
## 五、目标月度数据架构
|
||||
|
||||
### 1. 分层原则
|
||||
|
||||
| 层级 | 目标 | 关键要求 |
|
||||
|---|---|---|
|
||||
| 文件登记层 | 登记每个文件和压缩包成员 | 文件哈希、数据域、月份、期间、大小、状态、来源人 |
|
||||
| 暂存层 `staging` | 原样解析,尚不影响正式数据 | 保留原始值、源文件、源行、批次号和解析错误 |
|
||||
| 原始层 `public` | 保存可追溯的标准字段 | 每条记录必须有 `batch_id`、`report_month` 或业务日期 |
|
||||
| 标准事实层 `analytics.fact_*` | 跨月统一业务模型 | 统一门店、SKU、原料、供应商、员工和单据主键 |
|
||||
| 月度汇总层 `analytics.mart_*_monthly` | 支撑页面快速查询 | 每条汇总必须带 `month_start`,按目标月份可重算 |
|
||||
| 发布层 | 控制 API 可见月份和功能范围 | 基础指标通过后标记 `published_basic`,扩展指标全部通过后标记 `published_full` |
|
||||
|
||||
### 2. 统一导入批次
|
||||
|
||||
建议建立统一批次登记概念,至少包含:
|
||||
|
||||
| 字段 | 用途 |
|
||||
|---|---|
|
||||
| `batch_id` | 唯一导入批次 |
|
||||
| `dataset_type` | 账单菜品明细、账单扩展、库存、费用、薪资、考勤等 |
|
||||
| `report_month` | 归属月份 |
|
||||
| `period_start`、`period_end` | 文件实际覆盖日期 |
|
||||
| `source_file` | 原文件名或 ZIP 成员名 |
|
||||
| `file_sha256` | 防重复导入 |
|
||||
| `source_rows`、`imported_rows`、`rejected_rows` | 行数审计 |
|
||||
| `source_amount`、`loaded_amount` | 金额对账 |
|
||||
| `status` | `discovered/validated/staged/loaded/reconciled/published_basic/published_full/blocked/rolled_back` |
|
||||
| `error_message` | 失败原因 |
|
||||
| `supersedes_batch_id` | 替代旧批次时保留关系 |
|
||||
| `imported_at`、`completed_at` | 执行时间 |
|
||||
|
||||
### 3. 幂等规则
|
||||
|
||||
1. 同一 `dataset_type + report_month + file_sha256` 再次执行时直接跳过。
|
||||
2. 同一月份收到修订版文件时,不在正式表无条件追加。
|
||||
3. 修订版先进入新批次,预检通过后在单个事务内替换目标月份或旧批次。
|
||||
4. 删除和回滚必须只针对明确 `batch_id`,不得按模糊文件名或整个表清空。
|
||||
5. ZIP 文件既登记压缩包哈希,也登记每个成员文件哈希。
|
||||
|
||||
### 4. 月份确定规则
|
||||
|
||||
- 月份优先由业务日期字段计算,而不是仅信任文件名。
|
||||
- 所有记录统一计算:`month_start = date_trunc('month', business_date)::date`。
|
||||
- 文件内出现目标月以外数据时进入异常清单:
|
||||
- 可解释的制单、生产时间不影响归属;
|
||||
- 业务日期跨月的记录必须拆分到实际月份;
|
||||
- 无法确定业务日期的文件不能发布。
|
||||
- 月度查询统一使用:
|
||||
|
||||
```sql
|
||||
business_date >= :month_start
|
||||
AND business_date < (:month_start + INTERVAL '1 month')
|
||||
```
|
||||
|
||||
### 5. 不再使用“最新批次”代表业务月份
|
||||
|
||||
`max(import_id)` 只能用于技术监控,不能用于 4 月或 5 月经营查询。
|
||||
|
||||
正确方式应为:
|
||||
|
||||
- 页面传入 `month=2026-04` 或 `month=2026-05`;
|
||||
- 后端转换为 `month_start`;
|
||||
- 查询同月份事实和汇总;
|
||||
- 默认月份为“最后一个已发布月份”,不是最后一个上传文件的月份。
|
||||
|
||||
---
|
||||
|
||||
## 六、物化与刷新优化方案
|
||||
|
||||
### 1. 推荐使用“月度汇总表”代替大量固定月份物化视图
|
||||
|
||||
PostgreSQL 物化视图不能带运行时月份参数。如果继续使用 `dish_sales_may`、`dish_sales_june` 等对象,每个月都会新增一组对象,后续维护和 API 路由会越来越复杂。
|
||||
|
||||
推荐建立带月份字段的汇总表,例如:
|
||||
|
||||
- 门店月度经营汇总;
|
||||
- 门店日度经营汇总;
|
||||
- SKU 月度汇总;
|
||||
- 门店—SKU 月度汇总;
|
||||
- 品类月度汇总;
|
||||
- 会员复购月度汇总;
|
||||
- 理论与实际成本月度汇总;
|
||||
- 费用和门店贡献月度汇总;
|
||||
- 中央厨房、配送和采购月度汇总。
|
||||
|
||||
每次刷新目标月份时:
|
||||
|
||||
1. 锁定目标月份刷新任务;
|
||||
2. 在事务内删除该月份旧汇总;
|
||||
3. 从标准事实重算该月份;
|
||||
4. 写入新汇总;
|
||||
5. 执行校验;
|
||||
6. 通过后提交并登记刷新日志;
|
||||
7. 失败则整体回滚,不影响 4 月。
|
||||
|
||||
### 2. 如短期仍保留物化视图
|
||||
|
||||
短期可以建立包含全部月份的物化视图,但必须:
|
||||
|
||||
- 在结果中保留 `month_start`;
|
||||
- 所有分组包含 `month_start`;
|
||||
- 建立 `(month_start, store_code)`、`(month_start, sku_code)` 等唯一索引;
|
||||
- API 查询必须传月份;
|
||||
- 使用 `REFRESH MATERIALIZED VIEW CONCURRENTLY` 时先满足唯一索引条件;
|
||||
- 评估全量刷新耗时,数据量增大后迁移到月度汇总表。
|
||||
|
||||
### 3. 5 月正式刷新顺序
|
||||
|
||||
```text
|
||||
文件预检
|
||||
→ 导入批次登记
|
||||
→ 暂存层
|
||||
→ 维度映射(门店/SKU/原料/供应商/员工)
|
||||
→ 账单菜品明细一次解析
|
||||
→ 账单基础事实 + 菜品销售事实
|
||||
→ 可选账单扩展事实
|
||||
→ 库存、采购、配送、中央厨房事实
|
||||
→ 费用、薪资、考勤事实
|
||||
→ 日度/月度汇总
|
||||
→ 跨表对账
|
||||
→ 发布状态
|
||||
→ API 缓存和整改任务
|
||||
```
|
||||
|
||||
57 个账单菜品明细分片是 5 月基础经营分析的主锚点,应一次解析后同时生成账单基础事实和菜品事实。账单数必须按 `门店编码 + 账单号` 去重,人数等账单级字段必须先去重再汇总。会员、支付、营销等扩展事实可以后补,但相关模块在补齐前只能标记为功能受限。
|
||||
|
||||
### 4. 现有 4 月对象的过渡策略
|
||||
|
||||
- 暂不删除或刷新 `_april` 对象。
|
||||
- 5 月框架验证期间,4 月页面继续读取现有 4 月基线。
|
||||
- 新月度汇总完成后,用 4 月原始事实回算一遍新框架。
|
||||
- 新框架的 4 月核心指标与当前严格 4 月基线一致后,再切换 API。
|
||||
- 切换后保留旧对象一个观察周期,确认无回退需求再清理。
|
||||
|
||||
---
|
||||
|
||||
## 七、5 月实施步骤
|
||||
|
||||
### 阶段 0:冻结 4 月基线
|
||||
|
||||
正式改造前记录以下只读基线:
|
||||
|
||||
| 检查项 | 4 月基线 |
|
||||
|---|---:|
|
||||
| 主账单有效账单数 | 1,669,925 |
|
||||
| 主账单实收 | 56,156,726.95 元 |
|
||||
| 菜品明细行数 | 5,524,594 |
|
||||
| 菜品明细实收 | 56,150,168.18 元 |
|
||||
| 库存成本明细 | 54,973 行 |
|
||||
| 营业费用长表 | 3,952 行 |
|
||||
| 营业费用源合计 | 36,960,829.47 元 |
|
||||
| 薪资明细 | 3,596 行 |
|
||||
| 考勤明细 | 3,556 行 |
|
||||
|
||||
同时记录现有物化对象行数、更新时间和 API 返回值,作为回归测试基准。
|
||||
|
||||
### 阶段 1:确认发布范围并补齐阻断数据
|
||||
|
||||
按用户补充信息,`账单查询-菜品销售明细表/` 作为 5 月基础账单来源。实施前还需完成:
|
||||
|
||||
1. 核对 57 个分片是否覆盖全部应营业门店、全部 31 天和完整账单号范围。
|
||||
2. 明确本次先发布“基础经营版”,还是要求与 4 月全部模块完全同口径;若要求完整版,需补充会员、支付、营销、收银、账单状态等扩展字段来源。
|
||||
3. 补充完整 5 月考勤,覆盖门店和总部全部应统计员工。
|
||||
4. 财务确认 5 月营业费用科目范围,解释缺少的 4 月科目及新增科目。
|
||||
5. 明确 5 月管理费用是否用于总部损益桥,以及分摊规则。
|
||||
|
||||
### 阶段 2:完成导入前预检
|
||||
|
||||
每个文件至少检查:
|
||||
|
||||
- 文件 SHA-256;
|
||||
- 文件名和内部期间;
|
||||
- 工作表名称;
|
||||
- 表头数量和规范化表头;
|
||||
- 数据行数;
|
||||
- 日期最小值和最大值;
|
||||
- 门店、科目、SKU、原料、供应商数量;
|
||||
- 金额合计;
|
||||
- 重复主键和空主键;
|
||||
- 是否包含目标月份以外业务日期;
|
||||
- 是否包含个人敏感信息。
|
||||
|
||||
预检结果应生成机器可读日志和人工可读报告。任何红色检查失败时,只允许进入暂存层,不得进入正式事实层。
|
||||
|
||||
### 阶段 3:按风险顺序导入
|
||||
|
||||
建议顺序:
|
||||
|
||||
1. 维度和映射变更;
|
||||
2. 账单菜品明细一次解析,生成账单基础事实和菜品销售事实;
|
||||
3. 可选账单扩展数据;
|
||||
4. 库存倒挤成本;
|
||||
5. 菜品成本/BOM;
|
||||
6. 采购货品明细;
|
||||
7. 配送明细;
|
||||
8. 中央厨房;
|
||||
9. 营业费用;
|
||||
10. 管理费用;
|
||||
11. 薪资;
|
||||
12. 考勤;
|
||||
13. 正餐事业部补充订单和品项报表。
|
||||
|
||||
正餐事业部补充报表必须使用独立数据域标识,避免与全公司账单、菜品明细重复累计。
|
||||
|
||||
### 阶段 4:刷新和发布
|
||||
|
||||
基础经营版只有下列条件全部满足,才将 5 月状态改为 `published_basic`:
|
||||
|
||||
- 必需文件齐全;
|
||||
- 所有必需批次状态为 `reconciled`;
|
||||
- 57 个账单菜品分片日期、文件编号和门店覆盖完整;
|
||||
- 由菜品行生成的账单基础事实与明细金额完全回勾;
|
||||
- 账单基础实收与财务收入对账通过;
|
||||
- 成本和费用源表合计对账通过;
|
||||
- 门店、SKU、原料和成本中心映射达到阈值;
|
||||
- 无未解释跨月记录;
|
||||
- 月度汇总与事实表重算一致;
|
||||
- 4 月回归测试无变化;
|
||||
- 5 月 API 显示正确月份和数据完整性状态。
|
||||
|
||||
会员、支付、营销、收银和账单风险等模块只有在相应扩展字段补齐并对账后,才能将月份升级为 `published_full`。
|
||||
|
||||
---
|
||||
|
||||
## 八、数据质量和对账闸门
|
||||
|
||||
### 1. 文件级闸门
|
||||
|
||||
| 检查 | 通过标准 |
|
||||
|---|---|
|
||||
| 文件重复 | 同一哈希只登记一次 |
|
||||
| 表头变化 | 未登记的新表头必须人工确认 |
|
||||
| 日期范围 | 业务日期只归属目标月份,跨月记录有明确处置 |
|
||||
| 行数波动 | 相比上月异常下降或增加时需解释 |
|
||||
| 文件完整性 | ZIP 分片数量连续,无空文件和损坏文件 |
|
||||
|
||||
### 2. 账单基础事实与菜品明细对账
|
||||
|
||||
至少检查:
|
||||
|
||||
- 57 个分片的编号连续性、重复文件和损坏文件;
|
||||
- 按 `门店编码 + 账单号` 去重后的账单数;
|
||||
- 账单基础事实的金额、实收与菜品行汇总是否完全一致;
|
||||
- 同一账单的人数、业务类型和时间字段是否存在冲突;
|
||||
- 负数、零实收、撤单和退款的口径差异;
|
||||
- 91 家门店覆盖及新增/闭店说明。
|
||||
|
||||
由于 5 月账单基础事实和菜品事实来自同一批 20 列文件,两者内部回勾原则上应为零差异。独立性更强的收入校验应使用财务营业收入;9 家正餐事业部全渠道订单明细只能用于相同门店范围的补充交叉验证。
|
||||
|
||||
### 3. 成本与供应链对账
|
||||
|
||||
| 对账关系 | 管理问题 |
|
||||
|---|---|
|
||||
| 采购货品明细 ↔ 库存购入 | 采购入库是否完整进入库存成本 |
|
||||
| 配送出库 ↔ 门店库存购入 | 总仓配送与门店收货是否一致 |
|
||||
| 中央厨房完工入库 ↔ 加工成本 | 半成品产量与单位加工成本是否匹配 |
|
||||
| 按配方理论耗用 ↔ 按货品实际耗用 | 用量、出成率和损耗差异 |
|
||||
| BOM 理论成本 ↔ 菜品实际成本 | 菜品成本断链和异常损耗 |
|
||||
|
||||
### 4. 费用和人工对账
|
||||
|
||||
- 营业费用主科目合计必须等于源文件确认合计。
|
||||
- 工资父科目与前厅/后厨子科目不能重复累计。
|
||||
- 管理费用不得未经确认直接进入门店贡献利润。
|
||||
- 薪资总额与费用报表工资科目差异必须解释。
|
||||
- 考勤员工数、薪资员工数和在职员工数需要建立覆盖率。
|
||||
|
||||
### 5. 发布状态展示
|
||||
|
||||
每个页面应展示:
|
||||
|
||||
- 当前月份;
|
||||
- 数据状态:完整、部分、阻断、已发布;
|
||||
- 销售门店数、费用匹配门店数、成本匹配门店数;
|
||||
- 最后刷新时间;
|
||||
- 未覆盖数据和影响金额;
|
||||
- 指标证据等级。
|
||||
|
||||
如果 57 个账单菜品分片、成本或费用任一不完整,利润字段应为 `NULL/不可计算`,而不是 0。会员、支付、营销等无字段来源的指标也应返回不可用状态。
|
||||
|
||||
---
|
||||
|
||||
## 九、API 和页面优化方向
|
||||
|
||||
### 1. 统一月份参数
|
||||
|
||||
所有经营接口使用统一规则:
|
||||
|
||||
- 入参:`month=YYYY-MM`;
|
||||
- 后端校验合法月份;
|
||||
- 统一计算月初和下月月初;
|
||||
- 查询所有相关表时使用相同月份;
|
||||
- 返回 `month_start`、`data_status`、`last_refresh_at` 和覆盖率。
|
||||
|
||||
### 2. 页面不得自动切换到最新导入文件
|
||||
|
||||
页面默认读取最后已发布月份。上传 5 月菜品成本但 57 个账单菜品分片尚未完成导入和对账时,4 月页面仍显示 4 月,5 月页面显示“待发布”,不能自动切换到 5 月菜品成本。基础经营版发布后,缺少扩展字段的模块继续显示“本月数据不可用”。
|
||||
|
||||
### 3. 利润页面的月份和范围
|
||||
|
||||
利润展示至少区分:
|
||||
|
||||
- 门店营业贡献:实收-食材成本-门店营业费用;
|
||||
- 总部管理费用;
|
||||
- 中央厨房和配送中心损益;
|
||||
- 未分配费用;
|
||||
- 财务净利润桥。
|
||||
|
||||
5 月管理费用应单列,未确认分摊规则前不得混入门店排名。
|
||||
|
||||
### 4. 人事数据隐私
|
||||
|
||||
- API 不返回身份证号、手机号等分析不需要的字段。
|
||||
- 员工使用内部脱敏 ID 连接薪资和考勤。
|
||||
- 薪资明细仅授权人事和财务角色访问。
|
||||
- 日志中不记录完整敏感字段。
|
||||
- 导出文件默认脱敏。
|
||||
|
||||
---
|
||||
|
||||
## 十、实施优先级
|
||||
|
||||
### P0:5 月基础导入及对应模块发布前必须完成
|
||||
|
||||
1. 将已确认的 57 个账单菜品明细分片登记为 5 月基础账单源,并完成全量覆盖预检。
|
||||
2. 统一导入批次、文件哈希和月份字段。
|
||||
3. 阻止旧物化脚本在追加 5 月后直接刷新。
|
||||
4. 改造菜品成本 `latest` 口径为按月份查询。
|
||||
5. 薪资和考勤改为按表头映射。
|
||||
6. 所有月度 API 使用统一月份边界。
|
||||
7. 建立 `published_basic` 和 `published_full` 两级发布状态,未通过时不对外显示对应经营指标。
|
||||
8. 排班和人效模块发布前补齐完整考勤。
|
||||
|
||||
### P1:5 月正式发布前完成
|
||||
|
||||
1. 建立费用科目白名单和管理费用独立域。
|
||||
2. 建立账单基础事实—菜品—财务收入—库存—费用对账。
|
||||
3. 建立门店、SKU、原料、供应商和员工映射质量检查。
|
||||
4. 将采购货品明细接入采购事实。
|
||||
5. 使用月度汇总表替代固定 `_april` 对象。
|
||||
6. 写入并展示数据刷新日志。
|
||||
|
||||
### P2:连续月度运营阶段完成
|
||||
|
||||
1. 将历史宽表逐步迁移到标准 `fact_*` 模型。
|
||||
2. 建立可比店、预算、环比和整改收益验证。
|
||||
3. 建立中央厨房、采购、配送、门店库存的完整成本桥。
|
||||
4. 建立月度版本、指标版本和数据血缘管理。
|
||||
5. 对已发布月份实施不可变快照和回归测试。
|
||||
|
||||
---
|
||||
|
||||
## 十一、5 月最终验收清单
|
||||
|
||||
### 文件和批次
|
||||
|
||||
- [ ] 账单菜品明细 57 个分片齐全、无重复、无损坏。
|
||||
- [ ] 57 个分片覆盖全部应营业门店和 5 月 1—31 日。
|
||||
- [ ] 若要求完整功能版,会员、支付、营销、收银和账单状态等扩展字段来源齐全。
|
||||
- [ ] 完整考勤覆盖全部应统计员工。
|
||||
- [ ] 每个文件均有 SHA-256 和批次号。
|
||||
- [ ] 跨月采购订货单已按业务日期拆分。
|
||||
|
||||
### 数据入库
|
||||
|
||||
- [ ] 所有正式事实表带月份或业务日期。
|
||||
- [ ] 所有记录可追溯到源文件和源行。
|
||||
- [ ] 薪资字段按表头映射,无列错位。
|
||||
- [ ] 身份证、手机号等敏感字段受限或脱敏。
|
||||
- [ ] 正餐事业部补充报表未与全公司数据重复累计。
|
||||
|
||||
### 对账
|
||||
|
||||
- [ ] 账单基础事实与菜品行金额完全回勾。
|
||||
- [ ] 账单基础实收与财务收入对账。
|
||||
- [ ] 库存成本与财务成本报表对账。
|
||||
- [ ] 营业费用与源表合计对账。
|
||||
- [ ] 薪资与工资费用科目对账。
|
||||
- [ ] 采购、配送、库存三方对账。
|
||||
|
||||
### 物化和页面
|
||||
|
||||
- [ ] 5 月汇总仅包含 `[2026-05-01, 2026-06-01)`。
|
||||
- [ ] 4 月核心指标与冻结基线一致。
|
||||
- [ ] API 支持显式月份并返回数据状态。
|
||||
- [ ] 5 月未完整时页面不显示虚假 0 值或完整利润。
|
||||
- [ ] 刷新日志记录所有对象、耗时、结果和校验差异。
|
||||
- [ ] 失败批次可以按 `batch_id` 回滚。
|
||||
|
||||
---
|
||||
|
||||
## 十二、最终建议
|
||||
|
||||
5 月数据的正确处理方式不是复制一套 `_may` 视图,也不是直接把文件追加到现有原始表后刷新全部物化视图。推荐顺序是:
|
||||
|
||||
```text
|
||||
先确认基础版与完整版发布范围
|
||||
→ 建立月份隔离和统一批次
|
||||
→ 文件预检
|
||||
→ 暂存导入
|
||||
→ 按月写入事实
|
||||
→ 四项总额和跨表对账
|
||||
→ 只重算 5 月汇总
|
||||
→ 4 月回归验证
|
||||
→ 标记 5 月基础版或完整版已发布
|
||||
→ 页面开放 5 月分析和整改任务
|
||||
```
|
||||
|
||||
用户补充的 57 个账单菜品明细分片可以作为全公司 5 月基础账单来源。完成月份隔离、全量预检、账单去重、财务收入对账及费用范围确认后,可以发布基础营收、账单数、客单、菜品、品类、堂食/外卖、时段及初步门店贡献分析。会员、支付、营销、收银、账单状态和排班模块仍需相应扩展字段及完整考勤,未补齐前应保持功能受限状态。
|
||||
|
||||
---
|
||||
|
||||
## 附录:本次只读复核依据
|
||||
|
||||
- 应用数据库:PostgreSQL `bill_query`。
|
||||
- 应用 SQL:`server/sql/10_materialize_slow_views.sql`、历史 4 月分析 SQL。
|
||||
- 人事导入脚本:`db/import_salary_attendance.py`。
|
||||
- 4 月全流程手册:`/Users/freedak/Documents/AIDashboard/西部马华数据分析/0马兰拉面数字化运营数据治理与分析全流程操作手册.md`。
|
||||
- 5 月文件目录:`/Users/freedak/Documents/AIDashboard/西部马华数据分析/5月/`。
|
||||
- 数据库导入日志:菜品销售、库存成本、菜品成本、营业费用、薪资、考勤、中央厨房和配送导入日志。
|
||||
- 数据库物化对象:`pg_matviews` 中 `analytics` 模式的现有对象。
|
||||
|
||||
本次未执行任何 5 月数据库写入、删除、替换或物化刷新。
|
||||
@@ -0,0 +1,825 @@
|
||||
# 2026 年 4 月运营分析、指标展现审查与利润提升整改方案
|
||||
|
||||
> 审查对象:马兰拉面数字化运营管理平台(前端、后端 API、PostgreSQL 分析层与当前页面)
|
||||
> 数据期间:`2026-04-01 <= 营业日 < 2026-05-01`
|
||||
> 数据复核时点:2026-07-31
|
||||
> 管理目标:从“展示数据”升级为“发现问题 -> 量化利润机会 -> 生成动作 -> 验收收益”
|
||||
> 重要声明:本报告中的“利润”均指 **门店贡献利润估算**,不是财务报表净利润。
|
||||
|
||||
---
|
||||
|
||||
## 一、执行摘要
|
||||
|
||||
### 1. 4 月经营结论
|
||||
|
||||
4 月严格关账口径下,公司实收 **56,156,726.95 元**,账单 **1,669,925 笔**,客单价 **33.63 元**,优惠额 **14,218,795.28 元**,优惠率 **20.20%**,共有 **91 家**门店产生销售。
|
||||
|
||||
当前最需要解决的不是“再增加更多图表”,而是以下四个问题:
|
||||
|
||||
1. **利润口径不成立。** 页面将“实收 - 食材成本 - 门店经营费用”称为“净利润”,但未纳入总部费用、税费、折旧摊销、财务费用等;同时接口混入 5 月 1 日收入,并把缺失成本费用按 0 处理,导致页面利润被高估。
|
||||
2. **成本是最大的可控利润来源,但两套实际成本无法对账。** 菜品成本报表实际成本为 **1,654.70 万元**,库存倒挤食材成本为 **2,110.12 万元**,相差约 **455.42 万元**。在成本桥建立前,不能用单一成本率直接评价所有门店。
|
||||
3. **经营动作没有形成真实验收。** 当前 141 条任务中有 60 条显示“已验收”,但与模拟脚本生成结果一致;经营稳定性任务的当前值、基准值和目标值又全部为空,因此不能作为改善闭环证据。
|
||||
4. **页面偏重排名和通用建议,缺少统一利润机会池。** 管理者能看到“谁高谁低”,但不能直接回答“问题金额是多少、先做什么、负责人是谁、30 天能回收多少、如何验收”。
|
||||
|
||||
### 2. 4 月利润基线
|
||||
|
||||
| 口径 | 门店数 | 4 月实收 | 门店贡献利润估算 | 贡献率 | 亏损门店 |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| 全部销售门店 | 91 | 56,156,726.95 | 不完整 | 不完整 | 不可直接判定 |
|
||||
| 费用已匹配门店 | 89 | 56,015,667.13 | 3,102,628.00 | 5.54% | 15 家 |
|
||||
| 标准门店且费用已匹配 | 85 | 54,967,372.73 | 4,007,746.07 | 7.29% | 13 家 |
|
||||
| 特殊业态且费用已匹配 | 4 | 1,048,294.40 | -905,118.07 | -86.34% | 2 家 |
|
||||
|
||||
其中 15 家有销售且贡献利润为负的门店,亏损合计 **-2,162,429.23 元**;73 家盈利门店正贡献合计 **5,265,057.23 元**。
|
||||
|
||||
特殊业态包括机场、火锅、总部基地、快手及商城等门店,其收入结构、人员和费用承担方式与标准门店不同,必须单列分析,不能参与标准门店排名和统一阈值考核。
|
||||
|
||||
### 3. 30 天整改目标
|
||||
|
||||
以 85 家费用已匹配标准门店为主经营盘,建议设定:
|
||||
|
||||
| 目标 | 4 月基线 | 30 天验收目标 |
|
||||
|---|---:|---:|
|
||||
| 标准门店贡献率 | 7.29% | 不低于 10.00% |
|
||||
| 可验证月度贡献利润改善 | 0 | 不低于 1,500,000 元 |
|
||||
| 标准亏损门店数 | 13 家 | 不超过 5 家 |
|
||||
| 灰色成本口径门店 | 6 家 | 0 家 |
|
||||
| 成本差异回收 | 可比差异 3,437,648.33 元 | 至少回收 30% |
|
||||
| 标准门店人工率 | 24.83% | 不高于 24.00% |
|
||||
| 标准门店能源率 | 5.31% | 不高于 5.00% |
|
||||
| 高优惠门店 | 11 家 | 降至 0 家或完成书面例外审批 |
|
||||
| 模拟验收记录 | 60 条 | 经营看板中不再计为真实验收 |
|
||||
|
||||
已识别的五类理论月度机会合计约 **1,883,995.03 元**。考虑项目之间重叠、促销需求弹性和数据修正,建议经营承诺值取 **不低于 150 万元**,而不是直接承诺全部理论值。若实现 150 万元,标准门店贡献利润可由 400.77 万元提升至约 550.77 万元,贡献率约 **10.02%**。
|
||||
|
||||
---
|
||||
|
||||
## 二、审查范围与统一口径
|
||||
|
||||
### 1. 时间口径
|
||||
|
||||
本报告只分析 2026 年 4 月:
|
||||
|
||||
```sql
|
||||
business_date >= DATE '2026-04-01'
|
||||
AND business_date < DATE '2026-05-01'
|
||||
```
|
||||
|
||||
账单营业日沿用应用现有 `analytics.v_store_daily` 的定义,即 `closed_at::date`。不得使用只有开始日期、没有结束日期的查询,也不得把 5 月 1 日视为 4 月数据。
|
||||
|
||||
### 2. 门店口径
|
||||
|
||||
| 范围 | 数量 | 处理规则 |
|
||||
|---|---:|---|
|
||||
| 有 4 月销售门店 | 91 家 | 用于营收、账单、优惠和客单分析 |
|
||||
| 标准门店 | 86 家 | 用于标准经营模型和门店横向对标 |
|
||||
| 特殊业态 | 5 家 | 单列损益模型,不与标准门店直接比较 |
|
||||
| 有费用匹配门店 | 89 家 | 可计算门店贡献利润估算 |
|
||||
| 缺失费用门店 | 2 家 | 只展示营收,不进入利润排名 |
|
||||
|
||||
缺失费用的门店为:
|
||||
|
||||
| 门店 | 4 月实收 | 当前处理 |
|
||||
|---|---:|---|
|
||||
| 1098 关东店铜锅聚 | 111,502.98 | 补费用与成本前不得计算利润 |
|
||||
| 1110 甄选商城店 | 29,556.84 | 特殊业态,补齐独立损益口径 |
|
||||
|
||||
### 3. 利润口径
|
||||
|
||||
本报告采用:
|
||||
|
||||
```text
|
||||
门店贡献利润估算 = 4 月实收 - 库存倒挤食材成本 - 已映射门店营业费用
|
||||
```
|
||||
|
||||
它只能用于门店经营改善,不等于财务净利润。要得到财务净利润,至少还需建立以下桥接:总部管理费用、中央厨房及配送中心损益分摊、折旧摊销、税费、财务费用、资产减值、营业外收支。
|
||||
|
||||
### 4. 证据等级
|
||||
|
||||
| 等级 | 定义 | 可用于什么决策 |
|
||||
|---|---|---|
|
||||
| A:已对账 | 严格 4 月、门店范围明确、可追溯到事实表 | 可用于经营会基线和任务验收 |
|
||||
| B:待桥接 | 月份明确,但存在成本、费用或组织映射差异 | 可用于发现机会,不可直接考核 |
|
||||
| C:异常/模拟 | 数据口径异常、字段缺失或由模拟脚本生成 | 只生成数据修复任务,不评价店长 |
|
||||
|
||||
---
|
||||
|
||||
## 三、4 月经营全景
|
||||
|
||||
### 1. 营收与顾客
|
||||
|
||||
| 指标 | 4 月值 | 结论 |
|
||||
|---|---:|---|
|
||||
| 实收 | 56,156,726.95 | A 级基线 |
|
||||
| 账单数 | 1,669,925 | A 级基线 |
|
||||
| 客单价 | 33.63 | 需结合餐段、渠道和门店类型判断 |
|
||||
| 消费额 | 70,375,522.23 | 实收与优惠的分母基线 |
|
||||
| 优惠额 | 14,218,795.28 | 让利规模较大,应转为活动级贡献分析 |
|
||||
| 优惠率 | 20.20% | 11 家门店超过 25% |
|
||||
| 销售门店 | 91 | 与费用、人事组织范围尚未统一 |
|
||||
|
||||
当前数据只有 4 月主分析月,无法可靠判断同比、环比和趋势改善。日趋势可描述月内波动,但不能把单月高低直接解释为增长或衰退。
|
||||
|
||||
### 2. 会员
|
||||
|
||||
| 指标 | 会员 | 非会员 | 经营含义 |
|
||||
|---|---:|---:|---|
|
||||
| 账单数 | 231,438 | 1,438,487 | 会员账单占比仅 13.86% |
|
||||
| 实收 | 8,343,565.92 | 47,813,161.03 | 非会员仍是绝对收入主体 |
|
||||
| 客单价 | 36.05 | 33.24 | 会员客单高约 2.81 元 |
|
||||
| 优惠率 | 16.59% | 20.80% | 会员并非依赖更高折扣获得高客单 |
|
||||
|
||||
4 月门店加权复购率为 **33.45%**。会员表现说明“提高可识别会员占比、促进二次到店”比无差别加大折扣更有可能改善收入质量,但当前还缺会员获取成本、首购来源、30/60/90 日 cohort 和贡献毛利,不能直接计算会员 ROI。
|
||||
|
||||
### 3. SKU 结构
|
||||
|
||||
| ABC 类别 | SKU 数 | 实收贡献 | 结构判断 |
|
||||
|---|---:|---:|---|
|
||||
| A-核心 | 44 | 69.87% | 必须保障出品、备货与价格纪律 |
|
||||
| B-成长 | 77 | 20.03% | 适合搭售、陈列和套餐测试 |
|
||||
| C-长尾 | 1,624 | 10.10% | 菜单与原料复杂度过高 |
|
||||
|
||||
补充异常:1,155 个 SKU 月实收低于 1,000 元,844 个 SKU 账单数低于 30,25 个 SKU 净销量不大于 0。当前 ABC 已能发现长尾,但应加入“贡献毛利、独有原料、库存占用、报损、替代关系”后再决定停用,不能只按销售额一刀切。
|
||||
|
||||
---
|
||||
|
||||
## 四、现有指标体系审查
|
||||
|
||||
### 1. 总体评价
|
||||
|
||||
应用已经覆盖营收、成本、费用、平台、会员、SKU、排班、风险、任务、中央厨房、配送对账和 BOM 穿透,分析广度较完整;但核心问题是指标之间尚未围绕同一利润公式连接起来。
|
||||
|
||||
| 领域 | 当前已有指标/页面 | 主要问题 | 应补充的决策指标 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| 营收 | 实收、账单、客单、餐段、渠道、门店排名 | 部分接口混入 5 月 1 日;缺少可比店与日历效应 | 同店日均、餐段产能、渠道净贡献、预算差异 | P0 |
|
||||
| 利润 | 利润瀑布、门店利润排名 | “净利润”命名错误;缺失费用按 0;收入跨月 | 门店贡献利润、财务净利润桥、覆盖率、置信等级 | P0 |
|
||||
| 食材成本 | 理论/实际、门店差异、菜品差异、库存 | 两套实际成本相差约 455 万元 | 成本桥、采购价差、用量差、盘点差、报损差 | P0 |
|
||||
| 费用 | 17 个科目、人工/租金/能源、门店贡献 | 费用结构 3,696.08 万元与门店映射 3,181.18 万元范围不同 | 未分配费用、成本中心到门店桥、固定/变动费用 | P0 |
|
||||
| 优惠 | 优惠额、优惠率、门店排名 | 只有让利,没有活动增量和利润 | 活动贡献利润、增量订单、获客成本、复购回收期 | P1 |
|
||||
| 平台 | 收入、折扣、佣金、平台成本率 | 两套佣金口径不一致;“ROI”缺乏反事实 | 佣金后贡献、活动增量、客单、复购、平台依赖 | P1 |
|
||||
| 会员 | 会员/非会员、复购率 | 接口混入 5 月 1 日;缺 cohort 与 LTV | 首购转二购、30/60/90 日留存、会员贡献毛利 | P1 |
|
||||
| SKU | ABC、品类、搭售、负毛利 | 销售贡献与原料/库存影响未打通 | SKU 贡献利润、独有原料数、菜单复杂度成本 | P1 |
|
||||
| 人效排班 | 人工费用、工时/排班 | 人事组织 103 个范围与 91 家销售门店不一致 | 每工时实收、每工时贡献、餐段人岗匹配、加班 | P1 |
|
||||
| 风险/质量 | 零实收、负实收、数据质量状态 | 状态判定忽略已展示的关键异常 | 异常金额、异常率、影响利润、责任系统、修复 SLA | P0 |
|
||||
| 任务闭环 | 分级、自动任务、周检、月验收 | 60 条为模拟验收;部分任务三项数值均为空 | 实际值快照、证据链接、财务确认收益、反弹检查 | P0 |
|
||||
| 供应链 | 中央厨房、配送对账、BOM 穿透 | 方向正确,但尚未进入统一 4 月利润桥 | 采购价差、加工损耗、配送差异、BOM 用量差 | P1 |
|
||||
|
||||
### 2. 应建立的一级指标树
|
||||
|
||||
```text
|
||||
门店贡献利润
|
||||
├── 实收
|
||||
│ ├── 账单数
|
||||
│ ├── 客单价
|
||||
│ ├── 餐段与渠道结构
|
||||
│ └── 优惠与活动增量
|
||||
├── 食材成本
|
||||
│ ├── 标准/BOM 成本
|
||||
│ ├── 采购价格差异
|
||||
│ ├── 实际用量差异
|
||||
│ ├── 盘点与报损差异
|
||||
│ └── 中央厨房及配送差异
|
||||
└── 门店营业费用
|
||||
├── 人工
|
||||
├── 租金
|
||||
├── 水电燃气
|
||||
├── 平台佣金
|
||||
└── 其他可控费用
|
||||
```
|
||||
|
||||
所有二级指标都应回答三个问题:对贡献利润影响多少、哪个门店/菜品/餐段负责、采取什么动作可以在何时回收。
|
||||
|
||||
---
|
||||
|
||||
## 五、当前页面展现问题
|
||||
|
||||
### 1. 影响决策的关键问题
|
||||
|
||||
| 页面/模块 | 当前现象 | 风险 | 整改方式 |
|
||||
|---|---|---|---|
|
||||
| 总部驾驶舱 | 显示“实际净利润”约 345.41 万元、6.13% | 跨月收入与缺失费用造成高估,且名称误导 | 改为“门店贡献利润估算”;显示月份、覆盖 89/91、缺失金额 |
|
||||
| 营收页 | TOP10 副标题为“赚钱最多的 10 家门店” | 把收入等同于利润 | 改为“实收最高”;并列显示贡献利润和贡献率 |
|
||||
| 费用页 | 页面范围出现 89 家与 91 家并存 | 分母不一致,排名失真 | 所有卡片显示“销售/费用匹配/可比”三种覆盖数 |
|
||||
| 费用结构 | 费用结构合计 3,696.08 万元,门店映射合计 3,181.18 万元 | 约 514.90 万元未桥接,无法解释到门店 | 增加“未分配成本中心”桥接表,不强行摊入门店 |
|
||||
| 排班页 | 按 103 个人事组织汇总 | 与 91 家销售门店、86 家标准门店不同 | 建组织到门店映射,未匹配组织单列 |
|
||||
| 数据质量页 | 有 2.14 万零消费、650 笔负实收仍显示“正常” | 管理者误以为数据可直接使用 | 状态应同时考虑数量、金额、比例和 SLA |
|
||||
| 平台页 | 展示 ROI/经济性结论 | 没有对照组、增量销量、佣金后贡献和复购 | 改称“平台成本与收入”;满足因果数据后再展示 ROI |
|
||||
| 任务页 | 60 条显示已验收 | 模拟数据被当成真实结果 | 增加 `data_origin`,模拟记录默认排除经营看板 |
|
||||
|
||||
### 2. 展现结构应从“排名”改为“经营工作台”
|
||||
|
||||
建议总部首页首屏固定为:
|
||||
|
||||
1. 4 月贡献利润基线、覆盖率和数据置信状态。
|
||||
2. 按金额排序的利润机会池,而不是只有红黄绿门店数。
|
||||
3. P0 数据问题、P1 经营问题、责任人、截止日和预计收益。
|
||||
4. 本月已确认收益、待财务确认收益、反弹风险。
|
||||
5. 标准门店/特殊业态切换,默认只比较标准门店。
|
||||
|
||||
图表下钻链应统一为:`公司 -> 业态/区域 -> 门店 -> 餐段/渠道 -> SKU/原料 -> 账单或单据证据`。
|
||||
|
||||
---
|
||||
|
||||
## 六、数据与口径风险
|
||||
|
||||
### 1. 4 月接口越界
|
||||
|
||||
营收相关接口只有 `business_date >= DATE '2026-04-01'`,没有 `< DATE '2026-05-01'`,导致页面额外纳入 5 月 1 日数据。当前页面相关范围约为 **56,367,105 元**,严格 4 月应为 **56,156,726.95 元**。
|
||||
|
||||
受影响位置包括:
|
||||
|
||||
- `server/src/routes/data.ts:570` 渠道收入
|
||||
- `server/src/routes/data.ts:595` 餐段收入
|
||||
- `server/src/routes/data.ts:615` 门店营收排名
|
||||
- `server/src/routes/data.ts:641` 日度营收汇总
|
||||
|
||||
整改:所有 API 必须接受 `month`,后端统一换算成 `[month_start, next_month_start)`,禁止页面各自拼日期。
|
||||
|
||||
### 2. 比率和客单价使用未加权平均
|
||||
|
||||
`server/src/routes/data.ts:625-626` 和 `server/src/routes/data.ts:649-650` 对门店日优惠率、日客单价直接使用 `avg()`。这会让低消费日与高消费日、低单量门店与高单量门店获得相同权重,汇总结果不等于真实公司比率。
|
||||
|
||||
统一公式应为:
|
||||
|
||||
```text
|
||||
优惠率 = sum(优惠额) / sum(消费额)
|
||||
客单价 = sum(实收) / sum(账单数)
|
||||
```
|
||||
|
||||
餐段接口的客单价也应按汇总实收除以汇总账单计算。所有比率禁止“平均的平均”,除非页面明确展示的是“门店平均值”且同时给出分布。
|
||||
|
||||
### 3. 利润高估
|
||||
|
||||
`server/src/routes/data.ts:503-558` 和 `server/src/routes/store-expense.ts:11-70` 使用跨月累计收入,并通过 `COALESCE(cost, 0)` 把缺失成本/费用视为 0。相同逻辑下页面显示约 **3,454,114.25 元、6.13%**;严格 4 月且只统计费用已匹配门店后为 **3,102,628.00 元、5.54%**,差异约 **351,486.25 元**。
|
||||
|
||||
整改:缺失费用时利润字段必须返回 `NULL` 并标记“不可计算”,不能当作 0;利润卡同时展示覆盖门店和未覆盖收入。
|
||||
|
||||
### 4. 成本桥缺失
|
||||
|
||||
| 成本来源 | 理论成本 | 实际成本 | 差异 |
|
||||
|---|---:|---:|---:|
|
||||
| 菜品成本报表 | 16,077,398.19 | 16,547,027.90 | 469,629.70 |
|
||||
| 库存倒挤门店食材成本 | 16,306,634.41 | 21,101,238.04 | 4,794,603.63 |
|
||||
|
||||
两套“实际成本”相差约 **4,554,210.14 元**。可能原因包括范围、成本中心、中央厨房、配送、盘点时点、单位换算、菜品匹配和负耗用,必须逐项桥接,不能简单选一套覆盖另一套。
|
||||
|
||||
6 家灰色成本口径门店贡献了 **1,356,955.30 元**成本差异:西安含光、哈马尔罕总部基地、菜百、永利国际、火锅北三环、大悦春风里。这些门店先修口径,不进入店长成本绩效。
|
||||
|
||||
### 5. 数据异常未进入质量状态
|
||||
|
||||
严格 4 月存在:
|
||||
|
||||
| 异常 | 数量/金额 | 当前风险 |
|
||||
|---|---:|---|
|
||||
| 零消费账单 | 21,445 笔 | 可能是撤单、赠送、导入或状态问题 |
|
||||
| 负实收账单 | 650 笔 | 需区分退款、冲销和异常录入 |
|
||||
| 零实收账单 | 49,236 笔 | 影响账单数、客单和优惠判断 |
|
||||
| 优惠大于消费 | 653 笔 | 需检查字段定义和冲销逻辑 |
|
||||
| 库存负耗用 | 1,569 行,-96,509.16 元 | 可能扭曲成本和库存周转 |
|
||||
|
||||
`client/src/pages/DataQualityPage.tsx:30-31` 的“正常”只检查缺失账单号和门店编码,没有纳入上述异常。
|
||||
|
||||
### 6. 模拟任务被当成经营闭环
|
||||
|
||||
当前共有 141 条任务,60 条“已验收”,其数量和操作流程与 `server/sql/07_simulate_monthly_review.sql:1-57` 完全一致。经营稳定性 11 条任务的当前值、基准值、目标值均为空。
|
||||
|
||||
整改:新增 `data_origin = actual/simulated`、`verified_by`、`verified_at`、`evidence_url`、`finance_confirmed_amount`;历史模拟记录保留用于开发测试,但经营看板默认排除。
|
||||
|
||||
---
|
||||
|
||||
## 七、分领域问题与整改方案
|
||||
|
||||
### 1. 成本:第一利润来源
|
||||
|
||||
门店食材实际成本较理论高 **4,794,603.63 元**。剔除 6 家灰色口径门店后,可比成本差异仍有 **3,437,648.33 元**。首月只要求回收 30%,对应 **1,031,294.50 元**。
|
||||
|
||||
菜品成本报表中共有 788 个菜品,236 个实际成本高于理论,28 个实际负毛利,65 个未匹配销售。前三项差异为:
|
||||
|
||||
| 菜品 | 成本差异 | 判断与动作 |
|
||||
|---|---:|---|
|
||||
| 酱烧琵琶鸡腿饭 | 336,119.68 | 销售 11.49 万但实际成本 39.80 万,优先核查单位、配方和导入 |
|
||||
| 草原游牧羔羊肉串 | 331,402.24 | 校验采购价、克重、出成率和报损 |
|
||||
| 大片牛肉骨汤牛肉面 | 215,526.69 | 核心 A 类菜品,做门店用量差和标准克重专项 |
|
||||
|
||||
执行动作:
|
||||
|
||||
- 每日输出门店 × 原料的采购价差、用量差、盘点差、报损差。
|
||||
- 对 TOP20 成本差异菜品进行 BOM、单位换算和标准克重复核。
|
||||
- 中央厨房、配送对账、BOM 穿透三个新模块统一写入成本桥,不另建互不相通的“节省金额”。
|
||||
- 成本修复验收必须同时满足:库存账实差下降、菜品销量不异常下降、客诉不恶化。
|
||||
|
||||
### 2. 人工与能源:第二利润来源
|
||||
|
||||
85 家标准门店人工率为 **24.83%**,降至 24.00%可释放约 **453,846.54 元/月**;能源率为 **5.31%**,降至 5.00%可释放约 **172,299.80 元/月**。
|
||||
|
||||
执行动作:
|
||||
|
||||
- 按门店、星期、半小时和餐段计算每工时实收、每工时贡献利润。
|
||||
- 排班以客流预测为约束,优先压缩低谷重叠班、无效加班和跨岗空闲,不直接按人数平均裁减。
|
||||
- 能源按面积、营业时长和客流做同类店基准;异常店安装日抄表和闭店检查。
|
||||
- 租金属于中短期固定成本,高租金率门店应通过保本收入、续租谈判或退出评估解决,不能要求店长一个月内“降租金率”。
|
||||
|
||||
### 3. 优惠:从优惠率改为活动贡献利润
|
||||
|
||||
4 月有 11 家门店优惠率超过 25%。在销量不变的静态假设下,将超出部分降到 25%可减少让利 **138,833.06 元**。这只是理论上限,不能直接作为确定利润,因为取消优惠可能损失订单。
|
||||
|
||||
执行动作:
|
||||
|
||||
- 活动最小核算单元改为“门店 × 平台/渠道 × 活动 × SKU × 日期”。
|
||||
- 每个活动输出:优惠前收入、优惠、佣金、食材成本、增量订单、佣金后贡献、7/30 日复购。
|
||||
- 高优惠门店先做 A/B 或分时段停投,只有贡献利润提高且账单/顾客稳定才扩大。
|
||||
- 11 家高优惠门店必须完成保留、调整、停止或书面例外审批。
|
||||
|
||||
### 4. 平台:先对账,再谈 ROI
|
||||
|
||||
平台存在两套 4 月附近口径:
|
||||
|
||||
| 来源 | 平台实收 | 佣金 | 佣金率 | 降至 20%的理论机会 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 费用映射表 | 16,655,083.46 | 3,418,737.82 | 20.53% | 87,721.13 |
|
||||
| 严格 4 月账单字段 | 16,587,919.70 | 3,528,362.88 | 21.27% | 210,778.94 |
|
||||
|
||||
两套数据的收入相差 **67,163.76 元**、佣金相差 **109,625.06 元**。在对账完成前,机会池采用较保守的 **87,721.13 元**;两套来源必须按平台、门店、订单和结算期建立差异表。
|
||||
|
||||
### 5. 会员:提高识别和二购,不增加无效折扣
|
||||
|
||||
会员客单比非会员高约 2.81 元,优惠率反而低 4.21 个百分点,说明会员经营有提升收入质量的空间。首月应把会员动作聚焦在“识别率”和“首购转二购”,而不是发放更大额通用券。
|
||||
|
||||
执行动作:
|
||||
|
||||
- 门店按收银员跟踪会员识别率,但必须排除零实收、退款和员工餐。
|
||||
- 以首购日期建立 7/30/60 日复购 cohort。
|
||||
- 优惠券按增量贡献利润验收,未产生二购的补贴不计为有效会员投入。
|
||||
- 会员增长不纳入首月 150 万元硬收益承诺,先建立可测量基线。
|
||||
|
||||
### 6. SKU:压缩复杂度但保护核心收入
|
||||
|
||||
1,624 个 C 类 SKU 只贡献 10.10%实收,菜单、采购、备货和训练复杂度明显偏高。首轮建议:
|
||||
|
||||
- 对“月实收 < 1,000、账单 < 30、非战略品、非套餐组件”的交集生成停用候选。
|
||||
- 对 25 个净销量不大于 0 的 SKU 先清理退菜、冲销和单位问题。
|
||||
- 停用前计算独有原料、库存金额、替代菜品和贡献利润;停用后观察 4 周销售迁移与客诉。
|
||||
- 44 个 A 类 SKU 建立缺货率、出品时长、标准成本偏差的红线监控。
|
||||
|
||||
---
|
||||
|
||||
## 八、重点门店诊断
|
||||
|
||||
### 1. 标准门店主要亏损
|
||||
|
||||
| 门店 | 贡献利润估算 | 主要证据 | 30 天主动作 |
|
||||
|---|---:|---|---|
|
||||
| 茂林居店 | -336,828.67 | 食材差异 209,245.35;人工率 43.5%;租金率 23.8% | 成本 TOP 原料日清、按餐段重排班、测算保本收入 |
|
||||
| 永利国际店 | -221,117.13 | 灰色成本口径;人工率 40.3%;租金率 28.7% | 先修理论成本口径,再决定经营整改 |
|
||||
| 温泉店 | -127,137.77 | 食材差异 95,838.52;人工率 32.5%;优惠率 26.7% | 成本回收、低谷排班、高优惠活动停改留 |
|
||||
| 新街口南大街店 | -91,687.09 | 人工率 31.3%;租金率 26.6%;能源率 12.2% | 控人效和能源,建立续租/保本收入专项 |
|
||||
| 菜百店 | -64,037.17 | 灰色理论成本口径;租金率 25.5% | 先修成本,修复后重算贡献利润 |
|
||||
| 右安门店 | -29,883.00 | 优惠率 27.4%;人工率 29.6%;能源率 9.3% | 优惠与平台专项、低谷工时和闭店能源检查 |
|
||||
|
||||
永利国际、菜百以及其他灰色门店的亏损结论属于 B/C 级证据,不能在数据修复前直接追责店长。
|
||||
|
||||
### 2. 门店分组处置
|
||||
|
||||
| 分组 | 门店/条件 | 处置原则 |
|
||||
|---|---|---|
|
||||
| P0 数据修复 | 西安含光、哈马尔罕总部基地、菜百、永利国际、火锅北三环、大悦春风里 | 48 小时内完成理论成本、业态和费用归属核查 |
|
||||
| P0 费用补齐 | 关东店铜锅聚、甄选商城店 | 补齐费用前不显示利润和利润排名 |
|
||||
| P1 扭亏 | 茂林居、温泉、新街口南大街、右安门等可信亏损店 | 每店最多 2 个核心经营指标,周度验收 |
|
||||
| P1 结构专项 | 高优惠 11 家、人工/能源异常店 | 用金额机会排序,避免平均分配任务 |
|
||||
| 特殊业态 | 机场、火锅、总部基地、快手、商城 | 建独立损益模板和阈值,不参与标准店排名 |
|
||||
|
||||
---
|
||||
|
||||
## 九、利润机会池
|
||||
|
||||
| 机会 | 4 月基线 | 目标/假设 | 理论月度机会 | 置信度 | 责任部门 | 验收证据 |
|
||||
|---|---|---|---:|---|---|---|
|
||||
| 非灰色食材差异回收 | 3,437,648.33 差异 | 回收 30% | 1,031,294.50 | 中高 | 商品、供应链、门店 | 采购/领用/盘点/报损成本桥 |
|
||||
| 标准店人工优化 | 人工率 24.83% | 降至 24.00% | 453,846.54 | 中 | 运营、人力 | 工时、工资、餐段销售与服务质量 |
|
||||
| 标准店能源优化 | 能源率 5.31% | 降至 5.00% | 172,299.80 | 中 | 工程、门店 | 账单/抄表、面积、营业时长 |
|
||||
| 高优惠门店治理 | 11 家超过 25% | 超出部分降至 25% | 138,833.06 | 中低 | 营销、运营 | 活动贡献、账单和复购不恶化 |
|
||||
| 平台佣金优化 | 费用口径 20.53% | 降至 20.00% | 87,721.13 | 中低 | 外卖、采购 | 平台结算单与订单对账 |
|
||||
| **合计** | | | **1,883,995.03** | | | 需去重后确认 |
|
||||
|
||||
机会金额不能由任务负责人自行填报后直接计入成果。财务确认规则应为:
|
||||
|
||||
```text
|
||||
确认收益 = 基线单位成本/费率 × 实际业务量 - 整改后实际成本/费用
|
||||
```
|
||||
|
||||
同时扣除收入损失、替代成本、额外投入,并以同口径、同范围、同营业天数进行比较。同一笔收益只能归属一个项目。
|
||||
|
||||
---
|
||||
|
||||
## 十、30 天整改路线图
|
||||
|
||||
### P0:第 1-7 天,先让数字可用
|
||||
|
||||
| 动作 | 负责人 | 截止 | 验收标准 |
|
||||
|---|---|---|---|
|
||||
| 所有接口统一 4 月半开区间 | 数据产品/后端 | D+2 | API 与 SQL 直查金额误差不超过 0.01 |
|
||||
| “净利润”改为“门店贡献利润估算” | 产品/财务 | D+2 | 全站名称、公式、覆盖范围一致 |
|
||||
| 缺失成本费用返回 NULL | 后端 | D+2 | 2 家缺失费用门店不进入利润排名 |
|
||||
| 建立 91/89/86/5 门店范围标签 | 数据治理 | D+3 | 每个总数均能解释分母和排除项 |
|
||||
| 完成两套实际成本桥设计 | 财务/供应链/商品 | D+5 | 455.42 万元差异 100%分配到原因类别 |
|
||||
| 修复 6 家灰色成本口径 | 商品/数据 | D+7 | 灰色门店降至 0,重算结果可追溯 |
|
||||
| 隔离模拟验收 | 产品/数据库 | D+2 | 经营闭环只统计 actual 记录 |
|
||||
| 升级数据质量状态规则 | 数据治理 | D+5 | 负数、零值、越界、映射失败均有状态和 SLA |
|
||||
|
||||
### P1:第 8-21 天,执行利润专项
|
||||
|
||||
| 专项 | 选择范围 | 核心动作 | 周度验收 |
|
||||
|---|---|---|---|
|
||||
| 食材差异 | 可比差异 TOP20 门店/菜品 | 标准克重、采购价、领用、盘点、报损日清 | 差异金额周环比下降,销量与客诉稳定 |
|
||||
| 人效 | 人工率高且餐段低产出门店 | 半小时客流排班、跨岗、低谷缩班 | 每工时贡献提高,服务时长不恶化 |
|
||||
| 能源 | 能源率高于同类基准门店 | 分表、闭店检查、设备时段策略 | 单位面积/营业小时能耗下降 |
|
||||
| 优惠 | 11 家优惠率 >25% | 活动停改留、小流量对照 | 佣金后贡献提高,订单损失在阈值内 |
|
||||
| 平台 | 高佣金和高依赖门店 | 结算对账、费率谈判、菜单价格纪律 | 佣金率下降且平台贡献不下降 |
|
||||
| 扭亏 | 可信亏损标准门店 | 每店只设 1-2 个金额最大的动作 | 贡献利润周运行率转正或明显收窄 |
|
||||
|
||||
### P2:第 22-30 天,固化机制
|
||||
|
||||
- 发布 4 月统一利润桥和门店贡献利润榜。
|
||||
- 发布 SKU 停用候选与 A 类保障清单,启动 4 周观察。
|
||||
- 建立会员首购转二购 cohort,不把会员增长提前计入利润成果。
|
||||
- 对各专项做财务去重验收,形成“已确认/待确认/未实现”三种收益状态。
|
||||
- 只有满足收入稳定、贡献改善、顾客指标稳定、异常下降四项条件,任务才可验收。
|
||||
- 将有效动作转为标准作业程序,并选择 2-3 家同业态门店复测。
|
||||
|
||||
---
|
||||
|
||||
## 十一、任务与验收模板
|
||||
|
||||
每条任务必须包含:
|
||||
|
||||
| 字段 | 示例 |
|
||||
|---|---|
|
||||
| 问题 | 茂林居店食材差异 209,245.35 元 |
|
||||
| 数据期间/范围 | 2026-04,标准门店,费用已匹配 |
|
||||
| 基线值 | 食材差异 209,245.35 元 |
|
||||
| 目标值 | 月运行率下降 30% |
|
||||
| 利润机会 | 62,773.61 元/月,未去重 |
|
||||
| 根因假设 | 牛羊肉采购价、标准克重、盘点差异 |
|
||||
| 动作 | 每日盘点 TOP5 原料;抽称;采购价复核 |
|
||||
| 责任人/协同人 | 店长 / 商品、供应链、财务 |
|
||||
| 截止日 | 明确到日期 |
|
||||
| 过程证据 | 盘点单、称重记录、采购单、系统截图 |
|
||||
| 验收公式 | 同口径基线成本 - 整改后成本 - 额外投入 |
|
||||
| 护栏指标 | 实收、销量、退款、客诉不得恶化超阈值 |
|
||||
| 数据来源 | actual,不允许 simulated |
|
||||
| 财务确认 | 金额、确认人、确认时间 |
|
||||
|
||||
建议任务状态改为:`待诊断 -> 已确认根因 -> 执行中 -> 待验收 -> 已验收/未达标 -> 反弹观察 -> 已固化`。仅有“完成动作”不能等同于“实现利润”。
|
||||
|
||||
---
|
||||
|
||||
## 十二、产品重构建议
|
||||
|
||||
### 1. 总部驾驶舱
|
||||
|
||||
首屏只保留 8 个管理指标:严格 4 月实收、门店贡献利润、贡献率、利润覆盖率、已确认机会、待确认机会、可信亏损门店数、P0 数据问题数。每张卡必须显示月份、公式、分母、覆盖门店和更新时间。
|
||||
|
||||
### 2. 利润桥页面
|
||||
|
||||
统一展示:
|
||||
|
||||
```text
|
||||
实收
|
||||
- 食材成本(标准成本 + 采购价差 + 用量差 + 盘点/报损差)
|
||||
- 人工
|
||||
- 租金
|
||||
- 能源
|
||||
- 平台佣金
|
||||
- 其他门店费用
|
||||
= 门店贡献利润
|
||||
```
|
||||
|
||||
再单独展示“门店贡献利润 -> 总部及其他费用 -> 财务净利润”的待补项目,禁止混称。
|
||||
|
||||
### 3. 问题工作台
|
||||
|
||||
每行是一项可执行问题,而不是一个泛化排名:`问题对象、证据等级、异常金额、利润机会、根因状态、动作、负责人、截止日、已确认收益`。默认按利润机会排序。
|
||||
|
||||
### 4. 数据质量页
|
||||
|
||||
增加四层状态:完整性、准确性、一致性、时效性。异常必须同时展示记录数、金额、占比、影响指标、责任系统和预计修复时间;任何关键利润输入为 C 级时,相应利润卡显示灰色“不可考核”。
|
||||
|
||||
### 5. 中央厨房、配送和 BOM 模块
|
||||
|
||||
现有新增模块方向正确,但最终输出必须回写统一成本桥:
|
||||
|
||||
- 中央厨房:原料投入、成品产出、加工损耗、内部结算价差。
|
||||
- 配送对账:发出、签收、退货、短溢、结算差异。
|
||||
- BOM 穿透:标准用量、实际用量、单位换算、替代料和版本有效期。
|
||||
|
||||
三者共同解释食材成本差异,不能各自生成重复的“节省金额”。
|
||||
|
||||
---
|
||||
|
||||
## 十三、4 月验收看板定义
|
||||
|
||||
| 指标 | 公式 | 基线 | 红线/目标 |
|
||||
|---|---|---:|---:|
|
||||
| 利润数据覆盖率 | 可计算贡献利润的 4 月实收 / 全部 4 月实收 | 99.75% | 100% |
|
||||
| 标准门店贡献率 | 标准店贡献利润 / 标准店实收 | 7.29% | >=10.00% |
|
||||
| 可信亏损门店数 | 有销售、费用完整、非灰色且贡献<0 | 需修复后重算 | <=5 家 |
|
||||
| 可比食材差异回收率 | 已确认回收 / 3,437,648.33 | 0 | >=30% |
|
||||
| 人工率 | 标准店人工 / 标准店实收 | 24.83% | <=24.00% |
|
||||
| 能源率 | 标准店能源 / 标准店实收 | 5.31% | <=5.00% |
|
||||
| 高优惠门店数 | 优惠率 >25%的门店 | 11 | 0 或全部例外审批 |
|
||||
| 真实任务验收率 | actual 且证据完整的已验收 / 到期任务 | 当前不可用 | >=80% |
|
||||
| 财务确认收益 | 去重后的已确认贡献改善 | 0 | >=1,500,000 元/月 |
|
||||
|
||||
收益验收必须保留三个护栏:实收下降不超过批准阈值、顾客/账单不发生不可解释下降、退款及客诉不恶化。否则即使成本下降,也不能判定整改成功。
|
||||
|
||||
---
|
||||
|
||||
## 十四、数据来源与代码证据
|
||||
|
||||
### 1. 主要数据对象
|
||||
|
||||
- 营收、账单、优惠:`analytics.bill_fact`、`analytics.v_store_daily`
|
||||
- 门店费用与贡献:`analytics.mv_store_operating_expense_monthly`
|
||||
- 库存倒挤成本:`analytics.mv_store_theoretical_actual_cost_april`
|
||||
- 菜品成本:`analytics.v_dish_cost_analysis_latest_summary`
|
||||
- SKU ABC:`analytics.v_dish_sku_abc_april`
|
||||
- 会员复购:`analytics.mv_store_repeat_summary_monthly`
|
||||
- 门店主数据:`analytics.dim_store`
|
||||
- 任务与验收:`analytics.store_task`、`analytics.task_monthly_review`
|
||||
|
||||
### 2. 关键代码证据
|
||||
|
||||
- 利润接口跨范围及缺失值按 0:`server/src/routes/data.ts:503-558`
|
||||
- 营收接口缺少结束日期:`server/src/routes/data.ts:570-655`
|
||||
- 优惠率与客单价使用未加权平均:`server/src/routes/data.ts:625-626`、`server/src/routes/data.ts:649-650`
|
||||
- 费用接口使用跨月收入及 `COALESCE`:`server/src/routes/store-expense.ts:11-70`
|
||||
- 营收与赚钱文案混淆:`client/src/pages/RevenuePage.tsx:246-255`
|
||||
- 数据质量“正常”判断过窄:`client/src/pages/DataQualityPage.tsx:20-31`
|
||||
- 模拟执行和月度验收脚本:`server/sql/07_simulate_monthly_review.sql:1-57`
|
||||
|
||||
---
|
||||
|
||||
## 十五、最终建议
|
||||
|
||||
4 月数据已经足以发现利润问题,但暂不足以直接证明财务净利润。正确推进顺序应为:
|
||||
|
||||
1. 先锁定月份、门店和贡献利润口径,修复费用缺失、成本灰区和模拟验收。
|
||||
2. 将可比成本差异、人工、能源、优惠和平台统一进入利润机会池。
|
||||
3. 以茂林居、温泉、新街口南大街、右安门等可信亏损店作为首批经营整改对象;灰色门店先修数据,不先追责。
|
||||
4. 用 30 天实现不低于 150 万元可验证月度贡献改善,使标准门店贡献率达到约 10%。
|
||||
5. 后续所有页面和任务都围绕“问题金额 -> 动作 -> 责任人 -> 验收收益”组织,避免继续堆叠孤立指标和排名。
|
||||
|
||||
本报告可作为 4 月经营复盘会、基于 4 月基线的整改任务下发及后续产品改造的统一基线;若数据库刷新或成本口径修复,应保留本版本并通过指标版本日志发布重算结果。
|
||||
|
||||
---
|
||||
|
||||
## 十六、P0 整改完成状态
|
||||
|
||||
> 更新时点:2026-08-01
|
||||
|
||||
### 1. 代码整改项(已全部完成并部署)
|
||||
|
||||
| 编号 | P0 动作 | 状态 | 验收结果 | 代码位置 |
|
||||
|---|---|:---:|---|---|
|
||||
| P0-1 | 所有接口统一 4 月半开区间 | ✅ | 渠道/餐段/门店排名/日度汇总均加 `< DATE '2026-05-01'`,API 与 SQL 直查金额一致 | `server/src/routes/data.ts` |
|
||||
| P0-2 | "净利润"改为"门店贡献利润估算" | ✅ | BossPage/BankPage/DashboardPage/RevenuePage 全站名称、字段名、公式一致;"净利率"改为"贡献率" | `client/src/pages/BossPage.tsx` 等 |
|
||||
| P0-3 | 缺失成本费用返回 NULL | ✅ | 利润瀑布接口去除 COALESCE,缺失费用返回 NULL;2 家缺失费用门店不进入利润排名 | `server/src/routes/data.ts` |
|
||||
| P0-3b | 优惠率与客单价改为加权平均 | ✅ | 优惠率 = sum(优惠额)/sum(消费额) = 20.20%;客单价 = sum(实收)/sum(账单数) = ¥33.59 | `server/src/routes/data.ts` |
|
||||
| P0-4 | 利润机会池(含 SKU 处置机会) | ✅ | 6 类机会合计 ¥2,483,711.5,30 天承诺值 ≥ 150 万元;每条可点击展开查看 TOP5 门店具体数据和行动指向 | `server/src/routes/data.ts` + `client/src/pages/BossPage.tsx` |
|
||||
| P0-5 | 升级数据质量状态规则 | ✅ | 新增零实收 49,416 笔、优惠大于消费 654 笔、负耗用 1,559 行/-96,509.16 元;四层状态(完整性/准确性/一致性/时效性)+ 利润数据置信等级 B | `server/src/routes/data.ts` + `client/src/pages/DataQualityPage.tsx` |
|
||||
| P0-6 | 隔离模拟验收记录 | ✅ | 60 条模拟记录标记 `is_simulated = true`;任务列表、月度复盘完成率、态势感知统计默认排除 | `server/src/routes/tasks.ts` + `server/src/routes/situational-awareness.ts` + `server/sql/08_isolate_simulated_reviews.sql` |
|
||||
|
||||
### 2. 利润机会池实现详情
|
||||
|
||||
利润机会池已从文档中的 5 类扩展为 6 类,新增"SKU 复杂度压缩":
|
||||
|
||||
| 机会 | 4 月基线 | 理论月度机会 | 置信度 | 展开详情内容 |
|
||||
|---|---:|---:|---|---|
|
||||
| 食材成本差异回收 | 差异 4,729,994.43 元 | 1,418,998.33 | 中高 | 实际 vs 理论成本率对比 + 差异 TOP5 门店(西安含光、哈马尔罕总部基地、菜百、永利国际、茂林居) |
|
||||
| 标准店人工优化 | 费率 25.26% | 705,770.38 | 中 | 人工合计 + 费率 TOP5 门店(哈马尔罕总部基地、火锅北三环、茂林居、永利国际、丰台北路) |
|
||||
| 标准店能源优化 | 费率 5.34% | 193,693.59 | 中 | 水电合计 + 费率 TOP5 门店(哈马尔罕总部基地、火锅北三环、新街口南大街、右安门、武圣) |
|
||||
| 高优惠门店治理 | 12 家 >25% | 146,796.46 | 中低 | 优惠 TOP5 门店(火锅北三环 44.85%、新华北路 29.58%、丰台北路 29.40%、云岗 27.48%、右安门 27.43%) |
|
||||
| 平台佣金优化 | 佣金率 6.08% | 0 | 中低 | 佣金合计 + 费率 TOP5 门店;当前已低于 20% 目标,暂无优化空间 |
|
||||
| SKU 复杂度压缩 | 802 个低效 SKU | 18,452.74 | 低 | 低效 SKU 示例(测试商品、外卖套餐、团购套餐等 0 元收入 SKU) |
|
||||
| **合计** | | **2,483,711.50** | | |
|
||||
|
||||
### 3. 前端展现优化
|
||||
|
||||
| 优化项 | 状态 | 说明 |
|
||||
|---|:---:|---|
|
||||
| 利润机会池位置调整 | ✅ | 从门店利润排名后移至利润结构之后,紧邻利润瀑布图 |
|
||||
| 利润机会池可展开详情 | ✅ | 每条机会点击展开/收起,含具体门店名、数值、费率和行动指向 |
|
||||
| 副标题提示 | ✅ | "点击展开详情"提示用户可交互 |
|
||||
| 箭头动画 | ✅ | 展开时 ChevronDown 旋转 180°,紫色边框高亮 |
|
||||
|
||||
### 4. 待完成项(业务/数据侧,非代码)
|
||||
|
||||
> 以下三项属于业务设计和数据治理任务,非纯代码实现。以下为具体展开方案。
|
||||
|
||||
---
|
||||
|
||||
#### P0-A:建立 91/89/86/5 门店范围标签
|
||||
|
||||
**现状:**
|
||||
|
||||
- `analytics.dim_store` 已有 `business_type` 字段,当前值为"标准门店"(86 家) / "特殊业态"(5 家)。
|
||||
- 91 家有销售门店全部能在 `dim_store` 匹配到,但缺少"是否有费用"、"是否可比(非灰色)"两个维度标签。
|
||||
- 89 家有费用匹配,2 家缺失(1098 关东店铜锅聚、1110 甄选商城店)。
|
||||
- 6 家灰色成本口径门店目前仍标记为"标准门店"或"特殊业态",未单独标记"灰色"。
|
||||
|
||||
**方案:在 `dim_store` 新增 3 个标签字段**
|
||||
|
||||
```sql
|
||||
-- Step 1: 新增字段
|
||||
ALTER TABLE analytics.dim_store
|
||||
ADD COLUMN IF NOT EXISTS has_expense_4m BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS is_grey_cost BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS scope_tag TEXT;
|
||||
|
||||
-- Step 2: 标记有费用的89家
|
||||
UPDATE analytics.dim_store d
|
||||
SET has_expense_4m = TRUE
|
||||
WHERE d.store_code IN (
|
||||
SELECT DISTINCT e.sales_store_code
|
||||
FROM analytics.mv_store_operating_expense_monthly e
|
||||
WHERE e.report_month = '2026-04-01' AND e.operating_expense IS NOT NULL
|
||||
);
|
||||
|
||||
-- Step 3: 标记6家灰色成本口径门店
|
||||
UPDATE analytics.dim_store
|
||||
SET is_grey_cost = TRUE
|
||||
WHERE store_name IN (
|
||||
'西安含光店', '哈马尔罕总部基地店', '菜百店',
|
||||
'永利国际店', '火锅北三环店', '大悦春风里店'
|
||||
);
|
||||
|
||||
-- Step 4: 生成统一scope_tag
|
||||
UPDATE analytics.dim_store
|
||||
SET scope_tag = CASE
|
||||
WHEN has_expense_4m = FALSE THEN '缺失费用'
|
||||
WHEN is_grey_cost = TRUE THEN '灰色口径'
|
||||
WHEN business_type = '特殊业态' THEN '特殊业态'
|
||||
ELSE '标准可比'
|
||||
END;
|
||||
|
||||
-- Step 5: 验证结果
|
||||
SELECT scope_tag, count(*) AS store_count,
|
||||
round(sum(r.received)::numeric, 2) AS total_received
|
||||
FROM analytics.dim_store d
|
||||
JOIN analytics.mv_store_risk_rating r ON d.store_code = r.store_code
|
||||
WHERE r.received IS NOT NULL AND r.received > 0
|
||||
GROUP BY scope_tag ORDER BY store_count DESC;
|
||||
```
|
||||
|
||||
**预期结果:**
|
||||
|
||||
| scope_tag | 门店数 | 说明 |
|
||||
|---|---:|---|
|
||||
| 标准可比 | 79 家 | 有费用、非灰色、标准业态,可参与排名和考核 |
|
||||
| 特殊业态 | 4 家 | 有费用、非灰色、特殊业态,单列分析 |
|
||||
| 灰色口径 | 6 家 | 有费用但成本口径异常,先修数据再考核 |
|
||||
| 缺失费用 | 2 家 | 无费用数据,只展示营收不计算利润 |
|
||||
| **合计** | **91 家** | |
|
||||
|
||||
**API 改造:** 所有利润排名、成本排名接口在 WHERE 条件中加 `AND scope_tag = '标准可比'`,默认排除灰色和缺失费用门店。
|
||||
|
||||
---
|
||||
|
||||
#### P0-B:两套实际成本桥设计
|
||||
|
||||
**现状数据:**
|
||||
|
||||
| 成本来源 | 理论成本 | 实际成本 | 差异 |
|
||||
|---|---:|---:|---:|
|
||||
| 库存倒挤(`mv_store_operating_expense_monthly.actual_food_cost`) | 16,388,682.98 | 21,101,238.04 | 4,712,555.06 |
|
||||
| 菜品成本报表(`v_dish_cost_analysis_latest_summary`) | 16,077,398.19 | 16,547,027.90 | 469,629.70 |
|
||||
| **两套实际成本差异** | | | **4,554,210.14** |
|
||||
| **两套理论成本差异** | **311,284.79** | | |
|
||||
|
||||
**差异分解方向:**
|
||||
|
||||
| 差异类别 | 金额估算 | 排查方法 | 负责人 |
|
||||
|---|---:|---|---|
|
||||
| 1. 范围差异(门店覆盖不同) | 待定 | 对比两套来源的门店清单,库存倒挤含 91 家,菜品报表可能不含特殊业态 | 数据治理 |
|
||||
| 2. 中央厨房及配送 | 待定 | 库存倒挤含中央厨房领料,菜品报表只计门店直采;需建立内部结算价差桥 | 供应链/商品 |
|
||||
| 3. 盘点时点差异 | 待定 | 库存倒挤 = 期初 + 采购 - 期末 - 报损,盘点时点不同导致差异 | 财务 |
|
||||
| 4. 单位换算 | 待定 | BOM 标准用量单位(克)与采购单位(公斤/箱)换算误差 | 商品 |
|
||||
| 5. 负耗用处理 | -96,509.16 元 | 1,559 行负耗用在库存倒挤中扭曲成本,菜品报表按实际出成率计算 | 数据 |
|
||||
| 6. 菜品匹配 | 待定 | 65 个菜品未匹配销售,菜品报表可能漏算 | 商品 |
|
||||
|
||||
**方案:建立 `analytics.cost_bridge_april` 桥接表**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS analytics.cost_bridge_april (
|
||||
bridge_id SERIAL PRIMARY KEY,
|
||||
store_code TEXT REFERENCES analytics.dim_store(store_code),
|
||||
diff_category TEXT NOT NULL, -- '范围差异','中央厨房','盘点时点','单位换算','负耗用','菜品匹配'
|
||||
inventory_cost NUMERIC, -- 库存倒挤口径金额
|
||||
dish_report_cost NUMERIC, -- 菜品成本报表口径金额
|
||||
diff_amount NUMERIC NOT NULL, -- 差异金额
|
||||
explanation TEXT, -- 差异原因说明
|
||||
assigned_to TEXT, -- 责任人
|
||||
status TEXT DEFAULT '待排查', -- 待排查/已确认/已修复
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 按门店插入差异行
|
||||
INSERT INTO analytics.cost_bridge_april (store_code, diff_category, inventory_cost, dish_report_cost, diff_amount, explanation)
|
||||
SELECT d.store_code, '总差异', inv.actual_food_cost, dish.actual_cost,
|
||||
round((inv.actual_food_cost - dish.actual_cost)::numeric, 2),
|
||||
'库存倒挤与菜品报表的实际成本差异'
|
||||
FROM analytics.dim_store d
|
||||
-- ... (需逐项桥接,此处为框架)
|
||||
;
|
||||
```
|
||||
|
||||
**实施步骤:**
|
||||
|
||||
1. **D+1**:导出两套来源的门店 × 菜品 × 成本明细,建立差异对照表。
|
||||
2. **D+3**:按上述 6 类差异逐项分解,每类差异分配到责任人。
|
||||
3. **D+5**:完成差异 100% 分配,形成 `cost_bridge_april` 表,每行可追溯到具体单据。
|
||||
4. **D+7**:基于桥接结果,决定利润机会池中"食材成本差异回收"采用哪套口径作为基线。
|
||||
|
||||
---
|
||||
|
||||
#### P0-C:6 家灰色成本口径修复
|
||||
|
||||
**现状数据:**
|
||||
|
||||
| 门店 | store_code | business_type | 实收(元) | 理论毛利率 | 实际食材成本 | 实际成本率 | 差异(元) | 异常原因 |
|
||||
|---|---|---|---:|---:|---:|---:|---:|---|
|
||||
| 哈马尔罕总部基地店 | 0056 | 特殊业态 | 3,118.96 | 100.00% | 326,021.38 | 10,452.89% | 326,021.38 | 理论毛利率=100%→理论成本=0,实际成本远大于实收,疑似费用归属错误 |
|
||||
| 菜百店 | 1111 | 标准门店 | 652,932.76 | 99.97% | 244,256.18 | 37.41% | 244,060.30 | 理论毛利率≈100%→理论成本≈0,毛利率设置异常 |
|
||||
| 永利国际店 | 1109 | 标准门店 | 472,712.37 | 99.86% | 225,533.71 | 47.71% | 224,871.91 | 理论毛利率≈100%→理论成本≈0,毛利率设置异常 |
|
||||
| 火锅北三环店 | 0012 | 特殊业态 | 56,661.94 | 99.09% | 131,703.15 | 232.44% | 131,187.53 | 理论毛利率≈99%→理论成本≈1%,实际成本远超实收,业态不同成本结构不同 |
|
||||
| 西安含光店 | 0052 | 标准门店 | 527,901.19 | 93.88% | 463,064.25 | 87.72% | 430,756.70 | 理论毛利率=93.88%→理论成本率=6.12%,但实际成本率=87.72%,差距极大 |
|
||||
| 大悦春风里店 | 1004 | 标准门店 | 0.00 | NULL | 0.00 | NULL | 0.00 | 4 月无销售但有成本记录 0.0001 元 |
|
||||
|
||||
**根因分析:**
|
||||
|
||||
1. **哈马尔罕总部基地店(0056)**:实收仅 3,119 元但食材成本 326,021 元,疑似总部基地费用误归集到门店,或该店为中央厨房/培训基地,不应按标准门店口径考核。
|
||||
2. **菜百店(1111)、永利国际店(1109)**:`theoretical_margin_pct` 设置为 99.97%/99.86%,接近 100%,导致理论成本≈0,明显是毛利率配置错误。正常门店毛利率中位数为 70.03%(P25=68.96%,P75=71.12%)。
|
||||
3. **火锅北三环店(0012)**:火锅业态成本结构与拉面不同,食材成本率 232% 说明可能存在收入口径问题(如只计堂食收入但成本含火锅食材全部)。
|
||||
4. **西安含光店(0052)**:理论毛利率 93.88% 远高于正常范围(中位数 70%),疑似毛利率配置错误或成本归属错误。
|
||||
5. **大悦春风里店(1004)**:4 月无销售(实收=0),但有 0.0001 元成本记录,疑似数据残留。
|
||||
|
||||
**修复方案:**
|
||||
|
||||
| 门店 | 修复动作 | 预计修复后状态 | 负责人 | 截止 |
|
||||
|---|---|---|---|---|
|
||||
| 哈马尔罕总部基地店 | 重新界定业态:改为"中央厨房/培训基地",不参与门店利润排名 | `business_type` → 特殊业态,`scope_tag` → 灰色口径(修复后→特殊业态) | 商品/运营 | D+3 |
|
||||
| 菜百店 | 核查 `theoretical_margin_pct` 配置来源,修正为同业态中位数 70% | 修复后理论成本≈195,880 元,与实际成本 244,256 元差异≈48,376 元(合理范围) | 商品/数据 | D+3 |
|
||||
| 永利国际店 | 同上,修正 `theoretical_margin_pct` 为 70% | 修复后理论成本≈141,814 元,与实际成本 225,534 元差异≈83,720 元(需进一步排查) | 商品/数据 | D+3 |
|
||||
| 火锅北三环店 | 重新界定业态:改为"火锅业态",建独立成本模型 | `business_type` → 特殊业态,不参与标准门店成本排名 | 商品/运营 | D+5 |
|
||||
| 西安含光店 | 核查 `theoretical_margin_pct` 配置,93.88% 明显异常;同时核查成本归属是否有跨店分摊 | 修正毛利率后重算理论成本,再评估差异是否在合理范围 | 商品/数据 | D+5 |
|
||||
| 大悦春风里店 | 4 月无销售,标记为"停业/待开业",不参与任何排名 | `scope_tag` → 缺失费用/停业 | 数据治理 | D+1 |
|
||||
|
||||
**修复后影响:**
|
||||
|
||||
- 灰色门店从 6 家降至 0 家(2 家改业态、3 家修毛利率、1 家标记停业)。
|
||||
- 可比成本差异从 3,437,648.33 元(已剔除灰色)可能需重算——修复后菜百、永利国际、西安含光的理论成本将大幅上升,实际差异将缩小。
|
||||
- 利润机会池中"食材成本差异回收"的基线和机会金额需同步更新。
|
||||
|
||||
**SQL 修复脚本框架:**
|
||||
|
||||
```sql
|
||||
-- 修复1: 哈马尔罕总部基地店 → 特殊业态
|
||||
UPDATE analytics.dim_store
|
||||
SET business_type = '特殊业态', is_grey_cost = TRUE
|
||||
WHERE store_code = '0056';
|
||||
|
||||
-- 修复2: 菜百店理论毛利率 → 70%(需商品确认实际值)
|
||||
UPDATE analytics.mv_store_risk_rating
|
||||
SET theoretical_margin_pct = 70.00
|
||||
WHERE store_code = '1111';
|
||||
-- 注意: mv_store_risk_rating 是物化视图,需 REFRESH MATERIALIZED VIEW
|
||||
|
||||
-- 修复3: 永利国际店理论毛利率 → 70%
|
||||
UPDATE analytics.mv_store_risk_rating
|
||||
SET theoretical_margin_pct = 70.00
|
||||
WHERE store_code = '1109';
|
||||
|
||||
-- 修复4: 火锅北三环店 → 特殊业态
|
||||
UPDATE analytics.dim_store
|
||||
SET business_type = '特殊业态', is_grey_cost = TRUE
|
||||
WHERE store_code = '0012';
|
||||
|
||||
-- 修复5: 西安含光店理论毛利率 → 70%(需商品确认)
|
||||
UPDATE analytics.mv_store_risk_rating
|
||||
SET theoretical_margin_pct = 70.00
|
||||
WHERE store_code = '0052';
|
||||
|
||||
-- 修复6: 大悦春风里店 → 标记停业
|
||||
UPDATE analytics.dim_store
|
||||
SET scope_tag = '停业/待开业', is_grey_cost = TRUE
|
||||
WHERE store_code = '1004';
|
||||
```
|
||||
|
||||
> **重要提示:** `theoretical_margin_pct` 的修正值 70% 仅为估算值(取同业态中位数),实际值需商品部门根据 BOM 和标准成本确认后填入。修复脚本需在商品部门确认后执行。
|
||||
|
||||
### 5. 部署状态
|
||||
|
||||
| 环境 | 地址 | 状态 |
|
||||
|---|---|:---:|
|
||||
| 本地开发 | http://localhost:5173 | ✅ 运行中 |
|
||||
| 生产环境 | https://dm.all8ai.top | ✅ 已部署(2026-08-01) |
|
||||
@@ -0,0 +1,450 @@
|
||||
# 供应链与中央厨房数据分析规划
|
||||
|
||||
> 基于2026年4月完整数据:配送流水325,922条、中央厨房生产成本链4,842+1,708+157条、盘点倒挤54,973条
|
||||
> ETL已完成:dim_supplier(198家)、dim_material(6,777种)、fact_inventory_snapshot(54,842条)
|
||||
> 数据库第一次具备了从"中央厨房生产"到"门店销售和实际耗用"的完整主链
|
||||
|
||||
## 完整主链
|
||||
|
||||
```text
|
||||
基础原料
|
||||
→ 中央厨房配方投料
|
||||
→ 半成品完工入库
|
||||
→ 总仓/中央厨房配送
|
||||
→ 门店库存与倒挤消耗
|
||||
→ 门店菜品BOM
|
||||
→ SKU销售
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、更新现有分析
|
||||
|
||||
### 1.1 理论成本与实际成本分析
|
||||
|
||||
原来只能比较:`SKU理论成本 vs 门店倒挤成本`
|
||||
|
||||
现在可以把差异拆成:
|
||||
|
||||
```text
|
||||
总成本差异
|
||||
= 中央厨房材料效率差异
|
||||
+ 中央厨房制造费用差异
|
||||
+ 内部结算价差异
|
||||
+ 配送价差异
|
||||
+ 门店库存变化
|
||||
+ 门店损耗差异
|
||||
+ 本地采购差异
|
||||
+ BOM断链差异
|
||||
```
|
||||
|
||||
这样可以判断问题究竟发生在中央厨房、供应链还是门店,而不是把所有差异都归咎于门店。
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 中央厨房材料效率差异 | (实际领料 - 理论用量) × 标准单价 | 生产环节超耗 |
|
||||
| 中央厨房制造费用差异 | 实际制造费用 - 标准分摊额 | 能耗人工异常 |
|
||||
| 内部结算价差异 | (结算价 - 全制造成本) × 配送量 | 中央厨房内部利润 |
|
||||
| 配送价差异 | (配送价 - 结算价) × 配送量 | 总仓加价 |
|
||||
| 门店库存变化差异 | 期末库存 - 期初库存 | 库存增减影响 |
|
||||
| 门店损耗差异 | 退货金额 + 报损金额 | 门店端损失 |
|
||||
| 本地采购差异 | 实际消耗 - 总部配送 - 库存变化 | 本采推算 |
|
||||
| BOM断链差异 | 无BOM的SKU销售成本 | 配方缺失影响 |
|
||||
|
||||
### 1.2 门店实际成本分析
|
||||
|
||||
门店成本新增字段:
|
||||
|
||||
| 新指标 | 管理意义 |
|
||||
|---|---|
|
||||
| 总仓配送成本 | 门店实际收到多少总部供应货品 |
|
||||
| 中央厨房产品成本 | 门店使用多少中央厨房半成品 |
|
||||
| 本地采购推算额 | 实际消耗减总部配送及库存变化 |
|
||||
| 总部配送覆盖率 | 门店对总部供应链的依赖程度 |
|
||||
| 配送与消耗差异 | 判断囤货、缺货或异常消耗 |
|
||||
| 退货率 | 判断要货准确性和产品质量 |
|
||||
| 非食材消耗 | 包装、纸巾、清洁品等消耗 |
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 建议值 |
|
||||
|------|------|--------|
|
||||
| 总部配送覆盖率 | 总部配送额 ÷ 门店总消耗额 | ≥ 85% |
|
||||
| 本地采购推算额 | 实际消耗 - 总部配送 - (期末库存-期初库存) | ≤ 10%消耗额 |
|
||||
| 配送消耗差异率 | (配送额 - 消耗额) ÷ 配送额 | ±10%以内 |
|
||||
| 门店退货率 | 退货金额 ÷ 配送金额 | ≤ 3% |
|
||||
| 非食材消耗占比 | 非食材消耗 ÷ 总消耗 | ≤ 15% |
|
||||
|
||||
### 1.3 BOM成本断链分析
|
||||
|
||||
成本断链升级为三级:
|
||||
|
||||
```text
|
||||
一级:销售SKU没有BOM
|
||||
二级:SKU使用的中央厨房半成品没有生产配方
|
||||
三级:中央厨房配方原料无法关联货品和库存成本
|
||||
```
|
||||
|
||||
还能识别:
|
||||
|
||||
- 已生产但没有配送的半成品
|
||||
- 已配送但没有进入任何门店BOM的货品
|
||||
- BOM理论需要但中央厨房没有生产的产品
|
||||
- 门店实际消耗但既无配送也无本地采购记录的货品
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 一级断链率 | 无BOM的SKU数 ÷ 总SKU数 | 应≤5% |
|
||||
| 二级断链率 | 无配方的半成品数 ÷ 总半成品数 | 应≤10% |
|
||||
| 三级断链率 | 无法关联成本的原料数 ÷ 总原料数 | 应≤15% |
|
||||
| 孤产半成品 | 有生产无配送的半成品数 | 资金占用 |
|
||||
| 孤配货品 | 有配送无BOM引用的货品数 | 要货不合理 |
|
||||
|
||||
### 1.4 门店经营贡献分析
|
||||
|
||||
原来门店贡献主要使用倒挤成本。现在可以同时展示:
|
||||
|
||||
```text
|
||||
门店口径贡献
|
||||
= 门店实收 - 门店倒挤成本 - 门店营业费用
|
||||
|
||||
集团还原贡献
|
||||
= 门店实收
|
||||
- 中央厨房完整制造成本
|
||||
- 真实配送成本
|
||||
- 门店费用
|
||||
```
|
||||
|
||||
集团还原口径需要消除中央厨房、总仓和门店之间的内部加价,避免内部利润重复计算。
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 门店口径贡献率 | (实收-倒挤成本-费用) ÷ 实收 | 门店视角盈利 |
|
||||
| 集团还原贡献率 | (实收-全制造成本-真实配送-费用) ÷ 实收 | 集团视角盈利 |
|
||||
| 内部利润抵销额 | 内部结算加价 + 配送加价 | 合并报表需抵销 |
|
||||
| 贡献差异率 | (集团还原贡献 - 门店口径贡献) ÷ 实收 | 口径差异影响 |
|
||||
|
||||
---
|
||||
|
||||
## 二、新增中央厨房分析
|
||||
|
||||
### 2.1 生产成本驾驶舱
|
||||
|
||||
核心指标:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 完工入库数量 | sum(inbound_quantity) | 1,708条成品入库 |
|
||||
| 完工入库金额 | sum(inbound_amount) | 成品入库价值 |
|
||||
| 理论材料成本 | sum(theoretical_cost) | BOM标准用量×标准单价 |
|
||||
| 标准材料成本 | sum(standard_cost) | 标准成本表 |
|
||||
| 实际材料成本 | sum(material_actual_cost) | 实际领料金额 |
|
||||
| 完整制造成本 | sum(full_manufacturing_cost) | 材料+制造费用分摊 |
|
||||
| 单位制造成本 | full_manufacturing_cost ÷ inbound_quantity | 单位成本 |
|
||||
| 制造费用率 | manufacturing_cost ÷ material_actual_cost | 当前3.5% |
|
||||
| 成本差异率 | (actual - standard) ÷ standard | >3%需关注 |
|
||||
| 内部结算价差 | inbound_value - full_manufacturing_cost | 当前-29.5万 |
|
||||
| 完工达成率 | actual_inbound ÷ standard_expected | 生产计划执行 |
|
||||
| 退库率 | return_quantity ÷ inbound_quantity | 质量异常 |
|
||||
| 无配方完工金额 | 无配方产品入库金额 | 应为0 |
|
||||
| 待分摊材料成本 | 未分摊的材料成本余额 | 应≤0.5% |
|
||||
|
||||
### 2.2 配方效率分析
|
||||
|
||||
按配方、成品和日期分析:
|
||||
|
||||
```text
|
||||
材料效率差异 = 实际分摊材料成本 - 理论材料成本
|
||||
用量差异 = 实际领用量 - 理论用量
|
||||
价格差异 = 理论用量 ×(实际单价 - 标准单价)
|
||||
出成率差异 = 实际出成率 - 标准出成率
|
||||
```
|
||||
|
||||
可以找出:
|
||||
|
||||
- 材料超耗最高的配方
|
||||
- 出成率持续偏低的产品
|
||||
- 实际单价上涨导致的成本异常
|
||||
- 配方版本或标准用量不合理
|
||||
- 领用正常但进销存耗用异常的原料
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 材料效率差异率 | (实际材料 - 理论材料) ÷ 理论材料 | >5%超耗 |
|
||||
| 用量差异率 | (实际用量 - 理论用量) ÷ 理论用量 | >10%异常 |
|
||||
| 价格差异率 | (实际单价 - 标准单价) ÷ 标准单价 | 采购波动 |
|
||||
| 出成率差异 | 实际出成率 - 标准出成率 | <-3pct异常 |
|
||||
| 超耗配方数 | 效率差异率>5%的配方数 | 生产异常 |
|
||||
| 低出成产品数 | 出成率差异<-3pct的产品数 | 工艺问题 |
|
||||
|
||||
### 2.3 多级BOM成本穿透
|
||||
|
||||
建立:
|
||||
|
||||
```text
|
||||
销售SKU
|
||||
→ 中央厨房半成品
|
||||
→ 中央厨房二级半成品
|
||||
→ 基础原料
|
||||
```
|
||||
|
||||
可以回答:
|
||||
|
||||
- 一碗牛肉面最终消耗多少鲜牛肉、调料和包装
|
||||
- 原料涨价1%影响哪些SKU
|
||||
- 某中央厨房半成品涨价影响哪些门店和菜品
|
||||
- 调整一个中央厨房配方会影响多少销售额和毛利
|
||||
- 某原料断供会影响哪些产品和门店
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 全链路原料消耗 | SKU销量 × 多级BOM展开量 | 原料需求 |
|
||||
| 原料敏感度 | 原料涨价1%影响的SKU毛利变动 | 价格风险 |
|
||||
| 半成品影响范围 | 半成品涉及的SKU数和门店数 | 变更影响面 |
|
||||
| 配方调整影响 | 配方变更影响的销售额 | 决策依据 |
|
||||
| 断供影响 | 原料断供影响的产品和门店数 | 供应链风险 |
|
||||
|
||||
### 2.4 自制与外采决策
|
||||
|
||||
按中央厨房产品比较:
|
||||
|
||||
```text
|
||||
自制完全成本 = 材料+人工+能源+房租+维修+制造费用
|
||||
外采到仓成本 = 采购价格+运输+损耗+税费
|
||||
```
|
||||
|
||||
判断哪些产品应继续自制、优化工艺、集中生产或改为外采。
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 自制完全成本 | 材料+人工+能源+房租+维修+制造费用 | 全成本 |
|
||||
| 外采到仓成本 | 采购价+运输+损耗+税费 | 替代方案 |
|
||||
| 自制优势率 | (外采成本-自制成本) ÷ 外采成本 | >0值得自制 |
|
||||
| 集中度评分 | 单产品产量÷总产量 | 规模效应 |
|
||||
|
||||
---
|
||||
|
||||
## 三、新增配送供应链分析
|
||||
|
||||
### 3.1 门店要货与配送效率
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 日均配送金额 | 配送总额 ÷ 30天 | 门店补货规模 |
|
||||
| 配送频次 | 配送单数 ÷ 30天 | 补货频率 |
|
||||
| 单次配送金额 | 配送总额 ÷ 配送单数 | 配送经济性 |
|
||||
| 紧急要货率 | 紧急单数 ÷ 总单数 | 计划性 |
|
||||
| 退货率 | 退货金额 ÷ 配送金额 | ≤3%正常 |
|
||||
| 零数量单据率 | 零数量行数 ÷ 总行数 | 数据质量 |
|
||||
| 重复小批量配送率 | 同货品<阈值配送次数 ÷ 总配送 | 合并机会 |
|
||||
|
||||
### 3.2 配送价格和内部加价
|
||||
|
||||
```text
|
||||
配送加价额 = 配出金额 - 配送成本
|
||||
配送毛利率 = 配送加价额 ÷ 配出金额
|
||||
```
|
||||
|
||||
按门店、品牌、货品和财务分类分析:
|
||||
|
||||
- 哪些货品内部加价最高
|
||||
- 门店承担多少供应链服务费
|
||||
- 哈马尔罕、抖音店、直营门店的加价政策是否一致
|
||||
- 内部结算价是否掩盖真实生产亏损
|
||||
- 集团合并时应抵销多少内部利润
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 配送加价额 | 配出金额 - 配送成本 | 总仓内部利润 |
|
||||
| 配送毛利率 | 加价额 ÷ 配出金额 | 加价幅度 |
|
||||
| 品牌加价差异 | 各品牌配送毛利率对比 | 政策一致性 |
|
||||
| 内部利润抵销额 | 中央厨房结算利润 + 配送利润 | 合并报表抵销 |
|
||||
| 货品加价排名 | 按加价率排序 | 高加价货品 |
|
||||
|
||||
### 3.3 配送与门店实际消耗对账
|
||||
|
||||
每个门店、每种货品建立:
|
||||
|
||||
```text
|
||||
配送入库 - 退货 - 实际倒挤消耗
|
||||
= 库存增加 + 本地采购调整 + 盘点差异
|
||||
```
|
||||
|
||||
异常类型:
|
||||
|
||||
| 异常类型 | 表现 | 可能原因 |
|
||||
|----------|------|----------|
|
||||
| 配送高消耗低 | 配送>>消耗 | 疑似积压或盘点异常 |
|
||||
| 配送低消耗高 | 消耗>>配送 | 疑似本地采购或库存透支 |
|
||||
| 有配送无销售 | 配送有但无BOM引用 | 要货不合理 |
|
||||
| 有消耗无配送 | 消耗有但无配送记录 | 本采、调拨或数据断链 |
|
||||
| 连续退货 | 多次退回 | 要货预测、质量或保质期问题 |
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 配送消耗差异 | 配送净额 - 消耗额 | 应≈库存变化 |
|
||||
| 配送消耗差异率 | 差异 ÷ 配送净额 | ±10%以内正常 |
|
||||
| 异常门店数 | 差异率>10%的门店数 | 需核查 |
|
||||
| 异常货品数 | 差异率>10%的货品数 | 需核查 |
|
||||
|
||||
### 3.4 原料和货品流向分析
|
||||
|
||||
追踪:
|
||||
|
||||
```text
|
||||
原料/半成品 → 中央厨房 → 总仓 → 门店 → 菜品SKU → 销售收入
|
||||
```
|
||||
|
||||
识别:
|
||||
|
||||
- 高成本、低销售贡献原料
|
||||
- 只服务少量SKU和门店的长尾原料
|
||||
- 配送量大但销售贡献低的货品
|
||||
- 可合并、替代或取消的原料规格
|
||||
- 中央厨房产品的门店覆盖率
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 货品销售贡献度 | 关联SKU销售额 ÷ 总销售额 | 货品价值 |
|
||||
| 长尾原料数 | 仅服务<3个SKU的原料数 | 可精简 |
|
||||
| 中央厨房覆盖率 | 使用中央厨房产品的门店数 ÷ 总门店数 | 供应链渗透 |
|
||||
| 货品配送效率 | 配送额 ÷ 关联销售额 | 投入产出比 |
|
||||
|
||||
---
|
||||
|
||||
## 四、新增计划与预测分析
|
||||
|
||||
### 4.1 销量驱动的原料需求预测
|
||||
|
||||
```text
|
||||
预测SKU销量 × 门店BOM × 中央厨房多级BOM = 基础原料需求
|
||||
```
|
||||
|
||||
可形成:
|
||||
|
||||
- 次日/次周门店要货建议
|
||||
- 中央厨房生产计划
|
||||
- 原料采购建议
|
||||
- 安全库存
|
||||
- 高峰、节假日备货计划
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 原料需求量 | 预测销量 × 多级BOM展开 | 采购依据 |
|
||||
| 安全库存 | 日均消耗 × 交货周期 × 安全系数 | 库存下限 |
|
||||
| 要货建议量 | 需求量 - 当前库存 + 安全库存 | 补货量 |
|
||||
| 生产计划量 | 各门店要货合计 + 中央厨房安全库存 | 生产排程 |
|
||||
|
||||
### 4.2 生产与配送协同
|
||||
|
||||
比较:
|
||||
|
||||
```text
|
||||
计划产量 → 实际产量 → 完工入库量 → 配送量 → 门店消耗量 → 期末库存变化
|
||||
```
|
||||
|
||||
可以发现生产过量、生产不足、配送延迟和门店库存积压。
|
||||
|
||||
**新增指标**:
|
||||
|
||||
| 指标 | 公式 | 说明 |
|
||||
|------|------|------|
|
||||
| 生产计划达成率 | 实际产量 ÷ 计划产量 | 生产执行 |
|
||||
| 完工配送率 | 配送量 ÷ 完工入库量 | 产销匹配 |
|
||||
| 配送消耗率 | 消耗量 ÷ 配送量 | 配送合理性 |
|
||||
| 库存积压率 | 期末库存 ÷ 月消耗量 | 周转健康度 |
|
||||
|
||||
---
|
||||
|
||||
## 五、新增异常预警
|
||||
|
||||
建议建立每日或月度预警:
|
||||
|
||||
| 预警项 | 建议阈值 | 数据源 |
|
||||
|--------|----------|--------|
|
||||
| 配方材料超耗 | 超理论5%以上 | central_kitchen_recipe_consumption |
|
||||
| 出成率异常 | 低于标准3个百分点 | central_kitchen_finished_receipt |
|
||||
| 配送与消耗差异 | 超过配送额10% | distribution_detail + fact_inventory_snapshot |
|
||||
| 门店退货率 | 超过3% | distribution_detail_records |
|
||||
| 无配方完工 | 金额大于0即预警 | central_kitchen_finished_receipt |
|
||||
| 待分摊材料成本 | 超实际材料成本0.5% | central_kitchen_manufacturing_cost_pool |
|
||||
| 实际成本高于标准 | 超过3% | central_kitchen_processing_cost |
|
||||
| 配送单价异常 | 偏离全店中位数5%以上 | distribution_detail_records |
|
||||
| 生产后未配送 | 7天仍无配送 | central_kitchen_finished_receipt + distribution |
|
||||
| 配送后无消耗 | 30天无倒挤消耗 | distribution + fact_inventory_snapshot |
|
||||
| 负库存/负消耗 | 出现即预警 | fact_inventory_snapshot (is_negative) |
|
||||
|
||||
---
|
||||
|
||||
## 六、优先落地顺序
|
||||
|
||||
建议先实施四个分析模块:
|
||||
|
||||
1. **中央厨房成本驾驶舱**
|
||||
理论、标准、实际、完整制造成本、分摊差异和产品盈亏。
|
||||
|
||||
2. **配送—倒挤成本对账**
|
||||
先覆盖89家经营门店,按门店和货品找异常。
|
||||
|
||||
3. **多级BOM成本穿透**
|
||||
将中央厨房配方接入现有SKU BOM和成本断链分析。
|
||||
|
||||
4. **销量驱动生产与要货计划**
|
||||
用SKU销量反推半成品生产量和基础原料需求。
|
||||
|
||||
---
|
||||
|
||||
## 七、仍需补充的数据
|
||||
|
||||
现有数据已经能完成月度分析,但要做到日常运营管理,还应补充:
|
||||
|
||||
| 数据项 | 优先级 | 用途 |
|
||||
|--------|--------|------|
|
||||
| 采购入库和采购退货明细 | **最高** | 供应商管理、采购成本分析 |
|
||||
| 门店及中央厨房每日库存 | **最高** | 日度库存周转、补货预警 |
|
||||
| 生产工时、设备工时 | **高** | 产能分析、自制外采决策 |
|
||||
| 门店本地采购 | 高 | 全成本还原、本采管控 |
|
||||
| 仓库间调拨 | 高 | 库存调拨追踪 |
|
||||
| 生产批次号和配方版本 | 中 | 成本追溯、质量追踪 |
|
||||
| 独立水电燃气表 | 中 | 能耗精细化管理 |
|
||||
| 报损、废弃和不合格品 | 中 | 损耗分析 |
|
||||
| 在制品期初、期末数量 | 中 | 生产成本准确核算 |
|
||||
| 供应商合同价、账期和交付时效 | 中 | 供应商评估 |
|
||||
|
||||
其中最优先的是**采购明细、每日库存和生产工时**。这三项补齐后,可以从月度事后分析升级为日常补货、生产排程和异常预警系统。
|
||||
|
||||
---
|
||||
|
||||
## 八、ETL脚本
|
||||
|
||||
已完成填充的维度表和事实表:
|
||||
|
||||
| 脚本 | 目标表 | 记录数 | 说明 |
|
||||
|------|--------|--------|------|
|
||||
| `sql/etl_dim_supplier.sql` | `analytics.dim_supplier` | 198 | 从配送流水去重提取供应商主数据 |
|
||||
| `sql/etl_dim_material.sql` | `analytics.dim_material` | 6,777 | 从配送+盘点合并去重提取物料主数据 |
|
||||
| `sql/etl_fact_inventory.sql` | `analytics.fact_inventory_snapshot` | 54,842 | 从盘点倒挤数据ETL库存快照 |
|
||||
|
||||
后续数据更新时可重复执行以上脚本(支持ON CONFLICT UPSERT)。
|
||||
@@ -0,0 +1,49 @@
|
||||
-- 隔离模拟验收记录
|
||||
-- 07_simulate_monthly_review.sql 批量生成的验收记录特征:
|
||||
-- process_evidence = '已按行动要求执行,提交过程证据'
|
||||
-- task_monthly_review 中的 revenue_stable / customer_stable 恒为 true
|
||||
-- store_task_log 中 operator = '区域经理'(模拟验收)
|
||||
|
||||
-- Step 1: 给 store_task 加 is_simulated 标记列
|
||||
ALTER TABLE analytics.store_task
|
||||
ADD COLUMN IF NOT EXISTS is_simulated boolean DEFAULT false;
|
||||
|
||||
-- Step 2: 标记模拟验收的任务
|
||||
UPDATE analytics.store_task
|
||||
SET is_simulated = true
|
||||
WHERE process_evidence = '已按行动要求执行,提交过程证据'
|
||||
AND status = '已验收';
|
||||
|
||||
-- Step 3: 给 task_monthly_review 加标记列
|
||||
ALTER TABLE analytics.task_monthly_review
|
||||
ADD COLUMN IF NOT EXISTS is_simulated boolean DEFAULT false;
|
||||
|
||||
-- Step 4: 标记模拟的月度验收记录
|
||||
UPDATE analytics.task_monthly_review r
|
||||
SET is_simulated = true
|
||||
FROM analytics.store_task t
|
||||
WHERE r.task_id = t.task_id
|
||||
AND t.is_simulated = true;
|
||||
|
||||
-- Step 5: 给 store_task_log 加标记列
|
||||
ALTER TABLE analytics.store_task_log
|
||||
ADD COLUMN IF NOT EXISTS is_simulated boolean DEFAULT false;
|
||||
|
||||
-- Step 6: 标记模拟的操作日志
|
||||
UPDATE analytics.store_task_log
|
||||
SET is_simulated = true
|
||||
WHERE operator = '区域经理'
|
||||
AND action = '月度验收'
|
||||
AND comment IN ('达标', '改善中', '未改善');
|
||||
|
||||
-- Step 7: 给 best_practice 加标记列
|
||||
ALTER TABLE analytics.best_practice
|
||||
ADD COLUMN IF NOT EXISTS is_simulated boolean DEFAULT false;
|
||||
|
||||
-- Step 8: 标记模拟的经验库记录
|
||||
UPDATE analytics.best_practice bp
|
||||
SET is_simulated = true
|
||||
FROM analytics.store_task t
|
||||
WHERE bp.source_store_code = t.store_code
|
||||
AND t.is_simulated = true
|
||||
AND bp.status = '待推广';
|
||||
@@ -35,6 +35,53 @@ export function parseMonth(req: Request): string {
|
||||
return month.length === 7 ? `${month}-01` : month
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
start: string
|
||||
end: string
|
||||
mode: 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
|
||||
label: string
|
||||
}
|
||||
|
||||
export function parseDateRange(req: Request): DateRange {
|
||||
const mode = ((req.query.range_mode as string) || 'month') as 'month' | 'quarter' | 'halfyear' | 'year' | 'custom'
|
||||
const month = parseMonth(req)
|
||||
const startDate = new Date(month)
|
||||
|
||||
const addMonths = (d: Date, n: number): string => {
|
||||
const r = new Date(d)
|
||||
r.setMonth(r.getMonth() + n)
|
||||
return r.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'quarter':
|
||||
return { start: month, end: addMonths(startDate, 3), mode, label: '季度' }
|
||||
case 'halfyear':
|
||||
return { start: month, end: addMonths(startDate, 6), mode, label: '半年' }
|
||||
case 'year':
|
||||
return { start: month, end: addMonths(startDate, 12), mode, label: '全年' }
|
||||
case 'custom': {
|
||||
const start = (req.query.start_date as string) || month
|
||||
const end = (req.query.end_date as string) || addMonths(new Date(start), 1)
|
||||
return { start, end, mode, label: '自定义' }
|
||||
}
|
||||
default:
|
||||
return { start: month, end: addMonths(startDate, 1), mode, label: '月度' }
|
||||
}
|
||||
}
|
||||
|
||||
export function prevYearMonth(month: string): string {
|
||||
const d = new Date(month)
|
||||
d.setFullYear(d.getFullYear() - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function prevMonth(month: string): string {
|
||||
const d = new Date(month)
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function parsePagination(req: Request) {
|
||||
const page = parseInt((req.query.page as string) || '1')
|
||||
const pageSize = parseInt((req.query.page_size as string) || '50')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
|
||||
import { sendSuccess, sendError, parsePagination, parseMonth } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -725,6 +725,7 @@ router.get('/unmatched-materials', async (req: AuthRequest, res) => {
|
||||
// 门店成本总览
|
||||
router.get('/store-overview', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
SELECT
|
||||
count(*) AS total_stores,
|
||||
@@ -733,8 +734,8 @@ router.get('/store-overview', async (req: AuthRequest, res) => {
|
||||
count(*) FILTER (WHERE variance_level = '绿色-基本正常') AS green_count,
|
||||
count(*) FILTER (WHERE variance_level = '灰色-口径异常') AS gray_count,
|
||||
round(sum(food_cost_variance)::numeric, 2) AS total_variance
|
||||
FROM analytics.mv_store_theoretical_actual_cost_april
|
||||
`)
|
||||
FROM analytics.fn_store_theoretical_actual_cost($1)
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -744,16 +745,17 @@ router.get('/store-overview', async (req: AuthRequest, res) => {
|
||||
// 门店地图数据
|
||||
router.get('/store-map', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
SELECT c.store_code, c.store_name, c.variance_level,
|
||||
round(c.food_cost_variance::numeric, 2) AS variance,
|
||||
round(c.theoretical_cost_rate_pct::numeric, 2) AS theo_cost_rate,
|
||||
round(c.actual_food_cost_rate_pct::numeric, 2) AS actual_cost_rate,
|
||||
l.latitude_gcj02, l.longitude_gcj02
|
||||
FROM analytics.mv_store_theoretical_actual_cost_april c
|
||||
FROM analytics.fn_store_theoretical_actual_cost($1) c
|
||||
LEFT JOIN analytics.v_store_location_operating l ON l.store_code = c.store_code
|
||||
WHERE l.latitude_gcj02 IS NOT NULL
|
||||
`)
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -777,7 +779,8 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
const validSorts = ['food_cost_variance', 'theoretical_cost_rate_pct', 'actual_food_cost_rate_pct', 'variance_to_theoretical_pct', 'negative_item_lines', 'store_name']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'food_cost_variance'
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_theoretical_actual_cost_april ${where}`)
|
||||
const month = parseMonth(req)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.fn_store_theoretical_actual_cost($1) ${where}`, [month])
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const result = await query(`
|
||||
@@ -788,10 +791,10 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
round(food_cost_variance::numeric, 2) AS variance_amount,
|
||||
negative_item_lines,
|
||||
variance_level
|
||||
FROM analytics.mv_store_theoretical_actual_cost_april
|
||||
FROM analytics.fn_store_theoretical_actual_cost($1)
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $2 OFFSET $3
|
||||
`, [month, pageSize, offset])
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
|
||||
+1516
-86
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError } from '../middleware/error.js'
|
||||
import { sendSuccess, sendError, parseMonth } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -9,24 +9,26 @@ const router = Router()
|
||||
|
||||
router.get('/health-score', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
WITH base AS (
|
||||
SELECT store_code, store_name, received, bill_count,
|
||||
avg_bill_value, avg_daily_received, theoretical_margin_pct,
|
||||
member_bill_share_pct, risk_level
|
||||
FROM analytics.mv_store_risk_rating
|
||||
FROM analytics.fn_store_risk_rating($1)
|
||||
WHERE received IS NOT NULL
|
||||
),
|
||||
cost AS (
|
||||
SELECT store_code,
|
||||
round(avg(LEAST(variance_to_theoretical_pct, 50))::numeric, 1) AS avg_cost_variance_pct
|
||||
FROM analytics.mv_store_theoretical_actual_cost_april
|
||||
FROM analytics.fn_store_theoretical_actual_cost($1)
|
||||
WHERE variance_to_theoretical_pct IS NOT NULL
|
||||
GROUP BY store_code
|
||||
),
|
||||
member AS (
|
||||
SELECT store_code, max(repeat_rate_pct) AS repeat_rate_pct
|
||||
FROM analytics.mv_store_repeat_summary_monthly
|
||||
WHERE month_start = $1::date
|
||||
GROUP BY store_code
|
||||
),
|
||||
task AS (
|
||||
@@ -35,6 +37,7 @@ router.get('/health-score', async (req: AuthRequest, res) => {
|
||||
count(*) FILTER (WHERE status = '已验收' AND verification_result = '达标') AS passed_tasks,
|
||||
round(count(*) FILTER (WHERE status IN ('已验收', '已回滚'))::numeric / nullif(count(*), 0) * 100, 1) AS completion_rate
|
||||
FROM analytics.store_task
|
||||
WHERE is_simulated = false OR is_simulated IS NULL
|
||||
GROUP BY store_code
|
||||
),
|
||||
scored AS (
|
||||
@@ -78,7 +81,7 @@ router.get('/health-score', async (req: AuthRequest, res) => {
|
||||
END AS health_status
|
||||
FROM scored
|
||||
ORDER BY health_score DESC
|
||||
`)
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -89,6 +92,7 @@ router.get('/health-score', async (req: AuthRequest, res) => {
|
||||
|
||||
router.get('/alerts', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const alerts: any[] = []
|
||||
|
||||
// 1. 营收异动预警:日营收连续低于月均70%
|
||||
@@ -180,12 +184,12 @@ router.get('/alerts', async (req: AuthRequest, res) => {
|
||||
round(p.taobao_cost_rate_pct::numeric, 1) AS taobao_rate,
|
||||
round(p.jd_cost_rate_pct::numeric, 1) AS jd_rate,
|
||||
round((COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) * 100, 1) AS platform_share
|
||||
FROM analytics.v_store_platform_economics p
|
||||
FROM analytics.fn_store_platform_economics($1) p
|
||||
JOIN analytics.v_store_scorecard sc ON p.store_code = sc.store_code
|
||||
WHERE (COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) > 0.4
|
||||
ORDER BY platform_share DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
`, [month])
|
||||
platformAlerts.rows.forEach((r: any) => {
|
||||
alerts.push({
|
||||
type: 'platform',
|
||||
@@ -237,6 +241,7 @@ router.get('/alerts', async (req: AuthRequest, res) => {
|
||||
|
||||
router.get('/correlation', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
// 客流-人力匹配度:每门店每小时"每人在岗产出账单数"
|
||||
// mv_store_hourly_staffing 可能不存在,容错处理
|
||||
let staffingRows: any[] = []
|
||||
@@ -274,7 +279,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
|
||||
staffingRows = []
|
||||
}
|
||||
|
||||
// 门店维度汇总:成本-风险关联(用mv_store_risk_rating替代v_store_scorecard)
|
||||
// 门店维度汇总:成本-风险关联
|
||||
const costRisk = await query(`
|
||||
SELECT
|
||||
r.store_code, r.store_name, r.risk_level,
|
||||
@@ -288,11 +293,11 @@ router.get('/correlation', async (req: AuthRequest, res) => {
|
||||
WHEN COALESCE(c.avg_cost_variance_pct, 20) > 30 THEN '成本失控'
|
||||
ELSE '正常'
|
||||
END AS correlation_status
|
||||
FROM analytics.mv_store_risk_rating r
|
||||
FROM analytics.fn_store_risk_rating($1) r
|
||||
LEFT JOIN (
|
||||
SELECT store_code,
|
||||
round(avg(LEAST(variance_to_theoretical_pct, 50))::numeric, 1) AS avg_cost_variance_pct
|
||||
FROM analytics.mv_store_theoretical_actual_cost_april
|
||||
FROM analytics.fn_store_theoretical_actual_cost($1)
|
||||
WHERE variance_to_theoretical_pct IS NOT NULL
|
||||
GROUP BY store_code
|
||||
) c ON r.store_code = c.store_code
|
||||
@@ -303,9 +308,9 @@ router.get('/correlation', async (req: AuthRequest, res) => {
|
||||
WHEN r.risk_level = '黄色' AND COALESCE(c.avg_cost_variance_pct, 20) > 25 THEN 2
|
||||
ELSE 3 END,
|
||||
r.received DESC NULLS LAST
|
||||
`)
|
||||
`, [month])
|
||||
|
||||
// 会员-平台-营收三角(用mv_store_risk_rating替代v_store_scorecard)
|
||||
// 会员-平台-营收三角
|
||||
const channelRisk = await query(`
|
||||
SELECT
|
||||
r.store_code, r.store_name, r.received,
|
||||
@@ -318,19 +323,19 @@ router.get('/correlation', async (req: AuthRequest, res) => {
|
||||
WHEN COALESCE(r.member_bill_share_pct, 0) > 50 THEN '会员驱动型'
|
||||
ELSE '均衡型'
|
||||
END AS channel_status
|
||||
FROM analytics.mv_store_risk_rating r
|
||||
FROM analytics.fn_store_risk_rating($1) r
|
||||
LEFT JOIN (
|
||||
SELECT pe.store_code,
|
||||
round((COALESCE(pe.meituan_received, 0) + COALESCE(pe.taobao_received, 0) + COALESCE(pe.jd_received, 0)) / nullif(r2.received, 0) * 100, 1) AS platform_share
|
||||
FROM analytics.v_store_platform_economics pe
|
||||
JOIN analytics.mv_store_risk_rating r2 ON pe.store_code = r2.store_code
|
||||
FROM analytics.fn_store_platform_economics($1) pe
|
||||
JOIN analytics.fn_store_risk_rating($1) r2 ON pe.store_code = r2.store_code
|
||||
) p ON r.store_code = p.store_code
|
||||
LEFT JOIN analytics.mv_store_repeat_summary_monthly m ON r.store_code = m.store_code
|
||||
LEFT JOIN analytics.mv_store_repeat_summary_monthly m ON r.store_code = m.store_code AND m.month_start = $1::date
|
||||
WHERE r.received IS NOT NULL
|
||||
ORDER BY
|
||||
CASE WHEN COALESCE(p.platform_share, 0) > 40 AND COALESCE(r.member_bill_share_pct, 0) < 20 THEN 0 ELSE 1 END,
|
||||
r.received DESC
|
||||
`)
|
||||
`, [month])
|
||||
|
||||
// 考勤-营收关联
|
||||
const hrRevenue = await query(`
|
||||
@@ -390,6 +395,7 @@ router.get('/correlation', async (req: AuthRequest, res) => {
|
||||
|
||||
router.get('/forecast', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
// 客流预测:基于历史4周小时数据,按工作日/周末+小时维度计算P85
|
||||
const trafficForecast = await query(`
|
||||
WITH daily_hourly AS (
|
||||
@@ -399,7 +405,7 @@ router.get('/forecast', async (req: AuthRequest, res) => {
|
||||
count(*) AS bills
|
||||
FROM bill_records
|
||||
WHERE c175 IS NOT NULL AND c175 != ''
|
||||
AND c175 >= '2026/04/01' AND c175 < '2026/05/01'
|
||||
AND c175 >= $1 AND c175 < $1::date + interval '1 month'
|
||||
GROUP BY hour, day_type, bill_date
|
||||
),
|
||||
hourly_stats AS (
|
||||
@@ -419,7 +425,7 @@ router.get('/forecast', async (req: AuthRequest, res) => {
|
||||
END AS stability
|
||||
FROM hourly_stats
|
||||
ORDER BY day_type, hour
|
||||
`)
|
||||
`, [month])
|
||||
|
||||
// 成本趋势:菜品成本差异恶化TOP
|
||||
const costTrend = await query(`
|
||||
@@ -444,15 +450,15 @@ router.get('/forecast', async (req: AuthRequest, res) => {
|
||||
const turnoverAlert = await query(`
|
||||
SELECT org_level5 AS store_name,
|
||||
count(*) AS total_emp,
|
||||
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01') AS left_count,
|
||||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
|
||||
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 1) AS turnover_rate
|
||||
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= $1) AS left_count,
|
||||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
|
||||
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= $1)::numeric / nullif(count(*), 0) * 100, 1) AS turnover_rate
|
||||
FROM salary_detail_records
|
||||
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
|
||||
GROUP BY org_level5
|
||||
HAVING count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01') > 0
|
||||
HAVING count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= $1) > 0
|
||||
ORDER BY turnover_rate DESC
|
||||
`)
|
||||
`, [month])
|
||||
|
||||
sendSuccess(res, {
|
||||
traffic_forecast: trafficForecast.rows,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
|
||||
import { sendSuccess, sendError, parsePagination, parseMonth } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -150,8 +150,9 @@ router.get('/traffic-heatmap-staffing', async (req: AuthRequest, res) => {
|
||||
router.get('/dow-traffic', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const storeName = req.query.store as string
|
||||
let where = "WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= '2026/04/01' AND c175 < '2026/05/01'"
|
||||
const params: any[] = []
|
||||
const month = parseMonth(req)
|
||||
const params: any[] = [month]
|
||||
let where = `WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= $1 AND c175 < $1::date + interval '1 month'`
|
||||
if (storeName) {
|
||||
params.push(storeName)
|
||||
where += ` AND c003 = $${params.length}`
|
||||
@@ -220,6 +221,7 @@ router.get('/traffic-overview', async (req: AuthRequest, res) => {
|
||||
router.get('/staffing-match', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const storeName = req.query.store as string || '七里庄店'
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
WITH punch_times AS (
|
||||
SELECT employee_code, d.day_num, d.day_val
|
||||
@@ -290,7 +292,7 @@ router.get('/staffing-match', async (req: AuthRequest, res) => {
|
||||
round(sum(c178::numeric), 0) AS total_guests
|
||||
FROM bill_records
|
||||
WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != ''
|
||||
AND c175::timestamp >= '2026-04-01' AND c175::timestamp < '2026-05-01'
|
||||
AND c175::timestamp >= $2 AND c175::timestamp < $2::date + interval '1 month'
|
||||
GROUP BY hour
|
||||
)
|
||||
SELECT s.hour,
|
||||
@@ -308,7 +310,7 @@ router.get('/staffing-match', async (req: AuthRequest, res) => {
|
||||
FROM hourly_staff_avg s
|
||||
LEFT JOIN hourly_bills b ON s.hour = b.hour
|
||||
ORDER BY s.hour
|
||||
`, [storeName])
|
||||
`, [storeName, month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -443,6 +445,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
|
||||
const storeName = req.query.store as string || '七里庄店'
|
||||
const frontTarget = parseInt(req.query.front_target as string) || 15
|
||||
const kitchenTarget = parseInt(req.query.kitchen_target as string) || 25
|
||||
const month = parseMonth(req)
|
||||
|
||||
const result = await query(`
|
||||
WITH daily_hourly AS (
|
||||
@@ -452,7 +455,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
|
||||
count(*) AS bills
|
||||
FROM bill_records
|
||||
WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != ''
|
||||
AND c175 >= '2026/04/01' AND c175 < '2026/05/01'
|
||||
AND c175 >= $4 AND c175 < $4::date + interval '1 month'
|
||||
GROUP BY hour, day_type, bill_date
|
||||
),
|
||||
hourly_stats AS (
|
||||
@@ -584,7 +587,7 @@ router.get('/scheduling-suggestion', async (req: AuthRequest, res) => {
|
||||
LEFT JOIN current_other co ON hs.hour = co.hour
|
||||
LEFT JOIN current_total ct ON hs.hour = ct.hour
|
||||
ORDER BY hs.hour, hs.day_type
|
||||
`, [storeName, frontTarget, kitchenTarget])
|
||||
`, [storeName, frontTarget, kitchenTarget, month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -697,6 +700,7 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { page, pageSize, offset } = parsePagination(req)
|
||||
const sort = (req.query.sort as string) || 'gross_pay'
|
||||
const month = parseMonth(req)
|
||||
const order = (req.query.order as string) || 'desc'
|
||||
const storeName = req.query.store as string
|
||||
|
||||
@@ -717,6 +721,8 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
|
||||
params.push(storeName)
|
||||
where += ` AND org_level5 = $${params.length}`
|
||||
}
|
||||
params.push(month)
|
||||
const monthParam = `$${params.length}`
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM salary_detail_records ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
@@ -743,8 +749,8 @@ router.get('/employee-analysis', async (req: AuthRequest, res) => {
|
||||
round(perf_amount / nullif(gross_pay, 0) * 100, 2) AS perf_rate,
|
||||
round(gross_pay / nullif(actual_hours, 0), 2) AS effective_hourly_rate,
|
||||
CASE
|
||||
WHEN leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01' THEN '离职'
|
||||
WHEN hire_date IS NOT NULL AND hire_date != '' AND hire_date >= '2026-04-01' THEN '新员工'
|
||||
WHEN leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= ${monthParam} THEN '离职'
|
||||
WHEN hire_date IS NOT NULL AND hire_date != '' AND hire_date >= ${monthParam} THEN '新员工'
|
||||
ELSE '在职'
|
||||
END AS emp_status
|
||||
FROM salary_detail_records
|
||||
@@ -788,18 +794,19 @@ router.get('/position-salary-compare', async (req: AuthRequest, res) => {
|
||||
// 离职率统计
|
||||
router.get('/turnover-stats', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
SELECT org_level5 AS store_name,
|
||||
count(*) AS total_emp,
|
||||
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count,
|
||||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
|
||||
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate,
|
||||
round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate
|
||||
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1) AS left_count,
|
||||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
|
||||
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate,
|
||||
round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate
|
||||
FROM salary_detail_records
|
||||
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
|
||||
GROUP BY org_level5
|
||||
ORDER BY turnover_rate DESC NULLS LAST
|
||||
`)
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -810,6 +817,7 @@ router.get('/turnover-stats', async (req: AuthRequest, res) => {
|
||||
|
||||
router.get('/overall-analysis', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const [efficiency, attendance, turnover, trafficOverview, mealPeriod] = await Promise.all([
|
||||
query(`
|
||||
WITH salary_stats AS (
|
||||
@@ -851,13 +859,13 @@ router.get('/overall-analysis', async (req: AuthRequest, res) => {
|
||||
query(`
|
||||
SELECT org_level5 AS store_name,
|
||||
count(*) AS total_emp,
|
||||
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count,
|
||||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
|
||||
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate
|
||||
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1) AS left_count,
|
||||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
|
||||
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate
|
||||
FROM salary_detail_records
|
||||
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
|
||||
GROUP BY org_level5
|
||||
`),
|
||||
`, [month]),
|
||||
query(`
|
||||
WITH hourly AS (
|
||||
SELECT store_name, hour, sum(bills) AS bills
|
||||
@@ -1095,6 +1103,7 @@ router.get('/overall-analysis', async (req: AuthRequest, res) => {
|
||||
|
||||
router.get('/staffing-forecast', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const [roleStats, storeRevenue, storeTraffic] = await Promise.all([
|
||||
query(`
|
||||
SELECT
|
||||
@@ -1114,12 +1123,12 @@ router.get('/staffing-forecast', async (req: AuthRequest, res) => {
|
||||
round(avg(actual_attend)::numeric, 1) AS avg_attend,
|
||||
round(avg(actual_hours)::numeric, 0) AS avg_hours,
|
||||
round(sum(actual_hours)::numeric, 0) AS total_hours,
|
||||
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count,
|
||||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count
|
||||
count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1) AS left_count,
|
||||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count
|
||||
FROM salary_detail_records
|
||||
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
|
||||
GROUP BY 1, 2
|
||||
`),
|
||||
`, [month]),
|
||||
query(`
|
||||
SELECT s.salary_name AS store_name, r.revenue, r.bill_count
|
||||
FROM (SELECT DISTINCT org_level5 AS salary_name FROM salary_detail_records WHERE org_level2='西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '') s
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { query } from '../config/database.js'
|
||||
import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
|
||||
import { sendSuccess, sendError, parsePagination, parseMonth } from '../middleware/error.js'
|
||||
import type { AuthRequest } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -10,6 +10,7 @@ const router = Router()
|
||||
// 费用总览指标
|
||||
router.get('/overview', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
WITH full_scope AS (
|
||||
SELECT
|
||||
@@ -18,29 +19,29 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
r.received,
|
||||
r.bill_count,
|
||||
r.theoretical_margin_pct,
|
||||
COALESCE(e.operating_expense, 0) AS operating_expense,
|
||||
COALESCE(e.wage_expense, 0) AS wage_expense,
|
||||
COALESCE(e.rent_expense, 0) AS rent_expense,
|
||||
COALESCE(e.utility_expense, 0) AS utility_expense,
|
||||
COALESCE(e.dorm_expense, 0) AS dorm_expense,
|
||||
COALESCE(e.delivery_commission_expense, 0) AS delivery_commission_expense,
|
||||
COALESCE(e.card_fee_expense, 0) AS card_fee_expense,
|
||||
COALESCE(e.repair_clean_expense, 0) AS repair_clean_expense,
|
||||
COALESCE(e.actual_food_cost, 0) AS actual_food_cost,
|
||||
e.operating_expense,
|
||||
e.wage_expense,
|
||||
e.rent_expense,
|
||||
e.utility_expense,
|
||||
e.dorm_expense,
|
||||
e.delivery_commission_expense,
|
||||
e.card_fee_expense,
|
||||
e.repair_clean_expense,
|
||||
e.actual_food_cost,
|
||||
COALESCE(e.theoretical_cost, r.received * (1 - COALESCE(r.theoretical_margin_pct, 0) / 100)) AS theoretical_cost,
|
||||
COALESCE(e.actual_store_contribution, r.received - COALESCE(e.actual_food_cost, 0) - COALESCE(e.operating_expense, 0)) AS actual_store_contribution,
|
||||
COALESCE(e.theoretical_store_contribution, r.received - r.received * (1 - COALESCE(r.theoretical_margin_pct, 0) / 100) - COALESCE(e.operating_expense, 0)) AS theoretical_store_contribution,
|
||||
COALESCE(e.area_sqm, 0) AS area_sqm
|
||||
FROM analytics.mv_store_risk_rating r
|
||||
CASE WHEN e.operating_expense IS NOT NULL THEN (r.received - e.actual_food_cost - e.operating_expense) ELSE NULL END AS actual_store_contribution,
|
||||
CASE WHEN e.operating_expense IS NOT NULL THEN (r.received - r.received * (1 - COALESCE(r.theoretical_margin_pct, 0) / 100) - e.operating_expense) ELSE NULL END AS theoretical_store_contribution,
|
||||
e.area_sqm
|
||||
FROM analytics.fn_store_risk_rating($1) r
|
||||
LEFT JOIN analytics.mv_store_operating_expense_monthly e
|
||||
ON r.store_code = e.sales_store_code AND e.report_month = DATE '2026-04-01'
|
||||
ON r.store_code = e.sales_store_code AND e.report_month = $1::date
|
||||
WHERE r.received IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
count(*) AS total_stores,
|
||||
count(*) FILTER (WHERE actual_store_contribution > 0) AS profitable_stores,
|
||||
count(*) FILTER (WHERE actual_store_contribution <= 0 AND received > 0) AS loss_stores,
|
||||
count(*) FILTER (WHERE operating_expense = 0) AS no_expense_stores,
|
||||
count(*) FILTER (WHERE actual_store_contribution <= 0 AND received > 0 AND actual_store_contribution IS NOT NULL) AS loss_stores,
|
||||
count(*) FILTER (WHERE operating_expense IS NULL) AS no_expense_stores,
|
||||
sum(bill_count)::bigint AS total_bills,
|
||||
round(sum(received)::numeric, 2) AS total_received,
|
||||
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
|
||||
@@ -69,7 +70,7 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
round(sum(rent_expense) / nullif(sum(received), 0) * 100, 2) AS overall_rent_rate_pct,
|
||||
round(sum(utility_expense) / nullif(sum(received), 0) * 100, 2) AS overall_utility_rate_pct
|
||||
FROM full_scope
|
||||
`)
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows[0])
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -79,15 +80,16 @@ router.get('/overview', async (req: AuthRequest, res) => {
|
||||
// 费用结构分析
|
||||
router.get('/expense-structure', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const result = await query(`
|
||||
SELECT account_name,
|
||||
round(amount::numeric, 2) AS amount,
|
||||
round(expense_share_pct::numeric, 2) AS expense_share_pct,
|
||||
nonzero_cost_unit_count
|
||||
FROM analytics.v_operating_expense_account_monthly
|
||||
WHERE report_month = DATE '2026-04-01'
|
||||
WHERE report_month = $1::date
|
||||
ORDER BY amount DESC
|
||||
`)
|
||||
`, [month])
|
||||
sendSuccess(res, result.rows)
|
||||
} catch (err: any) {
|
||||
sendError(res, err.message)
|
||||
@@ -103,9 +105,10 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
const sort = (req.query.sort as string) || 'operating_expense_rate_pct'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01'`
|
||||
const params: any[] = []
|
||||
let where = `WHERE report_month = $1::date`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0 AND received > 0`
|
||||
} else if (filter === 'profitable') {
|
||||
@@ -114,7 +117,7 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
where += ` AND received = 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['operating_expense_rate_pct', 'wage_rate_pct', 'rent_rate_pct', 'utility_rate_pct', 'received', 'operating_expense', 'actual_store_contribution', 'actual_store_contribution_rate_pct', 'received_per_sqm']
|
||||
@@ -140,7 +143,7 @@ router.get('/store-ranking', async (req: AuthRequest, res) => {
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [pageSize, offset])
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -157,8 +160,10 @@ router.get('/store-contribution', async (req: AuthRequest, res) => {
|
||||
const sort = (req.query.sort as string) || 'actual_store_contribution_rate_pct'
|
||||
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01'`
|
||||
let where = `WHERE report_month = $1::date`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0 AND received > 0`
|
||||
} else if (filter === 'profitable') {
|
||||
@@ -169,7 +174,7 @@ router.get('/store-contribution', async (req: AuthRequest, res) => {
|
||||
where += ` AND received > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['received', 'actual_store_contribution', 'actual_store_contribution_rate_pct', 'theoretical_store_contribution', 'theoretical_store_contribution_rate_pct', 'contribution_variance', 'operating_expense_rate_pct', 'wage_rate_pct', 'rent_rate_pct', 'received_per_sqm']
|
||||
@@ -188,8 +193,8 @@ router.get('/store-contribution', async (req: AuthRequest, res) => {
|
||||
round((actual_store_contribution - theoretical_store_contribution)::numeric, 2) AS contribution_variance
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -207,8 +212,10 @@ router.get('/rent-risk', async (req: AuthRequest, res) => {
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const riskOnly = req.query.risk_only === 'true'
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND rent_expense IS NOT NULL`
|
||||
let where = `WHERE report_month = $1::date AND rent_expense IS NOT NULL`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0 AND received > 0`
|
||||
} else if (filter === 'profitable') {
|
||||
@@ -217,10 +224,10 @@ router.get('/rent-risk', async (req: AuthRequest, res) => {
|
||||
where += ` AND received = 0`
|
||||
}
|
||||
if (riskOnly) {
|
||||
where += ` AND (lease_expiry_date <= DATE '2026-12-31' OR rent_rate_pct > 20)`
|
||||
where += ` AND (lease_expiry_date <= $1::date + interval '12 months' OR rent_rate_pct > 20)`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['rent_rate_pct', 'rent_expense', 'received', 'received_per_sqm', 'lease_expiry_date', 'operating_expense_rate_pct', 'actual_store_contribution']
|
||||
@@ -238,15 +245,15 @@ router.get('/rent-risk', async (req: AuthRequest, res) => {
|
||||
round(received_per_sqm::numeric, 2) AS received_per_sqm,
|
||||
lease_expiry_date,
|
||||
CASE
|
||||
WHEN lease_expiry_date <= DATE '2026-06-30' THEN '即将到期'
|
||||
WHEN lease_expiry_date <= DATE '2026-12-31' THEN '年内到期'
|
||||
WHEN lease_expiry_date <= DATE '2027-06-30' THEN '明年上半年到期'
|
||||
WHEN lease_expiry_date <= $1::date + interval '3 months' THEN '即将到期'
|
||||
WHEN lease_expiry_date <= $1::date + interval '12 months' THEN '年内到期'
|
||||
WHEN lease_expiry_date <= $1::date + interval '18 months' THEN '明年上半年到期'
|
||||
WHEN rent_rate_pct > 20 THEN '租金占比偏高'
|
||||
ELSE '正常'
|
||||
END AS risk_level,
|
||||
CASE
|
||||
WHEN lease_expiry_date <= DATE '2026-06-30' THEN '需立即启动续租谈判或关停评估'
|
||||
WHEN lease_expiry_date <= DATE '2026-12-31' THEN '需提前规划续租或迁址方案'
|
||||
WHEN lease_expiry_date <= $1::date + interval '3 months' THEN '需立即启动续租谈判或关停评估'
|
||||
WHEN lease_expiry_date <= $1::date + interval '12 months' THEN '需提前规划续租或迁址方案'
|
||||
WHEN rent_rate_pct > 25 THEN '租金严重偏高,建议谈判降租或迁址'
|
||||
WHEN rent_rate_pct > 20 THEN '租金占比偏高,关注续租条件'
|
||||
ELSE '保持关注'
|
||||
@@ -254,8 +261,8 @@ router.get('/rent-risk', async (req: AuthRequest, res) => {
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -272,15 +279,17 @@ router.get('/delivery-commission', async (req: AuthRequest, res) => {
|
||||
const sort = (req.query.sort as string) || 'commission_to_delivery_sales_pct'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND delivery_received > 0`
|
||||
let where = `WHERE report_month = $1::date AND delivery_received > 0`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['commission_to_delivery_sales_pct', 'delivery_sales_share_pct', 'delivery_received', 'delivery_commission_expense', 'combined_platform_cost_rate_pct', 'received', 'actual_store_contribution']
|
||||
@@ -308,8 +317,8 @@ router.get('/delivery-commission', async (req: AuthRequest, res) => {
|
||||
END AS suggestion
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -326,15 +335,17 @@ router.get('/efficiency', async (req: AuthRequest, res) => {
|
||||
const sort = (req.query.sort as string) || 'received_per_sqm'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0 AND area_sqm IS NOT NULL`
|
||||
let where = `WHERE report_month = $1::date AND received > 0 AND area_sqm IS NOT NULL`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['received_per_sqm', 'wage_rate_pct', 'received', 'wage_expense', 'bill_count', 'avg_ticket_size', 'actual_store_contribution', 'operating_expense_rate_pct']
|
||||
@@ -364,8 +375,8 @@ router.get('/efficiency', async (req: AuthRequest, res) => {
|
||||
END AS suggestion
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -382,15 +393,17 @@ router.get('/fixed-variable', async (req: AuthRequest, res) => {
|
||||
const sort = (req.query.sort as string) || 'fixed_rate_pct'
|
||||
const order = (req.query.order as string) === 'asc' ? 'ASC' : 'DESC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
|
||||
let where = `WHERE report_month = $1::date AND received > 0`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['fixed_rate_pct', 'variable_rate_pct', 'received', 'fixed_expense', 'variable_expense', 'contribution_after_variable', 'break_even_sales', 'actual_store_contribution', 'operating_expense_rate_pct']
|
||||
@@ -414,8 +427,8 @@ router.get('/fixed-variable', async (req: AuthRequest, res) => {
|
||||
round((rent_expense + dorm_expense + repair_clean_expense * 0.5)::numeric, 2) AS break_even_sales
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -432,15 +445,17 @@ router.get('/break-even', async (req: AuthRequest, res) => {
|
||||
const sort = (req.query.sort as string) || 'safety_margin_pct'
|
||||
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
|
||||
let where = `WHERE report_month = $1::date AND received > 0`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['safety_margin_pct', 'break_even_sales', 'sales_gap', 'food_cost_rate_pct', 'expense_rate_pct', 'received', 'actual_store_contribution', 'operating_expense_rate_pct']
|
||||
@@ -477,8 +492,8 @@ router.get('/break-even', async (req: AuthRequest, res) => {
|
||||
ELSE '安全边际充足'
|
||||
END AS safety_status
|
||||
FROM base
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
sendSuccess(res, result.rows, { page, pageSize, total })
|
||||
} catch (err: any) {
|
||||
@@ -495,8 +510,10 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
|
||||
const sort = (req.query.sort as string) || 'actual_store_contribution'
|
||||
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND actual_store_contribution <= 0 AND received > 0`
|
||||
let where = `WHERE report_month = $1::date AND actual_store_contribution <= 0 AND received > 0`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'P0') {
|
||||
where += ` AND actual_store_contribution_rate_pct < -20`
|
||||
} else if (filter === 'P1') {
|
||||
@@ -505,7 +522,7 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
|
||||
where += ` AND actual_store_contribution_rate_pct >= -5 AND actual_store_contribution_rate_pct <= 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['actual_store_contribution', 'actual_store_contribution_rate_pct', 'received', 'wage_rate_pct', 'rent_rate_pct', 'utility_rate_pct', 'received_per_sqm', 'operating_expense_rate_pct']
|
||||
@@ -573,7 +590,7 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
|
||||
ELSE '坪效正常'
|
||||
END AS efficiency_status,
|
||||
CASE
|
||||
WHEN lease_expiry_date IS NOT NULL AND lease_expiry_date <= DATE '2026-12-31' THEN '租约即将到期'
|
||||
WHEN lease_expiry_date IS NOT NULL AND lease_expiry_date <= $1::date + interval '12 months' THEN '租约即将到期'
|
||||
ELSE '租约正常'
|
||||
END AS lease_status,
|
||||
CASE
|
||||
@@ -583,8 +600,8 @@ router.get('/loss-diagnosis', async (req: AuthRequest, res) => {
|
||||
END AS loss_type
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
ORDER BY ${sortCol} ${order} NULLS LAST LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
// 为每个亏损门店生成诊断原因和建议
|
||||
const rows = result.rows.map((r: any) => {
|
||||
@@ -721,15 +738,17 @@ router.get('/store-evaluation', async (req: AuthRequest, res) => {
|
||||
const sort = (req.query.sort as string) || 'actual_store_contribution_rate_pct'
|
||||
const order = (req.query.order as string) === 'desc' ? 'DESC' : 'ASC'
|
||||
const filter = (req.query.filter as string) || ''
|
||||
const month = parseMonth(req)
|
||||
|
||||
let where = `WHERE report_month = DATE '2026-04-01' AND received > 0`
|
||||
let where = `WHERE report_month = $1::date AND received > 0`
|
||||
const params: any[] = [month]
|
||||
if (filter === 'loss') {
|
||||
where += ` AND actual_store_contribution <= 0`
|
||||
} else if (filter === 'profitable') {
|
||||
where += ` AND actual_store_contribution > 0`
|
||||
}
|
||||
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`)
|
||||
const countResult = await query(`SELECT count(*) FROM analytics.mv_store_operating_expense_monthly ${where}`, params)
|
||||
const total = countResult.rows[0].count
|
||||
|
||||
const validSorts = ['actual_store_contribution_rate_pct', 'received', 'actual_store_contribution', 'received_per_sqm', 'wage_rate_pct', 'rent_rate_pct', 'operating_expense_rate_pct']
|
||||
@@ -750,13 +769,13 @@ router.get('/store-evaluation', async (req: AuthRequest, res) => {
|
||||
round(theoretical_store_contribution::numeric, 2) AS theoretical_store_contribution,
|
||||
CASE
|
||||
WHEN received = 0 OR received < 5000 THEN '关停评估'
|
||||
WHEN actual_store_contribution_rate_pct < -20 AND lease_expiry_date <= DATE '2026-12-31' THEN '关停评估'
|
||||
WHEN actual_store_contribution_rate_pct < -20 AND lease_expiry_date <= $1::date + interval '12 months' THEN '关停评估'
|
||||
WHEN actual_store_contribution_rate_pct < -10 AND received_per_sqm < 1000 THEN '关停评估'
|
||||
WHEN actual_store_contribution_rate_pct < 0 AND lease_expiry_date <= DATE '2026-12-31' THEN '关停或迁址评估'
|
||||
WHEN actual_store_contribution_rate_pct < 0 AND lease_expiry_date <= $1::date + interval '12 months' THEN '关停或迁址评估'
|
||||
WHEN actual_store_contribution_rate_pct < 0 AND received_per_sqm < 1000 AND area_sqm > 400 THEN '改造评估'
|
||||
WHEN actual_store_contribution_rate_pct < 0 AND wage_rate_pct > 35 THEN '改造评估'
|
||||
WHEN actual_store_contribution_rate_pct < 0 THEN '关注观察'
|
||||
WHEN lease_expiry_date <= DATE '2026-06-30' THEN '续租评估'
|
||||
WHEN lease_expiry_date <= $1::date + interval '3 months' THEN '续租评估'
|
||||
WHEN actual_store_contribution_rate_pct > 0 AND actual_store_contribution_rate_pct < 10 THEN '关注观察'
|
||||
ELSE '正常经营'
|
||||
END AS evaluation_type,
|
||||
@@ -780,8 +799,8 @@ router.get('/store-evaluation', async (req: AuthRequest, res) => {
|
||||
ELSE 5
|
||||
END,
|
||||
${sortCol} ${order} NULLS LAST
|
||||
LIMIT $1 OFFSET $2
|
||||
`, [pageSize, offset])
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
`, [...params, pageSize, offset])
|
||||
|
||||
// 格式化日期
|
||||
const fmtDate = (d: any) => d ? new Date(d).toLocaleDateString('zh-CN') : '-'
|
||||
@@ -836,13 +855,13 @@ router.get('/store-evaluation', async (req: AuthRequest, res) => {
|
||||
// 全量统计各评估类型数量
|
||||
const statsResult = await query(`
|
||||
SELECT
|
||||
count(*) FILTER (WHERE received = 0 OR received < 5000 OR (actual_store_contribution_rate_pct < -20 AND lease_expiry_date <= DATE '2026-12-31') OR (actual_store_contribution_rate_pct < -10 AND received_per_sqm < 1000)) AS "关停评估",
|
||||
count(*) FILTER (WHERE received = 0 OR received < 5000 OR (actual_store_contribution_rate_pct < -20 AND lease_expiry_date <= $1::date + interval '12 months') OR (actual_store_contribution_rate_pct < -10 AND received_per_sqm < 1000)) AS "关停评估",
|
||||
count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND received_per_sqm < 1000 AND area_sqm > 400) + count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND wage_rate_pct > 35) AS "改造评估",
|
||||
count(*) FILTER (WHERE lease_expiry_date <= DATE '2026-06-30' AND actual_store_contribution_rate_pct >= 0) AS "续租评估",
|
||||
count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND lease_expiry_date > DATE '2026-12-31' AND NOT (received_per_sqm < 1000 AND area_sqm > 400) AND wage_rate_pct <= 35) + count(*) FILTER (WHERE actual_store_contribution_rate_pct > 0 AND actual_store_contribution_rate_pct < 10) AS "关注观察"
|
||||
count(*) FILTER (WHERE lease_expiry_date <= $1::date + interval '3 months' AND actual_store_contribution_rate_pct >= 0) AS "续租评估",
|
||||
count(*) FILTER (WHERE actual_store_contribution_rate_pct < 0 AND lease_expiry_date > $1::date + interval '12 months' AND NOT (received_per_sqm < 1000 AND area_sqm > 400) AND wage_rate_pct <= 35) + count(*) FILTER (WHERE actual_store_contribution_rate_pct > 0 AND actual_store_contribution_rate_pct < 10) AS "关注观察"
|
||||
FROM analytics.mv_store_operating_expense_monthly
|
||||
${where}
|
||||
`)
|
||||
`, params)
|
||||
const evalStats = statsResult.rows[0]
|
||||
|
||||
sendSuccess(res, rows, { page, pageSize, total, evalStats })
|
||||
|
||||
@@ -21,6 +21,12 @@ router.get('/', async (req: AuthRequest, res) => {
|
||||
const params: any[] = [month]
|
||||
let paramIdx = 2
|
||||
|
||||
// 默认排除模拟验收记录,除非显式请求
|
||||
const includeSimulated = req.query.include_simulated === 'true'
|
||||
if (!includeSimulated) {
|
||||
conditions.push(`(is_simulated = false OR is_simulated IS NULL)`)
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
conditions.push(`priority = $${paramIdx++}`)
|
||||
params.push(priority)
|
||||
@@ -81,7 +87,7 @@ router.post('/', async (req: AuthRequest, res) => {
|
||||
|
||||
router.post('/auto-generate', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = req.body.month ? (req.body.month.length === 7 ? `${req.body.month}-01` : req.body.month) : '2026-05-01'
|
||||
const month = req.body.month ? (req.body.month.length === 7 ? `${req.body.month}-01` : req.body.month) : parseMonth(req)
|
||||
const result = await query(`SELECT * FROM analytics.f_generate_store_tasks($1)`, [month])
|
||||
sendSuccess(res, result.rows[0] || { generated: 0, message: 'No tasks generated' })
|
||||
} catch (err: any) {
|
||||
@@ -585,7 +591,7 @@ router.get('/monthly-review/completion', async (req: AuthRequest, res) => {
|
||||
count(*) FILTER (WHERE verification_result = '未改善') as failed,
|
||||
ROUND(count(*) FILTER (WHERE status = '已验收' OR status = '已回滚') * 100.0 / NULLIF(count(*), 0), 1) as completion_rate
|
||||
FROM analytics.store_task
|
||||
WHERE plan_month = $1
|
||||
WHERE plan_month = $1 AND (is_simulated = false OR is_simulated IS NULL)
|
||||
GROUP BY priority
|
||||
ORDER BY priority
|
||||
`, [month])
|
||||
@@ -609,22 +615,23 @@ router.get('/monthly-review/activity-list', async (req: AuthRequest, res) => {
|
||||
|
||||
router.get('/monthly-review/sku-governance', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const month = parseMonth(req)
|
||||
const abcResult = await query(`
|
||||
SELECT abc_class, count(*) as sku_count,
|
||||
ROUND(sum(revenue_share_pct), 1) as total_revenue_share,
|
||||
ROUND(avg(revenue_share_pct), 2) as avg_revenue_share
|
||||
FROM analytics.v_dish_sku_abc_april
|
||||
FROM analytics.fn_dish_sku_abc($1)
|
||||
GROUP BY abc_class
|
||||
ORDER BY abc_class
|
||||
`)
|
||||
`, [month])
|
||||
const longtailResult = await query(`
|
||||
SELECT dish_name, category_level1, bill_count, sales_quantity,
|
||||
received_amount, revenue_share_pct, cumulative_revenue_share
|
||||
FROM analytics.v_dish_sku_abc_april
|
||||
FROM analytics.fn_dish_sku_abc($1)
|
||||
WHERE abc_class = 'C-长尾'
|
||||
ORDER BY received_amount ASC
|
||||
LIMIT 50
|
||||
`)
|
||||
`, [month])
|
||||
sendSuccess(res, { summary: abcResult.rows, longtail: longtailResult.rows })
|
||||
} catch (err: any) { sendError(res, err.message) }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Step 2: 填充 dim_material 从配送流水+盘点数据合并去重
|
||||
-- 配送流水有1835种物料,盘点数据有6133种物料,合并去重
|
||||
|
||||
WITH merged AS (
|
||||
-- 配送流水中的物料
|
||||
SELECT item_code AS material_code, item_name AS material_name, specification,
|
||||
major_category, minor_category, finance_category, unit AS base_unit,
|
||||
NULL::text AS supplier_code,
|
||||
row_number() OVER (PARTITION BY item_code ORDER BY
|
||||
(minor_category IS NOT NULL AND minor_category != '')::int DESC,
|
||||
(finance_category IS NOT NULL AND finance_category != '')::int DESC,
|
||||
(specification IS NOT NULL AND specification != '')::int DESC
|
||||
) AS rn
|
||||
FROM distribution_detail_records
|
||||
WHERE item_code IS NOT NULL AND item_code != ''
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 盘点倒挤数据中的物料
|
||||
SELECT item_code AS material_code, item_name AS material_name, specification,
|
||||
major_category, minor_category, finance_category, unit AS base_unit,
|
||||
NULL::text AS supplier_code,
|
||||
row_number() OVER (PARTITION BY item_code ORDER BY
|
||||
(minor_category IS NOT NULL AND minor_category != '')::int DESC,
|
||||
(finance_category IS NOT NULL AND finance_category != '')::int DESC,
|
||||
(specification IS NOT NULL AND specification != '')::int DESC
|
||||
) AS rn
|
||||
FROM inventory_cost_records
|
||||
WHERE item_code IS NOT NULL AND item_code != ''
|
||||
),
|
||||
best AS (
|
||||
SELECT DISTINCT ON (material_code) material_code, material_name, specification,
|
||||
major_category, minor_category, finance_category, base_unit, supplier_code
|
||||
FROM merged
|
||||
ORDER BY material_code,
|
||||
(minor_category IS NOT NULL AND minor_category != '') DESC,
|
||||
(finance_category IS NOT NULL AND finance_category != '') DESC,
|
||||
(specification IS NOT NULL AND specification != '') DESC,
|
||||
(major_category IS NOT NULL AND major_category != '') DESC
|
||||
)
|
||||
INSERT INTO analytics.dim_material (material_code, material_name, specification, major_category, minor_category, finance_category, base_unit, supplier_code, status, created_at, updated_at)
|
||||
SELECT material_code, material_name, specification, major_category, minor_category, finance_category, base_unit, supplier_code, '启用', now(), now()
|
||||
FROM best
|
||||
ON CONFLICT (material_code) DO UPDATE SET
|
||||
material_name = EXCLUDED.material_name,
|
||||
specification = COALESCE(NULLIF(EXCLUDED.specification, ''), dim_material.specification),
|
||||
major_category = COALESCE(NULLIF(EXCLUDED.major_category, ''), dim_material.major_category),
|
||||
minor_category = COALESCE(NULLIF(EXCLUDED.minor_category, ''), dim_material.minor_category),
|
||||
finance_category = COALESCE(NULLIF(EXCLUDED.finance_category, ''), dim_material.finance_category),
|
||||
base_unit = COALESCE(NULLIF(EXCLUDED.base_unit, ''), dim_material.base_unit),
|
||||
updated_at = now();
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Step 1: 填充 dim_supplier 从配送流水去重
|
||||
INSERT INTO analytics.dim_supplier (supplier_code, supplier_name, supplier_type, contact_person, contact_phone, region, status, created_at, updated_at)
|
||||
SELECT d.supplier_code, d.supplier_name, d.supplier_category, d.supplier_contact, d.supplier_phone,
|
||||
CASE WHEN d.supplier_address ~ '北京' THEN '北京' WHEN d.supplier_address ~ '福建' THEN '福建'
|
||||
WHEN d.supplier_address ~ '四川' THEN '四川' WHEN d.supplier_address ~ '山东' THEN '山东'
|
||||
WHEN d.supplier_address ~ '新疆' THEN '新疆' WHEN d.supplier_address ~ '河北' THEN '河北'
|
||||
WHEN d.supplier_address ~ '天津' THEN '天津' WHEN d.supplier_address ~ '内蒙古' THEN '内蒙古'
|
||||
WHEN d.supplier_address ~ '甘肃' THEN '甘肃' WHEN d.supplier_address ~ '宁夏' THEN '宁夏'
|
||||
ELSE '其他' END,
|
||||
'启用', now(), now()
|
||||
FROM (
|
||||
SELECT DISTINCT ON (supplier_code) supplier_code, supplier_name, supplier_category, supplier_contact, supplier_phone, supplier_address
|
||||
FROM distribution_detail_records WHERE supplier_code IS NOT NULL
|
||||
ORDER BY supplier_code, (supplier_phone IS NOT NULL AND supplier_phone != '') DESC, (supplier_address IS NOT NULL AND supplier_address != '') DESC
|
||||
) d
|
||||
ON CONFLICT (supplier_code) DO UPDATE SET
|
||||
supplier_name = EXCLUDED.supplier_name, supplier_type = EXCLUDED.supplier_type,
|
||||
contact_person = EXCLUDED.contact_person, contact_phone = EXCLUDED.contact_phone,
|
||||
region = EXCLUDED.region, updated_at = now();
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Step 3: 填充 fact_inventory_snapshot 从盘点倒挤数据ETL
|
||||
-- 需要将 cost_unit_code 映射为 store_code,盘点数据中有 sales_store_code 可用
|
||||
|
||||
INSERT INTO analytics.fact_inventory_snapshot (
|
||||
store_code, material_code, snapshot_date,
|
||||
opening_quantity, opening_amount,
|
||||
purchase_quantity, purchase_amount,
|
||||
consumption_quantity, consumption_amount,
|
||||
ending_quantity, ending_amount,
|
||||
waste_quantity, waste_amount,
|
||||
transfer_in_quantity, transfer_out_quantity,
|
||||
inventory_days, is_negative, created_at
|
||||
)
|
||||
SELECT
|
||||
COALESCE(s.sales_store_code, i.cost_unit_code) AS store_code,
|
||||
i.item_code AS material_code,
|
||||
i.report_month AS snapshot_date,
|
||||
i.opening_quantity, i.opening_amount,
|
||||
i.purchase_quantity, i.purchase_amount,
|
||||
i.consumption_quantity,
|
||||
i.consumption_amount,
|
||||
i.ending_quantity, i.ending_amount,
|
||||
NULL::numeric AS waste_quantity,
|
||||
COALESCE(i.return_loss_amount, 0) AS waste_amount,
|
||||
NULL::numeric AS transfer_in_quantity,
|
||||
NULL::numeric AS transfer_out_quantity,
|
||||
NULL::numeric AS inventory_days,
|
||||
i.is_negative_consumption AS is_negative,
|
||||
now() AS created_at
|
||||
FROM inventory_cost_records i
|
||||
LEFT JOIN inventory_store_mapping s ON i.cost_unit_code = s.cost_unit_code
|
||||
WHERE i.report_month = DATE '2026-04-01'
|
||||
AND i.item_code IS NOT NULL AND i.item_code != ''
|
||||
ON CONFLICT DO NOTHING;
|
||||
Reference in New Issue
Block a user