From 75a483bfdb08a725857a236f272611321aa2aec9 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Wed, 29 Jul 2026 23:41:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=99=BA=E8=83=BD=E6=8E=92=E7=8F=AD?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E9=A2=84=E6=B5=8B=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增人员预测tab,基于多维度产能指标生成招聘/优化建议 - 规则引擎:7条优化规则 + 7条招聘规则 + 4步决策逻辑 - 动态标准值:基于全部门店中位数计算,替代硬编码阈值 - 互斥逻辑:有优化信号时抑制所有招聘信号 - 占比超配时不触发招聘信号(R9/R11/R12/R13) - 离职缺口需出勤不足才触发招聘(R10) - 管理岗满编时不触发出勤不足招聘(R9) - 前端可折叠规则引擎面板,展示全部规则和触发条件 - 产能指标对比展示实际值 vs 中位数标准 --- client/src/App.tsx | 2 + client/src/components/DataTable.tsx | 6 +- client/src/components/Layout.tsx | 3 +- .../smart-scheduling/AttendanceAlertTab.tsx | 183 ++ .../EfficiencyBenchmarkTab.tsx | 130 ++ .../smart-scheduling/EmployeeAnalysisTab.tsx | 190 +++ .../smart-scheduling/OverallAnalysisTab.tsx | 114 ++ .../SchedulingSuggestionTab.tsx | 138 ++ .../smart-scheduling/StaffingForecastTab.tsx | 362 ++++ .../smart-scheduling/StaffingMatchTab.tsx | 119 ++ .../smart-scheduling/TrafficHeatmapTab.tsx | 216 +++ client/src/pages/SmartSchedulingPage.tsx | 47 + db/import_salary_attendance.py | 226 +++ db/import_salary_attendance.sql | 213 +++ server/src/index.ts | 2 + server/src/lib/ai.ts | 64 + server/src/routes/smart-scheduling.ts | 1485 +++++++++++++++++ 17 files changed, 3497 insertions(+), 3 deletions(-) create mode 100644 client/src/components/smart-scheduling/AttendanceAlertTab.tsx create mode 100644 client/src/components/smart-scheduling/EfficiencyBenchmarkTab.tsx create mode 100644 client/src/components/smart-scheduling/EmployeeAnalysisTab.tsx create mode 100644 client/src/components/smart-scheduling/OverallAnalysisTab.tsx create mode 100644 client/src/components/smart-scheduling/SchedulingSuggestionTab.tsx create mode 100644 client/src/components/smart-scheduling/StaffingForecastTab.tsx create mode 100644 client/src/components/smart-scheduling/StaffingMatchTab.tsx create mode 100644 client/src/components/smart-scheduling/TrafficHeatmapTab.tsx create mode 100644 client/src/pages/SmartSchedulingPage.tsx create mode 100644 db/import_salary_attendance.py create mode 100644 db/import_salary_attendance.sql create mode 100644 server/src/lib/ai.ts create mode 100644 server/src/routes/smart-scheduling.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index b8f7184..f40faea 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/client/src/components/DataTable.tsx b/client/src/components/DataTable.tsx index 62bfa30..7a19308 100644 --- a/client/src/components/DataTable.tsx +++ b/client/src/components/DataTable.tsx @@ -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 (
@@ -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) => ( diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index ea98b56..ad1c5e3 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -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'] }, diff --git a/client/src/components/smart-scheduling/AttendanceAlertTab.tsx b/client/src/components/smart-scheduling/AttendanceAlertTab.tsx new file mode 100644 index 0000000..d404b5a --- /dev/null +++ b/client/src/components/smart-scheduling/AttendanceAlertTab.tsx @@ -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 = { + 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 + + return ( +
+
+
+

红色预警(旷工/出勤异常)

+

{redCount}

+
+
+

橙色预警(偏差大)

+

{orangeCount}

+
+
+

黄色预警(考勤扣款/出勤率低)

+

{yellowCount}

+
+
+ + +
+ + | + {summarySorts.map(s => ( + + ))} + | + +
+ 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) => 0 ? 'text-red-600 font-medium' : ''}>{r.total_absent_days || 0} }, + { key: 'absent_emp_count', label: '旷工人数', align: 'right', render: (r) => 0 ? 'text-red-600' : ''}>{r.absent_emp_count || 0} }, + { key: 'total_late_deduction', label: '迟到扣款', align: 'right', render: (r) => formatCurrency(r.total_late_deduction) }, + { key: 'punch_issue_count', label: '打卡异常人数', align: 'right', render: (r) => 5 ? 'text-orange-600' : ''}>{r.punch_issue_count || 0} }, + { key: 'low_attendance_rate_pct', label: '低出勤率%', align: 'right', render: (r) => 20 ? 'text-red-600 font-medium' : ''}>{r.low_attendance_rate_pct ? r.low_attendance_rate_pct + '%' : '0%'} }, + ]} + data={summaryPaged} + /> + +
+ + +
+ + + | + {alertSorts.map(s => ( + + ))} + | + +
+ r.salary_attend_days }, + { key: 'expected_attend', label: '应出勤天', align: 'right', render: (r) => r.expected_attend }, + { key: 'attend_diff', label: '偏差天', align: 'right', render: (r) => 5 ? 'text-red-600 font-medium' : ''}>{r.attend_diff} }, + { key: 'absent_days', label: '旷工天', align: 'right', render: (r) => 0 ? 'text-red-600 font-bold' : ''}>{r.absent_days || 0} }, + { 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) => {r.alert_type} }, + ]} + data={alertPaged} + /> + +
+
+ ) +} diff --git a/client/src/components/smart-scheduling/EfficiencyBenchmarkTab.tsx b/client/src/components/smart-scheduling/EfficiencyBenchmarkTab.tsx new file mode 100644 index 0000000..1e7a96c --- /dev/null +++ b/client/src/components/smart-scheduling/EfficiencyBenchmarkTab.tsx @@ -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 + + const sorts = [ + { key: 'revenue_per_emp', label: '人均创收' }, + { key: 'emp_count', label: '人数' }, + { key: 'gross_pay', label: '应发工资' }, + { key: 'wage_rate', label: '人力成本率' }, + { key: 'avg_hours', label: '人均工时' }, + ] + + return ( +
+ +
+ {sorts.map(s => ( + + ))} + | + +
+ formatNumber(r.emp_count) }, + { key: 'revenue', label: '营收', align: 'right', render: (r) => formatCurrency(r.revenue) }, + { key: 'revenue_per_emp', label: '人均创收', align: 'right', render: (r) => 50000 ? 'text-green-600 font-medium' : parseFloat(r.revenue_per_emp) < 20000 ? 'text-red-600' : ''}>{formatCurrency(r.revenue_per_emp)} }, + { 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) => 25 ? 'text-red-600 font-medium' : parseFloat(r.wage_rate) < 15 ? 'text-green-600' : ''}>{formatPercent(r.wage_rate)} }, + { 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) => 5 ? 'text-orange-600' : ''}>{formatPercent(r.overtime_rate)} }, + ]} + data={rows} + /> + +
+ + +
+ + | + {posSorts.map(s => ( + + ))} + | + +
+ formatNumber(r.total_emp) }, + { key: 'manager_count', label: '管理岗', align: 'right', render: (r) => formatNumber(r.manager_count) }, + { key: 'manager_pct', label: '管理占比%', align: 'right', render: (r) => 20 ? 'text-orange-600' : ''}>{r.manager_pct}% }, + { 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} + /> + +
+
+ ) +} diff --git a/client/src/components/smart-scheduling/EmployeeAnalysisTab.tsx b/client/src/components/smart-scheduling/EmployeeAnalysisTab.tsx new file mode 100644 index 0000000..832bb32 --- /dev/null +++ b/client/src/components/smart-scheduling/EmployeeAnalysisTab.tsx @@ -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 = { + '离职': '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 + + 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 ( +
+ +
+ + | + {sorts.map(s => ( + + ))} + | + +
+ {r.emp_status} }, + { 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) => {formatCurrency(r.net_pay)} }, + { key: 'effective_hourly_rate', label: '有效时薪', align: 'right', render: (r) => 100 ? 'text-green-600' : parseFloat(r.effective_hourly_rate) < 50 ? 'text-red-600' : ''}>{formatCurrency(r.effective_hourly_rate)} }, + { key: 'overtime_rate', label: '加班占比%', align: 'right', render: (r) => 5 ? 'text-orange-600' : ''}>{r.overtime_rate}% }, + ]} + data={rows} + /> + +
+ + +
+ {posSorts.map(s => ( + + ))} + | + +
+ 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) => {formatCurrency(r.min_gross)} }, + { key: 'max_gross', label: '最高应发', align: 'right', render: (r) => {formatCurrency(r.max_gross)} }, + { 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} + /> + +
+ + +
+ {turnoverSorts.map(s => ( + + ))} + | + +
+ formatNumber(r.total_emp) }, + { key: 'left_count', label: '离职人数', align: 'right', render: (r) => 0 ? 'text-red-600' : ''}>{r.left_count} }, + { key: 'turnover_rate', label: '离职率%', align: 'right', render: (r) => 15 ? 'text-red-600 font-medium' : parseFloat(r.turnover_rate) > 5 ? 'text-orange-600' : ''}>{r.turnover_rate ? r.turnover_rate + '%' : '0%'} }, + { key: 'new_count', label: '新员工数', align: 'right', render: (r) => {r.new_count} }, + { key: 'new_hire_rate', label: '新员工占比%', align: 'right', render: (r) => 20 ? 'text-orange-600' : ''}>{r.new_hire_rate ? r.new_hire_rate + '%' : '0%'} }, + ]} + data={turnoverPaged} + /> + +
+
+ ) +} diff --git a/client/src/components/smart-scheduling/OverallAnalysisTab.tsx b/client/src/components/smart-scheduling/OverallAnalysisTab.tsx new file mode 100644 index 0000000..0cf4419 --- /dev/null +++ b/client/src/components/smart-scheduling/OverallAnalysisTab.tsx @@ -0,0 +1,114 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { LoadingSpinner } from '@/components/LoadingSpinner' + +const LEVEL_STYLES: Record = { + 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 = { + '人效': '人效分析', + '成本': '成本管控', + '加班': '加班管理', + '客流': '客流规律', + '考勤': '考勤管理', + '离职': '人员稳定', +} + +export function OverallAnalysisTab() { + const { data, isLoading } = useQuery({ + queryKey: ['ss/overall-analysis'], + queryFn: () => api.get('/smart-scheduling/overall-analysis'), + }) + + if (isLoading) return + + 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 ( +
+ {/* KPI 卡片 */} +
+ {kpis.map((k) => ( +
+

{k.label}

+

+ {k.value} + {k.unit} +

+
+ ))} +
+ + {/* 预警汇总 */} +
+
+

红色预警

+

{redCount}

+
+
+

橙色预警

+

{orangeCount}

+
+
+

黄色提醒

+

{yellowCount}

+
+
+

良好表现

+

{greenCount}

+
+
+ + {/* 智能诊断建议 */} +
+
+

智能诊断建议

+ 基于人效、客流、考勤、离职等多维度数据自动生成 +
+ + {insights.length === 0 ? ( +
+ 暂无异常,各项指标正常 +
+ ) : ( +
+ {insights.map((ins, i) => { + const style = LEVEL_STYLES[ins.level] || LEVEL_STYLES.yellow + return ( +
+
+ {style.icon} +
+
+ + {CATEGORY_LABELS[ins.category] || ins.category} + +

{ins.title}

+
+

{ins.detail}

+
+ 💡 建议: +

{ins.suggestion}

+
+
+
+
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/client/src/components/smart-scheduling/SchedulingSuggestionTab.tsx b/client/src/components/smart-scheduling/SchedulingSuggestionTab.tsx new file mode 100644 index 0000000..650613e --- /dev/null +++ b/client/src/components/smart-scheduling/SchedulingSuggestionTab.tsx @@ -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 = { + '需增配': 'text-red-600 font-medium', + '可减配': 'text-blue-500 font-medium', + '配置合理': 'text-green-600', +} + +const ROW_BG: Record = { + '需增配': '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) => {formatNumber(r.p85_bills)} }, + { key: 'volatility', label: '波动系数', align: 'right' as const, render: (r: any) => 1 ? 'text-red-600 font-medium' : ''}>{r.volatility} }, + { 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) => {gapLabel(r.suggested_front, r.current_front)} }, + { 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) => {gapLabel(r.suggested_kitchen, r.current_kitchen)} }, + { 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) => {gapLabel(r.suggested_manager, r.current_other)} }, + { key: 'suggested_total', label: '建议合计', align: 'right' as const, render: (r: any) => {r.suggested_total} }, + { 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) => 0 ? 'text-red-600 font-bold' : parseFloat(r.staff_gap) < 0 ? 'text-blue-500 font-bold' : ''}>{parseFloat(r.staff_gap) > 0 ? '+' : ''}{r.staff_gap} }, + { key: 'action', label: '建议', render: (r: any) => {r.action} }, +] + +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 ( +
+ +
+
+ + +
+
+ + setFrontTarget(parseInt(e.target.value) || 15)} className="border rounded px-2 py-1 text-sm w-16" /> + 单/人/时 +
+
+ + setKitchenTarget(parseInt(e.target.value) || 25)} className="border rounded px-2 py-1 text-sm w-16" /> + 单/人/时 +
+
+ + {isLoading ? : ( + <> +
+
+

需增配时段

+

{increaseCount}

+
+
+

可减配时段

+

{decreaseCount}

+
+
+

配置合理时段

+

{okCount}

+
+
+ +
+
+

工作日排班建议

+ ROW_BG[r.action] || ''} /> +
+
+

周末排班建议

+ ROW_BG[r.action] || ''} /> +
+
+ + )} +
+
+ ) +} diff --git a/client/src/components/smart-scheduling/StaffingForecastTab.tsx b/client/src/components/smart-scheduling/StaffingForecastTab.tsx new file mode 100644 index 0000000..9e500a7 --- /dev/null +++ b/client/src/components/smart-scheduling/StaffingForecastTab.tsx @@ -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 = { + '建议招聘': '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 = { + 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(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 + + 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 ( +
+ {/* 汇总卡片 */} +
+
+

预测总数

+

{summary.total || 0}

+
+
+

建议招聘

+

{summary.hire || 0}

+
+
+

建议优化

+

{summary.optimize || 0}

+
+
+

需关注

+

{summary.watch || 0}

+
+
+ + {/* 规则引擎面板 */} +
+ + {showRules && result.ruleEngine && ( +
+ {/* 动态标准 */} +
+

动态标准值(基于全部门店中位数)

+
+
+

人均创收中位数

+

{formatCurrency(result.ruleEngine.standards.revenue_per_emp)}

+
+
+

工时产能中位数

+

{result.ruleEngine.standards.revenue_per_hour}元/h

+
+
+

人力成本率中位数

+

{result.ruleEngine.standards.wage_ratio}%

+
+
+
+
+ + + + + + + + + + + + + {Object.entries(result.ruleEngine.standards.roles || {}).map(([role, s]: [string, any]) => ( + + + + + + + + + + ))} + +
岗位占比标准均薪出勤标准工时标准最低出勤最大人数
{role}{s.role_ratio}%{formatCurrency(s.normal_pay)}{s.normal_attend}天{s.normal_hours}h{s.min_attend}天{s.max_count}人
+
+ + + {/* 决策逻辑 */} +
+

决策逻辑(4步推理)

+
+ {(result.ruleEngine.decisionLogic || []).map((step: any) => ( +
+ {step.step}. +
+ {step.name} + {step.desc} +
+
+ ))} +
+
+ + {/* 优化规则 */} +
+

优化信号规则(R1~R7,触发后抑制所有招聘信号)

+
+ {(result.ruleEngine.optimizationRules || []).map((r: any) => ( +
+ {r.id} +
+ {r.name} + {r.condition} +
+ +{r.score}分 +
+ ))} +
+
+ + {/* 招聘规则 */} +
+

招聘信号规则(R8~R14,仅无优化信号时触发)

+
+ {(result.ruleEngine.hireRules || []).map((r: any) => ( +
+ {r.id} +
+ {r.name} + {r.condition} + {r.constraint && ({r.constraint})} +
+ +{r.score}分 +
+ ))} +
+
+ + )} + + + {/* 筛选排序 */} +
+ + + | + {sorts.map(s => ( + + ))} + | + +
+ + {/* 预测表格 */} +
+ + + + + + + + + + + + + + + + + {paged.length === 0 ? ( + + ) : paged.map((r, i) => { + const key = rowKey(r) + const isExpanded = expandedRow === key + return ( + + setExpandedRow(isExpanded ? null : key)} + className={`border-t hover:bg-muted/30 cursor-pointer ${LEVEL_ROW_STYLES[r.action_level] || ''}`} + > + + + + + + + + + + + + {isExpanded && ( + + + + )} + + ) + })} + +
门店岗位建议动作紧急度人数人均创收工时产能人力成本率岗位占比离职率
暂无数据
{r.store_name}{r.role} + {r.action} + + = 70 ? 'text-red-600 font-bold' : r.urgency >= 40 ? 'text-orange-600 font-medium' : 'text-yellow-600'}>{r.urgency}% + {formatNumber(r.emp_count)} + 0 && r.revenue_per_emp < 15000 ? 'text-red-600' : ''}>{r.revenue_per_emp > 0 ? formatCurrency(r.revenue_per_emp) : '-'} + + 0 && r.revenue_per_hour < 200 ? 'text-red-600' : ''}>{r.revenue_per_hour > 0 ? `${r.revenue_per_hour}元/h` : '-'} + + 30 ? 'text-orange-600' : ''}>{r.wage_ratio > 0 ? `${r.wage_ratio}%` : '-'} + {r.role_emp_ratio > 0 ? `${r.role_emp_ratio}%` : '-'} 0 ? 'text-red-600' : ''}>{r.turnover_pct > 0 ? `${r.turnover_pct}%` : '-'}
+
+ {r.analysis && ( +
+ AI分析: + {r.analysis} +
+ )} + {r.hire_reasons && ( +
+ 招聘依据: + {r.hire_reasons || '无'} +
+ )} + {r.optimize_reasons && ( +
+ 优化依据: + {r.optimize_reasons || '无'} +
+ )} +
+

