feat: 智能排班人员预测系统
- 新增人员预测tab,基于多维度产能指标生成招聘/优化建议 - 规则引擎:7条优化规则 + 7条招聘规则 + 4步决策逻辑 - 动态标准值:基于全部门店中位数计算,替代硬编码阈值 - 互斥逻辑:有优化信号时抑制所有招聘信号 - 占比超配时不触发招聘信号(R9/R11/R12/R13) - 离职缺口需出勤不足才触发招聘(R10) - 管理岗满编时不触发出勤不足招聘(R9) - 前端可折叠规则引擎面板,展示全部规则和触发条件 - 产能指标对比展示实际值 vs 中位数标准
This commit is contained in:
@@ -21,6 +21,7 @@ import { TimePage } from '@/pages/TimePage'
|
||||
import { DataQualityPage } from '@/pages/DataQualityPage'
|
||||
import { OntologyPage } from '@/pages/OntologyPage'
|
||||
import { SiteSelectionPage } from '@/pages/SiteSelectionPage'
|
||||
import { SmartSchedulingPage } from '@/pages/SmartSchedulingPage'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -83,6 +84,7 @@ export default function App() {
|
||||
<Route path="/indicators" element={<IndicatorsPage />} />
|
||||
<Route path="/ontology" element={<OntologyPage />} />
|
||||
<Route path="/site-selection" element={<SiteSelectionPage />} />
|
||||
<Route path="/smart-scheduling" element={<SmartSchedulingPage />} />
|
||||
<Route path="/login" element={<Navigate to="/" />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
|
||||
@@ -6,9 +6,10 @@ interface DataTableProps {
|
||||
data: any[]
|
||||
onRowClick?: (row: any) => void
|
||||
className?: string
|
||||
rowClassName?: (row: any) => string
|
||||
}
|
||||
|
||||
export function DataTable({ columns, data, onRowClick, className }: DataTableProps) {
|
||||
export function DataTable({ columns, data, onRowClick, className, rowClassName }: DataTableProps) {
|
||||
return (
|
||||
<div className={cn('overflow-x-auto rounded-lg border', className)}>
|
||||
<table className="w-full text-sm">
|
||||
@@ -43,7 +44,8 @@ export function DataTable({ columns, data, onRowClick, className }: DataTablePro
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={cn(
|
||||
'border-t hover:bg-muted/30',
|
||||
onRowClick && 'cursor-pointer'
|
||||
onRowClick && 'cursor-pointer',
|
||||
rowClassName?.(row)
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet } from 'lucide-react'
|
||||
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -39,6 +39,7 @@ const menuGroups: MenuGroup[] = [
|
||||
{ path: '/cost', label: '成本库存', icon: DollarSign, roles: ['hq', 'dept'] },
|
||||
{ path: '/cost-analysis', label: '菜品成本分析', icon: PieChart, roles: ['hq', 'dept'] },
|
||||
{ path: '/store-expense', label: '门店费用分析', icon: Wallet, roles: ['hq', 'dept'] },
|
||||
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] },
|
||||
{ path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] },
|
||||
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
|
||||
{ path: '/risk', label: '风险内控', icon: AlertTriangle, roles: ['hq', 'dept'] },
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const LEVEL_BG: Record<string, string> = {
|
||||
red: 'bg-red-100 text-red-700 border-red-300',
|
||||
orange: 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
}
|
||||
|
||||
export function AttendanceAlertTab() {
|
||||
const [summaryPage, setSummaryPage] = useState(1)
|
||||
const [alertPage, setAlertPage] = useState(1)
|
||||
const [summaryFilter, setSummaryFilter] = useState('')
|
||||
const [summarySort, setSummarySort] = useState('emp_count')
|
||||
const [summaryOrder, setSummaryOrder] = useState('desc')
|
||||
const [alertFilter, setAlertFilter] = useState('')
|
||||
const [alertLevelFilter, setAlertLevelFilter] = useState('')
|
||||
const [alertSort, setAlertSort] = useState('attend_diff')
|
||||
const [alertOrder, setAlertOrder] = useState('desc')
|
||||
|
||||
const { data: summaryData, isLoading: summaryLoading } = useQuery({
|
||||
queryKey: ['ss/attendance-summary'],
|
||||
queryFn: () => api.get('/smart-scheduling/attendance-summary'),
|
||||
})
|
||||
|
||||
const { data: alertData, isLoading: alertLoading } = useQuery({
|
||||
queryKey: ['ss/attendance-alert'],
|
||||
queryFn: () => api.get('/smart-scheduling/attendance-alert'),
|
||||
})
|
||||
|
||||
const summaryRows = (summaryData as any)?.data || []
|
||||
const alertRows = (alertData as any)?.data || []
|
||||
|
||||
const storeNames = [...new Set(summaryRows.map((r: any) => r.store_name))].sort() as string[]
|
||||
|
||||
const summarySorts = [
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'avg_attend_days', label: '均出勤天' },
|
||||
{ key: 'avg_hours', label: '均工时' },
|
||||
{ key: 'total_absent_days', label: '旷工天数' },
|
||||
{ key: 'total_late_deduction', label: '迟到扣款' },
|
||||
{ key: 'low_attendance_rate_pct', label: '低出勤率' },
|
||||
]
|
||||
|
||||
const summaryFiltered = useMemo(() => {
|
||||
let r = summaryRows
|
||||
if (summaryFilter) r = r.filter((s: any) => s.store_name === summaryFilter)
|
||||
return [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[summarySort]) || 0
|
||||
const bv = parseFloat(b[summarySort]) || 0
|
||||
return summaryOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [summaryRows, summaryFilter, summarySort, summaryOrder])
|
||||
|
||||
const summaryTotal = summaryFiltered.length
|
||||
const summaryPaged = summaryFiltered.slice((summaryPage - 1) * PAGE_SIZE, summaryPage * PAGE_SIZE)
|
||||
|
||||
const alertSorts = [
|
||||
{ key: 'attend_diff', label: '偏差天' },
|
||||
{ key: 'absent_days', label: '旷工天' },
|
||||
{ key: 'late_deduction', label: '迟到扣款' },
|
||||
{ key: 'actual_hours', label: '实际工时' },
|
||||
]
|
||||
|
||||
const alertFiltered = useMemo(() => {
|
||||
let r = alertRows
|
||||
if (alertFilter) r = r.filter((s: any) => s.store_name === alertFilter)
|
||||
if (alertLevelFilter) r = r.filter((s: any) => s.alert_level === alertLevelFilter)
|
||||
return [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[alertSort]) || 0
|
||||
const bv = parseFloat(b[alertSort]) || 0
|
||||
return alertOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [alertRows, alertFilter, alertLevelFilter, alertSort, alertOrder])
|
||||
|
||||
const alertTotal = alertFiltered.length
|
||||
const alertPaged = alertFiltered.slice((alertPage - 1) * PAGE_SIZE, alertPage * PAGE_SIZE)
|
||||
|
||||
const redCount = alertRows.filter((r: any) => r.alert_level === 'red').length
|
||||
const orangeCount = alertRows.filter((r: any) => r.alert_level === 'orange').length
|
||||
const yellowCount = alertRows.filter((r: any) => r.alert_level === 'yellow').length
|
||||
|
||||
if (summaryLoading || alertLoading) return <LoadingSpinner text="加载考勤预警数据..." />
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 p-3">
|
||||
<p className="text-xs text-red-600">红色预警(旷工/出勤异常)</p>
|
||||
<p className="text-2xl font-bold text-red-700">{redCount}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-orange-300 bg-orange-50 p-3">
|
||||
<p className="text-xs text-orange-600">橙色预警(偏差大)</p>
|
||||
<p className="text-2xl font-bold text-orange-700">{orangeCount}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-yellow-300 bg-yellow-50 p-3">
|
||||
<p className="text-xs text-yellow-600">黄色预警(考勤扣款/出勤率低)</p>
|
||||
<p className="text-2xl font-bold text-yellow-700">{yellowCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleSection title="门店考勤汇总" subtitle="各门店出勤天数、旷工、迟到扣款、低出勤率">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={summaryFilter} onChange={(e) => { setSummaryFilter(e.target.value); setSummaryPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{summarySorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSummarySort(s.key); setSummaryPage(1) }} className={`px-3 py-1 rounded text-xs ${summarySort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setSummaryOrder(summaryOrder === 'asc' ? 'desc' : 'asc'); setSummaryPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{summaryOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
|
||||
{ key: 'avg_attend_days', label: '均出勤天', align: 'right', render: (r) => r.avg_attend_days },
|
||||
{ key: 'avg_hours', label: '均工时', align: 'right', render: (r) => formatNumber(r.avg_hours) },
|
||||
{ key: 'total_absent_days', label: '旷工天数', align: 'right', render: (r) => <span className={parseFloat(r.total_absent_days) > 0 ? 'text-red-600 font-medium' : ''}>{r.total_absent_days || 0}</span> },
|
||||
{ key: 'absent_emp_count', label: '旷工人数', align: 'right', render: (r) => <span className={parseFloat(r.absent_emp_count) > 0 ? 'text-red-600' : ''}>{r.absent_emp_count || 0}</span> },
|
||||
{ key: 'total_late_deduction', label: '迟到扣款', align: 'right', render: (r) => formatCurrency(r.total_late_deduction) },
|
||||
{ key: 'punch_issue_count', label: '打卡异常人数', align: 'right', render: (r) => <span className={parseFloat(r.punch_issue_count) > 5 ? 'text-orange-600' : ''}>{r.punch_issue_count || 0}</span> },
|
||||
{ key: 'low_attendance_rate_pct', label: '低出勤率%', align: 'right', render: (r) => <span className={parseFloat(r.low_attendance_rate_pct) > 20 ? 'text-red-600 font-medium' : ''}>{r.low_attendance_rate_pct ? r.low_attendance_rate_pct + '%' : '0%'}</span> },
|
||||
]}
|
||||
data={summaryPaged}
|
||||
/>
|
||||
<Pagination page={summaryPage} pageSize={PAGE_SIZE} total={summaryTotal} onPageChange={setSummaryPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="考勤异常明细" subtitle="员工级考勤预警列表(按严重程度排序)">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={alertFilter} onChange={(e) => { setAlertFilter(e.target.value); setAlertPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select value={alertLevelFilter} onChange={(e) => { setAlertLevelFilter(e.target.value); setAlertPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部等级</option>
|
||||
<option value="red">红色</option>
|
||||
<option value="orange">橙色</option>
|
||||
<option value="yellow">黄色</option>
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{alertSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setAlertSort(s.key); setAlertPage(1) }} className={`px-3 py-1 rounded text-xs ${alertSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setAlertOrder(alertOrder === 'asc' ? 'desc' : 'asc'); setAlertPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{alertOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'employee_code', label: '工号' },
|
||||
{ key: 'position', label: '岗位' },
|
||||
{ key: 'salary_attend_days', label: '薪资出勤天', align: 'right', render: (r) => r.salary_attend_days },
|
||||
{ key: 'expected_attend', label: '应出勤天', align: 'right', render: (r) => r.expected_attend },
|
||||
{ key: 'attend_diff', label: '偏差天', align: 'right', render: (r) => <span className={parseFloat(r.attend_diff) > 5 ? 'text-red-600 font-medium' : ''}>{r.attend_diff}</span> },
|
||||
{ key: 'absent_days', label: '旷工天', align: 'right', render: (r) => <span className={parseFloat(r.absent_days) > 0 ? 'text-red-600 font-bold' : ''}>{r.absent_days || 0}</span> },
|
||||
{ key: 'late_deduction', label: '迟到扣款', align: 'right', render: (r) => formatCurrency(r.late_deduction) },
|
||||
{ key: 'no_punch_deduction', label: '未打卡扣款', align: 'right', render: (r) => formatCurrency(r.no_punch_deduction) },
|
||||
{ key: 'actual_hours', label: '实际工时', align: 'right', render: (r) => formatNumber(r.actual_hours) },
|
||||
{ key: 'alert_type', label: '预警类型', render: (r) => <span className={`px-2 py-0.5 rounded text-xs border ${LEVEL_BG[r.alert_level] || ''}`}>{r.alert_type}</span> },
|
||||
]}
|
||||
data={alertPaged}
|
||||
/>
|
||||
<Pagination page={alertPage} pageSize={PAGE_SIZE} total={alertTotal} onPageChange={setAlertPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function EfficiencyBenchmarkTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('revenue_per_emp')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const [posPage, setPosPage] = useState(1)
|
||||
const [posFilter, setPosFilter] = useState('')
|
||||
const [posSort, setPosSort] = useState('total_emp')
|
||||
const [posOrder, setPosOrder] = useState('desc')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/efficiency', page, sort, order],
|
||||
queryFn: () => api.get(`/smart-scheduling/efficiency-ranking?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}`),
|
||||
})
|
||||
|
||||
const { data: posData } = useQuery({
|
||||
queryKey: ['ss/position-distribution'],
|
||||
queryFn: () => api.get('/smart-scheduling/position-distribution'),
|
||||
})
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
const posRows = (posData as any)?.data || []
|
||||
const posStoreNames = [...new Set(posRows.map((r: any) => r.store_name))].sort() as string[]
|
||||
|
||||
const posSorts = [
|
||||
{ key: 'total_emp', label: '总人数' },
|
||||
{ key: 'manager_pct', label: '管理占比' },
|
||||
{ key: 'kitchen_pct', label: '后厨占比' },
|
||||
{ key: 'front_pct', label: '前厅占比' },
|
||||
{ key: 'part_time_count', label: '兼职数' },
|
||||
]
|
||||
|
||||
const posFiltered = useMemo(() => {
|
||||
let r = posRows
|
||||
if (posFilter) r = r.filter((s: any) => s.store_name === posFilter)
|
||||
return [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[posSort]) || 0
|
||||
const bv = parseFloat(b[posSort]) || 0
|
||||
return posOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [posRows, posFilter, posSort, posOrder])
|
||||
|
||||
const posTotal = posFiltered.length
|
||||
const posPaged = posFiltered.slice((posPage - 1) * PAGE_SIZE, posPage * PAGE_SIZE)
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载人效数据..." />
|
||||
|
||||
const sorts = [
|
||||
{ key: 'revenue_per_emp', label: '人均创收' },
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'gross_pay', label: '应发工资' },
|
||||
{ key: 'wage_rate', label: '人力成本率' },
|
||||
{ key: 'avg_hours', label: '人均工时' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="门店人效排名" subtitle="人均创收、人力成本率、人均工时、加班占比">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
|
||||
{ key: 'revenue', label: '营收', align: 'right', render: (r) => formatCurrency(r.revenue) },
|
||||
{ key: 'revenue_per_emp', label: '人均创收', align: 'right', render: (r) => <span className={parseFloat(r.revenue_per_emp) > 50000 ? 'text-green-600 font-medium' : parseFloat(r.revenue_per_emp) < 20000 ? 'text-red-600' : ''}>{formatCurrency(r.revenue_per_emp)}</span> },
|
||||
{ key: 'gross_pay', label: '应发工资', align: 'right', render: (r) => formatCurrency(r.gross_pay) },
|
||||
{ key: 'cost_per_emp', label: '人均成本', align: 'right', render: (r) => formatCurrency(r.cost_per_emp) },
|
||||
{ key: 'wage_rate', label: '人力成本率', align: 'right', render: (r) => <span className={parseFloat(r.wage_rate) > 25 ? 'text-red-600 font-medium' : parseFloat(r.wage_rate) < 15 ? 'text-green-600' : ''}>{formatPercent(r.wage_rate)}</span> },
|
||||
{ key: 'avg_hours', label: '人均工时', align: 'right', render: (r) => formatNumber(r.avg_hours) },
|
||||
{ key: 'revenue_per_hour', label: '时均创收', align: 'right', render: (r) => formatCurrency(r.revenue_per_hour) },
|
||||
{ key: 'overtime_rate', label: '加班占比%', align: 'right', render: (r) => <span className={parseFloat(r.overtime_rate) > 5 ? 'text-orange-600' : ''}>{formatPercent(r.overtime_rate)}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="岗位配比分析" subtitle="各门店前厅/后厨/管理岗位人数及占比">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={posFilter} onChange={(e) => { setPosFilter(e.target.value); setPosPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{posStoreNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{posSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setPosSort(s.key); setPosPage(1) }} className={`px-3 py-1 rounded text-xs ${posSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setPosOrder(posOrder === 'asc' ? 'desc' : 'asc'); setPosPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{posOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'total_emp', label: '总人数', align: 'right', render: (r) => formatNumber(r.total_emp) },
|
||||
{ key: 'manager_count', label: '管理岗', align: 'right', render: (r) => formatNumber(r.manager_count) },
|
||||
{ key: 'manager_pct', label: '管理占比%', align: 'right', render: (r) => <span className={parseFloat(r.manager_pct) > 20 ? 'text-orange-600' : ''}>{r.manager_pct}%</span> },
|
||||
{ key: 'kitchen_count', label: '后厨', align: 'right', render: (r) => formatNumber(r.kitchen_count) },
|
||||
{ key: 'kitchen_pct', label: '后厨占比%', align: 'right', render: (r) => `${r.kitchen_pct}%` },
|
||||
{ key: 'front_count', label: '前厅', align: 'right', render: (r) => formatNumber(r.front_count) },
|
||||
{ key: 'front_pct', label: '前厅占比%', align: 'right', render: (r) => `${r.front_pct}%` },
|
||||
{ key: 'part_time_count', label: '兼职', align: 'right', render: (r) => formatNumber(r.part_time_count) },
|
||||
]}
|
||||
data={posPaged}
|
||||
/>
|
||||
<Pagination page={posPage} pageSize={PAGE_SIZE} total={posTotal} onPageChange={setPosPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
'离职': 'text-red-600',
|
||||
'新员工': 'text-blue-500',
|
||||
'在职': 'text-green-600',
|
||||
}
|
||||
|
||||
export function EmployeeAnalysisTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [sort, setSort] = useState('gross_pay')
|
||||
const [order, setOrder] = useState('desc')
|
||||
const [storeFilter, setStoreFilter] = useState('')
|
||||
const [posPage, setPosPage] = useState(1)
|
||||
const [turnoverPage, setTurnoverPage] = useState(1)
|
||||
const [posSort, setPosSort] = useState('avg_gross')
|
||||
const [posOrder, setPosOrder] = useState('desc')
|
||||
const [turnoverSort, setTurnoverSort] = useState('turnover_rate')
|
||||
const [turnoverOrder, setTurnoverOrder] = useState('desc')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/employee', page, sort, order, storeFilter],
|
||||
queryFn: () => api.get(`/smart-scheduling/employee-analysis?page=${page}&page_size=${PAGE_SIZE}&sort=${sort}&order=${order}${storeFilter ? `&store=${storeFilter}` : ''}`),
|
||||
})
|
||||
|
||||
const { data: posData } = useQuery({
|
||||
queryKey: ['ss/position-salary'],
|
||||
queryFn: () => api.get('/smart-scheduling/position-salary-compare'),
|
||||
})
|
||||
|
||||
const { data: turnoverData } = useQuery({
|
||||
queryKey: ['ss/turnover'],
|
||||
queryFn: () => api.get('/smart-scheduling/turnover-stats'),
|
||||
})
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
const meta = (data as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
|
||||
const posRows = (posData as any)?.data || []
|
||||
const turnoverRows = (turnoverData as any)?.data || []
|
||||
|
||||
const posSorts = [
|
||||
{ key: 'avg_gross', label: '均应发' },
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'store_count', label: '门店数' },
|
||||
{ key: 'avg_hourly_rate', label: '均时薪' },
|
||||
{ key: 'avg_hours', label: '均工时' },
|
||||
]
|
||||
|
||||
const posFiltered = useMemo(() => {
|
||||
return [...posRows].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[posSort]) || 0
|
||||
const bv = parseFloat(b[posSort]) || 0
|
||||
return posOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [posRows, posSort, posOrder])
|
||||
|
||||
const posTotal = posFiltered.length
|
||||
const posPaged = posFiltered.slice((posPage - 1) * PAGE_SIZE, posPage * PAGE_SIZE)
|
||||
|
||||
const turnoverSorts = [
|
||||
{ key: 'turnover_rate', label: '离职率' },
|
||||
{ key: 'left_count', label: '离职人数' },
|
||||
{ key: 'new_hire_rate', label: '新员工占比' },
|
||||
{ key: 'total_emp', label: '总人数' },
|
||||
]
|
||||
|
||||
const turnoverFiltered = useMemo(() => {
|
||||
return [...turnoverRows].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[turnoverSort]) || 0
|
||||
const bv = parseFloat(b[turnoverSort]) || 0
|
||||
return turnoverOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [turnoverRows, turnoverSort, turnoverOrder])
|
||||
|
||||
const turnoverTotal = turnoverFiltered.length
|
||||
const turnoverPaged = turnoverFiltered.slice((turnoverPage - 1) * PAGE_SIZE, turnoverPage * PAGE_SIZE)
|
||||
const storeNames = [...new Set(turnoverRows.map((r: any) => r.store_name))].sort() as string[]
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="加载员工数据..." />
|
||||
|
||||
const sorts = [
|
||||
{ key: 'gross_pay', label: '应发工资' },
|
||||
{ key: 'net_pay', label: '实发工资' },
|
||||
{ key: 'actual_hours', label: '工时' },
|
||||
{ key: 'perf_amount', label: '绩效' },
|
||||
{ key: 'overtime_pay', label: '加班费' },
|
||||
{ key: 'hourly_rate', label: '时薪' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="员工薪资明细" subtitle="员工级薪资构成、工时、绩效、有效时薪">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={storeFilter} onChange={(e) => { setStoreFilter(e.target.value); setPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s: any) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'employee_code', label: '工号' },
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'position', label: '岗位' },
|
||||
{ key: 'emp_status', label: '状态', render: (r) => <span className={STATUS_COLORS[r.emp_status] || ''}>{r.emp_status}</span> },
|
||||
{ key: 'attend_days', label: '出勤天', align: 'right', render: (r) => r.attend_days },
|
||||
{ key: 'work_hours', label: '工时', align: 'right', render: (r) => formatNumber(r.work_hours) },
|
||||
{ key: 'base_wage', label: '基本工资', align: 'right', render: (r) => formatCurrency(r.base_wage) },
|
||||
{ key: 'overtime_pay', label: '加班费', align: 'right', render: (r) => formatCurrency(r.overtime_pay) },
|
||||
{ key: 'perf_amount', label: '绩效', align: 'right', render: (r) => formatCurrency(r.perf_amount) },
|
||||
{ key: 'gross_pay', label: '应发', align: 'right', render: (r) => formatCurrency(r.gross_pay) },
|
||||
{ key: 'net_pay', label: '实发', align: 'right', render: (r) => <span className="font-medium">{formatCurrency(r.net_pay)}</span> },
|
||||
{ key: 'effective_hourly_rate', label: '有效时薪', align: 'right', render: (r) => <span className={parseFloat(r.effective_hourly_rate) > 100 ? 'text-green-600' : parseFloat(r.effective_hourly_rate) < 50 ? 'text-red-600' : ''}>{formatCurrency(r.effective_hourly_rate)}</span> },
|
||||
{ key: 'overtime_rate', label: '加班占比%', align: 'right', render: (r) => <span className={parseFloat(r.overtime_rate) > 5 ? 'text-orange-600' : ''}>{r.overtime_rate}%</span> },
|
||||
]}
|
||||
data={rows}
|
||||
/>
|
||||
<Pagination page={page} pageSize={meta.pageSize} total={meta.total} onPageChange={setPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="岗位薪资对比" subtitle="相同岗位在不同门店的薪资水平差异">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{posSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setPosSort(s.key); setPosPage(1) }} className={`px-3 py-1 rounded text-xs ${posSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setPosOrder(posOrder === 'asc' ? 'desc' : 'asc'); setPosPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{posOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'position', label: '岗位' },
|
||||
{ key: 'emp_count', label: '人数', align: 'right', render: (r) => formatNumber(r.emp_count) },
|
||||
{ key: 'store_count', label: '门店数', align: 'right', render: (r) => formatNumber(r.store_count) },
|
||||
{ key: 'avg_gross', label: '均应发', align: 'right', render: (r) => formatCurrency(r.avg_gross) },
|
||||
{ key: 'min_gross', label: '最低应发', align: 'right', render: (r) => <span className="text-red-600">{formatCurrency(r.min_gross)}</span> },
|
||||
{ key: 'max_gross', label: '最高应发', align: 'right', render: (r) => <span className="text-green-600">{formatCurrency(r.max_gross)}</span> },
|
||||
{ key: 'avg_net', label: '均实发', align: 'right', render: (r) => formatCurrency(r.avg_net) },
|
||||
{ key: 'avg_hours', label: '均工时', align: 'right', render: (r) => formatNumber(r.avg_hours) },
|
||||
{ key: 'avg_perf_score', label: '均绩效分', align: 'right', render: (r) => r.avg_perf_score },
|
||||
{ key: 'avg_hourly_rate', label: '均有效时薪', align: 'right', render: (r) => formatCurrency(r.avg_hourly_rate) },
|
||||
]}
|
||||
data={posPaged}
|
||||
/>
|
||||
<Pagination page={posPage} pageSize={PAGE_SIZE} total={posTotal} onPageChange={setPosPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="离职率统计" subtitle="各门店离职率、新员工占比">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
{turnoverSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setTurnoverSort(s.key); setTurnoverPage(1) }} className={`px-3 py-1 rounded text-xs ${turnoverSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setTurnoverOrder(turnoverOrder === 'asc' ? 'desc' : 'asc'); setTurnoverPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{turnoverOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'total_emp', label: '总人数', align: 'right', render: (r) => formatNumber(r.total_emp) },
|
||||
{ key: 'left_count', label: '离职人数', align: 'right', render: (r) => <span className={parseFloat(r.left_count) > 0 ? 'text-red-600' : ''}>{r.left_count}</span> },
|
||||
{ key: 'turnover_rate', label: '离职率%', align: 'right', render: (r) => <span className={parseFloat(r.turnover_rate) > 15 ? 'text-red-600 font-medium' : parseFloat(r.turnover_rate) > 5 ? 'text-orange-600' : ''}>{r.turnover_rate ? r.turnover_rate + '%' : '0%'}</span> },
|
||||
{ key: 'new_count', label: '新员工数', align: 'right', render: (r) => <span className="text-blue-500">{r.new_count}</span> },
|
||||
{ key: 'new_hire_rate', label: '新员工占比%', align: 'right', render: (r) => <span className={parseFloat(r.new_hire_rate) > 20 ? 'text-orange-600' : ''}>{r.new_hire_rate ? r.new_hire_rate + '%' : '0%'}</span> },
|
||||
]}
|
||||
data={turnoverPaged}
|
||||
/>
|
||||
<Pagination page={turnoverPage} pageSize={PAGE_SIZE} total={turnoverTotal} onPageChange={setTurnoverPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
|
||||
const LEVEL_STYLES: Record<string, { bg: string; border: string; text: string; icon: string }> = {
|
||||
red: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', icon: '🔴' },
|
||||
orange: { bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-700', icon: '🟠' },
|
||||
yellow: { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-700', icon: '🟡' },
|
||||
green: { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', icon: '🟢' },
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
'人效': '人效分析',
|
||||
'成本': '成本管控',
|
||||
'加班': '加班管理',
|
||||
'客流': '客流规律',
|
||||
'考勤': '考勤管理',
|
||||
'离职': '人员稳定',
|
||||
}
|
||||
|
||||
export function OverallAnalysisTab() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/overall-analysis'],
|
||||
queryFn: () => api.get('/smart-scheduling/overall-analysis'),
|
||||
})
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="生成总体智能分析..." />
|
||||
|
||||
const result = (data as any)?.data || {}
|
||||
const kpis: any[] = result.kpis || []
|
||||
const insights: any[] = result.insights || []
|
||||
|
||||
const redCount = insights.filter(i => i.level === 'red').length
|
||||
const orangeCount = insights.filter(i => i.level === 'orange').length
|
||||
const yellowCount = insights.filter(i => i.level === 'yellow').length
|
||||
const greenCount = insights.filter(i => i.level === 'green').length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* KPI 卡片 */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">{k.label}</p>
|
||||
<p className="text-xl font-bold mt-1">
|
||||
{k.value}
|
||||
<span className="text-xs font-normal text-muted-foreground ml-1">{k.unit}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 预警汇总 */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<p className="text-xs text-red-600">红色预警</p>
|
||||
<p className="text-2xl font-bold text-red-700">{redCount}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
|
||||
<p className="text-xs text-orange-600">橙色预警</p>
|
||||
<p className="text-2xl font-bold text-orange-700">{orangeCount}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3">
|
||||
<p className="text-xs text-yellow-600">黄色提醒</p>
|
||||
<p className="text-2xl font-bold text-yellow-700">{yellowCount}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-3">
|
||||
<p className="text-xs text-green-600">良好表现</p>
|
||||
<p className="text-2xl font-bold text-green-700">{greenCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 智能诊断建议 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-bold">智能诊断建议</h2>
|
||||
<span className="text-xs text-muted-foreground">基于人效、客流、考勤、离职等多维度数据自动生成</span>
|
||||
</div>
|
||||
|
||||
{insights.length === 0 ? (
|
||||
<div className="rounded-lg border p-8 text-center text-muted-foreground">
|
||||
暂无异常,各项指标正常
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{insights.map((ins, i) => {
|
||||
const style = LEVEL_STYLES[ins.level] || LEVEL_STYLES.yellow
|
||||
return (
|
||||
<div key={i} className={`rounded-lg border ${style.border} ${style.bg} p-4`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-lg mt-0.5">{style.icon}</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded bg-white/60 ${style.text} font-medium`}>
|
||||
{CATEGORY_LABELS[ins.category] || ins.category}
|
||||
</span>
|
||||
<h3 className="font-bold text-sm">{ins.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">{ins.detail}</p>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">💡 建议:</span>
|
||||
<p className="text-sm">{ins.suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useState, useMemo, Fragment } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatCurrency, formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const ACTION_STYLES: Record<string, string> = {
|
||||
'建议招聘': 'bg-red-100 text-red-700',
|
||||
'建议优化': 'bg-orange-100 text-orange-700',
|
||||
'关注': 'bg-yellow-100 text-yellow-700',
|
||||
'维持': 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
const LEVEL_ROW_STYLES: Record<string, string> = {
|
||||
red: 'bg-red-50/50',
|
||||
orange: 'bg-orange-50/50',
|
||||
yellow: 'bg-yellow-50/30',
|
||||
}
|
||||
|
||||
export function StaffingForecastTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [storeFilter, setStoreFilter] = useState('')
|
||||
const [actionFilter, setActionFilter] = useState('')
|
||||
const [sort, setSort] = useState('urgency')
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc')
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null)
|
||||
const [showRules, setShowRules] = useState(false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/staffing-forecast'],
|
||||
queryFn: () => api.get('/smart-scheduling/staffing-forecast'),
|
||||
})
|
||||
|
||||
const result = (data as any)?.data || {}
|
||||
const forecasts: any[] = result.forecasts || []
|
||||
const summary = result.summary || {}
|
||||
|
||||
const storeNames = useMemo(() => [...new Set(forecasts.map((f) => f.store_name))].sort(), [forecasts])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let r = forecasts
|
||||
if (storeFilter) r = r.filter(f => f.store_name === storeFilter)
|
||||
if (actionFilter) r = r.filter(f => f.action === actionFilter)
|
||||
return [...r].sort((a, b) => {
|
||||
let av: number, bv: number
|
||||
if (sort === 'store_name') {
|
||||
return order === 'desc' ? b.store_name.localeCompare(a.store_name) : a.store_name.localeCompare(b.store_name)
|
||||
}
|
||||
av = parseFloat(a[sort]) || 0
|
||||
bv = parseFloat(b[sort]) || 0
|
||||
return order === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
}, [forecasts, storeFilter, actionFilter, sort, order])
|
||||
|
||||
const total = filtered.length
|
||||
const paged = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
|
||||
if (isLoading) return <LoadingSpinner text="生成人员预测分析..." />
|
||||
|
||||
const sorts = [
|
||||
{ key: 'urgency', label: '紧急度' },
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'emp_count', label: '人数' },
|
||||
{ key: 'turnover_pct', label: '离职率' },
|
||||
{ key: 'revenue_per_emp', label: '人均创收' },
|
||||
{ key: 'avg_attend', label: '出勤天' },
|
||||
]
|
||||
|
||||
const rowKey = (r: any) => `${r.store_name}_${r.role}`
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 汇总卡片 */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">预测总数</p>
|
||||
<p className="text-2xl font-bold mt-1">{summary.total || 0}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-center">
|
||||
<p className="text-xs text-red-600">建议招聘</p>
|
||||
<p className="text-2xl font-bold text-red-700 mt-1">{summary.hire || 0}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3 text-center">
|
||||
<p className="text-xs text-orange-600">建议优化</p>
|
||||
<p className="text-2xl font-bold text-orange-700 mt-1">{summary.optimize || 0}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 text-center">
|
||||
<p className="text-xs text-yellow-600">需关注</p>
|
||||
<p className="text-2xl font-bold text-yellow-700 mt-1">{summary.watch || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 规则引擎面板 */}
|
||||
<div className="rounded-lg border">
|
||||
<button onClick={() => setShowRules(!showRules)} className="w-full flex items-center justify-between px-4 py-2 text-sm font-medium hover:bg-muted/50">
|
||||
<span>规则引擎说明(优化规则7条 + 招聘规则7条 + 决策逻辑4步)</span>
|
||||
<span className="text-xs text-muted-foreground">{showRules ? '收起 ▲' : '展开 ▼'}</span>
|
||||
</button>
|
||||
{showRules && result.ruleEngine && (
|
||||
<div className="border-t px-4 py-3 space-y-4">
|
||||
{/* 动态标准 */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">动态标准值(基于全部门店中位数)</p>
|
||||
<div className="grid grid-cols-3 gap-3 mb-2">
|
||||
<div className="rounded border p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">人均创收中位数</p>
|
||||
<p className="text-sm font-bold">{formatCurrency(result.ruleEngine.standards.revenue_per_emp)}</p>
|
||||
</div>
|
||||
<div className="rounded border p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">工时产能中位数</p>
|
||||
<p className="text-sm font-bold">{result.ruleEngine.standards.revenue_per_hour}元/h</p>
|
||||
</div>
|
||||
<div className="rounded border p-2 text-center">
|
||||
<p className="text-xs text-muted-foreground">人力成本率中位数</p>
|
||||
<p className="text-sm font-bold">{result.ruleEngine.standards.wage_ratio}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground">
|
||||
<th className="text-left py-1 px-2">岗位</th>
|
||||
<th className="text-right py-1 px-2">占比标准</th>
|
||||
<th className="text-right py-1 px-2">均薪</th>
|
||||
<th className="text-right py-1 px-2">出勤标准</th>
|
||||
<th className="text-right py-1 px-2">工时标准</th>
|
||||
<th className="text-right py-1 px-2">最低出勤</th>
|
||||
<th className="text-right py-1 px-2">最大人数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(result.ruleEngine.standards.roles || {}).map(([role, s]: [string, any]) => (
|
||||
<tr key={role} className="border-b">
|
||||
<td className="py-1 px-2 font-medium">{role}</td>
|
||||
<td className="text-right py-1 px-2">{s.role_ratio}%</td>
|
||||
<td className="text-right py-1 px-2">{formatCurrency(s.normal_pay)}</td>
|
||||
<td className="text-right py-1 px-2">{s.normal_attend}天</td>
|
||||
<td className="text-right py-1 px-2">{s.normal_hours}h</td>
|
||||
<td className="text-right py-1 px-2">{s.min_attend}天</td>
|
||||
<td className="text-right py-1 px-2">{s.max_count}人</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 决策逻辑 */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">决策逻辑(4步推理)</p>
|
||||
<div className="space-y-1">
|
||||
{(result.ruleEngine.decisionLogic || []).map((step: any) => (
|
||||
<div key={step.step} className="flex items-start gap-2 text-xs">
|
||||
<span className="font-bold text-primary min-w-[20px]">{step.step}.</span>
|
||||
<div>
|
||||
<span className="font-medium">{step.name}</span>
|
||||
<span className="text-muted-foreground ml-2">{step.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 优化规则 */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-orange-600 mb-2">优化信号规则(R1~R7,触发后抑制所有招聘信号)</p>
|
||||
<div className="space-y-1">
|
||||
{(result.ruleEngine.optimizationRules || []).map((r: any) => (
|
||||
<div key={r.id} className="flex items-start gap-2 text-xs rounded border border-orange-100 bg-orange-50/30 p-2">
|
||||
<span className="font-bold text-orange-700 min-w-[28px]">{r.id}</span>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="text-muted-foreground ml-2">{r.condition}</span>
|
||||
</div>
|
||||
<span className="text-orange-600 font-medium min-w-[40px] text-right">+{r.score}分</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 招聘规则 */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-red-600 mb-2">招聘信号规则(R8~R14,仅无优化信号时触发)</p>
|
||||
<div className="space-y-1">
|
||||
{(result.ruleEngine.hireRules || []).map((r: any) => (
|
||||
<div key={r.id} className="flex items-start gap-2 text-xs rounded border border-red-100 bg-red-50/30 p-2">
|
||||
<span className="font-bold text-red-700 min-w-[28px]">{r.id}</span>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="text-muted-foreground ml-2">{r.condition}</span>
|
||||
{r.constraint && <span className="text-muted-foreground ml-1">({r.constraint})</span>}
|
||||
</div>
|
||||
<span className="text-red-600 font-medium min-w-[40px] text-right">+{r.score}分</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 筛选排序 */}
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<select value={storeFilter} onChange={(e) => { setStoreFilter(e.target.value); setPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select value={actionFilter} onChange={(e) => { setActionFilter(e.target.value); setPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部动作</option>
|
||||
<option value="建议招聘">建议招聘</option>
|
||||
<option value="建议优化">建议优化</option>
|
||||
<option value="关注">关注</option>
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{sorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOrder(order === 'asc' ? 'desc' : 'asc'); setPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{order === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 预测表格 */}
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-left">门店</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-left">岗位</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-left">建议动作</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-right">紧急度</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-right">人数</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-right">人均创收</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-right">工时产能</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-right">人力成本率</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-right">岗位占比</th>
|
||||
<th className="px-3 py-2 font-medium text-muted-foreground whitespace-nowrap text-right">离职率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr><td colSpan={10} className="px-3 py-8 text-center text-muted-foreground">暂无数据</td></tr>
|
||||
) : paged.map((r, i) => {
|
||||
const key = rowKey(r)
|
||||
const isExpanded = expandedRow === key
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => setExpandedRow(isExpanded ? null : key)}
|
||||
className={`border-t hover:bg-muted/30 cursor-pointer ${LEVEL_ROW_STYLES[r.action_level] || ''}`}
|
||||
>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{r.store_name}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap font-medium">{r.role}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${ACTION_STYLES[r.action] || ''}`}>{r.action}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-right">
|
||||
<span className={r.urgency >= 70 ? 'text-red-600 font-bold' : r.urgency >= 40 ? 'text-orange-600 font-medium' : 'text-yellow-600'}>{r.urgency}%</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-right">{formatNumber(r.emp_count)}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-right">
|
||||
<span className={r.revenue_per_emp > 0 && r.revenue_per_emp < 15000 ? 'text-red-600' : ''}>{r.revenue_per_emp > 0 ? formatCurrency(r.revenue_per_emp) : '-'}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-right">
|
||||
<span className={r.revenue_per_hour > 0 && r.revenue_per_hour < 200 ? 'text-red-600' : ''}>{r.revenue_per_hour > 0 ? `${r.revenue_per_hour}元/h` : '-'}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-right">
|
||||
<span className={r.wage_ratio > 30 ? 'text-orange-600' : ''}>{r.wage_ratio > 0 ? `${r.wage_ratio}%` : '-'}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-right">{r.role_emp_ratio > 0 ? `${r.role_emp_ratio}%` : '-'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-right"><span className={r.turnover_pct > 0 ? 'text-red-600' : ''}>{r.turnover_pct > 0 ? `${r.turnover_pct}%` : '-'}</span></td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr key={`${i}-detail`} className="border-t bg-muted/20">
|
||||
<td colSpan={10} className="px-4 py-3">
|
||||
<div className="space-y-3">
|
||||
{r.analysis && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs font-medium text-blue-600 whitespace-nowrap mt-0.5">AI分析:</span>
|
||||
<span className="text-sm">{r.analysis}</span>
|
||||
</div>
|
||||
)}
|
||||
{r.hire_reasons && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs font-medium text-red-600 whitespace-nowrap mt-0.5">招聘依据:</span>
|
||||
<span className="text-sm">{r.hire_reasons || '无'}</span>
|
||||
</div>
|
||||
)}
|
||||
{r.optimize_reasons && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-xs font-medium text-orange-600 whitespace-nowrap mt-0.5">优化依据:</span>
|
||||
<span className="text-sm">{r.optimize_reasons || '无'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-2">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">产能指标对比(实际 vs 标准)</p>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">人均创收</p>
|
||||
<p className="text-sm font-medium">{r.revenue_per_emp > 0 ? formatCurrency(r.revenue_per_emp) : '-'} <span className="text-xs text-muted-foreground">/ 中位{formatCurrency(r.standards?.revenue_per_emp ?? 15000)}</span></p>
|
||||
</div>
|
||||
<div className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">工时产能</p>
|
||||
<p className="text-sm font-medium">{r.revenue_per_hour > 0 ? `${r.revenue_per_hour}元/h` : '-'} <span className="text-xs text-muted-foreground">/ 中位{r.standards?.revenue_per_hour ?? 200}元/h</span></p>
|
||||
</div>
|
||||
<div className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">人力成本率</p>
|
||||
<p className="text-sm font-medium">{r.wage_ratio > 0 ? `${r.wage_ratio}%` : '-'} <span className="text-xs text-muted-foreground">/ 中位{r.standards?.wage_ratio ?? 30}%</span></p>
|
||||
</div>
|
||||
<div className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">岗位占比</p>
|
||||
<p className="text-sm font-medium">{r.role_emp_ratio > 0 ? `${r.role_emp_ratio}%` : '-'} <span className="text-xs text-muted-foreground">/ 标准{r.standards?.expected_role_ratio ?? '-'}%</span></p>
|
||||
</div>
|
||||
<div className="rounded border p-2">
|
||||
<p className="text-xs text-muted-foreground">出勤/工时</p>
|
||||
<p className="text-sm font-medium">{r.avg_attend > 0 ? `${r.avg_attend}天/${r.avg_hours}h` : '-'} <span className="text-xs text-muted-foreground">/ 标准{r.standards?.normal_attend ?? 20}天/{r.standards?.normal_hours ?? 160}h</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3 border-t pt-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">在职人数</p>
|
||||
<p className="text-sm font-medium">{r.active_count ?? r.emp_count}人</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">当月离职/入职</p>
|
||||
<p className="text-sm font-medium"><span className="text-red-600">{r.left_count}</span> / <span className="text-blue-500">{r.new_count}</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">岗位月薪资</p>
|
||||
<p className="text-sm font-medium">{formatCurrency(r.total_pay ?? r.avg_pay * r.emp_count)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">均薪/月</p>
|
||||
<p className="text-sm font-medium">{formatCurrency(r.avg_pay)} <span className="text-xs text-muted-foreground">/ 均值{formatCurrency(r.standards?.normal_pay ?? 0)}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
{r.suggestion && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">AI建议:</p>
|
||||
<p className="text-sm">{r.suggestion}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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 STATUS_COLORS: Record<string, string> = {
|
||||
'严重不足': 'text-red-600 font-bold',
|
||||
'偏紧': 'text-orange-600 font-medium',
|
||||
'合理': 'text-green-600',
|
||||
'偏松': 'text-blue-500',
|
||||
'过剩': 'text-gray-400',
|
||||
}
|
||||
|
||||
export function StaffingMatchTab() {
|
||||
const { data: storesData } = useQuery({
|
||||
queryKey: ['ss/stores'],
|
||||
queryFn: () => api.get('/smart-scheduling/stores'),
|
||||
})
|
||||
const stores = (storesData as any)?.data || []
|
||||
const [storeName, setStoreName] = useState('七里庄店')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['ss/staffing-match', storeName],
|
||||
queryFn: () => api.get(`/smart-scheduling/staffing-match?store=${storeName}`),
|
||||
enabled: !!storeName,
|
||||
})
|
||||
|
||||
const rows = (data as any)?.data || []
|
||||
|
||||
const maxBills = Math.max(...rows.map((r: any) => r.bills), 1)
|
||||
const maxStaff = Math.max(...rows.map((r: any) => r.avg_staff), 1)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="排班匹配度分析" subtitle="各时段在岗人数 vs 客流账单量,识别排班错配">
|
||||
<div className="mb-3 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>
|
||||
|
||||
{isLoading ? <LoadingSpinner text="加载排班匹配数据..." /> : (
|
||||
<>
|
||||
<div className="mb-4 flex gap-4 flex-wrap">
|
||||
{Object.entries(STATUS_COLORS).map(([status, color]) => (
|
||||
<div key={status} className="flex items-center gap-1">
|
||||
<span className={`text-xs ${color}`}>●</span>
|
||||
<span className="text-xs text-muted-foreground">{status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">时段</th>
|
||||
<th className="px-3 py-2 text-right">在岗人数</th>
|
||||
<th className="px-3 py-2 text-right">账单量</th>
|
||||
<th className="px-3 py-2 text-right">人均接待</th>
|
||||
<th className="px-3 py-2 text-center">客流条</th>
|
||||
<th className="px-3 py-2 text-center">人力条</th>
|
||||
<th className="px-3 py-2 text-center">匹配状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r: any) => (
|
||||
<tr key={r.hour} className="border-t">
|
||||
<td className="px-3 py-2">{r.hour}:00 - {r.hour + 1}:00</td>
|
||||
<td className="px-3 py-2 text-right">{r.avg_staff}</td>
|
||||
<td className="px-3 py-2 text-right">{formatNumber(r.bills)}</td>
|
||||
<td className="px-3 py-2 text-right">{r.bills_per_staff || '-'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="w-24 h-3 bg-muted rounded overflow-hidden">
|
||||
<div className="h-full bg-red-400" style={{ width: `${(r.bills / maxBills) * 100}%` }} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="w-24 h-3 bg-muted rounded overflow-hidden">
|
||||
<div className="h-full bg-blue-400" style={{ width: `${(r.avg_staff / maxStaff) * 100}%` }} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<span className={STATUS_COLORS[r.match_status] || ''}>{r.match_status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">严重不足时段</p>
|
||||
<p className="text-xl font-bold text-red-600">{rows.filter((r: any) => r.match_status === '严重不足').length}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">偏紧时段</p>
|
||||
<p className="text-xl font-bold text-orange-600">{rows.filter((r: any) => r.match_status === '偏紧').length}</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">{rows.filter((r: any) => r.match_status === '过剩' || r.match_status === '偏松').length}</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">{rows.filter((r: any) => r.match_status === '合理').length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '@/lib/api'
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function TrafficHeatmapTab() {
|
||||
const [storeName, setStoreName] = useState('')
|
||||
const [overviewPage, setOverviewPage] = useState(1)
|
||||
const [mealPage, setMealPage] = useState(1)
|
||||
const [overviewSort, setOverviewSort] = useState('total_bills')
|
||||
const [overviewOrder, setOverviewOrder] = useState('desc')
|
||||
const [overviewFilter, setOverviewFilter] = useState('')
|
||||
const [mealSort, setMealSort] = useState('total')
|
||||
const [mealOrder, setMealOrder] = useState('desc')
|
||||
const [mealFilter, setMealFilter] = useState('')
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ['ss/traffic-overview'],
|
||||
queryFn: () => api.get('/smart-scheduling/traffic-overview'),
|
||||
})
|
||||
|
||||
const { data: heatmap, isLoading } = useQuery({
|
||||
queryKey: ['ss/traffic-heatmap', storeName],
|
||||
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap${storeName ? `?store=${storeName}` : ''}`),
|
||||
})
|
||||
|
||||
const { data: mealPeriod } = useQuery({
|
||||
queryKey: ['ss/meal-period-traffic', storeName],
|
||||
queryFn: () => api.get(`/smart-scheduling/meal-period-traffic${storeName ? `?store=${storeName}` : ''}`),
|
||||
})
|
||||
|
||||
const stores = (overview as any)?.data || []
|
||||
const heatRows = (heatmap as any)?.data || []
|
||||
const mealRows = (mealPeriod as any)?.data || []
|
||||
|
||||
const storeNames = Array.from(new Set(stores.map((s: any) => s.store_name as string))) as string[]
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
const heatMap: Record<string, Record<number, number>> = {}
|
||||
heatRows.forEach((r: any) => {
|
||||
if (!heatMap[r.store_name]) heatMap[r.store_name] = {}
|
||||
heatMap[r.store_name][r.hour] = r.bills
|
||||
})
|
||||
|
||||
function heatColor(bills: number, max: number): string {
|
||||
if (!bills) return ''
|
||||
const ratio = bills / max
|
||||
if (ratio > 0.8) return 'bg-red-500 text-white'
|
||||
if (ratio > 0.6) return 'bg-orange-400 text-white'
|
||||
if (ratio > 0.4) return 'bg-yellow-400'
|
||||
if (ratio > 0.2) return 'bg-green-300'
|
||||
if (ratio > 0) return 'bg-blue-200'
|
||||
return ''
|
||||
}
|
||||
|
||||
const maxBills = Math.max(...heatRows.map((r: any) => r.bills), 1)
|
||||
|
||||
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] = r.bills
|
||||
})
|
||||
|
||||
const overviewSorts = [
|
||||
{ key: 'total_bills', label: '月账单量' },
|
||||
{ key: 'peak_bills', label: '峰值' },
|
||||
{ key: 'peak_valley_ratio', label: '峰谷比' },
|
||||
{ key: 'peak_concentration_pct', label: '集中度' },
|
||||
]
|
||||
|
||||
const overviewFiltered = useMemo(() => {
|
||||
let r = stores
|
||||
if (overviewFilter) r = r.filter((s: any) => s.store_name.includes(overviewFilter))
|
||||
r = [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[overviewSort]) || 0
|
||||
const bv = parseFloat(b[overviewSort]) || 0
|
||||
return overviewOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
return r
|
||||
}, [stores, overviewFilter, overviewSort, overviewOrder])
|
||||
|
||||
const overviewTotal = overviewFiltered.length
|
||||
const overviewPaged = overviewFiltered.slice((overviewPage - 1) * PAGE_SIZE, overviewPage * PAGE_SIZE)
|
||||
|
||||
const mealStores = useMemo(() => {
|
||||
let r = Object.keys(mealMap).map(s => ({
|
||||
store_name: s,
|
||||
早市: mealMap[s]?.['早市'] || 0,
|
||||
午市: mealMap[s]?.['午市'] || 0,
|
||||
下午茶: mealMap[s]?.['下午茶'] || 0,
|
||||
晚市: mealMap[s]?.['晚市'] || 0,
|
||||
夜宵: mealMap[s]?.['夜宵'] || 0,
|
||||
total: Object.values(mealMap[s] || {}).reduce((a: number, b: number) => a + b, 0),
|
||||
}))
|
||||
if (mealFilter) r = r.filter((s: any) => s.store_name.includes(mealFilter))
|
||||
r = [...r].sort((a: any, b: any) => {
|
||||
const av = parseFloat(a[mealSort]) || 0
|
||||
const bv = parseFloat(b[mealSort]) || 0
|
||||
return mealOrder === 'desc' ? bv - av : av - bv
|
||||
})
|
||||
return r
|
||||
}, [mealMap, mealFilter, mealSort, mealOrder])
|
||||
|
||||
const mealTotal = mealStores.length
|
||||
const mealPaged = mealStores.slice((mealPage - 1) * PAGE_SIZE, mealPage * PAGE_SIZE)
|
||||
|
||||
const mealSorts = [
|
||||
{ key: 'total', label: '总账单' },
|
||||
{ key: '午市', label: '午市' },
|
||||
{ key: '晚市', label: '晚市' },
|
||||
{ key: '早市', label: '早市' },
|
||||
{ key: '夜宵', label: '夜宵' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CollapsibleSection title="小时客流热力图" subtitle="门店×小时账单量分布,红色为高峰时段">
|
||||
<div className="mb-3 flex gap-2 items-center">
|
||||
<select value={storeName} onChange={(e) => setStoreName(e.target.value)} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">颜色越深客流越大</span>
|
||||
</div>
|
||||
{isLoading ? <LoadingSpinner text="加载客流热力图..." /> : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left sticky left-0 bg-card">门店</th>
|
||||
{hours.map(h => <th key={h} className="px-1 py-1 text-center min-w-[28px]">{h}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(heatMap).map(store => (
|
||||
<tr key={store}>
|
||||
<td className="px-2 py-1 whitespace-nowrap sticky left-0 bg-card">{store}</td>
|
||||
{hours.map(h => {
|
||||
const bills = heatMap[store]?.[h] || 0
|
||||
return <td key={h} className={`px-1 py-1 text-center ${heatColor(bills, maxBills)}`}>{bills > 0 ? bills : ''}</td>
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="门店客流概览" subtitle="各门店月度账单量、峰谷比、客流集中度">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={overviewFilter} onChange={(e) => { setOverviewFilter(e.target.value); setOverviewPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{overviewSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setOverviewSort(s.key); setOverviewPage(1) }} className={`px-3 py-1 rounded text-xs ${overviewSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setOverviewOrder(overviewOrder === 'asc' ? 'desc' : 'asc'); setOverviewPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{overviewOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: 'total_bills', label: '月账单量', align: 'right', render: (r) => formatNumber(r.total_bills) },
|
||||
{ key: 'peak_bills', label: '峰值(小时)', align: 'right', render: (r) => formatNumber(r.peak_bills) },
|
||||
{ key: 'min_bills', label: '谷值(小时)', align: 'right', render: (r) => formatNumber(r.min_bills) },
|
||||
{ key: 'peak_valley_ratio', label: '峰谷比', align: 'right', render: (r) => <span className={parseFloat(r.peak_valley_ratio) > 10 ? 'text-red-600 font-medium' : ''}>{r.peak_valley_ratio}</span> },
|
||||
{ key: 'peak_concentration_pct', label: '高峰集中度%', align: 'right', render: (r) => <span className={parseFloat(r.peak_concentration_pct) > 60 ? 'text-orange-600 font-medium' : ''}>{r.peak_concentration_pct}%</span> },
|
||||
{ key: 'volatility_level', label: '波动等级', render: (r) => <span className={r.volatility_level === '波动极大' ? 'text-red-600' : r.volatility_level === '波动较大' ? 'text-orange-600' : ''}>{r.volatility_level}</span> },
|
||||
]}
|
||||
data={overviewPaged}
|
||||
/>
|
||||
<Pagination page={overviewPage} pageSize={PAGE_SIZE} total={overviewTotal} onPageChange={setOverviewPage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection title="餐段客流分布" subtitle="各门店早市/午市/下午茶/晚市/夜宵账单占比">
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<select value={mealFilter} onChange={(e) => { setMealFilter(e.target.value); setMealPage(1) }} className="border rounded px-2 py-1 text-sm">
|
||||
<option value="">全部门店</option>
|
||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
{mealSorts.map(s => (
|
||||
<button key={s.key} onClick={() => { setMealSort(s.key); setMealPage(1) }} className={`px-3 py-1 rounded text-xs ${mealSort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>按{s.label}</button>
|
||||
))}
|
||||
<span className="mx-2 text-muted-foreground">|</span>
|
||||
<button onClick={() => { setMealOrder(mealOrder === 'asc' ? 'desc' : 'asc'); setMealPage(1) }} className="px-3 py-1 rounded text-xs bg-muted">
|
||||
{mealOrder === 'asc' ? '↑ 升序' : '↓ 降序'}
|
||||
</button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'store_name', label: '门店' },
|
||||
{ key: '早市', label: '早市', align: 'right', render: (r) => formatNumber(r['早市']) },
|
||||
{ key: '午市', label: '午市', align: 'right', render: (r) => formatNumber(r['午市']) },
|
||||
{ key: '下午茶', label: '下午茶', align: 'right', render: (r) => formatNumber(r['下午茶']) },
|
||||
{ key: '晚市', label: '晚市', align: 'right', render: (r) => formatNumber(r['晚市']) },
|
||||
{ key: '夜宵', label: '夜宵', align: 'right', render: (r) => formatNumber(r['夜宵']) },
|
||||
]}
|
||||
data={mealPaged}
|
||||
/>
|
||||
<Pagination page={mealPage} pageSize={PAGE_SIZE} total={mealTotal} onPageChange={setMealPage} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import { Tabs } from '@/components/Tabs'
|
||||
import { OverallAnalysisTab } from '@/components/smart-scheduling/OverallAnalysisTab'
|
||||
import { StaffingForecastTab } from '@/components/smart-scheduling/StaffingForecastTab'
|
||||
import { TrafficHeatmapTab } from '@/components/smart-scheduling/TrafficHeatmapTab'
|
||||
import { StaffingMatchTab } from '@/components/smart-scheduling/StaffingMatchTab'
|
||||
import { EfficiencyBenchmarkTab } from '@/components/smart-scheduling/EfficiencyBenchmarkTab'
|
||||
import { SchedulingSuggestionTab } from '@/components/smart-scheduling/SchedulingSuggestionTab'
|
||||
import { AttendanceAlertTab } from '@/components/smart-scheduling/AttendanceAlertTab'
|
||||
import { EmployeeAnalysisTab } from '@/components/smart-scheduling/EmployeeAnalysisTab'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overall', label: '总体智能分析' },
|
||||
{ key: 'forecast', label: '人员预测' },
|
||||
{ key: 'traffic', label: '客流热力图' },
|
||||
{ key: 'match', label: '排班匹配度' },
|
||||
{ key: 'efficiency', label: '人效对标' },
|
||||
{ key: 'suggestion', label: '排班建议' },
|
||||
{ key: 'alert', label: '考勤预警' },
|
||||
{ key: 'employee', label: '员工分析' },
|
||||
]
|
||||
|
||||
export function SmartSchedulingPage() {
|
||||
const [activeTab, setActiveTab] = useState('overall')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">智能排班分析</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">2026年4月 · 客流规律 × 排班匹配 × 人效优化 · 103家门店</p>
|
||||
</div>
|
||||
|
||||
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
<div>
|
||||
{activeTab === 'overall' && <OverallAnalysisTab />}
|
||||
{activeTab === 'forecast' && <StaffingForecastTab />}
|
||||
{activeTab === 'traffic' && <TrafficHeatmapTab />}
|
||||
{activeTab === 'match' && <StaffingMatchTab />}
|
||||
{activeTab === 'efficiency' && <EfficiencyBenchmarkTab />}
|
||||
{activeTab === 'suggestion' && <SchedulingSuggestionTab />}
|
||||
{activeTab === 'alert' && <AttendanceAlertTab />}
|
||||
{activeTab === 'employee' && <EmployeeAnalysisTab />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
薪资拆分明细表 + 考勤数据表 导入脚本
|
||||
遵循现有 import_log + records 模式
|
||||
"""
|
||||
import openpyxl
|
||||
import hashlib
|
||||
import psycopg2
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
DB_CONFIG = {
|
||||
'host': 'localhost',
|
||||
'port': 5432,
|
||||
'dbname': 'bill_query',
|
||||
'user': 'freedak',
|
||||
'password': '',
|
||||
}
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SALARY_FILE = os.path.join(BASE_DIR, '马兰拉面数据分析', '薪资拆分明细表_脱敏.xlsx')
|
||||
ATTENDANCE_FILE = os.path.join(BASE_DIR, '马兰拉面数据分析', '考勤数据表-脱敏.xlsx')
|
||||
|
||||
def file_sha256(filepath):
|
||||
h = hashlib.sha256()
|
||||
with open(filepath, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(8192), b''):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
def to_num(val):
|
||||
if val is None or val == '':
|
||||
return 0
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
def to_text(val):
|
||||
if val is None:
|
||||
return None
|
||||
s = str(val).strip()
|
||||
return s if s else None
|
||||
|
||||
def import_salary(conn):
|
||||
print(f'导入薪资拆分明细表: {SALARY_FILE}')
|
||||
sha = file_sha256(SALARY_FILE)
|
||||
wb = openpyxl.load_workbook(SALARY_FILE, read_only=True, data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
|
||||
# Row 1: title, Row 2: merged title, Row 3: headers, Row 4+: data
|
||||
rows = list(ws.iter_rows(min_row=4, values_only=True))
|
||||
data_rows = [r for r in rows if r[0] is not None and r[9] is not None and str(r[9]).strip()]
|
||||
print(f' 数据行数: {len(data_rows)}')
|
||||
|
||||
report_month = '2026-04-01'
|
||||
source_file = os.path.basename(SALARY_FILE)
|
||||
|
||||
cur = conn.cursor()
|
||||
# Check if already imported
|
||||
cur.execute(
|
||||
'SELECT import_id FROM public.salary_import_log WHERE report_month = %s AND source_file = %s AND file_sha256 = %s',
|
||||
(report_month, source_file, sha)
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
print(f' 已导入过, import_id={existing[0]}, 跳过')
|
||||
wb.close()
|
||||
return
|
||||
|
||||
# Insert import log
|
||||
cur.execute(
|
||||
'''INSERT INTO public.salary_import_log (report_month, source_file, file_sha256, workbook_rows, imported_rows)
|
||||
VALUES (%s, %s, %s, %s, %s) RETURNING import_id''',
|
||||
(report_month, source_file, sha, len(data_rows), len(data_rows))
|
||||
)
|
||||
import_id = cur.fetchone()[0]
|
||||
print(f' import_id={import_id}')
|
||||
|
||||
# Batch insert
|
||||
insert_sql = '''INSERT INTO public.salary_detail_records (
|
||||
import_id, source_row,
|
||||
org_level1, org_level2, org_level3, org_level4, org_level5, org_level6, org_level7, org_level8,
|
||||
employee_code, salary_period, position, work_type, employment_type, hire_date, leave_date,
|
||||
salary_standard, base_wage, overtime_subsidy, social_subsidy, position_wage, tenure_wage,
|
||||
hourly_rate, total_wage, brand_wage, rent_subsidy,
|
||||
month_days, expected_attend, calc_attend, actual_attend, actual_hours,
|
||||
expected_rest, actual_rest, expected_legal_holiday, actual_legal_holiday,
|
||||
comp_leave_days, annual_leave, marriage_leave, paid_leave, bereavement_leave,
|
||||
personal_leave_days, personal_leave_deduction, furlough_days, furlough_deduction,
|
||||
injury_leave_days, injury_leave_deduction, absent_days, absent_deduction, join_leave_absent,
|
||||
attendance_wage, rest_subsidy, overtime_pay, shift_subsidy, comp_leave_wage,
|
||||
cashier_amount, bonus, subsidy, subsidy_remark, rush_wage, total_supplement,
|
||||
late_deduction, no_punch_deduction, internal_social_total, internal_social_remark,
|
||||
loan, loan_remark, fine, compensation, total_deduction, injury_wage, singapore_subsidy,
|
||||
perf_standard, perf_score, perf_amount, phone_allowance, dorm_fee,
|
||||
net_salary, gross_pay, income_tax, net_pay,
|
||||
external_base_standard, external_overtime, external_absence, external_bonus,
|
||||
external_subsidy, external_other_deduction, external_gross,
|
||||
pension_deduction, medical_deduction, unemployment_deduction,
|
||||
external_social_total, external_tax, external_net, internal_tax, internal_net,
|
||||
external_unit, attendance_remark, employment_type_orig, salary_category
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)'''
|
||||
|
||||
batch = []
|
||||
batch_size = 500
|
||||
for idx, r in enumerate(data_rows):
|
||||
source_row = idx + 4 # Excel row number
|
||||
vals = [
|
||||
import_id, source_row,
|
||||
to_text(r[1]), to_text(r[2]), to_text(r[3]), to_text(r[4]), to_text(r[5]), to_text(r[6]), to_text(r[7]), to_text(r[8]),
|
||||
to_text(r[9]), to_text(r[10]), to_text(r[11]), to_text(r[12]), to_text(r[13]), to_text(r[14]), to_text(r[15]),
|
||||
to_num(r[16]), to_num(r[17]), to_num(r[18]), to_num(r[19]), to_num(r[20]), to_num(r[21]),
|
||||
to_num(r[22]), to_num(r[23]), to_num(r[24]), to_num(r[25]),
|
||||
to_num(r[26]), to_num(r[27]), to_num(r[28]), to_num(r[29]), to_num(r[30]),
|
||||
to_num(r[31]), to_num(r[32]), to_num(r[33]), to_num(r[34]),
|
||||
to_num(r[35]), to_num(r[36]), to_num(r[37]), to_num(r[38]), to_num(r[39]),
|
||||
to_num(r[40]), to_num(r[41]), to_num(r[42]), to_num(r[43]),
|
||||
to_num(r[44]), to_num(r[45]), to_num(r[46]), to_num(r[47]), to_num(r[48]),
|
||||
to_num(r[49]), to_num(r[50]), to_num(r[51]), to_num(r[52]), to_num(r[53]),
|
||||
to_num(r[54]), to_num(r[55]), to_num(r[56]), to_text(r[57]), to_num(r[58]), to_num(r[59]),
|
||||
to_num(r[60]), to_num(r[61]), to_num(r[62]), to_text(r[63]),
|
||||
to_num(r[64]), to_text(r[65]), to_num(r[66]), to_num(r[67]), to_num(r[68]), to_num(r[69]), to_num(r[70]),
|
||||
to_num(r[71]), to_num(r[72]), to_num(r[73]), to_num(r[74]), to_num(r[75]),
|
||||
to_num(r[76]), to_num(r[77]), to_num(r[78]), to_num(r[79]),
|
||||
to_num(r[80]), to_num(r[81]), to_num(r[82]), to_num(r[83]),
|
||||
to_num(r[84]), to_num(r[85]), to_num(r[86]),
|
||||
to_num(r[87]), to_num(r[88]), to_num(r[89]),
|
||||
to_num(r[90]), to_num(r[91]), to_num(r[92]), to_num(r[93]), to_num(r[94]),
|
||||
to_text(r[95]), to_text(r[96]), to_text(r[97]), to_text(r[98]),
|
||||
]
|
||||
batch.append(vals)
|
||||
if len(batch) >= batch_size:
|
||||
cur.executemany(insert_sql, batch)
|
||||
conn.commit()
|
||||
print(f' 已导入 {idx + 1}/{len(data_rows)} 行')
|
||||
batch = []
|
||||
|
||||
if batch:
|
||||
cur.executemany(insert_sql, batch)
|
||||
conn.commit()
|
||||
print(f' 已导入 {len(data_rows)}/{len(data_rows)} 行')
|
||||
|
||||
wb.close()
|
||||
print(f' 薪资明细导入完成: {len(data_rows)} 行')
|
||||
|
||||
def import_attendance(conn):
|
||||
print(f'\n导入考勤数据表: {ATTENDANCE_FILE}')
|
||||
sha = file_sha256(ATTENDANCE_FILE)
|
||||
wb = openpyxl.load_workbook(ATTENDANCE_FILE, read_only=True, data_only=True)
|
||||
ws = wb[wb.sheetnames[0]]
|
||||
|
||||
# Row 1: headers, Row 2+: data
|
||||
rows = list(ws.iter_rows(min_row=2, values_only=True))
|
||||
data_rows = [r for r in rows if r[0] is not None]
|
||||
print(f' 数据行数: {len(data_rows)}')
|
||||
|
||||
report_month = '2026-04-01'
|
||||
source_file = os.path.basename(ATTENDANCE_FILE)
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
'SELECT import_id FROM public.attendance_import_log WHERE report_month = %s AND source_file = %s AND file_sha256 = %s',
|
||||
(report_month, source_file, sha)
|
||||
)
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
print(f' 已导入过, import_id={existing[0]}, 跳过')
|
||||
wb.close()
|
||||
return
|
||||
|
||||
cur.execute(
|
||||
'''INSERT INTO public.attendance_import_log (report_month, source_file, file_sha256, workbook_rows, imported_rows)
|
||||
VALUES (%s, %s, %s, %s, %s) RETURNING import_id''',
|
||||
(report_month, source_file, sha, len(data_rows), len(data_rows))
|
||||
)
|
||||
import_id = cur.fetchone()[0]
|
||||
print(f' import_id={import_id}')
|
||||
|
||||
# 33 columns: 工号, 岗位, 所在部门, day_01..day_30
|
||||
cols = ['import_id', 'source_row', 'employee_code', 'position', 'department']
|
||||
cols += [f'day_{str(i).zfill(2)}' for i in range(1, 31)]
|
||||
placeholders = ', '.join(['%s'] * len(cols))
|
||||
col_names = ', '.join(cols)
|
||||
insert_sql = f'INSERT INTO public.attendance_records ({col_names}) VALUES ({placeholders})'
|
||||
|
||||
batch = []
|
||||
batch_size = 500
|
||||
for idx, r in enumerate(data_rows):
|
||||
source_row = idx + 2
|
||||
vals = [import_id, source_row, to_text(r[0]), to_text(r[1]), to_text(r[2])]
|
||||
# Days 1-30 (columns 3-32)
|
||||
for d in range(30):
|
||||
vals.append(to_text(r[3 + d]) if 3 + d < len(r) else None)
|
||||
batch.append(vals)
|
||||
if len(batch) >= batch_size:
|
||||
cur.executemany(insert_sql, batch)
|
||||
conn.commit()
|
||||
print(f' 已导入 {idx + 1}/{len(data_rows)} 行')
|
||||
batch = []
|
||||
|
||||
if batch:
|
||||
cur.executemany(insert_sql, batch)
|
||||
conn.commit()
|
||||
print(f' 已导入 {len(data_rows)}/{len(data_rows)} 行')
|
||||
|
||||
wb.close()
|
||||
print(f' 考勤数据导入完成: {len(data_rows)} 行')
|
||||
|
||||
def main():
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
conn.autocommit = False
|
||||
try:
|
||||
import_salary(conn)
|
||||
import_attendance(conn)
|
||||
print('\n=== 导入完成 ===')
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f'错误: {e}', file=sys.stderr)
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,213 @@
|
||||
-- ============================================================
|
||||
-- 薪资拆分明细表 + 考勤数据表 建表SQL
|
||||
-- 创建时间: 2026-07-29
|
||||
-- 数据来源: 薪资拆分明细表_脱敏.xlsx / 考勤数据表-脱敏.xlsx
|
||||
-- 导入规则: 遵循现有 import_log + records 模式
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 一、薪资拆分明细表
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 导入日志表
|
||||
CREATE TABLE IF NOT EXISTS public.salary_import_log (
|
||||
import_id BIGSERIAL PRIMARY KEY,
|
||||
report_month DATE NOT NULL,
|
||||
source_file TEXT NOT NULL,
|
||||
file_sha256 TEXT NOT NULL,
|
||||
workbook_rows INTEGER,
|
||||
imported_rows INTEGER,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(report_month, source_file, file_sha256)
|
||||
);
|
||||
|
||||
-- 2. 薪资明细记录表
|
||||
CREATE TABLE IF NOT EXISTS public.salary_detail_records (
|
||||
record_id BIGSERIAL PRIMARY KEY,
|
||||
import_id BIGINT NOT NULL REFERENCES public.salary_import_log(import_id) ON DELETE CASCADE,
|
||||
source_row INTEGER NOT NULL,
|
||||
|
||||
-- 组织层级
|
||||
org_level1 TEXT,
|
||||
org_level2 TEXT,
|
||||
org_level3 TEXT,
|
||||
org_level4 TEXT,
|
||||
org_level5 TEXT, -- 门店名
|
||||
org_level6 TEXT,
|
||||
org_level7 TEXT,
|
||||
org_level8 TEXT,
|
||||
|
||||
-- 员工信息
|
||||
employee_code TEXT NOT NULL,
|
||||
salary_period TEXT,
|
||||
position TEXT,
|
||||
work_type TEXT,
|
||||
employment_type TEXT,
|
||||
hire_date TEXT,
|
||||
leave_date TEXT,
|
||||
|
||||
-- 工资构成
|
||||
salary_standard NUMERIC(18,2) DEFAULT 0, -- 工资额度
|
||||
base_wage NUMERIC(18,2) DEFAULT 0, -- 基本工资
|
||||
overtime_subsidy NUMERIC(18,2) DEFAULT 0, -- 加班补贴
|
||||
social_subsidy NUMERIC(18,2) DEFAULT 0, -- 社保补贴
|
||||
position_wage NUMERIC(18,2) DEFAULT 0, -- 岗位工资
|
||||
tenure_wage NUMERIC(18,2) DEFAULT 0, -- 工龄工资
|
||||
hourly_rate NUMERIC(18,2) DEFAULT 0, -- 时薪
|
||||
total_wage NUMERIC(18,2) DEFAULT 0, -- 工资总额
|
||||
brand_wage NUMERIC(18,2) DEFAULT 0, -- 品牌工资
|
||||
rent_subsidy NUMERIC(18,2) DEFAULT 0, -- 租房补贴
|
||||
|
||||
-- 出勤信息
|
||||
month_days NUMERIC(18,2) DEFAULT 0, -- 本月天数
|
||||
expected_attend NUMERIC(18,2) DEFAULT 0, -- 应出勤天数
|
||||
calc_attend NUMERIC(18,2) DEFAULT 0, -- 算薪天数
|
||||
actual_attend NUMERIC(18,2) DEFAULT 0, -- 实出勤天数
|
||||
actual_hours NUMERIC(18,2) DEFAULT 0, -- 实际出勤时长
|
||||
expected_rest NUMERIC(18,2) DEFAULT 0, -- 应休公休天数
|
||||
actual_rest NUMERIC(18,2) DEFAULT 0, -- 实公休天数
|
||||
expected_legal_holiday NUMERIC(18,2) DEFAULT 0,
|
||||
actual_legal_holiday NUMERIC(18,2) DEFAULT 0,
|
||||
comp_leave_days NUMERIC(18,2) DEFAULT 0, -- 调休天数
|
||||
annual_leave NUMERIC(18,2) DEFAULT 0, -- 年假天数
|
||||
marriage_leave NUMERIC(18,2) DEFAULT 0,
|
||||
paid_leave NUMERIC(18,2) DEFAULT 0,
|
||||
bereavement_leave NUMERIC(18,2) DEFAULT 0,
|
||||
personal_leave_days NUMERIC(18,2) DEFAULT 0,
|
||||
personal_leave_deduction NUMERIC(18,2) DEFAULT 0,
|
||||
furlough_days NUMERIC(18,2) DEFAULT 0,
|
||||
furlough_deduction NUMERIC(18,2) DEFAULT 0,
|
||||
injury_leave_days NUMERIC(18,2) DEFAULT 0,
|
||||
injury_leave_deduction NUMERIC(18,2) DEFAULT 0,
|
||||
absent_days NUMERIC(18,2) DEFAULT 0, -- 旷工天数
|
||||
absent_deduction NUMERIC(18,2) DEFAULT 0,
|
||||
join_leave_absent NUMERIC(18,2) DEFAULT 0,
|
||||
|
||||
-- 薪资计算
|
||||
attendance_wage NUMERIC(18,2) DEFAULT 0, -- 出勤薪资
|
||||
rest_subsidy NUMERIC(18,2) DEFAULT 0, -- 公休补助
|
||||
overtime_pay NUMERIC(18,2) DEFAULT 0, -- 加班
|
||||
shift_subsidy NUMERIC(18,2) DEFAULT 0, -- 班次补贴
|
||||
comp_leave_wage NUMERIC(18,2) DEFAULT 0,
|
||||
cashier_amount NUMERIC(18,2) DEFAULT 0, -- 收银金额
|
||||
bonus NUMERIC(18,2) DEFAULT 0, -- 奖励
|
||||
subsidy NUMERIC(18,2) DEFAULT 0, -- 补助
|
||||
subsidy_remark TEXT,
|
||||
rush_wage NUMERIC(18,2) DEFAULT 0, -- 抢班工资
|
||||
total_supplement NUMERIC(18,2) DEFAULT 0, -- 应补合计
|
||||
late_deduction NUMERIC(18,2) DEFAULT 0, -- 迟到早退扣款
|
||||
no_punch_deduction NUMERIC(18,2) DEFAULT 0, -- 未打卡扣款
|
||||
internal_social_total NUMERIC(18,2) DEFAULT 0,
|
||||
internal_social_remark TEXT,
|
||||
loan NUMERIC(18,2) DEFAULT 0,
|
||||
loan_remark TEXT,
|
||||
fine NUMERIC(18,2) DEFAULT 0,
|
||||
compensation NUMERIC(18,2) DEFAULT 0,
|
||||
total_deduction NUMERIC(18,2) DEFAULT 0, -- 应扣合计
|
||||
injury_wage NUMERIC(18,2) DEFAULT 0,
|
||||
singapore_subsidy NUMERIC(18,2) DEFAULT 0,
|
||||
|
||||
-- 绩效
|
||||
perf_standard NUMERIC(18,2) DEFAULT 0,
|
||||
perf_score NUMERIC(18,2) DEFAULT 0,
|
||||
perf_amount NUMERIC(18,2) DEFAULT 0,
|
||||
|
||||
-- 其他
|
||||
phone_allowance NUMERIC(18,2) DEFAULT 0,
|
||||
dorm_fee NUMERIC(18,2) DEFAULT 0,
|
||||
net_salary NUMERIC(18,2) DEFAULT 0, -- 实得薪资
|
||||
gross_pay NUMERIC(18,2) DEFAULT 0, -- 应发工资
|
||||
income_tax NUMERIC(18,2) DEFAULT 0, -- 个人所得税
|
||||
net_pay NUMERIC(18,2) DEFAULT 0, -- 实发工资
|
||||
|
||||
-- 外账
|
||||
external_base_standard NUMERIC(18,2) DEFAULT 0,
|
||||
external_overtime NUMERIC(18,2) DEFAULT 0,
|
||||
external_absence NUMERIC(18,2) DEFAULT 0,
|
||||
external_bonus NUMERIC(18,2) DEFAULT 0,
|
||||
external_subsidy NUMERIC(18,2) DEFAULT 0,
|
||||
external_other_deduction NUMERIC(18,2) DEFAULT 0,
|
||||
external_gross NUMERIC(18,2) DEFAULT 0,
|
||||
pension_deduction NUMERIC(18,2) DEFAULT 0,
|
||||
medical_deduction NUMERIC(18,2) DEFAULT 0,
|
||||
unemployment_deduction NUMERIC(18,2) DEFAULT 0,
|
||||
external_social_total NUMERIC(18,2) DEFAULT 0,
|
||||
external_tax NUMERIC(18,2) DEFAULT 0,
|
||||
external_net NUMERIC(18,2) DEFAULT 0,
|
||||
internal_tax NUMERIC(18,2) DEFAULT 0,
|
||||
internal_net NUMERIC(18,2) DEFAULT 0,
|
||||
|
||||
-- 其他字段
|
||||
external_unit TEXT,
|
||||
attendance_remark TEXT, -- 考勤备注_店长填写
|
||||
employment_type_orig TEXT, -- 雇佣类型
|
||||
salary_category TEXT, -- 薪酬类别
|
||||
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_salary_import_id ON public.salary_detail_records(import_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_salary_employee ON public.salary_detail_records(employee_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_salary_org5 ON public.salary_detail_records(org_level5);
|
||||
CREATE INDEX IF NOT EXISTS idx_salary_period ON public.salary_detail_records(salary_period);
|
||||
|
||||
-- ============================================================
|
||||
-- 二、考勤数据表
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 导入日志表
|
||||
CREATE TABLE IF NOT EXISTS public.attendance_import_log (
|
||||
import_id BIGSERIAL PRIMARY KEY,
|
||||
report_month DATE NOT NULL,
|
||||
source_file TEXT NOT NULL,
|
||||
file_sha256 TEXT NOT NULL,
|
||||
workbook_rows INTEGER,
|
||||
imported_rows INTEGER,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(report_month, source_file, file_sha256)
|
||||
);
|
||||
|
||||
-- 2. 考勤记录表(宽表:工号×日期)
|
||||
CREATE TABLE IF NOT EXISTS public.attendance_records (
|
||||
record_id BIGSERIAL PRIMARY KEY,
|
||||
import_id BIGINT NOT NULL REFERENCES public.attendance_import_log(import_id) ON DELETE CASCADE,
|
||||
source_row INTEGER NOT NULL,
|
||||
employee_code TEXT NOT NULL,
|
||||
position TEXT,
|
||||
department TEXT, -- 所在部门
|
||||
day_01 TEXT,
|
||||
day_02 TEXT,
|
||||
day_03 TEXT,
|
||||
day_04 TEXT,
|
||||
day_05 TEXT,
|
||||
day_06 TEXT,
|
||||
day_07 TEXT,
|
||||
day_08 TEXT,
|
||||
day_09 TEXT,
|
||||
day_10 TEXT,
|
||||
day_11 TEXT,
|
||||
day_12 TEXT,
|
||||
day_13 TEXT,
|
||||
day_14 TEXT,
|
||||
day_15 TEXT,
|
||||
day_16 TEXT,
|
||||
day_17 TEXT,
|
||||
day_18 TEXT,
|
||||
day_19 TEXT,
|
||||
day_20 TEXT,
|
||||
day_21 TEXT,
|
||||
day_22 TEXT,
|
||||
day_23 TEXT,
|
||||
day_24 TEXT,
|
||||
day_25 TEXT,
|
||||
day_26 TEXT,
|
||||
day_27 TEXT,
|
||||
day_28 TEXT,
|
||||
day_29 TEXT,
|
||||
day_30 TEXT,
|
||||
day_31 TEXT,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_import_id ON public.attendance_records(import_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_employee ON public.attendance_records(employee_code);
|
||||
@@ -8,6 +8,7 @@ import dataRoutes from './routes/data.js'
|
||||
import taskRoutes from './routes/tasks.js'
|
||||
import costAnalysisRoutes from './routes/cost-analysis.js'
|
||||
import storeExpenseRoutes from './routes/store-expense.js'
|
||||
import smartSchedulingRoutes from './routes/smart-scheduling.js'
|
||||
|
||||
const app = express()
|
||||
const PORT = parseInt(process.env.PORT || '3333')
|
||||
@@ -35,6 +36,7 @@ app.use('/api', dataRoutes)
|
||||
app.use('/api/tasks', taskRoutes)
|
||||
app.use('/api/cost-analysis', costAnalysisRoutes)
|
||||
app.use('/api/store-expense', storeExpenseRoutes)
|
||||
app.use('/api/smart-scheduling', smartSchedulingRoutes)
|
||||
|
||||
app.use(notFoundHandler)
|
||||
app.use(errorHandler)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
const AI_API_KEY = process.env.ZHIPU_API_KEY || process.env.DASHSCOPE_API_KEY || ''
|
||||
const AI_API_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'
|
||||
|
||||
interface AIMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export async function callAI(messages: AIMessage[], temperature = 0.3): Promise<string> {
|
||||
const resp = await fetch(AI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'qwen-plus',
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: 4096,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const errText = await resp.text()
|
||||
throw new Error(`AI API error ${resp.status}: ${errText}`)
|
||||
}
|
||||
|
||||
const data = await resp.json()
|
||||
return data.choices?.[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
export async function callAIJSON(messages: AIMessage[], temperature = 0.3): Promise<any> {
|
||||
const raw = await callAI(messages, temperature)
|
||||
console.log('[AI] raw response length:', raw.length)
|
||||
// 提取JSON块(兼容```json包裹)
|
||||
let jsonStr = raw.trim()
|
||||
const codeBlockMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||||
if (codeBlockMatch) {
|
||||
jsonStr = codeBlockMatch[1].trim()
|
||||
} else {
|
||||
// 找第一个[或{到最后一个]或}
|
||||
const firstBracket = raw.search(/[[{]/)
|
||||
if (firstBracket >= 0) {
|
||||
const lastBracket = Math.max(raw.lastIndexOf(']'), raw.lastIndexOf('}'))
|
||||
if (lastBracket > firstBracket) {
|
||||
jsonStr = raw.substring(firstBracket, lastBracket + 1).trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(jsonStr)
|
||||
} catch (e) {
|
||||
// AI可能返回多个独立JSON对象(非数组格式),尝试包装成数组
|
||||
try {
|
||||
// 将相邻的}{替换为},{
|
||||
const arrayStr = '[' + jsonStr.replace(/\}\s*\{/g, '},{') + ']'
|
||||
return JSON.parse(arrayStr)
|
||||
} catch (e2) {
|
||||
console.error('[AI] JSON parse failed. Raw (500 chars):', raw.substring(0, 500))
|
||||
throw e2
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user