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:
@@ -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
@@ -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: '状态' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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> = (实际材料 - 理论材料) / 理论材料,负值表示节约(实际 < 理论)<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>
|
||||
)
|
||||
}} />
|
||||
|
||||
@@ -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> 灰色(口径异常,理论成本<消费额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> 差异为负 → 门店有直采(采购 > 配送,正常)| 差异为正 → 配送未全部入账(需关注)| 差异接近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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}},
|
||||
]}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user