+
+
门店选址分析
+
基于全部现有数据的选址决策支持 · 2026年4月 · 91家经营门店
+
+
+ {/* 概览指标 */}
+
+
+
+
+
+
+
+ {/* 面积×坪效散点图 */}
+
+
+
+
+
+ v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v} />
+
+ {
+ if (!payload || !payload.length) return null
+ const d = payload[0].payload
+ return (
+
+
{d.store_name}
+
场景: {d.site_scene}
+
面积: {d.area_sqm}㎡
+
月坪效: {formatCurrency(d.received_per_sqm)}
+
实收: {formatCurrency(d.received)}
+
+ )
+ }}
+ />
+
+ {scatterData.map((entry: any, i: number) => (
+ |
+ ))}
+
+
+
+
+ {Object.entries(SCENE_COLORS).map(([scene, color]) => (
+
+
+ {scene}
+
+ ))}
+
+
+
+ {/* Tab 切换 */}
+
+ {TABS.map((t) => (
+
+ ))}
+
+
+ {/* 分段基准 */}
+ {tab === 'benchmark' && (
+
+
+ `${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}
+ />
+
+
+
+
+ {['', '办公园区', '商场商业体', '社区居民', '街边综合'].map((f) => (
+
+ ))}
+
+
+
+ {r.site_scene} },
+ { 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}`)}
+ />
+
+
+
+ )}
+
+ {/* 复制评分 */}
+ {tab === 'replication' && (
+
+
+
+ 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 = 75 ? 'font-bold text-green-600' : v >= 60 ? 'text-blue-600' : v < 40 ? 'text-red-600' : ''}>{v.toFixed(2)}
+ } },
+ { 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 {rec}
+ } },
+ { key: 'spatial_recommendation', label: '空间建议', render: (r) => {r.spatial_recommendation} },
+ ]}
+ data={pagedRepl}
+ onRowClick={(r) => navigate(`/stores/${r.store_code}`)}
+ />
+
+
+ )}
+
+ {/* 重叠风险 */}
+ {tab === 'overlap' && (
+
+
+ r.distance_km < 1).length} format="number" description="需做顾客来源和配送圈验证" />
+ r.distance_km >= 1 && r.distance_km < 2).length} format="number" description="检查道路阻隔和商圈边界" />
+ r.distance_km >= 2).length} format="number" description="不能仅用直线距离判断" />
+
+
+
+
+ `${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 {risk}
+ } },
+ ]}
+ data={pagedOverlap}
+ />
+
+
+
+ )}
+
+ {/* 区域基准 */}
+ {tab === 'district' && (
+
+
+
+
+
+
+ v >= 10000 ? `${(v / 10000).toFixed(0)}万` : v} />
+ formatCurrency(v)} />
+
+
+
+
+
+
+ `${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 ? {r.p0_count} : '0' },
+ { key: 'p1_count', label: 'P1', align: 'center', render: (r) => r.p1_count > 0 ? {r.p1_count} : '0' },
+ ]}
+ data={districtRows}
+ />
+
+
+ )}
+
+ )
+}
diff --git a/client/src/pages/StoreDetailPage.tsx b/client/src/pages/StoreDetailPage.tsx
index 672cc45..dc3534e 100644
--- a/client/src/pages/StoreDetailPage.tsx
+++ b/client/src/pages/StoreDetailPage.tsx
@@ -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({
diff --git a/demo/README.md b/demo/README.md
new file mode 100644
index 0000000..905c4cb
--- /dev/null
+++ b/demo/README.md
@@ -0,0 +1,112 @@
+# 马兰拉面数字化运营管理平台 - 产品演示视频
+
+## 视频文件
+
+- **product-demo.mp4** — 32秒,16张截图,每张2秒,1920×1080,硬切无黑屏
+
+## 截图编排(按《餐易通×收钱吧战略融合价值分析》逻辑组织)
+
+### 第一章:行业本质问题——餐饮数字化的"半截子"困境
+
+| 序号 | 文件 | 页面 | 对应文档逻辑 |
+|---|---|---|---|
+| s01 | s01-dashboard-top.png | 总部驾驶舱-顶部 | **底层基础设施已就位**:支付数据已沉淀,KPI卡片总览全局经营 |
+| s02 | s02-dashboard-charts.png | 总部驾驶舱-图表 | **但数据未转化为经营决策**:象限散点图、瀑布图、平台成本率图展示数据深度分析能力 |
+
+### 第二章:战略定位——从"帮商户收钱"到"帮商户赚钱"
+
+| 序号 | 文件 | 页面 | 对应文档逻辑 |
+|---|---|---|---|
+| s03 | s03-regional-overview.png | 区域经理工作台 | **区域经营汇总**:12个区按实收排名,红黄绿风险分布,从GMV到经营利润的价值锚点转换 |
+
+### 第三章:数据飞轮——融合后的增长引擎
+
+| 序号 | 文件 | 页面 | 对应文档逻辑 |
+|---|---|---|---|
+| s04 | s04-store-manager-full.png | 店长工作台-全页 | **数据→分析→指令→行动闭环**:经营概览→指标进度→SKU备货→任务执行 |
+| s05 | s05-store-manager-top.png | 店长工作台-顶部 | **可执行的经营指令**:最新经营概览(异常标注)+ 本周指标进度条(达标判定),店长几分钟看完就知道今天干什么 |
+
+### 第四章:餐易通为什么能做到——不是软件公司,是餐饮产业公司
+
+| 序号 | 文件 | 页面 | 对应文档逻辑 |
+|---|---|---|---|
+| s06 | s06-store-detail-overview.png | 门店详情-概览 | **从支付→菜品→成本全链路**:菜百店KPI、日度趋势、平台经济性 |
+| s07 | s07-store-detail-meal-period.png | 门店详情-餐段分析 | **AI客流预测基础**:午市/晚市/下午茶/早市客流热力分布 |
+| s08 | s08-store-detail-category.png | 门店详情-品类结构 | **标准化SKU结构**:品类销售占比、搭售分析 |
+| s09 | s09-store-detail-cost.png | 门店详情-成本分析 | **成本诊断模型**:理论成本vs实际成本差异定位 |
+| s10 | s10-store-detail-member.png | 门店详情-会员分析 | **会员复购分析**:RFM分层、沉睡会员召回机会 |
+| s11 | s11-store-detail-anomaly.png | 门店详情-异常账单 | **风险内控**:异常账单识别与闭环处理 |
+
+### 第五章:产业级影响——从服务商户到定义标准
+
+| 序号 | 文件 | 页面 | 对应文档逻辑 |
+|---|---|---|---|
+| s12 | s12-monthly-review-summary.png | 月度验收-验收汇总 | **从"数字化"到"智能化"**:月度验收KPI汇总,每天只推少量异常和待办 |
+| s13 | s13-monthly-review-grade.png | 月度验收-升降级面板 | **从"开店"到"复制盈利模型"**:P0/P1/P2等级变化,标准化经营模型验证 |
+| s14 | s14-monthly-review-completion.png | 月度验收-任务完成率 | **闭环验证**:按优先级汇总任务完成率,验证改善效果 |
+| s15 | s15-monthly-review-activity.png | 月度验收-活动清单 | **平台经济性分析**:逐活动贡献毛利,停/改/留清单由数据驱动 |
+| s16 | s16-monthly-review-sku.png | 月度验收-SKU治理 | **标准化SKU输出**:ABC分类饼图,长尾SKU精简治理 |
+
+## 视频合成命令
+
+```bash
+# 每张图2秒,硬切无转场,1920×1080
+cd demo && ffmpeg -y -framerate 1/2 -pattern_type glob -i 's*.png' \
+ -c:v libx264 -pix_fmt yuv420p \
+ -vf "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:0x0F0F0F" \
+ -r 30 product-demo.mp4
+```
+
+## 推荐旁白文案(按文档九章结构)
+
+> **s01-s02 总部驾驶舱**
+> "中国餐饮数字化面临'半截子'困境:支付基础设施已铺到位,但数据没有向上转化为经营决策。我们的平台从支付数据出发,构建象限散点图、瀑布图、平台成本率图,让总部一屏看全局。"
+>
+> **s03 区域经理工作台**
+> "区域经理工作台,按12个区汇总经营数据,红黄绿风险一目了然。价值锚点从交易规模转向经营利润,从'帮商户收钱'升级为'帮商户赚钱'。"
+>
+> **s04-s05 店长工作台**
+> "数据飞轮的核心:经营概览标注异常项,本周指标进度条直观显示达标情况,核心SKU备货提醒确保不断货,待办任务指引每日行动。店长几分钟看完就知道今天干什么——不是更多报表,而是更少的、更准的、能直接行动的指令。"
+>
+> **s06 门店详情-概览**
+> "以菜百店为例,从支付到菜品到成本的全链路分析。日度实收趋势、平台经济性,一切用数据说话。"
+>
+> **s07 餐段分析**
+> "餐段分析精准定位午市、晚市、下午茶、早市的客流与客单价差异,为AI客流预测和智能排班提供基础。"
+>
+> **s08 品类结构**
+> "品类结构分析揭示销售集中度和搭售机会,为标准化SKU模型提供数据支撑。"
+>
+> **s09 成本分析**
+> "成本诊断模型对比理论成本与实际成本,精准定位差异来源——这不是给商户一套漂亮的报表,而是给出一个店长明天就能执行的动作。"
+>
+> **s10 会员分析**
+> "会员复购分析,基于支付身份识别自动分层,发现沉睡会员召回机会,构建精准营销闭环。"
+>
+> **s11 异常账单**
+> "风险内控模块自动识别异常账单,从发现到处理全程闭环,让每一家门店的经营都合规可控。"
+>
+> **s12 月度验收汇总**
+> "月度验收与复盘,从'数字化'升级为'智能化'。不是给商户更多报表,而是更少的、更准的、能直接行动的指令。"
+>
+> **s13 升降级面板**
+> "P0/P1/P2等级变化记录,验证标准化经营模型的可复制性。从'开店'到'复制盈利模型','开一家赚一家'取代'开了再说'。"
+>
+> **s14 任务完成率**
+> "任务完成率按优先级汇总,闭环验证改善效果。每一个改善都可追踪、可量化。"
+>
+> **s15 活动清单**
+> "逐活动贡献毛利分析,识别'越卖越亏'的活动。停、改、留清单由数据驱动而非经验判断。"
+>
+> **s16 SKU治理**
+> "ABC分类识别长尾SKU,推动SKU精简治理。标准SKU结构、标准备货量、标准成本管控——这就是可复制的盈利模型。"
+>
+> **结尾**
+> "马兰拉面数字化运营管理平台——支付底座+经营大脑,数据驱动,闭环管理,让每一家门店都更好。"
+
+## 截图参数
+
+- 分辨率:1440×900 viewport
+- 格式:PNG
+- 设备像素比:device(Retina高清)
+- 门店:菜百店(store_code=1111)
diff --git a/demo/add-subtitles.py b/demo/add-subtitles.py
new file mode 100644
index 0000000..4f3517d
--- /dev/null
+++ b/demo/add-subtitles.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""Add subtitle text to each screenshot, then build video with ffmpeg."""
+import os, subprocess
+from PIL import Image, ImageDraw, ImageFont
+
+DEMO_DIR = os.path.dirname(os.path.abspath(__file__))
+FONT_PATH = "/System/Library/Fonts/STHeiti Medium.ttc"
+FONT_SIZE = 36
+
+# (filename, subtitle_text)
+screenshots = [
+ ("s01-dashboard-top.png", "总部驾驶舱 — 全局经营总览,KPI一屏掌握"),
+ ("s02-dashboard-charts.png", "象限散点图·瀑布图·平台成本率 — 数据深度分析"),
+ ("s03-regional-overview.png", "区域经理工作台 — 12区经营汇总,红黄绿风险分布"),
+ ("s04-store-manager-full.png", "店长工作台 — 经营概览·指标进度·SKU备货·任务闭环"),
+ ("s05-store-manager-top.png", "店长工作台 — 异常标注+指标进度条,几分钟看完就知道今天干什么"),
+ ("s06-store-detail-overview.png", "门店详情·概览 — 菜百店全链路:KPI·日度趋势·平台经济性"),
+ ("s07-store-detail-meal-period.png", "门店详情·餐段分析 — 午市/晚市/下午茶/早市客流与客单价"),
+ ("s08-store-detail-category.png", "门店详情·品类结构 — 销售集中度与搭售机会分析"),
+ ("s09-store-detail-cost.png", "门店详情·成本分析 — 理论成本vs实际成本,精准定位差异"),
+ ("s10-store-detail-member.png", "门店详情·会员分析 — RFM分层,沉睡会员召回机会"),
+ ("s11-store-detail-anomaly.png", "门店详情·异常账单 — 风险内控,自动识别与闭环处理"),
+ ("s12-monthly-review-summary.png", "月度验收·验收汇总 — 从数字化到智能化,只推可执行指令"),
+ ("s13-monthly-review-grade.png", "月度验收·升降级面板 — P0/P1/P2等级变化,复制盈利模型"),
+ ("s14-monthly-review-completion.png","月度验收·任务完成率 — 按优先级汇总,闭环验证改善效果"),
+ ("s15-monthly-review-activity.png", "月度验收·活动清单 — 逐活动贡献毛利,停改留由数据驱动"),
+ ("s16-monthly-review-sku.png", "月度验收·SKU治理 — ABC分类,长尾精简,标准化输出"),
+]
+
+sub_dir = os.path.join(DEMO_DIR, "sub")
+os.makedirs(sub_dir, exist_ok=True)
+
+font = ImageFont.truetype(FONT_PATH, FONT_SIZE)
+
+for fname, text in screenshots:
+ src = os.path.join(DEMO_DIR, fname)
+ img = Image.open(src).convert("RGBA")
+ w, h = img.size
+
+ # Create overlay for text background bar
+ overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ draw = ImageDraw.Draw(overlay)
+
+ # Measure text
+ bbox = draw.textbbox((0, 0), text, font=font)
+ tw = bbox[2] - bbox[0]
+ th = bbox[3] - bbox[1]
+
+ # Draw semi-transparent black bar at bottom
+ bar_h = th + 30
+ bar_y = h - bar_h
+ draw.rectangle([0, bar_y, w, h], fill=(0, 0, 0, 180))
+
+ # Draw text centered
+ tx = (w - tw) // 2
+ ty = bar_y + 15
+ draw.text((tx, ty), text, font=font, fill=(255, 255, 255, 255))
+
+ # Composite
+ result = Image.alpha_composite(img, overlay).convert("RGB")
+ dst = os.path.join(sub_dir, fname)
+ result.save(dst, "PNG")
+ print(f" {fname} -> sub/{fname}")
+
+print(f"\nGenerated {len(screenshots)} annotated screenshots in sub/")
+
+# Build video
+print("\nBuilding video with ffmpeg...")
+cmd = [
+ "ffmpeg", "-y",
+ "-framerate", "1/2",
+ "-pattern_type", "glob",
+ "-i", os.path.join(sub_dir, "s*.png"),
+ "-c:v", "libx264",
+ "-pix_fmt", "yuv420p",
+ "-vf", "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:0x0F0F0F",
+ "-r", "30",
+ os.path.join(DEMO_DIR, "product-demo.mp4")
+]
+result = subprocess.run(cmd, capture_output=True, text=True)
+if result.returncode == 0:
+ print("Video built: product-demo.mp4")
+else:
+ print(f"Error: {result.stderr[-500:]}")
diff --git a/demo/base.mp4 b/demo/base.mp4
new file mode 100644
index 0000000..3749f1f
Binary files /dev/null and b/demo/base.mp4 differ
diff --git a/demo/build-video.sh b/demo/build-video.sh
new file mode 100755
index 0000000..39ff3ad
--- /dev/null
+++ b/demo/build-video.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+cd /Users/freedak/Documents/AIDashboard/SBrainCO/demo
+
+ffmpeg -y -framerate 1/2 -pattern_type glob -i 's*.png' \
+ -c:v libx264 -pix_fmt yuv420p \
+ -vf "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:0x0F0F0F,subtitles=subtitles.srt:force_style='FontName=Heiti SC,FontSize=22,PrimaryColour=&H00FFFFFF,OutlineColour=&H000F0F0F,BorderStyle=3,Outline=2,Shadow=0,MarginV=40,Alignment=2'" \
+ -r 30 product-demo.mp4
diff --git a/demo/filelist.txt b/demo/filelist.txt
new file mode 100644
index 0000000..e5ba38f
--- /dev/null
+++ b/demo/filelist.txt
@@ -0,0 +1,32 @@
+file 's01-dashboard-top.png'
+file 's01-dashboard-top.png'
+file 's02-dashboard-charts.png'
+file 's02-dashboard-charts.png'
+file 's03-regional-overview.png'
+file 's03-regional-overview.png'
+file 's04-store-manager-full.png'
+file 's04-store-manager-full.png'
+file 's05-store-manager-top.png'
+file 's05-store-manager-top.png'
+file 's06-store-detail-overview.png'
+file 's06-store-detail-overview.png'
+file 's07-store-detail-meal-period.png'
+file 's07-store-detail-meal-period.png'
+file 's08-store-detail-category.png'
+file 's08-store-detail-category.png'
+file 's09-store-detail-cost.png'
+file 's09-store-detail-cost.png'
+file 's10-store-detail-member.png'
+file 's10-store-detail-member.png'
+file 's11-store-detail-anomaly.png'
+file 's11-store-detail-anomaly.png'
+file 's12-monthly-review-summary.png'
+file 's12-monthly-review-summary.png'
+file 's13-monthly-review-grade.png'
+file 's13-monthly-review-grade.png'
+file 's14-monthly-review-completion.png'
+file 's14-monthly-review-completion.png'
+file 's15-monthly-review-activity.png'
+file 's15-monthly-review-activity.png'
+file 's16-monthly-review-sku.png'
+file 's16-monthly-review-sku.png'
diff --git a/demo/filter.txt b/demo/filter.txt
new file mode 100644
index 0000000..8473cd1
--- /dev/null
+++ b/demo/filter.txt
@@ -0,0 +1,3 @@
+scale=1920:1080:force_original_aspect_ratio=decrease
+pad=1920:1080:(ow-iw)/2:(oh-ih)/2:0x0F0F0F
+subtitles=subtitles.srt:force_style='FontName=Heiti SC,FontSize=22,PrimaryColour=&H00FFFFFF,OutlineColour=&H000F0F0F,BorderStyle=3,Outline=2,Shadow=0,MarginV=40,Alignment=2'
diff --git a/demo/product-demo.mp4 b/demo/product-demo.mp4
new file mode 100644
index 0000000..93e3f07
Binary files /dev/null and b/demo/product-demo.mp4 differ
diff --git a/demo/subtitles.srt b/demo/subtitles.srt
new file mode 100644
index 0000000..8b307c9
--- /dev/null
+++ b/demo/subtitles.srt
@@ -0,0 +1,71 @@
+1
+00:00:00,000 --> 00:00:02,000
+总部驾驶舱 — 全局经营总览,KPI一屏掌握
+
+2
+00:00:02,000 --> 00:00:04,000
+象限散点图·瀑布图·平台成本率 — 数据深度分析
+
+3
+00:00:00,000 --> 00:00:02,000
+总部驾驶舱 — 全局经营总览,KPI一屏掌握
+
+2
+00:00:02,000 --> 00:00:04,000
+象限散点图·瀑布图·平台成本率 — 数据深度分析
+
+3
+00:00:04,000 --> 00:00:06,000
+区域经理工作台 — 12区经营汇总,红黄绿风险分布
+
+4
+00:00:06,000 --> 00:00:08,000
+店长工作台 — 经营概览·指标进度·SKU备货·任务闭环
+
+5
+00:00:08,000 --> 00:00:10,000
+店长工作台 — 异常标注+指标进度条,几分钟看完就知道今天干什么
+
+6
+00:00:10,000 --> 00:00:12,000
+门店详情·概览 — 菜百店全链路:KPI·日度趋势·平台经济性
+
+7
+00:00:12,000 --> 00:00:14,000
+门店详情·餐段分析 — 午市/晚市/下午茶/早市客流与客单价
+
+8
+00:00:14,000 --> 00:00:16,000
+门店详情·品类结构 — 销售集中度与搭售机会分析
+
+9
+00:00:16,000 --> 00:00:18,000
+门店详情·成本分析 — 理论成本vs实际成本,精准定位差异
+
+10
+00:00:18,000 --> 00:00:20,000
+门店详情·会员分析 — RFM分层,沉睡会员召回机会
+
+11
+00:00:20,000 --> 00:00:22,000
+门店详情·异常账单 — 风险内控,自动识别与闭环处理
+
+12
+00:00:22,000 --> 00:00:24,000
+月度验收·验收汇总 — 从数字化到智能化,只推可执行指令
+
+13
+00:00:24,000 --> 00:00:26,000
+月度验收·升降级面板 — P0/P1/P2等级变化,复制盈利模型
+
+14
+00:00:26,000 --> 00:00:28,000
+月度验收·任务完成率 — 按优先级汇总,闭环验证改善效果
+
+15
+00:00:28,000 --> 00:00:30,000
+月度验收·活动清单 — 逐活动贡献毛利,停改留由数据驱动
+
+16
+00:00:30,000 --> 00:00:32,000
+月度验收·SKU治理 — ABC分类,长尾精简,标准化输出
diff --git a/server/sql/09_add_indexes.sql b/server/sql/09_add_indexes.sql
new file mode 100644
index 0000000..73b6e0e
--- /dev/null
+++ b/server/sql/09_add_indexes.sql
@@ -0,0 +1,10 @@
+-- ============================================================
+-- 09_add_indexes.sql
+-- 门店详情页性能优化索引
+-- ============================================================
+
+-- bill_fact 表:门店+日期复合索引(日度趋势、概览等查询使用)
+CREATE INDEX IF NOT EXISTS idx_bill_fact_store_closed ON analytics.bill_fact (store_code, closed_at);
+
+-- bill_fact 物化视图:单独的 store_code 索引(其他视图按 store_code 过滤时使用)
+CREATE INDEX IF NOT EXISTS idx_bill_fact_store ON analytics.bill_fact (store_code);
diff --git a/server/sql/10_materialize_slow_views.sql b/server/sql/10_materialize_slow_views.sql
new file mode 100644
index 0000000..7b600c8
--- /dev/null
+++ b/server/sql/10_materialize_slow_views.sql
@@ -0,0 +1,492 @@
+-- ============================================================
+-- 10_materialize_slow_views.sql
+-- 将门店详情页慢视图转为物化视图,加 store_code 索引
+-- 预期:/stores/:code 从 ~15s 降至 <100ms
+-- ============================================================
+
+-- ============================================================
+-- Step 1: 依赖视图按逆序 DROP
+-- ============================================================
+DROP VIEW IF EXISTS analytics.v_store_execution_priority;
+DROP VIEW IF EXISTS analytics.v_store_deep_diagnosis_april;
+DROP VIEW IF EXISTS analytics.v_store_benchmark_composite;
+DROP VIEW IF EXISTS analytics.v_store_action_list;
+
+-- ============================================================
+-- Step 2: 基础视图 → 物化视图
+-- ============================================================
+
+-- 2a. v_store_platform_economics
+DROP VIEW IF EXISTS analytics.v_store_platform_economics;
+CREATE MATERIALIZED VIEW analytics.v_store_platform_economics AS
+ SELECT NULLIF(bill_records.c002, ''::text) AS store_code,
+ NULLIF(bill_records.c003, ''::text) AS store_name,
+ sum(COALESCE(NULLIF(bill_records.c151, ''::text)::numeric, 0::numeric)) AS meituan_received,
+ sum(COALESCE(NULLIF(bill_records.c101, ''::text)::numeric, 0::numeric)) AS meituan_discount,
+ sum(COALESCE(NULLIF(bill_records.c097, ''::text)::numeric, 0::numeric)) AS meituan_commission,
+ sum(COALESCE(NULLIF(bill_records.c152, ''::text)::numeric, 0::numeric)) AS taobao_received,
+ sum(COALESCE(NULLIF(bill_records.c102, ''::text)::numeric, 0::numeric)) AS taobao_discount,
+ sum(COALESCE(NULLIF(bill_records.c098, ''::text)::numeric, 0::numeric)) AS taobao_commission,
+ sum(COALESCE(NULLIF(bill_records.c150, ''::text)::numeric, 0::numeric)) AS jd_received,
+ sum(COALESCE(NULLIF(bill_records.c099, ''::text)::numeric, 0::numeric)) AS jd_discount,
+ sum(COALESCE(NULLIF(bill_records.c100, ''::text)::numeric, 0::numeric)) AS jd_commission,
+ round((sum(COALESCE(NULLIF(bill_records.c101, ''::text)::numeric, 0::numeric)) + sum(COALESCE(NULLIF(bill_records.c097, ''::text)::numeric, 0::numeric))) / NULLIF(sum(COALESCE(NULLIF(bill_records.c151, ''::text)::numeric, 0::numeric)) + sum(COALESCE(NULLIF(bill_records.c101, ''::text)::numeric, 0::numeric)) + sum(COALESCE(NULLIF(bill_records.c097, ''::text)::numeric, 0::numeric)), 0::numeric) * 100::numeric, 2) AS meituan_cost_rate_pct,
+ round((sum(COALESCE(NULLIF(bill_records.c102, ''::text)::numeric, 0::numeric)) + sum(COALESCE(NULLIF(bill_records.c098, ''::text)::numeric, 0::numeric))) / NULLIF(sum(COALESCE(NULLIF(bill_records.c152, ''::text)::numeric, 0::numeric)) + sum(COALESCE(NULLIF(bill_records.c102, ''::text)::numeric, 0::numeric)) + sum(COALESCE(NULLIF(bill_records.c098, ''::text)::numeric, 0::numeric)), 0::numeric) * 100::numeric, 2) AS taobao_cost_rate_pct,
+ round((sum(COALESCE(NULLIF(bill_records.c099, ''::text)::numeric, 0::numeric)) + sum(COALESCE(NULLIF(bill_records.c100, ''::text)::numeric, 0::numeric))) / NULLIF(sum(COALESCE(NULLIF(bill_records.c150, ''::text)::numeric, 0::numeric)) + sum(COALESCE(NULLIF(bill_records.c099, ''::text)::numeric, 0::numeric)) + sum(COALESCE(NULLIF(bill_records.c100, ''::text)::numeric, 0::numeric)), 0::numeric) * 100::numeric, 2) AS jd_cost_rate_pct
+ FROM bill_records
+ WHERE NULLIF(bill_records.c005, ''::text) IS NOT NULL
+ GROUP BY (NULLIF(bill_records.c002, ''::text)), (NULLIF(bill_records.c003, ''::text))
+ WITH DATA;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_platform_economics_store ON analytics.v_store_platform_economics (store_code);
+
+-- 2b. v_store_category_mix
+DROP VIEW IF EXISTS analytics.v_store_category_mix;
+CREATE MATERIALIZED VIEW analytics.v_store_category_mix AS
+ SELECT NULLIF(bill_records.c002, ''::text) AS store_code,
+ NULLIF(bill_records.c003, ''::text) AS store_name,
+ sum(COALESCE(NULLIF(bill_records.c009, ''::text)::numeric, 0::numeric)) AS consumption,
+ sum(COALESCE(NULLIF(bill_records.c010, ''::text)::numeric, 0::numeric)) AS lanzhou_noodle,
+ sum(COALESCE(NULLIF(bill_records.c016, ''::text)::numeric, 0::numeric)) AS western_staple,
+ sum(COALESCE(NULLIF(bill_records.c027, ''::text)::numeric, 0::numeric)) AS delivery_package,
+ sum(COALESCE(NULLIF(bill_records.c013, ''::text)::numeric, 0::numeric)) AS night_bbq,
+ sum(COALESCE(NULLIF(bill_records.c015, ''::text)::numeric, 0::numeric)) AS cold_dishes,
+ sum(COALESCE(NULLIF(bill_records.c019, ''::text)::numeric, 0::numeric)) AS silk_road_food,
+ round(sum(COALESCE(NULLIF(bill_records.c010, ''::text)::numeric, 0::numeric)) / NULLIF(sum(COALESCE(NULLIF(bill_records.c009, ''::text)::numeric, 0::numeric)), 0::numeric) * 100::numeric, 2) AS noodle_share_pct,
+ round(sum(COALESCE(NULLIF(bill_records.c027, ''::text)::numeric, 0::numeric)) / NULLIF(sum(COALESCE(NULLIF(bill_records.c009, ''::text)::numeric, 0::numeric)), 0::numeric) * 100::numeric, 2) AS delivery_package_share_pct,
+ round(GREATEST(sum(COALESCE(NULLIF(bill_records.c010, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c016, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c027, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c013, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c015, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c019, ''::text)::numeric, 0::numeric))) / NULLIF(sum(COALESCE(NULLIF(bill_records.c009, ''::text)::numeric, 0::numeric)), 0::numeric) * 100::numeric, 2) AS top_category_share_pct,
+ CASE GREATEST(sum(COALESCE(NULLIF(bill_records.c010, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c016, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c027, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c013, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c015, ''::text)::numeric, 0::numeric)), sum(COALESCE(NULLIF(bill_records.c019, ''::text)::numeric, 0::numeric)))
+ WHEN sum(COALESCE(NULLIF(bill_records.c010, ''::text)::numeric, 0::numeric)) THEN '兰州牛肉面'::text
+ WHEN sum(COALESCE(NULLIF(bill_records.c016, ''::text)::numeric, 0::numeric)) THEN '西部主食'::text
+ WHEN sum(COALESCE(NULLIF(bill_records.c027, ''::text)::numeric, 0::numeric)) THEN '外卖套餐'::text
+ WHEN sum(COALESCE(NULLIF(bill_records.c013, ''::text)::numeric, 0::numeric)) THEN '夜市烧烤'::text
+ WHEN sum(COALESCE(NULLIF(bill_records.c015, ''::text)::numeric, 0::numeric)) THEN '爽口凉菜'::text
+ ELSE '丝路美食'::text
+ END AS top_category
+ FROM bill_records
+ WHERE NULLIF(bill_records.c005, ''::text) IS NOT NULL
+ GROUP BY (NULLIF(bill_records.c002, ''::text)), (NULLIF(bill_records.c003, ''::text))
+ WITH DATA;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_category_mix_store ON analytics.v_store_category_mix (store_code);
+
+-- 2c. v_store_benchmark
+DROP VIEW IF EXISTS analytics.v_store_benchmark;
+CREATE MATERIALIZED VIEW analytics.v_store_benchmark AS
+ WITH eligible AS (
+ SELECT v_store_risk_rating.store_code,
+ v_store_risk_rating.store_name,
+ v_store_risk_rating.bill_count,
+ v_store_risk_rating.active_days,
+ v_store_risk_rating.received,
+ v_store_risk_rating.avg_daily_received,
+ v_store_risk_rating.avg_bill_value,
+ v_store_risk_rating.avg_guest_value,
+ v_store_risk_rating.discount_rate_pct,
+ v_store_risk_rating.theoretical_margin_pct,
+ v_store_risk_rating.member_bill_share_pct,
+ v_store_risk_rating.anomaly_rate_pct,
+ v_store_risk_rating.risk_level,
+ v_store_risk_rating.primary_issue
+ FROM analytics.v_store_risk_rating
+ WHERE v_store_risk_rating.active_days >= 25 AND v_store_risk_rating.bill_count >= 5000
+ ), stats AS (
+ SELECT percentile_cont(0.25::double precision) WITHIN GROUP (ORDER BY (eligible.avg_daily_received::double precision)) AS revenue_p25,
+ percentile_cont(0.50::double precision) WITHIN GROUP (ORDER BY (eligible.avg_daily_received::double precision)) AS revenue_p50,
+ percentile_cont(0.75::double precision) WITHIN GROUP (ORDER BY (eligible.avg_daily_received::double precision)) AS revenue_p75,
+ percentile_cont(0.50::double precision) WITHIN GROUP (ORDER BY (eligible.theoretical_margin_pct::double precision)) AS margin_p50,
+ percentile_cont(0.75::double precision) WITHIN GROUP (ORDER BY (eligible.theoretical_margin_pct::double precision)) AS margin_p75,
+ percentile_cont(0.75::double precision) WITHIN GROUP (ORDER BY (eligible.discount_rate_pct::double precision)) AS discount_p75
+ FROM eligible
+ ), base AS (
+ SELECT e.store_code,
+ e.store_name,
+ e.bill_count,
+ e.active_days,
+ e.received,
+ e.avg_daily_received,
+ e.avg_bill_value,
+ e.avg_guest_value,
+ e.discount_rate_pct,
+ e.theoretical_margin_pct,
+ e.member_bill_share_pct,
+ e.anomaly_rate_pct,
+ e.risk_level,
+ e.primary_issue,
+ s.revenue_p25,
+ s.revenue_p50,
+ s.revenue_p75,
+ s.margin_p50,
+ s.margin_p75,
+ s.discount_p75
+ FROM eligible e
+ CROSS JOIN stats s
+ )
+ SELECT base.store_code,
+ base.store_name,
+ base.bill_count,
+ base.active_days,
+ base.received,
+ base.avg_daily_received,
+ base.avg_bill_value,
+ base.avg_guest_value,
+ base.discount_rate_pct,
+ base.theoretical_margin_pct,
+ base.member_bill_share_pct,
+ base.anomaly_rate_pct,
+ base.risk_level,
+ base.primary_issue,
+ base.revenue_p25,
+ base.revenue_p50,
+ base.revenue_p75,
+ base.margin_p50,
+ base.margin_p75,
+ base.discount_p75,
+ CASE
+ WHEN base.avg_daily_received::double precision >= base.revenue_p75 AND base.theoretical_margin_pct::double precision >= base.margin_p50 THEN '明星门店'::text
+ WHEN base.avg_daily_received::double precision >= base.revenue_p75 AND base.theoretical_margin_pct::double precision < base.margin_p50 THEN '规模承压'::text
+ WHEN base.avg_daily_received::double precision < base.revenue_p75 AND base.theoretical_margin_pct::double precision >= base.margin_p75 THEN '高效潜力'::text
+ WHEN base.avg_daily_received::double precision <= base.revenue_p25 OR base.theoretical_margin_pct < 68::numeric OR base.discount_rate_pct::double precision > base.discount_p75 THEN '重点改善'::text
+ ELSE '稳健经营'::text
+ END AS management_quadrant,
+ round(GREATEST(base.received / NULLIF(1::numeric - base.discount_rate_pct / 100::numeric, 0::numeric) * (base.discount_rate_pct - 20.23) / 100::numeric, 0::numeric), 2) AS discount_saving_to_company_avg,
+ round(GREATEST(base.received * 0.7092 - base.received * base.theoretical_margin_pct / 100::numeric, 0::numeric), 2) AS profit_uplift_to_company_margin
+ FROM base
+ WITH DATA;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_benchmark_store ON analytics.v_store_benchmark (store_code);
+
+-- ============================================================
+-- Step 3: 组合视图 → 物化视图
+-- ============================================================
+
+-- 3a. v_store_action_list (joins benchmark + category_mix + platform_economics)
+CREATE MATERIALIZED VIEW analytics.v_store_action_list AS
+ SELECT b.store_code,
+ b.store_name,
+ b.management_quadrant,
+ b.risk_level,
+ b.primary_issue,
+ b.received,
+ b.avg_daily_received,
+ b.avg_bill_value,
+ b.discount_rate_pct,
+ b.theoretical_margin_pct,
+ b.anomaly_rate_pct,
+ b.member_bill_share_pct,
+ b.discount_saving_to_company_avg,
+ b.profit_uplift_to_company_margin,
+ c.top_category,
+ c.top_category_share_pct,
+ c.delivery_package_share_pct,
+ p.meituan_cost_rate_pct,
+ p.taobao_cost_rate_pct,
+ p.jd_cost_rate_pct
+ FROM analytics.v_store_benchmark b
+ LEFT JOIN analytics.v_store_category_mix c USING (store_code, store_name)
+ LEFT JOIN analytics.v_store_platform_economics p USING (store_code, store_name)
+ WITH DATA;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_action_list_store ON analytics.v_store_action_list (store_code);
+
+-- 3b. v_store_execution_priority (joins action_list + meal + member + zero_bill)
+CREATE MATERIALIZED VIEW analytics.v_store_execution_priority AS
+ WITH meal AS (
+ SELECT v_store_meal_opportunity.store_code,
+ sum(v_store_meal_opportunity.avg_bill_uplift_scenario) AS meal_uplift_scenario
+ FROM analytics.v_store_meal_opportunity
+ WHERE v_store_meal_opportunity.bill_count >= 500
+ GROUP BY v_store_meal_opportunity.store_code
+ ), zero_bill AS (
+ SELECT v_zero_received_detail.store_code,
+ count(*) AS zero_received_bills,
+ count(*) FILTER (WHERE v_zero_received_detail.zero_received_type = '优惠不足但无实收'::text) AS unexplained_zero_bills
+ FROM analytics.v_zero_received_detail
+ GROUP BY v_zero_received_detail.store_code
+ )
+ SELECT a.store_code,
+ a.store_name,
+ a.management_quadrant,
+ a.risk_level,
+ a.primary_issue,
+ a.received,
+ a.avg_daily_received,
+ a.avg_bill_value,
+ a.discount_rate_pct,
+ a.theoretical_margin_pct,
+ a.anomaly_rate_pct,
+ a.member_bill_share_pct,
+ a.discount_saving_to_company_avg,
+ a.profit_uplift_to_company_margin,
+ a.top_category,
+ a.top_category_share_pct,
+ a.delivery_package_share_pct,
+ a.meituan_cost_rate_pct,
+ a.taobao_cost_rate_pct,
+ a.jd_cost_rate_pct,
+ round(COALESCE(m.meal_uplift_scenario, 0::numeric), 2) AS meal_uplift_scenario,
+ mo.member_share_pct,
+ mo.conversion_bill_scenario,
+ mo.revenue_uplift_scenario AS member_revenue_uplift_scenario,
+ COALESCE(z.zero_received_bills, 0::bigint) AS zero_received_bills,
+ COALESCE(z.unexplained_zero_bills, 0::bigint) AS unexplained_zero_bills
+ FROM analytics.v_store_action_list a
+ LEFT JOIN meal m USING (store_code)
+ LEFT JOIN analytics.v_store_member_opportunity mo USING (store_code, store_name)
+ LEFT JOIN zero_bill z USING (store_code)
+ WITH DATA;
+CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_execution_priority_store ON analytics.v_store_execution_priority (store_code);
+
+-- ============================================================
+-- Step 4: 重建依赖普通视图(基于物化视图,查询会走索引)
+-- ============================================================
+
+-- 4a. v_store_benchmark_composite
+CREATE OR REPLACE VIEW analytics.v_store_benchmark_composite AS
+ WITH eligible AS (
+ SELECT b.store_code,
+ b.store_name,
+ b.received,
+ b.avg_daily_received,
+ b.avg_bill_value,
+ b.discount_rate_pct,
+ b.theoretical_margin_pct,
+ b.anomaly_rate_pct,
+ b.member_bill_share_pct,
+ r.identified_members,
+ r.repeat_rate_pct,
+ r.avg_orders,
+ r.repeat_revenue_share_pct,
+ p.meituan_cost_rate_pct,
+ p.taobao_cost_rate_pct,
+ p.jd_cost_rate_pct
+ FROM analytics.v_store_benchmark b
+ JOIN analytics.v_store_repeat_summary_monthly r ON r.store_code = b.store_code AND r.month_start = '2026-04-01'::date
+ LEFT JOIN analytics.v_store_platform_economics p ON p.store_code = b.store_code
+ WHERE b.theoretical_margin_pct >= 60::numeric AND b.theoretical_margin_pct <= 80::numeric AND r.identified_members >= 500
+ ), scored AS (
+ SELECT eligible.store_code,
+ eligible.store_name,
+ eligible.received,
+ eligible.avg_daily_received,
+ eligible.avg_bill_value,
+ eligible.discount_rate_pct,
+ eligible.theoretical_margin_pct,
+ eligible.anomaly_rate_pct,
+ eligible.member_bill_share_pct,
+ eligible.identified_members,
+ eligible.repeat_rate_pct,
+ eligible.avg_orders,
+ eligible.repeat_revenue_share_pct,
+ eligible.meituan_cost_rate_pct,
+ eligible.taobao_cost_rate_pct,
+ eligible.jd_cost_rate_pct,
+ percent_rank() OVER (ORDER BY eligible.avg_daily_received) AS revenue_score,
+ percent_rank() OVER (ORDER BY eligible.theoretical_margin_pct) AS margin_score,
+ 1::double precision - percent_rank() OVER (ORDER BY eligible.discount_rate_pct) AS discount_score,
+ 1::double precision - percent_rank() OVER (ORDER BY eligible.anomaly_rate_pct) AS anomaly_score,
+ percent_rank() OVER (ORDER BY eligible.repeat_rate_pct) AS repeat_score
+ FROM eligible
+ )
+ SELECT scored.store_code,
+ scored.store_name,
+ scored.received,
+ scored.avg_daily_received,
+ scored.avg_bill_value,
+ scored.discount_rate_pct,
+ scored.theoretical_margin_pct,
+ scored.anomaly_rate_pct,
+ scored.member_bill_share_pct,
+ scored.identified_members,
+ scored.repeat_rate_pct,
+ scored.avg_orders,
+ scored.repeat_revenue_share_pct,
+ scored.meituan_cost_rate_pct,
+ scored.taobao_cost_rate_pct,
+ scored.jd_cost_rate_pct,
+ scored.revenue_score,
+ scored.margin_score,
+ scored.discount_score,
+ scored.anomaly_score,
+ scored.repeat_score,
+ round(((scored.revenue_score * 0.25::double precision + scored.margin_score * 0.25::double precision + scored.discount_score * 0.20::double precision + scored.anomaly_score * 0.15::double precision + scored.repeat_score * 0.15::double precision) * 100::double precision)::numeric, 2) AS benchmark_score
+ FROM scored;
+
+-- 4b. v_store_deep_diagnosis_april
+CREATE OR REPLACE VIEW analytics.v_store_deep_diagnosis_april AS
+ WITH store_base AS (
+ SELECT s.store_code,
+ s.store_name,
+ s.bill_count,
+ s.active_days,
+ s.received,
+ s.avg_daily_received,
+ s.avg_bill_value,
+ s.discount_rate_pct,
+ s.theoretical_margin_pct,
+ s.member_bill_share_pct,
+ d.items_per_bill,
+ d.skus_per_bill,
+ d.delivery_bill_share_pct,
+ d.noodle_snack_attach_pct,
+ d.noodle_drink_attach_pct,
+ d.noodle_cold_attach_pct,
+ d.combo_bill_share_pct,
+ c.theoretical_cost,
+ c.actual_food_cost,
+ c.food_cost_variance,
+ c.theoretical_cost_rate_pct,
+ c.actual_food_cost_rate_pct,
+ c.variance_to_theoretical_pct,
+ c.comparison_status,
+ c.variance_level,
+ b.identified_members,
+ b.repeat_rate_pct,
+ b.repeat_revenue_share_pct,
+ b.benchmark_score,
+ p.meituan_received,
+ p.taobao_received,
+ p.jd_received,
+ round((COALESCE(p.meituan_discount, 0::numeric) + COALESCE(p.taobao_discount, 0::numeric) + COALESCE(p.jd_discount, 0::numeric) + COALESCE(p.meituan_commission, 0::numeric) + COALESCE(p.taobao_commission, 0::numeric) + COALESCE(p.jd_commission, 0::numeric)) / NULLIF(COALESCE(p.meituan_received, 0::numeric) + COALESCE(p.taobao_received, 0::numeric) + COALESCE(p.jd_received, 0::numeric) + COALESCE(p.meituan_discount, 0::numeric) + COALESCE(p.taobao_discount, 0::numeric) + COALESCE(p.jd_discount, 0::numeric) + COALESCE(p.meituan_commission, 0::numeric) + COALESCE(p.taobao_commission, 0::numeric) + COALESCE(p.jd_commission, 0::numeric), 0::numeric) * 100::numeric, 2) AS combined_platform_cost_rate_pct,
+ CASE
+ WHEN s.store_name ~ '机场|火锅|商城|快手|哈马尔罕'::text THEN '特殊业态'::text
+ ELSE '标准门店'::text
+ END AS business_type
+ FROM analytics.v_store_scorecard s
+ LEFT JOIN analytics.dish_store_summary_april d USING (store_code)
+ LEFT JOIN analytics.v_store_theoretical_actual_cost_april c USING (store_code)
+ LEFT JOIN analytics.v_store_benchmark_composite b USING (store_code)
+ LEFT JOIN analytics.v_store_platform_economics p USING (store_code)
+ ), tiered AS (
+ SELECT store_base.store_code,
+ store_base.store_name,
+ store_base.bill_count,
+ store_base.active_days,
+ store_base.received,
+ store_base.avg_daily_received,
+ store_base.avg_bill_value,
+ store_base.discount_rate_pct,
+ store_base.theoretical_margin_pct,
+ store_base.member_bill_share_pct,
+ store_base.items_per_bill,
+ store_base.skus_per_bill,
+ store_base.delivery_bill_share_pct,
+ store_base.noodle_snack_attach_pct,
+ store_base.noodle_drink_attach_pct,
+ store_base.noodle_cold_attach_pct,
+ store_base.combo_bill_share_pct,
+ store_base.theoretical_cost,
+ store_base.actual_food_cost,
+ store_base.food_cost_variance,
+ store_base.theoretical_cost_rate_pct,
+ store_base.actual_food_cost_rate_pct,
+ store_base.variance_to_theoretical_pct,
+ store_base.comparison_status,
+ store_base.variance_level,
+ store_base.identified_members,
+ store_base.repeat_rate_pct,
+ store_base.repeat_revenue_share_pct,
+ store_base.benchmark_score,
+ store_base.meituan_received,
+ store_base.taobao_received,
+ store_base.jd_received,
+ store_base.combined_platform_cost_rate_pct,
+ store_base.business_type,
+ CASE
+ WHEN store_base.business_type = '特殊业态'::text THEN '特殊业态'::text
+ WHEN percent_rank() OVER (PARTITION BY store_base.business_type ORDER BY store_base.received) >= 0.67::double precision THEN '高规模'::text
+ WHEN percent_rank() OVER (PARTITION BY store_base.business_type ORDER BY store_base.received) >= 0.33::double precision THEN '中规模'::text
+ ELSE '低规模'::text
+ END AS scale_tier
+ FROM store_base
+ )
+ SELECT tiered.store_code,
+ tiered.store_name,
+ tiered.bill_count,
+ tiered.active_days,
+ tiered.received,
+ tiered.avg_daily_received,
+ tiered.avg_bill_value,
+ tiered.discount_rate_pct,
+ tiered.theoretical_margin_pct,
+ tiered.member_bill_share_pct,
+ tiered.items_per_bill,
+ tiered.skus_per_bill,
+ tiered.delivery_bill_share_pct,
+ tiered.noodle_snack_attach_pct,
+ tiered.noodle_drink_attach_pct,
+ tiered.noodle_cold_attach_pct,
+ tiered.combo_bill_share_pct,
+ tiered.theoretical_cost,
+ tiered.actual_food_cost,
+ tiered.food_cost_variance,
+ tiered.theoretical_cost_rate_pct,
+ tiered.actual_food_cost_rate_pct,
+ tiered.variance_to_theoretical_pct,
+ tiered.comparison_status,
+ tiered.variance_level,
+ tiered.identified_members,
+ tiered.repeat_rate_pct,
+ tiered.repeat_revenue_share_pct,
+ tiered.benchmark_score,
+ tiered.meituan_received,
+ tiered.taobao_received,
+ tiered.jd_received,
+ tiered.combined_platform_cost_rate_pct,
+ tiered.business_type,
+ tiered.scale_tier,
+ CASE
+ WHEN tiered.comparison_status = '可比'::text AND tiered.variance_to_theoretical_pct >= 20::numeric THEN 1
+ ELSE 0
+ END +
+ CASE
+ WHEN tiered.discount_rate_pct >= 23.02 THEN 1
+ ELSE 0
+ END +
+ CASE
+ WHEN tiered.theoretical_margin_pct < 70::numeric THEN 1
+ ELSE 0
+ END +
+ CASE
+ WHEN tiered.identified_members >= 500 AND tiered.repeat_rate_pct < 30::numeric THEN 1
+ ELSE 0
+ END +
+ CASE
+ WHEN tiered.combined_platform_cost_rate_pct >= 40::numeric THEN 1
+ ELSE 0
+ END +
+ CASE
+ WHEN tiered.noodle_drink_attach_pct < 12::numeric THEN 1
+ ELSE 0
+ END AS problem_count,
+ concat_ws('+'::text,
+ CASE
+ WHEN tiered.comparison_status = '理论成本口径异常'::text THEN '成本口径异常'::text
+ ELSE NULL::text
+ END,
+ CASE
+ WHEN tiered.comparison_status = '可比'::text AND tiered.variance_to_theoretical_pct >= 20::numeric THEN '实际成本严重超耗'::text
+ ELSE NULL::text
+ END,
+ CASE
+ WHEN tiered.discount_rate_pct >= 23.02 THEN '优惠偏高'::text
+ ELSE NULL::text
+ END,
+ CASE
+ WHEN tiered.theoretical_margin_pct < 70::numeric THEN '理论毛利偏低'::text
+ ELSE NULL::text
+ END,
+ CASE
+ WHEN tiered.identified_members >= 500 AND tiered.repeat_rate_pct < 30::numeric THEN '会员复购偏低'::text
+ ELSE NULL::text
+ END,
+ CASE
+ WHEN tiered.combined_platform_cost_rate_pct >= 40::numeric THEN '平台成本偏高'::text
+ ELSE NULL::text
+ END,
+ CASE
+ WHEN tiered.noodle_drink_attach_pct < 12::numeric THEN '饮品搭售偏低'::text
+ ELSE NULL::text
+ END) AS problem_combination
+ FROM tiered;
+
+-- ============================================================
+-- Step 5: 验证
+-- ============================================================
+SELECT 'Materialized views created successfully' AS status;
diff --git a/server/src/routes/data.ts b/server/src/routes/data.ts
index 7e79a95..0a4f4f8 100644
--- a/server/src/routes/data.ts
+++ b/server/src/routes/data.ts
@@ -134,12 +134,14 @@ router.get('/stores/quadrant', async (req: AuthRequest, res) => {
router.get('/stores/:code', async (req: AuthRequest, res) => {
try {
const code = req.params.code
- const scorecard = await query(`SELECT * FROM analytics.v_store_scorecard WHERE store_code = $1`, [code])
- const risk = await query(`SELECT * FROM analytics.v_store_risk_rating WHERE store_code = $1`, [code])
- const platform = await query(`SELECT * FROM analytics.v_store_platform_economics WHERE store_code = $1`, [code])
- const benchmark = await query(`SELECT * FROM analytics.v_store_benchmark WHERE store_code = $1`, [code])
- const action = await query(`SELECT * FROM analytics.v_store_action_list WHERE store_code = $1`, [code])
- const execution = await query(`SELECT * FROM analytics.v_store_execution_priority WHERE store_code = $1`, [code])
+ const [scorecard, risk, platform, benchmark, action, execution] = await Promise.all([
+ query(`SELECT * FROM analytics.v_store_scorecard WHERE store_code = $1`, [code]),
+ query(`SELECT * FROM analytics.v_store_risk_rating WHERE store_code = $1`, [code]),
+ query(`SELECT * FROM analytics.v_store_platform_economics WHERE store_code = $1`, [code]),
+ query(`SELECT * FROM analytics.v_store_benchmark WHERE store_code = $1`, [code]),
+ query(`SELECT * FROM analytics.v_store_action_list WHERE store_code = $1`, [code]),
+ query(`SELECT * FROM analytics.v_store_execution_priority WHERE store_code = $1`, [code]),
+ ])
if (scorecard.rows.length === 0) {
return sendError(res, 'Store not found', 404)
@@ -386,41 +388,47 @@ router.get('/stores/:code/meal-period', async (req: AuthRequest, res) => {
router.get('/stores/:code/category-mix', async (req: AuthRequest, res) => {
try {
- const result = await query(`SELECT * FROM analytics.v_store_category_mix WHERE store_code = $1`, [req.params.code])
- const companyResult = await query(`
- SELECT
- SUM(consumption) as total_consumption,
- SUM(lanzhou_noodle) as total_noodle,
- SUM(western_staple) as total_western,
- SUM(delivery_package) as total_delivery,
- SUM(cold_dishes) as total_cold,
- SUM(silk_road_food) as total_silk
- FROM analytics.v_store_category_mix
- `)
+ const [result, companyResult] = await Promise.all([
+ query(`SELECT * FROM analytics.v_store_category_mix WHERE store_code = $1`, [req.params.code]),
+ query(`
+ SELECT
+ SUM(consumption) as total_consumption,
+ SUM(lanzhou_noodle) as total_noodle,
+ SUM(western_staple) as total_western,
+ SUM(delivery_package) as total_delivery,
+ SUM(cold_dishes) as total_cold,
+ SUM(silk_road_food) as total_silk
+ FROM analytics.v_store_category_mix
+ `),
+ ])
sendSuccess(res, { store: result.rows[0], company: companyResult.rows[0] })
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/cost', async (req: AuthRequest, res) => {
try {
- const costResult = await query(`SELECT * FROM analytics.v_store_theoretical_actual_cost_april WHERE store_code = $1`, [req.params.code])
- const catResult = await query(`SELECT * FROM analytics.v_store_category_cost_benchmark_april WHERE store_code = $1`, [req.params.code])
+ const [costResult, catResult] = await Promise.all([
+ query(`SELECT * FROM analytics.v_store_theoretical_actual_cost_april WHERE store_code = $1`, [req.params.code]),
+ query(`SELECT * FROM analytics.v_store_category_cost_benchmark_april WHERE store_code = $1`, [req.params.code]),
+ ])
sendSuccess(res, { cost: costResult.rows[0], categories: catResult.rows })
} catch (err: any) { sendError(res, err.message) }
})
router.get('/stores/:code/member', async (req: AuthRequest, res) => {
try {
- const oppResult = await query(`SELECT * FROM analytics.v_store_member_opportunity WHERE store_code = $1`, [req.params.code])
- const repeatResult = await query(`SELECT * FROM analytics.v_store_repeat_summary_monthly WHERE store_code = $1`, [req.params.code])
- const monthlyResult = await query(`
- SELECT store_code, store_name, count(*) as member_count,
- sum(orders) as total_orders, sum(received) as total_received,
- avg(orders) as avg_orders, avg(received) as avg_received
- FROM analytics.v_store_member_monthly_activity
- WHERE store_code = $1
- GROUP BY store_code, store_name
- `, [req.params.code])
+ const [oppResult, repeatResult, monthlyResult] = await Promise.all([
+ query(`SELECT * FROM analytics.v_store_member_opportunity WHERE store_code = $1`, [req.params.code]),
+ query(`SELECT * FROM analytics.v_store_repeat_summary_monthly WHERE store_code = $1`, [req.params.code]),
+ query(`
+ SELECT store_code, store_name, count(*) as member_count,
+ sum(orders) as total_orders, sum(received) as total_received,
+ avg(orders) as avg_orders, avg(received) as avg_received
+ FROM analytics.v_store_member_monthly_activity
+ WHERE store_code = $1
+ GROUP BY store_code, store_name
+ `, [req.params.code]),
+ ])
sendSuccess(res, { opportunity: oppResult.rows[0], repeat: repeatResult.rows[0], monthly: monthlyResult.rows[0] })
} catch (err: any) { sendError(res, err.message) }
})
@@ -442,4 +450,44 @@ router.get('/region/summary', async (req, res) => {
} catch (err: any) { sendError(res, err.message) }
})
+// 门店选址分析 — 门店选址画像
+router.get('/site-selection/profile', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`SELECT * FROM analytics.mv_store_site_profile_april WHERE business_type='标准门店' AND received>0 ORDER BY received DESC`)
+ sendSuccess(res, result.rows)
+ } catch (err: any) { sendError(res, err.message) }
+})
+
+// 门店选址分析 — 分段基准(场景×面积)
+router.get('/site-selection/segment-benchmark', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`SELECT * FROM analytics.mv_site_segment_benchmark_april ORDER BY avg_received_per_sqm DESC`)
+ sendSuccess(res, result.rows)
+ } catch (err: any) { sendError(res, err.message) }
+})
+
+// 门店选址分析 — 复制评分
+router.get('/site-selection/replication', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`SELECT * FROM analytics.mv_store_site_replication_score_april ORDER BY site_replication_score DESC`)
+ sendSuccess(res, result.rows)
+ } catch (err: any) { sendError(res, err.message) }
+})
+
+// 门店选址分析 — 重叠风险
+router.get('/site-selection/overlap-risk', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`SELECT * FROM analytics.mv_store_location_overlap_risk_april ORDER BY distance_km`)
+ sendSuccess(res, result.rows)
+ } catch (err: any) { sendError(res, err.message) }
+})
+
+// 门店选址分析 — 区域基准
+router.get('/site-selection/district-benchmark', async (req: AuthRequest, res) => {
+ try {
+ const result = await query(`SELECT * FROM analytics.mv_district_site_benchmark_april ORDER BY total_received DESC`)
+ sendSuccess(res, result.rows)
+ } catch (err: any) { sendError(res, err.message) }
+})
+
export default router
diff --git a/基于全部现有数据的门店选址分析报告.md b/基于全部现有数据的门店选址分析报告.md
new file mode 100644
index 0000000..c1efc47
--- /dev/null
+++ b/基于全部现有数据的门店选址分析报告.md
@@ -0,0 +1,215 @@
+# 基于全部现有数据的门店选址分析报告
+
+> 分析期:2026年4月
+> 数据范围:91家经营门店,其中90家实体店完成精确地理编码;结合账单、菜品、会员复购、平台、成本、库存、面积、开业日期、租约和地址信息。
+
+## 一、结论先行
+
+现有数据能够解决新店选址中的四个核心问题:
+
+1. **开什么店型**:办公、社区、商场、街边或小型高效店。
+2. **多大面积更合适**:面积越大,绝对收入通常越高,但坪效明显下降;不能为了形象盲目拿大铺。
+3. **应该复制哪些门店经验**:按场景、面积、经营质量寻找3—5家相似标杆,而不是简单照搬销售额最高的门店。
+4. **是否会分流现有门店**:利用坐标计算与现店距离,初步识别1—3公里范围内的重叠风险。
+
+但内部经营数据不能单独回答“某个具体铺位一定能不能开”。最终决策必须补充候选点客流、租金、竞品、人口、办公人数、配送覆盖、座位和投资数据。
+
+## 二、现有成熟店经营基准
+
+85家正常销售标准门店的4月基准:
+
+| 指标 | P25 | 中位数 | P75 |
+|---|---:|---:|---:|
+| 月实收 | 47.55万元 | 60.94万元 | 79.91万元 |
+
+其他中位指标:日均实收1.97万元、客单价33.38元、优惠率20.51%、复购率33.60%、实际成本率28.12%、平台合并加权成本率37.67%。
+
+因此,新店成熟后的月实收可暂设三个情景:
+
+- 保守情景:约47.5万元/月;
+- 基准情景:约60.9万元/月;
+- 较好情景:约79.9万元/月。
+
+这只是成熟店参照。当前只有2026年4月一个完整经营月,尚不能可靠估计淡旺季和新店爬坡速度。
+
+## 三、面积如何选择
+
+| 面积段 | 门店数 | 平均面积 | 平均月实收 | 月实收中位数 | 平均月坪效 |
+|---|---:|---:|---:|---:|---:|
+| ≤180㎡ | 8 | 141㎡ | 46.66万元 | 47.43万元 | 3,281元/㎡ |
+| 181—250㎡ | 23 | 219㎡ | 56.54万元 | 54.42万元 | 2,603元/㎡ |
+| 251—350㎡ | 26 | 298㎡ | 60.04万元 | 55.68万元 | 2,025元/㎡ |
+| 351—500㎡ | 12 | 418㎡ | 81.32万元 | 83.03万元 | 1,945元/㎡ |
+| >500㎡ | 12 | 615㎡ | 88.78万元 | 73.92万元 | 1,464元/㎡ |
+
+经营含义:
+
+- 180㎡以下适合做“小面积、高周转、高坪效”模型,但收入天花板相对较低。
+- 181—250㎡是较稳健的轻量标准店区间,兼顾收入和坪效。
+- 251—350㎡适合堂食更完整的标准门店,但必须有足够客流支撑。
+- 350㎡以上虽然平均销售更高,但坪效下降明显,必须验证座位利用率、包间需求和租金条件。
+- 500㎡以上不应作为默认开店规格,只适用于已经验证的强商圈、旗舰或特殊经营需求。
+
+面积决策不能只看坪效,还要核算后厨面积、座位数、翻台率、人工和装修投入。
+
+## 四、场景表现及可复制方向
+
+| 场景 | 样本数 | 平均月实收 | 月实收中位数 | 平均月坪效 | 平均复购率 |
+|---|---:|---:|---:|---:|---:|
+| 办公园区 | 8 | 82.32万元 | 72.61万元 | 2,213元/㎡ | 50.2% |
+| 商场商业体 | 4 | 71.49万元 | 73.09万元 | 2,242元/㎡ | 37.6% |
+| 社区居民 | 13 | 71.61万元 | 64.16万元 | 2,371元/㎡ | 28.1% |
+| 街边综合 | 56 | 60.73万元 | 59.26万元 | 2,182元/㎡ | 37.2% |
+
+方向判断:
+
+- **办公园区**:收入和复购表现突出,可重点研究稳定就业人口、工作日午餐刚需和企业团餐机会。但需核查周末及晚餐空档。
+- **社区居民**:坪效较好,但当前复购率偏低于办公和街边样本,选址时要重点验证常住人口、家庭结构、晚餐和周末需求。
+- **商场商业体**:现有样本只有4家,不能仅凭均值扩大结论;必须核算扣点、推广费、营业时间和同层竞品。
+- **街边综合**:样本最多、差异也最大,必须进一步按道路等级、周边功能和面积匹配标杆。
+
+场景分类目前主要由门店地址文本推断,只适合作为第一轮分组,后续应通过现场和地图数据校正。
+
+## 五、可提炼的门店原型
+
+综合坪效、日均销售、复购、优惠纪律、实际成本、平台成本和执行问题后,当前靠前样本包括:
+
+| 门店 | 场景 | 面积 | 4月实收 | 月坪效 | 最近现店距离 | 复制评分 |
+|---|---|---:|---:|---:|---:|---:|
+| 三里河店 | 街边综合 | 155㎡ | 90.91万元 | 5,865元/㎡ | 0.99km | 85.98 |
+| 为公桥店 | 街边综合 | 400㎡ | 141.59万元 | 3,540元/㎡ | 0.57km | 81.73 |
+| 航天桥店 | 街边综合 | 198㎡ | 94.22万元 | 4,759元/㎡ | 0.57km | 80.35 |
+| 联想桥店 | 街边综合 | 200㎡ | 81.80万元 | 4,090元/㎡ | 0.75km | 80.13 |
+| 七里庄店 | 街边综合 | 204㎡ | 90.91万元 | 4,463元/㎡ | 1.06km | 76.46 |
+| 天秀路店 | 办公园区 | 295㎡ | 95.46万元 | 3,236元/㎡ | 3.07km | 76.10 |
+
+建议提炼三类原型:
+
+1. **150—220㎡高效街边店**:三里河、航天桥、联想桥、七里庄。适合租金较高、客流明确、强调高周转的铺位。
+2. **250—350㎡办公园区标准店**:天秀路等。重点复制工作日午餐、会员复购和团餐能力。
+3. **350—500㎡强商圈大店**:为公桥等。只在收入确定性高、租金可承受时采用。
+
+“原型可复制”不等于可以在原店旁边继续开店。为公桥—航天桥等距离很近,其高业绩可能来自商圈总容量、门店定位差异或已有分流,必须另行研究顾客来源。
+
+## 六、空间重叠风险
+
+90家实体经营门店的两两距离结果:
+
+| 距离范围 | 门店对数 | 初步管理含义 |
+|---|---:|---|
+| <1公里 | 22 | 高度重叠,新增门店必须做顾客来源和配送圈验证 |
+| 1—2公里 | 57 | 较高重叠,检查道路阻隔、商圈边界和客群差异 |
+| 2—3公里 | 64 | 观察区,不能只用直线距离判断 |
+| ≥3公里 | 3,862 | 相对独立,但不代表区域具备新店需求 |
+
+建议把距离作为否决前的预警,而不是单独的开店规则:
+
+- 北京高密度城区,1—3公里可能跨越多个独立商圈;
+- 道路、河流、地铁、园区门禁和商场动线会改变实际可达性;
+- 外卖配送圈重叠可能比堂食商圈重叠更严重;
+- 距离近且现店经营承压时,应优先改善现店或迁址,不宜继续加密。
+
+## 七、如果开新门店,具体怎么做
+
+### 第一步:先定店型,不先找铺
+
+由运营明确目标模型:办公午餐店、社区全天店、街边标准店、商场店、小型高效店或外卖补充店。同步确定目标客单、堂食/外卖结构、面积、座位和营业餐段。
+
+### 第二步:为候选点匹配3—5家相似老店
+
+匹配顺序:场景相同 → 面积相近 → 城市和商圈等级接近 → 楼层相近 → 外卖结构相近。不得直接拿全公司平均数作为唯一预算依据。
+
+输出相似店的:月实收、日均实收、午晚餐结构、客单、单均件数、复购率、优惠率、外送占比、成本率、平台成本、坪效和问题数。
+
+### 第三步:建立三情景收入预算
+
+以相似店P25、中位数、P75或公司成熟店47.5万、60.9万、79.9万元为起点,再按候选点客流和现场条件修正。
+
+收入预算应拆成:
+
+`月销售 = 午市日均单量 × 午市客单 + 晚市日均单量 × 晚市客单 + 其他餐段 + 外卖净增量`,再乘营业天数。
+
+外卖不能全部当作新增收入,需要扣除与堂食及现有店配送圈的重叠。
+
+### 第四步:用面积和坪效做交叉校验
+
+例如计划开220㎡门店,若按基准情景60.9万元计算,月坪效约2,770元/㎡,高于181—250㎡现店平均2,603元/㎡,属于略有挑战但可解释的目标。若面积500㎡、预算仍只有60万元,则坪效约1,200元/㎡,应优先缩面积或放弃。
+
+### 第五步:计算盈亏平衡和租金上限
+
+完整模型:
+
+`门店贡献 = 实收 - 食材成本 - 平台及支付成本 - 人工 - 租金物业 - 水电能耗 - 其他可变费用`
+
+`盈亏平衡销售额 = 固定成本 ÷ 综合贡献毛利率`
+
+租金上限不能用统一比例粗暴决定,应在保守收入情景下反推:
+
+`可承受租金物业 = 保守实收 - 食材 - 平台 - 人工 - 水电 - 其他费用 - 目标利润`
+
+只有基准或较好情景盈利、保守情景严重亏损的项目,应谨慎立项。
+
+### 第六步:检查现店分流
+
+列出候选点1公里、2公里、3公里内所有现店,比较:堂食商圈、外卖配送圈、地铁出口、道路阻隔、顾客来源和餐段。若分流不可避免,预算必须以“区域总增量”而非“新店销售额”评价。
+
+### 第七步:连续7天现场验证
+
+至少覆盖2个工作日午市、2个工作日晚市、周五晚市、周六和周日;每30分钟记录:
+
+- 经过人数和进入餐饮门店人数;
+- 同类竞品进店人数、排队和翻台;
+- 外卖骑手取餐量;
+- 停车、地铁、公交、步行可达性;
+- 门头可见性、上下楼障碍和反向动线;
+- 办公园区周末、社区工作日午间等弱势时段。
+
+开发提供铺位条件,运营负责客流和竞品判断,财务审核预算和投资回收期,三方共同签字。
+
+## 八、候选点100分打分卡
+
+| 模块 | 权重 | 主要依据 |
+|---|---:|---|
+| 需求与有效客流 | 25 | 分时客流、办公/常住人口、餐饮转化 |
+| 与成功原型匹配度 | 15 | 场景、面积、楼层、餐段结构 |
+| 销售预测可信度 | 15 | 相似店、现场数客、三情景预测 |
+| 盈利与租金承受力 | 20 | 保守情景利润、盈亏平衡、投资回收 |
+| 竞争与品牌空白 | 10 | 同类数量、价格带、竞品经营状态 |
+| 与现店重叠风险 | 10 | 1—3公里门店、配送圈、客群分流 |
+| 物业与经营条件 | 5 | 排烟、燃气、电力、消防、门头、证照 |
+
+建议设置红线:总分低于70不立项;盈利模块或有效客流模块低于及格线直接否决;数据缺失不得用主观高分填补。
+
+## 九、现阶段还缺什么数据
+
+优先补齐:
+
+1. 每家店租金、物业费、人工、水电、装修及设备投资;
+2. 座位数、前厅/后厨面积、营业时间和翻台率;
+3. 候选点分时客流、周边办公人数和常住人口;
+4. 三公里内竞品名称、品类、客单、销量或繁忙度;
+5. 外卖订单的收货网格或脱敏坐标,用于真实配送热力和分流判断;
+6. 会员居住/工作网格或消费商圈,不保留直接身份信息;
+7. 至少12—24个月连续经营数据,用于季节性、趋势和新店爬坡曲线;
+8. 新店筹建、开业日期、开业营销费用及月度损益,用于回测选址模型。
+
+## 十、管理建议
+
+短期不要直接做“自动推荐地址”,而应先建立候选点审批表:每个候选点录入坐标、面积、楼层、租金、客流、竞品和物业条件,系统自动匹配相似店、计算距离、生成三情景预算和100分评分。
+
+每开一家店,应保存立项预测和开业后1、3、6、12个月结果,持续对比“预测与实际”。经过一批新店回测后,才能把当前经验评分升级为真正可验证的选址模型。
+
+## 十一、配套SQL
+
+本报告对应SQL:`连锁门店选址分析.sql`。
+
+已建立视图:
+
+- `analytics.v_store_site_profile_april`
+- `analytics.v_store_spatial_pairs_april`
+- `analytics.v_store_nearest_neighbor_april`
+- `analytics.v_site_segment_benchmark_april`
+- `analytics.v_district_site_benchmark_april`
+- `analytics.v_store_site_replication_score_april`
+- `analytics.v_store_location_overlap_risk_april`
+
diff --git a/连锁门店选址分析.sql b/连锁门店选址分析.sql
new file mode 100644
index 0000000..20cf05c
--- /dev/null
+++ b/连锁门店选址分析.sql
@@ -0,0 +1,190 @@
+-- 连锁餐饮门店选址分析
+-- 基准期:2026年4月
+-- 数据:经营、菜品、会员、平台、成本、库存、面积、店龄、租约及精确坐标
+
+CREATE OR REPLACE VIEW analytics.v_store_site_profile_april AS
+SELECT a.*,
+ CASE
+ WHEN a.business_type='特殊业态' THEN '特殊业态'
+ WHEN a.business_address ~ '机场|航站楼' THEN '交通枢纽'
+ WHEN a.business_address ~ '大学|食堂|档口' THEN '校园档口'
+ WHEN a.business_address ~ '总部|科技园|产业园|创业园|商务楼|写字楼|信息产业基地|生命科学园|自贸试验区|经海|荣华' THEN '办公园区'
+ WHEN a.business_address ~ '商场|商城|购物|超市|万科|龙湖|大悦|搜秀|美食城|商业大厦|商铺' THEN '商场商业体'
+ WHEN a.business_address ~ '社区|小区|家园|里|园一区|园东街' THEN '社区居民'
+ ELSE '街边综合'
+ END AS site_scene,
+ CASE
+ WHEN a.business_address ~ '地下一层|负一层|-1层|-1至|B1|b1' THEN '地下层'
+ WHEN a.business_address ~ '二层|2层|四层|4层|4F|五层|5层|23层' THEN '非首层'
+ WHEN a.business_address ~ '一层|1层|底商' THEN '首层'
+ ELSE '楼层不明'
+ END AS floor_type,
+ CASE
+ WHEN a.area_sqm IS NULL THEN '面积缺失'
+ WHEN a.area_sqm<=180 THEN '≤180㎡'
+ WHEN a.area_sqm<=250 THEN '181-250㎡'
+ WHEN a.area_sqm<=350 THEN '251-350㎡'
+ WHEN a.area_sqm<=500 THEN '351-500㎡'
+ ELSE '>500㎡'
+ END AS area_band,
+ CASE
+ WHEN a.store_age_years IS NULL THEN '店龄缺失'
+ WHEN a.store_age_years<1 THEN '新店<1年'
+ WHEN a.store_age_years<3 THEN '成长期1-3年'
+ WHEN a.store_age_years<8 THEN '成熟期3-8年'
+ ELSE '老店≥8年'
+ END AS age_band
+FROM analytics.v_store_area_efficiency_april a;
+
+CREATE OR REPLACE VIEW analytics.v_store_spatial_pairs_april AS
+WITH physical AS (
+ SELECT store_code,store_name,district,site_scene,action_priority,received,
+ monthly_received_per_sqm,repeat_rate_pct,actual_food_cost_rate_pct,
+ latitude_gcj02 AS lat,longitude_gcj02 AS lon
+ FROM analytics.v_store_site_profile_april
+ WHERE latitude_gcj02 IS NOT NULL AND longitude_gcj02 IS NOT NULL
+), pairs AS (
+ SELECT a.store_code AS store_code_a,a.store_name AS store_name_a,
+ b.store_code AS store_code_b,b.store_name AS store_name_b,
+ a.district AS district_a,b.district AS district_b,
+ a.site_scene AS scene_a,b.site_scene AS scene_b,
+ a.action_priority AS priority_a,b.action_priority AS priority_b,
+ a.received AS received_a,b.received AS received_b,
+ a.monthly_received_per_sqm AS sqm_efficiency_a,
+ b.monthly_received_per_sqm AS sqm_efficiency_b,
+ 6371 * acos(least(1.0,greatest(-1.0,
+ cos(radians(a.lat))*cos(radians(b.lat))*cos(radians(b.lon-a.lon))+
+ sin(radians(a.lat))*sin(radians(b.lat))
+ ))) AS distance_km
+ FROM physical a
+ JOIN physical b ON a.store_code