Files
SBrainCO/db/migration_phase6_site_functions.sql

316 lines
17 KiB
PL/PgSQL

-- ============================================================
-- Phase 6: 选址/空间参数化函数
-- 替换 _april 后缀的选址/空间视图为参数化函数
-- 依赖: fn_store_area_efficiency(p_month) 已在 Phase 2 中创建
-- ============================================================
-- 1. fn_store_site_profile(p_month) — 替换 v_store_site_profile_april
-- 依赖: fn_store_area_efficiency
CREATE OR REPLACE FUNCTION analytics.fn_store_site_profile(p_month date)
RETURNS TABLE (
store_code text, store_name text, bill_count bigint, active_days bigint,
received numeric, avg_daily_received numeric, avg_bill_value numeric,
discount_rate_pct numeric, theoretical_margin_pct numeric, member_bill_share_pct numeric,
items_per_bill numeric, skus_per_bill numeric, delivery_bill_share_pct numeric,
noodle_snack_attach_pct numeric, noodle_drink_attach_pct numeric, noodle_cold_attach_pct numeric,
combo_bill_share_pct numeric, theoretical_cost numeric, actual_food_cost numeric,
food_cost_variance numeric, theoretical_cost_rate_pct numeric, actual_food_cost_rate_pct numeric,
variance_to_theoretical_pct numeric, comparison_status text, variance_level text,
identified_members bigint, repeat_rate_pct numeric, repeat_revenue_share_pct numeric,
benchmark_score numeric, meituan_received numeric, taobao_received numeric, jd_received numeric,
combined_platform_cost_rate_pct numeric, business_type text, scale_tier text,
problem_count bigint, problem_combination text, action_priority text,
area_sqm numeric, business_address text, city text, district text,
latitude_gcj02 double precision, longitude_gcj02 double precision,
open_date date, lease_expiry_date date, store_age_years numeric,
monthly_received_per_sqm numeric, daily_received_per_sqm numeric,
estimated_inventory_days numeric, site_scene text, floor_type text,
area_band text, age_band text
) AS $$
SELECT
a.store_code, a.store_name, a.bill_count, a.active_days,
a.received, a.avg_daily_received, a.avg_bill_value,
a.discount_rate_pct, a.theoretical_margin_pct, a.member_bill_share_pct,
a.items_per_bill, a.skus_per_bill, a.delivery_bill_share_pct,
a.noodle_snack_attach_pct, a.noodle_drink_attach_pct, a.noodle_cold_attach_pct,
a.combo_bill_share_pct, a.theoretical_cost, a.actual_food_cost,
a.food_cost_variance, a.theoretical_cost_rate_pct, a.actual_food_cost_rate_pct,
a.variance_to_theoretical_pct, a.comparison_status, a.variance_level,
a.identified_members, a.repeat_rate_pct, a.repeat_revenue_share_pct,
a.benchmark_score, a.meituan_received, a.taobao_received, a.jd_received,
a.combined_platform_cost_rate_pct, a.business_type, a.scale_tier,
a.problem_count, a.problem_combination, a.action_priority,
a.area_sqm, a.business_address, a.city, a.district,
a.latitude_gcj02, a.longitude_gcj02,
a.open_date, a.lease_expiry_date, a.store_age_years,
a.monthly_received_per_sqm, a.daily_received_per_sqm,
a.estimated_inventory_days,
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.fn_store_area_efficiency(p_month) a
$$ LANGUAGE SQL STABLE;
-- 2. fn_store_spatial_pairs(p_month) — 替换 v_store_spatial_pairs_april
CREATE OR REPLACE FUNCTION analytics.fn_store_spatial_pairs(p_month date)
RETURNS TABLE (
store_code_a text, store_name_a text, store_code_b text, store_name_b text,
district_a text, district_b text, scene_a text, scene_b text,
priority_a text, priority_b text, received_a numeric, received_b numeric,
sqm_efficiency_a numeric, sqm_efficiency_b numeric, distance_km double precision,
proximity_level text
) AS $$
WITH physical AS (
SELECT store_code, store_name, district, site_scene, action_priority,
received, monthly_received_per_sqm, latitude_gcj02 AS lat, longitude_gcj02 AS lon
FROM analytics.fn_store_site_profile(p_month)
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.0 * 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
pairs.store_code_a, pairs.store_name_a, pairs.store_code_b, pairs.store_name_b,
pairs.district_a, pairs.district_b, pairs.scene_a, pairs.scene_b,
pairs.priority_a, pairs.priority_b, pairs.received_a, pairs.received_b,
pairs.sqm_efficiency_a, pairs.sqm_efficiency_b, pairs.distance_km,
CASE
WHEN pairs.distance_km < 1 THEN '高度重叠<1km'
WHEN pairs.distance_km < 2 THEN '较高重叠1-2km'
WHEN pairs.distance_km < 3 THEN '观察2-3km'
ELSE '相对独立≥3km'
END AS proximity_level
FROM pairs
$$ LANGUAGE SQL STABLE;
-- 3. fn_store_nearest_neighbor(p_month) — 替换 v_store_nearest_neighbor_april
CREATE OR REPLACE FUNCTION analytics.fn_store_nearest_neighbor(p_month date)
RETURNS TABLE (
store_code text, store_name text, nearest_store_code text, nearest_store_name text,
nearest_distance_km numeric, nearest_proximity_level text
) 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.fn_store_spatial_pairs(p_month)
UNION ALL
SELECT store_code_b, store_name_b, store_code_a, store_name_a, distance_km
FROM analytics.fn_store_spatial_pairs(p_month)
), ranked AS (
SELECT store_code, store_name, nearest_store_code, nearest_store_name, distance_km,
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
$$ LANGUAGE SQL STABLE;
-- 4. fn_store_site_replication(p_month) — 替换 v_store_site_replication_score_april
CREATE OR REPLACE FUNCTION analytics.fn_store_site_replication(p_month date)
RETURNS TABLE (
store_code text, store_name text, bill_count bigint, active_days bigint,
received numeric, avg_daily_received numeric, avg_bill_value numeric,
discount_rate_pct numeric, theoretical_margin_pct numeric, member_bill_share_pct numeric,
items_per_bill numeric, skus_per_bill numeric, delivery_bill_share_pct numeric,
noodle_snack_attach_pct numeric, noodle_drink_attach_pct numeric, noodle_cold_attach_pct numeric,
combo_bill_share_pct numeric, theoretical_cost numeric, actual_food_cost numeric,
food_cost_variance numeric, theoretical_cost_rate_pct numeric, actual_food_cost_rate_pct numeric,
variance_to_theoretical_pct numeric, comparison_status text, variance_level text,
identified_members bigint, repeat_rate_pct numeric, repeat_revenue_share_pct numeric,
benchmark_score numeric, meituan_received numeric, taobao_received numeric, jd_received numeric,
combined_platform_cost_rate_pct numeric, business_type text, scale_tier text,
problem_count bigint, problem_combination text, action_priority text,
area_sqm numeric, business_address text, city text, district text,
latitude_gcj02 double precision, longitude_gcj02 double precision,
open_date date, lease_expiry_date date, store_age_years numeric,
monthly_received_per_sqm numeric, daily_received_per_sqm numeric,
estimated_inventory_days numeric, site_scene text, floor_type text,
area_band text, age_band text,
nearest_store_code text, nearest_store_name text, nearest_distance_km numeric,
sqm_score double precision, daily_score double precision, repeat_score double precision,
discount_score double precision, cost_score double precision, platform_score double precision,
execution_score numeric, site_replication_score numeric,
replication_recommendation text, spatial_recommendation text
) 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.0 - percent_rank() OVER (ORDER BY p.discount_rate_pct) AS discount_score,
1.0 - percent_rank() OVER (ORDER BY p.actual_food_cost_rate_pct) AS cost_score,
1.0 - percent_rank() OVER (ORDER BY p.combined_platform_cost_rate_pct) AS platform_score,
GREATEST(0, 1 - p.problem_count / 6.0) AS execution_score
FROM analytics.fn_store_site_profile(p_month) p
LEFT JOIN analytics.fn_store_nearest_neighbor(p_month) n USING (store_code, store_name)
WHERE p.business_type = '标准门店' AND p.received > 0 AND p.area_sqm IS NOT NULL
), scored AS (
SELECT eligible.*,
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::double precision)::numeric * 100, 2) AS site_replication_score
FROM eligible
)
SELECT scored.*,
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
$$ LANGUAGE SQL STABLE;
-- 5. fn_store_overlap_risk(p_month) — 替换 v_store_location_overlap_risk_april
CREATE OR REPLACE FUNCTION analytics.fn_store_overlap_risk(p_month date)
RETURNS TABLE (
store_code_a text, store_name_a text, store_code_b text, store_name_b text,
district_a text, district_b text, scene_a text, scene_b text,
priority_a text, priority_b text, received_a numeric, received_b numeric,
sqm_efficiency_a numeric, sqm_efficiency_b numeric, distance_km double precision,
proximity_level text, problem_count_a bigint, problem_count_b bigint,
overlap_risk text
) AS $$
SELECT
p.store_code_a, p.store_name_a, p.store_code_b, p.store_name_b,
p.district_a, p.district_b, p.scene_a, p.scene_b,
p.priority_a, p.priority_b, p.received_a, p.received_b,
p.sqm_efficiency_a, p.sqm_efficiency_b, p.distance_km, p.proximity_level,
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.fn_store_spatial_pairs(p_month) p
JOIN analytics.fn_store_site_profile(p_month) a ON a.store_code = p.store_code_a
JOIN analytics.fn_store_site_profile(p_month) b ON b.store_code = p.store_code_b
WHERE p.distance_km < 3
$$ LANGUAGE SQL STABLE;
-- 6. fn_district_site_benchmark(p_month) — 替换 v_district_site_benchmark_april
CREATE OR REPLACE FUNCTION analytics.fn_district_site_benchmark(p_month date)
RETURNS TABLE (
city text, district text, store_count bigint, avg_area_sqm numeric,
total_received numeric, avg_received numeric, median_received numeric,
avg_received_per_sqm numeric, avg_bill_value numeric, avg_discount_rate_pct numeric,
avg_repeat_rate_pct numeric, avg_actual_cost_rate_pct numeric,
avg_platform_cost_rate_pct numeric, p0_count bigint, p1_count bigint
) AS $$
SELECT
city, district,
count(*) AS store_count,
round(avg(area_sqm), 1) AS avg_area_sqm,
round(sum(received), 2) AS total_received,
round(avg(received), 2) AS avg_received,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY received::double precision)::numeric, 2) AS median_received,
round(avg(monthly_received_per_sqm), 2) AS avg_received_per_sqm,
round(avg(avg_bill_value), 2) AS avg_bill_value,
round(avg(discount_rate_pct), 2) AS avg_discount_rate_pct,
round(avg(repeat_rate_pct), 2) AS avg_repeat_rate_pct,
round(avg(actual_food_cost_rate_pct) FILTER (WHERE comparison_status = '可比'), 2) AS avg_actual_cost_rate_pct,
round(avg(combined_platform_cost_rate_pct), 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.fn_store_site_profile(p_month)
WHERE business_type = '标准门店' AND received > 0
GROUP BY city, district
$$ LANGUAGE SQL STABLE;
-- 7. fn_site_segment_benchmark(p_month) — 替换 v_site_segment_benchmark_april
CREATE OR REPLACE FUNCTION analytics.fn_site_segment_benchmark(p_month date)
RETURNS TABLE (
site_scene text, area_band text, store_count bigint, avg_area_sqm numeric,
avg_received numeric, median_received numeric, avg_received_per_sqm numeric,
median_received_per_sqm numeric, avg_bill_value numeric, avg_discount_rate_pct numeric,
avg_repeat_rate_pct numeric, avg_actual_cost_rate_pct numeric,
avg_delivery_share_pct numeric, avg_drink_attach_pct numeric
) AS $$
SELECT
site_scene, area_band,
count(*) AS store_count,
round(avg(area_sqm), 1) AS avg_area_sqm,
round(avg(received), 2) AS avg_received,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY received::double precision)::numeric, 2) AS median_received,
round(avg(monthly_received_per_sqm), 2) AS avg_received_per_sqm,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY monthly_received_per_sqm::double precision)::numeric, 2) AS median_received_per_sqm,
round(avg(avg_bill_value), 2) AS avg_bill_value,
round(avg(discount_rate_pct), 2) AS avg_discount_rate_pct,
round(avg(repeat_rate_pct), 2) AS avg_repeat_rate_pct,
round(avg(actual_food_cost_rate_pct) FILTER (WHERE comparison_status = '可比'), 2) AS avg_actual_cost_rate_pct,
round(avg(delivery_bill_share_pct), 2) AS avg_delivery_share_pct,
round(avg(noodle_drink_attach_pct), 2) AS avg_drink_attach_pct
FROM analytics.fn_store_site_profile(p_month)
WHERE business_type = '标准门店' AND received > 0 AND area_sqm IS NOT NULL
GROUP BY site_scene, area_band
$$ LANGUAGE SQL STABLE;
-- 8. fn_dish_member_repeat(p_month) — 替换 v_dish_member_repeat_april
CREATE OR REPLACE FUNCTION analytics.fn_dish_member_repeat(p_month date)
RETURNS TABLE (
dish_name text, purchasing_members bigint, repeat_members bigint,
repeat_member_rate_pct numeric, avg_member_orders numeric, member_received_amount numeric
) AS $$
SELECT
dish_name,
count(*) AS purchasing_members,
count(*) FILTER (WHERE order_count >= 2) AS repeat_members,
round(count(*) FILTER (WHERE order_count >= 2)::numeric / NULLIF(count(*), 0) * 100, 2) AS repeat_member_rate_pct,
round(avg(order_count), 2) AS avg_member_orders,
sum(received_amount) AS member_received_amount
FROM analytics.fn_dish_member_sku(p_month)
GROUP BY dish_name
$$ LANGUAGE SQL STABLE;