feat: 店长工作台新增人力排班Tab及概览折叠功能

- 后端新增 /api/stores/:code/staffing 路由,返回人工成本概览、按星期/餐段排班建议、日营收明细、综合诊断
- 前端 StorePage 新增 staffing Tab,展示 MetricCard 概览、优化建议、排班图表、诊断算法说明
- 修复公司均值人工率被异常门店污染问题(过滤 wage_rate_pct > 100)
- 诊断逻辑改为与公司均值动态比较,建议人工率改为动态计算
- 增强任务反馈表单(执行证据、未完成原因、下一步计划)
- 点击非概览Tab时自动折叠上方概况区域
This commit is contained in:
freedakgmail
2026-08-04 08:19:20 +08:00
parent 6bc2f43da7
commit 37cd5ae7e1
2 changed files with 459 additions and 21 deletions
+195
View File
@@ -730,6 +730,201 @@ router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
} catch (err: any) { sendError(res, err.message) }
})
// 门店人力排班分析
router.get('/stores/:code/staffing', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const monthStart = parseMonth(req)
// 1. 门店人工成本概览
const expenseResult = await query(`
SELECT
sales_store_code, sales_store_name,
wage_expense,
wage_rate_pct,
received,
bill_count,
area_sqm,
round(received / nullif(wage_expense, 0), 2) AS revenue_per_wage,
round(wage_expense / nullif(bill_count, 0), 2) AS wage_per_bill
FROM analytics.mv_store_operating_expense_monthly
WHERE report_month = $1::date AND sales_store_code = $2
`, [monthStart, code])
// 2. 公司均值
const avgResult = await query(`
SELECT
round(avg(wage_expense), 2) AS avg_wage,
round(avg(wage_rate_pct), 2) AS avg_wage_rate,
round(avg(received), 2) AS avg_received,
round(avg(bill_count), 0) AS avg_bills,
round(avg(received / nullif(wage_expense, 0)), 2) AS avg_rev_per_wage
FROM analytics.mv_store_operating_expense_monthly
WHERE report_month = $1::date AND wage_expense > 0 AND wage_rate_pct <= 100
`, [monthStart])
// 3. 按星期分析营收和账单
const weekdayResult = await query(`
SELECT
extract(dow FROM business_date)::int AS dow,
CASE extract(dow FROM business_date)
WHEN 0 THEN '周日' WHEN 1 THEN '周一' WHEN 2 THEN '周二'
WHEN 3 THEN '周三' WHEN 4 THEN '周四' WHEN 5 THEN '周五'
WHEN 6 THEN '周六'
END AS weekday,
count(*) AS days,
round(avg(received), 2) AS avg_received,
round(avg(bill_count), 0) AS avg_bills,
round(max(received), 2) AS max_received,
round(min(received), 2) AS min_received,
round(sum(received), 2) AS total_received,
round(sum(bill_count), 0) AS total_bills
FROM analytics.v_store_daily
WHERE store_code = $1
AND business_date >= $2::date AND business_date < $2::date + interval '1 month'
GROUP BY 1, 2
ORDER BY 1
`, [code, monthStart])
// 4. 餐段分布
const mealResult = await query(`
SELECT meal_period, bill_count, received, round(avg_bill, 2) AS avg_bill
FROM analytics.v_store_meal_opportunity
WHERE store_code = $1
ORDER BY received DESC
`, [code])
// 5. 日营收明细(用于排班热力图)
const dailyResult = await query(`
SELECT
business_date,
to_char(business_date, 'Dy') AS dow_short,
extract(dow FROM business_date)::int AS dow,
round(received, 2) AS received,
bill_count,
round(received / nullif(bill_count, 0), 2) AS avg_bill_value
FROM analytics.v_store_daily
WHERE store_code = $1
AND business_date >= $2::date AND business_date < $2::date + interval '1 month'
ORDER BY business_date
`, [code, monthStart])
const store = expenseResult.rows[0] || {}
const avg = avgResult.rows[0] || {}
const weekdays = weekdayResult.rows
const meals = mealResult.rows
const daily = dailyResult.rows
// 6. 计算排班建议
const avgWageRate = Number(avg.avg_wage_rate) || 25
const storeWageRate = Number(store.wage_rate_pct) || 0
const suggestedWageRate = Math.max(20, Math.round((avgWageRate - 3) * 10) / 10)
const suggestedWage = Number(store.received || 0) * suggestedWageRate / 100
const wageSavings = Number(store.wage_expense || 0) - suggestedWage
const savingsPct = Number(store.wage_expense) > 0
? Math.round((wageSavings / Number(store.wage_expense)) * 100 * 10) / 10
: 0
// 按星期分布生成排班建议
const maxDayRev = Math.max(...weekdays.map((w: any) => Number(w.avg_received)))
const minDayRev = Math.min(...weekdays.map((w: any) => Number(w.avg_received)))
const avgDayRev = weekdays.reduce((s: number, w: any) => s + Number(w.avg_received), 0) / (weekdays.length || 1)
const staffingSuggestions = weekdays.map((w: any) => {
const ratio = Number(w.avg_received) / avgDayRev
let level: string, action: string
if (ratio > 1.1) {
level = '高峰'
action = '建议满编排班,确保出餐效率'
} else if (ratio < 0.85) {
level = '低谷'
action = '建议减少1-2人排班,控制人力成本'
} else {
level = '正常'
action = '建议标准排班'
}
return {
...w,
staffing_level: level,
staffing_action: action,
suggested_headcount_ratio: Math.round(ratio * 100),
}
})
// 餐段排班建议
const totalMealBills = meals.reduce((s: number, m: any) => s + Number(m.bill_count), 0)
const mealSuggestions = meals.map((m: any) => {
const share = totalMealBills > 0 ? Number(m.bill_count) / totalMealBills : 0
let level: string, action: string
if (share > 0.4) {
level = '主力时段'
action = '建议安排全量人员,前厅后厨满编'
} else if (share > 0.2) {
level = '次高峰'
action = '建议安排80%人员,保证出餐速度'
} else if (share > 0.05) {
level = '辅助时段'
action = '建议安排50%人员,控制人力投入'
} else {
level = '低谷时段'
action = '建议安排值班人员即可,可安排备料和清洁'
}
return {
...m,
bill_share_pct: Math.round(share * 1000) / 10,
staffing_level: level,
staffing_action: action,
}
})
// 综合诊断
const diagnosis: string[] = []
if (storeWageRate > 35) {
diagnosis.push(`人工成本率 ${storeWageRate}% 严重偏高,公司均值 ${avgWageRate}%,需立即优化排班`)
} else if (storeWageRate > avgWageRate + 3) {
diagnosis.push(`人工成本率 ${storeWageRate}% 高于公司均值 ${avgWageRate}%,建议优化排班结构`)
} else if (storeWageRate > 0) {
diagnosis.push(`人工成本率 ${storeWageRate}% 在合理范围内,公司均值 ${avgWageRate}%`)
}
if (maxDayRev > 0 && minDayRev > 0 && maxDayRev / minDayRev > 1.5) {
const fmt = (v: number) => `¥${Math.round(v).toLocaleString()}`
diagnosis.push(`日营收波动大(最高 ${fmt(maxDayRev)} vs 最低 ${fmt(minDayRev)}),建议差异化排班`)
}
if (wageSavings > 0) {
const fmt = (v: number) => `¥${Math.round(v).toLocaleString()}`
diagnosis.push(`按建议人工率 ${suggestedWageRate}% 测算,可节省人工成本 ${fmt(wageSavings)}${savingsPct}%`)
}
const peakMeal = mealSuggestions[0]
if (peakMeal && peakMeal.bill_share_pct > 40) {
diagnosis.push(`${peakMeal.meal_period}为绝对主力(占 ${peakMeal.bill_share_pct}%),建议集中人力保障`)
}
sendSuccess(res, {
store,
company_avg: avg,
weekdays: staffingSuggestions,
meals: mealSuggestions,
daily,
summary: {
wage_expense: store.wage_expense,
wage_rate_pct: store.wage_rate_pct,
received: store.received,
suggested_wage: Math.round(suggestedWage * 100) / 100,
wage_savings: Math.round(wageSavings * 100) / 100,
savings_pct: savingsPct,
revenue_per_wage: store.revenue_per_wage,
wage_per_bill: store.wage_per_bill,
avg_wage_rate: avg.avg_wage_rate,
avg_rev_per_wage: avg.avg_rev_per_wage,
},
diagnosis,
})
} catch (err: any) { sendError(res, err.message) }
})
// 区域汇总
router.get('/region/summary', async (req: AuthRequest, res) => {
try {