feat: 连锁餐饮数字本体与AI经营智脑 - 新增分析页面、ETL脚本、总结文档

This commit is contained in:
freedakgmail
2026-08-02 10:54:48 +08:00
parent 73f12f40d1
commit a1a8b7ee5e
19 changed files with 3453 additions and 2 deletions
+282
View File
@@ -0,0 +1,282 @@
-- ============================================================
-- ETL Batch 1: 填充维度主数据表
-- 日期: 2026-08-02
-- 说明: 从 bill_fact / salary_detail_records / dish_sales_details
-- 聚合数据填充 dim_member / dim_employee / dim_channel / dim_sku
-- 并创建 member_level / position 标准化映射表
-- 执行: psql -d bill_query -f db/etl_batch1_fill_dims.sql
-- ============================================================
-- ============================================================
-- 1. 填充 dim_member — 会员主数据
-- ============================================================
TRUNCATE TABLE analytics.dim_member;
INSERT INTO analytics.dim_member (
member_id, phone_hash, register_channel, register_store, register_date,
member_level, total_orders, total_revenue, last_order_date, status, tags,
created_at, updated_at
)
WITH member_raw AS (
SELECT
member_id,
-- 首笔消费
MIN(opened_at) AS first_order,
MAX(opened_at) AS last_order,
COUNT(DISTINCT bill_no) AS order_count,
SUM(received_total) AS total_revenue,
-- 最新等级(取最近一笔的member_level
(array_agg(member_level ORDER BY opened_at DESC))[1] AS latest_level,
-- 首笔消费门店
(array_agg(store_code ORDER BY opened_at ASC))[1] AS first_store,
-- 首笔消费渠道
(array_agg(
CASE
WHEN meituan_delivery_received > 0 THEN '美团外卖'
WHEN taobao_delivery_received > 0 THEN '淘宝外卖'
WHEN jd_delivery_received > 0 THEN '京东外卖'
WHEN meituan_received > 0 THEN '美团到店'
WHEN douyin_received > 0 THEN '抖音'
WHEN cash_received > 0 THEN '现金'
WHEN alipay_received > 0 THEN '支付宝'
WHEN wechat_received > 0 THEN '微信'
WHEN unionpay_received > 0 THEN '银联'
WHEN credit_received > 0 THEN '挂账'
ELSE '堂食'
END
ORDER BY opened_at ASC
))[1] AS first_channel
FROM analytics.bill_fact
WHERE member_id IS NOT NULL AND member_id != ''
GROUP BY member_id
),
max_date AS (
SELECT MAX(opened_at)::date AS d FROM analytics.bill_fact WHERE opened_at IS NOT NULL
)
SELECT
mr.member_id,
NULL AS phone_hash, -- 无手机号数据
mr.first_channel AS register_channel,
mr.first_store AS register_store,
(mr.first_order AT TIME ZONE 'Asia/Shanghai')::date AS register_date,
-- 标准化会员等级
CASE
WHEN mr.latest_level IN ('1') THEN '普通'
WHEN mr.latest_level IN ('2','3') THEN '银卡'
WHEN mr.latest_level IN ('4','5') THEN '金卡'
WHEN mr.latest_level IN ('6','7','LV6','LV7') THEN '钻石'
ELSE '普通'
END AS member_level,
mr.order_count::integer AS total_orders,
round(mr.total_revenue::numeric, 2) AS total_revenue,
(mr.last_order AT TIME ZONE 'Asia/Shanghai')::date AS last_order_date,
-- 会员状态(基于最新账单日期)
CASE
WHEN (md.d - (mr.last_order AT TIME ZONE 'Asia/Shanghai')::date) <= 30 THEN '活跃'
WHEN (md.d - (mr.last_order AT TIME ZONE 'Asia/Shanghai')::date) <= 90 THEN '沉睡'
ELSE '流失'
END AS status,
-- 标签
CASE
WHEN mr.order_count = 1 THEN ARRAY['新客']
WHEN mr.order_count >= 20 THEN ARRAY['高频']
WHEN mr.total_revenue >= 500 THEN ARRAY['高价值']
ELSE ARRAY[]::TEXT[]
END AS tags,
now(), now()
FROM member_raw mr
CROSS JOIN max_date md;
CREATE INDEX IF NOT EXISTS idx_dim_member_status ON analytics.dim_member(status);
CREATE INDEX IF NOT EXISTS idx_dim_member_level ON analytics.dim_member(member_level);
CREATE INDEX IF NOT EXISTS idx_dim_member_store ON analytics.dim_member(register_store);
-- ============================================================
-- 2. 填充 dim_employee — 员工主数据
-- ============================================================
TRUNCATE TABLE analytics.dim_employee;
INSERT INTO analytics.dim_employee (
employee_id, employee_name, position, store_code, hire_date, leave_date, status,
created_at, updated_at
)
SELECT DISTINCT ON (employee_code)
employee_code,
NULL AS employee_name, -- 薪资表无姓名字段
-- 标准化岗位
CASE
WHEN position LIKE '店长%' OR position = '储备店长' OR position LIKE '见习经理%' OR position = '储备经理' THEN '店长'
WHEN position LIKE '副店%' THEN '副店长'
WHEN position LIKE '前厅经理%' OR position = '大堂经理' OR position = '服务主管' THEN '前厅经理'
WHEN position = '区经理' OR position LIKE '营运经理%' OR position = '营运助理' OR position LIKE '营运总监%' THEN '区经理'
WHEN position = '厨师长' OR position LIKE '厨师长%' OR position = '行政总厨' OR position LIKE '区厨%' OR position LIKE '大区总厨%' OR position LIKE '拉面区厨%' OR position LIKE '拉面总厨%' OR position = '研发总厨' OR position = '研发经理' OR position LIKE '配送中心%总厨%' OR position = '烤鸭总厨' OR position = '西餐总厨' THEN '厨师长'
WHEN position LIKE '拉面师%' OR position = '拉面' OR position = '拉面主管%' OR position = '面工' OR position LIKE '面工%' OR position = '面点' OR position = '面点师' OR position = '打馕' THEN '拉面师'
WHEN position LIKE '厨师%' OR position = '炒锅' OR position = '砧板' OR position = '打荷' OR position = '上什' OR position = '蒸箱' OR position = '出品' OR position = '副厨' OR position = '明档师傅' OR position = '西餐' OR position = '锅底' THEN '厨师'
WHEN position LIKE '配菜师%' OR position = '配菜师' OR position = '切菜师' OR position LIKE '切菜师%' OR position = '切肉师' OR position = '砧板主管' THEN '配菜师'
WHEN position LIKE '凉菜%' OR position = '凉菜' THEN '凉菜师'
WHEN position LIKE '烧烤%' OR position = '烧烤师' OR position LIKE '烧烤工%' OR position = '烧烤师傅' OR position = '烤鸭师' THEN '烧烤师'
WHEN position LIKE '服务员%' OR position = '传菜员' OR position = '迎宾员' OR position = '吧员' THEN '服务员'
WHEN position = '收银员' OR position = '出纳主管' OR position LIKE '%出纳%' THEN '收银员'
WHEN position = '训练员' OR position LIKE '训练员%' OR position = '训练经理' THEN '训练员'
WHEN position LIKE '厨工%' OR position = '兼职工' OR position = '非全兼职工' OR position = '小时工' OR position = '计时工' THEN '厨工'
WHEN position = '保洁员' OR position = '保洁' THEN '保洁'
WHEN position = '洗碗' THEN '洗碗工'
WHEN position LIKE '%组员%' OR position LIKE '%组长%' THEN '中央厨房工'
WHEN position = '库房组员' OR position = '库房专员' OR position = '库房资深专员' THEN '库管'
WHEN position LIKE '副总%' OR position LIKE '执行总裁%' OR position = '首席营销官%' OR position LIKE '%总监%' OR position LIKE '%经理%' OR position LIKE '%主管%' OR position LIKE '%专员%' OR position LIKE '%助理%' OR position = '主管' THEN '管理岗'
ELSE '其他'
END AS position_std,
-- 门店编码:org_level3 中以人名命名的区域无法直接映射到门店,暂取NULL
NULL AS store_code,
-- hire_date: text转date
CASE
WHEN hire_date ~ '^\d{4}-\d{2}-\d{2}$' THEN hire_date::date
WHEN hire_date ~ '^\d{4}/\d{2}/\d{2}$' THEN to_date(hire_date, 'YYYY/MM/DD')
ELSE NULL
END AS hire_date,
-- leave_date: '0' 表示在职
CASE
WHEN leave_date = '0' OR leave_date IS NULL OR leave_date = '' THEN NULL
WHEN leave_date ~ '^\d{4}-\d{2}-\d{2}$' THEN leave_date::date
WHEN leave_date ~ '^\d{4}/\d{2}/\d{2}$' THEN to_date(leave_date, 'YYYY/MM/DD')
ELSE NULL
END AS leave_date,
-- 状态
CASE
WHEN leave_date = '0' OR leave_date IS NULL OR leave_date = '' THEN '在职'
ELSE '离职'
END AS status,
now(), now()
FROM public.salary_detail_records
ORDER BY employee_code, leave_date DESC NULLS LAST;
CREATE INDEX IF NOT EXISTS idx_dim_employee_status ON analytics.dim_employee(status);
CREATE INDEX IF NOT EXISTS idx_dim_employee_position ON analytics.dim_employee(position);
-- ============================================================
-- 3. 填充 dim_channel — 渠道主数据
-- ============================================================
TRUNCATE TABLE analytics.dim_channel;
INSERT INTO analytics.dim_channel (channel_code, channel_name, channel_group, channel_type, platform, commission_rate, is_delivery, sort_order, status)
VALUES
('dinein', '堂食', '堂食', '堂食', '自有', 0, false, 1, '启用'),
('meituan_dm', '美团到店', '堂食', '美团到店', '美团', 0, false, 2, '启用'),
('meituan_wm', '美团外卖', '外卖', '美团外卖', '美团', 15.0, true, 3, '启用'),
('taobao_wm', '淘宝外卖', '外卖', '淘宝外卖', '淘宝', 12.0, true, 4, '启用'),
('jd_wm', '京东外卖', '外卖', '京东到家', '京东', 10.0, true, 5, '启用'),
('douyin', '抖音', '支付', '抖音', '抖音', 0, false, 6, '启用'),
('alipay', '支付宝', '支付', '支付宝', '自有', 0.6, false, 7, '启用'),
('wechat', '微信', '支付', '微信', '自有', 0.6, false, 8, '启用'),
('cash', '现金', '支付', '现金', '自有', 0, false, 9, '启用'),
('unionpay', '银联', '支付', '银联', '自有', 0.5, false, 10, '启用'),
('credit', '挂账', '支付', '挂账', '自有', 0, false, 11, '启用');
-- ============================================================
-- 4. 填充 dim_sku — SKU主数据(从 dish_sales_details 聚合)
-- ============================================================
TRUNCATE TABLE analytics.dim_sku;
INSERT INTO analytics.dim_sku (
sku_code, standard_name, pos_code, category_l1, category_l2,
status, abc_class, unit, created_at, updated_at
)
SELECT
-- SKU编码:DISH- + 序号(用dense_rank生成)
'DISH-' || lpad(dense_rank() OVER (ORDER BY dish_name)::text, 5, '0'),
dish_name,
NULL AS pos_code,
min(category_level1) AS category_l1,
min(category_level2) AS category_l2,
'在售' AS status,
-- 取最新月度ABC分类
(SELECT abc.abc_class FROM analytics.mv_dish_sku_abc_monthly abc
WHERE abc.dish_name = d.dish_name
ORDER BY abc.month_start DESC LIMIT 1) AS abc_class,
min(unit) AS unit,
now(), now()
FROM public.dish_sales_details d
WHERE d.dish_name IS NOT NULL AND d.dish_name != ''
GROUP BY d.dish_name;
-- ============================================================
-- 5. 创建 member_level_mapping 视图 — 会员等级标准化映射
-- ============================================================
CREATE OR REPLACE VIEW analytics.v_member_level_mapping AS
SELECT
member_level AS raw_level,
CASE
WHEN member_level IN ('1') THEN '普通'
WHEN member_level IN ('2','3') THEN '银卡'
WHEN member_level IN ('4','5') THEN '金卡'
WHEN member_level IN ('6','7','LV6','LV7') THEN '钻石'
ELSE '普通'
END AS standard_level,
CASE
WHEN member_level IN ('1') THEN 1
WHEN member_level IN ('2','3') THEN 2
WHEN member_level IN ('4','5') THEN 3
WHEN member_level IN ('6','7','LV6','LV7') THEN 4
ELSE 1
END AS level_sort
FROM (SELECT DISTINCT member_level FROM analytics.bill_fact WHERE member_level IS NOT NULL AND member_level != '') t;
-- ============================================================
-- 6. 创建 v_position_mapping 视图 — 岗位标准化映射
-- ============================================================
CREATE OR REPLACE VIEW analytics.v_position_mapping AS
SELECT DISTINCT
position AS raw_position,
CASE
WHEN position LIKE '店长%' OR position = '储备店长' OR position LIKE '见习经理%' OR position = '储备经理' THEN '店长'
WHEN position LIKE '副店%' THEN '副店长'
WHEN position LIKE '前厅经理%' OR position = '大堂经理' OR position = '服务主管' THEN '前厅经理'
WHEN position = '区经理' OR position LIKE '营运经理%' OR position = '营运助理' OR position LIKE '营运总监%' THEN '区经理'
WHEN position = '厨师长' OR position LIKE '厨师长%' OR position = '行政总厨' OR position LIKE '区厨%' OR position LIKE '大区总厨%' OR position LIKE '拉面区厨%' OR position LIKE '拉面总厨%' OR position = '研发总厨' OR position = '研发经理' OR position LIKE '配送中心%总厨%' OR position = '烤鸭总厨' OR position = '西餐总厨' THEN '厨师长'
WHEN position LIKE '拉面师%' OR position = '拉面' OR position LIKE '拉面主管%' OR position = '面工' OR position LIKE '面工%' OR position = '面点' OR position = '面点师' OR position = '打馕' THEN '拉面师'
WHEN position LIKE '厨师%' OR position = '炒锅' OR position = '砧板' OR position = '打荷' OR position = '上什' OR position = '蒸箱' OR position = '出品' OR position = '副厨' OR position = '明档师傅' OR position = '西餐' OR position = '锅底' THEN '厨师'
WHEN position LIKE '配菜师%' OR position = '配菜师' OR position = '切菜师' OR position LIKE '切菜师%' OR position = '切肉师' OR position = '砧板主管' THEN '配菜师'
WHEN position LIKE '凉菜%' OR position = '凉菜' THEN '凉菜师'
WHEN position LIKE '烧烤%' OR position = '烧烤师' OR position LIKE '烧烤工%' OR position = '烧烤师傅' OR position = '烤鸭师' THEN '烧烤师'
WHEN position LIKE '服务员%' OR position = '传菜员' OR position = '迎宾员' OR position = '吧员' THEN '服务员'
WHEN position = '收银员' OR position = '出纳主管' OR position LIKE '%出纳%' THEN '收银员'
WHEN position = '训练员' OR position LIKE '训练员%' OR position = '训练经理' THEN '训练员'
WHEN position LIKE '厨工%' OR position = '兼职工' OR position = '非全兼职工' OR position = '小时工' OR position = '计时工' THEN '厨工'
WHEN position = '保洁员' OR position = '保洁' THEN '保洁'
WHEN position = '洗碗' THEN '洗碗工'
WHEN position LIKE '%组员%' OR position LIKE '%组长%' THEN '中央厨房工'
WHEN position = '库房组员' OR position = '库房专员' OR position = '库房资深专员' THEN '库管'
WHEN position LIKE '副总%' OR position LIKE '执行总裁%' OR position = '首席营销官%' OR position LIKE '%总监%' OR position LIKE '%经理%' OR position LIKE '%主管%' OR position LIKE '%专员%' OR position LIKE '%助理%' OR position = '主管' THEN '管理岗'
ELSE '其他'
END AS standard_position
FROM public.salary_detail_records
WHERE position IS NOT NULL AND position != '';
-- ============================================================
-- 7. 补录 dim_store 缺失的 region
-- ============================================================
UPDATE analytics.dim_store SET region = '未知区域' WHERE region IS NULL OR region = '';
-- ============================================================
-- 验证结果
-- ============================================================
SELECT 'dim_member' AS table_name, count(*) AS row_count FROM analytics.dim_member
UNION ALL SELECT 'dim_employee', count(*) FROM analytics.dim_employee
UNION ALL SELECT 'dim_channel', count(*) FROM analytics.dim_channel
UNION ALL SELECT 'dim_sku', count(*) FROM analytics.dim_sku
UNION ALL SELECT 'v_member_level_mapping', count(*) FROM analytics.v_member_level_mapping
UNION ALL SELECT 'v_position_mapping', count(*) FROM analytics.v_position_mapping;
-- dim_member 状态分布
SELECT 'dim_member_status' AS check_name, status, count(*) AS cnt
FROM analytics.dim_member GROUP BY status
UNION ALL
SELECT 'dim_member_level', member_level, count(*)
FROM analytics.dim_member GROUP BY member_level
UNION ALL
SELECT 'dim_employee_status', status, count(*)
FROM analytics.dim_employee GROUP BY status
UNION ALL
SELECT 'dim_employee_position', position, count(*)
FROM analytics.dim_employee GROUP BY position
ORDER BY 1, 3 DESC;