-- ============================================================ -- 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;