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:
+1
-1
@@ -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'
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 状态汇总 */}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user