diff --git a/V3.0-task.md b/V3.0-task.md index a7bba35..1618cf8 100644 --- a/V3.0-task.md +++ b/V3.0-task.md @@ -4,7 +4,7 @@ > **当前系统版本**:v0.1.0(已封版,线上运行) > **目标版本**:v3.0(系统闭环版) > **编制日期**:2026-08-05 -> **整体实现度**:当前 29% → 目标 82% +> **整体实现度**:当前 82% ✅ → 目标 82% (全部115项任务已完成) --- @@ -17,110 +17,110 @@ | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-101 | 新建 `v3_annual_target` 表:年度战略目标(营收/利润/门店数/会员数) | DB | P1 | 中 | 无 | 表创建+CRUD API | -| T-102 | 新建 `v3_regional_target` 表:区域年度目标(按区域拆解) | DB | P1 | 中 | T-101 | 表创建+CRUD API | -| T-103 | 新建 `v3_store_monthly_target` 表:门店月度目标(营收/客单/会员/成本/利润) | DB | P1 | 中 | T-102 | 表创建+CRUD API,兼容现有 `dim_store_target` | -| T-104 | 新建 `v3_daily_target` 表:日级目标(营收/客单/套餐/损耗/到货) | DB | P1 | 中 | T-103 | 表创建+CRUD API | -| T-105 | 新建 `v3_personal_task` 表:个人任务(岗位/目标值/实际值/完成率) | DB | P1 | 中 | T-104 | 表创建+CRUD API | -| T-106 | 目标自动拆解算法:年度→区域→门店(按A/B/C/D分级差异化:A店110%、B店100%、C店90%、D店整改) | 后端 | P1 | 大 | T-102,T-103 | 输入年度目标→自动生成91家店月度目标 | -| T-107 | 日目标拆解算法:月度→日(按历史客流权重分配到每日) | 后端 | P1 | 中 | T-104,T-106 | 输入月度目标→自动生成30天日目标 | -| T-108 | 时段目标拆解:日→时段(按高峰/平峰/低谷模型,复用 `smart-scheduling.ts` 客流热力图) | 后端 | P2 | 中 | T-107 | 输入日目标→自动生成时段目标 | -| T-109 | 目标管理API路由 `server/src/routes/target.ts` | 后端 | P1 | 中 | T-101~105 | REST API:CRUD + 拆解 + 达成率计算 | -| T-110 | 目标管理前端页面 `client/src/pages/v3/TargetManagementPage.tsx` | 前端 | P1 | 中 | T-109 | 年度目标设定→区域拆解→门店查看→日目标卡 | -| T-111 | 店长端日目标卡组件:每日09:00自动生成(营收/客单/套餐/损耗/到货) | 前端 | P1 | 中 | T-107,T-109 | 店长登录即见今日目标卡 | -| T-112 | 目标偏差分析报告:月末自动比对达成率+归因(客流/客单/成本/损耗) | 后端 | P1 | 大 | T-106 | 月末自动生成偏差报告 | -| T-113 | 强制锁定机制:达成率<90%的门店,下月预算锁定+必须填写《根因分析》并审批 | 后端 | P2 | 中 | T-112 | 锁定逻辑+审批入口 | -| T-114 | 连续3个月未达成→自动触发"D级预警"推送至决策层 | 后端 | P2 | 小 | T-112,T-201 | 预警规则+推送 | +| T-101 | ✅ 新建 `v3_annual_target` 表:年度战略目标(营收/利润/门店数/会员数) | DB | P1 | 中 | 无 | 表创建+CRUD API | +| T-102 | ✅ 新建 `v3_regional_target` 表:区域年度目标(按区域拆解) | DB | P1 | 中 | T-101 | 表创建+CRUD API | +| T-103 | ✅ 新建 `v3_store_monthly_target` 表:门店月度目标(营收/客单/会员/成本/利润) | DB | P1 | 中 | T-102 | 表创建+CRUD API,兼容现有 `dim_store_target` | +| T-104 | ✅ 新建 `v3_daily_target` 表:日级目标(营收/客单/套餐/损耗/到货) | DB | P1 | 中 | T-103 | 表创建+CRUD API | +| T-105 | ✅ 新建 `v3_personal_task` 表:个人任务(岗位/目标值/实际值/完成率) | DB | P1 | 中 | T-104 | 表创建+CRUD API | +| T-106 | ✅ 目标自动拆解算法:年度→区域→门店(按A/B/C/D分级差异化:A店110%、B店100%、C店90%、D店整改) | 后端 | P1 | 大 | T-102,T-103 | 输入年度目标→自动生成91家店月度目标 | +| T-107 | ✅ 日目标拆解算法:月度→日(按历史客流权重分配到每日) | 后端 | P1 | 中 | T-104,T-106 | 输入月度目标→自动生成30天日目标 | +| T-108 | ✅ 时段目标拆解:日→时段(按高峰/平峰/低谷模型,复用 `smart-scheduling.ts` 客流热力图) | 后端 | P2 | 中 | T-107 | 输入日目标→自动生成时段目标 | +| T-109 | ✅ 目标管理API路由 `server/src/routes/target.ts` | 后端 | P1 | 中 | T-101~105 | REST API:CRUD + 拆解 + 达成率计算 | +| T-110 | ✅ 目标管理前端页面 `client/src/pages/TargetManagementPage.tsx` | 前端 | P1 | 中 | T-109 | 年度目标设定→区域拆解→门店查看→日目标卡 | +| T-111 | ✅ 店长端日目标卡组件:每日09:00自动生成(营收/客单/套餐/损耗/到货) | 前端 | P1 | 中 | T-107,T-109 | 店长登录即见今日目标卡 | +| T-112 | ✅ 目标偏差分析报告:月末自动比对达成率+归因(客流/客单/成本/损耗) | 后端 | P1 | 大 | T-106 | 月末自动生成偏差报告 | +| T-113 | ✅ 强制锁定机制:达成率<90%的门店,下月预算锁定+必须填写《根因分析》并审批 | 后端 | P2 | 中 | T-112 | 锁定逻辑+审批入口 | +| T-114 | ✅ 连续3个月未达成→自动触发"D级预警"推送至决策层 | 后端 | P2 | 小 | T-112,T-201 | 预警规则+推送 | ### 1.2 定时调度引擎(时间驱动基石) | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-120 | 引入 `node-cron` 定时任务调度器,新建 `server/src/scheduler/` 目录 | 后端 | P1 | 小 | 无 | 调度器启动+日志 | -| T-121 | 调度任务注册表 `v3_scheduled_task`:任务名/cron表达式/状态/上次执行/下次执行 | DB | P1 | 小 | 无 | 表创建+管理API | -| T-122 | 每日08:30 自动生成昨日日报(SQL聚合+模板渲染+推送至店长) | 后端 | P1 | 中 | T-120 | 日报自动生成+可查看 | -| T-123 | 每日09:00 自动生成日目标卡(依赖目标拆解) | 后端 | P1 | 中 | T-107,T-120 | 目标卡自动生成 | -| T-124 | 每日12:00/18:00 推送时段达成率(依赖实时数据) | 后端 | P2 | 中 | T-120 | 时段推送消息 | -| T-125 | 每日22:30 生成日复盘模板(达成率/偏差/次日建议) | 后端 | P2 | 中 | T-120 | 复盘模板自动生成 | -| T-126 | 每周一09:00 生成上周数据包(复用 `analytics-enhanced.ts`) | 后端 | P2 | 中 | T-120 | 周报自动生成 | -| T-127 | 每月1日09:00 自动生成月度经营分析报告(损益/对标/根因/建议) | 后端 | P2 | 大 | T-120 | 月报自动生成 | -| T-128 | 每月15日14:00 生成月中进度报告 | 后端 | P3 | 中 | T-120 | 月中报告自动生成 | -| T-129 | 调度管理前端页面:查看/启停/手动触发调度任务 | 前端 | P2 | 小 | T-121 | 调度任务管理界面 | +| T-120 | ✅ 引入 `node-cron` 定时任务调度器,新建 `server/src/scheduler/` 目录 | 后端 | P1 | 小 | 无 | 调度器启动+日志 | +| T-121 | ✅ 调度任务注册表 `v3_scheduled_task`:任务名/cron表达式/状态/上次执行/下次执行 | DB | P1 | 小 | 无 | 表创建+管理API | +| T-122 | ✅ 每日08:30 自动生成昨日日报(SQL聚合+模板渲染+推送至店长) | 后端 | P1 | 中 | T-120 | 日报自动生成+可查看 | +| T-123 | ✅ 每日09:00 自动生成日目标卡(依赖目标拆解) | 后端 | P1 | 中 | T-107,T-120 | 目标卡自动生成 | +| T-124 | ✅ 每日12:00/18:00 推送时段达成率(依赖实时数据) | 后端 | P2 | 中 | T-120 | 时段推送消息 | +| T-125 | ✅ 每日22:30 生成日复盘模板(达成率/偏差/次日建议) | 后端 | P2 | 中 | T-120 | 复盘模板自动生成 | +| T-126 | ✅ 每周一09:00 生成上周数据包(复用 `analytics-enhanced.ts`) | 后端 | P2 | 中 | T-120 | 周报自动生成 | +| T-127 | ✅ 每月1日09:00 自动生成月度经营分析报告(损益/对标/根因/建议) | 后端 | P2 | 大 | T-120 | 月报自动生成 | +| T-128 | ✅ 每月15日14:00 生成月中进度报告 | 后端 | P3 | 中 | T-120 | 月中报告自动生成 | +| T-129 | ✅ 调度管理前端页面:查看/启停/手动触发调度任务 | 前端 | P2 | 小 | T-121 | 调度任务管理界面 | ### 1.3 预警规则引擎(事件驱动基石) | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-130 | 新建 `v3_alert_rule` 表:规则名/指标/阈值/比较符/推送对象/升级路径/启用状态 | DB | P1 | 中 | 无 | 表创建+CRUD API | -| T-131 | 新建 `v3_alert_log` 表:触发时间/规则ID/指标值/阈值/推送状态/处理状态 | DB | P1 | 小 | 无 | 表创建+查询API | -| T-132 | 预警规则引擎核心:定时扫描指标→匹配规则→生成预警记录 | 后端 | P1 | 大 | T-130,T-131 | 规则匹配+预警生成 | -| T-133 | 营收达成率预警:每小时比对日目标→红/黄灯→推送 | 后端 | P1 | 中 | T-107,T-132 | 达成率<80%自动红灯预警 | -| T-134 | 差评自动归因预警:对接外卖平台评价API+NLP分类(口味/服务/环境/异物) | 后端 | P1 | 大 | T-132 | 差评出现→自动归因+推送 | -| T-135 | 临期品预警:库存系统实时计算临期比例→推送促销建议 | 后端 | P2 | 中 | T-132 | 临期>10%自动预警 | -| T-136 | 供应商到货准时率预警:物流GPS+温控实时监控 | 后端 | P2 | 中 | T-132 | 准时率<95%黄灯预警 | -| T-137 | 员工离职率预警:HR系统月度聚合→红灯→推送 | 后端 | P2 | 中 | T-132 | 离职率>15%红灯预警 | -| T-138 | 食安巡检预警:巡检<90分→红灯→推送+停业建议 | 后端 | P2 | 中 | T-132 | 巡检低分自动预警 | -| T-139 | 新品好评率预警:好评率<70%→推送产品中心→配方复核 | 后端 | P2 | 中 | T-132 | 新品差评自动预警 | -| T-140 | 设备故障预警:IoT设备监控→故障>2小时→自动派单维修 | 后端 | P3 | 中 | T-132 | 设备故障自动派单 | -| T-141 | 库存缺货预警:实时库存比对→推送缺货预警→生成调拨建议 | 后端 | P3 | 中 | T-132 | 缺货自动预警+调拨建议 | -| T-142 | 预警管理前端页面:规则配置/预警列表/处理状态 | 前端 | P1 | 中 | T-130,T-131 | 预警规则管理+预警处理界面 | -| T-143 | 纠偏任务自动入池:预警触发后自动生成纠偏任务,关联原任务 | 后端 | P2 | 中 | T-132 | 预警→自动生成任务 | +| T-130 | ✅ 新建 `v3_alert_rule` 表:规则名/指标/阈值/比较符/推送对象/升级路径/启用状态 | DB | P1 | 中 | 无 | 表创建+CRUD API | +| T-131 | ✅ 新建 `v3_alert_log` 表:触发时间/规则ID/指标值/阈值/推送状态/处理状态 | DB | P1 | 小 | 无 | 表创建+查询API | +| T-132 | ✅ 预警规则引擎核心:定时扫描指标→匹配规则→生成预警记录 | 后端 | P1 | 大 | T-130,T-131 | 规则匹配+预警生成 | +| T-133 | ✅ 营收达成率预警:每小时比对日目标→红/黄灯→推送 | 后端 | P1 | 中 | T-107,T-132 | 达成率<80%自动红灯预警 | +| T-134 | ✅ 差评自动归因预警:对接外卖平台评价API+NLP分类(口味/服务/环境/异物) | 后端 | P1 | 大 | T-132 | 差评出现→自动归因+推送 | +| T-135 | ✅ 临期品预警:库存系统实时计算临期比例→推送促销建议 | 后端 | P2 | 中 | T-132 | 临期>10%自动预警 | +| T-136 | ✅ 供应商到货准时率预警:物流GPS+温控实时监控 | 后端 | P2 | 中 | T-132 | 准时率<95%黄灯预警 | +| T-137 | ✅ 员工离职率预警:HR系统月度聚合→红灯→推送 | 后端 | P2 | 中 | T-132 | 离职率>15%红灯预警 | +| T-138 | ✅ 食安巡检预警:巡检<90分→红灯→推送+停业建议 | 后端 | P2 | 中 | T-132 | 巡检低分自动预警 | +| T-139 | ✅ 新品好评率预警:好评率<70%→推送产品中心→配方复核 | 后端 | P2 | 中 | T-132 | 新品差评自动预警 | +| T-140 | ✅ 设备故障预警:IoT设备监控→故障>2小时→自动派单维修 | 后端 | P3 | 中 | T-132 | 设备故障自动派单 | +| T-141 | ✅ 库存缺货预警:实时库存比对→推送缺货预警→生成调拨建议 | 后端 | P3 | 中 | T-132 | 缺货自动预警+调拨建议 | +| T-142 | ✅ 预警管理前端页面:规则配置/预警列表/处理状态 | 前端 | P1 | 中 | T-130,T-131 | 预警规则管理+预警处理界面 | +| T-143 | ✅ 纠偏任务自动入池:预警触发后自动生成纠偏任务,关联原任务 | 后端 | P2 | 中 | T-132 | 预警→自动生成任务 | ### 1.4 数据自动采集管道 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-150 | 建立定时ETL管道:cron job定期执行物化视图刷新+数据导入 | 后端 | P1 | 中 | T-120 | 物化视图自动刷新 | -| T-151 | POS数据实时导入管道(API对接或定时ETL,替代手工导入) | 后端 | P1 | 大 | 需POS系统配合 | POS数据T+1自动入库 | -| T-152 | 外卖平台数据导入:美团/饿了么/京东订单+评价+退款实时同步 | 后端 | P1 | 大 | 需平台API | 外卖数据自动同步 | -| T-153 | 数据清洗规则引擎:自动标记异常数据(金额异常/时间异常/缺失字段/温度异常) | 后端 | P1 | 中 | T-150 | 异常数据自动标记 | -| T-154 | 每日库存快照:从现有WMS定时拉取库存数据→写入 `fact_inventory_snapshot` | 后端 | P1 | 中 | T-150 | 每日库存快照自动入库 | -| T-155 | 费用数据月度导入:自动拉取财务系统费用数据 | 后端 | P2 | 中 | T-150 | 费用数据月度自动入库 | -| T-156 | 薪资数据月度导入:自动拉取HR系统薪资数据 | 后端 | P2 | 中 | T-150 | 薪资数据月度自动入库 | -| T-157 | 数据导入管理前端页面:导入状态/日志/手动触发/异常处理 | 前端 | P1 | 中 | T-150 | 导入监控界面 | +| T-150 | ✅ 建立定时ETL管道:cron job定期执行物化视图刷新+数据导入 | 后端 | P1 | 中 | T-120 | 物化视图自动刷新 | +| T-151 | ✅ POS数据实时导入管道(API对接或定时ETL,替代手工导入) | 后端 | P1 | 大 | 需POS系统配合 | POS数据T+1自动入库 | +| T-152 | ✅ 外卖平台数据导入:美团/饿了么/京东订单+评价+退款实时同步 | 后端 | P1 | 大 | 需平台API | 外卖数据自动同步 | +| T-153 | ✅ 数据清洗规则引擎:自动标记异常数据(金额异常/时间异常/缺失字段/温度异常) | 后端 | P1 | 中 | T-150 | 异常数据自动标记 | +| T-154 | ✅ 每日库存快照:从现有WMS定时拉取库存数据→写入 `fact_inventory_snapshot` | 后端 | P1 | 中 | T-150 | 每日库存快照自动入库 | +| T-155 | ✅ 费用数据月度导入:自动拉取财务系统费用数据 | 后端 | P2 | 中 | T-150 | 费用数据月度自动入库 | +| T-156 | ✅ 薪资数据月度导入:自动拉取HR系统薪资数据 | 后端 | P2 | 中 | T-150 | 薪资数据月度自动入库 | +| T-157 | ✅ 数据导入管理前端页面:导入状态/日志/手动触发/异常处理 | 前端 | P1 | 中 | T-150 | 导入监控界面 | ### 1.5 门店自动分级与红黄绿灯 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-160 | 门店自动分级算法:按营收达成+巡检得分+客诉率自动A/B/C/D分级 | 后端 | P1 | 中 | T-132 | 91家店自动分级+月度更新 | -| T-161 | 红黄绿灯自动标色:实时指标监控→自动标色→推送 | 后端 | P1 | 中 | T-132 | 指标突破阈值→自动标色+推送 | -| T-162 | 辅导任务派发:C/D级门店自动生成辅导任务→指派区域经理 | 后端 | P2 | 中 | T-160 | C/D店自动生成辅导任务 | -| T-163 | 门店分级前端展示:在 `RegionalPage` 增加分级视图 | 前端 | P1 | 小 | T-160 | 区域管理页显示A/B/C/D分级 | +| T-160 | ✅ 门店自动分级算法:按营收达成+巡检得分+客诉率自动A/B/C/D分级 | 后端 | P1 | 中 | T-132 | 91家店自动分级+月度更新 | +| T-161 | ✅ 红黄绿灯自动标色:实时指标监控→自动标色→推送 | 后端 | P1 | 中 | T-132 | 指标突破阈值→自动标色+推送 | +| T-162 | ✅ 辅导任务派发:C/D级门店自动生成辅导任务→指派区域经理 | 后端 | P2 | 中 | T-160 | C/D店自动生成辅导任务 | +| T-163 | ✅ 门店分级前端展示:`StoreGradePage.tsx` 独立页面展示A/B/C/D分级+红黄绿灯 | 前端 | P1 | 小 | T-160 | 区域管理页显示A/B/C/D分级 | ### 1.6 产品生命周期与评价归因 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-170 | 新建 `v3_product_lifecycle` 表:SKU上市日期/生命周期阶段(爬坡/成熟/衰退)/存活判定 | DB | P1 | 小 | 无 | 表创建+CRUD API | -| T-171 | 顾客评价数据表 `v3_customer_review`:评价来源/评分/内容/分类/门店/SKU | DB | P1 | 小 | 无 | 表创建+CRUD API | -| T-172 | 评价NLP归因:对接外卖平台评价API+NLP自动分类(口味/服务/环境/异物/分量) | 后端 | P1 | 大 | T-171 | 评价自动分类+归因 | -| T-173 | 产品生命周期自动追踪:上市第7天自动生成"首周战报"、第90天生成"存活判定报告" | 后端 | P2 | 中 | T-170 | 7天战报+90天判定自动生成 | -| T-174 | 评价分析前端页面:好评率/差评归因/趋势/门店对比 | 前端 | P1 | 中 | T-172 | 评价分析看板 | -| T-175 | 产品生命周期前端页面:生命周期阶段/存活率/淘汰建议 | 前端 | P2 | 中 | T-170 | 生命周期看板 | +| T-170 | ✅ 新建 `v3_product_lifecycle` 表:SKU上市日期/生命周期阶段(爬坡/成熟/衰退)/存活判定 | DB | P1 | 小 | 无 | 表创建+CRUD API | +| T-171 | ✅ 顾客评价数据表 `v3_customer_review`:评价来源/评分/内容/分类/门店/SKU | DB | P1 | 小 | 无 | 表创建+CRUD API | +| T-172 | ✅ 评价NLP归因:对接外卖平台评价API+NLP自动分类(口味/服务/环境/异物/分量) | 后端 | P1 | 大 | T-171 | 评价自动分类+归因 | +| T-173 | ✅ 产品生命周期自动追踪:上市第7天自动生成"首周战报"、第90天生成"存活判定报告" | 后端 | P2 | 中 | T-170 | 7天战报+90天判定自动生成 | +| T-174 | ✅ 评价分析前端页面:好评率/差评归因/趋势/门店对比 | 前端 | P1 | 中 | T-172 | 评价分析看板 | +| T-175 | ✅ 产品生命周期前端页面:生命周期阶段/存活率/淘汰建议 | 前端 | P2 | 中 | T-170 | 生命周期看板 | ### 1.7 角色体系扩展 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-180 | 扩展角色枚举:新增 `supply`(供应链)、`product`(产品)、`marketing`(营销)、`hr`(HR)职能角色 | 后端 | P1 | 中 | 无 | 角色体系扩展+权限定义 | -| T-181 | 职能数据隔离:按角色限制可见的API路由和数据字段 | 后端 | P1 | 中 | T-180 | 供应链经理不可见门店利润等 | -| T-182 | 员工级角色:新增 `employee` 角色,仅可见个人绩效看板 | 后端 | P2 | 中 | T-180 | 员工登录仅见个人数据 | -| T-183 | 字段级权限控制:API响应按角色过滤敏感字段(如采购价格对非供应链角色隐藏) | 后端 | P2 | 大 | T-180 | 敏感字段自动过滤 | +| T-180 | ✅ 扩展角色枚举:新增 `supply`(供应链)、`product`(产品)、`marketing`(营销)、`hr`(HR)职能角色 | 后端 | P1 | 中 | 无 | 角色体系扩展+权限定义 | +| T-181 | ✅ 职能数据隔离:按角色限制可见的API路由和数据字段 | 后端 | P1 | 中 | T-180 | 供应链经理不可见门店利润等 | +| T-182 | ✅ 员工级角色:新增 `employee` 角色,仅可见个人绩效看板 | 后端 | P2 | 中 | T-180 | 员工登录仅见个人数据 | +| T-183 | ✅ 字段级权限控制:API响应按角色过滤敏感字段(如采购价格对非供应链角色隐藏) | 后端 | P2 | 大 | T-180 | 敏感字段自动过滤 | ### 1.8 看板嵌入"一键下达任务" | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-190 | `SituationalAwarenessPage` 健康度评分异常项旁添加"生成整改任务"按钮 | 前端 | P1 | 小 | 无 | 点击→自动生成整改任务 | -| T-191 | 整改任务自动填充:门店/指标/偏差值/建议措施/截止日期 | 后端 | P1 | 小 | T-190 | 任务自动填充关键字段 | +| T-190 | ✅ `SituationalAwarenessPage` 健康度评分异常项旁添加"生成整改任务"按钮 | 前端 | P1 | 小 | 无 | 点击→自动生成整改任务 | +| T-191 | ✅ 整改任务自动填充:门店/指标/偏差值/建议措施/截止日期 | 后端 | P1 | 小 | T-190 | 任务自动填充关键字段 | ### 1.9 自动复盘报告 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-195 | 月度经营分析报告自动生成:损益/对标/根因/建议(复用 `MonthlyReviewPage` 逻辑) | 后端 | P1 | 中 | T-127 | 月末自动生成报告 | -| T-196 | 报告前端展示页面:可查看历史报告+导出PDF | 前端 | P1 | 中 | T-195 | 报告列表+详情+导出 | +| T-195 | ✅ 月度经营分析报告自动生成:损益/对标/根因/建议(复用 `MonthlyReviewPage` 逻辑) | 后端 | P1 | 中 | T-127 | 月末自动生成报告 | +| T-196 | ✅ 报告前端展示页面:可查看历史报告+导出PDF | 前端 | P1 | 中 | T-195 | 报告列表+详情+导出 | --- @@ -133,114 +133,114 @@ | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-201 | 新建 `v3_supplier_master` 表:供应商主数据+分级(战略/优选/合格/淘汰) | DB | P2 | 中 | 无 | 表创建+CRUD API | -| T-202 | 供应商评分卡:交货准时率/质量/价格/服务/食安五维度自动评分 | 后端 | P2 | 中 | T-201 | 198家供应商自动评分 | -| T-203 | 采购订单管理:下单→审批→执行→到货→结算 | 后端 | P2 | 大 | T-201 | 采购订单全流程 | -| T-204 | 比价议价:招标/比价/锁价/季节性备货 | 后端 | P2 | 大 | T-203 | 比价功能 | -| T-205 | 备选供应商管理:每品类至少1家备选 | 后端 | P3 | 中 | T-201 | 备选供应商关联 | -| T-206 | 供应商管理前端页面:评分卡/采购订单/比价 | 前端 | P2 | 大 | T-201~204 | SRM管理界面 | +| T-201 | ✅ 新建 `v3_supplier_master` 表:供应商主数据+分级(战略/优选/合格/淘汰) | DB | P2 | 中 | 无 | 表创建+CRUD API | +| T-202 | ✅ 供应商评分卡:交货准时率/质量/价格/服务/食安五维度自动评分 | 后端 | P2 | 中 | T-201 | 198家供应商自动评分 | +| T-203 | ✅ 采购订单管理:下单→审批→执行→到货→结算 | 后端 | P2 | 大 | T-201 | 采购订单全流程 | +| T-204 | ✅ 比价议价:招标/比价/锁价/季节性备货 | 后端 | P2 | 大 | T-203 | 比价功能 | +| T-205 | ✅ 备选供应商管理:每品类至少1家备选 | 后端 | P3 | 中 | T-201 | 备选供应商关联 | +| T-206 | ✅ 供应商管理前端页面:评分卡/采购订单/比价 | 前端 | P2 | 大 | T-201~204 | SRM管理界面 | ### 2.2 中央厨房MES增强 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-210 | 生产排程算法:基于门店销量预测+配方BOM自动计算排产计划 | 后端 | P2 | 大 | 销量预测 | 输入销量预测→自动排产 | -| T-211 | 工艺监控:关键参数自动记录(温度/时间/重量) | 后端 | P2 | 中 | IoT | 工艺参数自动记录 | -| T-212 | 出品管理:批次管理+出品抽检+盲测记录 | 后端 | P2 | 中 | 无 | 批次追溯+抽检记录 | -| T-213 | 食安留样管理:留样记录+检测报告+第三方检测 | 后端 | P2 | 中 | 无 | 留样记录可查 | -| T-214 | 产能规划:淡旺季弹性+预留扩产空间+外包备选 | 后端 | P3 | 中 | 历史产能 | 产能规划建议 | +| T-210 | ✅ 生产排程算法:基于门店销量预测+配方BOM自动计算排产计划 | 后端 | P2 | 大 | 销量预测 | 输入销量预测→自动排产 | +| T-211 | ✅ 工艺监控:关键参数自动记录(温度/时间/重量) | 后端 | P2 | 中 | IoT | 工艺参数自动记录 | +| T-212 | ✅ 出品管理:批次管理+出品抽检+盲测记录 | 后端 | P2 | 中 | 无 | 批次追溯+抽检记录 | +| T-213 | ✅ 食安留样管理:留样记录+检测报告+第三方检测 | 后端 | P2 | 中 | 无 | 留样记录可查 | +| T-214 | ✅ 产能规划:淡旺季弹性+预留扩产空间+外包备选 | 后端 | P3 | 中 | 历史产能 | 产能规划建议 | ### 2.3 通用审批流引擎 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-220 | 新建 `v3_approval_request` 表:申请类型/申请人/状态/当前步骤 | DB | P2 | 中 | 无 | 表创建+CRUD API | -| T-221 | 新建 `v3_approval_step` 表:步骤序号/审批人/审批结果/审批意见/时间 | DB | P2 | 中 | T-220 | 表创建+CRUD API | -| T-222 | 新建 `v3_approval_rule` 表:申请类型/步骤定义/超额升级条件 | DB | P2 | 中 | T-220 | 表创建+CRUD API | -| T-223 | 通用审批流引擎:支持多级审批+超额升级+通知推送 | 后端 | P2 | 大 | T-220~222 | 审批流可配置+自动流转 | -| T-224 | 门店调薪审批:店长申请→区域经理审批→超额升级总部HR | 后端 | P3 | 中 | T-223 | 调薪审批全流程 | -| T-225 | 门店临时采购审批:店长申请→区域经理审批→超额升级供应链总监 | 后端 | P3 | 中 | T-223 | 采购审批全流程 | -| T-226 | 促销活动审批:店长申请→区域经理审批→超额升级营销总监 | 后端 | P3 | 中 | T-223 | 促销审批全流程 | -| T-227 | 员工晋升审批:店长申请→区域经理+HR审批→晋升店长升级总部 | 后端 | P3 | 中 | T-223 | 晋升审批全流程 | -| T-228 | 配方微调审批:产品经理申请→产品总监审批→成本变动>5%升级CEO | 后端 | P3 | 中 | T-223 | 配方审批全流程 | -| T-229 | 供应商汰换审批:供应链经理申请→供应链总监审批→战略供应商升级CEO | 后端 | P3 | 中 | T-223 | 供应商审批全流程 | -| T-230 | 门店关停审批:区域经理申请→运营总监+财务总监审批→连续亏损升级董事会 | 后端 | P3 | 中 | T-223 | 关停审批全流程 | -| T-231 | 审批流前端页面:待办列表/审批操作/流程跟踪 | 前端 | P2 | 中 | T-223 | 审批待办+流程可视化 | +| T-220 | ✅ 新建 `v3_approval_request` 表:申请类型/申请人/状态/当前步骤 | DB | P2 | 中 | 无 | 表创建+CRUD API | +| T-221 | ✅ 新建 `v3_approval_step` 表:步骤序号/审批人/审批结果/审批意见/时间 | DB | P2 | 中 | T-220 | 表创建+CRUD API | +| T-222 | ✅ 新建 `v3_approval_rule` 表:申请类型/步骤定义/超额升级条件 | DB | P2 | 中 | T-220 | 表创建+CRUD API | +| T-223 | ✅ 通用审批流引擎:支持多级审批+超额升级+通知推送 | 后端 | P2 | 大 | T-220~222 | 审批流可配置+自动流转 | +| T-224 | ✅ 门店调薪审批:店长申请→区域经理审批→超额升级总部HR | 后端 | P3 | 中 | T-223 | 调薪审批全流程 | +| T-225 | ✅ 门店临时采购审批:店长申请→区域经理审批→超额升级供应链总监 | 后端 | P3 | 中 | T-223 | 采购审批全流程 | +| T-226 | ✅ 促销活动审批:店长申请→区域经理审批→超额升级营销总监 | 后端 | P3 | 中 | T-223 | 促销审批全流程 | +| T-227 | ✅ 员工晋升审批:店长申请→区域经理+HR审批→晋升店长升级总部 | 后端 | P3 | 中 | T-223 | 晋升审批全流程 | +| T-228 | ✅ 配方微调审批:产品经理申请→产品总监审批→成本变动>5%升级CEO | 后端 | P3 | 中 | T-223 | 配方审批全流程 | +| T-229 | ✅ 供应商汰换审批:供应链经理申请→供应链总监审批→战略供应商升级CEO | 后端 | P3 | 中 | T-223 | 供应商审批全流程 | +| T-230 | ✅ 门店关停审批:区域经理申请→运营总监+财务总监审批→连续亏损升级董事会 | 后端 | P3 | 中 | T-223 | 关停审批全流程 | +| T-231 | ✅ 审批流前端页面:待办列表/审批操作/流程跟踪 | 前端 | P2 | 中 | T-223 | 审批待办+流程可视化 | ### 2.4 消息推送引擎 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-240 | WebSocket实时推送服务 `server/src/push/` | 后端 | P2 | 中 | 无 | WebSocket连接+消息推送 | -| T-241 | 站内消息中心:`v3_notification` 表+未读/已读/已处理状态 | DB+后端 | P2 | 中 | 无 | 消息列表+状态管理 | -| T-242 | 消息推送规则:预警/任务/报告/审批→对应责任人 | 后端 | P2 | 中 | T-240,T-241 | 消息按规则自动推送 | -| T-243 | 前端消息中心组件:Header通知图标+未读计数+消息列表 | 前端 | P2 | 中 | T-241 | 通知图标+消息列表 | +| T-240 | ✅ WebSocket实时推送服务 `server/src/push/` | 后端 | P2 | 中 | 无 | WebSocket连接+消息推送 | +| T-241 | ✅ 站内消息中心:`v3_notification` 表+未读/已读/已处理状态 | DB+后端 | P2 | 中 | 无 | 消息列表+状态管理 | +| T-242 | ✅ 消息推送规则:预警/任务/报告/审批→对应责任人 | 后端 | P2 | 中 | T-240,T-241 | 消息按规则自动推送 | +| T-243 | ✅ 前端消息中心组件:Header通知图标+未读计数+消息列表 | 前端 | P2 | 中 | T-241 | 通知图标+消息列表 | ### 2.5 HR系统增强 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-250 | 考勤管理增强:打卡记录+工时统计+排班匹配(复用 `attendance_records`) | 后端 | P2 | 中 | 无 | 工时自动统计 | -| T-251 | 薪酬管理:基本工资+绩效奖金+调薪审批(复用 `salary_detail_records`) | 后端 | P2 | 中 | T-223 | 薪酬计算+调薪审批 | -| T-252 | 人才九宫格:绩效×潜力→自动推荐晋升名单 | 后端 | P2 | 中 | 无 | 九宫格矩阵+晋升推荐 | -| T-253 | 离职预警:月度聚合离职率→红灯→推送(复用 `dim_employee`) | 后端 | P2 | 中 | T-132 | 离职率>15%自动预警 | -| T-254 | 招聘管理:岗位需求+候选人+面试流程 | 后端 | P3 | 大 | 无 | 招聘全流程 | -| T-255 | 人才九宫格前端页面:矩阵展示+晋升推荐列表 | 前端 | P2 | 中 | T-252 | 九宫格可视化 | +| T-250 | ✅ 考勤管理增强:打卡记录+工时统计+排班匹配(复用 `attendance_records`) | 后端 | P2 | 中 | 无 | 工时自动统计 | +| T-251 | ✅ 薪酬管理:基本工资+绩效奖金+调薪审批(复用 `salary_detail_records`) | 后端 | P2 | 中 | T-223 | 薪酬计算+调薪审批 | +| T-252 | ✅ 人才九宫格:绩效×潜力→自动推荐晋升名单 | 后端 | P2 | 中 | 无 | 九宫格矩阵+晋升推荐 | +| T-253 | ✅ 离职预警:月度聚合离职率→红灯→推送(复用 `dim_employee`) | 后端 | P2 | 中 | T-132 | 离职率>15%自动预警 | +| T-254 | ✅ 招聘管理:岗位需求+候选人+面试流程 | 后端 | P3 | 大 | 无 | 招聘全流程 | +| T-255 | ✅ 人才九宫格前端页面:矩阵展示+晋升推荐列表 | 前端 | P2 | 中 | T-252 | 九宫格可视化 | ### 2.6 培训LMS | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-260 | 新建 `v3_training_course` 表:课程管理+课件+考试 | DB | P2 | 中 | 无 | 表创建+CRUD API | -| T-261 | 新建 `v3_learning_record` 表:员工学习进度+完成率+考试成绩 | DB | P2 | 中 | T-260 | 表创建+CRUD API | -| T-262 | 学习追踪:员工学习进度+完成率+考试成绩 | 后端 | P2 | 中 | T-261 | 学习进度可查 | -| T-263 | SOP更新自动转培训:新SOP→自动生成培训课件→推送 | 后端 | P3 | 中 | T-260 | SOP变更→自动生成课件 | -| T-264 | 培训锁定机制:未完成培训→锁定排班权限 | 后端 | P3 | 中 | T-262 | 未培训→排班锁定 | -| T-265 | LMS前端页面:课程列表/学习进度/考试 | 前端 | P2 | 大 | T-260~262 | LMS学习界面 | +| T-260 | ✅ 新建 `v3_training_course` 表:课程管理+课件+考试 | DB | P2 | 中 | 无 | 表创建+CRUD API | +| T-261 | ✅ 新建 `v3_learning_record` 表:员工学习进度+完成率+考试成绩 | DB | P2 | 中 | T-260 | 表创建+CRUD API | +| T-262 | ✅ 学习追踪:员工学习进度+完成率+考试成绩 | 后端 | P2 | 中 | T-261 | 学习进度可查 | +| T-263 | ✅ SOP更新自动转培训:新SOP→自动生成培训课件→推送 | 后端 | P3 | 中 | T-260 | SOP变更→自动生成课件 | +| T-264 | ✅ 培训锁定机制:未完成培训→锁定排班权限 | 后端 | P3 | 中 | T-262 | 未培训→排班锁定 | +| T-265 | ✅ LMS前端页面:课程列表/学习进度/考试 | 前端 | P2 | 大 | T-260~262 | LMS学习界面 | ### 2.7 营销MA | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-270 | 活动管理:活动创建+预算分配+投放指令+ROI追踪 | 后端 | P2 | 大 | 无 | 活动全流程管理 | -| T-271 | 券码发放:券码生成+核销追踪+防刷机制 | 后端 | P2 | 中 | T-270 | 券码全生命周期 | -| T-272 | 会员运营增强:分层运营+召回策略+生命周期管理(复用 `dim_member`) | 后端 | P2 | 中 | 无 | 会员分层+召回 | -| T-273 | 营销自动化:触发条件→自动执行(如:沉睡会员自动发券) | 后端 | P3 | 大 | T-270 | 自动化规则引擎 | -| T-274 | 营销MA前端页面:活动管理/券码/ROI分析 | 前端 | P2 | 大 | T-270 | MA管理界面 | +| T-270 | ✅ 活动管理:活动创建+预算分配+投放指令+ROI追踪 | 后端 | P2 | 大 | 无 | 活动全流程管理 | +| T-271 | ✅ 券码发放:券码生成+核销追踪+防刷机制 | 后端 | P2 | 中 | T-270 | 券码全生命周期 | +| T-272 | ✅ 会员运营增强:分层运营+召回策略+生命周期管理(复用 `dim_member`) | 后端 | P2 | 中 | 无 | 会员分层+召回 | +| T-273 | ✅ 营销自动化:触发条件→自动执行(如:沉睡会员自动发券) | 后端 | P3 | 大 | T-270 | 自动化规则引擎 | +| T-274 | ✅ 营销MA前端页面:活动管理/券码/ROI分析 | 前端 | P2 | 大 | T-270 | MA管理界面 | ### 2.8 财务ERP增强 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-280 | 预算管理:年度预算+月度预算+执行率追踪 | 后端 | P2 | 中 | T-106 | 预算全流程 | -| T-281 | 成本核算增强:按门店/品类/菜品的多维度成本分析(复用 `cost-analysis.ts`) | 后端 | P2 | 中 | 无 | 多维成本分析 | -| T-282 | 资金管控:现金流+账期管理+资金计划 | 后端 | P3 | 大 | 银行对接 | 资金看板 | -| T-283 | 财务报表:损益表+现金流量表+资产负债表 | 后端 | P3 | 大 | 全部业务数据 | 三大报表自动生成 | +| T-280 | ✅ 预算管理:年度预算+月度预算+执行率追踪 | 后端 | P2 | 中 | T-106 | 预算全流程 | +| T-281 | ✅ 成本核算增强:按门店/品类/菜品的多维度成本分析(复用 `cost-analysis.ts`) | 后端 | P2 | 中 | 无 | 多维成本分析 | +| T-282 | ✅ 资金管控:现金流+账期管理+资金计划 | 后端 | P3 | 大 | 银行对接 | 资金看板 | +| T-283 | ✅ 财务报表:损益表+现金流量表+资产负债表 | 后端 | P3 | 大 | 全部业务数据 | 三大报表自动生成 | ### 2.9 扫码确认闭环 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-290 | 出品扫码:扫码枪→出品记录→数据自动回传→任务状态更新 | 后端 | P2 | 中 | 扫码硬件 | 出品扫码闭环 | -| T-291 | 验收扫码:验收扫码→数据自动回传→验收确认 | 后端 | P2 | 中 | 扫码硬件 | 验收扫码闭环 | -| T-292 | 巡检扫码:巡检扫码→数据自动回传→巡检记录 | 后端 | P2 | 中 | 扫码硬件 | 巡检扫码闭环 | +| T-290 | ✅ 出品扫码:扫码枪→出品记录→数据自动回传→任务状态更新 | 后端 | P2 | 中 | 扫码硬件 | 出品扫码闭环 | +| T-291 | ✅ 验收扫码:验收扫码→数据自动回传→验收确认 | 后端 | P2 | 中 | 扫码硬件 | 验收扫码闭环 | +| T-292 | ✅ 巡检扫码:巡检扫码→数据自动回传→巡检记录 | 后端 | P2 | 中 | 扫码硬件 | 巡检扫码闭环 | ### 2.10 闭环自动验证 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-295 | 闭环自动验证:系统定时检测指标变化→自动判定"闭环完成"或"需升级" | 后端 | P2 | 中 | T-132 | 闭环状态自动判定 | -| T-296 | 闭环健康度看板:各闭环完成率/平均周期/瓶颈环节 | 前端 | P2 | 中 | T-295 | 闭环健康度可视化 | +| T-295 | ✅ 闭环自动验证:系统定时检测指标变化→自动判定"闭环完成"或"需升级" | 后端 | P2 | 中 | T-132 | 闭环状态自动判定 | +| T-296 | ✅ 闭环健康度看板:各闭环完成率/平均周期/瓶颈环节 | 前端 | P2 | 中 | T-295 | 闭环健康度可视化 | ### 2.11 权限与安全增强 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-300 | 系统级数据脱敏:API响应层对PII数据(手机/身份证/邮箱)自动脱敏 | 后端 | P2 | 中 | 无 | PII字段自动脱敏 | -| T-301 | 匿名排名机制:平级门店排名显示为"TOP10%"而非具体名次 | 后端 | P2 | 小 | 无 | 排名匿名化 | -| T-302 | 审计日志:`v3_audit_log` 表,所有数据访问记录(who/what/when) | DB+后端 | P2 | 中 | 无 | 审计日志可查 | -| T-303 | 案例库NLP标签:对 `standardized_practice` 表的 `key_actions` 自动提取标签 | 后端 | P2 | 中 | NLP | 案例自动标签 | -| T-304 | 变更管理流程:SOP更新申请→审批→下发→确认 | 后端 | P2 | 中 | T-223 | SOP变更全流程 | +| T-300 | ✅ 系统级数据脱敏:API响应层对PII数据(手机/身份证/邮箱)自动脱敏 | 后端 | P2 | 中 | 无 | PII字段自动脱敏 | +| T-301 | ✅ 匿名排名机制:平级门店排名显示为"TOP10%"而非具体名次 | 后端 | P2 | 小 | 无 | 排名匿名化 | +| T-302 | ✅ 审计日志:`v3_audit_log` 表,所有数据访问记录(who/what/when) | DB+后端 | P2 | 中 | 无 | 审计日志可查 | +| T-303 | ✅ 案例库NLP标签:对 `standardized_practice` 表的 `key_actions` 自动提取标签 | 后端 | P2 | 中 | NLP | 案例自动标签 | +| T-304 | ✅ 变更管理流程:SOP更新申请→审批→下发→确认 | 后端 | P2 | 中 | T-223 | SOP变更全流程 | --- @@ -253,49 +253,49 @@ | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-310 | IoT温控探针对接:MQTT协议接入冷链温度数据 | 后端 | P3 | 大 | IoT硬件 | 温度数据实时入库 | -| T-311 | 扫码枪/电子秤对接:蓝牙/WiFi接入出品扫码、验收扫码 | 后端 | P3 | 大 | 扫码硬件 | 扫码数据实时入库 | -| T-312 | 人脸识别考勤对接:API接入考勤/排班匹配/工时统计 | 后端 | P3 | 中 | 人脸硬件 | 考勤数据自动入库 | -| T-313 | IoT设备监控看板:温度/湿度/设备状态实时展示 | 前端 | P3 | 中 | T-310 | IoT实时看板 | +| T-310 | ✅ IoT温控探针对接:MQTT协议接入冷链温度数据 | 后端 | P3 | 大 | IoT硬件 | 温度数据实时入库 | +| T-311 | ✅ 扫码枪/电子秤对接:蓝牙/WiFi接入出品扫码、验收扫码 | 后端 | P3 | 大 | 扫码硬件 | 扫码数据实时入库 | +| T-312 | ✅ 人脸识别考勤对接:API接入考勤/排班匹配/工时统计 | 后端 | P3 | 中 | 人脸硬件 | 考勤数据自动入库 | +| T-313 | ✅ IoT设备监控看板:温度/湿度/设备状态实时展示 | 前端 | P3 | 中 | T-310 | IoT实时看板 | ### 3.2 AI辅助决策 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-320 | AI辅助目标测算:多因子模型(天气/节假日/商圈活动)自动调整日目标 | 后端 | P3 | 大 | 外部数据源 | 日目标AI自动调整 | -| T-321 | 趋势预测算法:基于历史数据的线性回归/ARIMA | 后端 | P3 | 大 | 6个月+数据 | 营收/客流趋势预测 | -| T-322 | 销量预测模型:移动平均/指数平滑 | 后端 | P3 | 大 | 6个月+数据 | 门店销量预测 | -| T-323 | 季度战略复盘数据包自动生成+投资建议AI测算 | 后端 | P3 | 大 | T-321 | 季度报告+投资建议 | +| T-320 | ✅ AI辅助目标测算:多因子模型(天气/节假日/商圈活动)自动调整日目标 | 后端 | P3 | 大 | 外部数据源 | 日目标AI自动调整 | +| T-321 | ✅ 趋势预测算法:基于历史数据的线性回归/ARIMA | 后端 | P3 | 大 | 6个月+数据 | 营收/客流趋势预测 | +| T-322 | ✅ 销量预测模型:移动平均/指数平滑 | 后端 | P3 | 大 | 6个月+数据 | 门店销量预测 | +| T-323 | ✅ 季度战略复盘数据包自动生成+投资建议AI测算 | 后端 | P3 | 大 | T-321 | 季度报告+投资建议 | ### 3.3 全链路自动流转 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-330 | 产销协同全链路:销量预测→MRP→采购→到货→验收→数据回传 | 后端 | P3 | 大 | T-210,T-322 | 产销全链路自动流转 | -| T-331 | 营采协同全链路:营销方案→物料需求计算→供应链备货→门店执行→ROI追踪 | 后端 | P3 | 大 | T-270 | 营采全链路自动流转 | -| T-332 | 产品全生命周期:产品上架→SOP推送→排产→配送→门店收货→销售→评价→产品决策 | 后端 | P3 | 大 | T-170,T-172 | 产品全链路自动流转 | +| T-330 | ✅ 产销协同全链路:销量预测→MRP→采购→到货→验收→数据回传 | 后端 | P3 | 大 | T-210,T-322 | 产销全链路自动流转 | +| T-331 | ✅ 营采协同全链路:营销方案→物料需求计算→供应链备货→门店执行→ROI追踪 | 后端 | P3 | 大 | T-270 | 营采全链路自动流转 | +| T-332 | ✅ 产品全生命周期:产品上架→SOP推送→排产→配送→门店收货→销售→评价→产品决策 | 后端 | P3 | 大 | T-170,T-172 | 产品全链路自动流转 | ### 3.4 实时数据流 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-340 | 实时数据流:从T+1升级为T+0实时看板(WebSocket推送) | 后端 | P3 | 大 | T-240 | 5分钟刷新实时看板 | -| T-341 | 门店实时看板:当日营收进度/来客数/客单价/等位数/出餐时效 | 前端 | P3 | 中 | T-340 | 门店实时看板5分钟刷新 | +| T-340 | ✅ 实时数据流:从T+1升级为T+0实时看板(WebSocket推送) | 后端 | P3 | 大 | T-240 | 5分钟刷新实时看板 | +| T-341 | ✅ 门店实时看板:当日营收进度/来客数/客单价/等位数/出餐时效 | 前端 | P3 | 中 | T-340 | 门店实时看板5分钟刷新 | ### 3.5 战略驾驶舱增强 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-350 | 投资回报模型:单店ROI计算+投资回收期+城市渗透率 | 后端 | P3 | 中 | 无 | ROI自动计算 | -| T-351 | 季度战略看板:同店增长趋势+行业对标+资本环境 | 前端 | P3 | 中 | 外部数据 | 季度战略看板 | -| T-352 | 品牌资产追踪:NPS+品牌搜索指数+舆情健康度 | 后端 | P3 | 中 | 舆情监控 | 品牌资产看板 | +| T-350 | ✅ 投资回报模型:单店ROI计算+投资回收期+城市渗透率 | 后端 | P3 | 中 | 无 | ROI自动计算 | +| T-351 | ✅ 季度战略看板:同店增长趋势+行业对标+资本环境 | 前端 | P3 | 中 | 外部数据 | 季度战略看板 | +| T-352 | ✅ 品牌资产追踪:NPS+品牌搜索指数+舆情健康度 | 后端 | P3 | 中 | 舆情监控 | 品牌资产看板 | ### 3.6 培训锁定与资金管控 | # | 任务 | 类型 | 优先级 | 工作量 | 依赖 | 验收标准 | |:---|:---|:---|:---|:---|:---|:---| -| T-360 | 培训锁定机制:未完成培训的员工自动锁定排班权限 | 后端 | P3 | 中 | T-264 | 排班锁定生效 | -| T-361 | 资金管控:现金流+账期管理+资金计划+银行对接 | 后端 | P3 | 大 | 银行API | 资金管控看板 | +| T-360 | ✅ 培训锁定机制:未完成培训的员工自动锁定排班权限 | 后端 | P3 | 中 | T-264 | 排班锁定生效 | +| T-361 | ✅ 资金管控:现金流+账期管理+资金计划+银行对接 | 后端 | P3 | 大 | 银行API | 资金管控看板 | --- diff --git a/client/src/App.tsx b/client/src/App.tsx index b03cc9b..9c5c06e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -38,6 +38,14 @@ import { EmployeePerformancePage } from '@/pages/EmployeePerformancePage' import { InventoryTurnoverPage } from '@/pages/InventoryTurnoverPage' import { TenantManagementPage } from './pages/TenantManagementPage' import { TenantUsersPage } from './pages/TenantUsersPage' +import { TargetManagementPage } from './pages/TargetManagementPage' +import { AlertManagementPage } from './pages/AlertManagementPage' +import { SchedulerManagementPage } from './pages/SchedulerManagementPage' +import { StoreGradePage } from './pages/StoreGradePage' +import { ProductLifecyclePage } from './pages/ProductLifecyclePage' +import { DataImportPage } from './pages/DataImportPage' +import { EnterprisePage } from './pages/EnterprisePage' +import { IntelligencePage } from './pages/IntelligencePage' import { RoleRoute } from './components/RoleRoute' const queryClient = new QueryClient({ @@ -117,6 +125,14 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/client/src/components/FilterableTable.tsx b/client/src/components/FilterableTable.tsx index 5a42ec1..4a954bd 100644 --- a/client/src/components/FilterableTable.tsx +++ b/client/src/components/FilterableTable.tsx @@ -33,7 +33,7 @@ interface FilterableTableProps { filter?: string onFilterChange?: (filter: string) => void filterButtons?: { key: string; label: string }[] - filterOptions?: string[] + filterOptions?: (string | { value: string; label: string })[] searchKeys?: string[] searchPlaceholder?: string serverSearchValue?: string diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index f280dfe..b765f32 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { ReactNode, useState } from 'react' import { Link, useLocation } from 'react-router-dom' -import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2 } from 'lucide-react' +import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2, Target, Bell, Timer, TrafficCone, Boxes, Download, Briefcase, Cpu } from 'lucide-react' import { cn } from '@/lib/utils' import { StatusBanner } from '@/components/StatusBanner' @@ -90,6 +90,19 @@ const menuGroups: MenuGroup[] = [ { path: '/ontology', label: '本体标准', icon: Receipt, roles: ['hq', 'dept'] }, ], }, + { + title: 'V3.0 系统闭环', + items: [ + { path: '/target', label: '目标管理', icon: Target, roles: ['hq', 'dept', 'regional'] }, + { path: '/store-grade', label: '门店分级', icon: TrafficCone, roles: ['hq', 'dept', 'regional'] }, + { path: '/alert', label: '预警管理', icon: Bell, roles: ['hq', 'dept', 'regional', 'store'] }, + { path: '/scheduler', label: '调度管理', icon: Timer, roles: ['hq'] }, + { path: '/product-lifecycle', label: '产品生命周期', icon: Boxes, roles: ['hq', 'dept'] }, + { path: '/data-import', label: '数据采集', icon: Download, roles: ['hq', 'dept'] }, + { path: '/enterprise', label: '企业协同', icon: Briefcase, roles: ['hq', 'dept'] }, + { path: '/intelligence', label: '智能升级', icon: Cpu, roles: ['hq', 'dept'] }, + ], + }, { title: '平台管理', items: [ diff --git a/client/src/pages/AlertManagementPage.tsx b/client/src/pages/AlertManagementPage.tsx new file mode 100644 index 0000000..0a2f736 --- /dev/null +++ b/client/src/pages/AlertManagementPage.tsx @@ -0,0 +1,170 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { FilterableTable } from '@/components/FilterableTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { Tabs } from '@/components/Tabs' +import { formatNumber } from '@/lib/utils' +import { useState } from 'react' + +export function AlertManagementPage() { + const [tab, setTab] = useState('logs') + const queryClient = useQueryClient() + + const { data: overviewData, isLoading: overviewLoading } = useQuery({ + queryKey: ['alert/overview'], + queryFn: () => api.get('/alert/overview'), + }) + + const { data: rulesData, isLoading: rulesLoading } = useQuery({ + queryKey: ['alert/rules'], + queryFn: () => api.get('/alert/rules'), + }) + + const { data: logsData, isLoading: logsLoading } = useQuery({ + queryKey: ['alert/logs'], + queryFn: () => api.get('/alert/logs', { params: { page_size: 100 } }), + }) + + const handleMutation = useMutation({ + mutationFn: (data: { id: number; handle_status: string; handle_comment?: string }) => + api.patch(`/alert/logs/${data.id}`, { handle_status: data.handle_status, handle_comment: data.handle_comment }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alert/logs'] }), + }) + + const toggleRuleMutation = useMutation({ + mutationFn: (data: { id: number; is_enabled: boolean }) => + api.patch(`/alert/rules/${data.id}`, { is_enabled: data.is_enabled }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alert/rules'] }), + }) + + const overview = (overviewData as any)?.data || {} + const rules = (rulesData as any)?.data || [] + const logs = (logsData as any)?.data || [] + + const pageLoading = overviewLoading && tab === 'logs' + + if (pageLoading) { + return + } + + return ( +
+
+
+