产能指标对比(实际 vs 标准)

+
+
+

人均创收

+

{r.revenue_per_emp > 0 ? formatCurrency(r.revenue_per_emp) : '-'} / 中位{formatCurrency(r.standards?.revenue_per_emp ?? 15000)}

+
+
+

工时产能

+

{r.revenue_per_hour > 0 ? `${r.revenue_per_hour}元/h` : '-'} / 中位{r.standards?.revenue_per_hour ?? 200}元/h

+
+
+

人力成本率

+

{r.wage_ratio > 0 ? `${r.wage_ratio}%` : '-'} / 中位{r.standards?.wage_ratio ?? 30}%

+
+
+

岗位占比

+

{r.role_emp_ratio > 0 ? `${r.role_emp_ratio}%` : '-'} / 标准{r.standards?.expected_role_ratio ?? '-'}%

+
+
+

出勤/工时

+

{r.avg_attend > 0 ? `${r.avg_attend}天/${r.avg_hours}h` : '-'} / 标准{r.standards?.normal_attend ?? 20}天/{r.standards?.normal_hours ?? 160}h

+
+
+
+
+
+

在职人数

+

{r.active_count ?? r.emp_count}人

+
+
+

当月离职/入职

+

{r.left_count} / {r.new_count}

+
+
+

岗位月薪资

+

{formatCurrency(r.total_pay ?? r.avg_pay * r.emp_count)}

+
+
+

均薪/月

+

{formatCurrency(r.avg_pay)} / 均值{formatCurrency(r.standards?.normal_pay ?? 0)}

