From ed7beb7e4799024a5fe0506ca6c7738ecc078506 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Sat, 1 Aug 2026 23:08:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80SearchSelect=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E7=BB=84=E4=BB=B6=20+=20=E4=BF=AE=E5=A4=8D=E8=90=A5?= =?UTF-8?q?=E9=94=80=E6=96=B9=E6=A1=88=E6=95=B0=E6=8D=AE=20+=20=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E7=BB=8F=E6=B5=8E=E6=80=A7=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建SearchSelect搜索下拉组件,选项≤8自动隐藏搜索框 - 全站替换原生select为SearchSelect(13处下拉) - 修复/revenue/store-ranking优惠率字段名不匹配(discount_rate_pct→avg_discount_rate_pct) - RevenuePage门店营收明细改用FilterableTable支持筛选排序分页 - 修复/marketing/plans按门店分组改为按marketing_plan分组 - 营销方案分析补充消费额、优惠额列和Top5概览卡片 - PlatformPage门店平台经济性增加门店搜索、经济性优列(平台分色) - 平台成本率算法说明显示在标题下方 --- client/src/App.tsx | 2 +- client/src/components/FilterableTable.tsx | 23 +- client/src/components/SearchSelect.tsx | 110 ++++ .../cost-analysis/AdjustmentTab.tsx | 55 +- .../cost-analysis/ProfitabilityTab.tsx | 14 +- .../SchedulingSuggestionTab.tsx | 11 +- .../smart-scheduling/StaffingForecastTab.tsx | 25 +- .../smart-scheduling/StaffingMatchTab.tsx | 11 +- .../smart-scheduling/TrafficHeatmapTab.tsx | 12 +- client/src/pages/CentralKitchenPage.tsx | 4 +- client/src/pages/PlatformPage.tsx | 47 +- client/src/pages/RevenuePage.tsx | 64 +-- client/src/pages/SituationalAwarenessPage.tsx | 40 +- client/src/pages/StorePage.tsx | 485 +++++++++++++++++- client/src/pages/TasksPage.tsx | 38 +- scripts/create_risk_mv.sql | 60 +++ scripts/create_time_mv.sql | 53 ++ server/src/routes/data.ts | 208 +++----- server/src/routes/situational-awareness.ts | 4 +- server/src/routes/tasks.ts | 55 +- 20 files changed, 1035 insertions(+), 286 deletions(-) create mode 100644 client/src/components/SearchSelect.tsx create mode 100644 scripts/create_risk_mv.sql create mode 100644 scripts/create_time_mv.sql diff --git a/client/src/App.tsx b/client/src/App.tsx index 2d74c86..d71932c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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' diff --git a/client/src/components/FilterableTable.tsx b/client/src/components/FilterableTable.tsx index 42b39c2..5a42ec1 100644 --- a/client/src/components/FilterableTable.tsx +++ b/client/src/components/FilterableTable.tsx @@ -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({ )) ) : filterKey && ( - + )} {statusFilterKey && statusOptions && ( - + { if (serverSide && onStatusFilterChange) { onStatusFilterChange(v) } else { setStatusFilter(v) }; setPage(1) }} + options={statusOptions} + emptyLabel={statusFilterLabel || '全部状态'} + className="min-w-[120px]" + /> )} | {sortOptions.map(s => ( diff --git a/client/src/components/SearchSelect.tsx b/client/src/components/SearchSelect.tsx new file mode 100644 index 0000000..8292621 --- /dev/null +++ b/client/src/components/SearchSelect.tsx @@ -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(null) + + const normalizedOptions = useMemo(() => { + 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 ( +
+ + {open && ( +
+ {showSearch && ( +
+ setSearch(e.target.value)} + className="w-full rounded border px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ )} +
+ + {filtered.map(o => ( + + ))} + {filtered.length === 0 && ( +
无匹配结果
+ )} +
+
+ )} +
+ ) +} diff --git a/client/src/components/cost-analysis/AdjustmentTab.tsx b/client/src/components/cost-analysis/AdjustmentTab.tsx index 4f934ad..adbb2df 100644 --- a/client/src/components/cost-analysis/AdjustmentTab.tsx +++ b/client/src/components/cost-analysis/AdjustmentTab.tsx @@ -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 ? '生成中...' : '生成诊断快照'} {generateDiagnosis.data && 已生成 {(generateDiagnosis.data as any)?.data?.generated} 条诊断} - + { setDiagPriority(v); setDiagPage(1) }} + options={['P0', 'P1', 'P2', 'P3']} + emptyLabel="全部优先级" + className="min-w-[120px]" + />
@@ -177,13 +178,20 @@ export function AdjustmentTab({ month }: { month: string }) {
- + 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" + />
setFormData({ ...formData, effective_date: e.target.value })} className="mt-1 w-full rounded border px-3 py-1.5 text-sm" />
@@ -205,13 +213,18 @@ export function AdjustmentTab({ month }: { month: string }) { {subTab === 'adjustment' && ( <>
- + { setAdjStatus(v); setAdjPage(1) }} + options={[ + { value: 'planned', label: '待执行' }, + { value: 'executing', label: '执行中' }, + { value: 'completed', label: '已完成' }, + { value: 'cancelled', label: '已取消' }, + ]} + emptyLabel="全部状态" + className="min-w-[120px]" + />
{la ? : ( 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 = { @@ -86,10 +87,13 @@ export function ProfitabilityTab({ month }: { month: string }) {
- + { setCategory(v); setPage(1) }} + options={categories} + emptyLabel="全部" + className="min-w-[100px]" + />
= { @@ -87,9 +88,13 @@ export function SchedulingSuggestionTab({ month }: { month: string }) {
- + s.store_name)} + emptyLabel="选择门店" + className="min-w-[140px]" + />
diff --git a/client/src/components/smart-scheduling/StaffingForecastTab.tsx b/client/src/components/smart-scheduling/StaffingForecastTab.tsx index da7ee46..b794f8d 100644 --- a/client/src/components/smart-scheduling/StaffingForecastTab.tsx +++ b/client/src/components/smart-scheduling/StaffingForecastTab.tsx @@ -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 }) { {/* 筛选排序 */}
- - + { setStoreFilter(v); setPage(1) }} + options={storeNames} + emptyLabel="全部门店" + className="min-w-[140px]" + /> + { setActionFilter(v); setPage(1) }} + options={['建议招聘', '建议优化', '关注', '维持']} + emptyLabel="全部动作" + className="min-w-[120px]" + /> | {sorts.map(s => ( diff --git a/client/src/components/smart-scheduling/StaffingMatchTab.tsx b/client/src/components/smart-scheduling/StaffingMatchTab.tsx index b53fbc6..2d793bf 100644 --- a/client/src/components/smart-scheduling/StaffingMatchTab.tsx +++ b/client/src/components/smart-scheduling/StaffingMatchTab.tsx @@ -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 = { @@ -37,9 +38,13 @@ export function StaffingMatchTab({ month }: { month: string }) {
- + s.store_name)} + emptyLabel="选择门店" + className="min-w-[140px]" + />
{isLoading ? : ( diff --git a/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx b/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx index f9c909a..5578e23 100644 --- a/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx +++ b/client/src/components/smart-scheduling/TrafficHeatmapTab.tsx @@ -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 }) {
- + 颜色越深客流越大 在岗: 前厅/后厨/管理/其他
diff --git a/client/src/pages/CentralKitchenPage.tsx b/client/src/pages/CentralKitchenPage.tsx index 80704a7..f9cc084 100644 --- a/client/src/pages/CentralKitchenPage.tsx +++ b/client/src/pages/CentralKitchenPage.tsx @@ -78,7 +78,7 @@ export function CentralKitchenPage() {
{/* 核心指标卡片 */} -
+
@@ -90,7 +90,7 @@ export function CentralKitchenPage() { {/* 成本对账瀑布 */} -
+
diff --git a/client/src/pages/PlatformPage.tsx b/client/src/pages/PlatformPage.tsx index 4228f26..cd16e6e 100644 --- a/client/src/pages/PlatformPage.tsx +++ b/client/src/pages/PlatformPage.tsx @@ -92,9 +92,11 @@ export function PlatformPage() { {/* 门店平台经济性 */} - + 0 ? = 38 ? 'font-medium text-red-600' : v >= 35 ? 'text-yellow-600' : 'text-green-600'}>{v.toFixed(1)}% : - }}, + { 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 - + 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 {best.name} (-{gap}%) + }}, ]} /> {/* 营销方案ROI */} + {marketingRows.length > 0 && ( +
+ {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 ( +
+
+ {i + 1} + {r.marketing_plan} +
+
+
实收{formatCurrency(r.received)}
+
消费额{formatCurrency(r.consumption)}
+
优惠额{formatCurrency(r.discounts)}
+
账单数{formatNumber(r.bill_count)}
+
客单价{formatCurrency(r.avg_bill_value)}
+
优惠率= 25 ? 'font-medium text-red-600' : dr >= 15 ? 'text-yellow-600' : 'text-green-600'}>{dr.toFixed(1)}%
+
毛利率= 70 ? 'text-green-600' : mr >= 60 ? 'text-yellow-600' : 'text-red-600'}>{mr.toFixed(1)}%
+
+
+ ) + })} +
+ )} 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) => { diff --git a/client/src/pages/RevenuePage.tsx b/client/src/pages/RevenuePage.tsx index db5e681..a1abc38 100644 --- a/client/src/pages/RevenuePage.tsx +++ b/client/src/pages/RevenuePage.tsx @@ -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() { {/* 门店营收明细表 */} -
- - - - - - - - - - - - - - - {rankRows.map((s: any, i: number) => ( - navigate(`/stores/${s.store_code}`)} - > - - - - - - - - - - ))} - -
排名门店实收账单数客单价优惠率客流量理论毛利率
{i + 1}{s.store_name}{formatCurrency(s.received)}{formatNumber(s.bill_count)}{formatCurrency(s.avg_bill_value)} - 25 ? 'text-red-600 font-bold' : Number(s.avg_discount_rate_pct) > 20 ? 'text-yellow-600' : ''}> - {formatPercent(s.avg_discount_rate_pct)} - - {formatNumber(s.guests)}{formatPercent(s.avg_theoretical_margin_pct)}
-
+ 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) => 25 ? 'text-red-600 font-bold' : Number(r.avg_discount_rate_pct) > 20 ? 'text-yellow-600' : ''}>{formatPercent(r.avg_discount_rate_pct)} }, + { 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) }, + ]} + />
) diff --git a/client/src/pages/SituationalAwarenessPage.tsx b/client/src/pages/SituationalAwarenessPage.tsx index 41a26a0..86eb9e8 100644 --- a/client/src/pages/SituationalAwarenessPage.tsx +++ b/client/src/pages/SituationalAwarenessPage.tsx @@ -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 ( <>
- - - + { setStoreFilter(v); setPage(1) }} + options={stores} + emptyLabel="全部门店" + className="min-w-[140px]" + /> + { setTypeFilter(v); setPage(1) }} + options={types.map((t) => ({ value: t, label: ALERT_TYPE_LABEL[t] || t }))} + emptyLabel="全部类型" + className="min-w-[120px]" + /> + { setLevelFilter(v); setPage(1) }} + options={[ + { value: 'red', label: '红色' }, + { value: 'orange', label: '橙色' }, + { value: 'yellow', label: '黄色' }, + ]} + emptyLabel="全部等级" + className="min-w-[100px]" + />
{paged.map((a: any, i: number) => ( diff --git a/client/src/pages/StorePage.tsx b/client/src/pages/StorePage.tsx index bb94547..51b9f34 100644 --- a/client/src/pages/StorePage.tsx +++ b/client/src/pages/StorePage.tsx @@ -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(null) const [storeCode, setStoreCode] = useState('1111') const [month, setMonth] = useState('2026-04') + const [detailTab, setDetailTab] = useState('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 = { + '绿色': { 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 = { + '明星门店': '高营收高毛利,是公司的核心利润来源。应总结其成功经验并推广至其他门店。', + '现金牛门店': '高营收但毛利偏低,通过成本优化有较大利润提升空间。', + '潜力门店': '营收偏低但毛利较好,具备增长潜力,需提升客流和营收规模。', + '问题门店': '营收和毛利均偏低,需全面诊断并考虑调整经营策略或选址。', + } + + 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() {

店长工作台

- + onChange={setStoreCode} + options={stores.map((s: any) => ({ value: s.code, label: s.name }))} + emptyLabel="选择门店" + className="min-w-[160px]" + />
@@ -392,6 +506,357 @@ export function StorePage() {
)} + + {/* ========== 门店详情区 ========== */} + {sc && Number(sc.received) > 0 && ( + <> + {/* 核心指标 */} +
+ + + + + + +
+ + {/* 风险与经营状态说明 */} +
+ {riskInfo && ( +
+
+ 风险等级:{risk.risk_level} + {action?.problem_combination && — {action.problem_combination}} +
+

{riskInfo.desc}

+ {problemDeviations.length > 0 && ( +
+

偏差指标明细(vs 公司均值):

+ {problemDeviations.map(d => { + const diff = d.store - d.company + const diffStr = diff > 0 ? `+${diff.toFixed(1)}${d.unit}` : `${diff.toFixed(1)}${d.unit}` + return ( +
+ {d.label} + {d.store.toFixed(1)}{d.unit} vs {d.company}{d.unit} {diffStr} +
+ ) + })} +
+ )} +
+ )} + {quadrantInfo && ( +
+
+ 经营象限:{quadrant} + {action?.scale_tier && — {action.scale_tier}} +
+

{quadrantInfo}

+
+

象限定位依据:

+
+ 日均营收 + {Number(risk?.avg_daily_received || 0).toFixed(0)}元 vs 中位数 18642元 +
+
+ 毛利率 + {Number(risk?.theoretical_margin_pct || 0).toFixed(1)}% vs 中位数 70.0% +
+ {action?.benchmark_score && ( +
+ 综合基准分 + {Number(action.benchmark_score).toFixed(1)}分 +
+ )} +
+
+ )} +
+ + {/* 行动建议 */} + {action?.action_priority && ( +
+
+ 行动优先级:{action.action_priority} + {action.problem_combination && 问题:{action.problem_combination}} + {action.business_type && {action.business_type}} + {action?.variance_level && ( + + 成本差异:{action.variance_to_theoretical_pct}% ({action.variance_level}) + + )} +
+
+ )} + + {/* Tab bar */} +
+ {TABS.map(t => ( + + ))} +
+ + {/* 概览 Tab */} + {detailTab === 'overview' && ( +
+ {health && ( +
+

健康度评分维度

+
+ + ({ dimension: d.label, score: Math.round(Number(health[d.key] || 0) / d.max * 100), fullMark: 100 }))}> + + + + = 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} /> + + + +
+ {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 ( +
+
+ {d.label} + {score.toFixed(1)} / {d.max} +
+
+
+
+
+ ) + })} +
+
+
+ )} + + {sd?.platform && ( +
+

平台经济性

+
+ + + + +
+
+ )} +
+ )} + + {/* 餐段分析 Tab */} + {detailTab === 'meal' && ( +
+

餐段分析

+ {mealRows.length === 0 ?

暂无数据

: ( + <> + + + + + v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} tick={{ fontSize: 10 }} /> + n === '实收' ? formatCurrency(v) : n === '平均单价' ? formatCurrency(v) : v} /> + + + + + +
+ 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) => {Number(r.vs_network_pct).toFixed(1)}% }, + ]} + /> +
+ + )} +
+ )} + + {/* 品类结构 Tab */} + {detailTab === 'category' && ( +
+

品类结构

+ {!cat?.store ?

暂无数据

: ( + (() => { + 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 ( + <> +
+
+

门店品类占比

+ + + `${name} ${value}%`}> + {pieData.map((d, i) => )} + + + + +
+
+

vs 公司整体占比

+ + ({ name: d.name, '门店占比': totalStore > 0 ? Math.round(d.store / totalStore * 1000) / 10 : 0, '公司占比': totalCompany > 0 ? Math.round(d.company / totalCompany * 1000) / 10 : 0 }))}> + + + + + + + + + +
+
+
+ + + + +
+ + ) + })() + )} +
+ )} + + {/* 成本分析 Tab */} + {detailTab === 'cost' && ( +
+ {cost?.cost ? ( + <> +
+ + + + +
+
+

原料分类成本对标

+ {cost.categories?.length > 0 ? ( + 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) => 2 ? 'text-red-600' : 'text-green-600'}>{Number(r.variance_pct).toFixed(1)}% }, + ]} + /> + ) :

