8f5271fcc6
- 修复schema前缀:确认central_kitchen/mv_distribution/mv_dish_sales等表在public schema,撤销错误的analytics.前缀 - 移除API内部物化视图刷新:mv_distribution_monthly/mv_dish_sales_monthly改为console.warn - 修复BOM树节点key:使用path字段替代material_code+level确保唯一性 - 修复HR人才矩阵potential_score:基于v3_learning_record动态计算替代硬编码60 - 修复StoreDetailPage公司均值:优先使用API返回的companyAvg - 修复前端字段名匹配:discount_rate_pct/check_comment等 - 添加explicit column list和LIMIT到大型查询 - 更新数据溯源审计.md:移除全部52个已修复/确认无需修复的问题条目
351 lines
19 KiB
TypeScript
351 lines
19 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import api from '@/lib/api'
|
||
import { FilterableTable } from '@/components/FilterableTable'
|
||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||
import { MetricCard } from '@/components/MetricCard'
|
||
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
|
||
import { useState } from 'react'
|
||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar } from 'recharts'
|
||
import { Tabs } from '@/components/Tabs'
|
||
import { MonthPicker } from '@/components/MonthPicker'
|
||
|
||
const GRADE_COLORS: Record<string, string> = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' }
|
||
const RISK_COLORS: Record<string, string> = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' }
|
||
|
||
export function StoreGradePage() {
|
||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||
const [selectedStore, setSelectedStore] = useState<any>(null)
|
||
const [tab, setTab] = useState('grade')
|
||
const queryClient = useQueryClient()
|
||
|
||
const { data: gradeData, isLoading: gradeLoading } = useQuery({
|
||
queryKey: ['store-grade/grades', month],
|
||
queryFn: () => api.get('/store-grade/grades', { params: { month } }),
|
||
enabled: tab === 'grade',
|
||
})
|
||
|
||
const { data: closureData, isLoading: closureLoading } = useQuery({
|
||
queryKey: ['store-grade/closure-analysis', month],
|
||
queryFn: () => api.get('/store-grade/closure-analysis', { params: { month } }),
|
||
enabled: tab === 'closure',
|
||
})
|
||
|
||
const autoGradeMutation = useMutation({
|
||
mutationFn: () => api.post('/store-grade/auto-grade', null, { params: { month } }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['store-grade/grades'] })
|
||
},
|
||
})
|
||
|
||
const gradeRows = (gradeData as any)?.data || []
|
||
|
||
const gradeDistribution = gradeRows.reduce((acc: Record<string, number>, r: any) => {
|
||
acc[r.grade] = (acc[r.grade] || 0) + 1
|
||
return acc
|
||
}, {})
|
||
const gradePieData = Object.entries(gradeDistribution).map(([name, value]) => ({ name: `${name}级`, value }))
|
||
|
||
const riskDistribution = gradeRows.reduce((acc: Record<string, number>, r: any) => {
|
||
acc[r.risk_level] = (acc[r.risk_level] || 0) + 1
|
||
return acc
|
||
}, {})
|
||
const riskPieData = [
|
||
{ name: '红色', value: riskDistribution['红色'] || 0 },
|
||
{ name: '黄色', value: riskDistribution['黄色'] || 0 },
|
||
{ name: '绿色', value: riskDistribution['绿色'] || 0 },
|
||
]
|
||
|
||
const avgScore = gradeRows.length > 0
|
||
? gradeRows.reduce((s: number, r: any) => s + Number(r.overall_score || 0), 0) / gradeRows.length
|
||
: 0
|
||
const aGradeCount = gradeDistribution.A || 0
|
||
const dGradeCount = gradeDistribution.D || 0
|
||
const redRiskCount = riskDistribution['红色'] || 0
|
||
|
||
if (gradeLoading && tab === 'grade') {
|
||
return <LoadingSpinner text="加载门店分级数据..." />
|
||
}
|
||
if (closureLoading && tab === 'closure') {
|
||
return <LoadingSpinner text="加载关停测算数据..." />
|
||
}
|
||
|
||
const closure = (closureData as any)?.data || {}
|
||
const closureSummary = closure.summary || {}
|
||
const closureStores = closure.stores || []
|
||
|
||
const closurePieData = [
|
||
{ name: '建议关停', value: closureSummary.recommend_close || 0 },
|
||
{ name: '整改观察', value: closureSummary.recommend_rectify || 0 },
|
||
{ name: '持续经营', value: (closureSummary.total_stores || 0) - (closureSummary.recommend_close || 0) - (closureSummary.recommend_rectify || 0) },
|
||
].filter(d => d.value > 0)
|
||
|
||
const CLOSURE_COLORS: Record<string, string> = { '建议关停': '#ef4444', '整改观察': '#eab308', '持续经营': '#22c55e' }
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<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">自动分级 · 风险监控 · 关停测算 · {month}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<MonthPicker month={month} onChange={setMonth} />
|
||
{tab === 'grade' && (
|
||
<button
|
||
onClick={() => autoGradeMutation.mutate()}
|
||
disabled={autoGradeMutation.isPending}
|
||
className="rounded bg-primary px-3 py-1 text-sm text-primary-foreground disabled:opacity-50"
|
||
>
|
||
{autoGradeMutation.isPending ? '分级中...' : '自动分级'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<Tabs tabs={[
|
||
{ key: 'grade', label: '门店分级' },
|
||
{ key: 'closure', label: '关停测算' },
|
||
]} active={tab} onChange={setTab} />
|
||
|
||
{tab === 'closure' && (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<MetricCard title="门店总数" value={closureSummary.total_stores || 0} format="number" description="参与测算的门店总数" />
|
||
<MetricCard title="建议关停" value={closureSummary.recommend_close || 0} format="number" description="持续亏损且D级门店" status="bad" />
|
||
<MetricCard title="整改观察" value={closureSummary.recommend_rectify || 0} format="number" description="亏损但有基础的门店" status="warn" />
|
||
<MetricCard title="关停可月省" value={closureSummary.total_monthly_savings || 0} format="currency" description="关停门店每月可节省亏损" status="good" />
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<MetricCard title="关停总成本" value={closureSummary.total_closure_cost || 0} format="currency" description="遣散+违约+资产处置" status="bad" />
|
||
<MetricCard title="12月预计亏损" value={closureSummary.total_projected_loss || 0} format="currency" description="不关停继续经营的预计亏损" status="bad" />
|
||
</div>
|
||
|
||
{closurePieData.length > 0 && (
|
||
<CollapsibleSection title="关停建议分布" subtitle="按盈利状况和综合评分分类">
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<PieChart>
|
||
<Pie data={closurePieData} cx="50%" cy="50%" outerRadius={60} dataKey="value"
|
||
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
|
||
{closurePieData.map((e, i) => <Cell key={i} fill={CLOSURE_COLORS[e.name] || '#999'} />)}
|
||
</Pie>
|
||
<Tooltip formatter={(v: any) => [formatNumber(v), '门店数']} />
|
||
<Legend />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
)}
|
||
|
||
<CollapsibleSection title={`关停测算明细 (${closureStores.length})`} subtitle="含关停成本、继续经营亏损、回本期、建议">
|
||
<FilterableTable
|
||
data={closureStores}
|
||
filterKey="recommendation"
|
||
filterLabel="全部建议"
|
||
sortOptions={[
|
||
{ key: 'monthly_profit', label: '月利润' },
|
||
{ key: 'total_closure_cost', label: '关停成本' },
|
||
{ key: 'projected_12m_loss', label: '12月亏损' },
|
||
{ key: 'payback_months', label: '回本月数' },
|
||
{ key: 'overall_score', label: '综合分' },
|
||
]}
|
||
defaultSort="monthly_profit"
|
||
defaultOrder="asc"
|
||
columns={[
|
||
{ key: 'store_name', label: '门店名称' },
|
||
{ key: 'region', label: '区域' },
|
||
{ key: 'grade', label: '分级', align: 'center', render: (r) => {
|
||
const colors: Record<string, string> = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' }
|
||
return <span className={`font-bold ${colors[r.grade] || ''}`}>{r.grade}</span>
|
||
}},
|
||
{ key: 'monthly_revenue', label: '月收入', align: 'right', render: (r) => formatCurrency(r.monthly_revenue) },
|
||
{ key: 'monthly_profit', label: '月利润', align: 'right', render: (r) => {
|
||
const v = Number(r.monthly_profit || 0)
|
||
return <span className={v < 0 ? 'text-red-600 font-medium' : 'text-green-600'}>{formatCurrency(v)}</span>
|
||
}},
|
||
{ key: 'monthly_rent', label: '月租', align: 'right', render: (r) => formatCurrency(r.monthly_rent) },
|
||
{ key: 'monthly_wage', label: '月人工', align: 'right', render: (r) => formatCurrency(r.monthly_wage) },
|
||
{ key: 'total_closure_cost', label: '关停成本', align: 'right', render: (r) => <span className="text-red-600">{formatCurrency(r.total_closure_cost)}</span> },
|
||
{ key: 'projected_12m_loss', label: '12月亏损', align: 'right', render: (r) => {
|
||
const v = Number(r.projected_12m_loss || 0)
|
||
return v ? <span className="text-red-600">{formatCurrency(v)}</span> : '-'
|
||
}},
|
||
{ key: 'monthly_savings_if_closed', label: '月节省', align: 'right', render: (r) => {
|
||
const v = Number(r.monthly_savings_if_closed || 0)
|
||
return v ? <span className="text-green-600">{formatCurrency(v)}</span> : '-'
|
||
}},
|
||
{ key: 'payback_months', label: '回本(月)', align: 'right', render: (r) => r.payback_months ? `${Number(r.payback_months).toFixed(1)}月` : '-' },
|
||
{ key: 'recommendation', label: '建议', align: 'center', render: (r) => {
|
||
const colors: Record<string, string> = { '建议关停': 'bg-red-500 text-white', '整改观察3个月': 'bg-yellow-500 text-white', '重点整改': 'bg-blue-500 text-white', '持续经营': 'bg-green-500 text-white' }
|
||
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.recommendation] || ''}`}>{r.recommendation}</span>
|
||
}},
|
||
{ key: 'reason', label: '原因' },
|
||
]}
|
||
/>
|
||
</CollapsibleSection>
|
||
</>
|
||
)}
|
||
|
||
{tab === 'grade' && (
|
||
<>
|
||
|
||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||
<MetricCard title="门店总数" value={gradeRows.length} format="number" description="参与分级的门店总数" />
|
||
<MetricCard title="平均综合分" value={avgScore} format="number" description="所有门店综合评分平均值" status={avgScore >= 75 ? 'good' : avgScore >= 60 ? 'warn' : 'bad'} />
|
||
<MetricCard title="A级门店" value={aGradeCount} format="number" description="综合分≥85的优秀门店" status="good" />
|
||
<MetricCard title="高风险门店" value={redRiskCount} format="number" description="风险等级红色的门店" status={redRiskCount > 0 ? 'bad' : 'good'} />
|
||
</div>
|
||
|
||
<div className="grid gap-4 lg:grid-cols-2">
|
||
<CollapsibleSection title="门店分级分布" subtitle="综合分=达成率40%+毛利率25%+会员占比15%+风险20% | A≥85 · B≥70 · C≥50 · D<50">
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<PieChart>
|
||
<Pie data={gradePieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
|
||
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
|
||
{gradePieData.map((entry, i) => <Cell key={i} fill={GRADE_COLORS[entry.name[0]] || '#999'} />)}
|
||
</Pie>
|
||
<Tooltip formatter={(v: any) => [formatNumber(v), '门店数']} />
|
||
<Legend />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
|
||
<CollapsibleSection title="风险等级分布" subtitle="风险模型基于毛利率、优惠率、异常率等指标综合评估">
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<PieChart>
|
||
<Pie data={riskPieData} cx="50%" cy="50%" outerRadius={70} dataKey="value"
|
||
labelLine={false} label={({ name, value }: any) => <tspan fontSize={11}>{`${name}: ${value}`}</tspan>}>
|
||
{riskPieData.map((entry, i) => <Cell key={i} fill={RISK_COLORS[entry.name] || '#999'} />)}
|
||
</Pie>
|
||
<Tooltip formatter={(v: any) => [formatNumber(v), '门店数']} />
|
||
<Legend />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
</CollapsibleSection>
|
||
</div>
|
||
|
||
<CollapsibleSection title={`门店分级明细 (${gradeRows.length})`} subtitle="综合分=达成率40%+毛利率25%+会员占比15%+风险20% | A≥85 · B≥70 · C≥50 · D<50">
|
||
<FilterableTable
|
||
data={gradeRows}
|
||
filterKey="grade"
|
||
filterLabel="全部分级"
|
||
sortOptions={[
|
||
{ key: 'overall_score', label: '综合分' },
|
||
{ key: 'revenue_achievement_pct', label: '达成率' },
|
||
{ key: 'store_name', label: '门店' },
|
||
]}
|
||
defaultSort="overall_score"
|
||
defaultOrder="desc"
|
||
onRowClick={(r) => setSelectedStore(r)}
|
||
columns={[
|
||
{ key: 'store_code', label: '门店编码' },
|
||
{ key: 'store_name', label: '门店名称' },
|
||
{ key: 'region', label: '区域' },
|
||
{ key: 'grade', label: '分级', align: 'center', render: (r) => {
|
||
const colors: Record<string, string> = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' }
|
||
return <span className={`font-bold ${colors[r.grade] || ''}`}>{r.grade}</span>
|
||
}},
|
||
{ key: 'risk_level', label: '风险', align: 'center', render: (r) => {
|
||
const colors: Record<string, string> = { '红色': 'bg-red-500 text-white', '黄色': 'bg-yellow-500 text-white', '绿色': 'bg-green-500 text-white' }
|
||
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.risk_level] || ''}`}>{r.risk_level || '-'}</span>
|
||
}},
|
||
{ key: 'overall_score', label: '综合分', align: 'right', render: (r) => Number(r.overall_score || 0).toFixed(1) },
|
||
{ key: 'revenue_achievement_pct', label: '营收达成率', align: 'right', render: (r) => {
|
||
const v = Number(r.revenue_achievement_pct || 0)
|
||
return <span className={v >= 100 ? 'text-green-600' : v >= 80 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}%</span>
|
||
}},
|
||
{ key: 'reason', label: '分级原因' },
|
||
]}
|
||
/>
|
||
</CollapsibleSection>
|
||
|
||
{selectedStore && <StoreDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const GRADE_DIMENSIONS = [
|
||
{ key: 'revenue_achievement_pct', label: '营收达成', max: 100 },
|
||
{ key: 'overall_score', label: '综合分', max: 100 },
|
||
{ key: 'margin_pct', label: '毛利率', max: 50 },
|
||
{ key: 'anomaly_rate', label: '异常率', max: 20, inverse: true },
|
||
]
|
||
|
||
function StoreDetailModal({ store, onClose }: { store: any; onClose: () => void }) {
|
||
const gradeColor = GRADE_COLORS[store.grade] || '#999'
|
||
const riskColor = RISK_COLORS[store.risk_level] || '#999'
|
||
|
||
const radarData = GRADE_DIMENSIONS.map(d => {
|
||
const raw = Number(store[d.key] || 0)
|
||
const pct = d.inverse ? Math.max(0, 100 - (raw / d.max) * 100) : Math.min((raw / d.max) * 100, 100)
|
||
return { dimension: d.label, score: Math.round(pct), fullMark: 100 }
|
||
})
|
||
|
||
const metrics = [
|
||
{ label: '门店编码', value: store.store_code },
|
||
{ label: '区域', value: store.region || '-' },
|
||
{ label: '分级', value: store.grade, color: gradeColor },
|
||
{ label: '综合分', value: Number(store.overall_score || 0).toFixed(1) },
|
||
{ label: '营收达成率', value: `${Number(store.revenue_achievement_pct || 0).toFixed(1)}%` },
|
||
{ label: '营收', value: formatCurrency(store.revenue) },
|
||
{ label: '毛利率', value: formatPercent(store.margin_pct) },
|
||
{ label: '优惠率', value: store.discount_rate != null ? `${Number(store.discount_rate).toFixed(1)}%` : '-' },
|
||
{ label: '异常率', value: store.anomaly_rate != null ? `${Number(store.anomaly_rate).toFixed(1)}%` : '-' },
|
||
{ label: '会员占比', value: store.member_pct != null ? `${Number(store.member_pct).toFixed(1)}%` : '-' },
|
||
{ label: '客单价', value: formatCurrency(store.avg_bill_value) },
|
||
{ label: '活跃天数', value: store.active_days != null ? `${store.active_days}天` : '-' },
|
||
{ label: '风险等级', value: store.risk_level || '-', color: riskColor },
|
||
]
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<h2 className="text-lg font-bold">{store.store_name}</h2>
|
||
<span className="rounded px-2 py-0.5 text-xs font-bold text-white" style={{ background: gradeColor }}>{store.grade}级</span>
|
||
<span className="rounded px-2 py-0.5 text-xs font-medium text-white" style={{ background: riskColor }}>
|
||
{store.risk_level || '-'}
|
||
</span>
|
||
</div>
|
||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl">✕</button>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||
<div>
|
||
<ResponsiveContainer width="100%" height={250}>
|
||
<RadarChart data={radarData}>
|
||
<PolarGrid />
|
||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
|
||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 8 }} />
|
||
<Radar dataKey="score" name="得分" stroke={gradeColor} fill={gradeColor} fillOpacity={0.3} />
|
||
<Tooltip formatter={(v: any) => [v, '得分']} />
|
||
</RadarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{metrics.map(m => (
|
||
<div key={m.label} className="flex items-center justify-between border-b pb-1 text-sm">
|
||
<span className="text-muted-foreground">{m.label}</span>
|
||
<span className="font-medium" style={m.color ? { color: m.color } : undefined}>{m.value}</span>
|
||
</div>
|
||
))}
|
||
{store.reason && (
|
||
<div className="pt-2 text-xs text-muted-foreground">
|
||
<span className="font-medium">分级原因:</span>{store.reason}
|
||
</div>
|
||
)}
|
||
{store.primary_issue && (
|
||
<div className="pt-1 text-xs text-muted-foreground">
|
||
<span className="font-medium">风险原因:</span>{store.primary_issue}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|