feat: 月度模式全面参数化 - 移除硬编码日期,前后端按月动态查询
This commit is contained in:
@@ -43,7 +43,7 @@ def to_text(val):
|
||||
s = str(val).strip()
|
||||
return s if s else None
|
||||
|
||||
def import_salary(conn):
|
||||
def import_salary(conn, report_month='2026-04-01'):
|
||||
print(f'导入薪资拆分明细表: {SALARY_FILE}')
|
||||
sha = file_sha256(SALARY_FILE)
|
||||
wb = openpyxl.load_workbook(SALARY_FILE, read_only=True, data_only=True)
|
||||
@@ -54,7 +54,7 @@ def import_salary(conn):
|
||||
data_rows = [r for r in rows if r[0] is not None and r[9] is not None and str(r[9]).strip()]
|
||||
print(f' 数据行数: {len(data_rows)}')
|
||||
|
||||
report_month = '2026-04-01'
|
||||
report_month = report_month
|
||||
source_file = os.path.basename(SALARY_FILE)
|
||||
|
||||
cur = conn.cursor()
|
||||
@@ -145,7 +145,7 @@ def import_salary(conn):
|
||||
wb.close()
|
||||
print(f' 薪资明细导入完成: {len(data_rows)} 行')
|
||||
|
||||
def import_attendance(conn):
|
||||
def import_attendance(conn, report_month='2026-04-01'):
|
||||
print(f'\n导入考勤数据表: {ATTENDANCE_FILE}')
|
||||
sha = file_sha256(ATTENDANCE_FILE)
|
||||
wb = openpyxl.load_workbook(ATTENDANCE_FILE, read_only=True, data_only=True)
|
||||
@@ -156,7 +156,7 @@ def import_attendance(conn):
|
||||
data_rows = [r for r in rows if r[0] is not None]
|
||||
print(f' 数据行数: {len(data_rows)}')
|
||||
|
||||
report_month = '2026-04-01'
|
||||
report_month = report_month
|
||||
source_file = os.path.basename(ATTENDANCE_FILE)
|
||||
|
||||
cur = conn.cursor()
|
||||
@@ -209,11 +209,13 @@ def import_attendance(conn):
|
||||
print(f' 考勤数据导入完成: {len(data_rows)} 行')
|
||||
|
||||
def main():
|
||||
report_month = sys.argv[1] if len(sys.argv) > 1 else '2026-04-01'
|
||||
print(f'导入月份: {report_month}')
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
conn.autocommit = False
|
||||
try:
|
||||
import_salary(conn)
|
||||
import_attendance(conn)
|
||||
import_salary(conn, report_month)
|
||||
import_attendance(conn, report_month)
|
||||
print('\n=== 导入完成 ===')
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
-- ============================================================
|
||||
-- Phase 5: SKU/菜品参数化函数
|
||||
-- 替换 _april 后缀的SKU/菜品视图为参数化函数
|
||||
-- 依赖: fn_dish_sales(p_month) 已在 Phase 2 中创建
|
||||
-- ============================================================
|
||||
|
||||
-- 1. fn_dish_sku_summary(p_month) — 替换 dish_sku_summary_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_sku_summary(p_month date)
|
||||
RETURNS TABLE (
|
||||
dish_name text,
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
detail_rows bigint,
|
||||
bill_count bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric,
|
||||
realized_unit_price numeric,
|
||||
revenue_share_pct numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
s.dish_name,
|
||||
min(s.category_level1) AS category_level1,
|
||||
min(s.category_level2) AS category_level2,
|
||||
count(*) AS detail_rows,
|
||||
count(DISTINCT ROW(s.store_code, s.bill_no)) AS bill_count,
|
||||
count(DISTINCT s.store_code) AS store_count,
|
||||
sum(s.sales_quantity) AS sales_quantity,
|
||||
sum(s.gross_amount) AS gross_amount,
|
||||
sum(s.received_amount) AS received_amount,
|
||||
sum(s.dish_discount_amount) AS discount_amount,
|
||||
round(sum(s.dish_discount_amount) / NULLIF(sum(s.gross_amount), 0) * 100, 2) AS discount_rate_pct,
|
||||
round(sum(s.received_amount) / NULLIF(sum(s.sales_quantity), 0), 2) AS realized_unit_price,
|
||||
round(sum(s.received_amount) / NULLIF(sum(sum(s.received_amount)) OVER (), 0) * 100, 4) AS revenue_share_pct
|
||||
FROM analytics.fn_dish_sales(p_month) s
|
||||
WHERE s.dish_name IS NOT NULL
|
||||
GROUP BY s.dish_name
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 2. fn_dish_sku_abc(p_month) — 替换 v_dish_sku_abc_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_sku_abc(p_month date)
|
||||
RETURNS TABLE (
|
||||
dish_name text,
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
detail_rows bigint,
|
||||
bill_count bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric,
|
||||
realized_unit_price numeric,
|
||||
revenue_share_pct numeric,
|
||||
cumulative_revenue_share numeric,
|
||||
median_quantity numeric,
|
||||
median_revenue numeric,
|
||||
abc_class text,
|
||||
sales_quadrant text
|
||||
) AS $$
|
||||
WITH medians AS (
|
||||
SELECT
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY s.sales_quantity::double precision) AS median_quantity,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY s.received_amount::double precision) AS median_revenue
|
||||
FROM analytics.fn_dish_sku_summary(p_month) s
|
||||
), ranked AS (
|
||||
SELECT
|
||||
s.dish_name,
|
||||
s.category_level1,
|
||||
s.category_level2,
|
||||
s.detail_rows,
|
||||
s.bill_count,
|
||||
s.store_count,
|
||||
s.sales_quantity,
|
||||
s.gross_amount,
|
||||
s.received_amount,
|
||||
s.discount_amount,
|
||||
s.discount_rate_pct,
|
||||
s.realized_unit_price,
|
||||
s.revenue_share_pct,
|
||||
sum(s.received_amount) OVER (ORDER BY s.received_amount DESC, s.dish_name ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
|
||||
/ NULLIF(sum(s.received_amount) OVER (), 0) AS cumulative_revenue_share,
|
||||
m.median_quantity,
|
||||
m.median_revenue
|
||||
FROM analytics.fn_dish_sku_summary(p_month) s
|
||||
CROSS JOIN medians m
|
||||
)
|
||||
SELECT
|
||||
ranked.dish_name,
|
||||
ranked.category_level1,
|
||||
ranked.category_level2,
|
||||
ranked.detail_rows,
|
||||
ranked.bill_count,
|
||||
ranked.store_count,
|
||||
ranked.sales_quantity,
|
||||
ranked.gross_amount,
|
||||
ranked.received_amount,
|
||||
ranked.discount_amount,
|
||||
ranked.discount_rate_pct,
|
||||
ranked.realized_unit_price,
|
||||
ranked.revenue_share_pct,
|
||||
ranked.cumulative_revenue_share,
|
||||
ranked.median_quantity,
|
||||
ranked.median_revenue,
|
||||
CASE
|
||||
WHEN ranked.cumulative_revenue_share <= 0.70 THEN 'A-核心'
|
||||
WHEN ranked.cumulative_revenue_share <= 0.90 THEN 'B-成长'
|
||||
ELSE 'C-长尾'
|
||||
END AS abc_class,
|
||||
CASE
|
||||
WHEN ranked.sales_quantity::double precision >= ranked.median_quantity AND ranked.received_amount::double precision >= ranked.median_revenue THEN '明星菜品'
|
||||
WHEN ranked.sales_quantity::double precision >= ranked.median_quantity AND ranked.received_amount::double precision < ranked.median_revenue THEN '引流菜品'
|
||||
WHEN ranked.sales_quantity::double precision < ranked.median_quantity AND ranked.received_amount::double precision >= ranked.median_revenue THEN '潜力菜品'
|
||||
ELSE '淘汰观察品'
|
||||
END AS sales_quadrant
|
||||
FROM ranked
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 3. fn_dish_category_summary(p_month) — 替换 dish_category_summary_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_category_summary(p_month date)
|
||||
RETURNS TABLE (
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
sku_count bigint,
|
||||
bill_count bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric,
|
||||
revenue_share_pct numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
s.category_level1,
|
||||
s.category_level2,
|
||||
count(DISTINCT s.dish_name) AS sku_count,
|
||||
count(DISTINCT ROW(s.store_code, s.bill_no)) AS bill_count,
|
||||
count(DISTINCT s.store_code) AS store_count,
|
||||
sum(s.sales_quantity) AS sales_quantity,
|
||||
sum(s.gross_amount) AS gross_amount,
|
||||
sum(s.received_amount) AS received_amount,
|
||||
sum(s.dish_discount_amount) AS discount_amount,
|
||||
round(sum(s.dish_discount_amount) / NULLIF(sum(s.gross_amount), 0) * 100, 2) AS discount_rate_pct,
|
||||
round(sum(s.received_amount) / NULLIF(sum(sum(s.received_amount)) OVER (), 0) * 100, 2) AS revenue_share_pct
|
||||
FROM analytics.fn_dish_sales(p_month) s
|
||||
GROUP BY s.category_level1, s.category_level2
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 4. fn_dish_member_sku(p_month) — 替换 dish_member_sku_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_member_sku(p_month date)
|
||||
RETURNS TABLE (
|
||||
member_id text,
|
||||
dish_name text,
|
||||
order_count bigint,
|
||||
purchase_days bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
received_amount numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
s.member_id,
|
||||
s.dish_name,
|
||||
count(DISTINCT ROW(s.store_code, s.bill_no)) AS order_count,
|
||||
count(DISTINCT s.business_date) AS purchase_days,
|
||||
count(DISTINCT s.store_code) AS store_count,
|
||||
sum(s.sales_quantity) AS sales_quantity,
|
||||
sum(s.received_amount) AS received_amount
|
||||
FROM analytics.fn_dish_sales(p_month) s
|
||||
WHERE s.member_id IS NOT NULL AND s.dish_name IS NOT NULL AND s.sales_quantity > 0
|
||||
GROUP BY s.member_id, s.dish_name
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 5. fn_dish_store_sku(p_month) — 替换 dish_store_sku_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_store_sku(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text,
|
||||
store_name text,
|
||||
dish_name text,
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
bill_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric
|
||||
) AS $$
|
||||
SELECT
|
||||
s.store_code,
|
||||
min(s.store_name) AS store_name,
|
||||
s.dish_name,
|
||||
min(s.category_level1) AS category_level1,
|
||||
min(s.category_level2) AS category_level2,
|
||||
count(DISTINCT s.bill_no) AS bill_count,
|
||||
sum(s.sales_quantity) AS sales_quantity,
|
||||
sum(s.gross_amount) AS gross_amount,
|
||||
sum(s.received_amount) AS received_amount,
|
||||
sum(s.dish_discount_amount) AS discount_amount,
|
||||
round(sum(s.dish_discount_amount) / NULLIF(sum(s.gross_amount), 0) * 100, 2) AS discount_rate_pct
|
||||
FROM analytics.fn_dish_sales(p_month) s
|
||||
WHERE s.dish_name IS NOT NULL
|
||||
GROUP BY s.store_code, s.dish_name
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 6. fn_dish_pair_summary(p_month) — 替换 dish_pair_summary_april (普通表)
|
||||
-- 注意: 此表由外部脚本填充,函数版本从 dish_sales_details 实时计算
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_dish_pair_summary(p_month date)
|
||||
RETURNS TABLE (
|
||||
dish_a text,
|
||||
dish_b text,
|
||||
pair_count bigint
|
||||
) AS $$
|
||||
WITH pairs AS (
|
||||
SELECT
|
||||
LEAST(a.dish_name, b.dish_name) AS dish_a,
|
||||
GREATEST(a.dish_name, b.dish_name) AS dish_b,
|
||||
count(DISTINCT a.bill_no) AS pair_count
|
||||
FROM dish_sales_details a
|
||||
JOIN dish_sales_details b
|
||||
ON a.store_code = b.store_code
|
||||
AND a.bill_no = b.bill_no
|
||||
AND a.dish_name < b.dish_name
|
||||
WHERE a.opened_at >= p_month
|
||||
AND a.opened_at < p_month + INTERVAL '1 month'
|
||||
AND b.opened_at >= p_month
|
||||
AND b.opened_at < p_month + INTERVAL '1 month'
|
||||
AND a.dish_name IS NOT NULL
|
||||
AND b.dish_name IS NOT NULL
|
||||
GROUP BY LEAST(a.dish_name, b.dish_name), GREATEST(a.dish_name, b.dish_name)
|
||||
)
|
||||
SELECT dish_a, dish_b, pair_count
|
||||
FROM pairs
|
||||
ORDER BY pair_count DESC
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
|
||||
-- 7. fn_c_sku_governance(p_month) — 替换 v_c_sku_governance_april
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_c_sku_governance(p_month date)
|
||||
RETURNS TABLE (
|
||||
dish_name text,
|
||||
category_level1 text,
|
||||
category_level2 text,
|
||||
detail_rows bigint,
|
||||
bill_count bigint,
|
||||
store_count bigint,
|
||||
sales_quantity numeric,
|
||||
gross_amount numeric,
|
||||
received_amount numeric,
|
||||
discount_amount numeric,
|
||||
discount_rate_pct numeric,
|
||||
realized_unit_price numeric,
|
||||
revenue_share_pct numeric,
|
||||
cumulative_revenue_share numeric,
|
||||
median_quantity numeric,
|
||||
median_revenue numeric,
|
||||
abc_class text,
|
||||
sales_quadrant text,
|
||||
item_types text,
|
||||
is_combo_header boolean,
|
||||
is_combo_component boolean,
|
||||
is_single_item boolean,
|
||||
dish_code text,
|
||||
cost_source_group_count bigint,
|
||||
cost_report_sales_amount numeric,
|
||||
theoretical_cost numeric,
|
||||
actual_cost numeric,
|
||||
cost_variance_amount numeric,
|
||||
cost_report_theoretical_cost_rate_pct numeric,
|
||||
cost_report_actual_cost_rate_pct numeric,
|
||||
material_count bigint,
|
||||
exclusive_material_count bigint,
|
||||
governance_group text,
|
||||
additional_risk text
|
||||
) AS $$
|
||||
WITH item_types AS (
|
||||
SELECT
|
||||
d.dish_name,
|
||||
string_agg(DISTINCT COALESCE(d.item_type, '未分类'), '、' ORDER BY (COALESCE(d.item_type, '未分类'))) AS item_types,
|
||||
bool_or(d.item_type = '套餐') AS is_combo_header,
|
||||
bool_or(d.item_type = '套餐明细菜') AS is_combo_component,
|
||||
bool_or(d.item_type = '单点') AS is_single_item
|
||||
FROM dish_sales_details d
|
||||
WHERE d.opened_at >= p_month AND d.opened_at < p_month + INTERVAL '1 month'
|
||||
GROUP BY d.dish_name
|
||||
), material_usage AS (
|
||||
SELECT
|
||||
m.material_name,
|
||||
count(DISTINCT m.dish_name) AS used_by_dish_count
|
||||
FROM analytics.v_dish_cost_analysis_latest_material_detail m
|
||||
GROUP BY m.material_name
|
||||
), material_profile AS (
|
||||
SELECT
|
||||
d.dish_name,
|
||||
count(DISTINCT d.material_name) AS material_count,
|
||||
count(DISTINCT d.material_name) FILTER (WHERE u.used_by_dish_count = 1) AS exclusive_material_count
|
||||
FROM analytics.v_dish_cost_analysis_latest_material_detail d
|
||||
JOIN material_usage u USING (material_name)
|
||||
GROUP BY d.dish_name
|
||||
)
|
||||
SELECT
|
||||
c.dish_name,
|
||||
c.category_level1,
|
||||
c.category_level2,
|
||||
c.detail_rows,
|
||||
c.bill_count,
|
||||
c.store_count,
|
||||
c.sales_quantity,
|
||||
c.gross_amount,
|
||||
c.received_amount,
|
||||
c.discount_amount,
|
||||
c.discount_rate_pct,
|
||||
c.realized_unit_price,
|
||||
c.revenue_share_pct,
|
||||
c.cumulative_revenue_share,
|
||||
c.median_quantity,
|
||||
c.median_revenue,
|
||||
c.abc_class,
|
||||
c.sales_quadrant,
|
||||
t.item_types,
|
||||
t.is_combo_header,
|
||||
t.is_combo_component,
|
||||
t.is_single_item,
|
||||
cost.dish_code,
|
||||
cost.source_group_count AS cost_source_group_count,
|
||||
cost.sales_amount AS cost_report_sales_amount,
|
||||
cost.theoretical_cost,
|
||||
cost.actual_cost,
|
||||
cost.cost_variance_amount,
|
||||
cost.theoretical_cost_rate_pct AS cost_report_theoretical_cost_rate_pct,
|
||||
cost.actual_cost_rate_pct AS cost_report_actual_cost_rate_pct,
|
||||
COALESCE(mp.material_count, 0) AS material_count,
|
||||
COALESCE(mp.exclusive_material_count, 0) AS exclusive_material_count,
|
||||
CASE
|
||||
WHEN c.received_amount <= 0 AND COALESCE(t.is_combo_header, false) THEN 'T1-套餐/技术项目治理'
|
||||
WHEN c.received_amount <= 0 THEN 'T2-零收入单点核查'
|
||||
WHEN c.store_count = 1 AND c.bill_count < 30 AND c.received_amount < 1000 THEN 'S1-首批停用评审'
|
||||
WHEN c.store_count <= 3 AND c.bill_count < 60 AND c.received_amount < 3000 THEN 'S2-区域低效评审'
|
||||
WHEN c.store_count > 3 AND c.bill_count < 30 AND c.received_amount < 1000 THEN 'S3-铺店不动销评审'
|
||||
WHEN c.sales_quadrant IN ('明星菜品', '潜力菜品') THEN 'K1-保留并优化'
|
||||
ELSE 'K2-继续观察'
|
||||
END AS governance_group,
|
||||
CASE
|
||||
WHEN COALESCE(mp.exclusive_material_count, 0) > 0 AND c.received_amount < 3000 THEN '高:低收入且占用独有原料'
|
||||
WHEN cost.cost_variance_amount > 0 AND cost.actual_cost > (cost.theoretical_cost * 1.2) THEN '高:成本报表显示明显超理论'
|
||||
WHEN c.discount_rate_pct >= 35 THEN '中:高折扣依赖'
|
||||
ELSE '常规'
|
||||
END AS additional_risk
|
||||
FROM analytics.fn_dish_sku_abc(p_month) c
|
||||
LEFT JOIN item_types t USING (dish_name)
|
||||
LEFT JOIN analytics.v_dish_cost_analysis_latest_dish_rollup cost USING (dish_name)
|
||||
LEFT JOIN material_profile mp USING (dish_name)
|
||||
WHERE c.abc_class = 'C-长尾'
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
@@ -0,0 +1,315 @@
|
||||
-- ============================================================
|
||||
-- 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;
|
||||
@@ -0,0 +1,84 @@
|
||||
-- ============================================================
|
||||
-- Phase 8: mv_store_risk_rating 参数化函数
|
||||
-- 替换普通表 mv_store_risk_rating(4月快照)为参数化函数
|
||||
-- 依赖: bill_fact, v_anomaly_bills
|
||||
-- ============================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_risk_rating(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,
|
||||
avg_guest_value numeric,
|
||||
discount_rate_pct numeric,
|
||||
theoretical_margin_pct numeric,
|
||||
member_bill_share_pct numeric,
|
||||
anomaly_rate_pct numeric,
|
||||
risk_level text,
|
||||
primary_issue text
|
||||
) AS $$
|
||||
WITH base AS (
|
||||
SELECT
|
||||
b.store_code,
|
||||
b.store_name,
|
||||
count(*) AS bill_count,
|
||||
count(DISTINCT b.closed_at::date) AS active_days,
|
||||
sum(b.received_total) AS received,
|
||||
round(sum(b.received_total) / NULLIF(count(DISTINCT b.closed_at::date), 0), 2) AS avg_daily_received,
|
||||
round(sum(b.received_total) / count(*), 2) AS avg_bill_value,
|
||||
round(sum(b.received_total) / NULLIF(sum(b.guest_count), 0), 2) AS avg_guest_value,
|
||||
round(sum(b.discount_total) / NULLIF(sum(b.consumption), 0) * 100, 2) AS discount_rate_pct,
|
||||
round(sum(b.theoretical_profit) / NULLIF(sum(b.received_total), 0) * 100, 2) AS theoretical_margin_pct,
|
||||
round(count(*) FILTER (WHERE b.member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_bill_share_pct
|
||||
FROM analytics.bill_fact b
|
||||
WHERE b.closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND b.closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY b.store_code, b.store_name
|
||||
),
|
||||
anomaly AS (
|
||||
SELECT
|
||||
a.store_code,
|
||||
round(count(*)::numeric / NULLIF(bc.bill_count, 0) * 100, 2) AS anomaly_rate_pct
|
||||
FROM analytics.v_anomaly_bills a
|
||||
JOIN (SELECT store_code, count(*) AS bill_count FROM analytics.bill_fact
|
||||
WHERE closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY store_code) bc ON a.store_code = bc.store_code
|
||||
WHERE a.closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND a.closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY a.store_code, bc.bill_count
|
||||
),
|
||||
combined AS (
|
||||
SELECT
|
||||
b.*,
|
||||
COALESCE(an.anomaly_rate_pct, 0) AS anomaly_rate_pct,
|
||||
CASE
|
||||
WHEN b.received IS NULL OR b.received = 0 THEN '绿色'
|
||||
WHEN b.theoretical_margin_pct < 68 OR b.discount_rate_pct > 25 OR COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '红色'
|
||||
WHEN b.theoretical_margin_pct >= 70 AND b.discount_rate_pct <= 22 AND COALESCE(an.anomaly_rate_pct, 0) <= 1.5 THEN '绿色'
|
||||
ELSE '黄色'
|
||||
END AS risk_level,
|
||||
CASE
|
||||
WHEN b.theoretical_margin_pct < 68 AND b.discount_rate_pct > 25 AND COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '毛利率低;优惠率高;异常率高'
|
||||
WHEN b.theoretical_margin_pct < 68 AND b.discount_rate_pct > 25 THEN '毛利率低;优惠率高'
|
||||
WHEN b.theoretical_margin_pct < 68 AND COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '毛利率低;异常率高'
|
||||
WHEN b.discount_rate_pct > 25 AND COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '优惠率高;异常率高'
|
||||
WHEN b.theoretical_margin_pct < 68 THEN '毛利率低'
|
||||
WHEN b.discount_rate_pct > 25 THEN '优惠率高'
|
||||
WHEN COALESCE(an.anomaly_rate_pct, 0) > 3 THEN '异常率高'
|
||||
ELSE ''
|
||||
END AS primary_issue
|
||||
FROM base b
|
||||
LEFT JOIN anomaly an ON b.store_code = an.store_code
|
||||
)
|
||||
SELECT
|
||||
store_code, store_name, bill_count, active_days, received,
|
||||
avg_daily_received, avg_bill_value, avg_guest_value,
|
||||
discount_rate_pct, theoretical_margin_pct, member_bill_share_pct,
|
||||
anomaly_rate_pct, risk_level, primary_issue
|
||||
FROM combined
|
||||
$$ LANGUAGE SQL STABLE;
|
||||
@@ -0,0 +1,177 @@
|
||||
-- ============================================================
|
||||
-- Phase 9: 全量视图参数化
|
||||
-- 替换 v_store_platform_economics, v_store_category_mix, v_store_member_opportunity
|
||||
-- 为按月参数化函数
|
||||
-- ============================================================
|
||||
|
||||
-- ============================================================
|
||||
-- 1. fn_store_platform_economics(p_month)
|
||||
-- 替换 v_store_platform_economics(无月份过滤的全量聚合)
|
||||
-- 数据源: bill_records, 按月份过滤 c175
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_platform_economics(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text,
|
||||
store_name text,
|
||||
meituan_received numeric,
|
||||
meituan_discount numeric,
|
||||
meituan_commission numeric,
|
||||
taobao_received numeric,
|
||||
taobao_discount numeric,
|
||||
taobao_commission numeric,
|
||||
jd_received numeric,
|
||||
jd_discount numeric,
|
||||
jd_commission numeric,
|
||||
meituan_cost_rate_pct numeric,
|
||||
taobao_cost_rate_pct numeric,
|
||||
jd_cost_rate_pct numeric
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
SELECT
|
||||
NULLIF(bill_records.c002, '') AS store_code,
|
||||
NULLIF(bill_records.c003, '') AS store_name,
|
||||
sum(COALESCE(NULLIF(bill_records.c151, '')::numeric, 0)) AS meituan_received,
|
||||
sum(COALESCE(NULLIF(bill_records.c101, '')::numeric, 0)) AS meituan_discount,
|
||||
sum(COALESCE(NULLIF(bill_records.c097, '')::numeric, 0)) AS meituan_commission,
|
||||
sum(COALESCE(NULLIF(bill_records.c152, '')::numeric, 0)) AS taobao_received,
|
||||
sum(COALESCE(NULLIF(bill_records.c102, '')::numeric, 0)) AS taobao_discount,
|
||||
sum(COALESCE(NULLIF(bill_records.c098, '')::numeric, 0)) AS taobao_commission,
|
||||
sum(COALESCE(NULLIF(bill_records.c150, '')::numeric, 0)) AS jd_received,
|
||||
sum(COALESCE(NULLIF(bill_records.c099, '')::numeric, 0)) AS jd_discount,
|
||||
sum(COALESCE(NULLIF(bill_records.c100, '')::numeric, 0)) AS jd_commission,
|
||||
round((sum(COALESCE(NULLIF(bill_records.c101, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c097, '')::numeric, 0)))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c151, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c101, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c097, '')::numeric, 0)), 0) * 100, 2) AS meituan_cost_rate_pct,
|
||||
round((sum(COALESCE(NULLIF(bill_records.c102, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c098, '')::numeric, 0)))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c152, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c102, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c098, '')::numeric, 0)), 0) * 100, 2) AS taobao_cost_rate_pct,
|
||||
round((sum(COALESCE(NULLIF(bill_records.c099, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c100, '')::numeric, 0)))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c150, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c099, '')::numeric, 0)) + sum(COALESCE(NULLIF(bill_records.c100, '')::numeric, 0)), 0) * 100, 2) AS jd_cost_rate_pct
|
||||
FROM bill_records
|
||||
WHERE NULLIF(bill_records.c005, '') IS NOT NULL
|
||||
AND bill_records.c175 IS NOT NULL AND bill_records.c175 != ''
|
||||
AND bill_records.c175::timestamp >= analytics.fn_month_start_ts(p_month)
|
||||
AND bill_records.c175::timestamp < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY NULLIF(bill_records.c002, ''), NULLIF(bill_records.c003, '');
|
||||
$function$;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. fn_store_category_mix(p_month)
|
||||
-- 替换 v_store_category_mix(无月份过滤的全量聚合)
|
||||
-- 数据源: bill_records, 按月份过滤 c175
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_category_mix(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text,
|
||||
store_name text,
|
||||
consumption numeric,
|
||||
lanzhou_noodle numeric,
|
||||
western_staple numeric,
|
||||
delivery_package numeric,
|
||||
night_bbq numeric,
|
||||
cold_dishes numeric,
|
||||
silk_road_food numeric,
|
||||
noodle_share_pct numeric,
|
||||
delivery_package_share_pct numeric,
|
||||
top_category_share_pct numeric,
|
||||
top_category text
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
SELECT
|
||||
NULLIF(bill_records.c002, '') AS store_code,
|
||||
NULLIF(bill_records.c003, '') AS store_name,
|
||||
sum(COALESCE(NULLIF(bill_records.c009, '')::numeric, 0)) AS consumption,
|
||||
sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0)) AS lanzhou_noodle,
|
||||
sum(COALESCE(NULLIF(bill_records.c016, '')::numeric, 0)) AS western_staple,
|
||||
sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0)) AS delivery_package,
|
||||
sum(COALESCE(NULLIF(bill_records.c013, '')::numeric, 0)) AS night_bbq,
|
||||
sum(COALESCE(NULLIF(bill_records.c015, '')::numeric, 0)) AS cold_dishes,
|
||||
sum(COALESCE(NULLIF(bill_records.c019, '')::numeric, 0)) AS silk_road_food,
|
||||
round(sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c009, '')::numeric, 0)), 0) * 100, 2) AS noodle_share_pct,
|
||||
round(sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0))
|
||||
/ NULLIF(sum(COALESCE(NULLIF(bill_records.c009, '')::numeric, 0)), 0) * 100, 2) AS delivery_package_share_pct,
|
||||
round(GREATEST(
|
||||
sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c016, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c013, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c015, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c019, '')::numeric, 0))
|
||||
) / NULLIF(sum(COALESCE(NULLIF(bill_records.c009, '')::numeric, 0)), 0) * 100, 2) AS top_category_share_pct,
|
||||
CASE GREATEST(
|
||||
sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c016, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c013, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c015, '')::numeric, 0)),
|
||||
sum(COALESCE(NULLIF(bill_records.c019, '')::numeric, 0))
|
||||
)
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c010, '')::numeric, 0)) THEN '兰州牛肉面'
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c016, '')::numeric, 0)) THEN '西部主食'
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c027, '')::numeric, 0)) THEN '外卖套餐'
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c013, '')::numeric, 0)) THEN '夜市烧烤'
|
||||
WHEN sum(COALESCE(NULLIF(bill_records.c015, '')::numeric, 0)) THEN '爽口凉菜'
|
||||
ELSE '丝路美食'
|
||||
END AS top_category
|
||||
FROM bill_records
|
||||
WHERE NULLIF(bill_records.c005, '') IS NOT NULL
|
||||
AND bill_records.c175 IS NOT NULL AND bill_records.c175 != ''
|
||||
AND bill_records.c175::timestamp >= analytics.fn_month_start_ts(p_month)
|
||||
AND bill_records.c175::timestamp < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY NULLIF(bill_records.c002, ''), NULLIF(bill_records.c003, '');
|
||||
$function$;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. fn_store_member_opportunity(p_month)
|
||||
-- 替换 v_store_member_opportunity(无月份过滤的全量聚合)
|
||||
-- 数据源: analytics.bill_fact, 按月份过滤 closed_at
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION analytics.fn_store_member_opportunity(p_month date)
|
||||
RETURNS TABLE (
|
||||
store_code text,
|
||||
store_name text,
|
||||
bill_count bigint,
|
||||
received numeric,
|
||||
member_share_pct numeric,
|
||||
company_member_share_pct numeric,
|
||||
conversion_bill_scenario numeric,
|
||||
revenue_uplift_scenario numeric
|
||||
)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
WITH company AS (
|
||||
SELECT
|
||||
count(*) FILTER (WHERE bill_fact.member_id IS NOT NULL)::numeric / count(*)::numeric AS member_share,
|
||||
avg(bill_fact.received_total) FILTER (WHERE bill_fact.member_id IS NOT NULL) AS member_avg_bill,
|
||||
avg(bill_fact.received_total) FILTER (WHERE bill_fact.member_id IS NULL) AS nonmember_avg_bill
|
||||
FROM analytics.bill_fact
|
||||
WHERE bill_fact.closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND bill_fact.closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
), stores AS (
|
||||
SELECT
|
||||
bill_fact.store_code,
|
||||
bill_fact.store_name,
|
||||
count(*) AS bill_count,
|
||||
count(*) FILTER (WHERE bill_fact.member_id IS NOT NULL)::numeric / count(*)::numeric AS member_share,
|
||||
sum(bill_fact.received_total) AS received
|
||||
FROM analytics.bill_fact
|
||||
WHERE bill_fact.closed_at >= analytics.fn_month_start_ts(p_month)
|
||||
AND bill_fact.closed_at < analytics.fn_next_month_start_ts(p_month)
|
||||
GROUP BY bill_fact.store_code, bill_fact.store_name
|
||||
)
|
||||
SELECT
|
||||
s.store_code,
|
||||
s.store_name,
|
||||
s.bill_count,
|
||||
round(s.received, 2) AS received,
|
||||
round(s.member_share * 100, 2) AS member_share_pct,
|
||||
round(c.member_share * 100, 2) AS company_member_share_pct,
|
||||
round(GREATEST(c.member_share - s.member_share, 0) * s.bill_count::numeric, 0) AS conversion_bill_scenario,
|
||||
round(GREATEST(c.member_share - s.member_share, 0) * s.bill_count::numeric * (c.member_avg_bill - c.nonmember_avg_bill), 2) AS revenue_uplift_scenario
|
||||
FROM stores s
|
||||
CROSS JOIN company c;
|
||||
$function$;
|
||||
Reference in New Issue
Block a user