316 lines
17 KiB
PL/PgSQL
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;
|