Files
SBrainCO/server/src/routes/smart-scheduling.ts
T
freedakgmail 6bab0ad84a feat: 客流热力图增加在岗人员行+规则引擎面板+菜单名称调整
- 热力图每门店增加在岗人员行(前厅/后厨/管理/其他),从考勤打卡记录解析
- 新增traffic-heatmap-staffing API,解析考勤打卡时间计算每小时在岗人数
- 人员预测tab新增可折叠规则引擎面板,展示全部规则和触发条件
- 后端API返回ruleEngine字段(动态标准+优化规则+招聘规则+决策逻辑)
- R9/R11/R12/R13增加占比超配约束,过滤不合理招聘建议
- 菜单项'菜品成本分析'→'菜品成本','门店费用分析'→'门店费用'
- 热力图横向滚动条始终可见
2026-07-30 00:04:54 +08:00

1572 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, Record<number, { 前厅: number; 后厨: number; 管理: number; 其他: number }>> = {}
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<string, string> = {
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<string, string> = {
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<string, Record<string, number>> = {}
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<string, number>
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<string, number> = {}
storeRevenue.rows.forEach((r: any) => { revMap[r.store_name] = parseFloat(r.revenue) })
const billMap: Record<string, number> = {}
storeRevenue.rows.forEach((r: any) => { billMap[r.store_name] = parseInt(r.bill_count) })
const trafficMap: Record<string, any> = {}
storeTraffic.rows.forEach((r: any) => { trafficMap[r.store_name] = r })
const storeEmpCount: Record<string, number> = {}
const storeActivePay: Record<string, number> = {}
const storeTotalHours: Record<string, number> = {}
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<string, number> = {}
const rolePayTotals: Record<string, number> = {}
const roleAttendSum: Record<string, number> = {}
const roleAttendCount: Record<string, number> = {}
const roleHoursSum: Record<string, number> = {}
const roleHoursCount: Record<string, number> = {}
const rolePaySum: Record<string, number> = {}
const rolePayCount: Record<string, number> = {}
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<string, any> = {}
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<string, number> = { '管理': 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