Files
SBrainCO/连锁门店选址分析.sql
freedakgmail 6e0c1bd7a4 feat: 新增门店选址分析功能
- 创建选址分析7个SQL视图+5张物化表(38s→33ms)
- 后端新增5个选址API端点
- 前端新增SiteSelectionPage页面(散点图+4Tab)
- 侧边栏新增门店选址导航入口
- 修复SQL列引用错误(d.→a.,去掉重复列)
- 创建v_store_action_priority_deep_april和v_store_area_efficiency_april基础视图
2026-07-27 08:33:42 +08:00

191 lines
10 KiB
SQL

-- 连锁餐饮门店选址分析
-- 基准期: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<b.store_code
)
SELECT *,
CASE WHEN distance_km<1 THEN '高度重叠<1km'
WHEN distance_km<2 THEN '较高重叠1-2km'
WHEN distance_km<3 THEN '观察2-3km'
ELSE '相对独立≥3km' END AS proximity_level
FROM pairs;
CREATE OR REPLACE VIEW analytics.v_store_nearest_neighbor_april AS
WITH directed AS (
SELECT store_code_a AS store_code,store_name_a AS store_name,
store_code_b AS nearest_store_code,store_name_b AS nearest_store_name,
distance_km
FROM analytics.v_store_spatial_pairs_april
UNION ALL
SELECT store_code_b,store_name_b,store_code_a,store_name_a,distance_km
FROM analytics.v_store_spatial_pairs_april
), ranked AS (
SELECT *,row_number() OVER(PARTITION BY store_code ORDER BY distance_km) AS rn
FROM directed
)
SELECT store_code,store_name,nearest_store_code,nearest_store_name,
round(distance_km::numeric,2) AS nearest_distance_km,
CASE WHEN distance_km<1 THEN '高度重叠<1km'
WHEN distance_km<2 THEN '较高重叠1-2km'
WHEN distance_km<3 THEN '观察2-3km'
ELSE '相对独立≥3km' END AS nearest_proximity_level
FROM ranked WHERE rn=1;
CREATE OR REPLACE VIEW analytics.v_site_segment_benchmark_april AS
SELECT site_scene,area_band,count(*) AS store_count,
round(avg(area_sqm)::numeric,1) AS avg_area_sqm,
round(avg(received)::numeric,2) AS avg_received,
round(percentile_cont(0.5) WITHIN GROUP(ORDER BY received)::numeric,2) AS median_received,
round(avg(monthly_received_per_sqm)::numeric,2) AS avg_received_per_sqm,
round(percentile_cont(0.5) WITHIN GROUP(ORDER BY monthly_received_per_sqm)::numeric,2) AS median_received_per_sqm,
round(avg(avg_bill_value)::numeric,2) AS avg_bill_value,
round(avg(discount_rate_pct)::numeric,2) AS avg_discount_rate_pct,
round(avg(repeat_rate_pct)::numeric,2) AS avg_repeat_rate_pct,
round(avg(actual_food_cost_rate_pct) FILTER(WHERE comparison_status='可比')::numeric,2) AS avg_actual_cost_rate_pct,
round(avg(delivery_bill_share_pct)::numeric,2) AS avg_delivery_share_pct,
round(avg(noodle_drink_attach_pct)::numeric,2) AS avg_drink_attach_pct
FROM analytics.v_store_site_profile_april
WHERE business_type='标准门店' AND received>0 AND area_sqm IS NOT NULL
GROUP BY site_scene,area_band;
CREATE OR REPLACE VIEW analytics.v_district_site_benchmark_april AS
SELECT city,district,count(*) AS store_count,
round(avg(area_sqm)::numeric,1) AS avg_area_sqm,
round(sum(received)::numeric,2) AS total_received,
round(avg(received)::numeric,2) AS avg_received,
round(percentile_cont(0.5) WITHIN GROUP(ORDER BY received)::numeric,2) AS median_received,
round(avg(monthly_received_per_sqm)::numeric,2) AS avg_received_per_sqm,
round(avg(avg_bill_value)::numeric,2) AS avg_bill_value,
round(avg(discount_rate_pct)::numeric,2) AS avg_discount_rate_pct,
round(avg(repeat_rate_pct)::numeric,2) AS avg_repeat_rate_pct,
round(avg(actual_food_cost_rate_pct) FILTER(WHERE comparison_status='可比')::numeric,2) AS avg_actual_cost_rate_pct,
round(avg(combined_platform_cost_rate_pct)::numeric,2) AS avg_platform_cost_rate_pct,
count(*) FILTER(WHERE action_priority LIKE 'P0%') AS p0_count,
count(*) FILTER(WHERE action_priority='P1-重点整改') AS p1_count
FROM analytics.v_store_site_profile_april
WHERE business_type='标准门店' AND received>0
GROUP BY city,district;
CREATE OR REPLACE VIEW analytics.v_store_site_replication_score_april AS
WITH eligible AS (
SELECT p.*,n.nearest_store_code,n.nearest_store_name,n.nearest_distance_km,
percent_rank() OVER(ORDER BY p.monthly_received_per_sqm) AS sqm_score,
percent_rank() OVER(ORDER BY p.avg_daily_received) AS daily_score,
percent_rank() OVER(ORDER BY p.repeat_rate_pct NULLS FIRST) AS repeat_score,
1-percent_rank() OVER(ORDER BY p.discount_rate_pct) AS discount_score,
1-percent_rank() OVER(ORDER BY p.actual_food_cost_rate_pct NULLS LAST) AS cost_score,
1-percent_rank() OVER(ORDER BY p.combined_platform_cost_rate_pct NULLS LAST) AS platform_score,
greatest(0,1-p.problem_count/6.0) AS execution_score
FROM analytics.v_store_site_profile_april p
LEFT JOIN analytics.v_store_nearest_neighbor_april n USING(store_code,store_name)
WHERE p.business_type='标准门店' AND p.received>0 AND p.area_sqm IS NOT NULL
), scored AS (
SELECT *,round((
0.30*sqm_score+0.20*daily_score+0.15*repeat_score+
0.10*discount_score+0.10*cost_score+0.10*platform_score+
0.05*execution_score
)::numeric*100,2) AS site_replication_score
FROM eligible
)
SELECT *,
CASE
WHEN site_replication_score>=75 AND problem_count<=1 THEN '优先提炼选址原型'
WHEN site_replication_score>=60 THEN '可作为同类参考'
WHEN site_replication_score<40 THEN '不宜作为选址标杆'
ELSE '观察验证'
END AS replication_recommendation,
CASE
WHEN nearest_distance_km>=3 AND site_replication_score>=70 THEN '高表现且周边相对独立,可研究相似商圈扩张'
WHEN nearest_distance_km<1.5 THEN '邻店较近,新址需重点防止同店分流'
ELSE '常规评估'
END AS spatial_recommendation
FROM scored;
CREATE OR REPLACE VIEW analytics.v_store_location_overlap_risk_april AS
SELECT p.*,
a.problem_count AS problem_count_a,b.problem_count AS problem_count_b,
CASE
WHEN p.distance_km<1 AND (a.action_priority LIKE 'P0%' OR b.action_priority LIKE 'P0%' OR a.action_priority='P1-重点整改' OR b.action_priority='P1-重点整改')
THEN '高风险:距离近且至少一家经营承压'
WHEN p.distance_km<1.5 THEN '中风险:需核查客群和配送圈重叠'
ELSE '观察'
END AS overlap_risk
FROM analytics.v_store_spatial_pairs_april p
JOIN analytics.v_store_site_profile_april a ON a.store_code=p.store_code_a
JOIN analytics.v_store_site_profile_april b ON b.store_code=p.store_code_b
WHERE p.distance_km<3;
-- 典型结果查询
SELECT site_scene,count(*) store_count,round(avg(received)::numeric,0) avg_received,
round(avg(monthly_received_per_sqm)::numeric,2) avg_received_per_sqm
FROM analytics.v_store_site_profile_april
WHERE business_type='标准门店' AND received>0 AND area_sqm IS NOT NULL
GROUP BY site_scene ORDER BY avg_received_per_sqm DESC;
SELECT store_name,site_scene,area_sqm,received,monthly_received_per_sqm,
nearest_store_name,nearest_distance_km,site_replication_score,
replication_recommendation,spatial_recommendation
FROM analytics.v_store_site_replication_score_april
ORDER BY site_replication_score DESC LIMIT 20;
SELECT store_name_a,store_name_b,round(distance_km::numeric,2) distance_km,
received_a,received_b,overlap_risk
FROM analytics.v_store_location_overlap_risk_april
ORDER BY distance_km LIMIT 30;