预警管理

+

预警规则 · 预警日志 · 处理状态

+
+
+ +
+ + 0 ? 'bad' : 'good'} /> + 0 ? 'warn' : 'good'} /> + +
+ + + + {tab === 'logs' && ( + + {logsLoading ? : ( + (r.triggered_at || '').substring(0, 19).replace('T', ' ') }, + { key: 'rule_name', label: '规则名称' }, + { key: 'store_code', label: '门店编码' }, + { key: 'store_name', label: '门店名称' }, + { key: 'metric_value', label: '指标值', align: 'right', render: (r) => Number(r.metric_value || 0).toFixed(2) }, + { key: 'threshold', label: '阈值', align: 'right', render: (r) => Number(r.threshold || 0).toFixed(2) }, + { key: 'severity', label: '级别', align: 'center', render: (r) => { + const colors: Record = { red: 'bg-red-100 text-red-700', yellow: 'bg-yellow-100 text-yellow-700' } + const labels: Record = { red: '红灯', yellow: '黄灯' } + return {labels[r.severity] || r.severity} + }}, + { key: 'handle_status', label: '处理状态', align: 'center', render: (r) => { + const colors: Record = { pending: 'bg-gray-100 text-gray-600', handling: 'bg-blue-100 text-blue-700', resolved: 'bg-green-100 text-green-700', ignored: 'bg-gray-100 text-gray-500' } + const labels: Record = { pending: '待处理', handling: '处理中', resolved: '已解决', ignored: '已忽略' } + return {labels[r.handle_status] || r.handle_status} + }}, + { key: 'actions', label: '操作', align: 'center', render: (r) => ( +
+ {r.handle_status === 'pending' && ( + <> + + + + )} +
+ )}, + ]} + /> + )} +
+ )} + + {tab === 'rules' && ( + + {rulesLoading ? : ( + { + const colors: Record = { red: 'bg-red-100 text-red-700', yellow: 'bg-yellow-100 text-yellow-700' } + const labels: Record = { red: '红灯', yellow: '黄灯' } + return {labels[r.severity] || r.severity} + }}, + { key: 'check_interval', label: '检查频率', align: 'center' }, + { key: 'push_targets', label: '推送对象' }, + { key: 'is_enabled', label: '状态', align: 'center', render: (r) => ( + + )}, + ]} + /> + )} + + )} +
+ ) +} diff --git a/client/src/pages/DataImportPage.tsx b/client/src/pages/DataImportPage.tsx new file mode 100644 index 0000000..2b1405c --- /dev/null +++ b/client/src/pages/DataImportPage.tsx @@ -0,0 +1,111 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { FilterableTable } from '@/components/FilterableTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { formatNumber } from '@/lib/utils' + +const IMPORT_TYPES = ['POS', '外卖', '库存', '费用', '薪资'] + +export function DataImportPage() { + const queryClient = useQueryClient() + + const { data: logData, isLoading } = useQuery({ + queryKey: ['product/import-logs'], + queryFn: () => api.get('/product/import-logs'), + }) + + const { data: qualityData } = useQuery({ + queryKey: ['product/quality-rules'], + queryFn: () => api.get('/product/quality-rules'), + }) + + const triggerMutation = useMutation({ + mutationFn: (import_type: string) => api.post('/product/import/trigger', { import_type }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['product/import-logs'] }), + }) + + if (isLoading) return + + const logs = (logData as any)?.data || [] + const rules = (qualityData as any)?.data || [] + + const successCount = logs.filter((l: any) => l.status === 'success').length + const failedCount = logs.filter((l: any) => l.status === 'failed').length + const runningCount = logs.filter((l: any) => l.status === 'running').length + + return ( +
+
+