暂无分类数据

} +
+ + ) :

暂无成本数据

} +
+ )} + + {/* 会员分析 Tab */} + {detailTab === 'member' && ( +
+ {member?.opportunity ? ( + <> +
+ + + + +
+ {member.monthly && ( +
+

会员月度活跃

+
+ + + + +
+
+ )} + {member.opportunity.conversion_bill_scenario && ( +
+

提升机会

+

{member.opportunity.conversion_bill_scenario}

+

{member.opportunity.revenue_uplift_scenario}

+
+ )} + + ) :

暂无会员数据

} +
+ )} + + {/* 异常账单 Tab */} + {detailTab === 'anomaly' && ( +
+

异常账单明细 ({anomalyDetailTotal})

+ {anomalyDetailRows.length === 0 ?

暂无异常账单

: ( + 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) }, + ]} + /> + )} +
+ )} + + + )}
) } diff --git a/client/src/pages/TasksPage.tsx b/client/src/pages/TasksPage.tsx index 7f38191..5185930 100644 --- a/client/src/pages/TasksPage.tsx +++ b/client/src/pages/TasksPage.tsx @@ -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() { {/* 筛选器 */}
- - + +
{/* 状态汇总 */} diff --git a/scripts/create_risk_mv.sql b/scripts/create_risk_mv.sql new file mode 100644 index 0000000..c3d8947 --- /dev/null +++ b/scripts/create_risk_mv.sql @@ -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); diff --git a/scripts/create_time_mv.sql b/scripts/create_time_mv.sql new file mode 100644 index 0000000..07e8b46 --- /dev/null +++ b/scripts/create_time_mv.sql @@ -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); diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts index 32ea2c2..da4dfb5 100644 --- a/server/src/routes/data.ts +++ b/server/src/routes/data.ts @@ -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 diff --git a/server/src/routes/situational-awareness.ts b/server/src/routes/situational-awareness.ts index 1d4a262..992ec18 100644 --- a/server/src/routes/situational-awareness.ts +++ b/server/src/routes/situational-awareness.ts @@ -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 diff --git a/server/src/routes/tasks.ts b/server/src/routes/tasks.ts index 7cef8e5..bca8c87 100644 --- a/server/src/routes/tasks.ts +++ b/server/src/routes/tasks.ts @@ -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