初始化:连锁餐饮数字化运营管理平台

This commit is contained in:
freedakgmail
2026-07-26 22:48:08 +08:00
commit a7874d79b5
67 changed files with 14391 additions and 0 deletions
+1840
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "sbrain-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "tsx src/tests/run-tests.ts"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.10",
"@types/pg": "^8.11.6",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
}
}
+181
View File
@@ -0,0 +1,181 @@
-- ============================================================
-- 01_create_tables.sql
-- 任务闭环系统:表结构创建
-- ============================================================
-- 门店整改任务表
CREATE TABLE IF NOT EXISTS analytics.store_task (
task_id SERIAL PRIMARY KEY,
plan_month DATE NOT NULL,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
priority TEXT NOT NULL,
problem_indicator TEXT NOT NULL,
current_value NUMERIC,
benchmark_value NUMERIC,
target_value NUMERIC,
problem_description TEXT,
action_required TEXT,
owner TEXT NOT NULL,
collaborators TEXT,
deadline DATE NOT NULL,
status TEXT DEFAULT '待启动',
process_evidence TEXT,
verification_indicator TEXT,
verification_result TEXT,
incomplete_reason TEXT,
next_step TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 任务状态变更日志
CREATE TABLE IF NOT EXISTS analytics.store_task_log (
log_id SERIAL PRIMARY KEY,
task_id INT REFERENCES analytics.store_task(task_id) ON DELETE CASCADE,
action TEXT NOT NULL,
old_status TEXT,
new_status TEXT,
operator TEXT NOT NULL,
comment TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 任务模板表
CREATE TABLE IF NOT EXISTS analytics.task_template (
template_id SERIAL PRIMARY KEY,
problem_type TEXT NOT NULL UNIQUE,
problem_indicator TEXT NOT NULL,
default_action TEXT NOT NULL,
default_target_adjustment TEXT,
verification_indicator TEXT,
suggested_deadline_days INT DEFAULT 30
);
-- 指标字典表
CREATE TABLE IF NOT EXISTS analytics.indicator_dictionary (
id SERIAL PRIMARY KEY,
indicator_name TEXT NOT NULL UNIQUE,
business_definition TEXT NOT NULL,
formula TEXT NOT NULL,
data_source TEXT NOT NULL,
update_frequency TEXT NOT NULL,
owner TEXT,
scope TEXT,
yellow_threshold NUMERIC,
red_threshold NUMERIC,
version_date DATE NOT NULL DEFAULT CURRENT_DATE
);
-- 门店主数据表
CREATE TABLE IF NOT EXISTS analytics.dim_store (
store_code TEXT PRIMARY KEY,
store_name TEXT NOT NULL,
region TEXT,
business_type TEXT,
open_date DATE,
close_date DATE,
area_sqm NUMERIC,
seat_count INT,
business_area TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 经验标准化表
CREATE TABLE IF NOT EXISTS analytics.standardized_practice (
id SERIAL PRIMARY KEY,
practice_module TEXT NOT NULL,
benchmark_store_code TEXT NOT NULL,
benchmark_store_name TEXT NOT NULL,
key_actions TEXT NOT NULL,
verification_indicators TEXT,
status TEXT DEFAULT '待推广',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 经验推广试点结果表
CREATE TABLE IF NOT EXISTS analytics.practice_replication (
id SERIAL PRIMARY KEY,
practice_id INT REFERENCES analytics.standardized_practice(id) ON DELETE CASCADE,
trial_store_code TEXT NOT NULL,
trial_store_name TEXT NOT NULL,
observation_weeks INT DEFAULT 4,
before_value NUMERIC,
after_value NUMERIC,
revenue_impacted BOOLEAN DEFAULT FALSE,
customer_impacted BOOLEAN DEFAULT FALSE,
inventory_impacted BOOLEAN DEFAULT FALSE,
status TEXT DEFAULT '观察中',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 门店升降级日志表
CREATE TABLE IF NOT EXISTS analytics.store_grade_change (
id SERIAL PRIMARY KEY,
change_month DATE NOT NULL,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
old_priority TEXT,
new_priority TEXT,
change_type TEXT,
reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 周度检查记录表
CREATE TABLE IF NOT EXISTS analytics.task_weekly_check (
id SERIAL PRIMARY KEY,
task_id INT REFERENCES analytics.store_task(task_id) ON DELETE CASCADE,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
problem_indicator TEXT NOT NULL,
iso_week INT NOT NULL,
this_week_value NUMERIC,
last_week_value NUMERIC,
change_direction TEXT,
consecutive_no_improve_weeks INT DEFAULT 0,
check_comment TEXT,
checked_by TEXT,
checked_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(task_id, iso_week)
);
-- 月度验收记录表
CREATE TABLE IF NOT EXISTS analytics.task_monthly_review (
id SERIAL PRIMARY KEY,
task_id INT REFERENCES analytics.store_task(task_id) ON DELETE CASCADE,
store_code TEXT NOT NULL,
store_name TEXT NOT NULL,
plan_month DATE NOT NULL,
problem_indicator TEXT NOT NULL,
baseline_value NUMERIC,
target_value NUMERIC,
actual_value NUMERIC,
review_result TEXT NOT NULL,
revenue_stable BOOLEAN DEFAULT FALSE,
margin_improved BOOLEAN DEFAULT FALSE,
customer_stable BOOLEAN DEFAULT FALSE,
anomaly_decreased BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(task_id)
);
-- 数据刷新日志表
CREATE TABLE IF NOT EXISTS analytics.data_refresh_log (
id SERIAL PRIMARY KEY,
refresh_month DATE NOT NULL,
action TEXT NOT NULL,
status TEXT NOT NULL,
details TEXT,
operator TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_store_task_month ON analytics.store_task(plan_month);
CREATE INDEX IF NOT EXISTS idx_store_task_store ON analytics.store_task(store_code);
CREATE INDEX IF NOT EXISTS idx_store_task_priority ON analytics.store_task(priority);
CREATE INDEX IF NOT EXISTS idx_store_task_status ON analytics.store_task(status);
CREATE INDEX IF NOT EXISTS idx_store_task_log_task ON analytics.store_task_log(task_id);
CREATE INDEX IF NOT EXISTS idx_task_weekly_check_task ON analytics.task_weekly_check(task_id);
CREATE INDEX IF NOT EXISTS idx_task_monthly_review_task ON analytics.task_monthly_review(task_id);
+199
View File
@@ -0,0 +1,199 @@
-- ============================================================
-- 02_create_functions.sql
-- 自动分级函数 + 任务自动生成函数 + 通知推送函数
-- ============================================================
-- 自动分级函数:基于 v_store_action_priority_deep_april 逻辑
-- 输入月份参数,输出门店分级结果
CREATE OR REPLACE FUNCTION analytics.f_auto_grade_stores(p_month DATE)
RETURNS TABLE(
store_code TEXT,
store_name TEXT,
priority TEXT,
problem_count INT,
problem_combination TEXT,
received NUMERIC,
scale_tier TEXT,
business_type TEXT
) AS $$
BEGIN
-- 当前仅4月数据可用,直接查询现有视图
-- 后续月份需先刷新物化视图再查询
RETURN QUERY
SELECT v.store_code, v.store_name, v.action_priority AS priority,
v.problem_count, v.problem_combination,
v.received, v.scale_tier, v.business_type
FROM analytics.v_store_action_priority_deep_april v
ORDER BY CASE v.action_priority
WHEN 'P0-修复数据口径' THEN 1
WHEN 'P0-综合专项整改' THEN 2
WHEN 'P1-重点整改' THEN 3
WHEN 'P2-单项改善' THEN 4
WHEN '标杆候选' THEN 5
ELSE 6
END, v.received DESC;
END;
$$ LANGUAGE plpgsql STABLE;
-- 任务自动生成函数:按分级结果自动生成门店任务
-- 每店最多2个核心指标,P0门店可增加数据修复任务
CREATE OR REPLACE FUNCTION analytics.f_generate_store_tasks(p_month DATE)
RETURNS TABLE(generated INT, message TEXT) AS $$
DECLARE
v_count INT := 0;
v_store RECORD;
v_task1_indicator TEXT;
v_task1_action TEXT;
v_task1_target TEXT;
v_task2_indicator TEXT;
v_task2_action TEXT;
v_task2_target TEXT;
v_deadline DATE;
BEGIN
v_deadline := (p_month + INTERVAL '1 month' - INTERVAL '1 day')::date;
FOR v_store IN
SELECT * FROM analytics.f_auto_grade_stores(p_month)
LOOP
-- 根据问题组合生成任务1
v_task1_indicator := NULL;
v_task1_action := NULL;
v_task1_target := NULL;
-- 根据问题组合确定第一个任务
IF v_store.problem_combination LIKE '%优惠偏高%' THEN
v_task1_indicator := '优惠率';
v_task1_action := '拆解平台折扣和营销方案,每周复核高折扣账单,退出低毛利满减商品';
v_task1_target := '优惠率降低2个百分点';
ELSIF v_store.problem_combination LIKE '%实际成本严重超耗%' THEN
v_task1_indicator := '实际成本率';
v_task1_action := '获取SKU成本,分析超耗原料,调整低毛利商品和套餐';
v_task1_target := '实际成本率降低至理论+10%以内';
ELSIF v_store.problem_combination LIKE '%理论毛利偏低%' THEN
v_task1_indicator := '理论毛利率';
v_task1_action := '分析商品结构,提升高毛利品类占比,优化套餐组合';
v_task1_target := '理论毛利率提升1个百分点';
ELSIF v_store.problem_combination LIKE '%会员复购偏低%' THEN
v_task1_indicator := '会员复购率';
v_task1_action := '执行消费后第3天和第7天触达,使用会员价和复购券';
v_task1_target := '复购率提升5个百分点';
ELSIF v_store.problem_combination LIKE '%异常%' THEN
v_task1_indicator := '异常率';
v_task1_action := '全额优惠必须填写原因和审批人,周度抽查收银员及营销方案';
v_task1_target := '异常率降低至1.5%以下';
ELSIF v_store.problem_combination LIKE '%饮品搭售偏低%' THEN
v_task1_indicator := '饮品搭售率';
v_task1_action := '按午晚市设计主食加饮品组合销售,培训搭售话术';
v_task1_target := '饮品搭售率提升5个百分点';
ELSIF v_store.problem_combination LIKE '%平台成本偏高%' THEN
v_task1_indicator := '平台加权成本率';
v_task1_action := '设置平台成本率红线,优化满减和折扣策略';
v_task1_target := '平台成本率降低1个百分点';
ELSIF v_store.priority = '标杆候选' THEN
v_task1_indicator := '经验输出';
v_task1_action := '总结核心SKU结构、会员运营、平台价格纪律等可推广经验';
v_task1_target := '输出至少1个标准化经验模块';
ELSIF v_store.priority = '持续跟踪' THEN
v_task1_indicator := '经营稳定性';
v_task1_action := '保持现有经营水平,关注核心指标波动';
v_task1_target := '核心指标不恶化';
END IF;
-- 生成第一个任务
IF v_task1_indicator IS NOT NULL THEN
INSERT INTO analytics.store_task
(plan_month, store_code, store_name, priority, problem_indicator,
problem_description, action_required, owner, deadline, status,
verification_indicator)
VALUES (p_month, v_store.store_code, v_store.store_name, v_store.priority,
v_task1_indicator, v_store.problem_combination,
v_task1_action, '店长/区域经理', v_deadline, '待启动',
v_task1_target)
ON CONFLICT DO NOTHING;
v_count := v_count + 1;
END IF;
-- 根据问题组合生成第二个任务(如果有多个问题)
IF v_store.problem_count >= 2 THEN
v_task2_indicator := NULL;
v_task2_action := NULL;
v_task2_target := NULL;
IF v_store.problem_combination LIKE '%优惠偏高%' AND v_task1_indicator != '优惠率' THEN
v_task2_indicator := '优惠率';
v_task2_action := '拆解平台折扣和营销方案,每周复核高折扣账单';
v_task2_target := '优惠率降低2个百分点';
ELSIF v_store.problem_combination LIKE '%会员复购偏低%' AND v_task1_indicator != '会员复购率' THEN
v_task2_indicator := '会员复购率';
v_task2_action := '执行消费后第3天和第7天触达,使用会员价和复购券';
v_task2_target := '复购率提升5个百分点';
ELSIF v_store.problem_combination LIKE '%异常%' AND v_task1_indicator != '异常率' THEN
v_task2_indicator := '异常率';
v_task2_action := '全额优惠必须填写原因和审批人,周度抽查';
v_task2_target := '异常率降低至1.5%以下';
ELSIF v_store.problem_combination LIKE '%理论毛利偏低%' AND v_task1_indicator != '理论毛利率' THEN
v_task2_indicator := '理论毛利率';
v_task2_action := '分析商品结构,提升高毛利品类占比';
v_task2_target := '理论毛利率提升1个百分点';
ELSIF v_store.problem_combination LIKE '%饮品搭售偏低%' AND v_task1_indicator != '饮品搭售率' THEN
v_task2_indicator := '饮品搭售率';
v_task2_action := '按午晚市设计主食加饮品组合销售';
v_task2_target := '饮品搭售率提升5个百分点';
END IF;
IF v_task2_indicator IS NOT NULL THEN
INSERT INTO analytics.store_task
(plan_month, store_code, store_name, priority, problem_indicator,
problem_description, action_required, owner, deadline, status,
verification_indicator)
VALUES (p_month, v_store.store_code, v_store.store_name, v_store.priority,
v_task2_indicator, v_store.problem_combination,
v_task2_action, '店长/区域经理', v_deadline, '待启动',
v_task2_target)
ON CONFLICT DO NOTHING;
v_count := v_count + 1;
END IF;
END IF;
-- P0-修复数据口径 门店增加数据修复任务
IF v_store.priority = 'P0-修复数据口径' THEN
INSERT INTO analytics.store_task
(plan_month, store_code, store_name, priority, problem_indicator,
problem_description, action_required, owner, deadline, status,
verification_indicator)
VALUES (p_month, v_store.store_code, v_store.store_name, v_store.priority,
'数据口径修复', '成本口径异常',
'核实成本单位与门店映射关系,修正盘点数据口径', '信息部/财务部',
v_deadline, '待启动', '成本口径校验通过')
ON CONFLICT DO NOTHING;
v_count := v_count + 1;
END IF;
END LOOP;
RETURN QUERY SELECT v_count, format('已为 %s 家门店生成 %s 条任务',
(SELECT count(DISTINCT store_code) FROM analytics.store_task WHERE plan_month = p_month),
v_count);
END;
$$ LANGUAGE plpgsql;
-- 每日通知推送函数
CREATE OR REPLACE FUNCTION analytics.f_dispatch_daily_notifications()
RETURNS TABLE(dispatched INT, message TEXT) AS $$
DECLARE
v_count INT := 0;
v_store RECORD;
BEGIN
-- 为每家门店的待启动任务标记为已推送
-- 实际推送由应用层处理,这里只做标记
UPDATE analytics.store_task
SET updated_at = NOW()
WHERE status = '待启动' AND updated_at < NOW() - INTERVAL '1 day';
GET DIAGNOSTICS v_count = ROW_COUNT;
RETURN QUERY SELECT v_count, format('已推送 %s 条待办任务通知', v_count);
END;
$$ LANGUAGE plpgsql;
+225
View File
@@ -0,0 +1,225 @@
-- ============================================================
-- 03_create_views.sql
-- 周度检查视图 + 月度验收视图 + 闭环健康度视图 + 数据质量视图
-- ============================================================
-- 周度检查视图
CREATE OR REPLACE VIEW analytics.v_task_weekly_check AS
SELECT
w.id, w.task_id, w.store_code, w.store_name, w.problem_indicator,
w.iso_week,
round(w.this_week_value, 2) AS this_week_value,
round(w.last_week_value, 2) AS last_week_value,
w.change_direction,
w.consecutive_no_improve_weeks,
w.check_comment,
w.checked_by,
w.checked_at,
t.plan_month,
t.priority,
t.status AS task_status,
t.target_value,
CASE
WHEN w.consecutive_no_improve_weeks >= 2 THEN '需重新判断原因'
WHEN w.change_direction = 'up' AND t.problem_indicator IN ('优惠率','异常率','实际成本率','平台加权成本率') THEN '改善中'
WHEN w.change_direction = 'down' AND t.problem_indicator IN ('优惠率','异常率','实际成本率','平台加权成本率') THEN '需关注'
WHEN w.change_direction = 'up' AND t.problem_indicator IN ('理论毛利率','会员复购率','饮品搭售率','经营稳定性') THEN '改善中'
WHEN w.change_direction = 'down' AND t.problem_indicator IN ('理论毛利率','会员复购率','饮品搭售率','经营稳定性') THEN '需关注'
ELSE '观察中'
END AS improvement_status
FROM analytics.task_weekly_check w
JOIN analytics.store_task t ON w.task_id = t.task_id;
-- 月度验收视图
CREATE OR REPLACE VIEW analytics.v_task_monthly_review AS
SELECT
r.id, r.task_id, r.store_code, r.store_name, r.plan_month,
r.problem_indicator,
round(r.baseline_value, 2) AS baseline_value,
round(r.target_value, 2) AS target_value,
round(r.actual_value, 2) AS actual_value,
r.review_result,
r.revenue_stable,
r.margin_improved,
r.customer_stable,
r.anomaly_decreased,
CASE
WHEN r.review_result = '达标' THEN true
WHEN r.review_result = '改善中' AND r.revenue_stable AND r.customer_stable THEN true
ELSE false
END AS can_promote,
t.priority,
t.owner,
t.action_required,
t.process_evidence,
t.incomplete_reason,
t.next_step
FROM analytics.task_monthly_review r
JOIN analytics.store_task t ON r.task_id = t.task_id;
-- 闭环健康度视图
CREATE OR REPLACE VIEW analytics.v_loop_health AS
WITH task_stats AS (
SELECT
count(DISTINCT store_code) AS total_stores,
count(DISTINCT CASE WHEN status != '待启动' THEN store_code END) AS executed_stores,
count(*) AS total_tasks,
count(*) FILTER (WHERE status = '已验收') AS verified_tasks,
count(*) FILTER (WHERE status = '已回滚') AS rolled_back_tasks
FROM analytics.store_task
WHERE plan_month = date_trunc('month', COALESCE(
(SELECT max(plan_month) FROM analytics.store_task), CURRENT_DATE))
),
weekly_stats AS (
SELECT
count(DISTINCT t.store_code) AS stores_with_weekly_check
FROM analytics.task_weekly_check w
JOIN analytics.store_task t ON w.task_id = t.task_id
WHERE t.plan_month = date_trunc('month', COALESCE(
(SELECT max(plan_month) FROM analytics.store_task), CURRENT_DATE))
AND w.checked_at >= date_trunc('month', COALESCE(
(SELECT max(plan_month) FROM analytics.store_task), CURRENT_DATE))
),
practice_stats AS (
SELECT
count(*) AS total_practices,
count(*) FILTER (WHERE status = '已推广') AS promoted_practices
FROM analytics.standardized_practice
),
store_count AS (
SELECT count(DISTINCT store_code) AS total FROM analytics.v_store_scorecard
)
SELECT
round(COALESCE(ts.total_stores::numeric / NULLIF(sc.total, 0) * 100, 0), 1) AS task_generation_rate,
round(COALESCE(ts.executed_stores::numeric / NULLIF(ts.total_stores, 0) * 100, 0), 1) AS store_execution_rate,
round(COALESCE(ws.stores_with_weekly_check::numeric / NULLIF(ts.total_stores, 0) * 100, 0), 1) AS weekly_check_rate,
round(COALESCE(ts.verified_tasks::numeric / NULLIF(ts.total_tasks, 0) * 100, 0), 1) AS monthly_review_rate,
round(COALESCE(ps.promoted_practices::numeric / NULLIF(ps.total_practices, 0) * 100, 0), 1) AS practice_promotion_rate
FROM task_stats ts
CROSS JOIN weekly_stats ws
CROSS JOIN practice_stats ps
CROSS JOIN store_count sc;
-- 数据质量检查视图
CREATE OR REPLACE VIEW analytics.v_data_quality_check AS
WITH bill_stats AS (
SELECT
count(*) AS total_bills,
count(*) FILTER (WHERE NULLIF(c005, '') IS NULL) AS missing_bill_no,
count(*) FILTER (WHERE NULLIF(c002, '') IS NULL) AS missing_store_code,
count(*) FILTER (WHERE c009::numeric <= 0 OR c009 IS NULL) AS zero_consumption,
count(*) FILTER (WHERE c114::numeric < 0) AS negative_received,
count(DISTINCT NULLIF(c002, '')) AS store_count,
min(c175::timestamp) AS min_date,
max(c176::timestamp) AS max_date
FROM bill_records
),
dish_stats AS (
SELECT
count(*) AS total_dish_records,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS missing_store,
count(*) FILTER (WHERE dish_name IS NULL OR dish_name = '') AS missing_dish
FROM dish_sales_details
)
SELECT
b.total_bills,
b.missing_bill_no,
b.missing_store_code,
b.zero_consumption,
b.negative_received,
b.store_count,
b.min_date,
b.max_date,
d.total_dish_records,
d.missing_store AS dish_missing_store,
d.missing_dish AS dish_missing_dish,
CASE
WHEN b.missing_bill_no > 0 THEN '有账单缺失单号'
WHEN b.missing_store_code > 0 THEN '有账单缺失门店编码'
WHEN b.negative_received > 0 THEN '有负实收账单'
ELSE '数据完整性正常'
END AS bill_quality_status,
CASE
WHEN d.missing_store > 0 OR d.missing_dish > 0 THEN '菜品明细有缺失字段'
ELSE '菜品明细完整性正常'
END AS dish_quality_status
FROM bill_stats b CROSS JOIN dish_stats d;
-- 店长日卡视图
CREATE OR REPLACE VIEW analytics.v_store_daily_card AS
WITH latest_date AS (
SELECT max(closed_at)::date AS business_date FROM analytics.bill_fact WHERE closed_at IS NOT NULL
),
-- 收入模块
revenue AS (
SELECT bf.store_code, bf.store_name,
'收入' AS module,
jsonb_build_array(
jsonb_build_object('metric', '实收', 'value', round(sum(bf.received_total), 2),
'baseline', round(avg(sw.received), 2), 'is_anomaly',
sum(bf.received_total) < avg(sw.received) * 0.8),
jsonb_build_object('metric', '账单数', 'value', count(*),
'baseline', round(avg(sw.bill_count), 0), 'is_anomaly',
count(*) < avg(sw.bill_count) * 0.8),
jsonb_build_object('metric', '客单价', 'value', round(sum(bf.received_total)/count(*), 2),
'baseline', round(avg(sw.avg_bill), 2), 'is_anomaly',
sum(bf.received_total)/count(*) < avg(sw.avg_bill) * 0.9)
) AS anomalies
FROM analytics.bill_fact bf
CROSS JOIN latest_date ld
LEFT JOIN LATERAL (
SELECT sum(r.received_total) AS received, count(*) AS bill_count,
sum(r.received_total)/count(*) AS avg_bill
FROM analytics.bill_fact r
WHERE r.store_code = bf.store_code
AND r.closed_at::date >= ld.business_date - 28
AND r.closed_at::date < ld.business_date
AND extract(isodow FROM r.closed_at) = extract(isodow FROM ld.business_date)
) sw ON true
WHERE bf.closed_at::date = ld.business_date
GROUP BY bf.store_code, bf.store_name
),
-- 优惠模块
discount AS (
SELECT bf.store_code, bf.store_name,
'优惠' AS module,
jsonb_build_array(
jsonb_build_object('metric', '优惠率', 'value',
round(sum(bf.discount_total)/nullif(sum(bf.consumption), 0) * 100, 2),
'baseline', 20.23, 'is_anomaly',
sum(bf.discount_total)/nullif(sum(bf.consumption), 0) * 100 > 25),
jsonb_build_object('metric', '异常优惠账单', 'value',
count(*) FILTER (WHERE bf.discount_total > bf.consumption AND bf.consumption > 0),
'baseline', 0, 'is_anomaly',
count(*) FILTER (WHERE bf.discount_total > bf.consumption AND bf.consumption > 0) > 5)
) AS anomalies
FROM analytics.bill_fact bf
CROSS JOIN latest_date ld
WHERE bf.closed_at::date = ld.business_date
GROUP BY bf.store_code, bf.store_name
),
-- 风险模块
risk AS (
SELECT bf.store_code, bf.store_name,
'风险' AS module,
jsonb_build_array(
jsonb_build_object('metric', '零实收账单', 'value',
count(*) FILTER (WHERE bf.received_total = 0), 'baseline', 0, 'is_anomaly',
count(*) FILTER (WHERE bf.received_total = 0) > 3),
jsonb_build_object('metric', '撤单/退款', 'value',
count(*) FILTER (WHERE bf.bill_status IN ('撤单', '退款')), 'baseline', 0, 'is_anomaly',
count(*) FILTER (WHERE bf.bill_status IN ('撤单', '退款')) > 2)
) AS anomalies
FROM analytics.bill_fact bf
CROSS JOIN latest_date ld
WHERE bf.closed_at::date = ld.business_date
GROUP BY bf.store_code, bf.store_name
)
SELECT
COALESCE(r.store_code, d.store_code, rk.store_code) AS store_code,
COALESCE(r.store_name, d.store_name, rk.store_name) AS store_name,
COALESCE(r.module, d.module, rk.module) AS module,
COALESCE(r.anomalies, d.anomalies, rk.anomalies) AS anomalies
FROM revenue r
FULL JOIN discount d USING (store_code, store_name)
FULL JOIN risk rk USING (store_code, store_name);
+38
View File
@@ -0,0 +1,38 @@
-- ============================================================
-- 04_init_data.sql
-- 初始化数据:任务模板 + 指标字典 + 门店主数据
-- ============================================================
-- 任务模板
INSERT INTO analytics.task_template (problem_type, problem_indicator, default_action, default_target_adjustment, verification_indicator, suggested_deadline_days) VALUES
('高优惠', '优惠率', '拆解平台折扣和营销方案,每周复核高折扣账单,退出低毛利满减商品', '降低2个百分点', '优惠率达标', 30),
('低毛利', '理论毛利率', '分析商品结构,提升高毛利品类占比,优化套餐组合', '提升1个百分点', '毛利率达标', 30),
('高异常', '异常率', '全额优惠必须填写原因和审批人,周度抽查收银员及营销方案', '降低至1.5%以下', '异常率达标', 30),
('低复购', '会员复购率', '执行消费后第3天和第7天触达,使用会员价和复购券', '提升5个百分点', '复购率达标', 30),
('低客单', '平均客单价', '按午晚市设计主食加小吃、凉菜、饮料的组合销售', '提升1元', '客单价达标', 30),
('标杆输出', '经验输出', '总结核心SKU结构、会员运营、平台价格纪律等可推广经验', '输出至少1个标准化经验模块', '经验文档完成', 30),
('数据修复', '数据口径修复', '核实成本单位与门店映射关系,修正盘点数据口径', '成本口径校验通过', '口径校验通过', 15)
ON CONFLICT (problem_type) DO NOTHING;
-- 指标字典
INSERT INTO analytics.indicator_dictionary (indicator_name, business_definition, formula, data_source, update_frequency, owner, scope, yellow_threshold, red_threshold, version_date) VALUES
('实收', '门店实际收到的金额,扣除优惠后的净收入', 'sum(received_total)', 'bill_fact / c114', '', '财务部', '门店/公司', NULL, NULL, '2026-04-01'),
('客单价', '平均每笔账单的实收金额', 'sum(received_total) / count(*)', 'bill_fact', '', '运营部', '门店/公司', 30, 28, '2026-04-01'),
('优惠率', '优惠总额占消费总额的比例', 'sum(discount_total) / sum(consumption) * 100', 'bill_fact / c068 / c009', '', '运营部', '门店/公司', 22, 25, '2026-04-01'),
('理论毛利率', '理论利润占实收的比例', 'sum(theoretical_profit) / sum(received_total) * 100', 'bill_fact / c181 / c114', '', '财务部', '门店/公司', 70, 68, '2026-04-01'),
('实际成本率', '实际倒挤成本占实收的比例', 'actual_food_cost / received * 100', 'inventory_cost_records + bill_fact', '', '财务部', '门店', 30, 35, '2026-04-01'),
('平台加权成本率', '三平台折扣+佣金占平台实收+折扣+佣金的比例', '(discount + commission) / (received + discount + commission) * 100', 'bill_fact', '', '运营部', '门店/公司', 35, 38, '2026-04-01'),
('复购率', '跨日消费会员占全部识别会员的比例', 'count(DISTINCT member_id FILTER(WHERE cross_day)) / count(DISTINCT member_id) * 100', 'bill_fact', '', '会员部', '门店/公司', 30, 25, '2026-04-01'),
('异常率', '异常账单数占总账单数的比例', 'count(anomaly_bills) / count(all_bills) * 100', 'v_anomaly_bills / bill_fact', '', '运营部', '门店', 1.5, 3, '2026-04-01'),
('库存天数', '库存金额 / 日均成本', 'inventory_amount / (monthly_cost / 30)', 'inventory_cost_records', '', '供应链', '门店/成本单位', 7, 10, '2026-04-01'),
('任务完成率', '已验收任务数占总任务数的比例', 'count(verified_tasks) / count(total_tasks) * 100', 'store_task', '', '运营部', '门店/公司', 80, 60, '2026-04-01')
ON CONFLICT (indicator_name) DO NOTHING;
-- 门店主数据(从现有视图提取)
INSERT INTO analytics.dim_store (store_code, store_name, business_type)
SELECT DISTINCT store_code, store_name,
CASE WHEN store_name ~ '机场|火锅|商城|快手|哈马尔罕' THEN '特殊业态' ELSE '标准门店' END
FROM analytics.v_store_scorecard
ON CONFLICT (store_code) DO UPDATE SET
store_name = EXCLUDED.store_name,
business_type = EXCLUDED.business_type;
+54
View File
@@ -0,0 +1,54 @@
-- 回填 store_task 的 current_value / benchmark_value / target_value
-- 标准7指标从 v_store_monthly_followup 取值
-- 实际成本率/饮品搭售率从 v_store_action_priority_deep_april 取值
UPDATE analytics.store_task t
SET
current_value = src.current_val::numeric,
benchmark_value = src.benchmark_val::numeric,
target_value = src.target_val::numeric
FROM (
SELECT
t.task_id,
CASE t.problem_indicator
WHEN '异常率' THEN f.actual_anomaly_rate::text
WHEN '优惠率' THEN f.actual_discount_rate::text
WHEN '理论毛利率' THEN f.actual_margin_rate::text
WHEN '会员复购率' THEN f.actual_repeat_rate::text
WHEN '平台加权成本率' THEN NULL
WHEN '客单价' THEN f.actual_avg_bill::text
WHEN '实收' THEN f.actual_received::text
WHEN '实际成本率' THEN v.actual_food_cost_rate_pct::text
WHEN '饮品搭售率' THEN v.noodle_drink_attach_pct::text
ELSE NULL
END AS current_val,
CASE t.problem_indicator
WHEN '异常率' THEN f.baseline_anomaly_rate::text
WHEN '优惠率' THEN f.baseline_discount_rate::text
WHEN '理论毛利率' THEN f.baseline_margin_rate::text
WHEN '会员复购率' THEN f.baseline_repeat_rate::text
WHEN '平台加权成本率' THEN f.baseline_meituan_cost_rate::text
WHEN '客单价' THEN f.baseline_avg_bill::text
WHEN '实收' THEN f.baseline_received::text
WHEN '实际成本率' THEN v.theoretical_cost_rate_pct::text
WHEN '饮品搭售率' THEN '15'
ELSE d.yellow_threshold::text
END AS benchmark_val,
CASE t.problem_indicator
WHEN '异常率' THEN f.target_anomaly_rate::text
WHEN '优惠率' THEN f.target_discount_rate::text
WHEN '理论毛利率' THEN f.target_margin_rate::text
WHEN '会员复购率' THEN f.target_repeat_rate::text
WHEN '平台加权成本率' THEN f.target_meituan_cost_rate::text
WHEN '客单价' THEN f.target_avg_bill::text
WHEN '实收' THEN f.target_received::text
WHEN '实际成本率' THEN d.red_threshold::text
WHEN '饮品搭售率' THEN '20'
ELSE d.red_threshold::text
END AS target_val
FROM analytics.store_task t
LEFT JOIN analytics.v_store_monthly_followup f ON f.store_code = t.store_code
LEFT JOIN analytics.v_store_action_priority_deep_april v ON v.store_code = t.store_code
LEFT JOIN analytics.indicator_dictionary d ON d.indicator_name = t.problem_indicator
WHERE t.problem_indicator IN ('异常率','优惠率','理论毛利率','会员复购率','平台加权成本率','实际成本率','饮品搭售率','客单价','实收')
) src
WHERE t.task_id = src.task_id;
+47
View File
@@ -0,0 +1,47 @@
-- 补充回填:v_store_monthly_followup 中值为 NULL 的门店
-- 从 v_store_action_priority_deep_april 和 v_store_risk_rating 补充
UPDATE analytics.store_task t
SET
current_value = src.current_val::numeric,
benchmark_value = src.benchmark_val::numeric,
target_value = src.target_val::numeric
FROM (
SELECT
t.task_id,
CASE t.problem_indicator
WHEN '异常率' THEN COALESCE(f.actual_anomaly_rate, r.anomaly_rate_pct)::text
WHEN '优惠率' THEN COALESCE(f.actual_discount_rate, v.discount_rate_pct)::text
WHEN '理论毛利率' THEN COALESCE(f.actual_margin_rate, v.theoretical_margin_pct)::text
WHEN '会员复购率' THEN COALESCE(f.actual_repeat_rate, v.repeat_rate_pct)::text
WHEN '实际成本率' THEN v.actual_food_cost_rate_pct::text
WHEN '饮品搭售率' THEN v.noodle_drink_attach_pct::text
ELSE NULL
END AS current_val,
CASE t.problem_indicator
WHEN '异常率' THEN COALESCE(f.baseline_anomaly_rate, d.yellow_threshold)::text
WHEN '优惠率' THEN COALESCE(f.baseline_discount_rate, d.yellow_threshold)::text
WHEN '理论毛利率' THEN COALESCE(f.baseline_margin_rate, d.yellow_threshold)::text
WHEN '会员复购率' THEN COALESCE(f.baseline_repeat_rate, d.yellow_threshold)::text
WHEN '实际成本率' THEN v.theoretical_cost_rate_pct::text
WHEN '饮品搭售率' THEN '15'
ELSE d.yellow_threshold::text
END AS benchmark_val,
CASE t.problem_indicator
WHEN '异常率' THEN COALESCE(f.target_anomaly_rate, d.red_threshold)::text
WHEN '优惠率' THEN COALESCE(f.target_discount_rate, d.red_threshold)::text
WHEN '理论毛利率' THEN COALESCE(f.target_margin_rate, d.red_threshold)::text
WHEN '会员复购率' THEN COALESCE(f.target_repeat_rate, d.red_threshold)::text
WHEN '实际成本率' THEN d.red_threshold::text
WHEN '饮品搭售率' THEN '20'
ELSE d.red_threshold::text
END AS target_val
FROM analytics.store_task t
LEFT JOIN analytics.v_store_monthly_followup f ON f.store_code = t.store_code
LEFT JOIN analytics.v_store_action_priority_deep_april v ON v.store_code = t.store_code
LEFT JOIN analytics.v_store_risk_rating r ON r.store_code = t.store_code
LEFT JOIN analytics.indicator_dictionary d ON d.indicator_name = t.problem_indicator
WHERE t.current_value IS NULL
AND t.problem_indicator IN ('异常率','优惠率','理论毛利率','会员复购率','实际成本率','饮品搭售率')
) src
WHERE t.task_id = src.task_id
AND src.current_val IS NOT NULL;
+78
View File
@@ -0,0 +1,78 @@
-- 模拟闭环流程:
-- 1. 将部分任务推进到"进行中"(模拟店长已提交执行反馈)
-- 2. 对部分"进行中"任务执行月度验收
-- 3. 生成 task_monthly_review 数据
-- Step 1: 将前60条任务推进到"进行中"(模拟执行反馈)
UPDATE analytics.store_task
SET status = '进行中', process_evidence = '已按行动要求执行,提交过程证据', updated_at = NOW()
WHERE task_id IN (
SELECT task_id FROM analytics.store_task WHERE status = '待启动' ORDER BY task_id LIMIT 60
);
-- 记录执行日志
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
SELECT task_id, '执行反馈', '进行中', '店长', '已按行动要求执行'
FROM analytics.store_task WHERE status = '进行中';
-- Step 2: 对"进行中"的任务执行月度验收
-- 达标:current_value 已优于 target_value
INSERT INTO analytics.task_monthly_review
(task_id, store_code, store_name, plan_month, problem_indicator,
baseline_value, target_value, actual_value, review_result,
revenue_stable, margin_improved, customer_stable, anomaly_decreased)
SELECT
t.task_id, t.store_code, t.store_name, t.plan_month, t.problem_indicator,
t.benchmark_value, t.target_value,
t.current_value,
CASE
WHEN t.current_value IS NOT NULL AND t.target_value IS NOT NULL AND t.current_value <= t.target_value THEN '达标'
WHEN t.current_value IS NOT NULL AND t.benchmark_value IS NOT NULL AND t.current_value < t.benchmark_value THEN '改善中'
ELSE '未改善'
END,
true,
CASE
WHEN t.current_value IS NOT NULL AND t.target_value IS NOT NULL AND t.current_value <= t.target_value THEN true
ELSE false
END,
true,
CASE
WHEN t.current_value IS NOT NULL AND t.benchmark_value IS NOT NULL AND t.current_value < t.benchmark_value THEN true
ELSE false
END
FROM analytics.store_task t
WHERE t.status = '进行中';
-- Step 3: 更新任务状态为"已验收"
UPDATE analytics.store_task t
SET status = '已验收',
verification_result = r.review_result,
updated_at = NOW()
FROM analytics.task_monthly_review r
WHERE t.task_id = r.task_id;
-- 记录验收日志
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
SELECT task_id, '月度验收', '已验收', '区域经理', review_result
FROM analytics.task_monthly_review;
-- Step 4: 创建1条标准化经验(达标门店)
INSERT INTO analytics.best_practice
(source_store_code, source_store_name, practice_title, practice_category, description, indicators, status)
SELECT
t.store_code, t.store_name,
t.problem_indicator || '改善经验',
CASE t.problem_indicator
WHEN '异常率' THEN '运营规范'
WHEN '优惠率' THEN '价格管理'
WHEN '理论毛利率' THEN '商品结构'
WHEN '会员复购率' THEN '会员运营'
ELSE '综合管理'
END,
'通过' || t.action_required || '实现' || t.problem_indicator || '' || t.benchmark_value || '改善至' || t.current_value,
t.problem_indicator,
'待推广'
FROM analytics.task_monthly_review r
JOIN analytics.store_task t ON t.task_id = r.task_id
WHERE r.review_result = '达标'
LIMIT 3;
+31
View File
@@ -0,0 +1,31 @@
-- 修正 review_result:区分指标方向(越高越好 vs 越低越好)
UPDATE analytics.task_monthly_review r
SET review_result = CASE
-- 越高越好的指标
WHEN r.problem_indicator IN ('理论毛利率','会员复购率','饮品搭售率','实收','客单价') THEN
CASE
WHEN r.actual_value >= r.target_value THEN '达标'
WHEN r.actual_value > r.baseline_value THEN '改善中'
ELSE '未改善'
END
-- 越低越好的指标
ELSE
CASE
WHEN r.actual_value <= r.target_value THEN '达标'
WHEN r.actual_value < r.baseline_value THEN '改善中'
ELSE '未改善'
END
END
WHERE r.actual_value IS NOT NULL;
-- 同步更新 store_task
UPDATE analytics.store_task t
SET verification_result = r.review_result
FROM analytics.task_monthly_review r
WHERE t.task_id = r.task_id;
-- 更新日志
UPDATE analytics.store_task_log
SET comment = r.review_result
FROM analytics.task_monthly_review r
WHERE store_task_log.task_id = r.task_id AND action = '月度验收';
+48
View File
@@ -0,0 +1,48 @@
import pg from 'pg'
const { Pool } = pg
const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'bill_query',
user: process.env.DB_USER || 'freedak',
password: process.env.DB_PASSWORD || '',
max: parseInt(process.env.DB_POOL_MAX || '10'),
})
pool.on('error', (err) => {
console.error('Unexpected error on idle client', err)
})
export interface QueryResult<T = any> {
rows: T[]
rowCount: number | null
}
export async function query<T = any>(text: string, params?: any[]): Promise<QueryResult<T>> {
const start = Date.now()
const res = await pool.query(text, params)
const duration = Date.now() - start
if (duration > 500) {
console.warn(`Slow query (${duration}ms):`, text.substring(0, 100))
}
return res
}
export async function withTransaction<T>(callback: (client: pg.PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect()
try {
await client.query('BEGIN')
const result = await callback(client)
await client.query('COMMIT')
return result
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
export default pool
+42
View File
@@ -0,0 +1,42 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import { authMiddleware, AuthRequest } from './middleware/auth.js'
import { errorHandler, notFoundHandler } from './middleware/error.js'
import authRoutes from './routes/auth.js'
import dataRoutes from './routes/data.js'
import taskRoutes from './routes/tasks.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3001')
app.use(cors({
origin: process.env.CLIENT_URL || 'http://localhost:5173',
credentials: true,
}))
app.use(express.json())
app.get('/api/health', (req, res) => {
res.json({ success: true, data: { status: 'ok', time: new Date().toISOString() } })
})
app.use('/api/auth', authRoutes)
app.use((req, res, next) => {
if (req.path === '/api/health' || req.path.startsWith('/api/auth')) {
return next()
}
authMiddleware(req as AuthRequest, res, next)
})
app.use('/api', dataRoutes)
app.use('/api/tasks', taskRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
})
export default app
+63
View File
@@ -0,0 +1,63 @@
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'
import type { AuthUser, ApiResponse } from '../types/index.js'
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret'
export interface AuthRequest extends Request {
user?: AuthUser
}
export function generateToken(user: AuthUser): string {
return jwt.sign(user, JWT_SECRET, { expiresIn: '7d' })
}
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
const response: ApiResponse = { success: false, data: null, error: 'No token provided' }
return res.status(401).json(response)
}
const token = authHeader.substring(7)
try {
const decoded = jwt.verify(token, JWT_SECRET) as AuthUser
req.user = decoded
next()
} catch {
const response: ApiResponse = { success: false, data: null, error: 'Invalid or expired token' }
return res.status(401).json(response)
}
}
export function requireRole(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
const response: ApiResponse = { success: false, data: null, error: 'Not authenticated' }
return res.status(401).json(response)
}
if (!roles.includes(req.user.role)) {
const response: ApiResponse = { success: false, data: null, error: 'Insufficient permissions' }
return res.status(403).json(response)
}
next()
}
}
export function requireStoreAccess(req: AuthRequest, res: Response, next: NextFunction) {
if (!req.user) {
const response: ApiResponse = { success: false, data: null, error: 'Not authenticated' }
return res.status(401).json(response)
}
if (req.user.role === 'hq' || req.user.role === 'dept') {
return next()
}
if (req.user.role === 'store') {
const storeCode = req.params.code || req.params.storeCode || req.body.store_code
if (storeCode && storeCode !== req.user.storeCode) {
const response: ApiResponse = { success: false, data: null, error: 'Access denied to this store' }
return res.status(403).json(response)
}
}
next()
}
+43
View File
@@ -0,0 +1,43 @@
import { Request, Response, NextFunction } from 'express'
import type { ApiResponse } from '../types/index.js'
export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
console.error('Error:', err.message)
const response: ApiResponse = {
success: false,
data: null,
error: err.message || 'Internal server error',
}
res.status(500).json(response)
}
export function notFoundHandler(req: Request, res: Response) {
const response: ApiResponse = {
success: false,
data: null,
error: `Route not found: ${req.method} ${req.path}`,
}
res.status(404).json(response)
}
export function sendSuccess<T>(res: Response, data: T, meta?: any) {
const response: ApiResponse<T> = { success: true, data, meta }
res.json(response)
}
export function sendError(res: Response, error: string, status = 400) {
const response: ApiResponse = { success: false, data: null, error }
res.status(status).json(response)
}
export function parseMonth(req: Request): string {
const month = (req.query.month as string) || '2026-04'
return month.length === 7 ? `${month}-01` : month
}
export function parsePagination(req: Request) {
const page = parseInt((req.query.page as string) || '1')
const pageSize = parseInt((req.query.page_size as string) || '50')
const offset = (page - 1) * pageSize
return { page, pageSize, offset }
}
+36
View File
@@ -0,0 +1,36 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import { generateToken } from '../middleware/auth.js'
import type { AuthUser } from '../types/index.js'
const router = Router()
const mockUsers: AuthUser[] = [
{ id: '1', role: 'hq', name: '总部管理员' },
{ id: '2', role: 'regional', name: '区域经理', region: '北京' },
{ id: '3', role: 'store', name: '潘家园店长', storeCode: '0026' },
{ id: '4', role: 'dept', name: '商品部', dept: 'product' },
]
router.post('/login', async (req, res) => {
const { username, password } = req.body
if (!username || !password) {
return sendError(res, 'Username and password required')
}
const user = mockUsers.find((u) => u.name === username || u.id === username)
if (!user) {
return sendError(res, 'Invalid credentials', 401)
}
const token = generateToken(user)
sendSuccess(res, { token, user })
})
router.get('/me', async (req: any, res) => {
if (!req.user) {
return sendError(res, 'Not authenticated', 401)
}
sendSuccess(res, req.user)
})
export default router
+376
View File
@@ -0,0 +1,376 @@
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()
router.get('/overview', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const sql = `
SELECT count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct,
round(sum(theoretical_profit) / nullif(sum(received_total), 0) * 100, 2) AS theoretical_margin_pct,
count(*) FILTER (WHERE member_id IS NOT NULL) AS member_bills,
round(count(*) FILTER (WHERE member_id IS NOT NULL)::numeric / count(*) * 100, 2) AS member_share_pct
FROM analytics.bill_fact
WHERE closed_at >= $1::date
AND closed_at < ($1::date + interval '1 month')
AND closed_at IS NOT NULL
`
const result = await query(sql, [month])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/overview/daily', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const sql = `
SELECT closed_at::date AS business_date,
count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct
FROM analytics.bill_fact
WHERE closed_at >= $1::date
AND closed_at < ($1::date + interval '1 month')
AND closed_at IS NOT NULL
GROUP BY closed_at::date
ORDER BY business_date
`
const result = await query(sql, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const riskLevel = req.query.risk_level as string
const quadrant = req.query.quadrant as string
let sql = `SELECT * FROM analytics.v_store_scorecard`
const params: any[] = []
const conditions: string[] = []
if (riskLevel) {
sql = `SELECT s.* FROM analytics.v_store_scorecard s
JOIN analytics.v_store_risk_rating r ON s.store_code = r.store_code
WHERE r.risk_level = $1`
params.push(riskLevel)
}
if (quadrant) {
if (params.length > 0) {
conditions.push(`b.management_quadrant = $${params.length + 1}`)
sql = sql.replace('FROM analytics.v_store_scorecard s', 'FROM analytics.v_store_scorecard s JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code')
} else {
sql = `SELECT s.* FROM analytics.v_store_scorecard s
JOIN analytics.v_store_benchmark b ON s.store_code = b.store_code
WHERE b.management_quadrant = $1`
params.push(quadrant)
}
}
sql += ` ORDER BY received DESC NULLS LAST`
const countResult = await query(`SELECT count(*) AS total FROM (${sql}) t`, params)
sql += ` LIMIT $${params.length + 1} OFFSET $${params.length + 2}`
params.push(pageSize, offset)
const result = await query(sql, params)
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/risk', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_risk_rating ORDER BY risk_level, received DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/priority', async (req: AuthRequest, res) => {
try {
const result = await query(`
SELECT store_code, store_name, action_priority, problem_count, problem_combination,
received, scale_tier, business_type
FROM analytics.cache_store_priority
ORDER BY CASE action_priority
WHEN 'P0-修复数据口径' THEN 1
WHEN 'P0-综合专项整改' THEN 2
WHEN 'P1-重点整改' THEN 3
WHEN 'P2-单项改善' THEN 4
WHEN '标杆候选' THEN 5
ELSE 6
END, received DESC
`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/quadrant', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_benchmark ORDER BY management_quadrant, avg_daily_received DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const scorecard = await query(`SELECT * FROM analytics.v_store_scorecard WHERE store_code = $1`, [code])
const risk = await query(`SELECT * FROM analytics.v_store_risk_rating WHERE store_code = $1`, [code])
const platform = await query(`SELECT * FROM analytics.v_store_platform_economics WHERE store_code = $1`, [code])
const benchmark = await query(`SELECT * FROM analytics.v_store_benchmark WHERE store_code = $1`, [code])
const action = await query(`SELECT * FROM analytics.v_store_action_list WHERE store_code = $1`, [code])
const execution = await query(`SELECT * FROM analytics.v_store_execution_priority WHERE store_code = $1`, [code])
if (scorecard.rows.length === 0) {
return sendError(res, 'Store not found', 404)
}
sendSuccess(res, {
scorecard: scorecard.rows[0],
risk: risk.rows[0],
platform: platform.rows[0],
benchmark: benchmark.rows[0],
action: action.rows[0],
execution: execution.rows[0],
})
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code/daily', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const month = parseMonth(req)
const result = await query(`
SELECT closed_at::date AS business_date,
count(*) AS bill_count,
round(sum(received_total), 2) AS received,
round(sum(received_total) / count(*), 2) AS avg_bill_value,
round(sum(discount_total) / nullif(sum(consumption), 0) * 100, 2) AS discount_rate_pct
FROM analytics.bill_fact
WHERE store_code = $1
AND closed_at >= $2::date
AND closed_at < ($2::date + interval '1 month')
AND closed_at IS NOT NULL
GROUP BY closed_at::date
ORDER BY business_date
`, [code, month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/comparison', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_theoretical_actual_cost_april ORDER BY variance_to_theoretical_pct DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/category-benchmark', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_category_cost_benchmark_april ORDER BY store_code`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/cost/inventory', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_inventory_efficiency_april ORDER BY estimated_inventory_days DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/platform/economics', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_platform_economics ORDER BY meituan_received DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/member/comparison', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_member_comparison`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/member/repeat', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_repeat_summary_monthly ORDER BY repeat_rate_pct DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/abc', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_dish_sku_abc_april ORDER BY cumulative_revenue_share`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/category', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.category_summary ORDER BY amount DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/sku/attach', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.dish_pair_summary_april ORDER BY pair_count DESC LIMIT 50`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/risk/anomaly', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const countResult = await query(`SELECT count(*) AS total FROM analytics.v_anomaly_bills`)
const result = await query(`SELECT * FROM analytics.v_anomaly_bills ORDER BY closed_at DESC LIMIT $1 OFFSET $2`, [pageSize, offset])
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/risk/zero-received', async (req: AuthRequest, res) => {
try {
const storeCode = req.query.store_code as string
let sql = `SELECT * FROM analytics.v_zero_received_detail`
const params: any[] = []
if (storeCode) {
sql += ` WHERE store_code = $1`
params.push(storeCode)
}
sql += ` ORDER BY closed_at DESC LIMIT 200`
const result = await query(sql, params)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/risk/cashier', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_cashier_risk ORDER BY anomaly_rate_pct DESC NULLS LAST`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/marketing/plans', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_marketing_plan_summary ORDER BY received DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/benchmark/composite', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_benchmark_composite ORDER BY benchmark_score DESC`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/time/weekday', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_weekday_summary ORDER BY weekday_no`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/time/hourly', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_hourly_summary ORDER BY closing_hour`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/channel', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_channel_daily ORDER BY business_date`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/data-quality', async (req: AuthRequest, res) => {
try {
const billStats = await query(`
SELECT
count(*) AS total_bills,
count(*) FILTER (WHERE bill_no IS NULL OR bill_no = '') AS missing_bill_no,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS missing_store_code,
count(*) FILTER (WHERE consumption = 0 OR consumption IS NULL) AS zero_consumption,
count(*) FILTER (WHERE received_total < 0) AS negative_received,
count(DISTINCT store_code) AS store_count,
min(closed_at)::text AS min_date,
max(closed_at)::text AS max_date
FROM analytics.bill_fact
`)
const dishStats = await query(`
SELECT
count(*) AS total_dish_records,
count(*) FILTER (WHERE store_code IS NULL OR store_code = '') AS dish_missing_store,
count(*) FILTER (WHERE dish_name IS NULL OR dish_name = '') AS dish_missing_dish
FROM public.dish_sales_details
`)
const result = { ...billStats.rows[0], ...dishStats.rows[0] }
sendSuccess(res, result)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+571
View File
@@ -0,0 +1,571 @@
import { Router } from 'express'
import { query, withTransaction } from '../config/database.js'
import { sendSuccess, sendError, parseMonth, parsePagination } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============================================================
// 固定路径路由(必须在 /:id 之前定义)
// ============================================================
router.get('/', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const month = parseMonth(req)
const priority = req.query.priority as string
const status = req.query.status as string
const storeCode = req.query.store_code as string
const conditions: string[] = [`plan_month = $1`]
const params: any[] = [month]
let paramIdx = 2
if (priority) {
conditions.push(`priority = $${paramIdx++}`)
params.push(priority)
}
if (status) {
conditions.push(`status = $${paramIdx++}`)
params.push(status)
}
if (storeCode) {
conditions.push(`store_code = $${paramIdx++}`)
params.push(storeCode)
}
const where = conditions.join(' AND ')
const countResult = await query(`SELECT count(*) AS total FROM analytics.store_task WHERE ${where}`, params)
const result = await query(
`SELECT * FROM analytics.store_task WHERE ${where} ORDER BY
CASE priority WHEN 'P0-修复数据口径' THEN 1 WHEN 'P0-综合专项整改' THEN 2 WHEN 'P1' THEN 3 WHEN 'P2' THEN 4 ELSE 5 END,
deadline ASC
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
[...params, pageSize, offset]
)
sendSuccess(res, result.rows, { total: parseInt(countResult.rows[0].total), page, page_size: pageSize })
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/', async (req: AuthRequest, res) => {
try {
const b = req.body
const result = await withTransaction(async (client) => {
const taskResult = await client.query(`
INSERT INTO analytics.store_task
(plan_month, store_code, store_name, priority, problem_indicator,
current_value, benchmark_value, target_value, problem_description,
action_required, owner, collaborators, deadline,
verification_indicator)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING task_id
`, [b.plan_month, b.store_code, b.store_name, b.priority, b.problem_indicator,
b.current_value, b.benchmark_value, b.target_value, b.problem_description,
b.action_required, b.owner, b.collaborators, b.deadline, b.verification_indicator])
const taskId = taskResult.rows[0].task_id
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '创建', '待启动', $2, $3)
`, [taskId, req.user?.name || 'system', b.problem_description])
return taskId
})
sendSuccess(res, { task_id: result })
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/auto-generate', async (req: AuthRequest, res) => {
try {
const month = req.body.month ? (req.body.month.length === 7 ? `${req.body.month}-01` : req.body.month) : '2026-05-01'
const result = await query(`SELECT * FROM analytics.f_generate_store_tasks($1)`, [month])
sendSuccess(res, result.rows[0] || { generated: 0, message: 'No tasks generated' })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/weekly-check', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.v_task_weekly_check WHERE plan_month = $1 ORDER BY consecutive_no_improve_weeks DESC, store_code`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/monthly-review', async (req: AuthRequest, res) => {
try {
const month = parseMonth(req)
const result = await query(`SELECT * FROM analytics.v_task_monthly_review WHERE plan_month = $1 ORDER BY store_code`, [month])
const summary = {
total: result.rows.length,
passed: result.rows.filter((r: any) => r.review_result === '达标').length,
improving: result.rows.filter((r: any) => r.review_result === '改善中').length,
failed: result.rows.filter((r: any) => r.review_result === '未改善').length,
}
sendSuccess(res, { summary, details: result.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/followup', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_store_monthly_followup ORDER BY priority, store_code`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/grade-change', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.store_grade_change ORDER BY change_month DESC, store_code`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/practices', async (req: AuthRequest, res) => {
try {
const mod = req.query.module as string
const status = req.query.status as string
let sql = `SELECT * FROM analytics.standardized_practice`
const params: any[] = []
const conditions: string[] = []
if (mod) {
conditions.push(`practice_module = $${params.length + 1}`)
params.push(mod)
}
if (status) {
conditions.push(`status = $${params.length + 1}`)
params.push(status)
}
if (conditions.length > 0) {
sql += ` WHERE ` + conditions.join(' AND ')
}
sql += ` ORDER BY created_at DESC`
const result = await query(sql, params)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/practices', async (req: AuthRequest, res) => {
try {
const b = req.body
const result = await query(`
INSERT INTO analytics.standardized_practice
(practice_module, benchmark_store_code, benchmark_store_name, key_actions, verification_indicators)
VALUES ($1, $2, $3, $4, $5) RETURNING id
`, [b.practice_module, b.benchmark_store_code, b.benchmark_store_name, b.key_actions, b.verification_indicators])
sendSuccess(res, { id: result.rows[0].id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/practices/:id/replication-result', async (req: AuthRequest, res) => {
try {
const practiceId = parseInt(req.params.id)
const result = await query(`SELECT * FROM analytics.practice_replication WHERE practice_id = $1 ORDER BY id`, [practiceId])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/practices/:id/replicate', async (req: AuthRequest, res) => {
try {
const practiceId = parseInt(req.params.id)
const b = req.body
const result = await query(`
INSERT INTO analytics.practice_replication
(practice_id, trial_store_code, trial_store_name, observation_weeks, before_value, status)
VALUES ($1, $2, $3, $4, $5, '观察中') RETURNING id
`, [practiceId, b.trial_store_code, b.trial_store_name, b.observation_weeks || 4, b.before_value])
sendSuccess(res, { id: result.rows[0].id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/practices/:id/promote', async (req: AuthRequest, res) => {
try {
const practiceId = parseInt(req.params.id)
await query(`UPDATE analytics.standardized_practice SET status = '已推广' WHERE id = $1`, [practiceId])
sendSuccess(res, { id: practiceId, status: '已推广' })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/indicators', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.indicator_dictionary ORDER BY id`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/indicators', async (req: AuthRequest, res) => {
try {
const b = req.body
const result = await query(`
INSERT INTO analytics.indicator_dictionary
(indicator_name, business_definition, formula, data_source, update_frequency,
owner, scope, yellow_threshold, red_threshold, version_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (indicator_name) DO UPDATE SET
business_definition = EXCLUDED.business_definition,
formula = EXCLUDED.formula,
data_source = EXCLUDED.data_source,
update_frequency = EXCLUDED.update_frequency,
owner = EXCLUDED.owner,
scope = EXCLUDED.scope,
yellow_threshold = EXCLUDED.yellow_threshold,
red_threshold = EXCLUDED.red_threshold,
version_date = EXCLUDED.version_date
RETURNING id
`, [b.indicator_name, b.business_definition, b.formula, b.data_source,
b.update_frequency, b.owner, b.scope, b.yellow_threshold, b.red_threshold,
b.version_date || new Date().toISOString().substring(0, 10)])
sendSuccess(res, { id: result.rows[0].id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/loop-health', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v_loop_health`)
sendSuccess(res, result.rows[0] || {
task_generation_rate: 0,
store_execution_rate: 0,
weekly_check_rate: 0,
monthly_review_rate: 0,
practice_promotion_rate: 0,
})
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/notifications/dispatch', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.f_dispatch_daily_notifications()`)
sendSuccess(res, result.rows[0] || { dispatched: 0 })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/stores/:code/daily-card', async (req: AuthRequest, res) => {
try {
const code = req.params.code
const result = await query(`SELECT * FROM analytics.v_store_daily_card WHERE store_code = $1 ORDER BY module`, [code])
const tasks = await query(`
SELECT t.* FROM analytics.store_task t
WHERE t.store_code = $1 AND t.status IN ('待启动', '进行中')
ORDER BY t.priority LIMIT 3
`, [code])
sendSuccess(res, { anomalies: result.rows, todos: tasks.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 动态路径路由 /:id(必须在所有固定路径之后)
// ============================================================
router.get('/:id', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
if (isNaN(id)) {
return sendError(res, 'Invalid task id', 400)
}
const result = await query(`SELECT * FROM analytics.store_task WHERE task_id = $1`, [id])
if (result.rows.length === 0) {
return sendError(res, 'Task not found', 404)
}
const logs = await query(`SELECT * FROM analytics.store_task_log WHERE task_id = $1 ORDER BY created_at`, [id])
sendSuccess(res, { task: result.rows[0], logs: logs.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
router.put('/:id', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const b = req.body
const fields = ['status', 'process_evidence', 'verification_result', 'incomplete_reason', 'next_step', 'owner', 'collaborators', 'deadline', 'action_required']
const updates: string[] = []
const params: any[] = []
let idx = 1
for (const f of fields) {
if (b[f] !== undefined) {
updates.push(`${f} = $${idx++}`)
params.push(b[f])
}
}
if (updates.length === 0) {
return sendError(res, 'No fields to update')
}
updates.push(`updated_at = NOW()`)
params.push(id)
await query(`UPDATE analytics.store_task SET ${updates.join(', ')} WHERE task_id = $${idx}`, params)
await query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '更新', $2, $3, $4)
`, [id, b.status || null, req.user?.name || 'system', b.next_step || null])
sendSuccess(res, { task_id: id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.put('/:id/execute', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const b = req.body
await withTransaction(async (client) => {
await client.query(`
UPDATE analytics.store_task
SET status = '进行中', process_evidence = $1, action_required = $2,
incomplete_reason = $3, next_step = $4, updated_at = NOW()
WHERE task_id = $5
`, [b.process_evidence, b.action_required, b.incomplete_reason, b.next_step, id])
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, old_status, new_status, operator, comment)
VALUES ($1, '执行反馈', '待启动', '进行中', $2, $3)
`, [id, req.user?.name || 'store', b.process_evidence])
})
sendSuccess(res, { task_id: id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.put('/:id/weekly-check', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const b = req.body
await withTransaction(async (client) => {
await client.query(`
UPDATE analytics.store_task
SET process_evidence = COALESCE($1, process_evidence),
next_step = $2, updated_at = NOW()
WHERE task_id = $3
`, [b.check_evidence, b.next_step, id])
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '周度检查', NULL, $2, $3)
`, [id, req.user?.name || 'regional', b.check_comment])
const task = await client.query(`SELECT store_code, store_name, problem_indicator, plan_month FROM analytics.store_task WHERE task_id = $1`, [id])
if (task.rows.length > 0) {
const t = task.rows[0]
await client.query(`
INSERT INTO analytics.task_weekly_check
(task_id, store_code, store_name, problem_indicator, iso_week,
this_week_value, last_week_value, change_direction,
consecutive_no_improve_weeks, check_comment, checked_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, [id, t.store_code, t.store_name, t.problem_indicator, b.iso_week,
b.this_week_value, b.last_week_value, b.change_direction,
b.consecutive_no_improve_weeks || 0, b.check_comment, req.user?.name])
}
})
sendSuccess(res, { task_id: id })
} catch (err: any) {
sendError(res, err.message)
}
})
router.put('/:id/verify', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const b = req.body
const reviewResult = b.review_result || '改善中'
await withTransaction(async (client) => {
await client.query(`
UPDATE analytics.store_task
SET status = CASE WHEN $1 = '达标' THEN '已验收'
WHEN $1 = '未改善' THEN '已验收'
ELSE '进行中' END,
verification_result = $1,
verification_indicator = $2,
incomplete_reason = $3,
next_step = $4,
updated_at = NOW()
WHERE task_id = $5
`, [reviewResult, b.verification_indicator, b.incomplete_reason, b.next_step, id])
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '月度验收', $2, $3, $4)
`, [id, reviewResult, req.user?.name || 'hq', JSON.stringify({
revenue_stable: b.revenue_stable,
margin_improved: b.margin_improved,
customer_stable: b.customer_stable,
anomaly_decreased: b.anomaly_decreased,
})])
const task = await client.query(`SELECT store_code, store_name, problem_indicator, plan_month, baseline_value, target_value FROM analytics.store_task WHERE task_id = $1`, [id])
if (task.rows.length > 0) {
const t = task.rows[0]
await client.query(`
INSERT INTO analytics.task_monthly_review
(task_id, store_code, store_name, plan_month, problem_indicator,
baseline_value, target_value, actual_value, review_result,
revenue_stable, margin_improved, customer_stable, anomaly_decreased)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
`, [id, t.store_code, t.store_name, t.plan_month, t.problem_indicator,
t.baseline_value, t.target_value, b.actual_value, reviewResult,
b.revenue_stable || false, b.margin_improved || false,
b.customer_stable || false, b.anomaly_decreased || false])
}
})
sendSuccess(res, { task_id: id, review_result: reviewResult })
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/:id/rollback', async (req: AuthRequest, res) => {
try {
const id = parseInt(req.params.id)
const reason = req.body.reason
await withTransaction(async (client) => {
await client.query(`
UPDATE analytics.store_task SET status = '已回滚', incomplete_reason = $1, updated_at = NOW()
WHERE task_id = $2
`, [reason, id])
await client.query(`
INSERT INTO analytics.store_task_log (task_id, action, new_status, operator, comment)
VALUES ($1, '回滚', '已回滚', $2, $3)
`, [id, req.user?.name || 'hq', reason])
})
sendSuccess(res, { task_id: id })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 本体标准 (Ontology Standard)
// ============================================================
router.get('/ontology/overview', async (req: AuthRequest, res) => {
try {
const dims = await query(`
SELECT table_name,
(SELECT count(*) FROM information_schema.columns WHERE table_schema='analytics' AND table_name=t.table_name) AS column_count,
(SELECT reltuples::bigint FROM pg_class WHERE relname=t.table_name) AS row_count
FROM information_schema.tables t
WHERE t.table_schema='analytics' AND t.table_name LIKE 'dim\_%'
ORDER BY t.table_name
`)
const facts = await query(`
SELECT table_name,
(SELECT count(*) FROM information_schema.columns WHERE table_schema='analytics' AND table_name=t.table_name) AS column_count,
(SELECT reltuples::bigint FROM pg_class WHERE relname=t.table_name) AS row_count
FROM information_schema.tables t
WHERE t.table_schema='analytics' AND t.table_name LIKE 'fact\_%'
ORDER BY t.table_name
`)
const enums = await query(`
SELECT table_name,
(SELECT count(*) FROM information_schema.columns WHERE table_schema='analytics' AND table_name=t.table_name) AS column_count,
(SELECT reltuples::bigint FROM pg_class WHERE relname=t.table_name) AS row_count
FROM information_schema.tables t
WHERE t.table_schema='analytics' AND t.table_name LIKE 'enum\_%'
ORDER BY t.table_name
`)
const metrics = await query(`
SELECT count(*) AS total,
count(*) FILTER (WHERE metric_category IS NOT NULL) AS standardized,
count(*) FILTER (WHERE is_active = true) AS active
FROM analytics.indicator_dictionary
`)
sendSuccess(res, {
dimensions: dims.rows,
facts: facts.rows,
enums: enums.rows,
metrics: metrics.rows[0],
})
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/ontology/dim/:table', async (req: AuthRequest, res) => {
try {
const tableName = req.params.table
if (!/^dim_\w+$/.test(tableName)) { sendError(res, 'Invalid table name'); return }
const cols = await query(`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema='analytics' AND table_name=$1
ORDER BY ordinal_position
`, [tableName])
const rows = await query(`SELECT * FROM analytics.${tableName} LIMIT 100`)
sendSuccess(res, { columns: cols.rows, rows: rows.rows })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/ontology/fact/:table', async (req: AuthRequest, res) => {
try {
const tableName = req.params.table
if (!/^fact_\w+$/.test(tableName)) { sendError(res, 'Invalid table name'); return }
const cols = await query(`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema='analytics' AND table_name=$1
ORDER BY ordinal_position
`, [tableName])
const count = await query(`SELECT count(*) AS cnt FROM analytics.${tableName}`)
sendSuccess(res, { columns: cols.rows, total: count.rows[0].cnt })
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/ontology/enum/:table', async (req: AuthRequest, res) => {
try {
const tableName = req.params.table
if (!/^enum_\w+$/.test(tableName)) { sendError(res, 'Invalid table name'); return }
const rows = await query(`SELECT * FROM analytics.${tableName} ORDER BY sort_order`)
sendSuccess(res, rows.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.get('/ontology/metrics', async (req: AuthRequest, res) => {
try {
const result = await query(`
SELECT * FROM analytics.indicator_dictionary
ORDER BY metric_category NULLS LAST, indicator_name
`)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+154
View File
@@ -0,0 +1,154 @@
export type UserRole = 'hq' | 'regional' | 'store' | 'dept'
export interface AuthUser {
id: string
role: UserRole
storeCode?: string
region?: string
dept?: string
name: string
}
export interface ApiResponse<T = any> {
success: boolean
data: T
meta?: {
total?: number
page?: number
page_size?: number
}
error?: string
}
export interface StoreScorecard {
store_code: string
store_name: string
bill_count: number
active_days: number
received: number
avg_daily_received: number
avg_bill_value: number
avg_guest_value: number
discount_rate_pct: number
theoretical_margin_pct: number
member_bill_share_pct: number
}
export interface StoreRiskRating extends StoreScorecard {
anomaly_rate_pct: number
risk_level: string
primary_issue: string
}
export interface StorePriority {
store_code: string
store_name: string
action_priority: string
problem_count: number
problem_combination: string
received: number
scale_tier: string
business_type: string
}
export interface StoreTask {
task_id: number
plan_month: string
store_code: string
store_name: string
priority: string
problem_indicator: string
current_value: number | null
benchmark_value: number | null
target_value: number | null
problem_description: string | null
action_required: string | null
owner: string
collaborators: string | null
deadline: string
status: string
process_evidence: string | null
verification_indicator: string | null
verification_result: string | null
incomplete_reason: string | null
next_step: string | null
created_at: string
updated_at: string
}
export interface WeeklyCheck {
task_id: number
store_code: string
store_name: string
iso_week: number
this_week_value: number | null
last_week_value: number | null
change_direction: string
consecutive_no_improve_weeks: number
check_comment: string | null
checked_by: string | null
checked_at: string | null
}
export interface MonthlyReview {
task_id: number
store_code: string
store_name: string
priority: string
problem_indicator: string
baseline_value: number | null
target_value: number | null
actual_value: number | null
review_result: string
revenue_stable: boolean
margin_improved: boolean
customer_stable: boolean
anomaly_decreased: boolean
}
export interface LoopHealth {
task_generation_rate: number
store_execution_rate: number
weekly_check_rate: number
monthly_review_rate: number
practice_promotion_rate: number
}
export interface IndicatorDict {
id: number
indicator_name: string
business_definition: string
formula: string
data_source: string
update_frequency: string
owner: string
scope: string
yellow_threshold: number | null
red_threshold: number | null
version_date: string
}
export interface StandardizedPractice {
id: number
practice_module: string
benchmark_store_code: string
benchmark_store_name: string
key_actions: string
verification_indicators: string
status: string
created_at: string
}
export interface PracticeReplication {
id: number
practice_id: number
trial_store_code: string
trial_store_name: string
observation_weeks: number
before_value: number | null
after_value: number | null
revenue_impacted: boolean
customer_impacted: boolean
inventory_impacted: boolean
status: string
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}