feat: 智能排班人员预测系统
- 新增人员预测tab,基于多维度产能指标生成招聘/优化建议 - 规则引擎:7条优化规则 + 7条招聘规则 + 4步决策逻辑 - 动态标准值:基于全部门店中位数计算,替代硬编码阈值 - 互斥逻辑:有优化信号时抑制所有招聘信号 - 占比超配时不触发招聘信号(R9/R11/R12/R13) - 离职缺口需出勤不足才触发招聘(R10) - 管理岗满编时不触发出勤不足招聘(R9) - 前端可折叠规则引擎面板,展示全部规则和触发条件 - 产能指标对比展示实际值 vs 中位数标准
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user