import { Router } from 'express' import { query } from '../config/database.js' import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js' import type { AuthRequest } from '../middleware/auth.js' const router = Router() // ============================================================ // 1. 会员LTV与分层 // ============================================================ router.get('/member/ltv', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const { page, pageSize, offset } = parsePagination(req) const status = (req.query.status as string) || '' const level = (req.query.level as string) || '' let whereClause = 'WHERE 1=1' const params: any[] = [] if (status) { params.push(status) whereClause += ` AND dm.status = $${params.length}` } if (level) { params.push(level) whereClause += ` AND dm.member_level = $${params.length}` } const countResult = await query(`SELECT count(*) as total FROM analytics.dim_member dm ${whereClause}`, params) const total = Number(countResult.rows[0].total) params.push(pageSize, offset) const result = await query(` SELECT dm.member_id, dm.register_channel, dm.register_store, ds.store_name as register_store_name, dm.register_date, dm.member_level, dm.total_orders, round(dm.total_revenue::numeric, 2) as total_revenue, dm.last_order_date, dm.status, dm.tags FROM analytics.dim_member dm LEFT JOIN analytics.dim_store ds ON dm.register_store = ds.store_code ${whereClause} ORDER BY dm.total_revenue DESC NULLS LAST LIMIT $${params.length - 1} OFFSET $${params.length} `, params) // 汇总统计 const summaryResult = await query(` SELECT count(*) as total_members, count(*) FILTER (WHERE status = '活跃') as active_count, count(*) FILTER (WHERE status = '沉睡') as dormant_count, count(*) FILTER (WHERE status = '流失') as churned_count, round(avg(total_revenue)::numeric, 2) as avg_ltv, round(sum(total_revenue)::numeric, 2) as total_revenue, round(avg(total_orders)::numeric, 1) as avg_orders FROM analytics.dim_member `) // 等级分布 const levelDist = await query(` SELECT member_level, count(*) as count, round(avg(total_revenue)::numeric, 2) as avg_revenue, round(avg(total_orders)::numeric, 1) as avg_orders FROM analytics.dim_member GROUP BY member_level ORDER BY CASE member_level WHEN '钻石' THEN 1 WHEN '金卡' THEN 2 WHEN '银卡' THEN 3 WHEN '普通' THEN 4 ELSE 5 END `) sendSuccess(res, { summary: summaryResult.rows[0], level_distribution: levelDist.rows, members: result.rows, }, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 2. 菜单工程行动清单 // ============================================================ router.get('/menu-engineering/actions', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT dish_name, abc_class, received_amount as revenue, bill_count as order_count, realized_unit_price as avg_price, category_level1 as category_l1, category_level2 as category_l2, CASE WHEN abc_class LIKE 'A%' AND bill_count > 0 THEN '保留并推广' WHEN abc_class LIKE 'A%' AND bill_count <= 0 THEN '调查停售原因' WHEN abc_class LIKE 'B%' THEN '优化提升' WHEN abc_class LIKE 'C%' AND received_amount < 1000 THEN '考虑淘汰' WHEN abc_class LIKE 'C%' THEN '降本或提价' ELSE '观察' END as action, CASE WHEN abc_class LIKE 'A%' AND realized_unit_price > 0 THEN '高人气高收入,维持品质,可考虑提价测试' WHEN abc_class LIKE 'B%' AND bill_count > 50 THEN '有潜力,优化呈现和推荐话术' WHEN abc_class LIKE 'C%' AND received_amount < 500 THEN '长尾SKU,建议下架或季节性供应' WHEN abc_class LIKE 'C%' THEN '收入偏低,尝试降本或搭配套餐' ELSE '持续观察' END as suggestion FROM analytics.mv_dish_sku_abc_monthly WHERE month_start = $1 ORDER BY CASE WHEN abc_class LIKE 'A%' THEN 1 WHEN abc_class LIKE 'B%' THEN 2 WHEN abc_class LIKE 'C%' THEN 3 ELSE 4 END, received_amount DESC NULLS LAST `, [month]) // 汇总 const summary = { total_skus: result.rows.length, class_a: result.rows.filter((r: any) => r.abc_class?.startsWith('A')).length, class_b: result.rows.filter((r: any) => r.abc_class?.startsWith('B')).length, class_c: result.rows.filter((r: any) => r.abc_class?.startsWith('C')).length, recommend_eliminate: result.rows.filter((r: any) => r.action === '考虑淘汰').length, recommend_promote: result.rows.filter((r: any) => r.action === '保留并推广').length, } sendSuccess(res, { summary, actions: result.rows }) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 3. 菜品搭配分析(套餐推荐) // ============================================================ router.get('/dish-pair/recommendations', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const { page, pageSize, offset } = parsePagination(req) const minPairCount = parseInt((req.query.min_count as string) || '10') const countResult = await query(` SELECT count(*) as total FROM analytics.mv_dish_pair_summary_monthly WHERE month_start = $1 AND pair_count >= $2 `, [month, minPairCount]) const total = Number(countResult.rows[0].total) const result = await query(` SELECT dish_a, dish_b, pair_count, dish_a_revenue, dish_b_revenue, combined_revenue, round(pair_count::numeric / NULLIF((SELECT max(pair_count) FROM analytics.mv_dish_pair_summary_monthly WHERE month_start = $1), 0) * 100, 1) as affinity_pct FROM analytics.mv_dish_pair_summary_monthly WHERE month_start = $1 AND pair_count >= $2 ORDER BY pair_count DESC LIMIT $3 OFFSET $4 `, [month, minPairCount, pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 4. 区域间对比排名 // ============================================================ router.get('/region/comparison', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` WITH bill_region AS ( SELECT COALESCE(ds.region, '未知区域') AS region, round(sum(b.consumption)::numeric, 2) AS total_consumption, round(sum(b.discount_total)::numeric, 2) AS total_discount FROM analytics.bill_fact b JOIN analytics.dim_store ds ON b.store_code = ds.store_code WHERE b.closed_at >= $1::date AND b.closed_at < ($1::date + interval '1 month') AND ds.region IS NOT NULL GROUP BY COALESCE(ds.region, '未知区域') ), store_metrics AS ( SELECT COALESCE(ds.region, '未知区域') AS region, count(DISTINCT r.store_code) as store_count, sum(r.received) as total_revenue, round(avg(r.avg_bill_value)::numeric, 2) as avg_bill_value, sum(r.bill_count) as bill_count, round(avg(r.theoretical_margin_pct)::numeric, 2) as avg_margin, round(sum(r.bill_count * r.member_bill_share_pct / 100)::numeric, 0) as member_count FROM analytics.mv_store_risk_rating_monthly r JOIN analytics.dim_store ds ON r.store_code = ds.store_code WHERE r.month_start = $1 AND ds.region IS NOT NULL GROUP BY COALESCE(ds.region, '未知区域') ), expense_metrics AS ( SELECT COALESCE(ds.region, '未知区域') AS region, avg(oe.operating_expense_rate_pct) as avg_expense_rate, avg(oe.rent_rate_pct) as avg_rent_rate, avg(oe.wage_rate_pct) as avg_wage_rate FROM analytics.mv_store_operating_expense_monthly oe JOIN analytics.dim_store ds ON oe.sales_store_code = ds.store_code WHERE oe.report_month = $1 AND ds.region IS NOT NULL GROUP BY COALESCE(ds.region, '未知区域') ) SELECT sm.region, sm.store_count, br.total_consumption, br.total_discount, round(sm.total_revenue::numeric, 2) as total_revenue, round((sm.total_revenue / sm.store_count)::numeric, 2) as revenue_per_store, round(sm.avg_bill_value::numeric, 2) as avg_bill_value, sm.bill_count, round(sm.avg_margin::numeric, 2) as avg_margin_pct, sm.member_count, round((sm.member_count::numeric / NULLIF(sm.bill_count, 0) * 100)::numeric, 1) as member_penetration_pct, round(em.avg_expense_rate::numeric, 2) as avg_expense_rate, round(em.avg_rent_rate::numeric, 2) as avg_rent_rate, round(em.avg_wage_rate::numeric, 2) as avg_wage_rate FROM store_metrics sm LEFT JOIN bill_region br ON sm.region = br.region LEFT JOIN expense_metrics em ON sm.region = em.region ORDER BY sm.total_revenue DESC `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 5. 区域KPI达成预测 // ============================================================ router.get('/region/kpi-forecast', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` WITH region_actual AS ( SELECT ds.region, round(sum(r.received)::numeric, 2) as actual_revenue, sum(r.bill_count) as actual_bills, max(r.active_days) as elapsed_days, round(sum(r.received) / NULLIF(max(r.active_days), 0)::numeric, 2) as avg_daily_revenue FROM analytics.mv_store_risk_rating_monthly r JOIN analytics.dim_store ds ON r.store_code = ds.store_code WHERE r.month_start = $1 AND ds.region IS NOT NULL AND ds.region != '未知区域' GROUP BY ds.region ), region_target AS ( SELECT ds.region, round(sum(t.revenue_target)::numeric, 2) as target_revenue, round(sum(t.bill_count_target)::numeric, 0) as target_bills FROM analytics.dim_store_target t JOIN analytics.dim_store ds ON t.store_code = ds.store_code WHERE t.target_month = $1 AND ds.region IS NOT NULL AND ds.region != '未知区域' GROUP BY ds.region ), calendar AS ( SELECT count(*) as total_days FROM analytics.dim_calendar WHERE date_value >= $1::date AND date_value < $1::date + interval '1 month' AND is_operating_day = true ) SELECT ra.region, ra.actual_revenue, rt.target_revenue, ra.actual_bills, rt.target_bills, ra.elapsed_days, c.total_days as working_days, ra.avg_daily_revenue, round((ra.avg_daily_revenue * c.total_days)::numeric, 2) as forecast_revenue, round((ra.actual_revenue / NULLIF(rt.target_revenue, 0) * 100)::numeric, 1) as achievement_pct, round((ra.actual_bills / NULLIF(rt.target_bills, 0) * 100)::numeric, 1) as bill_achievement_pct FROM region_actual ra LEFT JOIN region_target rt ON ra.region = rt.region CROSS JOIN calendar c ORDER BY ra.actual_revenue DESC `, [month]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 6. 每日销售目标分解 // ============================================================ router.get('/store/daily-target', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const storeCode = (req.query.store_code as string) || '' if (!storeCode) { return sendError(res, 'store_code is required') } // 该门店历史日均收入(按餐段) const mealPeriodStats = await query(` SELECT meal_period, round(avg(daily_revenue)::numeric, 2) as avg_revenue, round(avg(bill_count)::numeric, 0) as avg_bills, round(avg(avg_bill_value)::numeric, 2) as avg_bill_value FROM ( SELECT meal_period, date_trunc('day', opened_at)::date as day, sum(received_total) as daily_revenue, count(*) as bill_count, round(avg(received_total)::numeric, 2) as avg_bill_value FROM analytics.bill_fact WHERE store_code = $1 AND opened_at >= $2 AND opened_at < $2::date + interval '1 month' GROUP BY meal_period, date_trunc('day', opened_at)::date ) t GROUP BY meal_period ORDER BY CASE meal_period WHEN '早市' THEN 1 WHEN '午市' THEN 2 WHEN '下午茶' THEN 3 WHEN '晚市' THEN 4 WHEN '夜宵' THEN 5 ELSE 6 END `, [storeCode, month]) // 该门店历史日均收入(按星期) const weekdayStats = await query(` SELECT extract(dow from opened_at)::int as weekday, round(avg(daily_revenue)::numeric, 2) as avg_revenue, round(avg(bill_count)::numeric, 0) as avg_bills FROM ( SELECT date_trunc('day', opened_at)::date as day, extract(dow from opened_at)::int as weekday, sum(received_total) as daily_revenue, count(*) as bill_count FROM analytics.bill_fact WHERE store_code = $1 AND opened_at >= $2 AND opened_at < $2::date + interval '1 month' GROUP BY date_trunc('day', opened_at)::date, extract(dow from opened_at)::int ) t GROUP by weekday ORDER BY weekday `, [storeCode, month]) // 月度总目标(基于历史日均 * 当月天数) const totalAvg = await query(` SELECT round(avg(daily_revenue)::numeric, 2) as avg_daily_revenue, count(DISTINCT date_trunc('day', opened_at)::date) as active_days, round(sum(daily_revenue)::numeric, 2) as total_revenue FROM ( SELECT date_trunc('day', opened_at)::date as day, sum(received_total) as daily_revenue FROM analytics.bill_fact WHERE store_code = $1 AND opened_at >= $2 AND opened_at < $2::date + interval '1 month' GROUP BY date_trunc('day', opened_at)::date ) t `, [storeCode, month]) const workingDays = await query(` SELECT count(*) as total_days FROM analytics.dim_calendar WHERE date_value >= $1::date AND date_value < $1::date + interval '1 month' AND is_operating_day = true `, [month]) const avgDaily = Number(totalAvg.rows[0]?.avg_daily_revenue || 0) const forecastTotal = avgDaily * Number(workingDays.rows[0]?.total_days || 30) sendSuccess(res, { monthly_target: Math.round(forecastTotal), avg_daily_revenue: avgDaily, meal_period_breakdown: mealPeriodStats.rows, weekday_breakdown: weekdayStats.rows, }) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 7. 门店会员活跃度面板 // ============================================================ router.get('/store/member-activity', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const storeCode = (req.query.store_code as string) || '' if (!storeCode) { return sendError(res, 'store_code is required') } // 该门店会员活跃度 const memberStats = await query(` WITH store_members AS ( SELECT bf.member_id, count(DISTINCT bf.bill_no) as orders, sum(bf.received_total) as revenue, max(bf.opened_at) as last_visit, min(bf.opened_at) as first_visit FROM analytics.bill_fact bf WHERE bf.store_code = $1 AND bf.opened_at >= $2 AND bf.opened_at < $2::date + interval '1 month' AND bf.member_id IS NOT NULL AND bf.member_id != '' GROUP BY bf.member_id ) SELECT count(*) as total_members, count(*) FILTER (WHERE orders >= 3) as frequent_members, count(*) FILTER (WHERE orders = 1) as one_time_members, round(avg(orders)::numeric, 1) as avg_orders, round(avg(revenue)::numeric, 2) as avg_revenue, round(sum(revenue)::numeric, 2) as total_revenue FROM store_members `, [storeCode, month]) // 会员等级分布 const levelDist = await query(` SELECT CASE WHEN bf.member_level IN ('1') THEN '普通' WHEN bf.member_level IN ('2','3') THEN '银卡' WHEN bf.member_level IN ('4','5') THEN '金卡' WHEN bf.member_level IN ('6','7','LV6','LV7') THEN '钻石' ELSE '普通' END as standard_level, count(DISTINCT bf.member_id) as count, round(sum(bf.received_total)::numeric, 2) as revenue FROM analytics.bill_fact bf WHERE bf.store_code = $1 AND bf.opened_at >= $2 AND bf.opened_at < $2::date + interval '1 month' AND bf.member_id IS NOT NULL AND bf.member_id != '' GROUP BY 1 ORDER BY CASE standard_level WHEN '钻石' THEN 1 WHEN '金卡' THEN 2 WHEN '银卡' THEN 3 WHEN '普通' THEN 4 ELSE 5 END `, [storeCode, month]) // 会员 vs 非会员 const comparison = await query(` SELECT CASE WHEN member_id IS NOT NULL AND member_id != '' THEN '会员' ELSE '非会员' END as customer_type, count(*) as bill_count, round(sum(received_total)::numeric, 2) as revenue, round(avg(received_total)::numeric, 2) as avg_bill_value FROM analytics.bill_fact WHERE store_code = $1 AND opened_at >= $2 AND opened_at < $2::date + interval '1 month' GROUP BY 1 `, [storeCode, month]) sendSuccess(res, { stats: memberStats.rows[0], level_distribution: levelDist.rows, comparison: comparison.rows, }) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 8. 员工绩效分析 // ============================================================ router.get('/employee/performance', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const position = (req.query.position as string) || '' const status = (req.query.status as string) || '' let whereClause = 'WHERE 1=1' const params: any[] = [] if (position) { params.push(position) whereClause += ` AND de.position = $${params.length}` } if (status) { params.push(status) whereClause += ` AND de.status = $${params.length}` } const countResult = await query(`SELECT count(*) as total FROM analytics.dim_employee ${whereClause}`, params) const total = Number(countResult.rows[0].total) params.push(pageSize, offset) const result = await query(` SELECT de.employee_id, de.position, de.hire_date, de.leave_date, de.status, s.salary_period, round(s.gross_pay::numeric, 2) as gross_pay, round(s.net_pay::numeric, 2) as net_pay, round(s.perf_score::numeric, 2) as perf_score, round(s.actual_attend::numeric, 0) as actual_attend, round(s.expected_attend::numeric, 0) as expected_attend, round(s.overtime_pay::numeric, 2) as overtime_pay, round(s.bonus::numeric, 2) as bonus, s.org_level2, s.org_level3 FROM analytics.dim_employee de LEFT JOIN public.salary_detail_records s ON de.employee_id = s.employee_code ${whereClause} ORDER BY s.gross_pay DESC NULLS LAST LIMIT $${params.length - 1} OFFSET $${params.length} `, params) // 岗位汇总 const positionSummary = await query(` SELECT de.position, count(*) as headcount, count(*) FILTER (WHERE de.status = '在职') as active_count, round(avg(s.gross_pay)::numeric, 2) as avg_gross_pay, round(avg(s.perf_score)::numeric, 2) as avg_perf_score, round(avg(s.actual_attend::numeric / NULLIF(s.expected_attend, 0) * 100)::numeric, 1) as avg_attendance_rate FROM analytics.dim_employee de LEFT JOIN public.salary_detail_records s ON de.employee_id = s.employee_code GROUP BY de.position ORDER BY count(*) DESC `) sendSuccess(res, { position_summary: positionSummary.rows, employees: result.rows, }, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 9. 库存周转与损耗趋势 // ============================================================ router.get('/inventory/turnover', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const storeCode = (req.query.store_code as string) || '' let storeFilter = '' const params: any[] = [month] if (storeCode) { params.push(storeCode) storeFilter = `AND fis.store_code = $${params.length}` } // 库存周转概览 const turnoverResult = await query(` SELECT fis.store_code, ds.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 FROM analytics.fact_inventory_snapshot fis LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code 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 `, params) // 损耗TOP const wasteTop = await query(` SELECT dm.material_name, round(sum(fis.waste_quantity)::numeric, 2) as waste_qty, round(sum(fis.waste_amount)::numeric, 2) as waste_value, count(DISTINCT fis.store_code) as affected_stores FROM analytics.fact_inventory_snapshot fis LEFT JOIN analytics.dim_material dm ON fis.material_code = dm.material_code WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month' AND fis.waste_amount > 0 ${storeFilter} GROUP BY dm.material_name ORDER BY waste_value DESC LIMIT 20 `, params) sendSuccess(res, { turnover: turnoverResult.rows, waste_top: wasteTop.rows, }) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 10. 营销ROI基础版 // ============================================================ router.get('/marketing/roi', async (req: AuthRequest, res) => { try { const month = parseMonth(req) // 各营销方案的效果对比 const result = await query(` WITH plan_stats AS ( SELECT marketing_plan, count(*) as bill_count, round(sum(received_total)::numeric, 2) as total_revenue, round(avg(received_total)::numeric, 2) as avg_bill_value, round(avg(theoretical_margin) * 100, 2) as avg_margin, round(avg(discount_total)::numeric, 2) as avg_discount, round(sum(discount_total)::numeric, 2) as total_discount, count(DISTINCT member_id) FILTER (WHERE member_id IS NOT NULL AND member_id != '') as member_bills FROM analytics.bill_fact WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month' AND marketing_plan IS NOT NULL AND marketing_plan != '' GROUP BY marketing_plan ), no_plan_stats AS ( SELECT count(*) as bill_count, round(avg(received_total)::numeric, 2) as avg_bill_value, round(avg(theoretical_margin) * 100, 2) as avg_margin, round(avg(discount_total)::numeric, 2) as avg_discount FROM analytics.bill_fact WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month' AND (marketing_plan IS NULL OR marketing_plan = '') ) SELECT ps.marketing_plan, ps.bill_count, ps.total_revenue, ps.avg_bill_value, ps.avg_margin, ps.total_discount, ps.avg_discount, ps.member_bills, round((ps.member_bills::numeric / NULLIF(ps.bill_count, 0) * 100)::numeric, 1) as member_share_pct, round(((ps.avg_bill_value - nps.avg_bill_value) / NULLIF(nps.avg_bill_value, 0) * 100)::numeric, 1) as bill_value_uplift_pct, round((ps.avg_margin - nps.avg_margin)::numeric, 2) as margin_delta FROM plan_stats ps CROSS JOIN no_plan_stats nps ORDER BY ps.total_revenue DESC `, [month]) // 无营销方案基准 const baseline = await query(` SELECT count(*) as bill_count, round(avg(received_total)::numeric, 2) as avg_bill_value, round(avg(theoretical_margin) * 100, 2) as avg_margin, round(avg(discount_total)::numeric, 2) as avg_discount FROM analytics.bill_fact WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month' AND (marketing_plan IS NULL OR marketing_plan = '') `, [month]) sendSuccess(res, { baseline: baseline.rows[0], campaigns: result.rows, }) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 11. 辖区巡检计划生成 // ============================================================ router.get('/region/inspection-plan', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const result = await query(` SELECT rr.store_code, rr.store_name, rr.risk_level, rr.primary_issue, rr.bill_count, round(rr.received::numeric, 2) as received, rr.anomaly_rate_pct, rr.discount_rate_pct, rr.member_bill_share_pct, ds.region, CASE rr.risk_level WHEN '高风险' THEN 1 WHEN '中风险' THEN 2 WHEN '低风险' THEN 3 ELSE 4 END as priority, CASE rr.risk_level WHEN '高风险' THEN '本周必须巡检' WHEN '中风险' THEN '两周内巡检' WHEN '低风险' THEN '月度例行巡检' ELSE '季度巡检' END as inspection_frequency, CASE rr.risk_level WHEN '高风险' THEN '重点检查: ' || COALESCE(rr.primary_issue, '综合风险') WHEN '中风险' THEN '关注: ' || COALESCE(rr.primary_issue, '常规指标') ELSE '常规检查' END as focus_area FROM analytics.mv_store_risk_rating_monthly rr LEFT JOIN analytics.dim_store ds ON rr.store_code = ds.store_code WHERE rr.month_start = $1 ORDER BY priority, rr.received ASC `, [month]) const summary = { total_stores: result.rows.length, high_risk: result.rows.filter((r: any) => r.risk_level === '高风险').length, medium_risk: result.rows.filter((r: any) => r.risk_level === '中风险').length, low_risk: result.rows.filter((r: any) => r.risk_level === '低风险').length, this_week: result.rows.filter((r: any) => r.priority === 1).length, } sendSuccess(res, { summary, plan: result.rows }) } catch (err: any) { sendError(res, err.message) } }) // ============================================================ // 12. 统一KPI达成率(支持门店/区域/总部三个维度) // ============================================================ router.get('/kpi', async (req: AuthRequest, res) => { try { const month = parseMonth(req) const level = (req.query.level as string) || 'hq' // hq | region | store const region = (req.query.region as string) || '' const storeCode = (req.query.store_code as string) || '' if (level === 'store' && storeCode) { // 单门店KPI const result = await query(` SELECT r.store_code, r.store_name, round(r.received::numeric, 2) as actual_revenue, round(r.bill_count::numeric, 0) as actual_bills, t.revenue_target, t.bill_count_target, t.profit_target, e.actual_store_contribution as actual_profit, round((r.received / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct, round(CASE WHEN t.profit_target > 0 THEN e.actual_store_contribution / t.profit_target * 100 WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(e.actual_store_contribution)) / ABS(t.profit_target) * 100 ELSE NULL END::numeric, 1) as profit_achievement_pct, round((e.actual_store_contribution / NULLIF(r.received, 0) * 100)::numeric, 2) as profit_margin_pct FROM analytics.mv_store_risk_rating_monthly r LEFT JOIN analytics.dim_store_target t ON r.store_code = t.store_code AND t.target_month = $1 LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1 WHERE r.month_start = $1 AND r.store_code = $2 `, [month, storeCode]) sendSuccess(res, result.rows[0] || {}) } else if (level === 'region') { // 按区域汇总KPI const result = await query(` WITH actual AS ( SELECT ds.region, round(sum(r.received)::numeric, 2) as actual_revenue, sum(r.bill_count) as actual_bills, round(sum(e.actual_store_contribution)::numeric, 2) as actual_profit FROM analytics.mv_store_risk_rating_monthly r JOIN analytics.dim_store ds ON r.store_code = ds.store_code LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1 WHERE r.month_start = $1 AND ds.region IS NOT NULL AND ds.region != '未知区域' GROUP BY ds.region ), target AS ( SELECT ds.region, round(sum(t.revenue_target)::numeric, 2) as revenue_target, round(sum(t.bill_count_target)::numeric, 0) as bill_count_target, round(sum(t.profit_target)::numeric, 2) as profit_target FROM analytics.dim_store_target t JOIN analytics.dim_store ds ON t.store_code = ds.store_code WHERE t.target_month = $1 AND ds.region IS NOT NULL AND ds.region != '未知区域' GROUP BY ds.region ) SELECT a.region, a.actual_revenue, a.actual_bills, a.actual_profit, t.revenue_target, t.bill_count_target, t.profit_target, round((a.actual_revenue / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct, round(CASE WHEN t.profit_target > 0 THEN a.actual_profit / t.profit_target * 100 WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(a.actual_profit)) / ABS(t.profit_target) * 100 ELSE NULL END::numeric, 1) as profit_achievement_pct, round((a.actual_profit / NULLIF(a.actual_revenue, 0) * 100)::numeric, 2) as profit_margin_pct FROM actual a LEFT JOIN target t ON a.region = t.region ORDER BY a.actual_revenue DESC `, [month]) sendSuccess(res, result.rows) } else { // 总部汇总KPI const result = await query(` WITH actual AS ( SELECT round(sum(r.received)::numeric, 2) as actual_revenue, sum(r.bill_count) as actual_bills, round(sum(e.actual_store_contribution)::numeric, 2) as actual_profit FROM analytics.mv_store_risk_rating_monthly r LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1 WHERE r.month_start = $1 ), target AS ( SELECT round(sum(t.revenue_target)::numeric, 2) as revenue_target, round(sum(t.bill_count_target)::numeric, 0) as bill_count_target, round(sum(t.profit_target)::numeric, 2) as profit_target FROM analytics.dim_store_target t WHERE t.target_month = $1 ) SELECT a.actual_revenue, a.actual_bills, a.actual_profit, t.revenue_target, t.bill_count_target, t.profit_target, round((a.actual_revenue / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct, round(CASE WHEN t.profit_target > 0 THEN a.actual_profit / t.profit_target * 100 WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(a.actual_profit)) / ABS(t.profit_target) * 100 ELSE NULL END::numeric, 1) as profit_achievement_pct, round((a.actual_profit / NULLIF(a.actual_revenue, 0) * 100)::numeric, 2) as profit_margin_pct FROM actual a CROSS JOIN target t `, [month]) sendSuccess(res, result.rows[0] || {}) } } catch (err: any) { sendError(res, err.message) } }) export default router