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
+239
View File
@@ -0,0 +1,239 @@
/**
* AI 员工详情弹窗
*
* 点击 AgentCard 后弹出,展示完整能力描述、数据来源 API、今日交付明细列表,
* 并提供跳转到对应业务页面的按钮。
*/
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { X, ExternalLink, Bot } from 'lucide-react'
import api from '@/lib/api'
import { cn, formatCurrency, formatNumber } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import type { AgentConfig } from '@/data/agents'
interface AgentDetailDialogProps {
agent: AgentConfig
deliveries: number | null
onClose: () => void
}
const AUTONOMY_LABELS: Record<string, string> = {
L1: 'L1 人工审批',
L2: 'L2 受控执行',
L3: 'L3 全自动',
}
const AUTONOMY_COLORS: Record<string, string> = {
L1: 'bg-red-50 text-red-600 border border-red-200',
L2: 'bg-amber-50 text-amber-600 border border-amber-200',
L3: 'bg-green-50 text-green-600 border border-green-200',
}
const STATUS_DOT: Record<string, string> = {
'运行中': 'bg-green-500',
'待审批': 'bg-amber-500',
'受控': 'bg-blue-500',
}
/** 后端统计字段名 → 中文标签映射(用于对象型 API 响应的统计卡片展示) */
const FIELD_LABELS: Record<string, string> = {
// 门店/成本总览
total_stores: '门店总数',
profitable_stores: '盈利门店',
loss_stores: '亏损门店',
no_expense_stores: '缺费用数据门店',
total_bills: '账单总数',
total_received: '实收合计',
total_consumption: '消费合计',
total_discount: '优惠合计',
total_expense: '费用合计',
total_wage: '人工合计',
total_rent: '房租合计',
total_utility: '水电合计',
total_dorm: '宿舍合计',
total_commission: '佣金合计',
total_card_fee: '刷卡手续费',
total_repair: '维修保洁',
total_food_cost: '食材成本',
total_theoretical_cost: '理论成本',
total_contribution: '贡献利润',
total_theoretical_contribution: '理论贡献',
theoretical_net_profit: '理论净利润',
actual_net_profit: '实际净利润',
total_area: '总面积',
overall_expense_rate_pct: '费用率%',
overall_contribution_rate_pct: '贡献率%',
theoretical_net_margin_pct: '理论净利率%',
actual_net_margin_pct: '实际净利率%',
theoretical_food_cost_rate_pct: '理论食材成本率%',
actual_food_cost_rate_pct: '实际食材成本率%',
overall_wage_rate_pct: '人工费率%',
overall_rent_rate_pct: '房租费率%',
overall_utility_rate_pct: '水电费率%',
// 成本分析
red_count: '红色严重超耗',
orange_count: '橙色明显超耗',
green_count: '绿色基本正常',
gray_count: '灰色口径异常',
total_variance: '差异合计',
// 告警总览
total_alerts: '告警总数',
red_alerts: '红色告警',
yellow_alerts: '黄色告警',
pending_alerts: '待处理告警',
resolved_alerts: '已处理告警',
today_alerts: '今日告警',
}
/** 数值字段格式化 */
function formatCell(value: any): string {
if (value === null || value === undefined || value === '') return '-'
const num = Number(value)
if (!isNaN(num) && typeof value !== 'string' && value !== null) {
return formatNumber(num)
}
return String(value)
}
export function AgentDetailDialog({ agent, deliveries, onClose }: AgentDetailDialogProps) {
const navigate = useNavigate()
// 获取今日交付明细原始数据
const { data: rawData, isLoading } = useQuery({
queryKey: ['agent-delivery-detail', agent.code],
queryFn: () => {
// /overview/profit-opportunity 需要 month 参数(与 AgentsPage chiefQuery 一致)
const params = agent.deliveryApi === '/overview/profit-opportunity' ? { month: '2026-04' } : undefined
return api.get(agent.deliveryApi || '', { params })
},
enabled: !!agent.deliveryApi,
staleTime: 5 * 60 * 1000,
})
// 从响应中提取明细数据
const root = rawData as any
const dataField = root?.data
// 支持 items 嵌套(如 profit-opportunity 返回 { items: [...] }
const items: any[] = Array.isArray(dataField) ? dataField : Array.isArray(dataField?.items) ? dataField.items : []
const stats: Record<string, any> | null = !Array.isArray(dataField) && !dataField?.items && dataField && typeof dataField === 'object' ? dataField : null
const top10 = items.slice(0, 10)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={onClose}>
<div
className="max-h-[85vh] w-[680px] 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="flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50">
<Bot className="h-6 w-6 text-blue-600" />
</div>
<div>
<div className="flex items-center gap-2">
<small className="text-[10px] text-muted-foreground">{agent.domain}</small>
<span className={cn('h-2 w-2 rounded-full', STATUS_DOT[agent.status] || 'bg-gray-400')} />
<small className="text-[10px] text-muted-foreground">{agent.status}</small>
</div>
<h2 className="text-lg font-bold">{agent.name}</h2>
<p className="text-sm text-muted-foreground">{agent.roleTarget}</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 px-2 py-0.5 text-xs font-medium', AUTONOMY_COLORS[agent.autonomyLevel])}>
{AUTONOMY_LABELS[agent.autonomyLevel]}
</span>
<span className="rounded border px-2 py-0.5 text-xs font-medium text-muted-foreground">
<b className="text-foreground">{deliveries ?? '…'}</b>
</span>
</div>
{/* 能力描述 */}
<section className="mb-4">
<h3 className="mb-1 text-sm font-bold"> SBrainCO </h3>
<div className="rounded-lg border bg-muted/20 p-3">
<p className="text-sm leading-relaxed text-muted-foreground">{agent.capability}</p>
</div>
</section>
{/* 数据来源 API */}
{agent.deliveryApi && (
<section className="mb-4">
<h3 className="mb-1 text-sm font-bold"></h3>
<div className="rounded-lg border bg-muted/20 p-3 font-mono text-xs">
<p><span className="text-muted-foreground">API:</span> GET {agent.deliveryApi}</p>
<p><span className="text-muted-foreground">:</span> {agent.deliveryField}</p>
</div>
</section>
)}
{/* 今日交付明细 */}
{agent.deliveryApi && (
<section className="mb-4">
<h3 className="mb-2 text-sm font-bold">{items.length > 10 ? `(前 10 条 / 共 ${items.length} 条)` : stats ? '' : `(共 ${items.length} 条)`}</h3>
{isLoading ? (
<LoadingSpinner />
) : top10.length > 0 && agent.deliveryColumns ? (
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-xs">
<thead className="bg-muted/30 text-muted-foreground">
<tr>
{agent.deliveryColumns.map((col) => (
<th key={col.field} className="border-b px-3 py-2 text-left font-medium">{col.label}</th>
))}
</tr>
</thead>
<tbody>
{top10.map((row, i) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/20">
{agent.deliveryColumns!.map((col) => (
<td key={col.field} className="px-3 py-2">{formatCell(row[col.field])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : stats ? (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Object.entries(stats).slice(0, 12).map(([key, val]) => (
<div key={key} className="rounded-lg border p-2">
<p className="text-[10px] text-muted-foreground">{FIELD_LABELS[key] || key}</p>
<p className="text-sm font-bold">{formatCell(val)}</p>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground"></p>
)}
</section>
)}
{/* 底部操作 */}
{agent.businessPage && (
<div className="flex justify-end border-t pt-4">
<button
className="flex items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={() => {
onClose()
navigate(agent.businessPage!)
}}
>
<ExternalLink className="h-4 w-4" />
</button>
</div>
)}
</div>
</div>
)
}
@@ -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>
)
}
@@ -32,15 +32,22 @@ export function OverviewTab({ month }: { month: string }) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="总销售额" value={ov.total_sales} format="currency" />
<MetricCard title="理论成本合计" value={ov.total_theo_cost} format="currency" />
<MetricCard title="实际成本合计" value={ov.total_actual_cost} format="currency" />
<MetricCard title="成本差异" value={ov.total_variance} format="currency" description="实际成本 - 理论成本" />
<MetricCard title="平均理论毛利率" value={ov.avg_theo_margin} format="percent" />
<MetricCard title="平均实际毛利率" value={ov.avg_actual_margin} format="percent" />
<MetricCard title="总销售额" value={ov.total_sales} format="currency" description={`与总部驾驶舱同源(bill_fact)${ov.total_stores || 94}家门店实收合计`} />
<MetricCard title="消费额(含税)" value={ov.total_consumption} format="currency" description="账单消费额(含税),总部驾驶舱同口径" />
<MetricCard title="理论毛利率(门店)" value={ov.store_avg_theo_margin} format="percent" description="门店倒挤口径: 1-理论成本率(排除口径异常)" />
<MetricCard title="实际毛利率(门店)" value={ov.store_avg_actual_margin} format="percent" description="门店倒挤口径: 1-实际食材成本率(排除口径异常)" status={Number(ov.store_avg_actual_margin) < Number(ov.store_avg_theo_margin) ? 'bad' : undefined} />
<MetricCard title="理论毛利率(菜品)" value={ov.avg_theo_margin} format="percent" description="菜品Excel口径,仅含物料损耗差异" />
<MetricCard title="实际毛利率(菜品)" value={ov.avg_actual_margin} format="percent" description="菜品Excel口径,未含库存盘亏/退损" />
<MetricCard title="低毛利菜品数" value={ov.low_margin_dishes} format="number" description="理论毛利率 < 50%" />
<MetricCard title="超理论菜品数" value={ov.over_cost_dishes} format="number" description="实际成本超过理论成本" />
</div>
<div className="rounded-lg border border-orange-200 bg-orange-50/50 p-3 text-sm text-orange-800">
<strong></strong><br/>
<strong>()</strong> {ov.store_avg_theo_margin}% vs {ov.store_avg_actual_margin}% {(Number(ov.store_avg_theo_margin) - Number(ov.store_avg_actual_margin)).toFixed(2)}%<br/>
<strong>(Excel)</strong> {ov.avg_theo_margin}% vs {ov.avg_actual_margin}% {(Number(ov.avg_theo_margin) - Number(ov.avg_actual_margin)).toFixed(2)}%<br/>
() {formatCurrency(ov.store_total_actual_cost)} {formatCurrency(ov.total_actual_cost)} {formatCurrency(Number(ov.store_total_actual_cost) - Number(ov.total_actual_cost))}
(/)<strong>退</strong>
</div>
<CollapsibleSection title="品类成本对比" subtitle="按一级品类汇总理论vs实际毛利率">
<ResponsiveContainer width="100%" height={300}>
@@ -144,6 +144,7 @@ export function ProfitabilityTab({ month }: { month: string }) {
{ key: 'actual_margin', label: '实际毛利率' },
{ key: 'sales_amount', label: '销售额' },
{ key: 'price', label: '售价' },
{ key: 'theo_cost_per_unit', label: '单位理论成本' },
]}
defaultSort="theo_margin"
defaultOrder="asc"
@@ -153,10 +154,14 @@ export function ProfitabilityTab({ month }: { month: string }) {
{ key: 'dish_name', label: '菜品' },
{ key: 'category_level1', label: '品类' },
{ key: 'price', label: '售价', align: 'right', render: (r) => formatCurrency(r.price) },
{ key: 'theo_cost', label: '理论成本', align: 'right', render: (r) => formatCurrency(r.theo_cost) },
{ key: 'theo_cost_rate', label: '理论成本率', align: 'right', render: (r) => formatPercent(r.theo_cost_rate) },
{ key: 'theo_cost_per_unit', label: '单位理论成本', align: 'right', render: (r) => formatCurrency(r.theo_cost_per_unit) },
{ key: 'theo_cost_rate', label: '理论成本率', align: 'right', render: (r) => {
const v = Number(r.theo_cost_rate || 0)
return <span className={v > 100 ? 'font-medium text-red-600' : v > 70 ? 'text-orange-600' : ''}>{formatPercent(v)}</span>
}},
{ key: 'theo_margin', label: '理论毛利率', align: 'right', render: (r) => <span className="text-red-600">{formatPercent(r.theo_margin)}</span> },
{ key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => formatPercent(r.actual_margin) },
{ key: 'sales_quantity', label: '销量', align: 'right', render: (r) => formatNumber(r.sales_quantity) },
{ key: 'sales_amount', label: '销售额', align: 'right', render: (r) => formatCurrency(r.sales_amount) },
]}
/>
+61 -11
View File
@@ -24,6 +24,10 @@ export interface AgentConfig {
deliveryApi?: string
/** 交付数提取路径(从 API 响应中提取数字) */
deliveryField?: string
/** 对应业务页面路径(详情弹窗"查看业务模块"按钮跳转) */
businessPage?: string
/** 今日交付明细列表展示列(仅数组型返回数据适用) */
deliveryColumns?: { field: string; label: string }[]
}
/** 经营参谋长(首席 AI 员工) */
@@ -37,6 +41,13 @@ export const chiefOfStaff: AgentConfig = {
status: '运行中',
deliveryApi: '/overview/profit-opportunity',
deliveryField: 'items.length',
businessPage: '/decisions',
deliveryColumns: [
{ field: 'category', label: '机会类别' },
{ field: 'opportunity', label: '预计机会' },
{ field: 'confidence', label: '置信度' },
{ field: 'owner', label: '责任方' },
],
}
/** 9 个 AI 员工(不含经营参谋长) */
@@ -50,7 +61,13 @@ export const agents: AgentConfig[] = [
capability: '风险评级 + 利润机会池 + 门店深度诊断',
status: '运行中',
deliveryApi: '/stores/risk',
deliveryField: 'data.length',
deliveryField: 'length',
businessPage: '/risk',
deliveryColumns: [
{ field: 'store_name', label: '门店' },
{ field: 'risk_level', label: '风险等级' },
{ field: 'received', label: '实收' },
],
},
{
code: 'dish_profit_optimizer',
@@ -61,7 +78,8 @@ export const agents: AgentConfig[] = [
capability: '成本分析(9个Tab+ 菜单工程 + BOM穿透',
status: '运行中',
deliveryApi: '/cost-analysis/store-overview',
deliveryField: 'data.stores.length',
deliveryField: 'total_stores',
businessPage: '/cost-analysis',
},
{
code: 'expense_auditor',
@@ -72,7 +90,8 @@ export const agents: AgentConfig[] = [
capability: '门店费用分析 + 人工水电费率监控',
status: '运行中',
deliveryApi: '/store-expense/overview',
deliveryField: 'data.stores.length',
deliveryField: 'total_stores',
businessPage: '/store-expense',
},
{
code: 'demand_forecaster',
@@ -83,7 +102,13 @@ export const agents: AgentConfig[] = [
capability: 'AI 预测模型 + 历史趋势分析',
status: '运行中',
deliveryApi: '/intelligence/ai/forecasts',
deliveryField: 'data.length',
deliveryField: 'length',
businessPage: '/intelligence',
deliveryColumns: [
{ field: 'forecast_type', label: '预测类型' },
{ field: 'target_date', label: '目标日期' },
{ field: 'predicted_value', label: '预测值' },
],
},
{
code: 'smart_replenisher',
@@ -93,8 +118,13 @@ export const agents: AgentConfig[] = [
autonomyLevel: 'L2',
capability: 'MRP 物料需求计划 + 库存周转分析',
status: '受控',
deliveryApi: '/intelligence/chain/mrp',
deliveryField: 'data.length',
deliveryApi: '/intelligence/chain/production-sales',
deliveryField: 'length',
businessPage: '/intelligence',
deliveryColumns: [
{ field: 'sku_name', label: '菜品' },
{ field: 'total_sales', label: '销量' },
],
},
{
code: 'operations_supervisor',
@@ -105,7 +135,14 @@ export const agents: AgentConfig[] = [
capability: '任务自动生成 + 周检 + 月度验收 + 闭环健康度',
status: '运行中',
deliveryApi: '/tasks',
deliveryField: 'meta.total',
deliveryField: 'meta.total', // meta. 前缀由 extractDelivery 从响应根取值
businessPage: '/tasks',
deliveryColumns: [
{ field: 'store_name', label: '门店' },
{ field: 'priority', label: '优先级' },
{ field: 'status', label: '状态' },
{ field: 'deadline', label: '截止日' },
],
},
{
code: 'compliance_auditor',
@@ -116,7 +153,8 @@ export const agents: AgentConfig[] = [
capability: '告警规则 + 告警日志 + 风险内控',
status: '运行中',
deliveryApi: '/alert/overview',
deliveryField: 'data.total_alerts',
deliveryField: 'total_alerts',
businessPage: '/alert',
},
{
code: 'member_operator',
@@ -126,8 +164,14 @@ export const agents: AgentConfig[] = [
autonomyLevel: 'L2',
capability: '会员复购分析 + 会员 LTV + 促销增量',
status: '待审批',
deliveryApi: '/member',
deliveryField: 'data.length',
deliveryApi: '/member/repeat',
deliveryField: 'length',
businessPage: '/member',
deliveryColumns: [
{ field: 'store_name', label: '门店' },
{ field: 'repeat_rate_pct', label: '复购率%' },
{ field: 'bill_count', label: '账单数' },
],
},
{
code: 'knowledge_manager',
@@ -138,6 +182,12 @@ export const agents: AgentConfig[] = [
capability: '标杆实践管理 + 经验推广 + 知识沉淀',
status: '运行中',
deliveryApi: '/tasks/practices',
deliveryField: 'data.length',
deliveryField: 'length',
businessPage: '/knowledge',
deliveryColumns: [
{ field: 'practice_module', label: '模块' },
{ field: 'benchmark_store_name', label: '标杆门店' },
{ field: 'status', label: '状态' },
],
},
]
+45 -7
View File
@@ -4,14 +4,18 @@
* 展示玄谋智脑的 10 个 AI 员工角色,包括经营参谋长(首席)和 9 个专业员工。
* 今日交付数从对应 API 动态获取。
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { BrainCircuit } from 'lucide-react'
import api from '@/lib/api'
import { AgentCard } from '@/components/AgentCard'
import { AgentDetailDialog } from '@/components/AgentDetailDialog'
import { chiefOfStaff, agents, type AgentConfig } from '@/data/agents'
import { cn } from '@/lib/utils'
export function AgentsPage() {
const [selected, setSelected] = useState<AgentConfig | null>(null)
// 批量获取各 AI 员工的今日交付数
const queries = agents.map((agent, idx) =>
useQuery({
@@ -29,15 +33,28 @@ export function AgentsPage() {
staleTime: 5 * 60 * 1000,
})
/** 从 API 响应中提取交付数 */
/** 从 API 响应中提取交付数
* field 以 `meta.` 开头时从响应根(含 data/meta)取值,否则从 data 字段取值。
* 支持 a.b.c 路径,末段为 length 时取数组长度。
*/
function extractDelivery(queryResult: any, field: string): number | null {
if (!queryResult) return null
const data = (queryResult as any)?.data
if (!data) return null
// 支持 a.b.c 路径
const root = queryResult as any
const parts = field.split('.')
let val: any = data
for (const p of parts) {
// meta. 前缀:从响应根开始遍历(覆盖分页总数等不在 data 内的字段)
let val: any
if (parts[0] === 'meta') {
val = root
} else {
val = root?.data
if (!val) return null
}
for (let i = 0; i < parts.length; i++) {
const p = parts[i]
// 末段为 length 且当前值为数组,直接取长度
if (p === 'length' && i === parts.length - 1 && Array.isArray(val)) {
return val.length
}
val = val?.[p]
if (val === undefined) return null
}
@@ -63,7 +80,10 @@ export function AgentsPage() {
</div>
{/* 经营参谋长(首席卡片) */}
<div className="rounded-lg border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-white p-4">
<div
className="cursor-pointer rounded-lg border-2 border-blue-200 bg-gradient-to-r from-blue-50 to-white p-4 transition-shadow hover:shadow-md"
onClick={() => setSelected(chiefOfStaff)}
>
<div className="flex items-center gap-4">
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-blue-600">
<BrainCircuit className="h-7 w-7 text-white" />
@@ -92,6 +112,7 @@ export function AgentsPage() {
? extractDelivery(queries[idx].data, agent.deliveryField || '') ?? 0
: null
}
onClick={() => setSelected(agent)}
/>
))}
</div>
@@ -106,6 +127,23 @@ export function AgentsPage() {
</p>
<p className="mt-1"> AI API </p>
</div>
{/* 详情弹窗 */}
{selected && (
<AgentDetailDialog
agent={selected}
deliveries={
selected.code === chiefOfStaff.code
? chiefDeliveries
: (() => {
const idx = agents.findIndex((a) => a.code === selected.code)
if (idx < 0 || !queries[idx]?.data) return null
return extractDelivery(queries[idx].data, selected.deliveryField || '') ?? 0
})()
}
onClose={() => setSelected(null)}
/>
)}
</div>
)
}
+45 -7
View File
@@ -5,11 +5,13 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Tabs } from '@/components/Tabs'
import { AlertLogDetailDialog } from '@/components/AlertLogDetailDialog'
import { formatNumber } from '@/lib/utils'
import { useState } from 'react'
export function AlertManagementPage() {
const [tab, setTab] = useState('logs')
const [selectedLog, setSelectedLog] = useState<any | null>(null)
const queryClient = useQueryClient()
const { data: overviewData, isLoading: overviewLoading } = useQuery({
@@ -79,8 +81,13 @@ export function AlertManagementPage() {
{logsLoading ? <LoadingSpinner text="加载预警日志..." /> : (
<FilterableTable
data={logs}
onRowClick={(r) => setSelectedLog(r)}
filterKey="severity"
filterLabel="全部级别"
filterOptions={[
{ value: 'red', label: '红灯' },
{ value: 'yellow', label: '黄灯' },
]}
sortOptions={[
{ key: 'triggered_at', label: '触发时间' },
{ key: 'metric_value', label: '指标值' },
@@ -91,7 +98,6 @@ export function AlertManagementPage() {
columns={[
{ key: 'triggered_at', label: '触发时间', render: (r) => (r.triggered_at || '').substring(0, 19).replace('T', ' ') },
{ key: 'rule_name', label: '规则名称' },
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称' },
{ key: 'metric_value', label: '指标值', align: 'right', render: (r) => Number(r.metric_value || 0).toFixed(2) },
{ key: 'threshold', label: '阈值', align: 'right', render: (r) => Number(r.threshold || 0).toFixed(2) },
@@ -107,14 +113,18 @@ export function AlertManagementPage() {
}},
{ key: 'actions', label: '操作', align: 'center', render: (r) => (
<div className="flex gap-1">
<button
onClick={(e) => { e.stopPropagation(); setSelectedLog(r) }}
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 hover:bg-blue-200"
></button>
{r.handle_status === 'pending' && (
<>
<button
onClick={() => handleMutation.mutate({ id: r.id, handle_status: 'resolved', handle_comment: '已处理' })}
onClick={(e) => { e.stopPropagation(); handleMutation.mutate({ id: r.id, handle_status: 'resolved', handle_comment: '已处理' }) }}
className="rounded bg-green-100 px-2 py-0.5 text-xs text-green-700 hover:bg-green-200"
></button>
<button
onClick={() => handleMutation.mutate({ id: r.id, handle_status: 'ignored', handle_comment: '已忽略' })}
onClick={(e) => { e.stopPropagation(); handleMutation.mutate({ id: r.id, handle_status: 'ignored', handle_comment: '已忽略' }) }}
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-200"
></button>
</>
@@ -142,16 +152,36 @@ export function AlertManagementPage() {
columns={[
{ key: 'rule_name', label: '规则名称' },
{ key: 'description', label: '描述' },
{ key: 'metric', label: '监控指标' },
{ key: 'operator', label: '比较符', align: 'center' },
{ key: 'metric', label: '监控指标', render: (r) => {
const labels: Record<string, string> = {
revenue_achievement_pct: '营收达成率',
theoretical_margin_pct: '理论毛利率',
member_bill_share_pct: '会员渗透率',
complaint_rate: '客诉率',
inspection_score: '巡检得分',
turnover_rate: '离职率',
}
return labels[r.metric] || r.metric
}},
{ key: 'operator', label: '比较符', align: 'center', render: (r) => {
const labels: Record<string, string> = { '<': '', '<=': '≤', '>': '', '>=': '≥', '=': '' }
return labels[r.operator] || r.operator
}},
{ key: 'threshold', label: '阈值', align: 'right' },
{ key: 'severity', label: '级别', align: 'center', render: (r) => {
const colors: Record<string, string> = { red: 'bg-red-100 text-red-700', yellow: 'bg-yellow-100 text-yellow-700' }
const labels: Record<string, string> = { red: '红灯', yellow: '黄灯' }
return <span className={`rounded px-2 py-0.5 text-xs ${colors[r.severity] || ''}`}>{labels[r.severity] || r.severity}</span>
}},
{ key: 'check_interval', label: '检查频率', align: 'center' },
{ key: 'push_targets', label: '推送对象' },
{ key: 'check_interval', label: '检查频率', align: 'center', render: (r) => {
const labels: Record<string, string> = { hourly: '每小时', daily: '每日', weekly: '每周', monthly: '每月', realtime: '实时' }
return labels[r.check_interval] || r.check_interval || '-'
}},
{ key: 'push_targets', label: '推送对象', render: (r) => {
if (!r.push_targets) return '-'
const labels: Record<string, string> = { store: '门店', regional: '区域', hq: '总部', platform: '平台' }
return r.push_targets.split(',').map((t: string) => labels[t.trim()] || t.trim()).join('、')
}},
{ key: 'is_enabled', label: '状态', align: 'center', render: (r) => (
<button
onClick={() => toggleRuleMutation.mutate({ id: r.id, is_enabled: !r.is_enabled })}
@@ -165,6 +195,14 @@ export function AlertManagementPage() {
)}
</CollapsibleSection>
)}
{/* 日志详情弹窗 */}
{selectedLog && (
<AlertLogDetailDialog
log={selectedLog}
onClose={() => setSelectedLog(null)}
/>
)}
</div>
)
}
+17 -4
View File
@@ -13,12 +13,14 @@ 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 (pct == null || isNaN(pct)) return 'good'
if (Math.abs(pct) <= 3) return 'good'
if (Math.abs(pct) <= 5) return 'warn'
return 'bad'
}
function varianceText(pct: number): string {
if (pct == null || isNaN(pct)) return '-'
if (pct > 0) return `+${pct.toFixed(2)}%`
return `${pct.toFixed(2)}%`
}
@@ -81,13 +83,24 @@ export function CentralKitchenPage() {
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-4">
<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={summary.theoretical_cost} format="currency" description="配方理论用量×单价" />
<MetricCard title="实际材料成本" value={summary.material_actual_cost} format="currency" status={varianceColor(summary.efficiency_variance_pct)} description="实际领料金额" />
<MetricCard title="完整制造成本" value={summary.full_manufacturing_cost} format="currency" description="实际材料+制造费用" />
<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>
{/* 指标关系说明 */}
<div className="rounded-lg border bg-blue-50/40 p-3 text-sm text-blue-800">
<strong></strong><br/>
<strong></strong> + <br/>
<strong></strong> = + {formatCurrency(summary.material_actual_cost)} + {formatCurrency(summary.allocated_manufacturing_cost)} = {formatCurrency(summary.full_manufacturing_cost)}<br/>
<strong></strong> = + // + {formatCurrency(summary.allocated_manufacturing_cost)}<br/>
<strong></strong> = ( - ) / &lt; <br/>
<strong></strong> = - <br/>
<strong></strong> {formatCurrency(summary.full_manufacturing_cost)}157"中央厨房加工投入"++
</div>
{/* 成本对账瀑布 */}
<CollapsibleSection title="成本对账瀑布" subtitle="理论→标准→实际→+制造费用→全成本→入库价值→制造毛利">
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-3">
@@ -172,7 +185,7 @@ export function CentralKitchenPage() {
<div>: {p.category}</div>
<div>: {formatCurrency(p.x)}</div>
<div>: {formatCurrency(p.y)}</div>
<div>: {p.z.toFixed(2)}%</div>
<div>: {p.z != null ? p.z.toFixed(2) : '-'}%</div>
</div>
)
}} />
+12 -5
View File
@@ -84,10 +84,16 @@ export function CostPage() {
{/* 概览指标 */}
<CollapsibleSection title="成本概览" subtitle="全门店成本效率汇总">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="平均理论成本率" value={avgTheoreticalRate} format="percent" description="按标准BOM计算的理论食材成本占实收比例(排除口径异常门店" />
<MetricCard title="平均实际成本率" value={avgActualRate} format="percent" description="盘点倒挤的实际食材成本占实收比例(排除口径异常门店" />
<MetricCard title="高偏差门店(>20%)" value={highVarianceCount} format="number" description="实际成本超理论20%以上的门店数(排除口径异常)" />
<MetricCard title="口径异常门店" value={abnormalRows.length} format="number" description="理论成本数据缺失或口径异常,不纳入成本率统计" />
<MetricCard title="平均理论成本率" value={avgTheoreticalRate} format="percent" description="理论食材成本/消费额×100%(排除口径异常)" />
<MetricCard title="平均实际成本率" value={avgActualRate} format="percent" description="实际食材成本/消费额×100%(排除口径异常)" />
<MetricCard title="高偏差门店(>20%)" value={highVarianceCount} format="number" description="实际成本超理论20%以上的门店数" />
<MetricCard title="口径异常门店" value={abnormalRows.length} format="number" description="理论成本数据缺失或口径异常" />
</div>
<div className="mt-3 rounded-lg border bg-blue-50/40 p-3 text-sm text-blue-800">
<strong></strong><br/>
<strong></strong> = (BOM) / × 100% | <strong></strong> = () / × 100%<br/>
<strong></strong> = ( - ) / × 100% | <strong>()</strong>()<br/>
<strong></strong> (,&lt;10%) 绿(±10%) (10%) (20%) () (10%+)
</div>
{abnormalRows.length > 0 && (
<div className="mt-3 rounded-md border border-gray-200 bg-gray-50/50 p-3">
@@ -184,7 +190,8 @@ export function CostPage() {
{ key: 'business_type', label: '业态' },
{ key: 'scale_tier', label: '规模' },
{ key: 'estimated_inventory_days', label: '库存天数', align: 'right', render: (r) => {
const d = Number(r.estimated_inventory_days || 0)
if (r.estimated_inventory_days == null) return <span className="text-muted-foreground text-xs">-</span>
const d = Number(r.estimated_inventory_days)
return <span className={d > 7 ? 'font-medium text-red-600' : d > 4 ? 'text-yellow-600' : 'text-green-600'}>{d.toFixed(1)}</span>
}},
{ key: 'ending_inventory_amount', label: '期末库存', align: 'right', render: (r) => formatCurrency(r.ending_inventory_amount) },
@@ -7,10 +7,11 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { FilterableTable } from '@/components/FilterableTable'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { Truck, AlertTriangle, PackageSearch, BarChart3 } from 'lucide-react'
import { Truck, AlertTriangle, PackageSearch, BarChart3, Warehouse, Store, ChevronRight, ChevronDown } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
function varianceStatus(pct: number): 'good' | 'warn' | 'bad' {
if (pct == null || isNaN(pct)) return 'good'
if (Math.abs(pct) <= 3) return 'good'
if (Math.abs(pct) <= 10) return 'warn'
return 'bad'
@@ -22,12 +23,14 @@ function varianceColorClass(pct: number): string {
}
function varianceText(pct: number): string {
if (pct == null || isNaN(pct)) return '-'
if (pct > 0) return `+${pct.toFixed(2)}%`
return `${pct.toFixed(2)}%`
}
export function DistributionReconciliationPage() {
const [month, setMonth] = useState('2026-04')
const [expandedItem, setExpandedItem] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['distribution-reconciliation', month],
@@ -40,29 +43,28 @@ export function DistributionReconciliationPage() {
if (isLoading) return <LoadingSpinner />
if (!data) return <div className="p-4 text-muted-foreground"></div>
const { summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation } = data
const { summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation, unmatchedStores, kitchenDetails } = data
// 倒挤公式瀑布数据
// 耗用分层瀑布:中央厨房加工 → 配送 → 门店最终耗用
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' },
{ name: '中央厨房采购', value: summary.kitchen_purchase, type: 'base' },
{ name: '中央厨房加工投入', value: summary.kitchen_consumption, type: 'kitchen' },
{ name: '配送出库→门店', value: summary.dist_to_stores_amt, type: 'dist' },
{ name: '门店采购入库', value: summary.store_purchase, type: 'add' },
{ name: '门店实际耗用', value: summary.store_consumption, type: 'actual' },
]
// 品类对账柱状图
const categoryChartData = categoryReconciliation.slice(0, 15).map((c: any) => ({
name: c.minor_category,
配送金额: c.dist_amt,
采购金额: c.purchase_amt,
耗用金额: c.consumption_amt,
期末库存: c.ending_amt,
}))
// 门店散点图:配送金额 vs 差异率
const storeScatterData = storeReconciliation
.filter((s: any) => s.dist_amt > 0 && s.consumption_amt > 0)
.filter((s: any) => !s.is_unmatched && s.dist_amt > 0)
.map((s: any) => ({
name: s.store_name || s.store_code,
x: s.dist_amt,
@@ -83,22 +85,127 @@ export function DistributionReconciliationPage() {
{/* 公式说明 */}
<div className="rounded-lg border bg-blue-50/40 p-3 text-sm text-blue-800">
<strong></strong> = + - = - () = / × 100%
<strong></strong> <br/>
<strong>vs采购差异</strong> = - = + <br/>
<strong></strong> &gt; 0
</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 className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{/* 资金流流程图 */}
<div className="space-y-1 rounded-lg bg-muted/30 p-4 font-mono text-xs leading-relaxed">
{/* 第一段:采购→加工 */}
<div className="flex items-center gap-2">
<span className="font-bold text-blue-700"></span>
<span className="font-bold">{formatCurrency(summary.kitchen_purchase)}</span>
</div>
<div className="flex items-center gap-2 pl-4 text-muted-foreground">
<span> </span>
<span className="font-medium text-yellow-700">{formatCurrency(summary.kitchen_consumption)}</span>
</div>
<div className="flex items-center gap-2 pl-4 text-muted-foreground">
<span> </span>
<span className={summary.kitchen_inventory_change >= 0 ? 'text-orange-600' : 'text-green-600'}>
{summary.kitchen_inventory_change >= 0 ? '+' : ''}{formatCurrency(summary.kitchen_inventory_change)}
</span>
<span className="text-muted-foreground">{summary.kitchen_inventory_change >= 0 ? '采购未用完,留作库存' : '消耗了期初库存'}</span>
</div>
{/* 第二段:加工→配送 */}
<div className="mt-2 flex items-center gap-2">
<span className="font-bold text-yellow-700"></span>
<span className="font-bold">{formatCurrency(summary.kitchen_consumption)}</span>
</div>
<div className="flex items-center gap-2 pl-4 text-muted-foreground">
<span> </span>
<span className="font-medium text-green-700">{formatCurrency(summary.total_dist_amt)}</span>
</div>
<div className="flex items-center gap-2 pl-8 text-muted-foreground">
<span> </span>
<span className="font-medium text-green-700">{formatCurrency(summary.dist_to_stores_amt)}</span>
<span></span>
</div>
<div className="flex items-center gap-2 pl-8 text-muted-foreground">
<span> /</span>
<span className="font-medium text-blue-600">{formatCurrency(summary.dist_to_kitchen_amt)}</span>
<span>/</span>
</div>
<div className="flex items-center gap-2 pl-4 text-muted-foreground">
<span> </span>
<span className="font-medium text-red-600">{formatCurrency(summary.kitchen_loss)}</span>
<span>{formatCurrency(summary.kitchen_consumption)} - {formatCurrency(summary.total_dist_amt)}</span>
</div>
{/* 第三段:真实总耗用 */}
<div className="mt-2 flex items-center gap-2 rounded bg-green-50 px-2 py-1">
<span className="font-bold text-green-800"></span>
<span className="font-bold text-green-800">= {formatCurrency(summary.store_consumption)} + {formatCurrency(summary.kitchen_loss)} = {formatCurrency(summary.real_total_consumption)}</span>
</div>
</div>
{/* 中央厨房/加工车间/分仓 库存平衡明细 */}
{kitchenDetails && kitchenDetails.length > 0 && (
<div className="mt-4">
<h3 className="mb-2 text-sm font-bold">// </h3>
<div className="space-y-1 rounded-lg bg-muted/20 p-3 font-mono text-xs">
{kitchenDetails.map((k: any) => (
<div key={k.store_code} className="border-b border-muted pb-1 last:border-0">
<div className="font-bold text-foreground">
{k.store_name}{k.store_code}
</div>
<div className="pl-4 text-muted-foreground">
{formatCurrency(k.opening_amt)}
{k.store_code === '2' ? (
<> + {formatCurrency(k.purchase_amt)} - {formatCurrency(k.consumption_amt)} = {formatCurrency(k.ending_amt)}</>
) : (
<> + {formatCurrency(k.dist_received_amt)} - {formatCurrency(k.consumption_amt)} = {formatCurrency(k.ending_amt)}</>
)}
{k.store_code === '2' && (
<span className="ml-2 text-orange-600">
{formatCurrency(k.consumption_amt)} : {formatCurrency(summary.dist_to_stores_amt)} + / {formatCurrency(summary.dist_to_kitchen_amt)}
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* 倒挤对账瀑布 */}
<CollapsibleSection title="倒挤成本对账瀑布" subtitle="期初库存 + 配送入库 - 期末库存 = 应耗用 vs 实际耗用">
{/* 门店对账核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
<MetricCard title="门店品项行" value={summary.store_total_lines} unit="行" description="门店库存记录数" />
<MetricCard title="门店期初库存" value={summary.store_opening} format="currency" />
<MetricCard title="门店采购入库" value={summary.store_purchase} format="currency" description="含配送+直采" />
<MetricCard title="门店期末库存" value={summary.store_ending} format="currency" />
<MetricCard title="配送-采购差异" value={summary.dist_purchase_variance_amt} format="currency" description="负=有直采,正=未入账" status={summary.dist_purchase_variance_amt > 100000 ? 'warn' : 'good'} />
<MetricCard title="配送-采购差异率" value={varianceText(summary.dist_purchase_variance_pct)} status={varianceStatus(summary.dist_purchase_variance_pct)} />
</div>
{/* 未入账门店警告 */}
{unmatchedStores && unmatchedStores.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-yellow-600" />
<div className="flex-1">
<h3 className="text-sm font-bold text-yellow-800">
{unmatchedStores.length} {formatCurrency(unmatchedStores.reduce((s: number, r: any) => s + r.dist_amt, 0))}
</h3>
<p className="mt-1 text-xs text-yellow-700">
</p>
<div className="mt-2 flex flex-wrap gap-2">
{unmatchedStores.map((s: any) => (
<span key={s.store_code} className="rounded border border-yellow-400 bg-white px-2 py-1 text-xs">
{s.store_name || s.store_code}{s.store_code}{formatCurrency(s.dist_amt)}
</span>
))}
</div>
</div>
</div>
)}
{/* 耗用分层瀑布 */}
<CollapsibleSection title="耗用分层瀑布" subtitle="中央厨房采购 → 加工耗用 → 配送出库 → 门店采购 → 门店最终耗用">
<ResponsiveContainer width="100%" height={300}>
<BarChart data={waterfallData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" />
@@ -108,29 +215,36 @@ export function DistributionReconciliationPage() {
<Bar dataKey="value" name="金额" radius={[4, 4, 0, 0]}>
{waterfallData.map((entry, idx) => (
<Cell key={idx} fill={
entry.type === 'add' ? '#52c41a' :
entry.type === 'calc' ? '#1677ff' :
entry.type === 'kitchen' ? '#faad14' :
entry.type === 'dist' ? '#52c41a' :
entry.type === 'add' ? '#1677ff' :
entry.type === 'actual' ? '#722ed1' :
entry.type === 'variance' ? (entry.value >= 0 ? '#faad14' : '#f5222d') :
'#8ec6ff'
} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded bg-blue-300" /> </span>
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded bg-yellow-500" /> </span>
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded bg-green-500" /> </span>
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded bg-blue-600" /> </span>
<span className="flex items-center gap-1"><span className="inline-block h-3 w-3 rounded bg-purple-600" /> </span>
</div>
</CollapsibleSection>
{/* 品类维度对账 */}
<CollapsibleSection title="品类维度对账" subtitle="按小类汇总配送金额与耗用金额">
<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="#52c41a" />
<Bar dataKey="采购金额" fill="#1677ff" />
<Bar dataKey="耗用金额" fill="#722ed1" />
<Bar dataKey="期末库存" fill="#faad14" />
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
@@ -140,6 +254,7 @@ export function DistributionReconciliationPage() {
filterLabel="全部分类"
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'purchase_amt', label: '采购金额' },
{ key: 'consumption_amt', label: '耗用金额' },
{ key: 'ending_amt', label: '期末库存' },
{ key: 'variance_amt', label: '差异金额' },
@@ -151,6 +266,7 @@ export function DistributionReconciliationPage() {
{ key: 'dist_qty', label: '配送量', align: 'right', render: (c) => formatNumber(c.dist_qty) },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (c) => formatCurrency(c.dist_amt) },
{ key: 'dist_cost_excl_tax', label: '不含税成本', align: 'right', render: (c) => formatCurrency(c.dist_cost_excl_tax) },
{ key: 'purchase_amt', label: '采购金额', align: 'right', render: (c) => formatCurrency(c.purchase_amt) },
{ key: 'consumption_amt', label: '耗用金额', align: 'right', render: (c) => formatCurrency(c.consumption_amt) },
{ key: 'ending_amt', label: '期末库存', align: 'right', render: (c) => formatCurrency(c.ending_amt) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (c) => <span className={c.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(c.variance_amt)}</span> },
@@ -161,7 +277,7 @@ export function DistributionReconciliationPage() {
</CollapsibleSection>
{/* 门店差异散点图 */}
<CollapsibleSection title="门店差异散点图" subtitle="X=配送金额,Y=差异率%" defaultOpen={false}>
<CollapsibleSection title="门店差异散点图" subtitle="X=配送金额,Y=差异率%(配送vs采购,仅已入账门店)" defaultOpen={false}>
<ResponsiveContainer width="100%" height={350}>
<ScatterChart margin={{ top: 20, right: 30, left: 20, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" />
@@ -174,7 +290,7 @@ export function DistributionReconciliationPage() {
<div className="rounded border bg-white px-3 py-2 text-xs shadow">
<div className="font-medium">{p.name} ({p.store_code})</div>
<div>: {formatCurrency(p.x)}</div>
<div>: {p.y.toFixed(2)}%</div>
<div>: {p.y != null ? p.y.toFixed(2) : '-'}%</div>
</div>
)
}} />
@@ -199,7 +315,7 @@ export function DistributionReconciliationPage() {
{/* 门店对账明细 */}
<CollapsibleSection
title="门店对账明细"
subtitle={`${storeReconciliation.length}家门店`}
subtitle={`${storeReconciliation.length}家门店(排除中央厨房/仓库)`}
>
<FilterableTable
data={storeReconciliation}
@@ -207,39 +323,39 @@ export function DistributionReconciliationPage() {
searchPlaceholder="搜索门店编码/名称..."
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'dist_cost_excl_tax', label: '不含税成本' },
{ key: 'purchase_amt', label: '采购金额' },
{ key: 'opening_amt', label: '期初库存' },
{ key: 'consumption_amt', label: '实际耗用' },
{ key: 'ending_amt', label: '期末库存' },
{ key: 'reverse_consumption_amt', label: '倒挤应耗用' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
{ key: 'neg_inventory_count', label: '负库存' },
{ key: 'neg_inventory_count', label: '退/盘盈记录' },
]}
columns={[
{ key: 'store_code', label: '门店编码' },
{ key: 'store_name', label: '门店名称', render: (s) => s.store_name || '-' },
{ key: 'is_unmatched', label: '状态', render: (s) => s.is_unmatched ? <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-700"></span> : <span className="text-xs text-green-600"></span> },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (s) => formatCurrency(s.dist_amt) },
{ key: 'dist_cost_excl_tax', label: '不含税成本', align: 'right', render: (s) => formatCurrency(s.dist_cost_excl_tax) },
{ key: 'purchase_amt', label: '采购入库', align: 'right', render: (s) => formatCurrency(s.purchase_amt) },
{ key: 'variance_amt', label: '差异(配送-采购)', align: 'right', render: (s) => <span className={s.variance_amt > 0 ? 'text-red-600' : s.variance_amt < 0 ? 'text-green-600' : 'text-muted-foreground'}>{formatCurrency(s.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (s) => <span className={`font-medium ${varianceColorClass(s.variance_pct)}`}>{varianceText(s.variance_pct)}</span> },
{ key: 'opening_amt', label: '期初库存', align: 'right', render: (s) => formatCurrency(s.opening_amt) },
{ key: 'consumption_amt', label: '实际耗用', align: 'right', render: (s) => formatCurrency(s.consumption_amt) },
{ key: 'ending_amt', label: '期末库存', align: 'right', render: (s) => formatCurrency(s.ending_amt) },
{ key: 'reverse_consumption_amt', label: '倒挤应耗用', align: 'right', render: (s) => formatCurrency(s.reverse_consumption_amt) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (s) => <span className={s.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}>{formatCurrency(s.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (s) => <span className={`font-medium ${varianceColorClass(s.variance_pct)}`}>{varianceText(s.variance_pct)}</span> },
{ key: 'neg_inventory_count', label: '负库存', align: 'right', render: (s) => <span className={s.neg_inventory_count > 0 ? 'text-red-600 font-medium' : ''}>{s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'}</span> },
{ key: 'neg_inventory_count', label: '退/盘盈', align: 'right', render: (s) => <span className={s.neg_inventory_count > 0 ? 'text-orange-600 font-medium' : ''}>{s.neg_inventory_count > 0 ? s.neg_inventory_count : '-'}</span> },
]}
/>
</CollapsibleSection>
{/* Top差异品项 */}
<CollapsibleSection title="Top30差异品项" subtitle="按差异金额绝对值排序" defaultOpen={false}>
<CollapsibleSection title="Top30差异品项" subtitle="按差异金额绝对值排序(仅门店)" defaultOpen={false}>
<FilterableTable
data={topVariances}
filterKey="item_name"
filterLabel="全部品项"
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'purchase_amt', label: '采购金额' },
{ key: 'consumption_amt', label: '实际耗用' },
{ key: 'variance_amt', label: '差异金额' },
{ key: 'variance_pct', label: '差异率' },
@@ -251,36 +367,38 @@ export function DistributionReconciliationPage() {
{ key: 'minor_category', label: '品类' },
{ key: 'dist_qty', label: '配送量', align: 'right', render: (v) => formatNumber(v.dist_qty) },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (v) => formatCurrency(v.dist_amt) },
{ key: 'purchase_amt', label: '采购入库', align: 'right', render: (v) => formatCurrency(v.purchase_amt) },
{ key: 'variance_amt', label: '差异(配送-采购)', align: 'right', render: (v) => <span className={`font-medium ${v.variance_amt > 0 ? 'text-red-600' : v.variance_amt < 0 ? 'text-green-600' : 'text-muted-foreground'}`}>{formatCurrency(v.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (v) => <span className={varianceColorClass(v.variance_pct)}>{varianceText(v.variance_pct)}</span> },
{ key: 'opening_amt', label: '期初', align: 'right', render: (v) => formatCurrency(v.opening_amt) },
{ key: 'consumption_amt', label: '实际耗用', align: 'right', render: (v) => formatCurrency(v.consumption_amt) },
{ key: 'ending_amt', label: '期末', align: 'right', render: (v) => formatCurrency(v.ending_amt) },
{ key: 'reverse_consumption_amt', label: '倒挤应耗用', align: 'right', render: (v) => formatCurrency(v.reverse_consumption_amt) },
{ key: 'variance_amt', label: '差异金额', align: 'right', render: (v) => <span className={`font-medium ${v.variance_amt >= 0 ? 'text-yellow-600' : 'text-red-600'}`}>{formatCurrency(v.variance_amt)}</span> },
{ key: 'variance_pct', label: '差异率', align: 'right', render: (v) => <span className={varianceColorClass(v.variance_pct)}>{varianceText(v.variance_pct)}</span> },
]}
/>
</CollapsibleSection>
{/* 未匹配品项 */}
<CollapsibleSection title="未匹配品项(有配送无库存耗用)" subtitle="配送系统有发货但库存系统无耗用记录" defaultOpen={false}>
<FilterableTable
data={unmatchedItems}
filterKey="item_name"
filterLabel="全部品项"
sortOptions={[
{ key: 'dist_amt', label: '配送金额' },
{ key: 'dist_qty', label: '配送量' },
{ key: 'store_count', label: '涉及门店数' },
]}
columns={[
{ key: 'item_code', label: '品项编码' },
{ key: 'item_name', label: '品项名称' },
{ key: 'minor_category', label: '品类' },
{ key: 'dist_qty', label: '配送量', align: 'right', render: (u) => formatNumber(u.dist_qty) },
{ key: 'dist_amt', label: '配送金额', align: 'right', render: (u) => formatCurrency(u.dist_amt) },
{ key: 'store_count', label: '涉及门店数', align: 'right' },
]}
/>
<CollapsibleSection title="未匹配品项(有配送无库存耗用)" subtitle="配送系统有发货但库存系统无耗用记录,点击行展开查看配送门店明细" defaultOpen={false}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-muted-foreground">
<th className="p-2"></th>
<th className="p-2"></th>
<th className="p-2"></th>
<th className="p-2"></th>
<th className="p-2 text-right"></th>
<th className="p-2 text-right"></th>
<th className="p-2 text-right"></th>
</tr>
</thead>
<tbody>
{unmatchedItems.map((u: any) => (
<UnmatchedItemRow key={u.item_code} item={u} month={month} expanded={expandedItem === u.item_code} onToggle={() => setExpandedItem(expandedItem === u.item_code ? null : u.item_code)} />
))}
</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>
@@ -289,3 +407,78 @@ export function DistributionReconciliationPage() {
</div>
)
}
// 未匹配品项可展开行
function UnmatchedItemRow({ item, month, expanded, onToggle }: { item: any; month: string; expanded: boolean; onToggle: () => void }) {
const { data: details, isLoading, error } = useQuery({
queryKey: ['unmatched-item-details', month, item.item_code],
queryFn: async () => {
const res = await api.get(`/distribution/unmatched-item-details?month=${month}&item_code=${item.item_code}`)
return res.data
},
enabled: expanded,
})
return (
<>
<tr className="border-b cursor-pointer hover:bg-muted/30" onClick={onToggle}>
<td className="p-2 text-muted-foreground">
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</td>
<td className="p-2">{item.item_code}</td>
<td className="p-2">{item.item_name}</td>
<td className="p-2">{item.minor_category}</td>
<td className="p-2 text-right">{formatNumber(item.dist_qty)}</td>
<td className="p-2 text-right">{formatCurrency(item.dist_amt)}</td>
<td className="p-2 text-right">{item.store_count}</td>
</tr>
{expanded && (
<tr>
<td colSpan={7} className="bg-muted/20 p-3">
{isLoading ? (
<div className="text-xs text-muted-foreground">...</div>
) : error ? (
<div className="text-xs text-red-600">: {String(error)}</div>
) : details && Array.isArray(details) && details.length > 0 ? (
<div>
<div className="mb-2 text-xs font-medium text-muted-foreground"></div>
<table className="w-full text-xs">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="p-1.5"></th>
<th className="p-1.5"></th>
<th className="p-1.5 text-right"></th>
<th className="p-1.5 text-right"></th>
<th className="p-1.5 text-right"></th>
<th className="p-1.5 text-right"></th>
<th className="p-1.5 text-right"></th>
<th className="p-1.5"></th>
</tr>
</thead>
<tbody>
{details.map((d: any, idx: number) => (
<tr key={idx} className="border-b last:border-0">
<td className="p-1.5">{d.store_code}</td>
<td className="p-1.5">{d.store_name || '-'}</td>
<td className="p-1.5 text-right">{formatNumber(d.dist_qty)}</td>
<td className="p-1.5 text-right">{formatCurrency(d.dist_amt)}</td>
<td className="p-1.5 text-right">{formatCurrency(d.dist_cost_excl_tax)}</td>
<td className="p-1.5 text-right">{formatCurrency(d.purchase_amt)}</td>
<td className="p-1.5 text-right">{formatCurrency(d.consumption_amt)}</td>
<td className="p-1.5">
{d.has_inventory ? <span className="text-green-600"></span> : <span className="text-red-600"></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-xs text-muted-foreground"></div>
)}
</td>
</tr>
)}
</>
)
}
+43 -8
View File
@@ -6,15 +6,19 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { Tabs } from '@/components/Tabs'
import { Pagination } from '@/components/Pagination'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
const URGENCY_COLORS: Record<string, string> = { '紧急': '#ef4444', '预警': '#eab308', '关注': '#3b82f6', '正常': '#22c55e' }
const PAGE_SIZE = 50
export function InventoryTurnoverPage() {
const [month, setMonth] = useState('2026-04')
const [tab, setTab] = useState('turnover')
const [expiryPage, setExpiryPage] = useState(1)
const [urgencyFilter, setUrgencyFilter] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['analytics-enhanced/inventory/turnover', month],
@@ -23,8 +27,8 @@ export function InventoryTurnoverPage() {
})
const { data: expiryData, isLoading: expiryLoading } = useQuery({
queryKey: ['analytics-enhanced/inventory/near-expiry', month],
queryFn: () => api.get('/analytics-enhanced/inventory/near-expiry', { params: { month } }),
queryKey: ['analytics-enhanced/inventory/near-expiry', month, expiryPage, urgencyFilter],
queryFn: () => api.get('/analytics-enhanced/inventory/near-expiry', { params: { month, page: expiryPage, page_size: PAGE_SIZE, urgency_level: urgencyFilter } }),
enabled: tab === 'expiry',
})
@@ -40,7 +44,7 @@ export function InventoryTurnoverPage() {
const totalWaste = turnover.reduce((s: number, r: any) => s + Number(r.waste_value || 0), 0)
const totalEnding = turnover.reduce((s: number, r: any) => s + Number(r.ending_value || 0), 0)
const avgTurnoverDays = turnover.length > 0
? turnover.reduce((s: number, r: any) => s + Number(r.turnover_days || 0), 0) / turnover.length
? turnover.reduce((s: number, r: any) => s + Number(r.turnover_days || 0), 0) / turnover.filter((r: any) => r.turnover_days != null).length || 0
: 0
const turnoverChartData = turnover.slice(0, 15).map((r: any) => ({
@@ -58,6 +62,7 @@ export function InventoryTurnoverPage() {
const nearExpiry = expiry.near_expiry || []
const storeRisk = expiry.store_risk || []
const expirySummary = expiry.summary || {}
const pagination = expiry.pagination || {}
const urgencyPieData = [
{ name: '紧急', value: expirySummary.urgent || 0 },
@@ -133,11 +138,30 @@ export function InventoryTurnoverPage() {
/>
</CollapsibleSection>
<CollapsibleSection title={`临期商品明细 (${nearExpiry.length})`} subtitle="按紧急度排序,含处置建议">
<CollapsibleSection title={`临期商品明细 (${pagination.total || 0})`} subtitle="按紧急度排序,含处置建议">
<div className="mb-3 flex items-center gap-2">
<span className="text-xs text-muted-foreground"></span>
{['', '紧急', '预警', '关注'].map(level => (
<button
key={level}
onClick={() => { setUrgencyFilter(level); setExpiryPage(1) }}
className={`rounded px-2 py-0.5 text-xs ${
urgencyFilter === level
? 'bg-primary text-primary-foreground'
: level === '紧急' ? 'bg-red-100 text-red-700 hover:bg-red-200'
: level === '预警' ? 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200'
: level === '关注' ? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
: 'bg-muted hover:bg-muted/80'
}`}
>
{level || '全部'}
</button>
))}
</div>
<FilterableTable
data={nearExpiry}
filterKey="urgency_level"
filterLabel="全部级别"
searchKeys={['store_name', 'material_name', 'minor_category']}
searchPlaceholder="搜索门店/物料/品类..."
sortOptions={[
{ key: 'estimated_days_to_consume', label: '预计天数' },
{ key: 'stock_value', label: '库存金额' },
@@ -148,7 +172,7 @@ export function InventoryTurnoverPage() {
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'material_name', label: '物料名称' },
{ key: 'category', label: '品类' },
{ key: 'minor_category', label: '品类' },
{ key: 'stock_qty', label: '库存量', align: 'right', render: (r) => formatNumber(r.stock_qty) },
{ key: 'stock_value', label: '库存金额', align: 'right', render: (r) => formatCurrency(r.stock_value) },
{ key: 'estimated_days_to_consume', label: '预计天数', align: 'right', render: (r) => {
@@ -162,6 +186,16 @@ export function InventoryTurnoverPage() {
{ key: 'suggested_action', label: '建议措施' },
]}
/>
{pagination.total_pages > 1 && (
<div className="mt-3">
<Pagination
page={expiryPage}
pageSize={PAGE_SIZE}
total={pagination.total || 0}
onPageChange={setExpiryPage}
/>
</div>
)}
</CollapsibleSection>
</>
)}
@@ -241,7 +275,8 @@ export function InventoryTurnoverPage() {
return <span className={v > 1000 ? 'text-red-600 font-medium' : ''}>{formatCurrency(v)}</span>
}},
{ key: 'turnover_days', label: '周转天数', align: 'right', render: (r) => {
const v = Number(r.turnover_days || 0)
if (r.turnover_days == null) return <span className="text-muted-foreground text-xs"></span>
const v = Number(r.turnover_days)
return <span className={v <= 7 ? 'text-green-600' : v <= 14 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}</span>
}},
]}
+4 -17
View File
@@ -8,13 +8,14 @@ import { Tabs } from '@/components/Tabs'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
import { MonthPicker } from '@/components/MonthPicker'
const GRADE_COLORS: Record<string, string> = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' }
export function TargetManagementPage() {
const [tab, setTab] = useState('overview')
const [year, setYear] = useState(new Date().getFullYear())
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
const [month, setMonth] = useState('2026-04')
const year = parseInt(month.split('-')[0])
const queryClient = useQueryClient()
const { data: annualData, isLoading: annualLoading } = useQuery({
@@ -96,21 +97,7 @@ export function TargetManagementPage() {
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · · · </p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="w-20 rounded border px-2 py-1 text-sm"
/>
<span className="text-sm text-muted-foreground"></span>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="rounded border px-2 py-1 text-sm"
/>
</div>
<MonthPicker month={month} onChange={setMonth} />
</div>
<Tabs
+10 -9
View File
@@ -11,7 +11,7 @@ import { useState, useMemo } from 'react'
export function TasksPage() {
const navigate = useNavigate()
const [month, setMonth] = useState('2026-04')
const [month, setMonth] = useState('2026-05')
const [priority, setPriority] = useState('')
const [status, setStatus] = useState('')
@@ -58,21 +58,22 @@ export function TasksPage() {
{/* 闭环流程条 */}
<div className="flex flex-wrap items-center gap-1 rounded-lg border bg-card p-3">
{[
{ label: '信号发现', count: '1,284', rate: (loopHealthData as any)?.data?.task_generation_rate },
{ label: '决策审批', count: String(tasks.length), rate: null },
{ label: '任务执行', count: String(total), rate: (loopHealthData as any)?.data?.store_execution_rate },
{ label: '周期检查', count: '-', rate: (loopHealthData as any)?.data?.weekly_check_rate },
{ label: '收益验收', count: String(tasks.filter((t: any) => t.status === '已验收').length), rate: (loopHealthData as any)?.data?.monthly_review_rate },
{ label: '经验标准化', count: '14', rate: (loopHealthData as any)?.data?.practice_promotion_rate },
{ label: '信号发现', sub: '风险门店', count: String((loopHealthData as any)?.data?.signal_count ?? 0), rate: (loopHealthData as any)?.data?.task_generation_rate, rateLabel: '任务生成率' },
{ label: '决策审批', sub: '全部任务', count: String(tasks.length), rate: null, rateLabel: '' },
{ label: '任务执行', sub: '执行中任务', count: String(total), rate: (loopHealthData as any)?.data?.store_execution_rate, rateLabel: '门店执行率' },
{ label: '周期检查', sub: '周检覆盖', count: '-', rate: (loopHealthData as any)?.data?.weekly_check_rate, rateLabel: '周检率' },
{ label: '收益验收', sub: '已验收任务', count: String(tasks.filter((t: any) => t.status === '已验收').length), rate: (loopHealthData as any)?.data?.monthly_review_rate, rateLabel: '月验收率' },
{ label: '经验标准化', sub: '标准实践', count: String((loopHealthData as any)?.data?.practice_count ?? 0), rate: (loopHealthData as any)?.data?.practice_promotion_rate, rateLabel: '推广率' },
].map((stage, i, arr) => (
<div key={stage.label} className="flex items-center gap-1">
<div className="flex flex-col items-center px-2">
<span className={`flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold ${i === 0 ? 'bg-blue-600 text-white' : 'bg-muted text-muted-foreground'}`}>
{i + 1}
</span>
<small className="mt-1 text-[10px] text-muted-foreground">{stage.label}</small>
<small className="mt-1 text-[10px] font-medium text-foreground">{stage.label}</small>
<small className="text-[9px] text-muted-foreground">{stage.sub}</small>
<b className="text-sm font-bold">{stage.count}</b>
{stage.rate != null && <em className="text-[10px] text-green-600">{Math.round(Number(stage.rate) * 100) / 100}% </em>}
{stage.rate != null && <em className="text-[10px] text-green-600">{Math.round(Number(stage.rate) * 100) / 100}% {stage.rateLabel}</em>}
</div>
{i < arr.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</div>
+141 -43
View File
@@ -555,22 +555,40 @@ router.get('/inventory/turnover', async (req: AuthRequest, res) => {
// 库存周转概览
const turnoverResult = await query(`
SELECT
SELECT
fis.store_code,
ds.store_name,
COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
WHEN '2' THEN '中央厨房'
WHEN '3' THEN '加工车间'
WHEN '6' THEN '子仓库'
ELSE '门店' || fis.store_code
END) as store_name,
round(sum(fis.opening_amount)::numeric, 2) as opening_value,
round(sum(fis.purchase_amount)::numeric, 2) as purchase_value,
round(sum(fis.consumption_amount)::numeric, 2) as consumption_value,
round(sum(fis.ending_amount)::numeric, 2) as ending_value,
round(sum(fis.waste_amount)::numeric, 2) as waste_value,
round(avg(fis.ending_amount)::numeric, 2) as avg_ending,
round(sum(fis.consumption_amount)::numeric / NULLIF(avg(fis.ending_amount) * count(*), 0) * 30, 1) as turnover_days
CASE WHEN sum(fis.consumption_amount) > 0
THEN round(sum(fis.consumption_amount)::numeric / NULLIF(avg(fis.ending_amount) * count(*), 0) * 30, 1)
ELSE NULL
END as turnover_days
FROM analytics.fact_inventory_snapshot fis
LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
LEFT JOIN analytics.dim_store ds ON LPAD(fis.store_code,4,'0') = ds.store_code
LEFT JOIN LATERAL (
SELECT store_name FROM mv_distribution_monthly
WHERE store_code = fis.store_code AND store_name IS NOT NULL AND store_name != ''
LIMIT 1
) dist ON true
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
${storeFilter}
GROUP BY fis.store_code, ds.store_name
ORDER BY turnover_days ASC
GROUP BY fis.store_code, COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
WHEN '2' THEN '中央厨房'
WHEN '3' THEN '加工车间'
WHEN '6' THEN '子仓库'
ELSE '门店' || fis.store_code
END)
ORDER BY CASE WHEN sum(fis.consumption_amount) > 0 THEN 0 ELSE 1 END, turnover_days ASC
`, params)
// 损耗TOP
@@ -606,32 +624,90 @@ router.get('/inventory/near-expiry', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const storeCode = (req.query.store_code as string) || ''
const urgencyLevel = (req.query.urgency_level as string) || ''
const page = Math.max(1, parseInt(req.query.page as string) || 1)
const pageSize = Math.min(500, Math.max(10, parseInt(req.query.page_size as string) || 50))
let storeFilter = ''
let urgencyFilter = ''
const params: any[] = [month]
if (storeCode) {
params.push(storeCode)
storeFilter = `AND fis.store_code = $${params.length}`
}
if (urgencyLevel === '紧急') {
urgencyFilter = `AND (fis.ending_amount / fis.consumption_amount * 30) <= 3`
} else if (urgencyLevel === '预警') {
urgencyFilter = `AND (fis.ending_amount / fis.consumption_amount * 30) > 3 AND (fis.ending_amount / fis.consumption_amount * 30) <= 7`
} else if (urgencyLevel === '关注') {
urgencyFilter = `AND (fis.ending_amount / fis.consumption_amount * 30) > 7 AND (fis.ending_amount / fis.consumption_amount * 30) <= 14`
}
// 临期商品明细(按门店+物料
// 临期汇总(全量统计
const summaryResult = await query(`
SELECT
count(*) AS total_items,
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3) AS urgent,
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7
AND (fis.ending_amount / fis.consumption_amount * 30) > 3) AS warning,
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14
AND (fis.ending_amount / fis.consumption_amount * 30) > 7) AS watch,
round(sum(fis.ending_amount) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3)::numeric, 2) AS urgent_value,
round(sum(fis.ending_amount) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7
AND (fis.ending_amount / fis.consumption_amount * 30) > 3)::numeric, 2) AS warning_value,
round(sum(fis.ending_amount) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7)::numeric, 2) AS potential_loss
FROM analytics.fact_inventory_snapshot fis
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
AND fis.ending_amount > 0
${storeFilter}
`, params)
// 临期商品明细总数(带筛选)
const countParams = [...params]
const countResult = await query(`
SELECT count(*) AS total
FROM analytics.fact_inventory_snapshot fis
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
AND fis.ending_amount > 0
AND fis.consumption_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14
${storeFilter}
${urgencyFilter}
`, countParams)
// 分页查询
const pageParams = [...params]
pageParams.push(pageSize)
pageParams.push((page - 1) * pageSize)
const nearExpiryResult = await query(`
SELECT
SELECT
fis.store_code,
ds.store_name,
COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
WHEN '2' THEN '中央厨房'
WHEN '3' THEN '加工车间'
WHEN '6' THEN '子仓库'
ELSE '门店' || fis.store_code
END) as store_name,
dm.material_name,
dm.material_code,
dm.category,
dm.major_category,
dm.minor_category,
round(fis.ending_quantity::numeric, 2) as stock_qty,
round(fis.ending_amount::numeric, 2) as stock_value,
round(fis.consumption_amount::numeric, 2) as monthly_consumption,
round(fis.waste_amount::numeric, 2) as waste_value,
CASE
CASE
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
THEN round((fis.ending_amount / fis.consumption_amount * 30)::numeric, 1)
ELSE NULL
ELSE NULL
END as estimated_days_to_consume,
CASE
CASE
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN '紧急'
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
@@ -640,7 +716,7 @@ router.get('/inventory/near-expiry', async (req: AuthRequest, res) => {
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14 THEN '关注'
ELSE '正常'
END as urgency_level,
CASE
CASE
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN '立即促销出清或报损'
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
@@ -650,47 +726,51 @@ router.get('/inventory/near-expiry', async (req: AuthRequest, res) => {
ELSE '正常周转'
END as suggested_action
FROM analytics.fact_inventory_snapshot fis
LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
LEFT JOIN analytics.dim_store ds ON LPAD(fis.store_code,4,'0') = ds.store_code
LEFT JOIN analytics.dim_material dm ON fis.material_code = dm.material_code
LEFT JOIN LATERAL (
SELECT store_name FROM mv_distribution_monthly
WHERE store_code = fis.store_code AND store_name IS NOT NULL AND store_name != ''
LIMIT 1
) dist ON true
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
AND fis.ending_amount > 0
AND fis.consumption_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14
${storeFilter}
ORDER BY
CASE
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN 1
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7 THEN 2
WHEN fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 14 THEN 3
ELSE 4
${urgencyFilter}
ORDER BY
CASE
WHEN (fis.ending_amount / fis.consumption_amount * 30) <= 3 THEN 1
WHEN (fis.ending_amount / fis.consumption_amount * 30) <= 7 THEN 2
ELSE 3
END,
fis.ending_amount DESC
LIMIT 200
`, params)
LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}
`, pageParams)
// 临期汇总
const sr = summaryResult.rows[0] as any
const summary = {
total_items: nearExpiryResult.rows.length,
urgent: nearExpiryResult.rows.filter((r: any) => r.urgency_level === '紧急').length,
warning: nearExpiryResult.rows.filter((r: any) => r.urgency_level === '预警').length,
watch: nearExpiryResult.rows.filter((r: any) => r.urgency_level === '关注').length,
urgent_value: nearExpiryResult.rows
.filter((r: any) => r.urgency_level === '紧急')
.reduce((s: number, r: any) => s + Number(r.stock_value || 0), 0),
warning_value: nearExpiryResult.rows
.filter((r: any) => r.urgency_level === '预警')
.reduce((s: number, r: any) => s + Number(r.stock_value || 0), 0),
potential_loss: nearExpiryResult.rows
.filter((r: any) => r.urgency_level === '紧急' || r.urgency_level === '预警')
.reduce((s: number, r: any) => s + Number(r.stock_value || 0) * 0.5, 0),
total_items: parseInt(sr?.total_items) || 0,
urgent: parseInt(sr?.urgent) || 0,
warning: parseInt(sr?.warning) || 0,
watch: parseInt(sr?.watch) || 0,
urgent_value: parseFloat(sr?.urgent_value) || 0,
warning_value: parseFloat(sr?.warning_value) || 0,
potential_loss: parseFloat(sr?.potential_loss) || 0,
}
// 按门店汇总临期风险
const storeRisk = await query(`
SELECT
SELECT
fis.store_code,
ds.store_name,
COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
WHEN '2' THEN '中央厨房'
WHEN '3' THEN '加工车间'
WHEN '6' THEN '子仓库'
ELSE '门店' || fis.store_code
END) as store_name,
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 3) as urgent_items,
count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
@@ -700,20 +780,38 @@ router.get('/inventory/near-expiry', async (req: AuthRequest, res) => {
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7)::numeric, 2) as at_risk_value,
round(sum(fis.waste_amount)::numeric, 2) as total_waste
FROM analytics.fact_inventory_snapshot fis
LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
LEFT JOIN analytics.dim_store ds ON LPAD(fis.store_code,4,'0') = ds.store_code
LEFT JOIN LATERAL (
SELECT store_name FROM mv_distribution_monthly
WHERE store_code = fis.store_code AND store_name IS NOT NULL AND store_name != ''
LIMIT 1
) dist ON true
WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
AND fis.ending_amount > 0
${storeFilter}
GROUP BY fis.store_code, ds.store_name
GROUP BY fis.store_code, COALESCE(ds.store_name, dist.store_name, CASE fis.store_code
WHEN '2' THEN '中央厨房'
WHEN '3' THEN '加工车间'
WHEN '6' THEN '子仓库'
ELSE '门店' || fis.store_code
END)
HAVING count(*) FILTER (WHERE fis.consumption_amount > 0 AND fis.ending_amount > 0
AND (fis.ending_amount / fis.consumption_amount * 30) <= 7) > 0
ORDER BY at_risk_value DESC
`, params)
const total = parseInt(countResult.rows[0]?.total) || 0
sendSuccess(res, {
summary,
near_expiry: nearExpiryResult.rows,
store_risk: storeRisk.rows,
pagination: {
page,
page_size: pageSize,
total,
total_pages: Math.ceil(total / pageSize),
},
})
} catch (err: any) {
sendError(res, err.message)
+34 -3
View File
@@ -11,6 +11,7 @@ const router = Router()
router.get('/overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
// 菜品级数据(Excel导入,可能不覆盖全部门店)
const result = await query(`
SELECT
count(*) AS total_dishes,
@@ -25,7 +26,35 @@ router.get('/overview', async (req: AuthRequest, res) => {
FROM public.dish_cost_analysis_summary
WHERE import_id = (SELECT import_id FROM public.dish_cost_analysis_import_log WHERE report_month = $1::date ORDER BY import_id DESC LIMIT 1)
`, [month])
sendSuccess(res, result.rows[0])
// 总部驾驶舱同源数据(bill_fact物化视图,覆盖全部94家门店)
const billResult = await query(`
SELECT
count(*) AS bill_count,
count(DISTINCT store_code) AS total_stores,
round(sum(received_total)::numeric, 2) AS total_sales,
round(sum(consumption)::numeric, 2) AS total_consumption,
round(sum(theoretical_cost)::numeric, 2) AS total_theo_cost,
round(sum(theoretical_profit)::numeric, 2) AS total_theo_profit
FROM analytics.bill_fact
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
`, [month])
// 门店级成本数据(物化视图,93家门店,倒挤口径)
const storeResult = await query(`
SELECT
count(*) AS store_count,
round(sum(sales_received)::numeric, 2) AS store_total_sales,
round(sum(theoretical_cost)::numeric, 2) AS store_total_theo_cost,
round(sum(actual_food_cost)::numeric, 2) AS store_total_actual_cost,
round(sum(actual_total_cost)::numeric, 2) AS store_total_all_cost,
round(sum(food_cost_variance)::numeric, 2) AS store_total_variance,
round(avg(theoretical_cost_rate_pct) FILTER (WHERE variance_level NOT LIKE '%口径异常%')::numeric, 2) AS store_avg_theo_cost_rate,
round(avg(actual_food_cost_rate_pct) FILTER (WHERE variance_level NOT LIKE '%口径异常%')::numeric, 2) AS store_avg_actual_cost_rate,
round((100 - avg(theoretical_cost_rate_pct) FILTER (WHERE variance_level NOT LIKE '%口径异常%'))::numeric, 2) AS store_avg_theo_margin,
round((100 - avg(actual_food_cost_rate_pct) FILTER (WHERE variance_level NOT LIKE '%口径异常%'))::numeric, 2) AS store_avg_actual_margin
FROM analytics.mv_store_theoretical_actual_cost_monthly
WHERE month_start = $1
`, [month])
sendSuccess(res, { ...result.rows[0], ...billResult.rows[0], ...storeResult.rows[0] })
} catch (err: any) {
sendError(res, err.message)
}
@@ -192,8 +221,10 @@ router.get('/pricing', async (req: AuthRequest, res) => {
const result = await query(`
SELECT dish_name, dish_code, category_level1,
round(price::numeric, 2) AS price,
round(theoretical_cost::numeric, 2) AS theo_cost,
round((theoretical_cost / nullif(price, 0) * 100)::numeric, 2) AS theo_cost_rate,
round(sales_quantity::numeric, 2) AS sales_quantity,
round(theoretical_cost::numeric, 2) AS theo_cost_total,
round((theoretical_cost / nullif(sales_quantity, 0))::numeric, 2) AS theo_cost_per_unit,
round((theoretical_cost / nullif(sales_quantity, 0) / nullif(price, 0) * 100)::numeric, 2) AS theo_cost_rate,
round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin,
round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
round(sales_amount::numeric, 2) AS sales_amount
+262 -102
View File
@@ -1964,89 +1964,104 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
// 中央厨房/仓库编码(库存系统中不带前导零,配送系统中可能带或不带)
const KITCHEN_CODES = "('2','3','6')"
// 检查配送物化视图是否包含当前月份数据
const distMvCheck = await query(`SELECT 1 FROM mv_distribution_monthly WHERE report_month = $1::date LIMIT 1`, [month])
if (distMvCheck.rows.length === 0) {
console.warn(`[distribution/reconciliation] mv_distribution_monthly 缺少 ${month} 数据,请通过ETL流程刷新物化视图`)
}
const [summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation] = await Promise.all([
// 汇总
const [summary, storeReconciliation, topVariances, unmatchedItems, categoryReconciliation, unmatchedStores, kitchenDetails] = await Promise.all([
// 汇总:区分中央厨房/门店,用采购金额做倒挤
query(`
WITH dist AS (
SELECT d.store_code, d.item_code,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
GROUP BY d.store_code, d.item_code
WITH kitchen AS (
SELECT
round(sum(opening_amount)::numeric,2) AS opening_amt,
round(sum(purchase_amount)::numeric,2) AS purchase_amt,
round(sum(consumption_amount)::numeric,2) AS consumption_amt,
round(sum(ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
AND store_code IN ${KITCHEN_CODES}
),
inv AS (
SELECT f.store_code, f.material_code,
round(sum(f.opening_quantity)::numeric,2) AS opening_qty,
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
round(sum(f.consumption_quantity)::numeric,2) AS consumption_qty,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_quantity)::numeric,2) AS ending_qty,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.store_code, f.material_code
stores AS (
SELECT
round(sum(opening_amount)::numeric,2) AS opening_amt,
round(sum(purchase_amount)::numeric,2) AS purchase_amt,
round(sum(consumption_amount)::numeric,2) AS consumption_amt,
round(sum(ending_amount)::numeric,2) AS ending_amt,
count(*) AS total_lines,
count(*) FILTER(WHERE is_negative) AS neg_inventory_count
FROM analytics.fact_inventory_snapshot
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
AND store_code NOT IN ${KITCHEN_CODES}
),
recon AS (
SELECT COALESCE(d.store_code, i.store_code) AS store_code,
COALESCE(d.item_code, i.material_code) AS item_code,
COALESCE(d.dist_qty,0) AS dist_qty,
COALESCE(d.dist_amt,0) AS dist_amt,
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
COALESCE(i.opening_qty,0) AS opening_qty,
COALESCE(i.opening_amt,0) AS opening_amt,
COALESCE(i.consumption_qty,0) AS consumption_qty,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_qty,0) AS ending_qty,
COALESCE(i.ending_amt,0) AS ending_amt
FROM dist d FULL OUTER JOIN inv i ON d.store_code = i.store_code AND d.item_code = i.material_code
dist AS (
SELECT
round(sum(outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax,
round(sum(outbound_total_amount) FILTER (WHERE store_code NOT IN ${KITCHEN_CODES})::numeric,2) AS dist_to_stores_amt,
round(sum(outbound_total_amount) FILTER (WHERE store_code IN ('3','6'))::numeric,2) AS dist_to_kitchen_amt
FROM mv_distribution_monthly WHERE report_month = $1::date
AND total_quantity > 0 AND outbound_total_amount > 0
)
SELECT
count(*) AS total_lines,
count(*) FILTER(WHERE dist_qty > 0 AND consumption_qty > 0) AS matched_lines,
count(*) FILTER(WHERE dist_qty > 0 AND consumption_qty = 0) AS dist_only_lines,
count(*) FILTER(WHERE dist_qty = 0 AND consumption_qty > 0) AS inv_only_lines,
round(sum(dist_amt)::numeric,2) AS total_dist_amt,
round(sum(dist_cost_excl_tax)::numeric,2) AS total_dist_cost_excl_tax,
round(sum(opening_amt)::numeric,2) AS total_opening_amt,
round(sum(consumption_amt)::numeric,2) AS total_consumption_amt,
round(sum(ending_amt)::numeric,2) AS total_ending_amt,
round((sum(opening_amt) + sum(dist_amt) - sum(ending_amt))::numeric,2) AS reverse_consumption_amt,
round((sum(consumption_amt) - (sum(opening_amt) + sum(dist_amt) - sum(ending_amt)))::numeric,2) AS variance_amt,
round((sum(consumption_amt) - (sum(opening_amt) + sum(dist_amt) - sum(ending_amt))) / NULLIF(sum(consumption_amt),0) * 100::numeric,2) AS variance_pct
FROM recon
k.opening_amt AS kitchen_opening,
k.purchase_amt AS kitchen_purchase,
k.consumption_amt AS kitchen_consumption,
k.ending_amt AS kitchen_ending,
s.opening_amt AS store_opening,
s.purchase_amt AS store_purchase,
s.consumption_amt AS store_consumption,
s.ending_amt AS store_ending,
s.total_lines AS store_total_lines,
s.neg_inventory_count AS store_neg_inventory_count,
d.dist_amt AS total_dist_amt,
d.dist_cost_excl_tax AS total_dist_cost_excl_tax,
d.dist_to_stores_amt AS dist_to_stores_amt,
d.dist_to_kitchen_amt AS dist_to_kitchen_amt,
-- 门店倒挤:期初 + 采购 - 期末 = 应耗用(库存自洽,必然等于实际耗用)
round((s.opening_amt + s.purchase_amt - s.ending_amt)::numeric,2) AS store_reverse_consumption,
-- 配送vs采购差异:配送发了多少 vs 门店入账了多少
round((d.dist_to_stores_amt - s.purchase_amt)::numeric,2) AS dist_purchase_variance_amt,
round((d.dist_to_stores_amt - s.purchase_amt) / NULLIF(d.dist_to_stores_amt,0) * 100::numeric,2) AS dist_purchase_variance_pct,
-- 中央厨房库存变动 = 期末 - 期初
round((k.ending_amt - k.opening_amt)::numeric,2) AS kitchen_inventory_change,
-- 中央厨房净加工损耗 = 加工投入 - 总配送出库(含发往加工车间/分仓)
round((k.consumption_amt - d.dist_amt)::numeric,2) AS kitchen_loss,
-- 真实总耗用 = 门店最终耗用 + 中央厨房净加工损耗
round((s.consumption_amt + (k.consumption_amt - d.dist_amt))::numeric,2) AS real_total_consumption
FROM kitchen k, stores s, dist d
`, [month]),
// 门店维度对账
// 门店维度对账(排除中央厨房,用采购金额做倒挤)
query(`
WITH dist AS (
SELECT d.store_code,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
GROUP BY d.store_code
),
inv AS (
SELECT f.store_code,
WITH inv AS (
SELECT LPAD(f.store_code, 4, '0') AS store_code,
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
round(sum(f.purchase_amount)::numeric,2) AS purchase_amt,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_amount)::numeric,2) AS ending_amt,
count(*) FILTER(WHERE f.is_negative) AS neg_inventory_count
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.store_code
AND f.store_code NOT IN ${KITCHEN_CODES}
GROUP BY LPAD(f.store_code, 4, '0')
),
dist AS (
SELECT LPAD(d.store_code, 4, '0') AS store_code,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
GROUP BY LPAD(d.store_code, 4, '0')
),
store_names AS (
SELECT DISTINCT store_code, store_name FROM mv_distribution_monthly
WHERE report_month = $1::date
SELECT DISTINCT LPAD(store_code, 4, '0') AS store_code, store_name FROM mv_distribution_monthly
WHERE report_month = $1::date AND store_code NOT IN ${KITCHEN_CODES}
)
SELECT COALESCE(d.store_code, i.store_code) AS store_code,
sn.store_name,
@@ -2054,46 +2069,52 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
COALESCE(d.dist_amt,0) AS dist_amt,
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
COALESCE(i.opening_amt,0) AS opening_amt,
COALESCE(i.purchase_amt,0) AS purchase_amt,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_amt,0) AS ending_amt,
round((COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0)))::numeric,2) AS variance_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))) / NULLIF(COALESCE(i.consumption_amt,0),0) * 100::numeric,2) AS variance_pct,
COALESCE(i.neg_inventory_count,0) AS neg_inventory_count
round((COALESCE(i.opening_amt,0) + COALESCE(i.purchase_amt,0) - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
round((COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0))::numeric,2) AS variance_amt,
round((COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0)) / NULLIF(COALESCE(d.dist_amt,0),0) * 100::numeric,2) AS variance_pct,
COALESCE(i.neg_inventory_count,0) AS neg_inventory_count,
CASE WHEN d.store_code IS NOT NULL AND i.store_code IS NULL THEN true ELSE false END AS is_unmatched
FROM dist d FULL OUTER JOIN inv i ON d.store_code = i.store_code
LEFT JOIN store_names sn ON COALESCE(d.store_code, i.store_code) = sn.store_code
ORDER BY abs(COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + COALESCE(d.dist_amt,0) - COALESCE(i.ending_amt,0))) DESC
ORDER BY is_unmatched DESC, abs(COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0)) DESC
`, [month]),
// Top差异品项
// Top差异品项(排除中央厨房,用采购金额做倒挤)
query(`
WITH dist AS (
SELECT d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
GROUP BY d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
),
inv AS (
SELECT f.store_code, f.material_code,
WITH inv AS (
SELECT LPAD(f.store_code, 4, '0') AS store_code, f.material_code,
round(sum(f.opening_amount)::numeric,2) AS opening_amt,
round(sum(f.purchase_amount)::numeric,2) AS purchase_amt,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
GROUP BY f.store_code, f.material_code
AND f.store_code NOT IN ${KITCHEN_CODES}
GROUP BY LPAD(f.store_code, 4, '0'), f.material_code
),
dist AS (
SELECT LPAD(d.store_code, 4, '0') AS store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
round(sum(d.total_quantity)::numeric,2) AS dist_qty,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
GROUP BY LPAD(d.store_code, 4, '0'), d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
)
SELECT d.store_code, d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
d.dist_qty, d.dist_amt,
COALESCE(i.opening_amt,0) AS opening_amt,
COALESCE(i.purchase_amt,0) AS purchase_amt,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.ending_amt,0) AS ending_amt,
round((COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0)))::numeric,2) AS variance_amt,
round((COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))) / NULLIF(COALESCE(i.consumption_amt,0),0) * 100::numeric,2) AS variance_pct
round((COALESCE(i.opening_amt,0) + COALESCE(i.purchase_amt,0) - COALESCE(i.ending_amt,0))::numeric,2) AS reverse_consumption_amt,
round((d.dist_amt - COALESCE(i.purchase_amt,0))::numeric,2) AS variance_amt,
round((d.dist_amt - COALESCE(i.purchase_amt,0)) / NULLIF(d.dist_amt,0) * 100::numeric,2) AS variance_pct
FROM dist d LEFT JOIN inv i ON d.store_code = i.store_code AND d.item_code = i.material_code
WHERE COALESCE(i.consumption_amt,0) > 0
ORDER BY abs(COALESCE(i.consumption_amt,0) - (COALESCE(i.opening_amt,0) + d.dist_amt - COALESCE(i.ending_amt,0))) DESC
WHERE d.dist_amt > 0
ORDER BY abs(d.dist_amt - COALESCE(i.purchase_amt,0)) DESC
LIMIT 30
`, [month]),
// 未匹配品项(有配送无库存耗用)
@@ -2104,12 +2125,15 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
count(DISTINCT d.store_code) AS store_count
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
GROUP BY d.item_code, d.item_name, d.unit, d.major_category, d.minor_category
),
inv_items AS (
SELECT DISTINCT material_code FROM analytics.fact_inventory_snapshot
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
AND store_code NOT IN ${KITCHEN_CODES}
)
SELECT d.item_code, d.item_name, d.unit, d.major_category, d.minor_category,
d.dist_qty, d.dist_amt, d.store_count
@@ -2118,7 +2142,7 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
ORDER BY d.dist_amt DESC
LIMIT 20
`, [month]),
// 品类维度对账
// 品类维度对账(排除中央厨房)
query(`
WITH dist AS (
SELECT COALESCE(NULLIF(d.minor_category,''),'未分类') AS minor_category,
@@ -2127,22 +2151,25 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
round(sum(d.cost_excl_tax_amount)::numeric,2) AS dist_cost_excl_tax,
count(DISTINCT d.item_code) AS item_count
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
GROUP BY COALESCE(NULLIF(d.minor_category,''),'未分类')
),
item_cat AS (
SELECT DISTINCT ic.item_code, COALESCE(NULLIF(dm.minor_category,''), NULLIF(ic.minor_category,''),'未分类') AS minor_category
FROM mv_distribution_monthly ic
LEFT JOIN analytics.dim_material dm ON ic.item_code = dm.material_code
WHERE ic.report_month = $1::date
WHERE ic.report_month = $1::date AND ic.store_code NOT IN ${KITCHEN_CODES}
),
inv AS (
SELECT ic.minor_category,
round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.purchase_amount)::numeric,2) AS purchase_amt,
round(sum(f.ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot f
JOIN item_cat ic ON f.material_code = ic.item_code
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
AND f.store_code NOT IN ${KITCHEN_CODES}
GROUP BY ic.minor_category
)
SELECT COALESCE(d.minor_category, i.minor_category) AS minor_category,
@@ -2151,13 +2178,65 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
COALESCE(d.dist_amt,0) AS dist_amt,
COALESCE(d.dist_cost_excl_tax,0) AS dist_cost_excl_tax,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.purchase_amt,0) AS purchase_amt,
COALESCE(i.ending_amt,0) AS ending_amt,
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0) + COALESCE(i.ending_amt,0))::numeric,2) AS reverse_opening_amt,
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0))::numeric,2) AS variance_amt,
round((COALESCE(i.consumption_amt,0) - COALESCE(d.dist_amt,0)) / NULLIF(COALESCE(d.dist_amt,0),0) * 100::numeric,2) AS variance_pct
round((COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0))::numeric,2) AS variance_amt,
round((COALESCE(d.dist_amt,0) - COALESCE(i.purchase_amt,0)) / NULLIF(COALESCE(d.dist_amt,0),0) * 100::numeric,2) AS variance_pct
FROM dist d FULL OUTER JOIN inv i ON d.minor_category = i.minor_category
ORDER BY COALESCE(d.dist_amt,0) DESC
`, [month]),
// 未入账门店(有配送无库存记录)
query(`
WITH dist AS (
SELECT LPAD(d.store_code, 4, '0') AS store_code, max(d.store_name) AS store_name,
round(sum(d.outbound_total_amount)::numeric,2) AS dist_amt,
count(DISTINCT d.item_code) AS item_count
FROM mv_distribution_monthly d
WHERE d.report_month = $1::date AND d.store_code NOT IN ${KITCHEN_CODES}
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
GROUP BY LPAD(d.store_code, 4, '0')
)
SELECT d.store_code, d.store_name, d.dist_amt, d.item_count
FROM dist d
WHERE d.store_code NOT IN (
SELECT DISTINCT LPAD(store_code, 4, '0') FROM analytics.fact_inventory_snapshot
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
AND store_code NOT IN ${KITCHEN_CODES}
)
ORDER BY d.dist_amt DESC
`, [month]),
// 中央厨房/加工车间/分仓 库存平衡明细
query(`
WITH inv AS (
SELECT store_code,
round(sum(opening_amount)::numeric,2) AS opening_amt,
round(sum(purchase_amount)::numeric,2) AS purchase_amt,
round(sum(consumption_amount)::numeric,2) AS consumption_amt,
round(sum(ending_amount)::numeric,2) AS ending_amt
FROM analytics.fact_inventory_snapshot
WHERE snapshot_date >= $1::date AND snapshot_date < ($1::date + INTERVAL '1 month')
AND store_code IN ${KITCHEN_CODES}
GROUP BY store_code
),
dist AS (
SELECT store_code,
round(sum(outbound_total_amount)::numeric,2) AS dist_received_amt
FROM mv_distribution_monthly
WHERE report_month = $1::date AND store_code IN ${KITCHEN_CODES}
GROUP BY store_code
)
SELECT i.store_code,
CASE i.store_code WHEN '2' THEN '中央厨房' WHEN '3' THEN '加工车间' WHEN '6' THEN '杭州分仓' ELSE i.store_code END AS store_name,
i.opening_amt,
COALESCE(d.dist_received_amt,0) AS dist_received_amt,
i.purchase_amt,
i.consumption_amt,
i.ending_amt,
round((i.opening_amt + COALESCE(d.dist_received_amt,0) - i.consumption_amt - i.ending_amt)::numeric,2) AS balance
FROM inv i
LEFT JOIN dist d ON i.store_code = d.store_code
ORDER BY CASE i.store_code WHEN '2' THEN 1 WHEN '3' THEN 2 WHEN '6' THEN 3 ELSE 9 END
`, [month]),
])
const s = summary.rows[0] as any
@@ -2166,18 +2245,33 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
sendSuccess(res, {
summary: {
total_lines: parseIntSafe(s?.total_lines),
matched_lines: parseIntSafe(s?.matched_lines),
dist_only_lines: parseIntSafe(s?.dist_only_lines),
inv_only_lines: parseIntSafe(s?.inv_only_lines),
// 中央厨房
kitchen_opening: parseNum(s?.kitchen_opening),
kitchen_purchase: parseNum(s?.kitchen_purchase),
kitchen_consumption: parseNum(s?.kitchen_consumption),
kitchen_ending: parseNum(s?.kitchen_ending),
// 门店
store_opening: parseNum(s?.store_opening),
store_purchase: parseNum(s?.store_purchase),
store_consumption: parseNum(s?.store_consumption),
store_ending: parseNum(s?.store_ending),
store_total_lines: parseIntSafe(s?.store_total_lines),
store_neg_inventory_count: parseIntSafe(s?.store_neg_inventory_count),
store_reverse_consumption: parseNum(s?.store_reverse_consumption),
store_variance_amt: parseNum(s?.store_variance_amt),
store_variance_pct: parseNum(s?.store_variance_pct),
// 配送
total_dist_amt: parseNum(s?.total_dist_amt),
total_dist_cost_excl_tax: parseNum(s?.total_dist_cost_excl_tax),
total_opening_amt: parseNum(s?.total_opening_amt),
total_consumption_amt: parseNum(s?.total_consumption_amt),
total_ending_amt: parseNum(s?.total_ending_amt),
reverse_consumption_amt: parseNum(s?.reverse_consumption_amt),
variance_amt: parseNum(s?.variance_amt),
variance_pct: parseNum(s?.variance_pct),
dist_to_stores_amt: parseNum(s?.dist_to_stores_amt),
dist_to_kitchen_amt: parseNum(s?.dist_to_kitchen_amt),
// 中央厨房库存变动与加工损耗
kitchen_inventory_change: parseNum(s?.kitchen_inventory_change),
kitchen_loss: parseNum(s?.kitchen_loss),
real_total_consumption: parseNum(s?.real_total_consumption),
// 配送vs采购差异
dist_purchase_variance_amt: parseNum(s?.dist_purchase_variance_amt),
dist_purchase_variance_pct: parseNum(s?.dist_purchase_variance_pct),
},
storeReconciliation: storeReconciliation.rows.map((row: any) => ({
...row,
@@ -2185,18 +2279,21 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
dist_amt: parseNum(row.dist_amt),
dist_cost_excl_tax: parseNum(row.dist_cost_excl_tax),
opening_amt: parseNum(row.opening_amt),
purchase_amt: parseNum(row.purchase_amt),
consumption_amt: parseNum(row.consumption_amt),
ending_amt: parseNum(row.ending_amt),
reverse_consumption_amt: parseNum(row.reverse_consumption_amt),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
neg_inventory_count: parseIntSafe(row.neg_inventory_count),
is_unmatched: row.is_unmatched || false,
})),
topVariances: topVariances.rows.map((row: any) => ({
...row,
dist_qty: parseNum(row.dist_qty),
dist_amt: parseNum(row.dist_amt),
opening_amt: parseNum(row.opening_amt),
purchase_amt: parseNum(row.purchase_amt),
consumption_amt: parseNum(row.consumption_amt),
ending_amt: parseNum(row.ending_amt),
reverse_consumption_amt: parseNum(row.reverse_consumption_amt),
@@ -2209,6 +2306,20 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
dist_amt: parseNum(row.dist_amt),
store_count: parseIntSafe(row.store_count),
})),
unmatchedStores: unmatchedStores.rows.map((row: any) => ({
...row,
dist_amt: parseNum(row.dist_amt),
item_count: parseIntSafe(row.item_count),
})),
kitchenDetails: kitchenDetails.rows.map((row: any) => ({
...row,
opening_amt: parseNum(row.opening_amt),
dist_received_amt: parseNum(row.dist_received_amt),
purchase_amt: parseNum(row.purchase_amt),
consumption_amt: parseNum(row.consumption_amt),
ending_amt: parseNum(row.ending_amt),
balance: parseNum(row.balance),
})),
categoryReconciliation: categoryReconciliation.rows.map((row: any) => ({
...row,
item_count: parseIntSafe(row.item_count),
@@ -2216,8 +2327,8 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
dist_amt: parseNum(row.dist_amt),
dist_cost_excl_tax: parseNum(row.dist_cost_excl_tax),
consumption_amt: parseNum(row.consumption_amt),
purchase_amt: parseNum(row.purchase_amt),
ending_amt: parseNum(row.ending_amt),
reverse_opening_amt: parseNum(row.reverse_opening_amt),
variance_amt: parseNum(row.variance_amt),
variance_pct: parseNum(row.variance_pct),
})),
@@ -2227,6 +2338,55 @@ router.get('/distribution/reconciliation', async (req: AuthRequest, res) => {
}
})
// 未匹配品项的门店配送明细
router.get('/distribution/unmatched-item-details', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const itemCode = (req.query.item_code as string) || ''
if (!itemCode) return sendError(res, 'item_code is required')
const KITCHEN_CODES = "('2','3','6')"
const rows = await query(`
SELECT d.store_code, d.store_name, d.item_code, d.item_name, d.unit,
d.major_category, d.minor_category,
round(d.total_quantity::numeric,2) AS dist_qty,
round(d.outbound_total_amount::numeric,2) AS dist_amt,
round(d.cost_excl_tax_amount::numeric,2) AS dist_cost_excl_tax,
COALESCE(i.consumption_amt,0) AS consumption_amt,
COALESCE(i.purchase_amt,0) AS purchase_amt,
CASE WHEN i.material_code IS NOT NULL THEN true ELSE false END AS has_inventory
FROM mv_distribution_monthly d
LEFT JOIN LATERAL (
SELECT round(sum(f.consumption_amount)::numeric,2) AS consumption_amt,
round(sum(f.purchase_amount)::numeric,2) AS purchase_amt,
f.material_code
FROM analytics.fact_inventory_snapshot f
WHERE f.snapshot_date >= $1::date AND f.snapshot_date < ($1::date + INTERVAL '1 month')
AND LPAD(f.store_code, 4, '0') = LPAD(d.store_code, 4, '0')
AND f.material_code = d.item_code
GROUP BY f.material_code
) i ON true
WHERE d.report_month = $1::date
AND d.item_code = $2
AND d.store_code NOT IN ${KITCHEN_CODES}
AND d.total_quantity > 0 AND d.outbound_total_amount > 0
ORDER BY d.outbound_total_amount DESC
`, [month, itemCode])
sendSuccess(res, rows.rows.map((row: any) => ({
...row,
dist_qty: parseFloat(row.dist_qty) || 0,
dist_amt: parseFloat(row.dist_amt) || 0,
dist_cost_excl_tax: parseFloat(row.dist_cost_excl_tax) || 0,
consumption_amt: parseFloat(row.consumption_amt) || 0,
purchase_amt: parseFloat(row.purchase_amt) || 0,
has_inventory: row.has_inventory || false,
})))
} catch (err: any) {
sendError(res, err.message)
}
})
// 多级BOM成本穿透
router.get('/central-kitchen/bom-penetration', async (req: AuthRequest, res) => {
try {
+1 -1
View File
@@ -319,7 +319,7 @@ router.get('/achievement', async (req: AuthRequest, res) => {
t.bill_count_target,
COALESCE(s.bill_count, 0) AS actual_bill_count,
CASE WHEN t.bill_count_target > 0
THEN ROUND(COALESCE(s.bill_count, 0) / t.bill_count_target * 100, 2)
THEN ROUND(COALESCE(s.bill_count, 0)::numeric / t.bill_count_target * 100, 2)
ELSE 0 END AS bill_achievement_rate
FROM analytics.v3_store_monthly_target t
LEFT JOIN analytics.mv_store_risk_rating_monthly s
+17 -1
View File
@@ -264,12 +264,28 @@ router.post('/indicators', async (req: AuthRequest, res) => {
router.get('/loop-health', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.mv_loop_health`)
sendSuccess(res, result.rows[0] || {
const loopHealth = result.rows[0] || {
task_generation_rate: 0,
store_execution_rate: 0,
weekly_check_rate: 0,
monthly_review_rate: 0,
practice_promotion_rate: 0,
}
// 信号发现数:最新业务月中红色+黄色风险门店数
const signalResult = await query(`
SELECT count(*) AS cnt FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = (SELECT max(month_start) FROM analytics.mv_store_risk_rating_monthly WHERE received IS NOT NULL)
AND risk_level IN ('红色', '黄色')
`)
// 经验标准化数:standardized_practice 表记录数
const practiceResult = await query(`SELECT count(*) AS cnt FROM analytics.standardized_practice`)
sendSuccess(res, {
...loopHealth,
signal_count: parseInt(signalResult.rows[0].cnt),
practice_count: parseInt(practiceResult.rows[0].cnt),
})
} catch (err: any) {
sendError(res, err.message)
+29 -9
View File
@@ -55,15 +55,26 @@ async function dailyTargetCard() {
async function hourlyAchievementCheck() {
const today = new Date().toISOString().slice(0, 10)
// 查最新有业务数据的月份(业务数据可能滞后于当前月)
const latestMonthResult = await query(`
SELECT month_start FROM analytics.mv_store_risk_rating_monthly
WHERE received IS NOT NULL ORDER BY month_start DESC LIMIT 1
`)
if (latestMonthResult.rows.length === 0) {
console.log('[hourlyAchievementCheck] No business data available, skipping')
return
}
const latestMonth = latestMonthResult.rows[0].month_start
const targets = await query(`
SELECT dt.store_code, dt.store_name, dt.revenue_target,
COALESCE(s.received, 0) AS actual_revenue
FROM analytics.v3_daily_target dt
LEFT JOIN analytics.mv_store_risk_rating_monthly s
ON s.store_code = dt.store_code
AND s.month_start = DATE_TRUNC('month', $1::date)::date
AND s.month_start = $2::date
WHERE dt.target_date = $1::date
`, [today])
`, [today, latestMonth])
for (const row of targets.rows) {
const achievement = Number(row.revenue_target) > 0
@@ -83,7 +94,7 @@ async function hourlyAchievementCheck() {
}
}
console.log(`[hourlyAchievementCheck] Checked ${targets.rows.length} stores`)
console.log(`[hourlyAchievementCheck] Checked ${targets.rows.length} stores (business month: ${latestMonth})`)
}
// T-125: 每日22:30 生成日复盘模板
@@ -118,6 +129,17 @@ async function alertEngineScan() {
WHERE is_enabled = true
`)
// 查最新有业务数据的月份(业务数据可能滞后于当前月)
const latestMonthResult = await query(`
SELECT month_start FROM analytics.mv_store_risk_rating_monthly
WHERE received IS NOT NULL ORDER BY month_start DESC LIMIT 1
`)
if (latestMonthResult.rows.length === 0) {
console.log('[alertEngineScan] No business data available, skipping')
return
}
const latestMonth = latestMonthResult.rows[0].month_start
for (const rule of rules.rows) {
let metricData: { store_code: string; store_name: string; value: number }[] = []
@@ -131,25 +153,23 @@ async function alertEngineScan() {
FROM analytics.v3_daily_target dt
LEFT JOIN analytics.mv_store_risk_rating_monthly s
ON s.store_code = dt.store_code
AND s.month_start = DATE_TRUNC('month', $1::date)::date
AND s.month_start = $2::date
WHERE dt.target_date = $1::date
`, [today])
`, [today, latestMonth])
metricData = result.rows
} else if (rule.metric === 'theoretical_margin_pct') {
const monthStart = new Date().toISOString().slice(0, 8) + '01'
const result = await query(`
SELECT store_code, store_name, COALESCE(theoretical_margin_pct, 0) AS value
FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = $1::date
`, [monthStart])
`, [latestMonth])
metricData = result.rows
} else if (rule.metric === 'member_bill_share_pct') {
const monthStart = new Date().toISOString().slice(0, 8) + '01'
const result = await query(`
SELECT store_code, store_name, COALESCE(member_bill_share_pct, 0) AS value
FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = $1::date
`, [monthStart])
`, [latestMonth])
metricData = result.rows
}