feat: 客流热力图增加在岗人员行+规则引擎面板+菜单名称调整
- 热力图每门店增加在岗人员行(前厅/后厨/管理/其他),从考勤打卡记录解析 - 新增traffic-heatmap-staffing API,解析考勤打卡时间计算每小时在岗人数 - 人员预测tab新增可折叠规则引擎面板,展示全部规则和触发条件 - 后端API返回ruleEngine字段(动态标准+优化规则+招聘规则+决策逻辑) - R9/R11/R12/R13增加占比超配约束,过滤不合理招聘建议 - 菜单项'菜品成本分析'→'菜品成本','门店费用分析'→'门店费用' - 热力图横向滚动条始终可见
This commit is contained in:
@@ -37,8 +37,8 @@ const menuGroups: MenuGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
|
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
|
||||||
{ path: '/cost', label: '成本库存', icon: DollarSign, roles: ['hq', 'dept'] },
|
{ path: '/cost', label: '成本库存', icon: DollarSign, roles: ['hq', 'dept'] },
|
||||||
{ path: '/cost-analysis', label: '菜品成本分析', icon: PieChart, roles: ['hq', 'dept'] },
|
{ path: '/cost-analysis', label: '菜品成本', icon: PieChart, roles: ['hq', 'dept'] },
|
||||||
{ path: '/store-expense', label: '门店费用分析', icon: Wallet, roles: ['hq', 'dept'] },
|
{ path: '/store-expense', label: '门店费用', icon: Wallet, roles: ['hq', 'dept'] },
|
||||||
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] },
|
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] },
|
||||||
{ path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] },
|
{ path: '/platform', label: '平台优惠', icon: ShoppingBag, roles: ['hq', 'dept'] },
|
||||||
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
|
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, Fragment } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import api from '@/lib/api'
|
import api from '@/lib/api'
|
||||||
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
import { CollapsibleSection } from '@/components/CollapsibleSection'
|
||||||
@@ -30,6 +30,11 @@ export function TrafficHeatmapTab() {
|
|||||||
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap${storeName ? `?store=${storeName}` : ''}`),
|
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap${storeName ? `?store=${storeName}` : ''}`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: staffing } = useQuery({
|
||||||
|
queryKey: ['ss/traffic-heatmap-staffing', storeName],
|
||||||
|
queryFn: () => api.get(`/smart-scheduling/traffic-heatmap-staffing${storeName ? `?store=${storeName}` : ''}`),
|
||||||
|
})
|
||||||
|
|
||||||
const { data: mealPeriod } = useQuery({
|
const { data: mealPeriod } = useQuery({
|
||||||
queryKey: ['ss/meal-period-traffic', storeName],
|
queryKey: ['ss/meal-period-traffic', storeName],
|
||||||
queryFn: () => api.get(`/smart-scheduling/meal-period-traffic${storeName ? `?store=${storeName}` : ''}`),
|
queryFn: () => api.get(`/smart-scheduling/meal-period-traffic${storeName ? `?store=${storeName}` : ''}`),
|
||||||
@@ -38,6 +43,7 @@ export function TrafficHeatmapTab() {
|
|||||||
const stores = (overview as any)?.data || []
|
const stores = (overview as any)?.data || []
|
||||||
const heatRows = (heatmap as any)?.data || []
|
const heatRows = (heatmap as any)?.data || []
|
||||||
const mealRows = (mealPeriod as any)?.data || []
|
const mealRows = (mealPeriod as any)?.data || []
|
||||||
|
const staffingRows = (staffing as any)?.data || []
|
||||||
|
|
||||||
const storeNames = Array.from(new Set(stores.map((s: any) => s.store_name as string))) as string[]
|
const storeNames = Array.from(new Set(stores.map((s: any) => s.store_name as string))) as string[]
|
||||||
|
|
||||||
@@ -48,6 +54,13 @@ export function TrafficHeatmapTab() {
|
|||||||
heatMap[r.store_name][r.hour] = r.bills
|
heatMap[r.store_name][r.hour] = r.bills
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 人员分布: store -> hour -> { front, kitchen, management, other, total }
|
||||||
|
const staffingMap: Record<string, Record<number, { front: number; kitchen: number; management: number; other: number; total: number }>> = {}
|
||||||
|
staffingRows.forEach((r: any) => {
|
||||||
|
if (!staffingMap[r.store_name]) staffingMap[r.store_name] = {}
|
||||||
|
staffingMap[r.store_name][r.hour] = { front: r.front, kitchen: r.kitchen, management: r.management, other: r.other, total: r.total }
|
||||||
|
})
|
||||||
|
|
||||||
function heatColor(bills: number, max: number): string {
|
function heatColor(bills: number, max: number): string {
|
||||||
if (!bills) return ''
|
if (!bills) return ''
|
||||||
const ratio = bills / max
|
const ratio = bills / max
|
||||||
@@ -127,25 +140,40 @@ export function TrafficHeatmapTab() {
|
|||||||
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<span className="text-xs text-muted-foreground">颜色越深客流越大</span>
|
<span className="text-xs text-muted-foreground">颜色越深客流越大</span>
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">在岗: <span className="text-blue-600">前厅</span>/<span className="text-orange-600">后厨</span>/<span className="text-purple-600">管理</span>/<span className="text-gray-500">其他</span></span>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? <LoadingSpinner text="加载客流热力图..." /> : (
|
{isLoading ? <LoadingSpinner text="加载客流热力图..." /> : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto -mx-4 px-4 heatmap-scroll">
|
||||||
<table className="text-xs">
|
<table className="text-xs whitespace-nowrap">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-2 py-1 text-left sticky left-0 bg-card">门店</th>
|
<th className="px-2 py-1 text-left sticky left-0 bg-card">门店</th>
|
||||||
{hours.map(h => <th key={h} className="px-1 py-1 text-center min-w-[28px]">{h}</th>)}
|
{hours.map(h => <th key={h} className="px-1 py-1 text-center min-w-[32px]">{h}</th>)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{Object.keys(heatMap).map(store => (
|
{Object.keys(heatMap).map(store => (
|
||||||
<tr key={store}>
|
<Fragment key={store}>
|
||||||
<td className="px-2 py-1 whitespace-nowrap sticky left-0 bg-card">{store}</td>
|
<tr>
|
||||||
{hours.map(h => {
|
<td className="px-2 py-1 whitespace-nowrap sticky left-0 bg-card font-medium">{store}</td>
|
||||||
const bills = heatMap[store]?.[h] || 0
|
{hours.map(h => {
|
||||||
return <td key={h} className={`px-1 py-1 text-center ${heatColor(bills, maxBills)}`}>{bills > 0 ? bills : ''}</td>
|
const bills = heatMap[store]?.[h] || 0
|
||||||
})}
|
return <td key={h} className={`px-1 py-1 text-center ${heatColor(bills, maxBills)}`}>{bills > 0 ? bills : ''}</td>
|
||||||
</tr>
|
})}
|
||||||
|
</tr>
|
||||||
|
<tr key={store + '-staff'} className="border-b">
|
||||||
|
<td className="px-2 py-0.5 whitespace-nowrap sticky left-0 bg-card text-xs text-muted-foreground">在岗</td>
|
||||||
|
{hours.map(h => {
|
||||||
|
const s = staffingMap[store]?.[h]
|
||||||
|
if (!s || s.total === 0) return <td key={h} className="px-1 py-0.5 text-center text-xs text-muted-foreground/40 min-w-[42px]">-</td>
|
||||||
|
return (
|
||||||
|
<td key={h} className="px-1 py-0.5 text-center text-xs min-w-[42px]">
|
||||||
|
<span className="text-blue-600">{s.front}</span><span className="text-muted-foreground">/</span><span className="text-orange-600">{s.kitchen}</span><span className="text-muted-foreground">/</span><span className="text-purple-600">{s.management}</span><span className="text-muted-foreground">/</span><span className="text-gray-500">{s.other}</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -54,3 +54,21 @@ body {
|
|||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.heatmap-scroll::-webkit-scrollbar {
|
||||||
|
height: 10px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
.heatmap-scroll::-webkit-scrollbar-track {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.heatmap-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #999;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 2px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.heatmap-scroll {
|
||||||
|
scrollbar-width: auto;
|
||||||
|
scrollbar-color: #999 #f0f0f0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function CostAnalysisPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold">菜品成本分析</h1>
|
<h1 className="text-xl font-bold">菜品成本</h1>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">基于BOM与成本报表的多维度运营分析</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">基于BOM与成本报表的多维度运营分析</p>
|
||||||
</div>
|
</div>
|
||||||
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
|
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} />
|
||||||
|
|||||||
@@ -60,6 +60,92 @@ router.get('/meal-period-traffic', async (req: AuthRequest, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 门店×小时在岗人员分布(从考勤打卡记录解析)
|
||||||
|
router.get('/traffic-heatmap-staffing', async (req: AuthRequest, res) => {
|
||||||
|
try {
|
||||||
|
const storeName = req.query.store as string
|
||||||
|
// 从考勤表取所有员工的打卡记录,解析上下班时间
|
||||||
|
// department格式: 北京西部马华餐饮有限公司/西部马华品牌门店/.../七里庄店/前厅/管理组
|
||||||
|
const result = await query(`
|
||||||
|
SELECT department, position, day_15
|
||||||
|
FROM attendance_records
|
||||||
|
WHERE department LIKE '%西部马华品牌门店%'
|
||||||
|
AND day_15 IS NOT NULL AND day_15 != ''
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 从department提取门店名(以"店"结尾的层级)
|
||||||
|
function extractStore(dept: string): string {
|
||||||
|
const parts = dept.split('/')
|
||||||
|
for (let i = parts.length - 1; i >= 0; i--) {
|
||||||
|
if (parts[i].endsWith('店')) return parts[i]
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从打卡记录解析上下班时间,格式: 08:30(考勤机:指纹)+20:28(考勤机:指纹)
|
||||||
|
function parseClockTimes(raw: string): { start: number; end: number } | null {
|
||||||
|
const times = raw.match(/(\d{1,2}):(\d{2})/g)
|
||||||
|
if (!times || times.length < 2) return null
|
||||||
|
const startParts = times[0].match(/(\d{1,2}):(\d{2})/)
|
||||||
|
const endParts = times[times.length - 1].match(/(\d{1,2}):(\d{2})/)
|
||||||
|
if (!startParts || !endParts) return null
|
||||||
|
const startHour = parseInt(startParts[1])
|
||||||
|
let endHour = parseInt(endParts[1])
|
||||||
|
// 如果下班时间小于上班时间,说明跨天,按23点算
|
||||||
|
if (endHour < startHour) endHour = 23
|
||||||
|
return { start: startHour, end: endHour }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇总:store -> hour -> { 前厅, 后厨, 管理, 其他 }
|
||||||
|
const staffing: Record<string, Record<number, { 前厅: number; 后厨: number; 管理: number; 其他: number }>> = {}
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const store = extractStore(row.department as string)
|
||||||
|
if (!store) continue
|
||||||
|
if (storeName && store !== storeName) continue
|
||||||
|
|
||||||
|
const clock = parseClockTimes(row.day_15 as string)
|
||||||
|
if (!clock) continue
|
||||||
|
|
||||||
|
// 用position做SQL LIKE风格的判断
|
||||||
|
const pos = row.position as string
|
||||||
|
const dept = row.department as string
|
||||||
|
let role = '其他'
|
||||||
|
if (pos.includes('店长') || pos.includes('经理') || pos.startsWith('储备') || pos.includes('副店')) role = '管理'
|
||||||
|
else if (dept.includes('/前厅/') || pos.includes('服务员') || pos.includes('训练员') || pos.includes('迎宾') || pos.includes('传菜') || pos.includes('主管')) role = '前厅'
|
||||||
|
else if (dept.includes('/后厨') || pos.includes('厨') || pos.includes('拉面') || pos.includes('配菜') || pos.includes('凉菜') || pos.includes('烧烤') || pos.includes('面点') || pos.includes('面工') || pos.includes('锅底') || pos.includes('切肉') || pos.includes('切菜') || pos.includes('炒锅') || pos.includes('砧板') || pos.includes('打荷') || pos.includes('洗碗') || pos.includes('上什') || pos.includes('打馕')) role = '后厨'
|
||||||
|
else if (pos.includes('兼职') || pos.includes('小时工')) role = '兼职'
|
||||||
|
|
||||||
|
if (!staffing[store]) staffing[store] = {}
|
||||||
|
for (let h = clock.start; h <= clock.end; h++) {
|
||||||
|
if (!staffing[store][h]) staffing[store][h] = { 前厅: 0, 后厨: 0, 管理: 0, 其他: 0 }
|
||||||
|
staffing[store][h][role as '前厅' | '后厨' | '管理' | '其他']++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转为数组输出
|
||||||
|
const output: any[] = []
|
||||||
|
for (const [store, hours] of Object.entries(staffing)) {
|
||||||
|
for (let h = 0; h < 24; h++) {
|
||||||
|
const s = hours[h] || { 前厅: 0, 后厨: 0, 管理: 0, 其他: 0 }
|
||||||
|
output.push({
|
||||||
|
store_name: store,
|
||||||
|
hour: h,
|
||||||
|
front: s.前厅,
|
||||||
|
kitchen: s.后厨,
|
||||||
|
management: s.管理,
|
||||||
|
other: s.其他,
|
||||||
|
total: s.前厅 + s.后厨 + s.管理 + s.其他,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSuccess(res, output)
|
||||||
|
} catch (err: any) {
|
||||||
|
sendError(res, err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 工作日vs周末客流
|
// 工作日vs周末客流
|
||||||
router.get('/dow-traffic', async (req: AuthRequest, res) => {
|
router.get('/dow-traffic', async (req: AuthRequest, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user