feat: 态势感知模块 - 实时监控面板+后端API
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<string, number> = { 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
|
||||
Reference in New Issue
Block a user