feat: 店长工作台新增人力排班Tab及概览折叠功能
- 后端新增 /api/stores/:code/staffing 路由,返回人工成本概览、按星期/餐段排班建议、日营收明细、综合诊断 - 前端 StorePage 新增 staffing Tab,展示 MetricCard 概览、优化建议、排班图表、诊断算法说明 - 修复公司均值人工率被异常门店污染问题(过滤 wage_rate_pct > 100) - 诊断逻辑改为与公司均值动态比较,建议人工率改为动态计算 - 增强任务反馈表单(执行证据、未完成原因、下一步计划) - 点击非概览Tab时自动折叠上方概况区域
This commit is contained in:
+264
-21
@@ -22,7 +22,7 @@ const SCORE_DIMENSIONS = [
|
|||||||
{ key: 'score_bill', label: '客单价', max: 5 },
|
{ key: 'score_bill', label: '客单价', max: 5 },
|
||||||
]
|
]
|
||||||
|
|
||||||
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks'
|
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'staffing'
|
||||||
|
|
||||||
const TABS: { key: DetailTab; label: string }[] = [
|
const TABS: { key: DetailTab; label: string }[] = [
|
||||||
{ key: 'overview', label: '概览' },
|
{ key: 'overview', label: '概览' },
|
||||||
@@ -31,12 +31,15 @@ const TABS: { key: DetailTab; label: string }[] = [
|
|||||||
{ key: 'cost', label: '成本分析' },
|
{ key: 'cost', label: '成本分析' },
|
||||||
{ key: 'member', label: '会员分析' },
|
{ key: 'member', label: '会员分析' },
|
||||||
{ key: 'anomaly', label: '异常账单' },
|
{ key: 'anomaly', label: '异常账单' },
|
||||||
|
{ key: 'staffing', label: '人力排班' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function StorePage() {
|
export function StorePage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [executeText, setExecuteText] = useState('')
|
const [executeText, setExecuteText] = useState('')
|
||||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
|
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
|
||||||
|
const [incompleteReason, setIncompleteReason] = useState('')
|
||||||
|
const [nextStep, setNextStep] = useState('')
|
||||||
const userStoreCode = (() => {
|
const userStoreCode = (() => {
|
||||||
try {
|
try {
|
||||||
const u = JSON.parse(localStorage.getItem('user') || '{}')
|
const u = JSON.parse(localStorage.getItem('user') || '{}')
|
||||||
@@ -46,6 +49,7 @@ export function StorePage() {
|
|||||||
const [storeCode, setStoreCode] = useState(userStoreCode || '1111')
|
const [storeCode, setStoreCode] = useState(userStoreCode || '1111')
|
||||||
const [month, setMonth] = useState('2026-04')
|
const [month, setMonth] = useState('2026-04')
|
||||||
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
|
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
|
||||||
|
const [overviewOpen, setOverviewOpen] = useState(true)
|
||||||
|
|
||||||
const { data: storesData, isLoading: storesLoading } = useQuery({
|
const { data: storesData, isLoading: storesLoading } = useQuery({
|
||||||
queryKey: ['stores-list', month],
|
queryKey: ['stores-list', month],
|
||||||
@@ -129,6 +133,12 @@ export function StorePage() {
|
|||||||
enabled: detailTab === 'anomaly',
|
enabled: detailTab === 'anomaly',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { data: staffingData } = useQuery({
|
||||||
|
queryKey: ['store', storeCode, 'staffing', month],
|
||||||
|
queryFn: () => api.get(`/stores/${storeCode}/staffing`, { params: { month } }),
|
||||||
|
enabled: detailTab === 'staffing',
|
||||||
|
})
|
||||||
|
|
||||||
const card = (cardData as any)?.data
|
const card = (cardData as any)?.data
|
||||||
const anomalies = card?.anomalies || []
|
const anomalies = card?.anomalies || []
|
||||||
const tasks = (tasksData as any)?.data || []
|
const tasks = (tasksData as any)?.data || []
|
||||||
@@ -152,6 +162,7 @@ export function StorePage() {
|
|||||||
const member = (memberData as any)?.data
|
const member = (memberData as any)?.data
|
||||||
const anomalyDetailRows = ((anomalyDetailData as any)?.data) || []
|
const anomalyDetailRows = ((anomalyDetailData as any)?.data) || []
|
||||||
const anomalyDetailTotal = (anomalyDetailData as any)?.meta?.total || anomalyDetailRows.length
|
const anomalyDetailTotal = (anomalyDetailData as any)?.meta?.total || anomalyDetailRows.length
|
||||||
|
const staffing = (staffingData as any)?.data
|
||||||
|
|
||||||
const sc = sd?.scorecard
|
const sc = sd?.scorecard
|
||||||
const action = sd?.action
|
const action = sd?.action
|
||||||
@@ -210,7 +221,7 @@ export function StorePage() {
|
|||||||
const anomalyCount = allAnomalyItems.filter((i: any) => i.is_anomaly).length
|
const anomalyCount = allAnomalyItems.filter((i: any) => i.is_anomaly).length
|
||||||
|
|
||||||
const executeMutation = useMutation({
|
const executeMutation = useMutation({
|
||||||
mutationFn: ({ id, text }: { id: number; text: string }) => api.put(`/tasks/${id}/execute`, { process_evidence: text }),
|
mutationFn: ({ id, data }: { id: number; data: any }) => api.put(`/tasks/${id}/execute`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['store', storeCode, 'tasks'] })
|
queryClient.invalidateQueries({ queryKey: ['store', storeCode, 'tasks'] })
|
||||||
setExecuteText('')
|
setExecuteText('')
|
||||||
@@ -246,6 +257,19 @@ export function StorePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 概览折叠区 */}
|
||||||
|
<div className="rounded-lg border bg-card">
|
||||||
|
<button className="flex w-full items-center justify-between p-4 text-left" onClick={() => setOverviewOpen(!overviewOpen)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-sm font-bold transition-transform ${overviewOpen ? 'rotate-90' : 'rotate-0'}`}>▶</span>
|
||||||
|
<h2 className="text-sm font-bold">门店概况与经营概览</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{overviewOpen ? '点击折叠' : '点击展开'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overviewOpen && (<>
|
||||||
|
|
||||||
{/* 门店概况 */}
|
{/* 门店概况 */}
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
@@ -466,24 +490,45 @@ export function StorePage() {
|
|||||||
<p className="mt-1 text-xs text-muted-foreground">截止日: {t.deadline?.substring(0, 10)}</p>
|
<p className="mt-1 text-xs text-muted-foreground">截止日: {t.deadline?.substring(0, 10)}</p>
|
||||||
|
|
||||||
{activeTaskId === t.task_id ? (
|
{activeTaskId === t.task_id ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2 space-y-2">
|
||||||
<textarea
|
<div>
|
||||||
value={executeText}
|
<label className="text-xs font-medium text-muted-foreground">执行过程与证据 *</label>
|
||||||
onChange={(e) => setExecuteText(e.target.value)}
|
<textarea
|
||||||
placeholder="填写执行结果和过程证据..."
|
value={executeText}
|
||||||
className="mb-2 w-full rounded-md border p-2 text-xs"
|
onChange={(e) => setExecuteText(e.target.value)}
|
||||||
rows={2}
|
placeholder="填写执行结果、过程证据、数据截图说明等..."
|
||||||
/>
|
className="mt-1 w-full rounded-md border p-2 text-xs"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">未完成原因(选填)</label>
|
||||||
|
<input
|
||||||
|
value={incompleteReason}
|
||||||
|
onChange={(e) => setIncompleteReason(e.target.value)}
|
||||||
|
placeholder="如遇阻碍请说明原因"
|
||||||
|
className="mt-1 w-full rounded-md border p-2 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">下一步计划(选填)</label>
|
||||||
|
<input
|
||||||
|
value={nextStep}
|
||||||
|
onChange={(e) => setNextStep(e.target.value)}
|
||||||
|
placeholder="后续改进或跟进计划"
|
||||||
|
className="mt-1 w-full rounded-md border p-2 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => executeMutation.mutate({ id: t.task_id, text: executeText })}
|
onClick={() => executeMutation.mutate({ id: t.task_id, data: { process_evidence: executeText, incomplete_reason: incompleteReason, next_step: nextStep } })}
|
||||||
disabled={!executeText || executeMutation.isPending}
|
disabled={!executeText || executeMutation.isPending}
|
||||||
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground disabled:opacity-50"
|
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{executeMutation.isPending ? '提交中...' : '提交反馈'}
|
{executeMutation.isPending ? '提交中...' : '提交反馈'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setActiveTaskId(null); setExecuteText('') }}
|
onClick={() => { setActiveTaskId(null); setExecuteText(''); setIncompleteReason(''); setNextStep('') }}
|
||||||
className="rounded-md border px-3 py-1 text-xs"
|
className="rounded-md border px-3 py-1 text-xs"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -511,24 +556,36 @@ export function StorePage() {
|
|||||||
<h2 className="mb-3 text-sm font-bold">已完成任务 ({doneTasks.length})</h2>
|
<h2 className="mb-3 text-sm font-bold">已完成任务 ({doneTasks.length})</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{doneTasks.map((t: any) => (
|
{doneTasks.map((t: any) => (
|
||||||
<div key={t.task_id} className="flex items-center justify-between rounded-md border p-3 opacity-70">
|
<div key={t.task_id} className="rounded-md border p-3 opacity-80">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<Badge type="priority" text={t.priority} />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm">{t.problem_indicator}</span>
|
<Badge type="priority" text={t.priority} />
|
||||||
</div>
|
<span className="text-sm font-medium">{t.problem_indicator}</span>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
{t.verification_result && <Badge type="review" text={t.verification_result} />}
|
<div className="flex items-center gap-2">
|
||||||
<Badge type="status" text={t.status} />
|
{t.verification_result && <Badge type="review" text={t.verification_result} />}
|
||||||
|
<Badge type="status" text={t.status} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{t.process_evidence && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">执行证据: {t.process_evidence}</p>
|
||||||
|
)}
|
||||||
|
{t.next_step && (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">后续计划: {t.next_step}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>)}
|
||||||
|
|
||||||
{/* ========== 门店详情区 ========== */}
|
{/* ========== 门店详情区 ========== */}
|
||||||
{sc && Number(sc.received) > 0 && (
|
{sc && Number(sc.received) > 0 && (
|
||||||
<>
|
<>
|
||||||
|
{overviewOpen && (
|
||||||
|
<>
|
||||||
{/* 核心指标 - 月度汇总 */}
|
{/* 核心指标 - 月度汇总 */}
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-8">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-8">
|
||||||
<MetricCard title="营业收入" value={sc.consumption} format="currency" />
|
<MetricCard title="营业收入" value={sc.consumption} format="currency" />
|
||||||
@@ -611,10 +668,12 @@ export function StorePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</>)}
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div className="flex gap-1 border-b">
|
<div className="flex gap-1 border-b">
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
<button key={t.key} onClick={() => setDetailTab(t.key)} className={`rounded-t-md border-b-2 px-3 py-2 text-sm font-medium transition-colors ${detailTab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}>
|
<button key={t.key} onClick={() => { setDetailTab(t.key); if (t.key !== 'overview') setOverviewOpen(false); else setOverviewOpen(true) }} className={`rounded-t-md border-b-2 px-3 py-2 text-sm font-medium transition-colors ${detailTab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'}`}>
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -877,6 +936,190 @@ export function StorePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 人力排班 Tab */}
|
||||||
|
{detailTab === 'staffing' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{!staffing ? <p className="text-sm text-muted-foreground">暂无数据</p> : (
|
||||||
|
<>
|
||||||
|
{/* 人力成本概览 */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
|
<MetricCard title="人工成本" value={staffing.summary.wage_expense} format="currency" />
|
||||||
|
<MetricCard title="人工成本率" value={staffing.summary.wage_rate_pct} format="percent" />
|
||||||
|
<MetricCard title="公司均值人工率" value={staffing.summary.avg_wage_rate} format="percent" />
|
||||||
|
<MetricCard title="人效(元/元人工)" value={staffing.summary.revenue_per_wage} format="number" />
|
||||||
|
<MetricCard title="每单人工成本" value={staffing.summary.wage_per_bill} format="currency" />
|
||||||
|
<MetricCard title="公司均值人效" value={staffing.summary.avg_rev_per_wage} format="number" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 优化建议 */}
|
||||||
|
{staffing.summary.wage_savings > 0 && (
|
||||||
|
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-blue-700">人力成本优化空间</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">建议人工成本</p>
|
||||||
|
<p className="font-bold text-blue-600">{formatCurrency(staffing.summary.suggested_wage)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">可节省金额</p>
|
||||||
|
<p className="font-bold text-green-600">{formatCurrency(staffing.summary.wage_savings)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">节省比例</p>
|
||||||
|
<p className="font-bold text-green-600">{staffing.summary.savings_pct}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 综合诊断 */}
|
||||||
|
{staffing.diagnosis?.length > 0 && (
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h2 className="mb-2 text-sm font-bold">综合诊断与建议</h2>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{staffing.diagnosis.map((d: string, i: number) => (
|
||||||
|
<li key={i} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="mt-0.5 text-blue-500">•</span>
|
||||||
|
<span className="text-foreground">{d}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-3 border-t pt-3">
|
||||||
|
<p className="mb-2 text-xs font-bold text-muted-foreground">算法说明</p>
|
||||||
|
<div className="space-y-2 text-xs text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">人工成本率判断:</span>
|
||||||
|
门店人工成本率 = 人工成本 / 实收 × 100。公司均值 = 全部门店人工成本率的平均值(排除超过100%的异常门店)。
|
||||||
|
判断规则:超过35% 严重偏高;超过公司均值+3% 偏高;否则合理。
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">可节省金额测算:</span>
|
||||||
|
建议人工率 = max(20%, 公司均值 - 3%)。建议人工成本 = 门店实收 × 建议人工率。
|
||||||
|
可节省金额 = 实际人工成本 - 建议人工成本。节省比例 = 可节省金额 / 实际人工成本 × 100。
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">餐段主力判断:</span>
|
||||||
|
各餐段账单占比 = 该餐段账单数 / 全餐段总账单数 × 100。占比超过40% 为绝对主力,超过20% 次高峰,超过5% 辅助,其余低谷。
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-foreground">按星期排班等级:</span>
|
||||||
|
建议排班比 = 该星期日均实收 / 全周日均实收 × 100。超过110% 高峰(满编),低于85% 低谷(减员),其余正常。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 按星期排班分析 */}
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-bold">按星期排班分析</h2>
|
||||||
|
{staffing.weekdays?.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={staffing.weekdays}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="weekday" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(1)}万` : v} tick={{ fontSize: 10 }} />
|
||||||
|
<Tooltip formatter={(v: any) => formatCurrency(v)} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Bar dataKey="avg_received" name="日均实收" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="max_received" name="最高实收" fill="#93c5fd" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="mt-3">
|
||||||
|
<FilterableTable
|
||||||
|
data={staffing.weekdays}
|
||||||
|
sortOptions={[
|
||||||
|
{ key: 'avg_received', label: '日均实收' },
|
||||||
|
{ key: 'avg_bills', label: '日均账单' },
|
||||||
|
]}
|
||||||
|
defaultSort="dow"
|
||||||
|
defaultOrder="asc"
|
||||||
|
columns={[
|
||||||
|
{ key: 'weekday', label: '星期' },
|
||||||
|
{ key: 'avg_received', label: '日均实收', align: 'right', render: (r: any) => formatCurrency(r.avg_received) },
|
||||||
|
{ key: 'avg_bills', label: '日均账单', align: 'right', render: (r: any) => formatNumber(r.avg_bills) },
|
||||||
|
{ key: 'max_received', label: '最高实收', align: 'right', render: (r: any) => formatCurrency(r.max_received) },
|
||||||
|
{ key: 'min_received', label: '最低实收', align: 'right', render: (r: any) => formatCurrency(r.min_received) },
|
||||||
|
{ key: 'suggested_headcount_ratio', label: '建议排班比', align: 'right', render: (r: any) => `${r.suggested_headcount_ratio}%` },
|
||||||
|
{ key: 'staffing_level', label: '排班等级', render: (r: any) => {
|
||||||
|
const colors: Record<string, string> = { '高峰': 'text-red-600 font-medium', '低谷': 'text-green-600 font-medium', '正常': '' }
|
||||||
|
return <span className={colors[r.staffing_level]}>{r.staffing_level}</span>
|
||||||
|
} },
|
||||||
|
{ key: 'staffing_action', label: '排班建议' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : <p className="text-sm text-muted-foreground">暂无数据</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 餐段排班分析 */}
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-bold">餐段排班分析</h2>
|
||||||
|
{staffing.meals?.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={staffing.meals}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="meal_period" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||||
|
<Tooltip formatter={(v: any, n: string) => n === '实收' ? formatCurrency(v) : v} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||||
|
<Bar dataKey="received" name="实收" fill="#eab308" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="bill_count" name="账单数" fill="#a855f7" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="mt-3">
|
||||||
|
<FilterableTable
|
||||||
|
data={staffing.meals}
|
||||||
|
sortOptions={[
|
||||||
|
{ key: 'received', label: '实收' },
|
||||||
|
{ key: 'bill_count', label: '账单数' },
|
||||||
|
{ key: 'bill_share_pct', label: '占比' },
|
||||||
|
]}
|
||||||
|
defaultSort="received"
|
||||||
|
defaultOrder="desc"
|
||||||
|
columns={[
|
||||||
|
{ key: 'meal_period', label: '餐段' },
|
||||||
|
{ key: 'bill_count', label: '账单数', align: 'right', render: (r: any) => formatNumber(r.bill_count) },
|
||||||
|
{ key: 'received', label: '实收', align: 'right', render: (r: any) => formatCurrency(r.received) },
|
||||||
|
{ key: 'avg_bill', label: '客单价', align: 'right', render: (r: any) => formatCurrency(r.avg_bill) },
|
||||||
|
{ key: 'bill_share_pct', label: '账单占比', align: 'right', render: (r: any) => `${r.bill_share_pct}%` },
|
||||||
|
{ key: 'staffing_level', label: '排班等级', render: (r: any) => {
|
||||||
|
const colors: Record<string, string> = { '主力时段': 'text-red-600 font-medium', '次高峰': 'text-orange-600 font-medium', '辅助时段': 'text-blue-600', '低谷时段': 'text-green-600 font-medium' }
|
||||||
|
return <span className={colors[r.staffing_level]}>{r.staffing_level}</span>
|
||||||
|
} },
|
||||||
|
{ key: 'staffing_action', label: '排班建议' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : <p className="text-sm text-muted-foreground">暂无数据</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日营收明细 */}
|
||||||
|
{staffing.daily?.length > 0 && (
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-bold">日营收明细(排班参考)</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<LineChart data={staffing.daily}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="business_date" tickFormatter={(v) => v?.substring(5, 10)} tick={{ fontSize: 10 }} />
|
||||||
|
<YAxis tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} />
|
||||||
|
<Tooltip labelFormatter={(v) => v?.substring(0, 10)} formatter={(v: any) => formatCurrency(v)} />
|
||||||
|
<Line type="monotone" dataKey="received" name="实收" stroke="#3b82f6" dot={false} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -730,6 +730,201 @@ router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
|
|||||||
} catch (err: any) { sendError(res, err.message) }
|
} catch (err: any) { sendError(res, err.message) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 门店人力排班分析
|
||||||
|
router.get('/stores/:code/staffing', async (req: AuthRequest, res) => {
|
||||||
|
try {
|
||||||
|
const code = req.params.code
|
||||||
|
const monthStart = parseMonth(req)
|
||||||
|
|
||||||
|
// 1. 门店人工成本概览
|
||||||
|
const expenseResult = await query(`
|
||||||
|
SELECT
|
||||||
|
sales_store_code, sales_store_name,
|
||||||
|
wage_expense,
|
||||||
|
wage_rate_pct,
|
||||||
|
received,
|
||||||
|
bill_count,
|
||||||
|
area_sqm,
|
||||||
|
round(received / nullif(wage_expense, 0), 2) AS revenue_per_wage,
|
||||||
|
round(wage_expense / nullif(bill_count, 0), 2) AS wage_per_bill
|
||||||
|
FROM analytics.mv_store_operating_expense_monthly
|
||||||
|
WHERE report_month = $1::date AND sales_store_code = $2
|
||||||
|
`, [monthStart, code])
|
||||||
|
|
||||||
|
// 2. 公司均值
|
||||||
|
const avgResult = await query(`
|
||||||
|
SELECT
|
||||||
|
round(avg(wage_expense), 2) AS avg_wage,
|
||||||
|
round(avg(wage_rate_pct), 2) AS avg_wage_rate,
|
||||||
|
round(avg(received), 2) AS avg_received,
|
||||||
|
round(avg(bill_count), 0) AS avg_bills,
|
||||||
|
round(avg(received / nullif(wage_expense, 0)), 2) AS avg_rev_per_wage
|
||||||
|
FROM analytics.mv_store_operating_expense_monthly
|
||||||
|
WHERE report_month = $1::date AND wage_expense > 0 AND wage_rate_pct <= 100
|
||||||
|
`, [monthStart])
|
||||||
|
|
||||||
|
// 3. 按星期分析营收和账单
|
||||||
|
const weekdayResult = await query(`
|
||||||
|
SELECT
|
||||||
|
extract(dow FROM business_date)::int AS dow,
|
||||||
|
CASE extract(dow FROM business_date)
|
||||||
|
WHEN 0 THEN '周日' WHEN 1 THEN '周一' WHEN 2 THEN '周二'
|
||||||
|
WHEN 3 THEN '周三' WHEN 4 THEN '周四' WHEN 5 THEN '周五'
|
||||||
|
WHEN 6 THEN '周六'
|
||||||
|
END AS weekday,
|
||||||
|
count(*) AS days,
|
||||||
|
round(avg(received), 2) AS avg_received,
|
||||||
|
round(avg(bill_count), 0) AS avg_bills,
|
||||||
|
round(max(received), 2) AS max_received,
|
||||||
|
round(min(received), 2) AS min_received,
|
||||||
|
round(sum(received), 2) AS total_received,
|
||||||
|
round(sum(bill_count), 0) AS total_bills
|
||||||
|
FROM analytics.v_store_daily
|
||||||
|
WHERE store_code = $1
|
||||||
|
AND business_date >= $2::date AND business_date < $2::date + interval '1 month'
|
||||||
|
GROUP BY 1, 2
|
||||||
|
ORDER BY 1
|
||||||
|
`, [code, monthStart])
|
||||||
|
|
||||||
|
// 4. 餐段分布
|
||||||
|
const mealResult = await query(`
|
||||||
|
SELECT meal_period, bill_count, received, round(avg_bill, 2) AS avg_bill
|
||||||
|
FROM analytics.v_store_meal_opportunity
|
||||||
|
WHERE store_code = $1
|
||||||
|
ORDER BY received DESC
|
||||||
|
`, [code])
|
||||||
|
|
||||||
|
// 5. 日营收明细(用于排班热力图)
|
||||||
|
const dailyResult = await query(`
|
||||||
|
SELECT
|
||||||
|
business_date,
|
||||||
|
to_char(business_date, 'Dy') AS dow_short,
|
||||||
|
extract(dow FROM business_date)::int AS dow,
|
||||||
|
round(received, 2) AS received,
|
||||||
|
bill_count,
|
||||||
|
round(received / nullif(bill_count, 0), 2) AS avg_bill_value
|
||||||
|
FROM analytics.v_store_daily
|
||||||
|
WHERE store_code = $1
|
||||||
|
AND business_date >= $2::date AND business_date < $2::date + interval '1 month'
|
||||||
|
ORDER BY business_date
|
||||||
|
`, [code, monthStart])
|
||||||
|
|
||||||
|
const store = expenseResult.rows[0] || {}
|
||||||
|
const avg = avgResult.rows[0] || {}
|
||||||
|
const weekdays = weekdayResult.rows
|
||||||
|
const meals = mealResult.rows
|
||||||
|
const daily = dailyResult.rows
|
||||||
|
|
||||||
|
// 6. 计算排班建议
|
||||||
|
const avgWageRate = Number(avg.avg_wage_rate) || 25
|
||||||
|
const storeWageRate = Number(store.wage_rate_pct) || 0
|
||||||
|
const suggestedWageRate = Math.max(20, Math.round((avgWageRate - 3) * 10) / 10)
|
||||||
|
const suggestedWage = Number(store.received || 0) * suggestedWageRate / 100
|
||||||
|
const wageSavings = Number(store.wage_expense || 0) - suggestedWage
|
||||||
|
const savingsPct = Number(store.wage_expense) > 0
|
||||||
|
? Math.round((wageSavings / Number(store.wage_expense)) * 100 * 10) / 10
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// 按星期分布生成排班建议
|
||||||
|
const maxDayRev = Math.max(...weekdays.map((w: any) => Number(w.avg_received)))
|
||||||
|
const minDayRev = Math.min(...weekdays.map((w: any) => Number(w.avg_received)))
|
||||||
|
const avgDayRev = weekdays.reduce((s: number, w: any) => s + Number(w.avg_received), 0) / (weekdays.length || 1)
|
||||||
|
|
||||||
|
const staffingSuggestions = weekdays.map((w: any) => {
|
||||||
|
const ratio = Number(w.avg_received) / avgDayRev
|
||||||
|
let level: string, action: string
|
||||||
|
if (ratio > 1.1) {
|
||||||
|
level = '高峰'
|
||||||
|
action = '建议满编排班,确保出餐效率'
|
||||||
|
} else if (ratio < 0.85) {
|
||||||
|
level = '低谷'
|
||||||
|
action = '建议减少1-2人排班,控制人力成本'
|
||||||
|
} else {
|
||||||
|
level = '正常'
|
||||||
|
action = '建议标准排班'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...w,
|
||||||
|
staffing_level: level,
|
||||||
|
staffing_action: action,
|
||||||
|
suggested_headcount_ratio: Math.round(ratio * 100),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 餐段排班建议
|
||||||
|
const totalMealBills = meals.reduce((s: number, m: any) => s + Number(m.bill_count), 0)
|
||||||
|
const mealSuggestions = meals.map((m: any) => {
|
||||||
|
const share = totalMealBills > 0 ? Number(m.bill_count) / totalMealBills : 0
|
||||||
|
let level: string, action: string
|
||||||
|
if (share > 0.4) {
|
||||||
|
level = '主力时段'
|
||||||
|
action = '建议安排全量人员,前厅后厨满编'
|
||||||
|
} else if (share > 0.2) {
|
||||||
|
level = '次高峰'
|
||||||
|
action = '建议安排80%人员,保证出餐速度'
|
||||||
|
} else if (share > 0.05) {
|
||||||
|
level = '辅助时段'
|
||||||
|
action = '建议安排50%人员,控制人力投入'
|
||||||
|
} else {
|
||||||
|
level = '低谷时段'
|
||||||
|
action = '建议安排值班人员即可,可安排备料和清洁'
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
bill_share_pct: Math.round(share * 1000) / 10,
|
||||||
|
staffing_level: level,
|
||||||
|
staffing_action: action,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 综合诊断
|
||||||
|
const diagnosis: string[] = []
|
||||||
|
if (storeWageRate > 35) {
|
||||||
|
diagnosis.push(`人工成本率 ${storeWageRate}% 严重偏高,公司均值 ${avgWageRate}%,需立即优化排班`)
|
||||||
|
} else if (storeWageRate > avgWageRate + 3) {
|
||||||
|
diagnosis.push(`人工成本率 ${storeWageRate}% 高于公司均值 ${avgWageRate}%,建议优化排班结构`)
|
||||||
|
} else if (storeWageRate > 0) {
|
||||||
|
diagnosis.push(`人工成本率 ${storeWageRate}% 在合理范围内,公司均值 ${avgWageRate}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxDayRev > 0 && minDayRev > 0 && maxDayRev / minDayRev > 1.5) {
|
||||||
|
const fmt = (v: number) => `¥${Math.round(v).toLocaleString()}`
|
||||||
|
diagnosis.push(`日营收波动大(最高 ${fmt(maxDayRev)} vs 最低 ${fmt(minDayRev)}),建议差异化排班`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wageSavings > 0) {
|
||||||
|
const fmt = (v: number) => `¥${Math.round(v).toLocaleString()}`
|
||||||
|
diagnosis.push(`按建议人工率 ${suggestedWageRate}% 测算,可节省人工成本 ${fmt(wageSavings)}(${savingsPct}%)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const peakMeal = mealSuggestions[0]
|
||||||
|
if (peakMeal && peakMeal.bill_share_pct > 40) {
|
||||||
|
diagnosis.push(`${peakMeal.meal_period}为绝对主力(占 ${peakMeal.bill_share_pct}%),建议集中人力保障`)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSuccess(res, {
|
||||||
|
store,
|
||||||
|
company_avg: avg,
|
||||||
|
weekdays: staffingSuggestions,
|
||||||
|
meals: mealSuggestions,
|
||||||
|
daily,
|
||||||
|
summary: {
|
||||||
|
wage_expense: store.wage_expense,
|
||||||
|
wage_rate_pct: store.wage_rate_pct,
|
||||||
|
received: store.received,
|
||||||
|
suggested_wage: Math.round(suggestedWage * 100) / 100,
|
||||||
|
wage_savings: Math.round(wageSavings * 100) / 100,
|
||||||
|
savings_pct: savingsPct,
|
||||||
|
revenue_per_wage: store.revenue_per_wage,
|
||||||
|
wage_per_bill: store.wage_per_bill,
|
||||||
|
avg_wage_rate: avg.avg_wage_rate,
|
||||||
|
avg_rev_per_wage: avg.avg_rev_per_wage,
|
||||||
|
},
|
||||||
|
diagnosis,
|
||||||
|
})
|
||||||
|
} catch (err: any) { sendError(res, err.message) }
|
||||||
|
})
|
||||||
|
|
||||||
// 区域汇总
|
// 区域汇总
|
||||||
router.get('/region/summary', async (req: AuthRequest, res) => {
|
router.get('/region/summary', async (req: AuthRequest, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user