{/* 经营概览 */}
diff --git a/db/etl_batch1_fill_dims.sql b/db/etl_batch1_fill_dims.sql
new file mode 100644
index 0000000..82fc2f8
--- /dev/null
+++ b/db/etl_batch1_fill_dims.sql
@@ -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;
diff --git a/db/etl_dim_employee.sql b/db/etl_dim_employee.sql
new file mode 100644
index 0000000..cc27440
--- /dev/null
+++ b/db/etl_dim_employee.sql
@@ -0,0 +1,33 @@
+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,
+ 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 LIKE '%总监%' OR position LIKE '%经理%' OR position LIKE '%主管%' OR position LIKE '%专员%' OR position LIKE '%助理%' OR position = '主管' THEN '管理岗'
+ ELSE '其他'
+ END,
+ NULL,
+ 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,
+ 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,
+ CASE WHEN leave_date = '0' OR leave_date IS NULL OR leave_date = '' THEN '在职' ELSE '离职' END,
+ now(), now()
+FROM public.salary_detail_records
+ORDER BY employee_code, leave_date DESC NULLS LAST;
diff --git a/db/etl_store_target.sql b/db/etl_store_target.sql
new file mode 100644
index 0000000..89a0e3a
--- /dev/null
+++ b/db/etl_store_target.sql
@@ -0,0 +1,67 @@
+-- ============================================================
+-- 门店月度目标表 ETL
+-- 基于历史实际收入自动生成基准目标
+-- 策略:用最近完整月份的实际收入作为下月目标基准,按区域增长率微调
+-- ============================================================
+
+-- 1. 创建目标表(如果不存在)
+CREATE TABLE IF NOT EXISTS analytics.dim_store_target (
+ id SERIAL PRIMARY KEY,
+ store_code VARCHAR(20) NOT NULL,
+ target_month DATE NOT NULL,
+ revenue_target NUMERIC(12,2) NOT NULL,
+ bill_count_target INTEGER,
+ avg_bill_value_target NUMERIC(8,2),
+ member_penetration_target NUMERIC(5,2), -- 百分比
+ cost_rate_target NUMERIC(5,2), -- 百分比
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(store_code, target_month)
+);
+
+-- 2. 生成2026-05目标(基于4月实际 × 增长系数1.03)
+INSERT INTO analytics.dim_store_target (store_code, target_month, revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target)
+SELECT
+ r.store_code,
+ '2026-05-01'::date,
+ round(r.received * 1.03, 2) as revenue_target,
+ round(r.bill_count * 1.03) as bill_count_target,
+ round(r.avg_bill_value, 2) as avg_bill_value_target,
+ round(r.member_bill_share_pct, 2) as member_penetration_target,
+ 30.00 as cost_rate_target
+FROM analytics.mv_store_risk_rating_monthly r
+WHERE r.month_start = '2026-04-01'
+ON CONFLICT (store_code, target_month) DO UPDATE SET
+ revenue_target = EXCLUDED.revenue_target,
+ bill_count_target = EXCLUDED.bill_count_target,
+ avg_bill_value_target = EXCLUDED.avg_bill_value_target,
+ member_penetration_target = EXCLUDED.member_penetration_target,
+ cost_rate_target = EXCLUDED.cost_rate_target,
+ updated_at = NOW();
+
+-- 3. 生成2026-04目标(用4月实际作为回顾目标,方便展示达成率)
+INSERT INTO analytics.dim_store_target (store_code, target_month, revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target)
+SELECT
+ r.store_code,
+ '2026-04-01'::date,
+ round(r.received, 2) as revenue_target,
+ r.bill_count as bill_count_target,
+ round(r.avg_bill_value, 2) as avg_bill_value_target,
+ round(r.member_bill_share_pct, 2) as member_penetration_target,
+ 30.00 as cost_rate_target
+FROM analytics.mv_store_risk_rating_monthly r
+WHERE r.month_start = '2026-04-01'
+ON CONFLICT (store_code, target_month) DO UPDATE SET
+ revenue_target = EXCLUDED.revenue_target,
+ bill_count_target = EXCLUDED.bill_count_target,
+ avg_bill_value_target = EXCLUDED.avg_bill_value_target,
+ member_penetration_target = EXCLUDED.member_penetration_target,
+ cost_rate_target = EXCLUDED.cost_rate_target,
+ updated_at = NOW();
+
+-- 4. 验证
+SELECT '2026-04' as month, count(*) as stores, round(sum(revenue_target)::numeric, 2) as total_target
+FROM analytics.dim_store_target WHERE target_month = '2026-04-01'
+UNION ALL
+SELECT '2026-05', count(*), round(sum(revenue_target)::numeric, 2)
+FROM analytics.dim_store_target WHERE target_month = '2026-05-01';
diff --git a/run.md b/run.md
index ebc7491..1dbeb0f 100644
--- a/run.md
+++ b/run.md
@@ -21,10 +21,23 @@ cd server && npm run dev
### 2. 启动反向隧道(线上前端访问本地后端)
-线上 Nginx 将 `/api/` 代理到远程 `127.0.0.1:13333`,通过反向 SSH 隧道转发到本地后端:
+线上 Nginx 将 `/api/` 代理到远程 `127.0.0.1:13333`,通过 frp tcp 代理转发到本地后端 3333 端口:
+
+frpc 配置(`/Users/freedak/frp/frpc.toml`)中已包含 dm-api 代理:
+
+```toml
+[[proxies]]
+name = "dm-api"
+type = "tcp"
+localIP = "127.0.0.1"
+localPort = 3333
+remotePort = 13333
+```
+
+重启 frpc 即可:
```bash
-sshpass -p 'Why_701208' ssh -o StrictHostKeyChecking=accept-new -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -N -R 13333:localhost:3333 ubuntu@dm.all8ai.top
+kill -9 $(pgrep frpc); cd /Users/freedak/frp && ./frpc -c frpc.toml &
```
### 3. 访问
diff --git a/server/src/index.ts b/server/src/index.ts
index 3de85b5..de2e146 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -10,6 +10,7 @@ import costAnalysisRoutes from './routes/cost-analysis.js'
import storeExpenseRoutes from './routes/store-expense.js'
import smartSchedulingRoutes from './routes/smart-scheduling.js'
import situationalAwarenessRoutes from './routes/situational-awareness.js'
+import analyticsEnhancedRoutes from './routes/analytics-enhanced.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3333')
@@ -39,6 +40,7 @@ app.use('/api/cost-analysis', costAnalysisRoutes)
app.use('/api/store-expense', storeExpenseRoutes)
app.use('/api/smart-scheduling', smartSchedulingRoutes)
app.use('/api/situational-awareness', situationalAwarenessRoutes)
+app.use('/api/analytics-enhanced', analyticsEnhancedRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
diff --git a/server/src/routes/analytics-enhanced.ts b/server/src/routes/analytics-enhanced.ts
new file mode 100644
index 0000000..0e0c24b
--- /dev/null
+++ b/server/src/routes/analytics-enhanced.ts
@@ -0,0 +1,843 @@
+import { Router } from 'express'
+import { query } from '../config/database.js'
+import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js'
+import type { AuthRequest } from '../middleware/auth.js'
+
+const router = Router()
+
+// ============================================================
+// 1. 会员LTV与分层
+// ============================================================
+router.get('/member/ltv', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+ const { page, pageSize, offset } = parsePagination(req)
+ const status = (req.query.status as string) || ''
+ const level = (req.query.level as string) || ''
+
+ let whereClause = 'WHERE 1=1'
+ const params: any[] = []
+ if (status) {
+ params.push(status)
+ whereClause += ` AND dm.status = $${params.length}`
+ }
+ if (level) {
+ params.push(level)
+ whereClause += ` AND dm.member_level = $${params.length}`
+ }
+
+ const countResult = await query(`SELECT count(*) as total FROM analytics.dim_member dm ${whereClause}`, params)
+ const total = Number(countResult.rows[0].total)
+
+ params.push(pageSize, offset)
+ const result = await query(`
+ SELECT dm.member_id, dm.register_channel, dm.register_store, ds.store_name as register_store_name,
+ dm.register_date, dm.member_level, dm.total_orders, round(dm.total_revenue::numeric, 2) as total_revenue,
+ dm.last_order_date, dm.status, dm.tags
+ FROM analytics.dim_member dm
+ LEFT JOIN analytics.dim_store ds ON dm.register_store = ds.store_code
+ ${whereClause}
+ ORDER BY dm.total_revenue DESC NULLS LAST
+ LIMIT $${params.length - 1} OFFSET $${params.length}
+ `, params)
+
+ // 汇总统计
+ const summaryResult = await query(`
+ SELECT
+ count(*) as total_members,
+ count(*) FILTER (WHERE status = '活跃') as active_count,
+ count(*) FILTER (WHERE status = '沉睡') as dormant_count,
+ count(*) FILTER (WHERE status = '流失') as churned_count,
+ round(avg(total_revenue)::numeric, 2) as avg_ltv,
+ round(sum(total_revenue)::numeric, 2) as total_revenue,
+ round(avg(total_orders)::numeric, 1) as avg_orders
+ FROM analytics.dim_member
+ `)
+
+ // 等级分布
+ const levelDist = await query(`
+ SELECT member_level, count(*) as count,
+ round(avg(total_revenue)::numeric, 2) as avg_revenue,
+ round(avg(total_orders)::numeric, 1) as avg_orders
+ FROM analytics.dim_member GROUP BY member_level ORDER BY
+ CASE member_level WHEN '钻石' THEN 1 WHEN '金卡' THEN 2 WHEN '银卡' THEN 3 WHEN '普通' THEN 4 ELSE 5 END
+ `)
+
+ sendSuccess(res, {
+ summary: summaryResult.rows[0],
+ level_distribution: levelDist.rows,
+ members: result.rows,
+ }, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 2. 菜单工程行动清单
+// ============================================================
+router.get('/menu-engineering/actions', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+
+ const result = await query(`
+ SELECT
+ dish_name,
+ abc_class,
+ received_amount as revenue,
+ bill_count as order_count,
+ realized_unit_price as avg_price,
+ category_level1 as category_l1,
+ category_level2 as category_l2,
+ CASE
+ WHEN abc_class LIKE 'A%' AND bill_count > 0 THEN '保留并推广'
+ WHEN abc_class LIKE 'A%' AND bill_count <= 0 THEN '调查停售原因'
+ WHEN abc_class LIKE 'B%' THEN '优化提升'
+ WHEN abc_class LIKE 'C%' AND received_amount < 1000 THEN '考虑淘汰'
+ WHEN abc_class LIKE 'C%' THEN '降本或提价'
+ ELSE '观察'
+ END as action,
+ CASE
+ WHEN abc_class LIKE 'A%' AND realized_unit_price > 0 THEN '高人气高收入,维持品质,可考虑提价测试'
+ WHEN abc_class LIKE 'B%' AND bill_count > 50 THEN '有潜力,优化呈现和推荐话术'
+ WHEN abc_class LIKE 'C%' AND received_amount < 500 THEN '长尾SKU,建议下架或季节性供应'
+ WHEN abc_class LIKE 'C%' THEN '收入偏低,尝试降本或搭配套餐'
+ ELSE '持续观察'
+ END as suggestion
+ FROM analytics.mv_dish_sku_abc_monthly
+ WHERE month_start = $1
+ ORDER BY
+ CASE WHEN abc_class LIKE 'A%' THEN 1 WHEN abc_class LIKE 'B%' THEN 2 WHEN abc_class LIKE 'C%' THEN 3 ELSE 4 END,
+ received_amount DESC NULLS LAST
+ `, [month])
+
+ // 汇总
+ const summary = {
+ total_skus: result.rows.length,
+ class_a: result.rows.filter((r: any) => r.abc_class?.startsWith('A')).length,
+ class_b: result.rows.filter((r: any) => r.abc_class?.startsWith('B')).length,
+ class_c: result.rows.filter((r: any) => r.abc_class?.startsWith('C')).length,
+ recommend_eliminate: result.rows.filter((r: any) => r.action === '考虑淘汰').length,
+ recommend_promote: result.rows.filter((r: any) => r.action === '保留并推广').length,
+ }
+
+ sendSuccess(res, { summary, actions: result.rows })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 3. 菜品搭配分析(套餐推荐)
+// ============================================================
+router.get('/dish-pair/recommendations', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+ const { page, pageSize, offset } = parsePagination(req)
+ const minPairCount = parseInt((req.query.min_count as string) || '10')
+
+ const countResult = await query(`
+ SELECT count(*) as total FROM analytics.mv_dish_pair_summary_monthly
+ WHERE month_start = $1 AND pair_count >= $2
+ `, [month, minPairCount])
+ const total = Number(countResult.rows[0].total)
+
+ const result = await query(`
+ SELECT
+ dish_a, dish_b, pair_count,
+ dish_a_revenue, dish_b_revenue,
+ combined_revenue,
+ round(pair_count::numeric / NULLIF((SELECT max(pair_count) FROM analytics.mv_dish_pair_summary_monthly WHERE month_start = $1), 0) * 100, 1) as affinity_pct
+ FROM analytics.mv_dish_pair_summary_monthly
+ WHERE month_start = $1 AND pair_count >= $2
+ ORDER BY pair_count DESC
+ LIMIT $3 OFFSET $4
+ `, [month, minPairCount, pageSize, offset])
+
+ sendSuccess(res, result.rows, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 4. 区域间对比排名
+// ============================================================
+router.get('/region/comparison', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+
+ const result = await query(`
+ WITH store_metrics AS (
+ SELECT
+ ds.region,
+ count(DISTINCT r.store_code) as store_count,
+ sum(r.received) as total_revenue,
+ round(avg(r.avg_bill_value)::numeric, 2) as avg_bill_value,
+ sum(r.bill_count) as bill_count,
+ round(avg(r.theoretical_margin_pct)::numeric, 2) as avg_margin,
+ round(sum(r.bill_count * r.member_bill_share_pct / 100)::numeric, 0) as member_count
+ FROM analytics.mv_store_risk_rating_monthly r
+ JOIN analytics.dim_store ds ON r.store_code = ds.store_code
+ WHERE r.month_start = $1
+ AND ds.region IS NOT NULL AND ds.region != '未知区域'
+ GROUP BY ds.region
+ ),
+ expense_metrics AS (
+ SELECT
+ ds.region,
+ avg(oe.operating_expense_rate_pct) as avg_expense_rate,
+ avg(oe.rent_rate_pct) as avg_rent_rate,
+ avg(oe.wage_rate_pct) as avg_wage_rate
+ FROM analytics.mv_store_operating_expense_monthly oe
+ JOIN analytics.dim_store ds ON oe.sales_store_code = ds.store_code
+ WHERE oe.report_month = $1
+ AND ds.region IS NOT NULL AND ds.region != '未知区域'
+ GROUP BY ds.region
+ )
+ SELECT
+ sm.region,
+ sm.store_count,
+ round(sm.total_revenue::numeric, 2) as total_revenue,
+ round((sm.total_revenue / sm.store_count)::numeric, 2) as revenue_per_store,
+ round(sm.avg_bill_value::numeric, 2) as avg_bill_value,
+ sm.bill_count,
+ round(sm.avg_margin::numeric, 2) as avg_margin_pct,
+ sm.member_count,
+ round((sm.member_count::numeric / NULLIF(sm.bill_count, 0) * 100)::numeric, 1) as member_penetration_pct,
+ round(em.avg_expense_rate::numeric, 2) as avg_expense_rate,
+ round(em.avg_rent_rate::numeric, 2) as avg_rent_rate,
+ round(em.avg_wage_rate::numeric, 2) as avg_wage_rate
+ FROM store_metrics sm
+ LEFT JOIN expense_metrics em ON sm.region = em.region
+ ORDER BY sm.total_revenue DESC
+ `, [month])
+
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 5. 区域KPI达成预测
+// ============================================================
+router.get('/region/kpi-forecast', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+
+ const result = await query(`
+ WITH region_actual AS (
+ SELECT
+ ds.region,
+ round(sum(r.received)::numeric, 2) as actual_revenue,
+ sum(r.bill_count) as actual_bills,
+ max(r.active_days) as elapsed_days,
+ round(sum(r.received) / NULLIF(max(r.active_days), 0)::numeric, 2) as avg_daily_revenue
+ FROM analytics.mv_store_risk_rating_monthly r
+ JOIN analytics.dim_store ds ON r.store_code = ds.store_code
+ WHERE r.month_start = $1
+ AND ds.region IS NOT NULL AND ds.region != '未知区域'
+ GROUP BY ds.region
+ ),
+ region_target AS (
+ SELECT
+ ds.region,
+ round(sum(t.revenue_target)::numeric, 2) as target_revenue,
+ round(sum(t.bill_count_target)::numeric, 0) as target_bills
+ FROM analytics.dim_store_target t
+ JOIN analytics.dim_store ds ON t.store_code = ds.store_code
+ WHERE t.target_month = $1
+ AND ds.region IS NOT NULL AND ds.region != '未知区域'
+ GROUP BY ds.region
+ ),
+ calendar AS (
+ SELECT count(*) as total_days
+ FROM analytics.dim_calendar
+ WHERE date_value >= $1::date AND date_value < $1::date + interval '1 month'
+ AND is_operating_day = true
+ )
+ SELECT
+ ra.region,
+ ra.actual_revenue,
+ rt.target_revenue,
+ ra.actual_bills,
+ rt.target_bills,
+ ra.elapsed_days,
+ c.total_days as working_days,
+ ra.avg_daily_revenue,
+ round((ra.avg_daily_revenue * c.total_days)::numeric, 2) as forecast_revenue,
+ round((ra.actual_revenue / NULLIF(rt.target_revenue, 0) * 100)::numeric, 1) as achievement_pct,
+ round((ra.actual_bills / NULLIF(rt.target_bills, 0) * 100)::numeric, 1) as bill_achievement_pct
+ FROM region_actual ra
+ LEFT JOIN region_target rt ON ra.region = rt.region
+ CROSS JOIN calendar c
+ ORDER BY ra.actual_revenue DESC
+ `, [month])
+
+ sendSuccess(res, result.rows)
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 6. 每日销售目标分解
+// ============================================================
+router.get('/store/daily-target', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+ const storeCode = (req.query.store_code as string) || ''
+
+ if (!storeCode) {
+ return sendError(res, 'store_code is required')
+ }
+
+ // 该门店历史日均收入(按餐段)
+ const mealPeriodStats = await query(`
+ SELECT
+ meal_period,
+ round(avg(daily_revenue)::numeric, 2) as avg_revenue,
+ round(avg(bill_count)::numeric, 0) as avg_bills,
+ round(avg(avg_bill_value)::numeric, 2) as avg_bill_value
+ FROM (
+ SELECT
+ meal_period,
+ date_trunc('day', opened_at)::date as day,
+ sum(received_total) as daily_revenue,
+ count(*) as bill_count,
+ round(avg(received_total)::numeric, 2) as avg_bill_value
+ FROM analytics.bill_fact
+ WHERE store_code = $1
+ AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
+ GROUP BY meal_period, date_trunc('day', opened_at)::date
+ ) t
+ GROUP BY meal_period
+ ORDER BY
+ CASE meal_period WHEN '早市' THEN 1 WHEN '午市' THEN 2 WHEN '下午茶' THEN 3 WHEN '晚市' THEN 4 WHEN '夜宵' THEN 5 ELSE 6 END
+ `, [storeCode, month])
+
+ // 该门店历史日均收入(按星期)
+ const weekdayStats = await query(`
+ SELECT
+ extract(dow from opened_at)::int as weekday,
+ round(avg(daily_revenue)::numeric, 2) as avg_revenue,
+ round(avg(bill_count)::numeric, 0) as avg_bills
+ FROM (
+ SELECT
+ date_trunc('day', opened_at)::date as day,
+ extract(dow from opened_at)::int as weekday,
+ sum(received_total) as daily_revenue,
+ count(*) as bill_count
+ FROM analytics.bill_fact
+ WHERE store_code = $1
+ AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
+ GROUP BY date_trunc('day', opened_at)::date, extract(dow from opened_at)::int
+ ) t
+ GROUP by weekday
+ ORDER BY weekday
+ `, [storeCode, month])
+
+ // 月度总目标(基于历史日均 * 当月天数)
+ const totalAvg = await query(`
+ SELECT
+ round(avg(daily_revenue)::numeric, 2) as avg_daily_revenue,
+ count(DISTINCT date_trunc('day', opened_at)::date) as active_days,
+ round(sum(daily_revenue)::numeric, 2) as total_revenue
+ FROM (
+ SELECT date_trunc('day', opened_at)::date as day, sum(received_total) as daily_revenue
+ FROM analytics.bill_fact
+ WHERE store_code = $1 AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
+ GROUP BY date_trunc('day', opened_at)::date
+ ) t
+ `, [storeCode, month])
+
+ const workingDays = await query(`
+ SELECT count(*) as total_days FROM analytics.dim_calendar
+ WHERE date_value >= $1::date AND date_value < $1::date + interval '1 month' AND is_operating_day = true
+ `, [month])
+
+ const avgDaily = Number(totalAvg.rows[0]?.avg_daily_revenue || 0)
+ const forecastTotal = avgDaily * Number(workingDays.rows[0]?.total_days || 30)
+
+ sendSuccess(res, {
+ monthly_target: Math.round(forecastTotal),
+ avg_daily_revenue: avgDaily,
+ meal_period_breakdown: mealPeriodStats.rows,
+ weekday_breakdown: weekdayStats.rows,
+ })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 7. 门店会员活跃度面板
+// ============================================================
+router.get('/store/member-activity', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+ const storeCode = (req.query.store_code as string) || ''
+
+ if (!storeCode) {
+ return sendError(res, 'store_code is required')
+ }
+
+ // 该门店会员活跃度
+ const memberStats = await query(`
+ WITH store_members AS (
+ SELECT
+ bf.member_id,
+ count(DISTINCT bf.bill_no) as orders,
+ sum(bf.received_total) as revenue,
+ max(bf.opened_at) as last_visit,
+ min(bf.opened_at) as first_visit
+ FROM analytics.bill_fact bf
+ WHERE bf.store_code = $1
+ AND bf.opened_at >= $2 AND bf.opened_at < $2::date + interval '1 month'
+ AND bf.member_id IS NOT NULL AND bf.member_id != ''
+ GROUP BY bf.member_id
+ )
+ SELECT
+ count(*) as total_members,
+ count(*) FILTER (WHERE orders >= 3) as frequent_members,
+ count(*) FILTER (WHERE orders = 1) as one_time_members,
+ round(avg(orders)::numeric, 1) as avg_orders,
+ round(avg(revenue)::numeric, 2) as avg_revenue,
+ round(sum(revenue)::numeric, 2) as total_revenue
+ FROM store_members
+ `, [storeCode, month])
+
+ // 会员等级分布
+ const levelDist = await query(`
+ SELECT
+ CASE
+ WHEN bf.member_level IN ('1') THEN '普通'
+ WHEN bf.member_level IN ('2','3') THEN '银卡'
+ WHEN bf.member_level IN ('4','5') THEN '金卡'
+ WHEN bf.member_level IN ('6','7','LV6','LV7') THEN '钻石'
+ ELSE '普通'
+ END as standard_level,
+ count(DISTINCT bf.member_id) as count,
+ round(sum(bf.received_total)::numeric, 2) as revenue
+ FROM analytics.bill_fact bf
+ WHERE bf.store_code = $1
+ AND bf.opened_at >= $2 AND bf.opened_at < $2::date + interval '1 month'
+ AND bf.member_id IS NOT NULL AND bf.member_id != ''
+ GROUP BY 1
+ ORDER BY
+ CASE standard_level WHEN '钻石' THEN 1 WHEN '金卡' THEN 2 WHEN '银卡' THEN 3 WHEN '普通' THEN 4 ELSE 5 END
+ `, [storeCode, month])
+
+ // 会员 vs 非会员
+ const comparison = await query(`
+ SELECT
+ CASE WHEN member_id IS NOT NULL AND member_id != '' THEN '会员' ELSE '非会员' END as customer_type,
+ count(*) as bill_count,
+ round(sum(received_total)::numeric, 2) as revenue,
+ round(avg(received_total)::numeric, 2) as avg_bill_value
+ FROM analytics.bill_fact
+ WHERE store_code = $1
+ AND opened_at >= $2 AND opened_at < $2::date + interval '1 month'
+ GROUP BY 1
+ `, [storeCode, month])
+
+ sendSuccess(res, {
+ stats: memberStats.rows[0],
+ level_distribution: levelDist.rows,
+ comparison: comparison.rows,
+ })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 8. 员工绩效分析
+// ============================================================
+router.get('/employee/performance', async (req: AuthRequest, res) => {
+ try {
+ const { page, pageSize, offset } = parsePagination(req)
+ const position = (req.query.position as string) || ''
+ const status = (req.query.status as string) || ''
+
+ let whereClause = 'WHERE 1=1'
+ const params: any[] = []
+ if (position) {
+ params.push(position)
+ whereClause += ` AND de.position = $${params.length}`
+ }
+ if (status) {
+ params.push(status)
+ whereClause += ` AND de.status = $${params.length}`
+ }
+
+ const countResult = await query(`SELECT count(*) as total FROM analytics.dim_employee ${whereClause}`, params)
+ const total = Number(countResult.rows[0].total)
+
+ params.push(pageSize, offset)
+ const result = await query(`
+ SELECT
+ de.employee_id,
+ de.position,
+ de.hire_date,
+ de.leave_date,
+ de.status,
+ s.salary_period,
+ round(s.gross_pay::numeric, 2) as gross_pay,
+ round(s.net_pay::numeric, 2) as net_pay,
+ round(s.perf_score::numeric, 2) as perf_score,
+ round(s.actual_attend::numeric, 0) as actual_attend,
+ round(s.expected_attend::numeric, 0) as expected_attend,
+ round(s.overtime_pay::numeric, 2) as overtime_pay,
+ round(s.bonus::numeric, 2) as bonus,
+ s.org_level2,
+ s.org_level3
+ FROM analytics.dim_employee de
+ LEFT JOIN public.salary_detail_records s ON de.employee_id = s.employee_code
+ ${whereClause}
+ ORDER BY s.gross_pay DESC NULLS LAST
+ LIMIT $${params.length - 1} OFFSET $${params.length}
+ `, params)
+
+ // 岗位汇总
+ const positionSummary = await query(`
+ SELECT
+ de.position,
+ count(*) as headcount,
+ count(*) FILTER (WHERE de.status = '在职') as active_count,
+ round(avg(s.gross_pay)::numeric, 2) as avg_gross_pay,
+ round(avg(s.perf_score)::numeric, 2) as avg_perf_score,
+ round(avg(s.actual_attend::numeric / NULLIF(s.expected_attend, 0) * 100)::numeric, 1) as avg_attendance_rate
+ FROM analytics.dim_employee de
+ LEFT JOIN public.salary_detail_records s ON de.employee_id = s.employee_code
+ GROUP BY de.position
+ ORDER BY count(*) DESC
+ `)
+
+ sendSuccess(res, {
+ position_summary: positionSummary.rows,
+ employees: result.rows,
+ }, { page, pageSize, total })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 9. 库存周转与损耗趋势
+// ============================================================
+router.get('/inventory/turnover', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+ const storeCode = (req.query.store_code as string) || ''
+
+ let storeFilter = ''
+ const params: any[] = [month]
+ if (storeCode) {
+ params.push(storeCode)
+ storeFilter = `AND fis.store_code = $${params.length}`
+ }
+
+ // 库存周转概览
+ const turnoverResult = await query(`
+ SELECT
+ fis.store_code,
+ ds.store_name,
+ round(sum(fis.opening_amount)::numeric, 2) as opening_value,
+ round(sum(fis.purchase_amount)::numeric, 2) as purchase_value,
+ round(sum(fis.consumption_amount)::numeric, 2) as consumption_value,
+ round(sum(fis.ending_amount)::numeric, 2) as ending_value,
+ round(sum(fis.waste_amount)::numeric, 2) as waste_value,
+ round(avg(fis.ending_amount)::numeric, 2) as avg_ending,
+ round(sum(fis.consumption_amount)::numeric / NULLIF(avg(fis.ending_amount) * count(*), 0) * 30, 1) as turnover_days
+ FROM analytics.fact_inventory_snapshot fis
+ LEFT JOIN analytics.dim_store ds ON fis.store_code = ds.store_code
+ WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
+ ${storeFilter}
+ GROUP BY fis.store_code, ds.store_name
+ ORDER BY turnover_days ASC
+ `, params)
+
+ // 损耗TOP
+ const wasteTop = await query(`
+ SELECT
+ dm.material_name,
+ round(sum(fis.waste_quantity)::numeric, 2) as waste_qty,
+ round(sum(fis.waste_amount)::numeric, 2) as waste_value,
+ count(DISTINCT fis.store_code) as affected_stores
+ FROM analytics.fact_inventory_snapshot fis
+ LEFT JOIN analytics.dim_material dm ON fis.material_code = dm.material_code
+ WHERE fis.snapshot_date >= $1 AND fis.snapshot_date < $1::date + interval '1 month'
+ AND fis.waste_amount > 0
+ ${storeFilter}
+ GROUP BY dm.material_name
+ ORDER BY waste_value DESC
+ LIMIT 20
+ `, params)
+
+ sendSuccess(res, {
+ turnover: turnoverResult.rows,
+ waste_top: wasteTop.rows,
+ })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 10. 营销ROI基础版
+// ============================================================
+router.get('/marketing/roi', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+
+ // 各营销方案的效果对比
+ const result = await query(`
+ WITH plan_stats AS (
+ SELECT
+ marketing_plan,
+ count(*) as bill_count,
+ round(sum(received_total)::numeric, 2) as total_revenue,
+ round(avg(received_total)::numeric, 2) as avg_bill_value,
+ round(avg(theoretical_margin) * 100, 2) as avg_margin,
+ round(avg(discount_total)::numeric, 2) as avg_discount,
+ round(sum(discount_total)::numeric, 2) as total_discount,
+ count(DISTINCT member_id) FILTER (WHERE member_id IS NOT NULL AND member_id != '') as member_bills
+ FROM analytics.bill_fact
+ WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
+ AND marketing_plan IS NOT NULL AND marketing_plan != ''
+ GROUP BY marketing_plan
+ ),
+ no_plan_stats AS (
+ SELECT
+ count(*) as bill_count,
+ round(avg(received_total)::numeric, 2) as avg_bill_value,
+ round(avg(theoretical_margin) * 100, 2) as avg_margin,
+ round(avg(discount_total)::numeric, 2) as avg_discount
+ FROM analytics.bill_fact
+ WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
+ AND (marketing_plan IS NULL OR marketing_plan = '')
+ )
+ SELECT
+ ps.marketing_plan,
+ ps.bill_count,
+ ps.total_revenue,
+ ps.avg_bill_value,
+ ps.avg_margin,
+ ps.total_discount,
+ ps.avg_discount,
+ ps.member_bills,
+ round((ps.member_bills::numeric / NULLIF(ps.bill_count, 0) * 100)::numeric, 1) as member_share_pct,
+ round(((ps.avg_bill_value - nps.avg_bill_value) / NULLIF(nps.avg_bill_value, 0) * 100)::numeric, 1) as bill_value_uplift_pct,
+ round((ps.avg_margin - nps.avg_margin)::numeric, 2) as margin_delta
+ FROM plan_stats ps
+ CROSS JOIN no_plan_stats nps
+ ORDER BY ps.total_revenue DESC
+ `, [month])
+
+ // 无营销方案基准
+ const baseline = await query(`
+ SELECT
+ count(*) as bill_count,
+ round(avg(received_total)::numeric, 2) as avg_bill_value,
+ round(avg(theoretical_margin) * 100, 2) as avg_margin,
+ round(avg(discount_total)::numeric, 2) as avg_discount
+ FROM analytics.bill_fact
+ WHERE opened_at >= $1 AND opened_at < $1::date + interval '1 month'
+ AND (marketing_plan IS NULL OR marketing_plan = '')
+ `, [month])
+
+ sendSuccess(res, {
+ baseline: baseline.rows[0],
+ campaigns: result.rows,
+ })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 11. 辖区巡检计划生成
+// ============================================================
+router.get('/region/inspection-plan', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+
+ const result = await query(`
+ SELECT
+ rr.store_code,
+ rr.store_name,
+ rr.risk_level,
+ rr.primary_issue,
+ rr.bill_count,
+ round(rr.received::numeric, 2) as received,
+ rr.anomaly_rate_pct,
+ rr.discount_rate_pct,
+ rr.member_bill_share_pct,
+ ds.region,
+ CASE rr.risk_level
+ WHEN '高风险' THEN 1
+ WHEN '中风险' THEN 2
+ WHEN '低风险' THEN 3
+ ELSE 4
+ END as priority,
+ CASE rr.risk_level
+ WHEN '高风险' THEN '本周必须巡检'
+ WHEN '中风险' THEN '两周内巡检'
+ WHEN '低风险' THEN '月度例行巡检'
+ ELSE '季度巡检'
+ END as inspection_frequency,
+ CASE rr.risk_level
+ WHEN '高风险' THEN '重点检查: ' || COALESCE(rr.primary_issue, '综合风险')
+ WHEN '中风险' THEN '关注: ' || COALESCE(rr.primary_issue, '常规指标')
+ ELSE '常规检查'
+ END as focus_area
+ FROM analytics.mv_store_risk_rating_monthly rr
+ LEFT JOIN analytics.dim_store ds ON rr.store_code = ds.store_code
+ WHERE rr.month_start = $1
+ ORDER BY priority, rr.received ASC
+ `, [month])
+
+ const summary = {
+ total_stores: result.rows.length,
+ high_risk: result.rows.filter((r: any) => r.risk_level === '高风险').length,
+ medium_risk: result.rows.filter((r: any) => r.risk_level === '中风险').length,
+ low_risk: result.rows.filter((r: any) => r.risk_level === '低风险').length,
+ this_week: result.rows.filter((r: any) => r.priority === 1).length,
+ }
+
+ sendSuccess(res, { summary, plan: result.rows })
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+// ============================================================
+// 12. 统一KPI达成率(支持门店/区域/总部三个维度)
+// ============================================================
+router.get('/kpi', async (req: AuthRequest, res) => {
+ try {
+ const month = parseMonth(req)
+ const level = (req.query.level as string) || 'hq' // hq | region | store
+ const region = (req.query.region as string) || ''
+ const storeCode = (req.query.store_code as string) || ''
+
+ if (level === 'store' && storeCode) {
+ // 单门店KPI
+ const result = await query(`
+ SELECT
+ r.store_code,
+ r.store_name,
+ round(r.received::numeric, 2) as actual_revenue,
+ round(r.bill_count::numeric, 0) as actual_bills,
+ t.revenue_target,
+ t.bill_count_target,
+ t.profit_target,
+ e.actual_store_contribution as actual_profit,
+ round((r.received / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct,
+ round(CASE
+ WHEN t.profit_target > 0 THEN e.actual_store_contribution / t.profit_target * 100
+ WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(e.actual_store_contribution)) / ABS(t.profit_target) * 100
+ ELSE NULL
+ END::numeric, 1) as profit_achievement_pct,
+ round((e.actual_store_contribution / NULLIF(r.received, 0) * 100)::numeric, 2) as profit_margin_pct
+ FROM analytics.mv_store_risk_rating_monthly r
+ LEFT JOIN analytics.dim_store_target t ON r.store_code = t.store_code AND t.target_month = $1
+ LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
+ WHERE r.month_start = $1 AND r.store_code = $2
+ `, [month, storeCode])
+ sendSuccess(res, result.rows[0] || {})
+ } else if (level === 'region') {
+ // 按区域汇总KPI
+ const result = await query(`
+ WITH actual AS (
+ SELECT
+ ds.region,
+ round(sum(r.received)::numeric, 2) as actual_revenue,
+ sum(r.bill_count) as actual_bills,
+ round(sum(e.actual_store_contribution)::numeric, 2) as actual_profit
+ FROM analytics.mv_store_risk_rating_monthly r
+ JOIN analytics.dim_store ds ON r.store_code = ds.store_code
+ LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
+ WHERE r.month_start = $1
+ AND ds.region IS NOT NULL AND ds.region != '未知区域'
+ GROUP BY ds.region
+ ),
+ target AS (
+ SELECT
+ ds.region,
+ round(sum(t.revenue_target)::numeric, 2) as revenue_target,
+ round(sum(t.bill_count_target)::numeric, 0) as bill_count_target,
+ round(sum(t.profit_target)::numeric, 2) as profit_target
+ FROM analytics.dim_store_target t
+ JOIN analytics.dim_store ds ON t.store_code = ds.store_code
+ WHERE t.target_month = $1
+ AND ds.region IS NOT NULL AND ds.region != '未知区域'
+ GROUP BY ds.region
+ )
+ SELECT
+ a.region,
+ a.actual_revenue,
+ a.actual_bills,
+ a.actual_profit,
+ t.revenue_target,
+ t.bill_count_target,
+ t.profit_target,
+ round((a.actual_revenue / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct,
+ round(CASE
+ WHEN t.profit_target > 0 THEN a.actual_profit / t.profit_target * 100
+ WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(a.actual_profit)) / ABS(t.profit_target) * 100
+ ELSE NULL
+ END::numeric, 1) as profit_achievement_pct,
+ round((a.actual_profit / NULLIF(a.actual_revenue, 0) * 100)::numeric, 2) as profit_margin_pct
+ FROM actual a
+ LEFT JOIN target t ON a.region = t.region
+ ORDER BY a.actual_revenue DESC
+ `, [month])
+ sendSuccess(res, result.rows)
+ } else {
+ // 总部汇总KPI
+ const result = await query(`
+ WITH actual AS (
+ SELECT
+ round(sum(r.received)::numeric, 2) as actual_revenue,
+ sum(r.bill_count) as actual_bills,
+ round(sum(e.actual_store_contribution)::numeric, 2) as actual_profit
+ FROM analytics.mv_store_risk_rating_monthly r
+ LEFT JOIN analytics.mv_store_operating_expense_monthly e ON r.store_code = e.sales_store_code AND e.report_month = $1
+ WHERE r.month_start = $1
+ ),
+ target AS (
+ SELECT
+ round(sum(t.revenue_target)::numeric, 2) as revenue_target,
+ round(sum(t.bill_count_target)::numeric, 0) as bill_count_target,
+ round(sum(t.profit_target)::numeric, 2) as profit_target
+ FROM analytics.dim_store_target t
+ WHERE t.target_month = $1
+ )
+ SELECT
+ a.actual_revenue,
+ a.actual_bills,
+ a.actual_profit,
+ t.revenue_target,
+ t.bill_count_target,
+ t.profit_target,
+ round((a.actual_revenue / NULLIF(t.revenue_target, 0) * 100)::numeric, 1) as revenue_achievement_pct,
+ round(CASE
+ WHEN t.profit_target > 0 THEN a.actual_profit / t.profit_target * 100
+ WHEN t.profit_target < 0 THEN (2 * ABS(t.profit_target) - ABS(a.actual_profit)) / ABS(t.profit_target) * 100
+ ELSE NULL
+ END::numeric, 1) as profit_achievement_pct,
+ round((a.actual_profit / NULLIF(a.actual_revenue, 0) * 100)::numeric, 2) as profit_margin_pct
+ FROM actual a
+ CROSS JOIN target t
+ `, [month])
+ sendSuccess(res, result.rows[0] || {})
+ }
+ } catch (err: any) {
+ sendError(res, err.message)
+ }
+})
+
+export default router