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个已修复/确认无需修复的问题条目
264 lines
13 KiB
TypeScript
264 lines
13 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import api from '@/lib/api'
|
|
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
|
import { FilterableTable } from '@/components/FilterableTable'
|
|
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
|
import { MetricCard } from '@/components/MetricCard'
|
|
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
|
|
|
const PAGE_SIZE = 15
|
|
|
|
const PRIORITY_COLORS: Record<string, string> = {
|
|
'P0': 'bg-red-100 text-red-700',
|
|
'P1': 'bg-orange-100 text-orange-700',
|
|
'P2': 'bg-yellow-100 text-yellow-700',
|
|
'P3': 'bg-gray-100 text-gray-700',
|
|
}
|
|
|
|
const LOSS_TYPE_COLORS: Record<string, string> = {
|
|
'理论即亏损': 'text-red-600 font-medium',
|
|
'实际超耗导致亏损': 'text-orange-600 font-medium',
|
|
'费用过高导致亏损': 'text-yellow-600 font-medium',
|
|
}
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
'收入极低': 'text-red-600 font-medium',
|
|
'收入偏低': 'text-orange-600',
|
|
'食材成本率过高': 'text-red-600 font-medium',
|
|
'食材成本率偏高': 'text-orange-600',
|
|
'人工成本率严重过高': 'text-red-600 font-medium',
|
|
'人工成本率过高': 'text-red-600',
|
|
'人工成本率偏高': 'text-orange-600',
|
|
'租金占比严重过高': 'text-red-600 font-medium',
|
|
'租金占比过高': 'text-red-600',
|
|
'租金占比偏高': 'text-orange-600',
|
|
'水电燃气率过高': 'text-red-600',
|
|
'水电燃气率偏高': 'text-orange-600',
|
|
'外卖依赖度过高': 'text-red-600 font-medium',
|
|
'外卖占比较高': 'text-orange-600',
|
|
'坪效极低': 'text-red-600 font-medium',
|
|
'坪效偏低': 'text-orange-600',
|
|
'租约即将到期': 'text-red-600 font-medium',
|
|
}
|
|
|
|
interface StoreDetail {
|
|
sales_store_name: string
|
|
received: string
|
|
actual_food_cost: string
|
|
operating_expense: string
|
|
actual_store_contribution: string
|
|
actual_store_contribution_rate_pct: string
|
|
wage_expense: string
|
|
wage_rate_pct: string
|
|
rent_expense: string
|
|
rent_rate_pct: string
|
|
utility_expense: string
|
|
utility_rate_pct: string
|
|
dorm_expense: string
|
|
delivery_commission_expense: string
|
|
delivery_received: string
|
|
delivery_sales_share_pct: string
|
|
area_sqm: string
|
|
received_per_sqm: string
|
|
bill_count: string
|
|
lease_expiry_date: string
|
|
theoretical_cost: string
|
|
theoretical_store_contribution: string
|
|
theoretical_store_contribution_rate_pct: string
|
|
revenue_status: string
|
|
food_cost_status: string
|
|
wage_status: string
|
|
rent_status: string
|
|
utility_status: string
|
|
delivery_status: string
|
|
efficiency_status: string
|
|
lease_status: string
|
|
loss_type: string
|
|
diagnosis_reasons: string
|
|
diagnosis_suggestions: string
|
|
suggested_actions: string
|
|
priority: string
|
|
}
|
|
|
|
function StoreDetailModal({ store, onClose }: { store: StoreDetail | null, onClose: () => void }) {
|
|
if (!store) return null
|
|
|
|
const statusItems = [
|
|
{ label: '收入状态', value: store.revenue_status },
|
|
{ label: '食材成本', value: store.food_cost_status },
|
|
{ label: '人工成本', value: store.wage_status },
|
|
{ label: '租金占比', value: store.rent_status },
|
|
{ label: '水电燃气', value: store.utility_status },
|
|
{ label: '外卖依赖', value: store.delivery_status },
|
|
{ label: '坪效', value: store.efficiency_status },
|
|
{ label: '租约', value: store.lease_status },
|
|
]
|
|
|
|
const metrics = [
|
|
{ label: '实收', value: formatCurrency(store.received) },
|
|
{ label: '食材成本', value: formatCurrency(store.actual_food_cost) },
|
|
{ label: '营业费用', value: formatCurrency(store.operating_expense) },
|
|
{ label: '贡献利润', value: formatCurrency(store.actual_store_contribution), red: true },
|
|
{ label: '贡献率', value: formatPercent(store.actual_store_contribution_rate_pct), red: true },
|
|
{ label: '理论贡献', value: formatCurrency(store.theoretical_store_contribution) },
|
|
{ label: '理论贡献率', value: formatPercent(store.theoretical_store_contribution_rate_pct) },
|
|
{ label: '人工成本', value: formatCurrency(store.wage_expense) },
|
|
{ label: '人工成本率', value: formatPercent(store.wage_rate_pct) },
|
|
{ label: '租金', value: formatCurrency(store.rent_expense) },
|
|
{ label: '租金率', value: formatPercent(store.rent_rate_pct) },
|
|
{ label: '水电燃气', value: formatCurrency(store.utility_expense) },
|
|
{ label: '水电率', value: formatPercent(store.utility_rate_pct) },
|
|
{ label: '宿舍费用', value: formatCurrency(store.dorm_expense) },
|
|
{ label: '外卖佣金', value: formatCurrency(store.delivery_commission_expense) },
|
|
{ label: '外卖收入', value: formatCurrency(store.delivery_received) },
|
|
{ label: '外卖占比', value: formatPercent(store.delivery_sales_share_pct) },
|
|
{ label: '面积(㎡)', value: formatNumber(store.area_sqm) },
|
|
{ label: '坪效', value: formatNumber(store.received_per_sqm) },
|
|
{ label: '账单数', value: formatNumber(store.bill_count) },
|
|
{ label: '租约到期', value: store.lease_expiry_date ? String(store.lease_expiry_date).substring(0, 10) : '-' },
|
|
]
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
|
<div className="bg-card rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-auto" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between p-4 border-b sticky top-0 bg-card z-10">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-bold">{store.sales_store_name}</h2>
|
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[store.priority] || ''}`}>{store.priority}</span>
|
|
<span className={`text-sm ${LOSS_TYPE_COLORS[store.loss_type] || ''}`}>{store.loss_type}</span>
|
|
</div>
|
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground text-xl">✕</button>
|
|
</div>
|
|
|
|
<div className="p-4 space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">关键指标</h3>
|
|
<div className="grid grid-cols-3 md:grid-cols-4 gap-2">
|
|
{metrics.map((m, i) => (
|
|
<div key={i} className="rounded border p-2">
|
|
<p className="text-xs text-muted-foreground">{m.label}</p>
|
|
<p className={`text-sm font-medium ${m.red ? 'text-red-600' : ''}`}>{m.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">诊断状态</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
|
{statusItems.map((s, i) => (
|
|
<div key={i} className="rounded border p-2">
|
|
<p className="text-xs text-muted-foreground">{s.label}</p>
|
|
<p className={`text-sm font-medium ${STATUS_COLORS[s.value] || ''}`}>{s.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">亏损原因分析</h3>
|
|
<div className="rounded border p-3 bg-red-50">
|
|
<p className="text-sm">{store.diagnosis_reasons}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">改进建议</h3>
|
|
<div className="rounded border p-3 bg-blue-50">
|
|
<p className="text-sm text-blue-700">{store.diagnosis_suggestions}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">建议动作</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{store.suggested_actions.split('、').map((action, i) => (
|
|
<span key={i} className="px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">{action}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function LossDiagnosisTab({ month }: { month: string }) {
|
|
const [page, setPage] = useState(1)
|
|
const [sort, setSort] = useState('actual_store_contribution')
|
|
const [filter, setFilter] = useState('')
|
|
const [order, setOrder] = useState<'asc' | 'desc'>('asc')
|
|
const [selectedStore, setSelectedStore] = useState<StoreDetail | null>(null)
|
|
const { data, isLoading } = useQuery({ queryKey: ['se/loss-diagnosis', month, page, sort, filter, order], queryFn: () => api.get(`/store-expense/loss-diagnosis?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&filter=${filter}&order=${order}&month=${month}`) })
|
|
|
|
if (isLoading) return <LoadingSpinner text="加载亏损门店诊断..." />
|
|
|
|
const rows = (data as any)?.data || []
|
|
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
|
|
|
const p0Count = meta.evalStats ? Number(meta.evalStats.p0_count) : rows.filter((r: any) => r.priority === 'P0').length
|
|
const p1Count = meta.evalStats ? Number(meta.evalStats.p1_count) : rows.filter((r: any) => r.priority === 'P1').length
|
|
const totalLoss = meta.evalStats ? Number(meta.evalStats.total_loss) : rows.reduce((sum: number, r: any) => sum + parseFloat(r.actual_store_contribution || 0), 0)
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<MetricCard title="亏损门店数" value={meta.total} unit="家" description="贡献利润 ≤ 0 的门店" />
|
|
<MetricCard title="P0 紧急" value={p0Count} unit="家" description="贡献率 < -20%" />
|
|
<MetricCard title="P1 整改" value={p1Count} unit="家" description="贡献率 -20% ~ -5%" />
|
|
<MetricCard title="亏损金额合计" value={totalLoss} format="currency" description="当前页亏损门店贡献利润之和" />
|
|
</div>
|
|
|
|
<CollapsibleSection title="亏损门店诊断" subtitle="点击门店名称查看详情">
|
|
<FilterableTable
|
|
data={rows}
|
|
serverSide
|
|
page={page}
|
|
onPageChange={setPage}
|
|
sort={sort}
|
|
onSortChange={setSort}
|
|
order={order}
|
|
onOrderChange={setOrder}
|
|
pageSize={PAGE_SIZE}
|
|
total={meta.total}
|
|
filter={filter}
|
|
onFilterChange={setFilter}
|
|
filterButtons={[
|
|
{ key: '', label: '全部' },
|
|
{ key: 'P0', label: 'P0 紧急' },
|
|
{ key: 'P1', label: 'P1 整改' },
|
|
{ key: 'P2', label: 'P2 观察' },
|
|
]}
|
|
sortOptions={[
|
|
{ key: 'actual_store_contribution', label: '贡献利润' },
|
|
{ key: 'actual_store_contribution_rate_pct', label: '贡献率' },
|
|
{ key: 'received', label: '实收' },
|
|
{ key: 'wage_rate_pct', label: '人工率' },
|
|
{ key: 'rent_rate_pct', label: '租金率' },
|
|
{ key: 'received_per_sqm', label: '坪效' },
|
|
]}
|
|
defaultSort="actual_store_contribution"
|
|
defaultOrder="asc"
|
|
onRowClick={(r) => setSelectedStore(r as StoreDetail)}
|
|
columns={[
|
|
{ key: 'sales_store_name', label: '门店', render: (r) => <button className="text-blue-600 hover:underline font-medium" onClick={() => setSelectedStore(r as StoreDetail)}>{r.sales_store_name}</button> },
|
|
{ key: 'priority', label: '优先级', render: (r) => <span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[r.priority] || ''}`}>{r.priority}</span> },
|
|
{ key: 'loss_type', label: '亏损类型', render: (r) => <span className={LOSS_TYPE_COLORS[r.loss_type] || ''}>{r.loss_type}</span> },
|
|
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
|
|
{ key: 'actual_store_contribution', label: '贡献利润', align: 'right', render: (r) => <span className="text-red-600 font-medium">{formatCurrency(r.actual_store_contribution)}</span> },
|
|
{ key: 'actual_store_contribution_rate_pct', label: '贡献率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.actual_store_contribution_rate_pct)}</span> },
|
|
{ key: 'wage_rate_pct', label: '人工率', align: 'right', render: (r) => <span className={parseFloat(r.wage_rate_pct) > 35 ? 'text-red-600' : ''}>{formatPercent(r.wage_rate_pct)}</span> },
|
|
{ key: 'rent_rate_pct', label: '租金率', align: 'right', render: (r) => <span className={parseFloat(r.rent_rate_pct) > 20 ? 'text-red-600' : ''}>{formatPercent(r.rent_rate_pct)}</span> },
|
|
{ key: 'received_per_sqm', label: '坪效', align: 'right', render: (r) => <span className={parseFloat(r.received_per_sqm) < 1000 ? 'text-red-600' : ''}>{formatNumber(r.received_per_sqm)}</span> },
|
|
{ key: 'diagnosis_reasons', label: '亏损原因', render: (r) => <span className="text-xs">{r.diagnosis_reasons}</span> },
|
|
{ key: 'suggested_actions', label: '建议动作', render: (r) => <span className="text-xs font-medium">{r.suggested_actions}</span> },
|
|
]}
|
|
/>
|
|
</CollapsibleSection>
|
|
|
|
<StoreDetailModal store={selectedStore} onClose={() => setSelectedStore(null)} />
|
|
</div>
|
|
)
|
|
}
|