+
+
+ {r.suggestion && ( +
+

AI建议:

+

{r.suggestion}

+
+ )} +
+
+
+ + + ) +} diff --git a/client/src/components/smart-scheduling/StaffingMatchTab.tsx b/client/src/components/smart-scheduling/StaffingMatchTab.tsx new file mode 100644 index 0000000..dd82981 --- /dev/null +++ b/client/src/components/smart-scheduling/StaffingMatchTab.tsx @@ -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 = { + '严重不足': '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 ( +
+ +
+ + +
+ + {isLoading ? : ( + <> +
+ {Object.entries(STATUS_COLORS).map(([status, color]) => ( +
+ + {status} +
+ ))} +
+ +
+ + + + + + + + + + + + + + {rows.map((r: any) => ( + + + + + + + + + + ))} + +
时段在岗人数账单量人均接待客流条人力条匹配状态
{r.hour}:00 - {r.hour + 1}:00{r.avg_staff}{formatNumber(r.bills)}{r.bills_per_staff || '-'} +
+
+
+
+
+
+
+
+ {r.match_status} +
+
+ +
+
+

严重不足时段

+

{rows.filter((r: any) => r.match_status === '严重不足').length}

+
+
+

偏紧时段

+

{rows.filter((r: any) => r.match_status === '偏紧').length}

+
+
+

过剩/偏松时段

+

{rows.filter((r: any) => r.match_status === '过剩' || r.match_status === '偏松').length}

+
+
+

合理时段

+

{rows.filter((r: any) => r.match_status === '合理').length}

+
+
+ + )} +
+
+ ) +} diff --git a/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx b/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx new file mode 100644 index 0000000..e659fcc --- /dev/null +++ b/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx @@ -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> = {} + 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> = {} + 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 ( +
+ +
+ + 颜色越深客流越大 +
+ {isLoading ? : ( +
+ + + + + {hours.map(h => )} + + + + {Object.keys(heatMap).map(store => ( + + + {hours.map(h => { + const bills = heatMap[store]?.[h] || 0 + return + })} + + ))} + +
门店{h}
{store}{bills > 0 ? bills : ''}
+
+ )} +
+ + +
+ + | + {overviewSorts.map(s => ( + + ))} + | + +
+ 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) => 10 ? 'text-red-600 font-medium' : ''}>{r.peak_valley_ratio} }, + { key: 'peak_concentration_pct', label: '高峰集中度%', align: 'right', render: (r) => 60 ? 'text-orange-600 font-medium' : ''}>{r.peak_concentration_pct}% }, + { key: 'volatility_level', label: '波动等级', render: (r) => {r.volatility_level} }, + ]} + data={overviewPaged} + /> + +
+ + +
+ + | + {mealSorts.map(s => ( + + ))} + | + +
+ 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} + /> + +
+
+ ) +} diff --git a/client/src/pages/SmartSchedulingPage.tsx b/client/src/pages/SmartSchedulingPage.tsx new file mode 100644 index 0000000..fd4ba54 --- /dev/null +++ b/client/src/pages/SmartSchedulingPage.tsx @@ -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 ( +
+
+

智能排班分析

+

2026年4月 · 客流规律 × 排班匹配 × 人效优化 · 103家门店

+
+ + + +
+ {activeTab === 'overall' && } + {activeTab === 'forecast' && } + {activeTab === 'traffic' && } + {activeTab === 'match' && } + {activeTab === 'efficiency' && } + {activeTab === 'suggestion' && } + {activeTab === 'alert' && } + {activeTab === 'employee' && } +
+
+ ) +} diff --git a/db/import_salary_attendance.py b/db/import_salary_attendance.py new file mode 100644 index 0000000..d00917d --- /dev/null +++ b/db/import_salary_attendance.py @@ -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() diff --git a/db/import_salary_attendance.sql b/db/import_salary_attendance.sql new file mode 100644 index 0000000..80621cb --- /dev/null +++ b/db/import_salary_attendance.sql @@ -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); diff --git a/server/src/index.ts b/server/src/index.ts index 0e77813..9c4790b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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) diff --git a/server/src/lib/ai.ts b/server/src/lib/ai.ts new file mode 100644 index 0000000..7545a76 --- /dev/null +++ b/server/src/lib/ai.ts @@ -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 { + 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 { + 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 + } + } +} diff --git a/server/src/routes/smart-scheduling.ts b/server/src/routes/smart-scheduling.ts new file mode 100644 index 0000000..660a0b0 --- /dev/null +++ b/server/src/routes/smart-scheduling.ts @@ -0,0 +1,1485 @@ +import { Router } from 'express' +import { query } from '../config/database.js' +import { sendSuccess, sendError, parsePagination } from '../middleware/error.js' +import type { AuthRequest } from '../middleware/auth.js' + +const router = Router() + +// ============ Tab1: 客流热力图 ============ + +// 门店×小时客流分布 +router.get('/traffic-heatmap', async (req: AuthRequest, res) => { + try { + const storeName = req.query.store as string + let where = '' + const params: any[] = [] + if (storeName) { + params.push(storeName) + where = `WHERE store_name = $${params.length}` + } + const result = await query(` + SELECT store_name, + hour, + sum(bills) AS bills, + round(avg(avg_guests), 1) AS avg_guests, + round(sum(total_guests), 0) AS total_guests + FROM mv_bill_hourly + ${where} + GROUP BY store_name, hour + ORDER BY store_name, hour + `, params) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 餐段客流分布 +router.get('/meal-period-traffic', async (req: AuthRequest, res) => { + try { + const storeName = req.query.store as string + let where = "WHERE meal_period != ''" + const params: any[] = [] + if (storeName) { + params.push(storeName) + where += ` AND store_name = $${params.length}` + } + const result = await query(` + SELECT store_name, meal_period, + sum(bills) AS bills, + round(sum(total_guests), 0) AS total_guests, + round(avg(avg_guests), 1) AS avg_guests_per_bill + FROM mv_bill_hourly + ${where} + GROUP BY store_name, meal_period + ORDER BY store_name, bills DESC + `, params) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 工作日vs周末客流 +router.get('/dow-traffic', async (req: AuthRequest, res) => { + try { + const storeName = req.query.store as string + let where = "WHERE c175 IS NOT NULL AND c175 != '' AND c175 >= '2026/04/01' AND c175 < '2026/05/01'" + const params: any[] = [] + if (storeName) { + params.push(storeName) + where += ` AND c003 = $${params.length}` + } + const result = await query(` + SELECT c003 AS store_name, + extract(dow FROM c175::timestamp)::int AS dow, + extract(hour FROM c175::timestamp)::int AS hour, + count(*) AS bills + FROM bill_records + ${where} + GROUP BY c003, dow, hour + ORDER BY c003, dow, hour + `, params) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 门店客流概览(峰谷比、集中度) +router.get('/traffic-overview', async (req: AuthRequest, res) => { + try { + const result = await query(` + WITH hourly AS ( + SELECT store_name, + hour, + sum(bills) AS bills + FROM mv_bill_hourly + GROUP BY store_name, hour + ), + store_stats AS ( + SELECT store_name, + max(bills) AS peak_bills, + min(bills) AS min_bills, + sum(bills) AS total_bills, + round(max(bills)::numeric / nullif(min(bills), 0), 2) AS peak_valley_ratio, + round(sum(bills) FILTER (WHERE hour IN (11, 12, 17, 18, 19))::numeric / nullif(sum(bills), 0) * 100, 2) AS peak_concentration_pct + FROM hourly + GROUP BY store_name + ) + SELECT store_name, + total_bills, + peak_bills, + min_bills, + peak_valley_ratio, + peak_concentration_pct, + CASE + WHEN peak_valley_ratio > 20 THEN '波动极大' + WHEN peak_valley_ratio > 10 THEN '波动较大' + WHEN peak_valley_ratio > 5 THEN '波动适中' + ELSE '波动较小' + END AS volatility_level + FROM store_stats + ORDER BY total_bills DESC + `) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// ============ Tab2: 排班匹配度 ============ + +// 时段在岗人数 vs 客流匹配度 +router.get('/staffing-match', async (req: AuthRequest, res) => { + try { + const storeName = req.query.store as string || '七里庄店' + const result = await query(` + WITH punch_times AS ( + SELECT employee_code, d.day_num, d.day_val + FROM attendance_records ar + CROSS JOIN LATERAL ( + SELECT 1 AS day_num, ar.day_01 AS day_val UNION ALL + SELECT 2, ar.day_02 UNION ALL + SELECT 3, ar.day_03 UNION ALL + SELECT 4, ar.day_04 UNION ALL + SELECT 5, ar.day_05 UNION ALL + SELECT 6, ar.day_06 UNION ALL + SELECT 7, ar.day_07 UNION ALL + SELECT 8, ar.day_08 UNION ALL + SELECT 9, ar.day_09 UNION ALL + SELECT 10, ar.day_10 UNION ALL + SELECT 11, ar.day_11 UNION ALL + SELECT 12, ar.day_12 UNION ALL + SELECT 13, ar.day_13 UNION ALL + SELECT 14, ar.day_14 UNION ALL + SELECT 15, ar.day_15 UNION ALL + SELECT 16, ar.day_16 UNION ALL + SELECT 17, ar.day_17 UNION ALL + SELECT 18, ar.day_18 UNION ALL + SELECT 19, ar.day_19 UNION ALL + SELECT 20, ar.day_20 UNION ALL + SELECT 21, ar.day_21 UNION ALL + SELECT 22, ar.day_22 UNION ALL + SELECT 23, ar.day_23 UNION ALL + SELECT 24, ar.day_24 UNION ALL + SELECT 25, ar.day_25 UNION ALL + SELECT 26, ar.day_26 UNION ALL + SELECT 27, ar.day_27 UNION ALL + SELECT 28, ar.day_28 UNION ALL + SELECT 29, ar.day_29 UNION ALL + SELECT 30, ar.day_30 + ) d + WHERE ar.department LIKE '%' || replace($1, '总', '') || '%' + AND d.day_val IS NOT NULL AND d.day_val != '' + ), + all_punches AS ( + SELECT employee_code, day_num, + (regexp_matches(day_val, '(\\d{2}:\\d{2})', 'g'))[1]::time AS punch_time + FROM punch_times + ), + daily_range AS ( + SELECT employee_code, day_num, + min(punch_time) AS first_punch, + max(punch_time) AS last_punch + FROM all_punches + GROUP BY employee_code, day_num + ), + hourly_staff AS ( + SELECT h.hour, d.day_num, count(DISTINCT d.employee_code) AS staff_on_duty + FROM generate_series(6, 23) AS h(hour) + CROSS JOIN daily_range d + WHERE d.first_punch <= (h.hour || ':00')::time + AND d.last_punch >= (h.hour || ':00')::time + GROUP BY h.hour, d.day_num + ), + hourly_staff_avg AS ( + SELECT hour, round(avg(staff_on_duty), 1) AS avg_staff + FROM hourly_staff + GROUP BY hour + ), + hourly_bills AS ( + SELECT extract(hour FROM c175::timestamp)::int AS hour, + count(*) AS bills, + round(sum(c178::numeric), 0) AS total_guests + FROM bill_records + WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != '' + AND c175::timestamp >= '2026-04-01' AND c175::timestamp < '2026-05-01' + GROUP BY hour + ) + SELECT s.hour, + s.avg_staff, + COALESCE(b.bills, 0) AS bills, + COALESCE(b.total_guests, 0) AS total_guests, + round(COALESCE(b.bills, 0) / nullif(s.avg_staff, 0), 1) AS bills_per_staff, + CASE + WHEN COALESCE(b.bills, 0) / nullif(s.avg_staff, 0) > 200 THEN '严重不足' + WHEN COALESCE(b.bills, 0) / nullif(s.avg_staff, 0) > 100 THEN '偏紧' + WHEN COALESCE(b.bills, 0) / nullif(s.avg_staff, 0) < 30 THEN '过剩' + WHEN COALESCE(b.bills, 0) / nullif(s.avg_staff, 0) < 50 THEN '偏松' + ELSE '合理' + END AS match_status + FROM hourly_staff_avg s + LEFT JOIN hourly_bills b ON s.hour = b.hour + ORDER BY s.hour + `, [storeName]) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 门店列表(从账单表获取,确保与客流查询一致) +router.get('/stores', async (req: AuthRequest, res) => { + try { + const result = await query(` + SELECT DISTINCT c003 AS store_name + FROM bill_records + WHERE c003 IS NOT NULL AND c003 != '' + ORDER BY store_name + `) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// ============ Tab3: 人效对标 ============ + +// 门店人效排名 +router.get('/efficiency-ranking', async (req: AuthRequest, res) => { + try { + const { page, pageSize, offset } = parsePagination(req) + const sort = (req.query.sort as string) || 'revenue_per_emp' + const order = (req.query.order as string) || 'desc' + + const validSorts: Record = { + revenue_per_emp: 'revenue_per_emp', + emp_count: 'emp_count', + gross_pay: 'gross_pay', + net_pay: 'net_pay', + avg_hours: 'avg_hours', + wage_rate: 'wage_rate', + } + const sortField = validSorts[sort] || 'revenue_per_emp' + const sortOrder = order === 'asc' ? 'ASC' : 'DESC' + + const countResult = await query(` + SELECT count(*) FROM ( + SELECT s.org_level5 AS store_name + FROM salary_detail_records s + WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != '' + GROUP BY s.org_level5 + ) t + `) + const total = countResult.rows[0].count + + const result = await query(` + WITH salary_stats AS ( + SELECT org_level5 AS store_name, + count(*) AS emp_count, + round(sum(gross_pay)::numeric, 2) AS gross_pay, + round(sum(net_pay)::numeric, 2) AS net_pay, + round(sum(actual_hours)::numeric, 0) AS total_hours, + round(avg(actual_hours)::numeric, 0) AS avg_hours, + round(sum(overtime_pay)::numeric, 2) AS overtime_pay, + round(sum(perf_amount)::numeric, 2) AS perf_amount, + round(sum(base_wage)::numeric, 2) AS base_wage + FROM salary_detail_records + WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' + GROUP BY org_level5 + ), + revenue_stats AS ( + SELECT COALESCE(m.salary_name, r.store_name) AS store_name, + r.revenue, + r.bill_count + FROM mv_store_revenue r + LEFT JOIN store_name_mapping m ON m.bill_name = r.store_name + ) + SELECT s.store_name, + s.emp_count, + s.gross_pay, + s.net_pay, + s.total_hours, + s.avg_hours, + s.overtime_pay, + s.perf_amount, + s.base_wage, + COALESCE(r.revenue, 0) AS revenue, + COALESCE(r.bill_count, 0) AS bill_count, + round(COALESCE(r.revenue, 0) / nullif(s.emp_count, 0), 2) AS revenue_per_emp, + round(s.gross_pay / nullif(s.emp_count, 0), 2) AS cost_per_emp, + round(s.gross_pay / nullif(r.revenue, 0) * 100, 2) AS wage_rate, + round(COALESCE(r.revenue, 0) / nullif(s.total_hours, 0), 2) AS revenue_per_hour, + round(s.overtime_pay / nullif(s.gross_pay, 0) * 100, 2) AS overtime_rate, + round(s.perf_amount / nullif(s.gross_pay, 0) * 100, 2) AS perf_rate, + round(s.base_wage / nullif(s.gross_pay, 0) * 100, 2) AS base_wage_rate + FROM salary_stats s + LEFT JOIN revenue_stats r ON s.store_name = r.store_name + ORDER BY ${sortField} ${sortOrder} + LIMIT $1 OFFSET $2 + `, [pageSize, offset]) + sendSuccess(res, result.rows, { page, pageSize, total }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 岗位配比分析 +router.get('/position-distribution', async (req: AuthRequest, res) => { + try { + const result = await query(` + SELECT org_level5 AS store_name, + count(*) AS total_emp, + count(*) FILTER (WHERE position LIKE '%店长%' OR position LIKE '%经理%') AS manager_count, + count(*) FILTER (WHERE position LIKE '%厨%' OR position LIKE '%拉面%' OR position LIKE '%配菜%' OR position LIKE '%烧烤%' OR position LIKE '%凉菜%') AS kitchen_count, + count(*) FILTER (WHERE position LIKE '%服务%' OR position LIKE '%前厅%' OR position LIKE '%收银%') AS front_count, + count(*) FILTER (WHERE position LIKE '%兼职%') AS part_time_count, + round(count(*) FILTER (WHERE position LIKE '%店长%' OR position LIKE '%经理%')::numeric / nullif(count(*), 0) * 100, 1) AS manager_pct, + round(count(*) FILTER (WHERE position LIKE '%厨%' OR position LIKE '%拉面%' OR position LIKE '%配菜%' OR position LIKE '%烧烤%' OR position LIKE '%凉菜%')::numeric / nullif(count(*), 0) * 100, 1) AS kitchen_pct, + round(count(*) FILTER (WHERE position LIKE '%服务%' OR position LIKE '%前厅%' OR position LIKE '%收银%')::numeric / nullif(count(*), 0) * 100, 1) AS front_pct + FROM salary_detail_records + WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' + GROUP BY org_level5 + ORDER BY total_emp DESC + `) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// ============ Tab4: 排班建议 ============ + +// 排班建议(基于历史客流规律,分岗位) +router.get('/scheduling-suggestion', async (req: AuthRequest, res) => { + try { + const storeName = req.query.store as string || '七里庄店' + const frontTarget = parseInt(req.query.front_target as string) || 15 + const kitchenTarget = parseInt(req.query.kitchen_target as string) || 25 + + const result = await query(` + WITH daily_hourly AS ( + SELECT extract(hour FROM c175::timestamp)::int AS hour, + CASE WHEN extract(dow FROM c175::timestamp)::int IN (0, 6) THEN '周末' ELSE '工作日' END AS day_type, + DATE(c175::timestamp) AS bill_date, + count(*) AS bills + FROM bill_records + WHERE c003 = $1 AND c175 IS NOT NULL AND c175 != '' + AND c175 >= '2026/04/01' AND c175 < '2026/05/01' + GROUP BY hour, day_type, bill_date + ), + hourly_stats AS ( + SELECT hour, day_type, + round(avg(bills)::numeric, 0) AS avg_daily_bills, + round(percentile_cont(0.85) WITHIN GROUP (ORDER BY bills)::numeric, 0) AS p85_bills, + round((max(bills) - min(bills))::numeric / nullif(avg(bills), 0), 2) AS volatility + FROM daily_hourly + GROUP BY hour, day_type + ), + punch_times AS ( + SELECT employee_code, position, d.day_num, d.day_val + FROM attendance_records ar + CROSS JOIN LATERAL ( + SELECT 1 AS day_num, ar.day_01 AS day_val UNION ALL + SELECT 2, ar.day_02 UNION ALL + SELECT 3, ar.day_03 UNION ALL + SELECT 4, ar.day_04 UNION ALL + SELECT 5, ar.day_05 UNION ALL + SELECT 6, ar.day_06 UNION ALL + SELECT 7, ar.day_07 UNION ALL + SELECT 8, ar.day_08 UNION ALL + SELECT 9, ar.day_09 UNION ALL + SELECT 10, ar.day_10 UNION ALL + SELECT 11, ar.day_11 UNION ALL + SELECT 12, ar.day_12 UNION ALL + SELECT 13, ar.day_13 UNION ALL + SELECT 14, ar.day_14 UNION ALL + SELECT 15, ar.day_15 UNION ALL + SELECT 16, ar.day_16 UNION ALL + SELECT 17, ar.day_17 UNION ALL + SELECT 18, ar.day_18 UNION ALL + SELECT 19, ar.day_19 UNION ALL + SELECT 20, ar.day_20 UNION ALL + SELECT 21, ar.day_21 UNION ALL + SELECT 22, ar.day_22 UNION ALL + SELECT 23, ar.day_23 UNION ALL + SELECT 24, ar.day_24 UNION ALL + SELECT 25, ar.day_25 UNION ALL + SELECT 26, ar.day_26 UNION ALL + SELECT 27, ar.day_27 UNION ALL + SELECT 28, ar.day_28 UNION ALL + SELECT 29, ar.day_29 UNION ALL + SELECT 30, ar.day_30 + ) d + WHERE ar.department LIKE '%' || replace($1, '总', '') || '%' + AND d.day_val IS NOT NULL AND d.day_val != '' + ), + all_punches AS ( + SELECT employee_code, position, day_num, + (regexp_matches(day_val, '(\\d{2}:\\d{2})', 'g'))[1]::time AS punch_time + FROM punch_times + ), + daily_range AS ( + SELECT employee_code, position, day_num, + min(punch_time) AS first_punch, + max(punch_time) AS last_punch + FROM all_punches + GROUP BY employee_code, position, day_num + ), + role_classify AS ( + SELECT employee_code, day_num, first_punch, last_punch, + CASE + WHEN position LIKE '%店长%' OR position LIKE '%经理%' OR position LIKE '储备店长%' THEN '管理' + WHEN position LIKE '%服务员%' THEN '前厅服务' + WHEN position LIKE '%厨师%' OR position LIKE '%厨工%' OR position LIKE '%拉面师%' + OR position LIKE '%配菜师%' OR position LIKE '%凉菜师%' OR position LIKE '%烧烤师%' THEN '后厨' + ELSE '其他' + END AS role + FROM daily_range + ), + hourly_staff AS ( + SELECT h.hour, rc.day_num, rc.role, count(DISTINCT rc.employee_code) AS staff_on_duty + FROM generate_series(6, 23) AS h(hour) + CROSS JOIN role_classify rc + WHERE rc.first_punch <= (h.hour || ':00')::time + AND rc.last_punch >= (h.hour || ':00')::time + GROUP BY h.hour, rc.day_num, rc.role + ), + current_staff AS ( + SELECT hour, role, round(avg(staff_on_duty))::int AS avg_staff + FROM hourly_staff + GROUP BY hour, role + ), + current_other AS ( + SELECT hour, round(sum(avg_staff))::int AS avg_staff + FROM current_staff + WHERE role IN ('管理', '其他') + GROUP BY hour + ), + current_total AS ( + SELECT hour, round(sum(avg_staff))::int AS avg_staff + FROM current_staff + GROUP BY hour + ) + SELECT hs.hour, + hs.day_type, + hs.avg_daily_bills, + hs.p85_bills, + hs.volatility, + GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) AS suggested_front, + GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) AS suggested_kitchen, + CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END AS suggested_manager, + (GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + + GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + + CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) AS suggested_total, + COALESCE(cs_front.avg_staff, 0) AS current_front, + COALESCE(cs_kitchen.avg_staff, 0) AS current_kitchen, + COALESCE(co.avg_staff, 0) AS current_other, + COALESCE(ct.avg_staff, 0) AS current_total, + (GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + + GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + + CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END + - COALESCE(ct.avg_staff, 0)) AS staff_gap, + CASE + WHEN (GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + + GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + + CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END + - COALESCE(ct.avg_staff, 0)) > 2 THEN '需增配' + WHEN (GREATEST(ceil(hs.p85_bills / $2), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + + GREATEST(ceil(hs.p85_bills / $3), CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END) + + CASE WHEN hs.hour BETWEEN 10 AND 22 THEN 1 ELSE 0 END + - COALESCE(ct.avg_staff, 0)) < -2 THEN '可减配' + ELSE '配置合理' + END AS action + FROM hourly_stats hs + LEFT JOIN current_staff cs_front ON hs.hour = cs_front.hour AND cs_front.role = '前厅服务' + LEFT JOIN current_staff cs_kitchen ON hs.hour = cs_kitchen.hour AND cs_kitchen.role = '后厨' + LEFT JOIN current_other co ON hs.hour = co.hour + LEFT JOIN current_total ct ON hs.hour = ct.hour + ORDER BY hs.hour, hs.day_type + `, [storeName, frontTarget, kitchenTarget]) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// ============ Tab5: 考勤预警 ============ + +// 考勤异常预警 +router.get('/attendance-alert', async (req: AuthRequest, res) => { + try { + const result = await query(` + SELECT s.org_level5 AS store_name, + s.employee_code, + s.position, + s.actual_attend AS salary_attend_days, + s.expected_attend, + round(abs(s.actual_attend - COALESCE(a.punch_days, 0))::numeric, 1) AS attend_diff, + s.absent_days, + s.absent_deduction, + s.late_deduction, + s.no_punch_deduction, + s.personal_leave_days, + s.actual_hours, + CASE + WHEN s.absent_days > 0 THEN '旷工' + WHEN s.actual_attend = 0 AND COALESCE(a.punch_days, 0) > 0 THEN '薪资出勤为零但有打卡' + WHEN abs(s.actual_attend - COALESCE(a.punch_days, 0)) > 5 THEN '出勤天数偏差大' + WHEN s.late_deduction > 0 OR s.no_punch_deduction > 0 THEN '考勤扣款' + WHEN s.actual_attend / nullif(s.expected_attend, 0) < 0.8 THEN '出勤率低' + ELSE NULL + END AS alert_type, + CASE + WHEN s.absent_days > 0 THEN 'red' + WHEN s.actual_attend = 0 AND COALESCE(a.punch_days, 0) > 0 THEN 'red' + WHEN abs(s.actual_attend - COALESCE(a.punch_days, 0)) > 5 THEN 'orange' + WHEN s.late_deduction > 0 OR s.no_punch_deduction > 0 THEN 'yellow' + WHEN s.actual_attend / nullif(s.expected_attend, 0) < 0.8 THEN 'yellow' + ELSE NULL + END AS alert_level + FROM salary_detail_records s + LEFT JOIN LATERAL ( + SELECT count(*) AS punch_days + FROM attendance_records ar + CROSS JOIN LATERAL unnest(ARRAY[ + ar.day_01, ar.day_02, ar.day_03, ar.day_04, ar.day_05, + ar.day_06, ar.day_07, ar.day_08, ar.day_09, ar.day_10, + ar.day_11, ar.day_12, ar.day_13, ar.day_14, ar.day_15, + ar.day_16, ar.day_17, ar.day_18, ar.day_19, ar.day_20, + ar.day_21, ar.day_22, ar.day_23, ar.day_24, ar.day_25, + ar.day_26, ar.day_27, ar.day_28, ar.day_29, ar.day_30 + ]) AS d(day_val) + WHERE ar.employee_code = s.employee_code + AND day_val IS NOT NULL AND day_val != '' + ) a ON true + WHERE s.org_level2 = '西部马华品牌门店' + AND s.org_level5 IS NOT NULL AND s.org_level5 != '' + AND ( + s.absent_days > 0 + OR (s.actual_attend = 0 AND COALESCE(a.punch_days, 0) > 0) + OR abs(s.actual_attend - COALESCE(a.punch_days, 0)) > 5 + OR s.late_deduction > 0 + OR s.no_punch_deduction > 0 + OR s.actual_attend / nullif(s.expected_attend, 0) < 0.8 + ) + ORDER BY + CASE WHEN s.absent_days > 0 THEN 0 + WHEN s.actual_attend = 0 AND COALESCE(a.punch_days, 0) > 0 THEN 1 + WHEN abs(s.actual_attend - COALESCE(a.punch_days, 0)) > 5 THEN 2 + ELSE 3 END, + s.org_level5, s.employee_code + LIMIT 200 + `) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 门店考勤汇总 +router.get('/attendance-summary', async (req: AuthRequest, res) => { + try { + const result = await query(` + SELECT s.org_level5 AS store_name, + count(*) AS emp_count, + round(avg(s.actual_attend)::numeric, 1) AS avg_attend_days, + round(avg(s.actual_hours)::numeric, 0) AS avg_hours, + round(sum(s.absent_days)::numeric, 0) AS total_absent_days, + round(sum(s.absent_deduction)::numeric, 2) AS total_absent_deduction, + round(sum(s.late_deduction)::numeric, 2) AS total_late_deduction, + round(sum(s.no_punch_deduction)::numeric, 2) AS total_no_punch_deduction, + count(*) FILTER (WHERE s.absent_days > 0) AS absent_emp_count, + count(*) FILTER (WHERE s.late_deduction > 0 OR s.no_punch_deduction > 0) AS punch_issue_count, + round(count(*) FILTER (WHERE s.actual_attend / nullif(s.expected_attend, 0) < 0.8)::numeric / nullif(count(*), 0) * 100, 1) AS low_attendance_rate_pct + FROM salary_detail_records s + WHERE s.org_level2 = '西部马华品牌门店' AND s.org_level5 IS NOT NULL AND s.org_level5 != '' + GROUP BY s.org_level5 + ORDER BY total_absent_days DESC NULLS LAST, total_late_deduction DESC NULLS LAST + `) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// ============ Tab6: 员工分析 ============ + +// 员工薪资分析 +router.get('/employee-analysis', async (req: AuthRequest, res) => { + try { + const { page, pageSize, offset } = parsePagination(req) + const sort = (req.query.sort as string) || 'gross_pay' + const order = (req.query.order as string) || 'desc' + const storeName = req.query.store as string + + const validSorts: Record = { + gross_pay: 'gross_pay', + net_pay: 'net_pay', + actual_hours: 'actual_hours', + perf_amount: 'perf_amount', + overtime_pay: 'overtime_pay', + hourly_rate: 'hourly_rate', + } + const sortField = validSorts[sort] || 'gross_pay' + const sortOrder = order === 'asc' ? 'ASC' : 'DESC' + + let where = "WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != ''" + const params: any[] = [] + if (storeName) { + params.push(storeName) + where += ` AND org_level5 = $${params.length}` + } + + const countResult = await query(`SELECT count(*) FROM salary_detail_records ${where}`, params) + const total = countResult.rows[0].count + + params.push(pageSize, offset) + const result = await query(` + SELECT employee_code, + org_level5 AS store_name, + position, + employment_type, + hire_date, + leave_date, + salary_period, + round(base_wage::numeric, 2) AS base_wage, + round(overtime_pay::numeric, 2) AS overtime_pay, + round(perf_amount::numeric, 2) AS perf_amount, + round(perf_score::numeric, 2) AS perf_score, + round(hourly_rate::numeric, 2) AS hourly_rate, + round(gross_pay::numeric, 2) AS gross_pay, + round(net_pay::numeric, 2) AS net_pay, + round(actual_attend::numeric, 1) AS attend_days, + round(actual_hours::numeric, 0) AS work_hours, + round(overtime_pay / nullif(gross_pay, 0) * 100, 2) AS overtime_rate, + round(perf_amount / nullif(gross_pay, 0) * 100, 2) AS perf_rate, + round(gross_pay / nullif(actual_hours, 0), 2) AS effective_hourly_rate, + CASE + WHEN leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01' THEN '离职' + WHEN hire_date IS NOT NULL AND hire_date != '' AND hire_date >= '2026-04-01' THEN '新员工' + ELSE '在职' + END AS emp_status + FROM salary_detail_records + ${where} + ORDER BY ${sortField} ${sortOrder} + LIMIT $${params.length - 1} OFFSET $${params.length} + `, params) + sendSuccess(res, result.rows, { page, pageSize, total }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 岗位薪资对比 +router.get('/position-salary-compare', async (req: AuthRequest, res) => { + try { + const result = await query(` + SELECT position, + count(*) AS emp_count, + count(DISTINCT org_level5) AS store_count, + round(avg(gross_pay)::numeric, 2) AS avg_gross, + round(min(gross_pay)::numeric, 2) AS min_gross, + round(max(gross_pay)::numeric, 2) AS max_gross, + round(avg(net_pay)::numeric, 2) AS avg_net, + round(avg(actual_hours)::numeric, 0) AS avg_hours, + round(avg(perf_score)::numeric, 2) AS avg_perf_score, + round(avg(gross_pay / nullif(actual_hours, 0))::numeric, 2) AS avg_hourly_rate + FROM salary_detail_records + WHERE org_level2 = '西部马华品牌门店' + AND position IS NOT NULL AND position != '' + GROUP BY position + HAVING count(*) >= 5 + ORDER BY avg_gross DESC + `) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// 离职率统计 +router.get('/turnover-stats', async (req: AuthRequest, res) => { + try { + const result = await query(` + SELECT org_level5 AS store_name, + count(*) AS total_emp, + count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count, + count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count, + round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate, + round(count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS new_hire_rate + FROM salary_detail_records + WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' + GROUP BY org_level5 + ORDER BY turnover_rate DESC NULLS LAST + `) + sendSuccess(res, result.rows) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// ============ 总体智能分析 ============ + +router.get('/overall-analysis', async (req: AuthRequest, res) => { + try { + const [efficiency, attendance, turnover, trafficOverview, mealPeriod] = await Promise.all([ + query(` + WITH salary_stats AS ( + SELECT org_level5 AS store_name, + count(*) AS emp_count, + round(sum(gross_pay)::numeric, 2) AS gross_pay, + round(avg(actual_hours)::numeric, 0) AS avg_hours, + round(sum(overtime_pay)::numeric, 2) AS overtime_pay + FROM salary_detail_records + WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' + GROUP BY org_level5 + ), + revenue_stats AS ( + SELECT COALESCE(m.salary_name, r.store_name) AS store_name, r.revenue, r.bill_count + FROM mv_store_revenue r + LEFT JOIN store_name_mapping m ON m.bill_name = r.store_name + ) + SELECT s.store_name, + s.emp_count, s.gross_pay, s.avg_hours, s.overtime_pay, + COALESCE(r.revenue, 0) AS revenue, + round(COALESCE(r.revenue, 0) / nullif(s.emp_count, 0), 2) AS revenue_per_emp, + round(s.gross_pay / nullif(COALESCE(r.revenue, 0), 0) * 100, 2) AS wage_rate, + round(s.overtime_pay / nullif(s.gross_pay, 0) * 100, 2) AS overtime_rate + FROM salary_stats s + LEFT JOIN revenue_stats r ON s.store_name = r.store_name + `), + query(` + SELECT org_level5 AS store_name, + count(*) AS emp_count, + round(avg(actual_attend)::numeric, 1) AS avg_attend_days, + round(avg(actual_hours)::numeric, 0) AS avg_hours, + sum(absent_days) AS total_absent, + sum(late_deduction + no_punch_deduction) AS total_deduction, + count(*) FILTER (WHERE absent_days > 0) AS absent_emp + FROM salary_detail_records + WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' + GROUP BY org_level5 + `), + query(` + SELECT org_level5 AS store_name, + count(*) AS total_emp, + count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count, + count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count, + round(count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01')::numeric / nullif(count(*), 0) * 100, 2) AS turnover_rate + FROM salary_detail_records + WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' + GROUP BY org_level5 + `), + query(` + WITH hourly AS ( + SELECT store_name, hour, sum(bills) AS bills + FROM mv_bill_hourly GROUP BY store_name, hour + ), + store_stats AS ( + SELECT store_name, + max(bills) AS peak_bills, min(bills) AS min_bills, sum(bills) AS total_bills, + round(max(bills)::numeric / nullif(min(bills), 0), 2) AS peak_valley_ratio, + round(sum(bills) FILTER (WHERE hour IN (11, 12, 17, 18, 19))::numeric / nullif(sum(bills), 0) * 100, 2) AS peak_concentration_pct + FROM hourly GROUP BY store_name + ) + SELECT store_name, total_bills, peak_bills, min_bills, peak_valley_ratio, peak_concentration_pct, + CASE WHEN peak_valley_ratio > 20 THEN '波动极大' WHEN peak_valley_ratio > 10 THEN '波动较大' WHEN peak_valley_ratio > 5 THEN '波动适中' ELSE '波动较小' END AS volatility_level + FROM store_stats ORDER BY total_bills DESC + `), + query(` + SELECT store_name, meal_period, sum(bills) AS bills + FROM mv_bill_hourly WHERE meal_period != '' + GROUP BY store_name, meal_period + `), + ]) + + const effRows = efficiency.rows + const attRows = attendance.rows + const turnRows = turnover.rows + const trafficRows = trafficOverview.rows + const mealRows = mealPeriod.rows + + // 品牌级汇总指标 + const totalStores = effRows.length + const totalEmp = effRows.reduce((s: number, r: any) => s + parseFloat(r.emp_count), 0) + const totalRevenue = effRows.reduce((s: number, r: any) => s + parseFloat(r.revenue), 0) + const totalPayroll = effRows.reduce((s: number, r: any) => s + parseFloat(r.gross_pay), 0) + const avgRevenuePerEmp = totalEmp > 0 ? totalRevenue / totalEmp : 0 + const avgWageRate = totalRevenue > 0 ? (totalPayroll / totalRevenue) * 100 : 0 + const avgOvertimeRate = totalPayroll > 0 ? (effRows.reduce((s: number, r: any) => s + parseFloat(r.overtime_pay), 0) / totalPayroll) * 100 : 0 + const totalAbsent = attRows.reduce((s: number, r: any) => s + parseFloat(r.total_absent || 0), 0) + const totalAbsentEmp = attRows.reduce((s: number, r: any) => s + parseFloat(r.absent_emp || 0), 0) + const totalLeft = turnRows.reduce((s: number, r: any) => s + parseFloat(r.left_count || 0), 0) + const totalNew = turnRows.reduce((s: number, r: any) => s + parseFloat(r.new_count || 0), 0) + const avgTurnoverRate = totalEmp > 0 ? (totalLeft / totalEmp) * 100 : 0 + + // 生成诊断建议 + const insights: any[] = [] + + // 1. 人效分析 + const lowEffStores = effRows.filter((r: any) => parseFloat(r.revenue_per_emp) < 20000 && parseFloat(r.revenue_per_emp) > 0).sort((a: any, b: any) => parseFloat(a.revenue_per_emp) - parseFloat(b.revenue_per_emp)) + const highEffStores = effRows.filter((r: any) => parseFloat(r.revenue_per_emp) > 50000).sort((a: any, b: any) => parseFloat(b.revenue_per_emp) - parseFloat(a.revenue_per_emp)) + if (lowEffStores.length > 0) { + insights.push({ + category: '人效', + level: 'red', + title: `${lowEffStores.length}家门店人均创收低于2万`, + detail: `人均创收最低:${lowEffStores.slice(0, 3).map((r: any) => `${r.store_name}(${Math.round(parseFloat(r.revenue_per_emp))}元)`).join('、')}`, + suggestion: '建议排查这些门店的排班合理性,是否存在人浮于事;同时对比高人效门店的运营模式', + metric: 'revenue_per_emp', + value: avgRevenuePerEmp.toFixed(0), + }) + } + if (highEffStores.length > 0) { + insights.push({ + category: '人效', + level: 'green', + title: `${highEffStores.length}家门店人均创收超5万`, + detail: `高人效标杆:${highEffStores.slice(0, 3).map((r: any) => `${r.store_name}(${Math.round(parseFloat(r.revenue_per_emp))}元)`).join('、')}`, + suggestion: '建议提炼高人效门店的排班模式和岗位配置经验,向其他门店推广', + metric: 'revenue_per_emp', + value: avgRevenuePerEmp.toFixed(0), + }) + } + + // 2. 人力成本率 + const highWageStores = effRows.filter((r: any) => parseFloat(r.wage_rate) > 25).sort((a: any, b: any) => parseFloat(b.wage_rate) - parseFloat(a.wage_rate)) + if (highWageStores.length > 0) { + insights.push({ + category: '成本', + level: highWageStores.length > 10 ? 'red' : 'orange', + title: `${highWageStores.length}家门店人力成本率超25%`, + detail: `成本率最高:${highWageStores.slice(0, 3).map((r: any) => `${r.store_name}(${parseFloat(r.wage_rate).toFixed(1)}%)`).join('、')}`, + suggestion: '人力成本率过高,建议优化排班减少冗余人力,或提升营收分摊固定成本', + metric: 'wage_rate', + value: avgWageRate.toFixed(1) + '%', + }) + } + + // 3. 加班占比 + const highOvertimeStores = effRows.filter((r: any) => parseFloat(r.overtime_rate) > 5).sort((a: any, b: any) => parseFloat(b.overtime_rate) - parseFloat(a.overtime_rate)) + if (highOvertimeStores.length > 0) { + insights.push({ + category: '加班', + level: 'orange', + title: `${highOvertimeStores.length}家门店加班费占比超5%`, + detail: `加班最严重:${highOvertimeStores.slice(0, 3).map((r: any) => `${r.store_name}(${parseFloat(r.overtime_rate).toFixed(1)}%)`).join('、')}`, + suggestion: '加班占比过高说明排班与客流不匹配,建议在高峰时段增加兼职或调整班次', + metric: 'overtime_rate', + value: avgOvertimeRate.toFixed(1) + '%', + }) + } + + // 4. 客流波动 + const highVolatilityStores = trafficRows.filter((r: any) => r.volatility_level === '波动极大' || r.volatility_level === '波动较大') + if (highVolatilityStores.length > 0) { + insights.push({ + category: '客流', + level: highVolatilityStores.length > 20 ? 'red' : 'orange', + title: `${highVolatilityStores.length}家门店客流波动较大`, + detail: `峰谷比最高:${highVolatilityStores.slice(0, 3).map((r: any) => `${r.store_name}(${r.peak_valley_ratio}倍)`).join('、')}`, + suggestion: '客流波动大的门店应推行弹性排班,低谷时段减少在岗人数,高峰前提前补人', + metric: 'peak_valley_ratio', + value: '', + }) + } + + // 5. 高峰集中度 + const highConcentrationStores = trafficRows.filter((r: any) => parseFloat(r.peak_concentration_pct) > 60) + if (highConcentrationStores.length > 0) { + insights.push({ + category: '客流', + level: 'orange', + title: `${highConcentrationStores.length}家门店高峰集中度超60%`, + detail: `集中度最高:${highConcentrationStores.slice(0, 3).map((r: any) => `${r.store_name}(${r.peak_concentration_pct}%)`).join('、')}`, + suggestion: '客流高度集中在午晚餐高峰,建议在11-13点和17-19点增加兼职力量,非高峰时段精简人员', + metric: 'peak_concentration_pct', + value: '', + }) + } + + // 6. 考勤异常 + if (totalAbsentEmp > 0) { + const absentStores = attRows.filter((r: any) => parseFloat(r.absent_emp) > 0).sort((a: any, b: any) => parseFloat(b.absent_emp) - parseFloat(a.absent_emp)) + insights.push({ + category: '考勤', + level: 'red', + title: `${totalAbsentEmp}名员工存在旷工`, + detail: `旷工最多:${absentStores.slice(0, 3).map((r: any) => `${r.store_name}(${r.absent_emp}人)`).join('、')}`, + suggestion: '旷工直接影响门店运营,建议立即核查原因并完善考勤管理制度', + metric: 'absent_emp', + value: totalAbsentEmp.toString(), + }) + } + + // 7. 离职率 + if (avgTurnoverRate > 5) { + const highTurnoverStores = turnRows.filter((r: any) => parseFloat(r.turnover_rate) > 15).sort((a: any, b: any) => parseFloat(b.turnover_rate) - parseFloat(a.turnover_rate)) + insights.push({ + category: '离职', + level: avgTurnoverRate > 10 ? 'red' : 'orange', + title: `品牌整体离职率${avgTurnoverRate.toFixed(1)}%`, + detail: highTurnoverStores.length > 0 + ? `离职率最高:${highTurnoverStores.slice(0, 3).map((r: any) => `${r.store_name}(${parseFloat(r.turnover_rate).toFixed(1)}%)`).join('、')}` + : `共${totalLeft}人离职,${totalNew}人入职`, + suggestion: '高离职率增加招聘培训成本,建议关注离职原因,优化薪酬福利和排班制度', + metric: 'turnover_rate', + value: avgTurnoverRate.toFixed(1) + '%', + }) + } + + // 8. 新员工占比 + if (totalNew > 0) { + const newRate = totalEmp > 0 ? (totalNew / totalEmp) * 100 : 0 + if (newRate > 15) { + insights.push({ + category: '离职', + level: 'orange', + title: `新员工占比${newRate.toFixed(1)}%,人员变动频繁`, + detail: `入职${totalNew}人,离职${totalLeft}人,净流失${totalLeft - totalNew}人`, + suggestion: '新员工占比高说明人员流动大,建议加强新员工培训和带教制度,降低试用期离职率', + metric: 'new_hire_rate', + value: newRate.toFixed(1) + '%', + }) + } + } + + // 9. 低出勤率 + const lowAttendStores = attRows.filter((r: any) => parseFloat(r.avg_attend_days) < 20 && parseFloat(r.avg_attend_days) > 0) + if (lowAttendStores.length > 0) { + insights.push({ + category: '考勤', + level: 'orange', + title: `${lowAttendStores.length}家门店平均出勤不足20天`, + detail: `出勤最低:${lowAttendStores.slice(0, 3).map((r: any) => `${r.store_name}(${r.avg_attend_days}天)`).join('、')}`, + suggestion: '出勤天数偏低可能存在排班不足或人员冗余,建议核查排班计划与实际出勤的差异', + metric: 'avg_attend_days', + value: '', + }) + } + + // 10. 餐段结构异常 + const mealMap: Record> = {} + mealRows.forEach((r: any) => { + if (!mealMap[r.store_name]) mealMap[r.store_name] = {} + mealMap[r.store_name][r.meal_period] = parseFloat(r.bills) + }) + const unbalancedStores = Object.entries(mealMap).filter(([store, meals]) => { + const m = meals as Record + const total = Object.values(m).reduce((s: number, v: number) => s + v, 0) + if (total === 0) return false + const maxPct = Math.max(...Object.values(m).map((v: number) => v / total * 100)) + return maxPct > 70 + }).map(([store]) => store) + if (unbalancedStores.length > 0) { + insights.push({ + category: '客流', + level: 'yellow', + title: `${unbalancedStores.length}家门店餐段结构极度不均衡`, + detail: `如:${unbalancedStores.slice(0, 3).join('、')},单一餐段占比超70%`, + suggestion: '餐段过于集中会增加高峰排班压力,建议在非主力餐段推出促销活动平衡客流', + metric: 'meal_balance', + value: '', + }) + } + + // 品牌级KPI + const kpis = [ + { label: '门店总数', value: totalStores.toString(), unit: '家' }, + { label: '总员工数', value: totalEmp.toString(), unit: '人' }, + { label: '总营收', value: (totalRevenue / 10000).toFixed(0), unit: '万元' }, + { label: '总工资', value: (totalPayroll / 10000).toFixed(0), unit: '万元' }, + { label: '人均创收', value: Math.round(avgRevenuePerEmp).toString(), unit: '元/人' }, + { label: '人力成本率', value: avgWageRate.toFixed(1), unit: '%' }, + { label: '加班费占比', value: avgOvertimeRate.toFixed(1), unit: '%' }, + { label: '离职率', value: avgTurnoverRate.toFixed(1), unit: '%' }, + { label: '旷工人数', value: totalAbsentEmp.toString(), unit: '人' }, + { label: '离职/入职', value: `${totalLeft}/${totalNew}`, unit: '人' }, + ] + + sendSuccess(res, { kpis, insights }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +// ============ 人员招聘/解聘预测(规则引擎) ============ + +router.get('/staffing-forecast', async (req: AuthRequest, res) => { + try { + const [roleStats, storeRevenue, storeTraffic] = await Promise.all([ + query(` + SELECT + org_level5 AS store_name, + CASE + WHEN position LIKE '%店长%' OR position LIKE '%经理%' OR position LIKE '储备%' OR position LIKE '副店%' THEN '管理' + WHEN position LIKE '%服务员%' OR position LIKE '%训练员%' OR position LIKE '%迎宾%' OR position LIKE '%传菜%' OR position LIKE '服务主管%' OR position LIKE '主管%' THEN '前厅' + WHEN position LIKE '%厨%' OR position LIKE '%拉面%' OR position LIKE '%配菜%' OR position LIKE '%凉菜%' OR position LIKE '%烧烤%' OR position LIKE '%面点%' OR position LIKE '%面工%' OR position LIKE '%锅底%' OR position LIKE '%切肉%' OR position LIKE '%切菜%' OR position LIKE '%炒锅%' OR position LIKE '%砧板%' OR position LIKE '%打荷%' OR position LIKE '%洗碗%' OR position LIKE '%上什%' OR position LIKE '%打馕%' THEN '后厨' + WHEN position LIKE '%兼职%' OR position LIKE '%小时工%' THEN '兼职' + ELSE '其他' + END AS role, + count(*) AS emp_count, + count(*) FILTER (WHERE leave_date IS NULL OR leave_date = '' OR leave_date = '0') AS active_count, + round(sum(gross_pay)::numeric, 2) AS total_pay, + round(sum(gross_pay) FILTER (WHERE leave_date IS NULL OR leave_date = '' OR leave_date = '0')::numeric, 2) AS active_pay, + round(avg(gross_pay)::numeric, 2) AS avg_pay, + round(avg(actual_attend)::numeric, 1) AS avg_attend, + round(avg(actual_hours)::numeric, 0) AS avg_hours, + round(sum(actual_hours)::numeric, 0) AS total_hours, + count(*) FILTER (WHERE leave_date IS NOT NULL AND leave_date != '' AND leave_date != '0' AND leave_date >= '2026-04-01') AS left_count, + count(*) FILTER (WHERE hire_date IS NOT NULL AND hire_date >= '2026-04-01') AS new_count + FROM salary_detail_records + WHERE org_level2 = '西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '' + GROUP BY 1, 2 + `), + query(` + SELECT s.salary_name AS store_name, r.revenue, r.bill_count + FROM (SELECT DISTINCT org_level5 AS salary_name FROM salary_detail_records WHERE org_level2='西部马华品牌门店' AND org_level5 IS NOT NULL AND org_level5 != '') s + LEFT JOIN store_name_mapping m ON m.salary_name = s.salary_name + LEFT JOIN mv_store_revenue r ON r.store_name = COALESCE(m.bill_name, s.salary_name) + `), + query(` + WITH hourly AS ( + SELECT store_name, hour, sum(bills) AS bills + FROM mv_bill_hourly GROUP BY store_name, hour + ) + SELECT store_name, + max(bills) AS peak_bills, + round(max(bills)::numeric / nullif(min(bills), 0), 2) AS peak_valley_ratio, + round(sum(bills) FILTER (WHERE hour IN (11, 12, 17, 18, 19))::numeric / nullif(sum(bills), 0) * 100, 2) AS peak_concentration_pct + FROM hourly GROUP BY store_name + `), + ]) + + // 组装指标数据 + const revMap: Record = {} + storeRevenue.rows.forEach((r: any) => { revMap[r.store_name] = parseFloat(r.revenue) }) + const billMap: Record = {} + storeRevenue.rows.forEach((r: any) => { billMap[r.store_name] = parseInt(r.bill_count) }) + const trafficMap: Record = {} + storeTraffic.rows.forEach((r: any) => { trafficMap[r.store_name] = r }) + + const storeEmpCount: Record = {} + const storeActivePay: Record = {} + const storeTotalHours: Record = {} + roleStats.rows.forEach((r: any) => { + storeEmpCount[r.store_name] = (storeEmpCount[r.store_name] || 0) + parseInt(r.emp_count) + storeActivePay[r.store_name] = (storeActivePay[r.store_name] || 0) + parseFloat(r.active_pay) + storeTotalHours[r.store_name] = (storeTotalHours[r.store_name] || 0) + parseInt(r.total_hours) + }) + + // 组装每个门店×岗位的指标数据 + const items: any[] = [] + for (const r of roleStats.rows) { + const store = r.store_name + const role = r.role + const empCount = parseInt(r.emp_count) + const activeCount = parseInt(r.active_count) + const revenue = revMap[store] || 0 + const bills = billMap[store] || 0 + const totalEmp = storeEmpCount[store] || empCount + const totalStorePay = storeActivePay[store] || 0 + const totalStoreHours = storeTotalHours[store] || 0 + + const revenuePerEmp = totalEmp > 0 ? Math.round(revenue / totalEmp) : 0 + const revenuePerHour = totalStoreHours > 0 ? Math.round(revenue / totalStoreHours) : 0 + const wageRatio = revenue > 0 ? Math.round((totalStorePay / revenue) * 1000) / 10 : 0 + const roleEmpRatio = totalEmp > 0 ? Math.round((empCount / totalEmp) * 1000) / 10 : 0 + const rolePayRatio = totalStorePay > 0 ? Math.round((parseFloat(r.active_pay) / totalStorePay) * 1000) / 10 : 0 + const turnoverPct = empCount > 0 ? Math.round((parseInt(r.left_count) / empCount) * 1000) / 10 : 0 + const traffic = trafficMap[store] + const peakConcentration = traffic ? parseFloat(traffic.peak_concentration_pct) : 0 + const peakValley = traffic ? parseFloat(traffic.peak_valley_ratio) : 0 + + items.push({ + id: `${store}|${role}`, + store_name: store, + role, + emp_count: empCount, + active_count: activeCount, + avg_pay: parseFloat(r.avg_pay), + avg_attend: parseFloat(r.avg_attend) || 0, + avg_hours: parseFloat(r.avg_hours) || 0, + total_hours: parseInt(r.total_hours) || 0, + total_pay: parseFloat(r.active_pay), + left_count: parseInt(r.left_count) || 0, + new_count: parseInt(r.new_count) || 0, + turnover_pct: turnoverPct, + revenue_per_emp: revenuePerEmp, + revenue_per_hour: revenuePerHour, + wage_ratio: wageRatio, + role_emp_ratio: roleEmpRatio, + role_pay_ratio: rolePayRatio, + peak_concentration: peakConcentration, + peak_valley_ratio: peakValley, + bill_count: bills, + }) + } + + // === 动态标准:基于全部门店实际数据计算(用中位数,比均值更抗极端值) === + const storeLevelMetrics: { store: string; revenue_per_emp: number; revenue_per_hour: number; wage_ratio: number }[] = [] + Object.keys(storeEmpCount).forEach(store => { + const rev = revMap[store] || 0 + if (rev > 0) { + const emp = storeEmpCount[store] || 0 + const hours = storeTotalHours[store] || 0 + const pay = storeActivePay[store] || 0 + storeLevelMetrics.push({ + store, + revenue_per_emp: emp > 0 ? Math.round(rev / emp) : 0, + revenue_per_hour: hours > 0 ? Math.round(rev / hours) : 0, + wage_ratio: rev > 0 ? Math.round((pay / rev) * 1000) / 10 : 0, + }) + } + }) + + function median(arr: number[]): number { + if (arr.length === 0) return 0 + const sorted = [...arr].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid] + } + + const avgRevenuePerEmp = median(storeLevelMetrics.map(s => s.revenue_per_emp)) || 15000 + const avgRevenuePerHour = median(storeLevelMetrics.map(s => s.revenue_per_hour)) || 200 + const avgWageRatio = median(storeLevelMetrics.map(s => Math.round(s.wage_ratio * 10))) / 10 || 30 + + // 各岗位实际平均占比 + const roleEmpTotals: Record = {} + const rolePayTotals: Record = {} + const roleAttendSum: Record = {} + const roleAttendCount: Record = {} + const roleHoursSum: Record = {} + const roleHoursCount: Record = {} + const rolePaySum: Record = {} + const rolePayCount: Record = {} + let grandTotalEmp = 0 + roleStats.rows.forEach((r: any) => { + const role = r.role + const empCount = parseInt(r.emp_count) + roleEmpTotals[role] = (roleEmpTotals[role] || 0) + empCount + rolePayTotals[role] = (rolePayTotals[role] || 0) + parseFloat(r.active_pay) + grandTotalEmp += empCount + if (parseFloat(r.avg_attend) > 0) { + roleAttendSum[role] = (roleAttendSum[role] || 0) + parseFloat(r.avg_attend) * empCount + roleAttendCount[role] = (roleAttendCount[role] || 0) + empCount + } + if (parseFloat(r.avg_hours) > 0) { + roleHoursSum[role] = (roleHoursSum[role] || 0) + parseFloat(r.avg_hours) * empCount + roleHoursCount[role] = (roleHoursCount[role] || 0) + empCount + } + if (parseFloat(r.avg_pay) > 0) { + rolePaySum[role] = (rolePaySum[role] || 0) + parseFloat(r.avg_pay) * empCount + rolePayCount[role] = (rolePayCount[role] || 0) + empCount + } + }) + + const DYNAMIC_STANDARDS: Record = {} + for (const role of ['管理', '前厅', '后厨', '兼职', '其他']) { + DYNAMIC_STANDARDS[role] = { + role_ratio: grandTotalEmp > 0 ? Math.round((roleEmpTotals[role] / grandTotalEmp) * 1000) / 10 : 0, + normal_pay: rolePayCount[role] > 0 ? Math.round(rolePaySum[role] / rolePayCount[role]) : 0, + normal_attend: roleAttendCount[role] > 0 ? Math.round(roleAttendSum[role] / roleAttendCount[role]) : 24, + normal_hours: roleHoursCount[role] > 0 ? Math.round(roleHoursSum[role] / roleHoursCount[role]) : 160, + } + } + // 管理岗max_count仍用经验值4 + DYNAMIC_STANDARDS['管理'].max_count = 4 + DYNAMIC_STANDARDS['前厅'].max_count = 15 + DYNAMIC_STANDARDS['后厨'].max_count = 20 + DYNAMIC_STANDARDS['兼职'].max_count = 5 + DYNAMIC_STANDARDS['其他'].max_count = 3 + // 兼职出勤标准为0(弹性工时) + DYNAMIC_STANDARDS['兼职'].normal_attend = 0 + DYNAMIC_STANDARDS['兼职'].min_attend = 0 + for (const role of ['管理', '前厅', '后厨', '其他']) { + DYNAMIC_STANDARDS[role].min_attend = 20 + } + + console.log('[StaffingForecast] 动态标准:', { + avgRevenuePerEmp, avgRevenuePerHour, avgWageRatio, + roleRatios: Object.fromEntries(Object.entries(DYNAMIC_STANDARDS).map(([k,v]) => [k, v.role_ratio])), + }) + + // === 规则库 === + const STANDARDS = DYNAMIC_STANDARDS + + const roleOrder: Record = { '管理': 0, '前厅': 1, '后厨': 2, '兼职': 3, '其他': 4 } + const forecasts: any[] = [] + + for (const item of items) { + const std = STANDARDS[item.role] || STANDARDS['其他'] + const expectedRoleRatio = std.role_ratio + + // === 第一层:产能诊断(优化信号) === + const optSignals: string[] = [] + let optScore = 0 + + // R1: 人均创收低于均值 + 岗位占比偏高 → 冗余 + if (item.revenue_per_emp > 0 && item.revenue_per_emp < avgRevenuePerEmp && item.role !== '兼职') { + if (item.role_emp_ratio > expectedRoleRatio + 5) { + optSignals.push(`门店人均创收${item.revenue_per_emp}元(均值${avgRevenuePerEmp}元),${item.role}占比${item.role_emp_ratio}%偏高(标准${expectedRoleRatio}%),岗位冗余`) + optScore += 30 + } else { + optSignals.push(`门店人均创收${item.revenue_per_emp}元(均值${avgRevenuePerEmp}元),差${avgRevenuePerEmp - item.revenue_per_emp}元`) + optScore += 15 + } + } + + // R2: 工时产能低于均值(非兼职)→ 人效偏低,门店级指标所有岗位均受约束 + if (item.revenue_per_hour > 0 && item.revenue_per_hour < avgRevenuePerHour && item.role !== '兼职') { + optSignals.push(`工时产能${item.revenue_per_hour}元/工时(均值${avgRevenuePerHour}元),人效偏低`) + optScore += 20 + } + + // R3: 人力成本率高于均值 + 岗位薪资占比偏高 + if (item.wage_ratio > avgWageRatio && item.role !== '兼职' && item.role_emp_ratio > expectedRoleRatio + 3) { + optSignals.push(`人力成本率${item.wage_ratio}%(均值${avgWageRatio}%),${item.role}薪资占比${item.role_pay_ratio}%偏高`) + optScore += 20 + } + + // R4: 岗位人数占比超配 + if (item.role_emp_ratio > expectedRoleRatio + 8) { + optSignals.push(`${item.role}人数占比${item.role_emp_ratio}%(标准${expectedRoleRatio}%),配置偏高`) + optScore += 15 + } + + // R5: 管理岗超配 + if (item.role === '管理' && item.emp_count > std.max_count) { + optSignals.push(`管理岗${item.emp_count}人(标准≤${std.max_count}人),管理层冗余`) + optScore += 20 + } + + // R6: 其他岗超配 + if (item.role === '其他' && item.emp_count > std.max_count) { + optSignals.push(`其他岗位${item.emp_count}人(标准≤${std.max_count}人),建议明确职责或转岗`) + optScore += 10 + } + + // R7: 工时产能低 + 出勤低 → 工时未饱和(转为优化信号) + if (item.avg_attend > 0 && item.avg_attend < std.min_attend && item.role !== '兼职') { + const hasLowHourly = optSignals.some(s => s.includes('工时产能')) + if (hasLowHourly) { + optSignals.push(`${item.role}平均出勤${item.avg_attend}天(标准≥${std.min_attend}天),工时未饱和,应增加排班而非加人`) + optScore += 15 + } + } + + // === 第二层:互斥判断 === + const hasOptSignals = optSignals.length > 0 + + // === 第三层:人手诊断(招聘信号,仅无优化信号时触发) === + const hireSignals: string[] = [] + let hireScore = 0 + + if (!hasOptSignals) { + const roleOverstaffed = item.role_emp_ratio > expectedRoleRatio + 3 + + // R8: 岗位人数占比偏低 + if (item.role_emp_ratio < expectedRoleRatio - 5 && item.role !== '兼职' && item.active_count > 0) { + hireSignals.push(`${item.role}人数占比${item.role_emp_ratio}%(标准${expectedRoleRatio}%),配置偏低`) + hireScore += 15 + } + + // R9: 出勤不足(占比未超配、管理岗未满编时才触发) + const mgmtFull = item.role === '管理' && item.emp_count >= std.max_count + if (item.avg_attend > 0 && item.avg_attend < std.min_attend && item.role !== '兼职' + && !roleOverstaffed && !mgmtFull) { + hireSignals.push(`${item.role}平均出勤${item.avg_attend}天(标准≥${std.min_attend}天),排班不足`) + hireScore += 15 + } + + // R10: 离职缺口(需同时满足:岗位未超配、门店人效正常、出勤不足——出勤正常说明人手够用) + const attendLow = item.avg_attend > 0 && item.avg_attend < std.min_attend && item.role !== '兼职' + if (item.left_count > 0 && item.turnover_pct >= 10 + && item.role_emp_ratio <= expectedRoleRatio + 3 + && (item.revenue_per_hour === 0 || item.revenue_per_hour >= avgRevenuePerHour || item.role !== '兼职') + && (attendLow || item.role === '兼职')) { + hireSignals.push(`${item.role}当月离职${item.left_count}人(离职率${item.turnover_pct}%,标准<10%),需填补缺口`) + hireScore += 25 + } + + // R11: 新员工稳定性(占比未超配+出勤不足时才触发) + if (item.new_count > 0 && item.new_count >= item.active_count * 0.2 && attendLow && !roleOverstaffed) { + hireSignals.push(`${item.role}当月新入职${item.new_count}人(占在职${Math.round(item.new_count / Math.max(item.active_count, 1) * 100)}%),需带教防流失`) + hireScore += 10 + } + + // R12: 客流匹配-前厅(占比未超配时才触发) + if (item.role === '前厅' && item.peak_concentration > 60 && item.emp_count < 10 && !roleOverstaffed) { + hireSignals.push(`高峰集中度${item.peak_concentration}%(标准<60%),前厅仅${item.emp_count}人,高峰服务压力大`) + hireScore += 20 + } + + // R13: 客流匹配-后厨(占比未超配时才触发) + if (item.role === '后厨' && item.peak_valley_ratio > 10 && item.emp_count < 8 && !roleOverstaffed) { + hireSignals.push(`客流峰谷比${item.peak_valley_ratio}倍(标准<10倍),后厨仅${item.emp_count}人,需弹性人手`) + hireScore += 15 + } + } + + // R14: 兼职特殊(不受互斥限制,但占比超配或人效低时不招聘) + if (item.role === '兼职' && item.peak_concentration > 50 && item.emp_count < 3 + && item.role_emp_ratio <= expectedRoleRatio + 3 + && (item.revenue_per_hour === 0 || item.revenue_per_hour >= avgRevenuePerHour)) { + hireSignals.push(`高峰集中度${item.peak_concentration}%,兼职仅${item.emp_count}人,建议增加兼职覆盖高峰`) + hireScore += 15 + } + + // === 第四层:动作决策 === + const urgency = Math.min(optScore + hireScore, 100) + const noRevenueData = item.revenue_per_emp === 0 + + let action = '维持' + let actionLevel = 'green' + + if (hasOptSignals && optScore >= 20) { + action = '建议优化' + actionLevel = optScore >= 60 ? 'red' : optScore >= 35 ? 'orange' : 'yellow' + } else if (hireSignals.length > 0 && hireScore >= 20) { + // 无营收数据时无法确认是否真的缺人,降级为关注 + action = noRevenueData ? '关注' : '建议招聘' + actionLevel = noRevenueData ? 'yellow' : (hireScore >= 60 ? 'red' : hireScore >= 35 ? 'orange' : 'yellow') + } else if (urgency >= 20) { + action = '关注' + actionLevel = 'yellow' + } + + if (action === '维持' && urgency === 0) continue + + // 生成分析文本 + const analysisParts: string[] = [] + if (item.revenue_per_emp > 0) analysisParts.push(`人均创收${item.revenue_per_emp}元${item.revenue_per_emp < avgRevenuePerEmp ? '(低于中位)' : ''}`) + if (item.revenue_per_hour > 0) analysisParts.push(`工时产能${item.revenue_per_hour}元/h${item.revenue_per_hour < avgRevenuePerHour ? '(低于中位)' : ''}`) + if (item.wage_ratio > 0) analysisParts.push(`人力成本率${item.wage_ratio}%${item.wage_ratio > avgWageRatio ? '(高于中位)' : ''}`) + if (item.avg_attend > 0) analysisParts.push(`出勤${item.avg_attend}天`) + if (item.turnover_pct > 0) analysisParts.push(`离职率${item.turnover_pct}%`) + const analysis = analysisParts.join(',') + '。' + + // 生成建议文本 + let suggestion = '' + if (action === '建议优化') { + if (optSignals.some(s => s.includes('工时未饱和'))) { + suggestion = `建议对 ${item.store_name} 的 ${item.role} 岗位提高排班覆盖率,增加现有人员工时饱和度,暂不需要增编。` + } else if (optSignals.some(s => s.includes('冗余'))) { + suggestion = `建议对 ${item.store_name} 的 ${item.role} 岗位进行人员优化,可考虑转岗或精简,预估可节省月人力成本约 ${item.avg_pay}元/人。` + } else if (optSignals.some(s => s.includes('人效偏低'))) { + suggestion = `建议对 ${item.store_name} 的 ${item.role} 岗位提升人效,优化工作流程或调整排班,暂不增编。` + } else { + suggestion = `建议对 ${item.store_name} 的 ${item.role} 岗位进行优化调整,关注产能指标改善。` + } + } else if (action === '建议招聘') { + const hireNum = Math.max(item.left_count, 1) + suggestion = `建议为 ${item.store_name} 的 ${item.role} 岗位补充${hireNum}人,预估月人力成本增加约 ${item.avg_pay * hireNum}元。` + } else if (action === '关注') { + if (noRevenueData && hireSignals.length > 0) { + suggestion = `${item.store_name} 缺少营收数据,无法评估人效。建议先录入营收数据再判断是否需要补充 ${item.role} 人员。` + } else { + suggestion = `建议持续关注 ${item.store_name} 的 ${item.role} 岗位的人员变动和人效表现,暂不需要立即调整。` + } + } + + forecasts.push({ + store_name: item.store_name, + role: item.role, + emp_count: item.emp_count, + active_count: item.active_count, + avg_pay: item.avg_pay, + avg_attend: item.avg_attend, + avg_hours: item.avg_hours, + total_hours: item.total_hours, + total_pay: item.total_pay, + left_count: item.left_count, + new_count: item.new_count, + turnover_pct: item.turnover_pct, + revenue_per_emp: item.revenue_per_emp, + revenue_per_hour: item.revenue_per_hour, + wage_ratio: item.wage_ratio, + role_emp_ratio: item.role_emp_ratio, + role_pay_ratio: item.role_pay_ratio, + action, + action_level: actionLevel, + urgency, + analysis, + suggestion, + hire_reasons: hireSignals.join(';'), + optimize_reasons: optSignals.join(';'), + reasons: [...hireSignals, ...optSignals].join(';'), + standards: { + normal_attend: std.normal_attend, + min_attend: std.min_attend, + max_count: std.max_count, + normal_pay: std.normal_pay, + normal_hours: std.normal_hours, + expected_role_ratio: expectedRoleRatio, + revenue_per_emp: avgRevenuePerEmp, + revenue_per_hour: avgRevenuePerHour, + wage_ratio: avgWageRatio, + turnover: 10, + }, + }) + } + + // 排序 + forecasts.sort((a, b) => { + if (b.urgency !== a.urgency) return b.urgency - a.urgency + if (a.store_name !== b.store_name) return a.store_name.localeCompare(b.store_name) + return (roleOrder[a.role] || 99) - (roleOrder[b.role] || 99) + }) + + const hireCount = forecasts.filter(f => f.action === '建议招聘').length + const optimizeCount = forecasts.filter(f => f.action === '建议优化').length + const watchCount = forecasts.filter(f => f.action === '关注').length + + sendSuccess(res, { + forecasts, + summary: { + total: forecasts.length, + hire: hireCount, + optimize: optimizeCount, + watch: watchCount, + }, + ruleEngine: { + standards: { + revenue_per_emp: avgRevenuePerEmp, + revenue_per_hour: avgRevenuePerHour, + wage_ratio: avgWageRatio, + roles: Object.fromEntries(Object.entries(DYNAMIC_STANDARDS).map(([k, v]: [string, any]) => [ + k, { role_ratio: v.role_ratio, normal_pay: v.normal_pay, normal_attend: v.normal_attend, normal_hours: v.normal_hours, min_attend: v.min_attend, max_count: v.max_count } + ])), + }, + optimizationRules: [ + { id: 'R1', name: '人均创收低+占比偏高', condition: `人均创收 < 中位数(${avgRevenuePerEmp}元) 且 岗位占比 > 标准+5%`, score: 30, action: '优化' }, + { id: 'R1b', name: '人均创收低', condition: `人均创收 < 中位数(${avgRevenuePerEmp}元) 且 占比正常`, score: 15, action: '优化' }, + { id: 'R2', name: '工时产能低', condition: `工时产能 < 中位数(${avgRevenuePerHour}元/h) 且 非兼职`, score: 20, action: '优化' }, + { id: 'R3', name: '人力成本率高+薪资占比高', condition: `人力成本率 > 中位数(${avgWageRatio}%) 且 岗位占比 > 标准+3% 且 非兼职`, score: 20, action: '优化' }, + { id: 'R4', name: '岗位占比超配', condition: `岗位占比 > 标准+8%`, score: 15, action: '优化' }, + { id: 'R5', name: '管理岗超编', condition: `管理岗人数 > ${DYNAMIC_STANDARDS['管理'].max_count}人`, score: 20, action: '优化' }, + { id: 'R6', name: '其他岗超编', condition: `其他岗人数 > ${DYNAMIC_STANDARDS['其他'].max_count}人`, score: 10, action: '优化' }, + { id: 'R7', name: '工时未饱和', condition: `已触发R2 且 出勤 < ${DYNAMIC_STANDARDS['后厨'].min_attend}天`, score: 15, action: '优化' }, + ], + hireRules: [ + { id: 'R8', name: '岗位占比偏低', condition: `岗位占比 < 标准-5% 且 非兼职 且 在职>0`, score: 15, action: '招聘', constraint: '无优化信号' }, + { id: 'R9', name: '出勤不足', condition: `出勤 < ${DYNAMIC_STANDARDS['后厨'].min_attend}天 且 非兼职 且 占比未超配 且 管理岗未满编`, score: 15, action: '招聘', constraint: '无优化信号' }, + { id: 'R10', name: '离职缺口', condition: `离职率≥10% 且 占比未超配 且 人效正常 且 出勤不足`, score: 25, action: '招聘', constraint: '无优化信号' }, + { id: 'R11', name: '新员工带教', condition: `新入职≥在职20% 且 出勤不足 且 占比未超配`, score: 10, action: '招聘', constraint: '无优化信号' }, + { id: 'R12', name: '前厅高峰压力', condition: `前厅 且 高峰集中度>60% 且 人数<10 且 占比未超配`, score: 20, action: '招聘', constraint: '无优化信号' }, + { id: 'R13', name: '后厨峰谷差', condition: `后厨 且 峰谷比>10倍 且 人数<8 且 占比未超配`, score: 15, action: '招聘', constraint: '无优化信号' }, + { id: 'R14', name: '兼职高峰覆盖', condition: `兼职 且 高峰集中度>50% 且 人数<3 且 占比未超配 且 人效正常`, score: 15, action: '招聘', constraint: '不受互斥限制' }, + ], + decisionLogic: [ + { step: 1, name: '产能诊断', desc: '依次检查R1~R7,累计优化信号和分值' }, + { step: 2, name: '互斥判断', desc: '有任何优化信号→所有招聘信号(R8~R13)被抑制' }, + { step: 3, name: '人手诊断', desc: '无优化信号时检查R8~R13,累计招聘信号和分值' }, + { step: 4, name: '动作决策', desc: '优化分≥20→建议优化;招聘分≥20→建议招聘(无营收数据降级为关注);其他≥20→关注' }, + ], + }, + }) + } catch (err: any) { + sendError(res, err.message) + } +}) + +export default router