feat: 智能排班人员预测系统

- 新增人员预测tab,基于多维度产能指标生成招聘/优化建议
- 规则引擎:7条优化规则 + 7条招聘规则 + 4步决策逻辑
- 动态标准值:基于全部门店中位数计算,替代硬编码阈值
- 互斥逻辑:有优化信号时抑制所有招聘信号
- 占比超配时不触发招聘信号(R9/R11/R12/R13)
- 离职缺口需出勤不足才触发招聘(R10)
- 管理岗满编时不触发出勤不足招聘(R9)
- 前端可折叠规则引擎面板,展示全部规则和触发条件
- 产能指标对比展示实际值 vs 中位数标准
This commit is contained in:
freedakgmail
2026-07-29 23:41:56 +08:00
parent 80d67ccb73
commit 75a483bfdb
17 changed files with 3497 additions and 3 deletions
@@ -0,0 +1,138 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { DataTable } from '@/components/DataTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { formatNumber } from '@/lib/utils'
const ACTION_COLORS: Record<string, string> = {
'需增配': 'text-red-600 font-medium',
'可减配': 'text-blue-500 font-medium',
'配置合理': 'text-green-600',
}
const ROW_BG: Record<string, string> = {
'需增配': 'bg-red-50',
'可减配': 'bg-blue-50',
'配置合理': 'bg-green-50',
}
function gapLabel(suggested: any, current: any): string {
const s = parseFloat(suggested) || 0
const c = parseFloat(current) || 0
const gap = s - c
if (gap > 0) return `+${gap.toFixed(1)}`
if (gap < 0) return gap.toFixed(1)
return '0'
}
function gapColor(suggested: any, current: any): string {
const s = parseFloat(suggested) || 0
const c = parseFloat(current) || 0
const gap = s - c
if (gap > 0) return 'text-red-600 font-medium'
if (gap < 0) return 'text-blue-500'
return 'text-muted-foreground'
}
const tableColumns = [
{ key: 'hour', label: '时段', render: (r: any) => `${r.hour}:00` },
{ key: 'avg_daily_bills', label: '日均账单', align: 'right' as const, render: (r: any) => formatNumber(r.avg_daily_bills) },
{ key: 'p85_bills', label: '高峰账单', align: 'right' as const, render: (r: any) => <span className="text-orange-600">{formatNumber(r.p85_bills)}</span> },
{ key: 'volatility', label: '波动系数', align: 'right' as const, render: (r: any) => <span className={parseFloat(r.volatility) > 1 ? 'text-red-600 font-medium' : ''}>{r.volatility}</span> },
{ key: 'suggested_front', label: '建议前厅', align: 'right' as const, render: (r: any) => r.suggested_front },
{ key: 'current_front', label: '当前前厅', align: 'right' as const, render: (r: any) => r.current_front },
{ key: 'gap_front', label: '前厅缺口', align: 'right' as const, render: (r: any) => <span className={gapColor(r.suggested_front, r.current_front)}>{gapLabel(r.suggested_front, r.current_front)}</span> },
{ key: 'suggested_kitchen', label: '建议后厨', align: 'right' as const, render: (r: any) => r.suggested_kitchen },
{ key: 'current_kitchen', label: '当前后厨', align: 'right' as const, render: (r: any) => r.current_kitchen },
{ key: 'gap_kitchen', label: '后厨缺口', align: 'right' as const, render: (r: any) => <span className={gapColor(r.suggested_kitchen, r.current_kitchen)}>{gapLabel(r.suggested_kitchen, r.current_kitchen)}</span> },
{ key: 'suggested_manager', label: '建议管理', align: 'right' as const, render: (r: any) => r.suggested_manager },
{ key: 'current_other', label: '当前管理', align: 'right' as const, render: (r: any) => r.current_other },
{ key: 'gap_manager', label: '管理缺口', align: 'right' as const, render: (r: any) => <span className={gapColor(r.suggested_manager, r.current_other)}>{gapLabel(r.suggested_manager, r.current_other)}</span> },
{ key: 'suggested_total', label: '建议合计', align: 'right' as const, render: (r: any) => <span className="font-medium">{r.suggested_total}</span> },
{ key: 'current_total', label: '当前合计', align: 'right' as const, render: (r: any) => r.current_total },
{ key: 'staff_gap', label: '总缺口', align: 'right' as const, render: (r: any) => <span className={parseFloat(r.staff_gap) > 0 ? 'text-red-600 font-bold' : parseFloat(r.staff_gap) < 0 ? 'text-blue-500 font-bold' : ''}>{parseFloat(r.staff_gap) > 0 ? '+' : ''}{r.staff_gap}</span> },
{ key: 'action', label: '建议', render: (r: any) => <span className={ACTION_COLORS[r.action] || ''}>{r.action}</span> },
]
export function SchedulingSuggestionTab() {
const { data: storesData } = useQuery({
queryKey: ['ss/stores'],
queryFn: () => api.get('/smart-scheduling/stores'),
})
const stores = (storesData as any)?.data || []
const [storeName, setStoreName] = useState('七里庄店')
const [frontTarget, setFrontTarget] = useState(15)
const [kitchenTarget, setKitchenTarget] = useState(25)
const { data, isLoading } = useQuery({
queryKey: ['ss/suggestion', storeName, frontTarget, kitchenTarget],
queryFn: () => api.get(`/smart-scheduling/scheduling-suggestion?store=${storeName}&front_target=${frontTarget}&kitchen_target=${kitchenTarget}`),
enabled: !!storeName,
})
const rows = (data as any)?.data || []
const workdayRows = rows.filter((r: any) => r.day_type === '工作日')
const weekendRows = rows.filter((r: any) => r.day_type === '周末')
const increaseCount = rows.filter((r: any) => r.action === '需增配').length
const decreaseCount = rows.filter((r: any) => r.action === '可减配').length
const okCount = rows.filter((r: any) => r.action === '配置合理').length
return (
<div className="space-y-4">
<CollapsibleSection title="排班建议" subtitle="基于历史客流规律,分岗位推荐各时段在岗人数">
<div className="mb-3 flex gap-3 items-center flex-wrap">
<div className="flex gap-2 items-center">
<label className="text-sm text-muted-foreground"></label>
<select value={storeName} onChange={(e) => setStoreName(e.target.value)} className="border rounded px-2 py-1 text-sm">
{stores.map((s: any) => <option key={s.store_name} value={s.store_name}>{s.store_name}</option>)}
</select>
</div>
<div className="flex gap-2 items-center">
<label className="text-sm text-muted-foreground"></label>
<input type="number" value={frontTarget} onChange={(e) => setFrontTarget(parseInt(e.target.value) || 15)} className="border rounded px-2 py-1 text-sm w-16" />
<span className="text-xs text-muted-foreground">//</span>
</div>
<div className="flex gap-2 items-center">
<label className="text-sm text-muted-foreground"></label>
<input type="number" value={kitchenTarget} onChange={(e) => setKitchenTarget(parseInt(e.target.value) || 25)} className="border rounded px-2 py-1 text-sm w-16" />
<span className="text-xs text-muted-foreground">//</span>
</div>
</div>
{isLoading ? <LoadingSpinner text="生成排班建议..." /> : (
<>
<div className="grid grid-cols-3 gap-3 mb-4">
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="text-xl font-bold text-red-600">{increaseCount}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="text-xl font-bold text-blue-500">{decreaseCount}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="text-xl font-bold text-green-600">{okCount}</p>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm font-bold mb-2"></h3>
<DataTable columns={tableColumns} data={workdayRows} rowClassName={(r) => ROW_BG[r.action] || ''} />
</div>
<div>
<h3 className="text-sm font-bold mb-2"></h3>
<DataTable columns={tableColumns} data={weekendRows} rowClassName={(r) => ROW_BG[r.action] || ''} />
</div>
</div>
</>
)}
</CollapsibleSection>
</div>
)
}