Files
SBrainCO/client/src/pages/StoreGradePage.tsx
T
freedakgmail 8f5271fcc6 fix: P2/P3审计问题修复 + 更新审计文档移除已修复问题
- 修复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个已修复/确认无需修复的问题条目
2026-08-11 22:00:58 +08:00

351 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}