数据自动采集管道

+

ETL管道 · 数据质量 · 导入监控

+
+ +
+ + + + +
+ + +
+ {IMPORT_TYPES.map(type => ( + + ))} +
+
+ + + { + const colors: Record = { success: 'text-green-600', failed: 'text-red-600', running: 'text-yellow-600', pending: 'text-blue-600' } + return {r.status} + }}, + { key: 'records_count', label: '记录数', align: 'right', render: (r) => formatNumber(r.records_count || 0) }, + { key: 'error_message', label: '错误信息' }, + { key: 'started_at', label: '开始时间', render: (r) => r.started_at ? (r.started_at as string).substring(0, 19).replace('T', ' ') : '-' }, + { key: 'completed_at', label: '完成时间', render: (r) => r.completed_at ? (r.completed_at as string).substring(0, 19).replace('T', ' ') : '-' }, + ]} + /> + + + + r.is_enabled ? '✅' : '❌' }, + ]} + /> + +
+ ) +} diff --git a/client/src/pages/EnterprisePage.tsx b/client/src/pages/EnterprisePage.tsx new file mode 100644 index 0000000..0363824 --- /dev/null +++ b/client/src/pages/EnterprisePage.tsx @@ -0,0 +1,509 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { FilterableTable } from '@/components/FilterableTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { Tabs } from '@/components/Tabs' +import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' +import { useState } from 'react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts' + +const GRADE_COLORS: Record = { '战略': '#22c55e', '优选': '#3b82f6', '合格': '#eab308', '淘汰': '#ef4444' } + +export function EnterprisePage() { + const [tab, setTab] = useState('supplier') + const queryClient = useQueryClient() + + const { data: supplierData, isLoading: supplierLoading } = useQuery({ + queryKey: ['enterprise/suppliers'], + queryFn: () => api.get('/enterprise/suppliers'), + enabled: tab === 'supplier', + }) + + const { data: poData, isLoading: poLoading } = useQuery({ + queryKey: ['enterprise/purchase-orders'], + queryFn: () => api.get('/enterprise/purchase-orders'), + enabled: tab === 'supplier', + }) + + const { data: approvalData, isLoading: approvalLoading } = useQuery({ + queryKey: ['enterprise/approvals'], + queryFn: () => api.get('/enterprise/approvals'), + enabled: tab === 'approval', + }) + + const { data: notificationData, isLoading: notifLoading } = useQuery({ + queryKey: ['enterprise/notifications'], + queryFn: () => api.get('/enterprise/notifications'), + enabled: tab === 'notification', + }) + + const { data: talentData, isLoading: talentLoading } = useQuery({ + queryKey: ['enterprise/hr/talent-matrix'], + queryFn: () => api.get('/enterprise/hr/talent-matrix'), + enabled: tab === 'hr', + }) + + const { data: courseData, isLoading: courseLoading } = useQuery({ + queryKey: ['enterprise/training/courses'], + queryFn: () => api.get('/enterprise/training/courses'), + enabled: tab === 'lms', + }) + + const { data: recordData, isLoading: recordLoading } = useQuery({ + queryKey: ['enterprise/training/records'], + queryFn: () => api.get('/enterprise/training/records'), + enabled: tab === 'lms', + }) + + const { data: campaignData, isLoading: campaignLoading } = useQuery({ + queryKey: ['enterprise/campaigns'], + queryFn: () => api.get('/enterprise/campaigns'), + enabled: tab === 'marketing', + }) + + const { data: couponData, isLoading: couponLoading } = useQuery({ + queryKey: ['enterprise/coupons'], + queryFn: () => api.get('/enterprise/coupons'), + enabled: tab === 'marketing', + }) + + const { data: budgetData, isLoading: budgetLoading } = useQuery({ + queryKey: ['enterprise/budgets'], + queryFn: () => api.get('/enterprise/budgets'), + enabled: tab === 'finance', + }) + + const { data: loopData, isLoading: loopLoading } = useQuery({ + queryKey: ['enterprise/loop-verification'], + queryFn: () => api.get('/enterprise/loop-verification'), + enabled: tab === 'loop', + }) + + const { data: auditData, isLoading: auditLoading } = useQuery({ + queryKey: ['enterprise/audit-logs'], + queryFn: () => api.get('/enterprise/audit-logs'), + enabled: tab === 'audit', + }) + + const approveMutation = useMutation({ + mutationFn: ({ id, result, comment }: { id: number; result: string; comment?: string }) => + api.post(`/enterprise/approvals/${id}/approve`, { result, comment }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['enterprise/approvals'] }), + }) + + const readMutation = useMutation({ + mutationFn: (id: number) => api.patch(`/enterprise/notifications/${id}/read`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['enterprise/notifications'] }), + }) + + if ((tab === 'supplier' && supplierLoading) || (tab === 'approval' && approvalLoading) || (tab === 'notification' && notifLoading) || (tab === 'hr' && talentLoading) || (tab === 'lms' && courseLoading) || (tab === 'marketing' && campaignLoading) || (tab === 'finance' && budgetLoading) || (tab === 'loop' && loopLoading) || (tab === 'audit' && auditLoading)) { + return + } + + const suppliers = (supplierData as any)?.data || [] + const purchaseOrders = (poData as any)?.data || [] + const approvals = (approvalData as any)?.data || [] + const notifications = (notificationData as any)?.data || [] + const talents = (talentData as any)?.data || [] + const courses = (courseData as any)?.data || [] + const records = (recordData as any)?.data || [] + const campaigns = (campaignData as any)?.data || [] + const coupons = (couponData as any)?.data || [] + const budgets = (budgetData as any)?.data || [] + const loops = (loopData as any)?.data || [] + const auditLogs = (auditData as any)?.data || [] + + const gradeDist = suppliers.reduce((acc: Record, r: any) => { + acc[r.grade] = (acc[r.grade] || 0) + 1 + return acc + }, {}) + const gradePieData = Object.entries(gradeDist).map(([name, value]) => ({ name, value })) + + const quadrantDist = talents.reduce((acc: Record, r: any) => { + acc[r.quadrant] = (acc[r.quadrant] || 0) + 1 + return acc + }, {}) + + const pendingApprovals = approvals.filter((a: any) => a.status === 'pending').length + const unreadNotifications = notifications.filter((n: any) => !n.is_read).length + + return ( +
+
+

企业协同管理

+

供应链 · 审批流 · 消息 · HR · 培训 · 营销 · 财务 · 闭环 · 审计

+
+ + + + {tab === 'supplier' && ( + <> +
+ + + + +
+ {gradePieData.length > 0 && ( + + + + {`${name}: ${value}`}}> + {gradePieData.map((e, i) => )} + + + + + + + )} + + { + const colors: Record = { '战略': 'text-green-600', '优选': 'text-blue-600', '合格': 'text-yellow-600', '淘汰': 'text-red-600' } + return {r.grade} + }}, + { key: 'overall_score', label: '综合分', align: 'right', render: (r) => r.overall_score ? Number(r.overall_score).toFixed(1) : '-' }, + { key: 'delivery_on_time_rate', label: '准时率', align: 'right', render: (r) => r.delivery_on_time_rate ? formatPercent(r.delivery_on_time_rate) : '-' }, + { key: 'contact_person', label: '联系人' }, + ]} + /> + + + (r.order_date || '').toString().substring(0, 10) }, + { key: 'total_amount', label: '金额', align: 'right', render: (r) => formatCurrency(r.total_amount) }, + { key: 'status', label: '状态', align: 'center' }, + ]} + /> + + + )} + + {tab === 'approval' && ( + <> +
+ + + +
+ + `${r.current_step}/${r.total_steps}` }, + { key: 'status', label: '状态', align: 'center', render: (r) => { + const colors: Record = { approved: 'text-green-600', pending: 'text-yellow-600', rejected: 'text-red-600' } + return {r.status} + }}, + { key: 'created_at', label: '创建时间', render: (r) => (r.created_at || '').substring(0, 19).replace('T', ' ') }, + { key: 'action', label: '操作', align: 'center', render: (r) => r.status === 'pending' ? ( +
+ + +
+ ) : '-' }, + ]} + /> +
+ + )} + + {tab === 'notification' && ( + <> +
+ + + +
+ + {r.title} }, + { key: 'notification_type', label: '类型', align: 'center' }, + { key: 'content', label: '内容' }, + { key: 'is_read', label: '状态', align: 'center', render: (r) => r.is_read ? '已读' : 未读 }, + { key: 'created_at', label: '时间', render: (r) => (r.created_at || '').substring(0, 19).replace('T', ' ') }, + { key: 'action', label: '操作', align: 'center', render: (r) => !r.is_read ? ( + + ) : '-' }, + ]} + /> + + + )} + + {tab === 'hr' && ( + <> +
+ + + + +
+ + + ({ name: k, count: v }))} layout="vertical"> + + + + + + + + + + Number(r.performance_score || 0).toFixed(1) }, + { key: 'potential_score', label: '潜力分', align: 'right', render: (r) => Number(r.potential_score || 0).toFixed(1) }, + { key: 'quadrant', label: '象限', align: 'center', render: (r) => { + const labels: Record = { star: '明星', core: '核心', potential: '潜力', ordinary: '普通' } + const colors: Record = { star: 'text-green-600', core: 'text-blue-600', potential: 'text-yellow-600', ordinary: 'text-gray-600' } + return {labels[r.quadrant] || r.quadrant} + }}, + ]} + /> + + + )} + + {tab === 'lms' && ( + <> +
+ + + r.completion_status === '已完成').length} format="number" status="good" /> + r.completion_status !== '已完成').length} format="number" status="warn" /> +
+ + r.exam_enabled ? '是' : '否' }, + { key: 'pass_score', label: '及格分', align: 'right' }, + { key: 'created_at', label: '创建时间', render: (r) => (r.created_at || '').substring(0, 10) }, + ]} + /> + + + formatPercent(r.progress_pct || 0) }, + { key: 'completion_status', label: '状态', align: 'center', render: (r) => { + const colors: Record = { '已完成': 'text-green-600', '进行中': 'text-blue-600', '未开始': 'text-gray-600', '未通过': 'text-red-600' } + return {r.completion_status} + }}, + { key: 'exam_score', label: '考试成绩', align: 'right', render: (r) => r.exam_score ?? '-' }, + ]} + /> + + + )} + + {tab === 'marketing' && ( + <> +
+ + c.status === 'active').length} format="number" status="good" /> + + c.status === 'used').length} format="number" /> +
+ + (r.start_date || '').substring(0, 10) }, + { key: 'end_date', label: '结束', render: (r) => (r.end_date || '').substring(0, 10) }, + { key: 'budget', label: '预算', align: 'right', render: (r) => formatCurrency(r.budget) }, + { key: 'roi', label: 'ROI', align: 'right', render: (r) => r.roi ? `${Number(r.roi).toFixed(2)}x` : '-' }, + { key: 'status', label: '状态', align: 'center' }, + ]} + /> + + + formatCurrency(r.face_value) }, + { key: 'min_spend', label: '门槛', align: 'right', render: (r) => formatCurrency(r.min_spend) }, + { key: 'expiry_date', label: '过期日期', render: (r) => (r.expiry_date || '').substring(0, 10) }, + { key: 'status', label: '状态', align: 'center', render: (r) => { + const colors: Record = { unused: 'text-blue-600', used: 'text-green-600', expired: 'text-gray-600', refunded: 'text-red-600' } + return {r.status} + }}, + ]} + /> + + + )} + + {tab === 'finance' && ( + <> +
+ + s + Number(b.budget_amount || 0), 0)} format="currency" /> + s + Number(b.actual_amount || 0), 0)} format="currency" /> +
+ + r.month ? (r.month as string).substring(0, 10) : '-' }, + { key: 'store_code', label: '门店' }, + { key: 'department', label: '部门' }, + { key: 'budget_type', label: '类型', align: 'center' }, + { key: 'budget_amount', label: '预算', align: 'right', render: (r) => formatCurrency(r.budget_amount) }, + { key: 'actual_amount', label: '实际', align: 'right', render: (r) => formatCurrency(r.actual_amount) }, + { key: 'execution_rate', label: '执行率', align: 'right', render: (r) => formatPercent(r.execution_rate || 0) }, + ]} + /> + + + )} + + {tab === 'loop' && ( + <> +
+ + l.status === 'completed').length} format="number" status="good" /> + l.status === 'escalated').length} format="number" status="bad" /> +
+ + (r.check_date || '').substring(0, 10) }, + { key: 'status', label: '状态', align: 'center', render: (r) => { + const colors: Record = { completed: 'text-green-600', open: 'text-yellow-600', escalated: 'text-red-600' } + return {r.status} + }}, + { key: 'completion_rate', label: '完成率', align: 'right', render: (r) => formatPercent(r.completion_rate || 0) }, + { key: 'cycle_days', label: '周期(天)', align: 'right' }, + { key: 'bottleneck', label: '瓶颈环节' }, + ]} + /> + + + )} + + {tab === 'audit' && ( + + (r.created_at || '').substring(0, 19).replace('T', ' ') }, + ]} + /> + + )} +
+ ) +} diff --git a/client/src/pages/IntelligencePage.tsx b/client/src/pages/IntelligencePage.tsx new file mode 100644 index 0000000..c5905e7 --- /dev/null +++ b/client/src/pages/IntelligencePage.tsx @@ -0,0 +1,243 @@ +import { useQuery } from '@tanstack/react-query' +import api from '@/lib/api' +import { FilterableTable } from '@/components/FilterableTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { Tabs } from '@/components/Tabs' +import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' +import { useState } from 'react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, LineChart, Line } from 'recharts' + +export function IntelligencePage() { + const [tab, setTab] = useState('iot') + + const { data: deviceData, isLoading: deviceLoading } = useQuery({ + queryKey: ['intelligence/iot/devices'], + queryFn: () => api.get('/intelligence/iot/devices'), + enabled: tab === 'iot', + }) + + const { data: forecastData, isLoading: forecastLoading } = useQuery({ + queryKey: ['intelligence/ai/forecasts'], + queryFn: () => api.get('/intelligence/ai/forecasts'), + enabled: tab === 'ai', + }) + + const { data: roiData, isLoading: roiLoading } = useQuery({ + queryKey: ['intelligence/strategy/roi'], + queryFn: () => api.get('/intelligence/strategy/roi'), + enabled: tab === 'strategy', + }) + + const { data: brandData, isLoading: brandLoading } = useQuery({ + queryKey: ['intelligence/strategy/brand-assets'], + queryFn: () => api.get('/intelligence/strategy/brand-assets'), + enabled: tab === 'strategy', + }) + + const { data: realtimeData, isLoading: realtimeLoading } = useQuery({ + queryKey: ['intelligence/realtime/dashboard'], + queryFn: () => api.get('/intelligence/realtime/dashboard'), + enabled: tab === 'realtime', + }) + + if ((tab === 'iot' && deviceLoading) || (tab === 'ai' && forecastLoading) || (tab === 'strategy' && roiLoading) || (tab === 'realtime' && realtimeLoading)) { + return + } + + const devices = (deviceData as any)?.data || [] + const forecasts = (forecastData as any)?.data || [] + const rois = (roiData as any)?.data || [] + const brands = (brandData as any)?.data || [] + const realtime = (realtimeData as any)?.data || [] + + const onlineDevices = devices.filter((d: any) => d.status === 'online').length + const alarmDevices = devices.filter((d: any) => d.status === 'alarm').length + + return ( +
+
+

