feat: 店长工作台新增人力排班Tab及概览折叠功能
- 后端新增 /api/stores/:code/staffing 路由,返回人工成本概览、按星期/餐段排班建议、日营收明细、综合诊断 - 前端 StorePage 新增 staffing Tab,展示 MetricCard 概览、优化建议、排班图表、诊断算法说明 - 修复公司均值人工率被异常门店污染问题(过滤 wage_rate_pct > 100) - 诊断逻辑改为与公司均值动态比较,建议人工率改为动态计算 - 增强任务反馈表单(执行证据、未完成原因、下一步计划) - 点击非概览Tab时自动折叠上方概况区域
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user