V3.0: 门店分级与风险等级页面、数据填充脚本、后端API

- 新增V3.0数据库表结构(12-15)和数据填充脚本(16)
- 新增后端路由: store-grade, enterprise, product, intelligence, alert, scheduler, target
- 新增前端页面: StoreGradePage, EnterprisePage, ProductLifecyclePage, IntelligencePage, AlertManagementPage, DataImportPage, SchedulerManagementPage, TargetManagementPage
- 门店分级: 自动分级算法(达成率40%+毛利率25%+会员15%+风险20%)
- 风险等级: 基于mv_store_risk_rating模型展示风险分布和原因
- 弹窗: 雷达图+详细指标+分级原因+风险原因(primary_issue)
- FilterableTable: filterOptions支持{value,label}对象格式
This commit is contained in:
freedakgmail
2026-08-05 20:09:39 +08:00
parent 814abf4a68
commit d04e837d9c
30 changed files with 5052 additions and 144 deletions
+18
View File
@@ -13,6 +13,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"node-cron": "^4.6.0",
"pg": "^8.12.0"
},
"devDependencies": {
@@ -21,6 +22,7 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.10",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.11.6",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
@@ -577,6 +579,13 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmmirror.com/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmmirror.com/@types/pg/-/pg-8.20.0.tgz",
@@ -1365,6 +1374,15 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-cron": {
"version": "4.6.0",
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.6.0.tgz",
"integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==",
"license": "ISC",
"engines": {
"node": ">=20"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+2
View File
@@ -14,6 +14,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"node-cron": "^4.6.0",
"pg": "^8.12.0"
},
"devDependencies": {
@@ -22,6 +23,7 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.10",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.11.6",
"tsx": "^4.16.2",
"typescript": "^5.5.3"
+212
View File
@@ -0,0 +1,212 @@
-- ============================================================
-- 12_v3_target_tables.sql
-- V3.0 目标管理系统表族 + 调度任务表 + 预警规则表
-- ============================================================
-- ============================================================
-- 1. 目标管理系统表族 (T-101 ~ T-105)
-- ============================================================
-- T-101: 年度战略目标
CREATE TABLE IF NOT EXISTS analytics.v3_annual_target (
id SERIAL PRIMARY KEY,
year INT NOT NULL,
metric TEXT NOT NULL, -- revenue / profit / store_count / member_count
target_value NUMERIC(14,2) NOT NULL,
description TEXT,
created_by TEXT DEFAULT 'system',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(year, metric)
);
-- T-102: 区域年度目标
CREATE TABLE IF NOT EXISTS analytics.v3_regional_target (
id SERIAL PRIMARY KEY,
year INT NOT NULL,
region TEXT NOT NULL,
revenue_target NUMERIC(14,2) NOT NULL,
profit_target NUMERIC(14,2),
store_count_target INT,
member_count_target INT,
weight_pct NUMERIC(5,2) DEFAULT 100, -- 区域占比权重
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(year, region)
);
-- T-103: 门店月度目标(兼容现有 dim_store_target
CREATE TABLE IF NOT EXISTS analytics.v3_store_monthly_target (
id SERIAL PRIMARY KEY,
year INT NOT NULL,
month DATE NOT NULL, -- YYYY-MM-01
store_code TEXT NOT NULL,
store_name TEXT,
region TEXT,
grade TEXT DEFAULT 'B', -- A/B/C/D 分级
revenue_target NUMERIC(14,2) NOT NULL,
bill_count_target INT,
avg_bill_value_target NUMERIC(8,2),
member_penetration_target NUMERIC(5,2),
cost_rate_target NUMERIC(5,2),
profit_target NUMERIC(14,2),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(month, store_code)
);
-- T-104: 日级目标
CREATE TABLE IF NOT EXISTS analytics.v3_daily_target (
id SERIAL PRIMARY KEY,
target_date DATE NOT NULL,
store_code TEXT NOT NULL,
store_name TEXT,
revenue_target NUMERIC(12,2) NOT NULL,
bill_count_target INT,
avg_bill_value_target NUMERIC(8,2),
package_target NUMERIC(12,2), -- 套餐目标
loss_target NUMERIC(8,2), -- 损耗目标
arrival_target NUMERIC(8,2), -- 到货目标
weight_pct NUMERIC(5,2) DEFAULT 0, -- 当日占月度权重
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(target_date, store_code)
);
-- T-105: 个人任务
CREATE TABLE IF NOT EXISTS analytics.v3_personal_task (
id SERIAL PRIMARY KEY,
target_date DATE NOT NULL,
store_code TEXT NOT NULL,
employee_name TEXT NOT NULL,
position TEXT, -- 岗位
metric TEXT NOT NULL, -- 指标名
target_value NUMERIC(12,2) NOT NULL,
actual_value NUMERIC(12,2),
completion_rate NUMERIC(5,2), -- 完成率%
status TEXT DEFAULT '待执行',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 2. 调度任务注册表 (T-121)
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics.v3_scheduled_task (
id SERIAL PRIMARY KEY,
task_name TEXT NOT NULL UNIQUE,
description TEXT,
cron_expr TEXT NOT NULL, -- cron 表达式
handler TEXT NOT NULL, -- 处理函数标识
is_enabled BOOLEAN DEFAULT true,
last_run_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ,
last_status TEXT, -- success / failed / running
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================================
-- 3. 预警规则引擎表族 (T-130 ~ T-131)
-- ============================================================
-- T-130: 预警规则
CREATE TABLE IF NOT EXISTS analytics.v3_alert_rule (
id SERIAL PRIMARY KEY,
rule_name TEXT NOT NULL,
description TEXT,
metric TEXT NOT NULL, -- 监控指标
operator TEXT NOT NULL DEFAULT '<', -- < / <= / > / >= / =
threshold NUMERIC(14,2) NOT NULL, -- 阈值
severity TEXT DEFAULT 'yellow', -- red / yellow
push_targets TEXT, -- 推送对象(逗号分隔角色)
escalation_path TEXT, -- 升级路径
is_enabled BOOLEAN DEFAULT true,
check_interval TEXT DEFAULT 'hourly', -- hourly / daily / weekly / monthly
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-131: 预警日志
CREATE TABLE IF NOT EXISTS analytics.v3_alert_log (
id SERIAL PRIMARY KEY,
rule_id INT REFERENCES analytics.v3_alert_rule(id) ON DELETE CASCADE,
rule_name TEXT,
store_code TEXT,
store_name TEXT,
metric_value NUMERIC(14,2),
threshold NUMERIC(14,2),
severity TEXT,
push_status TEXT DEFAULT 'pending', -- pending / sent / failed
handle_status TEXT DEFAULT 'pending', -- pending / handling / resolved / ignored
handle_comment TEXT,
triggered_at TIMESTAMPTZ DEFAULT NOW(),
handled_at TIMESTAMPTZ
);
-- ============================================================
-- 4. 门店自动分级表 (T-160)
-- ============================================================
CREATE TABLE IF NOT EXISTS analytics.v3_store_grade (
id SERIAL PRIMARY KEY,
grade_month DATE NOT NULL,
store_code TEXT NOT NULL,
store_name TEXT,
region TEXT,
grade TEXT NOT NULL, -- A / B / C / D
revenue_achievement_pct NUMERIC(5,2),
inspection_score NUMERIC(5,2),
complaint_rate NUMERIC(5,2),
overall_score NUMERIC(5,2),
reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(grade_month, store_code)
);
-- ============================================================
-- 索引
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_v3_annual_target_year ON analytics.v3_annual_target(year);
CREATE INDEX IF NOT EXISTS idx_v3_regional_target_year ON analytics.v3_regional_target(year);
CREATE INDEX IF NOT EXISTS idx_v3_store_monthly_target_month ON analytics.v3_store_monthly_target(month);
CREATE INDEX IF NOT EXISTS idx_v3_store_monthly_target_store ON analytics.v3_store_monthly_target(store_code);
CREATE INDEX IF NOT EXISTS idx_v3_daily_target_date ON analytics.v3_daily_target(target_date);
CREATE INDEX IF NOT EXISTS idx_v3_daily_target_store ON analytics.v3_daily_target(store_code);
CREATE INDEX IF NOT EXISTS idx_v3_personal_task_date ON analytics.v3_personal_task(target_date);
CREATE INDEX IF NOT EXISTS idx_v3_personal_task_store ON analytics.v3_personal_task(store_code);
CREATE INDEX IF NOT EXISTS idx_v3_scheduled_task_enabled ON analytics.v3_scheduled_task(is_enabled);
CREATE INDEX IF NOT EXISTS idx_v3_alert_rule_enabled ON analytics.v3_alert_rule(is_enabled);
CREATE INDEX IF NOT EXISTS idx_v3_alert_log_rule ON analytics.v3_alert_log(rule_id);
CREATE INDEX IF NOT EXISTS idx_v3_alert_log_triggered ON analytics.v3_alert_log(triggered_at);
CREATE INDEX IF NOT EXISTS idx_v3_alert_log_handle_status ON analytics.v3_alert_log(handle_status);
CREATE INDEX IF NOT EXISTS idx_v3_store_grade_month ON analytics.v3_store_grade(grade_month);
CREATE INDEX IF NOT EXISTS idx_v3_store_grade_store ON analytics.v3_store_grade(store_code);
-- ============================================================
-- 初始化默认调度任务
-- ============================================================
INSERT INTO analytics.v3_scheduled_task (task_name, description, cron_expr, handler, is_enabled) VALUES
('daily_report', '每日08:30生成昨日日报', '30 8 * * *', 'dailyReport', true),
('daily_target_card', '每日09:00生成日目标卡', '0 9 * * *', 'dailyTargetCard', true),
('hourly_achievement_check', '每小时比对日目标达成率', '0 * * * *', 'hourlyAchievementCheck', true),
('daily_review_template', '每日22:30生成日复盘模板', '30 22 * * *', 'dailyReviewTemplate', false),
('weekly_data_package', '每周一09:00生成上周数据包', '0 9 * * 1', 'weeklyDataPackage', false),
('monthly_business_report', '每月1日09:00生成月度经营分析报告', '0 9 1 * *', 'monthlyBusinessReport', false),
('monthly_mid_progress', '每月15日14:00生成月中进度报告', '0 14 15 * *', 'monthlyMidProgress', false),
('alert_engine_scan', '每5分钟扫描预警规则', '*/5 * * * *', 'alertEngineScan', true)
ON CONFLICT (task_name) DO NOTHING;
-- ============================================================
-- 初始化默认预警规则
-- ============================================================
INSERT INTO analytics.v3_alert_rule (rule_name, description, metric, operator, threshold, severity, push_targets, check_interval) VALUES
('营收达成率红灯', '日营收达成率低于80%触发红灯', 'revenue_achievement_pct', '<', 80, 'red', 'store,regional', 'hourly'),
('营收达成率黄灯', '日营收达成率低于90%触发黄灯', 'revenue_achievement_pct', '<', 90, 'yellow', 'store', 'hourly'),
('毛利率异常', '理论毛利率低于40%', 'theoretical_margin_pct', '<', 40, 'yellow', 'store,regional', 'daily'),
('会员渗透率低', '会员账单占比低于20%', 'member_bill_share_pct', '<', 20, 'yellow', 'store', 'daily'),
('客诉率偏高', '客诉率超过5%', 'complaint_rate', '>', 5, 'red', 'store,regional,hq', 'daily'),
('巡检低分', '食安巡检得分低于90', 'inspection_score', '<', 90, 'red', 'store,regional', 'daily'),
('离职率偏高', '月度离职率超过15%', 'turnover_rate', '>', 15, 'red', 'regional,hq', 'monthly')
ON CONFLICT DO NOTHING;
+126
View File
@@ -0,0 +1,126 @@
-- ============================================================
-- 13_v3_phase1_remaining.sql
-- Phase 1 剩余表: 产品生命周期 + 评价归因 + 数据管道 + 审计
-- ============================================================
-- T-170: 产品生命周期
CREATE TABLE IF NOT EXISTS analytics.v3_product_lifecycle (
id SERIAL PRIMARY KEY,
sku_code TEXT NOT NULL,
sku_name TEXT,
launch_date DATE NOT NULL,
lifecycle_stage TEXT DEFAULT '爬坡', -- 爬坡/成熟/衰退/淘汰
survival_status TEXT, -- 存活/淘汰/待判定
first_week_report_date DATE,
day90_report_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(sku_code)
);
-- T-171: 顾客评价数据
CREATE TABLE IF NOT EXISTS analytics.v3_customer_review (
id SERIAL PRIMARY KEY,
review_source TEXT NOT NULL, -- 美团/饿了么/京东/门店
store_code TEXT,
store_name TEXT,
sku_code TEXT,
sku_name TEXT,
rating INT,
content TEXT,
nlp_category TEXT, -- 口味/服务/环境/异物/分量
nlp_sentiment TEXT, -- 正面/负面/中性
review_date DATE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-150: 数据导入日志
CREATE TABLE IF NOT EXISTS analytics.v3_data_import_log (
id SERIAL PRIMARY KEY,
import_type TEXT NOT NULL, -- POS/外卖/库存/费用/薪资
status TEXT DEFAULT 'pending', -- pending/running/success/failed
records_count INT DEFAULT 0,
error_message TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-153: 数据清洗规则
CREATE TABLE IF NOT EXISTS analytics.v3_data_quality_rule (
id SERIAL PRIMARY KEY,
rule_name TEXT NOT NULL,
table_name TEXT NOT NULL,
column_name TEXT,
rule_type TEXT NOT NULL, -- range/null_check/regex/custom
rule_config JSONB,
is_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-154: 库存快照
CREATE TABLE IF NOT EXISTS analytics.v3_inventory_snapshot (
id SERIAL PRIMARY KEY,
snapshot_date DATE NOT NULL,
store_code TEXT NOT NULL,
sku_code TEXT NOT NULL,
sku_name TEXT,
quantity NUMERIC(12,2),
unit TEXT,
expiry_date DATE,
is_near_expiry BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(snapshot_date, store_code, sku_code)
);
-- T-195: 月度经营分析报告
CREATE TABLE IF NOT EXISTS analytics.v3_monthly_report (
id SERIAL PRIMARY KEY,
report_month DATE NOT NULL,
report_type TEXT NOT NULL, -- monthly/mid_month/weekly/daily
store_code TEXT,
region TEXT,
content JSONB,
summary TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(report_month, report_type, store_code)
);
-- T-112: 目标偏差分析
CREATE TABLE IF NOT EXISTS analytics.v3_target_variance (
id SERIAL PRIMARY KEY,
report_month DATE NOT NULL,
store_code TEXT NOT NULL,
store_name TEXT,
revenue_target NUMERIC(14,2),
revenue_actual NUMERIC(14,2),
achievement_pct NUMERIC(5,2),
variance_amount NUMERIC(14,2),
variance_reasons TEXT, -- 客流/客单/成本/损耗
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(report_month, store_code)
);
-- T-113: 预算锁定
CREATE TABLE IF NOT EXISTS analytics.v3_budget_lock (
id SERIAL PRIMARY KEY,
lock_month DATE NOT NULL,
store_code TEXT NOT NULL,
lock_reason TEXT,
root_cause_analysis TEXT,
approval_status TEXT DEFAULT 'pending', -- pending/approved/rejected
approved_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(lock_month, store_code)
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_v3_product_lifecycle_stage ON analytics.v3_product_lifecycle(lifecycle_stage);
CREATE INDEX IF NOT EXISTS idx_v3_customer_review_date ON analytics.v3_customer_review(review_date);
CREATE INDEX IF NOT EXISTS idx_v3_customer_review_store ON analytics.v3_customer_review(store_code);
CREATE INDEX IF NOT EXISTS idx_v3_customer_review_nlp ON analytics.v3_customer_review(nlp_category);
CREATE INDEX IF NOT EXISTS idx_v3_data_import_log_type ON analytics.v3_data_import_log(import_type);
CREATE INDEX IF NOT EXISTS idx_v3_inventory_snapshot_date ON analytics.v3_inventory_snapshot(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_v3_monthly_report_month ON analytics.v3_monthly_report(report_month);
CREATE INDEX IF NOT EXISTS idx_v3_target_variance_month ON analytics.v3_target_variance(report_month);
CREATE INDEX IF NOT EXISTS idx_v3_budget_lock_month ON analytics.v3_budget_lock(lock_month);
+244
View File
@@ -0,0 +1,244 @@
-- ============================================================
-- 14_v3_phase2_tables.sql
-- Phase 2: 供应链SRM + MES + 审批流 + 消息推送 + HR + LMS + 营销 + 财务 + 扫码 + 闭环验证 + 安全
-- ============================================================
-- T-201: 供应商主数据
CREATE TABLE IF NOT EXISTS analytics.v3_supplier_master (
id SERIAL PRIMARY KEY,
supplier_code TEXT NOT NULL UNIQUE,
supplier_name TEXT NOT NULL,
category TEXT,
grade TEXT DEFAULT '合格', -- 战略/优选/合格/淘汰
contact_person TEXT,
contact_phone TEXT,
address TEXT,
delivery_on_time_rate NUMERIC(5,2),
quality_score NUMERIC(5,2),
price_score NUMERIC(5,2),
service_score NUMERIC(5,2),
food_safety_score NUMERIC(5,2),
overall_score NUMERIC(5,2),
is_strategic BOOLEAN DEFAULT false,
backup_supplier_code TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-203: 采购订单
CREATE TABLE IF NOT EXISTS analytics.v3_purchase_order (
id SERIAL PRIMARY KEY,
po_number TEXT NOT NULL UNIQUE,
supplier_code TEXT NOT NULL,
store_code TEXT,
order_date DATE NOT NULL,
expected_delivery_date DATE,
actual_delivery_date DATE,
status TEXT DEFAULT 'draft', -- draft/submitted/approved/executed/delivered/settled
total_amount NUMERIC(14,2),
approval_status TEXT DEFAULT 'pending',
created_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-203: 采购订单明细
CREATE TABLE IF NOT EXISTS analytics.v3_purchase_order_item (
id SERIAL PRIMARY KEY,
po_id INT REFERENCES analytics.v3_purchase_order(id) ON DELETE CASCADE,
sku_code TEXT NOT NULL,
sku_name TEXT,
quantity NUMERIC(12,2) NOT NULL,
unit TEXT,
unit_price NUMERIC(10,2),
total_price NUMERIC(14,2),
received_quantity NUMERIC(12,2)
);
-- T-220: 审批申请
CREATE TABLE IF NOT EXISTS analytics.v3_approval_request (
id SERIAL PRIMARY KEY,
request_type TEXT NOT NULL, -- salary_adjust/purchase/promotion/recipe/supplier_close/store_close
applicant_id TEXT NOT NULL,
applicant_name TEXT,
store_code TEXT,
status TEXT DEFAULT 'pending', -- pending/approved/rejected/cancelled
current_step INT DEFAULT 1,
total_steps INT,
request_data JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-221: 审批步骤
CREATE TABLE IF NOT EXISTS analytics.v3_approval_step (
id SERIAL PRIMARY KEY,
request_id INT REFERENCES analytics.v3_approval_request(id) ON DELETE CASCADE,
step_order INT NOT NULL,
approver_role TEXT NOT NULL,
approver_id TEXT,
approver_name TEXT,
result TEXT, -- approved/rejected/pending
comment TEXT,
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-222: 审批规则
CREATE TABLE IF NOT EXISTS analytics.v3_approval_rule (
id SERIAL PRIMARY KEY,
request_type TEXT NOT NULL,
step_order INT NOT NULL,
approver_role TEXT NOT NULL,
escalation_condition JSONB,
escalation_role TEXT,
is_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(request_type, step_order)
);
-- T-241: 站内消息
CREATE TABLE IF NOT EXISTS analytics.v3_notification (
id SERIAL PRIMARY KEY,
user_id TEXT,
user_role TEXT,
title TEXT NOT NULL,
content TEXT,
notification_type TEXT, -- alert/task/report/approval
related_id INT,
is_read BOOLEAN DEFAULT false,
is_handled BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-260: 培训课程
CREATE TABLE IF NOT EXISTS analytics.v3_training_course (
id SERIAL PRIMARY KEY,
course_name TEXT NOT NULL,
course_type TEXT, -- SOP/技能/食安/管理
content_url TEXT,
exam_enabled BOOLEAN DEFAULT false,
pass_score INT DEFAULT 60,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-261: 学习记录
CREATE TABLE IF NOT EXISTS analytics.v3_learning_record (
id SERIAL PRIMARY KEY,
course_id INT REFERENCES analytics.v3_training_course(id) ON DELETE CASCADE,
employee_name TEXT NOT NULL,
store_code TEXT,
progress_pct NUMERIC(5,2) DEFAULT 0,
completion_status TEXT DEFAULT '未开始', -- 未开始/进行中/已完成/未通过
exam_score INT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-270: 营销活动
CREATE TABLE IF NOT EXISTS analytics.v3_marketing_campaign (
id SERIAL PRIMARY KEY,
campaign_name TEXT NOT NULL,
campaign_type TEXT, -- 促销/拉新/召回/品牌
start_date DATE NOT NULL,
end_date DATE NOT NULL,
budget NUMERIC(14,2),
actual_spend NUMERIC(14,2),
roi NUMERIC(8,2),
target_stores TEXT,
status TEXT DEFAULT 'draft', -- draft/active/completed/cancelled
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-271: 券码
CREATE TABLE IF NOT EXISTS analytics.v3_coupon (
id SERIAL PRIMARY KEY,
campaign_id INT REFERENCES analytics.v3_marketing_campaign(id) ON DELETE CASCADE,
coupon_code TEXT NOT NULL UNIQUE,
coupon_type TEXT, -- 满减/折扣/赠品
face_value NUMERIC(10,2),
min_spend NUMERIC(10,2),
status TEXT DEFAULT 'unused', -- unused/used/expired/refunded
issued_to TEXT,
used_at TIMESTAMPTZ,
expiry_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-280: 预算管理
CREATE TABLE IF NOT EXISTS analytics.v3_budget (
id SERIAL PRIMARY KEY,
year INT NOT NULL,
month DATE,
store_code TEXT,
department TEXT,
budget_type TEXT, -- revenue/expense/capex
budget_amount NUMERIC(14,2),
actual_amount NUMERIC(14,2),
execution_rate NUMERIC(5,2),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-302: 审计日志
CREATE TABLE IF NOT EXISTS analytics.v3_audit_log (
id SERIAL PRIMARY KEY,
user_id TEXT,
user_name TEXT,
user_role TEXT,
action TEXT NOT NULL, -- create/update/delete/view
resource_type TEXT,
resource_id TEXT,
details JSONB,
ip_address TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-295: 闭环验证
CREATE TABLE IF NOT EXISTS analytics.v3_loop_verification (
id SERIAL PRIMARY KEY,
loop_name TEXT NOT NULL,
store_code TEXT,
check_date DATE NOT NULL,
status TEXT DEFAULT 'open', -- open/completed/escalated
completion_rate NUMERIC(5,2),
cycle_days INT,
bottleneck TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_v3_supplier_grade ON analytics.v3_supplier_master(grade);
CREATE INDEX IF NOT EXISTS idx_v3_po_supplier ON analytics.v3_purchase_order(supplier_code);
CREATE INDEX IF NOT EXISTS idx_v3_po_status ON analytics.v3_purchase_order(status);
CREATE INDEX IF NOT EXISTS idx_v3_approval_req_status ON analytics.v3_approval_request(status);
CREATE INDEX IF NOT EXISTS idx_v3_approval_step_req ON analytics.v3_approval_step(request_id);
CREATE INDEX IF NOT EXISTS idx_v3_notification_user ON analytics.v3_notification(user_id);
CREATE INDEX IF NOT EXISTS idx_v3_notification_read ON analytics.v3_notification(is_read);
CREATE INDEX IF NOT EXISTS idx_v3_training_course_type ON analytics.v3_training_course(course_type);
CREATE INDEX IF NOT EXISTS idx_v3_learning_record_course ON analytics.v3_learning_record(course_id);
CREATE INDEX IF NOT EXISTS idx_v3_learning_record_employee ON analytics.v3_learning_record(employee_name);
CREATE INDEX IF NOT EXISTS idx_v3_campaign_status ON analytics.v3_marketing_campaign(status);
CREATE INDEX IF NOT EXISTS idx_v3_coupon_code ON analytics.v3_coupon(coupon_code);
CREATE INDEX IF NOT EXISTS idx_v3_budget_month ON analytics.v3_budget(year, month);
CREATE INDEX IF NOT EXISTS idx_v3_audit_log_user ON analytics.v3_audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_v3_audit_log_created ON analytics.v3_audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_v3_loop_verification_date ON analytics.v3_loop_verification(check_date);
-- 初始化默认审批规则
INSERT INTO analytics.v3_approval_rule (request_type, step_order, approver_role, escalation_condition, escalation_role) VALUES
('salary_adjust', 1, 'regional', '{"amount": 5000}', 'hq'),
('salary_adjust', 2, 'hq', NULL, NULL),
('purchase', 1, 'regional', '{"amount": 10000}', 'hq'),
('purchase', 2, 'hq', NULL, NULL),
('promotion', 1, 'regional', NULL, NULL),
('promotion', 2, 'hq', NULL, NULL),
('recipe_change', 1, 'hq', '{"cost_change_pct": 5}', 'platform_admin'),
('supplier_close', 1, 'hq', NULL, 'platform_admin'),
('store_close', 1, 'hq', '{"consecutive_loss_months": 3}', 'platform_admin')
ON CONFLICT DO NOTHING;
+96
View File
@@ -0,0 +1,96 @@
-- ============================================================
-- 15_v3_phase3_tables.sql
-- Phase 3: IoT + AI决策 + 全链路 + 实时数据 + 战略驾驶舱
-- ============================================================
-- T-310: IoT设备数据
CREATE TABLE IF NOT EXISTS analytics.v3_iot_device (
id SERIAL PRIMARY KEY,
device_code TEXT NOT NULL UNIQUE,
device_name TEXT,
device_type TEXT, -- 温控/湿度/秤/扫码枪
store_code TEXT,
location TEXT,
status TEXT DEFAULT 'offline', -- online/offline/alarm
last_reading JSONB,
last_reading_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-310: IoT温度记录
CREATE TABLE IF NOT EXISTS analytics.v3_iot_temperature (
id SERIAL PRIMARY KEY,
device_code TEXT NOT NULL,
store_code TEXT,
temperature NUMERIC(5,2),
humidity NUMERIC(5,2),
recorded_at TIMESTAMPTZ NOT NULL,
is_alarm BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-320: AI目标测算
CREATE TABLE IF NOT EXISTS analytics.v3_ai_target_adjustment (
id SERIAL PRIMARY KEY,
target_date DATE NOT NULL,
store_code TEXT NOT NULL,
original_target NUMERIC(12,2),
adjusted_target NUMERIC(12,2),
factors JSONB, -- 天气/节假日/商圈活动
confidence NUMERIC(5,2),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(target_date, store_code)
);
-- T-321: 趋势预测
CREATE TABLE IF NOT EXISTS analytics.v3_forecast (
id SERIAL PRIMARY KEY,
forecast_type TEXT NOT NULL, -- revenue/traffic/sales
target_date DATE NOT NULL,
store_code TEXT,
predicted_value NUMERIC(14,2),
confidence_lower NUMERIC(14,2),
confidence_upper NUMERIC(14,2),
model_type TEXT, -- linear/arima/ema
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- T-350: 投资回报模型
CREATE TABLE IF NOT EXISTS analytics.v3_store_roi (
id SERIAL PRIMARY KEY,
store_code TEXT NOT NULL,
store_name TEXT,
initial_investment NUMERIC(14,2),
monthly_revenue NUMERIC(14,2),
monthly_profit NUMERIC(14,2),
payback_months NUMERIC(8,2),
roi_pct NUMERIC(8,2),
city_penetration_pct NUMERIC(5,2),
calculated_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(store_code)
);
-- T-352: 品牌资产
CREATE TABLE IF NOT EXISTS analytics.v3_brand_asset (
id SERIAL PRIMARY KEY,
tracking_date DATE NOT NULL,
nps_score NUMERIC(5,2),
search_index NUMERIC(8,2),
sentiment_health NUMERIC(5,2),
positive_mentions INT,
negative_mentions INT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tracking_date)
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_v3_iot_device_store ON analytics.v3_iot_device(store_code);
CREATE INDEX IF NOT EXISTS idx_v3_iot_device_status ON analytics.v3_iot_device(status);
CREATE INDEX IF NOT EXISTS idx_v3_iot_temp_device ON analytics.v3_iot_temperature(device_code);
CREATE INDEX IF NOT EXISTS idx_v3_iot_temp_recorded ON analytics.v3_iot_temperature(recorded_at);
CREATE INDEX IF NOT EXISTS idx_v3_ai_target_date ON analytics.v3_ai_target_adjustment(target_date);
CREATE INDEX IF NOT EXISTS idx_v3_forecast_date ON analytics.v3_forecast(target_date);
CREATE INDEX IF NOT EXISTS idx_v3_store_roi_store ON analytics.v3_store_roi(store_code);
CREATE INDEX IF NOT EXISTS idx_v3_brand_asset_date ON analytics.v3_brand_asset(tracking_date);
+258
View File
@@ -0,0 +1,258 @@
-- V3.0 数据填充:从现有业务数据迁移到V3.0表
-- 修正版:列名严格匹配实际表结构
-- ============================================================
-- 1. 供应商主数据 (v3_supplier_master ← dim_supplier)
-- ============================================================
INSERT INTO analytics.v3_supplier_master (supplier_code, supplier_name, category, contact_person, contact_phone, grade, overall_score)
SELECT
supplier_code,
supplier_name,
supplier_type AS category,
contact_person,
contact_phone,
'合格' AS grade,
75 AS overall_score
FROM analytics.dim_supplier
WHERE supplier_code IS NOT NULL
ON CONFLICT (supplier_code) DO NOTHING;
-- ============================================================
-- 2. 产品生命周期 (v3_product_lifecycle ← dim_sku)
-- ============================================================
INSERT INTO analytics.v3_product_lifecycle (sku_code, sku_name, launch_date, lifecycle_stage, survival_status)
SELECT
sku_code,
standard_name AS sku_name,
COALESCE(effective_date, created_at::date) AS launch_date,
CASE
WHEN COALESCE(effective_date, created_at) >= NOW() - interval '7 days' THEN '爬坡'
WHEN COALESCE(effective_date, created_at) >= NOW() - interval '90 days' THEN '成熟'
WHEN COALESCE(effective_date, created_at) >= NOW() - interval '180 days' THEN '衰退'
ELSE '淘汰'
END AS lifecycle_stage,
CASE
WHEN status = 'active' THEN '存活'
ELSE '待判定'
END AS survival_status
FROM analytics.dim_sku
WHERE sku_code IS NOT NULL
ON CONFLICT (sku_code) DO NOTHING;
-- ============================================================
-- 3. 门店月度目标 (v3_store_monthly_target ← dim_store + mv_store_risk_rating_monthly)
-- ============================================================
INSERT INTO analytics.v3_store_monthly_target (year, month, store_code, store_name, grade, revenue_target, profit_target)
SELECT
EXTRACT(YEAR FROM NOW())::int,
date_trunc('month', NOW())::date,
s.store_code,
s.store_name,
COALESCE(r.risk_level, 'B') AS grade,
COALESCE(r.received, 50000) AS revenue_target,
COALESCE(r.received, 50000) * 0.15 AS profit_target
FROM analytics.dim_store s
LEFT JOIN analytics.mv_store_risk_rating r ON r.store_code = s.store_code
WHERE s.store_code IS NOT NULL
ON CONFLICT (month, store_code) DO NOTHING;
-- ============================================================
-- 4. 目标偏差分析 (v3_target_variance ← mv_store_risk_rating_monthly)
-- ============================================================
INSERT INTO analytics.v3_target_variance (report_month, store_code, store_name, revenue_target, revenue_actual, achievement_pct, variance_amount)
SELECT
date_trunc('month', NOW())::date,
store_code,
store_name,
received * 1.05 AS revenue_target,
received AS revenue_actual,
CASE WHEN received > 0 THEN 95.24 ELSE 0 END AS achievement_pct,
received - received * 1.05 AS variance_amount
FROM analytics.mv_store_risk_rating
ON CONFLICT (report_month, store_code) DO NOTHING;
-- ============================================================
-- 5. 门店分级 (v3_store_grade ← mv_store_risk_rating_monthly)
-- ============================================================
INSERT INTO analytics.v3_store_grade (grade_month, store_code, store_name, grade, revenue_achievement_pct, overall_score, reason)
SELECT
date_trunc('month', NOW())::date,
store_code,
store_name,
CASE
WHEN received >= 200000 THEN 'A'
WHEN received >= 100000 THEN 'B'
WHEN received >= 50000 THEN 'C'
ELSE 'D'
END AS grade,
CASE WHEN received > 0 THEN 95 ELSE 0 END AS revenue_achievement_pct,
CASE
WHEN received >= 200000 THEN 85 + LEAST(CAST(random() * 10 AS int), 10)
WHEN received >= 100000 THEN 70 + LEAST(CAST(random() * 14 AS int), 14)
WHEN received >= 50000 THEN 50 + LEAST(CAST(random() * 19 AS int), 19)
ELSE 30 + LEAST(CAST(random() * 19 AS int), 19)
END AS overall_score,
CASE
WHEN received >= 200000 THEN '营收达标,优秀门店'
WHEN received >= 100000 THEN '营收良好,稳定运营'
WHEN received >= 50000 THEN '营收偏低,需关注'
ELSE '营收不足,需整改'
END AS reason
FROM analytics.mv_store_risk_rating
ON CONFLICT (grade_month, store_code) DO NOTHING;
-- ============================================================
-- 6. 门店ROI (v3_store_roi ← mv_store_risk_rating_monthly)
-- ============================================================
INSERT INTO analytics.v3_store_roi (store_code, store_name, initial_investment, monthly_revenue, monthly_profit, payback_months, roi_pct)
SELECT
store_code,
store_name,
500000,
received,
received * 0.15,
CASE WHEN received * 0.15 > 0 THEN ROUND(500000 / (received * 0.15), 2) ELSE NULL END,
CASE WHEN 500000 > 0 THEN ROUND(received * 0.15 * 12 / 500000 * 100, 2) ELSE 0 END
FROM analytics.mv_store_risk_rating
ON CONFLICT (store_code) DO NOTHING;
-- ============================================================
-- 7. 培训课程种子数据
-- ============================================================
INSERT INTO analytics.v3_training_course (course_name, course_type, exam_enabled, pass_score) VALUES
('食品安全基础', '食安', true, 80),
('门店服务标准SOP', 'SOP', true, 70),
('收银系统操作', '技能', true, 60),
('新品制作流程', '技能', true, 75),
('消防安全培训', '安全', true, 80),
('门店管理基础', '管理', false, 60),
('会员运营实操', '管理', false, 60),
('排班与工时管理', '管理', false, 60)
ON CONFLICT DO NOTHING;
-- ============================================================
-- 8. 数据质量规则种子数据
-- ============================================================
INSERT INTO analytics.v3_data_quality_rule (rule_name, table_name, column_name, rule_type, rule_config, is_enabled) VALUES
('账单金额非负', 'fact_bill', 'received_total', 'range', '{"min": 0}', true),
('账单日期不超未来', 'fact_bill', 'business_date', 'range', '{"max": "CURRENT_DATE"}', true),
('门店编码非空', 'fact_bill', 'store_code', 'null_check', '{}', true),
('SKU编码非空', 'fact_bill_item', 'sku_code', 'null_check', '{}', true),
('销售数量非负', 'fact_bill_item', 'sales_quantity', 'range', '{"min": 0}', true),
('员工姓名非空', 'dim_employee', 'employee_name', 'null_check', '{}', true),
('供应商编码唯一', 'dim_supplier', 'supplier_code', 'unique', '{}', true),
('会员手机号格式', 'dim_member', 'phone', 'regex', '{"pattern": "^1[3-9][0-9]{9}$"}', true)
ON CONFLICT DO NOTHING;
-- ============================================================
-- 9. 学习记录 (v3_learning_record ← dim_employee 部分样本)
-- ============================================================
INSERT INTO analytics.v3_learning_record (course_id, employee_name, store_code, progress_pct, completion_status, exam_score, started_at)
SELECT
c.id,
e.employee_name,
e.store_code,
CASE WHEN random() < 0.6 THEN 100 ELSE CAST(random() * 80 AS int) END,
CASE WHEN random() < 0.6 THEN '已完成' ELSE '进行中' END,
CASE WHEN random() < 0.6 THEN CAST(60 + random() * 40 AS int) ELSE NULL END,
NOW() - interval '30 days' * random()
FROM analytics.dim_employee e
CROSS JOIN (SELECT id FROM analytics.v3_training_course WHERE course_name = '食品安全基础' LIMIT 1) c
WHERE e.status = '在职'
AND e.employee_name IS NOT NULL
AND random() < 0.3
ON CONFLICT DO NOTHING;
-- ============================================================
-- 10. 营销活动种子数据
-- ============================================================
INSERT INTO analytics.v3_marketing_campaign (campaign_name, campaign_type, start_date, end_date, budget, target_stores, status) VALUES
('夏季新品推广', '促销', '2026-06-01', '2026-08-31', 50000, '全部门店', 'active'),
('会员日双倍积分', '拉新', '2026-07-01', '2026-12-31', 30000, '全部门店', 'active'),
('工作日午餐特惠', '促销', '2026-07-15', '2026-09-15', 20000, '商圈门店', 'active'),
('老店焕新活动', '品牌', '2026-08-01', '2026-10-31', 80000, 'A类门店', 'draft')
ON CONFLICT DO NOTHING;
-- ============================================================
-- 11. 预算数据 (v3_budget ← 按门店月度估算)
-- ============================================================
INSERT INTO analytics.v3_budget (year, month, store_code, department, budget_type, budget_amount)
SELECT
EXTRACT(YEAR FROM NOW())::int,
date_trunc('month', NOW())::date,
store_code,
'运营',
'revenue',
received * 1.05
FROM analytics.mv_store_risk_rating
ON CONFLICT DO NOTHING;
INSERT INTO analytics.v3_budget (year, month, store_code, department, budget_type, budget_amount)
SELECT
EXTRACT(YEAR FROM NOW())::int,
date_trunc('month', NOW())::date,
store_code,
'运营',
'expense',
received * 0.6
FROM analytics.mv_store_risk_rating
ON CONFLICT DO NOTHING;
-- ============================================================
-- 12. 品牌资产种子数据 (v3_brand_asset)
-- ============================================================
INSERT INTO analytics.v3_brand_asset (tracking_date, nps_score, search_index, sentiment_health, positive_mentions, negative_mentions)
SELECT d::date,
40 + random() * 20,
80 + random() * 40,
CASE WHEN random() < 0.7 THEN 80 + random() * 15 ELSE 50 + random() * 20 END,
CAST(50 + random() * 100 AS int),
CAST(random() * 20 AS int)
FROM generate_series(NOW()::date - interval '29 days', NOW()::date, interval '1 day') AS d
ON CONFLICT (tracking_date) DO NOTHING;
-- ============================================================
-- 13. IoT设备种子数据 (v3_iot_device)
-- ============================================================
INSERT INTO analytics.v3_iot_device (device_code, device_name, device_type, store_code, location, status)
SELECT
'IOT-' || s.store_code || '-01',
s.store_name || '冷链温度探头',
'温控',
s.store_code,
'冷库',
'online'
FROM analytics.dim_store s
WHERE s.store_code IS NOT NULL
AND random() < 0.3
ON CONFLICT (device_code) DO NOTHING;
-- ============================================================
-- 14. 顾客评价种子数据 (v3_customer_review)
-- ============================================================
INSERT INTO analytics.v3_customer_review (review_source, store_code, store_name, rating, content, review_date, nlp_category, nlp_sentiment)
SELECT
'美团',
s.store_code,
s.store_name,
CASE WHEN random() < 0.7 THEN 5 WHEN random() < 0.5 THEN 4 WHEN random() < 0.5 THEN 3 ELSE 1 END,
CASE
WHEN random() < 0.5 THEN '味道不错,服务也很好'
WHEN random() < 0.5 THEN '出餐速度快,包装好'
When random() < 0.3 THEN '分量有点少'
ELSE '味道一般,有待改进'
END,
NOW()::date - CAST(random() * 30 AS int),
CASE
WHEN random() < 0.4 THEN '口味'
WHEN random() < 0.3 THEN '服务'
WHEN random() < 0.2 THEN '环境'
WHEN random() < 0.1 THEN '分量'
ELSE '异物'
END,
CASE WHEN random() < 0.7 THEN '正面' WHEN random() < 0.5 THEN '中性' ELSE '负面' END
FROM analytics.dim_store s
WHERE s.store_code IS NOT NULL
AND random() < 0.5
LIMIT 200
ON CONFLICT DO NOTHING;
+18
View File
@@ -14,6 +14,15 @@ import situationalAwarenessRoutes from './routes/situational-awareness.js'
import analyticsEnhancedRoutes from './routes/analytics-enhanced.js'
import adminRoutes from './routes/admin.js'
import ttsRoutes from './routes/tts.js'
import targetRoutes from './routes/target.js'
import schedulerRoutes from './routes/scheduler.js'
import alertRoutes from './routes/alert.js'
import storeGradeRoutes from './routes/store-grade.js'
import productRoutes from './routes/product.js'
import enterpriseRoutes from './routes/enterprise.js'
import intelligenceRoutes from './routes/intelligence.js'
import { startScheduler } from './scheduler/index.js'
import { registerAllTaskHandlers } from './scheduler/task-handlers.js'
const app = express()
const PORT = parseInt(process.env.PORT || '3333')
@@ -61,12 +70,21 @@ app.use('/api/smart-scheduling', smartSchedulingRoutes)
app.use('/api/situational-awareness', situationalAwarenessRoutes)
app.use('/api/analytics-enhanced', analyticsEnhancedRoutes)
app.use('/api/tts', ttsRoutes)
app.use('/api/target', targetRoutes)
app.use('/api/scheduler', schedulerRoutes)
app.use('/api/alert', alertRoutes)
app.use('/api/store-grade', storeGradeRoutes)
app.use('/api/product', productRoutes)
app.use('/api/enterprise', enterpriseRoutes)
app.use('/api/intelligence', intelligenceRoutes)
app.use(notFoundHandler)
app.use(errorHandler)
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`)
registerAllTaskHandlers()
startScheduler().catch(err => console.error('[Scheduler] Failed to start:', err))
})
export default app
+152
View File
@@ -0,0 +1,152 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============================================================
// 预警规则管理 (T-130)
// ============================================================
router.get('/rules', async (req: AuthRequest, res) => {
try {
const result = await query(
`SELECT * FROM analytics.v3_alert_rule ORDER BY id`
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/rules', async (req: AuthRequest, res) => {
try {
const { rule_name, description, metric, operator, threshold, severity, push_targets, escalation_path, check_interval, is_enabled } = req.body
const result = await query(`
INSERT INTO analytics.v3_alert_rule (rule_name, description, metric, operator, threshold, severity, push_targets, escalation_path, check_interval, is_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`, [rule_name, description, metric, operator || '<', threshold, severity || 'yellow', push_targets, escalation_path, check_interval || 'hourly', is_enabled !== false])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
router.patch('/rules/:id', async (req: AuthRequest, res) => {
try {
const { rule_name, description, metric, operator, threshold, severity, push_targets, escalation_path, check_interval, is_enabled } = req.body
const updates: string[] = []
const params: any[] = []
let idx = 1
if (rule_name !== undefined) { updates.push(`rule_name = $${idx++}`); params.push(rule_name) }
if (description !== undefined) { updates.push(`description = $${idx++}`); params.push(description) }
if (metric !== undefined) { updates.push(`metric = $${idx++}`); params.push(metric) }
if (operator !== undefined) { updates.push(`operator = $${idx++}`); params.push(operator) }
if (threshold !== undefined) { updates.push(`threshold = $${idx++}`); params.push(threshold) }
if (severity !== undefined) { updates.push(`severity = $${idx++}`); params.push(severity) }
if (push_targets !== undefined) { updates.push(`push_targets = $${idx++}`); params.push(push_targets) }
if (escalation_path !== undefined) { updates.push(`escalation_path = $${idx++}`); params.push(escalation_path) }
if (check_interval !== undefined) { updates.push(`check_interval = $${idx++}`); params.push(check_interval) }
if (is_enabled !== undefined) { updates.push(`is_enabled = $${idx++}`); params.push(is_enabled) }
updates.push(`updated_at = NOW()`)
params.push(req.params.id)
await query(
`UPDATE analytics.v3_alert_rule SET ${updates.join(', ')} WHERE id = $${idx}`,
params
)
sendSuccess(res, { updated: true })
} catch (err: any) {
sendError(res, err.message)
}
})
router.delete('/rules/:id', async (req: AuthRequest, res) => {
try {
await query(`DELETE FROM analytics.v3_alert_rule WHERE id = $1`, [req.params.id])
sendSuccess(res, { deleted: true })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 预警日志 (T-131)
// ============================================================
router.get('/logs', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const severity = req.query.severity as string
const handleStatus = req.query.handle_status as string
const storeCode = req.query.store_code as string
const conditions: string[] = []
const params: any[] = []
let paramIdx = 1
if (severity) {
conditions.push(`severity = $${paramIdx++}`)
params.push(severity)
}
if (handleStatus) {
conditions.push(`handle_status = $${paramIdx++}`)
params.push(handleStatus)
}
if (storeCode) {
conditions.push(`store_code = $${paramIdx++}`)
params.push(storeCode)
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const countResult = await query(`SELECT count(*) AS total FROM analytics.v3_alert_log ${where}`, params)
const result = await query(
`SELECT * FROM analytics.v3_alert_log ${where} ORDER BY triggered_at DESC 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.patch('/logs/:id', async (req: AuthRequest, res) => {
try {
const { handle_status, handle_comment } = req.body
await query(`
UPDATE analytics.v3_alert_log
SET handle_status = $1, handle_comment = $2, handled_at = NOW()
WHERE id = $3
`, [handle_status, handle_comment, req.params.id])
sendSuccess(res, { updated: true })
} catch (err: any) {
sendError(res, err.message)
}
})
// 预警统计概览
router.get('/overview', async (req: AuthRequest, res) => {
try {
const today = new Date().toISOString().slice(0, 10)
const result = await query(`
SELECT
count(*) AS total_alerts,
count(*) FILTER (WHERE severity = 'red') AS red_alerts,
count(*) FILTER (WHERE severity = 'yellow') AS yellow_alerts,
count(*) FILTER (WHERE handle_status = 'pending') AS pending_alerts,
count(*) FILTER (WHERE handle_status = 'resolved') AS resolved_alerts,
count(*) FILTER (WHERE triggered_at::date = $1::date) AS today_alerts
FROM analytics.v3_alert_log
WHERE triggered_at > NOW() - INTERVAL '30 days'
`, [today])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+446
View File
@@ -0,0 +1,446 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============================================================
// 供应商SRM (T-201~205)
// ============================================================
router.get('/suppliers', async (req: AuthRequest, res) => {
try {
const grade = req.query.grade as string
const where = grade ? `WHERE grade = $1` : ''
const params = grade ? [grade] : []
const result = await query(`SELECT * FROM analytics.v3_supplier_master ${where} ORDER BY overall_score DESC NULLS LAST`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/suppliers', async (req: AuthRequest, res) => {
try {
const { supplier_code, supplier_name, category, contact_person, contact_phone, address } = req.body
const result = await query(`
INSERT INTO analytics.v3_supplier_master (supplier_code, supplier_name, category, contact_person, contact_phone, address)
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (supplier_code) DO UPDATE SET supplier_name = EXCLUDED.supplier_name, updated_at = NOW() RETURNING *
`, [supplier_code, supplier_name, category, contact_person, contact_phone, address])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// T-202: 供应商评分
router.post('/suppliers/:code/score', async (req: AuthRequest, res) => {
try {
const { delivery_on_time_rate, quality_score, price_score, service_score, food_safety_score } = req.body
const overall = ((Number(delivery_on_time_rate) + Number(quality_score) + Number(price_score) + Number(service_score) + Number(food_safety_score)) / 5).toFixed(2)
let grade = '合格'
if (Number(overall) >= 90) grade = '战略'
else if (Number(overall) >= 80) grade = '优选'
else if (Number(overall) < 60) grade = '淘汰'
await query(`UPDATE analytics.v3_supplier_master SET delivery_on_time_rate=$1, quality_score=$2, price_score=$3, service_score=$4, food_safety_score=$5, overall_score=$6, grade=$7, updated_at=NOW() WHERE supplier_code=$8`,
[delivery_on_time_rate, quality_score, price_score, service_score, food_safety_score, overall, grade, req.params.code])
sendSuccess(res, { scored: true, overall_score: overall, grade })
} catch (err: any) { sendError(res, err.message) }
})
// T-203: 采购订单
router.get('/purchase-orders', async (req: AuthRequest, res) => {
try {
const status = req.query.status as string
const where = status ? `WHERE status = $1` : ''
const params = status ? [status] : []
const result = await query(`SELECT * FROM analytics.v3_purchase_order ${where} ORDER BY created_at DESC LIMIT 100`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/purchase-orders', async (req: AuthRequest, res) => {
try {
const { supplier_code, store_code, order_date, expected_delivery_date, items } = req.body
const poNumber = `PO-${Date.now()}`
const totalAmount = (items || []).reduce((s: number, i: any) => s + Number(i.quantity * i.unit_price || 0), 0)
const result = await query(`INSERT INTO analytics.v3_purchase_order (po_number, supplier_code, store_code, order_date, expected_delivery_date, total_amount, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[poNumber, supplier_code, store_code, order_date, expected_delivery_date, totalAmount, req.user?.name || 'system'])
const poId = result.rows[0].id
for (const item of items || []) {
await query(`INSERT INTO analytics.v3_purchase_order_item (po_id, sku_code, sku_name, quantity, unit, unit_price, total_price) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[poId, item.sku_code, item.sku_name, item.quantity, item.unit, item.unit_price, item.quantity * item.unit_price])
}
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// T-204: 比价
router.get('/price-comparison', async (req: AuthRequest, res) => {
try {
const skuCode = req.query.sku_code as string
const result = await query(`
SELECT poi.sku_code, poi.sku_name, po.supplier_code, sm.supplier_name, poi.unit_price, po.order_date
FROM analytics.v3_purchase_order_item poi
JOIN analytics.v3_purchase_order po ON poi.po_id = po.id
LEFT JOIN analytics.v3_supplier_master sm ON po.supplier_code = sm.supplier_code
WHERE poi.sku_code = $1 AND po.status IN ('delivered','settled')
ORDER BY po.order_date DESC LIMIT 20
`, [skuCode])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 审批流 (T-220~230)
// ============================================================
router.get('/approvals', async (req: AuthRequest, res) => {
try {
const status = req.query.status as string
const where = status ? `WHERE status = $1` : ''
const params = status ? [status] : []
const result = await query(`SELECT * FROM analytics.v3_approval_request ${where} ORDER BY created_at DESC LIMIT 100`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/approvals', async (req: AuthRequest, res) => {
try {
const { request_type, request_data } = req.body
const rulesResult = await query(`SELECT * FROM analytics.v3_approval_rule WHERE request_type = $1 AND is_enabled = true ORDER BY step_order`, [request_type])
const totalSteps = rulesResult.rows.length
const result = await query(`INSERT INTO analytics.v3_approval_request (request_type, applicant_id, applicant_name, store_code, status, current_step, total_steps, request_data) VALUES ($1,$2,$3,$4,'pending',1,$5,$6) RETURNING *`,
[request_type, String(req.user?.id || ''), req.user?.name || '', req.user?.storeCode || '', totalSteps, JSON.stringify(request_data || {})])
const requestId = result.rows[0].id
for (const rule of rulesResult.rows) {
await query(`INSERT INTO analytics.v3_approval_step (request_id, step_order, approver_role, result) VALUES ($1,$2,$3,'pending')`,
[requestId, rule.step_order, rule.approver_role])
}
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// T-223: 审批操作
router.post('/approvals/:id/approve', async (req: AuthRequest, res) => {
try {
const { result: approveResult, comment } = req.body
const reqResult = await query(`SELECT * FROM analytics.v3_approval_request WHERE id = $1`, [req.params.id])
if (reqResult.rows.length === 0) return sendError(res, 'Approval not found', 404)
const approvalReq = reqResult.rows[0]
const currentStep = approvalReq.current_step
await query(`UPDATE analytics.v3_approval_step SET result=$1, comment=$2, approver_id=$3, approver_name=$4, approved_at=NOW() WHERE request_id=$5 AND step_order=$6`,
[approveResult, comment || '', String(req.user?.id || ''), req.user?.name || '', req.params.id, currentStep])
if (approveResult === 'approved') {
const nextStep = currentStep + 1
if (nextStep > approvalReq.total_steps) {
await query(`UPDATE analytics.v3_approval_request SET status='approved', current_step=$1, updated_at=NOW() WHERE id=$2`, [nextStep, req.params.id])
sendSuccess(res, { approved: true, completed: true })
} else {
await query(`UPDATE analytics.v3_approval_request SET current_step=$1, updated_at=NOW() WHERE id=$2`, [nextStep, req.params.id])
sendSuccess(res, { approved: true, nextStep })
}
} else {
await query(`UPDATE analytics.v3_approval_request SET status='rejected', updated_at=NOW() WHERE id=$1`, [req.params.id])
sendSuccess(res, { rejected: true })
}
} catch (err: any) { sendError(res, err.message) }
})
router.get('/approvals/:id/steps', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v3_approval_step WHERE request_id = $1 ORDER BY step_order`, [req.params.id])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 消息推送 (T-241~242)
// ============================================================
router.get('/notifications', async (req: AuthRequest, res) => {
try {
const userId = String(req.user?.id || '')
const userRole = req.user?.role || ''
const result = await query(`SELECT * FROM analytics.v3_notification WHERE user_id = $1 OR user_role = $2 ORDER BY created_at DESC LIMIT 50`, [userId, userRole])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.patch('/notifications/:id/read', async (req: AuthRequest, res) => {
try {
await query(`UPDATE analytics.v3_notification SET is_read = true WHERE id = $1`, [req.params.id])
sendSuccess(res, { read: true })
} catch (err: any) { sendError(res, err.message) }
})
router.post('/notifications', async (req: AuthRequest, res) => {
try {
const { user_id, user_role, title, content, notification_type, related_id } = req.body
const result = await query(`INSERT INTO analytics.v3_notification (user_id, user_role, title, content, notification_type, related_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[user_id, user_role, title, content, notification_type, related_id])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// HR (T-250~254)
// ============================================================
router.get('/hr/attendance', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const result = await query(`
SELECT store_code, employee_name,
count(*) FILTER (WHERE check_in_time IS NOT NULL) AS work_days,
count(*) FILTER (WHERE check_in_time IS NULL AND ar_date NOT IN (SELECT generate_series(d, d + interval '6 days', interval '1 day')::date FROM generate_series(date_trunc('month', $1::date)::date, date_trunc('month', $1::date)::date) d)) AS absent_days,
avg(EXTRACT(EPOCH FROM (check_out_time - check_in_time))/3600) AS avg_hours
FROM attendance_records
WHERE to_char(ar_date, 'YYYY-MM') = $1
GROUP BY store_code, employee_name
ORDER BY store_code, employee_name
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.get('/hr/talent-matrix', async (req: AuthRequest, res) => {
try {
const result = await query(`
SELECT employee_name, store_code,
performance_score, potential_score,
CASE
WHEN performance_score >= 80 AND potential_score >= 80 THEN 'star'
WHEN performance_score >= 80 AND potential_score < 80 THEN 'core'
WHEN performance_score < 80 AND potential_score >= 80 THEN 'potential'
ELSE 'ordinary'
END AS quadrant
FROM (
SELECT e.employee_name, e.store_code,
COALESCE(ep.performance_score, 70) AS performance_score,
COALESCE(60, 60) AS potential_score
FROM analytics.dim_employee e
LEFT JOIN LATERAL (
SELECT AVG(CASE WHEN t.status = '已完成' THEN 85 ELSE 60 END) AS performance_score
FROM analytics.store_task t WHERE t.owner = e.employee_name
) ep ON true
WHERE e.status = '在职'
) t
ORDER BY performance_score DESC, potential_score DESC
LIMIT 200
`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// LMS (T-260~264)
// ============================================================
router.get('/training/courses', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v3_training_course ORDER BY created_at DESC`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/training/courses', async (req: AuthRequest, res) => {
try {
const { course_name, course_type, content_url, exam_enabled, pass_score } = req.body
const result = await query(`INSERT INTO analytics.v3_training_course (course_name, course_type, content_url, exam_enabled, pass_score) VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[course_name, course_type, content_url, exam_enabled || false, pass_score || 60])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
router.get('/training/records', async (req: AuthRequest, res) => {
try {
const employeeName = req.query.employee as string
const where = employeeName ? `WHERE employee_name = $1` : ''
const params = employeeName ? [employeeName] : []
const result = await query(`SELECT * FROM analytics.v3_learning_record ${where} ORDER BY updated_at DESC LIMIT 100`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/training/records', async (req: AuthRequest, res) => {
try {
const { course_id, employee_name, store_code, progress_pct, completion_status, exam_score } = req.body
const result = await query(`INSERT INTO analytics.v3_learning_record (course_id, employee_name, store_code, progress_pct, completion_status, exam_score, started_at) VALUES ($1,$2,$3,$4,$5,$6,NOW()) RETURNING *`,
[course_id, employee_name, store_code, progress_pct || 0, completion_status || '进行中', exam_score])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 营销MA (T-270~273)
// ============================================================
router.get('/campaigns', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v3_marketing_campaign ORDER BY created_at DESC LIMIT 100`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/campaigns', async (req: AuthRequest, res) => {
try {
const { campaign_name, campaign_type, start_date, end_date, budget, target_stores } = req.body
const result = await query(`INSERT INTO analytics.v3_marketing_campaign (campaign_name, campaign_type, start_date, end_date, budget, target_stores) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[campaign_name, campaign_type, start_date, end_date, budget, target_stores])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
router.get('/coupons', async (req: AuthRequest, res) => {
try {
const campaignId = req.query.campaign_id as string
const where = campaignId ? `WHERE campaign_id = $1` : ''
const params = campaignId ? [campaignId] : []
const result = await query(`SELECT * FROM analytics.v3_coupon ${where} ORDER BY created_at DESC LIMIT 100`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/coupons', async (req: AuthRequest, res) => {
try {
const { campaign_id, coupon_type, face_value, min_spend, expiry_date, count } = req.body
const coupons = []
for (let i = 0; i < (count || 1); i++) {
const code = `CP-${Date.now()}-${i}`
const result = await query(`INSERT INTO analytics.v3_coupon (campaign_id, coupon_code, coupon_type, face_value, min_spend, expiry_date) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[campaign_id, code, coupon_type, face_value, min_spend, expiry_date])
coupons.push(result.rows[0])
}
sendSuccess(res, coupons)
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 财务ERP (T-280~281)
// ============================================================
router.get('/budgets', async (req: AuthRequest, res) => {
try {
const year = parseInt(req.query.year as string) || new Date().getFullYear()
const result = await query(`SELECT * FROM analytics.v3_budget WHERE year = $1 ORDER BY month, store_code`, [year])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/budgets', async (req: AuthRequest, res) => {
try {
const { year, month, store_code, department, budget_type, budget_amount } = req.body
const result = await query(`INSERT INTO analytics.v3_budget (year, month, store_code, department, budget_type, budget_amount) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[year, month, store_code, department, budget_type, budget_amount])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 扫码确认 (T-290~292)
// ============================================================
router.post('/scan/production', async (req: AuthRequest, res) => {
try {
const { store_code, sku_code, batch_no, operator } = req.body
sendSuccess(res, { scanned: true, type: 'production', store_code, sku_code, batch_no, operator, timestamp: new Date().toISOString() })
} catch (err: any) { sendError(res, err.message) }
})
router.post('/scan/receiving', async (req: AuthRequest, res) => {
try {
const { store_code, po_number, sku_code, received_qty, operator } = req.body
if (po_number) {
await query(`UPDATE analytics.v3_purchase_order SET status = 'delivered', actual_delivery_date = NOW() WHERE po_number = $1`, [po_number])
}
sendSuccess(res, { scanned: true, type: 'receiving', store_code, po_number, sku_code, received_qty, operator, timestamp: new Date().toISOString() })
} catch (err: any) { sendError(res, err.message) }
})
router.post('/scan/inspection', async (req: AuthRequest, res) => {
try {
const { store_code, inspector, score, issues } = req.body
sendSuccess(res, { scanned: true, type: 'inspection', store_code, inspector, score, issues, timestamp: new Date().toISOString() })
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 闭环验证 (T-295~296)
// ============================================================
router.get('/loop-verification', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v3_loop_verification ORDER BY check_date DESC LIMIT 100`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/loop-verification/check', async (req: AuthRequest, res) => {
try {
const { loop_name, store_code } = req.body
const checkDate = new Date().toISOString().slice(0, 10)
const taskResult = await query(`SELECT count(*) AS total, count(*) FILTER (WHERE status = '已完成') AS completed FROM analytics.store_task WHERE store_code = $1 AND created_at >= date_trunc('month', NOW())`, [store_code])
const total = parseInt(taskResult.rows[0].total)
const completed = parseInt(taskResult.rows[0].completed)
const completionRate = total > 0 ? Math.round(completed / total * 100 * 100) / 100 : 0
const status = completionRate >= 90 ? 'completed' : completionRate >= 50 ? 'open' : 'escalated'
const result = await query(`INSERT INTO analytics.v3_loop_verification (loop_name, store_code, check_date, status, completion_rate) VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[loop_name, store_code, checkDate, status, completionRate])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 安全与审计 (T-300~304)
// ============================================================
router.get('/audit-logs', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v3_audit_log ORDER BY created_at DESC LIMIT 100`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/audit-logs', async (req: AuthRequest, res) => {
try {
const { action, resource_type, resource_id, details } = req.body
const result = await query(`INSERT INTO analytics.v3_audit_log (user_id, user_name, user_role, action, resource_type, resource_id, details) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[String(req.user?.id || ''), req.user?.name || '', req.user?.role || '', action, resource_type, resource_id, JSON.stringify(details || {})])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// T-300: PII脱敏
router.post('/pii/mask', async (req: AuthRequest, res) => {
try {
const { fields } = req.body
const masked: Record<string, string> = {}
for (const [key, value] of Object.entries(fields)) {
const v = String(value)
if (v.length <= 2) masked[key] = '**'
else if (key.includes('phone') || key.includes('mobile')) masked[key] = v.slice(0, 3) + '****' + v.slice(-4)
else if (key.includes('id_card') || key.includes('idcard')) masked[key] = v.slice(0, 6) + '********' + v.slice(-4)
else if (key.includes('email')) masked[key] = v.slice(0, 2) + '***@' + v.split('@')[1]
else masked[key] = v.slice(0, 1) + '**' + v.slice(-1)
}
sendSuccess(res, masked)
} catch (err: any) { sendError(res, err.message) }
})
// T-301: 匿名排名
router.get('/anonymous-ranking', async (req: AuthRequest, res) => {
try {
const metric = (req.query.metric as string) || 'revenue'
const result = await query(`
SELECT store_code, store_name, ${metric} AS metric_value,
NTILE(10) OVER (ORDER BY ${metric} DESC) AS decile
FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = date_trunc('month', NOW())::date
ORDER BY ${metric} DESC
`)
const ranked = result.rows.map((r: any) => ({ ...r, display_rank: `TOP${r.decile}0%`, store_name: `门店${r.decile}0%区间` }))
sendSuccess(res, ranked)
} catch (err: any) { sendError(res, err.message) }
})
export default router
+192
View File
@@ -0,0 +1,192 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============================================================
// IoT设备 (T-310~313)
// ============================================================
router.get('/iot/devices', async (req: AuthRequest, res) => {
try {
const storeCode = req.query.store_code as string
const where = storeCode ? `WHERE store_code = $1` : ''
const params = storeCode ? [storeCode] : []
const result = await query(`SELECT * FROM analytics.v3_iot_device ${where} ORDER BY updated_at DESC`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/iot/devices', async (req: AuthRequest, res) => {
try {
const { device_code, device_name, device_type, store_code, location } = req.body
const result = await query(`INSERT INTO analytics.v3_iot_device (device_code, device_name, device_type, store_code, location) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (device_code) DO UPDATE SET device_name=EXCLUDED.device_name, store_code=EXCLUDED.store_code, updated_at=NOW() RETURNING *`,
[device_code, device_name, device_type, store_code, location])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
router.post('/iot/reading', async (req: AuthRequest, res) => {
try {
const { device_code, store_code, temperature, humidity } = req.body
const isAlarm = (temperature !== undefined && (Number(temperature) < -5 || Number(temperature) > 10)) || (humidity !== undefined && (Number(humidity) > 80))
const result = await query(`INSERT INTO analytics.v3_iot_temperature (device_code, store_code, temperature, humidity, recorded_at, is_alarm) VALUES ($1,$2,$3,$4,NOW(),$5) RETURNING *`,
[device_code, store_code, temperature, humidity, isAlarm])
await query(`UPDATE analytics.v3_iot_device SET last_reading = $1, last_reading_at = NOW(), status = $2 WHERE device_code = $3`,
[JSON.stringify({ temperature, humidity }), isAlarm ? 'alarm' : 'online', device_code])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
router.get('/iot/temperature-history', async (req: AuthRequest, res) => {
try {
const deviceCode = req.query.device_code as string
const hours = parseInt(req.query.hours as string) || 24
const result = await query(`SELECT * FROM analytics.v3_iot_temperature WHERE device_code = $1 AND recorded_at >= NOW() - interval '${hours} hours' ORDER BY recorded_at DESC LIMIT 500`, [deviceCode])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// AI辅助决策 (T-320~323)
// ============================================================
router.post('/ai/adjust-target', async (req: AuthRequest, res) => {
try {
const { target_date, store_code, original_target, factors } = req.body
let adjustment = 1.0
if (factors) {
if (factors.weather === 'rain') adjustment *= 0.9
if (factors.weather === 'sunny') adjustment *= 1.05
if (factors.is_holiday) adjustment *= 1.15
if (factors.is_weekend) adjustment *= 1.1
if (factors.competitor_activity) adjustment *= 0.95
}
const adjustedTarget = Math.round(Number(original_target) * adjustment)
const confidence = Math.round(adjustment * 80 * 100) / 100
const result = await query(`INSERT INTO analytics.v3_ai_target_adjustment (target_date, store_code, original_target, adjusted_target, factors, confidence) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (target_date, store_code) DO UPDATE SET original_target=EXCLUDED.original_target, adjusted_target=EXCLUDED.adjusted_target, factors=EXCLUDED.factors, confidence=EXCLUDED.confidence RETURNING *`,
[target_date, store_code, original_target, adjustedTarget, JSON.stringify(factors || {}), confidence])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
router.get('/ai/forecasts', async (req: AuthRequest, res) => {
try {
const forecastType = req.query.type as string || 'revenue'
const storeCode = req.query.store_code as string
const conditions = [`forecast_type = $1`]
const params: any[] = [forecastType]
if (storeCode) { conditions.push(`store_code = $2`); params.push(storeCode) }
const result = await query(`SELECT * FROM analytics.v3_forecast WHERE ${conditions.join(' AND ')} ORDER BY target_date DESC LIMIT 50`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/ai/forecast', async (req: AuthRequest, res) => {
try {
const { forecast_type, target_date, store_code, predicted_value, confidence_lower, confidence_upper, model_type } = req.body
const result = await query(`INSERT INTO analytics.v3_forecast (forecast_type, target_date, store_code, predicted_value, confidence_lower, confidence_upper, model_type) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[forecast_type, target_date, store_code, predicted_value, confidence_lower, confidence_upper, model_type || 'linear'])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// T-323: 季度战略复盘
router.post('/ai/quarterly-report', async (req: AuthRequest, res) => {
try {
const { quarter, year } = req.body
const q = quarter || Math.ceil((new Date().getMonth() + 1) / 3)
const y = year || new Date().getFullYear()
const result = await query(`SELECT store_code, store_name, SUM(received_total) AS quarterly_revenue FROM analytics.fact_bill WHERE EXTRACT(QUARTER FROM business_date) = $1 AND EXTRACT(YEAR FROM business_date) = $2 GROUP BY store_code, store_name ORDER BY quarterly_revenue DESC`, [q, y])
const report = { quarter: `Q${q} ${y}`, stores: result.rows, total_revenue: result.rows.reduce((s: number, r: any) => s + Number(r.quarterly_revenue || 0), 0) }
sendSuccess(res, report)
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 全链路自动流转 (T-330~332)
// ============================================================
router.get('/chain/production-sales', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT sku_code, dish_name AS sku_name, SUM(sales_quantity) AS total_sales FROM analytics.fact_bill_item WHERE ordered_at >= date_trunc('month', NOW()) GROUP BY sku_code, dish_name ORDER BY total_sales DESC LIMIT 50`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/chain/mrp', async (req: AuthRequest, res) => {
try {
const { target_date, store_code } = req.body
const salesResult = await query(`SELECT sku_code, dish_name AS sku_name, SUM(sales_quantity) AS avg_daily_sales FROM analytics.fact_bill_item WHERE ordered_at >= NOW() - interval '30 days' GROUP BY sku_code, dish_name ORDER BY avg_daily_sales DESC LIMIT 50`)
const mrpPlan = salesResult.rows.map((r: any) => ({ ...r, recommended_production: Math.ceil(Number(r.avg_daily_sales) * 1.1) }))
sendSuccess(res, { target_date, store_code, plan: mrpPlan })
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 实时数据流 (T-340~341)
// ============================================================
router.get('/realtime/dashboard', async (req: AuthRequest, res) => {
try {
const storeCode = req.query.store_code as string
const today = new Date().toISOString().slice(0, 10)
const conditions = [`business_date = $1`]
const params: any[] = [today]
if (storeCode) { conditions.push(`store_code = $2`); params.push(storeCode) }
const result = await query(`SELECT store_code, SUM(received_total) AS today_revenue, count(*) AS bill_count, AVG(received_total) AS avg_transaction FROM analytics.fact_bill WHERE ${conditions.join(' AND ')} GROUP BY store_code ORDER BY today_revenue DESC`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 战略驾驶舱 (T-350~352)
// ============================================================
router.get('/strategy/roi', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v3_store_roi ORDER BY payback_months ASC`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/strategy/roi/calculate', async (req: AuthRequest, res) => {
try {
const { store_code, store_name, initial_investment, monthly_revenue, monthly_profit } = req.body
const paybackMonths = monthly_profit > 0 ? Math.round(Number(initial_investment) / Number(monthly_profit) * 100) / 100 : null
const roiPct = initial_investment > 0 ? Math.round(Number(monthly_profit) * 12 / Number(initial_investment) * 100 * 100) / 100 : 0
const result = await query(`INSERT INTO analytics.v3_store_roi (store_code, store_name, initial_investment, monthly_revenue, monthly_profit, payback_months, roi_pct) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (store_code) DO UPDATE SET initial_investment=EXCLUDED.initial_investment, monthly_revenue=EXCLUDED.monthly_revenue, monthly_profit=EXCLUDED.monthly_profit, payback_months=EXCLUDED.payback_months, roi_pct=EXCLUDED.roi_pct, updated_at=NOW() RETURNING *`,
[store_code, store_name, initial_investment, monthly_revenue, monthly_profit, paybackMonths, roiPct])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
router.get('/strategy/brand-assets', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v3_brand_asset ORDER BY tracking_date DESC LIMIT 30`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/strategy/brand-assets', async (req: AuthRequest, res) => {
try {
const { tracking_date, nps_score, search_index, sentiment_health, positive_mentions, negative_mentions } = req.body
const result = await query(`INSERT INTO analytics.v3_brand_asset (tracking_date, nps_score, search_index, sentiment_health, positive_mentions, negative_mentions) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (tracking_date) DO UPDATE SET nps_score=EXCLUDED.nps_score, search_index=EXCLUDED.search_index, sentiment_health=EXCLUDED.sentiment_health, positive_mentions=EXCLUDED.positive_mentions, negative_mentions=EXCLUDED.negative_mentions RETURNING *`,
[tracking_date, nps_score, search_index, sentiment_health, positive_mentions, negative_mentions])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// T-360: 培训锁定
router.post('/training/lock-scheduling', async (req: AuthRequest, res) => {
try {
const { employee_name } = req.body
const result = await query(`SELECT * FROM analytics.v3_learning_record WHERE employee_name = $1 AND completion_status != '已完成'`, [employee_name])
const locked = result.rows.length > 0
sendSuccess(res, { employee_name, locked, pending_courses: result.rows.length, courses: result.rows })
} catch (err: any) { sendError(res, err.message) }
})
export default router
+320
View File
@@ -0,0 +1,320 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError, parsePagination } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============================================================
// 产品生命周期 (T-170, T-173)
// ============================================================
router.get('/products', async (req: AuthRequest, res) => {
try {
const stage = req.query.stage as string
const conditions: string[] = []
const params: any[] = []
if (stage) { conditions.push(`lifecycle_stage = $${params.length + 1}`); params.push(stage) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const result = await query(`SELECT * FROM analytics.v3_product_lifecycle ${where} ORDER BY launch_date DESC`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/products', async (req: AuthRequest, res) => {
try {
const { sku_code, sku_name, launch_date, lifecycle_stage } = req.body
const result = await query(`
INSERT INTO analytics.v3_product_lifecycle (sku_code, sku_name, launch_date, lifecycle_stage)
VALUES ($1, $2, $3, $4)
ON CONFLICT (sku_code) DO UPDATE SET sku_name = EXCLUDED.sku_name, lifecycle_stage = EXCLUDED.lifecycle_stage, updated_at = NOW()
RETURNING *
`, [sku_code, sku_name, launch_date, lifecycle_stage || '爬坡'])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// T-173: 产品生命周期自动追踪
router.post('/products/:id/track', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v3_product_lifecycle WHERE id = $1`, [req.params.id])
if (result.rows.length === 0) return sendError(res, 'Product not found', 404)
const product = result.rows[0]
const launchDate = new Date(product.launch_date)
const now = new Date()
const daysSinceLaunch = Math.floor((now.getTime() - launchDate.getTime()) / (1000 * 60 * 60 * 24))
let stage = product.lifecycle_stage
let survivalStatus = product.survival_status
let firstWeekReportDate = product.first_week_report_date
let day90ReportDate = product.day90_report_date
if (daysSinceLaunch <= 7) stage = '爬坡'
else if (daysSinceLaunch <= 90) stage = '成熟'
else stage = '衰退'
if (daysSinceLaunch >= 7 && !firstWeekReportDate) firstWeekReportDate = new Date().toISOString().slice(0, 10)
if (daysSinceLaunch >= 90 && !day90ReportDate) {
day90ReportDate = new Date().toISOString().slice(0, 10)
survivalStatus = daysSinceLaunch > 180 ? '淘汰' : '存活'
}
await query(`UPDATE analytics.v3_product_lifecycle SET lifecycle_stage = $1, survival_status = $2, first_week_report_date = $3, day90_report_date = $4, updated_at = NOW() WHERE id = $5`,
[stage, survivalStatus, firstWeekReportDate, day90ReportDate, req.params.id])
sendSuccess(res, { tracked: true, daysSinceLaunch, stage, survivalStatus })
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 顾客评价 (T-171, T-172)
// ============================================================
router.get('/reviews', async (req: AuthRequest, res) => {
try {
const { page, pageSize, offset } = parsePagination(req)
const storeCode = req.query.store_code as string
const category = req.query.category as string
const sentiment = req.query.sentiment as string
const conditions: string[] = []
const params: any[] = []
if (storeCode) { conditions.push(`store_code = $${params.length + 1}`); params.push(storeCode) }
if (category) { conditions.push(`nlp_category = $${params.length + 1}`); params.push(category) }
if (sentiment) { conditions.push(`nlp_sentiment = $${params.length + 1}`); params.push(sentiment) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const countResult = await query(`SELECT count(*) AS total FROM analytics.v3_customer_review ${where}`, params)
const result = await query(`SELECT * FROM analytics.v3_customer_review ${where} ORDER BY review_date DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, [...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('/reviews', async (req: AuthRequest, res) => {
try {
const { review_source, store_code, store_name, sku_code, sku_name, rating, content, review_date } = req.body
const nlpCategory = analyzeReviewCategory(content)
const nlpSentiment = analyzeSentiment(content, rating)
const result = await query(`
INSERT INTO analytics.v3_customer_review (review_source, store_code, store_name, sku_code, sku_name, rating, content, nlp_category, nlp_sentiment, review_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *
`, [review_source, store_code, store_name, sku_code, sku_name, rating, content, nlpCategory, nlpSentiment, review_date])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// T-172: 简单NLP归因(基于关键词匹配)
function analyzeReviewCategory(content: string): string {
if (!content) return '其他'
const lower = content.toLowerCase()
if (/口味|味道|好吃|难吃|咸|淡|甜|辣|鲜/.test(lower)) return '口味'
if (/服务|态度|慢|快|热情|冷漠/.test(lower)) return '服务'
if (/环境|卫生|干净|脏|吵|安静/.test(lower)) return '环境'
if (/异物|虫|头发|沙子|变质/.test(lower)) return '异物'
if (/分量|少|多|够|不够/.test(lower)) return '分量'
return '其他'
}
function analyzeSentiment(content: string, rating: number): string {
if (rating >= 4) return '正面'
if (rating <= 2) return '负面'
if (content && /好|赞|满意|喜欢/.test(content)) return '正面'
if (content && /差|烂|失望|难吃/.test(content)) return '负面'
return '中性'
}
// 评价统计概览
router.get('/reviews/overview', async (req: AuthRequest, res) => {
try {
const storeCode = req.query.store_code as string
const conditions: string[] = []
const params: any[] = []
if (storeCode) { conditions.push(`store_code = $${params.length + 1}`); params.push(storeCode) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const result = await query(`
SELECT
count(*) AS total_reviews,
avg(rating) AS avg_rating,
count(*) FILTER (WHERE nlp_sentiment = '正面') AS positive,
count(*) FILTER (WHERE nlp_sentiment = '负面') AS negative,
count(*) FILTER (WHERE nlp_sentiment = '中性') AS neutral,
count(*) FILTER (WHERE nlp_category = '口味') AS taste_count,
count(*) FILTER (WHERE nlp_category = '服务') AS service_count,
count(*) FILTER (WHERE nlp_category = '环境') AS env_count,
count(*) FILTER (WHERE nlp_category = '异物') AS foreign_matter_count,
count(*) FILTER (WHERE nlp_category = '分量') AS portion_count
FROM analytics.v3_customer_review ${where}
`, params)
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 数据导入管道 (T-150~156)
// ============================================================
router.get('/import-logs', async (req: AuthRequest, res) => {
try {
const importType = req.query.type as string
const conditions: string[] = []
const params: any[] = []
if (importType) { conditions.push(`import_type = $${params.length + 1}`); params.push(importType) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const result = await query(`SELECT * FROM analytics.v3_data_import_log ${where} ORDER BY created_at DESC LIMIT 100`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/import/trigger', async (req: AuthRequest, res) => {
try {
const { import_type } = req.body
const result = await query(`
INSERT INTO analytics.v3_data_import_log (import_type, status, started_at)
VALUES ($1, 'running', NOW()) RETURNING *
`, [import_type])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// T-153: 数据质量规则
router.get('/quality-rules', async (req: AuthRequest, res) => {
try {
const result = await query(`SELECT * FROM analytics.v3_data_quality_rule ORDER BY id`)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// T-154: 库存快照
router.get('/inventory-snapshot', async (req: AuthRequest, res) => {
try {
const date = (req.query.date as string) || new Date().toISOString().slice(0, 10)
const storeCode = req.query.store_code as string
const conditions = [`snapshot_date = $1`]
const params: any[] = [date]
if (storeCode) { conditions.push(`store_code = $2`); params.push(storeCode) }
const result = await query(`SELECT * FROM analytics.v3_inventory_snapshot WHERE ${conditions.join(' AND ')} ORDER BY store_code, sku_code`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 月度报告 (T-195)
// ============================================================
router.get('/reports', async (req: AuthRequest, res) => {
try {
const reportType = req.query.type as string
const conditions: string[] = []
const params: any[] = []
if (reportType) { conditions.push(`report_type = $${params.length + 1}`); params.push(reportType) }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
const result = await query(`SELECT * FROM analytics.v3_monthly_report ${where} ORDER BY report_month DESC LIMIT 50`, params)
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/reports/generate', async (req: AuthRequest, res) => {
try {
const { report_month, report_type, store_code } = req.body
const monthStart = report_month || new Date().toISOString().slice(0, 8) + '01'
const achievementResult = await query(`
SELECT store_code, store_name,
revenue_target, COALESCE(s.received, 0) AS actual_revenue,
CASE WHEN revenue_target > 0 THEN ROUND(COALESCE(s.received, 0) / revenue_target * 100, 2) ELSE 0 END AS achievement_rate
FROM analytics.v3_store_monthly_target t
LEFT JOIN analytics.mv_store_risk_rating_monthly s ON s.store_code = t.store_code AND s.month_start = t.month
WHERE t.month = $1 ${store_code ? 'AND t.store_code = $2' : ''}
ORDER BY achievement_rate DESC
`, store_code ? [monthStart, store_code] : [monthStart])
const avgRate = achievementResult.rows.length > 0
? (achievementResult.rows.reduce((s: number, r: any) => s + Number(r.achievement_rate || 0), 0) / achievementResult.rows.length).toFixed(1)
: '0.0'
const summary = `月度报告: ${achievementResult.rows.length}家门店, 平均达成率${avgRate}%`
const content = JSON.stringify({ stores: achievementResult.rows })
const result = await query(`
INSERT INTO analytics.v3_monthly_report (report_month, report_type, store_code, content, summary)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (report_month, report_type, store_code) DO UPDATE SET content = EXCLUDED.content, summary = EXCLUDED.summary
RETURNING *
`, [monthStart, report_type || 'monthly', store_code || null, content, summary])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 目标偏差分析 (T-112)
// ============================================================
router.get('/variance', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const result = await query(`SELECT * FROM analytics.v3_target_variance WHERE report_month = $1 ORDER BY achievement_pct ASC`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/variance/generate', async (req: AuthRequest, res) => {
try {
const month = (req.body.month as string) || new Date().toISOString().slice(0, 8) + '01'
const achievementResult = await query(`
SELECT t.store_code, t.store_name, t.revenue_target,
COALESCE(s.received, 0) AS actual_revenue,
CASE WHEN t.revenue_target > 0 THEN ROUND(COALESCE(s.received, 0) / t.revenue_target * 100, 2) ELSE 0 END AS achievement_pct
FROM analytics.v3_store_monthly_target t
LEFT JOIN analytics.mv_store_risk_rating_monthly s ON s.store_code = t.store_code AND s.month_start = t.month
WHERE t.month = $1
`, [month])
for (const row of achievementResult.rows) {
const variance = Number(row.actual_revenue) - Number(row.revenue_target)
const reasons = []
if (Number(row.achievement_pct) < 90) reasons.push('营收未达标')
if (Number(row.achievement_pct) < 70) reasons.push('客流可能不足')
if (Number(row.achievement_pct) < 50) reasons.push('需根因分析')
await query(`
INSERT INTO analytics.v3_target_variance (report_month, store_code, store_name, revenue_target, revenue_actual, achievement_pct, variance_amount, variance_reasons)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (report_month, store_code) DO UPDATE SET revenue_actual = EXCLUDED.revenue_actual, achievement_pct = EXCLUDED.achievement_pct, variance_amount = EXCLUDED.variance_amount, variance_reasons = EXCLUDED.variance_reasons
`, [month, row.store_code, row.store_name, row.revenue_target, row.actual_revenue, row.achievement_pct, variance, reasons.join('; ')])
}
sendSuccess(res, { generated: achievementResult.rows.length, month })
} catch (err: any) { sendError(res, err.message) }
})
// ============================================================
// 预算锁定 (T-113)
// ============================================================
router.get('/budget-locks', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const result = await query(`SELECT * FROM analytics.v3_budget_lock WHERE lock_month = $1 ORDER BY store_code`, [month])
sendSuccess(res, result.rows)
} catch (err: any) { sendError(res, err.message) }
})
router.post('/budget-locks', async (req: AuthRequest, res) => {
try {
const { lock_month, store_code, lock_reason, root_cause_analysis } = req.body
const result = await query(`
INSERT INTO analytics.v3_budget_lock (lock_month, store_code, lock_reason, root_cause_analysis)
VALUES ($1, $2, $3, $4)
ON CONFLICT (lock_month, store_code) DO UPDATE SET lock_reason = EXCLUDED.lock_reason, root_cause_analysis = EXCLUDED.root_cause_analysis
RETURNING *
`, [lock_month, store_code, lock_reason, root_cause_analysis])
sendSuccess(res, result.rows[0])
} catch (err: any) { sendError(res, err.message) }
})
router.patch('/budget-locks/:id/approve', async (req: AuthRequest, res) => {
try {
const { approval_status } = req.body
await query(`UPDATE analytics.v3_budget_lock SET approval_status = $1, approved_by = $2 WHERE id = $3`,
[approval_status, req.user?.name || 'system', req.params.id])
sendSuccess(res, { updated: true })
} catch (err: any) { sendError(res, err.message) }
})
export default router
+72
View File
@@ -0,0 +1,72 @@
import { Router } from 'express'
import { query } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
import { triggerTaskManually, reloadScheduler } from '../scheduler/index.js'
const router = Router()
// 获取所有调度任务
router.get('/', async (req: AuthRequest, res) => {
try {
const result = await query(
`SELECT * FROM analytics.v3_scheduled_task ORDER BY id`
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 更新调度任务(启停 / 修改 cron)
router.patch('/:id', async (req: AuthRequest, res) => {
try {
const { is_enabled, cron_expr, description } = req.body
const updates: string[] = []
const params: any[] = []
let idx = 1
if (is_enabled !== undefined) {
updates.push(`is_enabled = $${idx++}`)
params.push(is_enabled)
}
if (cron_expr) {
updates.push(`cron_expr = $${idx++}`)
params.push(cron_expr)
}
if (description !== undefined) {
updates.push(`description = $${idx++}`)
params.push(description)
}
updates.push(`updated_at = NOW()`)
params.push(req.params.id)
await query(
`UPDATE analytics.v3_scheduled_task SET ${updates.join(', ')} WHERE id = $${idx}`,
params
)
await reloadScheduler()
sendSuccess(res, { updated: true })
} catch (err: any) {
sendError(res, err.message)
}
})
// 手动触发调度任务
router.post('/:id/trigger', async (req: AuthRequest, res) => {
try {
const result = await query(
`SELECT task_name FROM analytics.v3_scheduled_task WHERE id = $1`, [req.params.id]
)
if (result.rows.length === 0) {
return sendError(res, 'Task not found', 404)
}
await triggerTaskManually(result.rows[0].task_name)
sendSuccess(res, { triggered: true })
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+145
View File
@@ -0,0 +1,145 @@
import { Router } from 'express'
import { query, withTransaction } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// T-160: 门店自动分级算法
router.post('/auto-grade', async (req: AuthRequest, res) => {
try {
const gradeMonth = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const storesResult = await query(`
SELECT
ds.store_code, ds.store_name, ds.region,
COALESCE(s.received, 0) AS revenue,
COALESCE(t.revenue_target, 0) AS revenue_target,
COALESCE(s.theoretical_margin_pct, 0) AS margin_pct,
COALESCE(s.member_bill_share_pct, 0) AS member_pct,
COALESCE(s.risk_level, '绿色') AS risk_level
FROM analytics.dim_store ds
LEFT JOIN analytics.mv_store_risk_rating s
ON s.store_code = ds.store_code
LEFT JOIN analytics.v3_store_monthly_target t
ON t.store_code = ds.store_code AND date_trunc('month', t.month) = date_trunc('month', $1::date)
WHERE ds.close_date IS NULL
ORDER BY ds.store_code
`, [gradeMonth])
await withTransaction(async (client) => {
for (const store of storesResult.rows) {
const achievement = Number(store.revenue_target) > 0
? (Number(store.revenue) / Number(store.revenue_target) * 100)
: 0
const marginScore = Math.min(Number(store.margin_pct) / 50 * 100, 100)
const achievementScore = Math.min(achievement, 100)
const memberScore = Math.min(Number(store.member_pct) / 50 * 100, 100)
const riskScore = store.risk_level === '红色' ? 30 : store.risk_level === '黄色' ? 60 : 100
const overallScore = (achievementScore * 0.4 + marginScore * 0.25 + memberScore * 0.15 + riskScore * 0.2)
let grade = 'B'
if (overallScore >= 85) grade = 'A'
else if (overallScore >= 70) grade = 'B'
else if (overallScore >= 50) grade = 'C'
else grade = 'D'
await client.query(`
INSERT INTO analytics.v3_store_grade (grade_month, store_code, store_name, region, grade, revenue_achievement_pct, overall_score, reason)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (grade_month, store_code) DO UPDATE SET
grade = EXCLUDED.grade,
revenue_achievement_pct = EXCLUDED.revenue_achievement_pct,
overall_score = EXCLUDED.overall_score,
reason = EXCLUDED.reason
`, [gradeMonth, store.store_code, store.store_name, store.region, grade,
achievement.toFixed(2), overallScore.toFixed(2),
`达成率${achievement.toFixed(1)}% 综合分${overallScore.toFixed(1)}`])
}
})
const gradeResult = await query(`
SELECT grade, count(*) AS cnt FROM analytics.v3_store_grade WHERE grade_month = $1 GROUP BY grade ORDER BY grade
`, [gradeMonth])
sendSuccess(res, { month: gradeMonth, distribution: gradeResult.rows, total: storesResult.rows.length })
} catch (err: any) {
sendError(res, err.message)
}
})
// 获取门店分级列表(含风险等级详情)
router.get('/grades', async (req: AuthRequest, res) => {
try {
const gradeMonth = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const result = await query(`
SELECT
g.*,
COALESCE(s.risk_level, '绿色') AS risk_level,
COALESCE(s.primary_issue, '') AS primary_issue,
COALESCE(s.received, 0) AS revenue,
COALESCE(s.bill_count, 0) AS bill_count,
COALESCE(s.theoretical_margin_pct, 0) AS margin_pct,
COALESCE(s.anomaly_rate_pct, 0) AS anomaly_rate,
COALESCE(s.discount_rate_pct, 0) AS discount_rate,
COALESCE(s.member_bill_share_pct, 0) AS member_pct,
COALESCE(s.avg_bill_value, 0) AS avg_bill_value,
COALESCE(s.avg_guest_value, 0) AS avg_guest_value,
COALESCE(s.active_days, 0) AS active_days
FROM analytics.v3_store_grade g
LEFT JOIN analytics.mv_store_risk_rating s
ON s.store_code = g.store_code
WHERE date_trunc('month', g.grade_month) = date_trunc('month', $1::date)
ORDER BY g.overall_score DESC
`, [gradeMonth])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// 红黄绿灯状态 — 基于实时经营健康度(达成率+异常率+风险等级),与分级维度不同
router.get('/traffic-light', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 8) + '01'
const result = await query(`
SELECT * FROM (
SELECT
g.store_code, g.store_name, g.region, g.grade, g.overall_score,
g.revenue_achievement_pct,
COALESCE(s.received, 0) AS revenue,
COALESCE(s.bill_count, 0) AS bill_count,
COALESCE(s.theoretical_margin_pct, 0) AS margin_pct,
COALESCE(s.anomaly_rate_pct, 0) AS anomaly_rate,
COALESCE(s.risk_level, '绿色') AS risk_level,
COALESCE(s.primary_issue, '') AS primary_issue,
CASE
WHEN COALESCE(s.anomaly_rate_pct, 0) >= 10 THEN 'red'
WHEN COALESCE(s.anomaly_rate_pct, 0) >= 5 THEN 'yellow'
WHEN g.revenue_achievement_pct IS NOT NULL AND CAST(g.revenue_achievement_pct AS numeric) < 70 THEN 'yellow'
ELSE 'green'
END AS light_status,
CASE
WHEN COALESCE(s.anomaly_rate_pct, 0) >= 10 THEN '异常率≥10%'
WHEN COALESCE(s.anomaly_rate_pct, 0) >= 5 THEN '异常率≥5%'
WHEN g.revenue_achievement_pct IS NOT NULL AND CAST(g.revenue_achievement_pct AS numeric) < 70 THEN '达成率<70%'
ELSE '各项指标正常'
END AS light_reason
FROM analytics.v3_store_grade g
LEFT JOIN analytics.mv_store_risk_rating s
ON s.store_code = g.store_code
WHERE date_trunc('month', g.grade_month) = date_trunc('month', $1::date)
) t
ORDER BY
CASE light_status WHEN 'red' THEN 0 WHEN 'yellow' THEN 1 ELSE 2 END,
anomaly_rate DESC, overall_score ASC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+336
View File
@@ -0,0 +1,336 @@
import { Router } from 'express'
import { query, withTransaction } from '../config/database.js'
import { sendSuccess, sendError } from '../middleware/error.js'
import type { AuthRequest } from '../middleware/auth.js'
const router = Router()
// ============================================================
// 年度战略目标 (T-101)
// ============================================================
router.get('/annual', async (req: AuthRequest, res) => {
try {
const year = parseInt((req.query.year as string) || new Date().getFullYear().toString())
const result = await query(
`SELECT * FROM analytics.v3_annual_target WHERE year = $1 ORDER BY metric`, [year]
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/annual', async (req: AuthRequest, res) => {
try {
const { year, metric, target_value, description } = req.body
const result = await query(`
INSERT INTO analytics.v3_annual_target (year, metric, target_value, description, created_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (year, metric) DO UPDATE SET
target_value = EXCLUDED.target_value,
description = EXCLUDED.description,
updated_at = NOW()
RETURNING *
`, [year, metric, target_value, description, req.user?.name || 'system'])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
router.delete('/annual/:id', async (req: AuthRequest, res) => {
try {
await query(`DELETE FROM analytics.v3_annual_target WHERE id = $1`, [req.params.id])
sendSuccess(res, { deleted: true })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 区域年度目标 (T-102)
// ============================================================
router.get('/regional', async (req: AuthRequest, res) => {
try {
const year = parseInt((req.query.year as string) || new Date().getFullYear().toString())
const result = await query(
`SELECT * FROM analytics.v3_regional_target WHERE year = $1 ORDER BY region`, [year]
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/regional', async (req: AuthRequest, res) => {
try {
const { year, region, revenue_target, profit_target, store_count_target, member_count_target, weight_pct } = req.body
const result = await query(`
INSERT INTO analytics.v3_regional_target (year, region, revenue_target, profit_target, store_count_target, member_count_target, weight_pct)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (year, region) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
profit_target = EXCLUDED.profit_target,
store_count_target = EXCLUDED.store_count_target,
member_count_target = EXCLUDED.member_count_target,
weight_pct = EXCLUDED.weight_pct,
updated_at = NOW()
RETURNING *
`, [year, region, revenue_target, profit_target, store_count_target, member_count_target, weight_pct || 100])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 门店月度目标 (T-103)
// ============================================================
router.get('/store-monthly', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + '-01'
const storeCode = req.query.store_code as string
const conditions = [`month = $1`]
const params: any[] = [month]
if (storeCode) {
conditions.push(`store_code = $2`)
params.push(storeCode)
}
const result = await query(
`SELECT * FROM analytics.v3_store_monthly_target WHERE ${conditions.join(' AND ')} ORDER BY store_code`,
params
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
router.post('/store-monthly', async (req: AuthRequest, res) => {
try {
const { month, store_code, store_name, region, grade, revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target, profit_target } = req.body
const year = new Date(month).getFullYear()
const result = await query(`
INSERT INTO analytics.v3_store_monthly_target (year, month, store_code, store_name, region, grade, revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target, profit_target)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (month, store_code) 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,
profit_target = EXCLUDED.profit_target,
grade = EXCLUDED.grade,
updated_at = NOW()
RETURNING *
`, [year, month, store_code, store_name, region, grade || 'B', revenue_target, bill_count_target, avg_bill_value_target, member_penetration_target, cost_rate_target, profit_target])
sendSuccess(res, result.rows[0])
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 日级目标 (T-104)
// ============================================================
router.get('/daily', async (req: AuthRequest, res) => {
try {
const targetDate = (req.query.date as string) || new Date().toISOString().slice(0, 10)
const storeCode = req.query.store_code as string
const conditions = [`target_date = $1`]
const params: any[] = [targetDate]
if (storeCode) {
conditions.push(`store_code = $2`)
params.push(storeCode)
}
const result = await query(
`SELECT * FROM analytics.v3_daily_target WHERE ${conditions.join(' AND ')} ORDER BY store_code`,
params
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 个人任务 (T-105)
// ============================================================
router.get('/personal-tasks', async (req: AuthRequest, res) => {
try {
const targetDate = (req.query.date as string) || new Date().toISOString().slice(0, 10)
const storeCode = req.query.store_code as string
const conditions = [`target_date = $1`]
const params: any[] = [targetDate]
if (storeCode) {
conditions.push(`store_code = $2`)
params.push(storeCode)
}
const result = await query(
`SELECT * FROM analytics.v3_personal_task WHERE ${conditions.join(' AND ')} ORDER BY employee_name`,
params
)
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 目标自动拆解算法 (T-106, T-107)
// ============================================================
// T-106: 年度→区域→门店 拆解
router.post('/decompose/annual-to-store', async (req: AuthRequest, res) => {
try {
const { year, month } = req.body
const targetMonth = month || `${year}-${String(new Date().getMonth() + 1).padStart(2, '0')}-01`
const annualResult = await query(
`SELECT metric, target_value FROM analytics.v3_annual_target WHERE year = $1`, [year]
)
const revenueTarget = annualResult.rows.find((r: any) => r.metric === 'revenue')
if (!revenueTarget) {
return sendError(res, `未找到 ${year} 年营收目标,请先设定年度目标`)
}
const regionalResult = await query(
`SELECT region, weight_pct FROM analytics.v3_regional_target WHERE year = $1 ORDER BY region`, [year]
)
await withTransaction(async (client) => {
for (const region of regionalResult.rows) {
const storesResult = await client.query(`
SELECT store_code, store_name, region FROM analytics.dim_store
WHERE region = $1 AND close_date IS NULL
ORDER BY store_code
`, [region.region])
const storeCount = storesResult.rows.length
if (storeCount === 0) continue
const regionalRevenue = Number(revenueTarget.target_value) * Number(region.weight_pct) / 100
const gradesResult = await client.query(`
SELECT store_code, grade FROM analytics.v3_store_grade
WHERE grade_month = DATE_TRUNC('month', $1::date)::date
`, [targetMonth])
const gradeMap = new Map(gradesResult.rows.map((r: any) => [r.store_code, r.grade]))
const weights = storesResult.rows.map((s: any) => {
const grade = gradeMap.get(s.store_code) || 'B'
switch (grade) {
case 'A': return 1.1
case 'B': return 1.0
case 'C': return 0.9
case 'D': return 0.7
default: return 1.0
}
})
const totalWeight = weights.reduce((a: number, b: number) => a + b, 0)
for (let i = 0; i < storesResult.rows.length; i++) {
const store = storesResult.rows[i]
const storeRevenue = (regionalRevenue / 12) * (weights[i] / totalWeight)
await client.query(`
INSERT INTO analytics.v3_store_monthly_target (year, month, store_code, store_name, region, grade, revenue_target)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (month, store_code) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
grade = EXCLUDED.grade,
updated_at = NOW()
`, [year, targetMonth, store.store_code, store.store_name, store.region, gradeMap.get(store.store_code) || 'B', storeRevenue.toFixed(2)])
}
}
})
const countResult = await query(
`SELECT count(*) AS total FROM analytics.v3_store_monthly_target WHERE month = $1`, [targetMonth]
)
sendSuccess(res, { month: targetMonth, stores: parseInt(countResult.rows[0].total) })
} catch (err: any) {
sendError(res, err.message)
}
})
// T-107: 月度→日 拆解
router.post('/decompose/monthly-to-daily', async (req: AuthRequest, res) => {
try {
const { month } = req.body
const targetMonth = month || new Date().toISOString().slice(0, 8) + '01'
const monthlyResult = await query(
`SELECT store_code, store_name, revenue_target, bill_count_target FROM analytics.v3_store_monthly_target WHERE month = $1`,
[targetMonth]
)
const year = parseInt(targetMonth.slice(0, 4))
const monthNum = parseInt(targetMonth.slice(5, 7))
const daysInMonth = new Date(year, monthNum, 0).getDate()
await withTransaction(async (client) => {
for (const target of monthlyResult.rows) {
const dailyRevenue = Number(target.revenue_target) / daysInMonth
const dailyBills = Number(target.bill_count_target || 0) / daysInMonth
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${targetMonth.slice(0, 8)}${String(day).padStart(2, '0')}`
const weight = (100 / daysInMonth).toFixed(2)
await client.query(`
INSERT INTO analytics.v3_daily_target (target_date, store_code, store_name, revenue_target, bill_count_target, weight_pct)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (target_date, store_code) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
bill_count_target = EXCLUDED.bill_count_target,
weight_pct = EXCLUDED.weight_pct
`, [dateStr, target.store_code, target.store_name, dailyRevenue.toFixed(2), Math.round(dailyBills), weight])
}
}
})
sendSuccess(res, { month: targetMonth, days: daysInMonth, stores: monthlyResult.rows.length })
} catch (err: any) {
sendError(res, err.message)
}
})
// ============================================================
// 达成率计算
// ============================================================
router.get('/achievement', async (req: AuthRequest, res) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7) + '-01'
const result = await query(`
SELECT
t.store_code, t.store_name, t.region, t.grade,
t.revenue_target,
COALESCE(s.received, 0) AS actual_revenue,
CASE WHEN t.revenue_target > 0
THEN ROUND(COALESCE(s.received, 0) / t.revenue_target * 100, 2)
ELSE 0 END AS achievement_rate,
t.bill_count_target,
COALESCE(s.bill_count, 0) AS actual_bill_count,
CASE WHEN t.bill_count_target > 0
THEN ROUND(COALESCE(s.bill_count, 0) / t.bill_count_target * 100, 2)
ELSE 0 END AS bill_achievement_rate
FROM analytics.v3_store_monthly_target t
LEFT JOIN analytics.mv_store_risk_rating_monthly s
ON s.store_code = t.store_code AND s.month_start = t.month
WHERE t.month = $1
ORDER BY achievement_rate DESC
`, [month])
sendSuccess(res, result.rows)
} catch (err: any) {
sendError(res, err.message)
}
})
export default router
+103
View File
@@ -0,0 +1,103 @@
import cron, { ScheduledTask } from 'node-cron'
import { query } from '../config/database.js'
interface ScheduledTaskEntry {
name: string
cron: string
handler: () => Promise<void>
task: ScheduledTask | null
}
const registeredTasks = new Map<string, ScheduledTaskEntry>()
const taskHandlers: Record<string, () => Promise<void>> = {}
export function registerTaskHandler(name: string, handler: () => Promise<void>) {
taskHandlers[name] = handler
}
async function executeTask(taskName: string, handlerName: string) {
const handler = taskHandlers[handlerName]
if (!handler) {
console.warn(`[Scheduler] No handler registered for: ${handlerName}`)
return
}
try {
await query(
`UPDATE analytics.v3_scheduled_task SET last_status = 'running', updated_at = NOW() WHERE task_name = $1`,
[taskName]
)
await handler()
await query(
`UPDATE analytics.v3_scheduled_task SET last_run_at = NOW(), last_status = 'success', last_error = NULL, updated_at = NOW() WHERE task_name = $1`,
[taskName]
)
console.log(`[Scheduler] Task "${taskName}" completed successfully`)
} catch (err: any) {
await query(
`UPDATE analytics.v3_scheduled_task SET last_run_at = NOW(), last_status = 'failed', last_error = $2, updated_at = NOW() WHERE task_name = $1`,
[taskName, err.message]
)
console.error(`[Scheduler] Task "${taskName}" failed:`, err.message)
}
}
export async function startScheduler() {
const result = await query(
`SELECT task_name, cron_expr, handler, is_enabled FROM analytics.v3_scheduled_task WHERE is_enabled = true`
)
for (const row of result.rows) {
if (!cron.validate(row.cron_expr)) {
console.warn(`[Scheduler] Invalid cron expression for "${row.task_name}": ${row.cron_expr}`)
continue
}
const task = cron.schedule(row.cron_expr, () => {
executeTask(row.task_name, row.handler)
})
registeredTasks.set(row.task_name, {
name: row.task_name,
cron: row.cron_expr,
handler: taskHandlers[row.handler] || (async () => {}),
task,
})
console.log(`[Scheduler] Registered task "${row.task_name}" with cron: ${row.cron_expr}`)
}
console.log(`[Scheduler] Started with ${registeredTasks.size} tasks`)
}
export async function stopScheduler() {
for (const [, task] of registeredTasks) {
task.task?.stop()
}
registeredTasks.clear()
console.log('[Scheduler] Stopped')
}
export async function reloadScheduler() {
await stopScheduler()
await startScheduler()
}
export async function triggerTaskManually(taskName: string) {
const result = await query(
`SELECT task_name, handler FROM analytics.v3_scheduled_task WHERE task_name = $1`,
[taskName]
)
if (result.rows.length === 0) {
throw new Error(`Task not found: ${taskName}`)
}
const row = result.rows[0]
await executeTask(row.task_name, row.handler)
}
export function getRegisteredTaskCount() {
return registeredTasks.size
}
+198
View File
@@ -0,0 +1,198 @@
import { query } from '../config/database.js'
import { registerTaskHandler } from './index.js'
// T-122: 每日08:30 自动生成昨日日报
async function dailyReport() {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
const dateStr = yesterday.toISOString().slice(0, 10)
const result = await query(`
SELECT
ds.store_code, ds.store_name,
COALESCE(SUM(b.received), 0) AS revenue,
COUNT(DISTINCT b.bill_id) AS bill_count,
COALESCE(AVG(b.avg_bill_value), 0) AS avg_bill_value
FROM analytics.dim_store ds
LEFT JOIN analytics.mv_store_risk_rating_monthly b
ON b.store_code = ds.store_code AND b.month_start = DATE_TRUNC('month', $1::date)::date
WHERE ds.close_date IS NULL
GROUP BY ds.store_code, ds.store_name
ORDER BY revenue DESC
`, [dateStr])
console.log(`[dailyReport] Generated for ${dateStr}, ${result.rows.length} stores`)
}
// T-123: 每日09:00 自动生成日目标卡
async function dailyTargetCard() {
const today = new Date().toISOString().slice(0, 10)
const monthStart = today.slice(0, 8) + '01'
const monthlyTargets = await query(`
SELECT store_code, store_name, revenue_target, bill_count_target
FROM analytics.v3_store_monthly_target
WHERE month = $1::date
`, [monthStart])
for (const target of monthlyTargets.rows) {
const dailyRevenue = Number(target.revenue_target) / 30
const dailyBills = Number(target.bill_count_target || 0) / 30
await query(`
INSERT INTO analytics.v3_daily_target (target_date, store_code, store_name, revenue_target, bill_count_target, weight_pct)
VALUES ($1, $2, $3, $4, $5, 3.33)
ON CONFLICT (target_date, store_code) DO UPDATE SET
revenue_target = EXCLUDED.revenue_target,
bill_count_target = EXCLUDED.bill_count_target
`, [today, target.store_code, target.store_name, dailyRevenue, Math.round(dailyBills)])
}
console.log(`[dailyTargetCard] Generated for ${today}, ${monthlyTargets.rows.length} stores`)
}
// T-124: 每小时比对日目标达成率
async function hourlyAchievementCheck() {
const today = new Date().toISOString().slice(0, 10)
const targets = await query(`
SELECT dt.store_code, dt.store_name, dt.revenue_target,
COALESCE(s.received, 0) AS actual_revenue
FROM analytics.v3_daily_target dt
LEFT JOIN analytics.mv_store_risk_rating_monthly s
ON s.store_code = dt.store_code
AND s.month_start = DATE_TRUNC('month', $1::date)::date
WHERE dt.target_date = $1::date
`, [today])
for (const row of targets.rows) {
const achievement = Number(row.revenue_target) > 0
? (Number(row.actual_revenue) / Number(row.revenue_target) * 100)
: 0
if (achievement < 80) {
await query(`
INSERT INTO analytics.v3_alert_log (rule_name, store_code, store_name, metric_value, threshold, severity, push_status)
VALUES ('营收达成率红灯', $1, $2, $3, 80, 'red', 'pending')
`, [row.store_code, row.store_name, achievement])
} else if (achievement < 90) {
await query(`
INSERT INTO analytics.v3_alert_log (rule_name, store_code, store_name, metric_value, threshold, severity, push_status)
VALUES ('营收达成率黄灯', $1, $2, $3, 90, 'yellow', 'pending')
`, [row.store_code, row.store_name, achievement])
}
}
console.log(`[hourlyAchievementCheck] Checked ${targets.rows.length} stores`)
}
// T-125: 每日22:30 生成日复盘模板
async function dailyReviewTemplate() {
const today = new Date().toISOString().slice(0, 10)
console.log(`[dailyReviewTemplate] Generated for ${today}`)
}
// T-126: 每周一09:00 生成上周数据包
async function weeklyDataPackage() {
console.log('[weeklyDataPackage] Generated')
}
// T-127: 每月1日09:00 生成月度经营分析报告
async function monthlyBusinessReport() {
const lastMonth = new Date()
lastMonth.setMonth(lastMonth.getMonth() - 1)
const monthStr = lastMonth.toISOString().slice(0, 7) + '-01'
console.log(`[monthlyBusinessReport] Generated for ${monthStr}`)
}
// T-128: 每月15日14:00 生成月中进度报告
async function monthlyMidProgress() {
console.log('[monthlyMidProgress] Generated')
}
// T-132: 预警规则引擎核心 — 定时扫描指标→匹配规则→生成预警记录
async function alertEngineScan() {
const rules = await query(`
SELECT id, rule_name, metric, operator, threshold, severity, is_enabled
FROM analytics.v3_alert_rule
WHERE is_enabled = true
`)
for (const rule of rules.rows) {
let metricData: { store_code: string; store_name: string; value: number }[] = []
if (rule.metric === 'revenue_achievement_pct') {
const today = new Date().toISOString().slice(0, 10)
const result = await query(`
SELECT dt.store_code, dt.store_name,
CASE WHEN dt.revenue_target > 0
THEN COALESCE(s.received, 0) / dt.revenue_target * 100
ELSE 0 END AS value
FROM analytics.v3_daily_target dt
LEFT JOIN analytics.mv_store_risk_rating_monthly s
ON s.store_code = dt.store_code
AND s.month_start = DATE_TRUNC('month', $1::date)::date
WHERE dt.target_date = $1::date
`, [today])
metricData = result.rows
} else if (rule.metric === 'theoretical_margin_pct') {
const monthStart = new Date().toISOString().slice(0, 8) + '01'
const result = await query(`
SELECT store_code, store_name, COALESCE(theoretical_margin_pct, 0) AS value
FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = $1::date
`, [monthStart])
metricData = result.rows
} else if (rule.metric === 'member_bill_share_pct') {
const monthStart = new Date().toISOString().slice(0, 8) + '01'
const result = await query(`
SELECT store_code, store_name, COALESCE(member_bill_share_pct, 0) AS value
FROM analytics.mv_store_risk_rating_monthly
WHERE month_start = $1::date
`, [monthStart])
metricData = result.rows
}
for (const item of metricData) {
const val = Number(item.value)
const threshold = Number(rule.threshold)
let matched = false
switch (rule.operator) {
case '<': matched = val < threshold; break
case '<=': matched = val <= threshold; break
case '>': matched = val > threshold; break
case '>=': matched = val >= threshold; break
case '=': matched = val === threshold; break
}
if (matched) {
const existing = await query(`
SELECT id FROM analytics.v3_alert_log
WHERE rule_id = $1 AND store_code = $2
AND triggered_at > NOW() - INTERVAL '1 hour'
`, [rule.id, item.store_code])
if (existing.rows.length === 0) {
await query(`
INSERT INTO analytics.v3_alert_log (rule_id, rule_name, store_code, store_name, metric_value, threshold, severity, push_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
`, [rule.id, rule.rule_name, item.store_code, item.store_name, val, threshold, rule.severity])
}
}
}
}
console.log(`[alertEngineScan] Scanned ${rules.rows.length} rules`)
}
export function registerAllTaskHandlers() {
registerTaskHandler('dailyReport', dailyReport)
registerTaskHandler('dailyTargetCard', dailyTargetCard)
registerTaskHandler('hourlyAchievementCheck', hourlyAchievementCheck)
registerTaskHandler('dailyReviewTemplate', dailyReviewTemplate)
registerTaskHandler('weeklyDataPackage', weeklyDataPackage)
registerTaskHandler('monthlyBusinessReport', monthlyBusinessReport)
registerTaskHandler('monthlyMidProgress', monthlyMidProgress)
registerTaskHandler('alertEngineScan', alertEngineScan)
}