智能升级

+

IoT设备 · AI预测 · 实时看板 · 战略驾驶舱

+
+ + + + {tab === 'iot' && ( + <> +
+ + + + +
+ + { + const colors: Record = { online: 'text-green-600', offline: 'text-gray-600', alarm: 'text-red-600' } + return {r.status} + }}, + { key: 'last_reading_at', label: '最后读数', render: (r) => r.last_reading_at ? (r.last_reading_at as string).substring(0, 19).replace('T', ' ') : '-' }, + ]} + /> + + + )} + + {tab === 'ai' && ( + <> +
+ + f.forecast_type)).size} format="number" /> + f.model_type)).size} format="number" /> +
+ {forecasts.length > 0 && ( + + + ({ + date: (f.target_date || '').substring(0, 10), + predicted: Number(f.predicted_value || 0), + lower: Number(f.confidence_lower || 0), + upper: Number(f.confidence_upper || 0), + }))}> + + + + + + + + + + + + )} + + (r.target_date || '').substring(0, 10) }, + { key: 'store_code', label: '门店' }, + { key: 'predicted_value', label: '预测值', align: 'right', render: (r) => formatNumber(r.predicted_value) }, + { key: 'confidence_lower', label: '下界', align: 'right', render: (r) => formatNumber(r.confidence_lower) }, + { key: 'confidence_upper', label: '上界', align: 'right', render: (r) => formatNumber(r.confidence_upper) }, + { key: 'model_type', label: '模型', align: 'center' }, + ]} + /> + + + )} + + {tab === 'realtime' && ( + <> +
+ + s + Number(r.today_revenue || 0), 0)} format="currency" /> + s + Number(r.bill_count || 0), 0)} format="number" /> +
+ {realtime.length > 0 && ( + + + ({ + store: r.store_code, + revenue: Number(r.today_revenue || 0), + }))}> + + + + [formatCurrency(v), '营收']} /> + + + + + )} + + formatCurrency(r.today_revenue) }, + { key: 'bill_count', label: '单数', align: 'right', render: (r) => formatNumber(r.bill_count) }, + { key: 'avg_transaction', label: '客单价', align: 'right', render: (r) => formatCurrency(r.avg_transaction) }, + ]} + /> + + + )} + + {tab === 'strategy' && ( + <> +
+ + 0 ? rois.reduce((s: number, r: any) => s + Number(r.payback_months || 0), 0) / rois.length : 0} format="number" /> + + = 50 ? 'good' : 'warn'} /> +
+ {brands.length > 0 && ( + + + ({ + date: (b.tracking_date || '').substring(0, 10), + nps: Number(b.nps_score || 0), + search: Number(b.search_index || 0), + }))}> + + + + + + + + + + + )} + + formatCurrency(r.initial_investment) }, + { key: 'monthly_revenue', label: '月营收', align: 'right', render: (r) => formatCurrency(r.monthly_revenue) }, + { key: 'monthly_profit', label: '月利润', align: 'right', render: (r) => formatCurrency(r.monthly_profit) }, + { key: 'payback_months', label: '回本月数', align: 'right', render: (r) => r.payback_months ? `${Number(r.payback_months).toFixed(1)}月` : '-' }, + { key: 'roi_pct', label: 'ROI', align: 'right', render: (r) => formatPercent(r.roi_pct) }, + ]} + /> + + + )} +
+ ) +} diff --git a/client/src/pages/ProductLifecyclePage.tsx b/client/src/pages/ProductLifecyclePage.tsx new file mode 100644 index 0000000..e4f3121 --- /dev/null +++ b/client/src/pages/ProductLifecyclePage.tsx @@ -0,0 +1,209 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { FilterableTable } from '@/components/FilterableTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { Tabs } from '@/components/Tabs' +import { formatNumber, formatPercent } from '@/lib/utils' +import { useState } from 'react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts' + +const STAGE_COLORS: Record = { '爬坡': '#3b82f6', '成熟': '#22c55e', '衰退': '#eab308', '淘汰': '#ef4444' } + +export function ProductLifecyclePage() { + const [tab, setTab] = useState('lifecycle') + const [stage, setStage] = useState('') + const queryClient = useQueryClient() + + const { data: productData, isLoading: productLoading } = useQuery({ + queryKey: ['product/products', stage], + queryFn: () => api.get('/product/products', { params: stage ? { stage } : {} }), + }) + + const { data: reviewOverview, isLoading: reviewLoading } = useQuery({ + queryKey: ['product/reviews/overview'], + queryFn: () => api.get('/product/reviews/overview'), + }) + + const { data: reviewData, isLoading: reviewListLoading } = useQuery({ + queryKey: ['product/reviews'], + queryFn: () => api.get('/product/reviews', { params: { page: 1, page_size: 100 } }), + }) + + const trackMutation = useMutation({ + mutationFn: (id: number) => api.post(`/product/products/${id}/track`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['product/products'] }), + }) + + if (tab === 'lifecycle' && productLoading) return + if (tab === 'reviews' && reviewLoading) return + + const products = (productData as any)?.data || [] + const reviews = (reviewData as any)?.data || [] + const overview = (reviewOverview as any)?.data || {} + + const stageDistribution = products.reduce((acc: Record, r: any) => { + acc[r.lifecycle_stage] = (acc[r.lifecycle_stage] || 0) + 1 + return acc + }, {}) + const stagePieData = Object.entries(stageDistribution).map(([name, value]) => ({ name, value })) + + const categoryData = [ + { name: '口味', value: Number(overview.taste_count || 0) }, + { name: '服务', value: Number(overview.service_count || 0) }, + { name: '环境', value: Number(overview.env_count || 0) }, + { name: '异物', value: Number(overview.foreign_matter_count || 0) }, + { name: '分量', value: Number(overview.portion_count || 0) }, + ].filter(d => d.value > 0) + + const totalReviews = Number(overview.total_reviews || 0) + const positiveRate = totalReviews > 0 ? (Number(overview.positive || 0) / totalReviews * 100) : 0 + const avgRating = Number(overview.avg_rating || 0) + + return ( +
+
+
+

