feat: 统一SearchSelect下拉组件 + 修复营销方案数据 + 平台经济性增强

- 创建SearchSelect搜索下拉组件,选项≤8自动隐藏搜索框
- 全站替换原生select为SearchSelect(13处下拉)
- 修复/revenue/store-ranking优惠率字段名不匹配(discount_rate_pct→avg_discount_rate_pct)
- RevenuePage门店营收明细改用FilterableTable支持筛选排序分页
- 修复/marketing/plans按门店分组改为按marketing_plan分组
- 营销方案分析补充消费额、优惠额列和Top5概览卡片
- PlatformPage门店平台经济性增加门店搜索、经济性优列(平台分色)
- 平台成本率算法说明显示在标题下方
This commit is contained in:
freedakgmail
2026-08-01 23:08:34 +08:00
parent d724a565f5
commit ed7beb7e47
20 changed files with 1035 additions and 286 deletions
+1 -1
View File
@@ -5,11 +5,11 @@ import { Layout } from '@/components/Layout'
import { DashboardPage } from '@/pages/DashboardPage'
import { TasksPage } from '@/pages/TasksPage'
import { TaskDetailPage } from '@/pages/TaskDetailPage'
import { StorePage } from '@/pages/StorePage'
import { StoreDetailPage } from '@/pages/StoreDetailPage'
import { MonthlyReviewPage } from '@/pages/MonthlyReviewPage'
import { IndicatorsPage } from '@/pages/IndicatorsPage'
import { RegionalPage } from '@/pages/RegionalPage'
import { StorePage } from '@/pages/StorePage'
import { SKUPage } from '@/pages/SKUPage'
import { CostPage } from '@/pages/CostPage'
import { CostAnalysisPage } from '@/pages/CostAnalysisPage'
+15 -8
View File
@@ -1,6 +1,7 @@
import { useState, useMemo } from 'react'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchSelect } from '@/components/SearchSelect'
import type { ReactNode } from 'react'
const DEFAULT_PAGE_SIZE = 20
@@ -145,16 +146,22 @@ export function FilterableTable({
</button>
))
) : filterKey && (
<select value={filter} onChange={(e) => setFilterVal(e.target.value)} className="rounded border px-2 py-1 text-sm">
<option value="">{filterLabel || '全部'}</option>
{filterValues.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<SearchSelect
value={filter}
onChange={setFilterVal}
options={filterValues}
emptyLabel={filterLabel || '全部'}
className="min-w-[140px]"
/>
)}
{statusFilterKey && statusOptions && (
<select value={currentStatusFilter} onChange={(e) => { if (serverSide && onStatusFilterChange) { onStatusFilterChange(e.target.value) } else { setStatusFilter(e.target.value) }; setPage(1) }} className="rounded border px-2 py-1 text-sm">
<option value="">{statusFilterLabel || '全部状态'}</option>
{statusOptions.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<SearchSelect
value={currentStatusFilter}
onChange={(v) => { if (serverSide && onStatusFilterChange) { onStatusFilterChange(v) } else { setStatusFilter(v) }; setPage(1) }}
options={statusOptions}
emptyLabel={statusFilterLabel || '全部状态'}
className="min-w-[120px]"
/>
)}
<span className="mx-1 text-muted-foreground">|</span>
{sortOptions.map(s => (
+110
View File
@@ -0,0 +1,110 @@
import { useState, useRef, useEffect, useMemo } from 'react'
interface SearchSelectOption {
value: string
label: string
}
interface SearchSelectProps {
value: string
onChange: (value: string) => void
options: (string | SearchSelectOption)[]
placeholder?: string
emptyLabel?: string
className?: string
searchThreshold?: number
size?: 'sm' | 'md'
}
export function SearchSelect({
value,
onChange,
options,
placeholder = '搜索...',
emptyLabel = '全部',
className = '',
searchThreshold = 8,
size = 'sm',
}: SearchSelectProps) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef<HTMLDivElement>(null)
const normalizedOptions = useMemo<SearchSelectOption[]>(() => {
return options.map(o => typeof o === 'string' ? { value: o, label: o } : o)
}, [options])
const selectedLabel = normalizedOptions.find(o => o.value === value)?.label || emptyLabel
const filtered = useMemo(() => {
if (!search) return normalizedOptions
return normalizedOptions.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
}, [normalizedOptions, search])
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
setSearch('')
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const showSearch = normalizedOptions.length > searchThreshold
const sizeClass = size === 'md' ? 'px-3 py-1.5' : 'px-2 py-1'
return (
<div ref={ref} className={`relative ${className}`}>
<button
type="button"
onClick={() => setOpen(!open)}
className={`flex w-full items-center justify-between rounded border bg-card ${sizeClass} text-sm hover:bg-muted/30`}
>
<span className={value ? '' : 'text-muted-foreground'}>{selectedLabel}</span>
<svg className={`ml-1 h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-50 mt-1 w-full min-w-[180px] rounded border bg-card shadow-lg">
{showSearch && (
<div className="p-2">
<input
type="text"
autoFocus
placeholder={placeholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
)}
<div className="max-h-60 overflow-y-auto">
<button
type="button"
onClick={() => { onChange(''); setOpen(false); setSearch('') }}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm hover:bg-muted/50 ${!value ? 'bg-muted/30 font-medium' : ''}`}
>
{emptyLabel}
</button>
{filtered.map(o => (
<button
key={o.value}
type="button"
onClick={() => { onChange(o.value); setOpen(false); setSearch('') }}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm hover:bg-muted/50 ${value === o.value ? 'bg-muted/30 font-medium' : ''}`}
>
{o.label}
</button>
))}
{filtered.length === 0 && (
<div className="px-3 py-2 text-center text-xs text-muted-foreground"></div>
)}
</div>
</div>
)}
</div>
)
}
@@ -4,6 +4,7 @@ import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { SearchSelect } from '@/components/SearchSelect'
import { formatCurrency, formatPercent, formatNumber, priorityColor } from '@/lib/utils'
import { useState } from 'react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, Legend } from 'recharts'
@@ -116,13 +117,13 @@ export function AdjustmentTab({ month }: { month: string }) {
{generateDiagnosis.isPending ? '生成中...' : '生成诊断快照'}
</button>
{generateDiagnosis.data && <span className="text-sm text-green-600"> {(generateDiagnosis.data as any)?.data?.generated} </span>}
<select value={diagPriority} onChange={(e) => { setDiagPriority(e.target.value); setDiagPage(1) }} className="rounded border px-2 py-1.5 text-sm">
<option value=""></option>
<option value="P0">P0</option>
<option value="P1">P1</option>
<option value="P2">P2</option>
<option value="P3">P3</option>
</select>
<SearchSelect
value={diagPriority}
onChange={(v) => { setDiagPriority(v); setDiagPage(1) }}
options={['P0', 'P1', 'P2', 'P3']}
emptyLabel="全部优先级"
className="min-w-[120px]"
/>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
@@ -177,13 +178,20 @@ export function AdjustmentTab({ month }: { month: string }) {
<div><label className="text-xs text-muted-foreground"></label><input value={formData.dish_name || ''} disabled className="mt-1 w-full rounded border px-3 py-1.5 text-sm bg-muted" /></div>
<div>
<label className="text-xs text-muted-foreground"></label>
<select value={formData.adjustment_type || ''} onChange={(e) => setFormData({ ...formData, adjustment_type: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm">
<option value="price"></option>
<option value="recipe"></option>
<option value="portion"></option>
<option value="delisting"></option>
<option value="relaunch"></option>
</select>
<SearchSelect
value={formData.adjustment_type || ''}
onChange={(v) => setFormData({ ...formData, adjustment_type: v })}
options={[
{ value: 'price', label: '涨价' },
{ value: 'recipe', label: '改配方' },
{ value: 'portion', label: '减份量' },
{ value: 'delisting', label: '下架' },
{ value: 'relaunch', label: '重新上架' },
]}
emptyLabel="选择类型"
size="md"
className="mt-1 w-full"
/>
</div>
<div><label className="text-xs text-muted-foreground"></label><input type="date" value={formData.effective_date || ''} onChange={(e) => setFormData({ ...formData, effective_date: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" /></div>
<div><label className="text-xs text-muted-foreground">(%)</label><input type="number" value={formData.before_theoretical_margin || ''} disabled className="mt-1 w-full rounded border px-3 py-1.5 text-sm bg-muted" /></div>
@@ -205,13 +213,18 @@ export function AdjustmentTab({ month }: { month: string }) {
{subTab === 'adjustment' && (
<>
<div className="flex flex-wrap items-center gap-2">
<select value={adjStatus} onChange={(e) => { setAdjStatus(e.target.value); setAdjPage(1) }} className="rounded border px-2 py-1.5 text-sm">
<option value=""></option>
<option value="planned"></option>
<option value="executing"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</select>
<SearchSelect
value={adjStatus}
onChange={(v) => { setAdjStatus(v); setAdjPage(1) }}
options={[
{ value: 'planned', label: '待执行' },
{ value: 'executing', label: '执行中' },
{ value: 'completed', label: '已完成' },
{ value: 'cancelled', label: '已取消' },
]}
emptyLabel="全部状态"
className="min-w-[120px]"
/>
</div>
{la ? <LoadingSpinner text="加载调整记录..." /> : (
<FilterableTable
@@ -3,6 +3,7 @@ import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { SearchSelect } from '@/components/SearchSelect'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { useState } from 'react'
import { ScatterChart, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ZAxis } from 'recharts'
@@ -34,7 +35,7 @@ export function ProfitabilityTab({ month }: { month: string }) {
const profitMeta = (profit as any)?.meta || { page, pageSize: PAGE_SIZE, total: 0 }
const pricingData = (pricing as any)?.data || []
const categories = [...new Set(matrixData.map((r: any) => r.category_level1).filter(Boolean))]
const categories: string[] = Array.from(new Set(matrixData.map((r: any) => r.category_level1 as string).filter(Boolean)))
const menuTypeColor = (type: string) => {
const c: Record<string, string> = {
@@ -86,10 +87,13 @@ export function ProfitabilityTab({ month }: { month: string }) {
<CollapsibleSection title="菜品盈利明细" subtitle="支持筛选和排序">
<div className="mb-3 flex items-center gap-2">
<label className="text-xs text-muted-foreground">:</label>
<select value={category} onChange={(e) => { setCategory(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-xs">
<option value=""></option>
{categories.map((c: any) => <option key={c} value={c}>{c}</option>)}
</select>
<SearchSelect
value={category}
onChange={(v) => { setCategory(v); setPage(1) }}
options={categories}
emptyLabel="全部"
className="min-w-[100px]"
/>
</div>
<FilterableTable
data={profitData}
@@ -4,6 +4,7 @@ import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { SearchSelect } from '@/components/SearchSelect'
import { formatNumber } from '@/lib/utils'
const ACTION_COLORS: Record<string, string> = {
@@ -87,9 +88,13 @@ export function SchedulingSuggestionTab({ month }: { month: string }) {
<div className="mb-3 flex gap-3 items-center flex-wrap">
<div className="flex gap-2 items-center">
<label className="text-sm text-muted-foreground"></label>
<select value={storeName} onChange={(e) => setStoreName(e.target.value)} className="border rounded px-2 py-1 text-sm">
{stores.map((s: any) => <option key={s.store_name} value={s.store_name}>{s.store_name}</option>)}
</select>
<SearchSelect
value={storeName}
onChange={setStoreName}
options={stores.map((s: any) => s.store_name)}
emptyLabel="选择门店"
className="min-w-[140px]"
/>
</div>
<div className="flex gap-2 items-center">
<label className="text-sm text-muted-foreground"></label>
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { SearchSelect } from '@/components/SearchSelect'
import { formatCurrency, formatNumber } from '@/lib/utils'
const PAGE_SIZE = 20
@@ -204,16 +205,20 @@ export function StaffingForecastTab({ month }: { month: string }) {
{/* 筛选排序 */}
<div className="flex gap-2 flex-wrap items-center">
<select value={storeFilter} onChange={(e) => { setStoreFilter(e.target.value); setPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select value={actionFilter} onChange={(e) => { setActionFilter(e.target.value); setPage(1) }} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
<option value="建议招聘"></option>
<option value="建议优化"></option>
<option value="关注"></option>
</select>
<SearchSelect
value={storeFilter}
onChange={(v) => { setStoreFilter(v); setPage(1) }}
options={storeNames}
emptyLabel="全部门店"
className="min-w-[140px]"
/>
<SearchSelect
value={actionFilter}
onChange={(v) => { setActionFilter(v); setPage(1) }}
options={['建议招聘', '建议优化', '关注', '维持']}
emptyLabel="全部动作"
className="min-w-[120px]"
/>
<span className="mx-2 text-muted-foreground">|</span>
{sorts.map(s => (
<button key={s.key} onClick={() => { setSort(s.key); setPage(1) }} className={`px-3 py-1 rounded text-xs ${sort === s.key ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>{s.label}</button>
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { SearchSelect } from '@/components/SearchSelect'
import { formatNumber } from '@/lib/utils'
const STATUS_COLORS: Record<string, string> = {
@@ -37,9 +38,13 @@ export function StaffingMatchTab({ month }: { month: string }) {
<CollapsibleSection title="排班匹配度分析" subtitle="各时段在岗人数 vs 客流账单量,识别排班错配">
<div className="mb-3 flex gap-2 items-center">
<label className="text-sm text-muted-foreground"></label>
<select value={storeName} onChange={(e) => setStoreName(e.target.value)} className="border rounded px-2 py-1 text-sm">
{stores.map((s: any) => <option key={s.store_name} value={s.store_name}>{s.store_name}</option>)}
</select>
<SearchSelect
value={storeName}
onChange={setStoreName}
options={stores.map((s: any) => s.store_name)}
emptyLabel="选择门店"
className="min-w-[140px]"
/>
</div>
{isLoading ? <LoadingSpinner text="加载排班匹配数据..." /> : (
@@ -4,6 +4,7 @@ import api from '@/lib/api'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { FilterableTable } from '@/components/FilterableTable'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { SearchSelect } from '@/components/SearchSelect'
import { formatNumber } from '@/lib/utils'
export function TrafficHeatmapTab({ month }: { month: string }) {
@@ -85,10 +86,13 @@ export function TrafficHeatmapTab({ month }: { month: string }) {
<div className="space-y-4">
<CollapsibleSection title="小时客流热力图" subtitle="门店×小时账单量分布,红色为高峰时段">
<div className="mb-3 flex gap-2 items-center">
<select value={storeName} onChange={(e) => setStoreName(e.target.value)} className="border rounded px-2 py-1 text-sm">
<option value=""></option>
{storeNames.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<SearchSelect
value={storeName}
onChange={setStoreName}
options={storeNames}
emptyLabel="全部门店"
className="min-w-[140px]"
/>
<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>
+2 -2
View File
@@ -78,7 +78,7 @@ export function CentralKitchenPage() {
</div>
{/* 核心指标卡片 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-7">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-4">
<MetricCard title="产品数" value={summary.product_count} unit="个" status="good" />
<MetricCard title="完工入库量" value={summary.total_inbound_qty} unit={products[0]?.unit || ''} format="number" />
<MetricCard title="理论材料成本" value={summary.theoretical_cost} format="currency" />
@@ -90,7 +90,7 @@ export function CentralKitchenPage() {
{/* 成本对账瀑布 */}
<CollapsibleSection title="成本对账瀑布" subtitle="理论→标准→实际→+制造费用→全成本→入库价值→制造毛利">
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4 lg:grid-cols-6">
<div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-3">
<MetricCard title="理论成本" value={reconciliation.theoretical_cost} format="currency" />
<MetricCard title="标准成本" value={reconciliation.standard_cost} format="currency" />
<MetricCard title="实际材料" value={reconciliation.material_actual_cost} format="currency" status={varianceColor(summary.efficiency_variance_pct)} />
+46 -1
View File
@@ -92,9 +92,11 @@ export function PlatformPage() {
</CollapsibleSection>
{/* 门店平台经济性 */}
<CollapsibleSection title={`门店平台经济性 (${storesWithPlatform.length})`} subtitle="各门店三平台实收与成本率">
<CollapsibleSection title={`门店平台经济性 (${storesWithPlatform.length})`} subtitle="成本率 = (折扣 + 佣金) ÷ 实收 × 100% | ≥38%红色警示,35-38%黄色关注,<35%绿色健康">
<FilterableTable
data={storesWithPlatform}
filterKey="store_name"
filterLabel="全部门店"
sortOptions={[
{ key: 'meituan_received', label: '美团实收' },
{ key: 'meituan_cost_rate_pct', label: '美团成本率' },
@@ -121,16 +123,57 @@ export function PlatformPage() {
const v = Number(r.jd_cost_rate_pct || 0)
return v > 0 ? <span className={v >= 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}%</span> : <span className="text-muted-foreground">-</span>
}},
{ key: 'best_platform', label: '经济性优', align: 'center', render: (r) => {
const platforms: { name: string; rate: number; received: number }[] = []
if (Number(r.meituan_received) > 0) platforms.push({ name: '美团', rate: Number(r.meituan_cost_rate_pct || 0), received: Number(r.meituan_received) })
if (Number(r.taobao_received) > 0) platforms.push({ name: '淘宝', rate: Number(r.taobao_cost_rate_pct || 0), received: Number(r.taobao_received) })
if (Number(r.jd_received) > 0) platforms.push({ name: '京东', rate: Number(r.jd_cost_rate_pct || 0), received: Number(r.jd_received) })
if (platforms.length === 0) return <span className="text-muted-foreground">-</span>
const best = platforms.reduce((a, b) => a.rate < b.rate ? a : b)
const worst = platforms.reduce((a, b) => a.rate > b.rate ? a : b)
const gap = (worst.rate - best.rate).toFixed(1)
const color = best.name === '美团' ? 'text-blue-600' : best.name === '淘宝' ? 'text-green-600' : 'text-purple-600'
return <span className={`font-medium ${color}`}>{best.name} <span className="text-xs text-muted-foreground">(-{gap}%)</span></span>
}},
]}
/>
</CollapsibleSection>
{/* 营销方案ROI */}
<CollapsibleSection title={`营销方案分析 (${marketingRows.length})`} subtitle="各营销方案的订单、实收、优惠率与毛利率">
{marketingRows.length > 0 && (
<div className="mb-3 grid grid-cols-1 gap-2 md:grid-cols-5">
{marketingRows.slice(0, 5).map((r: any, i: number) => {
const dr = Number(r.discount_rate_pct || 0)
const mr = Number(r.theoretical_margin_pct || 0)
return (
<div key={r.marketing_plan} className="rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">{i + 1}</span>
<span className="text-sm font-medium truncate">{r.marketing_plan}</span>
</div>
<div className="mt-2 space-y-1 text-xs">
<div className="flex justify-between"><span className="text-muted-foreground"></span><span className="font-medium">{formatCurrency(r.received)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span>{formatCurrency(r.consumption)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span className="text-red-600">{formatCurrency(r.discounts)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span>{formatNumber(r.bill_count)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span>{formatCurrency(r.avg_bill_value)}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span className={dr >= 25 ? 'font-medium text-red-600' : dr >= 15 ? 'text-yellow-600' : 'text-green-600'}>{dr.toFixed(1)}%</span></div>
<div className="flex justify-between"><span className="text-muted-foreground"></span><span className={mr >= 70 ? 'text-green-600' : mr >= 60 ? 'text-yellow-600' : 'text-red-600'}>{mr.toFixed(1)}%</span></div>
</div>
</div>
)
})}
</div>
)}
<FilterableTable
data={marketingRows}
filterKey="marketing_plan"
filterLabel="全部方案"
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'consumption', label: '消费额' },
{ key: 'discounts', label: '优惠额' },
{ key: 'bill_count', label: '账单数' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'discount_rate_pct', label: '优惠率' },
@@ -141,6 +184,8 @@ export function PlatformPage() {
columns={[
{ key: 'marketing_plan', label: '营销方案' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discounts', label: '优惠额', align: 'right', render: (r) => formatCurrency(r.discounts) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'discount_rate_pct', label: '优惠率', align: 'right', render: (r) => {
+26 -38
View File
@@ -5,6 +5,7 @@ import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { FilterableTable } from '@/components/FilterableTable'
import { formatNumber, formatCurrency, formatPercent } from '@/lib/utils'
import { BarChart3, TrendingDown, Users, Receipt } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
@@ -282,44 +283,31 @@ export function RevenuePage() {
{/* 门店营收明细表 */}
<CollapsibleSection title="门店营收明细" subtitle="全部门店营收排名">
<div className="overflow-auto" style={{ maxHeight: 400 }}>
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted">
<tr>
<th className="text-left p-2"></th>
<th className="text-left p-2"></th>
<th className="text-right p-2"></th>
<th className="text-right p-2"></th>
<th className="text-right p-2"></th>
<th className="text-right p-2"></th>
<th className="text-right p-2"></th>
<th className="text-right p-2"></th>
</tr>
</thead>
<tbody>
{rankRows.map((s: any, i: number) => (
<tr
key={s.store_code}
className="cursor-pointer border-b hover:bg-muted/50"
onClick={() => navigate(`/stores/${s.store_code}`)}
>
<td className="p-2 font-bold">{i + 1}</td>
<td className="p-2 font-medium">{s.store_name}</td>
<td className="text-right p-2">{formatCurrency(s.received)}</td>
<td className="text-right p-2">{formatNumber(s.bill_count)}</td>
<td className="text-right p-2">{formatCurrency(s.avg_bill_value)}</td>
<td className="text-right p-2">
<span className={Number(s.avg_discount_rate_pct) > 25 ? 'text-red-600 font-bold' : Number(s.avg_discount_rate_pct) > 20 ? 'text-yellow-600' : ''}>
{formatPercent(s.avg_discount_rate_pct)}
</span>
</td>
<td className="text-right p-2">{formatNumber(s.guests)}</td>
<td className="text-right p-2">{formatPercent(s.avg_theoretical_margin_pct)}</td>
</tr>
))}
</tbody>
</table>
</div>
<FilterableTable
data={rankRows}
filterKey="store_name"
filterLabel="全部门店"
onRowClick={(r) => r.store_code && navigate(`/stores/${r.store_code}`)}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'avg_bill_value', label: '客单价' },
{ key: 'avg_discount_rate_pct', label: '优惠率' },
{ key: 'guests', label: '客流量' },
{ key: 'avg_theoretical_margin_pct', label: '毛利率' },
]}
defaultSort="received"
defaultOrder="desc"
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'avg_discount_rate_pct', label: '优惠率', align: 'right', render: (r) => <span className={Number(r.avg_discount_rate_pct) > 25 ? 'text-red-600 font-bold' : Number(r.avg_discount_rate_pct) > 20 ? 'text-yellow-600' : ''}>{formatPercent(r.avg_discount_rate_pct)}</span> },
{ key: 'guests', label: '客流量', align: 'right', render: (r) => formatNumber(r.guests) },
{ key: 'avg_theoretical_margin_pct', label: '理论毛利率', align: 'right', render: (r) => formatPercent(r.avg_theoretical_margin_pct) },
]}
/>
</CollapsibleSection>
</div>
)
+26 -14
View File
@@ -11,6 +11,7 @@ import { Badge } from '@/components/Badge'
import { formatCurrency, cn } from '@/lib/utils'
import { Activity, AlertTriangle, Link2, TrendingUp } from 'lucide-react'
import { MonthPicker } from '@/components/MonthPicker'
import { SearchSelect } from '@/components/SearchSelect'
const PAGE_SIZE = 20
@@ -414,20 +415,31 @@ function AlertList({ alerts }: { alerts: any[] }) {
return (
<>
<div className="mb-3 flex flex-wrap gap-2">
<select value={storeFilter} onChange={(e) => { setStoreFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
<option value=""></option>
{stores.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select value={typeFilter} onChange={(e) => { setTypeFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
<option value=""></option>
{types.map((t) => <option key={t} value={t}>{ALERT_TYPE_LABEL[t] || t}</option>)}
</select>
<select value={levelFilter} onChange={(e) => { setLevelFilter(e.target.value); setPage(1) }} className="rounded border px-2 py-1 text-sm">
<option value=""></option>
<option value="red"></option>
<option value="orange"></option>
<option value="yellow"></option>
</select>
<SearchSelect
value={storeFilter}
onChange={(v) => { setStoreFilter(v); setPage(1) }}
options={stores}
emptyLabel="全部门店"
className="min-w-[140px]"
/>
<SearchSelect
value={typeFilter}
onChange={(v) => { setTypeFilter(v); setPage(1) }}
options={types.map((t) => ({ value: t, label: ALERT_TYPE_LABEL[t] || t }))}
emptyLabel="全部类型"
className="min-w-[120px]"
/>
<SearchSelect
value={levelFilter}
onChange={(v) => { setLevelFilter(v); setPage(1) }}
options={[
{ value: 'red', label: '红色' },
{ value: 'orange', label: '橙色' },
{ value: 'yellow', label: '黄色' },
]}
emptyLabel="全部等级"
className="min-w-[100px]"
/>
</div>
<div className="space-y-2">
{paged.map((a: any, i: number) => (
+475 -10
View File
@@ -1,18 +1,44 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { Badge } from '@/components/Badge'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { formatCurrency, formatPercent, formatNumber } from '@/lib/utils'
import { MetricCard } from '@/components/MetricCard'
import { FilterableTable } from '@/components/FilterableTable'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis, PolarRadiusAxis, PieChart, Pie, Legend } from 'recharts'
import { formatCurrency, formatPercent, formatNumber, cn } from '@/lib/utils'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { MonthPicker } from '@/components/MonthPicker'
import { SearchSelect } from '@/components/SearchSelect'
import { useState } from 'react'
const SCORE_DIMENSIONS = [
{ key: 'score_revenue', label: '营收规模', max: 20 },
{ key: 'score_cost', label: '成本控制', max: 20 },
{ key: 'score_margin', label: '毛利率', max: 15 },
{ key: 'score_risk', label: '风险等级', max: 10 },
{ key: 'score_repeat', label: '复购率', max: 10 },
{ key: 'score_member', label: '会员占比', max: 10 },
{ key: 'score_task', label: '任务完成', max: 10 },
{ key: 'score_bill', label: '客单价', max: 5 },
]
type DetailTab = 'overview' | 'meal' | 'category' | 'cost' | 'member' | 'anomaly' | 'tasks'
const TABS: { key: DetailTab; label: string }[] = [
{ key: 'overview', label: '概览' },
{ key: 'meal', label: '餐段分析' },
{ key: 'category', label: '品类结构' },
{ key: 'cost', label: '成本分析' },
{ key: 'member', label: '会员分析' },
{ key: 'anomaly', label: '异常账单' },
]
export function StorePage() {
const queryClient = useQueryClient()
const [executeText, setExecuteText] = useState('')
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
const [storeCode, setStoreCode] = useState('1111')
const [month, setMonth] = useState('2026-04')
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
const { data: storesData, isLoading: storesLoading } = useQuery({
queryKey: ['stores-list', month],
@@ -55,6 +81,47 @@ export function StorePage() {
queryFn: () => api.get('/sku/abc', { params: { month } }),
})
// StoreDetailPage queries
const { data: storeDetailData } = useQuery({
queryKey: ['store-detail', storeCode, month],
queryFn: () => api.get(`/stores/${storeCode}`, { params: { month } }),
})
const { data: healthData } = useQuery({
queryKey: ['store-health', storeCode, month],
queryFn: () => api.get('/situational-awareness/health-score', { params: { month } }),
})
const { data: mealData } = useQuery({
queryKey: ['store', storeCode, 'meal-period', month],
queryFn: () => api.get(`/stores/${storeCode}/meal-period`, { params: { month } }),
enabled: detailTab === 'meal',
})
const { data: catData } = useQuery({
queryKey: ['store', storeCode, 'category-mix', month],
queryFn: () => api.get(`/stores/${storeCode}/category-mix`, { params: { month } }),
enabled: detailTab === 'category',
})
const { data: costData } = useQuery({
queryKey: ['store', storeCode, 'cost', month],
queryFn: () => api.get(`/stores/${storeCode}/cost`, { params: { month } }),
enabled: detailTab === 'cost',
})
const { data: memberData } = useQuery({
queryKey: ['store', storeCode, 'member', month],
queryFn: () => api.get(`/stores/${storeCode}/member`, { params: { month } }),
enabled: detailTab === 'member',
})
const { data: anomalyDetailData } = useQuery({
queryKey: ['store', storeCode, 'anomalies', month],
queryFn: () => api.get(`/stores/${storeCode}/anomalies`, { params: { month, page_size: 200 } }),
enabled: detailTab === 'anomaly',
})
const card = (cardData as any)?.data
const anomalies = card?.anomalies || []
const tasks = (tasksData as any)?.data || []
@@ -68,6 +135,55 @@ export function StorePage() {
const priorityRows = (priorityData as any)?.data || []
const priorityInfo = priorityRows.find((p: any) => p.store_code === storeCode)
// StoreDetailPage derived data
const sd = (storeDetailData as any)?.data
const healthRows = ((healthData as any)?.data) || []
const health = healthRows.find((r: any) => r.store_code === storeCode)
const mealRows = ((mealData as any)?.data) || []
const cat = (catData as any)?.data
const cost = (costData as any)?.data
const member = (memberData as any)?.data
const anomalyDetailRows = ((anomalyDetailData as any)?.data) || []
const anomalyDetailTotal = (anomalyDetailData as any)?.meta?.total || anomalyDetailRows.length
const sc = sd?.scorecard
const action = sd?.action
const risk = sd?.risk
const riskDesc: Record<string, { color: string; desc: string }> = {
'绿色': { color: 'text-green-600 bg-green-50 border-green-200', desc: '各项指标健康,经营状态良好,维持现有运营策略即可。' },
'黄色': { color: 'text-yellow-600 bg-yellow-50 border-yellow-200', desc: '存在单项指标偏差,需关注并针对性改善,避免风险升级。' },
'红色': { color: 'text-red-600 bg-red-50 border-red-200', desc: '多项指标异常,经营风险较高,需立即介入并制定整改计划。' },
}
const quadrantDesc: Record<string, string> = {
'明星门店': '高营收高毛利,是公司的核心利润来源。应总结其成功经验并推广至其他门店。',
'现金牛门店': '高营收但毛利偏低,通过成本优化有较大利润提升空间。',
'潜力门店': '营收偏低但毛利较好,具备增长潜力,需提升客流和营收规模。',
'问题门店': '营收和毛利均偏低,需全面诊断并考虑调整经营策略或选址。',
}
const riskInfo = risk?.risk_level ? riskDesc[risk.risk_level] : null
const quadrant = action?.management_quadrant || ''
const quadrantInfo = quadrantDesc[quadrant]
const companyAvg = { daily_rev: 20144, bill: 36.8, discount: 20.5, margin: 71.5, member: 15.9, repeat: 37.0, delivery: 36.1, combo: 20.6, items: 4.05 }
const deviations: { label: string; store: number; company: number; unit: string; good: 'high' | 'low' }[] = [
{ label: '日均营收', store: Number(risk?.avg_daily_received) || 0, company: companyAvg.daily_rev, unit: '元', good: 'high' },
{ label: '客单价', store: Number(risk?.avg_bill_value) || 0, company: companyAvg.bill, unit: '元', good: 'high' },
{ label: '优惠率', store: Number(risk?.discount_rate_pct) || 0, company: companyAvg.discount, unit: '%', good: 'low' },
{ label: '毛利率', store: Number(risk?.theoretical_margin_pct) || 0, company: companyAvg.margin, unit: '%', good: 'high' },
{ label: '会员占比', store: Number(risk?.member_bill_share_pct) || 0, company: companyAvg.member, unit: '%', good: 'high' },
{ label: '复购率', store: Number(action?.repeat_rate_pct) || 0, company: companyAvg.repeat, unit: '%', good: 'high' },
{ label: '外卖占比', store: Number(action?.delivery_bill_share_pct) || 0, company: companyAvg.delivery, unit: '%', good: 'low' },
{ label: '成本差异率', store: Number(action?.variance_to_theoretical_pct) || 0, company: 20, unit: '%', good: 'low' },
]
const problemDeviations = deviations.filter(d => {
const diff = d.store - d.company
return d.good === 'high' ? diff < 0 : diff > 0
})
const storeInfo = stores.find((s: any) => s.code === storeCode)
const storeName = storeInfo?.name || ''
const primaryIssue = storeInfo?.issue || priorityInfo?.problem_combination || '-'
@@ -113,15 +229,13 @@ export function StorePage() {
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center gap-3">
<MonthPicker month={month} onChange={setMonth} />
<select
<SearchSelect
value={storeCode}
onChange={(e) => setStoreCode(e.target.value)}
className="rounded-md border px-3 py-1.5 text-sm"
>
{stores.map((s: any) => (
<option key={s.code} value={s.code}>{s.name}</option>
))}
</select>
onChange={setStoreCode}
options={stores.map((s: any) => ({ value: s.code, label: s.name }))}
emptyLabel="选择门店"
className="min-w-[160px]"
/>
</div>
</div>
@@ -392,6 +506,357 @@ export function StorePage() {
</div>
</div>
)}
{/* ========== 门店详情区 ========== */}
{sc && Number(sc.received) > 0 && (
<>
{/* 核心指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6">
<MetricCard title="实收" value={sc.received} format="currency" />
<MetricCard title="账单数" value={sc.bill_count} format="number" />
<MetricCard title="客单价" value={sc.avg_bill_value} format="currency" />
<MetricCard title="优惠率" value={sc.discount_rate_pct} format="percent" />
<MetricCard title="理论毛利率" value={sc.theoretical_margin_pct} format="percent" />
<MetricCard title="会员占比" value={sc.member_bill_share_pct} format="percent" />
</div>
{/* 风险与经营状态说明 */}
<div className="grid gap-3 md:grid-cols-2">
{riskInfo && (
<div className={`rounded-lg border p-3 ${riskInfo.color}`}>
<div className="flex items-center gap-2">
<span className="font-bold">{risk.risk_level}</span>
{action?.problem_combination && <span className="text-xs opacity-80"> {action.problem_combination}</span>}
</div>
<p className="mt-1 text-sm opacity-90">{riskInfo.desc}</p>
{problemDeviations.length > 0 && (
<div className="mt-2 space-y-1 border-t border-current/20 pt-2">
<p className="text-xs font-medium opacity-80">vs </p>
{problemDeviations.map(d => {
const diff = d.store - d.company
const diffStr = diff > 0 ? `+${diff.toFixed(1)}${d.unit}` : `${diff.toFixed(1)}${d.unit}`
return (
<div key={d.label} className="flex items-center justify-between text-xs">
<span className="opacity-80">{d.label}</span>
<span className="font-medium">{d.store.toFixed(1)}{d.unit} <span className="opacity-60">vs {d.company}{d.unit}</span> <span className="font-bold">{diffStr}</span></span>
</div>
)
})}
</div>
)}
</div>
)}
{quadrantInfo && (
<div className="rounded-lg border p-3 bg-blue-50 border-blue-200 text-blue-700">
<div className="flex items-center gap-2">
<span className="font-bold">{quadrant}</span>
{action?.scale_tier && <span className="text-xs opacity-80"> {action.scale_tier}</span>}
</div>
<p className="mt-1 text-sm opacity-90">{quadrantInfo}</p>
<div className="mt-2 space-y-1 border-t border-blue-200/50 pt-2">
<p className="text-xs font-medium opacity-80"></p>
<div className="flex items-center justify-between text-xs">
<span className="opacity-80"></span>
<span className="font-medium">{Number(risk?.avg_daily_received || 0).toFixed(0)} <span className="opacity-60">vs 18642</span></span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="opacity-80"></span>
<span className="font-medium">{Number(risk?.theoretical_margin_pct || 0).toFixed(1)}% <span className="opacity-60">vs 70.0%</span></span>
</div>
{action?.benchmark_score && (
<div className="flex items-center justify-between text-xs">
<span className="opacity-80"></span>
<span className="font-medium">{Number(action.benchmark_score).toFixed(1)}</span>
</div>
)}
</div>
</div>
)}
</div>
{/* 行动建议 */}
{action?.action_priority && (
<div className="rounded-lg border p-3 bg-muted/50">
<div className="flex flex-wrap items-center gap-3">
<span className="font-bold text-sm">{action.action_priority}</span>
{action.problem_combination && <span className="text-sm text-muted-foreground">{action.problem_combination}</span>}
{action.business_type && <span className="text-xs rounded bg-muted px-2 py-0.5">{action.business_type}</span>}
{action?.variance_level && (
<span className={`text-xs rounded px-2 py-0.5 ${action.variance_level.includes('红色') ? 'bg-red-100 text-red-700' : action.variance_level.includes('黄色') ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}>
{action.variance_to_theoretical_pct}% ({action.variance_level})
</span>
)}
</div>
</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'}`}>
{t.label}
</button>
))}
</div>
{/* 概览 Tab */}
{detailTab === 'overview' && (
<div className="space-y-4">
{health && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid gap-4 md:grid-cols-2">
<ResponsiveContainer width="100%" height={250}>
<RadarChart data={SCORE_DIMENSIONS.map(d => ({ dimension: d.label, score: Math.round(Number(health[d.key] || 0) / d.max * 100), fullMark: 100 }))}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 10 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={{ fontSize: 8 }} />
<Radar dataKey="score" stroke={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fill={Number(health.health_score) >= 75 ? '#22c55e' : Number(health.health_score) >= 55 ? '#eab308' : '#ef4444'} fillOpacity={0.3} />
<Tooltip />
</RadarChart>
</ResponsiveContainer>
<div className="space-y-2">
{SCORE_DIMENSIONS.map(d => {
const score = Number(health[d.key] || 0)
const pct = (score / d.max) * 100
const color = pct >= 75 ? '#22c55e' : pct >= 50 ? '#eab308' : '#ef4444'
return (
<div key={d.key}>
<div className="flex items-center justify-between text-xs">
<span className="font-medium">{d.label}</span>
<span className="text-muted-foreground">{score.toFixed(1)} / {d.max}</span>
</div>
<div className="mt-0.5 h-2 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: color }} />
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{sd?.platform && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="美团实收" value={sd.platform.meituan_received} format="currency" />
<MetricCard title="饿了么实收" value={sd.platform.eleme_received} format="currency" />
<MetricCard title="抖音实收" value={sd.platform.douyin_received} format="currency" />
<MetricCard title="平台加权成本率" value={sd.platform.weighted_cost_rate_pct} format="percent" />
</div>
</div>
)}
</div>
)}
{/* 餐段分析 Tab */}
{detailTab === 'meal' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{mealRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={mealRows}>
<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) : n === '平均单价' ? formatCurrency(v) : v} />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="received" name="实收" fill="#3b82f6" radius={[4, 4, 0, 0]} />
<Bar dataKey="avg_bill" name="平均单价" fill="#eab308" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div className="mt-3">
<FilterableTable
data={mealRows}
sortOptions={[
{ key: 'received', label: '实收' },
{ key: 'bill_count', label: '账单数' },
{ key: 'avg_bill', label: '平均单价' },
]}
defaultSort="received"
defaultOrder="desc"
columns={[
{ key: 'meal_period', label: '餐段' },
{ key: 'bill_count', label: '账单数', align: 'right', render: (r) => formatNumber(r.bill_count) },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'avg_bill', label: '平均单价', align: 'right', render: (r) => formatCurrency(r.avg_bill) },
{ key: 'network_avg_bill', label: '全网均价', align: 'right', render: (r) => formatCurrency(r.network_avg_bill) },
{ key: 'vs_network_pct', label: 'vs全网', align: 'right', render: (r) => <span className={Number(r.vs_network_pct) < 0 ? 'text-red-600' : 'text-green-600'}>{Number(r.vs_network_pct).toFixed(1)}%</span> },
]}
/>
</div>
</>
)}
</div>
)}
{/* 品类结构 Tab */}
{detailTab === 'category' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{!cat?.store ? <p className="text-sm text-muted-foreground"></p> : (
(() => {
const s = cat.store
const c = cat.company
const catData = [
{ name: '兰州拉面', store: Number(s.lanzhou_noodle || 0), company: Number(c.total_noodle || 0) },
{ name: '西式简餐', store: Number(s.western_staple || 0), company: Number(c.total_western || 0) },
{ name: '外卖套餐', store: Number(s.delivery_package || 0), company: Number(c.total_delivery || 0) },
{ name: '冷菜', store: Number(s.cold_dishes || 0), company: Number(c.total_cold || 0) },
{ name: '丝路美食', store: Number(s.silk_road_food || 0), company: Number(c.total_silk || 0) },
]
const totalStore = catData.reduce((sum, d) => sum + d.store, 0)
const totalCompany = catData.reduce((sum, d) => sum + d.company, 0)
const pieData = catData.map(d => ({ name: d.name, value: totalStore > 0 ? Math.round(d.store / totalStore * 1000) / 10 : 0, fill: ['#3b82f6', '#eab308', '#22c55e', '#a855f7', '#f97316'][catData.indexOf(d)] }))
return (
<>
<div className="grid gap-4 lg:grid-cols-2">
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground"></p>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={pieData} cx="50%" cy="50%" outerRadius={70} dataKey="value" label={({ name, value }: any) => `${name} ${value}%`}>
{pieData.map((d, i) => <Cell key={i} fill={d.fill} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">vs </p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={catData.map(d => ({ name: d.name, '门店占比': totalStore > 0 ? Math.round(d.store / totalStore * 1000) / 10 : 0, '公司占比': totalCompany > 0 ? Math.round(d.company / totalCompany * 1000) / 10 : 0 }))}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 10 }} />
<YAxis unit="%" tick={{ fontSize: 10 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 10 }} />
<Bar dataKey="门店占比" fill="#3b82f6" />
<Bar dataKey="公司占比" fill="#e2e8f0" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="主力品类" value={s.top_category || '-'} format="text" />
<MetricCard title="主力品类占比" value={s.top_category_share_pct} format="percent" />
<MetricCard title="面食占比" value={s.noodle_share_pct} format="percent" />
<MetricCard title="外卖占比" value={s.delivery_package_share_pct} format="percent" />
</div>
</>
)
})()
)}
</div>
)}
{/* 成本分析 Tab */}
{detailTab === 'cost' && (
<div className="space-y-4">
{cost?.cost ? (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="理论成本率" value={cost.cost.theoretical_cost_rate_pct} format="percent" />
<MetricCard title="实际成本率" value={cost.cost.actual_food_cost_rate_pct} format="percent" />
<MetricCard title="差异率" value={cost.cost.variance_to_theoretical_pct} format="percent" />
<MetricCard title="差异等级" value={cost.cost.variance_level} format="text" />
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
{cost.categories?.length > 0 ? (
<FilterableTable
data={cost.categories}
filterKey="category_name"
filterLabel="全部分类"
sortOptions={[
{ key: 'consumption_amount', label: '消耗金额' },
{ key: 'actual_cost_rate_pct', label: '实际占比' },
{ key: 'benchmark_rate_pct', label: '公司基准' },
{ key: 'variance_pct', label: '差异' },
]}
defaultSort="consumption_amount"
columns={[
{ key: 'category_name', label: '原料分类' },
{ key: 'consumption_amount', label: '消耗金额', align: 'right', render: (r) => formatCurrency(r.consumption_amount) },
{ key: 'actual_cost_rate_pct', label: '实际占比', align: 'right', render: (r) => formatPercent(r.actual_cost_rate_pct) },
{ key: 'benchmark_rate_pct', label: '公司基准', align: 'right', render: (r) => formatPercent(r.benchmark_rate_pct) },
{ key: 'variance_pct', label: '差异', align: 'right', render: (r) => <span className={Number(r.variance_pct) > 2 ? 'text-red-600' : 'text-green-600'}>{Number(r.variance_pct).toFixed(1)}%</span> },
]}
/>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
</>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
)}
{/* 会员分析 Tab */}
{detailTab === 'member' && (
<div className="space-y-4">
{member?.opportunity ? (
<>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="会员占比" value={member.opportunity.member_share_pct} format="percent" />
<MetricCard title="公司均值" value={member.opportunity.company_member_share_pct} format="percent" />
<MetricCard title="复购率" value={member.repeat?.repeat_rate_pct} format="percent" />
<MetricCard title="复购收入占比" value={member.repeat?.repeat_revenue_share_pct} format="percent" />
</div>
{member.monthly && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="活跃会员数" value={member.monthly.member_count} format="number" />
<MetricCard title="总订单数" value={member.monthly.total_orders} format="number" />
<MetricCard title="人均订单" value={member.monthly.avg_orders ? Number(member.monthly.avg_orders).toFixed(1) : '-'} format="text" />
<MetricCard title="人均消费" value={member.monthly.avg_received} format="currency" />
</div>
</div>
)}
{member.opportunity.conversion_bill_scenario && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-2 text-sm font-bold"></h2>
<p className="text-sm text-muted-foreground">{member.opportunity.conversion_bill_scenario}</p>
<p className="mt-1 text-sm text-muted-foreground">{member.opportunity.revenue_uplift_scenario}</p>
</div>
)}
</>
) : <p className="text-sm text-muted-foreground"></p>}
</div>
)}
{/* 异常账单 Tab */}
{detailTab === 'anomaly' && (
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"> ({anomalyDetailTotal})</h2>
{anomalyDetailRows.length === 0 ? <p className="text-sm text-muted-foreground"></p> : (
<FilterableTable
data={anomalyDetailRows}
sortOptions={[
{ key: 'consumption', label: '消费额' },
{ key: 'discount_total', label: '优惠' },
{ key: 'received_total', label: '实收' },
]}
defaultSort="consumption"
defaultOrder="desc"
columns={[
{ key: 'bill_no', label: '账单号' },
{ key: 'meal_period', label: '餐段' },
{ key: 'consumption', label: '消费额', align: 'right', render: (r) => formatCurrency(r.consumption) },
{ key: 'discount_total', label: '优惠', align: 'right', render: (r) => formatCurrency(r.discount_total) },
{ key: 'received_total', label: '实收', align: 'right', render: (r) => formatCurrency(r.received_total) },
{ key: 'anomaly_reason', label: '异常原因' },
{ key: 'closed_at', label: '结账时间', render: (r) => r.closed_at?.substring(0, 16) },
]}
/>
)}
</div>
)}
</>
)}
</div>
)
}
+22 -16
View File
@@ -5,6 +5,7 @@ import { Badge } from '@/components/Badge'
import { FilterableTable } from '@/components/FilterableTable'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { MonthPicker } from '@/components/MonthPicker'
import { SearchSelect } from '@/components/SearchSelect'
import { useState, useMemo } from 'react'
export function TasksPage() {
@@ -50,22 +51,27 @@ export function TasksPage() {
{/* 筛选器 */}
<div className="flex flex-wrap gap-2">
<select value={priority} onChange={(e) => setPriority(e.target.value)} className="rounded-md border px-3 py-1.5 text-sm">
<option value=""></option>
<option value="P0-修复数据口径">P0-</option>
<option value="P0-综合专项整改">P0-</option>
<option value="P1-重点整改">P1-</option>
<option value="P2-单项改善">P2-</option>
<option value="标杆候选"></option>
<option value="持续跟踪"></option>
</select>
<select value={status} onChange={(e) => setStatus(e.target.value)} className="rounded-md border px-3 py-1.5 text-sm">
<option value=""></option>
<option value="待启动"></option>
<option value="进行中"></option>
<option value="已验收"></option>
<option value="已回滚"></option>
</select>
<SearchSelect
value={priority}
onChange={setPriority}
options={[
{ value: 'P0-修复数据口径', label: 'P0-修复数据口径' },
{ value: 'P0-综合专项整改', label: 'P0-综合专项整改' },
{ value: 'P1-重点整改', label: 'P1-重点整改' },
{ value: 'P2-单项改善', label: 'P2-单项改善' },
{ value: '标杆候选', label: '标杆候选' },
{ value: '持续跟踪', label: '持续跟踪' },
]}
emptyLabel="全部优先级"
className="min-w-[140px]"
/>
<SearchSelect
value={status}
onChange={setStatus}
options={['待启动', '进行中', '已验收', '已回滚']}
emptyLabel="全部状态"
className="min-w-[120px]"
/>
</div>
{/* 状态汇总 */}
+60
View File
@@ -0,0 +1,60 @@
DROP MATERIALIZED VIEW IF EXISTS mv_risk_anomaly;
DROP MATERIALIZED VIEW IF EXISTS mv_risk_cashier;
DROP MATERIALIZED VIEW IF EXISTS mv_risk_zero;
CREATE MATERIALIZED VIEW mv_risk_anomaly AS
SELECT
c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
(COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)) AS received_total,
c191 AS cashier, c176 AS closed_at, c175::timestamp AS bill_time,
to_char(c176::timestamp, 'YYYY-MM') AS month,
CASE
WHEN c009::numeric > 0 AND (COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)) = 0 THEN '有消费无实收'
WHEN c068::numeric > c009::numeric THEN '优惠大于消费'
WHEN abs(c009::numeric - COALESCE(c068::numeric,0) - (COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0))) > 0.05 THEN '消费-优惠与实收不平'
END AS anomaly_reason
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != '' AND c176 IS NOT NULL AND c176 != ''
AND (
(c009::numeric > 0 AND (COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)) = 0)
OR (c068::numeric > c009::numeric)
OR (abs(c009::numeric - COALESCE(c068::numeric,0) - (COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0))) > 0.05)
);
CREATE MATERIALIZED VIEW mv_risk_cashier AS
SELECT
c003 AS store_name, c191 AS cashier,
count(*) AS bill_count,
round(sum(c009::numeric),2) AS consumption_total,
round(sum(COALESCE(c068::numeric,0)),2) AS discount_total,
round(sum(COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)),2) AS received,
count(*) FILTER (WHERE c009::numeric > 0 AND (COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)) = 0) AS anomaly_no_received,
count(*) FILTER (WHERE c068::numeric > c009::numeric) AS anomaly_discount_over,
count(*) FILTER (WHERE abs(c009::numeric - COALESCE(c068::numeric,0) - (COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0))) > 0.05) AS anomaly_unbalanced,
to_char(c176::timestamp, 'YYYY-MM') AS month
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != '' AND c176 IS NOT NULL AND c176 != ''
GROUP BY c003, c191, to_char(c176::timestamp, 'YYYY-MM');
CREATE MATERIALIZED VIEW mv_risk_zero AS
SELECT
c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
c191 AS cashier, c176 AS closed_at, c175::timestamp AS bill_time,
to_char(c176::timestamp, 'YYYY-MM') AS month,
CASE
WHEN c009::numeric = 0 AND COALESCE(c068::numeric,0) = 0 THEN '无消费无优惠'
WHEN c009::numeric > 0 AND COALESCE(c068::numeric,0) >= c009::numeric THEN '全额优惠'
ELSE '其他零实收'
END AS zero_received_type
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != '' AND c176 IS NOT NULL AND c176 != ''
AND (COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)) = 0;
CREATE INDEX idx_mv_anomaly_month ON mv_risk_anomaly (month);
CREATE INDEX idx_mv_anomaly_store ON mv_risk_anomaly (store_name);
CREATE INDEX idx_mv_anomaly_cashier ON mv_risk_anomaly (cashier);
CREATE INDEX idx_mv_anomaly_reason ON mv_risk_anomaly (anomaly_reason);
CREATE INDEX idx_mv_cashier_month ON mv_risk_cashier (month);
CREATE INDEX idx_mv_zero_month ON mv_risk_zero (month);
+53
View File
@@ -0,0 +1,53 @@
DROP MATERIALIZED VIEW IF EXISTS mv_time_weekday;
DROP MATERIALIZED VIEW IF EXISTS mv_time_hourly;
DROP MATERIALIZED VIEW IF EXISTS mv_channel_daily;
CREATE MATERIALIZED VIEW mv_time_weekday AS
SELECT
EXTRACT(isodow FROM c176::timestamp)::int AS weekday_no,
CASE EXTRACT(isodow FROM c176::timestamp)::int
WHEN 1 THEN '周一' WHEN 2 THEN '周二' WHEN 3 THEN '周三'
WHEN 4 THEN '周四' WHEN 5 THEN '周五' WHEN 6 THEN '周六' ELSE '周日'
END AS weekday,
count(*) AS bill_count,
round(sum(COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)),2) AS received,
round(sum(COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)) / count(*), 2) AS avg_bill_value,
round(avg(EXTRACT(epoch FROM (c176::timestamp - c175::timestamp))/60), 2) AS avg_duration_minutes,
to_char(c176::timestamp, 'YYYY-MM') AS month
FROM bill_records
WHERE c176 IS NOT NULL AND c176 != '' AND c175 IS NOT NULL AND c175 != ''
GROUP BY 1, 2, to_char(c176::timestamp, 'YYYY-MM');
CREATE MATERIALIZED VIEW mv_time_hourly AS
SELECT
EXTRACT(hour FROM c176::timestamp)::int AS closing_hour,
count(*) AS bill_count,
round(sum(COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)),2) AS received,
round(sum(COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)) / count(*), 2) AS avg_bill_value,
round(avg(EXTRACT(epoch FROM (c176::timestamp - c175::timestamp))/60), 2) AS avg_duration_minutes,
to_char(c176::timestamp, 'YYYY-MM') AS month
FROM bill_records
WHERE c176 IS NOT NULL AND c176 != '' AND c175 IS NOT NULL AND c175 != ''
GROUP BY 1, to_char(c176::timestamp, 'YYYY-MM');
CREATE MATERIALIZED VIEW mv_channel_daily AS
SELECT
c176::date AS business_date,
round(sum(c143::numeric),2) AS cash,
round(sum(c144::numeric),2) AS alipay,
round(sum(c145::numeric),2) AS wechat,
round(sum(c146::numeric),2) AS meituan,
round(sum(c147::numeric),2) AS unionpay,
round(sum(c148::numeric),2) AS douyin,
round(sum(c149::numeric),2) AS credit,
round(sum(c150::numeric),2) AS jd_delivery,
round(sum(c151::numeric),2) AS meituan_delivery,
round(sum(c152::numeric),2) AS taobao_delivery,
to_char(c176::timestamp, 'YYYY-MM') AS month
FROM bill_records
WHERE c176 IS NOT NULL AND c176 != ''
GROUP BY 1, to_char(c176::timestamp, 'YYYY-MM');
CREATE INDEX idx_mv_weekday_month ON mv_time_weekday (month);
CREATE INDEX idx_mv_hourly_month ON mv_time_hourly (month);
CREATE INDEX idx_mv_channel_month ON mv_channel_daily (month);
+83 -125
View File
@@ -117,20 +117,22 @@ router.get('/stores/:code', async (req: AuthRequest, res) => {
const month = parseMonth(req)
const [scorecard, risk, platform, benchmark, action] = await Promise.all([
query(`
SELECT store_code, store_name,
SELECT c002 AS store_code, c003 AS store_name,
count(*) AS bill_count,
count(DISTINCT closed_at::date) AS active_days,
sum(received_total) AS received,
round(sum(received_total) / NULLIF(count(DISTINCT closed_at::date), 0), 2) AS avg_daily_received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(received_total) / NULLIF(sum(guest_count), 0), 2) AS avg_guest_value,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct,
round(count(*) FILTER (WHERE member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct
FROM analytics.fact_bill
WHERE store_code = $1
AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month')
GROUP BY store_code, store_name
count(DISTINCT c176::date) AS active_days,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)), 2) AS received,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / NULLIF(count(DISTINCT c176::date), 0), 2) AS avg_daily_received,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / count(*), 2) AS avg_bill_value,
round(sum(COALESCE(NULLIF(c114,'')::numeric,0)) / NULLIF(sum(COALESCE(NULLIF(c178,'')::numeric,0)), 0), 2) AS avg_guest_value,
round(sum(COALESCE(NULLIF(c068,'')::numeric,0)) / NULLIF(sum(COALESCE(NULLIF(c009,'')::numeric,0)), 0) * 100, 2) AS discount_rate_pct,
round(sum(COALESCE(NULLIF(c181,'')::numeric,0)) / NULLIF(sum(COALESCE(NULLIF(c114,'')::numeric,0)), 0) * 100, 2) AS theoretical_margin_pct,
round(count(*) FILTER (WHERE NULLIF(c185,'') IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct
FROM bill_records
WHERE c002 = $1
AND c176 IS NOT NULL AND c176 != ''
AND c176::timestamp >= $2::date
AND c176::timestamp < ($2::date + interval '1 month')
GROUP BY c002, c003
`, [code, month]),
query(`SELECT * FROM analytics.mv_store_risk_rating_monthly WHERE month_start = $1 AND store_code = $2`, [month, code]),
query(`SELECT * FROM analytics.mv_store_platform_economics_monthly WHERE month_start = $2 AND store_code = $1`, [code, month]),
@@ -183,20 +185,23 @@ router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const storeInfo = await query(`SELECT store_name FROM analytics.mv_store_risk_rating_monthly WHERE store_code = $1 AND month_start = $2::date LIMIT 1`, [code, month])
if (storeInfo.rows.length === 0) { sendSuccess(res, []); return }
const storeName = storeInfo.rows[0].store_name
const result = await query(`
SELECT closed_at::date AS business_date,
SELECT c176::date AS business_date,
count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct
FROM analytics.fact_bill
WHERE store_code = $1
AND closed_at >= $2::date
AND closed_at < ($2::date + interval '1 month')
AND closed_at IS NOT NULL
GROUP BY closed_at::date
round(sum(${RECEIVED_COLS}), 2) AS received,
round(sum(${RECEIVED_COLS}) / count(*), 2) AS avg_bill_value,
round(sum(COALESCE(c068::numeric,0)) / nullif(sum(c009::numeric), 0) * 100, 2) AS discount_rate_pct
FROM bill_records
WHERE c003 = $1
AND c176 IS NOT NULL AND c176 != ''
AND c176::timestamp >= $2::date
AND c176::timestamp < ($2::date + interval '1 month')
GROUP BY c176::date
ORDER BY business_date
`, [code, month])
`, [storeName, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
@@ -291,7 +296,7 @@ router.get('/member/comparison', async (req: AuthRequest, res) => {
round(avg(discount_total), 2) AS avg_discount,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct
FROM analytics.fact_bill
FROM analytics.bill_fact
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY CASE WHEN member_id IS NULL THEN '非会员' ELSE '会员' END
ORDER BY customer_type
@@ -351,44 +356,32 @@ router.get('/risk/anomaly', async (req: AuthRequest, res) => {
const reason = req.query.reason as string
const cashier = req.query.cashier as string
const conditions: string[] = [
`c175 IS NOT NULL AND c175 != ''`,
`c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')`,
`((c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) OR (c068::numeric > c009::numeric) OR (abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05))`,
]
const conditions: string[] = [`month = to_char($1::date, 'YYYY-MM')`]
const params: any[] = [month]
let paramIdx = 2
if (storeName) {
conditions.push(`c003 = $${paramIdx}`)
conditions.push(`store_name = $${paramIdx}`)
params.push(storeName)
paramIdx++
}
if (reason) {
if (reason === '有消费无实收') conditions.push(`c009::numeric > 0 AND (${RECEIVED_COLS}) = 0`)
else if (reason === '优惠大于消费') conditions.push(`c068::numeric > c009::numeric`)
else if (reason === '消费-优惠与实收不平') conditions.push(`abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 AND NOT (c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AND NOT (c068::numeric > c009::numeric)`)
conditions.push(`anomaly_reason = $${paramIdx}`)
params.push(reason)
paramIdx++
}
if (cashier) {
conditions.push(`c191 ILIKE $${paramIdx}`)
conditions.push(`cashier ILIKE $${paramIdx}`)
params.push(`%${cashier}%`)
paramIdx++
}
const whereClause = conditions.join(' AND ')
const countResult = await query(`SELECT count(*) AS total, round(sum(c009::numeric),2) AS sum_consumption, round(sum(COALESCE(c068::numeric,0)),2) AS sum_discount, round(sum(${RECEIVED_COLS}),2) AS sum_received FROM bill_records WHERE ${whereClause}`, params)
const countResult = await query(`SELECT count(*) AS total, round(sum(consumption),2) AS sum_consumption, round(sum(discount_total),2) AS sum_discount, round(sum(received_total),2) AS sum_received FROM mv_risk_anomaly WHERE ${whereClause}`, params)
const result = await query(`
SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
(${RECEIVED_COLS}) AS received_total,
c191 AS cashier, c176 AS closed_at,
CASE
WHEN c009::numeric > 0 AND (${RECEIVED_COLS}) = 0 THEN '有消费无实收'
WHEN c068::numeric > c009::numeric THEN '优惠大于消费'
WHEN abs(c009::numeric - COALESCE(c068::numeric,0) - (${RECEIVED_COLS})) > 0.05 THEN '消费-优惠与实收不平'
END AS anomaly_reason
FROM bill_records
SELECT store_name, bill_no, meal_period, consumption, discount_total, received_total, cashier, closed_at, anomaly_reason
FROM mv_risk_anomaly
WHERE ${whereClause}
ORDER BY c176 DESC
ORDER BY closed_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
`, [...params, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize, sum_consumption: countResult.rows[0].sum_consumption, sum_discount: countResult.rows[0].sum_discount, sum_received: countResult.rows[0].sum_received })
@@ -402,26 +395,17 @@ router.get('/risk/zero-received', async (req: AuthRequest, res) => {
const month = parseMonth(req)
const storeName = req.query.store as string
let sql = `
SELECT c003 AS store_name, c005 AS bill_no, c004 AS meal_period,
c009::numeric AS consumption, COALESCE(c068::numeric,0) AS discount_total,
0 AS received_total,
c191 AS cashier, c176 AS closed_at,
CASE
WHEN c068::numeric >= c009::numeric AND c009::numeric > 0 THEN '全额优惠'
WHEN c009::numeric = 0 OR c009 IS NULL OR c009 = '' THEN '零消费零实收'
ELSE '有消费无实收'
END AS zero_received_type
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')
AND c009::numeric > 0 AND (${RECEIVED_COLS}) = 0
SELECT store_name, bill_no, meal_period, consumption, discount_total,
0 AS received_total, cashier, closed_at, zero_received_type
FROM mv_risk_zero
WHERE month = to_char($1::date, 'YYYY-MM')
`
const params: any[] = [month]
if (storeName) {
sql += ` AND c003 = $2`
sql += ` AND store_name = $2`
params.push(storeName)
}
sql += ` ORDER BY c009::numeric DESC LIMIT 200`
sql += ` ORDER BY consumption DESC LIMIT 200`
const result = await query(sql, params)
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -433,18 +417,14 @@ router.get('/risk/cashier', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT c003 AS store_name, c191 AS cashier,
count(*) AS bill_count,
round(sum(${RECEIVED_COLS})::numeric, 2) AS received,
count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0) AS anomaly_bills,
round(count(*) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric / count(*) * 100, 2) AS anomaly_rate_pct,
round(sum(c009::numeric) FILTER (WHERE c009::numeric > 0 AND (${RECEIVED_COLS}) = 0)::numeric, 2) AS anomaly_consumption
FROM bill_records
WHERE c175 IS NOT NULL AND c175 != ''
AND c175::timestamp >= $1::date AND c175::timestamp < ($1::date + interval '1 month')
AND c191 IS NOT NULL AND c191 != ''
GROUP BY c003, c191
ORDER BY anomaly_rate_pct DESC NULLS LAST
SELECT store_name, cashier, bill_count,
received,
(anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) AS anomaly_bills,
round((anomaly_no_received + anomaly_discount_over + anomaly_unbalanced)::numeric / bill_count * 100, 2) AS anomaly_rate_pct,
consumption_total
FROM mv_risk_cashier
WHERE month = to_char($1::date, 'YYYY-MM') AND cashier IS NOT NULL AND cashier != ''
ORDER BY (anomaly_no_received + anomaly_discount_over + anomaly_unbalanced) DESC NULLS LAST
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -456,17 +436,18 @@ router.get('/marketing/plans', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT store_code, store_name,
SELECT marketing_plan,
count(*) AS bill_count,
sum(received_total) AS received,
sum(discount_total) AS discount_amount,
round(sum(consumption)::numeric, 2) AS consumption,
round(sum(discount_total)::numeric, 2) AS discounts,
round(sum(received_total)::numeric, 2) AS received,
round(avg(received_total), 2) AS avg_bill_value,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(third_party_discount) / NULLIF(sum(received_total), 0) * 100, 2) AS third_party_rate_pct,
count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '') AS plan_bills,
round(count(*) FILTER (WHERE marketing_plan IS NOT NULL AND marketing_plan != '')::numeric / count(*) * 100, 2) AS plan_coverage_pct
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY store_code, store_name
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct
FROM analytics.bill_fact
WHERE marketing_plan IS NOT NULL AND marketing_plan != ''
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY marketing_plan
ORDER BY received DESC
`, [month])
sendSuccess(res, result.rows)
@@ -489,18 +470,10 @@ router.get('/time/weekday', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT EXTRACT(isodow FROM closed_at)::int AS weekday_no,
CASE EXTRACT(isodow FROM closed_at)::int
WHEN 1 THEN '周一' WHEN 2 THEN '周二' WHEN 3 THEN '周三'
WHEN 4 THEN '周四' WHEN 5 THEN '周五' WHEN 6 THEN '周六' ELSE '周日'
END AS weekday,
count(*) AS bill_count,
sum(received_total) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(avg(duration_minutes), 2) AS avg_duration_minutes
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1, 2 ORDER BY 1
SELECT weekday_no, weekday, bill_count, received, avg_bill_value, avg_duration_minutes
FROM mv_time_weekday
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY weekday_no
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -512,15 +485,10 @@ router.get('/time/hourly', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT EXTRACT(hour FROM closed_at)::int AS closing_hour,
count(*) AS bill_count,
sum(received_total) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(avg(duration_minutes), 2) AS avg_duration_minutes
FROM analytics.fact_bill
WHERE closed_at IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1 ORDER BY 1
SELECT closing_hour, bill_count, received, avg_bill_value, avg_duration_minutes
FROM mv_time_hourly
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY closing_hour
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -532,20 +500,10 @@ router.get('/channel', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`
SELECT closed_at::date AS business_date,
sum(cash_received) AS cash,
sum(alipay_received) AS alipay,
sum(wechat_received) AS wechat,
sum(meituan_received) AS meituan,
sum(unionpay_received) AS unionpay,
sum(douyin_received) AS douyin,
sum(credit_received) AS credit,
sum(jd_delivery_received) AS jd_delivery,
sum(meituan_delivery_received) AS meituan_delivery,
sum(taobao_delivery_received) AS taobao_delivery
FROM analytics.fact_bill
WHERE closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY 1 ORDER BY 1
SELECT business_date, cash, alipay, wechat, meituan, unionpay, douyin, credit, jd_delivery, meituan_delivery, taobao_delivery
FROM mv_channel_daily
WHERE month = to_char($1::date, 'YYYY-MM')
ORDER BY business_date
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
@@ -567,7 +525,7 @@ router.get('/data-quality', async (req: AuthRequest, res) => {
count(DISTINCT store_code) AS store_count,
min(closed_at)::text AS min_date,
max(closed_at)::text AS max_date
FROM analytics.fact_bill
FROM analytics.bill_fact
`)
const dishStats = await query(`
SELECT
@@ -604,7 +562,7 @@ router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => {
SELECT meal_period,
count(*) AS network_bills,
(sum(received_total) / NULLIF(count(*), 0)) AS network_avg_bill
FROM analytics.fact_bill
FROM analytics.bill_fact
WHERE meal_period IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY meal_period
@@ -613,7 +571,7 @@ router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => {
count(*) AS bill_count,
sum(received_total) AS received,
(sum(received_total) / NULLIF(count(*), 0)) AS avg_bill
FROM analytics.fact_bill
FROM analytics.bill_fact
WHERE meal_period IS NOT NULL
AND store_code = $2
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
@@ -712,8 +670,8 @@ router.get('/stores/:code/anomalies', async (req: AuthRequest, res) => {
const { page, pageSize, offset } = parsePagination(req)
const month = parseMonth(req)
const code = req.params.code
const countResult = await query(`SELECT count(*) AS total FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month')`, [code, month])
const result = await query(`SELECT * FROM analytics.fact_bill WHERE store_code = $1 AND is_anomaly = true AND closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') ORDER BY closed_at DESC LIMIT $3 OFFSET $4`, [code, month, pageSize, offset])
const countResult = await query(`SELECT count(*) AS total FROM mv_risk_anomaly WHERE store_name = (SELECT store_name FROM analytics.dim_store WHERE store_code = $1) AND month = to_char($2::date, 'YYYY-MM')`, [code, month])
const result = await query(`SELECT * FROM mv_risk_anomaly WHERE store_name = (SELECT store_name FROM analytics.dim_store WHERE store_code = $1) AND month = to_char($2::date, 'YYYY-MM') ORDER BY closed_at DESC LIMIT $3 OFFSET $4`, [code, month, pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) { sendError(res, err.message) }
})
@@ -734,7 +692,7 @@ router.get('/region/summary', async (req: AuthRequest, res) => {
count(DISTINCT CASE WHEN r.risk_level = '红色' THEN b.store_code END) AS red_count,
count(DISTINCT CASE WHEN r.risk_level = '黄色' THEN b.store_code END) AS yellow_count,
count(DISTINCT CASE WHEN r.risk_level = '绿色' THEN b.store_code END) AS green_count
FROM analytics.fact_bill b
FROM analytics.bill_fact b
JOIN analytics.dim_store d ON d.store_code = b.store_code
LEFT JOIN analytics.mv_store_risk_rating_monthly r ON r.store_code = b.store_code AND r.month_start = $1::date
WHERE d.region IS NOT NULL AND d.region <> ''
@@ -1267,7 +1225,7 @@ router.get('/revenue/store-ranking', async (req: AuthRequest, res) => {
sum(bill_count)::bigint AS bill_count,
round(sum(received)::numeric, 2) AS received,
round(sum(discounts)::numeric, 2) AS discounts,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS discount_rate_pct,
round(sum(discounts) / nullif(sum(received) + sum(discounts), 0) * 100, 2) AS avg_discount_rate_pct,
round(sum(received) / nullif(sum(bill_count), 0), 2) AS avg_bill_value,
round(sum(guests)::numeric, 0) AS guests,
round(avg(theoretical_margin_pct)::numeric, 2) AS avg_theoretical_margin_pct
+2 -2
View File
@@ -113,8 +113,8 @@ router.get('/alerts', async (req: AuthRequest, res) => {
SELECT closed_at::date AS business_date,
count(*) AS bill_count,
sum(received_total) AS received
FROM analytics.fact_bill
WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.fact_bill)
FROM analytics.bill_fact
WHERE closed_at >= (SELECT max(closed_at)::date - interval '30 days' FROM analytics.bill_fact)
AND closed_at IS NOT NULL
GROUP BY closed_at::date
ORDER BY business_date
+32 -23
View File
@@ -5,6 +5,8 @@ import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
const RECEIVED_COLS = `COALESCE(c143::numeric,0)+COALESCE(c144::numeric,0)+COALESCE(c145::numeric,0)+COALESCE(c146::numeric,0)+COALESCE(c147::numeric,0)+COALESCE(c148::numeric,0)+COALESCE(c149::numeric,0)+COALESCE(c150::numeric,0)+COALESCE(c151::numeric,0)+COALESCE(c152::numeric,0)+COALESCE(c153::numeric,0)+COALESCE(c155::numeric,0)+COALESCE(c160::numeric,0)+COALESCE(c161::numeric,0)+COALESCE(c164::numeric,0)+COALESCE(c166::numeric,0)+COALESCE(c169::numeric,0)+COALESCE(c173::numeric,0)+COALESCE(c174::numeric,0)+COALESCE(c118::numeric,0)+COALESCE(c119::numeric,0)+COALESCE(c120::numeric,0)+COALESCE(c121::numeric,0)+COALESCE(c139::numeric,0)+COALESCE(c141::numeric,0)+COALESCE(c142::numeric,0)`
// ============================================================
// 固定路径路由(必须在 /:id 之前定义)
// ============================================================
@@ -287,47 +289,54 @@ router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = req.query.month as string | undefined
let dateFilter: string
let params: any[] = [code]
let storeNameFilter: string
let params: any[] = []
if (month) {
const monthDate = month + '-01'
dateFilter = `closed_at::date = (SELECT max(closed_at)::date FROM analytics.fact_bill WHERE closed_at >= $2::date AND closed_at < ($2::date + interval '1 month') AND closed_at IS NOT NULL)`
params.push(monthDate)
const storeInfo = await query(`SELECT store_name FROM analytics.mv_store_risk_rating_monthly WHERE store_code = $1 AND month_start = $2::date LIMIT 1`, [code, monthDate])
if (storeInfo.rows.length === 0) { sendSuccess(res, { anomalies: [], todos: [] }); return }
const storeName = storeInfo.rows[0].store_name
params = [storeName, monthDate]
storeNameFilter = `c003 = $1 AND c176 IS NOT NULL AND c176 != '' AND c176::timestamp >= $2::date AND c176::timestamp < ($2::date + interval '1 month')`
} else {
dateFilter = `closed_at::date = (SELECT max(closed_at)::date FROM analytics.fact_bill WHERE closed_at IS NOT NULL)`
const storeInfo = await query(`SELECT store_name FROM analytics.mv_store_risk_rating_monthly WHERE store_code = $1 LIMIT 1`, [code])
if (storeInfo.rows.length === 0) { sendSuccess(res, { anomalies: [], todos: [] }); return }
const storeName = storeInfo.rows[0].store_name
params = [storeName]
storeNameFilter = `c003 = $1 AND c176 IS NOT NULL AND c176 != ''`
}
const result = await query(`
WITH target_day AS (
SELECT max(closed_at)::date AS business_date
FROM analytics.fact_bill
WHERE ${dateFilter.replace('closed_at', 'fact_bill.closed_at')}
SELECT max(c176::date) AS business_date
FROM bill_records
WHERE ${storeNameFilter}
),
today_stats AS (
SELECT bf.store_code, bf.store_name,
round(sum(bf.received_total), 2) AS received,
SELECT c003 AS store_name,
round(sum(${RECEIVED_COLS}), 2) AS received,
count(*) AS bill_count,
round(sum(bf.received_total) / count(*), 2) AS avg_bill
FROM analytics.fact_bill bf, target_day td
WHERE bf.store_code = $1 AND bf.closed_at::date = td.business_date
GROUP BY bf.store_code, bf.store_name
round(sum(${RECEIVED_COLS}) / count(*), 2) AS avg_bill
FROM bill_records bf, target_day td
WHERE ${storeNameFilter.replace('$1', '$1')} AND bf.c176::date = td.business_date
GROUP BY c003
),
week_ago_stats AS (
SELECT bf.store_code,
round(sum(bf.received_total), 2) AS received,
SELECT
round(sum(${RECEIVED_COLS}), 2) AS received,
count(*) AS bill_count,
round(sum(bf.received_total) / count(*), 2) AS avg_bill
FROM analytics.fact_bill bf, target_day td
WHERE bf.store_code = $1 AND bf.closed_at::date = td.business_date - interval '7 days'
GROUP BY bf.store_code
round(sum(${RECEIVED_COLS}) / count(*), 2) AS avg_bill
FROM bill_records bf, target_day td
WHERE ${storeNameFilter.replace('$1', '$1')} AND bf.c176::date = td.business_date - interval '7 days'
GROUP BY c003
)
SELECT t.store_code, t.store_name, '收入' AS module,
SELECT t.store_name, '收入' AS module,
jsonb_build_array(
jsonb_build_object('metric', '实收', 'value', t.received, 'baseline', COALESCE(w.received, 0), 'is_anomaly', t.received < (COALESCE(w.received, 0) * 0.8)),
jsonb_build_object('metric', '账单数', 'value', t.bill_count, 'baseline', COALESCE(w.bill_count, 0), 'is_anomaly', t.bill_count::numeric < (COALESCE(w.bill_count, 0) * 0.8)),
jsonb_build_object('metric', '客单价', 'value', t.avg_bill, 'baseline', COALESCE(w.avg_bill, 0), 'is_anomaly', t.avg_bill < (COALESCE(w.avg_bill, 0) * 0.9))
) AS anomalies
FROM today_stats t
LEFT JOIN week_ago_stats w ON t.store_code = w.store_code
LEFT JOIN week_ago_stats w ON true
`, params)
const tasks = await query(`
SELECT t.* FROM analytics.store_task t
@@ -666,7 +675,7 @@ router.get('/monthly-review/activity-list', async (req: AuthRequest, res) => {
round(avg(received_total), 2) AS avg_bill_value,
round(sum(discount_total) / NULLIF(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / NULLIF(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct
FROM analytics.fact_bill
FROM analytics.bill_fact
WHERE marketing_plan IS NOT NULL
AND closed_at >= $1::date AND closed_at < ($1::date + interval '1 month')
GROUP BY marketing_plan