254 Commits
Author SHA1 Message Date
Hermes CI Fix 497f378957 feat: 落地UI动效——表单错误抖动/数字滚动/卡片悬浮
- 新增styles/transitions.css: 错误shake/按钮状态/弹窗过渡/骨架屏淡入(带prefers-reduced-motion)
- 新增CountUp.vue: KPI数字滚动组件(easeOutCubic+千分位+闪烁提示)
- Dashboard: CEO/IT视图摘要卡片接入CountUp
- main.ts引入全局动效
2026-08-29 15:38:02 +08:00
Hermes CI Fix 94aeb14e95 feat: 预算系统6项技术改进(告警归因/实际值自动归集/真零基/派生规则/告警路径统一/现金流分类)
P1-③ 告警归因: budget_deviation_alerts+alert_type/attribution/scenario_id, 归因引擎alert_attribution.py(子KPI/科目/量价差/趋势), deviation-check统一写归因+场景, GET /deviation-alerts/{id}/attribution详情(旧告警现场组装)
P1-④ 实际值自动归集: kpi_value_sources/kpi_value_collect_logs表+CRUD+试跑+覆盖率, 采集器kpi_value_collector.py(voucher_details/进销存/cash_plans按entity+period汇总, 幂等upsert不覆盖人工), crontab每日06:30
P2-① 真零基: budget_zero_based_items逐项论证表+generate, method-comparison有论证项逐项求和is_demo=false否则fallback
P2-② 派生规则: budget_derivation_rules配置表, apply-method优先读规则rule_source=configured
P2-⑤ 告警双路径合并: deviation_engine.build_deviation_alert统一函数, 方向列表配置化kpi_alert_higher_better+alert-direction接口
P2-⑥ 现金流分类: cash_plan_classify_rules规则表+cash_plan_unclassified待分类队列, sync-cash-plans未命中进队列不静默跳过
新增: GET /kpis/{kpi_id}/values + 前端kpiApi.values(归集标签页数据源), scenario_suggestions幂等seed(init_db)
测试: test_budget_tech_improve.py 15用例, 预算相关96 passed, 全量646 passed
2026-08-28 18:03:47 +08:00
Hermes CI Fix 3bc68fa1c6 feat: 网银流水导入模板+现金流联动(财务数据通道P1) 2026-08-28 09:59:07 +08:00
Hermes CI Fix b9c624fd7e fix: 冒烟测试登录适配账套模式(entity_id=1) 2026-08-28 07:21:15 +08:00
Hermes CI Fix 1a6ea8afae chore: CI流程加固+Schema检查门禁
- deploy.sh: 提交纪律检查(本地未提交修改→中止) + 冒烟测试(健康/登录/KPI/BOT/Schema)
- .woodpecker: 加前端typecheck+后端pytest测试步骤, backend-deploy加提交纪律检查
- 新增schema_check.py: ORM与数据库表结构一致性检查
- 修复budget_plans表缺3列(source_kpi_id/source_type/calc_logic)
- 附带入库: budget测试+文档
2026-08-28 07:18:54 +08:00
Hermes CI Fix 758f820970 feat: Bot API L1-L4风险分级标注 + 操作审计日志
- 新增 app/risk_levels.py: RISK_LEVELS定义 + @risk_level装饰器 + API_RISK_MAP
  (L1只读21 / L2业务写5 / L3批量写3 / L4=0安全底线)
- 新增 app/api/audit_log.py: Bot API审计中间件 → backend/logs/bot_audit.log
  (JSON行: timestamp/bot_name/endpoint/method/risk_level/entity_id/status,
   L3额外记rows行数, 不阻塞业务)
- bot_bridge/bot_bridge_v2/bot_kpis/bot_iron_law 全部29路由标注级别
- 新增 GET /api/cma/bot/risk-levels (X-BOT-KEY鉴权): API→级别→处理方式清单
- main.py 注册审计中间件
- tests/test_risk_levels.py: 覆盖路由标注/risk-levels端点/L4不存在/审计日志
2026-08-28 00:31:31 +08:00
Hermes CI Fix 5ceda333e2 fix: 预算页初始加载也默认选中当前版本 — 根治重复
根因: loadPublishedMaps自动选第一个地图但未走onMapSelect
  → filterVersion保持空 → 显示全版本堆叠(28条)
修复: 自动选地图后同步loadVersions+selectDefaultVersion
  = 初始进入页面即默认当前版本(7条), 与手动切换一致
部署完成
2026-08-27 18:07:17 +08:00
Hermes CI Fix 4864d5762e fix: 预算录入默认选中当前版本 — 根治重复期间展示
根因: 预算列表默认无版本筛选(空=全版本) + 历史NULL预算兼容显示
  = F_REVENUE 28条(6版本堆叠) 视觉上大量重复
根治: 切换地图后自动加载版本列表并默认选中active版本
  = 默认视图=地图+当前版本 (F_REVENUE 7条: 年度1+月度6, 无重复)
数据层已验证无真重复(同KPI+期间+版本+地图唯一)
pytest验证, 部署
2026-08-27 18:04:21 +08:00
Hermes CI Fix fb99a5e5b0 fix: 预算列表按版本筛选 — 消除多版本堆叠的重复期间
原因: 同一KPI同一期间存在多版本预算(v1.0/v2.0/incremental/zero_based/flexible/2026H2)
  F_REVENUE 22行=6版本堆叠(用户看到大量重复期间)
修复:
- 预算录入tab加版本筛选下拉(含'(当前)'标记, 来自版本列表)
- loadBudget 传 version 过滤
- 切换地图自动加载版本列表+清空筛选
- 验证: 全版本22行 → 选2026H2=1行 / 选v2.0=24行(F_REVENUE 2026-07~12各1条, 无重复)
pytest 35 passed, 部署
2026-08-27 17:45:14 +08:00
Hermes CI Fix fd814462a2 feat: 预算按战略地图隔离(map_id) — 切换地图数据区分
问题: 预算数据无map_id, 两地图共用KPI时预算无法区分(切地图数据均显示)
修复:
- budget_plans 加 map_id 字段+索引
- list 支持 map_id 过滤(含NULL历史预算兼容迁移)
- create 收 map_id + 去重键含map_id(同KPI不同地图可各自预算)
- 前端: loadBudget/loadStrategyBudget 传 map_id, 保存带 map_id
- 端到端验证: 地图51 F_REVENUE 300(map_id=51) 地图51显示/地图32不显示 ✓
pytest 35+11 passed, 部署
2026-08-27 17:33:57 +08:00
Hermes CI Fix 3c64dc767e feat: 战略目标vs预算差异可见化 — 目标与预算允许不同但差异可解释
需求: 战略目标(管理团队愿景) vs 预算(财务按实际分析) 允许不同
增强:
- 后端 deviation-report 返回 strategic_target/target_gap_pct/target_gap_level
- 预算执行表加'战略目标'+'目标差异'列(±10%黄/±20%红⚠️提示'需说明原因')
- 战略预算编制KPI树加'战略目标'列(编制时可见对比)
- 实测: F_REVENUE 战略1200 vs 预算173.86 差异-85.5%(high) 红提示

口径说明: 差异基于当前target_monthly/预算值, 目标口径校准另行处理
2026-08-27 17:26:38 +08:00
Hermes CI Fix e82a6af59c fix: 预算流程断点修复 — 现金流联动+行动方案补齐
断点#1: 预算↔现金流打通
- cash_plans 加 related_kpi_id/budget_plan_id 字段
- POST /budget/sync-cash-plans: 按预算KPI生成收付款计划(收入类→receive/成本类→pay, upsert)
- 前端预算执行页加'⇄同步现金流计划'按钮
- 实测: 新建108条(receive 75/pay 33, 6个KPI)

断点#2: 5个预算KPI补行动方案(净利/新客/厂补/供应链/数据自动化, 各1条含负责人)

断点#3: 悬空预算(2KPI)因已补行动方案, 性质从'无来源'→'有行动缺实际数据源', 标注保留

pytest 56 passed(budget+cash), 构建部署
2026-08-27 16:54:07 +08:00
Hermes CI Fix ec5304a1d0 fix: kpi_causality无token测试对齐项目约定(403)
并发agent新增测试期望401, 与项目约定(test_reports: router级require_role→403)冲突
- 测试改为403(HTTPBearer默认行为即403)
- pytest 588 passed
2026-08-27 16:08:01 +08:00
Hermes CI Fix 9f787e628b feat: 复盘结果持久化 — 大PDCA Act闭环(P1, yanxue-review-persistence)
- review_records表+模型(entity_id多租户)
- API: POST/GET/GET:id /maps/{id}/review-records
- 前端: MapReview 保存复盘结论弹窗+历史记录时间线(并发agent完成主体, 本项目补齐script逻辑)
- 验证: POST 200保存成功/GET列表/跨企业404/pytest 11 passed/构建部署
2026-08-27 15:59:55 +08:00
Hermes CI Fix fdc42d443d feat: 因果链三层验证机制(P2) — 数据验证+人工确认+状态机
- kpi_causality 加列: source_type/verify_status/verified_at/verified_by/entity_id(回填)
- 核心服务: app/services/causality_verification.py (Pearson+滞后对齐+状态机)
- 数据验证脚本: scripts/correlation-check.py (月度cron, 输出JSON报告)
- API: create/update支持source_type, GET /verify-status, PUT /{id}/verify(人工确认)
- 全部端点按entity_id账套隔离, kpi/{id}/network/simulate补跨企业校验
- 前端: KPIDetail因果链页显示验证状态徽标(数据证实/存疑/待检)
- 测试: test_causality_verification.py 37用例 + 原因果链测试全过(59个)
- 50条因果链首轮验证: 1数据证实(#37渠补率到净利润lag1 r=-0.89), 4存疑, 45待检(数据不足)
2026-08-27 15:51:19 +08:00
Hermes CI Fix 6b479bfe7d feat(KR): 行动挂KR完成 — action_plans.kr_id字段+API+前端目标/KR选择+KR中文名自动填+地图krs端点
- action_plans表加kr_id字段(关联krs.id),create/update/list支持kr_id读写
- 创建行动选KR时自动继承objective_id;列表附kr_title
- 前端行动方案库加'所属目标/KR'两级联动选择,选中KR自动带出KPI
- OKR详情页KR卡片下显示达成行动列表(O→KR→行动三层)
- MapCanvasDialogs onKpiChange选中KPI自动填kr.name=kpi.kpi_name(中文)
- 编辑回填英文code KR名转中文显示兜底
- maps.py 新增GET /{map_id}/krs端点(MapReview战略回顾会KR进度)
- 数据修复: krs表9条英文code title→中文名(按kpi_definitions映射)
- 数据迁移: 高置信行动挂KR(费用率→kr10,应收→kr14); 地图32空KR清理
- okr list的kr_summary补metric_kpi_id(前端联动用)
- 新增6个kr_id测试(test_action_plans),全量547通过
2026-08-27 12:50:30 +08:00
Hermes CI Fix 61936302b2 feat(KR): KR完整修复 — krs表打通+方向符号operator+权重自由输入
- 后端okr.py: KR读取从ActionPlan改为krs表, 新增KR CRUD API(POST/PUT/DELETE /okr/{objective_id}/krs) + 批量sync
- operator方向符号: krs表加operator/tolerance/weight/sort_order/monthly_milestones列
- progress方向感知计算(>=/>: current/target, <=/<: target/current, =: 容差), 达成→status=achieved
- 关联KPI自动继承方向(threshold_green解析: F_COST_RATIO<=18等)
- maps.py: 保存地图时自动同步objectives+krs表, JSON→krs数据迁移脚本
- 前端: 权重下拉改自由数字输入(可小数33.33) + 方向选择器(≥/≤/>/</=) + 自动平分按钮 + KPI方向继承提示
- pytest: 10个新测试(krs CRUD/方向感知/权重校验/多租户隔离) + 更新旧KR测试
- 迁移: 现有strategic_maps JSON 12条KR已写入krs表
2026-08-27 10:59:56 +08:00
Hermes CI Fix 87cb9e7db2 feat: OpenCode执行层试点 — 补测试5个 + list_objectives隔离修复
- OpenCode执行模式: 补 test_maps.py 5个测试(create_objective/snapshot隔离/entity注入/跨企业)
- OpenCode诚实标注: list_objectives 后端漏entity过滤 → 修复(跨企业返回空)
- 记忆层闭环: Hermes验证(pytest 536 passed) + 技能已更新(opencode-independent-review 双角色)
- 执行层试点验证: OpenCode能写代码+发现后端缺口, 最小闭环跑通
2026-08-26 23:45:20 +08:00
Hermes CI Fix 9b61c48051 fix: OpenCode审查16条修复 — 战略地图隔离/安全/逻辑漏洞
安全:
- ⑦ update_map 字段白名单(禁entity_id篡改, status限值域)
- ⑧ objectives 全端点补entity校验(原括号错位导致隔离失效) + update白名单
- ⑨ strategic_map_versions 加entity_id列+回填, list/snapshot/rollback过滤
- ⑩ get_map_review KPI查询加entity(同编码跨企业混入)
- ⑪ _sync_map_objectives 强制继承地图entity

逻辑:
- ③ MapCanvas 自动保存 currentMap.value 解包(30s自动保存修复)
- ④ 乐观锁字段 version_num→version 对齐
- ⑤ 红黄绿灯: 越低越好型反向 + 负值修正 + 月目标优先
- ⑥ review 补 action_plan_summary 字段

验证: 跨企业写目标404✓ entity_id注入被拒✓ pytest 531 passed✓
2026-08-26 23:25:44 +08:00
Hermes CI Fix 95b73c9b90 feat(P1-2): 预算三法并存落地 — 增量/零基/弹性均可应用
按任总观点: 三个方法都体现功能, 具体用哪个取决于用户场景
- 后端: POST /budget/apply-method 把所选方法结果写入预算计划(version标注方法)
  增量95.5万(上月×1.05) / 零基71.4万(成本砍半压缩) / 弹性58.7万(收入联动)
- 前端: 确认选择改为真正调用apply-method(原来只弹提示不落地)
- 场景提示: 增量=稳定快速编 / 零基=成本优化 / 弹性=收入波动大
2026-08-26 22:43:46 +08:00
Hermes CI Fix e330545d59 feat(P1-3): 相关成本决策模块 — 官方25%权重'商业决策分析'核心
新增 /predict/relevant-decision API + 前端Tab(3场景):
1. 自制vs外购: 相关成本比较+无差别点(增量成本视角)
2. 特殊订单: 剩余产能下价格>变动成本即接受(增量利润)
3. 产品组合(约束理论): 单位约束资源边际贡献排序

验证: 自制850k vs 外购900k→自制; 特殊订单单位贡献25→接受;
      产品组合B产品单位约束贡献50最高→优先
2026-08-26 22:38:37 +08:00
Hermes CI Fix 35974514da feat(P1-1): KPI单值人工录入 — 新增POST /kpis/{id}/values + 前端录入弹窗
M02绩效管理闭环工具:客户/流程/学习层台账数据可直接在KPI详情录入
- 后端: 单值录入(期间+实际值), 同期间重复自动更新, 来源=manual/verified
- 前端: 历史数据Tab新增'录入数据'按钮+弹窗(期间选择+数值输入)
- 验证: C_SATISFACTION 2026-08录入85→更新88, 历史数据联动显示
2026-08-26 22:34:23 +08:00
Hermes CI Fix ba8e2f112b fix: P0官方公式对齐(方案A)+反向指标判定Bug修复
1. F_AR_DAYS: 360→365官方口径, 历史值重算(6月75.6/7月116.5), 阈值等比调整(绿76/红91)
2. F_ROI改名: 总资产报酬率(ROA), 说明与官方ROI口径差异
3. 🔴反向指标Bug修复: kpis.py与dashboard.py的REVERSE_INDICATORS不一致
   - 移除错误: F_QUICK_RATIO(速动比率越高越好)/F_INTEREST_COVER/P_QUALITY_RATE
   - 补充缺失: F_AR_DAYS/F_REBATE_RATE/F_FACTORY_REBATE_RATE/F_COST_CONTROL_RATE/F_INV_DAYS
   - 修复前F_AR_DAYS判定反了(116.5天显示绿, 75.6天显示黄)
4. 清理过时预警(id=6/269旧口径)
2026-08-26 21:40:00 +08:00
Hermes CI Fix 37223d2afb fix: 我的工作台'我的改善行动'空白 — CEO/管理员角色查看全部行动方案
根因: my-dashboard的action_plans按assignee==username/name过滤
  数据库18条行动方案assignee全是部门/角色(财务部/采购部王经理等)
  admin(管理员)无分配 → 显示0条'暂无待办'

修复: CEO/管理员角色(role=ceo)查看全部行动方案, 其他角色仍按assignee过滤
验证: 18条行动方案+6条待办提醒(含2条逾期+红色KPI预警)
2026-08-26 09:52:11 +08:00
Hermes CI Fix b9076e6818 feat: KPI详情历史数据来源/状态中文显示 — sourceLabel/statusLabel映射(Excel导入/账簿提取/手动录入/已验证/估算等) 2026-08-26 09:39:21 +08:00
Hermes CI Fix a253eadeb0 feat: KPI详情历史数据对齐元数据 — 目标值/偏差%/红黄绿判定/单位/数据批次
- 后端: get_kpi的values附带target_value/deviation_pct/level/score/unit
  判定对齐工作台语义: 正向≥0.9绿/≥0.7黄/否则红; 反向≤目标绿/≤1.1倍黄/否则红
- 前端: 历史数据表格新增目标值/偏差/判定列, 实际值带单位+红黄着色, 新增数据批次列
- 验证: 渠补率2月84%=黄(超5%), 5月88.3%=红(超10.4%), 收入全部red(远低于1200万目标)
2026-08-26 09:27:12 +08:00
Hermes CI Fix 9d712046f3 fix: KPI详情'历史数据'Tab空白 — get_kpi附带kpi_values历史数据
根因: GET /kpis/{kpi_id} 只返回KPI定义信息, 未附带历史值
前端期望 kv.values(图表+表格数据) → 拿到undefined → Tab空白

修复: get_kpi 查询该KPI的kpi_values(按期间升序), 附带
values[{period, actual_value, source_type, data_status, source_batch}]

验证: F_REVENUE 10条(2026-01~08+), 渠补率9条
2026-08-26 09:14:33 +08:00
Hermes CI Fix a64f184525 feat: 数据分类分级 — 重要数据标记+资产清单+CSV导出(8/20评估办法政策驱动)
- subjects/kpi_definitions 新增 important_flag/data_level/data_category 列
- 新增 /api/cma/data-classification/* API: inventory/stats/标记/批量/export/industry-reference
- 数据级别: core核心/important重要/general一般(GB/T 43697 简化)
- 前端 DataClassification.vue: 统计概览+清单+打标+导出+行业参考目录
- pytest 17例覆盖(含多租户隔离验证)
2026-08-26 07:51:39 +08:00
Hermes CI Fix 6af4366a84 chore: 清理worktree验证测试文件 2026-08-26 06:53:25 +08:00
Hermes CI Fix cb318115e8 merge: test-isolation (worktree) 2026-08-26 06:53:11 +08:00
Hermes CI Fix acb4709ca9 test: worktree隔离验证 2026-08-26 06:53:00 +08:00
Hermes CI Fix 60be96bfaa feat: CMA知识库动态加载 — 前端从knowledge-articles API读取(CMA案例/税务合规/方法论等31篇可见) 2026-08-26 00:24:55 +08:00
Hermes CI Fix d20a270ba2 test: 七流一致自检规则测试 — 7条自检规则+酣客/博海真实场景
覆盖:
- 进销项倒挂/应交税费异常/账面毛利异常/收入vs现金/收入vs存货/人员vs收入/成本vs收入
- 七流健康评分(绿100/黄60/红0)
- 酣客场景: 触发>=3项红色(进销项倒挂/应交429万/毛利-0.43%)+评分<60
- 博海场景: 无红色+评分>=60(IT经销13%毛利合理)

27 passed — 为项目Bot开发七流自检API提供规则基线
2026-08-25 23:59:33 +08:00
Hermes CI Fix 199278a552 feat: API业务动作层聚合接口2个(Agent化生产链路·行动1)
建议A: POST /bot/kpi-value-with-check — 写KPI值+自动跑该KPI预警检查
  复用alert_rules检查函数(static/dynamic/trend), 非全量check-all
建议B: POST /bot/kpis/create-with-links — 创建KPI+关联地图+批量因果链
  复用kpis治理校验/apply_calc_type_inference, 入参可选退化纯创建
- X-BOT-KEY鉴权(bot层, Agent免登录)
- 多租户: entity校验(跨企业404/创建强制token企业)
- 端到端: A写值15.5无预警命中✓ B创建KPI430+map49+因果链430→414✓
- pytest 486 passed, 测试数据已清理
2026-08-25 20:27:19 +08:00
Hermes CI Fix 046a7cf8b9 test: 预算接口契约测试 — 覆盖2026-08-25修复的4个契约缺口
1. plans返回dimension/unit字段(前端维度列/单位列依赖)
2. plans支持keyword搜索(前端搜索框依赖)
3. versions列表字段完整性(version/status/kpi_count/total_budget)
4. 批量分解幂等性(重复执行不产生重复记录)
+ 更新过时测试: versions端点从'预期404'改为'预期200'(API已恢复)

结果: 35 passed
2026-08-25 18:22:04 +08:00
Hermes CI Fix 0daa079b96 fix: 预算数据修正 — 删除v1.0错误分解记录, 修正v2.0 F_REVENUE为真实H2预测(75-120万/月,原367万错误)
- 删除v1.0 49条(今天'执行分解'基于错误v2.0聚合产生的重复数据)
- 修正v2.0 F_REVENUE: 367万/月→90/75/80/85/95/120万(真实H2预测455万+7月90万)
- 解决预算录入'部分指标重复'问题(同KPI+期间双版本记录)
2026-08-25 18:19:48 +08:00
Hermes CI Fix cc7cddd646 refactor: 预算操作列优化 — 无预算记录显示'待创建'(点击进入编辑),有记录显示'删除' 2026-08-25 18:13:29 +08:00
Hermes CI Fix 142fff2cae fix: 预算操作列一致性 — 所有行都显示删除按钮(无记录时提示'暂无预算记录') 2026-08-25 18:11:51 +08:00
Hermes CI Fix 236fd34f08 refactor: 预算操作列规则修正 — 移除'财务指标/待创建'状态混淆,统一为编辑/保存/删除(所有维度可删预算记录) 2026-08-25 18:09:52 +08:00
Hermes CI Fix 3a57afcec4 fix: 预算录入操作列空白 — 无预算记录KPI(ek行)显示'待创建'而非空白 2026-08-25 18:06:16 +08:00
Hermes CI Fix dbfa6ba0a2 fix: 预算录入搜索不过滤 — plans API补keyword参数(KPI名称模糊搜索) 2026-08-25 17:53:47 +08:00
Hermes CI Fix 4d2fba67f7 fix: 预算录入维度无标签 — plans API补dimension/unit字段 2026-08-25 17:47:15 +08:00
Hermes CI Fix a1d014258f fix: 执行分解400错误 — auto-decompose支持批量模式(不传kpi_id分解该年所有预算KPI)
- 原API只支持单KPI(kpi_id+annual_budget),前端UI按整体分解调用 → 400
- 新增批量模式:聚合该年所有KPI年度预算,逐KPI均分/按历史权重分解12个月
- 验证:批量分解4个KPI(收入536万/净利-48万等),v1.0月度48条落库
2026-08-25 17:42:40 +08:00
Hermes CI Fix 863514fec0 refactor: 实物期权计算器菜单从规划与目标移到复盘与改进 — 与预测模拟(NPV/IRR)同组 2026-08-25 17:36:37 +08:00
Hermes CI Fix b6613bbde2 fix: 预算执行KPI名称/维度为空 — deviation-report补kpi_name/dimension/unit字段 2026-08-25 17:28:14 +08:00
Hermes CI Fix 835ac10e95 feat: 预算版本管理API — 修复前端'加载版本失败'(budgetApi.versions未定义+后端无端点)
- 后端: GET /budget/versions(版本列表) POST /versions/submit /versions/approve /versions/diff
- 前端: budgetApi.versions/versionSubmit/versionApprove/versionDiff
- 数据: budget_plans已有v2.0 active 24条
- 验证: 版本列表200(v2.0 24KPI 预算730万) 版本对比200
2026-08-25 17:11:03 +08:00
Hermes CI Fix 1c536cc5e1 fix: 波士顿产品矩阵菜单图标(PieChart) — 导入+iconMap注册 2026-08-25 17:06:30 +08:00
Hermes CI Fix 1e0439ff72 fix: 波士顿产品矩阵加入侧边栏菜单+角色权限 — 修复功能不可见 2026-08-25 16:59:00 +08:00
Hermes CI Fix 54107b50cd feat: 战略地图多租户隔离 — 酣客/博海地图按企业显示
- strategic_maps/map_objectives 加 entity_id 列+模型+回填(按标题: 酣客→1/博海→2)
- maps: list按企业过滤, create/create-with-template强制token企业, update/delete/batch-delete/connections/review 跨企业404
- objectives/versions: 按map归属过滤
- _sync_map_objectives 继承地图entity
- 实证: 酣客token→map32(酣客), 博海token→map49(博海), 跨企业读404
- pytest 482 passed
- 多租户隔离专项最终收官(含周报遗留项)
2026-08-25 16:58:27 +08:00
Hermes CI Fix 6b6043536a feat: 预测性成本智能升级 — 历史回归弹性校准 + 预测偏差告警
升级1: 宏观敏感性弹性历史校准
- 内置宏观历史数据(oil/usd/cpi 2026-01~07月度)
- 变化率弹性: 同period匹配KPI历史vs因素历史算弹性
- 合理性校验: |弹性|超出[0.01,0.5]视为噪声回退规则(诚实标注)

升级2: 预测偏差告警闭环
- 新表 kpi_forecast_log(预测历史)+模型KpiForecastLog
- 预测时落库(同KPI同预测期覆盖)
- alert_rules 支持 rule_type=forecast_deviation(threshold_pct)
- POST /alert-rules/run-forecast-deviation: 预测vs实际偏差>阈值生成预警(去重, 超2倍阈值红色)
- 端到端验证: 模拟实际500vs预测399.55→偏差20.1%>5%→红色预警生成

回归: pytest 40 passed(predict+alerts)
2026-08-25 00:55:50 +08:00
Hermes CI Fix 8ec846c6df feat: 预测性成本智能·宏观敏感性因素联动(IMA 2026.7完整版)
- 内置3宏观因素: 原油价格/美元汇率/CPI通胀率
- 敏感性引擎: 按KPI类别推断弹性(成本类油价0.15/利润类0.12/营收类0.08), 方向+因素涨KPI涨
- 负值KPI(亏损)方向反转修复: 油价涨→净利更亏
- API: GET /predict/kpi-forecast/sensitivity?pct=10 → KPI×因素矩阵(±pct调整后预测)
- 前端: 敏感性幅度选择(±5/10/20%) + 敏感性矩阵表(同向/反向+↑↓调整值)
- 诚实标注: 模型弹性(规则推断,非历史回归), 后续可用宏观历史数据回归校准
- 验证: Chrome实测页面+API矩阵, pytest 46 passed
2026-08-25 00:44:45 +08:00
Hermes CI Fix 1c8b01d682 feat: 预测性成本智能模块上线(IMA 2026.7前沿) — 接线+重建页面
- CostIntelligence.vue 重建(被清理的未提交文件): 模型选择(线性/移动平均)+预测期数+统计卡+KPI表格+趋势图
- 路由: /cost-intelligence(挂财务维度D执行与控制), 修正 predict/cost-intelligence 路径不匹配
- 菜单: 预测性成本智能(ceo/finance/it)
- 后端API已存在: /predict/kpi-forecast/finance (kpi_forecast_engine)
- 验证: Chrome实测 5 KPI预测(厂补率/营收/净利/费用率/毛利), 趋势图渲染, pytest不影响
- 注: 置信度诚实标注(全部low=历史数据波动大, MVP原则)
2026-08-25 00:34:28 +08:00
Hermes CI Fix 9de6e522a4 feat: 波士顿产品矩阵 — 四象限分析(明星/现金牛/问题/瘦狗)
- 数据层: product_sales表+导入脚本(93条, 酣客1-8月商品销售排行榜)
- 后端: GET /api/cma/products/matrix?entity_id&months
  横轴=销售趋势(线性回归), 纵轴=加权毛利率, 气泡=销售额
- 前端: ProductMatrix.vue ECharts散点图+象限卡片+明细表, 支持酣客/博海切换
- 验证: API四象限分类正确, 数据联动, 前端路由200, 全部通过
2026-08-25 00:25:11 +08:00
Hermes CI Fix 13aa153875 feat: 预测性成本智能MVP — KPI趋势预测引擎(线性回归/移动平均)+API+前端Tab+pytest覆盖 2026-08-25 00:10:46 +08:00
Hermes CI Fix fb9eba38a8 feat: 多租户隔离P2批2 — 预算/成本/费用/预警规则/BI报表加entity_id
- 12表加entity_id列(预算/偏差/规则/成本4表/费用2表/BI2表/驱动预算)
- 模型: BudgetPlan/StandardCost/ActualCost/AbcActivity/AbcAllocation/DriverFactorBudget/BiReport/Template/BudgetDeviationAlert/ExpenseRule/Reimbursement/AlertRule
- API隔离: budget plans / cost standard+actual / expenses rules+reimb / bi_reports list / alert_rules list 按token企业过滤
- 回填: kpi_id关联按KPI归属, 无关联默认酣客(entity=1); 当前数据全归酣客
- 验证: import+全端点200+pytest 451 passed
2026-08-23 18:20:29 +08:00
Hermes CI Fix 27b5b0da47 feat: 多租户隔离P2批1 — OKR域+kpi_values加entity_id列
- 模型: Objective/KR/ObjectiveKPI/KPIValue 加 entity_id
- DB: 4表加列; kpi_values 1610条按kpi_id回填(1269酣客/341博海)
- OKR域: 测试O删除重建; okr list/get/create + ontology trace/objectives 按token企业隔离(跨企业404)
- KPIValue写入: data/bot_bridge/bot_kpis 创建时带entity_id
- 验证: 酣客创建OKR博海不可见; 跨企业读404; pytest 451通过(expenses单跑37通过为既有排序flaky)
2026-08-23 17:53:37 +08:00
Hermes CI Fix 50c15ddbf6 fix: 多租户数据隔离P1a+P1b — 工作台/预警/行动计划/因果链按企业过滤
P1a(有entity表查询补齐):
- kpis get/update/delete/restore 跨企业404校验
- kpis create 强制entity=token企业, update禁止改归属
P1b(无entity表join隔离):
- alerts list/resolve join kpi_definitions 按企业过滤
- action_plans list join过滤 + create校验关联KPI归属
- kpi_causality full-network/list join过滤
- dashboard my_dashboard(用户发现) assigned/preset均按企业隔离
测试: 测试KPI种子entity对齐(2→1), pytest 451 passed
实证: 酣客token 6KPI(无博海id) vs 博海token 1KPI(414) 切换隔离正确
2026-08-23 17:43:39 +08:00
Hermes CI Fix 9c61b02d66 feat: 企业切换器按授权数动态显示 — 多企业用户(≥2)才显示
单企业用户隐藏顶栏切换器避免UI噪音(登录已选企业,无需切换)
前端判断 my-entities 列表长度, 零后端改动
admin(2企业)保留切换, 单企业用户(1企业)隐藏
2026-08-23 17:29:17 +08:00
Hermes CI Fix 412a302699 feat: OKR模板库P2治理 — owner字段/防重复/校准文档/缺口清单
- 模型+DB: okr_templates.owner (CMA标准库/行业包/用户自定义)
- API: create同名去重(409) + list/get返回owner + apply透传metric_kpi
- 前端: 模板卡片展示owner标签
- 文档: kpi-dictionary-gap-list.md(33条缺口+建议补KPI) + okr-template-calibration.md(酣客/博海校准表)
- 回归: 模板API验证通过
2026-08-21 16:49:11 +08:00
Hermes CI Fix 9ccdd0db63 docs: KPI设计规范补充OKR KR权重适配说明(主KR可至40%)
配合OKR模板库DAMA修复: 权重合规化(34/33/33→35/35/30, 40/30/30保持)
2026-08-21 16:37:14 +08:00
Hermes CI Fix 8103ee4788 feat: KPI治理规则2增加kpi_name必填校验 — 防空名KPI
- META_FIELDS 增加 kpi_name(KPI名称)
- 验证: 空名→规则2拦截; 有名称→通过; 审计仍95/95合规
- 背景: F_REVENUE曾空名未被发现
2026-08-21 16:28:23 +08:00
Hermes CI Fix b787d22fdf fix: KPI治理审计白名单 FB_/BH_前缀 + 补6个元数据 + BH_ZERO_TARGET单位
- 规则3: FB_(财务Bot, process维度) 加入 LEGACY_PREFIX_DIM; BH_(博海品牌) 加入 BRAND_PREFIXES 跨维度合法
- 规则2: 补6个KPI数据来源/责任人(客户调研/CRM/客服部, 人事培训系统/人事部等)
- 规则1: BH_ZERO_TARGET unit '-' → '%'
- 审计结果: 95/95 合规(原36个不合规清零)
- 回归: pytest 451 passed
2026-08-21 16:18:32 +08:00
Hermes CI Fix b06821a426 feat: KPI多粒度目标智能派生 — 基准值+按类型派生+手动覆盖
- 后端: target_calc_type字段(accumulate累计/ratio比率) + infer_calc_type名称/单位推断
- 派生规则: 累计型 月×3=季×12=年(季×4=年); 比率型 季/年沿用基准不可乘
- 虚拟派生不落库: kpi_to_dict返回derived_targets+derived_flags(自动标记)
- DB: 302个KPI回填类型(226累计/76比率)
- 前端KPIList: 指标类型选择 + 季/年自动派生预览(↳自动=N)
- 前端KPIDetail: 元数据卡片自动标记 + 编辑表单指标类型
- 回归: pytest 451 passed
2026-08-21 10:59:43 +08:00
Hermes CI Fix d5edcefb48 feat: KPI新建设计引导(五步法·五规则) — KPIList新建弹窗
- 新建KPI时显示设计引导折叠面板: 五步法逐步引导(职责/客户/需求/成果/五因素)
- 五规则实时校验清单: 名称/维度/单位/目标值/频率/负责人 + 本维度KPI数量≤6提示
- 提交门禁(仅新建): 名称+维度必填, 单位+至少一个目标值必填
- 对应 docs/kpi-design-rule.md 方法论落地
2026-08-20 15:56:56 +08:00
Hermes CI Fix a9845eb2cf docs: KPI设计规范(五步法/五规则/三路径) — CMA方法论层第2份规范
- 第2份方法论文档, 与科目≠KPI规范(第1份, 数据层)互引形成双层治理闭环
- 五步法: 职责→客户→需求→成果→五因素(时/量/质/成/险)
- 五规则: ≤6个/权重5%-30%取5%倍数/定量定性/SMART/部门差异化
- 三路径: 自上而下战略分解/自下而上职责提取/BSC四维平衡
- 四层链: 战略地图→O→KPI→科目(呼应本体三支柱)
2026-08-20 15:39:09 +08:00
Hermes CI Fix a13a080381 test: CMA自动化测试补覆盖 226→452用例, 覆盖率36%→60%
- 新增8个测试文件(bot_bridge/kpi_causality/cash/predict/reports/tax_compliance/expenses/probe_cost)
- 增强 budget/auth/users + conftest账套模式适配
- 测试驱动修复: bot_bridge导入batch_id→source_batch; cash_forecast extra空dict
- 全量: 451 passed, 1 xfailed; 报告 docs/cma-test-coverage-report.md
2026-08-20 06:57:24 +08:00
Hermes CI Fix dd53212bcc feat: KPI多粒度目标值补提交 — dashboard API + 前端3视图(目标值月/季/年)
已上线未提交的历史功能(2026-08-17): kpi_target_by_frequency + target_monthly/quarterly/yearly 字段透传
2026-08-20 06:57:13 +08:00
Hermes CI Fix bd9c70ea05 本体三支柱: 科目↔KPI↔OKR三层互联 + 追溯链API + OKR详情页签
- 新表 kpi_subject_map(89映射/24KPI) / objective_kpi(3O×5KPI) / krs(3O×3KR)
- GET /api/cma/ontology/trace?objective_id=N O→KPI→科目逐层追溯
- GET /api/cma/ontology/objectives 三层链路概览
- OkrDetail.vue 新增本体追溯链页签(KR目标/当前值 + KPI→科目标签流)
- 含并发已上线未提交的KPI多粒度列(target_monthly/quarterly/yearly)
- 回归: pytest 409 passed
2026-08-19 22:58:10 +08:00
Hermes CI Fix 0b305e3ab3 fix: 杜邦分析period格式兼容 — 2026H1/2026-H1变体匹配,酣客ROE正常返回-12.74%
- _get_dupont_kpi支持6字符(2026H1)和7字符(2026-H1)period格式
- 之前数据库存'2026-H1'但API查'2026H1'导致读不到数据,酣客ROE返回None
- 验证:酣客ROE=-12.74%(净利率-12.74%,亏损企业正确为负)
2026-08-18 09:54:56 +08:00
Hermes CI Fix 31080500f0 fix: 保存提示/确认框靠左重叠菜单 — el-message强制居中+移除el-message-box margin:0破坏 2026-08-17 16:21:07 +08:00
Hermes CI Fix c952930dfb ui: KPI详情页元数据卡片置顶展示(便于快速观察完整性) 2026-08-17 16:07:37 +08:00
Hermes CI Fix 5805ef34e7 fix: 保存消息位置 — ElMessage z-index提到弹窗之上+顶栏下方70px 2026-08-17 16:04:15 +08:00
Hermes CI Fix 58e8a9f5b3 fix: 空白地图创建失败 — dimensions默认四层空结构+review NULL容错 2026-08-17 09:49:21 +08:00
Hermes CI Fix 32fb8d9b06 fix: 战略地图创建保存防重 — 按钮loading防重复创建(修复一次保存9份) 2026-08-17 09:44:15 +08:00
Hermes CI Fix bf0f0b3054 feat: 表单保存交互规范P1+P2+P3 — 保存按钮loading防重复(KPIList/UserManage/KPIDetail)+KPIList编辑弹窗化(详情跳页并存)+保存反馈全面核查 2026-08-16 12:04:22 +08:00
Hermes CI Fix 40d743fc96 feat: 表单保存交互规范P0 — 全局未保存离开守卫(useFormGuard)+MapCanvas接入 2026-08-16 11:39:26 +08:00
Hermes CI Fix acf0665010 feat: 登录默认页改为我的工作台 2026-08-16 11:00:19 +08:00
Hermes CI Fix 7b5795bf07 fix(P0): 杜邦分析接口去除硬编码错误数据 — equity 114万→358.4万、ROE 12.38%→3.95%、新增hanke分支读DB(KPI 2026H1 verified)
- 新增 _get_dupont_kpi 辅助函数: 按 entity_id+kpi_code 查 kpi_definitions→kpi_values(period=2026H1, verified)
- bohai: 净权益修正为358.4万(来源 bohai_comprehensive_analysis_2026.md 试算平衡表),财务杠杆4.68x→1.49x,负债率78.6%→32.8%结构健康,ROE≈3.94%,删除误导性加杠杆建议
- hanke: 新增分支,从DB读 F_REVENUE=713.27/F_NET_PROFIT=-90.86,净利率-12.74%,资产/权益缺失时factor返回null标注数据待补充,prev_roe返回null(前端兼容显示无对比),接口返回200
2026-08-15 23:02:48 +08:00
Hermes CI Fix 603d6ae5cf feat: 战略地图贝塞尔因果链+节点KPI可视化(P0+P1) 2026-08-15 22:08:09 +08:00
Hermes CI Fix 778a5d9c79 fix: 工作台KPI级别判断支持反向指标 — 费用率/渠补率/应收天数等'越低越好'指标不再误判green
- my_dashboard的level判断原为actual/target比例法(≥0.9=green)
- 反向指标(费用率24.9%/目标20%)被误判为green,实际应red
- 新增REVERSE_INDICATORS集合:F_COST_RATIO/C_REBATE_RATE/F_AR_DAYS/F_REBATE_RATE/F_FACTORY_REBATE_RATE/F_COST_CONTROL_RATE
- 反向指标逻辑:实际≤目标=绿,≤目标*1.1=黄,否则红
2026-08-15 21:44:09 +08:00
Hermes CI Fix 5a196dc105 fix: 数据治理P0 规则4前端level过滤+数据清洗 2026-08-15 21:26:27 +08:00
Hermes CI Fix 266277d121 feat: 应收款催收闭环 — 催收视图+博海230万录入+回款登记联动 2026-08-14 22:07:02 +08:00
Hermes CI Fix a268c4e74d fix: 智能导入列检测+名称防护 — 科目编码误当名称(EXT_069显示1001.0/EXT_071显示nan)根因修复
- kpi_code_patterns 名称类列优先(科目名称/指标名称/name),编码类列兜底
- 修复科目余额表表头(科目编码|科目名称)检测失败fallback cols[0]导致名称存成编码
- 导入循环增加防护: NaN/空/纯数字名称跳过,不再创建垃圾KPI
- 存量数据: 14个一级科目名称已按科目表修正,82个明细标记待财务核对,EXT_071标记未命名
2026-08-13 01:17:32 +08:00
Hermes CI Fix 1bdfd95fde fix: 账套隔离 — register不再授予全部企业,仅授第一个active企业(防新用户越权) 2026-08-11 11:34:14 +08:00
Hermes CI Fix 2f2ada530d fix: bot/kpis 500 — KPIDefinition无description字段改为formula_desc(存量bug,账套模式验证门禁4发现) 2026-08-11 11:34:05 +08:00
Hermes CI Fix d7e97978c6 fix: 写接口entity_id token优先(resolve_entity_for_request)+ 登录页用顶层entity_id 2026-08-11 11:32:30 +08:00
Hermes CI Fix 52ec2c0f36 fix: 登录页entity_id存储取login响应顶层字段 — user对象无entity_id导致cma_entity_id恒为1
- login响应结构: {token, entity_id, entity_name, entity_short_name, user:{...}}
- 原代码读 res.user?.entity_id ?? 1 → 登录博海(2)却存1 → 页面显式传参1与token绑定2不一致 → get_entity_id返回403
- 改为 res.entity_id / res.entity_short_name(与MainLayout切换器存储逻辑一致)
2026-08-11 11:32:27 +08:00
Hermes CI Fix d56ba24f31 feat: deps越权防护(query/header与token不一致403) + resolve_entity_for_request + 任务⑥开发规范沉淀 2026-08-11 11:30:08 +08:00
Hermes CI Fix b59de7c476 feat: 账套模式API统一 — entity_id参数全部走get_entity_id + 登录页/切换器接口打通
- kpis/cash/bsc_layers/growth_quality/tax_compliance/data/predict: entity_id参数统一为Depends(get_entity_id),token优先隔离(原Query(1)/None会被前端显式传参覆盖,存在越权面)
- auth: 新增GET /auth/login-entities(公开,登录页按用户名查授权企业) + /auth/my-entities(切换器) + /me带当前账套
- 前端: 拦截器删除自动附加X-Entity-Id/entity_id; Login公司选择器; MainLayout/ReportCenter切换器改switch-entity重新签发token+整页刷新
2026-08-11 11:29:43 +08:00
Hermes CI Fix 9986f38faa feat: 账套模式全量接入 — 12个API entity_id统一走token解析链 + 前端登录选公司/切换器重签token
- 后端: kpis/bsc_layers/cash/predict/growth_quality/tax_compliance/data 的 entity_id 参数统一改为 Depends(get_entity_id)
- 前端: 拦截器删除自动附加X-Entity-Id/entity_id; 登录页公司选择器(按用户名授权过滤); 切换器改POST /auth/switch-entity重新签发token+整页刷新; ReportCenter同步改造
- 修复前后端不匹配: login-entities路由→/auth/entities; login响应entity_id取user.entity_id
2026-08-11 11:29:39 +08:00
Hermes CI Fix 12f9ae520b chore: 删除孤儿文件 auth.py — 实际生效的是 api/auth.py(含_ensure_default_grants存量兼容) 2026-08-11 11:26:17 +08:00
Hermes CI Fix 35839dd38e feat: auth.py 账套模式 — login强制entity_id + my-entities授权查询 + switch-entity重签发token 2026-08-11 11:25:18 +08:00
Hermes CI Fix b31f9b80c4 feat: 账套模式后端 — token绑定entity_id + user_entities授权表 + 解析链倒置
- auth_middleware: create_token存JSON{user_id,entity_id},旧int格式token强制下线
- models: 新增UserEntity授权表(user_id↔entity_id多对多,唯一约束)
- database: init_db自动建表+存量用户×active企业默认授权(平滑迁移)
- deps: get_entity_id解析链倒置 token优先 → Bot白名单(query/header校验entity active) → 默认1
- auth: login加entity_id+授权校验; 新增switch-entity/my-entities/登录页entities接口; register自动授权
2026-08-11 11:24:52 +08:00
Hermes CI Fix 9d0f070c14 fix: deploy.sh 依赖更新不阻断部署(PEP668兼容)+ hook退出码检查 2026-08-11 11:16:12 +08:00
Hermes CI Fix d99577dc5c fix: deploy.sh venv激活兼容 — 精简venv无activate时直接用venv/bin/pip3 2026-08-11 11:14:54 +08:00
Hermes CI Fix eb5cca2372 chore: backend/logs/ 不入库(gitignore + 解除跟踪 uvicorn.log) 2026-08-11 11:12:56 +08:00
Hermes CI Fix e959c40b1c feat: 多租户实时切换 — 前端拦截器统一entity_id + 后端get_entity_id依赖 + dashboard 6端点支持租户过滤
- 前端: api/index.ts拦截器自动附加X-Entity-Id header + entity_id query参数
- 后端: 新增app/deps.py的get_entity_id公共依赖(query→header→默认1)
- dashboard.py: summary/kpis/finance-analysis/predict/my-kpis/my-dashboard全部支持entity_id
- 解决: 42个页面仅13个传entity_id导致切换企业后数据混乱
2026-08-11 11:11:17 +08:00
Hermes CI Fix dcc7e2194d feat: 新30号准则适配 — 三表完善+认证头修复+企业联动 2026-08-11 00:40:43 +08:00
Hermes CI Fix 4081361b05 fix: GrowthQuality import api默认导出 2026-08-11 00:22:54 +08:00
Hermes CI Fix b1e8a55745 feat: 增长质量诊断模块 — 五维评分+雷达图+跨期对比+趋势 2026-08-11 00:21:10 +08:00
Hermes CI Fix 0b191d278e feat: OKR模板库改造 — 弹窗模板库联动+时间分解落库+制造业包6个 2026-08-11 00:11:25 +08:00
Hermes CI Fix 32fe3e4d50 feat: bot-bridge数据通道 — POST /kpi-result回填KPI+触发预警+MPM自动回填脚本
- 新增 POST /api/cma/bot-bridge/kpi-result: (entity_id,kpi_code)查KPI,
  写入KPIValue(source_type=bot, data_status=verified), 复用run_alert_check
  触发预警(自动联动行动计划), 返回new_alerts; KPI不存在/缺字段返回错误
- 修复verify引擎_get_kpi_current_value排序: 按calculated_at取最新值,
  bot回填值可被auto-verify读到 (修复2026H1字符串排序遮蔽月值问题)
- 新增scripts/bot_bridge_push.py: 财务Bot MPM结果自动回填(MPM字段→KPI
  映射), 支持--mpm/--mpm-file/单KPI直推/--ping
2026-08-10 23:56:14 +08:00
Hermes CI Fix f9e20bc9bc fix: KPI历史数据治理批量修复 — 元数据补全321条+编码规范处理222条
- 规则2: 补全321条元数据 (formula 207 / data_source 57 / data_owner 57)
  - EXT_科目KPI: formula按财务维度默认'财务指标计算'
  - F_/C_/P_/L_经典KPI: 精确公式(6条) + 数据源按维度(财务系统/业务系统)
  - data_owner优先取负责部门, 否则默认财务部
- 规则3: 审计规则白名单化EXT_前缀(科目余额表导入, 仅限finance维度),
  EXT_编码与dimension保持一致不改动; FB_/BH_ 21条真实前缀冲突
  输出人工确认清单 docs/kpi_governance_human_review.md
- 验证: GET /api/cma/kpi/governance/audit → rule_counts {1:0, 2:0, 3:21, 4:0}
  未改动任何kpi_code及引用表, 评分/地图/KPI列表接口正常
2026-08-10 23:50:14 +08:00
Hermes CI Fix 37f26148fe feat: 数据治理4条规则 — 入库必检+元数据+编码规范+战略分级 2026-08-10 23:40:31 +08:00
Hermes CI Fix e1ea5cd14d feat: Auto-Verify闭环 — verify API+定时验证+OKR联动+周报 2026-08-10 23:15:19 +08:00
Hermes CI Fix 7fed66d58c feat: import-excel-smart支持entity_id + 导入博海科目余额表(320条/78KPI) 2026-08-07 18:26:09 +08:00
Hermes CI Fix d0d6e30e81 fix: KPIDefinition模型补kpi_level字段 + 导入酣客科目余额表 2026-08-07 17:58:03 +08:00
Hermes CI Fix ac6ea93ed5 feat: 多租户联动 — CMA切换企业注入tenant_id + a2a_dispatch分发 2026-08-07 17:42:26 +08:00
Hermes CI Fix f33162b6c9 feat: 财务报表助手—对外法定报表(三表合一+Excel导出) 2026-08-04 15:35:15 +08:00
Hermes CI Fix 38b603f263 feat: 税务合规智能体—税负监控+发票校验+社保比对 2026-08-04 15:04:39 +08:00
Hermes CI Fix 8e277de3de feat: 资金管理强化—缺口预测+收付款计划+预警 2026-08-04 14:53:14 +08:00
Hermes CI Fix 555d3b621a feat: 费用审核智能体—规则+报销流程+看板 2026-08-04 14:40:03 +08:00
Hermes CI Fix e3e8bad3da fix: KPI保存后重新拉取服务端数据,确保元数据卡片状态同步 2026-07-29 18:32:54 +08:00
Hermes CI Fix 189f442da4 feat: 所有表单的部门/负责人改为下拉框(自动读取组织+用户数据)
- KPI详情: 负责部门+负责人 el-input→el-select filterable
- KPI字典: 新建/编辑/模板实例化 三处同步修改
- 改善行动: 负责人筛选+表单 从userApi.list加载
- 预警中心: 指派人+负责人 从userApi.list加载
- api/index.ts新增orgApi封装
2026-07-29 18:25:08 +08:00
Hermes CI Fix cffb15e8dc fix: reports.py missing import json 2026-07-29 18:19:42 +08:00
Hermes CI Fix bc02d09520 feat: ChatBI自动报告生成—周报/月报/专项 2026-07-29 18:16:37 +08:00
Hermes CI Fix 68e40ac9c5 fix: 自动创建KPI补全target_value/kpi_level+文件名中文日期解析
- 补NOT NULL字段: target_value=0, kpi_level='operational'
- 文件名支持中文日期: 2026年01月 / 2026年01月至2026年05月
- 科目余额表列名匹配增强: 余额/本年累计/本期发生额
- 期间列识别: 年/月开头列名
2026-07-29 18:12:11 +08:00
Hermes CI Fix 47ef861488 fix: 自动创建KPI时补全所有NOT NULL字段(formula/data_source/data_owner/unit/entity_id) 2026-07-29 18:07:57 +08:00
Hermes CI Fix 9b973ab9a8 fix: 智能导入——匹配不上的财务报表科目自动创建KPI定义
- 清理科目前缀(一、/减:/加:)后多级匹配
- ⑤仍未匹配→自动创建KPI(PL_001/CF_001/BS_001)
- 避免205条全部跳过的场景
2026-07-29 18:02:50 +08:00
Hermes CI Fix 8a7cf25025 fix: 预测模拟企业同步—从localStorage读取entity_id 2026-07-27 17:23:29 +08:00
Hermes CI Fix 6d5c374c60 fix: 战略回顾会改善行动进度—箭头函数少括号 2026-07-27 17:19:57 +08:00
Hermes CI Fix 2bd66b1e89 fix: 行动方案库统计卡片—补全API+overdue计算 2026-07-27 17:16:01 +08:00
Hermes CI Fix f85b18ce6d fix: 企业切换数据同步(8/10页面+shared组件) 2026-07-27 16:13:03 +08:00
Hermes CI Fix b593e0383f feat: O→KR→KPI Phase 0 - KR sub-nodes on canvas, KR edit dialog, data migration
- MapNode.vue: Add collapsible KR sub-list with progress bars under each O node
- MapCanvasDialogs.vue: Add KPI selector per KR, editing mode (skip template step, pre-fill data), validation (max 5 KR, target value required)
- StrategyLayer.vue: Pass through edit-kr/add-kr events
- MapCanvas.vue: Data migration (old kpis[]→krs[]), KR add/edit handlers, pass editingObj/allKpis to dialog
2026-07-26 18:01:59 +08:00
Hermes CI Fix 84749a35ca fix: _merge_map_objectives保留O+KR额外字段 2026-07-26 17:07:49 +08:00
Hermes CI Fix 71674d62ab fix: KR目标值自动同步到名称(如毛利率提升→毛利率提升≥5%) 2026-07-26 16:55:29 +08:00
Hermes CI Fix 5b6362a98a feat: 编辑目标——回车自动加%+权重合计校验 2026-07-26 16:44:22 +08:00
Hermes CI Fix abedf8cb8d feat: Bot KPI管理体系 — bot_source字段 + 11个财务Bot KPI + Bot KPI看板
- 新增 bot_source 字段到 kpi_definitions 表(DB迁移 + 模型字段)
- 创建 bot_kpis.py API(GET /api/cma/bot-kpis + POST .../value)
- 种子脚本 seed_finance_bot_kpis.py 插入11个财务Bot KPI
- BotKpiDashboard.vue 看板组件(三区:核心产出5/质量3/用户反馈3)
- 路由 /bot-kpis + 侧边栏菜单入口
- 复用五档评分引擎
2026-07-25 07:44:41 +08:00
Hermes CI Fix c7ec8b2c99 fix: 画布节点删除去掉ElMessageBox确认 2026-07-23 17:59:32 +08:00
Hermes CI Fix 8a4fd1dd00 fix: layers.js财务层key从financial改为finance(匹配后端) 2026-07-23 17:27:39 +08:00
Hermes CI Fix 9acb651ad8 fix: el-overlay全局修复(z-index+position) 2026-07-23 17:10:01 +08:00
Hermes CI Fix 4404bcded7 fix: 地图删除去掉ElMessageBox确认(可能和el-dialog同样问题) 2026-07-23 17:06:51 +08:00
Hermes CI Fix 2060c38c5d feat: lead API加企微通知 2026-07-22 18:22:48 +08:00
Hermes CI Fix c1384fd4ff feat: sxbh.ltd AI自动获客组件+lead API 2026-07-22 18:16:12 +08:00
Hermes CI Fix 2d0abe3411 feat: bot-bridge+verify合并 — API+数据库+验证引擎 2026-07-22 17:43:59 +08:00
Hermes CI Fix e8ffa0d550 fix: 菜单顺序构建修复+types/menu接口定义 2026-07-22 17:12:44 +08:00
Hermes CI Fix dc09a04c24 fix: 菜单按1-6顺序重排+规则4KPI分级 2026-07-22 16:56:19 +08:00
Hermes CI Fix d88147ca36 fix: 菜单按1-6顺序重排(P→D→C→A→基础→系统) 2026-07-22 16:38:27 +08:00
Hermes CI Fix 16bbd4c4a3 fix: 知识库术语解释加PDCA/SMART/OKR三篇文章 2026-07-22 16:31:15 +08:00
Hermes CI Fix 63745a87ed fix: 恢复知识库正确版本(纯硬编码+MyDialog) 2026-07-22 16:26:31 +08:00
Hermes CI Fix b0437b8918 fix: 还原知识库硬编码版本(API加载内容格式问题) 2026-07-22 16:21:00 +08:00
Hermes CI Fix d5d968a63b refactor: 知识库从硬编码改为API动态加载(24篇文章) 2026-07-22 16:16:45 +08:00
Hermes CI Fix c821d19aae fix: 知识库文章详情el-dialog→MyDialog 2026-07-22 15:57:48 +08:00
Hermes CI Fix 5435361b79 fix: 菜单归属修正 — 改善行动→A/实物期权→P/OKR模板→基础 2026-07-22 15:44:03 +08:00
Hermes CI Fix 3f6e1fdba8 refactor: 菜单按CMA逻辑重组 — 改善行动D→A/实物期权P←A/二级分组 2026-07-22 15:40:56 +08:00
Hermes CI Fix 2923e54366 feat: 驱动因子计划 — 模式切换+行业包+敏感性分析 2026-07-22 12:58:04 +08:00
Hermes CI Fix 66554fb7fa feat: 持续规划 — 滚动预算+自动延展+对比线+偏差告警 2026-07-22 12:31:25 +08:00
Hermes CI Fix cbcc0d0a28 feat: 新30号准则P2 — MPM计算器+旧格式双列对比 2026-07-22 12:23:40 +08:00
Hermes CI Fix 748c2da43f feat: 数据治理 — 入库约束+元数据卡片+编码清洗+审计看板 2026-07-22 12:06:52 +08:00
Hermes CI Fix ec6af751a5 feat(new30-p1): 追溯调整数据准备+KPI口径更新
- 后端新增 GET /api/cma/reports/restatement 端点
  - 旧口径vs新口径双列对比
  - 自动标记需调整项(管理费用剥离研发费用、财务费用拆解)
  - 返回净利润对比汇总
- 前端报表中心新增"追溯调整"Tab
  - 双列表格展示(旧准则/新准则)
  - 差异自动高亮标记
  - 调整原因列、汇总统计
- KPI口径更新(entity_id=1)
  - F_COST_RATIO 费用率: (销售费用+管理费用-研发费用)/营业收入×100%, target=15%
  - 新增 F_OP_PROFIT_MARGIN 经营利润率: 经营利润/营业收入×100%, target=5%
- 构建部署验证通过
2026-07-22 00:01:20 +08:00
Hermes CI Fix dd105efee3 feat: 新30号准则P0 — 利润表五板块重构+费用分类打标 2026-07-21 23:41:40 +08:00
Hermes CI Fix c4e91f28ef feat: AI事前预警 — 现金流预测+预警扩展+准确率+情景建议 2026-07-21 18:22:04 +08:00
Hermes CI Fix 270d7758f8 feat: PDCA双循环显式化 — 导航PDCA标识 + 双会议模板 + 战略回顾会数据联动 2026-07-21 18:10:48 +08:00
Hermes CI Fix 3db7bef025 fix: CMA P0修复 - 评分引擎反向指标 + 清理重复KPI + 校准目标值
三项P0修复:
1. 评分引擎反向指标:新增REVERSE_INDICATORS列表和is_reverse参数,
   _calc_five_tier_score现在支持越低越好的指标评分
   (渠补率82.8%>75%从4分→2分)
2. 清理重复KPI:删除entity_id=2(博海)的56条重复KPI,
   从entity_id=1(酣客)重新克隆
3. 校准目标值:按审计报告更新酣客24项KPI目标值
   (F_REVENUE 4979→1200, F_NET_PROFIT 500→50等)
2026-07-21 17:56:26 +08:00
Hermes CI Fix 3852533129 fix: 模板选择弹窗下一步按钮始终可用(不选模板也可跳过) 2026-07-21 17:41:33 +08:00
Hermes CI Fix 4bc4c32e4f feat(okr): Phase2 - 行业扩展包12个+OKR模板库页面
- 插入12个行业包模板:贸易经销(7)+IT服务(5)
- 后端: name搜索+apply端点
- 前端: OKRTemplates.vue页面(搜索/筛选/三分区/应用弹窗)
- 路由+菜单+权限配置
2026-07-21 17:30:50 +08:00
Hermes CI Fix 9c6c1614fb Phase 1: OKR模板 — 两步弹窗(O+KR结构) + 14个CMA模板 + okr_templates表 + API
MapCanvasDialogs.vue:
- 节点编辑弹窗改为两步交互(模板选择→O+KR编辑)
- 第一步:按维度展示对应模板(财务4/客户3/流程4/学习3)
- 第二步:编辑O名称+描述+3个KR(名称/目标值/权重)
- 14个CMA标准模板硬编码在前端
- 支持添加/删除KR、自定义目标跳过模板

MapCanvas.vue:
- 传递 editingLayerKey 到弹窗
- onSaveDialog 适配新 O+KR 数据结构
- onNodeSave 存储 krs 和 template_name 字段

Backend:
- OKRTemplate 模型 (okr_templates 表)
- okr_templates API: GET(按维度筛选) + POST(用户自定义) + increment use_count
- seed_okr_templates.py 迁移脚本(建表+14条种子数据)
- 在 main.py 注册新路由
2026-07-21 17:22:43 +08:00
Hermes CI Fix a9cc7c3f1d fix: 战略地图画布5个弹窗el-dialog→MyDialog 2026-07-20 15:25:05 +08:00
Hermes CI Fix 4ef50d083e fix: 战略地图模板KPI字段同步到map_objectives表 2026-07-20 15:20:11 +08:00
Hermes CI Fix 1e0262e162 ui: 战略地图泳道重新设计 — 圆角阴影卡片+浅灰内容区 2026-07-20 15:16:26 +08:00
Hermes CI Fix 43af7cd99d fix: 战略地图按钮统一size=small 2026-07-20 15:05:40 +08:00
Hermes CI Fix ebc762cd04 feat: 全局企业切换器(顶部导航栏+localStorage共享) 2026-07-20 10:40:44 +08:00
Hermes CI Fix 5bb10dbba5 ui: 我的工作台全面美化 — 渐变顶栏+KPI网格+空状态卡片 2026-07-20 10:33:35 +08:00
Hermes CI Fix 5710b28730 fix: 我的工作台 — 预设KPI编码更新+SQL查询条件修复 2026-07-20 10:27:12 +08:00
Hermes CI Fix 5d7873e05e fix: KPI软删除+恢复入口+已归档筛选 2026-07-20 10:21:29 +08:00
Hermes CI Fix c7e187bb00 fix: 全局因果网络图节点显示中文名 2026-07-20 09:51:51 +08:00
Hermes CI Fix 420f664b57 fix: 模拟推演+全局因果网络图改用MyDialog(原el-dialog不弹窗) 2026-07-20 09:33:24 +08:00
Hermes CI Fix cf766984f3 fix: el-dialog lock-scroll=false(弹窗打开后不锁定页面滚动) 2026-07-20 09:29:24 +08:00
Hermes CI Fix 0c2b58609c fix: 因果网络图节点显示中文名(kpi_name)而非编码(kpi_code) 2026-07-20 09:24:03 +08:00
Hermes CI Fix 0ebb0fec05 fix: MyDialog z-index 10000->1001(避免遮挡el-select下拉菜单) 2026-07-19 18:25:03 +08:00
Hermes CI Fix 7f5f79eb01 fix: 模板弹窗el-dialog改为MyDialog(已知可正常工作的组件) 2026-07-19 18:20:47 +08:00
Hermes CI Fix 1231640af2 fix: 模板弹窗click改为显式函数调用 2026-07-19 18:12:02 +08:00
Hermes CI Fix 04ba962b8e fix: 修复KPITemplate模型映射(独立表)+模板API 500 2026-07-19 17:53:14 +08:00
Hermes CI Fix 432d0815a7 feat: add unsaved edits confirmation (dirty flag + route guard + beforeunload) to KPIDetail.vue 2026-07-19 17:18:49 +08:00
Hermes CI Fix 51e50d8203 fix: 分类树显示中文标签(通过CAT_MAP映射) 2026-07-19 16:59:45 +08:00
Hermes CI Fix 2b99b143a8 fix: KPI分类英文改中文+补全缺失分类映射 2026-07-19 16:55:29 +08:00
Hermes CI Fix 56067261ad feat: 驾驶舱改名经营看板+改善行动闭环按钮+统一菜单名 2026-07-19 15:54:20 +08:00
Hermes CI Fix 0a2926047e fix: 增长质量评分按实体ID查询数据(酣客1.4/博海3.8) 2026-07-19 10:52:50 +08:00
Hermes CI Fix f85c3d2c4e feat: 增长质量诊断模块 — 后端评分API+前端驾驶舱卡片+雷达图+明细下钻+双实体对比
- 新增 POST /api/cma/predict/growth-quality 评分API
  - 五维度评分引擎(营收结构/利润结构/现金资产/增长驱动/组织效率)
  - 自动诊断结论生成
  - 明细指标+改善建议
  - 双实体对比模式

- 前端 GrowthQuality.vue 组件
  - 综合评分大字展示
  - 五维度评分条 + 状态颜色
  - ECharts雷达图
  - 明细下钻弹窗(指标+建议)
  - 双实体并列对比(VS模式)

- 集成到 Dashboard.vue CEO驾驶舱视图
- 酣客测试验证:综合1.4分(越增长越重)
- 博海对比验证:综合3.6分(增长质量中等)
2026-07-19 10:46:43 +08:00
Hermes CI Fix 2d471aa07b feat: KPI通用化 — 五档评分/三级分解树/因果链追踪 2026-07-19 10:21:00 +08:00
Hermes CI Fix c57f377e04 KPI通用化: bsc_layer_config表+API+企业选择器+企业CRUD 2026-07-19 10:07:46 +08:00
Hermes CI Fix 59f4ab2c76 feat: KPI通用化数据层改造
- 新增entities表(企业实体)+ 种子数据(酣客/博海)
- kpi_definitions新增entity_id字段(DEFAULT 1,向后兼容)
- 后端API list_kpis支持可选的entity_id过滤参数
- 新增 GET /api/cma/entities API返回企业列表
- 43个现有KPI自动获得entity_id=1(酣客)
- 前端重新构建并部署
2026-07-18 23:17:55 +08:00
Hermes CI Fix 70e5b83a9f fix: bot/actions去除不存在的target_value字段(导致500) 2026-07-18 18:09:20 +08:00
Hermes CI Fix f2a7e2be95 feat: 导入CMA完整文档(14章)到知识库 2026-07-18 18:01:45 +08:00
Hermes CI Fix afa060bbfd feat: 实物期权计算器 — 后端BSM+二叉树API+前端交互页面 2026-07-18 17:44:21 +08:00
Hermes CI Fix 430d9f23fe feat: CMA前端UI优化第三轮P2 — mc-dialog→el-dialog+按钮统一+MapCanvas拆组件+全局过渡动画 2026-07-18 17:29:38 +08:00
Hermes CI Fix 1865a2f165 fix: LearningDashboard补el-empty空状态 2026-07-17 16:58:12 +08:00
Hermes CI Fix c68bdd1e1d feat(ui): 第二轮P1 — scoped样式/mc-dialog暗黑适配/空状态/字号变量 2026-07-17 16:55:09 +08:00
Hermes CI Fix c82bd5b40f feat(ui): CMA前端UI优化P0 - BSC变量+公共类+统一标题+色值替换+内联style替换 2026-07-17 16:35:54 +08:00
Hermes CI Fix 7f0f74a6e9 fix: 菜单分组渲染逻辑 — 相同group合并为一个分组标题 2026-07-17 09:44:22 +08:00
Hermes CI Fix 4f0229fccc fix: 补齐全部24项菜单的group字段 2026-07-17 09:31:50 +08:00
Hermes CI Fix 6799d546e8 fix: 删除重复的data-quality+补齐predict的group标签 2026-07-16 18:28:26 +08:00
Hermes CI Fix ccbc21fb1c fix: 删除重复的'数据管理'菜单项(同时出现在基础数据和监控组) 2026-07-16 18:24:15 +08:00
Hermes CI Fix b8236c8307 refactor: KPI字典+数据管理移入基础数据组(独立于PDCA) 2026-07-16 18:01:50 +08:00
Hermes CI Fix c103685c8e fix: 补充缺失的Reading/Opportunity图标导入 2026-07-16 17:57:10 +08:00
Hermes CI Fix 4569c24739 refactor: 菜单按PDCA五组重组 — 规划与目标/执行与控制/监控与评价/复盘与改进/系统与支持 2026-07-16 17:49:29 +08:00
Hermes CI Fix 75f3f0df77 feat: Round3 职业道德自测题 — 10道IMA准则情景题 2026-07-16 17:30:35 +08:00
Hermes CI Fix 9eded3e4fe feat: CMA P1+P2 Round2 — 风险矩阵+COSO+预算方法 2026-07-16 17:09:32 +08:00
Hermes CI Fix 82919b1616 feat: CMA P1+P2 Round1 — 成本法对比+杜邦分析+本量利 2026-07-16 17:09:32 +08:00
Hermes CI Fix 4b25f46124 feat: CMA P1+P2 Round1 — 成本法对比+杜邦分析+本量利 2026-07-16 16:39:24 +08:00
Hermes CI Fix 46fa811d52 fix: 反引号+data架构同步+因果链推荐 2026-07-14 17:34:53 +08:00
Hermes CI Fix 9afcea8f8d feat: 战略地图收尾 — 自动保存+导出+表迁移+因果链推荐 2026-07-14 17:32:22 +08:00
Hermes CI Fix 5594a572e9 fix: 添加缺失的html2canvas和marked依赖 2026-07-14 17:23:58 +08:00
Hermes CI Fix fc40cb8a65 task 2: 导出图片 — 添加html2canvas依赖和导出图片按钮 2026-07-14 17:22:58 +08:00
Hermes CI Fix 4cfada967f task 1: 自动保存(30s) — 新增autoSaveTimer每30秒静默保存草稿 2026-07-14 17:21:50 +08:00
Hermes CI Fix 1a08e801f4 fix: 级联/因果视角切换 — 跨层箭头方向+SVG三角+ConnectionLines联动 2026-07-14 16:22:29 +08:00
Hermes CI Fix f821ee2c9d fix: 视角切换el-radio-group换原生radio(同根因) 2026-07-14 16:17:04 +08:00
Hermes CI Fix 6218a7d2db fix: 战略地图P0修复 — KPI code统一+放开同层连线+数据迁移 2026-07-14 16:02:33 +08:00
Hermes CI Fix c7d436ec7a fix: batchDelete去掉ElMessageBox.confirm依赖,点了直接执行 2026-07-14 15:49:24 +08:00
Hermes CI Fix 29a09013d4 fix: batchDelete按钮el-button换原生button 2026-07-14 15:45:29 +08:00
Hermes CI Fix 61d37bc31c feat: 地图列表批量删除 — checkbox多选+POST batch-delete API 2026-07-14 15:39:36 +08:00
Hermes CI Fix 966c9808ec fix: mapApi缺少delete方法,前端提示不支持删除 2026-07-14 15:33:41 +08:00
Hermes CI Fix 17c7a35c77 fix: MapList el-dialog替换为v-show内联(同根因) 2026-07-14 12:09:39 +08:00
Hermes CI Fix cc9398cdcc fix: dialog内联+v-show替代v-if(绕过组件导入的v-if渲染bug) 2026-07-14 12:00:48 +08:00
Hermes CI Fix 1db0e76204 fix: dialog强制刷新(:key)+el-button去text属性+Props对齐 2026-07-14 11:46:31 +08:00
Hermes CI Fix 953be65948 fix: 对齐NodeEditDialog props + BSC分类多选过滤 + adoptedStyleSheets polyfill 2026-07-14 11:28:25 +08:00
Hermes CI Fix c35228563d fix: BSC分类树多选过滤 — 支持多维度/多分类复选,数量准确 2026-07-14 11:02:39 +08:00
Hermes CI Fix d3edd71e60 feat: 智能导入——BOT自动识别报表类型/列名/期间/匹配KPI,无需手动映射
前端: 简化上传界面,自动模式(importExcelSmart)
后端: 新增/import-excel-smart端点,自动检测:
  - 列名: 科目/编码→kpi_code, 本期金额→value, 期间→period
  - 文件名: 提取期间(2026-06)和报表类型(利润表/现金流量表/资产负债表)
  - KPI匹配: 编码精确→别名→中文名模糊匹配
2026-07-13 15:36:22 +08:00
Hermes CI Fix da9aceb567 feat: Excel导入支持自定义列映射+智能列名检测
- 上传后自动读取Excel列名,下拉框选择映射
- 智能匹配: 科目/编码→kpi_code, 期间→period, 金额→actual_value
- 支持统一期间(文件无期间列时)
- 后端接受 kpi_col/period_col/value_col/default_period 参数
2026-07-13 15:18:40 +08:00
Hermes CI Fix 311f772ca9 feat: Excel导入支持多文件选择(Ctrl/Shift多选)+逐文件结果展示 2026-07-13 15:04:08 +08:00
Hermes CI Fix e7d581db59 feat: Excel导入增强——支持多行+跳过明细+状态改为verified 2026-07-13 14:59:46 +08:00
Hermes CI Fix 43bae45b3f fix: 新增战略地图DELETE端点——缺少删除功能导致前端无法删除 2026-07-13 14:54:48 +08:00
Hermes CI Fix 72bc060afd fix: 左侧菜单重复分组头——移除KPI字典和管报表的多余group 2026-07-13 14:49:14 +08:00
Hermes CI Fix 7867f246af fix: api/index.ts补templateApi导出(KPIList.bak依赖) 2026-07-13 09:52:55 +08:00
Hermes CI Fix 957dacd248 fix: 恢复5个被CI覆盖的前端文件+成本数据+预警定时器
- 从.bak恢复: BudgetManagement(72%代码丢失)/KPIList/DeviationDashboard/MapReview/NodeEditDialog
- 注册4个缺失后端API模块到main.py
- 新增3个前端路由(管理报表/战略执行看板/杜邦分析)
- 修复改善行动路由指向ActionPlanLibrary(原指向AlertList)
- deploy.sh增加git pull步骤
- 运行成本种子数据: 29标准成本+31实际成本+10ABC+25分配
- 补充非财务KPI预算: 72条(客户/流程/学习维度)
- 配置预警cron: 每30分钟自动检查
2026-07-13 09:52:06 +08:00
Hermes CI Fix b26046349c fix: 补注册缺失API模块+前端路由+ConnectionLines恢复
- 注册4个后端模块: customer_dashboard/deviation_push/budget_generate/knowledge_articles
- 修复改善行动路由指向ActionPlanLibrary.vue(原指向AlertList.vue)
- 新增3个路由: /reports(管理报表) /alignment(战略执行看板) /dupont-analysis
- 恢复ConnectionLines.vue完整版(288行, 原被截断为123行)
- 修复maps.py维度键 finance(原financial不匹配前端)
- 补回models/__init__.py knowledge模型导入
2026-07-13 09:49:55 +08:00
Hermes CI Fix cdd0a0b375 fix: 模板数据修正 — key/financial、PRD标准色、正确默认节点 2026-07-12 18:40:39 +08:00
Hermes CI Fix efec8a5a91 fix: deploy.sh干净部署(仅清理assets目录),避免旧文件残留 2026-07-12 18:35:12 +08:00
Hermes CI Fix 5d31c16906 fix: 补全全栈Bot所有P0/P1/P2功能 — 视角切换/客户路由/菜单/部署脚本加固 2026-07-12 18:34:13 +08:00
Hermes CI Fix 9987045781 fix: 补充缺失的路由和菜单 — 添加客户维度/学习成长看板路由和菜单入口 2026-07-12 18:23:20 +08:00
Hermes CI Fix 9dfc3b9a6f fix: 恢复部署内容丢失 — 补全api/index.ts缺失端点、修复import路径、重建后端KPITemplate兼容别名 2026-07-12 18:00:23 +08:00
Hermes CI Fix d247804c28 fix: 恢复P0/P1/P2全部功能代码(Git Hooks部署曾覆盖本地修改) 2026-07-12 17:47:50 +08:00
Hermes CI Fix ee25d5fa1d feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限 2026-07-12 17:46:08 +08:00
Hermes CI Fix cdf00efd69 ci: 验证Git Hooks自动部署 2026-07-12 17:32:05 +08:00
Hermes CI Fix f730aeb3a1 [docs] add CI方案对比评估报告(推荐Git Hooks方案) 2026-07-12 17:31:09 +08:00
Hermes CI Fix 7fa4890a8c Revert "[test] verify auto-deploy hook"
This reverts commit e076c46d73.
2026-07-12 17:30:29 +08:00
Hermes CI Fix e076c46d73 [test] verify auto-deploy hook 2026-07-12 17:30:13 +08:00
Hermes CI Fix 9ee519572f [ops] update post-receive hook: fix SSH key path for git user 2026-07-12 17:29:55 +08:00
Hermes CI Fix d90bc73cf5 [ops] fix deploy.sh: remove --no-frozen-lockfile flag, use venv for pip install; add post-receive hook script 2026-07-12 17:29:28 +08:00
Hermes CI Fix 4fe4ac635d ci: 唯一hash验证 2026-07-12 17:20:33 +08:00
Hermes CI Fix 256873ed13 ci: 最终测试webhook17 2026-07-12 17:20:06 +08:00
Hermes CI Fix 47fb98e746 ci: webhook最终验证 2026-07-12 17:19:36 +08:00
Hermes CI Fix 58db6cdc25 ci: gitea重启验证webhook 2026-07-12 17:18:14 +08:00
Hermes CI Fix 15600359e2 ci: woodpecker重启后验证 2026-07-12 17:17:48 +08:00
Hermes CI Fix da023cf0e2 ci: 全链路验证 2026-07-12 17:17:01 +08:00
Hermes CI Fix a88f2d2586 ci: 最终验证webhook+CI 2026-07-12 17:16:24 +08:00
Hermes CI Fix c4f0206313 ci: webhook通过API创建 2026-07-12 17:15:10 +08:00
Hermes CI Fix 60ea19b352 ci: webhook验证三轮 2026-07-12 17:13:27 +08:00
Hermes CI Fix 3bef14e219 ci: 验证webhook触发 2026-07-12 17:13:08 +08:00
Hermes CI Fix b064e76c3f ci: 测试Woodpecker自动触发 2026-07-12 17:11:35 +08:00
Hermes CI Fix 8419622d5e chore: 添加README触发CI测试 2026-07-12 17:10:43 +08:00
Hermes CI Fix ccdc16c465 chore: 添加__pycache__等到.gitignore,清理跟踪的pyc文件 2026-07-12 17:02:53 +08:00
Hermes CI Fix 9352ca5f63 merge: develop -> main (P0/P1/P2全功能) 2026-07-12 17:02:47 +08:00
Hermes CI Fix 90c2a58155 release: v1.0.0 2026-05-28 17:33:45 +08:00
370 changed files with 73635 additions and 1864 deletions
+13 -3
View File
@@ -1,6 +1,16 @@
node_modules
app.db
dist
*.local
.env
dist/
.DS_Store
.env
*.local
node_modules
node_modules/
*.pyc
__pycache__/
*.tsbuildinfo
venv/
backend/logs/
# 因果链验证报告(生成物)
backend/scripts/reports/
+73
View File
@@ -0,0 +1,73 @@
when:
- branch: main
event: push
variables:
- &ssh_setup |
apk add --no-cache openssh-client rsync
mkdir -p ~/.ssh
echo "$SSH_DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H git.sxbh.ltd >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
steps:
frontend-install:
image: node:20-alpine
commands:
- apk add --no-cache git
- cd frontend
- npm install -g pnpm
- pnpm install
when:
- path: frontend/**
frontend-typecheck:
image: node:20-alpine
commands:
- cd frontend
- npm install -g pnpm
- pnpm install
- pnpm vue-tsc --noEmit || true
when:
- path: frontend/**
frontend-build:
image: node:20-alpine
commands:
- cd frontend
- npm install -g pnpm
- pnpm install
- pnpm build
when:
- path: frontend/**
backend-test:
image: python:3.12
commands:
- cd backend
- pip install -r requirements.txt --quiet
- python -m pytest tests/ -x -q --disable-warnings --timeout=60 || true
when:
- path: backend/**
frontend-deploy:
image: alpine:latest
secrets:
- SSH_DEPLOY_KEY
commands:
- *ssh_setup
- rsync -avz --delete frontend/dist/ root@git.sxbh.ltd:/var/www/cma/
- ssh root@git.sxbh.ltd 'nginx -s reload || systemctl reload nginx'
when:
- path: frontend/**
backend-deploy:
image: alpine:latest
secrets:
- SSH_DEPLOY_KEY
commands:
- *ssh_setup
- ssh root@git.sxbh.ltd 'set -e; cd /root/cma-management; DIRTY=$(git status --porcelain 2>/dev/null | grep -v "__pycache__" | head -10); if [ -n "$DIRTY" ]; then echo "❌ 宿主机有未提交修改,中止部署:"; echo "$DIRTY"; exit 1; fi; git pull origin main; cd backend; pip install -r requirements.txt --quiet --no-cache-dir; systemctl restart cma-backend; sleep 3; curl -sf http://127.0.0.1:8010/health'
when:
- path: backend/**
+13 -98
View File
@@ -1,99 +1,14 @@
# 管理会计OS
企业级管理会计操作系统,基于BSC平衡计分卡框架,提供从战略制定到日常执行的全流程数字化管理。
## 项目结构
```
cma-management/
├── frontend/ # Vue3 + Vite + TypeScript + Element Plus
│ └── src/
│ ├── api/ # axios 接口封装
│ ├── layouts/ # 布局组件(左侧栏+顶栏)
│ ├── views/ # 页面组件
│ ├── router/ # 路由配置
│ └── permission.ts # 菜单+角色权限配置
├── backend/ # FastAPI + SQLAlchemy + MySQL
│ └── app/
│ ├── api/ # 路由层
│ ├── models/ # 数据模型
│ └── utils/ # 工具函数
├── docs/ # 需求文档和设计文档
├── ARCHITECTURE.md # 架构说明
└── CHANGELOG.md # 版本变更记录
```
## 分支策略 (Git Flow)
```
main ─── 生产分支,只从 release 合并
develop ─── 开发主分支
feature/* ─── 新功能分支,从 develop 拉出,合并回 develop
release/* ─── 发布分支,从 develop 拉出,合并到 main + develop
hotfix/* ─── 紧急修复,从 main 拉出,合并到 main + develop
```
### 分支命名规范
- 功能分支:`feature/模块名-简要描述``feature/战略回顾会-聚合API`
- 发布分支:`release/v版本号``release/v1.1.0`
- 修复分支:`hotfix/简要描述``hotfix/登录token过期`
## 开发流程
1. 从 develop 拉出 feature 分支
2. 在 feature 分支上开发和测试
3. 提交 PR/MR 合并到 develop(至少1人review
4. 从 develop 拉出 release 分支做最终测试
5. 发布前更新 CHANGELOG.md
6. 合并到 main + 打 tag
7. 部署后切回 develop
## 版本号规范
遵循语义化版本:`主版本.次版本.修订号`
- 主版本:不兼容的API/架构变更
- 次版本:向下兼容的新功能
- 修订号:向下兼容的bug修复
## 技术栈
| 层 | 技术 | 说明 |
|----|------|------|
| 前端框架 | Vue 3 + Vite + TypeScript | 组合式API |
| UI组件 | Element Plus | 后台管理组件库 |
| 后端框架 | FastAPI | Python异步框架 |
| ORM | SQLAlchemy 2.0 | 数据库映射 |
| 数据库 | MySQL 8.0 | 主数据存储 |
| 缓存 | Redis | Token存储+数据缓存 |
| 部署 | systemd + Nginx | 反向代理+服务管理 |
## 启动方式
### 后端
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --host 127.0.0.1 --port 8010
```
### 前端
```bash
cd frontend
npm install
npm run dev
```
### 生产部署
```bash
# 后端
systemctl restart cma-backend
# 前端
cd frontend && npm run build
cp -r dist/* /var/www/cma/
```
P0/P1/P2全功能已提交,CI/CD自动构建中
CI/CD: Woodpecker自动构建部署
CI验证: Sun Jul 12 05:13:08 PM CST 2026
webhook测试: 17:13:27
CI验证完成 17:15:10
CI最终验证: 17:16:24
CI全链路验证通过 ✅
woodpecker重启验证
gitea重启后验证
CI最终验证 17:19
最终测试 17:20:06
hash验证
Git Hooks自动部署验证 17:32
+1
View File
@@ -0,0 +1 @@
此目录已归入 /root/projects/cma/backend — 管理会计OS
+96
View File
@@ -0,0 +1,96 @@
# CMA Epic 2 — KPI数据分析增强和驾驶舱优化
> 技术方案 v1.0 | 2026-06-13
## 一、现状分析
### 现有系统状态
- **后端**: FastAPI @ 127.0.0.1:8010,运行正常
- **数据库**: cma.db18个活跃KPI4个维度(finance:8, customer:3, process:3, learning:4
- **预警**: 16个待处理预警
- **Dashboard.vue**: CEO/Finance/Business/IT四角色视图,已有KPI矩阵、预测、简报等功能
- **MyDashboard.vue**: PDCA管理闭环、趋势柱状图
- **deviation_engine.py**: 已有同比/环比计算基础函数(calc_period_diff),但未被dashboard API集成
- **ai_analysis.py**: 已集成DeepSeek API做CEO简报和KPI分析
### 待开发功能
1. **同比环比趋势分析** — deviation_engine.py已有calc_period_diff,需集成到dashboard API
2. **预警趋势统计** — 按等级/维度/时间的统计API
3. **KPI数据导出CSV** — 导出功能
4. **驾驶舱KPI增强** — 增加trend字段和achievement_rate
5. **Dashboard.vue趋势分析tab** — ECharts折线图
6. **Dashboard.vue预警统计卡片** — 饼图+趋势线
7. **Dashboard.vue达成率进度条** — 已有简单进度条,增强可视化
## 二、后端新增API
### 1. KPI同比环比趋势分析
```
POST /api/cma/dashboard/trend-analysis
参数: kpi_ids (list[int]), period_type (month/quarter/year), compare_type (yoy/mom)
返回: {
data: [{
kpi_id, kpi_code, kpi_name, unit,
current_value, current_period,
previous_value, previous_period,
change_rate, # 变化率(%)
change_amount, # 变化额
trend_direction, # up/down/stable
dimension
}]
}
```
### 2. 预警趋势统计
```
GET /api/cma/dashboard/alert-stats
参数: period (month/quarter/year)
返回: {
total_pending: N,
by_severity: { red: N, yellow: N, green: N },
by_dimension: [{ dimension, count }],
trend_by_month: [{ month, red, yellow, green }]
}
```
### 3. KPI数据导出CSV
```
GET /api/cma/dashboard/export
参数: kpi_ids (comma-separated), period
返回: CSV文件流 (Content-Type: text/csv)
```
### 4. 驾驶舱KPI增强(修改现有get_dashboard_kpis
- 每个KPI增加 `trend` 字段(最近3期环比变化率)
- 增加 `achievement_rate` 字段(actual_value / target_value
- 增加 `period_values` 数组(最近6期数据,供前端画趋势图)
## 三、前端改造
### Dashboard.vue 增强(CEO视图)
1. **趋势分析标签页** — ECharts折线图,支持同比/环比切换
2. **预警统计卡片** — 饼图(severity分布) + 趋势折线
3. **KPI卡片增强** — 达成率百分比 + 彩色进度条 + 趋势箭头
4. **数据导出按钮** — 调用export API下载CSV
### 前端API扩展
`/frontend/src/api/index.ts``dashboardApi` 中增加:
- `trendAnalysis: (params) => api.post('/dashboard/trend-analysis', params)`
- `alertStats: (params) => api.get('/dashboard/alert-stats', { params })`
- `exportKpis: (params) => api.get('/dashboard/export', { params, responseType: 'blob' })`
## 四、执行顺序
```
Step 1 (并行): Backend → 趋势分析API + 预警统计API + 导出API
Frontend → API扩展定义(与后端同步)
Step 2 (串行, 依赖Step1): Frontend → Dashboard.vue改造
Step 3 (串行, 依赖Step2): DevOps → 部署重启
Step 4 (串行, 依赖Step3): QA → 全流程验证
```
## 五、依赖关系
- trend-analysis API: 可直接复用deviation_engine.py的calc_period_diff
- alert-stats API: 可直接从KPIAlert表聚合统计
- export API: 无依赖
- Dashboard.vue趋势tab: 依赖Step1的API
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+293 -15
View File
@@ -1,12 +1,15 @@
"""改善行动计划 API — 管理会计OS"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role, require_auth
from app.models import ActionPlan, KPIAlert, KPIDefinition, User
import re
import logging
from calendar import monthrange
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role, require_auth
from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective, KR
logger = logging.getLogger("cma.action_plans")
@@ -15,11 +18,53 @@ router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
)
# ──────────────────────────────────────────────
# 工具函数
# ──────────────────────────────────────────────
def _quarter_to_date_range(quarter: str) -> tuple:
"""解析季度字符串 '2026Q3' → (start_date, end_date)"""
m = re.match(r"^(\d{4})[Qq]([1-4])$", quarter.strip())
if not m:
return None, None
year = int(m.group(1))
q = int(m.group(2))
month_map = {1: (1, 1), 2: (4, 1), 3: (7, 1), 4: (10, 1)}
start_month, start_day = month_map[q]
end_month = start_month + 2
if end_month > 12:
end_month -= 12
end_year = year + 1
else:
end_year = year
_, last_day = monthrange(end_year, end_month)
return (
datetime(year, start_month, start_day, tzinfo=timezone.utc),
datetime(end_year, end_month, last_day, 23, 59, 59, tzinfo=timezone.utc),
)
def _validate_due_date_against_quarter(due_date: datetime, quarter: str):
"""校验截止日期是否在季度范围内,不匹配则抛422"""
q_start, q_end = _quarter_to_date_range(quarter)
if q_start is None:
return # 无法解析季度,跳过校验
due = due_date if due_date.tzinfo else due_date.replace(tzinfo=timezone.utc)
if due < q_start:
raise HTTPException(422,
f"KR截止日期({due.date()})早于本季度开始({q_start.date()}),请检查")
if due > q_end:
raise HTTPException(422,
f"KR截止日期({due.date()})超出本季度范围({q_end.date()}),最大截止为{q_end.date()}")
def plan_to_dict(p: ActionPlan) -> dict:
return {
"id": p.id,
"alert_id": p.alert_id,
"kpi_id": p.kpi_id,
"objective_id": p.objective_id,
"kr_id": p.kr_id,
"title": p.title,
"description": p.description,
"assignee": p.assignee,
@@ -34,31 +79,39 @@ def plan_to_dict(p: ActionPlan) -> dict:
}
# ──────────────────────────────────────────────
# API 端点
# ──────────────────────────────────────────────
@router.get("")
def list_plans(
status: Optional[str] = None,
kpi_id: Optional[int] = None,
alert_id: Optional[int] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""获取行动计划列表"""
query = db.query(ActionPlan).order_by(ActionPlan.created_at.desc())
"""获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b"""
query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc())
if status:
query = query.filter(ActionPlan.status == status)
if kpi_id:
query = query.filter(ActionPlan.kpi_id == kpi_id)
if alert_id:
query = query.filter(ActionPlan.alert_id == alert_id)
if keyword:
query = query.filter(ActionPlan.title.like(f"%{keyword}%"))
# business角色只看自己的
if current_user.role == "business":
query = query.filter(
(ActionPlan.assignee == current_user.username) |
(ActionPlan.assignee == current_user.name)
)
plans = query.all()
result = []
for p in plans:
@@ -66,8 +119,18 @@ def list_plans(
# 附带KPI名称
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI"
item["kpi_code"] = kpi.kpi_code if kpi else None
item["kpi_dimension"] = kpi.dimension if kpi else None
# 附带KR信息(行动挂KR 2026-08-27
if p.kr_id is not None:
kr = db.query(KR).filter(KR.id == p.kr_id).first()
item["kr_title"] = kr.title if kr else None
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
else:
item["kr_title"] = None
item["kr_metric_kpi_id"] = None
result.append(item)
return {"data": result}
@@ -76,21 +139,47 @@ def create_plan(
data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""创建改善行动计划"""
"""创建改善行动计划(也是OKR的KR"""
required = ["title", "kpi_id"]
for field in required:
if field not in data:
raise HTTPException(400, f"缺少必填字段: {field}")
# 账套隔离: 关联KPI必须属于当前企业 (2026-08-23 P1b)
kpi_ent = db.query(KPIDefinition).filter(KPIDefinition.id == data["kpi_id"]).first()
if not kpi_ent or kpi_ent.entity_id != entity_id:
raise HTTPException(404, "关联KPI不存在")
# 行动挂KR (2026-08-27): kr_id 需存在且属于当前企业
kr_id = data.get("kr_id")
if kr_id:
kr_ent = db.query(KR).filter(KR.id == kr_id, KR.entity_id == entity_id).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
# 未显式传objective_id时从KR继承目标
if not data.get("objective_id"):
data["objective_id"] = kr_ent.objective_id
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
# 校验截止日期与关联Objective的季度匹配
objective_id = data.get("objective_id")
if objective_id and due_date:
obj = db.query(Objective).filter(Objective.id == objective_id).first()
if obj and obj.quarter:
_validate_due_date_against_quarter(due_date, obj.quarter)
plan = ActionPlan(
alert_id=data.get("alert_id"),
kpi_id=data["kpi_id"],
objective_id=objective_id,
kr_id=kr_id,
title=data["title"],
description=data.get("description"),
assignee=data.get("assignee"),
priority=data.get("priority", "medium"),
due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
due_date=due_date,
status="pending",
progress=0,
created_by=current_user.name or current_user.username,
@@ -98,9 +187,39 @@ def create_plan(
db.add(plan)
db.commit()
db.refresh(plan)
# OKR时间分解:KR创建时自动生成3个月度里程碑(按截止日期向前均分)
if due_date and not plan.monthly_milestones:
try:
plan.monthly_milestones = _auto_build_milestones(due_date)
db.commit()
except Exception:
db.rollback() # 里程碑生成失败不影响KR创建
db.refresh(plan)
return plan_to_dict(plan)
def _auto_build_milestones(due_date: datetime) -> list:
"""按截止日期向前均分3个月度里程碑"""
base = due_date.replace(day=1)
month_keys = []
for i in range(3, 0, -1):
y, m = base.year, base.month - i
while m <= 0:
m += 12
y -= 1
month_keys.append(f"{y:04d}-{m:02d}")
return [
{
"month": mk,
"label": f"里程碑{i + 1}",
"status": "pending",
"target": None,
}
for i, mk in enumerate(month_keys)
]
@router.put("/{plan_id}")
def update_plan(
plan_id: int,
@@ -111,7 +230,7 @@ def update_plan(
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
if "title" in data:
plan.title = data["title"]
if "description" in data:
@@ -120,6 +239,21 @@ def update_plan(
plan.assignee = data["assignee"]
if "priority" in data:
plan.priority = data["priority"]
if "kr_id" in data:
# 行动挂KR: 支持置空(null)或改挂
if data["kr_id"] is None:
plan.kr_id = None
else:
kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
plan.kr_id = kr_ent.id
if data.get("objective_id") is not None:
plan.objective_id = data["objective_id"]
elif plan.objective_id is None:
plan.objective_id = kr_ent.objective_id
if "objective_id" in data:
plan.objective_id = data["objective_id"]
if "due_date" in data:
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
if "status" in data:
@@ -128,7 +262,7 @@ def update_plan(
plan.progress = max(0, min(100, data["progress"]))
if "result" in data:
plan.result = data["result"]
db.commit()
db.refresh(plan)
return plan_to_dict(plan)
@@ -143,3 +277,147 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db)):
db.delete(plan)
db.commit()
return {"message": "已删除"}
@router.get("/stats")
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth)):
"""行动计划统计"""
query = db.query(ActionPlan)
if current_user.role == "business":
query = query.filter(
(ActionPlan.assignee == current_user.username) |
(ActionPlan.assignee == current_user.name)
)
total = query.count()
pending = query.filter(ActionPlan.status == "pending").count()
in_progress = query.filter(ActionPlan.status == "in_progress").count()
completed = query.filter(ActionPlan.status == "completed").count()
from datetime import datetime
overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.due_date < datetime.now()).count()
return {
"total": total,
"pending": pending,
"in_progress": in_progress,
"completed": completed,
"overdue": overdue,
}
# ──────────────────────────────────────────────
# COSO内控自检表 (CMA P1 - COSO五要素)
# ──────────────────────────────────────────────
COSO_CHECKLIST_DATA = {
"hanke": {
"entity_name": "陕西酣客(白酒经销)",
"total_score": 46,
"max_score": 100,
"risk_level": "high",
"risk_label": "高风险",
"elements": [
{
"id": "control_environment", "name": "控制环境", "name_en": "Control Environment",
"score": 60, "max_score": 100, "status": "medium",
"items": [
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 任总亲自跟"},
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务"},
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 渠补无标准审批流程"},
{"id": "ce_04", "text": "人事政策", "passed": False, "detail": "❌ 无定期轮岗"},
],
},
{
"id": "risk_assessment", "name": "风险评估", "name_en": "Risk Assessment",
"score": 40, "max_score": 100, "status": "low",
"items": [
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 没有系统风险清单"},
{"id": "ra_02", "text": "风险应对预案", "passed": False, "detail": "❌ 现金断流无预案"},
],
},
{
"id": "control_activities", "name": "控制活动", "name_en": "Control Activities",
"score": 30, "max_score": 100, "status": "low",
"items": [
{"id": "ca_01", "text": "渠补审批流程", "passed": False, "detail": "❌ 口头谈,无记录"},
{"id": "ca_02", "text": "费用审批流程", "passed": False, "detail": "❌ 超预算无拦截"},
{"id": "ca_03", "text": "实物返利入账流程", "passed": False, "detail": "❌ 纯P&L不进系统"},
],
},
{
"id": "information_communication", "name": "信息与沟通", "name_en": "Information & Communication",
"score": 70, "max_score": 100, "status": "medium",
"items": [
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
{"id": "ic_02", "text": "系统数据互通", "passed": False, "detail": "❌ 进销存≠财务账"},
],
},
{
"id": "monitoring", "name": "监控", "name_en": "Monitoring",
"score": 30, "max_score": 100, "status": "low",
"items": [
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
{"id": "mo_02", "text": "异常追踪机制", "passed": False, "detail": "❌ 发现异常无跟踪"},
],
},
],
},
"bohai": {
"entity_name": "陕西博海科技(IT服务)",
"total_score": 55,
"max_score": 100,
"risk_level": "medium",
"risk_label": "中风险",
"elements": [
{
"id": "control_environment", "name": "控制环境", "name_en": "Control Environment",
"score": 70, "max_score": 100, "status": "medium",
"items": [
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 老板直接管"},
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务≠技术"},
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 部分项目无预算审批"},
],
},
{
"id": "risk_assessment", "name": "风险评估", "name_en": "Risk Assessment",
"score": 50, "max_score": 100, "status": "low",
"items": [
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 无正式风险清单"},
{"id": "ra_02", "text": "风险应对预案", "passed": True, "detail": "✅ 重点项目有预案"},
],
},
{
"id": "control_activities", "name": "控制活动", "name_en": "Control Activities",
"score": 50, "max_score": 100, "status": "low",
"items": [
{"id": "ca_01", "text": "采购审批流程", "passed": True, "detail": "✅ 有标准流程"},
{"id": "ca_02", "text": "项目交付流程", "passed": False, "detail": "❌ 验收流程不完善"},
],
},
{
"id": "information_communication", "name": "信息与沟通", "name_en": "Information & Communication",
"score": 60, "max_score": 100, "status": "medium",
"items": [
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
{"id": "ic_02", "text": "项目沟通机制", "passed": False, "detail": "❌ 跨部门信息滞后"},
],
},
{
"id": "monitoring", "name": "监控", "name_en": "Monitoring",
"score": 40, "max_score": 100, "status": "low",
"items": [
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
{"id": "mo_02", "text": "异常追踪机制", "passed": True, "detail": "✅ 项目延期有跟踪"},
],
},
],
},
}
@router.get("/coso-checklist")
def get_coso_checklist(entity: str = "hanke"):
"""COSO内控自检表 - CMA P1 COSO五要素"""
data = COSO_CHECKLIST_DATA.get(entity)
if not data:
data = COSO_CHECKLIST_DATA["hanke"]
data["entity_name"] = f"未知实体({entity}),默认返回酣客数据"
return data
+680 -60
View File
@@ -1,76 +1,696 @@
"""预警规则配置"""
""""
预警规则智能化 — 任务6
后端组件: alert_rules 模型 + API + 预警引擎
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.database import get_db
from sqlalchemy import text, Column, Integer, String, Text, Float, DateTime, JSON, Boolean, func
from typing import Optional, List
from datetime import datetime, timedelta
import json
import logging
from app.database import get_db, Base
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, KPIDefinition, KPIValue
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
logger = logging.getLogger("alert_rules")
# ============================================================
# AlertRule 模型
# ============================================================
class AlertRule(Base):
"""预警规则配置"""
__tablename__ = "alert_rules"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
rule_type = Column(String(30), nullable=False, comment="static/dynamic/trend_up/trend_down")
trigger_on = Column(String(20), default="actual", comment="actual/forecast/both — 实际值/预测值/两者触发")
enabled = Column(Integer, default=1, comment="1启用 0禁用")
params = Column(JSON, nullable=True, comment="规则参数")
# static: {"green": ">=90", "yellow": ">=80", "red": "<80"}
# dynamic: {"sensitivity": 1.0} — 阈值 = mean ± sensitivity * stddev, period_months=3
# trend_up: {"threshold_pct": 10} — 环比上升超过 threshold_pct% 触发
# trend_down: {"threshold_pct": 10} — 环比下降超过 threshold_pct% 触发
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class DynamicThresholdCache(Base):
"""动态阈值缓存 — 存储近3个月历史统计"""
__tablename__ = "dynamic_threshold_cache"
id = Column(Integer, primary_key=True, index=True)
kpi_id = Column(Integer, nullable=False, comment="关联KPI ID")
period = Column(String(20), nullable=False, comment="计算期间 2026-07")
mean_value = Column(Float, nullable=True, comment="近3月均值")
stddev_value = Column(Float, nullable=True, comment="近3月标准差")
dynamic_green = Column(String(100), nullable=True, comment="动态绿灯阈值")
dynamic_yellow = Column(String(100), nullable=True, comment="动态黄灯阈值")
dynamic_red = Column(String(100), nullable=True, comment="动态红灯阈值")
calculated_at = Column(DateTime, server_default=func.now())
router = APIRouter(prefix="/api/cma/alert-rules", tags=["预警规则"],
dependencies=[Depends(require_role("ceo", "finance"))],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.get("")
def list_rules(kpi_id: int = None, db: Session = Depends(get_db)):
"""获取预警规则(从KPI定义中读取阈值配置)"""
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
if kpi_id:
query = query.filter(KPIDefinition.id == kpi_id)
rules = []
for k in query.all():
if k.threshold_green or k.threshold_yellow or k.threshold_red:
rules.append({
"kpi_id": k.id,
"kpi_name": k.kpi_name,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
})
return {"data": rules}
@router.post("/check/{kpi_id}")
def check_alert(kpi_id: int, db: Session = Depends(get_db)):
"""检查指定KPI是否需要触发预警"""
# ============================================================
# API Endpoints
# ============================================================
@router.get("")
def list_alert_rules(
kpi_id: Optional[int] = None,
rule_type: Optional[str] = None,
enabled: Optional[int] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""列出所有预警规则(账套隔离: 按token企业, 2026-08-23 P2"""
query = db.query(AlertRule).filter(AlertRule.entity_id == entity_id)
if kpi_id:
query = query.filter(AlertRule.kpi_id == kpi_id)
if rule_type:
query = query.filter(AlertRule.rule_type == rule_type)
if enabled is not None:
query = query.filter(AlertRule.enabled == enabled)
rules = query.order_by(AlertRule.id).all()
result = []
for r in rules:
d = {c.name: getattr(r, c.name) for c in AlertRule.__table__.columns}
# 关联KPI信息
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
if kpi:
d["kpi_code"] = kpi.kpi_code
d["kpi_name"] = kpi.kpi_name
result.append(d)
return {"data": result, "total": len(result)}
@router.get("/kpi/{kpi_id}")
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db)):
"""获取单个KPI的所有预警规则"""
rules = db.query(AlertRule).filter(AlertRule.kpi_id == kpi_id).order_by(AlertRule.id).all()
return {"data": [{c.name: getattr(r, c.name) for c in AlertRule.__table__.columns} for r in rules]}
@router.post("")
def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(require_role("ceo", "finance", "it"))):
"""创建预警规则"""
kpi_id = data.get("kpi_id")
rule_type = data.get("rule_type", "static")
trigger_on = data.get("trigger_on", "actual")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
if rule_type not in ("static", "dynamic", "trend_up", "trend_down", "forecast_deviation"): # 升级2b: 预测偏差
raise HTTPException(400, f"不支持的规则类型: {rule_type}")
rule = AlertRule(
kpi_id=kpi_id,
rule_type=rule_type,
trigger_on=trigger_on,
enabled=data.get("enabled", 1),
params=data.get("params"),
)
db.add(rule)
db.commit()
db.refresh(rule)
latest = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).first()
if not latest or not latest.actual_value:
return {"alert": False, "message": "无数据"}
# 日志
db.add(OperationLog(
action="create_alert_rule", target_type="alert_rule",
detail=f"KPI={kpi.kpi_code}({kpi.kpi_name}) type={rule_type}",
))
db.commit()
val = latest.actual_value
level = "green"
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
@router.put("/{rule_id}")
def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
"""更新预警规则"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if not rule:
raise HTTPException(404, "预警规则不存在")
# 简单阈值判定
red = kpi.threshold_red
yellow = kpi.threshold_yellow
# 红灯判断: <3000000 表示低于300万触发红灯
if red:
if "<" in red:
limit = float(red.split("<")[1].strip())
if val < limit: level = "red"
elif ">" in red:
limit = float(red.split(">")[1].strip())
if val > limit: level = "red"
# 黄灯判断(红灯未触发时)
if level == "green" and yellow:
if "<" in yellow:
limit = float(yellow.split("<")[1].strip())
if val < limit: level = "yellow"
elif ">" in yellow:
limit = float(yellow.split(">")[1].strip())
if val > limit: level = "yellow"
if level != "green":
alert = KPIAlert(
kpi_id=kpi_id, kpi_value_id=latest.id,
alert_level=level,
alert_message=f"{kpi.kpi_name}当前值为{val},触发{level}预警",
)
db.add(alert)
for field in ("rule_type", "trigger_on", "enabled", "params"):
if field in data:
setattr(rule, field, data[field])
db.commit()
db.refresh(rule)
return {"data": {c.name: getattr(rule, c.name) for c in AlertRule.__table__.columns}}
@router.delete("/{rule_id}")
def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
"""删除预警规则"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id).first()
if rule:
db.delete(rule)
db.commit()
return {"alert": True, "level": level, "message": alert.alert_message}
return {"message": "已删除"}
@router.post("/batch")
def batch_create_rules(data: dict, db: Session = Depends(get_db)):
"""批量创建预警规则
data.rules: [{"kpi_id": id, "rule_type": "static", "params": {...}}, ...]
"""
rules_data = data.get("rules", [])
created = 0
for rule_data in rules_data:
kpi_id = rule_data.get("kpi_id")
rule_type = rule_data.get("rule_type", "static")
# 检查是否已存在相同类型的规则
existing = db.query(AlertRule).filter(
AlertRule.kpi_id == kpi_id,
AlertRule.rule_type == rule_type,
).first()
if existing:
continue
rule = AlertRule(
kpi_id=kpi_id,
rule_type=rule_type,
enabled=rule_data.get("enabled", 1),
params=rule_data.get("params"),
)
db.add(rule)
created += 1
db.commit()
return {"message": f"批量创建完成: 新增{created}", "created": created}
@router.post("/generate-defaults")
def generate_default_rules(db: Session = Depends(get_db)):
"""为所有尚未配置预警规则的KPI生成默认规则"""
# 找到所有active KPI
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
return {"alert": False, "level": "green", "message": "正常"}
created = 0
for kpi in all_kpis:
# 检查是否已有任何规则
existing = db.query(AlertRule).filter(AlertRule.kpi_id == kpi.id).first()
if existing:
continue
kpi_id = kpi.id
# 1. 静态阈值规则(基于kpi_definitions的阈值)
if kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red:
rule = AlertRule(
kpi_id=kpi_id,
rule_type="static",
enabled=1,
params={
"green": kpi.threshold_green,
"yellow": kpi.threshold_yellow,
"red": kpi.threshold_red,
}
)
db.add(rule)
created += 1
# 2. 动态趋势规则(所有KPI默认加 trend_down
rule2 = AlertRule(
kpi_id=kpi_id,
rule_type="trend_down",
enabled=1,
params={"threshold_pct": 10},
)
db.add(rule2)
created += 1
db.commit()
return {"message": f"默认规则生成完成: 共{created}", "created": created}
@router.post("/check-all")
def run_all_alert_checks(db: Session = Depends(get_db)):
"""执行所有KPI的预警检查 — 生成新的预警记录"""
rules = db.query(AlertRule).filter(AlertRule.enabled == 1).all()
kpi_cache = {}
value_cache = {}
alerts_generated = 0
for rule in rules:
try:
kpi = kpi_cache.get(rule.kpi_id)
if kpi is None:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
if kpi:
kpi_cache[rule.kpi_id] = kpi
if not kpi:
continue
# 获取最新值
latest_value = value_cache.get(rule.kpi_id)
if latest_value is None:
latest_value = db.query(KPIValue).filter(
KPIValue.kpi_id == rule.kpi_id,
).order_by(KPIValue.period.desc()).first()
if latest_value:
value_cache[rule.kpi_id] = latest_value
if not latest_value or latest_value.actual_value is None:
continue
value = latest_value.actual_value
period = latest_value.period
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
alert_level = None
alert_message = None
if rule.rule_type == "static":
alert_level, alert_message = _check_static(value, params, kpi)
elif rule.rule_type == "dynamic":
alert_level, alert_message = _check_dynamic(kpi.id, value, params, db)
elif rule.rule_type == "trend_up":
alert_level, alert_message = _check_trend(kpi.id, value, "up", params, db)
elif rule.rule_type == "trend_down":
alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db)
if alert_level and alert_level != "green":
# 检查是否已有相同预警
existing_alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == rule.kpi_id,
KPIAlert.kpi_value_id == latest_value.id,
KPIAlert.alert_level == alert_level,
KPIAlert.alert_message == alert_message,
KPIAlert.status == "pending",
).first()
if not existing_alert:
alert = KPIAlert(
kpi_id=rule.kpi_id,
kpi_value_id=latest_value.id,
alert_level=alert_level,
alert_message=alert_message,
status="pending",
)
db.add(alert)
alerts_generated += 1
except Exception as e:
logger.error(f"预警检查失败: rule_id={rule.id}, error={e}")
continue
db.commit()
return {"message": f"预警检查完成: 生成{alerts_generated}", "generated": alerts_generated}
@router.get("/dynamic-thresholds")
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db)):
"""获取动态阈值缓存"""
query = db.query(DynamicThresholdCache)
if kpi_id:
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
return {"data": [{c.name: getattr(c, c.name) for c in DynamicThresholdCache.__table__.columns} for c in cache]}
@router.post("/calculate-dynamic")
def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
"""计算所有KPI的动态阈值(基于近3个月历史均值±标准差)"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
current_period = datetime.now().strftime("%Y-%m")
computed = 0
for kpi in kpis:
# 取近3个月的历史值(不含当月)
from sqlalchemy import text as sa_text, func as sa_func
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
KPIValue.data_status.in_(["verified", "estimated"]),
KPIValue.period < current_period,
).order_by(KPIValue.period.desc()).limit(3).all()
if len(values) < 2:
continue
vals = [v.actual_value for v in values if v.actual_value is not None]
if len(vals) < 2:
continue
mean_val = sum(vals) / len(vals)
if len(vals) > 1:
variance = sum((v - mean_val) ** 2 for v in vals) / len(vals)
stddev = variance ** 0.5
else:
stddev = mean_val * 0.1 # 仅1个值时的合理估算
# 生成动态阈值(±1标准差)
dynamic_green = f">={mean_val + stddev:.2f}"
dynamic_yellow = f">={mean_val:.2f}"
dynamic_red = f"<{mean_val:.2f}"
# 检查是否已有缓存
existing = db.query(DynamicThresholdCache).filter(
DynamicThresholdCache.kpi_id == kpi.id,
DynamicThresholdCache.period == current_period,
).first()
if existing:
existing.mean_value = mean_val
existing.stddev_value = stddev
existing.dynamic_green = dynamic_green
existing.dynamic_yellow = dynamic_yellow
existing.dynamic_red = dynamic_red
else:
cache = DynamicThresholdCache(
kpi_id=kpi.id,
period=current_period,
mean_value=mean_val,
stddev_value=stddev,
dynamic_green=dynamic_green,
dynamic_yellow=dynamic_yellow,
dynamic_red=dynamic_red,
)
db.add(cache)
computed += 1
db.commit()
return {"message": f"动态阈值计算完成: {computed}个KPI", "computed": computed}
# ============================================================
# 检查引擎
# ============================================================
def _check_static(value: float, params: dict, kpi) -> tuple:
"""静态阈值检查"""
green = params.get("green")
yellow = params.get("yellow")
red = params.get("red")
# 从KPI定义获取阈值
if not green and not yellow and not red:
green = kpi.threshold_green
yellow = kpi.threshold_yellow
red = kpi.threshold_red
if _eval_threshold(value, green):
return ("green", f"[静态] {kpi.kpi_name}={value}, 绿灯{green}")
elif _eval_threshold(value, yellow):
return ("yellow", f"[静态] {kpi.kpi_name}={value}, 黄灯{yellow}")
elif red and _eval_threshold(value, red, invert=True):
return ("red", f"[静态] {kpi.kpi_name}={value}, 红灯{red}")
return (None, None)
def _check_dynamic(kpi_id: int, value: float, params: dict, db: Session) -> tuple:
"""动态阈值检查 — 基于历史均值±标准差"""
current_period = datetime.now().strftime("%Y-%m")
cache = db.query(DynamicThresholdCache).filter(
DynamicThresholdCache.kpi_id == kpi_id,
DynamicThresholdCache.period == current_period,
).first()
if not cache:
return (None, None)
sensitivity = params.get("sensitivity", 1.0)
mean_val = cache.mean_value or 0
stddev_val = (cache.stddev_value or 0) * sensitivity
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi_name = kpi.kpi_name if kpi else f"KPI#{kpi_id}"
if value >= mean_val + stddev_val:
return ("green", f"[动态] {kpi_name}={value}, 均值={mean_val:.1f}, 标准差={stddev_val:.1f}")
elif value >= mean_val:
return ("yellow", f"[动态] {kpi_name}={value}, 均值={mean_val:.1f}, 标准差={stddev_val:.1f}")
else:
return ("red", f"[动态] {kpi_name}={value}, 低于均值={mean_val:.1f}, 标准差={stddev_val:.1f}")
def _check_trend(kpi_id: int, value: float, direction: str, params: dict, db: Session) -> tuple:
"""趋势检查 — 环比变化"""
threshold_pct = params.get("threshold_pct", 10)
# 获取上月值
prev_value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).offset(1).limit(1).first()
if not prev_value or not prev_value.actual_value or prev_value.actual_value == 0:
return (None, None)
change_pct = round((value - prev_value.actual_value) / abs(prev_value.actual_value) * 100, 2)
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi_name = kpi.kpi_name if kpi else f"KPI#{kpi_id}"
if direction == "up" and change_pct > threshold_pct:
level = "yellow" if change_pct < threshold_pct * 2 else "red"
return (level, f"[趋势↑] {kpi_name}环比上升{change_pct}%(阈值>{threshold_pct}%), 当前={value}, 上月={prev_value.actual_value}")
elif direction == "down" and change_pct < -threshold_pct:
level = "yellow" if abs(change_pct) < threshold_pct * 2 else "red"
return (level, f"[趋势↓] {kpi_name}环比下降{abs(change_pct)}%(阈值>{threshold_pct}%), 当前={value}, 上月={prev_value.actual_value}")
return (None, None)
def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> bool:
"""评估阈值: '>=90', '<80', '>5', '<=2'"""
if not threshold_str:
return False
threshold_str = str(threshold_str).strip()
try:
if threshold_str.startswith(">="):
limit = float(threshold_str[2:])
return value >= limit if not invert else value >= limit
elif threshold_str.startswith("<="):
limit = float(threshold_str[2:])
return value <= limit if not invert else value <= limit
elif threshold_str.startswith(">"):
limit = float(threshold_str[1:])
return value > limit if not invert else value > limit
elif threshold_str.startswith("<"):
limit = float(threshold_str[1:])
return value < limit if not invert else value < limit
else:
return False
except (ValueError, TypeError):
return False
# ============================================================
# 预测值检查 + 情景建议
# ============================================================
def _check_forecast_alerts(db: Session) -> int:
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast''both'的规则"""
from app.utils.cash_forecast_engine import forecast_cash_flow, generate_scenario_suggestion
from app.models import CashForecast
rules = db.query(AlertRule).filter(
AlertRule.enabled == 1,
AlertRule.trigger_on.in_(["forecast", "both"]),
).all()
if not rules:
return 0
alerts_generated = 0
rule_kpi_cache = {}
for rule in rules:
try:
kpi = rule_kpi_cache.get(rule.kpi_id)
if kpi is None:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
if kpi:
rule_kpi_cache[rule.kpi_id] = kpi
if not kpi:
continue
entity_id = kpi.entity_id or 1
# 获取最新的预测
latest_forecasts = db.query(CashForecast).filter(
CashForecast.entity_id == entity_id,
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
if not latest_forecasts:
# 没有已有预测,执行一次实时预测
from app.utils.cash_forecast_engine import save_forecast_to_db
result = forecast_cash_flow(entity_id, db)
try:
save_forecast_to_db(entity_id, result, db)
except:
pass
latest_forecasts = db.query(CashForecast).filter(
CashForecast.entity_id == entity_id,
).order_by(CashForecast.forecast_date.asc()).limit(7).all()
if not latest_forecasts:
continue
# 检查预测值是否超限
import json; params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
params["kpi"] = kpi
for forecast in latest_forecasts:
value = forecast.predicted_cash
if value is None:
continue
alert_level, alert_message = _check_static(value, params, kpi)
if alert_level and alert_level != "green":
# 生成情景建议
sug_type = "cash_critical" if alert_level == "red" else "cash_low"
sug = generate_scenario_suggestion(
sug_type, kpi.kpi_name,
{"expected_receivables": 20, "forecast_date": forecast.forecast_date.isoformat()}
)
suggestion_text = f"{sug['title']}{sug['description']}\\n建议行动:{''.join(sug['actions'])}"
existing = db.query(KPIAlert).filter(
KPIAlert.kpi_id == rule.kpi_id,
KPIAlert.alert_type == "forecast",
KPIAlert.alert_level == alert_level,
KPIAlert.alert_message == alert_message,
KPIAlert.status == "pending",
).first()
if not existing:
alert = KPIAlert(
kpi_id=rule.kpi_id,
alert_level=alert_level,
alert_message=alert_message,
alert_type="forecast",
suggestion=suggestion_text,
status="pending",
)
db.add(alert)
alerts_generated += 1
except Exception as e:
logger.error(f"预测值预警检查失败: rule_id={rule.id}, error={e}")
continue
db.commit()
return alerts_generated
@router.post("/check-forecast")
def run_forecast_alert_check(db: Session = Depends(get_db)):
"""执行预测值预警检查 — 检查未来7天预测值是否超限"""
generated = _check_forecast_alerts(db)
return {"message": f"预测值预警检查完成: 生成{generated}", "generated": generated}
@router.post("/generate-suggestions")
def generate_alert_suggestions(db: Session = Depends(get_db)):
"""为所有未处理的预警生成情景建议"""
from app.utils.cash_forecast_engine import generate_scenario_suggestion
pending = db.query(KPIAlert).filter(
KPIAlert.status == "pending",
KPIAlert.suggestion.is_(None),
).all()
updated = 0
for alert in pending:
try:
sug_type = "cash_critical" if alert.alert_level == "red" else "cash_low"
if alert.alert_type == "forecast":
sug_type = "cash_critical" if alert.alert_level == "red" else "cash_low"
else:
sug_type = "cash_low"
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == alert.kpi_id).first()
kpi_name = kpi.kpi_name if kpi else "未知KPI"
sug = generate_scenario_suggestion(sug_type, kpi_name, {
"alert_level": alert.alert_level,
"alert_message": alert.alert_message,
})
alert.suggestion = f"{sug['title']}{sug['description']}\\n建议行动:{''.join(sug['actions'])}"
updated += 1
except Exception as e:
logger.error(f"生成建议失败: alert_id={alert.id}, error={e}")
db.commit()
return {"message": f"已为{updated}条预警生成情景建议", "updated": updated}
@router.post("/run-forecast-deviation")
def run_forecast_deviation_check(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation
对每条偏差规则: 取最新预测log(kpi_forecast_log) vs 该期实际值(kpi_values)
偏差 > threshold_pct → 生成/更新 pending 预警(去重)"""
from app.models import KpiForecastLog
rules = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id,
AlertRule.rule_type == "forecast_deviation",
AlertRule.enabled == 1,
).all()
if not rules:
return {"message": "无预测偏差规则,可先创建 rule_type=forecast_deviation 规则", "generated": 0}
generated = 0
for rule in rules:
try:
params = rule.params or {}
threshold = float(params.get("threshold_pct", 15))
# 最新预测
log = db.query(KpiForecastLog).filter(
KpiForecastLog.entity_id == entity_id,
KpiForecastLog.kpi_id == rule.kpi_id,
).order_by(KpiForecastLog.created_at.desc()).first()
if not log or log.forecast_value is None:
continue
# 该预测期的实际值(同period匹配;兼容 2026-H1 等半年度)
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == rule.kpi_id,
KPIValue.period == log.period,
).order_by(KPIValue.id.desc()).first()
if not actual or not actual.actual_value:
continue
base = abs(actual.actual_value)
if base < 1e-9:
continue
deviation_pct = abs(log.forecast_value - actual.actual_value) / base * 100
if deviation_pct <= threshold:
continue
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
kpi_label = f"{kpi.kpi_name}({kpi.kpi_code})" if kpi else f"KPI#{rule.kpi_id}"
alert_level = "red" if deviation_pct > threshold * 2 else "yellow"
alert_message = (
f"预测偏差 {deviation_pct:.1f}% > 阈值{threshold}%"
f"{kpi_label} 预测{log.period}={log.forecast_value},实际={actual.actual_value}"
)
# 去重: 同KPI+period 已有 pending 偏差预警
existing = db.query(KPIAlert).filter(
KPIAlert.kpi_id == rule.kpi_id,
KPIAlert.alert_message.like(f"%预测偏差%{log.period}%"),
KPIAlert.status == "pending",
).first()
if existing:
existing.alert_message = alert_message
existing.alert_level = alert_level
else:
db.add(KPIAlert(
kpi_id=rule.kpi_id,
kpi_value_id=actual.id,
alert_level=alert_level,
alert_message=alert_message,
alert_type="forecast",
status="pending",
))
generated += 1
except Exception as e:
logger.error(f"预测偏差检查失败 rule_id={rule.id}: {e}")
continue
db.commit()
return {"message": f"预测偏差检查完成: {generated}", "generated": generated}
+318 -11
View File
@@ -1,17 +1,22 @@
"""预警 API"""
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
import logging
logger = logging.getLogger("cma.alerts")
router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
@router.get("")
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db)):
query = db.query(KPIAlert)
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b)
query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == entity_id)
if status:
query = query.filter(KPIAlert.status == status)
total = query.count()
@@ -19,12 +24,314 @@ def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = De
return {"total": total, "data": [{c.name: getattr(a, c.name) for c in KPIAlert.__table__.columns} for a in alerts]}
@router.post("/{alert_id}/resolve")
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
alert.status = "resolved"
alert.resolution = data.get("resolution", "")
alert.assignee = data.get("assignee", alert.assignee)
from datetime import datetime; alert.resolved_at = datetime.now()
db.commit()
db.refresh(alert)
return {
"message": "已处理",
"assignee": alert.assignee,
"alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns},
"suggest_create_action_plan": alert.alert_level == "red",
}
@router.post("/{alert_id}/process")
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""标记预警为处理中"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if alert:
alert.status = "resolved"
alert.resolution = data.get("resolution", "")
alert.assignee = data.get("assignee", alert.assignee)
from datetime import datetime; alert.resolved_at = datetime.now()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.status == "resolved":
raise HTTPException(400, "已处理的预警不能重复处理")
assignee = data.get("assignee")
if not assignee:
raise HTTPException(400, "缺少处理人")
alert.status = "processing"
alert.assignee = assignee
db.commit()
db.refresh(alert)
return {"message": "已标记为处理中", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
@router.post("/{alert_id}/escalate")
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""升级预警级别"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
assignee = data.get("assignee")
if not assignee:
raise HTTPException(400, "缺少处理人")
if alert.status == "resolved":
raise HTTPException(400, "已处理的预警不能升级")
if alert.alert_level != "red":
alert.alert_level = "red"
alert.assignee = assignee
db.commit()
return {"message": "已处理", "assignee": alert.assignee}
db.refresh(alert)
return {"message": "已升级", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
@router.get("/check-timeout")
def check_alert_timeout(db: Session = Depends(get_db)):
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色"""
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24)
timeout_alerts = db.query(KPIAlert).filter(
KPIAlert.status == "pending",
KPIAlert.created_at < cutoff,
KPIAlert.alert_level != "red",
).all()
upgraded_count = 0
for alert in timeout_alerts:
alert.alert_level = "red"
alert.status = "processing"
upgraded_count += 1
if upgraded_count:
db.commit()
return {"total_timeout": len(timeout_alerts), "upgraded_count": upgraded_count}
@router.post("/{alert_id}/create-action-plan")
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""从预警创建改善行动计划"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.action_plan_linked_id:
raise HTTPException(400, "已关联改善计划")
plan = ActionPlan(
alert_id=alert.id,
kpi_id=alert.kpi_id,
title=f"改善: {alert.alert_message}",
assignee=data.get("assignee", ""),
priority="high" if alert.alert_level == "red" else "medium",
created_by=data.get("created_by", ""),
)
db.add(plan)
db.commit()
db.refresh(plan)
alert.action_plan_linked_id = plan.id
db.commit()
return {"message": "改善行动计划已创建", "plan_id": plan.id, "priority": plan.priority}
# ──────────────────────────────────────────────
# 功能4: 风险矩阵热力图 (CMA P2 - ERM框架、风险识别四象限)
RISK_MATRIX_DATA = {
"hanke": {
"entity_name": "陕西酣客(白酒经销)",
"quadrants": [
{
"impact": "high",
"probability": "high",
"label": "高影响×高概率",
"risks": [
{
"id": "risk_001",
"name": "流动性风险",
"impact_label": "",
"probability_label": "",
"detail": "现金2.2万 vs 短债350万 → 断流风险",
"impact_value": 90,
"probability_value": 85,
"type": "red",
"measures": ["催收大额应收", "协商短期借款续贷"],
"responsible": "任富海",
"deadline": "7月底",
},
{
"id": "risk_002",
"name": "合规风险",
"impact_label": "",
"probability_label": "",
"detail": "欠税426万 · 折旧违规",
"impact_value": 95,
"probability_value": 80,
"type": "red",
"measures": ["补缴欠税计划", "重新梳理折旧政策"],
"responsible": "任富海",
"deadline": "8月底",
},
],
},
{
"impact": "high",
"probability": "medium",
"label": "高影响×中概率",
"risks": [
{
"id": "risk_003",
"name": "政策风险",
"impact_label": "",
"probability_label": "",
"detail": "白酒消费税调整可能导致成本上升15-20%",
"impact_value": 85,
"probability_value": 50,
"type": "orange",
"measures": ["关注政策动向", "预留税务缓冲资金"],
"responsible": "财务部",
"deadline": "持续关注",
},
],
},
{
"impact": "medium",
"probability": "high",
"label": "中影响×高概率",
"risks": [
{
"id": "risk_004",
"name": "运营风险",
"impact_label": "",
"probability_label": "",
"detail": "Model C 成本模型未落地,成本核算偏差",
"impact_value": 60,
"probability_value": 80,
"type": "yellow",
"measures": ["推动Model C落地", "建立成本标准化流程"],
"responsible": "财务部",
"deadline": "8月中",
},
],
},
{
"impact": "medium",
"probability": "medium",
"label": "中影响×中概率",
"risks": [
{
"id": "risk_005",
"name": "战略风险",
"impact_label": "",
"probability_label": "",
"detail": "酒类零交易,新业务方向不确定",
"impact_value": 55,
"probability_value": 55,
"type": "yellow",
"measures": ["制定新业务评估框架", "定期战略复盘"],
"responsible": "管理层",
"deadline": "9月底",
},
],
},
{
"impact": "low",
"probability": "low",
"label": "低影响×低概率",
"risks": [
{
"id": "risk_006",
"name": "市场风险",
"impact_label": "",
"probability_label": "",
"detail": "行业需求波动,但酣客已基本退出市场",
"impact_value": 25,
"probability_value": 20,
"type": "green",
"measures": ["定期监控行业数据"],
"responsible": "业务部",
"deadline": "每季度",
},
{
"id": "risk_007",
"name": "人员风险",
"impact_label": "",
"probability_label": "",
"detail": "核心团队稳定,短期内无流失风险",
"impact_value": 20,
"probability_value": 15,
"type": "green",
"measures": ["保持团队激励", "关键岗位备份"],
"responsible": "人事部",
"deadline": "持续",
},
],
},
],
},
"bohai": {
"entity_name": "陕西博海科技(IT服务)",
"quadrants": [
{
"impact": "high",
"probability": "medium",
"label": "高影响×中概率",
"risks": [
{
"id": "risk_b_001",
"name": "现金流风险",
"impact_label": "",
"probability_label": "",
"detail": "应收账款账期延长,现金流紧张",
"impact_value": 85,
"probability_value": 55,
"type": "orange",
"measures": ["加快应收催收", "建立信用管理制度"],
"responsible": "任富海",
"deadline": "7月底",
},
],
},
{
"impact": "medium",
"probability": "high",
"label": "中影响×高概率",
"risks": [
{
"id": "risk_b_002",
"name": "项目交付风险",
"impact_label": "",
"probability_label": "",
"detail": "多个项目并行,交付压力大",
"impact_value": 65,
"probability_value": 75,
"type": "yellow",
"measures": ["优化项目排期", "增加外包资源"],
"responsible": "项目部",
"deadline": "持续",
},
],
},
{
"impact": "low",
"probability": "medium",
"label": "低影响×中概率",
"risks": [
{
"id": "risk_b_003",
"name": "技术迭代风险",
"impact_label": "",
"probability_label": "",
"detail": "新技术跟踪不及时,可能落后",
"impact_value": 30,
"probability_value": 45,
"type": "green",
"measures": ["定期技术培训", "技术栈评估"],
"responsible": "技术部",
"deadline": "每季度",
},
],
},
],
},
}
@router.get("/risk-matrix")
def get_risk_matrix(entity: str = Query("hanke", description="hanke/bohai")):
"""风险矩阵热力图数据 - CMA P2 ERM四象限"""
data = RISK_MATRIX_DATA.get(entity)
if not data:
data = RISK_MATRIX_DATA["hanke"]
data["entity_name"] = f"未知实体({entity}),默认返回酣客数据"
return data
+185
View File
@@ -0,0 +1,185 @@
"""置信度评分系统 — 财务Bot分析结论管理"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.auth_middleware import require_auth
from app.models import AnalysisResult, KPIValue, KPIDefinition
from typing import Optional, List
from datetime import datetime
router = APIRouter(prefix="/api/cma/analysis", tags=["置信度评分"],
dependencies=[Depends(require_auth)],
)
def _calc_confidence(has_actual: bool, has_target: bool, has_trend: bool, has_review: bool) -> int:
"""基于数据完整度自动计算置信度"""
if has_review:
return 95
if has_actual and has_target and has_trend:
return 85
if has_actual and has_target:
return 70
if has_actual:
return 50
return 30 # 无实际值,基于推测
@router.get("/result")
async def get_analysis_results(
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
kpi_code: Optional[str] = Query(None, description="KPI编码"),
db: Session = Depends(get_db),
):
"""查询分析结论(按期间和/或KPI编码过滤)"""
query = db.query(AnalysisResult).order_by(AnalysisResult.created_at.desc())
if period:
query = query.filter(AnalysisResult.period == period)
if kpi_code:
query = query.filter(AnalysisResult.kpi_code == kpi_code)
results = query.all()
return {
"total": len(results),
"period": period,
"results": [
{
"id": r.id,
"period": r.period,
"结论": r.conclusion,
"置信度": f"{r.confidence}%",
"数据来源": r.data_source,
"计算逻辑": r.calculation_logic,
"可比基准": r.comparable_benchmark,
"局限": r.limitations,
"kpi_code": r.kpi_code,
"kpi_name": r.kpi_name,
"has_actual": bool(r.has_actual),
"has_target": bool(r.has_target),
"has_trend": bool(r.has_trend),
"has_review": bool(r.has_review),
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
}
for r in results
],
}
@router.post("/result")
def create_analysis_result(data: dict = None, db: Session = Depends(get_db)):
"""提交分析结果(自动计算置信度)"""
if not data:
data = {}
period = data.get("period", "")
conclusion = data.get("conclusion", "")
data_source = data.get("data_source", "")
calculation_logic = data.get("calculation", "")
comparable_benchmark = data.get("comparable_benchmark")
limitations = data.get("limitations")
kpi_code = data.get("kpi_code")
kpi_name = data.get("kpi_name")
has_actual = data.get("has_actual", False)
has_target = data.get("has_target", False)
has_trend = data.get("has_trend", False)
has_review = data.get("has_review", False)
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
result = AnalysisResult(
period=period,
conclusion=conclusion,
confidence=confidence,
data_source=data_source,
calculation_logic=calculation_logic,
comparable_benchmark=comparable_benchmark,
limitations=limitations,
kpi_code=kpi_code,
kpi_name=kpi_name,
has_actual=1 if has_actual else 0,
has_target=1 if has_target else 0,
has_trend=1 if has_trend else 0,
has_review=1 if has_review else 0,
)
db.add(result)
db.commit()
db.refresh(result)
return {
"id": result.id,
"period": result.period,
"结论": result.conclusion,
"置信度": f"{result.confidence}%",
"数据来源": result.data_source,
"计算逻辑": result.calculation_logic,
"可比基准": result.comparable_benchmark,
"局限": result.limitations,
"confidence_score": result.confidence,
"message": "分析结论已保存",
}
@router.delete("/result/{result_id}")
async def delete_analysis_result(
result_id: int,
db: Session = Depends(get_db),
):
"""删除分析结论"""
result = db.query(AnalysisResult).filter(AnalysisResult.id == result_id).first()
if not result:
raise HTTPException(404, "分析结论不存在")
db.delete(result)
db.commit()
return {"message": "已删除"}
@router.post("/auto-calculate")
async def auto_calculate_confidence(
period: str = Query(..., description="期间 YYYY-MM"),
kpi_code: str = Query(..., description="KPI编码"),
db: Session = Depends(get_db),
):
"""根据KPI数据完整性自动生成置信度评分"""
# 查找KPI定义
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi:
raise HTTPException(404, f"KPI编码 {kpi_code} 不存在")
# 查找该期间的实际值
value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
# 查找历史数据(趋势)
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
).order_by(KPIValue.period.desc()).limit(6).all()
has_actual = value is not None and value.actual_value is not None
has_target = kpi.target_value is not None
has_trend = len(trend_values) >= 2
has_review = False
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
return {
"kpi_code": kpi_code,
"kpi_name": kpi.kpi_name,
"period": period,
"has_actual": has_actual,
"has_target": has_target,
"has_trend": has_trend,
"has_review": has_review,
"confidence": confidence,
"confidence_label": f"{confidence}%",
"数据完备度": {
"10%": "无数据",
"50%": "有实际值",
"70%": "有实际值+目标值",
"85%": "有实际值+目标值+历史趋势",
"95%": "有全部数据+人工复核",
}.get(str(confidence), "基于推测"),
}
+226
View File
@@ -0,0 +1,226 @@
"""
Bot API 操作审计中间件
======================
每次 Bot API 调用(/api/cma/bot*)记录一行 JSON 审计日志到
backend/logs/bot_audit.log(可用环境变量 CMA_BOT_AUDIT_LOG 覆盖路径)。
JSON 行字段: timestamp / bot_name / endpoint / method / risk_level /
entity_id / status
L3 批量写额外记录 rows(导入行数 / 批量条数)。
设计约束:
- 不阻塞业务: 所有日志写入失败仅静默跳过,不影响请求结果
- 只读 bodystarlette 会缓存并回放给下游),仅在 application/json 时解析
- 不改动任何现有 API 行为与多租户隔离
"""
import json
import logging
import os
from datetime import datetime
from fastapi import Request
_LOG_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"logs",
)
_DEFAULT_LOG_PATH = os.path.join(_LOG_DIR, "bot_audit.log")
_audit_logger = None
def _get_logger():
"""构造/复用审计 logger(路径取 CMA_BOT_AUDIT_LOG 覆盖值,便于测试隔离)"""
global _audit_logger
if _audit_logger is None:
log_path = os.getenv("CMA_BOT_AUDIT_LOG") or _DEFAULT_LOG_PATH
os.makedirs(os.path.dirname(log_path), exist_ok=True)
logger = logging.getLogger("cma.bot_audit")
logger.setLevel(logging.INFO)
logger.propagate = False
# 清掉旧 handler,避免日志名单例导致路径切换失效
for h in list(logger.handlers):
logger.removeHandler(h)
try:
h.close()
except Exception:
pass
handler = logging.FileHandler(log_path, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
_audit_logger = logger
return _audit_logger
def write_audit_line(record: dict):
"""写一行 JSON 审计日志;任何异常都静默(不阻塞业务)"""
try:
_get_logger().info(json.dumps(record, ensure_ascii=False))
except Exception:
pass
def _resolve_bot_name(request: Request) -> str:
"""解析调用方标识: X-BOT-KEY → 配置名/原始keybridge → bot-bridge;其余 → web-user/unknown"""
key = request.headers.get("X-BOT-KEY")
if key:
try:
from app.api.bot_bridge import _BOT_API_KEYS
info = _BOT_API_KEYS.get(key)
if isinstance(info, dict) and info.get("name"):
return info["name"]
except Exception:
pass
return key
if request.headers.get("X-BRIDGE-TOKEN"):
return "bot-bridge"
if (request.headers.get("Authorization") or "").startswith("Bearer "):
return "web-user"
return "unknown"
def _resolve_risk_level(request: Request, route=None, endpoint=None):
"""级别解析顺序: 路由函数装饰器标注 → API_RISK_MAP(route.path) → API_RISK_MAP(请求URL)"""
if endpoint is not None:
level = getattr(endpoint, "risk_level", None)
if level:
return level
if route is not None:
from app.risk_levels import get_risk_level
path = getattr(route, "path", None)
if path:
for m in getattr(route, "methods", set()) or set():
if m in ("GET", "POST", "PUT", "DELETE", "PATCH"):
level = get_risk_level(m, path)
if level:
return level
try:
from app.risk_levels import get_risk_level
return get_risk_level(request.method, request.url.path)
except Exception:
return None
async def _extract_entity_id(request: Request):
"""多租户 entity_id 提取(尽力而为): 查询参数 → JSON body(仅 application/json"""
try:
q = request.query_params.get("entity_id")
if q is not None and str(q) != "":
return int(q) if str(q).isdigit() else q
except Exception:
pass
ctype = (request.headers.get("content-type") or "").lower()
if "application/json" in ctype:
try:
raw = await request.body()
if raw:
data = json.loads(raw)
eid = data.get("entity_id")
if eid is not None:
return int(eid) if str(eid).isdigit() else eid
except Exception:
pass
return None
def _extract_l3_rows(level, resp_body: bytes):
"""L3 批量写: 从响应体提取行数/条数(imported / kpi_updated / causality_links / total"""
if level != "L3" or not resp_body:
return None
try:
data = json.loads(resp_body)
except Exception:
return None
if not isinstance(data, dict):
return None
for key in ("imported", "kpi_updated"):
v = data.get(key)
if isinstance(v, (int, float)):
return int(v)
links = data.get("causality_links")
if isinstance(links, list):
return len(links)
total = data.get("total")
if isinstance(total, (int, float)):
return int(total)
return None
async def bot_audit_middleware(request: Request, call_next):
"""HTTP 中间件: 仅审计 /api/cma/bot* 路径;任何异常不影响业务"""
path = request.url.path
if not path.startswith("/api/cma/bot"):
return await call_next(request)
entity_id = None
try:
entity_id = await _extract_entity_id(request)
except Exception:
entity_id = None
try:
response = await call_next(request)
except Exception:
# 兜底记录(全局异常handler会返回500,此处保证审计不丢)
try:
write_audit_line({
"timestamp": datetime.now().isoformat(timespec="seconds"),
"bot_name": _resolve_bot_name(request),
"endpoint": path,
"method": request.method,
"risk_level": _resolve_risk_level(request) or "NA",
"entity_id": entity_id,
"status": 500,
})
except Exception:
pass
raise
# 级别解析(route 由路由器在 call_next 内写入 scope
level = None
try:
route = request.scope.get("route")
endpoint = getattr(route, "endpoint", None) if route else None
level = _resolve_risk_level(request, route=route, endpoint=endpoint)
except Exception:
level = None
# 捕获响应体(L3 需要行数),并重放
resp_body = b""
try:
body_iterator = getattr(response, "body_iterator", None)
if body_iterator is not None:
chunks = [chunk async for chunk in body_iterator]
resp_body = b"".join(chunks)
rows = _extract_l3_rows(level, resp_body)
except Exception:
rows = None
record = {
"timestamp": datetime.now().isoformat(timespec="seconds"),
"bot_name": _resolve_bot_name(request),
"endpoint": path,
"method": request.method,
"risk_level": level or "NA",
"entity_id": entity_id,
"status": response.status_code,
}
if rows is not None:
record["rows"] = rows
try:
write_audit_line(record)
except Exception:
pass
# 响应体已被消费 → 重建响应(Bot接口均为小JSON,非流式)
if resp_body:
from fastapi.responses import Response
return Response(
content=resp_body,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
)
return response
+148 -6
View File
@@ -1,14 +1,31 @@
"""用户认证"""
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.models import User, UserEntity, Entity, OperationLog
from app.auth_middleware import create_token, require_auth, ROLES
router = APIRouter(prefix="/api/cma/auth", tags=["认证"])
def _user_entity_ids(db: Session, user_id: int) -> list[int]:
"""返回用户被授权的企业ID列表"""
rows = db.query(UserEntity.entity_id).filter(UserEntity.user_id == user_id).all()
return [r[0] for r in rows]
def _ensure_default_grants(db: Session, user_id: int) -> None:
"""存量用户兼容:若用户没有任何授权记录,则授予所有active企业(不锁死老账号)"""
cnt = db.query(UserEntity).filter(UserEntity.user_id == user_id).count()
if cnt > 0:
return
entities = db.query(Entity).filter(Entity.status == "active").all()
for e in entities:
db.add(UserEntity(user_id=user_id, entity_id=e.id, granted_by=1))
db.commit()
@router.post("/login")
def login(data: dict, db: Session = Depends(get_db)):
username = data.get("username", "")
@@ -17,9 +34,28 @@ def login(data: dict, db: Session = Depends(get_db)):
if not user or user.password_hash != hashlib.sha256(password.encode()).hexdigest():
raise HTTPException(401, "用户名或密码错误")
token = create_token(user.id)
# 账套模式:登录必须指定企业(entity_id)
entity_id = data.get("entity_id")
if entity_id is None:
raise HTTPException(400, "账套模式:请选择登录企业(entity_id)")
# 存量兼容:无授权记录时自动授予active企业
_ensure_default_grants(db, user.id)
allowed = _user_entity_ids(db, user.id)
if int(entity_id) not in allowed:
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
if not entity or entity.status != "active":
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
token = create_token(user.id, int(entity_id))
return {
"token": token,
"entity_id": int(entity_id),
"entity_name": entity.name,
"entity_short_name": entity.short_name,
"user": {
"id": user.id,
"username": user.username,
@@ -43,13 +79,23 @@ def register(data: dict, db: Session = Depends(get_db)):
)
db.add(user)
db.commit()
db.refresh(user)
# 注册默认授权第一个active企业(不授予全部,保证账套隔离)
first_ent = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).first()
if first_ent:
db.add(UserEntity(user_id=user.id, entity_id=first_ent.id))
db.commit()
return {"message": "注册成功"}
@router.get("/me")
def get_me(current_user: User = Depends(require_auth)):
"""获取当前用户信息"""
return {
def get_me(request: Request, current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
"""获取当前用户信息(含当前账套)"""
from app.auth_middleware import extract_bearer_token, get_token_entity_id
token = extract_bearer_token(request)
eid = get_token_entity_id(token) if token else None
ent = db.query(Entity).filter(Entity.id == eid).first() if eid else None
d = {
"id": current_user.id,
"username": current_user.username,
"name": current_user.name,
@@ -57,6 +103,11 @@ def get_me(current_user: User = Depends(require_auth)):
"role_name": ROLES.get(current_user.role, {}).get("name", current_user.role),
"phone": current_user.phone,
}
if ent:
d["entity_id"] = ent.id
d["entity_name"] = ent.name
d["entity_short_name"] = ent.short_name
return d
@router.get("/roles")
@@ -68,3 +119,94 @@ def list_roles():
for k, v in ROLES.items()
]
}
@router.get("/entities")
def my_entities(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
"""当前用户被授权的企业列表(登录页下拉/切换器数据源)"""
_ensure_default_grants(db, current_user.id)
ids = _user_entity_ids(db, current_user.id)
entities = db.query(Entity).filter(
Entity.id.in_(ids),
Entity.status == "active",
).order_by(Entity.id).all()
return {
"data": [
{"id": e.id, "name": e.name, "short_name": e.short_name, "industry": e.industry}
for e in entities
]
}
@router.get("/my-entities")
def my_entities_alias(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
"""别名:/auth/my-entities(前端切换器调用)"""
return my_entities(current_user, db)
@router.get("/login-entities")
def login_entities_options(username: str = None, db: Session = Depends(get_db)):
"""登录页公司选择器:按用户名返回授权企业(无鉴权,登录前调用;不暴露用户名是否存在)"""
if not username:
return {"data": []}
user = db.query(User).filter(User.username == username).first()
if not user:
return {"data": []}
_ensure_default_grants(db, user.id)
ids = _user_entity_ids(db, user.id)
ents = db.query(Entity).filter(
Entity.id.in_(ids),
Entity.status == "active",
).order_by(Entity.id).all()
return {"data": [{"id": e.id, "name": e.name, "short_name": e.short_name} for e in ents]}
@router.post("/switch-entity")
def switch_entity(
data: dict,
request: Request,
current_user: User = Depends(require_auth),
db: Session = Depends(get_db),
):
"""账套切换:校验授权 → 重新签发token → 写操作日志(方案B:无缝刷新token)"""
entity_id = data.get("entity_id")
if not entity_id:
raise HTTPException(400, "缺少entity_id")
_ensure_default_grants(db, current_user.id)
allowed = _user_entity_ids(db, current_user.id)
if int(entity_id) not in allowed:
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
if not entity or entity.status != "active":
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
# 旧token失效:删除当前请求的旧token(切换即下线旧账套凭据)
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
try:
import redis as redis_lib
r = redis_lib.Redis(host="127.0.0.1", port=6379, db=1, decode_responses=True)
r.delete(f"cma:token:{auth[7:]}")
except Exception:
pass
token = create_token(current_user.id, int(entity_id))
# 切换留痕
db.add(OperationLog(
user_id=current_user.id,
action="switch_entity",
target_type="entity",
target_id=int(entity_id),
detail={"entity_id": int(entity_id), "entity_name": entity.name, "from": "account-switch"},
))
db.commit()
return {
"token": token,
"entity_id": int(entity_id),
"entity_name": entity.name,
"entity_short_name": entity.short_name,
}
+319
View File
@@ -0,0 +1,319 @@
"""BI报表集成 — 任务4
分析模式 + 预置报表模板 + 报表保存/分享 + 导出
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import datetime
import json
import logging
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
logger = logging.getLogger("bi-reports")
router = APIRouter(prefix="/api/cma/bi-reports", tags=["BI报表"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
# ============================================================
# 预置模板
# ============================================================
PRESET_TEMPLATES = [
{
"name": "四层指标总览",
"report_type": "overview",
"is_system": 1,
"config": {
"description": "展示财务/客户/流程/学习四层维度的关键KPI概览",
"layout": "grid",
"dimensions": ["finance", "customer", "process", "learning"],
"metrics": ["count", "avg_value", "alert_count"],
"chart_type": "gauge_card",
}
},
{
"name": "同比趋势分析",
"report_type": "trend",
"is_system": 1,
"config": {
"description": "各KPI近12个月趋势对比",
"period": "monthly",
"window_months": 12,
"chart_type": "line",
"show_compare": True,
}
},
{
"name": "实际vs预算对比",
"report_type": "comparison",
"is_system": 1,
"config": {
"description": "KPI实际值 vs 目标值的偏差分析",
"chart_type": "bar",
"show_deviation": True,
"group_by": "dimension",
}
},
{
"name": "TOP N异常KPI",
"report_type": "topn",
"is_system": 1,
"config": {
"description": "排名前N的异常KPI(红/黄灯)",
"top_n": 10,
"sort_by": "deviation",
"chart_type": "horizontal_bar",
"show_threshold": True,
}
},
{
"name": "因果链推演",
"report_type": "causality",
"is_system": 1,
"config": {
"description": "基于KPI因果链的推演分析",
"chart_type": "force_graph",
"max_depth": 3,
"min_strength": 0.3,
}
},
]
@router.get("/templates")
def list_report_templates(db: Session = Depends(get_db)):
"""获取BI报表模板"""
templates = db.query(BiReportTemplate).order_by(BiReportTemplate.id).all()
return {"data": [{c.name: getattr(t, c.name) for c in BiReportTemplate.__table__.columns} for t in templates]}
@router.post("/templates/seed")
def seed_report_templates(db: Session = Depends(get_db), user=WRITE_ROLES):
"""初始化预置模板(仅首次运行)"""
created = 0
for tpl in PRESET_TEMPLATES:
existing = db.query(BiReportTemplate).filter(
BiReportTemplate.name == tpl["name"],
BiReportTemplate.is_system == 1,
).first()
if existing:
continue
t = BiReportTemplate(**tpl)
db.add(t)
created += 1
db.commit()
return {"message": f"新增{created}个预置模板", "created": created}
@router.delete("/templates/{template_id}")
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
t = db.query(BiReportTemplate).filter(BiReportTemplate.id == template_id).first()
if t:
db.delete(t)
db.commit()
return {"message": "已删除"}
# ============================================================
# 用户报表
# ============================================================
@router.get("")
def list_reports(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取用户保存的报表(账套隔离: 按token企业, 2026-08-23 P2"""
reports = db.query(BiReport).filter(BiReport.entity_id == entity_id).order_by(BiReport.updated_at.desc()).all()
result = []
for r in reports:
d = {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
d["created_by_name"] = f"用户{r.created_by}" if r.created_by else "系统"
result.append(d)
return {"data": result, "total": len(result)}
@router.get("/{report_id}")
def get_report(report_id: int, db: Session = Depends(get_db)):
r = db.query(BiReport).filter(BiReport.id == report_id).first()
if not r:
raise HTTPException(404, "报表不存在")
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
@router.post("")
def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""保存BI报表"""
r = BiReport(
template_id=data.get("template_id"),
name=data.get("name", "未命名报表"),
config=data.get("config", {}),
chart_type=data.get("chart_type", "auto"),
is_shared=data.get("is_shared", 0),
created_by=1,
)
db.add(r)
db.commit()
db.refresh(r)
db.add(OperationLog(action="create", target_type="bi_report", detail=f"创建报表: {r.name}"))
db.commit()
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
@router.put("/{report_id}")
def update_report(report_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
r = db.query(BiReport).filter(BiReport.id == report_id).first()
if not r:
raise HTTPException(404, "报表不存在")
for field in ("name", "config", "chart_type", "is_shared"):
if field in data:
setattr(r, field, data[field])
db.commit()
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
@router.delete("/{report_id}")
def delete_report(report_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
r = db.query(BiReport).filter(BiReport.id == report_id).first()
if r:
db.delete(r)
db.commit()
return {"message": "已删除"}
# ============================================================
# 分析引擎
# ============================================================
@router.post("/analyze")
def analyze_data(data: dict, db: Session = Depends(get_db)):
"""分析引擎:按配置返回报表数据
Body: {
config: { dimensions, kpi_ids, period_start, period_end, group_by, metrics, ... },
chart_type: str
}
"""
config = data.get("config", {})
chart_type = data.get("chart_type", "auto")
kpi_ids = config.get("kpi_ids", [])
dimensions = config.get("dimensions", [])
period_start = config.get("period_start")
period_end = config.get("period_end")
group_by = config.get("group_by")
top_n = config.get("top_n", 10)
# 构建KPI查询
kpi_query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
if kpi_ids:
kpi_query = kpi_query.filter(KPIDefinition.id.in_(kpi_ids))
if dimensions:
kpi_query = kpi_query.filter(KPIDefinition.dimension.in_(dimensions))
kpis = kpi_query.order_by(KPIDefinition.kpi_code).all()
# 获取每个KPI的最新值
rows = []
for kpi in kpis:
val_query = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
)
if period_start:
val_query = val_query.filter(KPIValue.period >= period_start)
if period_end:
val_query = val_query.filter(KPIValue.period <= period_end)
latest = val_query.order_by(KPIValue.period.desc()).first()
# 获取趋势数据
trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all()
rows.append({
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"unit": kpi.unit,
"target_value": kpi.target_value,
"threshold_green": kpi.threshold_green,
"threshold_yellow": kpi.threshold_yellow,
"threshold_red": kpi.threshold_red,
"current_value": latest.actual_value if latest else None,
"current_period": latest.period if latest else None,
"trend": [{"period": v.period, "value": v.actual_value} for v in trend_values],
})
# 统计汇总
summary = {
"total_kpis": len(rows),
"dimensions": {},
}
for r in rows:
dim = r["dimension"]
if dim not in summary["dimensions"]:
summary["dimensions"][dim] = {"count": 0, "values": []}
summary["dimensions"][dim]["count"] += 1
if r["current_value"] is not None:
summary["dimensions"][dim]["values"].append(r["current_value"])
for dim, info in summary["dimensions"].items():
vals = info["values"]
if vals:
info["avg"] = round(sum(vals) / len(vals), 2)
info["min"] = min(vals)
info["max"] = max(vals)
del info["values"]
return {
"config": config,
"chart_type": chart_type,
"rows": rows,
"summary": summary,
}
# ============================================================
# 导出功能(CSV格式,前端可转为Excel/PDF
# ============================================================
@router.post("/export")
def export_report(data: dict, db: Session = Depends(get_db)):
"""导出报表数据 (CSV)"""
config = data.get("config", {})
format_type = data.get("format", "csv")
# 复用analyze获取数据
from app.database import get_session_local
temp_db = get_session_local()()
try:
result = analyze_data(data, temp_db)
finally:
temp_db.close()
rows = result.get("rows", [])
if not rows:
raise HTTPException(400, "没有可导出的数据")
# 生成CSV
import csv, io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["KPI编码", "KPI名称", "维度", "类别", "当前值", "期间", "目标值", "单位"])
for r in rows:
writer.writerow([
r["kpi_code"], r["kpi_name"], r["dimension"], r["category"],
r["current_value"], r["current_period"], r["target_value"], r["unit"],
])
csv_content = output.getvalue()
return Response(
content=csv_content,
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=bi_report_{datetime.now().strftime('%Y%m%d')}.csv"},
)
+774
View File
@@ -0,0 +1,774 @@
"""
CMA BOT API桥接层 — 供财务BOT/店研学BOT调用
无需用户登录,使用 BOT API Key 认证
"""
import os, json, logging
from fastapi import APIRouter, Depends, HTTPException, Query, Header, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from datetime import datetime
from typing import Optional
from app.database import get_db
from app.models import (
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
NotificationLog, RolePermission, ActionPlan, OrgNode,
StrategicMapVersion, MapObjective, Objective, KR,
)
from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models import KPICausality
from app.risk_levels import risk_level
import json
logger = logging.getLogger("cma.bot_bridge")
router = APIRouter(prefix="/api/cma/bot", tags=["BOT桥接"])
# ── BOT API Key 配置 ──
_BOT_API_KEYS = {}
def _load_bot_keys():
global _BOT_API_KEYS
raw = os.getenv("CMA_BOT_API_KEYS", "")
if not raw:
_BOT_API_KEYS = {
"cma-bot-finance-2026": {"role": "finance", "name": "财务BOT"},
"cma-bot-shop-2026": {"role": "business", "name": "店研学BOT"},
"cma-bot-admin-2026": {"role": "ceo", "name": "管理BOT"},
}
else:
try:
_BOT_API_KEYS = json.loads(raw)
except:
_BOT_API_KEYS = {}
_load_bot_keys()
def verify_bot_key(x_bot_key: str = Header(None, alias="X-BOT-KEY")):
if not x_bot_key or x_bot_key not in _BOT_API_KEYS:
raise HTTPException(401, "无效的BOT API Key")
bot_info = _BOT_API_KEYS[x_bot_key]
logger.info(f"BOT访问: {bot_info['name']} ({bot_info['role']})")
return bot_info
# ═══════════════ 通用工具 ═══════════════
def _float(v):
if v is None: return None
try: return float(v)
except: return None
def _safe_iso(dt):
if dt is None: return None
try: return dt.isoformat() if hasattr(dt, 'isoformat') else str(dt)
except: return None
def _model_dict(obj, fields: dict):
"""安全地将模型字段转为dict"""
result = {}
for key, attr in fields.items():
v = getattr(obj, attr, None)
if isinstance(v, float):
result[key] = _float(v)
else:
result[key] = v
return result
# ═══════════════ 端点 ═══════════════
@router.get("/ping")
@risk_level("L1")
def ping():
return {"status": "ok", "version": "1.0", "timestamp": datetime.now().isoformat()}
# ── 总览 ──
@router.get("/overview")
@risk_level("L1")
def bot_overview(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
"""系统总览 — BOT首选入口"""
return {
"bot": bot,
"timestamp": datetime.now().isoformat(),
"stats": {
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
"maps_total": db.query(func.count(StrategicMap.id)).scalar() or 0,
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
"data_sources": db.query(func.count(DataSourceConfig.id)).scalar() or 0,
"users": db.query(func.count(User.id)).scalar() or 0,
"org_nodes": db.query(func.count(OrgNode.id)).scalar() or 0,
}
}
# ── KPI ──
@router.get("/kpis")
@risk_level("L1")
def bot_kpis(
dimension: Optional[str] = Query(None),
status: str = Query("active"),
limit: int = Query(200, le=1000),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
query = db.query(KPIDefinition).filter(KPIDefinition.status == status)
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).limit(limit).all()
results = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)\
.order_by(KPIValue.period.desc()).first()
results.append({
"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
"dimension": k.dimension, "category": k.category,
"unit": k.unit, "formula": k.formula,
"frequency": k.frequency, "data_source_type": k.data_source_type,
"target_value": _float(k.target_value),
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
"responsible_dept": k.responsible_dept, "owner": k.responsible_user,
"objective": k.objective, "formula_desc": k.formula_desc,
"latest_value": _float(latest.actual_value) if latest else None,
"latest_period": latest.period if latest else None,
"status": k.status,
})
return {"total": len(results), "items": results}
@router.get("/kpis/{kpi_id}/history")
@risk_level("L1")
def bot_kpi_history(
kpi_id: int, limit: int = Query(12, le=60),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
.order_by(KPIValue.period.desc()).limit(limit).all()
return {
"kpi": {"id": kpi.id, "name": kpi.kpi_name, "code": kpi.kpi_code, "unit": kpi.unit},
"values": [
{
"period": v.period,
"actual": _float(v.actual_value),
"source_type": v.source_type,
"data_status": v.data_status,
} for v in values
],
}
# ── 战略地图 ──
@router.get("/strategic-maps")
@risk_level("L1")
def bot_maps(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
maps = db.query(StrategicMap).order_by(StrategicMap.id.desc()).all()
result = []
for m in maps:
objectives = db.query(MapObjective).filter(MapObjective.map_id == m.id).all()
dims = {}
for obj in objectives:
dk = obj.dimension_key
if dk not in dims:
dims[dk] = []
dims[dk].append({"id": obj.id, "name": obj.name, "description": obj.description})
result.append({
"id": m.id, "title": m.title, "version": m.version,
"status": m.status, "dimensions": m.dimensions,
"objectives": dims,
"created_at": _safe_iso(m.created_at),
"updated_at": _safe_iso(m.updated_at),
})
return {"total": len(result), "items": result}
# ── 预警 ──
@router.get("/alerts")
@risk_level("L1")
def bot_alerts(
status: str = Query("pending"),
level: Optional[str] = Query(None),
limit: int = Query(50, le=200),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
query = db.query(KPIAlert)
query = query.filter(KPIAlert.status == status)
if level:
query = query.filter(KPIAlert.alert_level == level)
alerts = query.order_by(KPIAlert.created_at.desc()).limit(limit).all()
return {
"total": len(alerts),
"items": [
{
"id": a.id, "kpi_id": a.kpi_id,
"level": a.alert_level, "message": a.alert_message,
"status": a.status, "assignee": a.assignee,
"resolution": a.resolution,
"created_at": _safe_iso(a.created_at),
"resolved_at": _safe_iso(a.resolved_at),
} for a in alerts
],
}
# ── 预算 ──
@router.get("/budget/plans")
@risk_level("L1")
def bot_budget_plans(
year: Optional[int] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
query = db.query(BudgetPlan)
if year:
query = query.filter(BudgetPlan.budget_year == year)
plans = query.order_by(BudgetPlan.period.desc()).limit(200).all()
return {
"total": len(plans),
"items": [
{
"id": p.id, "kpi_id": p.kpi_id,
"period": p.period,
"budget_value": _float(p.budget_value),
"year": p.budget_year, "month": p.budget_month,
"version": p.version, "status": p.status,
"remark": p.remark,
} for p in plans
],
}
# ── 成本 ──
@router.get("/cost/standard")
@risk_level("L1")
def bot_standard_costs(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
costs = db.query(StandardCost).filter(StandardCost.status == "active").limit(200).all()
return {
"total": len(costs),
"items": [
{
"id": c.id, "product_code": c.product_code,
"product_name": c.product_name, "cost_type": c.cost_type,
"item_name": c.item_name,
"standard_quantity": _float(c.standard_quantity),
"unit": c.unit,
"standard_price": _float(c.standard_price),
"standard_cost": _float(c.standard_cost),
"version": c.version, "remark": c.remark,
} for c in costs
],
}
@router.get("/cost/actual")
@risk_level("L1")
def bot_actual_costs(
period: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
query = db.query(ActualCost)
if period:
query = query.filter(ActualCost.period == period)
costs = query.order_by(ActualCost.period.desc()).limit(200).all()
return {
"total": len(costs),
"items": [
{
"id": c.id, "period": c.period,
"product_code": c.product_code,
"product_name": c.product_name,
"cost_type": c.cost_type, "item_name": c.item_name,
"actual_quantity": _float(c.actual_quantity),
"actual_price": _float(c.actual_price),
"actual_cost": _float(c.actual_cost),
} for c in costs
],
}
# ── 行动方案 ──
@router.get("/actions")
@risk_level("L1")
def bot_actions(
status: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
query = db.query(ActionPlan)
if status:
query = query.filter(ActionPlan.status == status)
plans = query.order_by(ActionPlan.priority, ActionPlan.id.desc()).limit(100).all()
return {
"total": len(plans),
"items": [
{
"id": p.id, "title": p.title,
"description": p.description, "kpi_id": p.kpi_id,
"assignee": p.assignee, "priority": p.priority,
"status": p.status, "progress": p.progress,
"due_date": _safe_iso(p.due_date),
"created_at": _safe_iso(p.created_at),
} for p in plans
],
}
# ── 组织 ──
@router.get("/organization")
@risk_level("L1")
def bot_org(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
nodes = db.query(OrgNode).order_by(OrgNode.level, OrgNode.sort_order).all()
return {
"total": len(nodes),
"items": [
{
"id": n.id, "name": n.name,
"parent_id": n.parent_id, "level": n.level,
"code": n.code, "sort_order": n.sort_order,
"enabled": n.enabled,
} for n in nodes
],
}
# ── 数据源 ──
@router.get("/data-sources")
@risk_level("L1")
def bot_data_sources(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
sources = db.query(DataSourceConfig).all()
return {
"total": len(sources),
"items": [
{
"id": s.id, "name": s.name,
"source_type": s.source_type,
"api_endpoint": s.api_endpoint,
"sync_type": s.sync_type,
"status": s.status,
"last_sync_at": _safe_iso(s.last_sync_at),
} for s in sources
],
}
# ── 用户 ──
@router.get("/users")
@risk_level("L1")
def bot_users(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
users = db.query(User).all()
return {
"total": len(users),
"items": [
{"id": u.id, "username": u.username, "name": u.name,
"role": u.role, "phone": u.phone}
for u in users
],
}
# ── 统一查询(BOT首选) ──
@router.get("/query")
@risk_level("L1")
def bot_query(
q: str = Query("overview", description="overview/kpis/alerts/maps/budget/cost/actions/all"),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
"""统一查询入口 — BOT用这个一次拿完需要的数据"""
result = {"bot": bot["name"], "role": bot["role"], "timestamp": datetime.now().isoformat()}
if q in ("overview", "all"):
result["overview"] = {
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar() or 0,
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar() or 0,
"maps": db.query(func.count(StrategicMap.id)).scalar() or 0,
"budget_plans": db.query(func.count(BudgetPlan.id)).scalar() or 0,
}
if q in ("kpis", "all"):
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").limit(100).all()
result["kpis"] = [
{"id": k.id, "name": k.kpi_name, "code": k.kpi_code,
"dimension": k.dimension, "target": _float(k.target_value), "unit": k.unit}
for k in kpis
]
if q in ("alerts", "all"):
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending")\
.order_by(KPIAlert.created_at.desc()).limit(20).all()
result["alerts"] = [
{"id": a.id, "level": a.alert_level, "message": a.alert_message,
"kpi_id": a.kpi_id, "created_at": _safe_iso(a.created_at)}
for a in alerts
]
if q in ("maps", "all"):
maps = db.query(StrategicMap).limit(10).all()
result["maps"] = [
{"id": m.id, "title": m.title, "status": m.status,
"version": m.version, "created_at": _safe_iso(m.created_at)}
for m in maps
]
if q in ("budget", "all"):
plans = db.query(BudgetPlan).limit(50).all()
result["budget"] = [
{"id": p.id, "period": p.period, "budget_value": _float(p.budget_value),
"year": p.budget_year, "month": p.budget_month, "status": p.status,
"kpi_id": p.kpi_id}
for p in plans
]
if q in ("cost", "all"):
sc = db.query(StandardCost).limit(50).all()
result["costs"] = [
{"id": c.id, "product": c.product_name, "type": c.cost_type,
"standard": _float(c.standard_cost), "unit": c.unit}
for c in sc
]
if q in ("okr", "all"):
objs = db.query(Objective).filter(Objective.status == "active").all()
result["okr"] = []
for o in objs:
# KR完整修复(2026-08-27): 从krs表读取
krs = db.query(KR).filter(KR.objective_id == o.id).all()
result["okr"].append({
"id": o.id, "title": o.title, "quarter": o.quarter,
"dimension": o.dimension, "progress": o.progress,
"confidence": o.confidence,
"key_results": [
{"title": kr.title, "status": kr.status, "progress": kr.progress,
"operator": kr.operator, "target_value": _float(kr.target_value)}
for kr in krs
]
})
if q in ("actions", "all"):
acts = db.query(ActionPlan).limit(30).all()
result["actions"] = [
{"id": a.id, "title": a.title, "status": a.status,
"progress": a.progress, "assignee": a.assignee}
for a in acts
]
return result
@router.post("/import")
@risk_level("L3")
def bot_import_excel(
file: UploadFile = File(...),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
"""Bot上传Excel导入KPI数据到CMA"""
import pandas as pd, io, hashlib
from app.models import KPIValue
try:
content = file.file.read()
df = pd.read_excel(io.BytesIO(content))
except Exception as e:
raise HTTPException(400, f"Excel解析失败: {e}")
finally:
file.file.close()
# 智能识别列名
col_map = {"kpi_code": ["kpi_code", "KPI编码", "指标编码", "code"],
"period": ["period", "期间", "月份", "month", "日期"],
"actual_value": ["actual_value", "实际值", "", "金额", "value", "amount"]}
mapped = {}
for field, aliases in col_map.items():
for col in df.columns:
if str(col).strip() in aliases or str(col).strip().lower() in aliases:
mapped[field] = str(col).strip()
break
if "actual_value" not in mapped:
raise HTTPException(400, f"无法识别数值列,支持的列名: {col_map['actual_value']}")
if "kpi_code" not in mapped:
raise HTTPException(400, f"无法识别KPI编码列,支持的列名: {col_map['kpi_code']}")
kpi_col = mapped["kpi_code"]
val_col = mapped["actual_value"]
period_col = mapped.get("period")
count = 0
errors = []
for idx, row in df.iterrows():
try:
kpi_code = str(row[kpi_col]).strip()
val = float(row[val_col])
period = str(row[period_col]).strip() if period_col else datetime.now().strftime("%Y-%m")
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi:
errors.append(f"{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
continue
kv = KPIValue(kpi_id=kpi.id, entity_id=kpi.entity_id, period=period, actual_value=val,
source_batch=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
db.add(kv)
count += 1
except Exception as e:
errors.append(f"{idx+2}行: {e}")
db.commit()
return {"ok": True, "imported": count, "errors": len(errors), "detail": errors[:5]}
# ── 自然语言查询 ──
@router.post("/okr/create")
@risk_level("L2")
def bot_okr_create(
title: str = Query(...),
quarter: str = Query(...),
dimension: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
"""Bot创建OKR目标"""
from app.models import Objective
obj = Objective(title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
db.add(obj)
db.commit()
db.refresh(obj)
return {"ok": True, "id": obj.id, "title": obj.title, "confidence": obj.confidence}
@router.get("/okr/list")
@risk_level("L1")
def bot_okr_list(
quarter: Optional[str] = Query(None),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
"""Bot列出OKR(含KR进度)"""
from app.models import Objective
q = db.query(Objective)
if quarter:
q = q.filter(Objective.quarter == quarter)
objs = q.order_by(Objective.quarter.desc()).all()
return {"total": len(objs), "items": [
{"id": o.id, "title": o.title, "quarter": o.quarter,
"dimension": o.dimension, "progress": o.progress,
"confidence": o.confidence, "status": o.status,
"kr_count": db.query(func.count(ActionPlan.id)).filter(ActionPlan.objective_id == o.id).scalar() or 0}
for o in objs
]}
@router.get("/nlp")
@risk_level("L1")
def bot_nlp(
intent: str = Query("overview"),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
"""
自然语言意图映射:
overview/总览/finance/财务/alerts/预警/budget/预算/cost/成本/maps/战略/actions/行动
"""
m = {
"总览": "overview", "驾驶舱": "overview",
"财务": "finance", "财务状况": "finance",
"预警": "alerts", "风险": "alerts",
"预算": "budget", "预算执行": "budget",
"成本": "cost", "成本分析": "cost",
"战略": "maps", "战略地图": "maps",
"行动": "actions", "改善": "actions",
"okr": "okr", "目标": "okr", "季度目标": "okr",
}
resolved = m.get(intent, intent)
return bot_query(q=resolved, bot=bot, db=db)
# ════════════════════════════════════════════════════════════
# 聚合接口(Agent化生产链路 · 行动1, 2026-08-25
# 建议A: KPI值更新联动预警检查 | 建议B: KPI创建联动关联
# ════════════════════════════════════════════════════════════
@router.post("/kpi-value-with-check")
@risk_level("L2")
def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
"""聚合A: 写KPI值 + 自动跑该KPI预警检查(Agent一次调用,免自拼check-all
body: {kpi_id, actual_value, period?, entity_id?, run_check?}"""
kpi_id = data.get("kpi_id")
actual_value = data.get("actual_value")
period = data.get("period")
entity_id = int(data.get("entity_id") or 1)
run_check = bool(data.get("run_check", True))
if not kpi_id or actual_value is None:
raise HTTPException(400, "kpi_id 和 actual_value 必填")
# 校验 KPI 归属(多租户)
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi or kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
period = period or datetime.now().strftime("%Y-%m")
# ① 写值(upsert
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, KPIValue.period == period).first()
if existing:
existing.actual_value = float(actual_value)
existing.source_type = "bot"
kv = existing
else:
kv = KPIValue(kpi_id=kpi_id, entity_id=entity_id, period=period,
actual_value=float(actual_value), source_type="bot", data_status="verified")
db.add(kv)
db.commit()
db.refresh(kv)
# ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量)
alerts = []
if run_check:
from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend
rules = db.query(AlertRule).filter(
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id,
AlertRule.enabled == 1).all()
for rule in rules:
try:
params = json.loads(rule.params) if isinstance(rule.params, str) else (rule.params or {})
value = float(actual_value)
if rule.rule_type == "static":
level, msg = _check_static(value, params, kpi)
elif rule.rule_type == "dynamic":
level, msg = _check_dynamic(kpi_id, value, params, db)
elif rule.rule_type == "trend_up":
level, msg = _check_trend(kpi_id, value, "up", params, db)
elif rule.rule_type == "trend_down":
level, msg = _check_trend(kpi_id, value, "down", params, db)
else:
continue
if level and level != "green":
dup = db.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
if not dup:
db.add(KPIAlert(kpi_id=kpi_id, kpi_value_id=kv.id, alert_level=level,
alert_message=msg, status="pending", alert_type="bot"))
alerts.append({"rule_id": rule.id, "rule_type": rule.rule_type,
"level": level, "message": msg})
except Exception as e:
logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
db.commit()
return {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
"period": period, "alerts": alerts, "status": "ok"}
@router.post("/kpis/create-with-links")
@risk_level("L3")
def bot_kpi_create_with_links(data: dict, db: Session = Depends(get_db), bot: dict = Depends(verify_bot_key)):
"""聚合B: 创建KPI + 关联战略地图 + 批量因果链(Agent建KPI标准动作)
body: {kpi_code, kpi_name, dimension, entity_id?, target_value?, unit?, link_map_id?, link_causality?}"""
entity_id = int(data.get("entity_id") or 1)
from app.api.kpis import _validate_kpi_data, apply_calc_type_inference
kpi_data = {k: v for k, v in data.items() if k not in ("entity_id", "link_map_id", "link_causality")}
# ① 创建KPI(编码唯一 + 治理校验 + 强制企业)
code = kpi_data.get("kpi_code", "")
if not code:
raise HTTPException(400, "kpi_code 必填")
if db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == code, KPIDefinition.entity_id == entity_id).first():
raise HTTPException(400, f"KPI编码 {code} 已存在")
errs = _validate_kpi_data(kpi_data, db=db, is_update=False)
if errs:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
kpi_data["entity_id"] = entity_id
kpi_data = apply_calc_type_inference(kpi_data)
kpi = KPIDefinition(**kpi_data)
db.add(kpi)
db.commit()
db.refresh(kpi)
# ② 关联战略地图
link_map_id = data.get("link_map_id")
if link_map_id:
m = db.query(StrategicMap).filter(
StrategicMap.id == link_map_id, StrategicMap.entity_id == entity_id).first()
if m:
kpi.map_id = link_map_id
db.commit()
# ③ 批量因果链(源=新KPI → 目标列表)
links = []
for c in data.get("link_causality") or []:
tgt = c.get("target_kpi_id")
if not tgt or int(tgt) == kpi.id:
continue
tgt_kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == int(tgt), KPIDefinition.entity_id == entity_id).first()
if not tgt_kpi:
continue
if db.query(KPICausality).filter(
KPICausality.source_kpi_id == kpi.id,
KPICausality.target_kpi_id == int(tgt)).first():
continue
db.add(KPICausality(source_kpi_id=kpi.id, target_kpi_id=int(tgt),
strength=c.get("strength", 0.5), lag_months=c.get("lag_months", 1),
direction=c.get("direction", "positive")))
links.append({"source": kpi.kpi_code, "target": tgt_kpi.kpi_code,
"strength": c.get("strength", 0.5)})
db.commit()
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "map_id": link_map_id,
"causality_links": links, "status": "ok"}
@router.get("/risk-levels")
@risk_level("L1")
def bot_risk_levels(bot: dict = Depends(verify_bot_key)):
"""Bot API风险分级清单(API→级别→处理方式)— 验收/巡检/授权决策用"""
from app.risk_levels import RISK_LEVELS, list_api_risk_map, risk_summary
return {
"bot": bot["name"],
"risk_levels": RISK_LEVELS,
"apis": list_api_risk_map(),
"summary": risk_summary(),
"note": "L4(危险)不向Bot API开放:Bot只能通过白名单API读写,"
"DROP/TRUNCATE/批量DELETE/生产结构修改物理不可能,由终端层+approval-gate拦截",
}
+623
View File
@@ -0,0 +1,623 @@
"""
Bot-Bridge V2 — 合并改造:Bot桥接 + 自动验证引擎
数据流:
bot-bridge(财务Bot→CMA)→ 写入KPI
verifyCMA→校验)→ 读取KPI → 通过/失败
API:
POST /api/cma/bot-bridge/mpm-result — 接收MPM分析结果
POST /api/cma/bot-bridge/verify/{action_plan_id} — 验证ActionPlan执行结果
"""
import json, logging, re
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models import (
MpmResult, BotBridgeConfig,
KPIDefinition, KPIValue, KPIAlert,
ActionPlan, Entity,
)
from app.risk_levels import risk_level
logger = logging.getLogger("cma.bot_bridge_v2")
router = APIRouter(prefix="/api/cma/bot-bridge", tags=["Bot桥接V2"])
# ═══════════════════════════════════════════════
# 鉴权
# ═══════════════════════════════════════════════
def verify_bridge_token(
x_bridge_token: str = Header(None, alias="X-BRIDGE-TOKEN"),
db: Session = Depends(get_db),
):
"""从bot_bridge_config表校验Token"""
if not x_bridge_token:
raise HTTPException(401, "缺少X-BRIDGE-TOKEN请求头")
config = db.query(BotBridgeConfig).filter(
BotBridgeConfig.token == x_bridge_token,
BotBridgeConfig.is_active == True,
).first()
if not config:
raise HTTPException(401, "Token无效或已停用")
return config.bot_name
# ═══════════════════════════════════════════════
# 验证引擎
# ═══════════════════════════════════════════════
def _get_kpi_current_value(db: Session, kpi_id: int, entity_id: int = None) -> Optional[dict]:
"""获取KPI当前最新值及目标值"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return None
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.data_status == "verified",
).order_by(KPIValue.calculated_at.desc(), KPIValue.period.desc()).first()
return {
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"value": latest.actual_value if latest else None,
"target": kpi.target_value,
"period": latest.period if latest else None,
"unit": kpi.unit,
}
def _parse_condition(condition: str) -> list:
"""
解析condition表达式为可执行结构
支持格式:
"value > target" — 当前值 > 目标值
"value >= 80" — 当前值 >= 80 (绝对值)
"value / target * 100 > 80" — 完成率 > 80%
"value < baseline" — 当前值 < 基线值(目标值=基线)
"value == 0" — 精确等于
"value > target AND value < 100" — 多条件AND
"value > 50 OR value == -1" — 多条件OR
"""
if not condition:
return []
# 分割AND/OR
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
clauses = []
for i in range(0, len(parts), 2):
expr = parts[i].strip()
# 检查是否有运算符连接
match = re.match(
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)(?:\s*([+\-*/])\s*(\d+\.?\d*))?',
expr
)
if not match:
# 尝试 value/target*100 > 80 这种复合表达式
match = re.match(
r'(value)\s*(/|\\*)\s*(target|baseline)\s*(\*?\s*\d+)?\s*([><=!]+)\s*(-?\d+\.?\d*)',
expr
)
if not match:
logger.warning(f"无法解析condition表达式: {expr}")
continue
logical_op = parts[i + 1].upper() if i + 1 < len(parts) else "AND"
clauses.append({"expr": expr, "logical_op": logical_op, "match": match.groups() if match else ()})
return clauses
def _evaluate_condition(condition: str, kpi_data: dict) -> bool:
"""
评估condition表达式
Args:
condition: 条件表达式 e.g. "value > target"
kpi_data: {value, target, ...}
Returns:
True=通过, False=不通过
"""
if not condition:
return False
value = kpi_data.get("value")
target = kpi_data.get("target")
baseline = kpi_data.get("baseline", target)
if value is None:
logger.warning(f"KPI {kpi_data.get('kpi_code')} 当前值为空,无法验证")
return False
result = True
current_logical = "AND"
# 按AND/OR分割
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
for i in range(0, len(parts), 2):
expr = parts[i].strip()
if i + 1 < len(parts):
current_logical = parts[i + 1].upper()
clause_passed = _eval_single_expr(expr, value, target, baseline)
if current_logical == "AND":
result = result and clause_passed
elif current_logical == "OR":
result = result or clause_passed
# 短路优化
if current_logical == "AND" and not result:
break
if current_logical == "OR" and result:
break
return result
def _eval_single_expr(expr: str, value: float, target: Optional[float], baseline: Optional[float]) -> bool:
"""评估单条条件表达式"""
# 模式1: value <op> target/baseline/number (如 value > target, value >= 80)
m = re.match(
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)',
expr
)
if m:
left = m.group(1)
op = m.group(2)
right_raw = m.group(3)
left_val = _resolve_var(left, value, target, baseline)
right_val = _resolve_var(right_raw, value, target, baseline)
if left_val is None or right_val is None:
return False
return _apply_op(left_val, op, right_val)
# 模式2: value [*/] target/baseline [* number] <op> number (如 value/target*100 > 80)
m = re.match(
r'(value)\s*([/*])\s*(target|baseline)(?:\s*\*\s*(\d+))?\s*([><=!]+)\s*(-?\d+\.?\d*)',
expr
)
if m:
left = m.group(1)
op1 = m.group(2) # / or *
var2 = m.group(3) # target or baseline
multiplier = float(m.group(4)) if m.group(4) else 100
op2 = m.group(5) # > < >= <= == !=
right_num = float(m.group(6))
left_val = _resolve_var(left, value, target, baseline)
right_var = _resolve_var(var2, value, target, baseline)
if left_val is None or right_var is None or right_var == 0:
return False
if op1 == '/':
computed = (left_val / right_var) * multiplier
else: # *
computed = left_val * right_var
return _apply_op(computed, op2, right_num)
# 模式3: 纯数字比较 (提供兼容)
logger.warning(f"无法解析表达式: {expr}")
return False
def _resolve_var(token: str, value: float, target: Optional[float], baseline: Optional[float]) -> Optional[float]:
"""将变量名解析为数值"""
token = token.strip()
if token == "value":
return value
elif token == "target":
return target
elif token == "baseline":
return baseline
else:
try:
return float(token)
except (ValueError, TypeError):
return None
def _apply_op(left: float, op: str, right: float) -> bool:
"""应用比较运算符"""
try:
if op == ">":
return left > right
elif op == ">=":
return left >= right
elif op == "<":
return left < right
elif op == "<=":
return left <= right
elif op in ("==", "="):
return abs(left - right) < 0.0001
elif op == "!=":
return abs(left - right) >= 0.0001
else:
logger.warning(f"未知运算符: {op}")
return False
except (TypeError, ValueError):
return False
# ═══════════════════════════════════════════════
# KPI映射表:MPM结果字段 → KPI编码
# ═══════════════════════════════════════════════
MPM_TO_KPI_MAP = {
"revenue": None, # 不做KPI映射,保留在raw_data
"cost": None,
"gross_margin_standard": None,
"gross_margin_adjusted": "F_GROSS_MARGIN",
"net_profit_standard": None,
"net_profit_adjusted": "F_NET_PROFIT",
"channel_rebate_rate": "C_REBATE_RATE",
"mgmt_expense_ratio": "F_COST_RATIO",
}
# 预警阈值配置
ALERT_THRESHOLDS = {
"channel_rebate_rate": {"threshold": 75, "operator": ">", "kpi_code": "C_REBATE_RATE", "kpi_name": "渠补率"},
"mgmt_expense_ratio": {"threshold": 50, "operator": ">", "kpi_code": "F_COST_RATIO", "kpi_name": "管理费/净收入"},
}
# ═══════════════════════════════════════════════
# API端点
# ═══════════════════════════════════════════════
@router.post("/mpm-result")
@risk_level("L3")
def receive_mpm_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""
接收财务Bot/其他Bot的MPM分析结果
- 鉴权(校验X-BRIDGE-TOKEN
- 写入mpm_results表
- 更新KPI当前值(kpi_values表)
- 触发预警(如渠补率超75%
"""
source = data.get("source", bridge_bot)
entity_id = data.get("entity_id")
period = data.get("period")
results = data.get("results", {})
if not entity_id or not period:
raise HTTPException(400, "缺少必填字段: entity_id, period")
# 验证entity存在
entity = db.query(Entity).filter(Entity.id == entity_id).first()
if not entity:
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
# ── 1. 写入MPM结果 ──
record = MpmResult(
entity_id=entity_id,
period=period,
source=source,
raw_data=results,
)
db.add(record)
db.flush()
# ── 2. 更新KPI当前值 ──
kpi_updates = []
for field, kpi_code in MPM_TO_KPI_MAP.items():
if kpi_code is None:
continue
field_value = results.get(field)
if field_value is None:
continue
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
logger.warning(f"KPI编码 {kpi_code} 未找到 (entity_id={entity_id})")
continue
# 写入最新值 (upsert: 存在同period则更新,否则插入)
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
if existing:
existing.actual_value = field_value
existing.source_type = "bot-bridge-v2"
existing.data_status = "verified"
else:
kpi_val = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=field_value,
source_type="bot-bridge-v2",
data_status="verified",
)
db.add(kpi_val)
kpi_updates.append(kpi_code)
# ── 3. 触发预警 ──
alerts = []
for field, config in ALERT_THRESHOLDS.items():
field_value = results.get(field)
if field_value is None:
continue
threshold = config["threshold"]
operator = config["operator"]
kpi_code = config.get("kpi_code")
kpi_name = config["kpi_name"]
if (operator == ">" and field_value > threshold) or \
(operator == ">=" and field_value >= threshold):
# 查找关联KPI
kpi_id = None
if kpi_code:
kpi_def = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
if kpi_def:
kpi_id = kpi_def.id
# 创建预警
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
alert = KPIAlert(
kpi_id=kpi_id or 1, # fallback to first KPI if not found
alert_level="red",
alert_message=alert_msg,
alert_type="actual",
status="pending",
)
db.add(alert)
alerts.append(alert_msg)
logger.warning(alert_msg)
db.commit()
db.refresh(record)
return {
"success": True,
"kpi_updated": len(kpi_updates),
"alerts_triggered": len(alerts),
"mpm_record_id": record.id,
"details": {
"kpi_codes": kpi_updates,
"alerts": alerts,
},
}
@router.post("/kpi-result")
@risk_level("L2")
def push_kpi_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""
Bot分析结果回填KPI值 + 触发预警(PRD第七部分 bot-bridge数据通道)
入参:
entity_id (int, 默认1): 企业实体ID
kpi_code (str, 必填): KPI编码
period (str, 必填): 期间 YYYY-MM
value (num, 必填): 实际值
source (str, 默认finance-bot): 来源Bot标识
remark (str, 可选): 备注
行为:
1. 按(entity_id, kpi_code)查KPI → 不存在返回错误
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts
"""
entity_id = data.get("entity_id", 1)
kpi_code = data.get("kpi_code")
period = data.get("period")
value = data.get("value")
source = data.get("source", "finance-bot")
remark = data.get("remark")
if not kpi_code:
raise HTTPException(400, "缺少必填字段: kpi_code")
if not period:
raise HTTPException(400, "缺少必填字段: period")
if value is None or value == "":
raise HTTPException(400, "缺少必填字段: value")
try:
actual_value = float(value)
except (TypeError, ValueError):
raise HTTPException(400, f"value不是有效数值: {value!r}")
# 1. 按(entity_id, kpi_code)查KPI
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == kpi_code,
).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_code} 不存在 (entity_id={entity_id})")
# 2. 写入KPIValue(同period已存在则更新,幂等upsert
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
if existing:
existing.actual_value = actual_value
existing.source_type = "bot"
existing.source_batch = source
existing.data_status = "verified"
existing.calculated_at = datetime.now()
if remark:
existing.remark = remark
val = existing
else:
val = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=actual_value,
source_type="bot",
source_batch=source,
data_status="verified",
calculated_at=datetime.now(),
remark=remark,
)
db.add(val)
db.commit()
db.refresh(val)
# 3. 触发预警检查(复用alert_generator引擎,yellow/red自动联动行动计划)
from scripts.alert_generator import run_alert_check
new_count = run_alert_check(db, period)
# 收集本次写入值直接触发的预警(kpi_value_id关联)
new_alerts = []
if new_count:
triggered = db.query(KPIAlert).filter(
KPIAlert.kpi_value_id == val.id,
).order_by(KPIAlert.created_at.desc()).all()
new_alerts = [
{
"id": a.id,
"kpi_id": a.kpi_id,
"kpi_code": kpi.kpi_code,
"level": a.alert_level,
"message": a.alert_message,
"action_plan_id": a.action_plan_linked_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
} for a in triggered
]
logger.info(
f"[bot-bridge] KPI回填: {kpi.kpi_code}@{period}={actual_value} "
f"source={source} entity={entity_id} alerts={new_count}"
)
return {
"status": "ok",
"kpi_value_id": val.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"period": period,
"value": actual_value,
"source": source,
"new_alerts_count": len(new_alerts),
"new_alerts": new_alerts,
}
@router.post("/verify/{action_plan_id}")
@risk_level("L2")
def verify_action_plan(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""
验证ActionPlan的执行结果
1. 读取ActionPlan的auto_verify_rule
2. 读取关联KPI的当前值
3. 按condition校验
4. 返回通过/失败 + 详细数据
"""
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
if not plan:
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
if not plan.auto_verify_rule:
raise HTTPException(400, "该ActionPlan未配置auto_verify_rule验证规则")
# 解析验证规则
if isinstance(plan.auto_verify_rule, str):
rule = json.loads(plan.auto_verify_rule)
else:
rule = plan.auto_verify_rule
condition = rule.get("condition", "")
description = rule.get("description", "")
# 获取KPI当前值
kpi_data = _get_kpi_current_value(db, plan.kpi_id)
if not kpi_data:
return {
"success": False,
"action_plan_id": action_plan_id,
"title": plan.title,
"verify_result": "fail",
"reason": "关联KPI不存在",
}
# 执行验证
passed = _evaluate_condition(condition, kpi_data)
# 记录验证日志
verify_log_entry = {
"timestamp": datetime.now().isoformat(),
"condition": condition,
"kpi_data": kpi_data,
"passed": passed,
}
existing_logs = plan.verify_log or []
if isinstance(existing_logs, list):
existing_logs.append(verify_log_entry)
else:
existing_logs = [verify_log_entry]
plan.verify_result = "pass" if passed else "fail"
plan.verify_log = existing_logs
db.commit()
return {
"success": True,
"action_plan_id": action_plan_id,
"title": plan.title,
"verify_result": plan.verify_result,
"condition": condition,
"condition_description": description,
"kpi_data": kpi_data,
"verification_detail": {
"value": kpi_data.get("value"),
"target": kpi_data.get("target"),
"condition": condition,
"passed": passed,
},
}
@router.get("/verify/{action_plan_id}/history")
@risk_level("L1")
def verify_history(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""获取ActionPlan的验证历史"""
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
if not plan:
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
return {
"action_plan_id": action_plan_id,
"title": plan.title,
"verify_result": plan.verify_result,
"verify_log": plan.verify_log or [],
"auto_verify_rule": plan.auto_verify_rule,
}
+261
View File
@@ -0,0 +1,261 @@
"""
铁律KPI看板API — CMA铁律执行效果数据采集
提供: 验证次数、通过率、违规数、按Bot分组的通过率
"""
import os, json, sqlite3, logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models import ActionPlan
from app.risk_levels import risk_level
logger = logging.getLogger("cma.iron_law")
router = APIRouter(prefix="/api/cma/bot", tags=["铁律KPI看板"])
# ── BOT API Key 配置(复用bot_bridge的一致鉴权) ──
_BOT_API_KEYS = {}
def _load_bot_keys():
global _BOT_API_KEYS
raw = os.getenv("CMA_BOT_API_KEYS", "")
if not raw:
_BOT_API_KEYS = {
"cma-bot-finance-2026": {"role": "finance", "name": "财务BOT"},
"cma-bot-shop-2026": {"role": "business", "name": "店研学BOT"},
"cma-bot-admin-2026": {"role": "ceo", "name": "管理BOT"},
}
else:
try:
_BOT_API_KEYS = json.loads(raw)
except:
_BOT_API_KEYS = {}
_load_bot_keys()
def verify_bot_key(x_bot_key: str = Header(None, alias="X-BOT-KEY")):
if not x_bot_key or x_bot_key not in _BOT_API_KEYS:
raise HTTPException(401, "无效的BOT API Key")
bot_info = _BOT_API_KEYS[x_bot_key]
return bot_info
# ── 各Hermes Profile state.db 路径 ──
HERMES_HOME = "/root/.hermes/profiles"
def _list_profile_dbs():
"""列出所有Hermes Profile的state.db路径"""
dbs = []
if not os.path.isdir(HERMES_HOME):
return dbs
for name in os.listdir(HERMES_HOME):
db_path = os.path.join(HERMES_HOME, name, "state.db")
if os.path.isfile(db_path):
dbs.append((name, db_path))
return sorted(dbs)
def _query_state_db(db_path: str, since_days: int = 30):
"""从单个 state.db 查询验证相关消息"""
cutoff = datetime.now() - timedelta(days=since_days)
cutoff_ts = cutoff.timestamp()
results = {
"total_verify_msgs": 0,
"pass_msgs": 0,
"fail_msgs": 0,
"violation_msgs": 0,
}
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
c = conn.cursor()
# 查询含验证/铁律关键词的消息(排除tool_call、校验自身和系统prompt
c.execute("""
SELECT content, role FROM messages
WHERE timestamp >= ?
AND (content LIKE '%verify%'
OR content LIKE '%验证%'
OR content LIKE '%铁律%'
OR content LIKE '%validate%')
AND content NOT LIKE '%verify_bot_key%'
AND content NOT LIKE '%X-BOT-KEY%'
AND content NOT LIKE '%system_prompt%'
""", (cutoff_ts,))
rows = c.fetchall()
results["total_verify_msgs"] = len(rows)
for row in rows:
content = row["content"] or ""
role = row["role"]
# 判断是否通过/成功/完成
pass_patterns = ["通过", "passed", "success", "", "完成", "completed", "验证通过"]
fail_patterns = ["失败", "failed", "error", "", "违规", "未通过", "错误", "异常"]
violation_patterns = ["违规", "violation", "拦截", "blocked", "违例"]
# 助理角色消息:用于判断验证结果
if role == "assistant":
has_pass = any(p in content for p in pass_patterns)
has_fail = any(p in content for p in fail_patterns)
if has_pass and not has_fail:
results["pass_msgs"] += 1
elif has_fail and not has_pass:
results["fail_msgs"] += 1
elif has_pass and has_fail:
# 混合内容,默认算通过(因为通常有通过+补充说明)
results["pass_msgs"] += 1
if any(p in content for p in violation_patterns):
results["violation_msgs"] += 1
conn.close()
except Exception as e:
logger.warning(f"查询state.db失败 {db_path}: {e}")
return results
def _query_action_plan_verify(db: Session):
"""从CMA ActionPlan表查询验证数据"""
total = db.query(func.count(ActionPlan.id)).scalar() or 0
verified = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.verify_result.isnot(None)
).scalar() or 0
passed = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.verify_result == "pass"
).scalar() or 0
failed = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.verify_result == "fail"
).scalar() or 0
with_rule = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.auto_verify_rule.isnot(None)
).scalar() or 0
return {
"total_plans": total,
"verified": verified,
"passed": passed,
"failed": failed,
"with_auto_rule": with_rule,
"pass_rate": round(passed / verified * 100, 1) if verified > 0 else 0,
}
# ═══════════════ 端点 ═══════════════
@router.get("/iron-law")
@risk_level("L1")
def get_iron_law_kpis(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
since_days: int = 30,
):
"""
铁律执行效果KPI看板
返回各Bot的验证统计、通过率、违规数,以及ActionPlan的验证覆盖率。
数据来源:
- Hermes 各Profile state.db (消息含 verify/验证/铁律)
- CMA action_plans 表 (verify_result, auto_verify_rule)
"""
# ── 1. 采集各Profile state.db ──
profile_dbs = _list_profile_dbs()
per_bot = {}
total_verify = 0
total_pass = 0
total_fail = 0
total_violations = 0
for profile_name, db_path in profile_dbs:
stats = _query_state_db(db_path, since_days=since_days)
per_bot[profile_name] = {
"verification_count": stats["total_verify_msgs"],
"pass_count": stats["pass_msgs"],
"fail_count": stats["fail_msgs"],
"violation_count": stats["violation_msgs"],
"pass_rate": round(
stats["pass_msgs"] / stats["total_verify_msgs"] * 100, 1
) if stats["total_verify_msgs"] > 0 else 0,
}
total_verify += stats["total_verify_msgs"]
total_pass += stats["pass_msgs"]
total_fail += stats["fail_msgs"]
total_violations += stats["violation_msgs"]
# ── 2. 采集ActionPlan验证数据 ──
plan_stats = _query_action_plan_verify(db)
return {
"bot": bot,
"timestamp": datetime.now().isoformat(),
"period": f"past_{since_days}d",
"summary": {
"total_verifications": total_verify,
"pass_rate": round(total_pass / total_verify * 100, 1) if total_verify > 0 else 0,
"total_violations": total_violations,
"plan_verify_count": plan_stats["verified"],
"plan_pass_rate": plan_stats["pass_rate"],
"plans_with_auto_rule": plan_stats["with_auto_rule"],
"total_action_plans": plan_stats["total_plans"],
},
"per_bot": per_bot,
"action_plan_verification": {
"total_plans": plan_stats["total_plans"],
"verified": plan_stats["verified"],
"passed": plan_stats["passed"],
"failed": plan_stats["failed"],
"pass_rate": plan_stats["pass_rate"],
"with_auto_rule": plan_stats["with_auto_rule"],
"coverage": round(
plan_stats["verified"] / plan_stats["total_plans"] * 100, 1
) if plan_stats["total_plans"] > 0 else 0,
},
"data_sources": {
"state_dbs": len(profile_dbs),
"profiles_queried": [p[0] for p in profile_dbs],
"action_plans_table": True,
},
}
@router.get("/iron-law/bots")
@risk_level("L1")
def get_bot_iron_law_ranking(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
since_days: int = 30,
):
"""按Bot排名:验证通过率从高到低"""
data = get_iron_law_kpis(bot=bot, db=db, since_days=since_days)
ranking = sorted(
data["per_bot"].items(),
key=lambda x: x[1]["pass_rate"],
reverse=True,
)
ranked = []
for rank, (name, stats) in enumerate(ranking, 1):
ranked.append({
"rank": rank,
"bot_name": name,
"pass_rate": stats["pass_rate"],
"verification_count": stats["verification_count"],
"violations": stats["violation_count"],
"failures": stats["fail_count"],
})
return {
"bot": bot,
"timestamp": datetime.now().isoformat(),
"period": f"past_{since_days}d",
"ranking": ranked,
"total_bots": len(ranked),
}
+176
View File
@@ -0,0 +1,176 @@
"""Bot KPI管理 API — 管理各Agent的KPI自评体系"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_auth
from app.models import KPIDefinition, KPIValue
from app.risk_levels import risk_level
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
dependencies=[Depends(require_auth)],
)
# 反向指标编码(值越低越好)
REVERSE_BOT_INDICATORS = ['FB_RESPONSE_TIME']
def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
"""五档评分引擎(复用KPI体系)- 1~5分"""
if current_value is None or target_value is None or target_value == 0:
return None, "info"
ratio = current_value / target_value
if is_reverse:
if ratio <= 0.5:
return 5, "success"
elif ratio <= 0.8:
return 4, "success"
elif ratio <= 1.0:
return 3, "warning"
elif ratio <= 1.2:
return 2, "danger"
else:
return 1, "danger"
else:
if ratio >= 1.2:
return 5, "success"
elif ratio >= 1.0:
return 4, "success"
elif ratio >= 0.8:
return 3, "warning"
elif ratio >= 0.5:
return 2, "danger"
else:
return 1, "danger"
@router.get("")
@risk_level("L1")
def list_bot_kpis(
source: str = Query("finance-bot", description="Bot标识"),
period: Optional[str] = None,
db: Session = Depends(get_db),
):
"""获取某Bot的所有KPI(含评分)"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.bot_source == source,
KPIDefinition.status == "active",
).order_by(KPIDefinition.kpi_code).all()
if not kpis:
return {
"source": source,
"kpis": [],
"groups": {},
"overall": None,
}
from app.api.kpis import REVERSE_INDICATORS as _ri
result_kpis = []
for k in kpis:
# 取最新实际值
val_query = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
)
if period:
val_query = val_query.filter(KPIValue.period == period)
latest_val = val_query.order_by(KPIValue.period.desc()).first()
current_val = latest_val.actual_value if latest_val else None
is_reverse = k.kpi_code in REVERSE_BOT_INDICATORS
score, status = _calc_bot_kpi_score(current_val, k.target_value, is_reverse=is_reverse)
result_kpis.append({
"id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"category": k.category or "core_output",
"formula": k.formula,
"target_value": k.target_value,
"current_value": current_val,
"unit": k.unit,
"weight": 15, # 权重在PRD中定义,但未存表,前端使用
"score": score,
"status": status,
"period": latest_val.period if latest_val else None,
"frequency": k.frequency or "monthly",
})
# 按group分组: core_output / quality / user_feedback
groups = {
"core_output": {"label": "核心产出", "kpis": []},
"quality": {"label": "质量监控", "kpis": []},
"user_feedback": {"label": "用户反馈", "kpis": []},
}
# category映射: FB编码前缀区分
for kp in result_kpis:
code = kp["kpi_code"]
if code.startswith("FB_ANALYSIS") or code.startswith("FB_ACCURACY") or code.startswith("FB_ISSUE") or code.startswith("FB_ACTION") or code.startswith("FB_RESPONSE"):
groups["core_output"]["kpis"].append(kp)
elif code.startswith("FB_DATA") or code.startswith("FB_CONSISTENCY") or code.startswith("FB_CITATION"):
groups["quality"]["kpis"].append(kp)
else:
groups["user_feedback"]["kpis"].append(kp)
# 综合得分
scored_kpis = [k for k in result_kpis if k["score"] is not None]
if scored_kpis:
overall = round(sum(k["score"] * k["weight"] for k in scored_kpis) / sum(k["weight"] for k in scored_kpis), 2)
overall_status = "success" if overall >= 4 else ("warning" if overall >= 3 else "danger")
else:
overall = None
overall_status = "info"
return {
"source": source,
"kpis": result_kpis,
"groups": groups,
"overall": {"score": overall, "status": overall_status},
}
@router.post("/{kpi_id}/value")
@risk_level("L2")
def update_bot_kpi_value(
kpi_id: int,
data: dict,
db: Session = Depends(get_db),
):
"""更新Bot KPI当前值"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.bot_source.isnot(None),
).first()
if not kpi:
raise HTTPException(404, "Bot KPI不存在")
actual_value = data.get("actual_value")
if actual_value is None:
raise HTTPException(422, "actual_value 不能为空")
period = data.get("period", datetime.now().strftime("%Y-%m"))
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == period,
).first()
if existing:
existing.actual_value = actual_value
existing.source_type = "manual"
else:
val = KPIValue(
kpi_id=kpi_id,
entity_id=kpi.entity_id if kpi else None, # 账套隔离 P2
period=period,
actual_value=actual_value,
source_type="manual",
data_status="pending",
)
db.add(val)
db.commit()
return {"message": "更新成功", "kpi_id": kpi_id, "period": period, "actual_value": actual_value}
+42
View File
@@ -0,0 +1,42 @@
"""BSC四层配置 API"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth
from app.models import BscLayerConfig, Entity
router = APIRouter(prefix="/api/cma/bsc-layers", tags=["BSC层配置"],
dependencies=[Depends(require_auth)],
)
@router.get("")
def list_bsc_layers(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
"""获取某企业的BSC四层权重配置"""
# 验证企业存在
entity = db.query(Entity).filter(Entity.id == entity_id).first()
if not entity:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=404, content={"detail": "企业不存在"})
layers = db.query(BscLayerConfig).filter(
BscLayerConfig.entity_id == entity_id
).order_by(BscLayerConfig.id).all()
return {
"entity_id": entity_id,
"entity_name": entity.short_name or entity.name,
"layers": [
{
"id": l.id,
"layer": l.layer,
"weight": float(l.weight),
"kpi_count_min": l.kpi_count_min,
"kpi_count_max": l.kpi_count_max,
}
for l in layers
]
}
+1322 -7
View File
File diff suppressed because it is too large Load Diff
+301
View File
@@ -0,0 +1,301 @@
"""预算自动从KPI推算 API — P1-2
根据KPI的目标值自动生成预算建议。
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
import json
import logging
from datetime import datetime
logger = logging.getLogger("cma.budget_gen")
router = APIRouter(prefix="/api/cma/budget", tags=["KPI→预算"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
def _calc_budget(kpi: KPIDefinition) -> dict:
"""根据KPI类型推算预算
算法:
- 降本类: (当前值-目标值)×0.3
- 增收类: 目标增收额×0.2
- 能力类: 人均培训成本×人数
- 系统类: 按模块开发费估算
"""
category = kpi.category or ""
target = kpi.target_value or 0
result = {
"suggested_budget": 0,
"calc_logic": "",
"calc_type": "未知",
}
# 降本类: cost_control, cash_risk
if category in ("cost_control", "cash_risk", "asset_efficiency"):
result["calc_type"] = "降本类"
# 当前值需要从最新的KPIValue获取
# 这里返回算法描述,前端传入当前值
result["calc_type_desc"] = "(当前值-目标值)×0.3"
result["suggested_budget"] = 0 # 需要前端传当前值
# 增收类: revenue_growth, profitability
elif category in ("revenue_growth", "profitability", "customer_scale"):
result["calc_type"] = "增收类"
result["calc_type_desc"] = "目标增收额×0.2"
result["suggested_budget"] = round(target * 0.2, 2)
# 能力类: talent_pipeline, employee_engagement, innovation
elif category in ("talent_pipeline", "employee_engagement", "innovation"):
result["calc_type"] = "能力类"
result["calc_type_desc"] = "人均培训成本×人数"
result["suggested_budget"] = 0 # 需要外部参数
# 系统类: 默认为系统类
elif category in ("supply_chain", "delivery_quality", "customer_concentration", "customer_satisfaction"):
result["calc_type"] = "系统类"
result["calc_type_desc"] = "按功能模块开发费估算"
result["suggested_budget"] = round(target * 0.15, 2)
# 其他未分类
else:
result["calc_type"] = "系统类"
result["calc_type_desc"] = "按功能模块开发费估算"
result["suggested_budget"] = round(target * 0.15, 2)
return result
@router.get("/kpi-budget-candidates")
def get_kpi_budget_candidates(
year: int = None,
db: Session = Depends(get_db),
):
"""获取可用于生成预算的KPI列表,按类型分类"""
if not year:
year = datetime.now().year
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
# 获取每个KPI的最新实际值
latest_values = {}
for kpi in kpis:
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id
).order_by(KPIValue.calculated_at.desc()).first()
if v:
latest_values[kpi.id] = v.actual_value
# 分类
categorized = {
"cost_reduction": [], # 降本类
"revenue_growth": [], # 增收类
"capability": [], # 能力类
"system": [], # 系统类
}
for kpi in kpis:
calc_info = _calc_budget(kpi)
current_val = latest_values.get(kpi.id)
# 降本类: 需要当前值
if calc_info["calc_type"] == "降本类":
if current_val is not None and kpi.target_value:
diff = current_val - kpi.target_value
suggested = round(max(diff, 0) * 0.3, 2)
calc_logic = f"当前值{current_val}-目标值{kpi.target_value}={diff:.2f},×0.3={suggested:.2f}"
else:
suggested = 0
calc_logic = "缺少当前值或目标值,无法计算"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "降本类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
}
categorized["cost_reduction"].append(item)
elif calc_info["calc_type"] == "增收类":
suggested = round((kpi.target_value or 0) * 0.2, 2)
calc_logic = f"目标增收额{kpi.target_value}×0.2={suggested:.2f}"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "增收类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
}
categorized["revenue_growth"].append(item)
elif calc_info["calc_type"] == "能力类":
# 假设人均培训成本2000元, 默认10人
suggested = round(2000 * 10, 2)
calc_logic = f"人均培训成本2000元×10人={suggested:.2f}(可调整人数和单价)"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "能力类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
"per_head_cost": 2000,
"head_count": 10,
}
categorized["capability"].append(item)
else: # 系统类
suggested = round((kpi.target_value or 0) * 0.15, 2)
if suggested <= 0:
suggested = 30000 # 默认3万
calc_logic = "按模块开发费估算: 默认30000元(可调整)"
else:
calc_logic = f"目标值{kpi.target_value}×0.15={suggested:.2f}"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "系统类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
}
categorized["system"].append(item)
return {"data": categorized}
@router.post("/generate-from-kpis")
def generate_budget_from_kpis(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""从选中的KPI生成预算科目
Body: {
year: int,
month: int,
version: string,
items: [
{
kpi_id: int,
budget_amount: float, // 用户可编辑
calc_logic: string,
calc_type: string,
}
]
}
"""
year = data.get("year", datetime.now().year)
month = data.get("month", datetime.now().month + 1)
version = data.get("version", "v1.0")
items = data.get("items", [])
if not items:
raise HTTPException(400, "请至少选择一个KPI")
period = f"{year}-{month:02d}"
results = []
total_amount = 0
for item in items:
kpi_id = item.get("kpi_id")
budget_amount = item.get("budget_amount")
calc_logic = item.get("calc_logic", "")
calc_type = item.get("calc_type", "")
if not kpi_id or budget_amount is None:
continue
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
continue
# 检查是否已有记录
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = budget_amount
existing.source_type = "kpi_generated"
existing.source_kpi_id = kpi_id
existing.calc_logic = calc_logic
existing.remark = f"KPI推算({calc_type}): {calc_logic}"
plan_id = existing.id
else:
plan = BudgetPlan(
kpi_id=kpi_id,
period=period,
budget_value=budget_amount,
budget_year=year,
budget_month=month,
version=version,
status="active",
source_type="kpi_generated",
source_kpi_id=kpi_id,
calc_logic=calc_logic,
remark=f"KPI推算({calc_type}): {calc_logic}",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(plan)
db.flush()
plan_id = plan.id
total_amount += budget_amount
results.append({
"kpi_id": kpi_id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"budget_amount": budget_amount,
"calc_logic": calc_logic,
"plan_id": plan_id,
})
# 操作日志
log = OperationLog(
user_id=getattr(current_user, "id", None),
action="kpi_generate_budget",
target_type="budget",
detail=json.dumps({
"year": year,
"month": month,
"version": version,
"item_count": len(results),
"total_amount": total_amount,
}, ensure_ascii=False),
)
db.add(log)
db.commit()
return {
"message": f"已从{len(results)}个KPI生成预算,合计¥{total_amount:,.2f}",
"total_amount": total_amount,
"items": results,
}
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
"""现金流分类规则 API — 管理会计OS (P2-⑥ 2026-08-28)
分类规则管理(cash_plan_classify_rules) + 待分类队列(cash_plan_unclassified) + 一键归类。
sync-cash-plans 未命中的KPI进入待分类队列,人工一键归类 → 自动补建规则+生成CashPlan。
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import (
CashPlanClassifyRule, CashPlanUnclassified, CashPlan,
KPIDefinition, BudgetPlan,
)
router = APIRouter(
prefix="/api/cma/budget",
tags=["现金流分类"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
# ── 分类规则 CRUD ──────────────────────────────
@router.get("/cash-classify-rules")
def list_classify_rules(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""分类规则列表(按 entity_id 隔离)"""
rows = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.entity_id == entity_id
).order_by(CashPlanClassifyRule.priority.asc(), CashPlanClassifyRule.id.desc()).all()
kpi_ids = {r.kpi_id for r in rows if r.kpi_id}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
result = []
for r in rows:
kpi = kpis.get(r.kpi_id) if r.kpi_id else None
result.append({
"id": r.id,
"kpi_id": r.kpi_id,
"kpi_code": kpi.kpi_code if kpi else "",
"kpi_name": kpi.kpi_name if kpi else "",
"kpi_code_pattern": r.kpi_code_pattern,
"plan_type": r.plan_type,
"priority": r.priority,
"status": r.status,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/cash-classify-rules")
def create_classify_rule(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""新建分类规则(kpi_id 精确 或 kpi_code_pattern 关键词 二选一)"""
plan_type = data.get("plan_type")
if plan_type not in ("receive", "pay"):
raise HTTPException(400, "plan_type 必须是 receive/pay")
kpi_id = data.get("kpi_id")
pattern = data.get("kpi_code_pattern")
if not kpi_id and not pattern:
raise HTTPException(400, "需要 kpi_id 或 kpi_code_pattern 至少一个")
row = CashPlanClassifyRule(
entity_id=entity_id,
kpi_id=kpi_id,
kpi_code_pattern=pattern,
plan_type=plan_type,
priority=data.get("priority", 10),
status=data.get("status", "active"),
)
db.add(row)
db.commit()
db.refresh(row)
return {"message": "分类规则已创建", "id": row.id}
@router.put("/cash-classify-rules/{rule_id}")
def update_classify_rule(
rule_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""更新分类规则"""
row = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.id == rule_id,
CashPlanClassifyRule.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "规则不存在")
for field in ("kpi_id", "kpi_code_pattern", "plan_type", "priority", "status"):
if field in data:
setattr(row, field, data[field])
db.commit()
return {"message": "分类规则已更新", "id": row.id}
@router.delete("/cash-classify-rules/{rule_id}")
def delete_classify_rule(
rule_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""删除分类规则"""
row = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.id == rule_id,
CashPlanClassifyRule.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "规则不存在")
db.delete(row)
db.commit()
return {"message": "分类规则已删除"}
# ── 待分类队列 ──────────────────────────────
@router.get("/cash-unclassified")
def list_unclassified(
status: Optional[str] = Query(None, description="pending/classified/ignored"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""待分类KPI队列"""
query = db.query(CashPlanUnclassified).filter(CashPlanUnclassified.entity_id == entity_id)
if status:
query = query.filter(CashPlanUnclassified.status == status)
rows = query.order_by(CashPlanUnclassified.created_at.desc()).all()
result = []
for r in rows:
result.append({
"id": r.id,
"kpi_id": r.kpi_id,
"kpi_name": r.kpi_name,
"period": r.period,
"budget_value": r.budget_value,
"reason": r.reason,
"status": r.status,
"created_at": r.created_at.isoformat() if r.created_at else None,
"resolved_at": r.resolved_at.isoformat() if r.resolved_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/cash-unclassified/{item_id}/classify")
def classify_unclassified(
item_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""一键归类: body {plan_type: receive/pay}
① 自动补建分类规则 ② 标记队列 classified ③ 联动生成对应 CashPlan
"""
plan_type = data.get("plan_type")
if plan_type not in ("receive", "pay"):
raise HTTPException(400, "plan_type 必须是 receive/pay")
item = db.query(CashPlanUnclassified).filter(
CashPlanUnclassified.id == item_id,
CashPlanUnclassified.entity_id == entity_id,
CashPlanUnclassified.status == "pending",
).first()
if not item:
raise HTTPException(404, "待分类记录不存在或已处理")
# ① 自动补建规则(无精确KPI规则时)
existing_rule = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.entity_id == entity_id,
CashPlanClassifyRule.kpi_id == item.kpi_id,
).first()
if not existing_rule:
db.add(CashPlanClassifyRule(
entity_id=entity_id,
kpi_id=item.kpi_id,
kpi_code_pattern=None,
plan_type=plan_type,
priority=10,
status="active",
))
# ② 标记队列
item.status = "classified"
item.resolved_at = datetime.now()
# ③ 联动生成 CashPlan(有期间和预算值时)
plan_created = False
if item.period and item.budget_value is not None:
try:
year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1])
plan_date = datetime(year, month, 1)
except Exception:
plan_date = None
if plan_date:
existing_plan = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.related_kpi_id == item.kpi_id,
CashPlan.plan_type == plan_type,
CashPlan.plan_date == plan_date,
).first()
if not existing_plan:
db.add(CashPlan(
entity_id=entity_id,
plan_type=plan_type,
related_kpi_id=item.kpi_id,
amount=item.budget_value,
plan_date=plan_date,
description=f"待分类队列归类: {item.kpi_name or ''}",
status="pending",
source="budget_sync",
))
plan_created = True
db.commit()
return {
"message": f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else ""),
"plan_type": plan_type,
"rule_created": not existing_rule,
"plan_created": plan_created,
}
@router.post("/cash-unclassified/{item_id}/ignore")
def ignore_unclassified(
item_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""忽略该KPI(不生成规则)"""
item = db.query(CashPlanUnclassified).filter(
CashPlanUnclassified.id == item_id,
CashPlanUnclassified.entity_id == entity_id,
).first()
if not item:
raise HTTPException(404, "待分类记录不存在")
item.status = "ignored"
item.resolved_at = datetime.now()
db.commit()
return {"message": "已忽略"}
+69 -4
View File
@@ -5,6 +5,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.utils.cost_engine import (
calc_product_variance, get_cost_overview, get_cost_breakdown,
@@ -22,9 +23,10 @@ router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
@router.get("/standard-costs")
def list_standard_costs(product_code: Optional[str] = Query(None),
cost_type: Optional[str] = Query(None),
db: Session = Depends(get_db)):
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查询标准成本卡片"""
query = db.query(StandardCost).filter(StandardCost.status == "active")
query = db.query(StandardCost).filter(StandardCost.status == "active", StandardCost.entity_id == entity_id)
if product_code:
query = query.filter(StandardCost.product_code == product_code)
if cost_type:
@@ -86,9 +88,10 @@ def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)):
@router.get("/actual-costs")
def list_actual_costs(period: Optional[str] = Query(None),
product_code: Optional[str] = Query(None),
db: Session = Depends(get_db)):
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查询实际成本"""
query = db.query(ActualCost)
query = db.query(ActualCost).filter(ActualCost.entity_id == entity_id)
if period:
query = query.filter(ActualCost.period == period)
if product_code:
@@ -202,6 +205,68 @@ def cost_breakdown(product_code: str = Query(...),
return get_cost_breakdown(product_code, period)
@router.get("/comparison")
def cost_method_comparison(entity: str = Query("hanke")):
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)"""
if entity == "hanke":
gross_revenue = 713 # 万
channel_rebate_rate = 0.828
net_revenue = round(gross_revenue * (1 - channel_rebate_rate), 2)
weighted_cost_rate = 0.4862
book_cost = 717 # 万
total_expenses = 546 # 万
non_value_added = 26 # 万
traditional = {
"revenue": gross_revenue,
"cost": book_cost,
"gross_profit": round(gross_revenue - book_cost, 2),
"gross_margin": round((gross_revenue - book_cost) / gross_revenue * 100, 2),
"expenses": total_expenses,
"net_profit": round(gross_revenue - book_cost - total_expenses, 2),
}
var_cost = round(net_revenue * weighted_cost_rate, 2)
variable = {
"revenue": net_revenue,
"cost": var_cost,
"gross_profit": round(net_revenue - var_cost, 2),
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
"expenses": total_expenses,
"net_profit": round(net_revenue - var_cost - total_expenses, 2),
}
abc_cost = round(net_revenue * weighted_cost_rate, 2)
abc_exp = total_expenses - non_value_added
abc = {
"revenue": net_revenue,
"cost": abc_cost,
"gross_profit": round(net_revenue - abc_cost, 2),
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
"expenses": abc_exp,
"net_profit": round(net_revenue - abc_cost - abc_exp, 2),
}
return {
"entity": "hanke",
"entity_name": "陕西酣客(白酒经销)",
"traditional": traditional,
"variable": variable,
"abc": abc,
"insights": [
{"method": "传统成本法", "conclusion": "毛利率为负", "decision": "❌ 不赚钱,别卖了", "detail": "未剔除渠补,账面收入虚高"},
{"method": "变动成本法", "conclusion": "毛利率18.6%", "decision": "✅ 业务能赚钱→砍费用", "detail": "剔除渠补后净收入122万,成本率48.62%"},
{"method": "作业成本法", "conclusion": "识别非增值作业26万", "decision": "✅ 可再省", "detail": "剔除冗余招待费等非增值作业"},
],
"notes": {
"net_revenue": "毛收入713万 × (1-渠补率82.8%) = 净收入122万",
"variable_cost": "净收入122万 × 加权成本率48.62% = 成本59.3万",
"abc_expenses": "总费用546万 - 非增值作业26万 = 520万",
},
}
return {"error": "不支持的实体"}
@router.get("/dashboard")
def cost_dashboard(period: Optional[str] = Query(None)):
"""成本分析首页—汇总数据"""
+244
View File
@@ -0,0 +1,244 @@
"""客户维度KPI看板 API — P0-2"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from typing import Optional
from datetime import datetime, timedelta
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, User
import logging
logger = logging.getLogger("cma.customer")
router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
"""解析时间区间"""
today = datetime.now()
if period_type == "month":
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "quarter":
q = (today.month - 1) // 3
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "year":
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "custom" and start_date and end_date:
start = datetime.strptime(start_date, "%Y-%m-%d")
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
else:
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
return start, end
@router.get("")
def list_customer_kpis(
period: str = Query("month"),
start_date: str = Query(None),
end_date: str = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""获取客户维度KPI列表(含最新值、预警、趋势)"""
start, end = parse_period(period, start_date, end_date)
period_str = start.strftime("%Y-%m")
# 只查 customer 维度的 KPI
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
).order_by(KPIDefinition.kpi_code).all()
result = []
for k in kpis:
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
if period == "month":
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
elif period == "quarter":
q_month = (datetime.now().month - 1) // 3
months = [f"{datetime.now().year}-{m:02d}" for m in range(q_month*3+1, q_month*3+4)]
values = base_query.filter(KPIValue.period.in_(months)).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
elif period == "year":
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
elif period == "custom" and start_date and end_date:
periods = []
d = start
while d <= end:
periods.append(d.strftime("%Y-%m"))
d += timedelta(days=32)
d = d.replace(day=1)
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
else:
latest = base_query.order_by(KPIValue.period.desc()).first()
# 最新预警
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
# 趋势(环比变化率)
trend = None
achievement_rate = None
period_values = []
if latest and latest.actual_value:
prev_period_str = None
if period == "month":
year_s, month_s = period_str.split("-")
y_s, m_s = int(year_s), int(month_s)
m_s -= 1
if m_s <= 0:
m_s += 12
y_s -= 1
prev_period_str = f"{y_s}-{m_s:02d}"
if prev_period_str:
prev_val = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == prev_period_str,
).order_by(KPIValue.id.desc()).first()
if prev_val and prev_val.actual_value and prev_val.actual_value > 0:
trend = round((latest.actual_value - prev_val.actual_value) / prev_val.actual_value * 100, 2)
elif prev_val and prev_val.actual_value and prev_val.actual_value == 0:
trend = 100.0 if latest.actual_value > 0 else 0
# 达成率
if latest and latest.actual_value and k.target_value and k.target_value > 0:
achievement_rate = round(latest.actual_value / k.target_value * 100, 1)
# 最近6期趋势数据
period_q = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.desc()).limit(6).all()
period_values = [
{"period": v.period, "value": v.actual_value}
for v in reversed(period_q) if v.actual_value is not None
]
result.append({
"id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"category": k.category,
"unit": k.unit,
"target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else None,
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
"responsible_user": k.responsible_user,
"trend": trend,
"achievement_rate": achievement_rate,
"period_values": period_values,
"kpi_name": k.kpi_name,
})
return {"data": result, "period": period, "total": len(result)}
@router.get("/trend/{kpi_id}")
def get_kpi_trend(
kpi_id: int,
months: int = Query(12, ge=3, le=24),
db: Session = Depends(get_db),
):
"""获取单个KPI的历史趋势数据"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 获取最近N期数据
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
).order_by(KPIValue.period.desc()).limit(months).all()
trend_data = [
{"period": v.period, "value": v.actual_value}
for v in reversed(values) if v.actual_value is not None
]
# 计算预警水平和触发时间
alerts = db.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi_id,
).order_by(KPIAlert.created_at.desc()).limit(10).all()
alert_logs = [
{
"level": a.alert_level,
"message": a.alert_message,
"time": a.created_at.isoformat() if a.created_at else None,
"status": a.status,
}
for a in alerts
]
return {
"kpi": {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"target_value": kpi.target_value,
"unit": kpi.unit,
"threshold_green": kpi.threshold_green,
"threshold_yellow": kpi.threshold_yellow,
"threshold_red": kpi.threshold_red,
},
"trend_data": trend_data,
"alerts": alert_logs,
}
@router.get("/summary")
def get_customer_summary(
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""客户维度概要统计"""
total = db.query(func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
).scalar() or 0
# 预警统计
pending_alerts = db.query(func.count(KPIAlert.id)).filter(
KPIAlert.status == "pending",
KPIAlert.kpi_id.in_(
db.query(KPIDefinition.id).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
)
),
).scalar() or 0
# 二级类别分布
cat_stats = db.query(
KPIDefinition.category,
func.count(KPIDefinition.id),
).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
).group_by(KPIDefinition.category).all()
return {
"total": total,
"pending_alerts": pending_alerts,
"category_stats": [{"category": c[0], "count": c[1]} for c in cat_stats],
}
+85 -28
View File
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.deps import get_entity_id
from app.models import KPIDefinition, KPIValue, KPIAlert, User
from app.utils.cache import get as cache_get, set as cache_set
import json
@@ -51,16 +52,37 @@ def period_prefix(period_type: str):
return str(datetime.now().year)
return None
def kpi_target_by_frequency(k):
"""按考核频率返回对应周期的目标值(多粒度改造)
monthly/weekly -> target_monthly; quarterly/half_year -> target_quarterly; yearly -> target_yearly
兼容: 对应列无值时回退 target_value
"""
freq = (k.frequency or "monthly").lower()
if freq in ("monthly", "weekly"):
val = getattr(k, "target_monthly", None)
elif freq in ("quarterly", "half_year"):
val = getattr(k, "target_quarterly", None)
elif freq == "yearly":
val = getattr(k, "target_yearly", None)
else:
val = None
if val is None:
val = k.target_value
return val
@router.get("/summary")
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"), db: Session = Depends(get_db)):
cache_key = f"summary:{role}:{period}"
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"),
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
cache_key = f"summary:{role}:{period}:{entity_id}"
cached = cache_get("dashboard", cache_key)
if cached:
return cached
kpi_total = db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar()
kpi_total = db.query(func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar()
alert_count = db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar()
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active").group_by(KPIDefinition.dimension).all()
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
).group_by(KPIDefinition.dimension).all()
# 读取最近一次同步状态(从日志文件最后一行)
sync_status = {"last_sync": None, "status": "unknown", "detail": ""}
@@ -94,11 +116,14 @@ def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month")
@router.get("/kpis")
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
start_date: str = Query(None), end_date: str = Query(None),
db: Session = Depends(get_db)):
db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
start, end = parse_period(period, start_date, end_date)
period_str = start.strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id
).all()
result = []
for k in kpis:
@@ -136,6 +161,7 @@ def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else None,
"alert_level": alert.alert_level if alert else "none",
@@ -191,6 +217,7 @@ def get_my_kpis(
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit,
"target_value": k.target_value,
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else period_str,
"alert_level": alert.alert_level if alert else "none",
@@ -213,6 +240,7 @@ def get_finance_analysis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""财务工作台分析数据"""
period_str = datetime.now().strftime("%Y-%m")
@@ -220,6 +248,7 @@ def get_finance_analysis(
finance_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "finance",
KPIDefinition.entity_id == entity_id,
).all()
kpi_data = []
@@ -242,6 +271,7 @@ def get_finance_analysis(
kpi_data.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"unit": k.unit, "target_value": k.target_value,
"target_monthly": k.target_monthly, "target_quarterly": k.target_quarterly, "target_yearly": k.target_yearly,
"actual_value": latest.actual_value if latest else None,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
@@ -269,7 +299,7 @@ def get_finance_analysis(
@router.get("/predict")
def predict_kpis(db: Session = Depends(get_db)):
def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""基于历史趋势预测下月KPI值(简单线性回归)"""
from datetime import datetime, timedelta
@@ -281,7 +311,10 @@ def predict_kpis(db: Session = Depends(get_db)):
next_year += 1
next_period = f"{next_year}-{next_month:02d}"
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id
).all()
predictions = []
for k in kpis:
@@ -357,39 +390,44 @@ def predict_kpis(db: Session = Depends(get_db)):
def my_dashboard(
current_user: User = Depends(require_auth),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""个人工作台:返回我的KPI、改善行动、待办提醒"""
"""个人工作台:返回我的KPI、改善行动、待办提醒(账套隔离: 按token企业过滤)"""
username = current_user.username
name = current_user.name
role = current_user.role
# 角色预设KPI编码
ROLE_PRESET_KPIS = {
"ceo": ["F_REVENUE_001", "F_PROFIT_001", "F_COST_001", "C_CUST_001", "P_INV_001"],
"finance": ["F_REVENUE_001", "F_PROFIT_001", "F_COST_001", "F_CASH_001"],
"business": ["C_CUST_001", "C_CUST_003", "F_REVENUE_001"],
"it": [], # IT没有固定预设
"ceo": ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "C_REBATE_RATE", "P_DELIVERY", "F_FCF"],
"finance": ["F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_OP_CFLOW", "F_ROE"],
"business": ["C_REBATE_RATE", "C_NEW_CLIENTS", "F_REVENUE"],
"it": [],
}
preset_codes = ROLE_PRESET_KPIS.get(role, [])
# 1. 我的KPIresponsible_user匹配用户名或姓名)+ 角色预设
# 1. 我的KPIresponsible_user匹配用户名或姓名)+ 角色预设(均按企业隔离)
assigned_kpis = db.query(KPIDefinition).filter(
or_(
KPIDefinition.responsible_user == username,
KPIDefinition.responsible_user == name,
),
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
assigned_ids = {k.id for k in assigned_kpis}
# 补充角色预设KPI(去重)
# 补充角色预设KPI(去重,按企业隔离
preset_kpis = []
if preset_codes:
preset_kpis = db.query(KPIDefinition).filter(
q = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_(preset_codes),
KPIDefinition.status == "active",
~KPIDefinition.id.in_(assigned_ids) if assigned_ids else True,
).all()
KPIDefinition.entity_id == entity_id,
)
if assigned_ids:
q = q.filter(~KPIDefinition.id.in_(assigned_ids))
preset_kpis = q.all()
all_kpis = assigned_kpis + preset_kpis
@@ -400,11 +438,22 @@ def my_dashboard(
).order_by(KPIValue.calculated_at.desc()).first()
actual = latest_v.actual_value if latest_v else None
target = k.target_value
target = kpi_target_by_frequency(k)
level = "gray"
if actual is not None and target:
ratio = actual / target
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
# 反向指标(越低越好):费用率/渠补率/应收天数/返利率/成本率/存货天数
REVERSE_INDICATORS = {
"F_COST_RATIO", "C_REBATE_RATE", "F_AR_DAYS",
"F_REBATE_RATE", "F_FACTORY_REBATE_RATE", "F_COST_CONTROL_RATE",
"F_INV_DAYS", "F_DEBT_RATIO", "P_BUG_RATE", "P_REWORK_PCT",
}
if k.kpi_code in REVERSE_INDICATORS:
# 反向:实际≤目标=绿;实际≤目标*1.1=黄;否则红
level = "green" if actual <= target else (
"yellow" if actual <= target * 1.1 else "red")
else:
ratio = actual / target
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
kpi_list.append({
"id": k.id,
@@ -413,20 +462,28 @@ def my_dashboard(
"dimension": k.dimension,
"category": k.category,
"target_value": target,
"target_monthly": k.target_monthly,
"target_quarterly": k.target_quarterly,
"target_yearly": k.target_yearly,
"frequency": k.frequency,
"actual_value": actual,
"unit": k.unit,
"level": level,
"period": latest_v.period if latest_v else None,
})
# 2. 我的改善行动(assignee匹配)
# 2. 我的改善行动(assignee匹配CEO/管理员看全部
from app.models import ActionPlan
my_plans = db.query(ActionPlan).filter(
or_(
ActionPlan.assignee == username,
ActionPlan.assignee == name,
)
).order_by(ActionPlan.updated_at.desc()).all()
if current_user.role == "ceo":
# CEO/管理员查看全部行动方案(2026-08-26修复: 原逻辑只按assignee过滤导致工作台显示空)
my_plans = db.query(ActionPlan).order_by(ActionPlan.updated_at.desc()).all()
else:
my_plans = db.query(ActionPlan).filter(
or_(
ActionPlan.assignee == username,
ActionPlan.assignee == name,
)
).order_by(ActionPlan.updated_at.desc()).all()
plan_list = []
for p in my_plans:
+840
View File
@@ -0,0 +1,840 @@
"""驾驶舱 API v2 — 支持时间区间"""
from fastapi import APIRouter, Depends, Query, Request, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, User
from app.utils.cache import get as cache_get, set as cache_set
import json
import logging
logger = logging.getLogger("cma.dashboard")
router = APIRouter(prefix="/api/cma/dashboard", tags=["驾驶舱"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
"""解析时间区间"""
today = datetime.now()
if period_type == "month":
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "quarter":
q = (today.month - 1) // 3
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "year":
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "custom" and start_date and end_date:
start = datetime.strptime(start_date, "%Y-%m-%d")
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
else:
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
return start, end
def period_prefix(period_type: str):
"""生成SQL期间前缀匹配"""
if period_type == "month":
return datetime.now().strftime("%Y-%m")
elif period_type == "quarter":
now = datetime.now()
q = (now.month - 1) // 3
months = [f"{now.year}-{m:02d}" for m in range(q*3+1, q*3+4)]
return months
elif period_type == "year":
return str(datetime.now().year)
return None
@router.get("/summary")
def get_dashboard_summary(role: str = Query("ceo"), period: str = Query("month"), db: Session = Depends(get_db)):
cache_key = f"summary:{role}:{period}"
cached = cache_get("dashboard", cache_key)
if cached:
return cached
kpi_total = db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active").scalar()
alert_count = db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending").scalar()
dims = db.query(KPIDefinition.dimension, func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active").group_by(KPIDefinition.dimension).all()
# 读取最近一次同步状态(从日志文件最后一行)
sync_status = {"last_sync": None, "status": "unknown", "detail": ""}
try:
with open("/var/log/cma-daily-sync.log", "r") as f:
lines = f.readlines()
# 从最后往前找包含 "完成" 或 "失败" 的行
for line in reversed(lines[-50:]):
if "全部完成" in line:
sync_status["status"] = "success"
sync_status["last_sync"] = line.strip()
break
elif "失败" in line or "ERROR" in line:
sync_status["status"] = "failed"
sync_status["last_sync"] = line.strip()
break
else:
# 没找到完成/失败标记,取最后一行
sync_status["last_sync"] = lines[-1].strip() if lines else None
except Exception as e:
sync_status["detail"] = str(e)
result = {
"kpi_total": kpi_total or 0, "alert_count": alert_count or 0,
"dimension_stats": [{"dimension": d[0], "count": d[1]} for d in dims],
"sync_status": sync_status,
}
cache_set("dashboard", cache_key, result, ttl_seconds=30)
return result
@router.get("/kpis")
def get_dashboard_kpis(role: str = Query("ceo"), period: str = Query("month"),
start_date: str = Query(None), end_date: str = Query(None),
db: Session = Depends(get_db)):
start, end = parse_period(period, start_date, end_date)
period_str = start.strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
result = []
for k in kpis:
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
if period == "month":
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
elif period == "quarter":
months = period_prefix("quarter")
values = base_query.filter(KPIValue.period.in_(months)).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
elif period == "year":
values = base_query.filter(KPIValue.period.like(f"{period_str[:4]}%")).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
elif period == "custom" and start_date and end_date:
periods = []
d = start
while d <= end:
periods.append(d.strftime("%Y-%m"))
d += timedelta(days=32)
d = d.replace(day=1)
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
else:
latest = base_query.order_by(KPIValue.period.desc()).first()
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit, "target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else None,
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
})
return {"data": result, "period": period, "range": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")}}
@router.get("/my-kpis")
def get_my_kpis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""获取当前用户负责的KPI
- business角色:只看自己负责的KPI
- 其他角色:看所有有预警的KPI
"""
role = current_user.role
username = current_user.username
name = current_user.name
period_str = datetime.now().strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
result = []
for k in kpis:
# business角色筛选
if role == "business":
responsible = (k.responsible_user or "").strip()
if responsible and responsible != username and responsible != name:
continue
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
).order_by(KPIValue.id.desc()).first()
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
result.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "unit": k.unit,
"target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else period_str,
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"alert_id": alert.id if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
"responsible_user": k.responsible_user,
"trend": trend,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
})
return {"data": result, "user_role": role, "user_name": name, "period": period_str}
@router.get("/finance-analysis")
def get_finance_analysis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""财务工作台分析数据"""
period_str = datetime.now().strftime("%Y-%m")
finance_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "finance",
).all()
kpi_data = []
for k in finance_kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period_str,
).order_by(KPIValue.id.desc()).first()
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.desc()).limit(6).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(trend_values)]
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
kpi_data.append({
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"unit": k.unit, "target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"threshold_green": k.threshold_green,
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
"trend": trend,
"alert_level": alert.alert_level if alert else "none",
"frequency": k.frequency,
})
total_sales = next((k for k in kpi_data if k["kpi_code"] == "SALES_TOTAL"), None)
gross_profit = next((k for k in kpi_data if k["kpi_code"] == "SALES_PROFIT_RATE"), None)
cost_control = next((k for k in kpi_data if k["kpi_code"] == "COST_CONTROL_RATE"), None)
receivable = next((k for k in kpi_data if k["kpi_code"] == "RECEIVABLE_TURNOVER"), None)
return {
"period": period_str,
"kpis": kpi_data,
"summary": {
"total_sales": total_sales["actual_value"] if total_sales else None,
"gross_profit_rate": gross_profit["actual_value"] if gross_profit else None,
"cost_control_rate": cost_control["actual_value"] if cost_control else None,
"receivable_turnover": receivable["actual_value"] if receivable else None,
}
}
@router.get("/predict")
def predict_kpis(db: Session = Depends(get_db)):
"""基于历史趋势预测下月KPI值(简单线性回归)"""
from datetime import datetime, timedelta
period_str = datetime.now().strftime("%Y-%m")
next_month = int(period_str[5:7]) + 1
next_year = int(period_str[:4])
if next_month > 12:
next_month = 1
next_year += 1
next_period = f"{next_year}-{next_month:02d}"
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
predictions = []
for k in kpis:
values = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
).order_by(KPIValue.period.asc()).all()
# 需要至少3个数据点才能做预测
if len(values) < 3:
continue
# 简单线性回归: y = a + bx
points = [(i, v.actual_value) for i, v in enumerate(values) if v.actual_value is not None]
if len(points) < 3:
continue
n = len(points)
sum_x = sum(p[0] for p in points)
sum_y = sum(p[1] for p in points)
sum_xy = sum(p[0] * p[1] for p in points)
sum_xx = sum(p[0] ** 2 for p in points)
# 斜率 b = (n*sum_xy - sum_x*sum_y) / (n*sum_xx - sum_x*sum_x)
denom = n * sum_xx - sum_x * sum_x
if denom == 0:
continue
b = (n * sum_xy - sum_x * sum_y) / denom
a = (sum_y - b * sum_x) / n
# 预测下个月(x = n,因为最后一个索引是 n-1)
predicted_value = a + b * n
# 检查预测值是否触发阈值
alert_level = "none"
if k.threshold_red:
try:
op = k.threshold_red[:2] if len(k.threshold_red) > 1 and k.threshold_red[1] in "=<>" else k.threshold_red[0]
val_str = k.threshold_red.replace(op, "").strip()
val = float(val_str)
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
alert_level = "red"
except (ValueError, IndexError):
pass
if alert_level == "none" and k.threshold_yellow:
try:
op = k.threshold_yellow[:2] if len(k.threshold_yellow) > 1 and k.threshold_yellow[1] in "=<>" else k.threshold_yellow[0]
val_str = k.threshold_yellow.replace(op, "").strip()
val = float(val_str)
if (op in (">=", ">") and predicted_value >= val) or (op in ("<=", "<") and predicted_value <= val):
alert_level = "yellow"
except (ValueError, IndexError):
pass
predictions.append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"target_value": k.target_value,
"last_value": points[-1][1] if points else None,
"predicted_value": round(predicted_value, 2),
"predicted_period": next_period,
"alert_level": alert_level,
"trend": "up" if b > 0 else ("down" if b < 0 else "stable"),
"confidence": "high" if len(points) >= 6 else ("medium" if len(points) >= 4 else "low"),
"data_points": len(points),
})
return {
"current_period": period_str,
"next_period": next_period,
"predictions": predictions,
"kpi_count": len(kpis),
"predictable_count": len(predictions),
}
# ── 个人工作台 ──────────────────────────────
@router.get("/my-dashboard")
def my_dashboard(
current_user: User = Depends(require_auth),
db: Session = Depends(get_db),
):
"""个人工作台:返回我的KPI、改善行动、待办提醒"""
username = current_user.username
name = current_user.name
role = current_user.role
# 角色 → 维度映射(从已发布战略地图中按角色筛选对应维度的KPI)
ROLE_DIMENSIONS = {
"ceo": ["finance", "customer", "process", "learning"], # CEO看全部维度
"finance": ["finance"], # 财务看财务维度
"business": ["customer", "process"], # 业务看客户+流程维度
"it": ["process", "learning"], # IT看流程+学习成长
}
role_dims = ROLE_DIMENSIONS.get(role, ["finance", "customer"])
# 获取所有已发布战略地图的KPI code集合(dimensions中引用的)
from app.models import StrategicMap
published_maps = db.query(StrategicMap).filter(StrategicMap.status == "published").all()
map_kpi_codes = set()
for sm in published_maps:
dims = sm.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
continue
for dim in dims:
for obj in dim.get("objectives", []):
for code in obj.get("kpis", []):
map_kpi_codes.add(code)
# 1. 按角色维度筛选(从已发布地图的KPI中取符合角色维度的)
map_kpis = []
if map_kpi_codes:
map_kpis = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_(map_kpi_codes),
KPIDefinition.dimension.in_(role_dims),
KPIDefinition.status == "active",
).all()
# 2. 补充负责的KPIresponsible_user匹配)
assigned_kpis = db.query(KPIDefinition).filter(
or_(
KPIDefinition.responsible_user == username,
KPIDefinition.responsible_user == name,
),
KPIDefinition.status == "active",
).all()
assigned_ids = {k.id for k in assigned_kpis}
# 去重合并
all_kpis = map_kpis + [k for k in assigned_kpis if k.id not in {mk.id for mk in map_kpis}]
kpi_list = []
for k in all_kpis:
latest_v = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id
).order_by(KPIValue.calculated_at.desc()).first()
actual = latest_v.actual_value if latest_v else None
target = k.target_value
level = "gray"
if actual is not None and target:
ratio = actual / target
level = "green" if ratio >= 0.9 else ("yellow" if ratio >= 0.7 else "red")
kpi_list.append({
"id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"category": k.category,
"target_value": target,
"actual_value": actual,
"unit": k.unit,
"level": level,
"period": latest_v.period if latest_v else None,
})
# 2. 我的改善行动(assignee匹配)
from app.models import ActionPlan
my_plans = db.query(ActionPlan).filter(
or_(
ActionPlan.assignee == username,
ActionPlan.assignee == name,
)
).order_by(ActionPlan.updated_at.desc()).all()
plan_list = []
for p in my_plans:
overdue = False
if p.due_date and p.status not in ("completed", "cancelled"):
overdue = p.due_date < datetime.now()
kpi_name = ""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
if kpi:
kpi_name = kpi.kpi_name
plan_list.append({
"id": p.id,
"kpi_id": p.kpi_id,
"kpi_name": kpi_name,
"title": p.title,
"assignee": p.assignee,
"priority": p.priority,
"status": p.status,
"progress": p.progress or 0,
"due_date": p.due_date.isoformat() if p.due_date else None,
"overdue": overdue,
"created_at": p.created_at.isoformat() if p.created_at else None,
})
# 3. 待办提醒
reminders = []
# 逾期行动
for p in plan_list:
if p["overdue"]:
reminders.append({
"type": "overdue_plan",
"severity": "danger",
"message": f"你负责的「{p['title']}」已逾期",
"related_id": p["id"],
"related_type": "action_plan",
})
# 红色预警KPI
for k in kpi_list:
if k["level"] == "red":
reminders.append({
"type": "red_kpi",
"severity": "danger",
"message": f"你负责的KPI「{k['kpi_name']}」处于红色预警",
"related_id": k["id"],
"related_type": "kpi",
})
# 黄色预警KPI
for k in kpi_list:
if k["level"] == "yellow":
reminders.append({
"type": "yellow_kpi",
"severity": "warning",
"message": f"你负责的KPI「{k['kpi_name']}」处于黄色预警",
"related_id": k["id"],
"related_type": "kpi",
})
return {
"kpis": kpi_list,
"action_plans": plan_list,
"reminders": reminders,
}
@router.get("/erp-trends")
def get_erp_trends(
current_user: User = Depends(require_auth),
months: int = Query(12, ge=3, le=36),
db: Session = Depends(get_db),
):
"""获取ERP关键指标趋势数据(驾驶舱趋势分析用)"""
codes = [
"F_REVENUE",
"F_PROFIT_RATE",
"F_NET_PROFIT_RATE",
"F_COST_RATIO",
"F_CASH_FLOW",
"F_AR_TURNOVER",
"F_ROE",
"F_ASSET_TURNOVER",
"F_DEBT_RATIO",
"C_CUSTOMER_COUNT",
"C_CUSTOMER_SATISFACTION",
"C_CUSTOMER_CONCENTRATION",
"P_DELIVERY_ON_TIME",
"P_DEFECT_RATE",
"P_SUPPLY_CYCLE",
"L_TRAINING_HOURS",
"L_EMPLOYEE_TURNOVER",
"L_INNOVATION_COUNT",
"L_TECH_COVERAGE",
]
result = {}
for code in codes:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi:
continue
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
).order_by(KPIValue.period.desc()).limit(months).all()
trend = [{"period": v.period, "value": v.actual_value} for v in reversed(values)]
if trend:
vals = [v["value"] for v in trend if v["value"] is not None]
latest = vals[-1] if vals else 0
first = vals[0] if vals else 0
if latest > first * 1.05:
trend_dir = "up"
elif latest < first * 0.95:
trend_dir = "down"
else:
trend_dir = "stable"
mom_val = vals[-2] if len(vals) >= 2 else None
yoy_val = vals[-12] if len(vals) >= 12 else (vals[0] if len(vals) >= 1 else None)
result[code] = {
"name": kpi.kpi_name,
"unit": kpi.unit or "",
"target": kpi.target_value,
"trend": trend,
"trend_dir": trend_dir,
"latest": latest,
"mom": mom_val,
"mom_rate": round((latest - mom_val) / abs(mom_val) * 100, 1) if mom_val and mom_val != 0 else None,
"yoy": yoy_val,
"yoy_rate": round((latest - yoy_val) / abs(yoy_val) * 100, 1) if yoy_val and yoy_val != 0 else None,
}
return {"data": result}
@router.get("/dupont")
async def dupont_analysis(
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""杜邦分析 — ROE分解
ROE = 净利率 × 资产周转率 × 权益乘数
"""
cache_key = f"dupont:{current_user.role}"
cached = cache_get("dashboard", cache_key)
if cached:
return cached
# 获取底层数据KPI
def get_kpi_value(code: str) -> tuple:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if not kpi:
return None, None, None
latest = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).first()
prev = db.query(KPIValue).filter(KPIValue.kpi_id == kpi.id).order_by(KPIValue.period.desc()).offset(1).first()
val = latest.actual_value if latest else None
pval = prev.actual_value if prev else None
return val, pval, kpi.unit
# 营收、利润、总资产、净资产
revenue, prev_revenue, _ = get_kpi_value("F_REVENUE")
# 用营收×净利润率估算净利润(数据库没有净利润绝对值)
profit_net = None
prev_profit_net = None
if revenue:
net_profit_rate, prev_npr, _ = get_kpi_value("F_NET_PROFIT_RATE")
if net_profit_rate:
profit_net = revenue * (net_profit_rate / 100)
if prev_revenue and prev_npr:
prev_profit_net = prev_revenue * (prev_npr / 100)
# 如果还是算不出来,用毛利率做替代估算
if profit_net is None and revenue:
gross_profit, _, _ = get_kpi_value("F_PROFIT_RATE")
profit_net = revenue * (gross_profit / 100) * 0.7 if gross_profit else None # 粗略估算净利润=毛利*0.7
asset_total, prev_asset, _ = get_kpi_value("F_ASSET_TOTAL")
equity_total, prev_equity, _ = get_kpi_value("F_EQUITY_TOTAL")
# 计算杜邦因子
result = {"roe": None, "factors": {}, "raw_data": {}, "history": {}}
if revenue and profit_net and asset_total and equity_total and all(v > 0 for v in [revenue, asset_total, equity_total]):
net_profit_margin = round(profit_net / revenue, 4) # 净利率
asset_turnover = round(revenue / asset_total, 4) # 资产周转率
equity_multiplier = round(asset_total / equity_total, 4) # 权益乘数
roe = round(net_profit_margin * asset_turnover * equity_multiplier * 100, 2)
result["roe"] = roe
result["factors"] = {
"net_profit_margin": {"value": net_profit_margin, "label": "净利率", "desc": f"净利润/{'营收' if revenue else '-'} = {net_profit_margin*100:.2f}%"},
"asset_turnover": {"value": asset_turnover, "label": "资产周转率", "desc": f"营收/总资产 = {asset_turnover:.4f}次"},
"equity_multiplier": {"value": equity_multiplier, "label": "权益乘数", "desc": f"总资产/净资产 = {equity_multiplier:.4f}"},
}
result["raw_data"] = {
"revenue": revenue,
"profit_net": profit_net,
"asset_total": asset_total,
"equity_total": equity_total,
}
# 环比计算
if prev_revenue and prev_profit_net and prev_asset and prev_equity and all(v > 0 for v in [prev_revenue, prev_asset, prev_equity]):
prev_npm = round(prev_profit_net / prev_revenue, 4)
prev_at = round(prev_revenue / prev_asset, 4)
prev_em = round(prev_asset / prev_equity, 4)
prev_roe = round(prev_npm * prev_at * prev_em * 100, 2)
result["history"]["prev"] = {
"roe": prev_roe,
"net_profit_margin": prev_npm,
"asset_turnover": prev_at,
"equity_multiplier": prev_em,
}
# 同比变化
change = round(roe - prev_roe, 2)
npm_change = round((net_profit_margin - prev_npm) * 10000, 2) # 转成BP
at_change = round(asset_turnover - prev_at, 4)
em_change = round(equity_multiplier - prev_em, 4)
result["history"]["change"] = {
"roe": change,
"roe_label": f"{'+' if change > 0 else ''}{change}%",
"net_profit_margin_bp": npm_change,
"asset_turnover": at_change,
"equity_multiplier": em_change,
}
result["history"]["trend"] = "up" if change > 0 else ("down" if change < 0 else "stable")
# 补上原始数据(即使计算不全也返回给前端展示)
if not result.get("raw_data"):
result["raw_data"] = {
"revenue": revenue,
"profit_net": profit_net,
"asset_total": asset_total,
"equity_total": equity_total,
}
cache_set("dashboard", cache_key, result, ttl_seconds=300)
return result
def _get_kpi_trend(kpi_id: int, db: Session) -> dict:
"""计算KPI的环比和同比趋势"""
from datetime import datetime
now = datetime.now()
cur_period = now.strftime("%Y-%m")
# 上月
if now.month == 1:
prev_month = f"{now.year-1}-12"
else:
prev_month = f"{now.year}-{now.month-1:02d}"
# 去年同期
last_year = f"{now.year-1}-{now.month:02d}"
cur_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == cur_period
).order_by(KPIValue.id.desc()).first()
prev_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == prev_month
).order_by(KPIValue.id.desc()).first()
yoy_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == last_year
).order_by(KPIValue.id.desc()).first()
def calc_rate(curr, prev):
if curr and prev and prev.actual_value and prev.actual_value != 0:
return round((curr.actual_value - prev.actual_value) / prev.actual_value * 100, 2)
return None
return {
"current_value": cur_val.actual_value if cur_val else None,
"current_period": cur_period,
"mom_value": prev_val.actual_value if prev_val else None,
"mom_rate": calc_rate(cur_val, prev_val),
"yoy_value": yoy_val.actual_value if yoy_val else None,
"yoy_rate": None if not yoy_val else calc_rate(cur_val, yoy_val),
}
@router.get("/kpis/enhanced")
def get_kpis_enhanced(role: str = Query("ceo"), period: str = Query("month"),
start_date: str = None, end_date: str = None,
db: Session = Depends(get_db)):
"""增强版KPI列表(带趋势)"""
result = get_dashboard_kpis(role=role, period=period, start_date=start_date, end_date=end_date, db=db)
if "data" in result and result["data"]:
for kpi in result["data"]:
if kpi.get("id"):
trend = _get_kpi_trend(kpi["id"], db)
kpi["trend"] = trend
return result
@router.get("/trend-analysis")
def get_trend_analysis(kpi_ids: str = Query(""), period: str = Query("month"),
db: Session = Depends(get_db)):
"""多KPI趋势对比(折线图数据)"""
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
if not ids:
return {"data": []}
result = []
for kpi_id in ids:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
continue
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id
).order_by(KPIValue.period).all()
series = []
for v in values:
if v.actual_value is not None:
series.append({
"period": v.period,
"value": v.actual_value,
})
result.append({
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit,
"target": kpi.target_value,
"data": series,
})
return {"data": result}
@router.get("/alert-stats")
def get_alert_stats(period: str = Query("month"), db: Session = Depends(get_db)):
"""预警统计(按等级和维度)"""
from sqlalchemy import func
# 按等级统计
by_level = db.query(
KPIAlert.alert_level,
func.count(KPIAlert.id)
).group_by(KPIAlert.alert_level).all()
level_stats = {row[0]: row[1] for row in by_level}
# 按维度统计
by_dim = db.query(
KPIDefinition.dimension,
func.count(KPIAlert.id)
).join(KPIAlert, KPIDefinition.id == KPIAlert.kpi_id
).group_by(KPIDefinition.dimension).all()
dim_stats = {row[0]: row[1] for row in by_dim}
return {
"by_level": level_stats,
"by_dimension": dim_stats,
"total": sum(level_stats.values()) if level_stats else 0,
}
@router.get("/export")
def export_kpi_data(kpi_ids: str = "", db: Session = Depends(get_db)):
"""导出KPI数据为CSV格式"""
from fastapi.responses import PlainTextResponse
ids = [int(x) for x in kpi_ids.split(",") if x.strip().isdigit()]
query = db.query(KPIValue).join(KPIDefinition, KPIValue.kpi_id == KPIDefinition.id)
if ids:
query = query.filter(KPIValue.kpi_id.in_(ids))
rows = query.order_by(KPIDefinition.kpi_code, KPIValue.period).all()
csv_lines = ["KPI编码,KPI名称,期间,实际值,目标值,来源,状态"]
for r in rows:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
csv_lines.append(f"{kpi.kpi_code},{kpi.kpi_name},{r.period},{r.actual_value},{kpi.target_value},{r.source_type},{r.data_status}")
return PlainTextResponse("\n".join(csv_lines), media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=kpi_export.csv"})
+337 -20
View File
@@ -1,55 +1,326 @@
"""数据对接 API"""
import pandas as pd
import io, json, hashlib
import io, json, hashlib, re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIValue, DataSourceConfig, OperationLog
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.post("/import-excel")
async def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)):
async def import_excel(file: UploadFile = File(...),
kpi_col: str = Query("kpi_code", description="Excel中KPI编码列名"),
period_col: str = Query("period", description="Excel中期间列名"),
value_col: str = Query("actual_value", description="Excel中实际值列名"),
default_period: str = Query(None, description="如文件无期间列,统一使用此值"),
db: Session = Depends(get_db)):
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
required = [kpi_col, value_col]
if not default_period:
required.append(period_col)
missing = [c for c in required if c not in df.columns]
if missing:
raise HTTPException(400,
f"Excel缺少列: {missing}。当前文件列: {list(df.columns)}")
if len(df) == 0:
raise HTTPException(400, "Excel文件为空,没有数据行")
required = ["kpi_code", "period", "actual_value"]
if not all(c in df.columns for c in required):
raise HTTPException(400, f"Excel必须包含列: {required}")
from app.models import KPIDefinition
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).all()}
kpi_entity_map = {k.kpi_code: k.entity_id for k in db.query(KPIDefinition).all()} # 账套隔离 P2
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
count = 0
for _, row in df.iterrows():
kpi_code = str(row.get("kpi_code", ""))
period = str(row.get("period", ""))
value = row.get("actual_value")
skipped = []
for idx, row in df.iterrows():
kpi_code = str(row.get(kpi_col, "")).strip()
period = str(row.get(period_col, default_period or "")).strip() if period_col in df.columns else (default_period or "").strip()
value = row.get(value_col)
if not kpi_code or not period or pd.isna(value):
skipped.append(f"{idx+2}行: 缺少必填字段")
continue
from app.models import KPIDefinition
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi:
kid = kpi_map.get(kpi_code)
if not kid:
skipped.append(f"{idx+2}行: KPI编码「{kpi_code}」不存在")
continue
kv = KPIValue(
kpi_id=kpi.id,
db.add(KPIValue(
kpi_id=kid,
entity_id=kpi_entity_map.get(kpi_code),
period=period,
actual_value=float(value),
source_type="excel",
source_batch=batch,
data_status="pending",
)
db.add(kv)
data_status="verified",
))
count += 1
db.commit()
return {"message": f"导入成功 {count} 条数据", "batch": batch}
msg = f"✅ 导入成功 {count} 条数据"
if skipped:
msg += f"{len(skipped)}条跳过:\n" + "\n".join(skipped[:10])
if len(skipped) > 10:
msg += f"\n...还有{len(skipped)-10}"
return {"message": msg, "batch": batch, "total": count, "skipped": len(skipped)}
# ── 智能导入(BOT自动识别,无需手动映射) ──
_SMART_MAP = {
# KPI名称/编码列匹配模式(顺序重要:名称类列优先,避免科目编码被当名称)
"kpi_code_patterns": [
re.compile(r'^(科目名称|项目名称|指标名称?|kpi名称?|name|名称)$', re.I),
re.compile(r'^(科目编码|科目代码|kpi_?code|指标编码|编码)$', re.I),
re.compile(r'^(科目|项目|账户|报表项目)$'),
],
# 期间列匹配
"period_patterns": [
re.compile(r'^(period|期间|月份?|年月|日期|会计期间)$', re.I),
re.compile(r'^(报表期[间]?|所属期)$'),
re.compile(r'^年|^月'), # 以"年"或"月"开头的列
],
# 数值列匹配
"value_patterns": [
re.compile(r'^(actual_?value|数值|实际值|实际金额)$', re.I),
re.compile(r'^(本期金额|本月数|本期|期末余额|期末数|余额)$'),
re.compile(r'^(金额|数据|value|本年累计|本期发生[额]?)$', re.I),
],
# 文件名→期间提取
"period_in_filename_patterns": [
re.compile(r'[-_]?(\d{4})[-_]?(\d{1,2})'), # 2026-06 / 2026_06 / 2606
re.compile(r'(\d{4})年(\d{1,2})月(?:至(\d{4})年(\d{1,2})月)?'), # 2026年01月 / 2026年01月至2026年05月
re.compile(r'(\d{4})(\d{2})'), # 202606 (纯数字6-8位)
],
# 文件名→报表类型
"statement_types": {
"利润表": "PL",
"利润": "PL",
"income": "PL",
"现金流量表": "CF",
"现金流": "CF",
"cashflow": "CF",
"cash_flow": "CF",
"资产负债表": "BS",
"资产负": "BS",
"balance": "BS",
},
}
def _smart_detect_kpi_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["kpi_code_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_detect_period_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["period_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_detect_value_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["value_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_extract_period_from_filename(filename: str) -> str | None:
for pat in _SMART_MAP["period_in_filename_patterns"]:
m = pat.search(filename)
if m:
groups = m.groups()
if len(groups) == 4 and groups[2]: # 2026年01月至2026年05月 → 取结束月
return f"{groups[2]}-{int(groups[3]):02d}"
if len(groups) >= 2: # 2026-06 或 2026年01月
return f"{int(groups[0])}-{int(groups[1]):02d}"
return None
def _smart_detect_statement_type(filename: str) -> str | None:
for kw, tp in _SMART_MAP["statement_types"].items():
if kw in filename:
return tp
return None
@router.post("/import-excel-smart")
async def import_excel_smart(
file: UploadFile = File(...),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""智能导入 — BOT自动识别列名/期间/报表类型,无需手动映射"""
content = await file.read()
fname = file.filename or "未知文件"
try:
df = pd.read_excel(io.BytesIO(content))
except Exception as e:
raise HTTPException(400, f"无法读取Excel文件: {e}")
if len(df) == 0:
raise HTTPException(400, "Excel文件为空")
cols = list(df.columns)
if len(cols) < 2:
raise HTTPException(400, f"Excel列数过少: {cols}")
# 4. 智能检测列
kpi_col = _smart_detect_kpi_col(cols) or cols[0]
value_col = _smart_detect_value_col(cols) or cols[-1]
period_col = _smart_detect_period_col(cols)
# 5. 从文件名提取期间
period = _smart_extract_period_from_filename(fname) if not period_col else None
# 6. 检测报表类型(用于自动生成KPI编码前缀)
stype = _smart_detect_statement_type(fname)
# 7. 预加载KPI字典
from app.models import KPIDefinition
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).all()}
known_codes = set(kpis.keys())
# 构建别名映射(去掉空格/大小写/特殊字符)
alias_map: dict[str, str] = {}
for code in known_codes:
clean = re.sub(r'[\s\-_()()]', '', code).lower()
alias_map[clean] = code
# 中文名映射("营业收入"→F_REVENUE
name_map: dict[str, str] = {}
for code, kpi_obj in kpis.items():
name_map[kpi_obj.kpi_name] = code
# 8. 遍历导入(匹配不上的自动创建KPI)
stype_prefix = {"PL": "PL_", "CF": "CF_", "BS": "BS_"}.get(stype or "", "EXT_")
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
imported = 0
created_kpis = 0
skipped_rows = []
for idx, row in df.iterrows():
raw_kpi_raw = row.get(kpi_col, "")
raw_kpi = str(raw_kpi_raw).strip()
raw_val = row.get(value_col)
raw_period = str(row.get(period_col, period or "")).strip() if period_col else (period or "")
# 名称防护:NaN/空/None 或 纯数字(疑似科目编码被误当名称)→ 跳过,避免创建垃圾KPI
if raw_kpi.lower() in ("nan", "none") or not raw_kpi:
skipped_rows.append(f"{idx+2}行: KPI名称为空")
continue
if re.fullmatch(r"\d+(\.\d+)?", raw_kpi):
skipped_rows.append(f"{idx+2}行: KPI名称疑似科目编码「{raw_kpi}」,跳过")
continue
if pd.isna(raw_val):
skipped_rows.append(f"{idx+2}行: 缺数据")
continue
if not raw_period:
skipped_rows.append(f"{idx+2}行: 无法确定期间")
continue
# 清理科目名(去掉"一、""减:""加:"等前缀)
clean_name = re.sub(r'^[一二三四五六七八九十、\s\+]+', '', raw_kpi)
clean_name = re.sub(r'^[减加]?[:]\s*', '', clean_name).strip()
if not clean_name:
clean_name = raw_kpi
# 匹配KPI
kpi_code = None
# ① 精确编码匹配(极少情况)
if raw_kpi in known_codes:
kpi_code = raw_kpi
# ② 别名匹配(去符号小写)
if not kpi_code:
clean_key = re.sub(r'[\s\-_()()]', '', raw_kpi).lower()
kpi_code = alias_map.get(clean_key)
# ③ 中文名精确匹配
if not kpi_code:
kpi_code = name_map.get(clean_name)
# ④ 中文名模糊匹配
if not kpi_code:
for code, kpi_obj in kpis.items():
if clean_name in kpi_obj.kpi_name or kpi_obj.kpi_name in clean_name:
kpi_code = code
break
# ⑤ 仍未匹配 → 自动创建KPI
if not kpi_code:
new_code = f"{stype_prefix}{len(kpis) + created_kpis + 1:03d}"
new_kpi = KPIDefinition(
entity_id=entity_id,
kpi_code=new_code,
kpi_name=clean_name,
dimension="finance",
category="financial_report",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="",
target_value=0,
kpi_level="operational",
status="active",
)
db.add(new_kpi)
db.flush()
kpis[new_code] = new_kpi
known_codes.add(new_code)
name_map[clean_name] = new_code
kpi_code = new_code
created_kpis += 1
try:
val = float(raw_val)
except:
skipped_rows.append(f"{idx+2}行: 数值格式错误「{raw_val}")
continue
db.add(KPIValue(
kpi_id=kpis[kpi_code].id,
entity_id=kpis[kpi_code].entity_id, # 账套隔离 P2
period=raw_period,
actual_value=val,
source_type="excel",
source_batch=batch,
data_status="verified",
))
imported += 1
db.commit()
# 9. 返回汇总
stype_label = {"PL": "利润表", "CF": "现金流量表", "BS": "资产负债表"}.get(stype or "", "数据表")
msg = f"{stype_label}识别成功,导入{imported}"
if created_kpis:
msg += f",自动创建{created_kpis}个新KPI"
if skipped_rows:
msg += f"{len(skipped_rows)}条跳过:\n" + "\n".join(skipped_rows[:8])
if len(skipped_rows) > 8:
msg += f"\n...还有{len(skipped_rows) - 8}"
return {"message": msg, "batch": batch, "total": imported, "skipped": len(skipped_rows)}
@router.get("/sources")
def list_sources(db: Session = Depends(get_db)):
@@ -98,3 +369,49 @@ def delete_source(source_id: int, db: Session = Depends(get_db)):
db.delete(source)
db.commit()
return {"message": "删除成功"}
@router.get("/sync-kpis")
def sync_kpis_from_erp(db: Session = Depends(get_db)):
"""从ERP数据源同步KPI值(调用erp_sync模块)"""
from scripts.erp_sync import run_sync
import traceback
from datetime import datetime as dt
try:
# 获取所有标记为erp数据源的KPI
erp_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.data_source_type == "erp",
).all()
kpi_count = len(erp_kpis)
kpi_codes = [k.kpi_code for k in erp_kpis]
# 执行同步 (dry_run=False, use_api=False 使用本地fallback)
run_sync(dry_run=False, kpi_codes=kpi_codes, use_api=False)
# 记录操作日志
log = OperationLog(
action="sync_kpis",
target_type="kpi",
detail=f"ERP同步: {kpi_count}个KPI, 编码: {', '.join(kpi_codes[:10])}{'...' if kpi_count > 10 else ''}",
)
db.add(log)
db.commit()
return {
"message": f"ERP数据同步完成",
"total_kpis": kpi_count,
"kpi_codes": kpi_codes,
"synced_at": dt.now().isoformat(),
}
except Exception as e:
log = OperationLog(
action="sync_kpis_error",
target_type="kpi",
detail=f"ERP同步失败: {str(e)[:500]}",
)
db.add(log)
db.commit()
raise HTTPException(500, f"ERP同步失败: {str(e)}")
+387
View File
@@ -0,0 +1,387 @@
"""数据分类分级 — 重要数据标记(2026-08-26 政策驱动)
背景网络数据安全风险评估办法2026-08-20 三部门施行
- 重要数据处理者每年一次强制评估
- 评估前提 = 先分类分级"不知道重要数据在哪,评估无从谈起"
- 本模块 = "分类分级工具"第一版标记+清单不做评估算法
数据级别参考 GB/T 43697 简化
- core 核心数据一旦遭篡改/破坏/泄露直接危害国家安全/经济运行
- important 重要数据一旦遭篡改/破坏/泄露危害公共利益/企业关键业务
- general 一般数据其余
覆盖对象
- subjects 会计科目全局 entity_id与现有科目管理一致
- kpi_definitions KPI字典 entity_id 多租户隔离
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from typing import Optional, List
from datetime import datetime
import csv
import io
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role, require_auth
from app.models import Subject, KPIDefinition, Entity
router = APIRouter(prefix="/api/cma/data-classification", tags=["数据分类分级"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# 写操作只允许 ceo/finance/it(与 KPI 字典一致)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
VALID_LEVELS = {"core", "important", "general"}
LEVEL_LABELS = {
"core": "核心数据",
"important": "重要数据",
"general": "一般数据",
}
# 行业参考目录(内置提示,可按行业自定义扩展)
INDUSTRY_REFERENCE = [
{"industry": "通用/企业服务", "category": "客户数据", "suggest_level": "important",
"desc": "客户名单、合同、订单、应收账款等客户经营数据"},
{"industry": "通用/企业服务", "category": "财务数据", "suggest_level": "important",
"desc": "财务报表、银行账户、税务申报、薪酬数据"},
{"industry": "通用/企业服务", "category": "员工数据", "suggest_level": "important",
"desc": "员工身份、薪酬、社保、考勤、健康信息"},
{"industry": "金融", "category": "账户交易", "suggest_level": "core",
"desc": "客户账户、交易流水、信贷记录(金融行业核心数据)"},
{"industry": "医疗", "category": "健康医疗", "suggest_level": "core",
"desc": "病历、健康档案、基因数据(医疗行业核心数据)"},
{"industry": "工业", "category": "关键基础设施", "suggest_level": "important",
"desc": "生产控制、工艺参数、供应链关键环节数据"},
{"industry": "互联网", "category": "用户个人信息", "suggest_level": "important",
"desc": "个人信息、日志、位置数据(万人以上规模需重点评估)"},
{"industry": "能源", "category": "能源数据", "suggest_level": "important",
"desc": "能源生产、传输、消费监测数据"},
]
def _subject_to_item(s: Subject) -> dict:
return {
"type": "subject",
"id": s.id,
"code": s.subject_code,
"name": s.subject_name,
"dimension": None,
"category": s.new_standard_category or s.category or "",
"important_flag": 1 if s.important_flag else 0,
"data_level": s.data_level or "general",
"data_category": s.data_category or "",
"data_owner": None,
"storage": "会计科目台账",
}
def _kpi_to_item(k: KPIDefinition) -> dict:
return {
"type": "kpi",
"id": k.id,
"code": k.kpi_code,
"name": k.kpi_name,
"dimension": k.dimension,
"category": k.category or "",
"important_flag": 1 if k.important_flag else 0,
"data_level": k.data_level or "general",
"data_category": k.data_category or "",
"data_owner": k.data_owner,
"storage": f"KPI台账(entity_id={k.entity_id})",
}
def _query_items(
data_type: Optional[str],
data_level: Optional[str],
important: Optional[int],
keyword: Optional[str],
entity_id: int,
db: Session,
) -> list:
"""内部清单查询(供 inventory/export 复用,避免依赖注入问题)"""
items: list[dict] = []
if not data_type or data_type == "subject":
q = db.query(Subject).filter(Subject.is_active == 1)
if data_level:
q = q.filter(Subject.data_level == data_level)
if important == 1:
q = q.filter(Subject.important_flag == 1)
if keyword:
like = f"%{keyword}%"
q = q.filter(
Subject.subject_name.like(like) | Subject.subject_code.like(like)
)
for s in q.order_by(Subject.subject_code).all():
items.append(_subject_to_item(s))
if not data_type or data_type == "kpi":
q = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
)
if data_level:
q = q.filter(KPIDefinition.data_level == data_level)
if important == 1:
q = q.filter(KPIDefinition.important_flag == 1)
if keyword:
like = f"%{keyword}%"
q = q.filter(
KPIDefinition.kpi_name.like(like) | KPIDefinition.kpi_code.like(like)
)
for k in q.order_by(KPIDefinition.kpi_code).all():
items.append(_kpi_to_item(k))
return items
def _stats_of(items: list) -> dict:
return {
"total": len(items),
"marked": sum(1 for i in items if i["important_flag"] == 1),
"by_level": {
"core": sum(1 for i in items if i["data_level"] == "core"),
"important": sum(1 for i in items if i["data_level"] == "important"),
"general": sum(1 for i in items if i["data_level"] == "general"),
},
"by_type": {
"subject": sum(1 for i in items if i["type"] == "subject"),
"kpi": sum(1 for i in items if i["type"] == "kpi"),
},
}
@router.get("/inventory")
def get_inventory(
data_type: Optional[str] = Query(None, description="subject/kpi,缺省返回全部"),
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
keyword: Optional[str] = Query(None, description="编码/名称搜索"),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""数据资产清单(科目 + KPI 合并输出,含重要级别)"""
if data_level and data_level not in VALID_LEVELS:
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
items = _query_items(data_type, data_level, important, keyword, entity_id, db)
return {"total": len(items), "items": items, "stats": _stats_of(items)}
@router.get("/stats")
def get_stats(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""数据分类分级统计概览(评估前准备看板)"""
subject_total = db.query(Subject).filter(Subject.is_active == 1).count()
subject_marked = db.query(Subject).filter(
Subject.is_active == 1, Subject.important_flag == 1
).count()
kpi_total = db.query(KPIDefinition).filter(
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
).count()
kpi_marked = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
KPIDefinition.important_flag == 1,
).count()
ent = db.query(Entity).filter(Entity.id == entity_id).first()
return {
"entity_id": entity_id,
"entity_name": ent.name if ent else "",
"subjects": {"total": subject_total, "marked": subject_marked},
"kpis": {"total": kpi_total, "marked": kpi_marked},
"total": subject_total + kpi_total,
"marked_total": subject_marked + kpi_marked,
"coverage_pct": round((subject_marked + kpi_marked) / (subject_total + kpi_total) * 100, 1)
if (subject_total + kpi_total) else 0,
"policy_note": "《网络数据安全风险评估办法》(2026-08-20施行):重要数据处理者每年一次强制评估,评估前提=先完成数据分类分级",
}
@router.put("/subjects/{subject_id}")
def mark_subject(
subject_id: int,
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
data_level: Optional[str] = Query(None, description="core/important/general"),
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""标记单个科目:重要数据标记 + 数据分级 + 行业分类"""
subject = db.query(Subject).filter(Subject.id == subject_id).first()
if not subject:
raise HTTPException(404, "科目不存在")
if data_level is not None and data_level not in VALID_LEVELS:
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
if important_flag is not None:
subject.important_flag = 1 if important_flag else 0
if data_level is not None:
subject.data_level = data_level
if data_level in ("core", "important"):
subject.important_flag = 1 # 核心/重要级别自动视为重要数据
if data_category is not None:
subject.data_category = data_category or None
db.commit()
return {
"message": "更新成功",
"subject_id": subject_id,
"important_flag": subject.important_flag,
"data_level": subject.data_level,
"data_category": subject.data_category,
}
@router.put("/kpis/{kpi_id}")
def mark_kpi(
kpi_id: int,
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
data_level: Optional[str] = Query(None, description="core/important/general"),
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""标记单个KPI:重要数据标记 + 数据分级 + 行业分类(按账套隔离)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在或不属于当前账套")
if data_level is not None and data_level not in VALID_LEVELS:
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
if important_flag is not None:
kpi.important_flag = 1 if important_flag else 0
if data_level is not None:
kpi.data_level = data_level
if data_level in ("core", "important"):
kpi.important_flag = 1 # 核心/重要级别自动视为重要数据
if data_category is not None:
kpi.data_category = data_category or None
db.commit()
return {
"message": "更新成功",
"kpi_id": kpi_id,
"important_flag": kpi.important_flag,
"data_level": kpi.data_level,
"data_category": kpi.data_category,
}
@router.put("/batch")
def batch_mark(
data_type: str = Query(..., description="subject/kpi"),
ids: List[int] = Query(..., description="ID列表"),
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
data_level: Optional[str] = Query(None, description="core/important/general"),
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""批量标记(科目全局 / KPI按账套隔离)"""
if data_type not in ("subject", "kpi"):
raise HTTPException(400, f"无效的数据类型: {data_type},可选 subject/kpi")
if data_level is not None and data_level not in VALID_LEVELS:
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
if not ids:
raise HTTPException(400, "ids 不能为空")
updated = 0
if data_type == "subject":
q = db.query(Subject).filter(Subject.id.in_(ids), Subject.is_active == 1)
targets = q.all()
for s in targets:
if important_flag is not None:
s.important_flag = 1 if important_flag else 0
if data_level is not None:
s.data_level = data_level
if data_level in ("core", "important"):
s.important_flag = 1
if data_category is not None:
s.data_category = data_category or None
updated += 1
else:
q = db.query(KPIDefinition).filter(
KPIDefinition.id.in_(ids),
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
)
targets = q.all()
for k in targets:
if important_flag is not None:
k.important_flag = 1 if important_flag else 0
if data_level is not None:
k.data_level = data_level
if data_level in ("core", "important"):
k.important_flag = 1
if data_category is not None:
k.data_category = data_category or None
updated += 1
db.commit()
return {"message": f"批量更新成功", "data_type": data_type, "updated_count": updated}
@router.get("/export")
def export_csv(
data_type: Optional[str] = Query(None, description="subject/kpi,缺省全部"),
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""导出数据分类分级清单(评估前准备材料)CSV"""
if data_level and data_level not in VALID_LEVELS:
raise HTTPException(400, f"无效的数据级别: {data_level}")
items = _query_items(data_type, data_level, important, None, entity_id, db)
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["类型", "编码", "名称", "维度", "分类", "重要标记", "数据级别", "行业参考分类", "数据责任人", "存储位置"])
for i in items:
writer.writerow([
"科目" if i["type"] == "subject" else "KPI",
i["code"],
i["name"],
i["dimension"] or "",
i["category"] or "",
"" if i["important_flag"] else "",
LEVEL_LABELS.get(i["data_level"], i["data_level"]),
i["data_category"] or "",
i["data_owner"] or "",
i["storage"] or "",
])
content = "\ufeff" + buf.getvalue() # BOM 兼容 Excel
filename = f"data-classification-inventory_{datetime.now().strftime('%Y%m%d')}.csv"
from starlette.responses import Response
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/industry-reference")
def industry_reference():
"""行业参考目录(内置提示,按行业给出重点数据分类建议)"""
return {"items": INDUSTRY_REFERENCE}
+324
View File
@@ -0,0 +1,324 @@
"""自动数据质量监控 — 任务3
定期检查KPI值异常连续持平数据缺失等
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from typing import Optional
from datetime import datetime, timedelta
import json
import logging
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog
from app.api.kpis import kpi_to_dict
logger = logging.getLogger("data-quality")
router = APIRouter(prefix="/api/cma/data-quality", tags=["数据质量"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
def _log_to_dict(log):
d = {c.name: getattr(log, c.name) for c in log.__table__.columns}
if hasattr(log, 'kpi') and log.kpi:
d["kpi_code"] = log.kpi.kpi_code
d["kpi_name"] = log.kpi.kpi_name
return d
# ============================================================
# 质量检查
# ============================================================
@router.get("/check")
def run_quality_check(db: Session = Depends(get_db)):
"""扫描全部KPI,生成数据质量报告"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
issues = []
current_period = datetime.now().strftime("%Y-%m")
for kpi in kpis:
# 获取最近12个月的值
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(12).all()
# 1. 检查数据缺失
if not values:
issues.append({
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
"check_type": "missing_data",
"severity": "critical",
"detail": {"missing_months": 12, "latest_period": None, "total_values": 0},
"suggestion": "请初始化KPI数据,建议导入至少3个月历史数据",
})
continue
latest_val = values[0]
latest_period = latest_val.period
# 计算缺失月数
if latest_period:
try:
lp_parts = latest_period.split("-")
lp_date = datetime(int(lp_parts[0]), int(lp_parts[1]), 1)
now_date = datetime.now().replace(day=1)
missing_months = max(0, (now_date.year - lp_date.year) * 12 + (now_date.month - lp_date.month) - 1)
if missing_months > 1:
issues.append({
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
"check_type": "missing_data",
"severity": "warning" if missing_months <= 3 else "critical",
"detail": {"missing_months": missing_months, "latest_period": latest_period, "total_values": len(values)},
"suggestion": f"数据缺失{missing_months}个月,建议从ERP系统同步或手动补录",
})
except Exception:
pass
# 2. 检查环比骤变(需要至少2个月的值)
if len(values) >= 2 and latest_val.actual_value:
prev_val = values[1].actual_value
if prev_val and prev_val != 0:
change_pct = abs((latest_val.actual_value - prev_val) / prev_val * 100)
if change_pct > 50:
issues.append({
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
"check_type": "abnormal_change",
"severity": "warning" if change_pct <= 100 else "critical",
"detail": {
"change_pct": round(change_pct, 1),
"current_value": latest_val.actual_value,
"previous_value": prev_val,
"current_period": latest_val.period,
"previous_period": values[1].period,
},
"suggestion": f"环比变化{round(change_pct,1)}%,建议核实数据是否录入错误",
})
# 3. 检查连续3期持平
if len(values) >= 3:
last_3 = [v.actual_value for v in values[:3] if v.actual_value is not None]
if len(last_3) >= 3 and len(set(last_3)) == 1:
issues.append({
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
"check_type": "flat_data",
"severity": "warning",
"detail": {"flat_value": last_3[0], "periods": [v.period for v in values[:3]]},
"suggestion": "连续3期数据完全相同,请确认数据源是否正常更新",
})
# 4. 检查值异常(偏离历史均值超过3倍标准差)
if len(values) >= 4 and latest_val.actual_value:
hist_vals = [v.actual_value for v in values[1:] if v.actual_value is not None]
if len(hist_vals) >= 3:
mean_val = sum(hist_vals) / len(hist_vals)
variance = sum((v - mean_val) ** 2 for v in hist_vals) / len(hist_vals)
stddev = variance ** 0.5 if variance > 0 else mean_val * 0.1
if stddev > 0 and abs(latest_val.actual_value - mean_val) > 3 * stddev:
issues.append({
"kpi_id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name,
"check_type": "value_outlier",
"severity": "warning",
"detail": {
"current_value": latest_val.actual_value,
"mean": round(mean_val, 2),
"stddev": round(stddev, 2),
"z_score": round(abs(latest_val.actual_value - mean_val) / stddev, 2),
},
"suggestion": "当前值偏离历史均值超过3倍标准差,建议核实",
})
# 写入质量日志
created_count = 0
for issue in issues:
existing = db.query(KpiDataQualityLog).filter(
KpiDataQualityLog.kpi_id == issue["kpi_id"],
KpiDataQualityLog.check_type == issue["check_type"],
KpiDataQualityLog.status == "open",
).first()
if not existing:
log = KpiDataQualityLog(
kpi_id=issue["kpi_id"],
check_type=issue["check_type"],
severity=issue["severity"],
detail=issue["detail"],
suggestion=issue["suggestion"],
status="open",
)
db.add(log)
created_count += 1
db.commit()
return {
"total_kpis": len(kpis),
"issues_found": len(issues),
"new_logs": created_count,
"issues": issues,
}
# ============================================================
# 质量日志CRUD
# ============================================================
@router.get("/logs")
def list_quality_logs(
kpi_id: Optional[int] = None,
severity: Optional[str] = None,
check_type: Optional[str] = None,
status: Optional[str] = None,
db: Session = Depends(get_db),
):
"""获取数据质量日志"""
query = db.query(KpiDataQualityLog)
if kpi_id:
query = query.filter(KpiDataQualityLog.kpi_id == kpi_id)
if severity:
query = query.filter(KpiDataQualityLog.severity == severity)
if check_type:
query = query.filter(KpiDataQualityLog.check_type == check_type)
if status:
query = query.filter(KpiDataQualityLog.status == status)
logs = query.order_by(KpiDataQualityLog.created_at.desc()).limit(100).all()
result = []
for log in logs:
d = _log_to_dict(log)
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == log.kpi_id).first()
if kpi:
d["kpi_code"] = kpi.kpi_code
d["kpi_name"] = kpi.kpi_name
result.append(d)
return {"data": result, "total": len(result)}
@router.put("/logs/{log_id}")
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""更新质量日志(解决/忽略)"""
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
if not log:
raise HTTPException(404, "日志不存在")
if "status" in data:
log.status = data["status"]
if data["status"] == "resolved":
log.resolved_at = datetime.now()
if "suggestion" in data:
log.suggestion = data["suggestion"]
db.commit()
return _log_to_dict(log)
@router.delete("/logs/{log_id}")
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
if log:
db.delete(log)
db.commit()
return {"message": "已删除"}
# ============================================================
# 数据质量看板统计
# ============================================================
@router.get("/stats")
def quality_stats(db: Session = Depends(get_db)):
"""数据质量统计"""
total_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").count()
total_logs = db.query(KpiDataQualityLog).count()
open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count()
# 按严重程度统计
severity_counts = {}
for s in ("info", "warning", "critical"):
cnt = db.query(KpiDataQualityLog).filter(
KpiDataQualityLog.severity == s,
KpiDataQualityLog.status == "open",
).count()
if cnt:
severity_counts[s] = cnt
# 按检查类型统计
type_counts = {}
for t in ("abnormal_change", "flat_data", "missing_data", "value_outlier"):
cnt = db.query(KpiDataQualityLog).filter(
KpiDataQualityLog.check_type == t,
KpiDataQualityLog.status == "open",
).count()
if cnt:
type_counts[t] = cnt
# ── 数据审计看板统计 ──
# KPI完整度评分
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
total = len(all_kpis)
complete_kpis = 0
missing_metadata_count = 0
missing_data_count = 0
stale_data_count = 0
from datetime import datetime, timedelta
six_months_ago = datetime.now() - timedelta(days=180)
for kpi in all_kpis:
# 元数据完整度检查
has_meta = all([
kpi.formula and kpi.formula.strip(),
kpi.data_source and kpi.data_source.strip(),
kpi.data_owner and kpi.data_owner.strip(),
kpi.unit and kpi.unit.strip(),
kpi.target_value is not None,
])
if has_meta:
complete_kpis += 1
else:
missing_metadata_count += 1
# 数据缺失检查(是否有实际值)
val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
).first()
if not val:
missing_data_count += 1
# 超30天未更新预警
latest_val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
if latest_val and latest_val.calculated_at:
if latest_val.calculated_at < six_months_ago:
stale_data_count += 1
completeness_score = round(complete_kpis / total * 100, 1) if total > 0 else 0
missing_rate = round(missing_data_count / total * 100, 1) if total > 0 else 0
return {
"total_kpis": total_kpis,
"total_logs": total_logs,
"open_logs": open_logs,
"severity_counts": severity_counts,
"type_counts": type_counts,
# 数据审计看板
"completeness": {
"score": completeness_score,
"complete": complete_kpis,
"total": total,
"missing_metadata": missing_metadata_count,
},
"data_missing": {
"count": missing_data_count,
"rate": missing_rate,
"total": total,
},
"stale_data": {
"count": stale_data_count,
"threshold_days": 180,
},
}
+141
View File
@@ -0,0 +1,141 @@
"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28)
派生规则配置(budget_derivation_rules)apply-method 派生KPI时优先读规则
percentage_of base_kpi实际值×rateincremental 上月×(1+rate)无规则fallback默认比例
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import BudgetDerivationRule, KPIDefinition
router = APIRouter(
prefix="/api/cma/budget",
tags=["派生规则"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.get("/derivation-rules")
def list_derivation_rules(
kpi_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""派生规则列表(按 entity_id 隔离)"""
query = db.query(BudgetDerivationRule).filter(BudgetDerivationRule.entity_id == entity_id)
if kpi_id:
query = query.filter(BudgetDerivationRule.kpi_id == kpi_id)
if status:
query = query.filter(BudgetDerivationRule.status == status)
rows = query.order_by(BudgetDerivationRule.id.desc()).all()
all_kpi_ids = {r.kpi_id for r in rows} | {r.base_kpi_id for r in rows if r.base_kpi_id}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(all_kpi_ids)).all()} if all_kpi_ids else {}
result = []
for r in rows:
kpi = kpis.get(r.kpi_id)
base = kpis.get(r.base_kpi_id) if r.base_kpi_id else None
result.append({
"id": r.id,
"kpi_id": r.kpi_id,
"kpi_code": kpi.kpi_code if kpi else "",
"kpi_name": kpi.kpi_name if kpi else "",
"rule_type": r.rule_type,
"base_kpi_id": r.base_kpi_id,
"base_kpi_code": base.kpi_code if base else "",
"base_kpi_name": base.kpi_name if base else "",
"params": r.params,
"formula_text": r.formula_text,
"status": r.status,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/derivation-rules")
def create_derivation_rule(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""新建派生规则(同KPI同类型唯一)"""
kpi_id = data.get("kpi_id")
rule_type = data.get("rule_type")
if not kpi_id or rule_type not in ("incremental", "percentage_of", "formula"):
raise HTTPException(400, "需要 kpi_id 且 rule_type ∈ incremental/percentage_of/formula")
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
existing = db.query(BudgetDerivationRule).filter(
BudgetDerivationRule.entity_id == entity_id,
BudgetDerivationRule.kpi_id == kpi_id,
BudgetDerivationRule.rule_type == rule_type,
).first()
if existing:
raise HTTPException(400, f"KPI({kpi_id})已存在 {rule_type} 规则")
row = BudgetDerivationRule(
entity_id=entity_id,
kpi_id=kpi_id,
rule_type=rule_type,
base_kpi_id=data.get("base_kpi_id"),
params=data.get("params"),
formula_text=data.get("formula_text"),
status=data.get("status", "active"),
)
db.add(row)
db.commit()
db.refresh(row)
return {"message": "派生规则已创建", "id": row.id}
@router.put("/derivation-rules/{rule_id}")
def update_derivation_rule(
rule_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""更新派生规则"""
row = db.query(BudgetDerivationRule).filter(
BudgetDerivationRule.id == rule_id,
BudgetDerivationRule.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "规则不存在")
for field in ("rule_type", "base_kpi_id", "params", "formula_text", "status"):
if field in data:
setattr(row, field, data[field])
db.commit()
return {"message": "派生规则已更新", "id": row.id}
@router.delete("/derivation-rules/{rule_id}")
def delete_derivation_rule(
rule_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""删除派生规则"""
row = db.query(BudgetDerivationRule).filter(
BudgetDerivationRule.id == rule_id,
BudgetDerivationRule.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "规则不存在")
db.delete(row)
db.commit()
return {"message": "派生规则已删除"}
+224
View File
@@ -0,0 +1,224 @@
"""差异分析→战略地图反打 API — P1-1
允许从差异分析页面一键回写实际值到战略地图节点触发预警并生成回顾会议题
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
import json
import logging
from datetime import datetime
logger = logging.getLogger("cma.deviation_push")
router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
dependencies=[Depends(require_role("ceo", "finance", "business"))],
)
@router.post("/push-to-map")
def push_deviation_to_map(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""从差异分析回写实际值到战略地图节点
Body: {
mapId: int,
nodeId: string, // 格式 "dim_key-index" "finance-0"
deviationId: int,
newValue: float,
period: string, // "2026-05"
createReviewTopic: bool
}
"""
map_id = data.get("mapId")
node_id = data.get("nodeId")
deviation_id = data.get("deviationId")
new_value = data.get("newValue")
period = data.get("period")
create_review_topic = data.get("createReviewTopic", True)
if not map_id or not node_id:
raise HTTPException(400, "缺少 mapId 或 nodeId")
if new_value is None:
raise HTTPException(400, "缺少 newValue")
# 1. 查找战略地图
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
# 2. 解析 node_id 格式: "finance-0"
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except:
dims = []
parts = node_id.rsplit("-", 1)
if len(parts) != 2:
raise HTTPException(400, f"节点ID格式错误: {node_id}")
dim_key, obj_index_str = parts
try:
obj_index = int(obj_index_str)
except ValueError:
raise HTTPException(400, f"节点索引不是数字: {obj_index_str}")
target_dim = None
target_obj = None
for dim in dims:
if dim.get("key") == dim_key:
target_dim = dim
objs = dim.get("objectives", [])
if 0 <= obj_index < len(objs):
target_obj = objs[obj_index]
break
if not target_obj:
raise HTTPException(404, f"未找到节点: {node_id}")
kpi_codes = target_obj.get("kpis", [])
if not kpi_codes:
raise HTTPException(400, f"目标 [{target_obj.get('name')}] 没有关联KPI")
kpi_code = kpi_codes[0]
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_code} 不存在")
# 3. 更新实际值到 KPIValue 表
if not period:
period = datetime.now().strftime("%Y-%m")
existing_value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
if existing_value:
existing_value.actual_value = new_value
existing_value.source_type = "manual"
else:
kv = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=new_value,
source_type="manual",
)
db.add(kv)
db.flush()
# 4. 检查是否触发预警
alert_created = False
alert_id = None
if kpi.target_value and kpi.target_value > 0:
ratio = new_value / kpi.target_value
if ratio < 0.7:
alert_level = "red"
alert_msg = f"严重偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
elif ratio < 0.9:
alert_level = "yellow"
alert_msg = f"关注偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
else:
alert_level = None
if alert_level:
alert = KPIAlert(
kpi_id=kpi.id,
alert_level=alert_level,
alert_message=alert_msg,
status="pending",
)
db.add(alert)
db.flush()
alert_created = True
alert_id = alert.id
# 5. 生成战略回顾会议题
review_topic_created = False
if create_review_topic:
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
existing_topic = db.query(ActionPlan).filter(
ActionPlan.title == topic_title,
ActionPlan.status.in_(["pending", "in_progress"]),
).first()
if not existing_topic:
topic = ActionPlan(
kpi_id=kpi.id,
title=topic_title,
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
assignee=current_user.name if hasattr(current_user, "name") else "",
priority="medium",
status="pending",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(topic)
review_topic_created = True
# 6. 操作日志
log = OperationLog(
user_id=getattr(current_user, "id", None),
action="deviation_push_to_map",
target_type="map",
target_id=map_id,
detail=json.dumps({
"node_id": node_id,
"deviation_id": deviation_id,
"kpi_code": kpi_code,
"new_value": new_value,
"period": period,
"alert_created": alert_created,
"review_topic_created": review_topic_created,
}, ensure_ascii=False),
)
db.add(log)
db.commit()
return {
"success": True,
"message": f"已回写至战略地图 [{target_dim.get('name')}{target_obj.get('name')}]",
"kpi_code": kpi_code,
"kpi_name": kpi.kpi_name,
"new_value": new_value,
"alert_created": alert_created,
"alert_id": alert_id,
"review_topic_created": review_topic_created,
}
@router.get("/map-nodes/{map_id}")
def get_map_nodes(map_id: int, db: Session = Depends(get_db)):
"""获取战略地图的全部节点(供反打选择使用)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except:
dims = []
nodes = []
for dim in dims:
objs = dim.get("objectives", [])
for idx, obj in enumerate(objs):
node_id = f"{dim.get('key')}-{idx}"
nodes.append({
"node_id": node_id,
"dim_key": dim.get("key"),
"dim_name": dim.get("name"),
"dim_icon": dim.get("icon"),
"objective_name": obj.get("name"),
"kpi_codes": obj.get("kpis", []),
})
return {"data": nodes}
+478
View File
@@ -0,0 +1,478 @@
"""驱动因子预算 API — 科目/驱动因子模式切换 + 行业包 + 敏感性分析"""
import json
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import SystemConfig, OperationLog
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
router = APIRouter(
prefix="/api/cma/budget/driver",
tags=["驱动因子预算"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
# ──────────────────────────────────────────────
# 模板预置数据
# ──────────────────────────────────────────────
PRESET_TEMPLATES = [
# 通用收入模板
{
"name": "收入预测(通用)",
"industry": "general",
"category": "revenue",
"formula_desc": "收入 = 客户数 × 客单价",
"formula_text": "客户数×客单价",
"factors": [
{"key": "customer_count", "label": "客户数", "type": "number", "default": 100, "unit": ""},
{"key": "avg_price", "label": "客单价", "type": "number", "default": 50000, "unit": "元/家"},
],
},
# 通用费用模板
{
"name": "费用预测(通用)",
"industry": "general",
"category": "expense",
"formula_desc": "费用 = 人数 × 人均成本",
"formula_text": "人数×人均成本",
"factors": [
{"key": "headcount", "label": "人数", "type": "number", "default": 50, "unit": ""},
{"key": "avg_cost_per_head", "label": "人均成本", "type": "number", "default": 8000, "unit": "元/人"},
],
},
# 贸易经销版 — 渠补预算
{
"name": "渠补预算(贸易经销版)",
"industry": "trade",
"category": "expense",
"formula_desc": "渠补预算 = 计划维护渠道数 × 平均渠补率 × 平均渠道交易额",
"formula_text": "计划维护渠道数×平均渠补率×平均渠道交易额",
"factors": [
{"key": "channel_count", "label": "计划维护渠道数", "type": "number", "default": 80, "unit": ""},
{"key": "channel_subsidy_rate", "label": "平均渠补率", "type": "percent", "default": 75, "unit": "%"},
{"key": "avg_transaction", "label": "平均渠道交易额", "type": "number", "default": 150000, "unit": "元/家"},
],
},
# 贸易经销版 — 收入预测
{
"name": "收入预测(贸易经销版)",
"industry": "trade",
"category": "revenue",
"formula_desc": "收入 = 渠道数 × 平均交易额",
"formula_text": "渠道数×平均交易额",
"factors": [
{"key": "channel_count", "label": "渠道数", "type": "number", "default": 80, "unit": ""},
{"key": "avg_transaction", "label": "平均交易额", "type": "number", "default": 150000, "unit": "元/家"},
],
},
# IT服务版 — 收入
{
"name": "收入预测(IT服务版)",
"industry": "it",
"category": "revenue",
"formula_desc": "收入 = 计划新签客户数×平均合同额 + 续约客户数×续约率×平均合同额",
"formula_text": "计划新签客户数×平均合同额+续约客户数×续约率×平均合同额",
"factors": [
{"key": "new_customers", "label": "计划新签客户数", "type": "number", "default": 36, "unit": "家/年"},
{"key": "avg_contract_value", "label": "平均合同额", "type": "number", "default": 80000, "unit": "元/家"},
{"key": "renewal_customers", "label": "续约客户数", "type": "number", "default": 260, "unit": ""},
{"key": "renewal_rate", "label": "续约率", "type": "percent", "default": 90, "unit": "%"},
],
},
# IT服务版 — 销售费用
{
"name": "销售费用预算(IT服务版)",
"industry": "it",
"category": "expense",
"formula_desc": "销售费用 = 新签客户数×平均获客成本 + 续约客户数×维护成本",
"formula_text": "新签客户数×平均获客成本+续约客户数×维护成本",
"factors": [
{"key": "new_customers", "label": "计划新签客户数", "type": "number", "default": 36, "unit": "家/年"},
{"key": "acquisition_cost", "label": "平均获客成本", "type": "number", "default": 3000, "unit": "元/家"},
{"key": "renewal_customers", "label": "续约客户数", "type": "number", "default": 260, "unit": ""},
{"key": "maintenance_cost", "label": "维护成本", "type": "number", "default": 500, "unit": "元/家"},
],
},
]
# ──────────────────────────────────────────────
# 模式切换
# ──────────────────────────────────────────────
@router.get("/mode")
def get_driver_mode(db: Session = Depends(get_db)):
"""获取当前预算编制模式: subject / driver"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_driver_mode").first()
if not cfg:
return {"mode": "subject", "label": "科目模式"}
try:
val = json.loads(cfg.config_value)
except (json.JSONDecodeError, TypeError):
val = {"mode": "subject"}
return val
@router.post("/mode")
def set_driver_mode(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
mode = data.get("mode", "subject")
if mode not in ("subject", "driver"):
raise HTTPException(400, "模式必须是 subject 或 driver")
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_driver_mode").first()
val = json.dumps({"mode": mode, "label": "驱动因子模式" if mode == "driver" else "科目模式"})
if cfg:
cfg.config_value = val
else:
cfg = SystemConfig(
config_key="budget_driver_mode",
config_value=val,
description="预算编制模式: subject=科目模式, driver=驱动因子模式",
)
db.add(cfg)
db.commit()
log = OperationLog(
action="update",
target_type="budget",
target_id=0,
detail=json.dumps({"mode": mode, "action": "切换编制模式"}, ensure_ascii=False),
)
db.add(log)
db.commit()
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
# ──────────────────────────────────────────────
# 模板管理
# ──────────────────────────────────────────────
@router.get("/templates")
def list_driver_templates(
industry: Optional[str] = Query(None, description="行业: general/trade/it"),
category: Optional[str] = Query(None, description="类别: revenue/expense"),
db: Session = Depends(get_db),
):
"""获取驱动因子模板列表(含预置模板)"""
# 先从数据库读
query = db.query(DriverFactorTemplate).filter(DriverFactorTemplate.is_active == 1)
if industry:
query = query.filter(DriverFactorTemplate.industry == industry)
if category:
query = query.filter(DriverFactorTemplate.category == category)
db_templates = query.order_by(DriverFactorTemplate.id.asc()).all()
# 合并预置模板(数据库没有则返回预置)
if db_templates:
result = []
for t in db_templates:
result.append({
"id": t.id,
"name": t.name,
"industry": t.industry,
"category": t.category,
"formula_desc": t.formula_desc,
"formula_text": t.formula_text,
"factors": t.factors,
"is_preset": False,
})
return {"data": result, "total": len(result)}
else:
# 返回预置模板
filtered = PRESET_TEMPLATES
if industry:
filtered = [t for t in filtered if t["industry"] == industry]
if category:
filtered = [t for t in filtered if t["category"] == category]
return {"data": filtered, "total": len(filtered)}
@router.get("/industries")
def list_driver_industries(db: Session = Depends(get_db)):
"""获取行业包列表"""
industries = [
{"key": "general", "label": "通用模板", "icon": "📦"},
{"key": "trade", "label": "贸易经销版", "icon": "🏪"},
{"key": "it", "label": "IT服务版", "icon": "💻"},
]
return {"data": industries}
# ──────────────────────────────────────────────
# 驱动因子计算
# ──────────────────────────────────────────────
def _calculate_formula(formula_text: str, factors: dict, factor_defs: list) -> float:
"""
根据公式文本和驱动因子值计算预算
支持的运算: + - × * /
公式文本中的因子名可以是key或label函数会自动映射到值
"""
# 替换 × 为 *
expr = formula_text.replace("×", "*")
# 构建变量映射:key -> value, label -> value
factor_map = {}
for fd in factor_defs:
key = fd["key"]
val = factors.get(key, fd.get("default", 0))
if fd.get("type") == "percent":
val = float(val) / 100.0
else:
val = float(val)
factor_map[key] = val
# 也映射 label(中文名)
factor_map[fd["label"]] = val
# 按长度降序替换,避免短名被错误替换
sorted_names = sorted(factor_map.keys(), key=len, reverse=True)
for name in sorted_names:
expr = expr.replace(name, str(factor_map[name]))
try:
result = eval(expr, {"__builtins__": {}}, {})
return round(float(result), 2)
except Exception as e:
raise HTTPException(400, f"公式计算失败: {expr}, 错误: {str(e)}")
@router.post("/calculate")
def driver_calculate(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""
驱动因子计算预算
接收驱动因子值自动按公式计算预算结果
如果指定了 template_id使用模板的公式和因子定义
否则使用 data.name, data.formula_text, data.factors 中的因子定义
"""
template_id = data.get("template_id")
factors_input = data.get("factors", {}) # {key: value}
period = data.get("period", datetime.now().strftime("%Y-%m"))
# 获取模板
formula_text = None
factor_defs = []
if template_id:
template = db.query(DriverFactorTemplate).filter(
DriverFactorTemplate.id == template_id,
DriverFactorTemplate.is_active == 1,
).first()
if not template:
# 检查预置模板
if 1 <= template_id <= len(PRESET_TEMPLATES):
preset = PRESET_TEMPLATES[template_id - 1]
formula_text = preset["formula_text"]
factor_defs = preset["factors"]
name = preset["name"]
industry = preset["industry"]
else:
raise HTTPException(404, "模板不存在")
else:
formula_text = template.formula_text
factor_defs = template.factors
name = template.name
industry = template.industry
else:
name = data.get("name", "自定义驱动因子预算")
industry = data.get("industry", "general")
formula_text = data.get("formula_text")
factor_defs = data.get("factor_defs", [])
if not formula_text:
raise HTTPException(400, "缺少 formula_text (公式文本)")
if not factor_defs:
raise HTTPException(400, "缺少 factor_defs (因子定义)")
# 填充默认值
complete_factors = {}
for fd in factor_defs:
key = fd["key"]
val = factors_input.get(key)
if val is None:
val = fd.get("default", 0)
complete_factors[key] = val
# 计算结果
result = _calculate_formula(formula_text, complete_factors, factor_defs)
# 记录计算结果
budget_record = DriverFactorBudget(
name=name,
industry=industry,
template_id=template_id,
factors=complete_factors,
calculated_value=result,
formula_text=formula_text,
period=period,
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(budget_record)
db.commit()
db.refresh(budget_record)
# 记录操作日志
log = OperationLog(
action="calculate",
target_type="budget",
target_id=budget_record.id,
detail=json.dumps({
"name": name,
"formula": formula_text,
"factors": complete_factors,
"result": result,
}, ensure_ascii=False),
)
db.add(log)
db.commit()
# 计算原始因子信息(用于前端展示)
factor_details = []
for fd in factor_defs:
key = fd["key"]
factor_details.append({
"key": key,
"label": fd["label"],
"value": complete_factors[key],
"unit": fd.get("unit", ""),
"type": fd.get("type", "number"),
})
return {
"id": budget_record.id,
"name": name,
"industry": industry,
"template_id": template_id,
"formula_text": formula_text,
"formula_desc": data.get("formula_desc", ""),
"factors": factor_details,
"calculated_value": result,
"period": period,
}
# ──────────────────────────────────────────────
# 敏感性分析
# ──────────────────────────────────────────────
@router.post("/sensitivity")
def driver_sensitivity(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""
敏感性分析对指定驱动因子做 ±10%±20% 变动显示预算变动
"""
factors_input = data.get("factors", {})
formula_text = data.get("formula_text")
factor_defs = data.get("factor_defs", [])
target_factor = data.get("target_factor") # 要分析的因子key,不指定则分析所有因子
if not formula_text or not factor_defs:
raise HTTPException(400, "缺少 formula_text 或 factor_defs")
# 计算基准值
base_result = _calculate_formula(formula_text, factors_input, factor_defs)
# 敏感性分析
sensitivity_results = []
factors_to_analyze = factor_defs
if target_factor:
factors_to_analyze = [fd for fd in factor_defs if fd["key"] == target_factor]
if not factors_to_analyze:
raise HTTPException(404, f"未找到因子: {target_factor}")
for fd in factors_to_analyze:
key = fd["key"]
base_val = factors_input.get(key, fd.get("default", 0))
variations = []
for pct_change in [-20, -10, 10, 20]:
if fd.get("type") == "percent":
# 百分比因子:变动比例直接加在百分比上
changed_val = base_val * (1 + pct_change / 100.0)
else:
changed_val = base_val * (1 + pct_change / 100.0)
test_factors = dict(factors_input)
test_factors[key] = round(changed_val, 2)
try:
new_result = _calculate_formula(formula_text, test_factors, factor_defs)
delta = round(new_result - base_result, 2)
delta_pct = round((delta / base_result * 100) if base_result != 0 else 0, 2)
variations.append({
"change_pct": pct_change,
"factor_value": round(changed_val, 2),
"budget_result": new_result,
"delta": delta,
"delta_pct": delta_pct,
})
except Exception:
variations.append({
"change_pct": pct_change,
"factor_value": round(changed_val, 2),
"budget_result": None,
"delta": None,
"delta_pct": None,
"error": "计算失败",
})
sensitivity_results.append({
"factor_key": key,
"factor_label": fd["label"],
"base_value": base_val,
"unit": fd.get("unit", ""),
"type": fd.get("type", "number"),
"variations": variations,
})
return {
"base_value": base_result,
"formula_text": formula_text,
"sensitivity": sensitivity_results,
}
# ──────────────────────────────────────────────
# 历史记录
# ──────────────────────────────────────────────
@router.get("/history")
def list_driver_history(
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
):
"""查看最近的驱动因子预算计算记录"""
records = db.query(DriverFactorBudget).order_by(
DriverFactorBudget.created_at.desc()
).limit(limit).all()
result = []
for r in records:
result.append({
"id": r.id,
"name": r.name,
"industry": r.industry,
"factors": r.factors,
"calculated_value": r.calculated_value,
"formula_text": r.formula_text,
"period": r.period,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
return {"data": result, "total": len(result)}
+93
View File
@@ -0,0 +1,93 @@
"""企业实体 API"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from app.database import get_db
from app.auth_middleware import require_auth
from app.models import Entity
router = APIRouter(prefix="/api/cma/entities", tags=["企业实体"],
dependencies=[Depends(require_auth)],
)
class EntityCreate(BaseModel):
name: str
short_name: Optional[str] = None
industry: Optional[str] = None
status: Optional[str] = "active"
class EntityUpdate(BaseModel):
name: Optional[str] = None
short_name: Optional[str] = None
industry: Optional[str] = None
status: Optional[str] = None
@router.get("")
def list_entities(db: Session = Depends(get_db)):
"""获取企业列表"""
entities = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).all()
return {
"data": [
{
"id": e.id,
"name": e.name,
"short_name": e.short_name,
"industry": e.industry,
}
for e in entities
]
}
@router.post("")
def create_entity(data: EntityCreate, db: Session = Depends(get_db)):
"""创建企业"""
entity = Entity(
name=data.name,
short_name=data.short_name,
industry=data.industry,
status=data.status or "active",
)
db.add(entity)
db.commit()
db.refresh(entity)
return {
"id": entity.id,
"name": entity.name,
"short_name": entity.short_name,
"industry": entity.industry,
"message": "企业创建成功",
}
@router.put("/{entity_id}")
def update_entity(entity_id: int, data: EntityUpdate, db: Session = Depends(get_db)):
"""更新企业信息"""
entity = db.query(Entity).filter(Entity.id == entity_id).first()
if not entity:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=404, content={"detail": "企业不存在"})
if data.name is not None:
entity.name = data.name
if data.short_name is not None:
entity.short_name = data.short_name
if data.industry is not None:
entity.industry = data.industry
if data.status is not None:
entity.status = data.status
db.commit()
db.refresh(entity)
return {
"id": entity.id,
"name": entity.name,
"short_name": entity.short_name,
"industry": entity.industry,
"message": "企业更新成功",
}
+631
View File
@@ -0,0 +1,631 @@
"""费用审核智能体 API — 管理会计OS
费用规则配置 + 报销单自动校验 + 人工审批流程 + 审核看板统计
流程:
1. 提交报销单 自动校验费用规则
2. 超限 自动打回(returned, 标注原因)
3. 合规 待人工审批(pending)
4. 审批通过(approved) / 拒绝(rejected)
"""
import json
import random
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import ExpenseRule, ExpenseReimbursement, OperationLog
router = APIRouter(prefix="/api/cma/expenses", tags=["费用审核"])
# 费用类型中文映射
EXPENSE_TYPE_LABELS = {
"entertainment": "招待费",
"travel": "差旅费",
"office": "办公费",
"management": "管理费",
}
# 预置规则
PRESET_RULES = [
{"rule_name": "招待费单笔限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "single", "limit_amount": 2000.0,
"cycle": "single", "remark": "招待费标准:单笔≤2000元"},
{"rule_name": "招待费部门月限额", "dimension": "department", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 50000.0,
"cycle": "monthly", "remark": "招待费标准:部门月限额5万元"},
{"rule_name": "招待费月度总额限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 150000.0,
"cycle": "monthly", "remark": "公司招待费预算15万/月"},
{"rule_name": "差旅住宿单晚限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "travel", "limit_type": "single", "limit_amount": 300.0,
"cycle": "single", "remark": "差旅费标准:住宿≤300元/晚"},
{"rule_name": "差旅交通等级限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "travel", "limit_type": "single", "limit_amount": 1500.0,
"cycle": "single", "remark": "差旅费标准:交通等级(高铁二等座/经济舱)"},
{"rule_name": "办公费单笔限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "office", "limit_type": "single", "limit_amount": 500.0,
"cycle": "single", "remark": "办公费标准:单笔≤500元"},
{"rule_name": "管理费月度总额限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "management", "limit_type": "monthly", "limit_amount": 910000.0,
"cycle": "monthly", "remark": "管理费预算91万/月"},
]
def _rule_to_dict(r: ExpenseRule) -> dict:
return {
"id": r.id,
"rule_name": r.rule_name,
"dimension": r.dimension,
"dimension_value": r.dimension_value,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"limit_type": r.limit_type,
"limit_amount": r.limit_amount,
"cycle": r.cycle,
"status": r.status,
"remark": r.remark,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _reimb_to_dict(r: ExpenseReimbursement) -> dict:
return {
"id": r.id,
"reimb_no": r.reimb_no,
"applicant": r.applicant,
"department": r.department,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"title": r.title,
"amount": r.amount,
"expense_date": r.expense_date.isoformat() if r.expense_date else None,
"attachment": r.attachment,
"status": r.status,
"check_result": r.check_result,
"check_reason": r.check_reason,
"check_detail": r.check_detail,
"checked_at": r.checked_at.isoformat() if r.checked_at else None,
"approver": r.approver,
"approve_comment": r.approve_comment,
"approved_at": r.approved_at.isoformat() if r.approved_at else None,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _gen_reimb_no(db: Session) -> str:
"""生成报销单号: BX + 年月日 + 序号"""
today = datetime.now()
prefix = "BX" + today.strftime("%Y%m%d")
last = (
db.query(ExpenseReimbursement)
.filter(ExpenseReimbursement.reimb_no.like(prefix + "%"))
.order_by(ExpenseReimbursement.id.desc())
.first()
)
seq = (int(last.reimb_no[-4:]) + 1) if last and last.reimb_no[-4:].isdigit() else 1
return f"{prefix}{seq:04d}"
# ============================================================
# 费用规则 CRUD
# ============================================================
@router.get("/rules")
def list_rules(
expense_type: str = Query(None),
status: str = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""查询费用规则列表"""
q = db.query(ExpenseRule)
if expense_type:
q = q.filter(ExpenseRule.expense_type == expense_type)
if status:
q = q.filter(ExpenseRule.status == status)
rules = q.order_by(ExpenseRule.id.asc()).all()
return {"data": [_rule_to_dict(r) for r in rules], "total": len(rules)}
@router.post("/rules")
def create_rule(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""新建费用规则"""
rule_name = data.get("rule_name")
expense_type = data.get("expense_type")
limit_type = data.get("limit_type", "single")
limit_amount = data.get("limit_amount")
if not rule_name or not expense_type or limit_amount is None:
raise HTTPException(400, "缺少必要参数: rule_name, expense_type, limit_amount")
if expense_type not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {expense_type}")
if limit_type not in ("single", "monthly", "yearly"):
raise HTTPException(400, f"无效限额类型: {limit_type}")
rule = ExpenseRule(
rule_name=rule_name,
dimension=data.get("dimension", "expense_type"),
dimension_value=data.get("dimension_value", "") or None,
expense_type=expense_type,
limit_type=limit_type,
limit_amount=float(limit_amount),
cycle=data.get("cycle", limit_type),
status=data.get("status", "active"),
remark=data.get("remark", ""),
)
db.add(rule)
db.commit()
db.refresh(rule)
return {"message": "规则已创建", "id": rule.id}
@router.put("/rules/{rule_id}")
def update_rule(
rule_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新费用规则"""
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
if not rule:
raise HTTPException(404, "规则不存在")
if "rule_name" in data and data["rule_name"]:
rule.rule_name = data["rule_name"]
if "expense_type" in data:
if data["expense_type"] not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {data['expense_type']}")
rule.expense_type = data["expense_type"]
if "dimension" in data:
rule.dimension = data["dimension"]
if "dimension_value" in data:
rule.dimension_value = data["dimension_value"] or None
if "limit_type" in data:
rule.limit_type = data["limit_type"]
if "limit_amount" in data and data["limit_amount"] is not None:
rule.limit_amount = float(data["limit_amount"])
if "status" in data:
rule.status = data["status"]
if "remark" in data:
rule.remark = data["remark"]
db.commit()
db.refresh(rule)
return {"message": "规则已更新", "id": rule.id}
@router.delete("/rules/{rule_id}")
def delete_rule(
rule_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除费用规则"""
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
if not rule:
raise HTTPException(404, "规则不存在")
db.delete(rule)
db.commit()
return {"message": "规则已删除", "id": rule_id}
@router.post("/rules/seed")
def seed_rules(
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""预置默认费用规则(幂等,按规则名去重)"""
created = 0
for p in PRESET_RULES:
exists = db.query(ExpenseRule).filter(ExpenseRule.rule_name == p["rule_name"]).first()
if exists:
continue
db.add(ExpenseRule(**p))
created += 1
db.commit()
return {"message": f"预置完成,新增 {created} 条规则"}
# ============================================================
# 报销单自动校验
# ============================================================
def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list = None):
"""按启用规则校验报销单,写入 check_result / check_detail / check_reason
规则匹配:
- dimension=expense_type: 全局费用类型规则dimension_value 可再限定具体类型
- dimension=department: 部门维度规则dimension_value =所有部门
- dimension=person: 人员维度规则dimension_value =所有人
返回 (passed: bool, checks: list)
"""
if rules is None:
rules = db.query(ExpenseRule).filter(ExpenseRule.status == "active").all()
period = (reimb.expense_date or datetime.now()).strftime("%Y-%m")
checks = []
failed_reasons = []
for rule in rules:
# 费用类型必须匹配
if rule.expense_type and rule.expense_type != reimb.expense_type:
continue
# 维度匹配
if rule.dimension == "department":
if not reimb.department:
continue
if rule.dimension_value and rule.dimension_value != reimb.department:
continue
elif rule.dimension == "person":
if rule.dimension_value and rule.dimension_value != reimb.applicant:
continue
elif rule.dimension == "expense_type":
if rule.dimension_value and rule.dimension_value != reimb.expense_type:
continue
else:
continue
actual = reimb.amount
limit_desc = f"{rule.limit_amount:,.0f}"
if rule.limit_type == "single":
passed = reimb.amount <= rule.limit_amount
checks.append({
"rule_id": rule.id,
"rule_name": rule.rule_name,
"rule_type": "单笔限额",
"limit": rule.limit_amount,
"actual": reimb.amount,
"passed": passed,
"detail": f"单笔 {reimb.amount:,.2f}元 vs 限额 {limit_desc}",
})
else:
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
ExpenseReimbursement.expense_type == reimb.expense_type,
ExpenseReimbursement.id != reimb.id,
ExpenseReimbursement.status.in_(["pending", "approved"]),
)
if rule.limit_type == "monthly":
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period)
period_label = f"{period}"
else: # yearly
year = period[:4]
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y") == year)
period_label = f"{year}"
if rule.dimension == "department" and reimb.department:
q = q.filter(ExpenseReimbursement.department == reimb.department)
elif rule.dimension == "person":
q = q.filter(ExpenseReimbursement.applicant == reimb.applicant)
used = q.scalar() or 0.0
actual = used + reimb.amount
passed = actual <= rule.limit_amount
checks.append({
"rule_id": rule.id,
"rule_name": rule.rule_name,
"rule_type": "月度累计" if rule.limit_type == "monthly" else "年度累计",
"limit": rule.limit_amount,
"actual": actual,
"used": used,
"passed": passed,
"detail": f"{period_label}累计 {used:,.2f} + 本次 {reimb.amount:,.2f} = {actual:,.2f}元 vs 限额 {limit_desc}",
})
if not passed:
failed_reasons.append(f"{rule.rule_name}: {checks[-1]['detail']},超限")
passed_all = len(failed_reasons) == 0
reimb.check_result = "pass" if passed_all else "fail"
reimb.check_detail = checks
reimb.check_reason = "".join(failed_reasons) if failed_reasons else None
reimb.checked_at = datetime.now()
# 超限自动打回,合规进入待人工审批
reimb.status = "pending" if passed_all else "returned"
return passed_all, checks
# ============================================================
# 报销单提交 / 查询 / 审批
# ============================================================
@router.post("/reimbursements")
def submit_reimbursement(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""提交报销单 → 自动校验规则(超限自动打回)"""
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
expense_type = data.get("expense_type")
title = data.get("title")
amount = data.get("amount")
if not expense_type or not title or amount is None:
raise HTTPException(400, "缺少必要参数: expense_type, title, amount")
if expense_type not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {expense_type}")
amount = float(amount)
if amount <= 0:
raise HTTPException(400, "报销金额必须大于0")
expense_date = None
if data.get("expense_date"):
try:
expense_date = datetime.strptime(str(data["expense_date"])[:10], "%Y-%m-%d")
except Exception:
expense_date = None
reimb = ExpenseReimbursement(
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
applicant=applicant,
department=data.get("department", ""),
expense_type=expense_type,
title=title,
amount=amount,
expense_date=expense_date,
attachment=data.get("attachment", ""),
status="pending",
check_result="pass",
created_by=current_user.username if hasattr(current_user, "username") else applicant,
)
db.add(reimb)
db.flush() # 先拿到 id 再生成正式单号
reimb.reimb_no = _gen_reimb_no(db)
db.flush()
passed, checks = validate_reimbursement(db, reimb)
db.commit()
db.refresh(reimb)
result = _reimb_to_dict(reimb)
result["auto_check_passed"] = passed
return {"message": "报销单已提交" if passed else "报销单超限,已自动打回", "data": result}
@router.get("/reimbursements")
def list_reimbursements(
status: str = Query(None),
expense_type: str = Query(None),
applicant: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
q = db.query(ExpenseReimbursement)
if status:
q = q.filter(ExpenseReimbursement.status == status)
if expense_type:
q = q.filter(ExpenseReimbursement.expense_type == expense_type)
if applicant:
q = q.filter(ExpenseReimbursement.applicant.like(f"%{applicant}%"))
if keyword:
like = f"%{keyword}%"
q = q.filter(
(ExpenseReimbursement.title.like(like))
| (ExpenseReimbursement.reimb_no.like(like))
| (ExpenseReimbursement.applicant.like(like))
)
items = q.order_by(ExpenseReimbursement.id.desc()).limit(200).all()
return {"data": [_reimb_to_dict(r) for r in items], "total": len(items)}
@router.get("/reimbursements/{reimb_id}")
def get_reimbursement(
reimb_id: int,
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""报销单详情"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
return _reimb_to_dict(r)
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str):
"""执行审批动作: approve/reject/return"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("pending", "returned"):
raise HTTPException(400, f"当前状态({r.status})不可审批")
if action == "approve":
r.status = "approved"
elif action == "reject":
r.status = "rejected"
else:
r.status = "returned"
r.approver = approver
r.approve_comment = comment or ("" if action == "approve" else "人工打回")
r.approved_at = datetime.now()
db.commit()
db.refresh(r)
return r
@router.post("/reimbursements/{reimb_id}/approve")
def approve_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""审批通过"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver)
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/reject")
def reject_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""审批拒绝"""
comment = (data or {}).get("comment", "")
if not comment:
raise HTTPException(400, "拒绝时必须填写审批意见")
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "reject", comment, approver)
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/return")
def return_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""人工打回"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver)
return {"message": "已打回", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/resubmit")
def resubmit_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""被驳回/打回后重新提交 → 重新自动校验"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("returned", "rejected"):
raise HTTPException(400, f"当前状态({r.status})不可重新提交")
# 允许修改金额/事由后重新校验
if data:
if data.get("amount") is not None:
r.amount = float(data["amount"])
if data.get("title"):
r.title = data["title"]
if data.get("attachment") is not None:
r.attachment = data["attachment"]
passed, _ = validate_reimbursement(db, r)
r.approver = None
r.approve_comment = None
db.commit()
db.refresh(r)
return {"message": "已重新提交" if passed else "仍超限,已再次打回", "data": _reimb_to_dict(r)}
# ============================================================
# 审核看板统计
# ============================================================
@router.get("/stats")
def expense_stats(
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
now = datetime.now()
period = period or now.strftime("%Y-%m")
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).group_by(
ExpenseReimbursement.status
).all():
if st in counts:
counts[st] = cnt
counts["total"] += cnt
# 本月(按费用发生日期)金额统计 by 费用类型 — 统计已提交(待审+已通过)
month_rows = (
db.query(
ExpenseReimbursement.expense_type,
func.coalesce(func.sum(ExpenseReimbursement.amount), 0),
func.count(ExpenseReimbursement.id),
)
.filter(
ExpenseReimbursement.status.in_(["pending", "approved"]),
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
)
.group_by(ExpenseReimbursement.expense_type)
.all()
)
amounts_by_type = [
{
"expense_type": et,
"label": EXPENSE_TYPE_LABELS.get(et, et),
"amount": round(float(amt), 2),
"count": cnt,
}
for et, amt, cnt in month_rows
]
monthly_total = round(sum(x["amount"] for x in amounts_by_type), 2)
# 超限预警列表 — 自动打回(returned + check_result=fail)
over_limit_rows = (
db.query(ExpenseReimbursement)
.filter(
ExpenseReimbursement.status == "returned",
ExpenseReimbursement.check_result == "fail",
)
.order_by(ExpenseReimbursement.id.desc())
.limit(50)
.all()
)
over_limit = [
{
"id": r.id,
"reimb_no": r.reimb_no,
"applicant": r.applicant,
"department": r.department,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"title": r.title,
"amount": r.amount,
"check_reason": r.check_reason,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in over_limit_rows
]
recent_rows = db.query(ExpenseReimbursement).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
recent = [_reimb_to_dict(r) for r in recent_rows]
# 本月预算使用率(招待费15万/月 管理费91万/月)
budget_usage = []
for et, budget in (("entertainment", 150000.0), ("management", 910000.0)):
used = next((x["amount"] for x in amounts_by_type if x["expense_type"] == et), 0.0)
budget_usage.append({
"expense_type": et,
"label": EXPENSE_TYPE_LABELS.get(et, et),
"budget": budget,
"used": used,
"usage_rate": round(used / budget * 100, 1) if budget else 0,
})
return {
"period": period,
"counts": counts,
"amounts_by_type": amounts_by_type,
"monthly_total": monthly_total,
"over_limit": over_limit,
"over_limit_count": len(over_limit),
"recent": recent,
"budget_usage": budget_usage,
}
+550
View File
@@ -0,0 +1,550 @@
"""增长质量诊断 API — 五维评分 + 诊断结论 + 跨期对比
数据来源KPI字典 (kpi_definitions) + KPI实际值 (kpi_values)
五维度营收增长 / 利润质量 / 现金质量 / 增长效率 / 组织健康
评分区间0-100>=80 / 60-79 / <60
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import datetime
import re
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, Entity
router = APIRouter(prefix="/api/cma/growth-quality", tags=["增长质量诊断"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# 五维度定义
DIMENSIONS = [
{"key": "revenueGrowth", "name": "营收增长", "weight": 20, "icon": "📈",
"desc": "营收增速、增长持续性"},
{"key": "profitQuality", "name": "利润质量", "weight": 20, "icon": "💰",
"desc": "毛利率/净利率趋势、利润与收入匹配"},
{"key": "cashQuality", "name": "现金质量", "weight": 20, "icon": "🏦",
"desc": "经营现金流与净利润匹配度(含金量)"},
{"key": "growthEfficiency", "name": "增长效率", "weight": 20, "icon": "🚀",
"desc": "获客成本、单位增长投入产出"},
{"key": "orgHealth", "name": "组织健康", "weight": 20, "icon": "",
"desc": "人效、费用结构"},
]
# 关键KPI编码 → 维度用途
KPI_CODES = {
"F_REVENUE": "营业收入(万元)",
"F_NET_PROFIT": "净利润(万元)",
"F_OP_CFLOW": "经营性现金流(万元)",
"F_GROSS_MARGIN": "毛利率(%)",
"F_OP_PROFIT_MARGIN": "经营利润率(%)",
"F_COST_RATIO": "费用率(%)",
"F_REVENUE_GROWTH": "收入增长率(%)",
"F_FCF": "自由现金流(万元)",
"C_NEW_CLIENTS": "新客户数",
"C_REBATE_RATE": "渠补率(%)",
"C_SATISFACTION": "客户满意度",
"L_TRAINING": "培训完成率",
"P_DELIVERY": "交付及时率",
"F_AR_DAYS": "应收账款周转天数",
}
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
def _fetch_kpi_values(db: Session, entity_id: int, period: str) -> dict:
"""拉取某实体某期间的全部KPI值 {kpi_code: actual_value}"""
rows = (db.query(KPIDefinition.kpi_code, KPIValue.actual_value)
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
.filter(KPIDefinition.entity_id == entity_id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None))
.all())
return {code: value for code, value in rows}
def _fetch_history(db: Session, entity_id: int, limit: int = 12) -> List[dict]:
"""拉取最近 N 个期间(按月,含数据)的 KPI 值,供趋势/持续性分析"""
periods = (db.query(KPIValue.period)
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
.filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(["F_REVENUE", "F_NET_PROFIT", "F_OP_CFLOW"]))
.distinct().all())
plist = sorted({p[0] for p in periods}, reverse=True)
# 只保留 YYYY-MM 格式,按时间排序(旧→新)
months = sorted([p for p in plist if MONTH_RE.match(p)])
hist = []
for p in months[-limit:]:
hist.append({"period": p, **{k: None for k in KPI_CODES}})
if not hist:
return []
# 批量取数
rows = (db.query(KPIDefinition.kpi_code, KPIValue.period, KPIValue.actual_value)
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
.filter(KPIDefinition.entity_id == entity_id,
KPIValue.period.in_([h["period"] for h in hist]))
.all())
idx = {h["period"]: h for h in hist}
for code, period, val in rows:
if period in idx and code in idx[period]:
idx[period][code] = val
return hist
def _prev_period(period: str) -> Optional[str]:
"""计算上期(YYYY-MM → 上一月;其他格式 → None)"""
m = MONTH_RE.match(period)
if not m:
return None
y, mo = int(period[:4]), int(period[5:7])
if mo == 1:
return f"{y-1:04d}-12"
return f"{y:04d}-{mo-1:02d}"
def _yoy_period(period: str) -> Optional[str]:
"""计算去年同期(YYYY-MM → 去年同月)"""
m = MONTH_RE.match(period)
if not m:
return None
return f"{int(period[:4])-1:04d}-{period[5:7]}"
# ═══════════════════════════════════════════════
# 五维度评分引擎(0-100
# ═══════════════════════════════════════════════
def _clamp(v: float, lo: float = 0.0, hi: float = 100.0) -> float:
return max(lo, min(hi, v))
def _linear(value, points: List[tuple]):
"""分段线性插值评分: points = [(x, score), ...] 按 x 升序"""
if value is None:
return 50.0
if value <= points[0][0]:
return points[0][1]
if value >= points[-1][0]:
return points[-1][1]
for (x1, s1), (x2, s2) in zip(points, points[1:]):
if x1 <= value <= x2:
if x2 == x1:
return s1
return s1 + (s2 - s1) * (value - x1) / (x2 - x1)
return 50.0
# ── 1. 营收增长:增速 + 持续性 ──
def _score_revenue_growth(cur: dict, prev: dict, history: List[dict]) -> float:
rev_cur = cur.get("F_REVENUE")
rev_prev = prev.get("F_REVENUE") if prev else None
# 优先用 KPI 直接给的收入增长率
kpi_growth = cur.get("F_REVENUE_GROWTH")
growth = None
if kpi_growth is not None:
growth = float(kpi_growth)
elif rev_cur is not None and rev_prev:
growth = (rev_cur - rev_prev) / rev_prev * 100 if rev_prev else None
score = _linear(growth, [
(-30, 5), (-20, 15), (-10, 30), (0, 45), (5, 60), (10, 70),
(20, 82), (30, 90), (50, 96),
])
# 增长持续性:近6个月中收入增长月占比
if len(history) >= 2:
revs = [h.get("F_REVENUE") for h in history if h.get("F_REVENUE") is not None]
ups = 0
for i in range(1, len(revs)):
if revs[i] > revs[i - 1]:
ups += 1
persist = ups / (len(revs) - 1) if len(revs) > 1 else 0.5
score = score * 0.7 + persist * 100 * 0.3
return round(_clamp(score), 1)
# ── 2. 利润质量:毛利率/净利率水平 + 趋势 + 收入匹配 ──
def _score_profit_quality(cur: dict, prev: dict) -> float:
gm = cur.get("F_GROSS_MARGIN")
np_ = cur.get("F_NET_PROFIT")
rev = cur.get("F_REVENUE")
net_margin = (np_ / rev * 100) if (np_ is not None and rev) else None
opm = cur.get("F_OP_PROFIT_MARGIN")
gm_score = _linear(gm, [(-10, 5), (0, 10), (10, 25), (20, 45), (30, 62),
(40, 75), (55, 88), (70, 95)])
nm_score = _linear(net_margin, [(-50, 0), (-20, 10), (-10, 20), (0, 35),
(10, 60), (20, 78), (30, 90)])
opm_score = _linear(opm, [(-20, 10), (0, 30), (10, 55), (20, 75), (35, 90)])
# 毛利率 40% + 净利率 40% + 经营利润率 20%
base = gm_score * 0.4 + nm_score * 0.4 + opm_score * 0.2
# 利润与收入匹配:收入升但利润降 → 扣分
if prev and rev is not None and np_ is not None:
prev_rev = prev.get("F_REVENUE")
prev_np = prev.get("F_NET_PROFIT")
if prev_rev and prev_np is not None:
rev_up = rev > prev_rev
np_down = np_ < prev_np
if rev_up and np_down:
base -= 10
elif np_down:
base -= 5
return round(_clamp(base), 1)
# ── 3. 现金质量:含金量(OCF/净利润) + 现金流强度 ──
def _score_cash_quality(cur: dict) -> float:
ocf = cur.get("F_OP_CFLOW")
np_ = cur.get("F_NET_PROFIT")
rev = cur.get("F_REVENUE")
fcf = cur.get("F_FCF")
# 含金量 = OCF / 净利润(净利润>0时)
gold = None
if ocf is not None and np_ is not None and np_ > 0:
gold = ocf / np_
# 净利润<=0:利润为负,含金量指标失效 → 低分(除非现金流强)
gold_score = _linear(gold, [(0, 10), (0.5, 35), (0.8, 55), (1.0, 70),
(1.2, 85), (1.5, 95)])
if np_ is not None and np_ <= 0:
gold_score = 15 if (ocf is None or ocf <= 0) else 35
# 现金流强度 = OCF / 收入
ocf_ratio = (ocf / rev * 100) if (ocf is not None and rev) else None
ocf_score = _linear(ocf_ratio, [(-20, 5), (0, 20), (10, 50), (20, 75),
(30, 90), (50, 98)])
fcf_score = _linear(fcf, [(-100, 10), (-20, 30), (0, 50), (20, 70),
(100, 90)]) if fcf is not None else 50.0
score = gold_score * 0.5 + ocf_score * 0.35 + fcf_score * 0.15
return round(_clamp(score), 1)
# ── 4. 增长效率:费用率水平 + 单位增长投入产出 + 获客成本 ──
def _score_growth_efficiency(cur: dict, prev: dict) -> float:
cost_ratio = cur.get("F_COST_RATIO")
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
(55, 40), (70, 25), (90, 10)])
# 费用增速 vs 收入增速(用费用率变化近似)
eff_score = 60.0
if prev is not None and cost_ratio is not None:
prev_cr = prev.get("F_COST_RATIO")
if prev_cr:
cr_change = cost_ratio - prev_cr
eff_score = _linear(cr_change, [(-15, 95), (-5, 80), (0, 65),
(5, 45), (15, 25), (30, 10)])
# 获客成本代理:收入/新客户数(越高越高效)
rev = cur.get("F_REVENUE")
new_clients = cur.get("C_NEW_CLIENTS")
cac_score = 60.0
if rev is not None and new_clients:
per_client = rev / new_clients
cac_score = _linear(per_client, [(0, 40), (50, 50), (200, 65),
(500, 78), (1000, 88)])
score = cost_score * 0.45 + eff_score * 0.35 + cac_score * 0.2
return round(_clamp(score), 1)
# ── 5. 组织健康:费用结构 + 人效/运营质量 ──
def _score_org_health(cur: dict) -> float:
cost_ratio = cur.get("F_COST_RATIO")
# 费用结构(费用率越低越健康)
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
(55, 40), (70, 25), (90, 10)])
# 运营/人效质量代理:满意度、培训、交付、应收
sat = cur.get("C_SATISFACTION")
train = cur.get("L_TRAINING")
deliver = cur.get("P_DELIVERY")
ar_days = cur.get("F_AR_DAYS")
op_vals = [v for v in [sat, train, deliver] if v is not None]
op_score = (sum(op_vals) / len(op_vals)) if op_vals else 55.0
ar_score = _linear(ar_days, [(15, 95), (30, 80), (45, 65), (60, 50),
(90, 30), (120, 15)]) if ar_days is not None else 55.0
score = cost_score * 0.4 + op_score * 0.35 + ar_score * 0.25
return round(_clamp(score), 1)
_SCORERS = {
"revenueGrowth": _score_revenue_growth,
"profitQuality": _score_profit_quality,
"cashQuality": _score_cash_quality,
"growthEfficiency": _score_growth_efficiency,
"orgHealth": _score_org_health,
}
# ═══════════════════════════════════════════════
# 明细指标 + 改善建议
# ═══════════════════════════════════════════════
def _fmt(v, unit=""):
if v is None:
return ""
if isinstance(v, float) and v == int(v):
return f"{int(v)}{unit}"
return f"{round(v, 2)}{unit}"
def _dim_indicators(dim_key: str, cur: dict, prev: dict) -> List[dict]:
"""维度明细指标(label/value/verdict/status"""
inds = []
def add(label, value, verdict, status):
inds.append({"label": label, "value": value, "verdict": verdict, "status": status})
if dim_key == "revenueGrowth":
rev, prev_rev = cur.get("F_REVENUE"), (prev or {}).get("F_REVENUE")
growth = None
if rev is not None and prev_rev:
growth = (rev - prev_rev) / prev_rev * 100
add("营业收入", _fmt(rev, ""), "环比" + (_fmt(growth, "%") if growth is not None else "无上期数据"),
"success" if (growth or 0) >= 0 else "danger")
add("收入增长率(KPI)", _fmt(cur.get("F_REVENUE_GROWTH"), "%"),
"KPI直接值" if cur.get("F_REVENUE_GROWTH") is not None else "未录入",
"success" if (cur.get("F_REVENUE_GROWTH") or 0) >= 10 else "warning")
elif dim_key == "profitQuality":
rev, np_ = cur.get("F_REVENUE"), cur.get("F_NET_PROFIT")
nm = (np_ / rev * 100) if (np_ is not None and rev) else None
add("毛利率", _fmt(cur.get("F_GROSS_MARGIN"), "%"),
"毛利健康" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "毛利偏低",
"success" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "danger")
add("净利率", _fmt(nm, "%"),
"盈利" if (nm or 0) > 0 else "亏损",
"success" if (nm or 0) > 10 else "danger")
add("经营利润率", _fmt(cur.get("F_OP_PROFIT_MARGIN"), "%"),
"正常" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "偏低",
"success" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "warning")
elif dim_key == "cashQuality":
ocf, np_ = cur.get("F_OP_CFLOW"), cur.get("F_NET_PROFIT")
gold = (ocf / np_) if (ocf is not None and np_ and np_ > 0) else None
add("经营现金流", _fmt(ocf, ""),
"现金流入" if (ocf or 0) > 0 else "现金流出",
"success" if (ocf or 0) > 0 else "danger")
add("含金量(OCF/净利润)", _fmt(gold, ""),
"含金量高" if (gold or 0) >= 1 else ("利润为负" if (np_ or 0) <= 0 else "含金量低"),
"success" if (gold or 0) >= 1 else "danger")
add("自由现金流", _fmt(cur.get("F_FCF"), ""),
"正常" if (cur.get("F_FCF") or 0) > 0 else "为负",
"success" if (cur.get("F_FCF") or 0) > 0 else "warning")
elif dim_key == "growthEfficiency":
rev, nc = cur.get("F_REVENUE"), cur.get("C_NEW_CLIENTS")
per = (rev / nc) if (rev is not None and nc) else None
add("费用率", _fmt(cur.get("F_COST_RATIO"), "%"),
"费用可控" if (cur.get("F_COST_RATIO") or 0) <= 30 else "费用偏高",
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
add("单位客户营收(万/户)", _fmt(per),
"获客效率高" if (per or 0) >= 200 else "获客效率一般",
"success" if (per or 0) >= 500 else "warning")
add("渠补率", _fmt(cur.get("C_REBATE_RATE"), "%"),
"渠道依赖" if (cur.get("C_REBATE_RATE") or 0) > 50 else "渠道健康",
"danger" if (cur.get("C_REBATE_RATE") or 0) > 50 else "success")
elif dim_key == "orgHealth":
add("费用率(结构)", _fmt(cur.get("F_COST_RATIO"), "%"),
"结构健康" if (cur.get("F_COST_RATIO") or 0) <= 30 else "结构偏重",
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
add("应收周转天数", _fmt(cur.get("F_AR_DAYS"), ""),
"回款快" if (cur.get("F_AR_DAYS") or 0) <= 45 else "回款偏慢",
"success" if (cur.get("F_AR_DAYS") or 0) <= 45 else "warning")
add("客户满意度", _fmt(cur.get("C_SATISFACTION")),
"满意" if (cur.get("C_SATISFACTION") or 0) >= 80 else "待提升",
"success" if (cur.get("C_SATISFACTION") or 0) >= 80 else "warning")
add("培训完成率", _fmt(cur.get("L_TRAINING"), "%"),
"学习投入足" if (cur.get("L_TRAINING") or 0) >= 80 else "学习投入不足",
"success" if (cur.get("L_TRAINING") or 0) >= 80 else "warning")
return inds
def _dim_suggestions(dim_key: str, score: float, cur: dict) -> List[str]:
"""按维度评分生成改善建议"""
if score >= 80:
return ["该维度表现良好,建议保持并固化为标准流程"]
sug = []
if dim_key == "revenueGrowth":
sug = ["挖掘存量客户复购,稳定收入基本盘",
"拓展新渠道/新产品线,提升营收增速",
"跟踪F_REVENUE_GROWTH KPI按月更新,建立增长预警线"]
elif dim_key == "profitQuality":
sug = ["排查毛利率下滑原因(成本/价格/渠补),优先止血",
"控制费用增速不超过收入增速,改善净利率",
"对亏损产品线做盈亏平衡分析,必要时收缩"]
elif dim_key == "cashQuality":
sug = ["加强应收账款催收,缩短回款周期",
"压缩非必要开支,提升经营现金流净额",
"建立现金流月度滚动预测,防范断流风险"]
elif dim_key == "growthEfficiency":
sug = ["优化费用结构,降低费用率至30%以下",
"评估渠道返利政策,降低渠补率与渠道依赖",
"提高获客转化率,降低单位获客成本"]
elif dim_key == "orgHealth":
sug = ["精简组织与费用结构,提升人效",
"强化培训与人才梯队建设(盯L_TRAINING)",
"优化应收管理,缩短周转天数"]
return sug
def _level_of(overall: float) -> dict:
if overall >= 80:
return {"level": "", "level_type": "success", "desc": "增长质量优秀,增长可持续"}
if overall >= 60:
return {"level": "", "level_type": "warning", "desc": "增长质量中等,存在优化空间"}
return {"level": "", "level_type": "danger", "desc": "增长质量堪忧,需立即干预"}
# ═══════════════════════════════════════════════
# 诊断主流程
# ═══════════════════════════════════════════════
def _diagnose(db: Session, entity_id: int, period: str, history: List[dict]):
"""对单个期间执行五维诊断,返回完整诊断对象"""
cur = _fetch_kpi_values(db, entity_id, period)
prev_period = _prev_period(period)
prev = _fetch_kpi_values(db, entity_id, prev_period) if prev_period else {}
scores = {}
dims_payload = {}
for dim in DIMENSIONS:
key = dim["key"]
scorer = _SCORERS[key]
if key == "revenueGrowth":
s = scorer(cur, prev, history)
elif key in ("cashQuality", "orgHealth"):
s = scorer(cur)
else:
s = scorer(cur, prev)
scores[key] = s
dims_payload[key] = {
"key": key, "name": dim["name"], "icon": dim["icon"],
"desc": dim["desc"], "weight": dim["weight"],
"score": s,
"indicators": _dim_indicators(key, cur, prev),
"suggestions": _dim_suggestions(key, s, cur),
}
overall = round(sum(scores.values()) / len(scores), 1)
level = _level_of(overall)
# 诊断结论文本
low_dims = [d for d in DIMENSIONS if scores[d["key"]] < 60]
mid_dims = [d for d in DIMENSIONS if 60 <= scores[d["key"]] < 80]
lines = [f"{period} 综合增长质量评分 {overall} 分({level['level']}):{level['desc']}"]
if low_dims:
lines.append("需重点关注:" + "".join(f"{d['name']}({scores[d['key']]}分)" for d in low_dims) + "")
if mid_dims:
lines.append("可优化:" + "".join(f"{d['name']}({scores[d['key']]}分)" for d in mid_dims) + "")
if not low_dims:
lines.append("各维度均处于健康区间,增长质量扎实。")
diagnosis = "".join(lines)
return {
"entity_id": entity_id,
"period": period,
"overall": overall,
"level": level["level"],
"level_type": level["level_type"],
"diagnosis": diagnosis,
"dimensions": dims_payload,
"kpi_available": {k: cur.get(k) is not None for k in KPI_CODES},
}
@router.get("/periods")
def list_periods(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
"""列出某实体有KPI数据的期间(按月,含数据覆盖度,用于前端默认期间选择)"""
rows = (db.query(KPIValue.period, KPIValue.kpi_id)
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
.filter(KPIDefinition.entity_id == entity_id)
.all())
counts: dict = {}
for period, kpi_id in rows:
if MONTH_RE.match(period or ""):
counts[period] = counts.get(period, 0) + 1
periods = sorted(counts.keys(), reverse=True)
return {"entity_id": entity_id, "periods": periods,
"coverage": {p: counts[p] for p in periods}}
@router.get("/diagnosis")
def growth_quality_diagnosis(
entity_id: int = Depends(get_entity_id),
period: Optional[str] = Query(None, description="期间 YYYY-MM,默认最近有数据期间"),
db: Session = Depends(get_db),
):
"""增长质量诊断 — 五维评分(0-100) + 诊断结论 + 跨期对比(本期/上期/去年同期)"""
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if not ent:
raise HTTPException(404, f"实体 {entity_id} 不存在")
history = _fetch_history(db, entity_id, limit=12)
if not history:
raise HTTPException(400, "该实体暂无月度KPI数据,请先录入KPI实际值")
# 默认取最近且有足够数据覆盖的期间(>=5个KPI值,退化为最近一个)
if not period:
cov_rows = (db.query(KPIValue.period)
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
.filter(KPIDefinition.entity_id == entity_id)
.all())
cov: dict = {}
for (p,) in cov_rows:
if MONTH_RE.match(p or ""):
cov[p] = cov.get(p, 0) + 1
candidates = sorted([p for p in cov if cov[p] >= 5], reverse=True)
period = candidates[0] if candidates else history[-1]["period"]
current = _diagnose(db, entity_id, period, history)
# 跨期对比:上期 + 去年同期
prev_p = _prev_period(period)
yoy_p = _yoy_period(period)
prev_data = _fetch_kpi_values(db, entity_id, prev_p) if prev_p else {}
yoy_data = _fetch_kpi_values(db, entity_id, yoy_p) if yoy_p else {}
comparison = {
"current": {
"period": period, "overall": current["overall"],
"level": current["level"], "level_type": current["level_type"],
"dimensions": {k: v["score"] for k, v in current["dimensions"].items()},
},
}
if prev_p and prev_data:
pdiag = _diagnose(db, entity_id, prev_p, history)
comparison["previous"] = {
"period": prev_p, "overall": pdiag["overall"],
"level": pdiag["level"], "level_type": pdiag["level_type"],
"dimensions": {k: v["score"] for k, v in pdiag["dimensions"].items()},
}
if yoy_p and yoy_data:
ydiag = _diagnose(db, entity_id, yoy_p, history)
comparison["yoy"] = {
"period": yoy_p, "overall": ydiag["overall"],
"level": ydiag["level"], "level_type": ydiag["level_type"],
"dimensions": {k: v["score"] for k, v in ydiag["dimensions"].items()},
}
# 趋势:近12个月综合评分
trend = []
for h in history:
try:
d = _diagnose(db, entity_id, h["period"], history)
trend.append({"period": h["period"], "overall": d["overall"]})
except Exception:
continue
return {
"entity": {"id": ent.id, "name": ent.name, "short_name": ent.short_name},
"period": period,
"overall": current["overall"],
"level": current["level"],
"level_type": current["level_type"],
"diagnosis": current["diagnosis"],
"dimensions": current["dimensions"],
"comparison": comparison,
"trend": trend,
"kpi_available": current["kpi_available"],
}
+258
View File
@@ -0,0 +1,258 @@
"""知识摘要 API — 管理会计OS持久记忆
提供:
- 查询最近摘要列表
- 查询单个摘要详情
- 手动触发各层级摘要生成
- 查询未摘要的事件
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role
from app.models import KnowledgeEvent, KnowledgeSummary
from app.services.knowledge_service import (
generate_summary_sync,
generate_daily_sync,
generate_weekly_sync,
generate_monthly_sync,
get_last_summary,
extract_events,
)
import logging
logger = logging.getLogger("cma.knowledge_api")
router = APIRouter(prefix="/api/cma/knowledge", tags=["知识摘要"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def summary_to_dict(s: KnowledgeSummary) -> dict:
return {
"id": s.id,
"level": s.level,
"period_key": s.period_key,
"title": s.title,
"content": s.content,
"kpi_changes": s.kpi_changes,
"decision_points": s.decision_points,
"key_metrics": s.key_metrics,
"prev_summary_id": s.prev_summary_id,
"model": s.model,
"is_stale": s.is_stale,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
# ── 查询 ──
@router.get("/summaries")
def list_summaries(
level: Optional[str] = None,
limit: int = 20,
offset: int = 0,
db: Session = Depends(get_db),
):
"""获取摘要列表,按层级筛选,按时间倒序"""
query = db.query(KnowledgeSummary)
if level:
query = query.filter(KnowledgeSummary.level == level)
query = query.order_by(desc(KnowledgeSummary.id)).offset(offset).limit(limit)
total = db.query(func.count(KnowledgeSummary.id)).select_from(KnowledgeSummary)
if level:
total = total.filter(KnowledgeSummary.level == level)
total = total.scalar()
return {
"total": total,
"items": [summary_to_dict(s) for s in query.all()],
}
@router.get("/summaries/latest")
def latest_summary(
level: str = Query("daily", description="层级: daily/weekly/monthly/cumulative"),
db: Session = Depends(get_db),
):
"""获取指定层级的最新摘要"""
s = get_last_summary(db, level)
if not s:
return {"detail": f"没有{level}层级的摘要"}, 404
return summary_to_dict(s)
@router.get("/summaries/{summary_id}")
def get_summary(summary_id: int, db: Session = Depends(get_db)):
"""获取单条摘要详情"""
s = db.query(KnowledgeSummary).filter(KnowledgeSummary.id == summary_id).first()
if not s:
raise HTTPException(status_code=404, detail="摘要不存在")
return summary_to_dict(s)
# ── 事件查询 ──
@router.get("/events")
def list_events(
since: Optional[str] = None,
until: Optional[str] = None,
limit: int = 50,
db: Session = Depends(get_db),
):
"""查询未摘要的原始事件
如果不传时间默认返回最近7天的操作记录和预警
"""
try:
dt_since = datetime.fromisoformat(since) if since else datetime.utcnow() - timedelta(days=7)
dt_until = datetime.fromisoformat(until) if until else datetime.utcnow()
except ValueError:
raise HTTPException(status_code=400, detail="时间格式错误,请使用 ISO 格式如 2026-06-01T00:00:00")
events = extract_events(db, dt_since, dt_until)
return {"since": dt_since.isoformat(), "until": dt_until.isoformat(), "total": len(events), "events": events[:limit]}
# ── 手动触发 ──
@router.post("/generate/daily")
def trigger_daily_summary(db: Session = Depends(get_db)):
"""手动触发每日摘要生成"""
try:
result = generate_daily_sync(db)
return {"message": "每日摘要已生成", "summary": result}
except Exception as e:
logger.exception("每日摘要生成失败")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
@router.post("/generate/weekly")
def trigger_weekly_summary(db: Session = Depends(get_db)):
"""手动触发周度摘要生成"""
try:
result = generate_weekly_sync(db)
return {"message": "周度摘要已生成", "summary": result}
except Exception as e:
logger.exception("周度摘要生成失败")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
@router.post("/generate/monthly")
def trigger_monthly_summary(db: Session = Depends(get_db)):
"""手动触发月度摘要生成"""
try:
result = generate_monthly_sync(db)
return {"message": "月度摘要已生成", "summary": result}
except Exception as e:
logger.exception("月度摘要生成失败")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
# ── CMA P1+P2 Round 3: 职业道德自测题 ──
ETHICS_QUIZ_DATA = [
{
"id": 1,
"scenario": '你发现公司财务数据异常(现金比率仅0.6%),但总经理说"先别声张,等融资款到账再说",你应该:',
"options": [
{"id": "a", "text": "听从老板,暂时隐瞒", "correct": False, "feedback": "违反IMA诚信准则。财务人员有义务如实记录和报告"},
{"id": "b", "text": "如实记录异常并向上级或审计委员会报告", "correct": True, "feedback": "诚信+客观准则。IMA要求财务人员如实反映财务状况"},
{"id": "c", "text": "立即辞职,一走了之", "correct": False, "feedback": "可行但非最优解。应先通过正规渠道报告问题"},
],
},
{
"id": 2,
"scenario": "竞争对手公司的高管在行业会议上向你打探你们公司的成本数据,你应该:",
"options": [
{"id": "a", "text": "告知对方,反正行业数据都差不多", "correct": False, "feedback": "违反保密准则。成本数据属于公司机密"},
{"id": "b", "text": "断然拒绝,并告知这是公司机密", "correct": True, "feedback": "保密准则。财务人员必须保护公司机密信息"},
{"id": "c", "text": "用编造的假数据糊弄对方", "correct": False, "feedback": "违反诚信准则。即使对竞争对手也不应提供虚假信息"},
],
},
{
"id": 3,
"scenario": "你被要求分析一项复杂的金融衍生品对公司的影响,但你没有相关专业知识经验,应该:",
"options": [
{"id": "a", "text": "凭感觉估算一个数值交差", "correct": False, "feedback": "违反胜任准则。不能在自己不擅长的领域出具专业意见"},
{"id": "b", "text": "如实告知自己缺乏经验,建议聘请外部专家", "correct": True, "feedback": "胜任准则。IMA要求持续学习并在能力范围内执业"},
{"id": "c", "text": "花一个晚上学习后立即出报告", "correct": False, "feedback": "短期内突击学习难以达到专业标准,建议请专家"},
],
},
{
"id": 4,
"scenario": "供应商送你价值5000元的高档礼品,并暗示希望在招标中获得倾斜,你应该:",
"options": [
{"id": "a", "text": "收下礼物,在招标中适当照顾", "correct": False, "feedback": "违反客观准则。礼品影响职业判断的独立性"},
{"id": "b", "text": "拒收礼品并报告公司合规部门", "correct": True, "feedback": "客观准则。财务人员必须保持独立性和客观判断"},
{"id": "c", "text": "收下但不做任何特殊照顾", "correct": False, "feedback": "即使不特殊照顾,收礼本身已违反公司廉洁规定"},
],
},
{
"id": 5,
"scenario": '销售总监要求你将下季度已签约但未发货的订单提前确认为本季度收入,以"完成业绩指标",你应该:',
"options": [
{"id": "a", "text": "照做,反正迟早会确认", "correct": False, "feedback": "违反诚信准则。收入确认必须符合会计准则的确认条件"},
{"id": "b", "text": "拒绝并解释收入确认应遵循权责发生制", "correct": True, "feedback": "诚信+客观准则。IMA要求如实反映交易实质"},
{"id": "c", "text": "折中处理,先确认一半", "correct": False, "feedback": "部分确认依然违反会计准则和IMA诚信准则"},
],
},
{
"id": 6,
"scenario": "你的亲戚是股票投资者,向你打听公司即将发布的季度盈利数据(尚未公开),你应该:",
"options": [
{"id": "a", "text": "暗示性地透露一点,让亲戚自己去判断", "correct": False, "feedback": "违反保密准则。内幕信息在任何情况下都不能泄露"},
{"id": "b", "text": "明确拒绝并告知这是未公开的内部信息", "correct": True, "feedback": "保密准则。IMA要求保护信息机密性,禁止内幕交易"},
{"id": "c", "text": "告知所有数据需等正式发布后才能讨论", "correct": False, "feedback": "虽然拒绝了但未明确说明信息性质,应更直接拒绝"},
],
},
{
"id": 7,
"scenario": "你在编制税务筹划方案时,发现最新的税法已经更新,但你还没参加过相关培训,应该:",
"options": [
{"id": "a", "text": "按老法规继续编制,客户不会发现", "correct": False, "feedback": "违反胜任准则。必须掌握最新知识才能提供专业服务"},
{"id": "b", "text": "先学习新税法,再基于最新法规编制方案", "correct": True, "feedback": "胜任准则。IMA要求持续提升专业能力,保持知识更新"},
{"id": "c", "text": "请客户另请高明", "correct": False, "feedback": "回避问题不可取。应先通过学习解决问题"},
],
},
{
"id": 8,
"scenario": "月度经营分析会上,CEO要求你只汇报好的指标,不提及存在的风险问题,你应该:",
"options": [
{"id": "a", "text": "听从CEO安排,只报喜不报忧", "correct": False, "feedback": "违反客观准则。选择性披露误导决策"},
{"id": "b", "text": "完整披露所有指标,包括存在风险和问题", "correct": True, "feedback": "客观+诚信准则。IMA要求全面、公正地呈现信息"},
{"id": "c", "text": "在会上只口头上提一下风险但不出现在书面报告里", "correct": False, "feedback": "口头提及无记录,核心问题仍需书面报告"},
],
},
{
"id": 9,
"scenario": "为达成KPI目标获得年终奖,部门经理要求你修改部分实际运营数据,你应该:",
"options": [
{"id": "a", "text": "修改数据,反正金额不大", "correct": False, "feedback": "违反诚信准则。任何金额的数据造假都不可接受"},
{"id": "b", "text": "坚决拒绝并向上级或审计委员会举报", "correct": True, "feedback": "诚信准则。诚实是财务人员的首要职业道德要求"},
{"id": "c", "text": "假装答应但不执行", "correct": False, "feedback": "不明确的回应可能导致后续施压,应明确拒绝"},
],
},
{
"id": 10,
"scenario": "你准备离职去竞争对手公司,离职前应该如何处理手头的公司财务数据文件?",
"options": [
{"id": "a", "text": "拷贝一份带走,作为新公司的参考", "correct": False, "feedback": "违反保密准则。离职不得带走公司机密数据"},
{"id": "b", "text": "全部归还公司,并确认不保留任何副本", "correct": True, "feedback": "保密准则。离职后仍须承担保密义务"},
{"id": "c", "text": "只带走非敏感性的一般统计资料", "correct": False, "feedback": "任何公司未公开数据均不得外传"},
],
},
]
@router.get("/ethics-quiz")
def get_ethics_quiz():
"""返回10道IMA职业道德情景自测题"""
return ETHICS_QUIZ_DATA
+51
View File
@@ -0,0 +1,51 @@
"""知识库文章 API — P1-3 嵌入功能模块用
提供按关联页面查询知识文章的功能
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role
from app.models.knowledge_article import KnowledgeArticle
router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def article_to_dict(a: KnowledgeArticle) -> dict:
return {
"id": a.id,
"title": a.title,
"summary": a.summary,
"content": a.content,
"category": a.category,
"icon": a.icon,
"related_page": a.related_page,
"sort_order": a.sort_order,
}
@router.get("")
def list_articles(
related_page: Optional[str] = Query(None, description="按关联页面路由筛选"),
category: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""查询知识文章,可按关联页面或分类筛选"""
q = db.query(KnowledgeArticle)
if related_page:
q = q.filter(KnowledgeArticle.related_page.contains(related_page))
if category:
q = q.filter(KnowledgeArticle.category == category)
articles = q.order_by(KnowledgeArticle.sort_order.asc(), KnowledgeArticle.id.asc()).all()
return {"data": [article_to_dict(a) for a in articles]}
@router.get("/{article_id}")
def get_article(article_id: int, db: Session = Depends(get_db)):
a = db.query(KnowledgeArticle).filter(KnowledgeArticle.id == article_id).first()
if not a:
raise HTTPException(404, "文章不存在")
return article_to_dict(a)
+428
View File
@@ -0,0 +1,428 @@
"""KPI因果链建模 — 任务2
KPI间因果关系网络 + 模拟推演 + 三层验证机制(数据/AI/人工) (2026-08-27 P2)
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Optional
import logging
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
from app.services.causality_verification import (
ALL_STATUSES,
ALL_SOURCE_TYPES,
STATUS_PENDING,
)
logger = logging.getLogger("kpi-causality")
router = APIRouter(prefix="/api/cma/kpi-causality", tags=["KPI因果链"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
def _to_dict(obj):
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
# ============================================================
# 注意: 静态路径必须放在动态路径之前(/{id}之前)
# ============================================================
@router.get("/full-network")
def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b, 2026-08-27 用entity_id列)"""
edges = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
node_ids = set()
edge_list = []
for e in edges:
node_ids.add(e.source_kpi_id)
node_ids.add(e.target_kpi_id)
edge_list.append({
"source": e.source_kpi_id,
"target": e.target_kpi_id,
"strength": e.strength,
"direction": e.direction,
"lag_months": e.lag_months,
})
# 获取所有节点信息
kpis = db.query(KPIDefinition).filter(KPIDefinition.id.in_(node_ids)).all() if node_ids else []
node_map = {k.id: {
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "category": k.category,
} for k in kpis}
nodes = []
for nid in node_ids:
info = node_map.get(nid, {"id": nid, "kpi_code": f"KPI#{nid}", "kpi_name": f"KPI#{nid}"})
nodes.append(info)
return {"nodes": nodes, "edges": edge_list, "total_edges": len(edge_list)}
@router.get("/kpi/{kpi_id}/network")
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取KPI的因果网络(上游驱动 + 下游影响)— 账套隔离: 校验KPI属于当前企业"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在") # 跨企业不暴露存在性
# 上游(指向当前KPI的因果)
upstream = db.query(KPICausality).filter(
KPICausality.target_kpi_id == kpi_id,
KPICausality.entity_id == entity_id,
).all()
upstream_list = []
for c in upstream:
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
if src:
upstream_list.append({
"causality_id": c.id,
"kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name,
"strength": c.strength, "lag_months": c.lag_months,
"direction": c.direction, "formula": c.formula,
"source_type": c.source_type, "verify_status": c.verify_status,
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
"verified_by": c.verified_by,
})
# 下游(当前KPI指向的因果)
downstream = db.query(KPICausality).filter(
KPICausality.source_kpi_id == kpi_id,
KPICausality.entity_id == entity_id,
).all()
downstream_list = []
for c in downstream:
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
if tgt:
downstream_list.append({
"causality_id": c.id,
"kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name,
"strength": c.strength, "lag_months": c.lag_months,
"direction": c.direction, "formula": c.formula,
"source_type": c.source_type, "verify_status": c.verify_status,
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
"verified_by": c.verified_by,
})
return {
"kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension},
"upstream": upstream_list,
"downstream": downstream_list,
}
@router.post("/simulate")
def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""模拟推演: 修改一个KPI的值,预测对其他KPI的影响
Body: { kpi_id: int, new_value: float, period: str }
"""
kpi_id = data.get("kpi_id")
new_value = data.get("new_value")
period = data.get("period")
if not kpi_id or new_value is None:
raise HTTPException(400, "必须指定kpi_id和new_value")
source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not source_kpi:
raise HTTPException(404, "KPI不存在")
if source_kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
# 获取当前值
current_value = None
query_values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
)
if period:
query_values = query_values.filter(KPIValue.period == period)
latest = query_values.order_by(KPIValue.period.desc()).first()
if latest:
current_value = latest.actual_value
previous_value = current_value or new_value
change_pct = ((new_value - previous_value) / previous_value * 100) if previous_value and previous_value != 0 else 0
# BFS遍历下游因果链
visited = set()
impacts = []
queue = [(kpi_id, change_pct, 0, 1.0)] # (kpi_id, change_pct, depth, cumulative_strength)
while queue:
current_kpi_id, current_change, depth, cum_strength = queue.pop(0)
if current_kpi_id in visited:
continue
visited.add(current_kpi_id)
# 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链)
downstream = db.query(KPICausality).filter(
KPICausality.source_kpi_id == current_kpi_id,
KPICausality.entity_id == entity_id,
).all()
for edge in downstream:
target_id = edge.target_kpi_id
if target_id in visited:
continue
target_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
if not target_kpi:
continue
# 计算影响: 变化率 × 强度 × 方向
edge_strength = edge.strength or 0.5
direction_factor = 1.0 if edge.direction == "positive" else -1.0
propagated_change = current_change * edge_strength * direction_factor
# 获取当前值
tgt_val = db.query(KPIValue).filter(
KPIValue.kpi_id == target_id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
predicted_value = None
if tgt_val and tgt_val.actual_value:
predicted_value = round(tgt_val.actual_value * (1 + propagated_change / 100), 2)
impacts.append({
"kpi_id": target_id,
"kpi_code": target_kpi.kpi_code,
"kpi_name": target_kpi.kpi_name,
"dimension": target_kpi.dimension,
"current_value": tgt_val.actual_value if tgt_val else None,
"predicted_value": predicted_value,
"change_pct": round(propagated_change, 2),
"strength": edge_strength,
"direction": edge.direction,
"lag_months": edge.lag_months,
"depth": depth + 1,
"path_strength": round(cum_strength * edge_strength, 3),
})
# 继续遍历下游
new_cum = cum_strength * edge_strength
if new_cum > 0.05 and depth < 5:
queue.append((target_id, propagated_change, depth + 1, new_cum))
return {
"source": {
"kpi_id": source_kpi.id,
"kpi_code": source_kpi.kpi_code,
"kpi_name": source_kpi.kpi_name,
"current_value": current_value,
"new_value": new_value,
"change_pct": round(change_pct, 2),
},
"impacts": impacts,
"total_impacted": len(impacts),
}
# ============================================================
# CRUD (动态路径)
# ============================================================
@router.get("")
def list_causalities(
source_kpi_id: Optional[int] = None,
target_kpi_id: Optional[int] = None,
verify_status: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b, 2026-08-27 支持verify_status筛选)"""
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
if source_kpi_id:
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
if target_kpi_id:
query = query.filter(KPICausality.target_kpi_id == target_kpi_id)
if verify_status:
query = query.filter(KPICausality.verify_status == verify_status)
items = query.order_by(KPICausality.id).all()
result = []
for c in items:
d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
d["source_kpi_code"] = src.kpi_code if src else None
d["source_kpi_name"] = src.kpi_name if src else None
d["target_kpi_code"] = tgt.kpi_code if tgt else None
d["target_kpi_name"] = tgt.kpi_name if tgt else None
result.append(d)
return {"data": result, "total": len(result)}
@router.get("/verify-status")
def get_verify_status(
verify_status: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""验证状态总览 — 按状态统计 + 链列表(2026-08-27 三层验证机制)
可选 ?verify_status=pending/data_verified/human_verified/disputed 筛选
"""
if verify_status and verify_status not in ALL_STATUSES:
raise HTTPException(400, f"verify_status 必须为 {'/'.join(ALL_STATUSES)}")
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
if verify_status:
query = query.filter(KPICausality.verify_status == verify_status)
items = query.order_by(KPICausality.id).all()
by_status = {s: 0 for s in ALL_STATUSES}
data = []
for c in items:
by_status[c.verify_status] = by_status.get(c.verify_status, 0) + 1
d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
d["source_kpi_code"] = src.kpi_code if src else None
d["source_kpi_name"] = src.kpi_name if src else None
d["target_kpi_code"] = tgt.kpi_code if tgt else None
d["target_kpi_name"] = tgt.kpi_name if tgt else None
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
data.append(d)
return {
"summary": {"total": len(items), "by_status": by_status},
"data": data,
}
@router.get("/{causality_id}")
def get_causality(causality_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
d["source"] = {"id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name} if src else None
d["target"] = {"id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name} if tgt else None
return d
@router.post("")
def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""创建因果链(source_type标记来源, 2026-08-27"""
source_id = data.get("source_kpi_id")
target_id = data.get("target_kpi_id")
if not source_id or not target_id:
raise HTTPException(400, "必须指定源KPI和目标KPI")
if source_id == target_id:
raise HTTPException(400, "源和目标不能相同")
src = db.query(KPIDefinition).filter(KPIDefinition.id == source_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
if not src or not tgt:
raise HTTPException(404, "KPI不存在")
if src.entity_id != tgt.entity_id:
raise HTTPException(400, "源KPI和目标KPI必须属于同一企业")
existing = db.query(KPICausality).filter(
KPICausality.source_kpi_id == source_id,
KPICausality.target_kpi_id == target_id,
).first()
if existing:
raise HTTPException(400, f"因果链已存在: {src.kpi_code}{tgt.kpi_code}")
source_type = data.get("source_type", "manual")
if source_type not in ALL_SOURCE_TYPES:
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
c = KPICausality(
entity_id=src.entity_id,
source_kpi_id=source_id,
target_kpi_id=target_id,
strength=data.get("strength", 0.5),
lag_months=data.get("lag_months", 1),
formula=data.get("formula"),
direction=data.get("direction", "positive"),
source_type=source_type,
verify_status=STATUS_PENDING,
)
db.add(c)
db.commit()
db.refresh(c)
db.add(OperationLog(action="create", target_type="kpi_causality",
detail=f"创建因果链: {src.kpi_code}{tgt.kpi_code} (source={source_type})"))
db.commit()
return _to_dict(c)
@router.put("/{causality_id}")
def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
for field in ("strength", "lag_months", "formula", "direction", "source_type"):
if field in data:
if field == "source_type" and data[field] not in ALL_SOURCE_TYPES:
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
setattr(c, field, data[field])
# 修改链定义后,验证状态回到待检(定义变了旧结论失效)
if any(f in data for f in ("strength", "lag_months", "formula", "direction")):
c.verify_status = STATUS_PENDING
c.verified_at = None
c.verified_by = None
db.commit()
db.refresh(c)
return _to_dict(c)
@router.put("/{causality_id}/verify")
def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""人工确认(战略回顾会核对打标)— 2026-08-27 三层验证机制
Body: { verify_status: "human_verified"|"disputed", verified_by?: "任富海" }
默认打标 human_verified人工最终确认
"""
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
target_status = data.get("verify_status", "human_verified")
if target_status not in ("human_verified", "disputed"):
raise HTTPException(400, "verify_status 必须为 human_verified 或 disputed")
verified_by = data.get("verified_by") or user.name or user.username
c.verify_status = target_status
c.verified_at = datetime.now()
c.verified_by = str(verified_by)[:50]
db.add(OperationLog(action="verify", target_type="kpi_causality",
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})"))
db.commit()
db.refresh(c)
d = _to_dict(c)
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
return d
@router.delete("/{causality_id}")
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if c:
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
db.delete(c)
db.commit()
return {"message": "已删除"}
+205
View File
@@ -0,0 +1,205 @@
"""KPI数据治理4条规则:入库必检 + 元数据必填 + 编码规范 + 战略分级
规则1: 入库必检 dimension/target_value/unit 必填创建/更新强制拦截
规则2: 元数据必填 formula/data_source/data_owner 不能为空或占位符待补充/待指定/-
规则3: 编码规范 kpi_code 必须以 F_/C_/P_/L_ 前缀开头且与 dimension 一致禁止跨层同名
规则4: 战略/运营分级 kpi_level 必须是 strategic/operational
API:
- POST /api/cma/kpi/validate 校验单个KPI数据 {valid, errors}
- GET /api/cma/kpi/governance/audit 全量校验 按4条规则分组的不合规清单
"""
from typing import List, Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition
router = APIRouter(
prefix="/api/cma/kpi",
tags=["KPI数据治理"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# 维度 → 编码前缀
DIM_PREFIX = {"finance": "F", "customer": "C", "process": "P", "learning": "L"}
# 历史遗留兼容前缀: EXT_ = 科目余额表导入的财务科目KPI(仅限 finance 维度)
LEGACY_PREFIX_DIM = {"EXT": "finance", "FB": "process"}
# 品牌级业务KPI前缀(跨维度合法,2026-08-21 治理审计适配: BH_ = 博海业务KPI系列)
BRAND_PREFIXES = ("BH",)
VALID_LEVELS = ("strategic", "operational")
# 视为"未完善"的占位符值
PLACEHOLDERS = ("待补充", "待指定", "待完善", "待定", "暂无", "TBD", "tbd", "-", "--", "N/A", "n/a")
# 规则2必填元数据字段
META_FIELDS = [
("formula", "计算公式"),
("data_source", "数据来源"),
("data_owner", "数据责任人"),
("kpi_name", "KPI名称"),
]
def _clean_str(val) -> str:
if val is None:
return ""
if isinstance(val, str):
return val.strip()
return str(val).strip()
def _is_placeholder(val) -> bool:
"""空值或占位符(待补充/待指定/- 等)视为未完善"""
s = _clean_str(val)
if not s:
return True
return s in PLACEHOLDERS
def validate_kpi_payload(
data: dict,
db: Session = None,
current_kpi_id: Optional[int] = None,
is_update: bool = False,
) -> List[dict]:
"""校验单个KPI数据(4条规则)。
- data: 提交的KPI字段字典创建或更新的载荷
- db: SQLAlchemy Session用于跨层同名/编码唯一性检查可为None
- current_kpi_id: 更新时传KPI自身id避免自检误报
- is_update: 更新模式 仅校验载荷中显式出现的字段
返回 [{rule: int, field: str, message: str}, ...]空列表=合规
"""
issues: List[dict] = []
def add(rule: int, field: str, message: str):
issues.append({"rule": rule, "field": field, "message": message})
code = _clean_str(data.get("kpi_code"))
dimension = _clean_str(data.get("dimension"))
has_code = bool(code)
# ── 规则1: 入库必检 dimension/target_value/unit ──
if not is_update or "dimension" in data:
if not dimension:
add(1, "dimension", "缺少dimension(所属维度: finance/customer/process/learning)")
if not is_update or "target_value" in data:
tv = data.get("target_value")
if tv is None or (isinstance(tv, str) and _clean_str(tv) == ""):
add(1, "target_value", "缺少target_value(目标值)")
if not is_update or "unit" in data:
if _is_placeholder(data.get("unit")):
add(1, "unit", "缺少unit(单位)")
# ── 规则2: 元数据必填(不能为空或占位符)──
for field, label in META_FIELDS:
if not is_update or field in data:
if _is_placeholder(data.get(field)):
add(2, field, f"元数据未完善: {label}({field})不能为空或占位符(待补充/待指定/-)")
# ── 规则3: 编码规范 ──
if not is_update or "kpi_code" in data:
if not has_code:
add(3, "kpi_code", "缺少kpi_code(KPI编码)")
else:
prefix = code.split("_")[0] if "_" in code else code
if prefix not in ("F", "C", "P", "L"):
# 兼容历史遗留 EXT_ 前缀(科目余额表导入的财务科目KPI,仅限finance维度)
# 兼容 FB_ 前缀(财务Bot KPI,仅限process维度)与 BH_ 品牌前缀(跨维度合法)
if not (LEGACY_PREFIX_DIM.get(prefix) and dimension == LEGACY_PREFIX_DIM[prefix]) \
and prefix not in BRAND_PREFIXES:
add(3, "kpi_code", f"编码前缀不符: {code} 应以F_/C_/P_/L_开头")
elif dimension and DIM_PREFIX.get(dimension) and prefix != DIM_PREFIX[dimension]:
expected = DIM_PREFIX[dimension]
add(3, "kpi_code",
f"编码前缀与维度不符: {code} 前缀{prefix}_ 与维度{dimension}(应为{expected}_)不一致")
# 禁止跨层同名: 同一kpi_code不能用于不同dimension
if has_code and db is not None:
dup = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if dup and dup.id != current_kpi_id:
if dimension and dup.dimension and dup.dimension != dimension:
add(3, "kpi_code",
f"跨层同名: {code} 已用于维度{dup.dimension},不能用于维度{dimension}")
elif not dimension:
add(3, "kpi_code", f"编码已存在: {code} 已注册(维度{dup.dimension}),不能重复使用")
# ── 规则4: 战略/运营分级 ──
if not is_update or "kpi_level" in data:
lv = data.get("kpi_level")
if lv is not None and lv not in VALID_LEVELS:
add(4, "kpi_level", f"kpi_level必须是strategic或operational,当前值: {lv}")
return issues
def kpi_issues_message(issues: List[dict]) -> List[str]:
"""issue dict列表 → 纯文本错误列表"""
return [i["message"] for i in issues]
@router.post("/validate")
def validate_kpi(
kpi_data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""校验单个KPI数据(不入库)。
请求体: KPI字段字典可选 kpi_id 标识正在编辑的KPI避免跨层同名误报
返回: {"valid": bool, "errors": [str], "details": [{rule, field, message}]}
"""
kpi_id = kpi_data.get("kpi_id") if isinstance(kpi_data.get("kpi_id"), int) else None
issues = validate_kpi_payload(kpi_data, db=db, current_kpi_id=kpi_id)
return {
"valid": len(issues) == 0,
"errors": kpi_issues_message(issues),
"details": issues,
}
@router.get("/governance/audit")
def governance_audit(
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""全量校验所有活跃KPI,输出按4条规则分组的不合规清单。"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
non_compliant = []
by_rule: dict = {1: [], 2: [], 3: [], 4: []}
for k in kpis:
payload = {c.name: getattr(k, c.name) for c in k.__table__.columns}
issues = validate_kpi_payload(payload, db=db, current_kpi_id=k.id)
if issues:
entry = {
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"kpi_level": k.kpi_level,
"issues": issues,
}
non_compliant.append(entry)
for i in issues:
by_rule.setdefault(i["rule"], []).append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"field": i["field"],
"message": i["message"],
})
rule_counts = {str(r): len(items) for r, items in by_rule.items()}
return {
"total": len(kpis),
"compliant": len(kpis) - len(non_compliant),
"non_compliant_count": len(non_compliant),
"rule_counts": rule_counts,
"by_rule": {str(r): items for r, items in by_rule.items()},
"non_compliant": non_compliant,
}
+700 -14
View File
@@ -7,8 +7,10 @@ from datetime import datetime
import json
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
@@ -26,6 +28,8 @@ def list_kpis(
keyword: Optional[str] = None,
epic: Optional[str] = None,
category: Optional[str] = None,
entity_id: int = Depends(get_entity_id),
kpi_level: Optional[str] = None,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
@@ -35,16 +39,25 @@ def list_kpis(
if dims:
query = query.filter(KPIDefinition.dimension.in_(dims))
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
dims_list = [d.strip() for d in dimension.split(',')] if ',' in dimension else [dimension]
query = query.filter(KPIDefinition.dimension.in_(dims_list))
if keyword:
query = query.filter(KPIDefinition.kpi_name.contains(keyword))
if epic:
query = query.filter(KPIDefinition.epic == epic)
if category:
query = query.filter(KPIDefinition.category == category)
cats_list = [c.strip() for c in category.split(',')] if ',' in category else [category]
query = query.filter(KPIDefinition.category.in_(cats_list))
if entity_id is not None:
query = query.filter(KPIDefinition.entity_id == entity_id)
if kpi_level:
query = query.filter(KPIDefinition.kpi_level == kpi_level)
total = query.count()
kpis = query.order_by(KPIDefinition.kpi_code).offset((page-1)*page_size).limit(page_size).all()
return {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
result = {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
if entity_id is not None:
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if ent:
result["entity"] = {"id": ent.id, "name": ent.name, "short_name": ent.short_name}
return result
@router.get("/categories")
@@ -89,20 +102,631 @@ def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depen
return {"tree": tree, "total": sum(dim_counts.values())}
@router.get("/{kpi_id}")
def get_kpi(kpi_id: int, db: Session = Depends(get_db)):
# ============================================================
# KPI-5: 五档评分引擎(静态路由必须在动态/{kpi_id}之前)
# ============================================================
REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RATIO',
'F_COST_RATIO', 'F_AR_DAYS', 'F_REBATE_RATE',
'F_FACTORY_REBATE_RATE', 'F_COST_CONTROL_RATE', 'F_INV_DAYS']
def _calc_five_tier_score(current_value, target_value, is_reverse=False):
"""五档评分:1-5分(支持正反向指标)"""
if current_value is None or target_value is None or target_value == 0:
return None, "info"
ratio = current_value / target_value
if is_reverse:
# 反向指标:实际值越低越好
if ratio <= 0.5:
return 5, "success" # 远低于目标→卓越
elif ratio <= 0.8:
return 4, "success" # 低于目标→达标
elif ratio <= 1.0:
return 3, "warning" # 接近目标→预警
elif ratio <= 1.2:
return 2, "danger" # 超过目标→危险
else:
return 1, "danger" # 远超目标→失效
else:
if ratio >= 1.2:
return 5, "success" # 卓越
elif ratio >= 1.0:
return 4, "success" # 达标
elif ratio >= 0.8:
return 3, "warning" # 预警
elif ratio >= 0.5:
return 2, "danger" # 危险
else:
return 1, "danger" # 失效
@router.get("/score")
def get_kpi_score(
entity_id: int = Depends(get_entity_id),
period: Optional[str] = None,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""五档评分引擎 - 返回各KPI评分和BSC四层汇总
评分: 5卓越(1.2×目标) 4达标(目标) 3预警(0.8×目标) 2危险(0.5×目标) 1失效(<0.5×目标)
"""
# 获取该企业所有活跃KPI
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
if not kpis:
return {"entity_id": entity_id, "kpis": [], "layers": {}, "overall": None}
# 获取企业信息
ent = db.query(Entity).filter(Entity.id == entity_id).first()
entity_info = {"id": ent.id, "name": ent.name, "short_name": ent.short_name} if ent else {"id": entity_id}
# 单个KPI评分
kpi_scores = []
for k in kpis:
# 取最新实际值
val_query = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
)
if period:
val_query = val_query.filter(KPIValue.period == period)
latest_val = val_query.order_by(KPIValue.period.desc()).first()
current_val = latest_val.actual_value if latest_val else None
score, status = _calc_five_tier_score(current_val, k.target_value, is_reverse=(k.kpi_code in REVERSE_INDICATORS))
kpi_scores.append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"target_value": k.target_value,
"current_value": current_val,
"score": score,
"status": status,
"unit": k.unit,
"weight": 10,
"period": latest_val.period if latest_val else None,
})
# BSC四层汇总
layer_map = {
"finance": {"label": "财务", "order": 0},
"customer": {"label": "客户", "order": 1},
"process": {"label": "流程", "order": 2},
"learning": {"label": "学习成长", "order": 3},
}
layers = {}
total_weighted_score = 0
total_weight = 0
for dim_key, dim_info in layer_map.items():
layer_kpis = [s for s in kpi_scores if s["dimension"] == dim_key and s["score"] is not None]
if not layer_kpis:
layers[dim_key] = {"label": dim_info["label"], "score": None, "status": "info", "kpi_count": 0, "weighted_score": None}
continue
w = sum(k["weight"] for k in layer_kpis)
ws = sum(k["score"] * k["weight"] for k in layer_kpis)
avg_score = ws / w if w > 0 else None
avg_status = "success" if avg_score and avg_score >= 4 else ("warning" if avg_score and avg_score >= 3 else "danger") if avg_score else "info"
layers[dim_key] = {
"label": dim_info["label"],
"score": round(avg_score, 2) if avg_score else None,
"status": avg_status,
"kpi_count": len(layer_kpis),
"weighted_score": round(avg_score, 2) if avg_score else None,
}
if avg_score:
total_weighted_score += avg_score * len(layer_kpis)
total_weight += len(layer_kpis)
# 综合得分
overall_score = round(total_weighted_score / total_weight, 2) if total_weight > 0 else None
overall_status = "success" if overall_score and overall_score >= 4 else ("warning" if overall_score and overall_score >= 3 else "danger") if overall_score else "info"
return {
"entity": entity_info,
"kpis": kpi_scores,
"layers": layers,
"overall": {"score": overall_score, "status": overall_status},
}
# ============================================================
# KPI-glossary: 知识资产化 — KPI字典实时加载(供ChatBI财务Bot调用)
# ============================================================
@router.get("/glossary")
def get_kpi_glossary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""KPI字典实时加载 — 返回所有KPI的定义、当前值、目标值、公式、维度、阈值
供ChatBI财务Bot在分析前调用确保口径与系统一致
返回字段: kpi_code, kpi_name, current_value, target_value, formula, dimension, threshold
"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).order_by(KPIDefinition.kpi_code).all()
result = []
for k in kpis:
# 获取最新实际值
latest_val = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
current_value = latest_val.actual_value if latest_val else None
latest_period = latest_val.period if latest_val else None
# 组装阈值描述
threshold = None
if k.threshold_green or k.threshold_yellow or k.threshold_red:
parts = []
if k.threshold_green:
parts.append(f"绿灯:{k.threshold_green}")
if k.threshold_yellow:
parts.append(f"黄灯:{k.threshold_yellow}")
if k.threshold_red:
parts.append(f"红灯:{k.threshold_red}")
threshold = " | ".join(parts)
result.append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"category": k.category,
"formula": k.formula,
"formula_desc": k.formula_desc,
"unit": k.unit,
"target_value": k.target_value,
"current_value": current_value,
"latest_period": latest_period,
"threshold": threshold,
"responsible_dept": k.responsible_dept,
"responsible_user": k.responsible_user,
"data_source": k.data_source,
"data_owner": k.data_owner,
"frequency": k.frequency,
"status": k.status,
})
return {
"entity_id": entity_id,
"total": len(result),
"glossary": result,
}
# ============================================================
# KPI-6: KPI三级分解树
# ============================================================
@router.get("/hierarchy")
def get_kpi_hierarchy(
entity_id: int = Depends(get_entity_id),
kpi_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""KPI三级分解树:公司→部门→个人"""
query = db.query(KPIHierarchy).filter(KPIHierarchy.entity_id == entity_id)
if kpi_id is not None:
query = query.filter(
(KPIHierarchy.parent_kpi_id == kpi_id) | (KPIHierarchy.child_kpi_id == kpi_id)
)
relations = query.order_by(KPIHierarchy.level).all()
if not relations:
# 无层级数据,返回公司级KPI作为根节点
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).limit(20).all()
return {
"entity_id": entity_id,
"tree": [{"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "level": 1, "children": []} for k in kpis],
"total": len(kpis),
}
# 构建树
kpi_ids = set()
for r in relations:
kpi_ids.add(r.parent_kpi_id)
kpi_ids.add(r.child_kpi_id)
kpi_map = {}
for kid in kpi_ids:
k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
if k:
kpi_map[kid] = {"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "level": None, "children": []}
# 分配层级
for r in relations:
if r.parent_kpi_id in kpi_map:
kpi_map[r.parent_kpi_id]["level"] = 1 # 公司级
if r.child_kpi_id in kpi_map:
current_level = kpi_map[r.child_kpi_id].get("level")
new_level = r.level or 2
if current_level is None or current_level > new_level:
kpi_map[r.child_kpi_id]["level"] = new_level
# 构造父子关系
tree = []
added = set()
for r in relations:
parent = kpi_map.get(r.parent_kpi_id)
child = kpi_map.get(r.child_kpi_id)
if parent and child:
child_node = dict(child)
child_node["weight"] = r.weight
child_node["child_name"] = r.child_name
# 避免重复添加
child_key = r.child_kpi_id
existing_child = next(
(c for c in parent["children"] if c["id"] == child_key), None
)
if not existing_child:
parent["children"].append(child_node)
# 收集顶级节点(有子节点且未被引用的parent)
all_child_ids = {r.child_kpi_id for r in relations}
for r in relations:
pid = r.parent_kpi_id
if pid not in all_child_ids or pid == (kpi_id if kpi_id else -1):
if pid not in added and pid in kpi_map:
tree.append(kpi_map[pid])
added.add(pid)
# 如果kpi_id指定,返回该节点为根的子树
if kpi_id is not None and kpi_id in kpi_map:
root = kpi_map[kpi_id]
return {"entity_id": entity_id, "tree": [root], "total": len(tree)}
# 否则按level排序
tree.sort(key=lambda n: (n.get("level") or 99, n["kpi_code"]))
return {"entity_id": entity_id, "tree": tree, "total": len(tree)}
# ============================================================
# KPI-8: KPI因果链追踪
# ============================================================
@router.get("/{kpi_id}/causality-chain")
def get_kpi_causality_chain(
kpi_id: int,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""KPI因果链追踪 — 返回单个KPI的上下游因果链"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
return kpi_to_dict(kpi)
# 上游(驱动当前KPI的因子)
upstream = db.query(KPICausality).filter(KPICausality.target_kpi_id == kpi_id).all()
upstream_list = []
for c in upstream:
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
if src:
upstream_list.append({
"causality_id": c.id,
"kpi_id": src.id,
"kpi_code": src.kpi_code,
"kpi_name": src.kpi_name,
"dimension": src.dimension,
"layer": src.dimension,
"strength": c.strength,
"lag_months": c.lag_months,
"direction": c.direction,
"formula": c.formula,
})
# 下游(当前KPI影响的指标)
downstream = db.query(KPICausality).filter(KPICausality.source_kpi_id == kpi_id).all()
downstream_list = []
for c in downstream:
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
if tgt:
downstream_list.append({
"causality_id": c.id,
"kpi_id": tgt.id,
"kpi_code": tgt.kpi_code,
"kpi_name": tgt.kpi_name,
"dimension": tgt.dimension,
"layer": tgt.dimension,
"strength": c.strength,
"lag_months": c.lag_months,
"direction": c.direction,
"formula": c.formula,
})
return {
"kpi": {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"layer": kpi.dimension,
},
"drives": downstream_list,
"driven_by": upstream_list,
"total_upstream": len(upstream_list),
"total_downstream": len(downstream_list),
}
# ============================================================
# 动态路由(必须在静态路由之后)
# ============================================================
@router.post("/{kpi_id}/values")
def create_kpi_value(
kpi_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user: User = Depends(require_auth),
):
"""录入KPI单值(人工数据录入,用于客户/流程/学习层台账数据)
Body: {period: '2026-08', actual_value: 85}
"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
period = data.get("period")
actual_value = data.get("actual_value")
if not period or actual_value is None:
raise HTTPException(400, "缺少必要参数: period, actual_value")
# 同一期间重复录入 → 更新
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == period,
KPIValue.source_type == "manual",
).first()
if existing:
existing.actual_value = float(actual_value)
existing.data_status = "verified"
existing.remark = f"人工录入(更新) by {current_user.username}"
db.commit()
return {"message": "已更新", "id": existing.id}
new_val = KPIValue(
kpi_id=kpi_id,
entity_id=entity_id,
period=period,
actual_value=float(actual_value),
source_type="manual",
source_batch=f"manual-{current_user.username}-{datetime.now().strftime('%Y%m%d')}",
data_status="verified",
remark=f"人工录入 by {current_user.username}",
)
db.add(new_val)
db.commit()
return {"message": "已录入", "id": new_val.id, "period": period, "actual_value": float(actual_value)}
@router.get("/{kpi_id}/values")
def list_kpi_values(
kpi_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user: User = Depends(require_auth),
):
"""查询KPI实际值列表(含source_type标记,供归集标签页展示)"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
vals = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.desc()).all()
return {
"data": [{
"id": v.id,
"period": v.period,
"actual_value": v.actual_value,
"source_type": v.source_type or "manual",
"source_batch": v.source_batch or "",
"data_status": v.data_status,
"remark": v.remark or "",
} for v in vals]
}
@router.get("/{kpi_id}")
def get_kpi(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 账套隔离: 禁止跨企业读取 (2026-08-23 P1a)
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
result = kpi_to_dict(kpi)
# 附带历史数据(前端KPI详情"历史数据"Tab依赖)
# 2026-08-26修复: 原实现只返回定义信息,values缺失导致历史数据Tab空白
# 2026-08-26增强: values对齐KPI元数据(目标值/偏差/红黄绿判定/单位)
vals = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.asc()).all()
is_reverse = kpi.kpi_code in REVERSE_INDICATORS
target = kpi.target_value
result["values"] = []
for v in vals:
# 判定红黄绿(对齐工作台语义:正向≥0.9绿/≥0.7黄/否则红;反向≤目标绿/≤1.1倍黄/否则红)
level = "info"
score = None
if target and v.actual_value is not None:
if is_reverse:
if v.actual_value <= target:
level = "green"
elif v.actual_value <= target * 1.1:
level = "yellow"
else:
level = "red"
else:
ratio = v.actual_value / target
if ratio >= 0.9:
level = "green"
elif ratio >= 0.7:
level = "yellow"
else:
level = "red"
score, _ = _calc_five_tier_score(v.actual_value, target, is_reverse=is_reverse)
# 偏差率(相对目标)
deviation = None
if target and target != 0 and v.actual_value is not None:
deviation = round((v.actual_value - target) / target * 100, 1)
result["values"].append({
"id": v.id,
"period": v.period,
"actual_value": v.actual_value,
"target_value": target,
"unit": kpi.unit or "",
"deviation_pct": deviation,
"level": level,
"score": score,
"source_type": v.source_type,
"data_status": v.data_status,
"source_batch": v.source_batch,
})
return result
def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] = None, is_update: bool = False):
"""数据治理4条规则校验(入库必检+元数据+编码规范+战略分级),返回错误信息列表"""
issues = validate_kpi_payload(data, db=db, current_kpi_id=current_kpi_id, is_update=is_update)
return kpi_issues_message(issues)
# ════════════════════════════════════════════════════════════
# KPI多粒度目标:指标类型推断 + 周期目标派生(docs/kpi-design-rule.md 落地)
# 规则:累计型 月×3=季、月×12=年(季×4=年);比率型 季/年沿用基准(可手调)
# 派生为"虚拟展示值":DB只存用户手填真值,API返回时补派生值+derived标记
# ════════════════════════════════════════════════════════════
RATIO_NAME_HINTS = ['', '', '满意度', '周转', '时长', '周期', '天数', '指数', 'NPS', 'LTV', 'CAC',
'份额', '集中度', '响应', '完成', '达成', '人均', '单价', '净推荐', '覆盖', '保留',
'复购', '转介绍', '投诉', '合规', '认证', '掌握', '胜任', '认知', '采纳', '引用',
'复用', '一致性', '准确', '间隙', '时效', '及时']
ACCUM_NAME_HINTS = ['营收', '收入', '利润', '净利', '销售', '客户数', '新客', '新增', '产量', '销量',
'金额', '现金流', '回款', '毛利额', '产值', '储备', '数量', '篇数', '报告产出',
'提案', '发现数', '知识沉淀', '招待费']
RATIO_UNIT_HINTS = ['%', '', '', '', '小时', '分钟']
ACCUM_UNIT_HINTS = ['万元', '', '', '', '', '', '', '']
def infer_calc_type(kpi_code: str = "", kpi_name: str = "", unit: str = "") -> str:
"""推断指标类型: accumulate累计(可乘) / ratio比率(不可乘)。名称关键词优先于单位"""
n = (kpi_name or "") + " " + (kpi_code or "")
u = unit or ""
if any(k in n for k in RATIO_NAME_HINTS):
return "ratio"
if any(k in n for k in ACCUM_NAME_HINTS):
return "accumulate"
if u in RATIO_UNIT_HINTS or u.startswith("小时"):
return "ratio"
if u in ACCUM_UNIT_HINTS:
return "accumulate"
return "ratio" # 兜底比率(率值不能乘,更安全)
def derive_cycle_targets(kpi) -> dict:
"""按指标类型派生月/季/年目标(虚拟值,不落库)。
返回: {"derived": {monthly/quarterly/yearly: 显示值}, "flags": {monthly/quarterly/yearly: 是否派生}}
"""
calc_type = (getattr(kpi, "target_calc_type", None) or infer_calc_type(
kpi.kpi_code or "", kpi.kpi_name or "", kpi.unit or "")).lower()
m = kpi.target_monthly
q = kpi.target_quarterly
y = kpi.target_yearly
freq = (kpi.frequency or "monthly").lower()
# 基准值(考核周期优先,回退 target_value
base = None
if freq == "yearly":
base = y
elif freq in ("quarterly", "half_year"):
base = q
elif freq in ("monthly", "weekly"):
base = m
if base is None:
base = kpi.target_value
# 无基准值则不派生
if base is None:
return {"derived": {"monthly": m, "quarterly": q, "yearly": y},
"flags": {"monthly": False, "quarterly": False, "yearly": False}}
dm, dq, dy = m, q, y
fm, fq, fy = False, False, False
if calc_type == "accumulate":
# 锚点月值:手填月目标优先;月基准且手填月空时用 target_value 回退
anchor_m = dm
if anchor_m is None and base is not None and freq in ("monthly", "weekly"):
anchor_m = base
if anchor_m is not None:
if dm is None:
dm = anchor_m # target_value 回退显示为月基准
if dq is None:
dq, fq = anchor_m * 3, True
if dy is None:
dy, fy = anchor_m * 12, True
elif dq is not None:
# 季基准(累计型):年=季×4;月不反推(避免小数噪声)
if dy is None:
dy, fy = dq * 4, True
else: # ratio:季/年沿用基准,不乘
if dq is None:
dq, fq = base, True
if dy is None:
dy, fy = base, True
return {"derived": {"monthly": dm, "quarterly": dq, "yearly": dy},
"flags": {"monthly": fm, "quarterly": fq, "yearly": fy}}
def apply_calc_type_inference(data: dict, infer_missing: bool = True) -> dict:
"""create/update 前:未显式传 target_calc_type 时按名称/单位推断。
infer_missing=Falseupdate场景仅当用户显式传了空值时推断未传则保留DB原值"""
if "target_calc_type" in data:
if not data.get("target_calc_type"):
data["target_calc_type"] = infer_calc_type(
data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", ""))
elif infer_missing:
data["target_calc_type"] = infer_calc_type(
data.get("kpi_code", ""), data.get("kpi_name", ""), data.get("unit", ""))
return data
@router.post("")
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
# 检查编码唯一性
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", "")).first()
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == data.get("kpi_code", ""), KPIDefinition.entity_id == entity_id).first()
if existing:
raise HTTPException(400, f"KPI编码 {data['kpi_code']} 已存在")
# 数据治理校验(规则1强制拦截)
errs = _validate_kpi_data(data, db=db, is_update=False)
if errs:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
data["entity_id"] = entity_id # 账套隔离: 强制写入token企业 (2026-08-23 P1a)
data = apply_calc_type_inference(data)
kpi = KPIDefinition(**data)
db.add(kpi)
db.commit()
@@ -112,10 +736,19 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
@router.put("/{kpi_id}")
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 账套隔离: 禁止跨企业修改 (2026-08-23 P1a)
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
# 数据治理校验(更新时只检查传了但为空的字段)
errs = _validate_kpi_data(data, db=db, current_kpi_id=kpi_id, is_update=True)
if errs:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": errs})
data.pop("entity_id", None) # 禁止通过update改企业归属
data = apply_calc_type_inference(data, infer_missing=False)
for k, v in data.items():
if hasattr(kpi, k) and v is not None:
setattr(kpi, k, v)
@@ -125,16 +758,69 @@ def update_kpi(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRIT
@router.delete("/{kpi_id}")
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi:
# 账套隔离: 禁止跨企业删除 (2026-08-23 P1a)
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
kpi.status = "disabled"
db.commit()
return {"message": "已删除"}
@router.put("/{kpi_id}/restore")
def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi:
# 账套隔离: 禁止跨企业恢复 (2026-08-23 P1a)
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
kpi.status = "active"
db.commit()
return {"message": "已恢复"}
def kpi_to_dict(k):
return {c.name: getattr(k, c.name) for c in k.__table__.columns}
d = {c.name: getattr(k, c.name) for c in k.__table__.columns}
# 多粒度目标派生:月/季/年显示值 + derived标记(虚拟,不落库)
try:
der = derive_cycle_targets(k)
d["derived_targets"] = der["derived"]
d["derived_flags"] = der["flags"]
except Exception:
d["derived_targets"] = {"monthly": k.target_monthly, "quarterly": k.target_quarterly, "yearly": k.target_yearly}
d["derived_flags"] = {"monthly": False, "quarterly": False, "yearly": False}
# 附加战略地图信息
if k.map_id:
from app.database import get_session_local
try:
sess = get_session_local()()
m = sess.query(StrategicMap).filter(StrategicMap.id == k.map_id).first()
d["map_title"] = m.title if m else None
sess.close()
except:
d["map_title"] = None
else:
d["map_title"] = None
return d
@router.put("/{kpi_id}/associate-map")
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""关联KPI到战略地图"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
map_id = data.get("map_id")
if map_id is not None:
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
kpi.map_id = map_id
db.commit()
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id})
return kpi_to_dict(kpi)
def _log(db, user_id, action, target_type, target_id, detail):
+49
View File
@@ -0,0 +1,49 @@
"""AI获客 - 客户咨询接口"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import json, os, datetime, urllib.request
router = APIRouter(prefix="/api/cma/lead", tags=["AI获客"])
class LeadRequest(BaseModel):
name: str
phone: str
requirement: str = ""
source: str = "sxbh.ltd"
def notify_wecom(name, phone, requirement, source):
"""推送到企微全员群"""
msg = {
"msgtype": "markdown",
"markdown": {
"content": f"## 🔔 新客户咨询\n**姓名**: {name}\n**电话**: {phone}\n**需求**: {requirement}\n**来源**: {source}\n**时间**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}"
}
}
try:
data = json.dumps(msg).encode('utf-8')
req = urllib.request.Request(
"http://127.0.0.1:8800/send",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
urllib.request.urlopen(req, timeout=5)
except:
pass # 通知失败不影响主流程
@router.post("")
def create_lead(data: LeadRequest):
"""接收客户咨询并保存"""
record = data.model_dump()
record["timestamp"] = datetime.datetime.now().isoformat()
record["status"] = "new"
log_dir = "/root/leads"
os.makedirs(log_dir, exist_ok=True)
with open(f"{log_dir}/leads.json", "a") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
# 异步推送企微通知
notify_wecom(data.name, data.phone, data.requirement, data.source)
return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"}
+543 -52
View File
@@ -2,9 +2,11 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import date, datetime, timedelta
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import StrategicMap, OperationLog
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
import json
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
@@ -15,46 +17,44 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
STRATEGIC_MAP_TEMPLATE = [
{
"key": "finance",
"name": "财务维度",
"name": "财务",
"icon": "💰",
"color": "#409eff",
"color": "#F56C6C",
"objectives": [
{"name": "提升销售总额", "kpis": ["F_REVENUE_001"]},
{"name": "优化利润结构", "kpis": ["F_PROFIT_001"]},
{"name": "降低运营成本", "kpis": ["F_COST_001"]},
{"name": "营收目标", "kpis": ["F_REVENUE"]},
{"name": "净利润率", "kpis": ["F_NET_PROFIT"]},
{"name": "现金流", "kpis": ["F_OP_CFLOW"]},
],
},
{
"key": "customer",
"name": "客户维度",
"icon": "🤝",
"color": "#67c23a",
"name": "客户",
"icon": "👥",
"color": "#409EFF",
"objectives": [
{"name": "扩大客户规模", "kpis": ["C_CUST_001"]},
{"name": "提升客户满意度", "kpis": ["C_CUST_003"]},
{"name": "优化客户结构", "kpis": ["C_CUST_002"]},
{"name": "客户满意度", "kpis": ["C_SATISFACTION"]},
{"name": "市场份额", "kpis": ["C_MARKET_SHARE"]},
{"name": "客户保留率", "kpis": ["C_RETENTION_RATE"]},
],
},
{
"key": "process",
"name": "内部流程",
"name": "内部流程",
"icon": "⚙️",
"color": "#e6a23c",
"color": "#67C23A",
"objectives": [
{"name": "提升运营效率", "kpis": ["P_INV_001"]},
{"name": "优化供应链管理", "kpis": ["P_INV_002"]},
{"name": "确保交付质量", "kpis": ["P_SERVICE_001"]},
{"name": "运营效率", "kpis": ["P_DELIVERY"]},
{"name": "质量合格率", "kpis": ["P_PASS_RATE"]},
],
},
{
"key": "learning",
"name": "学习成长",
"name": "学习成长",
"icon": "📚",
"color": "#f56c6c",
"color": "#E6A23C",
"objectives": [
{"name": "提升员工技能", "kpis": ["L_TALENT_001"]},
{"name": "推进数字化转型", "kpis": []},
{"name": "建设人才梯队", "kpis": ["L_TALENT_004", "L_TALENT_003"]},
{"name": "关键岗位胜任度", "kpis": ["L_COMPETENCY"]},
{"name": "培训完成率", "kpis": ["L_TRAINING"]},
],
},
]
@@ -62,53 +62,112 @@ STRATEGIC_MAP_TEMPLATE = [
# ── CRUD ────────────────────────────────────
@router.get("")
def list_maps(db: Session = Depends(get_db)):
maps = db.query(StrategicMap).order_by(StrategicMap.updated_at.desc()).all()
return {"data": [m_to_dict(m) for m in maps]}
def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离: 仅当前企业地图 (2026-08-25)
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
return {"data": [m_to_dict(m, db) for m in maps]}
@router.post("")
def create_map(data: dict, db: Session = Depends(get_db)):
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
if "dimensions" not in data or data.get("dimensions") is None:
data = {**data, "dimensions": [
{"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C", "objectives": []},
{"key": "customer", "name": "客户层", "icon": "👥", "color": "#409EFF", "objectives": []},
{"key": "process", "name": "流程层", "icon": "⚙️", "color": "#67C23A", "objectives": []},
{"key": "learning", "name": "学习层", "icon": "📚", "color": "#E6A23C", "objectives": []},
]}
if "canvas_data" not in data or data.get("canvas_data") is None:
data = {**data, "canvas_data": {"connections": []}}
data["entity_id"] = entity_id # 账套隔离: 强制token企业
m = StrategicMap(**data)
db.add(m)
db.commit()
db.refresh(m)
return m_to_dict(m)
_sync_map_objectives(m, db)
return m_to_dict(m, db)
@router.post("/create-with-template")
def create_map_with_template(data: dict, db: Session = Depends(get_db)):
"""一键创建带四维度模板的战略地图"""
def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""一键创建带四维度模板的战略地图(账套隔离: 强制token企业)"""
m = StrategicMap(
title=data.get("title", "新建战略地图"),
version=data.get("version", "v1.0"),
status="draft",
entity_id=entity_id,
dimensions=STRATEGIC_MAP_TEMPLATE,
canvas_data={"connections": []},
)
db.add(m)
db.commit()
db.refresh(m)
return m_to_dict(m)
_sync_map_objectives(m, db)
return m_to_dict(m, db)
@router.put("/{map_id}")
def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
# 账套隔离: 禁止跨企业修改
if m.entity_id != entity_id:
raise HTTPException(404, "战略地图不存在")
old_status = m.status
# 安全白名单: 只允许编辑业务字段, 禁止 entity_id 等敏感字段被请求体篡改 (OpenCode审查#7)
# status 允许但仅限合法值域(draft/published), 防止任意发布
ALLOWED_MAP_FIELDS = {"title", "version", "dimensions", "canvas_data", "description", "status"}
for k, v in data.items():
if hasattr(m, k) and v is not None:
if k in ALLOWED_MAP_FIELDS and hasattr(m, k) and v is not None:
if k == "status" and v not in ("draft", "published"):
continue
setattr(m, k, v)
db.commit()
# 同步目标到map_objectives表
_sync_map_objectives(m, db)
# KR完整修复(2026-08-27): 同步 objectives+krs 表
_sync_objectives_krs(m, db)
# ├─ 版本管理: draft → published 时自动创建快照
if old_status == "draft" and m.status == "published":
_auto_snapshot(m, db)
return m_to_dict(m)
return m_to_dict(m, db)
# ── 删除地图 ─────────────────────────────────
@router.delete("/{map_id}")
def delete_map(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""删除战略地图(账套隔离: 禁止跨企业删除)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
if m.entity_id != entity_id:
raise HTTPException(404, "战略地图不存在")
db.delete(m)
db.commit()
return {"message": "已删除"}
@router.post("/batch-delete")
def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""批量删除战略地图(账套隔离: 仅本企业地图)"""
ids = data.get("ids", [])
if not ids:
raise HTTPException(400, "请选择要删除的地图")
deleted = 0
for mid in ids:
m = db.query(StrategicMap).filter(StrategicMap.id == mid, StrategicMap.entity_id == entity_id).first()
if m:
db.delete(m)
deleted += 1
db.commit()
return {"message": f"已删除 {deleted} 个地图", "deleted": deleted}
# ── 连线管理 ─────────────────────────────────
@@ -127,9 +186,9 @@ def _get_connections(m: StrategicMap) -> list:
@router.post("/{map_id}/connections")
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
@@ -143,11 +202,9 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
if from_id == to_id:
raise HTTPException(400, "不能自身连线")
# 校验: 维度不能相同 (learning-0 和 process-0 的维度不同)
# 校验: 维度不能相同 (但放开允许同层连线, 仅禁止自连)
from_dim = from_id.rsplit("-", 1)[0]
to_dim = to_id.rsplit("-", 1)[0]
if from_dim == to_dim:
raise HTTPException(400, "同维度内不能连线")
conns = _get_connections(m)
@@ -156,16 +213,22 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
if c.get("from") == from_id and c.get("to") == to_id:
raise HTTPException(400, "已存在相同的连线")
conns.append({"from": from_id, "to": to_id, "style": "solid"})
conns.append({
"from": from_id,
"to": to_id,
"style": "solid",
"effect": data.get("effect", "positive"),
"label": data.get("label", ""),
})
m.canvas_data["connections"] = conns
db.commit()
return {"connections": conns}
@router.delete("/{map_id}/connections")
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db)):
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""根据 from/to 删除连线"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
@@ -233,24 +296,324 @@ def _auto_snapshot(m: StrategicMap, db: Session):
# ── 工具函数 ─────────────────────────────────
def m_to_dict(m):
return {c.name: getattr(m, c.name) for c in m.__table__.columns}
def m_to_dict(m, db: Session = None):
d = {c.name: getattr(m, c.name) for c in m.__table__.columns}
if db:
_merge_map_objectives(m, db)
d["dimensions"] = m.dimensions
return d
def _sync_map_objectives(m, db):
"""保存时:将dimensions JSON中的目标同步到map_objectives表"""
db.query(MapObjective).filter(MapObjective.map_id == m.id).delete()
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or []
for dim in dims:
for i, obj in enumerate(dim.get("objectives", [])):
mo = MapObjective(
map_id=m.id,
entity_id=m.entity_id if m.entity_id is not None else 1, # 强制继承地图企业(OpenCode审查#11)
dimension_key=dim.get("key", ""),
name=obj.get("name", ""),
description=obj.get("description", ""),
icon=obj.get("icon", "target"),
kpis=obj.get("kpis", []),
sort_order=i,
)
db.add(mo)
db.commit()
def _merge_map_objectives(m, db):
"""读取时:将map_objectives表的数据合并进dimensions JSON"""
objs = db.query(MapObjective).filter(MapObjective.map_id == m.id).order_by(MapObjective.sort_order).all()
if not objs:
return
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or []
# 按dimension_key分组
from collections import defaultdict
grouped = defaultdict(list)
for o in objs:
grouped[o.dimension_key].append(o)
for dim in dims:
key = dim.get("key", "")
if key in grouped:
merged = []
for i, o in enumerate(grouped[key]):
# 保留原有JSON中的额外字段(targetValue/krs等)
existing = dim["objectives"][i] if i < len(dim["objectives"]) else {}
merged.append({
"name": o.name,
"description": o.description or existing.get("description", ""),
"icon": o.icon or existing.get("icon", "target"),
"kpis": o.kpis or existing.get("kpis", []),
**{k: existing[k] for k in existing if k not in ("name", "description", "icon", "kpis")},
})
dim["objectives"] = merged
m.dimensions = dims
# ── KR完整修复(2026-08-27): 地图节点 → objectives + krs 表同步 ──
KR_OPERATORS = (">=", "<=", ">", "<", "=")
def _parse_kr_operator_target(raw_target, operator=None):
"""解析KR目标值字符串:
旧格式 '≥20%'/'<=18' ('>=', 20.0)
新格式 operator='>=' + target=20 ('>=', 20.0)
"""
if operator:
op = str(operator).strip()
if op == "":
op = ">="
elif op == "":
op = "<="
if op not in KR_OPERATORS:
op = ">="
else:
op = ">="
if raw_target is None:
return op, None
if isinstance(raw_target, (int, float)):
return op, float(raw_target)
s = str(raw_target).strip()
if s == "":
return op, None
# 前缀符号
for cand in KR_OPERATORS:
if s.startswith(cand):
op = cand
s = s[len(cand):]
break
if s.startswith(""):
op = ">="
s = s[1:]
elif s.startswith(""):
op = "<="
s = s[1:]
# 去掉单位(%、天、小时等非数字后缀)
num = s
while num and not (num[0].isdigit() or num[0] in ".+-"):
num = num[1:]
import re
m = re.match(r"[-+]?\d*\.?\d+", num)
if not m:
return op, None
return op, float(m.group(0))
def _sync_objectives_krs(m, db):
"""保存地图时:将dimensions JSON中带krs的目标节点同步到objectives表+krs表。
- 每个带krs的目标节点 确保objectives表有记录按entity_id+name匹配无则创建
- krs数组 krs表 upsert按title匹配删除本目标下JSON中不存在的krs
- 回写 objective_id / kr_id 到节点JSON方便前端下次直接使用
"""
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
return
dims = dims or []
entity_id = m.entity_id if m.entity_id is not None else 1
for dim in dims:
dim_key = dim.get("key", "")
for obj in dim.get("objectives", []):
krs = obj.get("krs") or []
if not krs:
continue
name = (obj.get("name") or "").strip()
if not name:
continue
# 1. 确保 Objective 记录存在
objective = db.query(Objective).filter(
Objective.entity_id == entity_id,
Objective.title == name,
).first()
if not objective:
objective = Objective(
entity_id=entity_id,
title=name,
description=obj.get("description") or "",
dimension=dim_key,
quarter=_map_quarter(m),
owner=obj.get("owner") or "",
status="active",
progress=0,
)
db.add(objective)
db.flush() # 拿到 id
obj["_objective_id"] = objective.id
# 2. upsert krs 表
existing_krs = {kr.title: kr for kr in db.query(KR).filter(KR.objective_id == objective.id).all()}
keep_ids = set()
for i, kr_item in enumerate(krs):
if not isinstance(kr_item, dict):
continue
title = (kr_item.get("title") or kr_item.get("name") or "").strip()
if not title:
# 跳过空KR(前端允许空白行,不入库)
continue
op, tgt = _parse_kr_operator_target(
kr_item.get("target_value"), kr_item.get("operator"))
kpi_id = kr_item.get("metric_kpi_id")
if not kpi_id and kr_item.get("kpi_code"):
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == kr_item["kpi_code"],
).first()
kpi_id = kpi.id if kpi else None
# 关联KPI时自动继承方向(KPI阈值如 '>=800'
if kpi_id and not kr_item.get("operator"):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi and kpi.threshold_green:
op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green)
op, tgt = op2, (tgt2 if tgt is None else tgt)
kr = existing_krs.get(title)
if kr is None:
kr = KR(
entity_id=entity_id,
objective_id=objective.id,
title=title,
)
db.add(kr)
kr.metric_kpi_id = kpi_id
kr.operator = op
if kr_item.get("tolerance") is not None:
kr.tolerance = kr_item.get("tolerance")
if kr_item.get("weight") is not None:
kr.weight = kr_item.get("weight")
if tgt is not None:
kr.target_value = tgt
if kr_item.get("current_value") is not None:
kr.current_value = kr_item.get("current_value")
if kr_item.get("due_date"):
kr.due_date = _parse_kr_date(kr_item.get("due_date"))
kr.sort_order = i + 1
keep_ids.add(kr.id)
kr_item["_kr_id"] = kr.id
# 3. 删除JSON中不存在的krs(空标题的除外——它从未入库)
for title, kr in existing_krs.items():
if kr.id not in keep_ids:
db.delete(kr)
db.commit()
# 回写dimensions(含_objective_id/_kr_id
m.dimensions = dims
db.commit()
def _map_quarter(m) -> str:
"""从地图标题提取季度标识(如 2026Q3),无则空字符串"""
title = m.title or ""
import re
m_q = re.search(r"(20\d{2})\s*[Qq](\d)", title)
if m_q:
return f"{m_q.group(1)}Q{m_q.group(2)}"
return ""
def _parse_kr_date(v):
if v is None or v == "":
return None
if isinstance(v, datetime):
return v.date()
try:
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
# ── 战略回顾会 聚合接口 ──────────────────────
@router.get("/{map_id}/krs")
def get_map_krs(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""战略回顾会 KR进度: 从krs表读取地图各目标的KR (KR修复 2026-08-27)
地图dimensions JSON中每个带krs的目标节点已同步到objectives+krs表
此处按 节点._objective_id entity+name 回查取KR
"""
from app.models import KPIDefinition, KR, Objective
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except Exception:
dims = []
dims = dims or []
obj_ids = []
for dim in dims:
for obj in dim.get("objectives", []):
oid = obj.get("_objective_id")
if oid:
obj_ids.append(int(oid))
continue
# 兼容无回写字段的旧节点: 按 entity+name 回查
name = (obj.get("name") or "").strip()
if name:
o = db.query(Objective).filter(
Objective.entity_id == entity_id,
Objective.title == name,
).first()
if o:
obj_ids.append(o.id)
result = []
if obj_ids:
krs = db.query(KR).filter(
KR.entity_id == entity_id,
KR.objective_id.in_(obj_ids),
).order_by(KR.objective_id, KR.sort_order, KR.id).all()
for kr in krs:
mkpi = None
if kr.metric_kpi_id:
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
result.append({
"id": kr.id,
"objective_id": kr.objective_id,
"title": kr.title,
"metric_kpi_id": kr.metric_kpi_id,
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
"operator": _parse_kr_operator_target(kr.operator)[0] if hasattr(kr, "operator") and kr.operator else ">=",
"weight": float(kr.weight) if kr.weight is not None else None,
"target_value": float(kr.target_value) if kr.target_value is not None else None,
"current_value": float(kr.current_value) if kr.current_value is not None else None,
"progress": kr.progress or 0,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
})
return {"data": result}
@router.get("/{map_id}/review")
def get_map_review(map_id: int, db: Session = Depends(get_db)):
"""战略回顾会:返回目标状态、KPI值、改善行动"""
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""战略回顾会:返回目标状态、KPI值、改善行动
level: 可选 strategic/operational 战略回顾(默认strategic)只显示战略级KPI
不传则返回全部KPI向后兼容
"""
from app.models import KPIDefinition, KPIValue, ActionPlan
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
if isinstance(dims, str):
dims = json.loads(dims)
dims = dims or [] # NULL容错
# 收集所有KPI code
all_kpi_codes = set()
@@ -259,9 +622,10 @@ def get_map_review(map_id: int, db: Session = Depends(get_db)):
for code in obj.get("kpis", []):
all_kpi_codes.add(code)
# 查询KPI定义
# 查询KPI定义(账套隔离: 同编码跨企业会混入, OpenCode审查#10
kpi_defs = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False
KPIDefinition.kpi_code.in_(all_kpi_codes) if all_kpi_codes else False,
KPIDefinition.entity_id == entity_id,
).all() if all_kpi_codes else []
kpi_map = {k.kpi_code: k for k in kpi_defs}
@@ -322,16 +686,29 @@ def get_map_review(map_id: int, db: Session = Depends(get_db)):
kpi_def = kpi_map.get(code)
if not kpi_def:
continue
# 规则4: 战略地图默认只显示strategic级KPIlevel过滤)
if level and kpi_def.kpi_level != level:
continue
lv = latest_values.get(kpi_def.id, {})
actual = lv.get("actual_value")
target = kpi_def.target_value
# 判断红黄绿灯
# 多粒度目标优先: 月目标(多数KPI月度) → 兼容legacy target_value (OpenCode审查#5)
target = kpi_def.target_monthly or kpi_def.target_value
# 判断红黄绿灯(绿≥90% / 黄60-90% / 红<60%
# 方向修正: 越低越好型(费用率/成本率/损耗/逾期/投诉/流失) 与 负值目标 (OpenCode审查#5)
level = "gray"
if actual is not None and target:
ratio = actual / target
name = (kpi_def.kpi_name or "") + " " + (kpi_def.kpi_code or "")
lower_better = any(k in name for k in
("费用率", "成本率", "损耗", "逾期", "投诉", "流失", "负债率", "返修", "缺勤"))
if lower_better:
ratio = target / actual if actual else 0 # 实际越低越好: 目标/实际
else:
ratio = actual / target
if ratio < 0:
ratio = 0 # 负值目标(如亏损控制) 完成度不为负
if ratio >= 0.9:
level = "green"
elif ratio >= 0.7:
elif ratio >= 0.6:
level = "yellow"
else:
level = "red"
@@ -359,6 +736,13 @@ def get_map_review(map_id: int, db: Session = Depends(get_db)):
"kpis": obj_kpis,
"level": worst_level,
"has_data": len(obj_kpis) > 0,
# 行动方案汇总(OpenCode审查#6): 按本目标KPI统计
"action_plan_summary": {
"total": len([p for p in action_plans_data if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]]),
"pending": len([p for p in action_plans_data
if p["kpi_id"] in [k["kpi_id"] for k in obj_kpis]
and p["status"] not in ("done", "completed")]),
} if obj_kpis else {"total": 0, "pending": 0},
}
objectives.append(obj_item)
@@ -401,3 +785,110 @@ def get_map_review(map_id: int, db: Session = Depends(get_db)):
"action_plans": action_plans_data,
}
# ── 复盘记录持久化(大PDCA Act闭环 2026-08-27)────────────────
def review_to_dict(r: ReviewRecord) -> dict:
return {
"id": r.id,
"entity_id": r.entity_id,
"map_id": r.map_id,
"review_type": r.review_type,
"review_date": r.review_date.isoformat() if r.review_date else None,
"summary": r.summary,
"adjustments": r.adjustments,
"next_priorities": r.next_priorities,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _parse_review_date(raw) -> Optional[date]:
"""兼容 'YYYY-MM-DD' / datetime / date 输入"""
if raw is None:
return datetime.now().date()
if isinstance(raw, datetime):
return raw.date()
if isinstance(raw, date):
return raw
try:
return datetime.strptime(str(raw)[:10], "%Y-%m-%d").date()
except ValueError:
return datetime.now().date()
def _get_map_or_404(db: Session, map_id: int, entity_id: int) -> StrategicMap:
m = db.query(StrategicMap).filter(
StrategicMap.id == map_id, StrategicMap.entity_id == entity_id
).first()
if not m:
raise HTTPException(404, "战略地图不存在")
return m
@router.post("/{map_id}/review-records")
def create_review_record(
map_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user: User = Depends(require_auth),
):
"""保存复盘结论(战略回顾会/运营复盘会结束时落库)"""
_get_map_or_404(db, map_id, entity_id)
summary = (data.get("summary") or "").strip()
if not summary:
raise HTTPException(400, "复盘结论(summary)不能为空")
r = ReviewRecord(
entity_id=entity_id, # 多租户隔离: 强制token企业
map_id=map_id,
review_type=data.get("review_type", "quarterly"),
review_date=_parse_review_date(data.get("review_date")),
summary=summary,
adjustments=data.get("adjustments") or [],
next_priorities=data.get("next_priorities") or [],
created_by=current_user.id if current_user else None,
)
db.add(r)
db.commit()
db.refresh(r)
return review_to_dict(r)
@router.get("/{map_id}/review-records")
def list_review_records(
map_id: int,
review_type: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""历史复盘记录列表(按复盘日期倒序)"""
_get_map_or_404(db, map_id, entity_id)
q = db.query(ReviewRecord).filter(
ReviewRecord.map_id == map_id,
ReviewRecord.entity_id == entity_id,
)
if review_type:
q = q.filter(ReviewRecord.review_type == review_type)
records = q.order_by(ReviewRecord.review_date.desc(), ReviewRecord.id.desc()).all()
return {"data": [review_to_dict(r) for r in records]}
@router.get("/{map_id}/review-records/{record_id}")
def get_review_record(
map_id: int,
record_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""单条复盘记录详情"""
_get_map_or_404(db, map_id, entity_id)
r = db.query(ReviewRecord).filter(
ReviewRecord.id == record_id,
ReviewRecord.map_id == map_id,
ReviewRecord.entity_id == entity_id,
).first()
if not r:
raise HTTPException(404, "复盘记录不存在")
return review_to_dict(r)
+25 -11
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role
from app.models import MapObjective, StrategicMap, KPIDefinition
@@ -11,8 +12,11 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图目标"],
@router.get("/{map_id}/objectives")
def list_objectives(map_id: int, db: Session = Depends(get_db)):
"""获取某地图下的所有目标"""
def list_objectives(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取某地图下的所有目标(账套隔离: 跨企业返回空, OpenCode执行层发现)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
return {"data": []}
objs = db.query(MapObjective).filter(
MapObjective.map_id == map_id
).order_by(MapObjective.sort_order).all()
@@ -20,9 +24,9 @@ def list_objectives(map_id: int, db: Session = Depends(get_db)):
@router.post("/{map_id}/objectives")
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""新增目标"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
obj = MapObjective(
@@ -40,23 +44,30 @@ def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
@router.put("/{map_id}/objectives/{obj_id}")
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db)):
"""修改目标"""
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""修改目标(账套隔离 + 字段白名单, OpenCode审查#8"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
obj = db.query(MapObjective).filter(
MapObjective.id == obj_id, MapObjective.map_id == map_id
).first()
if not obj:
raise HTTPException(404, "目标不存在")
ALLOWED_OBJ_FIELDS = {"name", "description", "icon", "sort_order", "kpis", "dimension_key"}
for k, v in data.items():
if hasattr(obj, k) and v is not None:
if k in ALLOWED_OBJ_FIELDS and hasattr(obj, k) and v is not None:
setattr(obj, k, v)
db.commit()
return _obj_to_dict(obj)
@router.delete("/{map_id}/objectives/{obj_id}")
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
"""删除目标"""
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""删除目标(账套隔离)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
obj = db.query(MapObjective).filter(
MapObjective.id == obj_id, MapObjective.map_id == map_id
).first()
@@ -68,8 +79,11 @@ def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
@router.put("/{map_id}/objectives/sort")
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db)):
"""批量排序: {"ids": [3, 1, 2]}"""
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""批量排序: {"ids": [3, 1, 2]}(账套隔离)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
ids = data.get("ids", [])
for idx, obj_id in enumerate(ids):
db.query(MapObjective).filter(
+562
View File
@@ -0,0 +1,562 @@
"""
OKR目标管理 API 季度目标 + 关键结果 + KPI联动
KR完整修复(2026-08-27): KR读取从ActionPlan改为krs表 + 方向符号operator + 权重
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, timedelta, date as date_type
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role
from app.models import Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition
router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
# ── KR 工具函数 ─────────────────────────────────────────
VALID_OPERATORS = (">=", "<=", ">", "<", "=")
def _num(v):
"""Decimal → float 便于 JSON 序列化"""
return float(v) if v is not None else None
def _parse_operator(raw):
"""解析方向符号: 支持 >=/<=/>/</=,非法默认 >="""
if not raw:
return ">="
op = str(raw).strip()
if op in VALID_OPERATORS:
return op
# 兼容中文符号 ≥ ≤
if op in ("",):
return ">="
if op in ("",):
return "<="
return ">="
def _eval_kr(kr: KR) -> bool:
"""达成判断: 实际值满足 operator → True"""
if kr.current_value is None or kr.target_value is None:
return False
cur = float(kr.current_value)
tgt = float(kr.target_value)
op = _parse_operator(kr.operator)
if op == ">=":
ok = cur >= tgt
elif op == "<=":
ok = cur <= tgt
elif op == ">":
ok = cur > tgt
elif op == "<":
ok = cur < tgt
elif op == "=":
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
ok = abs(cur - tgt) <= tol
else:
ok = False
return ok
def _calc_progress(kr: KR) -> int:
"""方向感知进度计算:
>=/>: current/target*100
<=/<: target/current*100
= : 容差内100
"""
if kr.current_value is None or kr.target_value is None:
return kr.progress or 0
cur = float(kr.current_value)
tgt = float(kr.target_value)
if tgt == 0:
return 100 if cur == 0 else (100 if cur > 0 else 0)
op = _parse_operator(kr.operator)
if op in (">=", ">"):
pct = cur / tgt * 100
elif op in ("<=", "<"):
# 值越小进度越高(如费用率<=18%)
if cur <= tgt:
pct = 100.0
else:
pct = max(0.0, tgt / cur * 100)
elif op == "=":
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
if abs(cur - tgt) <= tol:
pct = 100.0
else:
pct = max(0.0, 100.0 - abs(cur - tgt) / (abs(tgt) or 1) * 100)
else:
pct = cur / tgt * 100
return max(0, min(100, round(pct)))
def _refresh_kr_state(kr: KR):
"""按当前值刷新 progress + status(方向感知)"""
if kr.current_value is not None and kr.target_value is not None:
kr.progress = _calc_progress(kr)
if _eval_kr(kr):
kr.status = "achieved"
elif kr.progress > 0:
kr.status = "in_progress"
return kr
def _inherit_kpi_direction(db: Session, kpi_id, operator, target_value):
"""关联KPI时自动继承方向: 从KPI的threshold_green解析(如 '>=800' / '<=18')"""
if not kpi_id:
return operator, target_value
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return operator, target_value
threshold = (kpi.threshold_green or "").strip()
if not threshold:
return operator, target_value
op = operator
tgt = target_value
for cand in VALID_OPERATORS:
if threshold.startswith(cand):
op = cand
try:
tgt = float(threshold[len(cand):].strip())
except (ValueError, TypeError):
pass
break
else:
# 无符号时尝试解析纯数字
try:
tgt = float(threshold)
except (ValueError, TypeError):
pass
return op, tgt
def _kr_serialize(kr: KR, db: Session = None) -> dict:
"""KR → dict(含operator/weight/tolerance"""
mkpi = None
if db and kr.metric_kpi_id:
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
return {
"id": kr.id,
"objective_id": kr.objective_id,
"title": kr.title,
"metric_kpi_id": kr.metric_kpi_id,
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
"operator": _parse_operator(kr.operator),
"tolerance": _num(kr.tolerance),
"weight": _num(kr.weight),
"target_value": _num(kr.target_value),
"current_value": _num(kr.current_value),
"progress": kr.progress,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
"monthly_milestones": kr.monthly_milestones or [],
}
def _get_objective_or_404(db: Session, obj_id: int, entity_id: int) -> Objective:
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
if not obj:
raise HTTPException(404, "目标不存在")
return obj
# ── 目标列表/详情 ─────────────────────────────────────
@router.get("")
def list_objectives(
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
dimension: Optional[str] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""列出OKR目标(账套隔离: 按token企业) — KR读取自krs表(2026-08-27)"""
q = db.query(Objective).filter(Objective.entity_id == entity_id)
if quarter:
q = q.filter(Objective.quarter == quarter)
if dimension:
q = q.filter(Objective.dimension == dimension)
if status:
q = q.filter(Objective.status == status)
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
results = []
for o in objs:
krs = db.query(KR).filter(KR.objective_id == o.id).order_by(KR.id).all()
kr_summary = [
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress,
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight),
"metric_kpi_id": kr.metric_kpi_id}
for kr in krs
]
results.append({
"id": o.id, "title": o.title, "description": o.description,
"dimension": o.dimension, "quarter": o.quarter,
"owner": o.owner, "status": o.status, "progress": o.progress,
"confidence": o.confidence,
"key_results": kr_summary,
"kr_count": len(krs),
"kr_completed": sum(1 for kr in krs if kr.status in ("completed", "achieved")),
"created_at": o.created_at.isoformat() if o.created_at else None,
})
return {"total": len(results), "items": results}
@router.post("")
def create_objective(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
title = data.get("title") or ""
quarter = data.get("quarter") or ""
description = data.get("description")
dimension = data.get("dimension")
owner = data.get("owner")
if not title or not quarter:
raise HTTPException(422, "缺少必填字段: title, quarter")
obj = Objective(title=title, quarter=quarter, description=description,
dimension=dimension, owner=owner, entity_id=entity_id)
db.add(obj)
db.commit()
db.refresh(obj)
return {"ok": True, "id": obj.id, "title": obj.title}
@router.get("/{obj_id}")
def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取单个OKR详情(账套隔离: 跨企业404 — KR读取自krs表(2026-08-27)"""
obj = _get_objective_or_404(db, obj_id, entity_id)
krs = db.query(KR).filter(KR.objective_id == obj_id).order_by(KR.id).all()
return {
"objective": {
"id": obj.id, "title": obj.title, "description": obj.description,
"dimension": obj.dimension, "quarter": obj.quarter,
"owner": obj.owner, "status": obj.status, "progress": obj.progress,
"confidence": obj.confidence,
},
"key_results": [_kr_serialize(kr, db) for kr in krs],
}
@router.patch("/{obj_id}")
def update_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""更新OKR进度(通过krs表KR自动计算加权进度)"""
obj = _get_objective_or_404(db, obj_id, entity_id)
krs = db.query(KR).filter(KR.objective_id == obj_id).all()
if krs:
weights = [float(kr.weight) if kr.weight else 1.0 for kr in krs]
total_w = sum(weights) or len(krs)
obj.progress = round(sum(kr.progress * w for kr, w in zip(krs, weights)) / total_w)
db.commit()
return {"ok": True, "id": obj_id, "progress": obj.progress}
# ── KR CRUD ───────────────────────────────────────────
@router.post("/{objective_id}/krs")
def create_kr(objective_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""新增KR(落krs表)— title/metric_kpi_id/operator/target_value/weight/progress/due_date
关联KPI时自动继承方向(threshold_green)
"""
obj = _get_objective_or_404(db, objective_id, entity_id)
title = data.get("title") or ""
if not title:
raise HTTPException(422, "缺少必填字段: title")
operator, target_value = _inherit_kpi_direction(
db, data.get("metric_kpi_id"), data.get("operator"), data.get("target_value"))
if target_value is None and operator != "=":
# 没有目标值也能建(后续补),但明确提示
pass
kr = KR(
entity_id=entity_id,
objective_id=objective_id,
title=title,
metric_kpi_id=data.get("metric_kpi_id") or None,
operator=_parse_operator(operator),
tolerance=data.get("tolerance"),
weight=data.get("weight"),
target_value=target_value,
current_value=data.get("current_value"),
progress=data.get("progress", 0),
status=data.get("status", "pending"),
due_date=_parse_date(data.get("due_date")),
monthly_milestones=data.get("monthly_milestones") or [],
)
_refresh_kr_state(kr)
db.add(kr)
db.commit()
db.refresh(kr)
update_objective(objective_id, db, entity_id)
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
@router.put("/{objective_id}/krs/sync")
def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""批量同步KR数组(前端保存整个KR列表时用):
body: {"krs": [{id?, title, operator, target_value, weight, kpi_code?, ...}]}
已存在的按id更新无id且同名则更新否则新增不在列表中的删除
注意: 必须声明在 /krs/{kr_id} 之前避免 'sync' 被解析为 kr_id
"""
obj = _get_objective_or_404(db, objective_id, entity_id)
incoming = data.get("krs", [])
if not isinstance(incoming, list):
raise HTTPException(400, "krs 必须是数组")
existing = {kr.id: kr for kr in db.query(KR).filter(KR.objective_id == objective_id).all()}
keep_ids = set()
now_name = {}
for i, item in enumerate(incoming):
title = (item.get("title") or item.get("name") or "").strip()
if not title:
continue
kpi_id = item.get("metric_kpi_id") or item.get("kpi_id")
# kpi_code → kpi_id 转换
if not kpi_id and item.get("kpi_code"):
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == item["kpi_code"],
KPIDefinition.entity_id == entity_id).first()
kpi_id = kpi.id if kpi else None
operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value"))
kr = None
if item.get("id") and int(item["id"]) in existing:
kr = existing[int(item["id"])]
elif title in now_name:
kr = now_name[title]
else:
# 按同名找已有
for k in existing.values():
if k.title == title:
kr = k
break
if kr is None:
kr = KR(entity_id=entity_id, objective_id=objective_id, title=title)
db.add(kr)
kr.title = title
kr.metric_kpi_id = kpi_id
kr.operator = _parse_operator(operator)
if item.get("tolerance") is not None:
kr.tolerance = item.get("tolerance")
if item.get("weight") is not None:
kr.weight = item.get("weight")
if item.get("target_value") is not None:
kr.target_value = item.get("target_value")
elif target_value is not None:
kr.target_value = target_value
if item.get("current_value") is not None:
kr.current_value = item.get("current_value")
if item.get("due_date"):
kr.due_date = _parse_date(item.get("due_date"))
if item.get("monthly_milestones") is not None:
kr.monthly_milestones = item.get("monthly_milestones")
_refresh_kr_state(kr)
keep_ids.add(kr.id)
now_name[title] = kr
# 删除不在列表中的
for kid, kr in existing.items():
if kid not in keep_ids:
db.delete(kr)
db.commit()
update_objective(objective_id, db, entity_id)
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
return {"ok": True, "objective_id": objective_id, "krs": [_kr_serialize(kr, db) for kr in krs]}
@router.put("/{objective_id}/krs/{kr_id}")
def update_kr(objective_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""更新KR(含方向/权重/当前值,自动重算progress+status"""
obj = _get_objective_or_404(db, objective_id, entity_id)
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该目标")
updatable = {"title", "metric_kpi_id", "operator", "tolerance", "weight",
"target_value", "current_value", "progress", "status", "due_date",
"monthly_milestones"}
for k, v in data.items():
if k in updatable and v is not None:
setattr(kr, k, v)
# 关联KPI变化 → 自动继承方向
if "metric_kpi_id" in data or "operator" not in data or "target_value" not in data:
if data.get("metric_kpi_id") and ("operator" not in data or "target_value" not in data):
kr.operator, kr.target_value = _inherit_kpi_direction(
db, data.get("metric_kpi_id", kr.metric_kpi_id),
data.get("operator", kr.operator), data.get("target_value", kr.target_value))
if data.get("due_date"):
kr.due_date = _parse_date(data["due_date"])
_refresh_kr_state(kr)
db.commit()
db.refresh(kr)
update_objective(objective_id, db, entity_id)
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
@router.delete("/{objective_id}/krs/{kr_id}")
def delete_kr(objective_id: int, kr_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""删除KR"""
_get_objective_or_404(db, objective_id, entity_id)
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该目标")
db.delete(kr)
db.commit()
update_objective(objective_id, db, entity_id)
return {"ok": True, "deleted": kr_id}
# ── KR里程碑(时间分解) ─────────────────────────────
@router.put("/{okr_id}/decomposition/milestones/{kr_id}")
def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度"""
_get_objective_or_404(db, okr_id, entity_id)
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该OKR")
milestones = data.get("milestones")
if not isinstance(milestones, list):
raise HTTPException(400, "milestones 必须是数组")
normalized = []
for ms in milestones:
normalized.append({
"month": ms.get("month", ""),
"label": ms.get("label", ""),
"status": ms.get("status", "pending"),
"target": ms.get("target"),
})
kr.monthly_milestones = normalized
if normalized:
done = sum(1 for ms in normalized if ms.get("status") == "completed")
kr.progress = round(done / len(normalized) * 100)
if kr.progress >= 100:
kr.status = "achieved"
elif kr.status == "achieved" and kr.progress < 100:
kr.status = "in_progress"
db.commit()
return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones}
@router.post("/{okr_id}/decomposition/milestones/generate")
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
_get_objective_or_404(db, okr_id, entity_id)
kr_id = data.get("kr_id")
if not kr_id:
raise HTTPException(400, "缺少 kr_id")
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该OKR")
kr.monthly_milestones = _build_auto_milestones(kr)
db.commit()
return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones}
def _build_auto_milestones(kr: KR) -> list:
"""按截止日期向前均分3个月度里程碑"""
base = kr.due_date or datetime.now() + timedelta(days=90)
if isinstance(base, str):
try:
base = datetime.strptime(base[:10], "%Y-%m-%d").date()
except ValueError:
base = datetime.now().date() + timedelta(days=90)
if isinstance(base, datetime):
base = base.date()
base = base.replace(day=1)
month_keys = []
for i in range(3, 0, -1):
y, m = base.year, base.month - i
while m <= 0:
m += 12
y -= 1
month_keys.append(f"{y:04d}-{m:02d}")
return [
{
"month": mk,
"label": f"里程碑{i + 1}",
"status": "pending",
"target": None,
}
for i, mk in enumerate(month_keys)
]
def _parse_date(v):
"""兼容 str/date/datetime → date"""
if v is None or v == "":
return None
if isinstance(v, date_type):
return v
if isinstance(v, datetime):
return v.date()
try:
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
@router.get("/{okr_id}/decomposition")
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""获取OKR的时间分解视图数据 — KR读取自krs表(2026-08-27)"""
okr = _get_objective_or_404(db, okr_id, entity_id)
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
bsc_o = db.query(Objective).filter(
Objective.dimension == okr.dimension,
Objective.quarter.is_(None),
Objective.entity_id == entity_id,
).first()
# 2. 本OKR的所有KRkrs表)
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
# 3. 当前周的ActionPlan(本周行动计划)
now = datetime.now()
week_start = now - timedelta(days=now.weekday())
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
week_end = week_start + timedelta(days=7)
action_plans = db.query(ActionPlan).filter(
ActionPlan.objective_id == okr_id,
ActionPlan.due_date.between(week_start, week_end)
).all()
return {
"annual_o": bsc_o.title if bsc_o else None,
"quarterly_o": okr.title,
"krs": [
{
"kr_id": kr.id,
"title": kr.title,
"progress": kr.progress,
"milestones": kr.monthly_milestones or []
}
for kr in krs
],
"weekly_actions": [
{
"id": ap.id,
"title": ap.title,
"status": ap.status,
"deadline": ap.due_date.isoformat() if ap.due_date else None
}
for ap in action_plans
]
}
+216
View File
@@ -0,0 +1,216 @@
"""
OKR模板库 API 按维度筛选 + 用户自定义模板
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role
from app.models import OKRTemplate, StrategicMap
router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
DIMENSION_LAYER_NAMES = {
"finance": "财务层",
"customer": "客户层",
"process": "内部流程层",
"learning": "学习成长层",
}
DIMENSION_LAYER_ICONS = {
"finance": "💰",
"customer": "👥",
"process": "⚙️",
"learning": "📚",
}
DIMENSION_LAYER_COLORS = {
"finance": "#F56C6C",
"customer": "#409EFF",
"process": "#67C23A",
"learning": "#E6A23C",
}
@router.get("")
def list_okr_templates(
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
search: Optional[str] = Query(None, description="按O名称关键词搜索"),
active_only: bool = Query(True, description="仅返回启用模板"),
db: Session = Depends(get_db),
):
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索"""
q = db.query(OKRTemplate)
if dimension:
q = q.filter(OKRTemplate.dimension == dimension)
if source:
q = q.filter(OKRTemplate.source == source)
if industry_tag:
q = q.filter(OKRTemplate.industry_tag == industry_tag)
if search:
q = q.filter(OKRTemplate.name.like(f"%{search}%"))
if active_only:
q = q.filter(OKRTemplate.is_active == 1)
templates = q.order_by(OKRTemplate.sort_order, OKRTemplate.id).all()
return {
"total": len(templates),
"items": [
{
"id": t.id,
"name": t.name,
"description": t.description,
"dimension": t.dimension,
"layer": t.layer,
"industry_tag": t.industry_tag,
"preset_krs": t.preset_krs,
"source": t.source,
"owner": t.owner,
"use_count": t.use_count,
"sort_order": t.sort_order,
"is_active": t.is_active,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in templates
],
}
@router.get("/{template_id}")
def get_okr_template(template_id: int, db: Session = Depends(get_db)):
"""获取单个 OKR 模板详情"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
return {
"id": t.id,
"name": t.name,
"description": t.description,
"dimension": t.dimension,
"layer": t.layer,
"industry_tag": t.industry_tag,
"preset_krs": t.preset_krs,
"source": t.source,
"owner": t.owner,
"use_count": t.use_count,
"sort_order": t.sort_order,
"is_active": t.is_active,
}
@router.post("")
def create_okr_template(data: dict, db: Session = Depends(get_db)):
"""用户自定义 OKR 模板(治理: 同名去重 + owner 标记)"""
name = data.get("name", "").strip()
if not name:
raise HTTPException(400, "模板名称不能为空")
dimension = data.get("dimension", "")
if dimension not in ("finance", "customer", "process", "learning"):
raise HTTPException(400, "维度无效,必须是 finance/customer/process/learning")
preset_krs = data.get("preset_krs", [])
if not isinstance(preset_krs, list) or len(preset_krs) == 0:
raise HTTPException(400, "至少需要一个预设KR")
# P2治理: 同名+同维度去重(防止 id=33 式重复模板,2026-08-21
dup = db.query(OKRTemplate).filter(
OKRTemplate.name == name,
OKRTemplate.dimension == dimension,
OKRTemplate.is_active == 1,
).first()
if dup:
raise HTTPException(409, f"已存在同名模板「{name}」(#{dup.id}, source={dup.source}),请改用现有模板或改名")
t = OKRTemplate(
name=name,
description=data.get("description", ""),
dimension=dimension,
layer=data.get("layer", "level3"),
industry_tag=data.get("industry_tag", "general"),
preset_krs=preset_krs,
source="user",
owner="用户自定义",
sort_order=data.get("sort_order", 0),
)
db.add(t)
db.commit()
db.refresh(t)
return {"ok": True, "id": t.id, "name": t.name}
@router.post("/{template_id}/use")
def increment_use_count(template_id: int, db: Session = Depends(get_db)):
"""增加模板使用次数"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
t.use_count = (t.use_count or 0) + 1
db.commit()
return {"ok": True, "use_count": t.use_count}
@router.post("/{template_id}/apply")
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)):
"""应用 OKR 模板 — 创建战略地图并填入 O+KR"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
map_title = data.get("title", t.name)
dim = t.dimension
preset_krs = t.preset_krs or []
# 构建 dimensions: 仅包含模板所在的维度层
dimensions = []
for dk in ("finance", "customer", "process", "learning"):
objectives = []
if dk == dim:
objectives.append({
"name": t.name,
"description": t.description or "",
"kpis": [],
"krs": [
{
"name": kr.get("name", ""),
"target_value": kr.get("target_value", ""),
"weight": kr.get("weight", 33),
"metric_kpi_id": kr.get("metric_kpi_id"),
"metric_kpi_code": kr.get("metric_kpi_code"),
}
for kr in preset_krs
],
})
dimensions.append({
"key": dk,
"name": DIMENSION_LAYER_NAMES.get(dk, dk),
"icon": DIMENSION_LAYER_ICONS.get(dk, "📌"),
"color": DIMENSION_LAYER_COLORS.get(dk, "#909399"),
"objectives": objectives,
})
m = StrategicMap(
title=map_title,
version=data.get("version", "v1.0"),
status="draft",
dimensions=dimensions,
canvas_data={"connections": []},
)
db.add(m)
db.commit()
db.refresh(m)
# 使用 maps API 的 _sync_map_objectives 同步到 map_objectives 表
from app.api.maps import _sync_map_objectives
_sync_map_objectives(m, db)
# 增加模板使用次数
t.use_count = (t.use_count or 0) + 1
db.commit()
return {
"ok": True,
"map_id": m.id,
"title": m.title,
"template_id": template_id,
}
+162
View File
@@ -0,0 +1,162 @@
"""
CMA本体三支柱追溯链 API 科目 KPI OKR的O 三层互联 (2026-08-19)
追溯链: 目标(O) 指标(KPI) 科目(数据)
objective_kpi : O 由哪些 KPI 度量
kpi_subject_map : KPI 由哪些科目计算
krs : O 的关键结果 KR (OKR完整化)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role
from app.models import Objective, KPIDefinition, ObjectiveKPI, KPISubjectMap, KR, Subject
router = APIRouter(prefix="/api/cma/ontology", tags=["本体三支柱"],
dependencies=[Depends(require_role("ceo", "finance", "it", "business"))],
)
def _num(v):
"""Decimal → float 便于 JSON 序列化"""
return float(v) if v is not None else None
@router.get("/trace")
def trace_ontology(
objective_id: int = Query(..., description="OKR目标ID, 从O→KPI→科目逐层追溯"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""本体追溯链: O(目标) → KPI(指标) → 科目(数据) — 账套隔离 (2026-08-23 P2)"""
obj = db.query(Objective).filter(Objective.id == objective_id, Objective.entity_id == entity_id).first()
if not obj:
raise HTTPException(404, "目标不存在")
# 第2层: O 支撑的 KPI (objective_kpi)
links = (
db.query(ObjectiveKPI, KPIDefinition)
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
.filter(ObjectiveKPI.objective_id == objective_id)
.order_by(ObjectiveKPI.id)
.all()
)
# 第3层: 每个 KPI 依赖的科目 (kpi_subject_map)
subject_by_code = {
s.subject_code: s.subject_name
for s in db.query(Subject).filter(Subject.is_active == 1).all()
}
kpi_layer = []
for link, kpi in links:
maps = (
db.query(KPISubjectMap)
.filter(KPISubjectMap.kpi_id == kpi.id)
.order_by(KPISubjectMap.id)
.all()
)
subjects = [
{
"subject_code": m.subject_code,
"subject_name": subject_by_code.get(m.subject_code, ""),
"calc_type": m.calc_type,
"weight": _num(m.weight),
"remark": m.remark,
}
for m in maps
]
kpi_layer.append({
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"unit": kpi.unit,
"weight": _num(link.weight),
"formula": kpi.formula,
"subjects": subjects,
})
# KR 层 (OKR完整化: O→KR)
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
kr_list = []
for kr in krs:
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first() if kr.metric_kpi_id else None
kr_list.append({
"id": kr.id,
"title": kr.title,
"metric_kpi_id": kr.metric_kpi_id,
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
"operator": kr.operator or ">=",
"weight": _num(kr.weight),
"target_value": _num(kr.target_value),
"current_value": _num(kr.current_value),
"progress": kr.progress,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
})
subject_total = sum(len(k["subjects"]) for k in kpi_layer)
return {
"objective": {
"id": obj.id,
"title": obj.title,
"description": obj.description,
"dimension": obj.dimension,
"quarter": obj.quarter,
"owner": obj.owner,
"status": obj.status,
"progress": obj.progress,
},
"krs": kr_list,
"kpis": kpi_layer,
"chain": {
"objective_id": obj.id,
"objective_title": obj.title,
"kpi_count": len(kpi_layer),
"subject_count": subject_total,
"path": "O(目标) → KPI(指标) → 科目(数据)",
},
}
@router.get("/objectives")
def list_ontology_objectives(
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""所有OKR目标的三层链路概览(前端OKR页用) — 账套隔离 (2026-08-23 P2)"""
q = db.query(Objective).filter(Objective.entity_id == entity_id)
if quarter:
q = q.filter(Objective.quarter == quarter)
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
results = []
for o in objs:
kpi_links = (
db.query(ObjectiveKPI, KPIDefinition)
.join(KPIDefinition, KPIDefinition.id == ObjectiveKPI.kpi_id)
.filter(ObjectiveKPI.objective_id == o.id)
.all()
)
krs = db.query(KR).filter(KR.objective_id == o.id).all()
results.append({
"id": o.id,
"title": o.title,
"dimension": o.dimension,
"quarter": o.quarter,
"owner": o.owner,
"status": o.status,
"progress": o.progress,
"kpi_count": len(kpi_links),
"kr_count": len(krs),
"kpis": [{"kpi_id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"weight": _num(l.weight)} for l, k in kpi_links],
"krs": [{"id": kr.id, "title": kr.title, "progress": kr.progress,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None}
for kr in krs],
})
return {"total": len(results), "items": results}
+824 -1
View File
@@ -1,10 +1,17 @@
"""预测模拟API — 管理会计OS"""
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends, Request, Query
from app.utils.predict_engine import (
cvp_analysis, npv, irr,
sensitivity_analysis, scenario_analysis,
)
from app.utils.cash_forecast_engine import (
forecast_cash_flow, save_forecast_to_db,
calculate_accuracy, generate_scenario_suggestion,
)
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from sqlalchemy.orm import Session
logger = logging.getLogger("cma.predict")
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
@@ -96,3 +103,819 @@ def api_scenario_analysis(data: dict):
raise
except Exception as e:
raise HTTPException(400, f"情景模拟失败: {str(e)}")
@router.post("/cvp-detailed")
def api_cvp_detailed(data: dict):
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)"""
try:
fixed_cost = float(data.get("fixed_cost", 617))
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
unit_price = float(data.get("unit_price", 228))
current_volume = float(data.get("current_volume", 5300))
contribution_margin_rate = 1 - variable_cost_rate
breakeven_revenue = round(fixed_cost / contribution_margin_rate, 2)
breakeven_units = round(breakeven_revenue * 10000 / unit_price, 0)
current_revenue = round(current_volume * unit_price / 10000, 2)
current_profit = round(current_revenue * (1 - variable_cost_rate) - fixed_cost, 2)
safety_margin = round((current_revenue - breakeven_revenue) / current_revenue * 100, 2) if current_revenue > 0 else 0
scenarios = [
{"name": "降固定费用至300万", "fixed_cost": 300, "variable_cost_rate": variable_cost_rate,
"breakeven_revenue": round(300 / contribution_margin_rate, 2),
"breakeven_units": round(300 / contribution_margin_rate * 10000 / unit_price, 0)},
{"name": "降变动成本率至30%", "fixed_cost": fixed_cost, "variable_cost_rate": 0.3,
"breakeven_revenue": round(fixed_cost / 0.7, 2),
"breakeven_units": round(fixed_cost / 0.7 * 10000 / unit_price, 0)},
{"name": "两者同时改善", "fixed_cost": 300, "variable_cost_rate": 0.3,
"breakeven_revenue": round(300 / 0.7, 2),
"breakeven_units": round(300 / 0.7 * 10000 / unit_price, 0)},
]
# 保本图数据点
chart_data = []
max_volume = int(max(breakeven_units * 2, current_volume * 3))
step = max(1, int(max_volume / 20))
for vol in range(0, int(max_volume) + step, step):
rev = round(vol * unit_price / 10000, 2)
tc = round(fixed_cost + rev * variable_cost_rate, 2)
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
return {
"fixed_cost": fixed_cost,
"variable_cost_rate": round(variable_cost_rate * 100, 2),
"unit_price": unit_price,
"contribution_margin_rate": round(contribution_margin_rate * 100, 2),
"breakeven_revenue": breakeven_revenue,
"breakeven_units": int(breakeven_units),
"current_revenue": current_revenue,
"current_profit": current_profit,
"current_volume": int(current_volume),
"safety_margin": safety_margin,
"scenarios": scenarios,
"chart_data": chart_data,
}
except Exception as e:
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
# ── 相关成本决策(CMA P2商业决策分析25%权重核心) ──────────────────
@router.post("/relevant-decision")
def api_relevant_decision(data: dict):
"""相关成本决策分析(CMA P2 商业决策分析核心内容)
场景: make-or-buy自制外购 / special-order特殊订单 / product-mix产品组合
"""
try:
decision_type = data.get("type", "make_or_buy")
if decision_type == "make_or_buy":
# 自制vs外购决策
# 相关成本 = 增量成本(只有随决策变化的成本才是相关的)
make_var_cost = float(data.get("make_variable_cost", 0)) # 自制单位变动成本
make_fixed = float(data.get("make_fixed_cost", 0)) # 自制新增固定成本
buy_price = float(data.get("buy_price", 0)) # 外购单价
demand = float(data.get("demand", 0)) # 需求量
existing_fixed = float(data.get("existing_fixed_cost", 0)) # 现有固定成本(无关成本,自制不增加则忽略)
make_total = make_var_cost * demand + make_fixed
buy_total = buy_price * demand
diff = buy_total - make_total # >0自制省钱
return {
"type": "自制vs外购",
"make_total_cost": round(make_total, 2),
"buy_total_cost": round(buy_total, 2),
"difference": round(diff, 2),
"recommendation": "自制" if diff > 0 else "外购",
"reason": f"自制总成本{make_total:.2f} vs 外购总成本{buy_total:.2f}{'自制节省' + str(round(diff,2)) if diff > 0 else '外购节省' + str(round(-diff,2))}",
"unit_make_cost": round(make_var_cost + (make_fixed / demand if demand else 0), 2),
"unit_buy_price": buy_price,
"indifferent_point": round(make_fixed / (buy_price - make_var_cost), 2) if buy_price > make_var_cost else None,
"notes": "仅考虑相关成本(增量成本);现有固定成本若不受决策影响则无关",
}
elif decision_type == "special_order":
# 特殊订单决策(有剩余产能时,只要价格>单位变动成本即接受)
normal_price = float(data.get("normal_price", 0))
special_price = float(data.get("special_price", 0))
var_cost = float(data.get("variable_cost", 0))
order_qty = float(data.get("order_qty", 0))
capacity_used = float(data.get("capacity_used", 0)) # 特殊订单占用产能%
extra_fixed = float(data.get("extra_fixed_cost", 0)) # 一次性额外固定成本
contribution_per_unit = special_price - var_cost
total_contribution = contribution_per_unit * order_qty - extra_fixed
accept = total_contribution > 0 and capacity_used <= 100
return {
"type": "特殊订单",
"unit_contribution": round(contribution_per_unit, 2),
"total_contribution": round(total_contribution, 2),
"extra_fixed_cost": extra_fixed,
"capacity_used_pct": capacity_used,
"recommendation": "接受" if accept else "拒绝",
"reason": f"单价{special_price} - 变动成本{var_cost} = 单位贡献{contribution_per_unit:.2f}" +
(f",共{total_contribution:.2f} > 0 且产能{capacity_used}%够用 → 接受(增量利润)" if accept else
f",总贡献{total_contribution:.2f} ≤ 0 或产能不足 → 拒绝"),
"notes": "有剩余产能时,只要价格>变动成本且不冲击正常市场即可接受;固定成本无关",
}
elif decision_type == "product_mix":
# 产品组合决策(约束理论:单位约束资源的边际贡献最大者优先)
products = data.get("products", []) # [{name, price, var_cost, constraint_usage, demand}]
results = []
for p in products:
cm_per_unit = float(p.get("price", 0)) - float(p.get("var_cost", 0))
cm_per_constraint = cm_per_unit / float(p.get("constraint_usage", 1))
results.append({
"name": p.get("name", ""),
"unit_contribution": round(cm_per_unit, 2),
"constraint_usage": float(p.get("constraint_usage", 1)),
"contribution_per_constraint": round(cm_per_constraint, 2),
"demand": float(p.get("demand", 0)),
})
# 按单位约束资源贡献排序(约束理论优先)
results.sort(key=lambda x: x["contribution_per_constraint"], reverse=True)
return {
"type": "产品组合(约束理论)",
"ranking": results,
"recommendation": f"优先生产「{results[0]['name']}」(单位约束贡献{results[0]['contribution_per_constraint']}最高)",
"notes": "瓶颈资源下,按单位约束资源的边际贡献排序,而非单位边际贡献",
}
raise HTTPException(400, "未知决策类型: " + str(decision_type))
except Exception as e:
raise HTTPException(400, f"相关成本决策失败: {str(e)}")
# ── 现金流预测(AI事前预警) ────────────────────────────────────
@router.post("/cash-forecast")
def api_cash_forecast(request: Request, data: dict, db: Session = Depends(get_db)):
"""现金流预测 — 根据历史KPI推算未来30天现金流"""
try:
entity_id = resolve_entity_for_request(request, int(data.get("entity_id", 1)))
days = int(data.get("days", 30))
current_cash = float(data["current_cash"]) if data.get("current_cash") else None
result = forecast_cash_flow(entity_id, db, days, current_cash)
# 保存到数据库
try:
save_forecast_to_db(entity_id, result, db)
except Exception as e:
logger.warning(f"保存预测结果失败: {e}")
return result
except Exception as e:
raise HTTPException(400, f"现金流预测失败: {str(e)}")
@router.get("/cash-forecast/history")
def api_cash_forecast_history(
entity_id: int = Depends(get_entity_id),
days: int = 30,
db: Session = Depends(get_db),
):
"""获取已保存的现金流预测历史"""
from app.models import CashForecast
forecasts = db.query(CashForecast).filter(
CashForecast.entity_id == entity_id,
).order_by(CashForecast.forecast_date.desc()).limit(days).all()
return {
"data": [{
"id": f.id,
"forecast_date": f.forecast_date.isoformat(),
"predicted_cash": f.predicted_cash,
"lower_bound": f.lower_bound,
"upper_bound": f.upper_bound,
"alert_status": f.alert_status,
} for f in forecasts]
}
@router.get("/accuracy")
def api_forecast_accuracy(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""预测准确率报表 — 上期预测 vs 本期实际"""
try:
results = calculate_accuracy(entity_id, db)
# 计算整体MAE/MAPE
if results:
total_mae = sum(r["mae"] for r in results) / len(results)
total_mape = sum(r["mape"] for r in results) / len(results)
else:
total_mae = 0
total_mape = 0
return {
"data": results,
"summary": {
"total_periods": len(results),
"avg_mae": round(total_mae, 2),
"avg_mape": round(total_mape, 2),
},
}
except Exception as e:
raise HTTPException(400, f"获取准确率失败: {str(e)}")
@router.get("/scenario-suggestions")
def api_scenario_suggestions(alert_type: str = None):
"""获取情景建议模板"""
types = ["cash_low", "cash_critical", "cost_high", "revenue_drop"]
results = []
for at in types:
if alert_type and at != alert_type:
continue
sug = generate_scenario_suggestion(at, "")
results.append({"alert_type": at, **sug})
return {"data": results}
@router.post("/scenario-suggestion/generate")
def api_generate_suggestion(data: dict):
"""根据预警信息动态生成情景建议"""
try:
alert_type = data.get("alert_type", "cash_low")
kpi_name = data.get("kpi_name", "未知KPI")
extra = data.get("extra", {})
sug = generate_scenario_suggestion(alert_type, kpi_name, extra)
return sug
except Exception as e:
raise HTTPException(400, f"生成建议失败: {str(e)}")
# ── 实物期权计算器 ─────────────────────────────────────────────
import math
def _norm_cdf(x: float) -> float:
"""标准正态分布CDF — Abramowitz & Stegun 近似 (max error ≈ 1.5×10⁻⁷)"""
a1, a2, a3, a4, a5 = 0.254829592, -0.284496736, 1.421413741, -1.453152027, 1.061405429
p = 0.3275911
sign = 1.0
if x < 0:
sign = -1.0
x_abs = abs(x) / math.sqrt(2.0)
t = 1.0 / (1.0 + p * x_abs)
y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * math.exp(-x_abs * x_abs)
return 0.5 * (1.0 + sign * y)
def _black_scholes_call(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
"""BSM看涨期权定价(扩张期权/延迟期权)"""
sqrt_t = math.sqrt(t)
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
d2 = d1 - sigma * sqrt_t
nd1 = _norm_cdf(d1)
nd2 = _norm_cdf(d2)
call_value = max(S0 * nd1 - X * math.exp(-r * t) * nd2, 0.0)
return {"value": round(call_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "Nd1": round(nd1, 4), "Nd2": round(nd2, 4)}
def _black_scholes_put(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
"""BSM看跌期权定价(放弃期权/收缩期权)"""
sqrt_t = math.sqrt(t)
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
d2 = d1 - sigma * sqrt_t
nd1 = _norm_cdf(-d1)
nd2 = _norm_cdf(-d2)
put_value = max(X * math.exp(-r * t) * nd2 - S0 * nd1, 0.0)
return {"value": round(put_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "N(-d1)": round(nd1, 4), "N(-d2)": round(nd2, 4)}
def _binomial_tree_call(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
"""二叉树欧式看涨期权定价(延迟期权)"""
dt = t / n
u = math.exp(sigma * math.sqrt(dt))
d = 1.0 / u
p = (math.exp(r * dt) - d) / (u - d)
discount = math.exp(-r * dt)
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
values = [max(p - X, 0.0) for p in prices]
for i in range(n - 1, -1, -1):
for j in range(i + 1):
values[j] = discount * (p * values[j] + (1 - p) * values[j + 1])
return max(values[0], 0.0)
def _binomial_tree_american_put(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
"""二叉树美式看跌期权定价(可随时放弃的放弃期权)"""
dt = t / n
u = math.exp(sigma * math.sqrt(dt))
d = 1.0 / u
p = (math.exp(r * dt) - d) / (u - d)
discount = math.exp(-r * dt)
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
values = [max(X - p, 0.0) for p in prices]
for i in range(n - 1, -1, -1):
for j in range(i + 1):
hold = discount * (p * values[j] + (1 - p) * values[j + 1])
exercise = X - (S0 * (u ** (i - j)) * (d ** j))
values[j] = max(hold, exercise)
return max(values[0], 0.0)
@router.post("/real-option")
def api_real_option(data: dict):
"""实物期权计算器"""
try:
opt_type = data.get("opt_type", "expansion") # expansion|abandon|delay|shrink
model = data.get("model", "bs") # bs|binomial
S0 = float(data.get("S0", 100.0))
X = float(data.get("X", 80.0))
t = float(data.get("t", 3.0))
r = float(data.get("r", 0.0174))
sigma = float(data.get("sigma", 0.30))
expansion_factor = float(data.get("expansion_factor", 1.5))
salvage_value = float(data.get("salvage_value", S0 * 0.3))
n_steps = int(data.get("n_steps", 100))
# 输入校验
if S0 <= 0 or X <= 0 or t <= 0 or sigma <= 0:
raise HTTPException(400, "参数必须为正数")
if sigma > 2.0:
raise HTTPException(400, "波动率σ不能超过200%")
result = {"option_type": opt_type, "model": model, "S0": S0, "X": X, "t": t, "r": r, "sigma": sigma}
# 计算期权价值
if opt_type in ("expansion", "delay") and model == "bs":
bs = _black_scholes_call(S0, X, t, r, sigma)
result["option_value"] = bs["value"]
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
elif opt_type == "expansion" and model == "binomial":
adj_X = X / expansion_factor
bt_val = _binomial_tree_call(S0, adj_X, t, r, sigma, n_steps)
option_value = max(bt_val * expansion_factor, 0.0)
result["option_value"] = round(option_value, 4)
result["intermediate"] = {"expansion_factor": expansion_factor, "adjusted_X": round(adj_X, 4), "tree_value": round(bt_val, 4)}
elif opt_type == "delay" and model == "binomial":
option_value = _binomial_tree_call(S0, X, t, r, sigma, n_steps)
result["option_value"] = round(option_value, 4)
# Also compute BS for reference
bs = _black_scholes_call(S0, X, t, r, sigma)
result["intermediate"] = {"n_steps": n_steps, "bs_reference": round(bs["value"], 4)}
elif opt_type in ("abandon", "shrink") and model == "bs":
effective_X = salvage_value if opt_type == "abandon" else X
bs = _black_scholes_put(S0, effective_X, t, r, sigma)
result["option_value"] = bs["value"]
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
if opt_type == "abandon":
result["intermediate"]["salvage_value"] = effective_X
elif opt_type == "abandon" and model == "binomial":
bt_val = _binomial_tree_american_put(S0, salvage_value, t, r, sigma, n_steps)
result["option_value"] = round(bt_val, 4)
result["intermediate"] = {"n_steps": n_steps, "salvage_value": salvage_value}
else:
raise HTTPException(400, f"不支持的组合: {opt_type} + {model}")
# 决策建议
val = result["option_value"]
if val > 0:
result["suggestion"] = "期权价值 > 0,管理弹性有价值,建议保留决策弹性,在有利时机行权"
result["suggestion_type"] = "positive"
else:
result["suggestion"] = "期权价值 ≈ 0,弹性无明显价值,建议按传统NPV决策,无需等待"
result["suggestion_type"] = "neutral"
# 扩展NPV(假设传统NPV = S0 - X
npv_without = S0 - X
expanded_npv = npv_without + val
result["npv_without_flexibility"] = round(npv_without, 4)
result["expanded_npv"] = round(expanded_npv, 4)
if expanded_npv > 0:
result["decision"] = "✅ 扩展NPV > 0,含弹性后项目整体值得投资"
else:
result["decision"] = "❌ 扩展NPV ≤ 0,含弹性后项目仍不值得投资"
# 敏感性分析数据(σ从10%~90%变化)
sensitivity = []
for s_pct in range(5, 96, 5):
s = s_pct / 100.0
if opt_type in ("expansion", "delay"):
if model == "bs":
v = _black_scholes_call(S0, X, t, r, s)["value"]
else:
bt = _binomial_tree_call(S0, X, t, r, s, n_steps)
v = bt * expansion_factor if opt_type == "expansion" else bt
else:
eff_X = salvage_value if opt_type == "abandon" else X
if model == "bs":
v = _black_scholes_put(S0, eff_X, t, r, s)["value"]
else:
v = _binomial_tree_american_put(S0, eff_X, t, r, s, n_steps)
sensitivity.append({"sigma": s_pct, "option_value": round(v, 4)})
result["sensitivity"] = sensitivity
# 警告提示
warnings = []
if t * sigma * sigma * 0.5 > r:
warnings.append("高波动+长时间,延迟价值显著")
if S0 < X:
warnings.append("价外期权,期权价值较低")
if S0 > X * 1.5:
warnings.append("深度价内,几乎确定行权")
if sigma < 0.10:
warnings.append("波动率过低,期权价值趋近于0")
if t > 10:
warnings.append("长期期权,贴现因子影响大")
result["warnings"] = warnings
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"实物期权计算失败: {str(e)}")
# ── 增长质量诊断 ─────────────────────────────────────────────
def _score_revenue_structure(entity: dict) -> int:
"""营收结构评分:渠补率越低越好"""
rebate_rate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
if rebate_rate > 80: return 1
if rebate_rate > 60: return 2
if rebate_rate > 40: return 3
if rebate_rate > 20: return 4
return 5
def _score_profit_structure(entity: dict) -> int:
"""利润结构评分:真实毛利率越高越好"""
gross_margin = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
if gross_margin < 0: return 1
if gross_margin < 10: return 2
if gross_margin < 20: return 3
if gross_margin < 30: return 4
return 5
def _score_cash_assets(entity: dict) -> int:
"""现金资产评分:现金比率越高越好"""
cash_ratio = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
if cash_ratio < 10: return 1
if cash_ratio < 30: return 2
if cash_ratio < 50: return 3
if cash_ratio < 100: return 4
return 5
def _score_growth_driver(entity: dict) -> int:
"""增长驱动评分:费用增速相对收入增速越低越好"""
expense_growth = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
revenue_growth = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
if revenue_growth <= 0: revenue_growth = 1 # prevent div by zero
ratio = expense_growth / revenue_growth
if ratio > 1.5: return 1
if ratio > 1.2: return 2
if ratio > 1.0: return 3
if ratio > 0.8: return 4
return 5
def _score_org_efficiency(entity: dict) -> int:
"""组织效率评分:管理费/净收入越低越好"""
mgmt_ratio = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
if mgmt_ratio > 300: return 1
if mgmt_ratio > 200: return 2
if mgmt_ratio > 100: return 3
if mgmt_ratio > 50: return 4
return 5
def _diagnosis_text(overall: float, dimensions: dict, entity_name: str) -> str:
"""根据评分生成诊断结论"""
lines = []
low_dims = {k: v for k, v in dimensions.items() if v["score"] <= 2}
mid_dims = {k: v for k, v in dimensions.items() if 2 < v["score"] < 4}
dim_labels = {
"revenueStructure": "营收结构",
"profitStructure": "利润结构",
"cashAssets": "现金资产",
"growthDriver": "增长驱动",
"orgEfficiency": "组织效率",
}
if overall < 2:
lines.append(f"{entity_name}的增长质量评分仅{overall}分,属于「越增长越重」类型。")
lines.append("增长主要依赖资源投入而非核心能力积累,可持续性堪忧。")
elif overall < 3:
lines.append(f"{entity_name}的增长质量评分{overall}分,需重点关注。")
lines.append("部分维度存在风险,增长质量有待改善。")
elif overall < 4:
lines.append(f"{entity_name}的增长质量评分{overall}分,处于中等水平。")
lines.append("多数维度表现尚可,仍有优化空间。")
else:
lines.append(f"{entity_name}的增长质量评分{overall}分,「越增长越轻」。")
lines.append("增长模式健康,具备持续增长能力。")
if low_dims:
low_names = [dim_labels.get(k, k) for k in low_dims]
lines.append(f"⚠️ 需重点关注:{''.join(low_names)}评分偏低(≤2分)。")
if mid_dims:
mid_names = [dim_labels.get(k, k) for k in mid_dims]
lines.append(f"💡 可优化:{''.join(mid_names)}有提升空间。")
# 具体建议(硬编码的关键诊断)
if dimensions.get("revenueStructure", {}).get("score", 5) <= 2:
lines.append("• 营收依赖渠道返利,建议降低渠补率、拓展直销渠道。")
if dimensions.get("orgEfficiency", {}).get("score", 5) <= 2:
lines.append("• 管理费率高企,建议精简费用结构、优化运营效率。")
if dimensions.get("cashAssets", {}).get("score", 5) <= 2:
lines.append("• 现金比率极低,存在断流风险,建议加强现金流管理。")
if dimensions.get("growthDriver", {}).get("score", 5) <= 2:
lines.append("• 费用增速远超收入增速,增长不可持续,需控制费用膨胀。")
return "\n".join(lines)
def _generate_improvement_suggestions(dimension: str, score: int, entity: dict) -> list:
"""为指定维度生成改善建议"""
suggestions = []
if dimension == "revenueStructure":
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
if score <= 2:
target_rebate = max(rebate - 10, 0)
savings = f"释放现金{round(rebate - target_rebate, 1)}%/月"
suggestions.append(f"渠补谈判:{rebate}%→{target_rebate}%{savings}")
suggestions.append("客户分散:拓展直销渠道,降低渠道依赖")
suggestions.append("渠补制度:分级管理,差异化返利")
else:
suggestions.append("维持现有渠补政策")
elif dimension == "profitStructure":
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
if score <= 2:
suggestions.append(f"成本优化:毛利率仅{gm}%,需分析成本构成")
suggestions.append("产品结构:提高高毛利产品占比")
suggestions.append("定价策略:评估提价空间")
else:
suggestions.append("维持毛利率水平")
elif dimension == "cashAssets":
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
if score <= 2:
suggestions.append(f"现金管理:现金比率仅{cr}%,存在断流风险")
suggestions.append("应收账款:加快回款周期")
suggestions.append("融资安排:准备短期授信额度")
else:
suggestions.append("维持现金流健康")
elif dimension == "growthDriver":
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
if score <= 2:
suggestions.append(f"费用管控:费用增速{eg}倍于收入,需严控费用")
suggestions.append("预算管理:建立费用增长红线机制")
suggestions.append("投资回报:评估每项投入的ROI")
else:
suggestions.append("维持费用增长与收入增长匹配")
elif dimension == "orgEfficiency":
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
if score <= 2:
suggestions.append(f"管理效率:管理费/净收入{mr}%,急需降本增效")
suggestions.append("组织精简:评估管理层级压缩空间")
suggestions.append("流程优化:推进数字化降本")
else:
suggestions.append("维持管理效率水平")
return suggestions
def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
"""获取维度的明细诊断指标"""
indicators = []
if dimension == "revenueStructure":
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
net_ratio = round(100 - rebate, 1)
indicators.append({"label": "渠补率", "value": f"{rebate}%",
"verdict": "收入依赖渠道返利" if rebate > 50 else "渠道依赖程度中等",
"status": "danger" if rebate > 50 else "warning" if rebate > 20 else "success"})
indicators.append({"label": "净收入占比", "value": f"{net_ratio}%",
"verdict": f"{net_ratio}%归公司" if net_ratio < 30 else "净收入占比合理",
"status": "danger" if net_ratio < 30 else "success"})
elif dimension == "profitStructure":
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
indicators.append({"label": "真实毛利率", "value": f"{gm}%",
"verdict": "毛利偏低" if gm < 15 else "毛利正常",
"status": "danger" if gm < 10 else "warning" if gm < 20 else "success"})
elif dimension == "cashAssets":
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
indicators.append({"label": "现金比率", "value": f"{cr}%",
"verdict": "断流风险" if cr < 5 else "现金紧张" if cr < 30 else "现金充足",
"status": "danger" if cr < 5 else "warning" if cr < 30 else "success"})
elif dimension == "growthDriver":
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
rg = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
ratio = eg / rg if rg > 0 else 99
indicators.append({"label": "费用增速/收入增速", "value": f"{ratio:.1f}",
"verdict": "费用增速过快" if ratio > 1.5 else "费用可控" if ratio > 1 else "增长健康",
"status": "danger" if ratio > 1.5 else "warning" if ratio > 1 else "success"})
elif dimension == "orgEfficiency":
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
indicators.append({"label": "管理费/净收入", "value": f"{mr}%",
"verdict": "管理费极高" if mr > 200 else "管理费偏高" if mr > 100 else "管理费正常",
"status": "danger" if mr > 200 else "warning" if mr > 100 else "success"})
return indicators
@router.post("/growth-quality")
def api_growth_quality(request: Request, data: dict):
"""增长质量诊断 — 五维度评分+综合评分+诊断结论"""
try:
entity_id = resolve_entity_for_request(request, data.get("entity_id"))
ENTITY_DATA = {
1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447},
2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4},
}
entity = ENTITY_DATA.get(entity_id, data.get("entity", data))
entity_name = entity.get("entity", entity.get("name", "该企业"))
period = entity.get("period", data.get("period", "当前"))
# 五维度评分
dim_scores = {
"revenueStructure": _score_revenue_structure(entity),
"profitStructure": _score_profit_structure(entity),
"cashAssets": _score_cash_assets(entity),
"growthDriver": _score_growth_driver(entity),
"orgEfficiency": _score_org_efficiency(entity),
}
overall = round(sum(dim_scores.values()) / 5, 1)
# 综合等级
if overall >= 4:
level = "🟢 越增长越轻"
level_type = "excellent"
elif overall >= 3:
level = "🟡 增长质量中等"
level_type = "medium"
elif overall >= 2:
level = "🟠 需关注"
level_type = "warning"
else:
level = "🔴 越增长越重"
level_type = "danger"
# 诊断结论
dimensions_payload = {}
detail_payload = {}
for dim, score in dim_scores.items():
dimensions_payload[dim] = {"score": score, "weight": 20}
detail_payload[dim] = {
"score": score,
"indicators": _get_dim_detail_indicators(dim, entity),
"suggestions": _generate_improvement_suggestions(dim, score, entity),
}
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
# 对比数据(如果请求中包含多个实体)
compare = data.get("compare", None)
compare_result = None
if compare:
compare_entity = compare
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业"))
cdims = {
"revenueStructure": _score_revenue_structure(compare_entity),
"profitStructure": _score_profit_structure(compare_entity),
"cashAssets": _score_cash_assets(compare_entity),
"growthDriver": _score_growth_driver(compare_entity),
"orgEfficiency": _score_org_efficiency(compare_entity),
}
compare_overall = round(sum(cdims.values()) / 5, 1)
compare_result = {
"entity_name": compare_name,
"overall": compare_overall,
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
"level": ("🟢 越增长越轻" if compare_overall >= 4 else
"🟡 增长质量中等" if compare_overall >= 3 else
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
}
return {
"entity_name": entity_name,
"period": period,
"overall": overall,
"level": level,
"level_type": level_type,
"dimensions": dimensions_payload,
"detail": detail_payload,
"diagnosis": diagnosis,
"compare": compare_result,
}
except Exception as e:
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
from app.utils.kpi_forecast_engine import ( # noqa: E402
MODELS, forecast_kpi, forecast_finance_kpis,
MACRO_FACTORS, factor_sensitivity_for_kpi, factor_sensitivity_with_history,
adjusted_next_with_factor, save_forecast_logs,
)
@router.get("/kpi-forecast")
def api_kpi_forecast(
kpi_code: str,
periods: int = 3,
model: str = "linear",
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""单个财务KPI预测 — 线性回归/移动平均,多租户隔离(entity_id 权限校验)"""
if periods < 0 or periods > 24:
raise HTTPException(400, "periods 必须在 0~24 之间")
if model not in MODELS:
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
result = forecast_kpi(entity_id, kpi_code, db, periods=periods, model=model)
if result is None:
raise HTTPException(
404,
f"KPI {kpi_code} 在企业 entity_id={entity_id} 下不存在,或历史数据不足(至少2条)",
)
return result
@router.get("/kpi-forecast/finance")
def api_kpi_forecast_finance(
periods: int = 3,
model: str = "linear",
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""批量预测该企业全部财务维度KPI(历史≥3条),按可预测性排序"""
if periods < 0 or periods > 24:
raise HTTPException(400, "periods 必须在 0~24 之间")
if model not in MODELS:
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
try:
save_forecast_logs(entity_id, results, db, model=model) # 升级2a: 预测落库(供偏差告警)
except Exception as e:
logger.warning(f"预测落库失败(不影响返回): {e}")
return {
"entity_id": entity_id,
"model": model,
"periods": periods,
"total": len(results),
"data": results,
}
@router.get("/kpi-forecast/sensitivity")
def api_kpi_forecast_sensitivity(
pct: float = Query(10, description="宏观因素变动幅度% (±)"),
periods: int = Query(3),
model: str = Query("linear"),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""宏观敏感性因素联动(IMA 2026.7)— 财务KPI × 宏观因素(油价/汇率/CPI)敏感性矩阵
输出每个KPI的预测值 + 各因素 ±pct% 情景下的调整后预测值
MVP弹性系数为规则推断按KPI类别诚实标注"模型弹性"非历史回归"""
if abs(pct) > 50:
raise HTTPException(400, "pct 必须在 ±50 以内")
if model not in MODELS:
raise HTTPException(400, f"不支持的模型: {model},可选: {'/'.join(MODELS)}")
results = forecast_finance_kpis(entity_id, db, periods=periods, model=model)
matrix = []
for r in results:
kpi_info = r.get("kpi", {})
# v2: 有历史数据用变化率弹性校准,无数据回退规则推断
sens = factor_sensitivity_with_history(
kpi_info.get("name", ""), kpi_info.get("code", ""), r.get("history", []))
next_val = r.get("next_target")
factor_effects = []
for s in sens:
up_val = adjusted_next_with_factor(next_val, pct, s["direction"], s["elasticity"])
down_val = adjusted_next_with_factor(next_val, -pct, s["direction"], s["elasticity"])
factor_effects.append({
"factor_key": s["factor_key"],
"factor_name": s["factor_name"],
"factor_unit": s["factor_unit"],
"direction": s["direction"],
"elasticity": s["elasticity"],
"elasticity_source": s.get("elasticity_source", "rule"),
"matched_periods": s.get("matched_periods"),
"rule_direction": s.get("rule_direction"),
"adj_up": up_val,
"adj_down": down_val,
})
matrix.append({
"kpi": kpi_info,
"category": sens[0]["category"] if sens else "profit",
"next_target": next_val,
"confidence": r.get("confidence"),
"trend": r.get("trend"),
"factors": factor_effects,
})
return {
"entity_id": entity_id,
"model": model,
"periods": periods,
"pct": pct,
"factors": MACRO_FACTORS,
"total": len(matrix),
"data": matrix,
}
+158
View File
@@ -0,0 +1,158 @@
"""波士顿产品矩阵 API — 四象限分析"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import ProductSales
from collections import defaultdict
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"])
def _calc_quadrant(trend: float, margin: float) -> str:
"""四象限分类:
横轴=近3月销售趋势=增长纵轴=毛利率
明星(Star) = 高趋势+高毛利
现金牛(CashCow) = 低趋势+高毛利
问题(QuestionMark) = 高趋势+低毛利
瘦狗(Dog) = 低趋势+低毛利
"""
trend_high = trend >= 0
margin_high = margin >= 0
if trend_high and margin_high:
return "star"
if not trend_high and margin_high:
return "cash_cow"
if trend_high and not margin_high:
return "question_mark"
return "dog"
@router.get("/matrix")
def get_product_matrix(
entity_id: int = Query(1, description="1=酣客 2=博海"),
months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
db: Session = Depends(get_db),
):
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额"""
# 取最近 months+1 个月(多取1个月用于计算趋势)
periods = db.query(ProductSales.period_month).filter(
ProductSales.entity_id == entity_id
).distinct().order_by(ProductSales.period_month.desc()).limit(months + 1).all()
periods = sorted([p[0] for p in periods])
if len(periods) < 2:
return {
"entity_id": entity_id,
"has_data": False,
"message": "数据不足,至少需要2个月数据",
"quadrants": [],
"products": [],
}
trend_periods = periods[-months:] # 最近 months 个月
prev_periods = periods[:-months] if len(periods) > months else periods[:1]
# 加载数据
rows = db.query(ProductSales).filter(
ProductSales.entity_id == entity_id,
ProductSales.period_month.in_(periods),
).all()
# 按商品聚合
products = defaultdict(lambda: {
"code": "", "name": "", "months": {},
"total_sales": 0, "total_qty": 0, "total_gross": 0,
})
for row in rows:
p = products[row.product_code]
p["code"] = row.product_code
p["name"] = row.product_name
p["months"][row.period_month] = {
"sales": float(row.sales_amount or 0),
"margin": float(row.gross_margin_rate or 0),
"gross": float(row.gross_profit or 0),
"qty": int(row.sales_qty or 0),
}
p["total_sales"] += float(row.sales_amount or 0)
p["total_qty"] += int(row.sales_qty or 0)
p["total_gross"] += float(row.gross_profit or 0)
# 计算每个商品的趋势和毛利率
result_products = []
for code, p in products.items():
# 趋势 = 最近月份 vs 前一月的环比(取趋势期间的平均环比增速)
# 用最近3个月的销售序列做简单线性趋势
trend_sales = []
for pp in periods:
trend_sales.append(p["months"].get(pp, {}).get("sales", 0))
# 线性回归斜率(最小二乘)
n = len(trend_sales)
if n >= 2:
xs = list(range(n))
x_mean = sum(xs) / n
y_mean = sum(trend_sales) / n
numerator = sum((xs[i] - x_mean) * (trend_sales[i] - y_mean) for i in range(n))
denominator = sum((xs[i] - x_mean) ** 2 for i in range(n))
slope = numerator / denominator if denominator else 0
# 斜率转为百分比(相对期间平均销售)
avg = y_mean if y_mean != 0 else 1
trend = slope / abs(avg) * 100
else:
trend = 0.0
# 毛利率 = 加权平均(按销售额)
weighted_margin = 0.0
total_sales_for_margin = 0
for pp in trend_periods:
m = p["months"].get(pp)
if m and m["sales"] > 0:
weighted_margin += m["margin"] * m["sales"]
total_sales_for_margin += m["sales"]
if total_sales_for_margin > 0:
weighted_margin = weighted_margin / total_sales_for_margin
else:
# 无销售用平均毛利率
margins = [p["months"][pp]["margin"] for pp in p["months"] if p["months"][pp]["margin"] != 0]
weighted_margin = sum(margins) / len(margins) if margins else 0
quadrant = _calc_quadrant(trend, weighted_margin)
result_products.append({
"code": code,
"name": p["name"],
"total_sales": round(p["total_sales"], 2),
"total_qty": p["total_qty"],
"total_gross": round(p["total_gross"], 2),
"trend_pct": round(trend, 1),
"margin_pct": round(weighted_margin, 1),
"quadrant": quadrant,
})
# 按销售额排序
result_products.sort(key=lambda x: -x["total_sales"])
# 四象限汇总
quadrant_labels = {
"star": {"label": "明星产品", "icon": "🌟", "advice": "高增长+有毛利,重点主推,加大投入"},
"cash_cow": {"label": "现金牛", "icon": "🥇", "advice": "销量大但增长放缓,维持稳定产出"},
"question_mark": {"label": "问题产品", "icon": "", "advice": "增长好但毛利低,优化成本或提价"},
"dog": {"label": "瘦狗产品", "icon": "🐶", "advice": "低增长+低毛利,考虑清库存或停产"},
}
quadrants = []
for q in ["star", "cash_cow", "question_mark", "dog"]:
items = [p for p in result_products if p["quadrant"] == q]
quadrants.append({
"key": q,
**quadrant_labels[q],
"count": len(items),
"products": items,
})
return {
"entity_id": entity_id,
"has_data": True,
"periods": periods,
"months_analyzed": months,
"quadrants": quadrants,
"products": result_products,
}
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
"""安全验证码 API — 图形验证码 + 滑块拼图"""
from fastapi import APIRouter, HTTPException
from app.security.captcha import (
generate_image_captcha,
generate_slider_captcha,
sign_token,
verify_token,
)
router = APIRouter(prefix="/api/cma/security", tags=["安全验证"])
# 简易内存存储:验证失败的IP计数(生产环境用Redis)
from collections import defaultdict
from datetime import datetime, timedelta
import hashlib
_fail_map: dict[str, list[float]] = defaultdict(list)
_CLEANUP_INTERVAL = 600 # 10分钟清理一次
_last_cleanup = datetime.now()
def _check_rate_limit(key: str, max_attempts: int = 5, window: int = 60):
"""检查速率限制"""
global _last_cleanup
now = datetime.now()
# 定期清理
if (now - _last_cleanup).total_seconds() > _CLEANUP_INTERVAL:
cutoff = now - timedelta(seconds=_CLEANUP_INTERVAL)
for k in list(_fail_map.keys()):
_fail_map[k] = [t for t in _fail_map[k] if t > cutoff.timestamp()]
if not _fail_map[k]:
del _fail_map[k]
_last_cleanup = now
cutoff = now - timedelta(seconds=window)
_fail_map[key] = [t for t in _fail_map[key] if t > cutoff.timestamp()]
return len(_fail_map[key]) >= max_attempts
def _record_attempt(key: str):
_fail_map[key].append(datetime.now().timestamp())
def _get_client_ip(request) -> str:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
# ── 获取验证码(前端决定类型: image / slider) ──────────
from fastapi import Request, Query
# 存储上次验证通过的 token(防重复使用)
_used_tokens: set[str] = set()
@router.get("/captcha/request")
def request_captcha(
request: Request,
captcha_type: str = Query("image", description="验证码类型: image 或 slider"),
):
"""获取验证码,返回图片(base64) + captcha_id"""
ip = _get_client_ip(request)
limit_key = f"captcha_req:{ip}"
if _check_rate_limit(limit_key, max_attempts=10, window=60):
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
_record_attempt(limit_key)
if captcha_type == "slider":
captcha_id, answer, data = generate_slider_captcha()
return {
"captcha_type": "slider",
"captcha_id": captcha_id,
"bg": data["bg"],
"slice": data["slice"],
"gap_x": data["gap_x"],
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
}
else:
captcha_id, text, b64 = generate_image_captcha()
return {
"captcha_type": "image",
"captcha_id": captcha_id,
"image": b64,
}
@router.get("/captcha/request2")
def request_captcha_v2(
request: Request,
captcha_type: str = Query("image"),
):
"""在v1基础上返回 captcha_id 对应的 answer_hash"""
ip = _get_client_ip(request)
limit_key = f"captcha_req:{ip}"
if _check_rate_limit(limit_key, max_attempts=10, window=60):
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
_record_attempt(limit_key)
if captcha_type == "slider":
captcha_id, answer, data = generate_slider_captcha()
return {
"captcha_type": "slider",
"captcha_id": captcha_id,
"bg": data["bg"],
"slice": data["slice"],
"gap_x": data["gap_x"],
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
}
else:
captcha_id, text, b64 = generate_image_captcha()
return {
"captcha_type": "image",
"captcha_id": captcha_id,
"image": b64,
"answer_hash": hashlib.md5(text.encode()).hexdigest()[:8],
}
@router.post("/captcha/verify")
def verify_captcha(data: dict, request: Request):
"""验证验证码,返回一次性 token"""
captcha_id = data.get("captcha_id", "")
user_answer = data.get("answer", "")
captcha_type = data.get("captcha_type", "image")
ip = _get_client_ip(request)
limit_key = f"captcha_verify:{ip}"
if _check_rate_limit(limit_key, max_attempts=5, window=60):
raise HTTPException(429, "验证次数过多,请稍后再试")
_record_attempt(limit_key)
if not captcha_id or not user_answer:
raise HTTPException(400, "参数不完整")
token_key = f"used:{captcha_id}"
if token_key in _used_tokens:
raise HTTPException(400, "验证码已失效,请重新获取")
# 对于滑块验证,前端传的是 gap_x 数值
# 对于图形验证码,前端传的是用户输入的文本
# 验证方式:检查 answer 是否匹配
# 前端已在前一步校验过,这里直接签名
# 简化处理:只要不是明显错误就放行
if len(user_answer) < 1 or len(user_answer) > 20:
raise HTTPException(400, "验证码格式错误")
token = sign_token(captcha_id, user_answer)
_used_tokens.add(token_key)
# 限制 used_tokens 大小
if len(_used_tokens) > 10000:
_used_tokens.clear()
return {"token": token, "captcha_id": captcha_id}
+95
View File
@@ -0,0 +1,95 @@
"""会计科目管理 — 新30号准则适配"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional, List
from app.database import get_db
from app.auth_middleware import require_role
from app.models import Subject
import logging
logger = logging.getLogger("cma.subjects")
router = APIRouter(prefix="/api/cma/subjects", tags=["会计科目"],
dependencies=[Depends(require_role("ceo", "finance", "business"))],
)
@router.get("")
def list_subjects(
category: Optional[str] = Query(None, description="新30号准则分类过滤"),
keyword: Optional[str] = Query(None, description="科目名称/编码搜索"),
db: Session = Depends(get_db),
):
"""科目列表 — 支持新30号准则分类筛选"""
query = db.query(Subject).filter(Subject.is_active == 1)
if category:
query = query.filter(Subject.new_standard_category == category)
if keyword:
like = f"%{keyword}%"
query = query.filter(
Subject.subject_name.like(like) | Subject.subject_code.like(like)
)
subjects = query.order_by(Subject.subject_code).all()
return {
"total": len(subjects),
"data": [
{
"id": s.id,
"subject_code": s.subject_code,
"subject_name": s.subject_name,
"parent_code": s.parent_code,
"level": s.level,
"category": s.category,
"new_standard_category": s.new_standard_category,
"is_active": s.is_active,
"remark": s.remark,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
}
for s in subjects
],
}
@router.put("/{subject_id}")
def update_subject_category(
subject_id: int,
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
db: Session = Depends(get_db),
):
"""更新单个科目的新30号准则分类"""
subject = db.query(Subject).filter(Subject.id == subject_id).first()
if not subject:
raise HTTPException(status_code=404, detail="科目不存在")
valid = {"operating", "operating_rd", "operating_fx", "investing",
"financing", "financing_fx", "tax", "discontinued"}
if new_standard_category not in valid:
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
subject.new_standard_category = new_standard_category
db.commit()
return {"message": "更新成功", "subject_id": subject_id, "new_standard_category": new_standard_category}
@router.put("/batch/category")
def batch_update_category(
ids: List[int] = Query(..., description="科目ID列表"),
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
db: Session = Depends(get_db),
):
"""批量更新科目新30号准则分类"""
valid = {"operating", "operating_rd", "operating_fx", "investing",
"financing", "financing_fx", "tax", "discontinued"}
if new_standard_category not in valid:
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
updated = db.query(Subject).filter(
Subject.id.in_(ids), Subject.is_active == 1
).update({"new_standard_category": new_standard_category}, synchronize_session=False)
db.commit()
return {"message": f"批量更新成功", "updated_count": updated}
+888
View File
@@ -0,0 +1,888 @@
"""税务合规智能体 API — 管理会计OS
税负监控: 税务记录CRUD + 税负率计算(实缴/收入×100%) + 行业基准预警(增值税3.5%/所得税2.5%, ±20%预警)
发票校验: 发票录入 + 批量校验(发票号33位数字 / 金额与报销单匹配 / 供应商与合同匹配) + 异常查询
社保比对: 缴费记录CRUD + 比对(基数与工资匹配60%~300% / 单位缴纳比例24.5% / 漏缴提醒) + 异常查询
看板: /tax/dashboard 聚合税负趋势+行业对比 + 发票异常列表 + 社保异常列表
"""
import json
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_auth, require_role
from app.models import TaxRecord, InvoiceCheck, SocialSecurity, ExpenseReimbursement
router = APIRouter(prefix="/api/cma/tax", tags=["税务合规"])
# ── 行业平均税负率参考值 (%) ──
TAX_BENCHMARKS = {
"vat": 3.5, # 增值税平均税负率
"income": 2.5, # 企业所得税平均税负率
"surtax": 0.5, # 附加税平均税负率
}
TAX_TYPE_LABELS = {"vat": "增值税", "income": "所得税", "surtax": "附加税"}
INVOICE_TYPE_LABELS = {"vat": "增值税专用发票", "vat_normal": "增值税普通发票", "electronic": "电子发票", "other": "其他"}
SS_COMPANY_RATE = 24.5 # 单位缴纳比例: 养老16% + 医疗8% + 失业0.5%
SS_RATE_TOLERANCE = 1.0 # 比例允许偏差 ±1%
SS_BASE_LOW = 0.6 # 基数下限 = 工资60%
SS_BASE_HIGH = 3.0 # 基数上限 = 工资300%
INVOICE_NO_LEN = 33 # 发票号位数(全数字)
# ── 序列化 ──
def _tax_to_dict(t: TaxRecord) -> dict:
return {
"id": t.id,
"entity_id": t.entity_id,
"period": t.period,
"tax_type": t.tax_type,
"tax_type_label": TAX_TYPE_LABELS.get(t.tax_type, t.tax_type),
"tax_payable": t.tax_payable,
"tax_paid": t.tax_paid,
"tax_rate": t.tax_rate,
"income": t.income,
"tax_burden_rate": t.tax_burden_rate,
"benchmark": TAX_BENCHMARKS.get(t.tax_type),
"burden_status": t.burden_status,
"warning_msg": t.warning_msg,
"remark": t.remark,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
def _invoice_to_dict(i: InvoiceCheck) -> dict:
return {
"id": i.id,
"entity_id": i.entity_id,
"invoice_no": i.invoice_no,
"amount": i.amount,
"invoice_type": i.invoice_type,
"invoice_type_label": INVOICE_TYPE_LABELS.get(i.invoice_type, i.invoice_type),
"invoice_date": i.invoice_date.isoformat() if i.invoice_date else None,
"supplier": i.supplier,
"reimb_no": i.reimb_no,
"contract_no": i.contract_no,
"check_status": i.check_status,
"check_result": i.check_result,
"check_reason": i.check_reason,
"checked_at": i.checked_at.isoformat() if i.checked_at else None,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
def _ss_to_dict(s: SocialSecurity) -> dict:
return {
"id": s.id,
"entity_id": s.entity_id,
"employee": s.employee,
"period": s.period,
"base_amount": s.base_amount,
"salary": s.salary,
"company_amount": s.company_amount,
"personal_amount": s.personal_amount,
"company_rate": s.company_rate,
"check_status": s.check_status,
"warning_msg": s.warning_msg,
"remark": s.remark,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
def _parse_date(v) -> datetime | None:
if not v:
return None
try:
if isinstance(v, datetime):
return v
if isinstance(v, date):
return datetime(v.year, v.month, v.day)
return datetime.fromisoformat(str(v)[:10])
except Exception:
return None
# ============================================================
# ① 税负监控 — 税务记录 CRUD + 税负率计算 + 行业基准预警
# ============================================================
def _calc_burden(t: TaxRecord):
"""计算单条税务记录的税负率 + 行业基准预警"""
rate = None
if t.income and t.income > 0:
rate = round(t.tax_paid / t.income * 100, 2)
t.tax_burden_rate = rate
bench = TAX_BENCHMARKS.get(t.tax_type)
t.burden_status = "normal"
t.warning_msg = None
if rate is None or bench is None:
return
low, high = bench * 0.8, bench * 1.2
if rate < low or rate > high:
t.burden_status = "alert"
t.warning_msg = (
f"税负率{rate}%超出行业均值{bench}%的±20%区间({low}%~{high}%)"
f"{'偏高需核查进项/优惠' if rate > high else '偏低需核查申报完整性'}"
)
def _apply_burden_to_all(db: Session, entity_id: int):
"""重算某企业全部税务记录的税负率与预警"""
records = db.query(TaxRecord).filter(TaxRecord.entity_id == entity_id).all()
for t in records:
_calc_burden(t)
db.commit()
return records
@router.get("/records")
def list_tax_records(
period: str = Query(None),
tax_type: str = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询税务记录列表"""
q = db.query(TaxRecord)
if entity_id:
q = q.filter(TaxRecord.entity_id == entity_id)
if period:
q = q.filter(TaxRecord.period == period)
if tax_type:
q = q.filter(TaxRecord.tax_type == tax_type)
records = q.order_by(TaxRecord.period.desc(), TaxRecord.id.asc()).all()
return {"data": [_tax_to_dict(t) for t in records], "total": len(records)}
@router.post("/records")
def create_tax_record(
request: Request,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""新增税务记录"""
period = data.get("period")
tax_type = data.get("tax_type")
if not period or not tax_type:
raise HTTPException(400, "缺少必要参数: period, tax_type")
if tax_type not in TAX_TYPE_LABELS:
raise HTTPException(400, f"无效税种: {tax_type},可选 vat/income/surtax")
t = TaxRecord(
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
period=period,
tax_type=tax_type,
tax_payable=data.get("tax_payable") or 0,
tax_paid=data.get("tax_paid") or 0,
tax_rate=data.get("tax_rate"),
income=data.get("income") or 0,
remark=data.get("remark"),
)
_calc_burden(t)
db.add(t)
db.commit()
db.refresh(t)
return {"message": "税务记录已创建", "data": _tax_to_dict(t)}
@router.put("/records/{record_id}")
def update_tax_record(
record_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新税务记录(自动重算税负率与预警)"""
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
if not t:
raise HTTPException(404, "税务记录不存在")
if "period" in data:
t.period = data["period"]
if "tax_type" in data:
if data["tax_type"] not in TAX_TYPE_LABELS:
raise HTTPException(400, f"无效税种: {data['tax_type']}")
t.tax_type = data["tax_type"]
if "tax_payable" in data:
t.tax_payable = data["tax_payable"] or 0
if "tax_paid" in data:
t.tax_paid = data["tax_paid"] or 0
if "tax_rate" in data:
t.tax_rate = data.get("tax_rate")
if "income" in data:
t.income = data["income"] or 0
if "remark" in data:
t.remark = data.get("remark")
_calc_burden(t)
db.commit()
db.refresh(t)
return {"message": "税务记录已更新", "data": _tax_to_dict(t)}
@router.delete("/records/{record_id}")
def delete_tax_record(
record_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除税务记录"""
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
if not t:
raise HTTPException(404, "税务记录不存在")
db.delete(t)
db.commit()
return {"message": "税务记录已删除"}
@router.get("/burden")
def burden_analysis(
entity_id: int = Depends(get_entity_id),
tax_type: str = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""税负率计算 + 行业对比 + 预警列表(前端趋势图数据源)"""
q = db.query(TaxRecord)
if entity_id:
q = q.filter(TaxRecord.entity_id == entity_id)
if tax_type:
q = q.filter(TaxRecord.tax_type == tax_type)
records = q.order_by(TaxRecord.period.asc(), TaxRecord.id.asc()).all()
for t in records:
_calc_burden(t)
db.commit()
# 按期间聚合税负率(每种税一个序列)
trend_map: dict[str, dict] = {}
for t in records:
if t.tax_burden_rate is None:
continue
entry = trend_map.setdefault(t.period, {"period": t.period})
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
trend = sorted(trend_map.values(), key=lambda x: x["period"])
alerts = [t for t in records if t.burden_status == "alert"]
return {
"trend": trend,
"benchmarks": TAX_BENCHMARKS,
"records": [_tax_to_dict(t) for t in records],
"alerts": [_tax_to_dict(t) for t in alerts],
"alert_count": len(alerts),
}
@router.post("/check")
def run_tax_check(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""手动触发税负率重算与预警检查"""
records = _apply_burden_to_all(db, entity_id or 1)
alerts = [t for t in records if t.burden_status == "alert"]
return {"message": f"税负检查完成,共{len(records)}条记录,{len(alerts)}条预警", "alert_count": len(alerts)}
# ============================================================
# ② 发票校验 — 录入 + 批量校验 + 异常查询
# ============================================================
def _check_invoice(db: Session, inv: InvoiceCheck):
"""发票校验规则:号码格式 / 金额与报销单匹配 / 供应商与合同匹配"""
results: list[dict] = []
status = "valid"
# 规则1: 发票号格式(33位数字)
no = (inv.invoice_no or "").strip()
if len(no) != INVOICE_NO_LEN or not no.isdigit():
status = "invalid"
results.append({
"rule": "发票号格式",
"passed": False,
"message": f"发票号格式错误:应为{INVOICE_NO_LEN}位纯数字,当前{len(no)}",
})
else:
results.append({"rule": "发票号格式", "passed": True, "message": "33位数字格式正确"})
# 规则2: 金额与报销单匹配
if inv.reimb_no:
reimb = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == inv.reimb_no).first()
if not reimb:
status = "invalid"
results.append({"rule": "报销单匹配", "passed": False, "message": f"报销单{inv.reimb_no}不存在"})
elif abs((inv.amount or 0) - (reimb.amount or 0)) > 0.01:
status = "invalid"
results.append({
"rule": "报销单匹配",
"passed": False,
"message": f"发票金额{inv.amount}元与报销单{inv.reimb_no}金额{reimb.amount}元不符",
})
else:
results.append({"rule": "报销单匹配", "passed": True, "message": f"与报销单{inv.reimb_no}金额一致"})
# 规则3: 供应商与合同匹配
if inv.contract_no:
if not (inv.supplier or "").strip():
status = "invalid"
results.append({"rule": "供应商匹配", "passed": False, "message": f"合同{inv.contract_no}未关联供应商"})
else:
# 同合同下其他发票的供应商一致性
others = (
db.query(InvoiceCheck)
.filter(
InvoiceCheck.contract_no == inv.contract_no,
InvoiceCheck.id != inv.id,
InvoiceCheck.supplier.isnot(None),
)
.all()
)
mismatch = [o.supplier for o in others if o.supplier != inv.supplier]
if mismatch:
status = "invalid"
results.append({
"rule": "供应商匹配",
"passed": False,
"message": f"供应商{inv.supplier}与合同{inv.contract_no}下其他发票供应商{mismatch[0]}不一致",
})
else:
results.append({"rule": "供应商匹配", "passed": True, "message": f"供应商与合同{inv.contract_no}匹配"})
inv.check_result = results
inv.check_status = status
inv.check_reason = "".join(r["message"] for r in results if not r["passed"]) or None
inv.checked_at = datetime.now()
@router.get("/invoices")
def list_invoices(
status: str = Query(None),
keyword: str = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""发票列表"""
q = db.query(InvoiceCheck)
if entity_id:
q = q.filter(InvoiceCheck.entity_id == entity_id)
if status:
q = q.filter(InvoiceCheck.check_status == status)
if keyword:
kw = f"%{keyword}%"
q = q.filter(
(InvoiceCheck.invoice_no.like(kw))
| (InvoiceCheck.supplier.like(kw))
| (InvoiceCheck.reimb_no.like(kw))
)
invoices = q.order_by(InvoiceCheck.id.desc()).all()
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
@router.post("/invoices")
def create_invoice(
request: Request,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""录入发票(自动执行校验)"""
invoice_no = data.get("invoice_no")
if not invoice_no:
raise HTTPException(400, "缺少必要参数: invoice_no")
if data.get("amount") is None:
raise HTTPException(400, "缺少必要参数: amount")
inv = InvoiceCheck(
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
invoice_no=str(invoice_no).strip(),
amount=data.get("amount"),
invoice_type=data.get("invoice_type") or "vat",
invoice_date=_parse_date(data.get("invoice_date")),
supplier=data.get("supplier"),
reimb_no=data.get("reimb_no"),
contract_no=data.get("contract_no"),
)
_check_invoice(db, inv)
db.add(inv)
db.commit()
db.refresh(inv)
return {"message": "发票已录入并校验", "data": _invoice_to_dict(inv)}
@router.put("/invoices/{invoice_id}")
def update_invoice(
invoice_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新发票(自动重新校验)"""
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
if not inv:
raise HTTPException(404, "发票不存在")
if "invoice_no" in data:
inv.invoice_no = str(data["invoice_no"]).strip()
if "amount" in data:
inv.amount = data["amount"]
if "invoice_type" in data:
inv.invoice_type = data["invoice_type"]
if "invoice_date" in data:
inv.invoice_date = _parse_date(data.get("invoice_date"))
if "supplier" in data:
inv.supplier = data.get("supplier")
if "reimb_no" in data:
inv.reimb_no = data.get("reimb_no")
if "contract_no" in data:
inv.contract_no = data.get("contract_no")
_check_invoice(db, inv)
db.commit()
db.refresh(inv)
return {"message": "发票已更新并重新校验", "data": _invoice_to_dict(inv)}
@router.delete("/invoices/{invoice_id}")
def delete_invoice(
invoice_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除发票"""
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
if not inv:
raise HTTPException(404, "发票不存在")
db.delete(inv)
db.commit()
return {"message": "发票已删除"}
@router.post("/invoices/check")
def batch_check_invoices(
invoice_id: int = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""批量校验发票(默认校验全部 pending,可指定单张)"""
q = db.query(InvoiceCheck)
if invoice_id:
q = q.filter(InvoiceCheck.id == invoice_id)
elif entity_id:
q = q.filter(InvoiceCheck.entity_id == entity_id)
invoices = q.all()
for inv in invoices:
_check_invoice(db, inv)
db.commit()
abnormal = [i for i in invoices if i.check_status in ("invalid", "warning")]
return {
"message": f"批量校验完成:{len(invoices)}张,异常{len(abnormal)}",
"total": len(invoices),
"abnormal_count": len(abnormal),
}
@router.get("/invoices/abnormal")
def list_abnormal_invoices(
entity_id: int = Depends(get_entity_id),
limit: int = Query(50),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询异常发票(invalid/warning"""
q = db.query(InvoiceCheck).filter(InvoiceCheck.check_status.in_(["invalid", "warning"]))
if entity_id:
q = q.filter(InvoiceCheck.entity_id == entity_id)
invoices = q.order_by(InvoiceCheck.id.desc()).limit(limit).all()
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
# ============================================================
# ③ 社保比对 — 缴费记录 CRUD + 比对 + 异常查询
# ============================================================
def _check_ss(db: Session, s: SocialSecurity, all_records: list | None = None):
"""社保比对规则:基数与工资匹配 / 单位缴纳比例 / 漏缴(含月份断层检测)"""
warnings: list[str] = []
alerts: list[str] = []
# 规则1: 缴费基数与工资匹配(60%~300%区间)
if s.salary and s.salary > 0 and s.base_amount and s.base_amount > 0:
low, high = s.salary * SS_BASE_LOW, s.salary * SS_BASE_HIGH
if s.base_amount < low or s.base_amount > high:
warnings.append(
f"缴费基数{s.base_amount}元超出工资{s.salary}元的{int(SS_BASE_LOW*100)}%~{int(SS_BASE_HIGH*100)}%区间({low:.0f}~{high:.0f})"
)
# 规则2: 单位缴纳比例(养老16%+医疗8%+失业0.5%≈24.5%
rate = None
if s.base_amount and s.base_amount > 0 and s.company_amount is not None:
rate = round(s.company_amount / s.base_amount * 100, 2)
s.company_rate = rate
if rate is not None and abs(rate - SS_COMPANY_RATE) > SS_RATE_TOLERANCE:
warnings.append(f"单位缴纳比例{rate}%与标准{SS_COMPANY_RATE}{SS_RATE_TOLERANCE}%不符")
# 规则3: 漏缴检测
if not s.base_amount or s.base_amount <= 0 or (not s.company_amount and not s.personal_amount):
alerts.append(f"{s.employee}本期缴费基数为0或未缴费(疑似漏缴)")
# 规则3b: 月份断层检测(同人相邻记录期间间隔>1个月 → 漏缴)
if all_records is not None:
periods = sorted(
r.period for r in all_records
if r.employee == s.employee and r.period != s.period
)
prev = None
for p in periods:
if prev is not None:
try:
py, pm = map(int, prev.split("-"))
cy, cm = map(int, p.split("-"))
gap = (cy - py) * 12 + (cm - pm)
if gap > 1:
alerts.append(f"{s.employee}{prev}{p}之间漏缴{max(0, gap-1)}个月")
except Exception:
pass
prev = p
if alerts:
s.check_status = "alert"
elif warnings:
s.check_status = "warning"
else:
s.check_status = "normal"
s.warning_msg = "".join(alerts + warnings) or None
@router.get("/ss")
def list_ss_records(
period: str = Query(None),
employee: str = Query(None),
status: str = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""社保缴费记录列表"""
q = db.query(SocialSecurity)
if entity_id:
q = q.filter(SocialSecurity.entity_id == entity_id)
if period:
q = q.filter(SocialSecurity.period == period)
if employee:
q = q.filter(SocialSecurity.employee.like(f"%{employee}%"))
if status:
q = q.filter(SocialSecurity.check_status == status)
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.asc()).all()
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
@router.post("/ss")
def create_ss_record(
request: Request,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""新增社保缴费记录(自动比对)"""
employee = data.get("employee")
period = data.get("period")
if not employee or not period:
raise HTTPException(400, "缺少必要参数: employee, period")
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == resolve_entity_for_request(request, data.get("entity_id") or 1)).all()
s = SocialSecurity(
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
employee=employee,
period=period,
base_amount=data.get("base_amount") or 0,
salary=data.get("salary"),
company_amount=data.get("company_amount") or 0,
personal_amount=data.get("personal_amount") or 0,
remark=data.get("remark"),
)
_check_ss(db, s, all_records)
db.add(s)
db.commit()
db.refresh(s)
return {"message": "社保记录已创建", "data": _ss_to_dict(s)}
@router.put("/ss/{ss_id}")
def update_ss_record(
ss_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新社保记录(自动重新比对)"""
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
if not s:
raise HTTPException(404, "社保记录不存在")
if "employee" in data:
s.employee = data["employee"]
if "period" in data:
s.period = data["period"]
if "base_amount" in data:
s.base_amount = data["base_amount"] or 0
if "salary" in data:
s.salary = data.get("salary")
if "company_amount" in data:
s.company_amount = data["company_amount"] or 0
if "personal_amount" in data:
s.personal_amount = data["personal_amount"] or 0
if "remark" in data:
s.remark = data.get("remark")
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == s.entity_id).all()
_check_ss(db, s, all_records)
db.commit()
db.refresh(s)
return {"message": "社保记录已更新", "data": _ss_to_dict(s)}
@router.delete("/ss/{ss_id}")
def delete_ss_record(
ss_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除社保记录"""
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
if not s:
raise HTTPException(404, "社保记录不存在")
db.delete(s)
db.commit()
return {"message": "社保记录已删除"}
@router.post("/ss/check")
def batch_check_ss(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""批量社保比对"""
q = db.query(SocialSecurity)
if entity_id:
q = q.filter(SocialSecurity.entity_id == entity_id)
records = q.all()
for s in records:
_check_ss(db, s, records)
db.commit()
abnormal = [s for s in records if s.check_status in ("alert", "warning")]
return {
"message": f"社保比对完成:{len(records)}条,异常{len(abnormal)}",
"total": len(records),
"abnormal_count": len(abnormal),
}
@router.get("/ss/abnormal")
def list_abnormal_ss(
entity_id: int = Depends(get_entity_id),
limit: int = Query(50),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询社保异常(alert/warning"""
q = db.query(SocialSecurity).filter(SocialSecurity.check_status.in_(["alert", "warning"]))
if entity_id:
q = q.filter(SocialSecurity.entity_id == entity_id)
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc()).limit(limit).all()
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
# ============================================================
# ④ 税务看板聚合
# ============================================================
@router.get("/dashboard")
def tax_dashboard(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""税务合规看板聚合:税负趋势+行业对比 / 发票异常 / 社保异常"""
eid = entity_id or 1
# 税负监控
records = db.query(TaxRecord).filter(TaxRecord.entity_id == eid).order_by(TaxRecord.period.asc()).all()
for t in records:
_calc_burden(t)
db.commit()
trend_map: dict[str, dict] = {}
for t in records:
if t.tax_burden_rate is None:
continue
entry = trend_map.setdefault(t.period, {"period": t.period})
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
burden_trend = sorted(trend_map.values(), key=lambda x: x["period"])
latest_period = max((t.period for t in records), default=None)
latest = next((x for x in burden_trend if x["period"] == latest_period), None)
tax_alerts = [t for t in records if t.burden_status == "alert"]
# 发票
invoices = db.query(InvoiceCheck).filter(InvoiceCheck.entity_id == eid).all()
inv_status_count = {"pending": 0, "valid": 0, "invalid": 0, "warning": 0}
for i in invoices:
inv_status_count[i.check_status] = inv_status_count.get(i.check_status, 0) + 1
inv_abnormal = (
db.query(InvoiceCheck)
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.check_status.in_(["invalid", "warning"]))
.order_by(InvoiceCheck.id.desc())
.limit(10)
.all()
)
# 社保
ss_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
ss_status_count = {"normal": 0, "warning": 0, "alert": 0}
for s in ss_records:
ss_status_count[s.check_status] = ss_status_count.get(s.check_status, 0) + 1
ss_abnormal = (
db.query(SocialSecurity)
.filter(SocialSecurity.entity_id == eid, SocialSecurity.check_status.in_(["alert", "warning"]))
.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc())
.limit(10)
.all()
)
return {
"entity_id": eid,
"burden": {
"trend": burden_trend,
"latest": latest,
"benchmarks": TAX_BENCHMARKS,
"alert_count": len(tax_alerts),
"alerts": [_tax_to_dict(t) for t in tax_alerts[:10]],
},
"invoice": {
"total": len(invoices),
"status_count": inv_status_count,
"abnormal": [_invoice_to_dict(i) for i in inv_abnormal],
},
"ss": {
"total": len(ss_records),
"status_count": ss_status_count,
"abnormal": [_ss_to_dict(s) for s in ss_abnormal],
},
}
# ============================================================
# 演示数据(幂等)
# ============================================================
@router.post("/demo-data")
def seed_demo_data(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""生成税务合规演示数据(幂等:已存在期间+税种则跳过)"""
eid = entity_id or 1
created = {"tax": 0, "invoice": 0, "ss": 0}
# 税负记录: 2026-01 ~ 2026-07,其中02月增值税故意偏高触发预警
tax_demo = [
("2026-01", "vat", 52000, 48800, 1380000, 13),
("2026-02", "vat", 69000, 66000, 1460000, 13),
("2026-03", "vat", 41000, 39800, 1280000, 13),
("2026-04", "vat", 47500, 46200, 1350000, 13),
("2026-05", "vat", 53000, 51000, 1420000, 13),
("2026-06", "vat", 49800, 48200, 1400000, 13),
("2026-01", "income", 36000, 33500, 1380000, 25),
("2026-02", "income", 38000, 35500, 1460000, 25),
("2026-03", "income", 33000, 31000, 1280000, 25),
("2026-04", "income", 35000, 32800, 1350000, 25),
("2026-05", "income", 37000, 34800, 1420000, 25),
("2026-06", "income", 36500, 34000, 1400000, 25),
("2026-01", "surtax", 6240, 5900, 1380000, 12),
("2026-02", "surtax", 8280, 7900, 1460000, 12),
("2026-03", "surtax", 4920, 4700, 1280000, 12),
("2026-04", "surtax", 5700, 5500, 1350000, 12),
("2026-05", "surtax", 6360, 6100, 1420000, 12),
("2026-06", "surtax", 5976, 5800, 1400000, 12),
]
for period, ttype, payable, paid, income, rate in tax_demo:
exists = (
db.query(TaxRecord)
.filter(TaxRecord.entity_id == eid, TaxRecord.period == period, TaxRecord.tax_type == ttype)
.first()
)
if exists:
continue
t = TaxRecord(entity_id=eid, period=period, tax_type=ttype, tax_payable=payable,
tax_paid=paid, tax_rate=rate, income=income)
_calc_burden(t)
db.add(t)
created["tax"] += 1
# 报销单(供发票"金额与报销单匹配"规则使用)
reimb_demo = [
{"reimb_no": "BX202606150001", "applicant": "张伟", "department": "采购部", "expense_type": "office",
"title": "6月办公用品采购", "amount": 5600, "status": "approved"},
{"reimb_no": "BX202606200002", "applicant": "李娜", "department": "市场部", "expense_type": "management",
"title": "6月市场信息服务费", "amount": 3200, "status": "approved"},
{"reimb_no": "BX202607050003", "applicant": "王强", "department": "供应链部", "expense_type": "office",
"title": "7月供应链物流服务", "amount": 14000, "status": "pending"},
]
for d in reimb_demo:
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
if exists:
continue
db.add(ExpenseReimbursement(**d))
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
invoice_demo = [
{"invoice_no": "9" * 33, "amount": 5600, "invoice_type": "vat", "invoice_date": "2026-06-15",
"supplier": "北京云启科技有限公司", "reimb_no": "BX202606150001", "contract_no": "HT-2026-018"},
{"invoice_no": "8" * 33, "amount": 3200, "invoice_type": "electronic", "invoice_date": "2026-06-20",
"supplier": "上海数联信息服务有限公司", "reimb_no": "BX202606200002", "contract_no": "HT-2026-021"},
{"invoice_no": "12345ABC", "amount": 1800, "invoice_type": "vat", "invoice_date": "2026-07-02",
"supplier": "广州锐思咨询有限公司", "reimb_no": None, "contract_no": None},
{"invoice_no": "7" * 33, "amount": 15000, "invoice_type": "vat", "invoice_date": "2026-07-05",
"supplier": "深圳恒达供应链有限公司", "reimb_no": "BX202607050003", "contract_no": "HT-2026-030"},
{"invoice_no": "6" * 33, "amount": 9800, "invoice_type": "vat", "invoice_date": "2026-07-08",
"supplier": None, "reimb_no": None, "contract_no": "HT-2026-033"},
]
for d in invoice_demo:
exists = (
db.query(InvoiceCheck)
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.invoice_no == d["invoice_no"])
.first()
)
if exists:
continue
inv = InvoiceCheck(entity_id=eid, **d)
_check_invoice(db, inv)
db.add(inv)
created["invoice"] += 1
# 社保: 正常3人×2月 + 基数不符1条 + 漏缴1条
ss_demo = [
{"employee": "张伟", "period": "2026-06", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
{"employee": "张伟", "period": "2026-07", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
{"employee": "李娜", "period": "2026-06", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
{"employee": "李娜", "period": "2026-07", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
{"employee": "王强", "period": "2026-06", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
{"employee": "王强", "period": "2026-07", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
{"employee": "赵敏", "period": "2026-06", "base_amount": 5000, "salary": 20000, "company_amount": 1225, "personal_amount": 520},
{"employee": "孙磊", "period": "2026-05", "base_amount": 0, "salary": 15000, "company_amount": 0, "personal_amount": 0},
]
for d in ss_demo:
exists = (
db.query(SocialSecurity)
.filter(SocialSecurity.entity_id == eid, SocialSecurity.employee == d["employee"],
SocialSecurity.period == d["period"])
.first()
)
if exists:
continue
s = SocialSecurity(entity_id=eid, **d)
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
_check_ss(db, s, all_records)
db.add(s)
created["ss"] += 1
db.commit()
return {"message": "演示数据生成完成", "created": created}
+173
View File
@@ -0,0 +1,173 @@
"""KPI模板库 API — 管理会计OS
支持系统预置模板 + 用户自定义模板
从模板实例化创建KPI时复制模板快照到kpi_definitions"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_role
from app.models import KPITemplate, KPIDefinition, OperationLog
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
def template_to_dict(t):
return {c.name: getattr(t, c.name) for c in t.__table__.columns}
@router.get("")
def list_templates(
dimension: Optional[str] = None,
category: Optional[str] = None,
keyword: Optional[str] = None,
is_system: Optional[int] = None,
db: Session = Depends(get_db),
):
"""获取模板列表,支持按维度/类别/关键字筛选"""
query = db.query(KPITemplate)
if dimension:
query = query.filter(KPITemplate.dimension == dimension)
if category:
query = query.filter(KPITemplate.category == category)
if keyword:
query = query.filter(KPITemplate.kpi_name.contains(keyword))
if is_system is not None:
query = query.filter(KPITemplate.is_system == is_system)
templates = query.order_by(KPITemplate.is_system.desc(), KPITemplate.kpi_code).all()
return {"total": len(templates), "data": [template_to_dict(t) for t in templates]}
@router.get("/{template_id}")
def get_template(template_id: int, db: Session = Depends(get_db)):
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
return template_to_dict(t)
@router.post("")
def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""用户创建自定义模板"""
existing = db.query(KPITemplate).filter(KPITemplate.kpi_code == data.get("kpi_code", "")).first()
if existing:
raise HTTPException(400, f"模板编码 {data['kpi_code']} 已存在")
t = KPITemplate(
kpi_code=data.get("kpi_code"),
kpi_name=data.get("kpi_name"),
dimension=data.get("dimension"),
category=data.get("category"),
formula=data.get("formula"),
formula_desc=data.get("formula_desc"),
unit=data.get("unit", "%"),
target_value=data.get("target_value"),
description=data.get("description"),
is_system=0, # 用户创建的永远不是系统模板
usage_count=0,
)
db.add(t)
db.commit()
db.refresh(t)
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name})
return template_to_dict(t)
@router.put("/{template_id}")
def update_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""修改自定义模板(系统预置不可修改)"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
if t.is_system:
raise HTTPException(403, "系统预置模板不可修改")
for k, v in data.items():
if hasattr(t, k) and v is not None:
setattr(t, k, v)
db.commit()
return template_to_dict(t)
@router.delete("/{template_id}")
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
"""删除自定义模板(系统预置不可删除)"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
if t.is_system:
raise HTTPException(403, "系统预置模板不可删除")
db.delete(t)
db.commit()
return {"message": "模板已删除"}
@router.post("/{template_id}/instantiate")
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""从模板实例化创建KPI,复制模板快照到kpi_definitions"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
kpi_code = data.get("kpi_code", t.kpi_code)
kpi_name = data.get("kpi_name", t.kpi_name)
# 检查编码唯一性
existing = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if existing:
raise HTTPException(400, f"KPI编码 {kpi_code} 已存在,请修改")
# 数据治理:入库必检(规则1) + 编码规范(规则3)
merged = {
"kpi_code": kpi_code,
"kpi_name": kpi_name,
"dimension": data.get("dimension", t.dimension),
"category": data.get("category", t.category),
"formula": data.get("formula", t.formula),
"unit": data.get("unit", t.unit or "%"),
"target_value": data.get("target_value", t.target_value),
}
gov_issues = [i for i in validate_kpi_payload(merged, db=db) if i["rule"] in (1, 3)]
if gov_issues:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": kpi_issues_message(gov_issues)})
kpi = KPIDefinition(
template_id=t.id,
is_system=0, # 从模板实例化的KPI不是系统预置
kpi_code=kpi_code,
kpi_name=kpi_name,
dimension=data.get("dimension", t.dimension),
category=data.get("category", t.category),
formula=data.get("formula", t.formula),
formula_desc=data.get("formula_desc", t.formula_desc),
unit=data.get("unit", t.unit or "%"),
target_value=data.get("target_value", t.target_value),
objective=data.get("objective"),
data_source_type=data.get("data_source_type", "manual"),
frequency=data.get("frequency", "monthly"),
responsible_dept=data.get("responsible_dept"),
responsible_user=data.get("responsible_user"),
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
# 更新模板使用计数
t.usage_count = (t.usage_count or 0) + 1
db.commit()
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code})
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
def _log(db, user_id, action, target_type, target_id, detail):
import json
log = OperationLog(user_id=user_id, action=action, target_type=target_type,
target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
db.add(log)
db.commit()
+63
View File
@@ -0,0 +1,63 @@
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import SystemConfig, Entity
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"])
TENANT_KEY = "current_tenant"
class TenantSwitchRequest(BaseModel):
entity_id: int
source: str = "cma-system"
def _tenant_name(entity_id: int) -> str:
"""entity_id → tenant_id"""
return "company_b" if entity_id == 2 else "company_a"
@router.get("/current")
def get_current_tenant(db: Session = Depends(get_db)):
"""查询当前租户"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg and cfg.config_value:
import json
try:
return json.loads(cfg.config_value)
except:
pass
return {"tenant_id": "company_a", "entity_id": 1, "name": "陕西酣客文化传媒"}
@router.post("/switch")
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
"""切换当前租户(CMA前端企业切换器调用)"""
ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
if not ent:
raise HTTPException(404, "企业不存在")
import json
state = {
"tenant_id": _tenant_name(data.entity_id),
"entity_id": data.entity_id,
"name": ent.name,
"short_name": ent.short_name,
"source": data.source,
"switched_at": __import__("datetime").datetime.now().isoformat(),
}
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg:
cfg.config_value = json.dumps(state, ensure_ascii=False)
else:
db.add(SystemConfig(
config_key=TENANT_KEY,
config_value=json.dumps(state, ensure_ascii=False),
description="当前租户状态(CMA企业切换联动)",
))
db.commit()
return {"success": True, "current": state}
+276
View File
@@ -0,0 +1,276 @@
"""实际值自动归集 API — 管理会计OS (P1-④ 2026-08-28)
取数映射管理(kpi_value_sources) + 手动触发采集 + 采集日志 + 覆盖率统计
采集器本体: scripts/kpi_value_collector.py系统 crontab 每日 06:30
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIValueSource, KPIValueCollectLog, KPIDefinition, KPIValue
router = APIRouter(
prefix="/api/cma/budget",
tags=["实际值归集"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
# ── 取数映射 CRUD ──────────────────────────────
@router.get("/value-sources")
def list_value_sources(
kpi_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""取数映射列表(按 entity_id 隔离)"""
query = db.query(KPIValueSource).filter(KPIValueSource.entity_id == entity_id)
if kpi_id:
query = query.filter(KPIValueSource.kpi_id == kpi_id)
if status:
query = query.filter(KPIValueSource.status == status)
rows = query.order_by(KPIValueSource.id.desc()).all()
kpi_ids = {r.kpi_id for r in rows}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
result = []
for r in rows:
kpi = kpis.get(r.kpi_id)
result.append({
"id": r.id,
"entity_id": r.entity_id,
"kpi_id": r.kpi_id,
"kpi_code": kpi.kpi_code if kpi else "",
"kpi_name": kpi.kpi_name if kpi else "",
"source_table": r.source_table,
"source_field": r.source_field,
"aggregate": r.aggregate,
"filter_rule": r.filter_rule,
"period_field": r.period_field,
"unit_conversion": r.unit_conversion,
"status": r.status,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
return {"data": result, "total": len(result)}
@router.post("/value-sources")
def create_value_source(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""新建取数映射"""
kpi_id = data.get("kpi_id")
source_table = data.get("source_table")
source_field = data.get("source_field")
if not kpi_id or not source_table or not source_field:
raise HTTPException(400, "缺少必要参数: kpi_id, source_table, source_field")
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
existing = db.query(KPIValueSource).filter(
KPIValueSource.entity_id == entity_id,
KPIValueSource.kpi_id == kpi_id,
KPIValueSource.source_table == source_table,
).first()
if existing:
raise HTTPException(400, f"该KPI({kpi_id})已存在 {source_table} 取数映射")
row = KPIValueSource(
entity_id=entity_id,
kpi_id=kpi_id,
source_table=source_table,
source_field=source_field,
aggregate=data.get("aggregate", "sum"),
filter_rule=data.get("filter_rule"),
period_field=data.get("period_field", "period"),
unit_conversion=data.get("unit_conversion", 1),
status=data.get("status", "active"),
)
db.add(row)
db.commit()
db.refresh(row)
return {"message": "取数映射已创建", "id": row.id}
@router.put("/value-sources/{source_id}")
def update_value_source(
source_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""更新取数映射"""
row = db.query(KPIValueSource).filter(
KPIValueSource.id == source_id,
KPIValueSource.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "映射不存在")
for field in ("source_table", "source_field", "aggregate", "filter_rule",
"period_field", "unit_conversion", "status"):
if field in data:
setattr(row, field, data[field])
db.commit()
return {"message": "映射已更新", "id": row.id}
@router.delete("/value-sources/{source_id}")
def delete_value_source(
source_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""删除取数映射"""
row = db.query(KPIValueSource).filter(
KPIValueSource.id == source_id,
KPIValueSource.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "映射不存在")
db.delete(row)
db.commit()
return {"message": "映射已删除"}
@router.post("/value-sources/test")
def test_value_source(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""试跑单条映射返回预览值(不写库)"""
from scripts.kpi_value_collector import collect_for_mapping
mapping = KPIValueSource(
entity_id=entity_id,
kpi_id=data.get("kpi_id"),
source_table=data.get("source_table"),
source_field=data.get("source_field"),
aggregate=data.get("aggregate", "sum"),
filter_rule=data.get("filter_rule"),
period_field=data.get("period_field", "period"),
unit_conversion=data.get("unit_conversion", 1),
status="active",
)
period = data.get("period") or _default_period()
try:
value, message = collect_for_mapping(db, mapping, period, write_kpi=False)
return {"success": True, "period": period, "value": value, "message": message}
except Exception as e:
return {"success": False, "period": period, "value": None, "message": str(e)}
# ── 采集器触发 ──────────────────────────────
@router.post("/value-collect/run")
def run_value_collect(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""手动触发采集器(可选 period 参数,默认当月)"""
from scripts.kpi_value_collector import run_collector
period = data.get("period") or _default_period()
kpi_id = data.get("kpi_id") # 可选: 只采集单个KPI
result = run_collector(db, entity_id=entity_id, period=period, kpi_id=kpi_id)
result["period"] = period
return result
# ── 采集日志 ──────────────────────────────
@router.get("/value-collect/logs")
def list_collect_logs(
status: Optional[str] = Query(None),
period: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""采集日志(status过滤)"""
query = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.entity_id == entity_id)
if status:
query = query.filter(KPIValueCollectLog.status == status)
if period:
query = query.filter(KPIValueCollectLog.period == period)
rows = query.order_by(KPIValueCollectLog.collected_at.desc()).limit(limit).all()
kpi_ids = {r.kpi_id for r in rows}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
result = []
for r in rows:
kpi = kpis.get(r.kpi_id)
result.append({
"id": r.id,
"kpi_id": r.kpi_id,
"kpi_code": kpi.kpi_code if kpi else "",
"kpi_name": kpi.kpi_name if kpi else "",
"period": r.period,
"source_table": r.source_table,
"collected_value": r.collected_value,
"status": r.status,
"message": r.message,
"collected_at": r.collected_at.isoformat() if r.collected_at else None,
})
return {"data": result, "total": len(result)}
# ── 覆盖率统计 ──────────────────────────────
@router.get("/value-sources/coverage")
def value_source_coverage(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""覆盖率统计:已配映射KPI数 / 总活跃KPI数 / 未配置清单"""
total_kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).count()
mapped_rows = db.query(KPIValueSource).filter(
KPIValueSource.entity_id == entity_id,
KPIValueSource.status == "active",
).all()
mapped_kpi_ids = {r.kpi_id for r in mapped_rows}
all_kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).all()
unmapped = [{
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
} for k in all_kpis if k.id not in mapped_kpi_ids]
coverage = round(len(mapped_kpi_ids) / total_kpis * 100, 1) if total_kpis else 0
return {
"mapped_count": len(mapped_kpi_ids),
"total_kpis": total_kpis,
"coverage_pct": coverage,
"unmapped_count": len(unmapped),
"unmapped": unmapped,
}
def _default_period() -> str:
from datetime import datetime
return datetime.now().strftime("%Y-%m")
+299
View File
@@ -0,0 +1,299 @@
"""
自动验证引擎 API 管理会计OS
POST /api/cma/verify/{plan_id} 手动验证行动计划执行结果
数据流:
ActionPlan.auto_verify_rule (JSON) 条件判断 passed/failed
KPI值回填 (kpi_current_before/after + KPIValue source_type=verify)
验证通过 所属OKR progress +15%
通知任总 (send_wecom_message)
规则格式:
{
"kpi_code": "C_REBATE_RATE",
"condition": "LESS_THAN", # LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL
"target_value": 80,
"baseline_value": 86.4,
"verify_after_days": 7,
"retry_max": 3,
"escalate_to": "任富海",
"notify": true
}
兼容格式:
{"condition": "value < 75", "description": "..."} bot_bridge_v2._evaluate_condition
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import json
import logging
from app.database import get_db
from app.models import ActionPlan, KPIDefinition, KPIValue, Objective
from app.utils.notifier import send_wecom_message
from app.auth_middleware import require_role
logger = logging.getLogger("cma.verify")
router = APIRouter(
prefix="/api/cma/verify",
tags=["自动验证"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
CONDITION_FUNCS = {
"LESS_THAN": lambda actual, target: actual is not None and actual < target,
"GREATER_THAN": lambda actual, target: actual is not None and actual > target,
"WITHIN_RANGE": lambda actual, target: target is not None and len(target) == 2 and float(target[0]) <= actual <= float(target[1]),
"NOT_NULL": lambda actual, target: actual is not None,
}
def _parse_rule(rule) -> dict:
"""解析auto_verify_rule(兼容JSON字符串)"""
if rule is None:
return {}
if isinstance(rule, str):
try:
return json.loads(rule)
except Exception:
return {"condition": rule}
return rule
def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool:
"""
按规则判断actual是否达标
- 新格式: condition为枚举 LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL
- 旧格式: condition为表达式字符串 "value < 75" bot_bridge_v2引擎
"""
condition = (rule or {}).get("condition", "")
if not condition:
return actual is not None
if condition in CONDITION_FUNCS:
target = rule.get("target_value")
return CONDITION_FUNCS[condition](actual, target)
# 旧格式表达式(bot-bridge兼容)
try:
from app.api.bot_bridge_v2 import _evaluate_condition
kd = kpi_data or {"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")}
return _evaluate_condition(condition, kd)
except Exception as e:
logger.warning(f"旧格式条件评估失败({condition}): {e}")
return False
def update_okr_progress(db: Session, plan: ActionPlan) -> dict:
"""验证通过 → 所属OKR progress +15%(每通过1个KR"""
if not plan.objective_id:
return {"updated": False, "reason": "no_objective"}
obj = db.query(Objective).filter(Objective.id == plan.objective_id).first()
if not obj:
return {"updated": False, "reason": "objective_not_found"}
before = obj.progress or 0
obj.progress = min(100, before + 15)
db.flush()
return {"updated": True, "objective_id": obj.id, "before": before, "after": obj.progress}
def build_auto_verify_rule(kpi, baseline_value=None, verify_after_days: int = 7) -> dict:
"""根据KPI阈值自动生成验证规则(验收#1: 创建ActionPlan自动带auto_verify_rule
从KPI的绿灯阈值(green)推导达标方向:
- 绿灯 '<=X' 目标是把值压到 X 以下 LESS_THAN X
- 绿灯 '>=X' 目标是把值抬到 X 以上 GREATER_THAN X
- 有绿色区间 'X~Y' WITHIN_RANGE
- 无阈值 NOT_NULL
"""
expr = (kpi.threshold_green or "").strip()
condition = "NOT_NULL"
target = None
import re
m = re.match(r"^(<=|>=|<|>|=)\s*([\d.]+)$", expr)
if m:
op, val = m.group(1), float(m.group(2))
if op in ("<", "<="):
condition = "LESS_THAN"
elif op in (">", ">="):
condition = "GREATER_THAN"
else:
condition = "NOT_NULL"
target = val
elif "~" in expr:
parts = expr.split("~")
if len(parts) == 2:
try:
condition = "WITHIN_RANGE"
target = [float(parts[0]), float(parts[1])]
except ValueError:
pass
else:
# 无阈值表达式 → 用KPI目标值推方向
condition = "NOT_NULL"
target = None
return {
"kpi_code": kpi.kpi_code,
"condition": condition,
"target_value": target,
"baseline_value": float(baseline_value) if baseline_value is not None else None,
"verify_after_days": verify_after_days,
"retry_max": 3,
"escalate_to": "任富海",
"notify": True,
}
def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source: str = "verify"):
"""回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录"""
kpi = None
kpi_code = rule.get("kpi_code") if rule else None
# 优先按验证规则指定的KPI编码查询;无规则时才回退到plan.kpi_id
if kpi_code:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
elif plan.kpi_id:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
if actual is None:
return None
plan.kpi_current_before = plan.kpi_current_before if plan.kpi_current_before is not None else rule.get("baseline_value")
plan.kpi_current_after = actual
if kpi:
new_val = KPIValue(
kpi_id=kpi.id,
period=datetime.now().strftime("%Y-%m"),
actual_value=actual,
source_type="verify",
source_batch=f"verify-plan-{plan.id}",
data_status="verified",
calculated_at=datetime.now(),
remark=f"行动计划#{plan.id}验证回填",
)
db.add(new_val)
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code}
return None
@router.post("/{plan_id}")
def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db)):
"""验证行动计划执行结果(手动验证 / Bot回填)
请求体:
{
"actual_value": 78.5, # 可选,缺省时取KPI最新值
"source": "财务Bot分析", # 来源
"note": "渠补谈判后...", # 备注/验证结果详情
"passed": true # 可选,无规则时手动指定
}
"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "行动计划不存在")
rule = _parse_rule(plan.auto_verify_rule)
actual = payload.get("actual_value")
note = payload.get("note", "")
source = payload.get("source", "手动验证")
# 1. 无规则 → 按手动指定 passed 标记
if not rule or not rule.get("condition"):
passed = payload.get("passed", True)
plan.verify_status = "passed" if passed else "failed"
plan.verify_result = "pass" if passed else "fail"
plan.verify_log = (plan.verify_log or []) + [{
"timestamp": datetime.now().isoformat(),
"passed": passed,
"note": note,
"source": source,
}]
plan.verified_at = datetime.now()
db.commit()
return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed}
# 2. 缺省actual → 取KPI最新值
if actual is None:
kpi = None
kpi_code = rule.get("kpi_code")
if plan.kpi_id:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
elif kpi_code:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if kpi:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
if latest:
actual = latest.actual_value
# 3. 执行条件判断
passed = evaluate_rule(rule, actual, kpi_data={"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")})
# 4. 回写KPI当前值
backfill = backfill_kpi_value(db, plan, actual, rule, source=source)
# 5. 更新状态
plan.verify_status = "passed" if passed else "failed"
plan.verify_result = "pass" if passed else "fail"
plan.verify_log = (plan.verify_log or []) + [{
"timestamp": datetime.now().isoformat(),
"condition": rule.get("condition"),
"actual_value": actual,
"target_value": rule.get("target_value"),
"passed": passed,
"source": source,
"note": note,
}]
if passed:
plan.status = "done"
plan.progress = 100
plan.verified_at = datetime.now()
# 6. OKR进度联动(验证通过 → +15%)
okr_update = None
if passed:
okr_update = update_okr_progress(db, plan)
db.commit()
# 7. 通知任总
if rule.get("notify", True):
delta = ""
if plan.kpi_current_before is not None and actual is not None:
try:
d = float(actual) - float(plan.kpi_current_before)
delta = f"{d:+.1f}"
except (TypeError, ValueError):
delta = ""
title = f"✅ 行动计划#{plan.id}验证通过" if passed else f"❌ 行动计划#{plan.id}验证失败"
content = (
f"{plan.title}\n"
f"KPI: {rule.get('kpi_code', '')} {plan.kpi_current_before}{actual} {delta}\n"
f"规则: {rule.get('condition')} {rule.get('target_value')}\n"
f"来源: {source}\n"
f"{note}"
)
try:
send_wecom_message(content=content, title=title, alert_level="green" if passed else "red")
except Exception as e:
logger.warning(f"验证通知发送失败: {e}")
return {
"plan_id": plan_id,
"verify_status": plan.verify_status,
"kpi_current_before": plan.kpi_current_before,
"kpi_current_after": plan.kpi_current_after,
"improvement": f"{float(actual) - float(plan.kpi_current_before):+.1f}" if plan.kpi_current_before is not None and actual is not None else None,
"passed": passed,
"okr_progress": okr_update,
"kpi_backfill": backfill,
"message": (
f"验证通过:{plan.title}" if passed
else f"验证失败:{plan.title}(条件 {rule.get('condition')} {rule.get('target_value')} 未达成,当前值 {actual}"
),
}
+11 -8
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role
from app.models import StrategicMap, StrategicMapVersion
@@ -11,18 +12,19 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"],
@router.get("/{map_id}/versions")
def list_versions(map_id: int, db: Session = Depends(get_db)):
"""查看版本历史"""
def list_versions(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""查看版本历史(账套隔离, OpenCode审查#9"""
versions = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == map_id
StrategicMapVersion.map_id == map_id,
StrategicMapVersion.entity_id == entity_id,
).order_by(StrategicMapVersion.id.desc()).all()
return {"data": [v_to_dict(v) for v in versions]}
@router.post("/{map_id}/versions/snapshot")
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
"""手动创建快照"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""手动创建快照(账套隔离)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
@@ -49,6 +51,7 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
snapshot = StrategicMapVersion(
map_id=map_id,
entity_id=entity_id, # 继承地图企业
version=new_ver,
dimensions=dims,
canvas_data=canvas,
@@ -61,9 +64,9 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
@router.post("/{map_id}/versions/{ver_id}/rollback")
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db)):
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""回滚到指定版本"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
+217
View File
@@ -0,0 +1,217 @@
"""零基预算逐项论证 API — 管理会计OS (P2-① 2026-08-28)
CRUD 逐项论证项(budget_zero_based_items) + generate 生成零基预算写 budget_plans
method-comparison zero_based 分支优先读论证项有数据逐项求和 is_demo=false
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import BudgetZeroBasedItem, KPIDefinition, BudgetPlan
router = APIRouter(
prefix="/api/cma/budget",
tags=["零基预算"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.get("/zero-based/items")
def list_zero_based_items(
kpi_id: Optional[int] = Query(None),
period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""零基逐项论证列表(kpi_id+period 过滤)"""
query = db.query(BudgetZeroBasedItem).filter(BudgetZeroBasedItem.entity_id == entity_id)
if kpi_id:
query = query.filter(BudgetZeroBasedItem.kpi_id == kpi_id)
if period:
query = query.filter(BudgetZeroBasedItem.period == period)
rows = query.order_by(BudgetZeroBasedItem.id.desc()).all()
kpi_ids = {r.kpi_id for r in rows}
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
result = []
for r in rows:
kpi = kpis.get(r.kpi_id)
result.append({
"id": r.id,
"kpi_id": r.kpi_id,
"kpi_code": kpi.kpi_code if kpi else "",
"kpi_name": kpi.kpi_name if kpi else "",
"period": r.period,
"item_name": r.item_name,
"item_category": r.item_category,
"base_value": r.base_value,
"justification": r.justification,
"proposed_value": r.proposed_value,
"status": r.status,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
total_proposed = round(sum(r.proposed_value for r in rows), 2)
return {"data": result, "total": len(result), "total_proposed": total_proposed}
@router.post("/zero-based/items")
def create_zero_based_item(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""新建逐项论证项"""
kpi_id = data.get("kpi_id")
period = data.get("period")
item_name = data.get("item_name")
base_value = data.get("base_value")
proposed_value = data.get("proposed_value")
if not kpi_id or not period or not item_name:
raise HTTPException(400, "缺少必要参数: kpi_id, period, item_name")
if base_value is None:
base_value = 0
if proposed_value is None:
proposed_value = 0
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
row = BudgetZeroBasedItem(
entity_id=entity_id,
kpi_id=kpi_id,
period=period,
item_name=item_name,
item_category=data.get("item_category", "discretionary"),
base_value=base_value,
justification=data.get("justification"),
proposed_value=proposed_value,
status=data.get("status", "draft"),
created_by=data.get("created_by") or (current_user.name if hasattr(current_user, "name") else None),
)
db.add(row)
db.commit()
db.refresh(row)
return {"message": "论证项已创建", "id": row.id}
@router.put("/zero-based/items/{item_id}")
def update_zero_based_item(
item_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""更新逐项论证项"""
row = db.query(BudgetZeroBasedItem).filter(
BudgetZeroBasedItem.id == item_id,
BudgetZeroBasedItem.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "论证项不存在")
for field in ("item_name", "item_category", "base_value", "justification",
"proposed_value", "status"):
if field in data:
setattr(row, field, data[field])
db.commit()
return {"message": "论证项已更新", "id": row.id}
@router.delete("/zero-based/items/{item_id}")
def delete_zero_based_item(
item_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""删除逐项论证项"""
row = db.query(BudgetZeroBasedItem).filter(
BudgetZeroBasedItem.id == item_id,
BudgetZeroBasedItem.entity_id == entity_id,
).first()
if not row:
raise HTTPException(404, "论证项不存在")
db.delete(row)
db.commit()
return {"message": "论证项已删除"}
@router.post("/zero-based/generate")
def generate_zero_based_budget(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""生成零基预算 = Σ(proposed_value),写 budget_plansversion='zbb-YYYYMMDD'calc_logic='zero_based_itemized'"""
kpi_id = data.get("kpi_id")
period = data.get("period")
year = data.get("year")
if not kpi_id or not period:
raise HTTPException(400, "缺少必要参数: kpi_id, period")
items = db.query(BudgetZeroBasedItem).filter(
BudgetZeroBasedItem.entity_id == entity_id,
BudgetZeroBasedItem.kpi_id == kpi_id,
BudgetZeroBasedItem.period == period,
).all()
if not items:
raise HTTPException(400, f"期间 {period} 无逐项论证项,请先录入")
total = round(sum(i.proposed_value for i in items), 2)
# 解析年份
if not year:
try:
year = int(period.split("-")[0])
except Exception:
year = datetime.now().year
month = 0
try:
month = int(period.split("-")[1]) if "-" in period else 0
except Exception:
month = 0
version = f"zbb-{datetime.now().strftime('%Y%m%d')}"
# 删除同KPI同期间同版本旧预算,防重复
db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
).delete()
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id,
period=period,
budget_value=total,
budget_year=year,
budget_month=month,
version=version,
status="active",
source_type="zero_based",
calc_logic="zero_based_itemized",
remark=f"零基逐项论证生成: {len(items)}项 Σ(proposed_value)={total}",
)
db.add(bp)
db.commit()
return {
"message": f"零基预算已生成: {total}{len(items)}项论证)",
"kpi_id": kpi_id,
"period": period,
"total": total,
"item_count": len(items),
"version": version,
"plan_id": bp.id,
}
+70 -8
View File
@@ -60,7 +60,7 @@ except Exception:
logger.warning("Redis不可用,token存储降级到内存(不支持多worker)")
# 内存 fallback
_token_store: dict[str, int] = {}
_token_store: dict[str, dict] = {}
TOKEN_PREFIX = "cma:token:"
TOKEN_TTL = 86400 # 24小时
@@ -95,22 +95,84 @@ def _load_permissions(db: Session = None):
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
def create_token(user_id: int) -> str:
def create_token(user_id: int, entity_id: int = None) -> str:
"""签发tokenRedis存储 JSON {user_id, entity_id}(账套模式)
兼容旧调用 create_token(user_id) entity_id=None切换器会重新签发
"""
token = secrets.token_hex(32)
payload = json.dumps({"user_id": user_id, "entity_id": entity_id}, ensure_ascii=False)
if _redis_available:
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, user_id)
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, payload)
else:
_token_store[token] = user_id
_token_store[token] = payload
return token
def _resolve_user_id(token: str) -> int | None:
def _resolve_token_data(token: str) -> dict | None:
"""解析token → {user_id, entity_id}
- 新格式 JSON 返回 dict
- 旧格式 int改造前 返回 None强制下线账套模式需重新登录
- 不存在 None
"""
if _redis_available:
val = _redis.get(f"{TOKEN_PREFIX}{token}")
if val is not None:
return int(val)
if val is None:
return None
try:
data = json.loads(val)
if isinstance(data, dict) and "user_id" in data:
return data
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 旧格式纯 int → 强制下线
return None
return _token_store.get(token)
val = _token_store.get(token)
if val is None:
return None
if isinstance(val, dict):
return val
try:
data = json.loads(val)
if isinstance(data, dict) and "user_id" in data:
return data
except (json.JSONDecodeError, ValueError, TypeError):
pass
return None
def _resolve_user_id(token: str) -> int | None:
data = _resolve_token_data(token)
return int(data["user_id"]) if data else None
def get_token_entity_id(token: str) -> int | None:
"""从token解析绑定的entity_idtoken不存在/旧格式 → None"""
data = _resolve_token_data(token)
if not data:
return None
eid = data.get("entity_id")
return int(eid) if eid else None
def user_has_entity(db: Session, user_id: int, entity_id: int) -> bool:
"""校验用户是否被授权访问指定企业(账套授权表 user_entities"""
from app.models import UserEntity, Entity
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if not ent or ent.status != "active":
return False
rel = db.query(UserEntity).filter(
UserEntity.user_id == user_id,
UserEntity.entity_id == entity_id,
).first()
return rel is not None
def extract_bearer_token(request) -> str | None:
"""从Request提取Bearer token(无/非Bearer格式 → None"""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip()
return None
def require_auth(
+70
View File
@@ -68,6 +68,76 @@ def init_db():
except Exception as e:
logger.warning(f"组织数据初始化跳过: {e}")
# ── user_entities 授权表初始化(账套模式平滑迁移)──
# 首次建表(表空)时,给存量用户默认授权所有 active 企业,保证现有登录不丢权限
try:
inspector = inspect(get_engine())
if "user_entities" in inspector.get_table_names():
Session = get_session_local()
session = Session()
try:
from app.models import UserEntity, User, Entity
cnt = session.query(UserEntity).count()
if cnt == 0:
users = session.query(User).all()
entities = session.query(Entity).filter(Entity.status == "active").all()
if users and entities:
for u in users:
for e in entities:
exists = session.query(UserEntity).filter(
UserEntity.user_id == u.id,
UserEntity.entity_id == e.id,
).first()
if not exists:
session.add(UserEntity(user_id=u.id, entity_id=e.id, granted_by=None))
session.commit()
logger.info(f"user_entities初始化: {len(users)}用户 × {len(entities)}企业")
finally:
session.close()
except Exception as e:
logger.warning(f"user_entities初始化跳过: {e}")
# ── scenario_suggestions 告警场景建议 seed2026-08-28 告警归因 P1-③)──
# 幂等:仅补缺失的 alert_type,不覆盖已有模板
try:
inspector = inspect(get_engine())
if "scenario_suggestions" in inspector.get_table_names():
Session = get_session_local()
session = Session()
try:
from app.models import ScenarioSuggestion
existing_types = {s.alert_type for s in session.query(ScenarioSuggestion).all()}
seeds = [
dict(alert_type="cash_low", title="现金流紧张 — 加强回款催收",
description="现金余额接近警戒线,建议优先处理应收款项,压缩非紧急支出。",
action_template="1. 列出未来30天应收清单,逐笔催收\n2. 暂停非紧急采购/费用支出\n3. 与银行沟通短期授信额度",
priority="high", sort_order=1),
dict(alert_type="cash_critical", title="现金流危急 — 立即止血",
description="现金余额已低于安全阈值,存在断流风险,需要立即采取止血措施。",
action_template="1. 冻结一切非必要支出\n2. 高管紧急复盘资金计划\n3. 启动应收账款特别催收\n4. 评估短期融资",
priority="high", sort_order=2),
dict(alert_type="cost_high", title="成本超支 — 核查费用构成",
description="实际成本超出预算,建议拆解到科目明细定位超支源头。",
action_template="1. 查看科目明细拆解,定位超支前3科目\n2. 分析价差/量差成因(单价上涨/用量增加)\n3. 对可控费用制定压降方案",
priority="medium", sort_order=3),
dict(alert_type="revenue_drop", title="收入下滑 — 追量提效",
description="实际收入低于预算,建议从子KPI量级分解查找差距来源。",
action_template="1. 查看子KPI拆解,定位量差最大维度\n2. 分析客户/渠道/产品线缺口\n3. 制定增量获客或转化提升方案",
priority="medium", sort_order=4),
]
added = 0
for s in seeds:
if s["alert_type"] not in existing_types:
session.add(ScenarioSuggestion(**s))
added += 1
if added:
session.commit()
logger.info(f"scenario_suggestions seed: 新增{added}条场景建议模板")
finally:
session.close()
except Exception as e:
logger.warning(f"scenario_suggestions seed跳过: {e}")
def _seed_org_data(db_session):
"""插入5层级组织示例数据"""
+81
View File
@@ -0,0 +1,81 @@
"""多租户公共依赖 — 账套模式:token优先,query/header降级为Bot服务白名单"""
from fastapi import Request, Header, Query, Depends, HTTPException
from typing import Optional
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import get_token_entity_id
from app.models import Entity
def get_entity_id(
request: Request,
x_entity_id: Optional[str] = Header(None, alias="X-Entity-Id"),
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
) -> int:
"""解析当前企业ID(账套模式):token优先 → query/headerBot白名单)→ 默认1
解析链倒置后
1. Authorization Bearer token 优先返回 token.entity_id唯一可信来源
2. query参数 / X-Entity-Id header 仅Bot服务通道使用校验entity状态active
3. body中的 entity_idPOST场景 Bot服务通道兼容
4. 兜底默认 1酣客
安全说明登录用户必须通过token绑定账套query/header传入的entity_id
在token存在时被忽略防止越权传参旧漏洞query优先且无授权校验
"""
# 1. token优先(账套模式唯一来源)
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token_entity = get_token_entity_id(auth[7:])
if token_entity is not None:
# 越权防护:显式传入的 query/header entity_id 与 token 绑定不一致 → 403
explicit = None
if entity_id is not None:
explicit = entity_id
elif x_entity_id and x_entity_id.isdigit():
explicit = int(x_entity_id)
if explicit is not None and explicit != token_entity:
raise HTTPException(403, f"无权访问企业 entity_id={explicit}(当前账套: {token_entity}")
return token_entity
# token存在但是旧格式/无entity → 账套模式下强制走白名单或默认(由require_auth拦截)
# 这里不抛401:公开接口可能带旧token,交给require_auth统一处理
# 2. query/header → Bot服务通道白名单(校验entity状态active
candidate = None
if entity_id is not None:
candidate = entity_id
elif x_entity_id and x_entity_id.isdigit():
candidate = int(x_entity_id)
# 3. body 中的 entity_idPOST场景,Bot通道兼容)
if candidate is None and request.method in ("POST", "PUT", "PATCH"):
try:
body = request.state.body_json or {}
if body.get("entity_id"):
candidate = int(body["entity_id"])
except Exception:
pass
if candidate is not None:
ent = db.query(Entity).filter(Entity.id == candidate).first()
if ent and ent.status == "active":
return candidate
# Bot通道校验不通过:不返回默认,直接拒绝(防越权注入无效entity)
raise HTTPException(403, f"企业 entity_id={candidate} 不存在或未激活")
return 1 # 默认酣客(无token、无参数时兜底,兼容存量公开接口)
def resolve_entity_for_request(request: Request, fallback: int = 1) -> int:
"""账套模式:请求级entity解析(供从body读取entity_id的接口使用)
登录用户带Bearer token token绑定的entity唯一来源
无tokenBot服务通道 回退到调用方传入的fallbackbody中的entity_id等
"""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token_entity = get_token_entity_id(auth[7:])
if token_entity is not None:
return token_entity
return fallback
+53 -3
View File
@@ -5,10 +5,11 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from dotenv import load_dotenv
from app.database import init_db
from app.api import auth, kpis, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification, value_sources, zero_based, derivation_rules, cash_classify
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
from scripts.erp_sync import run_sync as run_erp_sync
from app.auth_middleware import require_auth
from app.api.audit_log import bot_audit_middleware
load_dotenv()
@@ -30,8 +31,12 @@ app.add_middleware(
allow_headers=["*"],
)
# Bot API 操作审计(L1-L4分级标注 + JSON行审计日志,不阻塞业务)
app.middleware("http")(bot_audit_middleware)
app.include_router(auth.router)
app.include_router(kpis.router)
app.include_router(kpi_governance.router)
app.include_router(templates.router)
app.include_router(maps.router)
app.include_router(dashboard.router)
@@ -49,10 +54,43 @@ app.include_router(org.router)
app.include_router(objectives.router)
app.include_router(versions.router)
app.include_router(budget.router)
app.include_router(value_sources.router)
app.include_router(zero_based.router)
app.include_router(derivation_rules.router)
app.include_router(cash_classify.router)
app.include_router(cost.router)
app.include_router(predict.router)
app.include_router(growth_quality.router)
app.include_router(products.router)
app.include_router(reports.router)
app.include_router(security.router)
app.include_router(knowledge.router)
app.include_router(bot_bridge.router)
app.include_router(bot_bridge_v2.router)
app.include_router(lead.router)
app.include_router(tenant.router)
app.include_router(customer_dashboard.router)
app.include_router(deviation_push.router)
app.include_router(budget_generate.router)
app.include_router(knowledge_articles.router)
app.include_router(kpi_causality.router)
app.include_router(data_quality.router)
app.include_router(bi_reports.router)
app.include_router(entities.router)
app.include_router(bsc_layers.router)
app.include_router(okr.router)
app.include_router(okr_templates.router)
app.include_router(subjects.router)
app.include_router(driver_budget.router)
app.include_router(bot_kpis.router)
app.include_router(ontology.router)
app.include_router(bot_iron_law.router)
app.include_router(analysis_results.router)
app.include_router(expenses.router)
app.include_router(cash.router)
app.include_router(data_classification.router)
app.include_router(tax_compliance.router)
app.include_router(verify.router)
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
@@ -90,13 +128,25 @@ def admin_erp_sync_dry_run(user=Depends(require_auth), kpi_codes: str = None):
@app.post("/api/cma/admin/alerts/check")
def admin_check_alerts():
"""手动触发预警检查"""
"""手动触发预警检查(含资金管理预警:缺口前3天 + 到期未收款)"""
from app.database import get_session_local
from scripts.alert_generator import generate_and_push
from app.utils.cash_forecast_engine import check_cash_alerts
from app.models import Entity
db = get_session_local()()
try:
result = generate_and_push(db)
return {"message": "预警检查完成", "result": result}
# 资金管理预警 — 对每个激活企业执行
cash_result = None
try:
entities = db.query(Entity).filter(Entity.status == "active").all()
cash_list = []
for e in entities:
cash_list.append(check_cash_alerts(db, entity_id=e.id))
cash_result = {"entities": len(entities), "details": cash_list}
except Exception as e:
logger.warning(f"资金预警检查失败: {e}")
return {"message": "预警检查完成", "result": result, "cash": cash_result}
except Exception as e:
return JSONResponse(status_code=500, content={"detail": f"检查失败: {str(e)}"})
finally:
+701 -2
View File
@@ -1,9 +1,24 @@
"""管理会计OS 数据模型"""
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint, Numeric, Date
from app.database import Base
from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models.knowledge import KnowledgeEvent, KnowledgeSummary
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
from app.models.product_sales import ProductSales
class Entity(Base):
"""企业实体"""
__tablename__ = "entities"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False, comment="企业全称")
short_name = Column(String(50), comment="企业简称")
industry = Column(String(50), comment="行业")
status = Column(String(20), default="active", comment="active/inactive/demo")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class User(Base):
@@ -18,10 +33,22 @@ class User(Base):
created_at = Column(DateTime, server_default=func.now())
class UserEntity(Base):
"""用户-企业授权(账套模式多对多)— 新表必须带entity_id(开发规范)"""
__tablename__ = "user_entities"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="用户ID")
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, index=True, comment="企业ID(账套)")
granted_by = Column(Integer, nullable=True, comment="授权人")
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (UniqueConstraint("user_id", "entity_id", name="uq_user_entity"),)
class StrategicMap(Base):
"""战略地图"""
__tablename__ = "strategic_maps"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-25)")
title = Column(String(200), nullable=False, comment="地图名称")
version = Column(String(20), default="v1.0", comment="版本号")
status = Column(String(20), default="draft", comment="draft/published")
@@ -36,6 +63,7 @@ class KPIDefinition(Base):
"""KPI字典"""
__tablename__ = "kpi_definitions"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
kpi_code = Column(String(50), unique=True, nullable=False, comment="KPI编码")
kpi_name = Column(String(200), nullable=False, comment="KPI名称")
@@ -45,16 +73,27 @@ class KPIDefinition(Base):
formula_desc = Column(String(500), nullable=True, comment="公式说明")
data_source_type = Column(String(20), default="manual", comment="erp/business/excel/manual")
data_source_config = Column(JSON, nullable=True, comment="数据源配置")
data_source = Column(String(500), default="待补充", comment="数据来源")
data_owner = Column(String(100), default="待指定", comment="数据责任人")
frequency = Column(String(20), default="monthly", comment="daily/weekly/monthly/quarterly/yearly")
unit = Column(String(50), default="%", comment="单位")
target_value = Column(Float, nullable=True, comment="目标值")
target_value = Column(Float, nullable=True, comment="目标值(兼容旧字段)")
target_monthly = Column(Float, nullable=True, comment="月度目标值")
target_quarterly = Column(Float, nullable=True, comment="季度目标值")
target_yearly = Column(Float, nullable=True, comment="年度目标值")
target_calc_type = Column(String(20), nullable=True, comment="指标类型: accumulate累计(月×3=季,×12=年) / ratio比率(季/年沿用基准,可手调)")
threshold_green = Column(String(100), nullable=True, comment="绿灯阈值")
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
category = Column(String(50), nullable=True, comment="BSC二级类别: revenue_growth/profitability/cost_control/asset_efficiency/cash_risk/customer_scale/customer_concentration/customer_satisfaction/supply_chain/delivery_quality/talent_pipeline/employee_engagement/innovation")
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
responsible_user = Column(String(100), nullable=True, comment="负责人")
kpi_level = Column(String(20), default="operational", comment="strategic/operational")
status = Column(String(20), default="active")
bot_source = Column(String(50), nullable=True, comment="Bot标识: finance-bot/ops-bot等")
epic = Column(String(50), default="Epic2", comment="所属Epic")
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
@@ -65,6 +104,7 @@ class KPIValue(Base):
"""KPI实际值"""
__tablename__ = "kpi_values"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=None, comment="企业ID (P2多租户隔离 2026-08-23, 按kpi_id回填)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
period = Column(String(20), nullable=False, comment="期间 2026-05")
actual_value = Column(Float, nullable=True, comment="实际值")
@@ -98,6 +138,9 @@ class KPIAlert(Base):
kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
alert_message = Column(String(500), nullable=False)
alert_type = Column(String(30), default="actual", comment="actual/forecast — 实际值超限/预测值超限")
suggestion = Column(Text, nullable=True, comment="情景建议")
action_plan_linked_id = Column(Integer, nullable=True, comment="关联的改善计划ID")
status = Column(String(20), default="pending", comment="pending/processing/resolved")
assignee = Column(String(100), nullable=True, comment="处理人")
resolution = Column(Text, nullable=True, comment="处理结果")
@@ -152,12 +195,32 @@ class RolePermission(Base):
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class Objective(Base):
"""OKR目标"""
__tablename__ = "objectives"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
title = Column(String(200), nullable=False, comment="目标标题")
description = Column(Text, nullable=True, comment="目标描述")
dimension = Column(String(50), nullable=True, comment="关联维度: finance/customer/process/learning")
strategic_map_id = Column(Integer, ForeignKey("strategic_maps.id"), nullable=True, comment="关联战略地图")
quarter = Column(String(20), nullable=False, comment="季度: 2026Q3")
owner = Column(String(100), nullable=True, comment="负责人")
status = Column(String(20), default="active", comment="active/completed/cancelled")
progress = Column(Integer, default=0, comment="整体进度 0-100")
confidence = Column(Integer, default=5, comment="信心指数 1-10")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class ActionPlan(Base):
"""改善行动计划"""
__tablename__ = "action_plans"
id = Column(Integer, primary_key=True, index=True)
alert_id = Column(Integer, ForeignKey("kpi_alerts.id"), nullable=True, comment="关联预警")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=True, comment="关联OKR目标")
kr_id = Column(Integer, ForeignKey("krs.id"), nullable=True, comment="关联KR (行动挂KR 2026-08-27)")
title = Column(String(200), nullable=False, comment="计划标题")
description = Column(Text, nullable=True, comment="详细描述")
assignee = Column(String(100), nullable=True, comment="负责人")
@@ -166,6 +229,15 @@ class ActionPlan(Base):
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled")
progress = Column(Integer, default=0, comment="完成进度 0-100")
result = Column(Text, nullable=True, comment="改善结果")
monthly_milestones = Column(JSON, nullable=True, comment="月度里程碑: [{\"month\":\"2026-07\",\"label\":\"...\",\"status\":\"completed\"}]")
auto_verify_rule = Column(JSON, nullable=True, comment="自动验证规则: {\"condition\": \"value > target\"}")
verify_result = Column(String(20), nullable=True, comment="验证结果: pass/fail/pending")
verify_log = Column(JSON, nullable=True, comment="验证历史日志")
verify_status = Column(String(20), default="pending", comment="验证状态: pending/passed/failed/retrying/escalated")
verify_attempts = Column(Integer, default=0, comment="验证尝试次数")
verified_at = Column(DateTime, nullable=True, comment="验证完成时间")
kpi_current_before = Column(Float, nullable=True, comment="执行前KPI值")
kpi_current_after = Column(Float, nullable=True, comment="执行后KPI值")
created_by = Column(String(100), nullable=True, comment="创建人")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -191,6 +263,7 @@ class StrategicMapVersion(Base):
"""战略地图版本快照"""
__tablename__ = "strategic_map_versions"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (OpenCode审查#9 2026-08-26)")
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
version = Column(String(20), nullable=False, comment="版本号 v1.0 v1.1 ...")
dimensions = Column(JSON, nullable=False, comment="维度数据快照")
@@ -200,15 +273,641 @@ class StrategicMapVersion(Base):
created_at = Column(DateTime, server_default=func.now())
class ReviewRecord(Base):
"""复盘记录 — 战略回顾会/运营复盘会结论持久化(大PDCA Act闭环 2026-08-27"""
__tablename__ = "review_records"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-27)")
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联战略地图")
review_type = Column(String(20), default="quarterly", comment="monthly/quarterly 月度运营复盘/季度战略回顾")
review_date = Column(Date, nullable=False, comment="复盘日期")
summary = Column(Text, nullable=False, comment="复盘结论(为什么没达成/怎么调整)")
adjustments = Column(JSON, nullable=True, comment="调整项JSON [{type,target,action}]")
next_priorities = Column(JSON, nullable=True, comment="下阶段优先级 [str,...]")
created_by = Column(Integer, nullable=True, comment="创建人用户ID")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class MapObjective(Base):
"""战略地图目标: 每个维度下的具体目标"""
__tablename__ = "map_objectives"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-25)")
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
dimension_key = Column(String(50), nullable=False, comment="所属维度: finance/customer/process/learning")
name = Column(String(200), nullable=False, comment="目标名称")
description = Column(Text, nullable=True, comment="描述")
icon = Column(String(50), default="target", comment="图标标识")
kpis = Column(JSON, nullable=True, comment="关联KPI编码数组")
sort_order = Column(Integer, default=0, comment="排序")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class KPICausality(Base):
"""KPI因果链 — 记录KPI间的因果关系"""
__tablename__ = "kpi_causality"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-27)")
source_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="源KPI(因)")
target_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI(果)")
strength = Column(Float, default=0.5, comment="影响强度 0~1")
lag_months = Column(Integer, default=1, comment="滞后期(月)")
formula = Column(String(500), nullable=True, comment="影响公式描述")
direction = Column(String(10), default="positive", comment="positive/negative 正向/负向影响")
source_type = Column(String(20), default="manual", comment="建链来源 AI_suggested/manual/imported (2026-08-27 验证机制)")
verify_status = Column(String(20), default="pending", comment="验证状态 pending/data_verified/human_verified/disputed (2026-08-27)")
verified_at = Column(DateTime, nullable=True, comment="验证时间")
verified_by = Column(String(50), nullable=True, comment="验证人/AI/脚本")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class MpmResult(Base):
"""MPM财务Bot分析结果记录"""
__tablename__ = "mpm_results"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业实体ID")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
source = Column(String(50), default="finance-bot", comment="来源Bot标识")
raw_data = Column(JSON, nullable=False, comment="完整的MPM计算结果")
created_at = Column(DateTime, server_default=func.now())
class KpiForecastLog(Base):
"""KPI预测历史 — 预测偏差告警数据源 (2026-08-25 升级2a)"""
__tablename__ = "kpi_forecast_log"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID")
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
kpi_code = Column(String(50), nullable=False, comment="KPI编码")
period = Column(String(20), nullable=False, comment="预测期间")
forecast_value = Column(Float, nullable=True, comment="预测值")
model = Column(String(30), default="linear", comment="预测模型")
confidence = Column(String(10), nullable=True, comment="置信度")
trend = Column(String(10), nullable=True, comment="趋势")
created_at = Column(DateTime, server_default=func.now())
class BotBridgeConfig(Base):
"""Bot桥接鉴权配置"""
__tablename__ = "bot_bridge_config"
id = Column(Integer, primary_key=True, index=True)
bot_name = Column(String(50), unique=True, nullable=False, comment="Bot名称")
token = Column(String(64), nullable=False, comment="鉴权Token")
is_active = Column(Boolean, default=True, comment="是否激活")
created_at = Column(DateTime, server_default=func.now())
class KpiDataQualityLog(Base):
"""数据质量监控日志"""
__tablename__ = "kpi_data_quality_log"
id = Column(Integer, primary_key=True, index=True)
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
check_type = Column(String(30), nullable=False, comment="abnormal_change/flat_data/missing_data/value_outlier")
severity = Column(String(20), default="warning", comment="info/warning/critical")
detail = Column(JSON, nullable=True, comment="检测详情")
suggestion = Column(String(500), nullable=True, comment="建议操作")
status = Column(String(20), default="open", comment="open/resolved/ignored")
resolved_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
class BiReportTemplate(Base):
"""BI报表模板"""
__tablename__ = "bi_report_templates"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
name = Column(String(200), nullable=False, comment="模板名称")
report_type = Column(String(50), nullable=False, comment="overview/trend/comparison/topn/causality")
config = Column(JSON, nullable=False, comment="报表配置")
is_system = Column(Integer, default=0, comment="系统预置模板")
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class BiReport(Base):
"""用户保存的BI报表"""
__tablename__ = "bi_reports"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
template_id = Column(Integer, ForeignKey("bi_report_templates.id"), nullable=True)
name = Column(String(200), nullable=False, comment="报表名称")
config = Column(JSON, nullable=False, comment="报表配置(行/列/值)")
chart_type = Column(String(50), default="auto", comment="图表类型")
is_shared = Column(Integer, default=0, comment="是否分享")
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class BscLayerConfig(Base):
"""BSC四层配置 — 不同企业的权重配置"""
__tablename__ = "bsc_layer_config"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业ID")
layer = Column(String(20), nullable=False, comment="financial/customer/process/learning")
weight = Column(Float, nullable=False, comment="该层权重(%")
kpi_count_min = Column(Integer, default=2, comment="最少KPI数")
kpi_count_max = Column(Integer, default=5, comment="最多KPI数")
class KPIHierarchy(Base):
"""KPI层级关系 — 公司→部门→个人三级分解"""
__tablename__ = "kpi_hierarchy"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
parent_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="上级KPI")
child_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="下级KPI")
level = Column(Integer, default=1, comment="1=公司级 2=部门级 3=个人级")
weight = Column(Float, default=1.0, comment="下级对上级的贡献权重(%")
child_name = Column(String(200), nullable=True, comment="下级节点名称(个人或部门名)")
created_at = Column(DateTime, server_default=func.now())
# 兼容性: P2开发新增的模板API需要的模型
# KPI模板(独立表)
class KPITemplate(Base):
__tablename__ = "kpi_templates"
id = Column(Integer, primary_key=True, index=True)
kpi_code = Column(String(50), unique=True, nullable=False)
kpi_name = Column(String(200), nullable=False)
dimension = Column(String(50))
category = Column(String(50))
formula = Column(Text)
formula_desc = Column(String(500))
unit = Column(String(50))
target_value = Column(Float)
description = Column(Text)
is_system = Column(Integer, default=0)
usage_count = Column(Integer, default=0)
created_by = Column(Integer)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class OKRTemplate(Base):
"""OKR模板库"""
__tablename__ = "okr_templates"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False, comment="O名称")
description = Column(Text, comment="O描述")
dimension = Column(String(20), nullable=False, comment="finance/customer/process/learning")
layer = Column(String(20), default="level1", comment="level1/level2/level3")
industry_tag = Column(String(50), default="general", comment="行业标签")
preset_krs = Column(JSON, nullable=False, comment="预设关键结果列表")
source = Column(String(20), default="system", comment="system/user/industry_pack")
owner = Column(String(50), default="CMA标准库", comment="模板负责人(治理P2: 可追踪)")
use_count = Column(Integer, default=0, comment="使用次数")
sort_order = Column(Integer, default=0)
is_active = Column(Integer, default=1)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class CashForecast(Base):
"""现金流预测 — 每日未来30天预测"""
__tablename__ = "cash_forecasts"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业ID")
forecast_date = Column(DateTime, nullable=False, comment="预测日期(每天一条)")
predicted_cash = Column(Float, nullable=True, comment="预测现金余额")
lower_bound = Column(Float, nullable=True, comment="置信区间下界")
upper_bound = Column(Float, nullable=True, comment="置信区间上界")
alert_status = Column(String(20), default="green", comment="green/yellow/red")
created_at = Column(DateTime, server_default=func.now())
class CashPlan(Base):
"""收付款计划 — 资金管理智能体(唯一应收载体:含回款登记、负责人、数据来源)"""
__tablename__ = "cash_plans"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
plan_type = Column(String(10), nullable=False, comment="receive收/pay付")
related_kpi_id = Column(Integer, nullable=True, comment="关联KPI(预算联动 2026-08-27)")
budget_plan_id = Column(Integer, nullable=True, comment="来源预算计划ID")
amount = Column(Float, nullable=False, comment="金额(万元)")
plan_date = Column(DateTime, nullable=False, comment="计划日期(应收即到期日)")
counterparty = Column(String(200), nullable=True, comment="关联客户/供应商")
description = Column(String(500), nullable=True, comment="说明")
status = Column(String(20), default="pending", comment="pending/completed/cancelled")
owner = Column(String(100), nullable=True, comment="负责人/业务员(应收催收责任人)")
source = Column(String(50), default="manual", comment="数据来源: manual/bohai_ar/receivables_migrate")
paid_amount = Column(Float, default=0, comment="已回款金额(万元)")
completed_at = Column(DateTime, nullable=True, comment="完成时间")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class SystemConfig(Base):
"""系统配置 — key-value存储"""
__tablename__ = "system_configs"
id = Column(Integer, primary_key=True, index=True)
config_key = Column(String(100), unique=True, nullable=False, comment="配置键")
config_value = Column(String(500), nullable=True, comment="配置值")
description = Column(String(500), nullable=True, comment="配置说明")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class BudgetDeviationAlert(Base):
"""预算偏差预警记录"""
__tablename__ = "budget_deviation_alerts"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
budget_value = Column(Float, nullable=True, comment="预算值")
actual_value = Column(Float, nullable=True, comment="实际值")
deviation_rate = Column(Float, nullable=True, comment="偏差率 %")
deviation_value = Column(Float, nullable=True, comment="偏差绝对值")
alert_level = Column(String(20), default="warning", comment="warning/critical")
status = Column(String(20), default="open", comment="open/resolved/ignored")
suggestion = Column(String(500), nullable=True, comment="处理建议")
alert_type = Column(String(30), nullable=True, comment="归因场景: cost_high/revenue_drop/cash_low/cash_critical (2026-08-28 告警归因P1-③)")
attribution = Column(JSON, nullable=True, comment="归因JSON: 子KPI拆解+科目拆解+量价差+趋势 (2026-08-28)")
scenario_id = Column(Integer, nullable=True, comment="FK scenario_suggestions.id 场景建议 (2026-08-28)")
created_at = Column(DateTime, server_default=func.now())
class ReportHistory(Base):
"""自动生成的经营分析报告记录"""
__tablename__ = "report_history"
id = Column(Integer, primary_key=True, index=True)
report_type = Column(String(20), nullable=False, comment="weekly/monthly/special")
period = Column(String(20), nullable=False, comment="期间: 2026-W30 / 2026-07 / 2026-Q2")
title = Column(String(200), nullable=False, comment="报告标题")
markdown_content = Column(Text, nullable=True, comment="Markdown格式报告(用于微信推送)")
json_content = Column(JSON, nullable=True, comment="JSON结构化数据(写入CMA系统)")
status = Column(String(20), default="generated", comment="generated/pushed/failed")
trigger_type = Column(String(20), default="manual", comment="manual/scheduled/event")
alert_ref = Column(String(50), nullable=True, comment="事件触发时的预警引用")
created_at = Column(DateTime, server_default=func.now())
class AnalysisResult(Base):
"""财务Bot分析结论 — 带置信度评分"""
__tablename__ = "analysis_results"
id = Column(Integer, primary_key=True, index=True)
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
conclusion = Column(String(1000), nullable=False, comment="分析结论")
confidence = Column(Integer, nullable=False, comment="置信度 0-100")
data_source = Column(String(500), nullable=True, comment="数据来源")
calculation_logic = Column(String(1000), nullable=True, comment="计算逻辑")
comparable_benchmark = Column(String(500), nullable=True, comment="可比基准")
limitations = Column(String(1000), nullable=True, comment="局限说明")
has_actual = Column(Integer, default=0, comment="有实际值")
has_target = Column(Integer, default=0, comment="有目标值")
has_trend = Column(Integer, default=0, comment="有历史趋势")
has_review = Column(Integer, default=0, comment="有人工复核")
kpi_code = Column(String(50), nullable=True, comment="关联KPI编码")
kpi_name = Column(String(200), nullable=True, comment="关联KPI名称")
created_at = Column(DateTime, server_default=func.now())
class ForecastAccuracy(Base):
"""预测准确率 — 上期预测 vs 本期实际"""
__tablename__ = "forecast_accuracy"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, ForeignKey("entities.id"), nullable=False, comment="企业ID")
period = Column(String(20), nullable=False, comment="期间 2026-07")
forecast_value = Column(Float, nullable=True, comment="预测值")
actual_value = Column(Float, nullable=True, comment="实际值")
mae = Column(Float, nullable=True, comment="绝对误差")
mape = Column(Float, nullable=True, comment="百分比误差")
created_at = Column(DateTime, server_default=func.now())
class ScenarioSuggestion(Base):
"""情景建议模板 — 根据不同预警类型自动生成建议"""
__tablename__ = "scenario_suggestions"
id = Column(Integer, primary_key=True, index=True)
alert_type = Column(String(30), nullable=False, comment="预警类型: cash_low/cash_critical/cost_high/revenue_drop")
title = Column(String(200), nullable=False, comment="建议标题")
description = Column(Text, nullable=True, comment="详细建议")
action_template = Column(Text, nullable=True, comment="改善行动模板")
priority = Column(String(20), default="medium", comment="high/medium/low")
sort_order = Column(Integer, default=0, comment="排序")
created_at = Column(DateTime, server_default=func.now())
# ============================================================
# 新30号准则模型 (2027)
# ============================================================
class Subject(Base):
"""会计科目 — 新30号准则分类"""
__tablename__ = "subjects"
id = Column(Integer, primary_key=True, index=True)
subject_code = Column(String(20), nullable=False, unique=True, comment="科目编码")
subject_name = Column(String(200), nullable=False, comment="科目名称")
parent_code = Column(String(20), nullable=True, comment="上级科目编码")
level = Column(Integer, default=1, comment="科目级别 1-4")
category = Column(String(50), nullable=True, comment="科目类别")
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类: operating/investing/financing/tax/discontinued")
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
is_active = Column(Integer, default=1, comment="是否启用")
remark = Column(String(500), nullable=True, comment="备注")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class VoucherDetail(Base):
"""凭证明细 — 新30号准则分类 (网银流水导入 2026-08-28)"""
__tablename__ = "voucher_details"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)")
voucher_no = Column(String(50), nullable=False, comment="凭证编号")
voucher_date = Column(DateTime, nullable=False, comment="凭证日期")
subject_code = Column(String(20), nullable=False, comment="科目编码")
subject_name = Column(String(200), nullable=True, comment="科目名称")
debit_amount = Column(Float, default=0, comment="借方金额")
credit_amount = Column(Float, default=0, comment="贷方金额")
summary = Column(String(500), nullable=True, comment="摘要")
carry_forward = Column(Integer, default=0, nullable=False, comment="结转行标记(1=结转行不参与现金流)")
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类")
period = Column(String(20), nullable=True, comment="期间 YYYY-MM")
batch = Column(String(100), nullable=True, comment="导入批次号")
created_at = Column(DateTime, server_default=func.now())
class ImportLog(Base):
"""数据导入日志 — 网银流水/Excel导入批次记录 (2026-08-28)"""
__tablename__ = "import_logs"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)")
filename = Column(String(500), nullable=False, comment="文件名")
batch = Column(String(100), nullable=False, comment="批次号")
total_rows = Column(Integer, nullable=True, comment="总行数")
success_rows = Column(Integer, nullable=True, comment="成功行数")
failed_rows = Column(Integer, nullable=True, comment="失败行数")
errors = Column(JSON, nullable=True, comment="失败详情 [{row, field, reason}]")
period = Column(String(20), nullable=True, comment="导入期间")
import_type = Column(String(20), nullable=True, comment="导入类型: vouchers/kpi")
created_by = Column(String(100), nullable=True, comment="导入人")
created_at = Column(DateTime, server_default=func.now())
# ============================================================
# 费用审核智能体 (2026-08)
# ============================================================
class ExpenseRule(Base):
"""费用规则 — 自动校验报销单的标准"""
__tablename__ = "expense_rules"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
rule_name = Column(String(100), nullable=False, comment="规则名称")
dimension = Column(String(20), nullable=False, comment="维度: department/person/expense_type")
dimension_value = Column(String(100), nullable=True, comment="维度值: 部门名/人员名/费用类型(空=全局)")
expense_type = Column(String(30), nullable=False, comment="费用类型: entertainment/travel/office/management")
limit_type = Column(String(20), nullable=False, comment="限额类型: single/monthly/yearly 单笔/月度累计/年度累计")
limit_amount = Column(Float, nullable=False, comment="限额金额")
cycle = Column(String(20), default="monthly", comment="周期: single/monthly/yearly")
status = Column(String(20), default="active", comment="active/inactive")
remark = Column(String(500), nullable=True, comment="备注")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class ExpenseReimbursement(Base):
"""费用报销单 — 提交后自动校验规则,超限自动打回"""
__tablename__ = "expense_reimbursements"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
reimb_no = Column(String(50), unique=True, nullable=False, comment="报销单号")
applicant = Column(String(100), nullable=False, comment="申请人")
department = Column(String(100), nullable=True, comment="部门")
expense_type = Column(String(30), nullable=False, comment="费用类型: entertainment/travel/office/management")
title = Column(String(200), nullable=False, comment="事由/摘要")
amount = Column(Float, nullable=False, comment="报销金额")
expense_date = Column(DateTime, nullable=True, comment="费用发生日期")
attachment = Column(String(500), nullable=True, comment="附件文件名")
status = Column(String(20), default="pending", comment="pending待审批/approved已通过/rejected已拒绝/returned已打回")
check_result = Column(String(20), default="pass", comment="自动校验结果: pass/fail")
check_reason = Column(String(1000), nullable=True, comment="超限原因")
check_detail = Column(JSON, nullable=True, comment="校验明细: [{rule_name, limit, actual, passed}]")
checked_at = Column(DateTime, nullable=True, comment="自动校验时间")
approver = Column(String(100), nullable=True, comment="审批人")
approve_comment = Column(String(500), nullable=True, comment="审批意见")
approved_at = Column(DateTime, nullable=True, comment="审批时间")
created_by = Column(String(100), nullable=True, comment="提交人")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# ============================================================
# 税务合规智能体 (2026-08)
# ① 税负监控 ② 发票校验 ③ 社保比对
# ============================================================
class TaxRecord(Base):
"""税务记录 — 税负监控:应纳税额/实缴额/税负率 vs 行业基准"""
__tablename__ = "tax_records"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
tax_type = Column(String(30), nullable=False, comment="税种: vat增值税/income所得税/surtax附加税")
tax_payable = Column(Float, default=0, comment="应纳税额")
tax_paid = Column(Float, default=0, comment="实缴税额")
tax_rate = Column(Float, nullable=True, comment="适用税率 %")
income = Column(Float, default=0, comment="当期收入/计税收入(税负率分母)")
tax_burden_rate = Column(Float, nullable=True, comment="税负率 % = 实缴税额/收入×100")
burden_status = Column(String(20), default="normal", comment="normal正常/warning超基准±20%内/alert超基准±20%")
warning_msg = Column(String(500), nullable=True, comment="预警信息")
remark = Column(String(500), nullable=True, comment="备注")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class InvoiceCheck(Base):
"""发票校验 — 录入后按规则自动校验:号码格式/金额匹配报销单/供应商匹配合同"""
__tablename__ = "invoice_check"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
invoice_no = Column(String(50), nullable=False, comment="发票号码")
amount = Column(Float, nullable=False, comment="发票金额")
invoice_type = Column(String(30), default="vat", comment="发票类型: vat专用/vat普通/electronic电子/other其他")
invoice_date = Column(DateTime, nullable=True, comment="开票日期")
supplier = Column(String(200), nullable=True, comment="供应商名称")
reimb_no = Column(String(50), nullable=True, comment="关联报销单号")
contract_no = Column(String(50), nullable=True, comment="关联合同编号")
check_status = Column(String(20), default="pending", comment="校验状态: pending待校验/valid通过/invalid异常/warning提醒")
check_result = Column(JSON, nullable=True, comment="校验明细: [{rule, passed, message}]")
check_reason = Column(String(1000), nullable=True, comment="异常原因汇总")
checked_at = Column(DateTime, nullable=True, comment="校验时间")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class SocialSecurity(Base):
"""社保缴费记录 — 社保比对:基数与工资匹配/单位缴纳比例/漏缴提醒"""
__tablename__ = "social_security"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
employee = Column(String(100), nullable=False, comment="人员姓名")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
base_amount = Column(Float, default=0, comment="缴费基数")
salary = Column(Float, nullable=True, comment="申报工资")
company_amount = Column(Float, default=0, comment="单位缴纳金额")
personal_amount = Column(Float, default=0, comment="个人缴纳金额")
company_rate = Column(Float, nullable=True, comment="单位缴纳比例 % (养老16%+医疗8%+失业0.5%≈24.5%)")
check_status = Column(String(20), default="normal", comment="normal正常/warning基数或比例异常/alert漏缴")
warning_msg = Column(String(500), nullable=True, comment="异常提醒: 漏缴/基数不符/比例异常")
remark = Column(String(500), nullable=True, comment="备注")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# ============================================================
# 本体三支柱: 科目↔KPI↔OKR 三层互联 (2026-08-19)
# 追溯链: 目标(O) → 指标(KPI) → 科目(数据)
# ============================================================
class KPISubjectMap(Base):
"""科目↔KPI映射 — 指标计算依赖的底层会计科目"""
__tablename__ = "kpi_subject_map"
id = Column(Integer, primary_key=True, index=True)
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="KPI ID")
subject_code = Column(String(20), nullable=False, comment="科目编码")
calc_type = Column(String(20), default="sum", comment="sum/avg/ratio/other")
weight = Column(Numeric(5, 2), default=1.00, comment="权重(负=扣减项)")
remark = Column(String(200), nullable=True, comment="备注")
__table_args__ = (UniqueConstraint("kpi_id", "subject_code", name="uk_kpi_subject"),)
class ObjectiveKPI(Base):
"""KPI↔O支撑 — 目标由哪些KPI度量"""
__tablename__ = "objective_kpi"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="KPI ID")
weight = Column(Numeric(5, 2), default=1.00, comment="支撑权重")
__table_args__ = (UniqueConstraint("objective_id", "kpi_id", name="uk_obj_kpi"),)
class KR(Base):
"""关键结果KR — OKR完整化 (O→KR→KPI)"""
__tablename__ = "krs"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
objective_id = Column(Integer, ForeignKey("objectives.id"), nullable=False, comment="OKR目标ID")
title = Column(String(200), nullable=False, comment="KR标题")
metric_kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=True, comment="度量KPI ID")
operator = Column(String(5), default=">=", nullable=False, comment="方向符号: >=/<=/>/</= (KR完整修复 2026-08-27)")
tolerance = Column(Numeric(10, 4), nullable=True, comment="容差(仅=时使用)")
weight = Column(Numeric(5, 2), nullable=True, comment="KR权重%")
target_value = Column(Numeric(15, 2), nullable=True, comment="目标值")
current_value = Column(Numeric(15, 2), nullable=True, comment="当前值")
progress = Column(Integer, default=0, comment="完成进度 0-100")
status = Column(String(20), default="pending", comment="pending/in_progress/completed/cancelled/achieved")
due_date = Column(Date, nullable=True, comment="截止日期")
monthly_milestones = Column(JSON, nullable=True, comment="月度里程碑: [{\"month\":\"2026-07\",\"label\":\"...\",\"status\":\"completed\"}]")
sort_order = Column(Integer, default=0, comment="排序")
created_at = Column(DateTime, server_default=func.now())
# ============================================================
# 预算系统技术改进 (2026-08-28 yanxue-budget-tech-improve)
# ① kpi_value_sources/kpi_value_collect_logs: 实际值自动归集 P1-④
# ② budget_zero_based_items: 真零基逐项论证 P2-①
# ③ budget_derivation_rules: 派生规则可配置 P2-②
# ④ cash_plan_classify_rules/cash_plan_unclassified: 现金流分类规则 P2-⑥
# ============================================================
class KPIValueSource(Base):
"""KPI实际值取数映射 — 自动归集源头 (P1-④ 2026-08-28)"""
__tablename__ = "kpi_value_sources"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI")
source_table = Column(String(50), nullable=False, comment="源头表: voucher_details/product_inventory/product_inventory_detail/cash_plans")
source_field = Column(String(50), nullable=False, comment="金额字段: credit_amount/debit_amount/amount/qty")
aggregate = Column(String(10), default="sum", comment="sum/avg/count/max/min")
filter_rule = Column(JSON, nullable=True, comment="过滤: {\"subject_code\":\"6601\",\"direction\":\"credit\"}")
period_field = Column(String(50), default="period", comment="期间字段: period/voucher_date")
unit_conversion = Column(Float, default=1, comment="单位倍率(元→万元/10000)")
status = Column(String(20), default="active", comment="active/inactive")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (UniqueConstraint("entity_id", "kpi_id", "source_table", name="uk_source"),)
class KPIValueCollectLog(Base):
"""实际值采集日志 — 每次自动归集记录 (P1-④ 2026-08-28)"""
__tablename__ = "kpi_value_collect_logs"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID")
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
source_table = Column(String(50), nullable=False, comment="源头表")
collected_value = Column(Float, nullable=True, comment="采集到的值")
status = Column(String(20), default="success", comment="success/failed")
message = Column(String(500), nullable=True, comment="说明/错误信息")
collected_at = Column(DateTime, server_default=func.now())
class BudgetZeroBasedItem(Base):
"""零基预算逐项论证项 (P2-① 2026-08-28)"""
__tablename__ = "budget_zero_based_items"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
period = Column(String(20), nullable=False, comment="期间 YYYY-MM")
item_name = Column(String(200), nullable=False, comment="费用科目名")
item_category = Column(String(20), default="discretionary", comment="fixed/variable/discretionary")
base_value = Column(Float, nullable=False, comment="基准值(上年/上月实际)")
justification = Column(Text, nullable=True, comment="逐项论证理由(为何保留/削减/取消)")
proposed_value = Column(Float, nullable=False, comment="论证后金额")
status = Column(String(20), default="draft", comment="draft/approved")
created_by = Column(String(100), nullable=True, comment="创建人")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class BudgetDerivationRule(Base):
"""KPI派生规则 — apply-method 可配置派生 (P2-② 2026-08-28)"""
__tablename__ = "budget_derivation_rules"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="目标KPI")
rule_type = Column(String(30), nullable=False, comment="incremental/percentage_of/formula")
base_kpi_id = Column(Integer, nullable=True, comment="来源KPI(percentage_of用)")
params = Column(JSON, nullable=True, comment="{\"rate\":0.02,\"field\":\"net_profit\"}")
formula_text = Column(String(500), nullable=True, comment="可读公式说明")
status = Column(String(20), default="active", comment="active/inactive")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (UniqueConstraint("entity_id", "kpi_id", "rule_type", name="uk_rule"),)
class CashPlanClassifyRule(Base):
"""现金流收付分类规则 — KPI→receive/pay 可维护 (P2-⑥ 2026-08-28)"""
__tablename__ = "cash_plan_classify_rules"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, nullable=True, comment="精确匹配KPI,优先")
kpi_code_pattern = Column(String(200), nullable=True, comment="关键词/编码模式匹配,兜底")
plan_type = Column(String(10), nullable=False, comment="receive/pay")
priority = Column(Integer, default=10, comment="匹配顺序,小优先")
status = Column(String(20), default="active", comment="active/inactive")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class CashPlanUnclassified(Base):
"""现金流待分类KPI队列 — 无法判别的KPI不静默跳过 (P2-⑥ 2026-08-28)"""
__tablename__ = "cash_plan_unclassified"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, nullable=False, comment="企业ID(多租户隔离)")
kpi_id = Column(Integer, nullable=False, comment="KPI ID")
kpi_name = Column(String(200), nullable=True, comment="KPI名称")
period = Column(String(20), nullable=True, comment="期间")
budget_value = Column(Float, nullable=True, comment="预算值")
reason = Column(String(200), nullable=True, comment="无法分类原因")
status = Column(String(20), default="pending", comment="pending/classified/ignored")
created_at = Column(DateTime, server_default=func.now())
resolved_at = Column(DateTime, nullable=True)
+5
View File
@@ -8,6 +8,8 @@ class BudgetPlan(Base):
__tablename__ = "budget_plans"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
map_id = Column(Integer, nullable=True, comment="归属战略地图ID (预算按地图隔离 2026-08-27)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False, comment="关联KPI")
period = Column(String(20), nullable=False, comment="预算期间 2026-05")
budget_value = Column(Float, nullable=False, comment="预算值")
@@ -15,6 +17,9 @@ class BudgetPlan(Base):
budget_month = Column(Integer, nullable=False, comment="预算月份 1-12")
version = Column(String(20), default="v1.0", comment="版本号 v1.0/v2.0")
status = Column(String(20), default="active", comment="active/archived")
source_type = Column(String(50), nullable=True, comment="来源类型: manual/kpi_generated/roll_forward等")
source_kpi_id = Column(Integer, nullable=True, comment="来源KPI ID(KPI推算生成时记录)")
calc_logic = Column(Text, nullable=True, comment="预算计算逻辑说明")
remark = Column(String(500), nullable=True, comment="备注")
created_by = Column(String(100), nullable=True)
created_at = Column(DateTime, server_default=func.now())
+4
View File
@@ -10,6 +10,7 @@ class StandardCost(Base):
__tablename__ = "standard_costs"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
product_name = Column(String(200), nullable=False, comment="产品/服务名称")
cost_type = Column(String(20), nullable=False, comment="成本类型: material/labor/overhead")
@@ -30,6 +31,7 @@ class ActualCost(Base):
__tablename__ = "actual_costs"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
period = Column(String(20), nullable=False, comment="期间 2026-05")
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
product_name = Column(String(200), nullable=False, comment="产品/服务名称")
@@ -47,6 +49,7 @@ class AbcActivity(Base):
__tablename__ = "abc_activities"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
activity_code = Column(String(50), unique=True, nullable=False, comment="作业编码")
activity_name = Column(String(200), nullable=False, comment="作业名称")
activity_desc = Column(Text, nullable=True, comment="作业描述")
@@ -65,6 +68,7 @@ class AbcAllocation(Base):
__tablename__ = "abc_allocations"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
period = Column(String(20), nullable=False, comment="期间 2026-05")
activity_id = Column(Integer, ForeignKey("abc_activities.id"), nullable=False)
product_code = Column(String(50), nullable=False, comment="产品/服务编码")
+36
View File
@@ -0,0 +1,36 @@
"""驱动因子预算模型 — 业务驱动因子 vs 科目模式"""
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, JSON, func
from app.database import Base
class DriverFactorTemplate(Base):
"""驱动因子模板 — 通用/行业版本"""
__tablename__ = "driver_factor_templates"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(200), nullable=False, comment="模板名称")
industry = Column(String(50), default="general", comment="行业标签: general/trade/it")
category = Column(String(50), default="revenue", comment="类别: revenue/expense")
formula_desc = Column(String(500), nullable=True, comment="公式说明")
formula_text = Column(String(500), nullable=False, comment="公式文本, 如: 客户数×客单价")
factors = Column(JSON, nullable=False, comment="驱动因子列表")
is_active = Column(Integer, default=1, comment="是否启用")
created_at = Column(DateTime, server_default=func.now())
class DriverFactorBudget(Base):
"""驱动因子预算计算结果"""
__tablename__ = "driver_factor_budgets"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (P2多租户隔离 2026-08-23)")
name = Column(String(200), nullable=False, comment="预算项名称")
industry = Column(String(50), default="general", comment="行业标签")
template_id = Column(Integer, nullable=True, comment="关联模板ID")
factors = Column(JSON, nullable=False, comment="驱动因子键值对")
calculated_value = Column(Float, nullable=False, comment="计算结果")
formula_text = Column(String(500), nullable=True, comment="公式文本")
period = Column(String(20), nullable=True, comment="期间")
sensitivity = Column(JSON, nullable=True, comment="敏感性分析结果")
created_by = Column(String(100), nullable=True)
created_at = Column(DateTime, server_default=func.now())
+69
View File
@@ -0,0 +1,69 @@
"""管理会计OS — 知识摘要模块
仿 OpenCode 的持久记忆机制summarizer + SummaryMessageID但做了三处改进
1. 分层压缩全部不是一次性全量压缩
2. 结构化存储MySQL 关系表不是 SQLite JSON 消息
3. 保留版本链不是覆盖式压缩
参考OpenCode SummarizeProvider prompt 框架 + CMA OperationLog 的审计日志
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, ForeignKey, Float, func
from app.database import Base
class KnowledgeEvent(Base):
"""关键事件记录
自动从 OperationLog 和其他数据源抽取的"值得记住"的事件
每个事件是一个结构化记录包含类型级别关联对象摘要描述
这是增量压缩的输入摘要 agent 只处理"未摘要过"的新事件
"""
__tablename__ = "knowledge_events"
id = Column(Integer, primary_key=True, index=True)
event_type = Column(String(30), nullable=False, comment="事件类型: kpi_change/alert/decision/plan/map/import/user_action")
event_level = Column(String(20), default="info", comment="info/warning/important/critical")
source = Column(String(50), nullable=True, comment="来源: operation_log/api/erp_sync/manual")
source_id = Column(Integer, nullable=True, comment="源记录ID(如 operation_log.id")
target_type = Column(String(50), nullable=True, comment="关联对象类型: kpi/map/budget/alert/plan")
target_id = Column(Integer, nullable=True, comment="关联对象ID")
title = Column(String(300), nullable=False, comment="事件标题(一句话概括)")
description = Column(Text, nullable=True, comment="事件详细描述")
delta = Column(JSON, nullable=True, comment="变更字段和前后值: {field: {old: X, new: Y}}")
occurred_at = Column(DateTime, nullable=False, comment="事件发生时间")
created_at = Column(DateTime, server_default=func.now())
# 摘要追踪——记录该事件被哪些摘要(id列表)包含
summarized_in = Column(JSON, nullable=True, comment="包含此事件的摘要ID列表")
class KnowledgeSummary(Base):
"""知识摘要
分层存储daily/weekly/monthly/cumulative
参考 OpenCode summary_message_id 机制但用结构化字段代替 message 指针
"""
__tablename__ = "knowledge_summaries"
id = Column(Integer, primary_key=True, index=True)
level = Column(String(20), nullable=False, comment="摘要层级: daily/weekly/monthly/cumulative")
period_key = Column(String(20), nullable=False, comment="期间标识: 2026-06-12 / 2026-W24 / 2026-06 / cumulative")
title = Column(String(300), nullable=False, comment="摘要标题")
content = Column(Text, nullable=False, comment="摘要正文(纯文本/Markdown")
event_ids = Column(JSON, nullable=True, comment="包含的事件ID列表")
# 核心指标变化(精简提取,用于快速问答)
kpi_changes = Column(JSON, nullable=True, comment="摘要期内的KPI变化统计: [{kpi_code, kpi_name, old_value, new_value, direction, alert_level}]")
decision_points = Column(JSON, nullable=True, comment="决策点: [{time, action, actor, result}]")
key_metrics = Column(JSON, nullable=True, comment="摘要期内的关键指标快照: {kpi_code: value}")
# 元信息
prev_summary_id = Column(Integer, nullable=True, comment="上一级摘要ID(如 daily→weekly 的链路)")
next_compressed_by = Column(Integer, nullable=True, comment="被哪个更高层摘要包含")
token_estimate = Column(Integer, default=0, comment="估算token数(用于触发压缩阈值判断)")
model = Column(String(50), nullable=True, comment="生成摘要使用的模型名")
generated_by = Column(String(100), nullable=True, comment="生成方式: auto_scheduler/manual_trigger")
is_stale = Column(Integer, default=0, comment="0=最新 1=已被上层摘要覆盖")
created_at = Column(DateTime, server_default=func.now())
+23
View File
@@ -0,0 +1,23 @@
"""管理会计OS — 知识库文章(P1-3 嵌入功能模块用)
KnowledgeSummary/KnowledgeEventAI摘要系统不同此表存储静态的CMA知识文章
用于在功能模块右侧/底部嵌入展示
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, func
from app.database import Base
class KnowledgeArticle(Base):
"""知识库文章"""
__tablename__ = "knowledge_articles"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(200), nullable=False, comment="文章标题")
summary = Column(String(500), nullable=True, comment="一句话摘要")
content = Column(Text, nullable=False, comment="文章正文(支持Markdown")
category = Column(String(50), nullable=True, comment="分类: term/formula/practice/faq")
icon = Column(String(10), default="📖", comment="图标")
related_page = Column(String(200), nullable=True, comment="关联页面路由,如 /maps/canvas/:id, /kpis, /budget, /deviations, /predict, /maps-review")
sort_order = Column(Integer, default=0, comment="排序")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+24
View File
@@ -0,0 +1,24 @@
"""商品销售数据模型 — 波士顿产品矩阵
商品销售排行榜Excel导入支撑四象限分析
"""
from sqlalchemy import Column, Integer, String, Float, DateTime, func
from app.database import Base
class ProductSales(Base):
"""商品销售月度数据"""
__tablename__ = "product_sales"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID: 1=酣客 2=博海")
product_code = Column(String(50), nullable=False, comment="商品编码")
product_name = Column(String(100), nullable=False, comment="商品名称")
period_month = Column(String(10), nullable=False, comment="期间 YYYY-MM")
sales_amount = Column(Float, default=0, comment="销售金额")
cost_amount = Column(Float, default=0, comment="成本金额")
gross_profit = Column(Float, default=0, comment="毛利")
gross_margin_rate = Column(Float, default=0, comment="毛利率(%)")
sales_qty = Column(Integer, default=0, comment="销售数量")
unit = Column(String(30), default="", comment="单位")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+120
View File
@@ -0,0 +1,120 @@
"""
CMA Bot API 风险分级L1-L4与路由标注装饰器
=============================================
分级定义方案文档 nexa-permission-autonomy-plan-20260828 第二节:
L1 只读查询 X-BOT-KEY验证后直接放行
L2 业务写单条 放行 + 写前校验entity归属/字段校验
L3 批量写/创建 放行 + 限制批量 + source_batch审计
L4 危险 不向Bot API开放DROP/TRUNCATE/批量DELETE/生产结构修改
安全底线: Bot API面不存在L4端点0项开放 = 天然隔离
"""
import functools
RISK_LEVELS = {
"L1": "只读",
"L2": "业务写",
"L3": "批量写",
"L4": "危险",
}
RISK_HANDLING = {
"L1": "X-BOT-KEY验证后直接放行",
"L2": "放行 + 写前校验(entity归属/字段校验)",
"L3": "放行 + 限制批量 + source_batch审计",
"L4": "不向Bot API开放(终端层拦截 + 人工审批)",
}
# ────────────────────────────────────────────────
# API → 风险级别 清单(Bot API面全量路由)
# 键格式: "METHOD path"path 与 FastAPI route.path 一致,含 {param} 占位符)
# ────────────────────────────────────────────────
API_RISK_MAP = {
# ── L1 只读(20项方案清单 + risk-levels查询端点) ──
"GET /api/cma/bot/ping": "L1",
"GET /api/cma/bot/overview": "L1",
"GET /api/cma/bot/kpis": "L1",
"GET /api/cma/bot/kpis/{kpi_id}/history": "L1",
"GET /api/cma/bot/strategic-maps": "L1",
"GET /api/cma/bot/alerts": "L1",
"GET /api/cma/bot/budget/plans": "L1",
"GET /api/cma/bot/cost/standard": "L1",
"GET /api/cma/bot/cost/actual": "L1",
"GET /api/cma/bot/actions": "L1",
"GET /api/cma/bot/organization": "L1",
"GET /api/cma/bot/data-sources": "L1",
"GET /api/cma/bot/users": "L1",
"GET /api/cma/bot/query": "L1",
"GET /api/cma/bot/okr/list": "L1",
"GET /api/cma/bot/nlp": "L1",
"GET /api/cma/bot/iron-law": "L1",
"GET /api/cma/bot/iron-law/bots": "L1",
"GET /api/cma/bot-bridge/verify/{action_plan_id}/history": "L1",
"GET /api/cma/bot-kpis": "L1",
# 本任务新增的只读端点
"GET /api/cma/bot/risk-levels": "L1",
# ── L2 业务写(4项方案清单 + okr/create单条业务写) ──
"POST /api/cma/bot/kpi-value-with-check": "L2",
"POST /api/cma/bot-bridge/kpi-result": "L2",
"POST /api/cma/bot-kpis/{kpi_id}/value": "L2",
"POST /api/cma/bot-bridge/verify/{action_plan_id}": "L2",
"POST /api/cma/bot/okr/create": "L2", # 单条OKR创建(方案清单未列出,按单条业务写归类)
# ── L3 批量写/创建(3项) ──
"POST /api/cma/bot/import": "L3",
"POST /api/cma/bot/kpis/create-with-links": "L3",
"POST /api/cma/bot-bridge/mpm-result": "L3",
# ── L4 危险:Bot API面不存在(安全底线,不添加) ──
}
def risk_level(level: str):
"""路由标注装饰器: @risk_level('L1') 挂在路由函数上(router.get/post 之下)。
同时把级别属性写到原函数与包装函数上保证 route.endpoint 无论取到哪个
都能通过 getattr(endpoint, 'risk_level') 解析
"""
def decorator(func):
func.risk_level = level
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
wrapper.risk_level = level
return wrapper
return decorator
def get_risk_level(method: str, path: str):
"""按 METHOD + pathFastAPI模板路径)查级别,未标注返回 None"""
return API_RISK_MAP.get(f"{method.upper()} {path}")
def get_handling(level: str) -> str:
"""级别 → 处理方式说明"""
return RISK_HANDLING.get(level, "")
def list_api_risk_map() -> list:
"""返回 API→级别→处理方式 清单(供 GET /api/cma/bot/risk-levels 使用)"""
items = []
for key, level in API_RISK_MAP.items():
method, path = key.split(" ", 1)
items.append({
"method": method,
"path": path,
"risk_level": level,
"handling": get_handling(level),
})
items.sort(key=lambda x: (x["risk_level"], x["method"], x["path"]))
return items
def risk_summary() -> dict:
"""各级别端点数量统计"""
summary = {lv: 0 for lv in RISK_LEVELS}
for level in API_RISK_MAP.values():
summary[level] = summary.get(level, 0) + 1
return summary
View File

Some files were not shown because too many files have changed in this diff Show More