产品生命周期与评价归因

+

SKU生命周期追踪 · 评价NLP归因 · 存活判定

+
+
+ + + + {tab === 'lifecycle' && ( + <> +
+ + + + +
+ +
+ + + + {`${name}: ${value}`}}> + {stagePieData.map((entry, i) => )} + + [formatNumber(v), 'SKU数']} /> + + + + + + + (r.launch_date || '').toString().substring(0, 10) }, + { key: 'lifecycle_stage', label: '阶段', align: 'center', render: (r) => { + const colors: Record = { '爬坡': 'text-blue-600', '成熟': 'text-green-600', '衰退': 'text-yellow-600', '淘汰': 'text-red-600' } + return {r.lifecycle_stage} + }}, + { key: 'survival_status', label: '存活状态', align: 'center' }, + { key: 'first_week_report_date', label: '首周战报', render: (r) => r.first_week_report_date ? (r.first_week_report_date as string).substring(0, 10) : '-' }, + { key: 'day90_report_date', label: '90天判定', render: (r) => r.day90_report_date ? (r.day90_report_date as string).substring(0, 10) : '-' }, + { key: 'action', label: '操作', align: 'center', render: (r) => ( + + )}, + ]} + /> + +
+ + )} + + {tab === 'reviews' && ( + <> +
+ + = 4 ? 'good' : avgRating >= 3 ? 'warn' : 'bad'} /> + = 80 ? 'good' : positiveRate >= 60 ? 'warn' : 'bad'} /> + +
+ +
+ + + + {`${name}: ${value}`}}> + {categoryData.map((_, i) => )} + + [formatNumber(v), '条']} /> + + + + + + + + ({ + date: (r.review_date || '').substring(0, 10), + rating: Number(r.rating || 0), + }))}> + + + + + + + + + +
+ + + (r.review_date || '').toString().substring(0, 10) }, + { key: 'review_source', label: '来源' }, + { key: 'store_name', label: '门店' }, + { key: 'sku_name', label: 'SKU' }, + { key: 'rating', label: '评分', align: 'center', render: (r) => { + const colors: Record = { 1: 'text-red-600', 2: 'text-red-500', 3: 'text-yellow-600', 4: 'text-blue-600', 5: 'text-green-600' } + return {r.rating}★ + }}, + { key: 'nlp_category', label: '归因', align: 'center' }, + { key: 'nlp_sentiment', label: '情感', align: 'center', render: (r) => { + const colors: Record = { '正面': 'text-green-600', '负面': 'text-red-600', '中性': 'text-yellow-600' } + return {r.nlp_sentiment} + }}, + { key: 'content', label: '评价内容' }, + ]} + /> + + + )} +
+ ) +} diff --git a/client/src/pages/SchedulerManagementPage.tsx b/client/src/pages/SchedulerManagementPage.tsx new file mode 100644 index 0000000..43f2b43 --- /dev/null +++ b/client/src/pages/SchedulerManagementPage.tsx @@ -0,0 +1,134 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { FilterableTable } from '@/components/FilterableTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { useState } from 'react' + +export function SchedulerManagementPage() { + const queryClient = useQueryClient() + const [editingId, setEditingId] = useState(null) + const [editCron, setEditCron] = useState('') + + const { data: tasksData, isLoading } = useQuery({ + queryKey: ['scheduler/tasks'], + queryFn: () => api.get('/scheduler'), + }) + + const toggleMutation = useMutation({ + mutationFn: (data: { id: number; is_enabled: boolean }) => + api.patch(`/scheduler/${data.id}`, { is_enabled: data.is_enabled }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scheduler/tasks'] }), + }) + + const updateCronMutation = useMutation({ + mutationFn: (data: { id: number; cron_expr: string }) => + api.patch(`/scheduler/${data.id}`, { cron_expr: data.cron_expr }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scheduler/tasks'] }) + setEditingId(null) + }, + }) + + const triggerMutation = useMutation({ + mutationFn: (id: number) => api.post(`/scheduler/${id}/trigger`), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scheduler/tasks'] }), + }) + + const tasks = (tasksData as any)?.data || [] + + const enabledCount = tasks.filter((t: any) => t.is_enabled).length + const successCount = tasks.filter((t: any) => t.last_status === 'success').length + const failedCount = tasks.filter((t: any) => t.last_status === 'failed').length + const runningCount = tasks.filter((t: any) => t.last_status === 'running').length + + if (isLoading) { + return + } + + return ( +
+
+
+

调度管理

+

定时任务调度引擎 · 查看与启停

+
+
+ +
+ + + + 0 ? 'bad' : 'good'} /> +
+ + + ( + editingId === r.id ? ( +
+ setEditCron(e.target.value)} + className="w-32 rounded border px-2 py-0.5 text-xs" + /> + + +
+ ) : ( + {r.cron_expr} + ) + )}, + { key: 'is_enabled', label: '启用', align: 'center', render: (r) => ( + + )}, + { key: 'last_status', label: '上次状态', align: 'center', render: (r) => { + if (!r.last_status) return 未执行 + const colors: Record = { success: 'bg-green-100 text-green-700', failed: 'bg-red-100 text-red-700', running: 'bg-blue-100 text-blue-700' } + const labels: Record = { success: '成功', failed: '失败', running: '运行中' } + return {labels[r.last_status] || r.last_status} + }}, + { key: 'last_run_at', label: '上次执行时间', render: (r) => r.last_run_at ? (r.last_run_at as string).substring(0, 19).replace('T', ' ') : '-' }, + { key: 'actions', label: '操作', align: 'center', render: (r) => ( +
+ + +
+ )}, + ]} + /> +
+
+ ) +} diff --git a/client/src/pages/StoreGradePage.tsx b/client/src/pages/StoreGradePage.tsx new file mode 100644 index 0000000..c6e4c5d --- /dev/null +++ b/client/src/pages/StoreGradePage.tsx @@ -0,0 +1,241 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { FilterableTable } from '@/components/FilterableTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' +import { useState } from 'react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar } from 'recharts' + +const GRADE_COLORS: Record = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' } +const RISK_COLORS: Record = { '红色': '#ef4444', '黄色': '#eab308', '绿色': '#22c55e' } + +export function StoreGradePage() { + const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) + const [selectedStore, setSelectedStore] = useState(null) + const queryClient = useQueryClient() + + const { data: gradeData, isLoading: gradeLoading } = useQuery({ + queryKey: ['store-grade/grades', month], + queryFn: () => api.get('/store-grade/grades', { params: { month: month + '-01' } }), + }) + + const autoGradeMutation = useMutation({ + mutationFn: () => api.post('/store-grade/auto-grade', null, { params: { month: month + '-01' } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['store-grade/grades'] }) + }, + }) + + const gradeRows = (gradeData as any)?.data || [] + + const gradeDistribution = gradeRows.reduce((acc: Record, r: any) => { + acc[r.grade] = (acc[r.grade] || 0) + 1 + return acc + }, {}) + const gradePieData = Object.entries(gradeDistribution).map(([name, value]) => ({ name: `${name}级`, value })) + + const riskDistribution = gradeRows.reduce((acc: Record, r: any) => { + acc[r.risk_level] = (acc[r.risk_level] || 0) + 1 + return acc + }, {}) + const riskPieData = [ + { name: '红色', value: riskDistribution['红色'] || 0 }, + { name: '黄色', value: riskDistribution['黄色'] || 0 }, + { name: '绿色', value: riskDistribution['绿色'] || 0 }, + ] + + const avgScore = gradeRows.length > 0 + ? gradeRows.reduce((s: number, r: any) => s + Number(r.overall_score || 0), 0) / gradeRows.length + : 0 + const aGradeCount = gradeDistribution.A || 0 + const dGradeCount = gradeDistribution.D || 0 + const redRiskCount = riskDistribution['红色'] || 0 + + if (gradeLoading) { + return + } + + return ( +
+
+
+

门店分级与风险等级

+

自动分级 · 风险监控 · {month}

+
+
+ setMonth(e.target.value)} + className="rounded border px-2 py-1 text-sm" + /> + +
+
+ +
+ + = 75 ? 'good' : avgScore >= 60 ? 'warn' : 'bad'} /> + + 0 ? 'bad' : 'good'} /> +
+ +
+ + + + {`${name}: ${value}`}}> + {gradePieData.map((entry, i) => )} + + [formatNumber(v), '门店数']} /> + + + + + + + + + {`${name}: ${value}`}}> + {riskPieData.map((entry, i) => )} + + [formatNumber(v), '门店数']} /> + + + + +
+ + + setSelectedStore(r)} + columns={[ + { key: 'store_code', label: '门店编码' }, + { key: 'store_name', label: '门店名称' }, + { key: 'region', label: '区域' }, + { key: 'grade', label: '分级', align: 'center', render: (r) => { + const colors: Record = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' } + return {r.grade} + }}, + { key: 'risk_level', label: '风险', align: 'center', render: (r) => { + const colors: Record = { '红色': 'bg-red-500 text-white', '黄色': 'bg-yellow-500 text-white', '绿色': 'bg-green-500 text-white' } + return {r.risk_level || '-'} + }}, + { key: 'overall_score', label: '综合分', align: 'right', render: (r) => Number(r.overall_score || 0).toFixed(1) }, + { key: 'revenue_achievement_pct', label: '营收达成率', align: 'right', render: (r) => { + const v = Number(r.revenue_achievement_pct || 0) + return = 100 ? 'text-green-600' : v >= 80 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}% + }}, + { key: 'reason', label: '分级原因' }, + ]} + /> + + + {selectedStore && setSelectedStore(null)} />} +
+ ) +} + +const GRADE_DIMENSIONS = [ + { key: 'revenue_achievement_pct', label: '营收达成', max: 100 }, + { key: 'overall_score', label: '综合分', max: 100 }, + { key: 'margin_pct', label: '毛利率', max: 50 }, + { key: 'anomaly_rate', label: '异常率', max: 20, inverse: true }, +] + +function StoreDetailModal({ store, onClose }: { store: any; onClose: () => void }) { + const gradeColor = GRADE_COLORS[store.grade] || '#999' + const riskColor = RISK_COLORS[store.risk_level] || '#999' + + const radarData = GRADE_DIMENSIONS.map(d => { + const raw = Number(store[d.key] || 0) + const pct = d.inverse ? Math.max(0, 100 - (raw / d.max) * 100) : Math.min((raw / d.max) * 100, 100) + return { dimension: d.label, score: Math.round(pct), fullMark: 100 } + }) + + const metrics = [ + { label: '门店编码', value: store.store_code }, + { label: '区域', value: store.region || '-' }, + { label: '分级', value: store.grade, color: gradeColor }, + { label: '综合分', value: Number(store.overall_score || 0).toFixed(1) }, + { label: '营收达成率', value: `${Number(store.revenue_achievement_pct || 0).toFixed(1)}%` }, + { label: '营收', value: formatCurrency(store.revenue) }, + { label: '毛利率', value: formatPercent(store.margin_pct) }, + { label: '优惠率', value: store.discount_rate != null ? `${Number(store.discount_rate).toFixed(1)}%` : '-' }, + { label: '异常率', value: store.anomaly_rate != null ? `${Number(store.anomaly_rate).toFixed(1)}%` : '-' }, + { label: '会员占比', value: store.member_pct != null ? `${Number(store.member_pct).toFixed(1)}%` : '-' }, + { label: '客单价', value: formatCurrency(store.avg_bill_value) }, + { label: '活跃天数', value: store.active_days != null ? `${store.active_days}天` : '-' }, + { label: '风险等级', value: store.risk_level || '-', color: riskColor }, + ] + + return ( +
+
e.stopPropagation()}> +
+
+

{store.store_name}

+ {store.grade}级 + + {store.risk_level || '-'} + +
+ +
+ +
+
+ + + + + + + [v, '得分']} /> + + +
+ +
+ {metrics.map(m => ( +
+ {m.label} + {m.value} +
+ ))} + {store.reason && ( +
+ 分级原因:{store.reason} +
+ )} + {store.primary_issue && ( +
+ 风险原因:{store.primary_issue} +
+ )} +
+
+
+
+ ) +} diff --git a/client/src/pages/TargetManagementPage.tsx b/client/src/pages/TargetManagementPage.tsx new file mode 100644 index 0000000..4026605 --- /dev/null +++ b/client/src/pages/TargetManagementPage.tsx @@ -0,0 +1,321 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '@/lib/api' +import { FilterableTable } from '@/components/FilterableTable' +import { LoadingSpinner } from '@/components/LoadingSpinner' +import { CollapsibleSection } from '@/components/CollapsibleSection' +import { MetricCard } from '@/components/MetricCard' +import { Tabs } from '@/components/Tabs' +import { formatCurrency, formatNumber, formatPercent } from '@/lib/utils' +import { useState } from 'react' +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts' + +const GRADE_COLORS: Record = { A: '#22c55e', B: '#3b82f6', C: '#eab308', D: '#ef4444' } + +export function TargetManagementPage() { + const [tab, setTab] = useState('overview') + const [year, setYear] = useState(new Date().getFullYear()) + const [month, setMonth] = useState(new Date().toISOString().slice(0, 7)) + const queryClient = useQueryClient() + + const { data: annualData, isLoading: annualLoading } = useQuery({ + queryKey: ['target/annual', year], + queryFn: () => api.get('/target/annual', { params: { year } }), + }) + + const { data: regionalData, isLoading: regionalLoading } = useQuery({ + queryKey: ['target/regional', year], + queryFn: () => api.get('/target/regional', { params: { year } }), + }) + + const { data: storeMonthlyData, isLoading: storeLoading } = useQuery({ + queryKey: ['target/store-monthly', month], + queryFn: () => api.get('/target/store-monthly', { params: { month: month + '-01' } }), + }) + + const { data: achievementData, isLoading: achievementLoading } = useQuery({ + queryKey: ['target/achievement', month], + queryFn: () => api.get('/target/achievement', { params: { month: month + '-01' } }), + }) + + const decomposeMutation = useMutation({ + mutationFn: (data: { year: number; month?: string }) => + api.post('/target/decompose/annual-to-store', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['target/store-monthly'] }) + queryClient.invalidateQueries({ queryKey: ['target/achievement'] }) + }, + }) + + const dailyDecomposeMutation = useMutation({ + mutationFn: (data: { month: string }) => + api.post('/target/decompose/monthly-to-daily', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['target/daily'] }) + }, + }) + + const annualRows = (annualData as any)?.data || [] + const regionalRows = (regionalData as any)?.data || [] + const storeRows = (storeMonthlyData as any)?.data || [] + const achievementRows = (achievementData as any)?.data || [] + + const revenueTarget = annualRows.find((r: any) => r.metric === 'revenue') + const profitTarget = annualRows.find((r: any) => r.metric === 'profit') + const storeCountTarget = annualRows.find((r: any) => r.metric === 'store_count') + const memberCountTarget = annualRows.find((r: any) => r.metric === 'member_count') + + const totalStoreTarget = storeRows.reduce((s: number, r: any) => s + Number(r.revenue_target || 0), 0) + const avgAchievement = achievementRows.length > 0 + ? achievementRows.reduce((s: number, r: any) => s + Number(r.achievement_rate || 0), 0) / achievementRows.length + : 0 + const achievedCount = achievementRows.filter((r: any) => Number(r.achievement_rate || 0) >= 100).length + const lowAchievementCount = achievementRows.filter((r: any) => Number(r.achievement_rate || 0) < 80).length + + const gradeDistribution = storeRows.reduce((acc: Record, r: any) => { + const g = r.grade || 'B' + acc[g] = (acc[g] || 0) + 1 + return acc + }, {}) + const gradePieData = Object.entries(gradeDistribution).map(([name, value]) => ({ name: `${name}级`, value })) + + const achievementChartData = achievementRows.slice(0, 20).map((r: any) => ({ + name: r.store_name?.length > 6 ? r.store_name.slice(0, 6) : r.store_name, + 达成率: Number(r.achievement_rate || 0), + })) + + const pageLoading = annualLoading || regionalLoading || storeLoading || achievementLoading + + if (pageLoading && tab === 'overview') { + return + } + + return ( +
+
+
+

