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 },
|
||||
]
|
||||
|
||||
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks'
|
||||
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'staffing'
|
||||
|
||||
const TABS: { key: DetailTab; label: string }[] = [
|
||||
{ key: 'overview', label: '概览' },
|
||||
@@ -31,12 +31,15 @@ const TABS: { key: DetailTab; label: string }[] = [
|
||||
{ key: 'cost', label: '成本分析' },
|
||||
{ key: 'member', label: '会员分析' },
|
||||
{ key: 'anomaly', label: '异常账单' },
|
||||
{ key: 'staffing', label: '人力排班' },
|
||||
]
|
||||
|
||||
export function StorePage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [executeText, setExecuteText] = useState('')
|
||||
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
|
||||
const [incompleteReason, setIncompleteReason] = useState('')
|
||||
const [nextStep, setNextStep] = useState('')
|
||||
const userStoreCode = (() => {
|
||||
try {
|
||||
const u = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
@@ -46,6 +49,7 @@ export function StorePage() {
|
||||
const [storeCode, setStoreCode] = useState(userStoreCode || '1111')
|
||||
const [month, setMonth] = useState('2026-04')
|
||||
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
|
||||
const [overviewOpen, setOverviewOpen] = useState(true)
|
||||
|
||||
const { data: storesData, isLoading: storesLoading } = useQuery({
|
||||
queryKey: ['stores-list', month],
|
||||
@@ -129,6 +133,12 @@ export function StorePage() {
|
||||
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 anomalies = card?.anomalies || []
|
||||
const tasks = (tasksData as any)?.data || []
|
||||
@@ -152,6 +162,7 @@ export function StorePage() {
|
||||
const member = (memberData as any)?.data
|
||||
const anomalyDetailRows = ((anomalyDetailData as any)?.data) || []
|
||||
const anomalyDetailTotal = (anomalyDetailData as any)?.meta?.total || anomalyDetailRows.length
|
||||
const staffing = (staffingData as any)?.data
|
||||
|
||||
const sc = sd?.scorecard
|
||||
const action = sd?.action
|
||||
@@ -210,7 +221,7 @@ export function StorePage() {
|
||||
const anomalyCount = allAnomalyItems.filter((i: any) => i.is_anomaly).length
|
||||
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['store', storeCode, 'tasks'] })
|
||||
setExecuteText('')
|
||||
@@ -246,6 +257,19 @@ export function StorePage() {
|
||||
</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="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>
|
||||
|
||||
{activeTaskId === t.task_id ? (
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
value={executeText}
|
||||
onChange={(e) => setExecuteText(e.target.value)}
|
||||
placeholder="填写执行结果和过程证据..."
|
||||
className="mb-2 w-full rounded-md border p-2 text-xs"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">执行过程与证据 *</label>
|
||||
<textarea
|
||||
value={executeText}
|
||||
onChange={(e) => setExecuteText(e.target.value)}
|
||||
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">
|
||||
<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}
|
||||
className="rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
{executeMutation.isPending ? '提交中...' : '提交反馈'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTaskId(null); setExecuteText('') }}
|
||||
onClick={() => { setActiveTaskId(null); setExecuteText(''); setIncompleteReason(''); setNextStep('') }}
|
||||
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>
|
||||
<div className="space-y-2">
|
||||
{doneTasks.map((t: any) => (
|
||||
<div key={t.task_id} className="flex items-center justify-between rounded-md border p-3 opacity-70">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge type="priority" text={t.priority} />
|
||||
<span className="text-sm">{t.problem_indicator}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{t.verification_result && <Badge type="review" text={t.verification_result} />}
|
||||
<Badge type="status" text={t.status} />
|
||||
<div key={t.task_id} className="rounded-md border p-3 opacity-80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge type="priority" text={t.priority} />
|
||||
<span className="text-sm font-medium">{t.problem_indicator}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{t.verification_result && <Badge type="review" text={t.verification_result} />}
|
||||
<Badge type="status" text={t.status} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
</>)}
|
||||
|
||||
{/* ========== 门店详情区 ========== */}
|
||||
{sc && Number(sc.received) > 0 && (
|
||||
<>
|
||||
{overviewOpen && (
|
||||
<>
|
||||
{/* 核心指标 - 月度汇总 */}
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-8">
|
||||
<MetricCard title="营业收入" value={sc.consumption} format="currency" />
|
||||
@@ -611,10 +668,12 @@ export function StorePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>)}
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{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}
|
||||
</button>
|
||||
))}
|
||||
@@ -877,6 +936,190 @@ export function StorePage() {
|
||||
</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>
|
||||
|
||||
@@ -730,6 +730,201 @@ router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
|
||||
} 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) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user