import { Router } from 'express' import { query } from '../config/database.js' import { sendSuccess, sendError, parsePagination } from '../middleware/error.js' import type { AuthRequest } from '../middleware/auth.js' const router = Router() // ============ Tab1: 客流热力图 ============ // 门店×小时客流分布 router.get('/traffic-heatmap', async (req: AuthRequest, res) => { try { const storeName = req.query.store as string let where = '' const params: any[] = [] if (storeName) { params.push(storeName) where = `WHERE store_name = $${params.length}` } const result = await query(` SELECT store_name, hour, sum(bills) AS bills, round(avg(avg_guests), 1) AS avg_guests, round(sum(total_guests), 0) AS total_guests FROM mv_bill_hourly ${where} GROUP BY store_name, hour ORDER BY store_name, hour `, params) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 餐段客流分布 router.get('/meal-period-traffic', async (req: AuthRequest, res) => { try { const storeName = req.query.store as string let where = "WHERE meal_period != ''" const params: any[] = [] if (storeName) { params.push(storeName) where += ` AND store_name = $${params.length}` } const result = await query(` SELECT store_name, meal_period, sum(bills) AS bills, round(sum(total_guests), 0) AS total_guests, round(avg(avg_guests), 1) AS avg_guests_per_bill FROM mv_bill_hourly ${where} GROUP BY store_name, meal_period ORDER BY store_name, bills DESC `, params) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 门店×小时在岗人员分布(从考勤打卡记录解析) router.get('/traffic-heatmap-staffing', async (req: AuthRequest, res) => { try { const storeName = req.query.store as string // 从考勤表取所有员工的打卡记录,解析上下班时间 // department格式: 北京西部马华餐饮有限公司/西部马华品牌门店/.../七里庄店/前厅/管理组 const result = await query(` SELECT department, position, day_15 FROM attendance_records WHERE department LIKE '%西部马华品牌门店%' AND day_15 IS NOT NULL AND day_15 != '' `) // 从department提取门店名(以"店"结尾的层级) function extractStore(dept: string): string { const parts = dept.split('/') for (let i = parts.length - 1; i >= 0; i--) { if (parts[i].endsWith('店')) return parts[i] } return '' } // 从打卡记录解析上下班时间,格式: 08:30(考勤机:指纹)+20:28(考勤机:指纹) function parseClockTimes(raw: string): { start: number; end: number } | null { const times = raw.match(/(\d{1,2}):(\d{2})/g) if (!times || times.length < 2) return null const startParts = times[0].match(/(\d{1,2}):(\d{2})/) const endParts = times[times.length - 1].match(/(\d{1,2}):(\d{2})/) if (!startParts || !endParts) return null const startHour = parseInt(startParts[1]) let endHour = parseInt(endParts[1]) // 如果下班时间小于上班时间,说明跨天,按23点算 if (endHour < startHour) endHour = 23 return { start: startHour, end: endHour } } // 汇总:store -> hour -> { 前厅, 后厨, 管理, 其他 } const staffing: Record> = {} for (const row of result.rows) { const store = extractStore(row.department as string) if (!store) continue if (storeName && store !== storeName) continue const clock = parseClockTimes(row.day_15 as string) if (!clock) continue // 用position做SQL LIKE风格的判断 const pos = row.position as string const dept = row.department as string let role = '其他' if (pos.includes('店长') || pos.includes('经理') || pos.startsWith('储备') || pos.includes('副店')) role = '管理' else if (dept.includes('/前厅/') || pos.includes('服务员') || pos.includes('训练员') || pos.includes('迎宾') || pos.includes('传菜') || pos.includes('主管')) role = '前厅' else if (dept.includes('/后厨') || pos.includes('厨') || pos.includes('拉面') || pos.includes('配菜') || pos.includes('凉菜') || pos.includes('烧烤') || pos.includes('面点') || pos.includes('面工') || pos.includes('锅底') || pos.includes('切肉') || pos.includes('切菜') || pos.includes('炒锅') || pos.includes('砧板') || pos.includes('打荷') || pos.includes('洗碗') || pos.includes('上什') || pos.includes('打馕')) role = '后厨' else if (pos.includes('兼职') || pos.includes('小时工')) role = '兼职' if (!staffing[store]) staffing[store] = {} for (let h = clock.start; h <= clock.end; h++) { if (!staffing[store][h]) staffing[store][h] = { 前厅: 0, 后厨: 0, 管理: 0, 其他: 0 } staffing[store][h][role as '前厅' | '后厨' | '管理' | '其他']++ } } // 转为数组输出 const output: any[] = [] for (const [store, hours] of Object.entries(staffing)) { for (let h = 0; h < 24; h++) { const s = hours[h] || { 前厅: 0, 后厨: 0, 管理: 0, 其他: 0 } output.push({ store_name: store, hour: h, front: s.前厅, kitchen: s.后厨, management: s.管理, other: s.其他, total: s.前厅 + s.后厨 + s.管理 + s.其他, }) } } sendSuccess(res, output) } catch (err: any) { sendError(res, err.message) } }) // 工作日vs周末客流 router.get('/dow-traffic', async (req: AuthRequest, res) => { try { const storeName = req.query.store as string let where = "WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= '2026/04/01' AND c175 < '2026/05/01'" const params: any[] = [] if (storeName) { params.push(storeName) where += ` AND c003 = $${params.length}` } const result = await query(` SELECT c003 AS store_name, extract(dow FROM c175::timestamp)::int AS dow, extract(hour FROM c175::timestamp)::int AS hour, count(*) AS bills FROM bill_records ${where} GROUP BY c003, dow, hour ORDER BY c003, dow, hour `, params) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 门店客流概览(峰谷比、集中度) router.get('/traffic-overview', async (req: AuthRequest, res) => { try { const result = await query(` WITH hourly AS ( SELECT store_name, hour, sum(bills) AS bills FROM mv_bill_hourly GROUP BY store_name, hour ), store_stats AS ( SELECT store_name, max(bills) AS peak_bills, min(bills) AS min_bills, sum(bills) AS total_bills, round(max(bills)::numeric / nullif(min(bills), 0), 2) AS peak_valley_ratio, round(sum(bills) FILTER (WHERE hour IN (11, 12, 17, 18, 19))::numeric / nullif(sum(bills), 0) * 100, 2) AS peak_concentration_pct FROM hourly GROUP BY store_name ) SELECT store_name, total_bills, peak_bills, min_bills, peak_valley_ratio, peak_concentration_pct, CASE WHEN peak_valley_ratio > 20 THEN '波动极大' WHEN peak_valley_ratio > 10 THEN '波动较大' WHEN peak_valley_ratio > 5 THEN '波动适中' ELSE '波动较小' END AS volatility_level FROM store_stats ORDER BY total_bills DESC `) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab2: 排班匹配度 ============ // 时段在岗人数 vs 客流匹配度 router.get('/staffing-match', async (req: AuthRequest, res) => { try { const storeName = req.query.store as string || '七里庄店' const result = await query(` WITH punch_times AS ( SELECT employee_code, d.day_num, d.day_val FROM attendance_records ar CROSS JOIN LATERAL ( SELECT 1 AS day_num, ar.day_01 AS day_val UNION ALL SELECT 2, ar.day_02 UNION ALL SELECT 3, ar.day_03 UNION ALL SELECT 4, ar.day_04 UNION ALL SELECT 5, ar.day_05 UNION ALL SELECT 6, ar.day_06 UNION ALL SELECT 7, ar.day_07 UNION ALL SELECT 8, ar.day_08 UNION ALL SELECT 9, ar.day_09 UNION ALL SELECT 10, ar.day_10 UNION ALL SELECT 11, ar.day_11 UNION ALL SELECT 12, ar.day_12 UNION ALL SELECT 13, ar.day_13 UNION ALL SELECT 14, ar.day_14 UNION ALL SELECT 15, ar.day_15 UNION ALL SELECT 16, ar.day_16 UNION ALL SELECT 17, ar.day_17 UNION ALL SELECT 18, ar.day_18 UNION ALL SELECT 19, ar.day_19 UNION ALL SELECT 20, ar.day_20 UNION ALL SELECT 21, ar.day_21 UNION ALL SELECT 22, ar.day_22 UNION ALL SELECT 23, ar.day_23 UNION ALL SELECT 24, ar.day_24 UNION ALL SELECT 25, ar.day_25 UNION ALL SELECT 26, ar.day_26 UNION ALL SELECT 27, ar.day_27 UNION ALL SELECT 28, ar.day_28 UNION ALL SELECT 29, ar.day_29 UNION ALL SELECT 30, ar.day_30 ) d WHERE ar.department LIKE '%' || replace($1, '总', '') || '%' AND d.day_val IS NOT NULL AND d.day_val != '' ), all_punches AS ( SELECT employee_code, day_num, (regexp_matches(day_val, '(\\d{2}:\\d{2})', 'g'))[1]::time AS punch_time FROM punch_times ), daily_range AS ( SELECT employee_code, day_num, min(punch_time) AS first_punch, max(punch_time) AS last_punch FROM all_punches GROUP BY employee_code, day_num ), hourly_staff AS ( SELECT h.hour, d.day_num, count(DISTINCT d.employee_code) AS staff_on_duty FROM generate_series(6, 23) AS h(hour) CROSS JOIN daily_range d WHERE d.first_punch <= (h.hour || ':00')::time AND d.last_punch >= (h.hour || ':00')::time GROUP BY h.hour, d.day_num ), hourly_staff_avg AS ( SELECT hour, round(avg(staff_on_duty), 1) AS avg_staff FROM hourly_staff GROUP BY hour ), hourly_bills AS ( SELECT extract(hour FROM c175::timestamp)::int AS hour, count(*) AS bills, round(sum(c178::numeric), 0) AS total_guests FROM bill_records WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != '' AND c175::timestamp >= '2026-04-01' AND c175::timestamp < '2026-05-01' GROUP BY hour ) SELECT s.hour, s.avg_staff, COALESCE(b.bills, 0) AS bills, COALESCE(b.total_guests, 0) AS total_guests, round(COALESCE(b.bills, 0) / nullif(s.avg_staff, 0), 1) AS bills_per_staff, CASE WHEN COALESCE(b.bills, 0) / nullif(s.avg_staff, 0) > 200 THEN '严重不足' WHEN COALESCE(b.bills, 0) / nullif(s.avg_staff, 0) > 100 THEN '偏紧' WHEN COALESCE(b.bills, 0) / nullif(s.avg_staff, 0) < 30 THEN '过剩' WHEN COALESCE(b.bills, 0) / nullif(s.avg_staff, 0) < 50 THEN '偏松' ELSE '合理' END AS match_status FROM hourly_staff_avg s LEFT JOIN hourly_bills b ON s.hour = b.hour ORDER BY s.hour `, [storeName]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 门店列表(从账单表获取,确保与客流查询一致) router.get('/stores', async (req: AuthRequest, res) => { try { const result = await query(` SELECT DISTINCT c003 AS store_name FROM bill_records WHERE c003 IS NOT NULL AND c003 != '' ORDER BY store_name `) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab3: 人效对标 ============ // 门店人效排名 router.get('/efficiency-ranking', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const sort = (req.query.sort as string) || 'revenue_per_emp' const order = (req.query.order as string) || 'desc' const validSorts: Record = { revenue_per_emp: 'revenue_per_emp', emp_count: 'emp_count', gross_pay: 'gross_pay', net_pay: 'net_pay', avg_hours: 'avg_hours', wage_rate: 'wage_rate', } const sortField = validSorts[sort] || 'revenue_per_emp' const sortOrder = order === 'asc' ? 'ASC' : 'DESC' const countResult = await query(` SELECT count(*) FROM ( SELECT s.org_level5 AS store_name 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 ) t `) const total = countResult.rows[0].count const result = await query(` WITH salary_stats AS ( SELECT org_level5 AS store_name, count(*) AS emp_count, round(sum(gross_pay)::numeric, 2) AS gross_pay, round(sum(net_pay)::numeric, 2) AS net_pay, round(sum(actual_hours)::numeric, 0) AS total_hours, round(avg(actual_hours)::numeric, 0) AS avg_hours, round(sum(overtime_pay)::numeric, 2) AS overtime_pay, round(sum(perf_amount)::numeric, 2) AS perf_amount, round(sum(base_wage)::numeric, 2) AS base_wage FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' GROUP BY org_level5 ), revenue_stats AS ( SELECT COALESCE(m.salary_name, r.store_name) AS store_name, r.revenue, r.bill_count FROM mv_store_revenue r LEFT JOIN store_name_mapping m ON m.bill_name = r.store_name ) SELECT s.store_name, s.emp_count, s.gross_pay, s.net_pay, s.total_hours, s.avg_hours, s.overtime_pay, s.perf_amount, s.base_wage, COALESCE(r.revenue, 0) AS revenue, COALESCE(r.bill_count, 0) AS bill_count, round(COALESCE(r.revenue, 0) / nullif(s.emp_count, 0), 2) AS revenue_per_emp, round(s.gross_pay / nullif(s.emp_count, 0), 2) AS cost_per_emp, round(s.gross_pay / nullif(r.revenue, 0) * 100, 2) AS wage_rate, round(COALESCE(r.revenue, 0) / nullif(s.total_hours, 0), 2) AS revenue_per_hour, round(s.overtime_pay / nullif(s.gross_pay, 0) * 100, 2) AS overtime_rate, round(s.perf_amount / nullif(s.gross_pay, 0) * 100, 2) AS perf_rate, round(s.base_wage / nullif(s.gross_pay, 0) * 100, 2) AS base_wage_rate FROM salary_stats s LEFT JOIN revenue_stats r ON s.store_name = r.store_name ORDER BY ${sortField} ${sortOrder} LIMIT $1 OFFSET $2 `, [pageSize, offset]) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // 岗位配比分析 router.get('/position-distribution', async (req: AuthRequest, res) => { try { const result = await query(` SELECT org_level5 AS store_name, count(*) AS total_emp, count(*) FILTER (WHERE position LIKE '%店长%' OR position LIKE '%经理%') AS manager_count, count(*) FILTER (WHERE position LIKE '%厨%' OR position LIKE '%拉面%' OR position LIKE '%配菜%' OR position LIKE '%烧烤%' OR position LIKE '%凉菜%') AS kitchen_count, count(*) FILTER (WHERE position LIKE '%服务%' OR position LIKE '%前厅%' OR position LIKE '%收银%') AS front_count, count(*) FILTER (WHERE position LIKE '%兼职%') AS part_time_count, round(count(*) FILTER (WHERE position LIKE '%店长%' OR position LIKE '%经理%')::numeric / nullif(count(*), 0) * 100, 1) AS manager_pct, round(count(*) FILTER (WHERE position LIKE '%厨%' OR position LIKE '%拉面%' OR position LIKE '%配菜%' OR position LIKE '%烧烤%' OR position LIKE '%凉菜%')::numeric / nullif(count(*), 0) * 100, 1) AS kitchen_pct, round(count(*) FILTER (WHERE position LIKE '%服务%' OR position LIKE '%前厅%' OR position LIKE '%收银%')::numeric / nullif(count(*), 0) * 100, 1) AS front_pct FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' GROUP BY org_level5 ORDER BY total_emp DESC `) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab4: 排班建议 ============ // 排班建议(基于历史客流规律,分岗位) router.get('/scheduling-suggestion', async (req: AuthRequest, res) => { try { const storeName = req.query.store as string || '七里庄店' const frontTarget = parseInt(req.query.front_target as string) || 15 const kitchenTarget = parseInt(req.query.kitchen_target as string) || 25 const result = 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 c003 = $1 AND 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) AS avg_daily_bills, round(percentile_cont(0.85) WITHIN GROUP (ORDER BY bills)::numeric, 0) AS p85_bills, round((max(bills) - min(bills))::numeric / nullif(avg(bills), 0), 2) AS volatility FROM daily_hourly GROUP BY hour, day_type ), punch_times AS ( SELECT employee_code, position, d.day_num, d.day_val FROM attendance_records ar CROSS JOIN LATERAL ( SELECT 1 AS day_num, ar.day_01 AS day_val UNION ALL SELECT 2, ar.day_02 UNION ALL SELECT 3, ar.day_03 UNION ALL SELECT 4, ar.day_04 UNION ALL SELECT 5, ar.day_05 UNION ALL SELECT 6, ar.day_06 UNION ALL SELECT 7, ar.day_07 UNION ALL SELECT 8, ar.day_08 UNION ALL SELECT 9, ar.day_09 UNION ALL SELECT 10, ar.day_10 UNION ALL SELECT 11, ar.day_11 UNION ALL SELECT 12, ar.day_12 UNION ALL SELECT 13, ar.day_13 UNION ALL SELECT 14, ar.day_14 UNION ALL SELECT 15, ar.day_15 UNION ALL SELECT 16, ar.day_16 UNION ALL SELECT 17, ar.day_17 UNION ALL SELECT 18, ar.day_18 UNION ALL SELECT 19, ar.day_19 UNION ALL SELECT 20, ar.day_20 UNION ALL SELECT 21, ar.day_21 UNION ALL SELECT 22, ar.day_22 UNION ALL SELECT 23, ar.day_23 UNION ALL SELECT 24, ar.day_24 UNION ALL SELECT 25, ar.day_25 UNION ALL SELECT 26, ar.day_26 UNION ALL SELECT 27, ar.day_27 UNION ALL SELECT 28, ar.day_28 UNION ALL SELECT 29, ar.day_29 UNION ALL SELECT 30, ar.day_30 ) d WHERE ar.department LIKE '%' || replace($1, '总', '') || '%' AND d.day_val IS NOT NULL AND d.day_val != '' ), all_punches AS ( SELECT employee_code, position, day_num, (regexp_matches(day_val, '(\\d{2}:\\d{2})', 'g'))[1]::time AS punch_time FROM punch_times ), daily_range AS ( SELECT employee_code, position, day_num, min(punch_time) AS first_punch, max(punch_time) AS last_punch FROM all_punches GROUP BY employee_code, position, day_num ), role_classify AS ( SELECT employee_code, day_num, first_punch, last_punch, CASE WHEN position LIKE '%店长%' OR position LIKE '%经理%' OR position LIKE '储备店长%' THEN '管理' WHEN position LIKE '%服务员%' THEN '前厅服务' WHEN position LIKE '%厨师%' OR position LIKE '%厨工%' OR position LIKE '%拉面师%' OR position LIKE '%配菜师%' OR position LIKE '%凉菜师%' OR position LIKE '%烧烤师%' THEN '后厨' ELSE '其他' END AS role FROM daily_range ), hourly_staff AS ( SELECT h.hour, rc.day_num, rc.role, count(DISTINCT rc.employee_code) AS staff_on_duty FROM generate_series(6, 23) AS h(hour) CROSS JOIN role_classify rc WHERE rc.first_punch <= (h.hour || ':00')::time AND rc.last_punch >= (h.hour || ':00')::time GROUP BY h.hour, rc.day_num, rc.role ), current_staff AS ( SELECT hour, role, round(avg(staff_on_duty))::int AS avg_staff FROM hourly_staff GROUP BY hour, role ), current_other AS ( SELECT hour, round(sum(avg_staff))::int AS avg_staff FROM current_staff WHERE role IN ('管理', '其他') GROUP BY hour ), current_total AS ( SELECT hour, round(sum(avg_staff))::int AS avg_staff FROM current_staff GROUP BY hour ) SELECT hs.hour, hs.day_type, hs.avg_daily_bills, hs.p85_bills, hs.volatility, GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) AS suggested_front, GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) AS suggested_kitchen, CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END AS suggested_manager, (GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) AS suggested_total, COALESCE(cs_front.avg_staff, 0) AS current_front, COALESCE(cs_kitchen.avg_staff, 0) AS current_kitchen, COALESCE(co.avg_staff, 0) AS current_other, COALESCE(ct.avg_staff, 0) AS current_total, (GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END - COALESCE(ct.avg_staff, 0)) AS staff_gap, CASE WHEN (GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END - COALESCE(ct.avg_staff, 0)) > 2 THEN '需增配' WHEN (GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END - COALESCE(ct.avg_staff, 0)) < -2 THEN '可减配' ELSE '配置合理' END AS action FROM hourly_stats hs LEFT JOIN current_staff cs_front ON hs.hour = cs_front.hour AND cs_front.role = '前厅服务' LEFT JOIN current_staff cs_kitchen ON hs.hour = cs_kitchen.hour AND cs_kitchen.role = '后厨' LEFT JOIN current_other co ON hs.hour = co.hour LEFT JOIN current_total ct ON hs.hour = ct.hour ORDER BY hs.hour, hs.day_type `, [storeName, frontTarget, kitchenTarget]) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab5: 考勤预警 ============ // 考勤异常预警 router.get('/attendance-alert', async (req: AuthRequest, res) => { try { const result = await query(` SELECT s.org_level5 AS store_name, s.employee_code, s.position, s.actual_attend AS salary_attend_days, s.expected_attend, round(abs(s.actual_attend - COALESCE(a.punch_days, 0))::numeric, 1) AS attend_diff, s.absent_days, s.absent_deduction, s.late_deduction, s.no_punch_deduction, s.personal_leave_days, s.actual_hours, CASE WHEN s.absent_days > 0 THEN '旷工' WHEN s.actual_attend = 0 AND COALESCE(a.punch_days, 0) > 0 THEN '薪资出勤为零但有打卡' WHEN abs(s.actual_attend - COALESCE(a.punch_days, 0)) > 5 THEN '出勤天数偏差大' WHEN s.late_deduction > 0 OR s.no_punch_deduction > 0 THEN '考勤扣款' WHEN s.actual_attend / nullif(s.expected_attend, 0) < 0.8 THEN '出勤率低' ELSE NULL END AS alert_type, CASE WHEN s.absent_days > 0 THEN 'red' WHEN s.actual_attend = 0 AND COALESCE(a.punch_days, 0) > 0 THEN 'red' WHEN abs(s.actual_attend - COALESCE(a.punch_days, 0)) > 5 THEN 'orange' WHEN s.late_deduction > 0 OR s.no_punch_deduction > 0 THEN 'yellow' WHEN s.actual_attend / nullif(s.expected_attend, 0) < 0.8 THEN 'yellow' ELSE NULL END AS alert_level FROM salary_detail_records s LEFT JOIN LATERAL ( SELECT count(*) AS punch_days FROM attendance_records ar CROSS JOIN LATERAL unnest(ARRAY[ ar.day_01, ar.day_02, ar.day_03, ar.day_04, ar.day_05, ar.day_06, ar.day_07, ar.day_08, ar.day_09, ar.day_10, ar.day_11, ar.day_12, ar.day_13, ar.day_14, ar.day_15, ar.day_16, ar.day_17, ar.day_18, ar.day_19, ar.day_20, ar.day_21, ar.day_22, ar.day_23, ar.day_24, ar.day_25, ar.day_26, ar.day_27, ar.day_28, ar.day_29, ar.day_30 ]) AS d(day_val) WHERE ar.employee_code = s.employee_code AND day_val IS NOT NULL AND day_val != '' ) a ON true WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != '' AND ( s.absent_days > 0 OR (s.actual_attend = 0 AND COALESCE(a.punch_days, 0) > 0) OR abs(s.actual_attend - COALESCE(a.punch_days, 0)) > 5 OR s.late_deduction > 0 OR s.no_punch_deduction > 0 OR s.actual_attend / nullif(s.expected_attend, 0) < 0.8 ) ORDER BY CASE WHEN s.absent_days > 0 THEN 0 WHEN s.actual_attend = 0 AND COALESCE(a.punch_days, 0) > 0 THEN 1 WHEN abs(s.actual_attend - COALESCE(a.punch_days, 0)) > 5 THEN 2 ELSE 3 END, s.org_level5, s.employee_code LIMIT 200 `) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 门店考勤汇总 router.get('/attendance-summary', async (req: AuthRequest, res) => { try { const result = await query(` SELECT s.org_level5 AS store_name, count(*) AS emp_count, round(avg(s.actual_attend)::numeric, 1) AS avg_attend_days, round(avg(s.actual_hours)::numeric, 0) AS avg_hours, round(sum(s.absent_days)::numeric, 0) AS total_absent_days, round(sum(s.absent_deduction)::numeric, 2) AS total_absent_deduction, round(sum(s.late_deduction)::numeric, 2) AS total_late_deduction, round(sum(s.no_punch_deduction)::numeric, 2) AS total_no_punch_deduction, count(*) FILTER (WHERE s.absent_days > 0) AS absent_emp_count, count(*) FILTER (WHERE s.late_deduction > 0 OR s.no_punch_deduction > 0) AS punch_issue_count, round(count(*) FILTER (WHERE s.actual_attend / nullif(s.expected_attend, 0) < 0.8)::numeric / nullif(count(*), 0) * 100, 1) AS low_attendance_rate_pct 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 ORDER BY total_absent_days DESC NULLS LAST, total_late_deduction DESC NULLS LAST `) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ Tab6: 员工分析 ============ // 员工薪资分析 router.get('/employee-analysis', async (req: AuthRequest, res) => { try { const { page, pageSize, offset } = parsePagination(req) const sort = (req.query.sort as string) || 'gross_pay' const order = (req.query.order as string) || 'desc' const storeName = req.query.store as string const validSorts: Record = { gross_pay: 'gross_pay', net_pay: 'net_pay', actual_hours: 'actual_hours', perf_amount: 'perf_amount', overtime_pay: 'overtime_pay', hourly_rate: 'hourly_rate', } const sortField = validSorts[sort] || 'gross_pay' const sortOrder = order === 'asc' ? 'ASC' : 'DESC' let where = "WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''" const params: any[] = [] if (storeName) { params.push(storeName) where += ` AND org_level5 = $${params.length}` } const countResult = await query(`SELECT count(*) FROM salary_detail_records ${where}`, params) const total = countResult.rows[0].count params.push(pageSize, offset) const result = await query(` SELECT employee_code, org_level5 AS store_name, position, employment_type, hire_date, leave_date, salary_period, round(base_wage::numeric, 2) AS base_wage, round(overtime_pay::numeric, 2) AS overtime_pay, round(perf_amount::numeric, 2) AS perf_amount, round(perf_score::numeric, 2) AS perf_score, round(hourly_rate::numeric, 2) AS hourly_rate, round(gross_pay::numeric, 2) AS gross_pay, round(net_pay::numeric, 2) AS net_pay, round(actual_attend::numeric, 1) AS attend_days, round(actual_hours::numeric, 0) AS work_hours, round(overtime_pay / nullif(gross_pay, 0) * 100, 2) AS overtime_rate, round(perf_amount / nullif(gross_pay, 0) * 100, 2) AS perf_rate, round(gross_pay / nullif(actual_hours, 0), 2) AS effective_hourly_rate, CASE WHEN leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01' THEN '离职' WHEN hire_date IS NOT NULL AND hire_date != '' AND hire_date >= '2026-04-01' THEN '新员工' ELSE '在职' END AS emp_status FROM salary_detail_records ${where} ORDER BY ${sortField} ${sortOrder} LIMIT $${params.length - 1} OFFSET $${params.length} `, params) sendSuccess(res, result.rows, { page, pageSize, total }) } catch (err: any) { sendError(res, err.message) } }) // 岗位薪资对比 router.get('/position-salary-compare', async (req: AuthRequest, res) => { try { const result = await query(` SELECT position, count(*) AS emp_count, count(DISTINCT org_level5) AS store_count, round(avg(gross_pay)::numeric, 2) AS avg_gross, round(min(gross_pay)::numeric, 2) AS min_gross, round(max(gross_pay)::numeric, 2) AS max_gross, round(avg(net_pay)::numeric, 2) AS avg_net, round(avg(actual_hours)::numeric, 0) AS avg_hours, round(avg(perf_score)::numeric, 2) AS avg_perf_score, round(avg(gross_pay / nullif(actual_hours, 0))::numeric, 2) AS avg_hourly_rate FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND position IS NOT NULL AND position != '' GROUP BY position HAVING count(*) >= 5 ORDER BY avg_gross DESC `) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // 离职率统计 router.get('/turnover-stats', async (req: AuthRequest, res) => { try { const result = 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 != '0' 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 != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate, round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' GROUP BY org_level5 ORDER BY turnover_rate DESC NULLS LAST `) sendSuccess(res, result.rows) } catch (err: any) { sendError(res, err.message) } }) // ============ 总体智能分析 ============ router.get('/overall-analysis', async (req: AuthRequest, res) => { try { const [efficiency, attendance, turnover, trafficOverview, mealPeriod] = await Promise.all([ query(` WITH salary_stats AS ( SELECT org_level5 AS store_name, count(*) AS emp_count, round(sum(gross_pay)::numeric, 2) AS gross_pay, round(avg(actual_hours)::numeric, 0) AS avg_hours, round(sum(overtime_pay)::numeric, 2) AS overtime_pay FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' GROUP BY org_level5 ), revenue_stats AS ( SELECT COALESCE(m.salary_name, r.store_name) AS store_name, r.revenue, r.bill_count FROM mv_store_revenue r LEFT JOIN store_name_mapping m ON m.bill_name = r.store_name ) SELECT s.store_name, s.emp_count, s.gross_pay, s.avg_hours, s.overtime_pay, COALESCE(r.revenue, 0) AS revenue, round(COALESCE(r.revenue, 0) / nullif(s.emp_count, 0), 2) AS revenue_per_emp, round(s.gross_pay / nullif(COALESCE(r.revenue, 0), 0) * 100, 2) AS wage_rate, round(s.overtime_pay / nullif(s.gross_pay, 0) * 100, 2) AS overtime_rate FROM salary_stats s LEFT JOIN revenue_stats r ON s.store_name = r.store_name `), query(` SELECT org_level5 AS store_name, count(*) AS emp_count, round(avg(actual_attend)::numeric, 1) AS avg_attend_days, round(avg(actual_hours)::numeric, 0) AS avg_hours, sum(absent_days) AS total_absent, sum(late_deduction + no_punch_deduction) AS total_deduction, count(*) FILTER (WHERE absent_days > 0) AS absent_emp FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' GROUP BY org_level5 `), 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 != '0' 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 != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' GROUP BY org_level5 `), query(` WITH hourly AS ( SELECT store_name, hour, sum(bills) AS bills FROM mv_bill_hourly GROUP BY store_name, hour ), store_stats AS ( SELECT store_name, max(bills) AS peak_bills, min(bills) AS min_bills, sum(bills) AS total_bills, round(max(bills)::numeric / nullif(min(bills), 0), 2) AS peak_valley_ratio, round(sum(bills) FILTER (WHERE hour IN (11, 12, 17, 18, 19))::numeric / nullif(sum(bills), 0) * 100, 2) AS peak_concentration_pct FROM hourly GROUP BY store_name ) SELECT store_name, total_bills, peak_bills, min_bills, peak_valley_ratio, peak_concentration_pct, CASE WHEN peak_valley_ratio > 20 THEN '波动极大' WHEN peak_valley_ratio > 10 THEN '波动较大' WHEN peak_valley_ratio > 5 THEN '波动适中' ELSE '波动较小' END AS volatility_level FROM store_stats ORDER BY total_bills DESC `), query(` SELECT store_name, meal_period, sum(bills) AS bills FROM mv_bill_hourly WHERE meal_period != '' GROUP BY store_name, meal_period `), ]) const effRows = efficiency.rows const attRows = attendance.rows const turnRows = turnover.rows const trafficRows = trafficOverview.rows const mealRows = mealPeriod.rows // 品牌级汇总指标 const totalStores = effRows.length const totalEmp = effRows.reduce((s: number, r: any) => s + parseFloat(r.emp_count), 0) const totalRevenue = effRows.reduce((s: number, r: any) => s + parseFloat(r.revenue), 0) const totalPayroll = effRows.reduce((s: number, r: any) => s + parseFloat(r.gross_pay), 0) const avgRevenuePerEmp = totalEmp > 0 ? totalRevenue / totalEmp : 0 const avgWageRate = totalRevenue > 0 ? (totalPayroll / totalRevenue) * 100 : 0 const avgOvertimeRate = totalPayroll > 0 ? (effRows.reduce((s: number, r: any) => s + parseFloat(r.overtime_pay), 0) / totalPayroll) * 100 : 0 const totalAbsent = attRows.reduce((s: number, r: any) => s + parseFloat(r.total_absent || 0), 0) const totalAbsentEmp = attRows.reduce((s: number, r: any) => s + parseFloat(r.absent_emp || 0), 0) const totalLeft = turnRows.reduce((s: number, r: any) => s + parseFloat(r.left_count || 0), 0) const totalNew = turnRows.reduce((s: number, r: any) => s + parseFloat(r.new_count || 0), 0) const avgTurnoverRate = totalEmp > 0 ? (totalLeft / totalEmp) * 100 : 0 // 生成诊断建议 const insights: any[] = [] // 1. 人效分析 const lowEffStores = effRows.filter((r: any) => parseFloat(r.revenue_per_emp) < 20000 && parseFloat(r.revenue_per_emp) > 0).sort((a: any, b: any) => parseFloat(a.revenue_per_emp) - parseFloat(b.revenue_per_emp)) const highEffStores = effRows.filter((r: any) => parseFloat(r.revenue_per_emp) > 50000).sort((a: any, b: any) => parseFloat(b.revenue_per_emp) - parseFloat(a.revenue_per_emp)) if (lowEffStores.length > 0) { insights.push({ category: '人效', level: 'red', title: `${lowEffStores.length}家门店人均创收低于2万`, detail: `人均创收最低:${lowEffStores.slice(0, 3).map((r: any) => `${r.store_name}(${Math.round(parseFloat(r.revenue_per_emp))}元)`).join('、')}`, suggestion: '建议排查这些门店的排班合理性,是否存在人浮于事;同时对比高人效门店的运营模式', metric: 'revenue_per_emp', value: avgRevenuePerEmp.toFixed(0), }) } if (highEffStores.length > 0) { insights.push({ category: '人效', level: 'green', title: `${highEffStores.length}家门店人均创收超5万`, detail: `高人效标杆:${highEffStores.slice(0, 3).map((r: any) => `${r.store_name}(${Math.round(parseFloat(r.revenue_per_emp))}元)`).join('、')}`, suggestion: '建议提炼高人效门店的排班模式和岗位配置经验,向其他门店推广', metric: 'revenue_per_emp', value: avgRevenuePerEmp.toFixed(0), }) } // 2. 人力成本率 const highWageStores = effRows.filter((r: any) => parseFloat(r.wage_rate) > 25).sort((a: any, b: any) => parseFloat(b.wage_rate) - parseFloat(a.wage_rate)) if (highWageStores.length > 0) { insights.push({ category: '成本', level: highWageStores.length > 10 ? 'red' : 'orange', title: `${highWageStores.length}家门店人力成本率超25%`, detail: `成本率最高:${highWageStores.slice(0, 3).map((r: any) => `${r.store_name}(${parseFloat(r.wage_rate).toFixed(1)}%)`).join('、')}`, suggestion: '人力成本率过高,建议优化排班减少冗余人力,或提升营收分摊固定成本', metric: 'wage_rate', value: avgWageRate.toFixed(1) + '%', }) } // 3. 加班占比 const highOvertimeStores = effRows.filter((r: any) => parseFloat(r.overtime_rate) > 5).sort((a: any, b: any) => parseFloat(b.overtime_rate) - parseFloat(a.overtime_rate)) if (highOvertimeStores.length > 0) { insights.push({ category: '加班', level: 'orange', title: `${highOvertimeStores.length}家门店加班费占比超5%`, detail: `加班最严重:${highOvertimeStores.slice(0, 3).map((r: any) => `${r.store_name}(${parseFloat(r.overtime_rate).toFixed(1)}%)`).join('、')}`, suggestion: '加班占比过高说明排班与客流不匹配,建议在高峰时段增加兼职或调整班次', metric: 'overtime_rate', value: avgOvertimeRate.toFixed(1) + '%', }) } // 4. 客流波动 const highVolatilityStores = trafficRows.filter((r: any) => r.volatility_level === '波动极大' || r.volatility_level === '波动较大') if (highVolatilityStores.length > 0) { insights.push({ category: '客流', level: highVolatilityStores.length > 20 ? 'red' : 'orange', title: `${highVolatilityStores.length}家门店客流波动较大`, detail: `峰谷比最高:${highVolatilityStores.slice(0, 3).map((r: any) => `${r.store_name}(${r.peak_valley_ratio}倍)`).join('、')}`, suggestion: '客流波动大的门店应推行弹性排班,低谷时段减少在岗人数,高峰前提前补人', metric: 'peak_valley_ratio', value: '', }) } // 5. 高峰集中度 const highConcentrationStores = trafficRows.filter((r: any) => parseFloat(r.peak_concentration_pct) > 60) if (highConcentrationStores.length > 0) { insights.push({ category: '客流', level: 'orange', title: `${highConcentrationStores.length}家门店高峰集中度超60%`, detail: `集中度最高:${highConcentrationStores.slice(0, 3).map((r: any) => `${r.store_name}(${r.peak_concentration_pct}%)`).join('、')}`, suggestion: '客流高度集中在午晚餐高峰,建议在11-13点和17-19点增加兼职力量,非高峰时段精简人员', metric: 'peak_concentration_pct', value: '', }) } // 6. 考勤异常 if (totalAbsentEmp > 0) { const absentStores = attRows.filter((r: any) => parseFloat(r.absent_emp) > 0).sort((a: any, b: any) => parseFloat(b.absent_emp) - parseFloat(a.absent_emp)) insights.push({ category: '考勤', level: 'red', title: `${totalAbsentEmp}名员工存在旷工`, detail: `旷工最多:${absentStores.slice(0, 3).map((r: any) => `${r.store_name}(${r.absent_emp}人)`).join('、')}`, suggestion: '旷工直接影响门店运营,建议立即核查原因并完善考勤管理制度', metric: 'absent_emp', value: totalAbsentEmp.toString(), }) } // 7. 离职率 if (avgTurnoverRate > 5) { const highTurnoverStores = turnRows.filter((r: any) => parseFloat(r.turnover_rate) > 15).sort((a: any, b: any) => parseFloat(b.turnover_rate) - parseFloat(a.turnover_rate)) insights.push({ category: '离职', level: avgTurnoverRate > 10 ? 'red' : 'orange', title: `品牌整体离职率${avgTurnoverRate.toFixed(1)}%`, detail: highTurnoverStores.length > 0 ? `离职率最高:${highTurnoverStores.slice(0, 3).map((r: any) => `${r.store_name}(${parseFloat(r.turnover_rate).toFixed(1)}%)`).join('、')}` : `共${totalLeft}人离职,${totalNew}人入职`, suggestion: '高离职率增加招聘培训成本,建议关注离职原因,优化薪酬福利和排班制度', metric: 'turnover_rate', value: avgTurnoverRate.toFixed(1) + '%', }) } // 8. 新员工占比 if (totalNew > 0) { const newRate = totalEmp > 0 ? (totalNew / totalEmp) * 100 : 0 if (newRate > 15) { insights.push({ category: '离职', level: 'orange', title: `新员工占比${newRate.toFixed(1)}%,人员变动频繁`, detail: `入职${totalNew}人,离职${totalLeft}人,净流失${totalLeft - totalNew}人`, suggestion: '新员工占比高说明人员流动大,建议加强新员工培训和带教制度,降低试用期离职率', metric: 'new_hire_rate', value: newRate.toFixed(1) + '%', }) } } // 9. 低出勤率 const lowAttendStores = attRows.filter((r: any) => parseFloat(r.avg_attend_days) < 20 && parseFloat(r.avg_attend_days) > 0) if (lowAttendStores.length > 0) { insights.push({ category: '考勤', level: 'orange', title: `${lowAttendStores.length}家门店平均出勤不足20天`, detail: `出勤最低:${lowAttendStores.slice(0, 3).map((r: any) => `${r.store_name}(${r.avg_attend_days}天)`).join('、')}`, suggestion: '出勤天数偏低可能存在排班不足或人员冗余,建议核查排班计划与实际出勤的差异', metric: 'avg_attend_days', value: '', }) } // 10. 餐段结构异常 const mealMap: Record> = {} mealRows.forEach((r: any) => { if (!mealMap[r.store_name]) mealMap[r.store_name] = {} mealMap[r.store_name][r.meal_period] = parseFloat(r.bills) }) const unbalancedStores = Object.entries(mealMap).filter(([store, meals]) => { const m = meals as Record const total = Object.values(m).reduce((s: number, v: number) => s + v, 0) if (total === 0) return false const maxPct = Math.max(...Object.values(m).map((v: number) => v / total * 100)) return maxPct > 70 }).map(([store]) => store) if (unbalancedStores.length > 0) { insights.push({ category: '客流', level: 'yellow', title: `${unbalancedStores.length}家门店餐段结构极度不均衡`, detail: `如:${unbalancedStores.slice(0, 3).join('、')},单一餐段占比超70%`, suggestion: '餐段过于集中会增加高峰排班压力,建议在非主力餐段推出促销活动平衡客流', metric: 'meal_balance', value: '', }) } // 品牌级KPI const kpis = [ { label: '门店总数', value: totalStores.toString(), unit: '家' }, { label: '总员工数', value: totalEmp.toString(), unit: '人' }, { label: '总营收', value: (totalRevenue / 10000).toFixed(0), unit: '万元' }, { label: '总工资', value: (totalPayroll / 10000).toFixed(0), unit: '万元' }, { label: '人均创收', value: Math.round(avgRevenuePerEmp).toString(), unit: '元/人' }, { label: '人力成本率', value: avgWageRate.toFixed(1), unit: '%' }, { label: '加班费占比', value: avgOvertimeRate.toFixed(1), unit: '%' }, { label: '离职率', value: avgTurnoverRate.toFixed(1), unit: '%' }, { label: '旷工人数', value: totalAbsentEmp.toString(), unit: '人' }, { label: '离职/入职', value: `${totalLeft}/${totalNew}`, unit: '人' }, ] sendSuccess(res, { kpis, insights }) } catch (err: any) { sendError(res, err.message) } }) // ============ 人员招聘/解聘预测(规则引擎) ============ router.get('/staffing-forecast', async (req: AuthRequest, res) => { try { const [roleStats, storeRevenue, storeTraffic] = await Promise.all([ query(` SELECT org_level5 AS store_name, CASE WHEN position LIKE '%店长%' OR position LIKE '%经理%' OR position LIKE '储备%' OR position LIKE '副店%' THEN '管理' WHEN position LIKE '%服务员%' OR position LIKE '%训练员%' OR position LIKE '%迎宾%' OR position LIKE '%传菜%' OR position LIKE '服务主管%' OR position LIKE '主管%' THEN '前厅' WHEN position LIKE '%厨%' OR position LIKE '%拉面%' OR position LIKE '%配菜%' OR position LIKE '%凉菜%' OR position LIKE '%烧烤%' OR position LIKE '%面点%' OR position LIKE '%面工%' OR position LIKE '%锅底%' OR position LIKE '%切肉%' OR position LIKE '%切菜%' OR position LIKE '%炒锅%' OR position LIKE '%砧板%' OR position LIKE '%打荷%' OR position LIKE '%洗碗%' OR position LIKE '%上什%' OR position LIKE '%打馕%' THEN '后厨' WHEN position LIKE '%兼职%' OR position LIKE '%小时工%' THEN '兼职' ELSE '其他' END AS role, count(*) AS emp_count, count(*) FILTER (WHERE leave_date IS NULL OR leave_date = '' OR leave_date = '0') AS active_count, round(sum(gross_pay)::numeric, 2) AS total_pay, round(sum(gross_pay) FILTER (WHERE leave_date IS NULL OR leave_date = '' OR leave_date = '0')::numeric, 2) AS active_pay, round(avg(gross_pay)::numeric, 2) AS avg_pay, round(avg(actual_attend)::numeric, 1) AS avg_attend, round(avg(actual_hours)::numeric, 0) AS avg_hours, round(sum(actual_hours)::numeric, 0) AS total_hours, count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' 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 FROM salary_detail_records WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' GROUP BY 1, 2 `), query(` SELECT s.salary_name AS store_name, r.revenue, r.bill_count FROM (SELECT DISTINCT org_level5 AS salary_name FROM salary_detail_records WHERE org_level2='西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '') s LEFT JOIN store_name_mapping m ON m.salary_name = s.salary_name LEFT JOIN mv_store_revenue r ON r.store_name = COALESCE(m.bill_name, s.salary_name) `), query(` WITH hourly AS ( SELECT store_name, hour, sum(bills) AS bills FROM mv_bill_hourly GROUP BY store_name, hour ) SELECT store_name, max(bills) AS peak_bills, round(max(bills)::numeric / nullif(min(bills), 0), 2) AS peak_valley_ratio, round(sum(bills) FILTER (WHERE hour IN (11, 12, 17, 18, 19))::numeric / nullif(sum(bills), 0) * 100, 2) AS peak_concentration_pct FROM hourly GROUP BY store_name `), ]) // 组装指标数据 const revMap: Record = {} storeRevenue.rows.forEach((r: any) => { revMap[r.store_name] = parseFloat(r.revenue) }) const billMap: Record = {} storeRevenue.rows.forEach((r: any) => { billMap[r.store_name] = parseInt(r.bill_count) }) const trafficMap: Record = {} storeTraffic.rows.forEach((r: any) => { trafficMap[r.store_name] = r }) const storeEmpCount: Record = {} const storeActivePay: Record = {} const storeTotalHours: Record = {} roleStats.rows.forEach((r: any) => { storeEmpCount[r.store_name] = (storeEmpCount[r.store_name] || 0) + parseInt(r.emp_count) storeActivePay[r.store_name] = (storeActivePay[r.store_name] || 0) + parseFloat(r.active_pay) storeTotalHours[r.store_name] = (storeTotalHours[r.store_name] || 0) + parseInt(r.total_hours) }) // 组装每个门店×岗位的指标数据 const items: any[] = [] for (const r of roleStats.rows) { const store = r.store_name const role = r.role const empCount = parseInt(r.emp_count) const activeCount = parseInt(r.active_count) const revenue = revMap[store] || 0 const bills = billMap[store] || 0 const totalEmp = storeEmpCount[store] || empCount const totalStorePay = storeActivePay[store] || 0 const totalStoreHours = storeTotalHours[store] || 0 const revenuePerEmp = totalEmp > 0 ? Math.round(revenue / totalEmp) : 0 const revenuePerHour = totalStoreHours > 0 ? Math.round(revenue / totalStoreHours) : 0 const wageRatio = revenue > 0 ? Math.round((totalStorePay / revenue) * 1000) / 10 : 0 const roleEmpRatio = totalEmp > 0 ? Math.round((empCount / totalEmp) * 1000) / 10 : 0 const rolePayRatio = totalStorePay > 0 ? Math.round((parseFloat(r.active_pay) / totalStorePay) * 1000) / 10 : 0 const turnoverPct = empCount > 0 ? Math.round((parseInt(r.left_count) / empCount) * 1000) / 10 : 0 const traffic = trafficMap[store] const peakConcentration = traffic ? parseFloat(traffic.peak_concentration_pct) : 0 const peakValley = traffic ? parseFloat(traffic.peak_valley_ratio) : 0 items.push({ id: `${store}|${role}`, store_name: store, role, emp_count: empCount, active_count: activeCount, avg_pay: parseFloat(r.avg_pay), avg_attend: parseFloat(r.avg_attend) || 0, avg_hours: parseFloat(r.avg_hours) || 0, total_hours: parseInt(r.total_hours) || 0, total_pay: parseFloat(r.active_pay), left_count: parseInt(r.left_count) || 0, new_count: parseInt(r.new_count) || 0, turnover_pct: turnoverPct, revenue_per_emp: revenuePerEmp, revenue_per_hour: revenuePerHour, wage_ratio: wageRatio, role_emp_ratio: roleEmpRatio, role_pay_ratio: rolePayRatio, peak_concentration: peakConcentration, peak_valley_ratio: peakValley, bill_count: bills, }) } // === 动态标准:基于全部门店实际数据计算(用中位数,比均值更抗极端值) === const storeLevelMetrics: { store: string; revenue_per_emp: number; revenue_per_hour: number; wage_ratio: number }[] = [] Object.keys(storeEmpCount).forEach(store => { const rev = revMap[store] || 0 if (rev > 0) { const emp = storeEmpCount[store] || 0 const hours = storeTotalHours[store] || 0 const pay = storeActivePay[store] || 0 storeLevelMetrics.push({ store, revenue_per_emp: emp > 0 ? Math.round(rev / emp) : 0, revenue_per_hour: hours > 0 ? Math.round(rev / hours) : 0, wage_ratio: rev > 0 ? Math.round((pay / rev) * 1000) / 10 : 0, }) } }) function median(arr: number[]): number { if (arr.length === 0) return 0 const sorted = [...arr].sort((a, b) => a - b) const mid = Math.floor(sorted.length / 2) return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid] } const avgRevenuePerEmp = median(storeLevelMetrics.map(s => s.revenue_per_emp)) || 15000 const avgRevenuePerHour = median(storeLevelMetrics.map(s => s.revenue_per_hour)) || 200 const avgWageRatio = median(storeLevelMetrics.map(s => Math.round(s.wage_ratio * 10))) / 10 || 30 // 各岗位实际平均占比 const roleEmpTotals: Record = {} const rolePayTotals: Record = {} const roleAttendSum: Record = {} const roleAttendCount: Record = {} const roleHoursSum: Record = {} const roleHoursCount: Record = {} const rolePaySum: Record = {} const rolePayCount: Record = {} let grandTotalEmp = 0 roleStats.rows.forEach((r: any) => { const role = r.role const empCount = parseInt(r.emp_count) roleEmpTotals[role] = (roleEmpTotals[role] || 0) + empCount rolePayTotals[role] = (rolePayTotals[role] || 0) + parseFloat(r.active_pay) grandTotalEmp += empCount if (parseFloat(r.avg_attend) > 0) { roleAttendSum[role] = (roleAttendSum[role] || 0) + parseFloat(r.avg_attend) * empCount roleAttendCount[role] = (roleAttendCount[role] || 0) + empCount } if (parseFloat(r.avg_hours) > 0) { roleHoursSum[role] = (roleHoursSum[role] || 0) + parseFloat(r.avg_hours) * empCount roleHoursCount[role] = (roleHoursCount[role] || 0) + empCount } if (parseFloat(r.avg_pay) > 0) { rolePaySum[role] = (rolePaySum[role] || 0) + parseFloat(r.avg_pay) * empCount rolePayCount[role] = (rolePayCount[role] || 0) + empCount } }) const DYNAMIC_STANDARDS: Record = {} for (const role of ['管理', '前厅', '后厨', '兼职', '其他']) { DYNAMIC_STANDARDS[role] = { role_ratio: grandTotalEmp > 0 ? Math.round((roleEmpTotals[role] / grandTotalEmp) * 1000) / 10 : 0, normal_pay: rolePayCount[role] > 0 ? Math.round(rolePaySum[role] / rolePayCount[role]) : 0, normal_attend: roleAttendCount[role] > 0 ? Math.round(roleAttendSum[role] / roleAttendCount[role]) : 24, normal_hours: roleHoursCount[role] > 0 ? Math.round(roleHoursSum[role] / roleHoursCount[role]) : 160, } } // 管理岗max_count仍用经验值4 DYNAMIC_STANDARDS['管理'].max_count = 4 DYNAMIC_STANDARDS['前厅'].max_count = 15 DYNAMIC_STANDARDS['后厨'].max_count = 20 DYNAMIC_STANDARDS['兼职'].max_count = 5 DYNAMIC_STANDARDS['其他'].max_count = 3 // 兼职出勤标准为0(弹性工时) DYNAMIC_STANDARDS['兼职'].normal_attend = 0 DYNAMIC_STANDARDS['兼职'].min_attend = 0 for (const role of ['管理', '前厅', '后厨', '其他']) { DYNAMIC_STANDARDS[role].min_attend = 20 } console.log('[StaffingForecast] 动态标准:', { avgRevenuePerEmp, avgRevenuePerHour, avgWageRatio, roleRatios: Object.fromEntries(Object.entries(DYNAMIC_STANDARDS).map(([k,v]) => [k, v.role_ratio])), }) // === 规则库 === const STANDARDS = DYNAMIC_STANDARDS const roleOrder: Record = { '管理': 0, '前厅': 1, '后厨': 2, '兼职': 3, '其他': 4 } const forecasts: any[] = [] for (const item of items) { const std = STANDARDS[item.role] || STANDARDS['其他'] const expectedRoleRatio = std.role_ratio // === 第一层:产能诊断(优化信号) === const optSignals: string[] = [] let optScore = 0 // R1: 人均创收低于均值 + 岗位占比偏高 → 冗余 if (item.revenue_per_emp > 0 && item.revenue_per_emp < avgRevenuePerEmp && item.role !== '兼职') { if (item.role_emp_ratio > expectedRoleRatio + 5) { optSignals.push(`门店人均创收${item.revenue_per_emp}元(均值${avgRevenuePerEmp}元),${item.role}占比${item.role_emp_ratio}%偏高(标准${expectedRoleRatio}%),岗位冗余`) optScore += 30 } else { optSignals.push(`门店人均创收${item.revenue_per_emp}元(均值${avgRevenuePerEmp}元),差${avgRevenuePerEmp - item.revenue_per_emp}元`) optScore += 15 } } // R2: 工时产能低于均值(非兼职)→ 人效偏低,门店级指标所有岗位均受约束 if (item.revenue_per_hour > 0 && item.revenue_per_hour < avgRevenuePerHour && item.role !== '兼职') { optSignals.push(`工时产能${item.revenue_per_hour}元/工时(均值${avgRevenuePerHour}元),人效偏低`) optScore += 20 } // R3: 人力成本率高于均值 + 岗位薪资占比偏高 if (item.wage_ratio > avgWageRatio && item.role !== '兼职' && item.role_emp_ratio > expectedRoleRatio + 3) { optSignals.push(`人力成本率${item.wage_ratio}%(均值${avgWageRatio}%),${item.role}薪资占比${item.role_pay_ratio}%偏高`) optScore += 20 } // R4: 岗位人数占比超配 if (item.role_emp_ratio > expectedRoleRatio + 8) { optSignals.push(`${item.role}人数占比${item.role_emp_ratio}%(标准${expectedRoleRatio}%),配置偏高`) optScore += 15 } // R5: 管理岗超配 if (item.role === '管理' && item.emp_count > std.max_count) { optSignals.push(`管理岗${item.emp_count}人(标准≤${std.max_count}人),管理层冗余`) optScore += 20 } // R6: 其他岗超配 if (item.role === '其他' && item.emp_count > std.max_count) { optSignals.push(`其他岗位${item.emp_count}人(标准≤${std.max_count}人),建议明确职责或转岗`) optScore += 10 } // R7: 工时产能低 + 出勤低 → 工时未饱和(转为优化信号) if (item.avg_attend > 0 && item.avg_attend < std.min_attend && item.role !== '兼职') { const hasLowHourly = optSignals.some(s => s.includes('工时产能')) if (hasLowHourly) { optSignals.push(`${item.role}平均出勤${item.avg_attend}天(标准≥${std.min_attend}天),工时未饱和,应增加排班而非加人`) optScore += 15 } } // === 第二层:互斥判断 === const hasOptSignals = optSignals.length > 0 // === 第三层:人手诊断(招聘信号,仅无优化信号时触发) === const hireSignals: string[] = [] let hireScore = 0 if (!hasOptSignals) { const roleOverstaffed = item.role_emp_ratio > expectedRoleRatio + 3 // R8: 岗位人数占比偏低 if (item.role_emp_ratio < expectedRoleRatio - 5 && item.role !== '兼职' && item.active_count > 0) { hireSignals.push(`${item.role}人数占比${item.role_emp_ratio}%(标准${expectedRoleRatio}%),配置偏低`) hireScore += 15 } // R9: 出勤不足(占比未超配、管理岗未满编时才触发) const mgmtFull = item.role === '管理' && item.emp_count >= std.max_count if (item.avg_attend > 0 && item.avg_attend < std.min_attend && item.role !== '兼职' && !roleOverstaffed && !mgmtFull) { hireSignals.push(`${item.role}平均出勤${item.avg_attend}天(标准≥${std.min_attend}天),排班不足`) hireScore += 15 } // R10: 离职缺口(需同时满足:岗位未超配、门店人效正常、出勤不足——出勤正常说明人手够用) const attendLow = item.avg_attend > 0 && item.avg_attend < std.min_attend && item.role !== '兼职' if (item.left_count > 0 && item.turnover_pct >= 10 && item.role_emp_ratio <= expectedRoleRatio + 3 && (item.revenue_per_hour === 0 || item.revenue_per_hour >= avgRevenuePerHour || item.role !== '兼职') && (attendLow || item.role === '兼职')) { hireSignals.push(`${item.role}当月离职${item.left_count}人(离职率${item.turnover_pct}%,标准<10%),需填补缺口`) hireScore += 25 } // R11: 新员工稳定性(占比未超配+出勤不足时才触发) if (item.new_count > 0 && item.new_count >= item.active_count * 0.2 && attendLow && !roleOverstaffed) { hireSignals.push(`${item.role}当月新入职${item.new_count}人(占在职${Math.round(item.new_count / Math.max(item.active_count, 1) * 100)}%),需带教防流失`) hireScore += 10 } // R12: 客流匹配-前厅(占比未超配时才触发) if (item.role === '前厅' && item.peak_concentration > 60 && item.emp_count < 10 && !roleOverstaffed) { hireSignals.push(`高峰集中度${item.peak_concentration}%(标准<60%),前厅仅${item.emp_count}人,高峰服务压力大`) hireScore += 20 } // R13: 客流匹配-后厨(占比未超配时才触发) if (item.role === '后厨' && item.peak_valley_ratio > 10 && item.emp_count < 8 && !roleOverstaffed) { hireSignals.push(`客流峰谷比${item.peak_valley_ratio}倍(标准<10倍),后厨仅${item.emp_count}人,需弹性人手`) hireScore += 15 } } // R14: 兼职特殊(不受互斥限制,但占比超配或人效低时不招聘) if (item.role === '兼职' && item.peak_concentration > 50 && item.emp_count < 3 && item.role_emp_ratio <= expectedRoleRatio + 3 && (item.revenue_per_hour === 0 || item.revenue_per_hour >= avgRevenuePerHour)) { hireSignals.push(`高峰集中度${item.peak_concentration}%,兼职仅${item.emp_count}人,建议增加兼职覆盖高峰`) hireScore += 15 } // === 第四层:动作决策 === const urgency = Math.min(optScore + hireScore, 100) const noRevenueData = item.revenue_per_emp === 0 let action = '维持' let actionLevel = 'green' if (hasOptSignals && optScore >= 20) { action = '建议优化' actionLevel = optScore >= 60 ? 'red' : optScore >= 35 ? 'orange' : 'yellow' } else if (hireSignals.length > 0 && hireScore >= 20) { // 无营收数据时无法确认是否真的缺人,降级为关注 action = noRevenueData ? '关注' : '建议招聘' actionLevel = noRevenueData ? 'yellow' : (hireScore >= 60 ? 'red' : hireScore >= 35 ? 'orange' : 'yellow') } else if (urgency >= 20) { action = '关注' actionLevel = 'yellow' } if (action === '维持' && urgency === 0) continue // 生成分析文本 const analysisParts: string[] = [] if (item.revenue_per_emp > 0) analysisParts.push(`人均创收${item.revenue_per_emp}元${item.revenue_per_emp < avgRevenuePerEmp ? '(低于中位)' : ''}`) if (item.revenue_per_hour > 0) analysisParts.push(`工时产能${item.revenue_per_hour}元/h${item.revenue_per_hour < avgRevenuePerHour ? '(低于中位)' : ''}`) if (item.wage_ratio > 0) analysisParts.push(`人力成本率${item.wage_ratio}%${item.wage_ratio > avgWageRatio ? '(高于中位)' : ''}`) if (item.avg_attend > 0) analysisParts.push(`出勤${item.avg_attend}天`) if (item.turnover_pct > 0) analysisParts.push(`离职率${item.turnover_pct}%`) const analysis = analysisParts.join(',') + '。' // 生成建议文本 let suggestion = '' if (action === '建议优化') { if (optSignals.some(s => s.includes('工时未饱和'))) { suggestion = `建议对 ${item.store_name} 的 ${item.role} 岗位提高排班覆盖率,增加现有人员工时饱和度,暂不需要增编。` } else if (optSignals.some(s => s.includes('冗余'))) { suggestion = `建议对 ${item.store_name} 的 ${item.role} 岗位进行人员优化,可考虑转岗或精简,预估可节省月人力成本约 ${item.avg_pay}元/人。` } else if (optSignals.some(s => s.includes('人效偏低'))) { suggestion = `建议对 ${item.store_name} 的 ${item.role} 岗位提升人效,优化工作流程或调整排班,暂不增编。` } else { suggestion = `建议对 ${item.store_name} 的 ${item.role} 岗位进行优化调整,关注产能指标改善。` } } else if (action === '建议招聘') { const hireNum = Math.max(item.left_count, 1) suggestion = `建议为 ${item.store_name} 的 ${item.role} 岗位补充${hireNum}人,预估月人力成本增加约 ${item.avg_pay * hireNum}元。` } else if (action === '关注') { if (noRevenueData && hireSignals.length > 0) { suggestion = `${item.store_name} 缺少营收数据,无法评估人效。建议先录入营收数据再判断是否需要补充 ${item.role} 人员。` } else { suggestion = `建议持续关注 ${item.store_name} 的 ${item.role} 岗位的人员变动和人效表现,暂不需要立即调整。` } } forecasts.push({ store_name: item.store_name, role: item.role, emp_count: item.emp_count, active_count: item.active_count, avg_pay: item.avg_pay, avg_attend: item.avg_attend, avg_hours: item.avg_hours, total_hours: item.total_hours, total_pay: item.total_pay, left_count: item.left_count, new_count: item.new_count, turnover_pct: item.turnover_pct, revenue_per_emp: item.revenue_per_emp, revenue_per_hour: item.revenue_per_hour, wage_ratio: item.wage_ratio, role_emp_ratio: item.role_emp_ratio, role_pay_ratio: item.role_pay_ratio, action, action_level: actionLevel, urgency, analysis, suggestion, hire_reasons: hireSignals.join(';'), optimize_reasons: optSignals.join(';'), reasons: [...hireSignals, ...optSignals].join(';'), standards: { normal_attend: std.normal_attend, min_attend: std.min_attend, max_count: std.max_count, normal_pay: std.normal_pay, normal_hours: std.normal_hours, expected_role_ratio: expectedRoleRatio, revenue_per_emp: avgRevenuePerEmp, revenue_per_hour: avgRevenuePerHour, wage_ratio: avgWageRatio, turnover: 10, }, }) } // 排序 forecasts.sort((a, b) => { if (b.urgency !== a.urgency) return b.urgency - a.urgency if (a.store_name !== b.store_name) return a.store_name.localeCompare(b.store_name) return (roleOrder[a.role] || 99) - (roleOrder[b.role] || 99) }) const hireCount = forecasts.filter(f => f.action === '建议招聘').length const optimizeCount = forecasts.filter(f => f.action === '建议优化').length const watchCount = forecasts.filter(f => f.action === '关注').length sendSuccess(res, { forecasts, summary: { total: forecasts.length, hire: hireCount, optimize: optimizeCount, watch: watchCount, }, ruleEngine: { standards: { revenue_per_emp: avgRevenuePerEmp, revenue_per_hour: avgRevenuePerHour, wage_ratio: avgWageRatio, roles: Object.fromEntries(Object.entries(DYNAMIC_STANDARDS).map(([k, v]: [string, any]) => [ k, { role_ratio: v.role_ratio, normal_pay: v.normal_pay, normal_attend: v.normal_attend, normal_hours: v.normal_hours, min_attend: v.min_attend, max_count: v.max_count } ])), }, optimizationRules: [ { id: 'R1', name: '人均创收低+占比偏高', condition: `人均创收 < 中位数(${avgRevenuePerEmp}元) 且 岗位占比 > 标准+5%`, score: 30, action: '优化' }, { id: 'R1b', name: '人均创收低', condition: `人均创收 < 中位数(${avgRevenuePerEmp}元) 且 占比正常`, score: 15, action: '优化' }, { id: 'R2', name: '工时产能低', condition: `工时产能 < 中位数(${avgRevenuePerHour}元/h) 且 非兼职`, score: 20, action: '优化' }, { id: 'R3', name: '人力成本率高+薪资占比高', condition: `人力成本率 > 中位数(${avgWageRatio}%) 且 岗位占比 > 标准+3% 且 非兼职`, score: 20, action: '优化' }, { id: 'R4', name: '岗位占比超配', condition: `岗位占比 > 标准+8%`, score: 15, action: '优化' }, { id: 'R5', name: '管理岗超编', condition: `管理岗人数 > ${DYNAMIC_STANDARDS['管理'].max_count}人`, score: 20, action: '优化' }, { id: 'R6', name: '其他岗超编', condition: `其他岗人数 > ${DYNAMIC_STANDARDS['其他'].max_count}人`, score: 10, action: '优化' }, { id: 'R7', name: '工时未饱和', condition: `已触发R2 且 出勤 < ${DYNAMIC_STANDARDS['后厨'].min_attend}天`, score: 15, action: '优化' }, ], hireRules: [ { id: 'R8', name: '岗位占比偏低', condition: `岗位占比 < 标准-5% 且 非兼职 且 在职>0`, score: 15, action: '招聘', constraint: '无优化信号' }, { id: 'R9', name: '出勤不足', condition: `出勤 < ${DYNAMIC_STANDARDS['后厨'].min_attend}天 且 非兼职 且 占比未超配 且 管理岗未满编`, score: 15, action: '招聘', constraint: '无优化信号' }, { id: 'R10', name: '离职缺口', condition: `离职率≥10% 且 占比未超配 且 人效正常 且 出勤不足`, score: 25, action: '招聘', constraint: '无优化信号' }, { id: 'R11', name: '新员工带教', condition: `新入职≥在职20% 且 出勤不足 且 占比未超配`, score: 10, action: '招聘', constraint: '无优化信号' }, { id: 'R12', name: '前厅高峰压力', condition: `前厅 且 高峰集中度>60% 且 人数<10 且 占比未超配`, score: 20, action: '招聘', constraint: '无优化信号' }, { id: 'R13', name: '后厨峰谷差', condition: `后厨 且 峰谷比>10倍 且 人数<8 且 占比未超配`, score: 15, action: '招聘', constraint: '无优化信号' }, { id: 'R14', name: '兼职高峰覆盖', condition: `兼职 且 高峰集中度>50% 且 人数<3 且 占比未超配 且 人效正常`, score: 15, action: '招聘', constraint: '不受互斥限制' }, ], decisionLogic: [ { step: 1, name: '产能诊断', desc: '依次检查R1~R7,累计优化信号和分值' }, { step: 2, name: '互斥判断', desc: '有任何优化信号→所有招聘信号(R8~R13)被抑制' }, { step: 3, name: '人手诊断', desc: '无优化信号时检查R8~R13,累计招聘信号和分值' }, { step: 4, name: '动作决策', desc: '优化分≥20→建议优化;招聘分≥20→建议招聘(无营收数据降级为关注);其他≥20→关注' }, ], }, }) } catch (err: any) { sendError(res, err.message) } }) export default router