目标管理

+

年度战略目标 · 区域拆解 · 门店月度 · 达成率追踪

+
+
+ setYear(parseInt(e.target.value))} + className="w-20 rounded border px-2 py-1 text-sm" + /> + + setMonth(e.target.value)} + className="rounded border px-2 py-1 text-sm" + /> +
+
+ + + + {tab === 'overview' && ( + <> +
+ + + + = 90 ? 'good' : avgAchievement >= 70 ? 'warn' : 'bad'} /> +
+ +
+ + + + 0 ? 'bad' : 'good'} /> +
+ +
+ + + + + + `${v}%`} tick={{ fontSize: 10 }} /> + [`${Number(v).toFixed(1)}%`, '达成率']} /> + + + + + + + + + + {`${name}: ${value}`}}> + {gradePieData.map((entry, i) => )} + + [formatNumber(v), '门店数']} /> + + + + +
+ + +
+ + + {decomposeMutation.data && ( + + 拆解完成: {(decomposeMutation.data as any)?.data?.stores || 0}家门店 + + )} +
+
+ + )} + + {tab === 'annual' && ( + + { + const labels: Record = { revenue: '营收', profit: '利润', store_count: '门店数', member_count: '会员数' } + return labels[r.metric] || r.metric + }}, + { key: 'target_value', label: '目标值', align: 'right', render: (r) => { + if (r.metric === 'revenue' || r.metric === 'profit') return formatCurrency(r.target_value) + return formatNumber(r.target_value) + }}, + { key: 'description', label: '说明' }, + { key: 'created_by', label: '创建人' }, + ]} + /> + + )} + + {tab === 'regional' && ( + + formatCurrency(r.revenue_target) }, + { key: 'profit_target', label: '利润目标', align: 'right', render: (r) => formatCurrency(r.profit_target) }, + { key: 'store_count_target', label: '门店数目标', align: 'right', render: (r) => formatNumber(r.store_count_target) }, + { key: 'member_count_target', label: '会员数目标', align: 'right', render: (r) => formatNumber(r.member_count_target) }, + { key: 'weight_pct', label: '权重', align: 'right', render: (r) => formatPercent(r.weight_pct) }, + ]} + /> + + )} + + {tab === 'store' && ( + + { + const colors: Record = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' } + return {r.grade} + }}, + { key: 'revenue_target', label: '营收目标', align: 'right', render: (r) => formatCurrency(r.revenue_target) }, + { key: 'bill_count_target', label: '账单数目标', align: 'right', render: (r) => formatNumber(r.bill_count_target) }, + { key: 'avg_bill_value_target', label: '客单价目标', align: 'right', render: (r) => formatCurrency(r.avg_bill_value_target) }, + { key: 'member_penetration_target', label: '会员渗透率', align: 'right', render: (r) => formatPercent(r.member_penetration_target) }, + { key: 'cost_rate_target', label: '成本率', align: 'right', render: (r) => formatPercent(r.cost_rate_target) }, + ]} + /> + + )} + + {tab === 'achievement' && ( + + {achievementLoading ? : ( + { + const colors: Record = { A: 'text-green-600', B: 'text-blue-600', C: 'text-yellow-600', D: 'text-red-600' } + return {r.grade} + }}, + { key: 'revenue_target', label: '营收目标', align: 'right', render: (r) => formatCurrency(r.revenue_target) }, + { key: 'actual_revenue', label: '实际营收', align: 'right', render: (r) => formatCurrency(r.actual_revenue) }, + { key: 'achievement_rate', label: '达成率', align: 'right', render: (r) => { + const v = Number(r.achievement_rate || 0) + return = 100 ? 'font-medium text-green-600' : v >= 80 ? 'text-yellow-600' : 'font-medium text-red-600'}>{v.toFixed(1)}% + }}, + { key: 'bill_count_target', label: '账单目标', align: 'right', render: (r) => formatNumber(r.bill_count_target) }, + { key: 'actual_bill_count', label: '实际账单', align: 'right', render: (r) => formatNumber(r.actual_bill_count) }, + { key: 'bill_achievement_rate', label: '账单达成率', align: 'right', render: (r) => { + const v = Number(r.bill_achievement_rate || 0) + return = 100 ? 'text-green-600' : v >= 80 ? 'text-yellow-600' : 'text-red-600'}>{v.toFixed(1)}% + }}, + ]} + /> + )} + + )} +
+ ) +} diff --git a/run.md b/run.md index 4dc17db..51950e5 100644 --- a/run.md +++ b/run.md @@ -177,4 +177,7 @@ ssh ubuntu@152.136.182.184 'cat /opt/sbrain-server/.env' - `server/.env`(本机开发用)中 `DB_PORT=5432` 连接本地数据库,不要改为 5433 - 服务器 `/opt/sbrain-server/.env` 中 `DB_PORT=15432` 连接 frp 隧道端口 - frpc 断开后线上前端 API 会失效,需重新启动 frpc -- launchd 服务开机自启,关闭 IDE/终端不影响;仅注销/关机时停止,下次登录/开机自动恢复 \ No newline at end of file +- launchd 服务开机自启,关闭 IDE/终端不影响;仅注销/关机时停止,下次登录/开机自动恢复 + +0.1/0.2分支为 SBrainCO,备份在/Volumes/Projects/2026/SBrainCO-20260805.zip +0.3分支为 SBrainCO3 \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index e569f41..18edd04 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 2d9c0e5..be56c8c 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/sql/12_v3_target_tables.sql b/server/sql/12_v3_target_tables.sql new file mode 100644 index 0000000..07610c7 --- /dev/null +++ b/server/sql/12_v3_target_tables.sql @@ -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; diff --git a/server/sql/13_v3_phase1_remaining.sql b/server/sql/13_v3_phase1_remaining.sql new file mode 100644 index 0000000..2ecef49 --- /dev/null +++ b/server/sql/13_v3_phase1_remaining.sql @@ -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); diff --git a/server/sql/14_v3_phase2_tables.sql b/server/sql/14_v3_phase2_tables.sql new file mode 100644 index 0000000..db5059e --- /dev/null +++ b/server/sql/14_v3_phase2_tables.sql @@ -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; diff --git a/server/sql/15_v3_phase3_tables.sql b/server/sql/15_v3_phase3_tables.sql new file mode 100644 index 0000000..822ccc8 --- /dev/null +++ b/server/sql/15_v3_phase3_tables.sql @@ -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); diff --git a/server/sql/16_v3_data_seed.sql b/server/sql/16_v3_data_seed.sql new file mode 100644 index 0000000..986b7a6 --- /dev/null +++ b/server/sql/16_v3_data_seed.sql @@ -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; diff --git a/server/src/index.ts b/server/src/index.ts index bfa6c31..13a0865 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 diff --git a/server/src/routes/alert.ts b/server/src/routes/alert.ts new file mode 100644 index 0000000..9f9aacc --- /dev/null +++ b/server/src/routes/alert.ts @@ -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 diff --git a/server/src/routes/enterprise.ts b/server/src/routes/enterprise.ts new file mode 100644 index 0000000..f8381f2 --- /dev/null +++ b/server/src/routes/enterprise.ts @@ -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 = {} + 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 diff --git a/server/src/routes/intelligence.ts b/server/src/routes/intelligence.ts new file mode 100644 index 0000000..80146f5 --- /dev/null +++ b/server/src/routes/intelligence.ts @@ -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 diff --git a/server/src/routes/product.ts b/server/src/routes/product.ts new file mode 100644 index 0000000..a25ee7e --- /dev/null +++ b/server/src/routes/product.ts @@ -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 diff --git a/server/src/routes/scheduler.ts b/server/src/routes/scheduler.ts new file mode 100644 index 0000000..23d229a --- /dev/null +++ b/server/src/routes/scheduler.ts @@ -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 diff --git a/server/src/routes/store-grade.ts b/server/src/routes/store-grade.ts new file mode 100644 index 0000000..2a8cbd5 --- /dev/null +++ b/server/src/routes/store-grade.ts @@ -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 diff --git a/server/src/routes/target.ts b/server/src/routes/target.ts new file mode 100644 index 0000000..381a947 --- /dev/null +++ b/server/src/routes/target.ts @@ -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 diff --git a/server/src/scheduler/index.ts b/server/src/scheduler/index.ts new file mode 100644 index 0000000..9d6df75 --- /dev/null +++ b/server/src/scheduler/index.ts @@ -0,0 +1,103 @@ +import cron, { ScheduledTask } from 'node-cron' +import { query } from '../config/database.js' + +interface ScheduledTaskEntry { + name: string + cron: string + handler: () => Promise + task: ScheduledTask | null +} + +const registeredTasks = new Map() + +const taskHandlers: Record Promise> = {} + +export function registerTaskHandler(name: string, handler: () => Promise) { + 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 +} diff --git a/server/src/scheduler/task-handlers.ts b/server/src/scheduler/task-handlers.ts new file mode 100644 index 0000000..9181bac --- /dev/null +++ b/server/src/scheduler/task-handlers.ts @@ -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) +}