fix: 修复成本/库存/对账多页面数据准确性与展示问题

- 库存周转: turnover_days零消耗时返回NULL并显示"无消耗",排序至末尾;
  门店名称三级回退(dim_store→mv_distribution_monthly→硬编码中央厨房)
- 成本管理: estimated_inventory_days为NULL时显示"-"而非"0.0天";
  补充成本率口径与指标关系说明
- 成本分析: 修复理论成本率计算(按单位售价而非单价),新增sales_quantity展示;
  区分菜品级(Excel)与门店级(倒挤)成本指标,说明差异原因
- 配送对账: 区分中央厨房/门店,分层展示耗用口径
- 告警/任务/目标管理: 新增详情弹窗组件,优化交互体验

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-15 17:37:06 +08:00
parent d373fb5f0c
commit 9b3a9cdb4b
19 changed files with 1605 additions and 298 deletions
@@ -0,0 +1,369 @@
/**
* 预警日志详情弹窗
*
* 点击预警日志行后弹出,展示完整预警信息、指标解释、问题诊断和整改建议,
* 并支持处理操作。
*/
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { X, AlertTriangle, CheckCircle, EyeOff, Stethoscope, Wrench } from 'lucide-react'
import api from '@/lib/api'
import { cn } from '@/lib/utils'
interface AlertLog {
id: number
rule_id: number | null
rule_name: string
store_code: string
store_name: string
metric_value: number | string
threshold: number | string
severity: string
push_status: string | null
handle_status: string
handle_comment: string | null
triggered_at: string
handled_at: string | null
}
interface AlertLogDetailDialogProps {
log: AlertLog
onClose: () => void
}
const SEVERITY_LABELS: Record<string, string> = { red: '红灯', yellow: '黄灯', green: '绿灯' }
const SEVERITY_COLORS: Record<string, string> = {
red: 'bg-red-100 text-red-700 border-red-200',
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
green: 'bg-green-100 text-green-700 border-green-200',
}
const STATUS_LABELS: Record<string, string> = { pending: '待处理', handling: '处理中', resolved: '已解决', ignored: '已忽略' }
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-gray-100 text-gray-600 border-gray-200',
handling: 'bg-blue-100 text-blue-700 border-blue-200',
resolved: 'bg-green-100 text-green-700 border-green-200',
ignored: 'bg-gray-100 text-gray-500 border-gray-200',
}
const PUSH_LABELS: Record<string, string> = {
pending: '待推送',
pushed: '已推送',
failed: '推送失败',
null: '未推送',
}
/** 按规则名称映射:指标解释、问题诊断、整改建议 */
const RULE_DETAILS: Record<string, {
metricLabel: string
metricUnit: string
thresholdDesc: string
problemDiagnosis: string[]
remediation: string[]
}> = {
'营收达成率红灯': {
metricLabel: '营收达成率',
metricUnit: '%',
thresholdDesc: '阈值 80%,低于此值表示门店日营收远低于目标,存在严重经营风险',
problemDiagnosis: [
'门店实际营收显著低于目标设定值,达成率不足 80%',
'可能原因:客流下降、客单价下滑、促销活动效果不佳、周边竞争加剧',
'需排查是目标设定过高还是实际经营下滑',
],
remediation: [
'店长复盘当日客流与客单价数据,定位下滑原因',
'区域经理 48 小时内到店诊断,提交改善方案',
'检查近期是否有竞争对手开业或促销活动影响',
'若目标设定不合理(如新店按成熟店标准),申请调整目标基准',
'连续 3 天低于 80% 升级为专项整改任务',
],
},
'营收达成率黄灯': {
metricLabel: '营收达成率',
metricUnit: '%',
thresholdDesc: '阈值 90%,低于此值表示门店营收未达预期,需关注',
problemDiagnosis: [
'门店营收达成率在 80%-90% 之间,未达预期但尚未严重恶化',
'可能原因:天气影响、节假日因素、短期客流波动',
],
remediation: [
'店长关注每日营收趋势,记录异常原因',
'区域经理周检时复盘,判断是否为偶发或趋势性下滑',
'若连续一周低于 90%,启动改善行动',
],
},
'毛利率异常': {
metricLabel: '理论毛利率',
metricUnit: '%',
thresholdDesc: '阈值 40%,低于此值表示食材成本占比过高,利润空间被压缩',
problemDiagnosis: [
'理论毛利率低于 40%,食材成本占营收比超过 60%',
'可能原因:菜品定价偏低、食材采购价上涨、BOM 配方不合理、高成本菜品占比过高',
],
remediation: [
'菜品利润优化师核查 BOM 配方与实际用量差异',
'供应链排查近期食材采购单价是否异常上涨',
'菜单工程分析:评估低毛利菜品占比,考虑优化或淘汰',
'门店检查是否存在超量出品或标准执行偏差',
],
},
'会员渗透率低': {
metricLabel: '会员账单占比',
metricUnit: '%',
thresholdDesc: '阈值 20%,低于此值表示会员消费占比过低,复购潜力未释放',
problemDiagnosis: [
'会员账单占比低于 20%,非会员消费为主,顾客粘性不足',
'可能原因:会员权益吸引力不够、店员未主动引导注册、周边客群流动性大',
],
remediation: [
'会员运营师分析该店会员画像与复购率',
'门店加强收银环节会员注册引导,设定周注册目标',
'评估会员权益是否需要针对该店客群调整',
'推送定向优惠券激活沉默会员',
],
},
'客诉率偏高': {
metricLabel: '客诉率',
metricUnit: '%',
thresholdDesc: '阈值 5%,超过此值表示顾客投诉比例过高,严重影响品牌口碑',
problemDiagnosis: [
'客诉率超过 5%,顾客投诉频次异常偏高',
'可能原因:出品质量不稳定、服务态度差、环境卫生问题、等待时间过长',
],
remediation: [
'店长 24 小时内复盘投诉内容,分类归因',
'出品问题:核查食材新鲜度与制作标准执行',
'服务问题:安排员工服务培训,加强现场管理',
'连续 2 周客诉率超 5% 升级为总部督办事项',
],
},
'巡检低分': {
metricLabel: '食安巡检得分',
metricUnit: '分',
thresholdDesc: '阈值 90 分,低于此值表示食品安全巡检不达标,存在合规风险',
problemDiagnosis: [
'食安巡检得分低于 90 分,食品安全管理存在漏洞',
'可能原因:食材储存不规范、加工区域卫生不达标、员工操作不规范',
],
remediation: [
'店长立即整改巡检扣分项,48 小时内提交整改报告',
'区域经理安排复查,确认整改到位',
'对扣分项涉及员工进行专项培训',
'连续 2 次低分暂停该店评级资格',
],
},
'离职率偏高': {
metricLabel: '月度离职率',
metricUnit: '%',
thresholdDesc: '阈值 15%,超过此值表示人员流失严重,影响门店稳定运营',
problemDiagnosis: [
'月度离职率超过 15%,人员流失过快',
'可能原因:薪酬竞争力不足、管理风格问题、工作强度过大、晋升通道不清晰',
],
remediation: [
'区域经理访谈离职员工,收集真实离职原因',
'人力部门评估该店薪酬水平与市场对标',
'店长管理风格评估,必要时安排管理培训',
'制定留人方案:调薪、排班优化、职业发展规划',
],
},
}
/** 通用兜底:未匹配到规则名时使用 */
const DEFAULT_DETAIL = {
metricLabel: '监控指标',
metricUnit: '',
thresholdDesc: '指标超出预设阈值范围',
problemDiagnosis: ['指标异常触发预警,需进一步排查具体原因'],
remediation: ['请相关负责人核查指标数据并制定改善方案'],
}
function formatTime(s: string | null): string {
if (!s) return '-'
return s.substring(0, 19).replace('T', ' ')
}
export function AlertLogDetailDialog({ log, onClose }: AlertLogDetailDialogProps) {
const queryClient = useQueryClient()
const [comment, setComment] = useState(log.handle_comment || '')
const handleMutation = useMutation({
mutationFn: (data: { id: number; handle_status: string; handle_comment?: string }) =>
api.patch(`/alert/logs/${data.id}`, { handle_status: data.handle_status, handle_comment: data.handle_comment }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['alert/logs'] })
queryClient.invalidateQueries({ queryKey: ['alert/overview'] })
onClose()
},
})
const isPending = log.handle_status === 'pending'
const detail = RULE_DETAILS[log.rule_name] || DEFAULT_DETAIL
const metricValue = Number(log.metric_value || 0)
const threshold = Number(log.threshold || 0)
const deviation = metricValue - threshold
const isLowerBetter = log.rule_name.includes('达成率') || log.rule_name.includes('毛利率') || log.rule_name.includes('渗透率') || log.rule_name.includes('巡检')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={onClose}>
<div
className="max-h-[88vh] w-[640px] max-w-[95vw] overflow-y-auto rounded-lg border bg-card p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
<div className="mb-4 flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', SEVERITY_COLORS[log.severity] || 'bg-gray-100')}>
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<h2 className="text-base font-bold">{log.rule_name}</h2>
<p className="text-xs text-muted-foreground"> #{log.id} · {log.store_name || '-'}</p>
</div>
</div>
<button onClick={onClose} className="rounded-md p-1 hover:bg-muted">
<X className="h-5 w-5" />
</button>
</div>
{/* 状态标签 */}
<div className="mb-4 flex flex-wrap gap-2">
<span className={cn('rounded border px-2 py-0.5 text-xs font-medium', SEVERITY_COLORS[log.severity] || '')}>
{SEVERITY_LABELS[log.severity] || log.severity}
</span>
<span className={cn('rounded border px-2 py-0.5 text-xs font-medium', STATUS_COLORS[log.handle_status] || '')}>
{STATUS_LABELS[log.handle_status] || log.handle_status}
</span>
<span className="rounded border border-gray-200 bg-gray-50 px-2 py-0.5 text-xs font-medium text-gray-600">
{PUSH_LABELS[log.push_status || 'null'] || log.push_status || '-'}
</span>
</div>
{/* 指标对比 */}
<section className="mb-4">
<h3 className="mb-2 text-sm font-bold"></h3>
<div className="grid grid-cols-3 gap-2 rounded-lg border bg-muted/20 p-3">
<div className="text-center">
<p className="text-[10px] text-muted-foreground">{detail.metricLabel}</p>
<p className={cn('text-lg font-bold', isLowerBetter ? 'text-red-600' : 'text-green-600')}>
{metricValue.toFixed(2)}{detail.metricUnit}
</p>
</div>
<div className="text-center">
<p className="text-[10px] text-muted-foreground"></p>
<p className="text-lg font-bold text-muted-foreground">
{threshold.toFixed(2)}{detail.metricUnit}
</p>
</div>
<div className="text-center">
<p className="text-[10px] text-muted-foreground"></p>
<p className={cn('text-lg font-bold', deviation < 0 ? 'text-red-600' : 'text-amber-600')}>
{deviation > 0 ? '+' : ''}{deviation.toFixed(2)}{detail.metricUnit}
</p>
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">{detail.thresholdDesc}</p>
</section>
{/* 基本信息字段 */}
<section className="mb-4">
<h3 className="mb-2 text-sm font-bold"></h3>
<div className="grid grid-cols-2 gap-2 rounded-lg border bg-muted/20 p-3 text-sm sm:grid-cols-3">
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className="font-medium">{log.store_name || '-'}</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground">ID</p>
<p className="font-medium">{log.rule_id || '-'}</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className="font-medium">{formatTime(log.triggered_at)}</p>
</div>
<div>
<p className="text-[10px] text-muted-foreground"></p>
<p className="font-medium">{formatTime(log.handled_at)}</p>
</div>
</div>
</section>
{/* 问题诊断 */}
<section className="mb-4">
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-bold">
<Stethoscope className="h-4 w-4 text-amber-600" />
</h3>
<div className="rounded-lg border border-amber-200 bg-amber-50/40 p-3">
<ul className="space-y-1.5">
{detail.problemDiagnosis.map((d, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed text-foreground">
<span className="mt-0.5 shrink-0 text-amber-600"></span>
<span>{d}</span>
</li>
))}
</ul>
</div>
</section>
{/* 整改建议 */}
<section className="mb-4">
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-bold">
<Wrench className="h-4 w-4 text-blue-600" />
</h3>
<div className="rounded-lg border border-blue-200 bg-blue-50/40 p-3">
<ol className="space-y-1.5">
{detail.remediation.map((r, i) => (
<li key={i} className="flex gap-2 text-xs leading-relaxed text-foreground">
<span className="mt-0.5 shrink-0 font-bold text-blue-600">{i + 1}.</span>
<span>{r}</span>
</li>
))}
</ol>
</div>
</section>
{/* 处理备注 */}
<section className="mb-4">
<h3 className="mb-1 text-sm font-bold"></h3>
<textarea
className="w-full rounded-lg border p-2 text-sm"
rows={2}
placeholder="输入处理说明..."
value={comment}
onChange={(e) => setComment(e.target.value)}
disabled={!isPending}
/>
</section>
{/* 底部操作 */}
{isPending ? (
<div className="mt-4 flex gap-2 border-t pt-4">
<button
className="flex flex-1 items-center justify-center gap-1.5 rounded-md bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
onClick={() => handleMutation.mutate({ id: log.id, handle_status: 'resolved', handle_comment: comment || '已处理' })}
disabled={handleMutation.isPending}
>
<CheckCircle className="h-4 w-4" />
{handleMutation.isPending ? '处理中…' : '标记为已解决'}
</button>
<button
className="flex flex-1 items-center justify-center gap-1.5 rounded-md border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-muted disabled:opacity-50"
onClick={() => handleMutation.mutate({ id: log.id, handle_status: 'ignored', handle_comment: comment || '已忽略' })}
disabled={handleMutation.isPending}
>
<EyeOff className="h-4 w-4" />
</button>
</div>
) : (
<div className="mt-4 border-t pt-4">
<p className="text-xs text-muted-foreground">
{STATUS_LABELS[log.handle_status] || log.handle_status}
{log.handle_comment && <>{log.handle_comment}</>}
</p>
</div>
)}
</div>
</div>
)
}