feat: 新增门店选址分析功能

- 创建选址分析7个SQL视图+5张物化表(38s→33ms)
- 后端新增5个选址API端点
- 前端新增SiteSelectionPage页面(散点图+4Tab)
- 侧边栏新增门店选址导航入口
- 修复SQL列引用错误(d.→a.,去掉重复列)
- 创建v_store_action_priority_deep_april和v_store_area_efficiency_april基础视图
This commit is contained in:
freedakgmail
2026-07-27 08:33:42 +08:00
parent 521ba88937
commit 6e0c1bd7a4
18 changed files with 1892 additions and 30 deletions
+2
View File
@@ -18,6 +18,7 @@ import { RiskPage } from '@/pages/RiskPage'
import { TimePage } from '@/pages/TimePage'
import { DataQualityPage } from '@/pages/DataQualityPage'
import { OntologyPage } from '@/pages/OntologyPage'
import { SiteSelectionPage } from '@/pages/SiteSelectionPage'
import { LoginPage } from '@/pages/LoginPage'
const queryClient = new QueryClient({
@@ -77,6 +78,7 @@ export default function App() {
<Route path="/data-quality" element={<DataQualityPage />} />
<Route path="/indicators" element={<IndicatorsPage />} />
<Route path="/ontology" element={<OntologyPage />} />
<Route path="/site-selection" element={<SiteSelectionPage />} />
<Route path="/login" element={<Navigate to="/" />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
+2 -1
View File
@@ -1,6 +1,6 @@
import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database } from 'lucide-react'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin } from 'lucide-react'
import { cn } from '@/lib/utils'
interface LayoutProps {
@@ -41,6 +41,7 @@ const menuGroups: MenuGroup[] = [
{ path: '/member', label: '会员复购', icon: Users, roles: ['hq', 'dept'] },
{ path: '/risk', label: '风险内控', icon: AlertTriangle, roles: ['hq', 'dept'] },
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
{ path: '/site-selection', label: '门店选址', icon: MapPin, roles: ['hq', 'dept'] },
],
},
{
+316
View File
@@ -0,0 +1,316 @@
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { CollapsibleSection } from '@/components/CollapsibleSection'
import { MetricCard } from '@/components/MetricCard'
import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils'
import { useState, useMemo } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ScatterChart, Scatter, ZAxis, ReferenceLine, Cell } from 'recharts'
const PAGE_SIZE = 15
const SCENE_COLORS: Record<string, string> = {
'办公园区': '#3b82f6', '商场商业体': '#a855f7', '社区居民': '#22c55e', '街边综合': '#f59e0b', '交通枢纽': '#ef4444', '校园档口': '#06b6d4', '特殊业态': '#6b7280',
}
const RISK_COLORS: Record<string, string> = { '高风险:距离近且至少一家经营承压': '#ef4444', '中风险:需核查客群和配送圈重叠': '#eab308', '观察': '#22c55e' }
type SiteTab = 'benchmark' | 'replication' | 'overlap' | 'district'
const TABS: { key: SiteTab; label: string }[] = [
{ key: 'benchmark', label: '分段基准' },
{ key: 'replication', label: '复制评分' },
{ key: 'overlap', label: '重叠风险' },
{ key: 'district', label: '区域基准' },
]
export function SiteSelectionPage() {
const navigate = useNavigate()
const [tab, setTab] = useState<SiteTab>('benchmark')
const [sceneFilter, setSceneFilter] = useState('')
const [replPage, setReplPage] = useState(1)
const [overlapPage, setOverlapPage] = useState(1)
const [profilePage, setProfilePage] = useState(1)
const { data: profileData, isLoading: profileLoading } = useQuery({
queryKey: ['site-selection/profile'],
queryFn: () => api.get('/site-selection/profile'),
})
const { data: segData, isLoading: segLoading } = useQuery({
queryKey: ['site-selection/segment-benchmark'],
queryFn: () => api.get('/site-selection/segment-benchmark'),
})
const { data: replData, isLoading: replLoading } = useQuery({
queryKey: ['site-selection/replication'],
queryFn: () => api.get('/site-selection/replication'),
})
const { data: overlapData, isLoading: overlapLoading } = useQuery({
queryKey: ['site-selection/overlap-risk'],
queryFn: () => api.get('/site-selection/overlap-risk'),
})
const { data: districtData, isLoading: districtLoading } = useQuery({
queryKey: ['site-selection/district-benchmark'],
queryFn: () => api.get('/site-selection/district-benchmark'),
})
const profileRows = ((profileData as any)?.data || []).filter((r: any) => !sceneFilter || r.site_scene === sceneFilter)
const segRows = (segData as any)?.data || []
const replRows = (replData as any)?.data || []
const overlapRows = (overlapData as any)?.data || []
const districtRows = (districtData as any)?.data || []
const pagedRepl = useMemo(() => replRows.slice((replPage - 1) * PAGE_SIZE, replPage * PAGE_SIZE), [replRows, replPage])
const pagedOverlap = useMemo(() => overlapRows.slice((overlapPage - 1) * PAGE_SIZE, overlapPage * PAGE_SIZE), [overlapRows, overlapPage])
const pagedProfile = useMemo(() => profileRows.slice((profilePage - 1) * PAGE_SIZE, profilePage * PAGE_SIZE), [profileRows, profilePage])
const pageLoading = profileLoading || segLoading || replLoading || overlapLoading || districtLoading
if (pageLoading) {
return <LoadingSpinner text="加载选址分析数据..." />
}
const totalStores = profileRows.length
const totalScenes = Object.keys(profileRows.reduce((acc: any, r: any) => { acc[r.site_scene] = true; return acc }, {})).length
const highRiskCount = overlapRows.filter((r: any) => r.overlap_risk?.startsWith('高风险')).length
const topReplication = replRows.filter((r: any) => r.replication_recommendation === '优先提炼选址原型').length
const scatterData = profileRows.map((r: any) => ({
store_name: r.store_name,
area_sqm: Number(r.area_sqm || 0),
received_per_sqm: Number(r.monthly_received_per_sqm || 0),
received: Number(r.received || 0),
site_scene: r.site_scene,
}))
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold"></h1>
<p className="mt-0.5 text-xs text-muted-foreground"> · 20264 · 91</p>
</div>
{/* 概览指标 */}
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricCard title="标准门店数" value={totalStores} format="number" description="有面积和经营数据的标准门店" />
<MetricCard title="场景类型" value={totalScenes} format="number" description="办公/社区/商场/街边/交通枢纽等" />
<MetricCard title="高风险重叠对" value={highRiskCount} format="number" description="距离<1km且至少一家经营承压" />
<MetricCard title="优先提炼原型" value={topReplication} format="number" description="复制评分≥75且问题数≤1" />
</div>
{/* 面积×坪效散点图 */}
<CollapsibleSection title="面积 × 坪效分布" subtitle="每个点代表一家门店,颜色区分场景">
<ResponsiveContainer width="100%" height={300}>
<ScatterChart margin={{ left: 20, right: 20, top: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" dataKey="area_sqm" name="面积(㎡)" tick={{ fontSize: 10 }} label={{ value: '面积(㎡)', position: 'bottom', offset: 0, fontSize: 11 }} />
<YAxis type="number" dataKey="received_per_sqm" name="月坪效(元/㎡)" tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v} />
<ZAxis type="number" dataKey="received" range={[40, 400]} name="实收" />
<Tooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ payload }: any) => {
if (!payload || !payload.length) return null
const d = payload[0].payload
return (
<div className="rounded border bg-white p-2 text-xs shadow">
<p className="font-bold">{d.store_name}</p>
<p>: {d.site_scene}</p>
<p>: {d.area_sqm}</p>
<p>: {formatCurrency(d.received_per_sqm)}</p>
<p>: {formatCurrency(d.received)}</p>
</div>
)
}}
/>
<Scatter data={scatterData}>
{scatterData.map((entry: any, i: number) => (
<Cell key={i} fill={SCENE_COLORS[entry.site_scene] || '#999'} />
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
<div className="mt-2 flex flex-wrap gap-3 text-xs">
{Object.entries(SCENE_COLORS).map(([scene, color]) => (
<span key={scene} className="flex items-center gap-1">
<span className="inline-block h-3 w-3 rounded-full" style={{ background: color }} />
{scene}
</span>
))}
</div>
</CollapsibleSection>
{/* Tab 切换 */}
<div className="flex gap-2 border-b">
{TABS.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === t.key ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'
}`}
>
{t.label}
</button>
))}
</div>
{/* 分段基准 */}
{tab === 'benchmark' && (
<div className="space-y-4">
<CollapsibleSection title={`场景 × 面积分段基准 (${segRows.length})`} subtitle="各场景各面积段的经营基准,按坪效降序">
<DataTable
columns={[
{ key: 'site_scene', label: '场景' },
{ key: 'area_band', label: '面积段' },
{ key: 'store_count', label: '门店数', align: 'center' },
{ key: 'avg_area_sqm', label: '平均面积', align: 'right', render: (r) => `${r.avg_area_sqm}` },
{ key: 'avg_received', label: '平均实收', align: 'right', render: (r) => formatCurrency(r.avg_received) },
{ key: 'median_received', label: '中位实收', align: 'right', render: (r) => formatCurrency(r.median_received) },
{ key: 'avg_received_per_sqm', label: '平均坪效', align: 'right', render: (r) => formatCurrency(r.avg_received_per_sqm) },
{ key: 'median_received_per_sqm', label: '中位坪效', align: 'right', render: (r) => formatCurrency(r.median_received_per_sqm) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'avg_repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.avg_repeat_rate_pct) },
]}
data={segRows}
/>
</CollapsibleSection>
<CollapsibleSection title="门店画像明细" subtitle="全部标准门店选址画像,可按场景筛选">
<div className="mb-3 flex gap-2">
{['', '办公园区', '商场商业体', '社区居民', '街边综合'].map((f) => (
<button
key={f || 'all'}
onClick={() => { setSceneFilter(f); setProfilePage(1) }}
className={`rounded-md border px-3 py-1.5 text-sm ${sceneFilter === f ? 'border-primary bg-primary text-primary-foreground' : ''}`}
>
{f || '全部'}
</button>
))}
</div>
<Pagination page={profilePage} pageSize={PAGE_SIZE} total={profileRows.length} onPageChange={setProfilePage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景', render: (r) => <span className="rounded px-2 py-0.5 text-xs" style={{ background: (SCENE_COLORS[r.site_scene] || '#999') + '20', color: SCENE_COLORS[r.site_scene] || '#999' }}>{r.site_scene}</span> },
{ key: 'area_band', label: '面积段' },
{ key: 'area_sqm', label: '面积(㎡)', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.repeat_rate_pct) },
{ key: 'actual_food_cost_rate_pct', label: '成本率', align: 'right', render: (r) => formatPercent(r.actual_food_cost_rate_pct) },
]}
data={pagedProfile}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
</CollapsibleSection>
</div>
)}
{/* 复制评分 */}
{tab === 'replication' && (
<CollapsibleSection title={`门店复制评分 (${replRows.length})`} subtitle="综合坪效、日均、复购、优惠纪律、成本、平台、执行七维评分">
<Pagination page={replPage} pageSize={PAGE_SIZE} total={replRows.length} onPageChange={setReplPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name', label: '门店' },
{ key: 'site_scene', label: '场景' },
{ key: 'area_sqm', label: '面积', align: 'right', render: (r) => r.area_sqm ? `${r.area_sqm}` : '-' },
{ key: 'received', label: '实收', align: 'right', render: (r) => formatCurrency(r.received) },
{ key: 'monthly_received_per_sqm', label: '坪效', align: 'right', render: (r) => formatCurrency(r.monthly_received_per_sqm) },
{ key: 'nearest_store_name', label: '最近门店' },
{ key: 'nearest_distance_km', label: '距离(km)', align: 'right', render: (r) => `${r.nearest_distance_km}km` },
{ key: 'site_replication_score', label: '复制评分', align: 'right', render: (r) => {
const v = Number(r.site_replication_score || 0)
return <span className={v >= 75 ? 'font-bold text-green-600' : v >= 60 ? 'text-blue-600' : v < 40 ? 'text-red-600' : ''}>{v.toFixed(2)}</span>
} },
{ key: 'replication_recommendation', label: '复制建议', render: (r) => {
const rec = r.replication_recommendation
const cls = rec === '优先提炼选址原型' ? 'bg-green-100 text-green-700' : rec === '不宜作为选址标杆' ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'
return <span className={`rounded px-2 py-0.5 text-xs ${cls}`}>{rec}</span>
} },
{ key: 'spatial_recommendation', label: '空间建议', render: (r) => <span className="text-xs text-muted-foreground">{r.spatial_recommendation}</span> },
]}
data={pagedRepl}
onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
/>
</div>
</CollapsibleSection>
)}
{/* 重叠风险 */}
{tab === 'overlap' && (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<MetricCard title="高度重叠(<1km)" value={overlapRows.filter((r: any) => r.distance_km < 1).length} format="number" description="需做顾客来源和配送圈验证" />
<MetricCard title="较高重叠(1-2km)" value={overlapRows.filter((r: any) => r.distance_km >= 1 && r.distance_km < 2).length} format="number" description="检查道路阻隔和商圈边界" />
<MetricCard title="观察(2-3km)" value={overlapRows.filter((r: any) => r.distance_km >= 2).length} format="number" description="不能仅用直线距离判断" />
</div>
<CollapsibleSection title={`空间重叠风险 (${overlapRows.length}对)`} subtitle="3公里内门店两两距离与重叠风险等级">
<Pagination page={overlapPage} pageSize={PAGE_SIZE} total={overlapRows.length} onPageChange={setOverlapPage} />
<div className="mt-3">
<DataTable
columns={[
{ key: 'store_name_a', label: '门店A' },
{ key: 'store_name_b', label: '门店B' },
{ key: 'distance_km', label: '距离(km)', align: 'right', render: (r) => `${Number(r.distance_km).toFixed(2)}km` },
{ key: 'received_a', label: 'A实收', align: 'right', render: (r) => formatCurrency(r.received_a) },
{ key: 'received_b', label: 'B实收', align: 'right', render: (r) => formatCurrency(r.received_b) },
{ key: 'overlap_risk', label: '风险等级', render: (r) => {
const risk = r.overlap_risk
const color = RISK_COLORS[risk] || '#999'
return <span className="rounded px-2 py-0.5 text-xs font-medium" style={{ background: color + '20', color }}>{risk}</span>
} },
]}
data={pagedOverlap}
/>
</div>
</CollapsibleSection>
</div>
)}
{/* 区域基准 */}
{tab === 'district' && (
<div className="space-y-4">
<CollapsibleSection title="区域基准柱状图" subtitle="各区域平均实收对比">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={districtRows.slice(0, 15)} margin={{ left: 20 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="district" tick={{ fontSize: 10 }} angle={-30} textAnchor="end" height={60} />
<YAxis tick={{ fontSize: 10 }} tickFormatter={(v) => v >= 10000 ? `${(v / 10000).toFixed(0)}` : v} />
<Tooltip formatter={(v: any) => formatCurrency(v)} />
<Bar dataKey="avg_received" fill="#3b82f6" name="平均实收" />
<Bar dataKey="median_received" fill="#22c55e" name="中位实收" />
</BarChart>
</ResponsiveContainer>
</CollapsibleSection>
<CollapsibleSection title={`区域基准明细 (${districtRows.length})`} subtitle="按区域汇总的经营基准">
<DataTable
columns={[
{ key: 'district', label: '区域' },
{ key: 'store_count', label: '门店数', align: 'center' },
{ key: 'avg_area_sqm', label: '平均面积', align: 'right', render: (r) => `${r.avg_area_sqm}` },
{ key: 'total_received', label: '总实收', align: 'right', render: (r) => formatCurrency(r.total_received) },
{ key: 'avg_received', label: '平均实收', align: 'right', render: (r) => formatCurrency(r.avg_received) },
{ key: 'median_received', label: '中位实收', align: 'right', render: (r) => formatCurrency(r.median_received) },
{ key: 'avg_received_per_sqm', label: '平均坪效', align: 'right', render: (r) => formatCurrency(r.avg_received_per_sqm) },
{ key: 'avg_bill_value', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_bill_value) },
{ key: 'avg_repeat_rate_pct', label: '复购率', align: 'right', render: (r) => formatPercent(r.avg_repeat_rate_pct) },
{ key: 'p0_count', label: 'P0', align: 'center', render: (r) => r.p0_count > 0 ? <span className="font-bold text-red-600">{r.p0_count}</span> : '0' },
{ key: 'p1_count', label: 'P1', align: 'center', render: (r) => r.p1_count > 0 ? <span className="font-bold text-yellow-600">{r.p1_count}</span> : '0' },
]}
data={districtRows}
/>
</CollapsibleSection>
</div>
)}
</div>
)
}
+1
View File
@@ -41,6 +41,7 @@ export function StoreDetailPage() {
const { data: tasksData } = useQuery({
queryKey: ['store', code, 'tasks'],
queryFn: () => api.get('/tasks', { params: { store_code: code, month: '2026-05', page_size: 50 } }),
enabled: tab === 'tasks',
})
const { data: mealData } = useQuery({