= {
+ '高峰人手不足': 'bg-red-100 text-red-700 border-red-300',
+ '低谷人员冗余': 'bg-orange-100 text-orange-700 border-orange-300',
+ '无在岗数据': 'bg-gray-100 text-gray-600 border-gray-300',
+ '配置合理': 'bg-green-100 text-green-700 border-green-300',
+ }
+
+ // 散点图数据:成本-风险
+ const costRiskScatter = costRiskRows.map((r: any) => ({
+ name: r.store_name,
+ x: Number(r.avg_daily_received || 0),
+ y: Number(r.over_cost_rate || 0),
+ risk: r.risk_level,
+ fill: r.risk_level === '红色' ? '#ef4444' : r.risk_level === '黄色' ? '#eab308' : '#22c55e',
+ }))
+
+ return (
+
+ {/* 成本-风险散点图 */}
+
+
+
+
+ `${(v / 10000).toFixed(0)}万`} tick={{ fontSize: 10 }} />
+
+
+ n === '超耗率' ? `${Number(v).toFixed(1)}%` : n === '日均实收' ? `¥${Number(v).toFixed(0)}` : v} />
+
+
+
+
+
+
+ {/* 成本-风险明细表 */}
+
+ navigate(`/stores/${r.store_code}`)}
+ columns={[
+ { key: 'store_name', label: '门店' },
+ { key: 'risk_level', label: '风险', align: 'center', render: (r) => r.risk_level ? : '-' },
+ { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
+ { key: 'avg_margin', label: '毛利率', align: 'right', render: (r) => `${r.avg_margin}%` },
+ { key: 'over_cost_rate', label: '超耗率', align: 'right', render: (r) => `${r.over_cost_rate}%` },
+ { key: 'correlation_status', label: '关联状态', align: 'center', render: (r) => (
+
+ {r.correlation_status}
+
+ )},
+ ]}
+ />
+
+
+ {/* 渠道-会员关联 */}
+
+ navigate(`/stores/${r.store_code}`)}
+ columns={[
+ { key: 'store_name', label: '门店' },
+ { key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
+ { key: 'platform_share', label: '平台占比', align: 'right', render: (r) => `${r.platform_share}%` },
+ { key: 'member_share_pct', label: '会员占比', align: 'right', render: (r) => `${r.member_share_pct}%` },
+ { key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => `${r.repeat_rate_pct}%` },
+ { key: 'channel_status', label: '渠道状态', align: 'center', render: (r) => (
+
+ {r.channel_status}
+
+ )},
+ ]}
+ />
+
+
+ {/* 考勤-营收关联 */}
+
+ formatCurrency(r.received) },
+ { key: 'emp_count', label: '员工数', align: 'center' },
+ { key: 'avg_attend', label: '平均出勤', align: 'right', render: (r) => r.avg_attend },
+ { key: 'total_absent', label: '旷工天数', align: 'center' },
+ { key: 'low_attend_rate', label: '低出勤率', align: 'right', render: (r) => `${r.low_attend_rate}%` },
+ { key: 'revenue_per_emp', label: '人效', align: 'right', render: (r) => formatCurrency(r.revenue_per_emp) },
+ { key: 'hr_status', label: '状态', align: 'center', render: (r) => (
+
+ {r.hr_status}
+
+ )},
+ ]}
+ />
+
+
+ {/* 客流-人力匹配 */}
+
+ `${r.hour}:00` },
+ { key: 'bills', label: '账单数', align: 'right' },
+ { key: 'staff', label: '在岗人数', align: 'right' },
+ { key: 'bills_per_staff', label: '人均产出', align: 'right' },
+ { key: 'match_status', label: '匹配状态', align: 'center', render: (r) => (
+
+ {r.match_status}
+
+ )},
+ ]}
+ />
+
+
+ )
+}
+
+// ============ Tab 4: 趋势预测 ============
+
+function ForecastTab() {
+ const { data, isLoading } = useQuery({
+ queryKey: ['sa-forecast'],
+ queryFn: () => api.get('/situational-awareness/forecast'),
+ staleTime: 5 * 60 * 1000,
+ })
+
+ if (isLoading) return
+
+ const d = (data as any)?.data
+ const trafficRows = d?.traffic_forecast || []
+ const costTrendRows = d?.cost_trend || []
+ const turnoverRows = d?.turnover_alert || []
+
+ // 工作日/周末分开
+ const weekdayData = trafficRows.filter((r: any) => r.day_type === '工作日')
+ const weekendData = trafficRows.filter((r: any) => r.day_type === '周末')
+
+ const stabilityColor: Record = {
+ '波动大': 'bg-red-100 text-red-700 border-red-300',
+ '中等波动': 'bg-orange-100 text-orange-700 border-orange-300',
+ '稳定': 'bg-green-100 text-green-700 border-green-300',
+ }
+ const trendStatusColor: Record = {
+ '恶化': 'bg-red-100 text-red-700 border-red-300',
+ '关注': 'bg-orange-100 text-orange-700 border-orange-300',
+ '正常': 'bg-green-100 text-green-700 border-green-300',
+ '数据异常': 'bg-gray-100 text-gray-600 border-gray-300',
+ }
+
+ return (
+
+ {/* 客流预测图 */}
+
+
+
+
+ `${v}:00`} tick={{ fontSize: 10 }} />
+
+ `${v}:00`} />
+
+
+
+
+
+
+
+
+
+
+ `${v}:00`} tick={{ fontSize: 10 }} />
+
+ `${v}:00`} />
+
+
+
+
+
+
+
+
+ {/* 客流稳定性明细 */}
+
+ `${r.hour}:00` },
+ { key: 'avg_bills', label: '均值', align: 'right' },
+ { key: 'p50_bills', label: 'P50', align: 'right' },
+ { key: 'p85_bills', label: 'P85', align: 'right' },
+ { key: 'volatility', label: '波动率', align: 'right', render: (r) => r.volatility },
+ { key: 'stability', label: '稳定性', align: 'center', render: (r) => (
+
+ {r.stability}
+
+ )},
+ ]}
+ />
+
+
+ {/* 成本趋势 */}
+
+ formatCurrency(r.sales_amount) },
+ { key: 'theo_margin', label: '理论毛利率', align: 'right', render: (r) => `${r.theo_margin}%` },
+ { key: 'actual_margin', label: '实际毛利率', align: 'right', render: (r) => `${r.actual_margin}%` },
+ { key: 'variance', label: '差异金额', align: 'right', render: (r) => formatCurrency(r.variance) },
+ { key: 'trend_status', label: '趋势', align: 'center', render: (r) => (
+
+ {r.trend_status}
+
+ )},
+ ]}
+ />
+
+
+ {/* 人员流失预警 */}
+
+ (
+ 20 ? 'font-bold text-red-600' : Number(r.turnover_rate) > 10 ? 'text-orange-600' : ''}>
+ {r.turnover_rate}%
+
+ )},
+ ]}
+ />
+
+
+ )
+}
diff --git a/client/src/pages/StoreDetailPage.tsx b/client/src/pages/StoreDetailPage.tsx
index dc3534e..0309e05 100644
--- a/client/src/pages/StoreDetailPage.tsx
+++ b/client/src/pages/StoreDetailPage.tsx
@@ -126,10 +126,10 @@ export function StoreDetailPage() {
-
+ v?.substring(5, 10)} tick={{ fontSize: 11 }} />
-
-
+ v?.substring(0, 10)} formatter={(v: any) => formatCurrency(v)} />
+
diff --git a/server/src/index.ts b/server/src/index.ts
index 9c4790b..3de85b5 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -9,6 +9,7 @@ import taskRoutes from './routes/tasks.js'
import costAnalysisRoutes from './routes/cost-analysis.js'
import storeExpenseRoutes from './routes/store-expense.js'
import smartSchedulingRoutes from './routes/smart-scheduling.js'
+import situationalAwarenessRoutes from './routes/situational-awareness.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3333')
@@ -37,6 +38,7 @@ app.use('/api/tasks', taskRoutes)
app.use('/api/cost-analysis', costAnalysisRoutes)
app.use('/api/store-expense', storeExpenseRoutes)
app.use('/api/smart-scheduling', smartSchedulingRoutes)
+app.use('/api/situational-awareness', situationalAwarenessRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
diff --git a/server/src/routes/situational-awareness.ts b/server/src/routes/situational-awareness.ts
new file mode 100644
index 0000000..e1b6525
--- /dev/null
+++ b/server/src/routes/situational-awareness.ts
@@ -0,0 +1,458 @@
+import { Router } from 'express'
+import { query } from '../config/database.js'
+import { sendSuccess, sendError } from '../middleware/error.js'
+import type { AuthRequest } from '../middleware/auth.js'
+
+const router = Router()
+
+// ============ P0: 门店健康度综合评分 ============
+
+router.get('/health-score', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`
+ WITH base AS (
+ SELECT store_code, store_name, received, bill_count,
+ avg_bill_value, avg_daily_received, theoretical_margin_pct,
+ member_bill_share_pct, risk_level
+ FROM analytics.mv_store_risk_rating
+ WHERE received IS NOT NULL
+ ),
+ max_rev AS (
+ SELECT max(avg_daily_received) AS max_avg FROM base WHERE avg_daily_received > 0
+ ),
+ cost AS (
+ SELECT store_code,
+ round(sum(CASE WHEN variance_to_theoretical_pct > 0 THEN 1 ELSE 0 END)::numeric / nullif(count(*), 0) * 100, 1) AS over_cost_rate
+ FROM analytics.v_store_theoretical_actual_cost_april
+ GROUP BY store_code
+ ),
+ member AS (
+ SELECT store_code, repeat_rate_pct
+ FROM analytics.v_store_repeat_summary_monthly
+ ),
+ task AS (
+ SELECT store_code,
+ count(*) AS total_tasks,
+ count(*) FILTER (WHERE status = '已验收' AND verification_result = '达标') AS passed_tasks,
+ round(count(*) FILTER (WHERE status IN ('已验收', '已回滚'))::numeric / nullif(count(*), 0) * 100, 1) AS completion_rate
+ FROM analytics.store_task
+ GROUP BY store_code
+ ),
+ scored AS (
+ SELECT
+ b.store_code, b.store_name, b.received, b.bill_count, b.avg_bill_value,
+ b.risk_level,
+ COALESCE(b.theoretical_margin_pct, 0) AS avg_margin,
+ COALESCE(c.over_cost_rate, 0) AS over_cost_rate,
+ COALESCE(m.repeat_rate_pct, 0) AS repeat_rate_pct,
+ COALESCE(b.member_bill_share_pct, 0) AS member_share_pct,
+ COALESCE(t.completion_rate, 0) AS task_completion_rate,
+ LEAST(100, GREATEST(0,
+ 25 * CASE WHEN b.avg_daily_received > 0 THEN LEAST(1.0, b.avg_daily_received / mx.max_avg) ELSE 0 END
+ + 20 * GREATEST(0, LEAST(1.0, COALESCE(b.theoretical_margin_pct, 0) / 70)) * GREATEST(0, 1 - COALESCE(c.over_cost_rate, 0) / 100)
+ + CASE b.risk_level WHEN '绿色' THEN 20 WHEN '黄色' THEN 12 WHEN '红色' THEN 4 ELSE 10 END
+ + 10 * GREATEST(0, LEAST(1.0, COALESCE(m.repeat_rate_pct, 0) / 80))
+ + 10 * COALESCE(t.completion_rate, 0) / 100
+ + 10 * GREATEST(0, LEAST(1.0, COALESCE(b.member_bill_share_pct, 0) / 60))
+ ))::numeric(5,1) AS health_score
+ FROM base b
+ CROSS JOIN max_rev mx
+ LEFT JOIN cost c ON b.store_code = c.store_code
+ LEFT JOIN member m ON b.store_code = m.store_code
+ LEFT JOIN task t ON b.store_code = t.store_code
+ )
+ SELECT store_code, store_name, received, bill_count, avg_bill_value,
+ risk_level, avg_margin, over_cost_rate, repeat_rate_pct,
+ member_share_pct, task_completion_rate, health_score,
+ CASE WHEN health_score >= 75 THEN '健康'
+ WHEN health_score >= 55 THEN '亚健康'
+ ELSE '需干预'
+ END AS health_status
+ FROM scored
+ ORDER BY health_score DESC
+ `)
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ P0: 自动化阈值预警 ============
+
+router.get('/alerts', async (req: AuthRequest, res) => {
+ try {
+ const alerts: any[] = []
+
+ // 1. 营收异动预警:日营收连续低于月均70%
+ const revenueAlerts = await query(`
+ WITH daily_stats AS (
+ SELECT closed_at::date AS business_date,
+ count(*) AS bill_count,
+ sum(received_total) AS received
+ FROM analytics.bill_fact
+ WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.bill_fact)
+ AND closed_at IS NOT NULL
+ GROUP BY closed_at::date
+ ORDER BY business_date
+ ),
+ monthly_avg AS (
+ SELECT avg(received) AS avg_received
+ FROM daily_stats
+ )
+ SELECT d.business_date::text, d.received, m.avg_received,
+ round(d.received / nullif(m.avg_received, 0) * 100, 1) AS ratio
+ FROM daily_stats d, monthly_avg m
+ WHERE d.received < m.avg_received * 0.7
+ ORDER BY d.business_date DESC
+ LIMIT 10
+ `)
+ revenueAlerts.rows.forEach((r: any) => {
+ alerts.push({
+ type: 'revenue',
+ level: 'red',
+ title: `营收异动 ${r.business_date}`,
+ detail: `日营收 ¥${Number(r.received).toFixed(0)} 仅为月均的 ${r.ratio}%`,
+ store: '全局',
+ date: r.business_date,
+ })
+ })
+
+ // 2. 成本异动预警:成本差异>30%的菜品
+ const costAlerts = await query(`
+ SELECT dish_name, dish_code, category_level1,
+ round(cost_variance_amount::numeric, 2) AS variance,
+ round((cost_variance_amount / nullif(theoretical_cost, 0) * 100)::numeric, 2) AS variance_pct,
+ round(sales_amount::numeric, 2) AS sales_amount
+ FROM public.dish_cost_analysis_summary
+ WHERE cost_variance_amount > 0
+ AND theoretical_cost > 0
+ AND (cost_variance_amount / theoretical_cost) > 0.3
+ ORDER BY variance_pct DESC
+ LIMIT 10
+ `)
+ costAlerts.rows.forEach((r: any) => {
+ alerts.push({
+ type: 'cost',
+ level: 'red',
+ title: `成本异动 ${r.dish_name}`,
+ detail: `成本差异率 ${r.variance_pct}%,差异金额 ¥${r.variance}`,
+ store: '全局',
+ date: null,
+ })
+ })
+
+ // 3. 人力异动预警:考勤异常员工
+ const hrAlerts = await query(`
+ SELECT s.org_level5 AS store_name, count(*) AS alert_count,
+ count(*) FILTER (WHERE s.absent_days > 0) AS absent_count,
+ count(*) FILTER (WHERE s.late_deduction > 0 OR s.no_punch_deduction > 0) AS punch_issue_count
+ FROM salary_detail_records s
+ WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
+ AND (s.absent_days > 0 OR s.late_deduction > 0 OR s.no_punch_deduction > 0)
+ GROUP BY s.org_level5
+ HAVING count(*) > 3
+ ORDER BY alert_count DESC
+ LIMIT 10
+ `)
+ hrAlerts.rows.forEach((r: any) => {
+ alerts.push({
+ type: 'hr',
+ level: r.alert_count > 5 ? 'red' : 'orange',
+ title: `考勤异常 ${r.store_name}`,
+ detail: `异常员工 ${r.alert_count} 人(旷工 ${r.absent_count},打卡问题 ${r.punch_issue_count})`,
+ store: r.store_name,
+ date: null,
+ })
+ })
+
+ // 4. 平台依赖预警:外卖佣金占比>40%
+ const platformAlerts = await query(`
+ SELECT p.store_code, p.store_name,
+ round(p.meituan_cost_rate_pct::numeric, 1) AS meituan_rate,
+ round(p.taobao_cost_rate_pct::numeric, 1) AS taobao_rate,
+ round(p.jd_cost_rate_pct::numeric, 1) AS jd_rate,
+ round((COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) * 100, 1) AS platform_share
+ FROM analytics.v_store_platform_economics p
+ JOIN analytics.v_store_scorecard sc ON p.store_code = sc.store_code
+ WHERE (COALESCE(p.meituan_received, 0) + COALESCE(p.taobao_received, 0) + COALESCE(p.jd_received, 0)) / nullif(sc.received, 0) > 0.4
+ ORDER BY platform_share DESC
+ LIMIT 10
+ `)
+ platformAlerts.rows.forEach((r: any) => {
+ alerts.push({
+ type: 'platform',
+ level: 'orange',
+ title: `平台依赖 ${r.store_name}`,
+ detail: `外卖占比 ${r.platform_share}%,美团 ${r.meituan_rate}%/淘宝 ${r.taobao_rate}%/京东 ${r.jd_rate}%`,
+ store: r.store_name,
+ date: null,
+ })
+ })
+
+ // 5. 任务逾期预警
+ const taskAlerts = await query(`
+ SELECT store_code, store_name, priority, problem_indicator, deadline::text AS deadline,
+ current_date - deadline::date AS overdue_days
+ FROM analytics.store_task
+ WHERE status NOT IN ('已验收', '已回滚')
+ AND deadline < current_date
+ ORDER BY overdue_days DESC
+ LIMIT 10
+ `)
+ taskAlerts.rows.forEach((r: any) => {
+ alerts.push({
+ type: 'task',
+ level: r.overdue_days > 14 ? 'red' : 'orange',
+ title: `任务逾期 ${r.store_name}`,
+ detail: `${r.priority} - ${r.problem_indicator},逾期 ${r.overdue_days} 天`,
+ store: r.store_name,
+ date: r.deadline,
+ })
+ })
+
+ // 按级别排序
+ const levelOrder: Record = { red: 0, orange: 1, yellow: 2 }
+ alerts.sort((a, b) => (levelOrder[a.level] || 3) - (levelOrder[b.level] || 3))
+
+ sendSuccess(res, {
+ total: alerts.length,
+ red: alerts.filter(a => a.level === 'red').length,
+ orange: alerts.filter(a => a.level === 'orange').length,
+ alerts,
+ })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ P1: 跨模块关联分析 ============
+
+router.get('/correlation', async (req: AuthRequest, res) => {
+ try {
+ // 客流-人力匹配度:每门店每小时"每人在岗产出账单数"
+ // mv_store_hourly_staffing 可能不存在,容错处理
+ let staffingRows: any[] = []
+ try {
+ const staffingEfficiency = await query(`
+ WITH hourly_bills AS (
+ SELECT store_name, hour, sum(bills) AS bills
+ FROM mv_bill_hourly
+ GROUP BY store_name, hour
+ ),
+ hourly_staff AS (
+ SELECT store_name, hour, total_staff
+ FROM mv_store_hourly_staffing
+ WHERE total_staff > 0
+ )
+ SELECT
+ COALESCE(b.store_name, s.store_name) AS store_name,
+ COALESCE(b.hour, s.hour) AS hour,
+ COALESCE(b.bills, 0) AS bills,
+ COALESCE(s.total_staff, 0) AS staff,
+ round(COALESCE(b.bills, 0)::numeric / nullif(COALESCE(s.total_staff, 0), 0), 1) AS bills_per_staff,
+ CASE
+ WHEN COALESCE(s.total_staff, 0) = 0 THEN '无在岗数据'
+ WHEN COALESCE(b.bills, 0) > 0 AND COALESCE(b.bills, 0)::numeric / COALESCE(s.total_staff, 0) > 15 THEN '高峰人手不足'
+ WHEN COALESCE(b.bills, 0) = 0 AND COALESCE(s.total_staff, 0) > 3 THEN '低谷人员冗余'
+ ELSE '配置合理'
+ END AS match_status
+ FROM hourly_bills b
+ FULL OUTER JOIN hourly_staff s ON b.store_name = s.store_name AND b.hour = s.hour
+ WHERE COALESCE(b.bills, 0) > 0 OR COALESCE(s.total_staff, 0) > 0
+ ORDER BY COALESCE(b.store_name, s.store_name), COALESCE(b.hour, s.hour)
+ `)
+ staffingRows = staffingEfficiency.rows
+ } catch {
+ staffingRows = []
+ }
+
+ // 门店维度汇总:成本-风险关联(用mv_store_risk_rating替代v_store_scorecard)
+ const costRisk = await query(`
+ SELECT
+ r.store_code, r.store_name, r.risk_level,
+ r.received, r.avg_daily_received,
+ COALESCE(r.theoretical_margin_pct, 0) AS avg_margin,
+ COALESCE(c.over_cost_rate, 0) AS over_cost_rate,
+ CASE
+ WHEN r.risk_level = '红色' AND COALESCE(c.over_cost_rate, 0) > 30 THEN '成本失控+高风险'
+ WHEN r.risk_level = '红色' THEN '高风险'
+ WHEN r.risk_level = '黄色' AND COALESCE(c.over_cost_rate, 0) > 20 THEN '成本偏高+中风险'
+ WHEN COALESCE(c.over_cost_rate, 0) > 30 THEN '成本失控'
+ ELSE '正常'
+ END AS correlation_status
+ FROM analytics.mv_store_risk_rating r
+ LEFT JOIN (
+ SELECT store_code,
+ round(sum(CASE WHEN variance_to_theoretical_pct > 0 THEN 1 ELSE 0 END)::numeric / nullif(count(*), 0) * 100, 1) AS over_cost_rate
+ FROM analytics.v_store_theoretical_actual_cost_april
+ GROUP BY store_code
+ ) c ON r.store_code = c.store_code
+ WHERE r.received IS NOT NULL
+ ORDER BY
+ CASE WHEN r.risk_level = '红色' AND COALESCE(c.over_cost_rate, 0) > 30 THEN 0
+ WHEN r.risk_level = '红色' THEN 1
+ WHEN r.risk_level = '黄色' AND COALESCE(c.over_cost_rate, 0) > 20 THEN 2
+ ELSE 3 END,
+ r.received DESC NULLS LAST
+ `)
+
+ // 会员-平台-营收三角(用mv_store_risk_rating替代v_store_scorecard)
+ const channelRisk = await query(`
+ SELECT
+ r.store_code, r.store_name, r.received,
+ COALESCE(p.platform_share, 0) AS platform_share,
+ COALESCE(r.member_bill_share_pct, 0) AS member_share_pct,
+ COALESCE(m.repeat_rate_pct, 0) AS repeat_rate_pct,
+ CASE
+ WHEN COALESCE(p.platform_share, 0) > 40 AND COALESCE(r.member_bill_share_pct, 0) < 20 THEN '渠道依赖预警'
+ WHEN COALESCE(p.platform_share, 0) > 30 AND COALESCE(r.member_bill_share_pct, 0) < 30 THEN '渠道风险'
+ WHEN COALESCE(r.member_bill_share_pct, 0) > 50 THEN '会员驱动型'
+ ELSE '均衡型'
+ END AS channel_status
+ FROM analytics.mv_store_risk_rating r
+ LEFT JOIN (
+ SELECT pe.store_code,
+ round((COALESCE(pe.meituan_received, 0) + COALESCE(pe.taobao_received, 0) + COALESCE(pe.jd_received, 0)) / nullif(r2.received, 0) * 100, 1) AS platform_share
+ FROM analytics.v_store_platform_economics pe
+ JOIN analytics.mv_store_risk_rating r2 ON pe.store_code = r2.store_code
+ ) p ON r.store_code = p.store_code
+ LEFT JOIN analytics.v_store_repeat_summary_monthly m ON r.store_code = m.store_code
+ WHERE r.received IS NOT NULL
+ ORDER BY
+ CASE WHEN COALESCE(p.platform_share, 0) > 40 AND COALESCE(r.member_bill_share_pct, 0) < 20 THEN 0 ELSE 1 END,
+ r.received DESC
+ `)
+
+ // 考勤-营收关联
+ const hrRevenue = await query(`
+ WITH hr AS (
+ SELECT s.org_level5 AS store_name,
+ count(*) AS emp_count,
+ round(avg(s.actual_attend)::numeric, 1) AS avg_attend,
+ round(sum(s.absent_days)::numeric, 0) AS total_absent,
+ round(count(*) FILTER (WHERE s.actual_attend / nullif(s.expected_attend, 0) < 0.8)::numeric / nullif(count(*), 0) * 100, 1) AS low_attend_rate
+ FROM salary_detail_records s
+ WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != ''
+ GROUP BY s.org_level5
+ ),
+ rev AS (
+ SELECT store_name, received, bill_count, avg_bill_value
+ FROM analytics.v_store_scorecard
+ WHERE received IS NOT NULL
+ ),
+ mapping AS (
+ SELECT salary_name, bill_name FROM public.store_name_mapping
+ )
+ SELECT
+ COALESCE(r.store_name, h.store_name) AS store_name,
+ COALESCE(r.received, 0) AS received,
+ COALESCE(r.bill_count, 0) AS bill_count,
+ COALESCE(h.emp_count, 0) AS emp_count,
+ COALESCE(h.avg_attend, 0) AS avg_attend,
+ COALESCE(h.total_absent, 0) AS total_absent,
+ COALESCE(h.low_attend_rate, 0) AS low_attend_rate,
+ round(COALESCE(r.received, 0) / nullif(COALESCE(h.emp_count, 0), 0), 0) AS revenue_per_emp,
+ CASE
+ WHEN COALESCE(h.low_attend_rate, 0) > 30 AND COALESCE(r.received, 0) > 0 THEN '出勤低+有营收'
+ WHEN COALESCE(h.low_attend_rate, 0) > 30 THEN '出勤低+营收低'
+ WHEN COALESCE(h.total_absent, 0) > 10 THEN '旷工严重'
+ ELSE '正常'
+ END AS hr_status
+ FROM hr h
+ LEFT JOIN mapping mp ON mp.salary_name = h.store_name
+ LEFT JOIN rev r ON r.store_name = mp.bill_name
+ ORDER BY
+ CASE WHEN COALESCE(h.low_attend_rate, 0) > 30 THEN 0 ELSE 1 END,
+ COALESCE(h.total_absent, 0) DESC
+ `)
+
+ sendSuccess(res, {
+ staffing_efficiency: staffingRows,
+ cost_risk: costRisk.rows,
+ channel_risk: channelRisk.rows,
+ hr_revenue: hrRevenue.rows,
+ })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============ P2: 趋势预测 ============
+
+router.get('/forecast', async (req: AuthRequest, res) => {
+ try {
+ // 客流预测:基于历史4周小时数据,按工作日/周末+小时维度计算P85
+ const trafficForecast = await query(`
+ WITH daily_hourly AS (
+ SELECT extract(hour FROM c175::timestamp)::int AS hour,
+ CASE WHEN extract(dow FROM c175::timestamp)::int IN (0, 6) THEN '周末' ELSE '工作日' END AS day_type,
+ DATE(c175::timestamp) AS bill_date,
+ count(*) AS bills
+ FROM bill_records
+ WHERE c175 IS NOT NULL AND c175 != ''
+ AND c175 >= '2026/04/01' AND c175 < '2026/05/01'
+ GROUP BY hour, day_type, bill_date
+ ),
+ hourly_stats AS (
+ SELECT hour, day_type,
+ round(avg(bills)::numeric, 0)::int AS avg_bills,
+ round(percentile_cont(0.85) WITHIN GROUP (ORDER BY bills)::numeric, 0)::int AS p85_bills,
+ round(percentile_cont(0.5) WITHIN GROUP (ORDER BY bills)::numeric, 0)::int AS p50_bills,
+ round((max(bills) - min(bills))::numeric / nullif(avg(bills), 0), 2)::float AS volatility
+ FROM daily_hourly
+ GROUP BY hour, day_type
+ )
+ SELECT hour, day_type, avg_bills, p50_bills, p85_bills, volatility,
+ CASE
+ WHEN volatility > 1.5 THEN '波动大'
+ WHEN volatility > 0.8 THEN '中等波动'
+ ELSE '稳定'
+ END AS stability
+ FROM hourly_stats
+ ORDER BY day_type, hour
+ `)
+
+ // 成本趋势:菜品成本差异恶化TOP
+ const costTrend = await query(`
+ SELECT dish_name, dish_code, category_level1,
+ round(sales_amount::numeric, 2) AS sales_amount,
+ round(theoretical_margin_rate_pct::numeric, 2) AS theo_margin,
+ round(actual_margin_rate_pct::numeric, 2) AS actual_margin,
+ round(cost_variance_amount::numeric, 2) AS variance,
+ CASE
+ WHEN actual_margin_rate_pct < 0 THEN '数据异常'
+ WHEN cost_variance_amount > 0 AND theoretical_cost > 0 AND (cost_variance_amount / theoretical_cost) > 0.3 THEN '恶化'
+ WHEN cost_variance_amount > 0 AND theoretical_cost > 0 AND (cost_variance_amount / theoretical_cost) > 0.1 THEN '关注'
+ ELSE '正常'
+ END AS trend_status
+ FROM public.dish_cost_analysis_summary
+ WHERE actual_margin_rate_pct IS NOT NULL
+ ORDER BY variance DESC
+ LIMIT 20
+ `)
+
+ // 人员流失预警
+ const turnoverAlert = await query(`
+ SELECT org_level5 AS store_name,
+ count(*) AS total_emp,
+ count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01') AS left_count,
+ count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count,
+ round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 1) AS turnover_rate
+ FROM salary_detail_records
+ WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
+ GROUP BY org_level5
+ HAVING count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date >= '2026-04-01') > 0
+ ORDER BY turnover_rate DESC
+ `)
+
+ sendSuccess(res, {
+ traffic_forecast: trafficForecast.rows,
+ cost_trend: costTrend.rows,
+ turnover_alert: turnoverAlert.rows,
+ })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+export default router