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

- 新增人员预测tab,基于多维度产能指标生成招聘/优化建议
- 规则引擎:7条优化规则 + 7条招聘规则 + 4步决策逻辑
- 动态标准值:基于全部门店中位数计算,替代硬编码阈值
- 互斥逻辑:有优化信号时抑制所有招聘信号
- 占比超配时不触发招聘信号(R9/R11/R12/R13)
- 离职缺口需出勤不足才触发招聘(R10)
- 管理岗满编时不触发出勤不足招聘(R9)
- 前端可折叠规则引擎面板,展示全部规则和触发条件
- 产能指标对比展示实际值 vs 中位数标准
This commit is contained in:
freedakgmail
2026-07-29 23:41:56 +08:00
parent 80d67ccb73
commit 75a483bfdb
17 changed files with 3497 additions and 3 deletions
+2
View File
@@ -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>
+4 -2
View File
@@ -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) => (
+2 -1
View File
@@ -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>
)
}
+47
View File
@@ -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">20264 · × × · 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>
)
}
+226
View File
@@ -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()
+213
View File
@@ -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);
+2
View File
@@ -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)
+64
View File
@@ -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