a011729fc9
- 添加 RoleRoute 路由守卫组件,防止越权访问页面 - 菜单配置:区域经理加'区域对比',店长加'态势感知' - database.ts: scope注入时检测主表别名避免store_code歧义 - analytics-enhanced.ts: KPI region模式手动注入scope过滤 - situational-awareness.ts: health-score手动注入scope,alerts加skipScope - smart-scheduling.ts: position-salary-compare店长角色降低HAVING阈值
1595 lines
74 KiB
TypeScript
1595 lines
74 KiB
TypeScript
import { Router } from 'express'
|
||
import { query } from '../config/database.js'
|
||
import { sendSuccess, sendError, parsePagination, parseMonth } 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
|
||
const month = parseMonth(req)
|
||
const params: any[] = [month]
|
||
let where = `WHERE c175 IS NOT NULL AND c175 != '' AND c175::timestamp >= $1::date AND c175::timestamp < $1::date + interval '1 month'`
|
||
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 month = parseMonth(req)
|
||
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 >= $2 AND c175::timestamp < $2::date + interval '1 month'
|
||
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, month])
|
||
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 month = parseMonth(req)
|
||
|
||
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::timestamp >= $4::date AND c175::timestamp < $4::date + interval '1 month'
|
||
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, month])
|
||
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 month = parseMonth(req)
|
||
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}`
|
||
}
|
||
// 将 2026-04 转为 2026年4月 格式匹配 salary_period
|
||
const [yr, mo] = month.split('-')
|
||
const periodLabel = `${parseInt(yr)}年${parseInt(mo)}月`
|
||
params.push(periodLabel)
|
||
where += ` AND salary_period = $${params.length}`
|
||
|
||
const countResult = await query(`SELECT count(*) FROM salary_detail_records ${where}`, params)
|
||
const total = countResult.rows[0].count
|
||
|
||
params.push(month)
|
||
const monthParam = `$${params.length}`
|
||
|
||
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 >= ${monthParam} THEN '离职'
|
||
WHEN hire_date IS NOT NULL AND hire_date != '' AND hire_date >= ${monthParam} 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 month = parseMonth(req)
|
||
const [yr, mo] = month.split('-')
|
||
const periodLabel = `${parseInt(yr)}年${parseInt(mo)}月`
|
||
const minCount = req.user?.role === 'store' ? 1 : 5
|
||
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 != ''
|
||
AND salary_period = $1
|
||
GROUP BY position
|
||
HAVING count(*) >= $2
|
||
ORDER BY avg_gross DESC
|
||
`, [periodLabel, minCount])
|
||
sendSuccess(res, result.rows)
|
||
} catch (err: any) {
|
||
sendError(res, err.message)
|
||
}
|
||
})
|
||
|
||
// 离职率统计
|
||
router.get('/turnover-stats', async (req: AuthRequest, res) => {
|
||
try {
|
||
const month = parseMonth(req)
|
||
const [yr, mo] = month.split('-')
|
||
const periodLabel = `${parseInt(yr)}年${parseInt(mo)}月`
|
||
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 >= $1) AS left_count,
|
||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
|
||
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1)::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate,
|
||
round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1)::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 != ''
|
||
AND salary_period = $2
|
||
GROUP BY org_level5
|
||
ORDER BY turnover_rate DESC NULLS LAST
|
||
`, [month, periodLabel])
|
||
sendSuccess(res, result.rows)
|
||
} catch (err: any) {
|
||
sendError(res, err.message)
|
||
}
|
||
})
|
||
|
||
// ============ 总体智能分析 ============
|
||
|
||
router.get('/overall-analysis', async (req: AuthRequest, res) => {
|
||
try {
|
||
const month = parseMonth(req)
|
||
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 >= $1) AS left_count,
|
||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count,
|
||
round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= $1)::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
|
||
`, [month]),
|
||
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 month = parseMonth(req)
|
||
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 >= $1) AS left_count,
|
||
count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= $1) AS new_count
|
||
FROM salary_detail_records
|
||
WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''
|
||
GROUP BY 1, 2
|
||
`, [month]),
|
||
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
|