Compare commits

..
111 Commits
Author SHA1 Message Date
Hermes CI Fix 9cccaa6cfc fix(budget): 年度分解400提示优化——含可用年份+双路径引导 (budget-decompose-400-hint) 2026-08-31 18:28:11 +08:00
Hermes CI Fix 2241a89fd1 chore(scripts): 提交分解弹窗E2E验证辅助脚本(cdp_driver + review) 2026-08-31 17:05:21 +08:00
Hermes CI Fix c45dc554ed fix(budget): 年度分解弹窗改MyDialog合规——el-dialog违反项目铁律 (budget-decompose-dialog-fix) 2026-08-31 16:58:31 +08:00
Hermes CI Fix e34d375b01 fix(workbench): 行动方案详情弹窗加编辑按钮——关闭详情后复用editPlan预填表单 (actionplan-detail-edit-btn) 2026-08-31 16:56:06 +08:00
Hermes CI Fix e216565d2b chore(deploy): 触发部署hook (budget-decompose-dialog-fix) 2026-08-31 16:50:03 +08:00
Hermes CI Fix d691cd7d2e fix(budget): 预算录入页年度分解按钮补弹窗——showDecompose无UI消费修复 (budget-decompose-dialog-fix) 2026-08-31 16:49:25 +08:00
Hermes CI Fix dc89b14479 chore(scripts): 提交工作台隔离验证脚本 verify_dashboard_isolation.py(dashboard-optimize 遗留) 2026-08-31 15:57:54 +08:00
Hermes CI Fix 0a9b3d5bf6 fix(workbench): 我的改善行动卡片可点击直达详情 (plan_id跳转) plan-card-click-fix
- MyDashboard.vue: plan-card 加 @click 跳 /action-plans?plan_id=xx
- 复用 26e0c8d ActionPlanLibrary 的 plan_id 直达详情弹窗能力
- .plan-card 样式补 cursor:pointer(hover 已有)
2026-08-31 15:57:35 +08:00
Hermes CI Fix 815acda0da fix(workbench): 工作台待关注优化+隔离补漏——类型标签/排序固化/标题措辞 + my_dashboard按entity_id过滤 (dashboard-optimize) 2026-08-31 15:51:39 +08:00
Hermes CI Fix 26e0c8da53 fix(workbench): 我的工作台待办跳转直达行动方案详情 (plan_id定位) todo-jump-fix
- MyDashboard goToDetail: action_plan 跳转带 plan_id(原只跳列表丢ID,与KPI待办直达详情语义对齐)
- ActionPlanLibrary: 支持 route.query.plan_id 定位,onMounted loadData 后在列表 find 目标行并 viewDetail 直达详情弹窗
- 边界: plan_id 不存在/无权限/非plans tab → 静默不弹,保持原列表行为
2026-08-31 15:34:13 +08:00
Hermes CI Fix 79bc877f31 chore(erp): 清理ERP_API_KEY硬编码默认值+任务脚本小调整(与本次待办跳转修复无关,单独成commit) 2026-08-31 15:33:07 +08:00
Hermes CI Fix 74dc9baff5 fix(security): 多租户隔离全量修复 security-fix multi-tenant (OpenCode审查P0)
- bot_bridge 18数据端点全部 entity_id 隔离(Depends(get_entity_id)/body),/ping /risk-levels 豁免
- alert_rules 11端点 entity_id 隔离 + KPIAlert/DynamicThresholdCache 写入 entity_id
- reports 17端点隔离 + generate_report 写 ReportHistory.entity_id + history 按 entity 过滤
- ai_analysis 移除硬编码默认key,改 _require_deepseek_key() 强制 env 缺失 503
- budget auto-decompose 硬编码 entity_id==1 改请求 entity
- kpis update_kpi 加 UPDATE_KPI_WHITELIST 白名单(status/important_flag 不可越权改)
- data_quality 收敛:删 MySQL JSON 版 _run_rule_checks,check-governance 复用 _run_governance_checks(SQLite 兼容)
- _eval_threshold invert 参数修复(>=↔< 等取反),red 分支不传 invert 保持行为
- 新增 test_security_multitenant.py 13条(bot_bridge/alert_rules/reports 隔离 + invert + SQLite governance)
- models 6表加 entity_id 列;生产库已 ALTER + 按真实归属回填(kpi_alerts 472行中216行属entity≠1)
2026-08-31 10:14:22 +08:00
Hermes CI Fix 72072dda8c fix(r1-touch): 8800 relay推送改form-encoded(JSON body会被拒msg required,lead.py旧写法是错的) 2026-08-31 09:12:03 +08:00
Hermes CI Fix 076bd0dae0 feat(r1-touch): 建议分级+决策类推送+应用前预览 (alert不推送/同title防轰炸/preview对比) 2026-08-31 09:07:54 +08:00
Hermes CI Fix df93b635b3 docs: API验证分工表+北极星四层地图(项目Bot产出) 2026-08-31 07:44:27 +08:00
Hermes CI Fix 136fe3811f fix: auto-verify cron残留缺陷修复+第三阶段接入 verify-cron-fix (auto_verify_cron 4项对齐verify.py: entity_id过滤/period过滤/OKR幂等/status completed; action_plan_weekly done->completed; action_plans创建自动生成auto_verify_rule) 2026-08-31 07:44:00 +08:00
Hermes CI Fix 27d8269667 feat(proforma): 预编报表(预算版三张报表)P2 — 预算vs实际vs差异,独立/proforma端点,复用budget_plans+报表模板,无预算行显式标注 2026-08-30 23:13:32 +08:00
Hermes CI Fix db0f7aa591 chore: token优化检查清单文档 2026-08-30 19:16:33 +08:00
Hermes CI Fix 6910d90288 fix: auto-verify验证引擎4项缺陷修复 verify-fix (OKR幂等防重累加/entity_id多租户回填/period期间过滤/status done->completed) 2026-08-30 19:16:19 +08:00
Hermes CI Fix 2da4cfc42d feat(mapcanvas): 田字格2x2布局+拖拽辅助(合法高亮/灰显/吸附)+连接管理面板+zoom transform缩放+连线rAF节流 2026-08-30 17:41:07 +08:00
Hermes CI Fix ad68471b29 feat: 路线图R1决策建议一键落地+R2机会推送+R5预算闭环
R1(P0): AI建议一键应用到KPI/预算/行动方案
- 新表 ai_suggestions + AISuggestion 模型(init_db自动建)
- /api/cma/ai/suggestions CRUD + /{id}/apply(复用kpis/budget/action_plans) + dismiss
- 应用写 OperationLog(action=ai_suggestion_apply, detail含suggestion_id/before/after)
- 规则驱动建议生成 generate_rule_suggestions(低执行率/高执行率/预算超支/pending预警)
- 幂等: 同entity+type+target_id+title+unapplied不重复建; applied后拒绝重复应用
- 前端: Dashboard AI面板建议卡(应用到/忽略) + 建议中心页 /ai-suggestions

R2(P1): 数据找人扩大-机会类推送
- scripts/opportunity_detector.py: KPI向好(执行率>110%)/预算余量(<70%且actual>0)/预测上行
- scripts/daily_push.py: 异常+机会 每日9:15推企微(8800/send, --dry-run调试)
- crontab: 15 9 * * * (alert_generator 9:00之后)

R5(P0): 预算闭环加固
- auto-decompose批量幂等: 只取年度行(period=YYYY-00)+同KPI多版本取一行
- scripts/closed_loop_check.py: 预算执行率异常→检查现金流/行动同步→缺失提示+报告
- scripts/verify_decompose_idempotent.py: 幂等验证脚本

测试: test_ai_suggestions(10例)+test_roadmap_r2r5(14例); 修test_budget幂等契约适配年度行
全量: 673 passed
2026-08-30 12:05:36 +08:00
Hermes CI Fix 5b920df8a0 fix: 预算年度分解幂等加固+数据质量七规则测试
- auto-decompose批量聚合只取年度行(period=YYYY-00)排除月度行,修复重复点击执行分解年底预算滚雪球
- 同一KPI多个version年度行时只取一行(优先请求version),避免多版本叠加总额虚高
- 新增tests/test_data_quality.py: 财务七规则(governance-check)3用例全过
2026-08-30 10:27:01 +08:00
Hermes CI Fix 974ec48564 feat(data-quality): 财务七规则 governance-check 端点+前端Tab(最终方案) 2026-08-30 07:33:20 +08:00
Hermes CI Fix 0c79ba32c8 chore: components.d.ts 自动更新(CountUp组件注册,build生成) 2026-08-30 07:27:21 +08:00
Hermes CI Fix c36ed35348 feat: DAMA数据治理规则检查(财务七规则)— 后端/check-governance + 前端数据质量中心升级
后端 backend/app/api/data_quality.py:
- 新增 GET /api/cma/data-quality/check-governance?entity_id= 端点
- 7条规则: unit_check单位校验/dup_alert重复预警/orphan_check孤儿预警/virtual_pollution虚拟污染/entity_check实体归属/kpi_completeness KPI完整性/reconciliation勾稽验证
- 质量评分: error扣10/warning扣5, 每条规则最多扣一次, 最低0分
- 兼容实体筛选(0全部/1酣客/2博海), 向后兼容既有/check端点

前端 frontend/src/api/index.ts + views/DataQuality.vue:
- dataQualityApi.checkGovernance() 新增
- 页面新增治理规则区: 质量评分仪表盘 + 7规则红黄绿状态表 + 异常明细展开 + 重新检查按钮 + 实体筛选
2026-08-30 07:26:59 +08:00
Hermes CI Fix 240a9fa899 fix: 应收预警重复生成Bug — 按plan_id去重替代全文匹配
根因: 到期未收款预警消息含'已逾期X天'动态文本, _exists()按全文匹配
  每天逾期天数变化→消息不同→去重永远失败→每天生成重复预警(68条)

修复: 到期未收款循环改为按plan_id查已有pending预警去重
验证: 连续触发2次均新增0条

附带修复:
- 清理159条金额单位错误预警(元→万元, 40980万→4.1万)
- 删除137条budget_sync虚拟收付款计划
- 作废129条孤儿预警(指向已删除plan)
- 清理68条重复预警
2026-08-30 07:08:20 +08:00
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
194 changed files with 27598 additions and 1197 deletions
+3
View File
@@ -11,3 +11,6 @@ __pycache__/
*.tsbuildinfo
venv/
backend/logs/
# 因果链验证报告(生成物)
backend/scripts/reports/
+20 -10
View File
@@ -22,6 +22,16 @@ steps:
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:
@@ -32,6 +42,15 @@ steps:
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:
@@ -49,15 +68,6 @@ steps:
- SSH_DEPLOY_KEY
commands:
- *ssh_setup
- ssh root@git.sxbh.ltd '
cd /root/cma-management &&
git pull origin main &&
cd backend &&
pip install -r requirements.txt --quiet --no-cache-dir &&
pkill -f uvicorn 2>/dev/null
sleep 2
cd /root/cma-management/backend &&
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8010 > /var/log/cma-backend.log 2>&1 &
'
- 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/**
BIN
View File
Binary file not shown.
+66 -3
View File
@@ -7,8 +7,10 @@ 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
from app.models import ActionPlan, KPIAlert, KPIDefinition, KPIValue, User, Objective, KR
from app.api.verify import build_auto_verify_rule
logger = logging.getLogger("cma.action_plans")
@@ -63,6 +65,7 @@ def plan_to_dict(p: ActionPlan) -> dict:
"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,
@@ -86,11 +89,13 @@ 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)
@@ -98,6 +103,8 @@ def list_plans(
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":
@@ -113,6 +120,16 @@ 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}
@@ -123,12 +140,27 @@ 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
@@ -143,6 +175,7 @@ def create_plan(
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"),
@@ -151,7 +184,22 @@ def create_plan(
status="pending",
progress=0,
created_by=current_user.name or current_user.username,
auto_verify_rule=data.get("auto_verify_rule"), # 显式规则原样保存;None 时下面自动生成
)
# 第三阶段接入(2026-08-31):创建 ActionPlan 自动生成验证规则
# 请求体未传 auto_verify_rule 且关联 KPI 存在 → 复用 verify.build_auto_verify_rule 生成默认规则(不改变现有创建行为;auto_close 默认 false
if not data.get("auto_verify_rule"):
baseline_value = data.get("baseline_value")
if baseline_value is None:
# 基线缺省时取 KPI 当前最新值(period <= 当前月),供 kpi_current_before 回填
latest_kpi = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_ent.id,
KPIValue.actual_value.isnot(None),
KPIValue.period <= datetime.now().strftime("%Y-%m"),
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
if latest_kpi:
baseline_value = latest_kpi.actual_value
plan.auto_verify_rule = build_auto_verify_rule(kpi_ent, baseline_value=baseline_value)
db.add(plan)
db.commit()
db.refresh(plan)
@@ -207,6 +255,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:
+263 -13
View File
@@ -2,20 +2,247 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, text as sa_text
from sqlalchemy import func, text as sa_text, or_
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, KPIAlert, StrategicMap, User, ActionPlan
from app.models import KPIDefinition, KPIValue, KPIAlert, StrategicMap, User, ActionPlan, BudgetPlan, AISuggestion
from app.utils.cache import get as cache_get, set as cache_set
import json, hashlib, httpx, os
from datetime import datetime
import json, hashlib, httpx, os, urllib.request, urllib.parse
from datetime import datetime, date
router = APIRouter(prefix="/api/cma/ai", tags=["AI分析"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ============================================================
# R1 决策建议生成(规则驱动,稳定可复现,落库 ai_suggestions
# ============================================================
def _sug_dict(s: AISuggestion) -> dict:
return {
"id": s.id,
"entity_id": s.entity_id,
"source": s.source,
"suggestion_type": s.suggestion_type,
"target_type": s.target_type,
"category": s.category or "decision",
"pushed": s.pushed or 0,
"target_id": s.target_id,
"title": s.title,
"content": s.content,
"suggestion_data": s.suggestion_data or {},
"status": s.status,
"applied_by": s.applied_by,
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
"apply_detail": s.apply_detail or [],
"created_at": s.created_at.isoformat() if s.created_at else None,
}
def _existing_unapplied(db: Session, entity_id: int, suggestion_type: str,
target_id: int, title: str) -> bool:
"""幂等:同entity+类型+目标+标题的未应用建议存在则跳过"""
return db.query(AISuggestion).filter(
AISuggestion.entity_id == entity_id,
AISuggestion.suggestion_type == suggestion_type,
AISuggestion.target_id == target_id,
AISuggestion.title == title,
AISuggestion.status == "unapplied",
).first() is not None
def generate_rule_suggestions(db: Session, entity_id: int,
source: str = "dashboard", user_id: int = None,
kpi_id: int = None) -> list:
"""从数据规则生成决策建议并落库(R1,路线图2026-08-30
规则:
1. KPI执行率<70% → 建议建行动方案(异常类)
2. KPI执行率>110% → 建议上调KPI目标(机会类)
3. 预算执行率>110% → 建议调预算(预算类)
4. 有pending预警 → 建议建行动方案处理预警
幂等:同 entity+type+target_id+title+status=unapplied 不重复建。
"""
now = datetime.now()
period = now.strftime("%Y-%m")
created = []
def _add(suggestion_type: str, target_type: str, tid: int,
title: str, content: str, suggestion_data: dict):
nonlocal created
if _existing_unapplied(db, entity_id, suggestion_type, tid, title):
return
sug = AISuggestion(
entity_id=entity_id,
user_id=user_id,
source=source,
suggestion_type=suggestion_type,
target_type=target_type,
category="alert" if target_type == "alert" else "decision",
target_id=tid,
title=title,
content=content,
suggestion_data=suggestion_data,
status="unapplied",
)
db.add(sug)
created.append(sug)
# 查询KPI(可按kpi_id过滤)
q = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active")
if kpi_id:
q = q.filter(KPIDefinition.id == kpi_id)
kpis = q.all()
for k in kpis:
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
or_(
KPIValue.entity_id == entity_id,
KPIValue.entity_id.is_(None),
),
).order_by(KPIValue.period.desc()).first()
if not latest or latest.actual_value is None:
continue
actual = latest.actual_value
target = k.target_value
ratio = (actual / target) if target else None
# 1. 异常:执行率<70% → 建行动方案
if ratio is not None and ratio < 0.7:
title = f"提升 {k.kpi_name}:达成率仅{ratio*100:.0f}%"
content = (f"KPI[{k.kpi_name}] 最新期间{latest.period}实际值{actual:g}"
f"目标{target:g},达成率{ratio*100:.1f}%,低于70%预警线。"
f"建议制定专项改善行动方案。")
_add("action_plan", "kpi", k.id, title, content, {
"kpi_id": k.id, "priority": "high",
"title": f"改善: {k.kpi_name}达成率提升",
})
# 2. 机会:执行率>110% → 上调KPI目标
elif ratio is not None and ratio > 1.1:
new_target = round(actual * 1.05, 2)
title = f"上调 {k.kpi_name} 目标:达成率{ratio*100:.0f}%超预期"
content = (f"KPI[{k.kpi_name}] 达成率{ratio*100:.1f}%超过110%"
f"建议将目标从{target:g}上调至{new_target:g},保持牵引力。")
_add("kpi_target", "kpi", k.id, title, content, {
"kpi_id": k.id, "target_value": new_target,
})
# 3. 预算执行率>110% → 调预算
budget_rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status == "active",
BudgetPlan.period == period,
).all()
for b in budget_rows:
actual = db.query(func.max(KPIValue.actual_value)).filter(
KPIValue.kpi_id == b.kpi_id,
KPIValue.period == b.period,
).scalar()
if actual is None or b.budget_value is None or b.budget_value <= 0:
continue
exec_ratio = actual / b.budget_value
if exec_ratio > 1.1:
kpi_name = "KPI"
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
if k:
kpi_name = k.kpi_name
title = f"调整 {kpi_name} 预算:执行率{exec_ratio*100:.0f}%超预算"
content = (f"预算[{kpi_name}] {period}预算值{b.budget_value:g}"
f"实际{actual:g},执行率{exec_ratio*100:.1f}%超过110%。"
f"建议同步调整预算/现金流/行动方案。")
_add("budget_adjust", "budget", b.kpi_id, title, content, {
"kpi_id": b.kpi_id, "period": period, "budget_value": round(actual, 2),
})
# 4. pending预警 → 建行动方案
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").all()
for a in alerts:
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
title = f"处理预警:{kpi_name} {a.alert_message[:30]}"
content = f"存在待处理预警({a.alert_level}级):{a.alert_message}。建议建立行动方案跟进。"
_add("action_plan", "alert", a.id, title, content, {
"kpi_id": a.kpi_id, "priority": "high" if a.alert_level == "red" else "medium",
"alert_id": a.id,
"title": f"处理预警: {kpi_name}",
})
if created:
db.commit()
for s in created:
db.refresh(s)
# R1触达修复(2026-08-31): 只对新建的决策类建议推送企微(预警类不推防噪音)
# 防轰炸: 同 title 建议幂等不重建 + pushed 标记只推一次;存量不推(只推新建)
for s in created:
if s.category == "decision" and not s.pushed:
ok = _push_decision_suggestion(s)
if ok:
s.pushed = 1
db.commit()
return created
_TYPE_LABELS = {"kpi_target": "KPI目标", "budget_adjust": "预算调整", "action_plan": "行动方案"}
def _push_decision_suggestion(s: AISuggestion) -> bool:
"""决策类建议推送到企微(8800 relay 公司群中继)
仅 decision 类;预警类不进推送流。失败不影响主流程(try/except)。
8800/send 只接受 form-encoded 参数(msg/source/msgtype/touser)
勿用 JSON bodyrelay 会返回 msg is requiredlead.py 的 JSON 写法是错的)。
"""
if getattr(s, "category", "decision") != "decision":
return False
type_label = _TYPE_LABELS.get(s.suggestion_type, s.suggestion_type)
content = (
f"## 📌 AI决策建议\n"
f"**{s.title}**\n"
f"{str(s.content or '')[:120]}\n"
f"类型标签: {type_label}\n"
f"---\n"
f"{datetime.now().strftime('%Y-%m-%d %H:%M')}"
)
try:
data = urllib.parse.urlencode({
"msg": content,
"source": "管理会计OS",
"msgtype": "markdown",
}).encode("utf-8")
req = urllib.request.Request(
"http://127.0.0.1:8800/send",
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
body = resp.read().decode("utf-8")
return '"ok": true' in body or '"ok":true' in body
except Exception:
return False
def _unapplied_suggestions(db: Session, entity_id: int, limit: int = 20) -> list:
items = db.query(AISuggestion).filter(
AISuggestion.entity_id == entity_id,
AISuggestion.status == "unapplied",
).order_by(AISuggestion.created_at.desc()).limit(limit).all()
return [_sug_dict(s) for s in items]
def _require_deepseek_key() -> str:
"""强制从环境变量读取 DeepSeek Key,禁止硬编码默认值(安全修复 2026-08-31)"""
api_key = os.getenv("DEEPSEEK_API_KEY")
if not api_key:
raise HTTPException(503, "DEEPSEEK_API_KEY 未配置(禁止硬编码默认key,安全修复 2026-08-31")
return api_key
async def _call_deepseek(prompt: str) -> str:
"""调用DeepSeek API"""
api_key = os.getenv("DEEPSEEK_API_KEY", "sk-8e24e6eb87f2475e96ea0980002dc2e8")
api_key = _require_deepseek_key()
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
"https://api.deepseek.com/v1/chat/completions",
@@ -34,15 +261,23 @@ async def _call_deepseek(prompt: str) -> str:
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
@router.get("/dashboard-analysis")
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db)):
async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""AI分析驾驶舱数据"""
# 尝试缓存
cache_key = f"dashboard_analysis:{role}"
cache_key = f"dashboard_analysis:{role}:{entity_id}"
cached = cache_get("ai", cache_key)
if cached:
# 缓存命中(LLM文本10分钟内不重复调用),但轻量规则建议仍执行(幂等)
try:
generate_rule_suggestions(db, entity_id, source="dashboard")
except Exception:
pass
cached["suggestions"] = _unapplied_suggestions(db, entity_id)
return cached
# 获取当前KPI数据
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
kpis = db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active").all()
kpi_summary = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
@@ -81,17 +316,25 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
except Exception as e:
analysis = f"AI分析暂时不可用: {str(e)}"
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts}
# R1: 规则驱动生成可落地决策建议(幂等落库)
try:
generate_rule_suggestions(db, entity_id, source="dashboard")
except Exception as e:
pass
result = {"analysis": analysis, "kpi_count": len(kpi_summary), "alert_count": alerts,
"suggestions": _unapplied_suggestions(db, entity_id)}
# 缓存10分钟
cache_set("ai", cache_key, result, ttl_seconds=600)
return result
@router.get("/kpi-analysis/{kpi_id}")
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db)):
async def kpi_analysis(kpi_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""AI分析单个KPI"""
# 尝试缓存
cache_key = f"kpi_analysis:{kpi_id}"
cache_key = f"kpi_analysis:{kpi_id}:{entity_id}"
cached = cache_get("ai", cache_key)
if cached:
return cached
@@ -129,7 +372,14 @@ KPI名称:{kpi.kpi_name}
except Exception as e:
analysis = f"分析暂时不可用: {str(e)}"
result = {"kpi_name": kpi.kpi_name, "analysis": analysis}
# R1: 生成该KPI的可落地建议
try:
generate_rule_suggestions(db, entity_id, source="kpi", kpi_id=kpi_id)
except Exception as e:
pass
result = {"kpi_name": kpi.kpi_name, "analysis": analysis,
"suggestions": _unapplied_suggestions(db, entity_id)}
cache_set("ai", cache_key, result, ttl_seconds=600)
return result
@@ -141,7 +391,7 @@ async def _stream_analysis(prompt: str):
"POST",
"https://api.deepseek.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY', 'sk-8e24e6eb87f2475e96ea0980002dc2e8')}",
"Authorization": f"Bearer {_require_deepseek_key()}",
"Content-Type": "application/json",
},
json={
+396
View File
@@ -0,0 +1,396 @@
"""AI决策建议 — 一键应用到KPI/预算/行动方案 (路线图R1 2026-08-30)
北极星④决策闭环:AI建议 → 点击应用 → 写库变更 → OperationLog留痕 → 前端可查已应用/未应用。
应用动作复用现有 kpis/budget/action_plans 数据模型,不新建业务接口。
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from datetime import datetime
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 AISuggestion, KPIDefinition, KPIValue, OperationLog, BudgetPlan, ActionPlan
router = APIRouter(prefix="/api/cma/ai/suggestions", tags=["AI建议"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def _sug_dict(s: AISuggestion) -> dict:
return {
"id": s.id,
"entity_id": s.entity_id,
"user_id": s.user_id,
"source": s.source,
"suggestion_type": s.suggestion_type,
"target_type": s.target_type,
"category": s.category or "decision",
"pushed": s.pushed or 0,
"target_id": s.target_id,
"title": s.title,
"content": s.content,
"suggestion_data": s.suggestion_data or {},
"status": s.status,
"applied_by": s.applied_by,
"applied_at": s.applied_at.isoformat() if s.applied_at else None,
"apply_detail": s.apply_detail or [],
"created_at": s.created_at.isoformat() if s.created_at else None,
}
@router.post("")
def create_suggestion(
request: Request,
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""创建AI建议(前端AI分析/手动保存建议)"""
suggestion_type = data.get("suggestion_type") or data.get("type")
title = (data.get("title") or "").strip()
if not suggestion_type:
raise HTTPException(400, "缺少 suggestion_type (kpi_target/budget_adjust/action_plan)")
if not title:
raise HTTPException(400, "缺少 title")
entity_id = resolve_entity_for_request(request, data.get("entity_id") or 1)
sug = AISuggestion(
entity_id=entity_id,
user_id=getattr(current_user, "id", None),
source=data.get("source", "manual"),
suggestion_type=suggestion_type,
target_type=data.get("target_type", "kpi"),
category="alert" if data.get("target_type") == "alert" else data.get("category", "decision"),
target_id=data.get("target_id"),
title=title,
content=data.get("content"),
suggestion_data=data.get("suggestion_data") or {},
status="unapplied",
)
db.add(sug)
db.commit()
db.refresh(sug)
return {"success": True, "message": "建议已保存", "data": _sug_dict(sug)}
@router.get("")
def list_suggestions(
status: Optional[str] = Query(None, description="unapplied/applied/dismissed"),
suggestion_type: Optional[str] = Query(None),
category: Optional[str] = Query(None, description="decision/alert 建议分类过滤"),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""建议列表(前端建议中心/详情页查看已应用/未应用状态)"""
query = db.query(AISuggestion).filter(AISuggestion.entity_id == entity_id)
if status:
query = query.filter(AISuggestion.status == status)
if suggestion_type:
query = query.filter(AISuggestion.suggestion_type == suggestion_type)
if category:
query = query.filter(AISuggestion.category == category)
items = query.order_by(AISuggestion.created_at.desc()).limit(200).all()
return {"data": [_sug_dict(s) for s in items], "total": len(items)}
@router.get("/{suggestion_id}")
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db)):
"""建议详情"""
s = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not s:
raise HTTPException(404, "建议不存在")
return {"data": _sug_dict(s)}
def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
"""改KPI目标"""
target_value = params.get("target_value")
if target_value is None:
raise HTTPException(400, "应用kpi_target需要 target_value")
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
before = kpi.target_value
kpi.target_value = float(target_value)
db.flush()
detail_item = {
"target_type": "kpi",
"target_id": kpi.id,
"target_name": kpi.kpi_name,
"action": "update_target_value",
"before": before,
"after": float(target_value),
}
db.add(OperationLog(
user_id=getattr(current_user, "id", None),
action="ai_suggestion_apply",
target_type="kpi",
target_id=kpi.id,
detail={
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "kpi_target",
"before": before,
"after": float(target_value),
},
))
return detail_item
def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
"""调预算(BudgetPlan upsert,按 kpi_id+period"""
period = params.get("period")
budget_value = params.get("budget_value")
if not period or budget_value is None:
raise HTTPException(400, "应用budget_adjust需要 period + budget_value")
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
# 解析期间 2026-09 → year=2026 month=9
try:
parts = period.split("-")
year = int(parts[0])
month = int(parts[1])
except Exception:
raise HTTPException(400, f"period格式错误: {period} (需要 YYYY-MM)")
plan = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == sug.entity_id,
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.budget_year == year,
BudgetPlan.budget_month == month,
BudgetPlan.status == "active",
).first()
before = None
if plan:
before = plan.budget_value
plan.budget_value = float(budget_value)
else:
plan = BudgetPlan(
entity_id=sug.entity_id,
kpi_id=kpi_id,
period=period,
budget_value=float(budget_value),
budget_year=year,
budget_month=month,
version="v1.0",
status="active",
source_type="ai_suggestion",
calc_logic=f"AI建议应用 #{sug.id}: {sug.title}",
created_by=getattr(current_user, "name", "") or "",
)
db.add(plan)
db.flush()
detail_item = {
"target_type": "budget",
"target_id": plan.id,
"target_name": f"{kpi.kpi_name}[{period}]",
"action": "update_budget" if before is not None else "create_budget",
"before": before,
"after": float(budget_value),
}
db.add(OperationLog(
user_id=getattr(current_user, "id", None),
action="ai_suggestion_apply",
target_type="budget",
target_id=plan.id,
detail={
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "budget_adjust",
"kpi_id": kpi_id,
"period": period,
"before": before,
"after": float(budget_value),
},
))
return detail_item
def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_user) -> dict:
"""建行动方案"""
title = (params.get("title") or "").strip() or sug.title
kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id:
raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
due_date = None
if params.get("due_date"):
try:
due_date = datetime.strptime(str(params["due_date"])[:10], "%Y-%m-%d")
except Exception:
due_date = None
plan = ActionPlan(
kpi_id=kpi_id,
title=title,
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
assignee=params.get("assignee") or "",
priority=params.get("priority") or "medium",
due_date=due_date,
status="pending",
progress=0,
created_by=getattr(current_user, "name", "") or "ai_suggestion",
)
db.add(plan)
db.flush()
detail_item = {
"target_type": "action_plan",
"target_id": plan.id,
"target_name": title,
"action": "create_action_plan",
"before": None,
"after": plan.id,
}
db.add(OperationLog(
user_id=getattr(current_user, "id", None),
action="ai_suggestion_apply",
target_type="action_plan",
target_id=plan.id,
detail={
"suggestion_id": sug.id,
"suggestion_title": sug.title,
"apply_action": "action_plan",
"kpi_id": kpi_id,
"plan_title": title,
},
))
return detail_item
_APPLYERS = {
"kpi_target": _apply_kpi_target,
"budget_adjust": _apply_budget_adjust,
"action_plan": _apply_action_plan,
}
@router.get("/{suggestion_id}/preview")
def preview_suggestion(suggestion_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""应用前预览:将变更什么(当前值 → 新值),建立信任 (R1触达修复 2026-08-31)
- kpi_target: {kpi_name, current_target, new_target}
- budget_adjust:{kpi_name, period, current_budget, new_budget}
- action_plan: {kpi_name, plan_title, assignee, priority, due_date}
"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
sd = sug.suggestion_data or {}
kpi = None
kpi_id = sd.get("kpi_id") or sug.target_id
if kpi_id:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if sug.suggestion_type == "kpi_target":
return {"data": {
"type": "kpi_target",
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
"current_target": kpi.target_value if kpi else None,
"new_target": sd.get("target_value"),
}}
if sug.suggestion_type == "budget_adjust":
period = sd.get("period") or sug.target_type
current_budget = None
if kpi and period:
bp = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == sug.entity_id,
BudgetPlan.kpi_id == kpi.id,
BudgetPlan.period == period,
BudgetPlan.status == "active",
).order_by(BudgetPlan.id.desc()).first()
current_budget = bp.budget_value if bp else None
return {"data": {
"type": "budget_adjust",
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
"period": period,
"current_budget": current_budget,
"new_budget": sd.get("budget_value"),
}}
# action_plan
return {"data": {
"type": "action_plan",
"kpi_name": kpi.kpi_name if kpi else "KPI#" + str(kpi_id),
"plan_title": sd.get("title") or sug.title,
"assignee": sd.get("assignee") or "",
"priority": sd.get("priority") or "medium",
"due_date": sd.get("due_date") or "",
}}
@router.post("/{suggestion_id}/apply")
def apply_suggestion(
suggestion_id: int,
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
Body 示例:
{"action": "kpi_target", "target_value": 2000000}
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
if sug.status == "applied":
raise HTTPException(400, "该建议已应用,不能重复应用")
if sug.status == "dismissed":
raise HTTPException(400, "该建议已忽略,如需应用请重新创建")
action = data.get("action") or sug.suggestion_type
applier = _APPLYERS.get(action)
if not applier:
raise HTTPException(400, f"不支持的应用动作: {action} (支持 kpi_target/budget_adjust/action_plan)")
# 应用参数 = 请求体参数 覆盖 建议默认参数
params = dict(sug.suggestion_data or {})
params.update({k: v for k, v in data.items() if k != "action" and v is not None})
detail_item = applier(db, sug, params, current_user)
sug.status = "applied"
sug.applied_by = getattr(current_user, "name", "") or ""
sug.applied_user_id = getattr(current_user, "id", None)
sug.applied_at = datetime.now()
sug.apply_detail = [detail_item]
db.commit()
db.refresh(sug)
return {
"success": True,
"message": "建议已应用并留痕",
"data": _sug_dict(sug),
}
@router.post("/{suggestion_id}/dismiss")
def dismiss_suggestion(
suggestion_id: int,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""忽略建议"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
if not sug:
raise HTTPException(404, "建议不存在")
sug.status = "dismissed"
db.commit()
return {"success": True, "message": "建议已忽略"}
+151 -49
View File
@@ -11,6 +11,7 @@ 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 KPIDefinition, KPIValue, KPIAlert, OperationLog
@@ -23,6 +24,7 @@ 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 — 实际值/预测值/两者触发")
@@ -39,6 +41,7 @@ class DynamicThresholdCache(Base):
"""动态阈值缓存 — 存储近3个月历史统计"""
__tablename__ = "dynamic_threshold_cache"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
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月均值")
@@ -64,9 +67,10 @@ def list_alert_rules(
rule_type: Optional[str] = None,
enabled: Optional[int] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""列出所有预警规则"""
query = db.query(AlertRule)
"""列出所有预警规则(账套隔离: 按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:
@@ -88,15 +92,16 @@ def list_alert_rules(
@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()
def get_kpi_rules(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取单个KPI的所有预警规则(账套隔离: 按token企业)"""
rules = db.query(AlertRule).filter(
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_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"))):
"""创建预警规则"""
def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(require_role("ceo", "finance", "it")), entity_id: int = Depends(get_entity_id)):
"""创建预警规则(账套隔离: 写入token企业, 2026-08-31 安全修复)"""
kpi_id = data.get("kpi_id")
rule_type = data.get("rule_type", "static")
trigger_on = data.get("trigger_on", "actual")
@@ -104,10 +109,13 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
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"):
if kpi.entity_id != entity_id:
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(
entity_id=entity_id,
kpi_id=kpi_id,
rule_type=rule_type,
trigger_on=trigger_on,
@@ -129,9 +137,9 @@ def create_alert_rule(data: dict, db: Session = Depends(get_db), user=Depends(re
@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()
def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""更新预警规则(账套隔离: 禁止跨企业修改)"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id, AlertRule.entity_id == entity_id).first()
if not rule:
raise HTTPException(404, "预警规则不存在")
@@ -144,9 +152,9 @@ def update_alert_rule(rule_id: int, data: dict, db: Session = Depends(get_db)):
@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()
def delete_alert_rule(rule_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""删除预警规则(账套隔离: 禁止跨企业删除)"""
rule = db.query(AlertRule).filter(AlertRule.id == rule_id, AlertRule.entity_id == entity_id).first()
if rule:
db.delete(rule)
db.commit()
@@ -154,8 +162,8 @@ def delete_alert_rule(rule_id: int, db: Session = Depends(get_db)):
@router.post("/batch")
def batch_create_rules(data: dict, db: Session = Depends(get_db)):
"""批量创建预警规则
def batch_create_rules(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""批量创建预警规则(账套隔离: 全部写入token企业)
data.rules: [{"kpi_id": id, "rule_type": "static", "params": {...}}, ...]
"""
rules_data = data.get("rules", [])
@@ -163,14 +171,19 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db)):
for rule_data in rules_data:
kpi_id = rule_data.get("kpi_id")
rule_type = rule_data.get("rule_type", "static")
# 检查是否已存在相同类型的规则
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi or kpi.entity_id != entity_id:
continue
# 检查是否已存在相同类型的规则(同企业内)
existing = db.query(AlertRule).filter(
AlertRule.kpi_id == kpi_id,
AlertRule.rule_type == rule_type,
AlertRule.entity_id == entity_id,
).first()
if existing:
continue
rule = AlertRule(
entity_id=entity_id,
kpi_id=kpi_id,
rule_type=rule_type,
enabled=rule_data.get("enabled", 1),
@@ -183,15 +196,17 @@ def batch_create_rules(data: dict, db: Session = Depends(get_db)):
@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()
def generate_default_rules(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""当前企业尚未配置预警规则的KPI生成默认规则(账套隔离 2026-08-31"""
# 找到当前企业所有active KPI
all_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
created = 0
for kpi in all_kpis:
# 检查是否已有任何规则
existing = db.query(AlertRule).filter(AlertRule.kpi_id == kpi.id).first()
# 检查是否已有任何规则(同企业内)
existing = db.query(AlertRule).filter(
AlertRule.kpi_id == kpi.id, AlertRule.entity_id == entity_id).first()
if existing:
continue
@@ -200,6 +215,7 @@ def generate_default_rules(db: Session = Depends(get_db)):
# 1. 静态阈值规则(基于kpi_definitions的阈值)
if kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red:
rule = AlertRule(
entity_id=entity_id,
kpi_id=kpi_id,
rule_type="static",
enabled=1,
@@ -214,6 +230,7 @@ def generate_default_rules(db: Session = Depends(get_db)):
# 2. 动态趋势规则(所有KPI默认加 trend_down
rule2 = AlertRule(
entity_id=entity_id,
kpi_id=kpi_id,
rule_type="trend_down",
enabled=1,
@@ -227,9 +244,10 @@ def generate_default_rules(db: Session = Depends(get_db)):
@router.post("/check-all")
def run_all_alert_checks(db: Session = Depends(get_db)):
"""执行所有KPI的预警检查 — 生成新的预警记录"""
rules = db.query(AlertRule).filter(AlertRule.enabled == 1).all()
def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31"""
rules = db.query(AlertRule).filter(
AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all()
kpi_cache = {}
value_cache = {}
@@ -243,7 +261,7 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
if kpi:
kpi_cache[rule.kpi_id] = kpi
if not kpi:
if not kpi or kpi.entity_id != entity_id:
continue
# 获取最新值
@@ -285,6 +303,7 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
).first()
if not existing_alert:
alert = KPIAlert(
entity_id=entity_id,
kpi_id=rule.kpi_id,
kpi_value_id=latest_value.id,
alert_level=alert_level,
@@ -303,9 +322,9 @@ def run_all_alert_checks(db: Session = Depends(get_db)):
@router.get("/dynamic-thresholds")
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db)):
"""获取动态阈值缓存"""
query = db.query(DynamicThresholdCache)
def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取动态阈值缓存(账套隔离 2026-08-31"""
query = db.query(DynamicThresholdCache).filter(DynamicThresholdCache.entity_id == entity_id)
if kpi_id:
query = query.filter(DynamicThresholdCache.kpi_id == kpi_id)
cache = query.order_by(DynamicThresholdCache.id.desc()).limit(50).all()
@@ -313,9 +332,10 @@ def get_dynamic_thresholds(kpi_id: Optional[int] = None, db: Session = Depends(g
@router.post("/calculate-dynamic")
def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
"""计算所有KPI的动态阈值(基于近3个月历史均值±标准差"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
def calculate_dynamic_thresholds(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""计算当前企业所有KPI的动态阈值(账套隔离 2026-08-31"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).all()
current_period = datetime.now().strftime("%Y-%m")
computed = 0
@@ -348,8 +368,9 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
dynamic_yellow = f">={mean_val:.2f}"
dynamic_red = f"<{mean_val:.2f}"
# 检查是否已有缓存
# 检查是否已有缓存(同企业内)
existing = db.query(DynamicThresholdCache).filter(
DynamicThresholdCache.entity_id == entity_id,
DynamicThresholdCache.kpi_id == kpi.id,
DynamicThresholdCache.period == current_period,
).first()
@@ -362,6 +383,7 @@ def calculate_dynamic_thresholds(db: Session = Depends(get_db)):
existing.dynamic_red = dynamic_red
else:
cache = DynamicThresholdCache(
entity_id=entity_id,
kpi_id=kpi.id,
period=current_period,
mean_value=mean_val,
@@ -397,7 +419,8 @@ def _check_static(value: float, params: dict, kpi) -> tuple:
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):
elif red and _eval_threshold(value, red):
# red 阈值字面即命中条件(如 "<600" = 低于600触发红灯;">25" = 高于25触发红灯)
return ("red", f"[静态] {kpi.kpi_name}={value}, 红灯{red}")
return (None, None)
@@ -466,16 +489,17 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
try:
if threshold_str.startswith(">="):
limit = float(threshold_str[2:])
return value >= limit if not invert else value >= limit
# invert=True 时取反:命中 = 值低于阈值(低于下限触发红灯等场景)
return value < limit if invert else value >= limit
elif threshold_str.startswith("<="):
limit = float(threshold_str[2:])
return value <= limit if not invert else value <= limit
return value > limit if invert else value <= limit
elif threshold_str.startswith(">"):
limit = float(threshold_str[1:])
return value > limit if not invert else value > limit
return value <= limit if invert else value > limit
elif threshold_str.startswith("<"):
limit = float(threshold_str[1:])
return value < limit if not invert else value < limit
return value >= limit if invert else value < limit
else:
return False
except (ValueError, TypeError):
@@ -486,13 +510,14 @@ def _eval_threshold(value: float, threshold_str: str, invert: bool = False) -> b
# 预测值检查 + 情景建议
# ============================================================
def _check_forecast_alerts(db: Session) -> int:
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast''both'的规则"""
def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
"""检查未来7天预测值是否超限 — 针对trigger_on='forecast''both'的规则(账套隔离 2026-08-31"""
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.entity_id == entity_id,
AlertRule.trigger_on.in_(["forecast", "both"]),
).all()
@@ -509,11 +534,10 @@ def _check_forecast_alerts(db: Session) -> int:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == rule.kpi_id).first()
if kpi:
rule_kpi_cache[rule.kpi_id] = kpi
if not kpi:
if not kpi or kpi.entity_id != entity_id:
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()
@@ -559,6 +583,7 @@ def _check_forecast_alerts(db: Session) -> int:
).first()
if not existing:
alert = KPIAlert(
entity_id=entity_id,
kpi_id=rule.kpi_id,
alert_level=alert_level,
alert_message=alert_message,
@@ -577,18 +602,19 @@ def _check_forecast_alerts(db: Session) -> int:
@router.post("/check-forecast")
def run_forecast_alert_check(db: Session = Depends(get_db)):
"""执行预测值预警检查 — 检查未来7天预测值是否超限"""
generated = _check_forecast_alerts(db)
def run_forecast_alert_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31"""
generated = _check_forecast_alerts(db, entity_id=entity_id)
return {"message": f"预测值预警检查完成: 生成{generated}", "generated": generated}
@router.post("/generate-suggestions")
def generate_alert_suggestions(db: Session = Depends(get_db)):
"""为所有未处理的预警生成情景建议"""
def generate_alert_suggestions(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""当前企业所有未处理的预警生成情景建议(账套隔离 2026-08-31"""
from app.utils.cash_forecast_engine import generate_scenario_suggestion
pending = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
KPIAlert.suggestion.is_(None),
).all()
@@ -616,3 +642,79 @@ def generate_alert_suggestions(db: Session = Depends(get_db)):
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(
entity_id=entity_id,
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}
+8 -5
View File
@@ -2,8 +2,9 @@
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, ActionPlan
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
import logging
logger = logging.getLogger("cma.alerts")
@@ -13,8 +14,9 @@ router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
)
@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()
@@ -22,8 +24,9 @@ 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)):
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
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"
+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
+6 -2
View File
@@ -79,8 +79,12 @@ def register(data: dict, db: Session = Depends(get_db)):
)
db.add(user)
db.commit()
# 新用户默认授予所有active企业
_ensure_default_grants(db, user.id)
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": "注册成功"}
+4 -3
View File
@@ -10,6 +10,7 @@ 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
@@ -126,9 +127,9 @@ def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_
# ============================================================
@router.get("")
def list_reports(db: Session = Depends(get_db)):
"""获取用户保存的报表"""
reports = db.query(BiReport).order_by(BiReport.updated_at.desc()).all()
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}
+241 -46
View File
@@ -9,14 +9,18 @@ from sqlalchemy import func, desc
from datetime import datetime
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.models import (
User, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
User, UserEntity, StrategicMap, KPIDefinition, KPITemplate, KPIValue,
DataSourceConfig, KPIAlert, OperationLog, NotificationChannel,
NotificationLog, RolePermission, ActionPlan, OrgNode,
StrategicMapVersion, MapObjective, Objective,
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")
@@ -77,6 +81,7 @@ def _model_dict(obj, fields: dict):
# ═══════════════ 端点 ═══════════════
@router.get("/ping")
@risk_level("L1")
def ping():
return {"status": "ok", "version": "1.0", "timestamp": datetime.now().isoformat()}
@@ -84,23 +89,26 @@ def ping():
# ── 总览 ──
@router.get("/overview")
@risk_level("L1")
def bot_overview(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""系统总览 — BOT首选入口"""
"""系统总览 — BOT首选入口(账套隔离 2026-08-31: 仅统计当前企业)"""
return {
"bot": bot,
"entity_id": entity_id,
"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,
"kpis_total": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar() or 0,
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id).scalar() or 0,
"maps_total": db.query(func.count(StrategicMap.id)).filter(StrategicMap.entity_id == entity_id).scalar() or 0,
"budget_plans": db.query(func.count(BudgetPlan.id)).filter(BudgetPlan.entity_id == entity_id).scalar() or 0,
"action_plans_pending": db.query(func.count(ActionPlan.id)).filter(ActionPlan.entity_id == entity_id, ActionPlan.status.in_(["pending", "in_progress"])).scalar() or 0,
"data_sources": db.query(func.count(DataSourceConfig.id)).filter(DataSourceConfig.entity_id == entity_id).scalar() or 0,
"users": db.query(func.count(User.id)).join(UserEntity, UserEntity.user_id == User.id).filter(UserEntity.entity_id == entity_id).scalar() or 0,
"org_nodes": db.query(func.count(OrgNode.id)).filter(OrgNode.entity_id == entity_id).scalar() or 0,
}
}
@@ -108,14 +116,16 @@ def bot_overview(
# ── 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),
entity_id: int = Depends(get_entity_id),
):
query = db.query(KPIDefinition).filter(KPIDefinition.status == status)
query = db.query(KPIDefinition).filter(KPIDefinition.status == status, KPIDefinition.entity_id == entity_id)
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
kpis = query.order_by(KPIDefinition.dimension, KPIDefinition.kpi_code).limit(limit).all()
@@ -134,7 +144,7 @@ def bot_kpis(
"threshold_yellow": k.threshold_yellow,
"threshold_red": k.threshold_red,
"responsible_dept": k.responsible_dept, "owner": k.responsible_user,
"objective": k.objective, "description": k.description,
"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,
@@ -143,13 +153,15 @@ def bot_kpis(
@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),
entity_id: int = Depends(get_entity_id),
):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
if not kpi or kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id)\
.order_by(KPIValue.period.desc()).limit(limit).all()
@@ -169,11 +181,13 @@ def bot_kpi_history(
# ── 战略地图 ──
@router.get("/strategic-maps")
@risk_level("L1")
def bot_maps(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
maps = db.query(StrategicMap).order_by(StrategicMap.id.desc()).all()
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.id.desc()).all()
result = []
for m in maps:
objectives = db.query(MapObjective).filter(MapObjective.map_id == m.id).all()
@@ -196,14 +210,16 @@ def bot_maps(
# ── 预警 ──
@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),
entity_id: int = Depends(get_entity_id),
):
query = db.query(KPIAlert)
query = db.query(KPIAlert).filter(KPIAlert.entity_id == entity_id)
query = query.filter(KPIAlert.status == status)
if level:
query = query.filter(KPIAlert.alert_level == level)
@@ -226,12 +242,14 @@ def bot_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),
entity_id: int = Depends(get_entity_id),
):
query = db.query(BudgetPlan)
query = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id)
if year:
query = query.filter(BudgetPlan.budget_year == year)
plans = query.order_by(BudgetPlan.period.desc()).limit(200).all()
@@ -253,11 +271,13 @@ def bot_budget_plans(
# ── 成本 ──
@router.get("/cost/standard")
@risk_level("L1")
def bot_standard_costs(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
costs = db.query(StandardCost).filter(StandardCost.status == "active").limit(200).all()
costs = db.query(StandardCost).filter(StandardCost.status == "active", StandardCost.entity_id == entity_id).limit(200).all()
return {
"total": len(costs),
"items": [
@@ -276,12 +296,14 @@ def bot_standard_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),
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)
costs = query.order_by(ActualCost.period.desc()).limit(200).all()
@@ -304,12 +326,14 @@ def bot_actual_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),
entity_id: int = Depends(get_entity_id),
):
query = db.query(ActionPlan)
query = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id)
if status:
query = query.filter(ActionPlan.status == status)
plans = query.order_by(ActionPlan.priority, ActionPlan.id.desc()).limit(100).all()
@@ -331,11 +355,13 @@ def bot_actions(
# ── 组织 ──
@router.get("/organization")
@risk_level("L1")
def bot_org(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
nodes = db.query(OrgNode).order_by(OrgNode.level, OrgNode.sort_order).all()
nodes = db.query(OrgNode).filter(OrgNode.entity_id == entity_id).order_by(OrgNode.level, OrgNode.sort_order).all()
return {
"total": len(nodes),
"items": [
@@ -352,11 +378,13 @@ def bot_org(
# ── 数据源 ──
@router.get("/data-sources")
@risk_level("L1")
def bot_data_sources(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
sources = db.query(DataSourceConfig).all()
sources = db.query(DataSourceConfig).filter(DataSourceConfig.entity_id == entity_id).all()
return {
"total": len(sources),
"items": [
@@ -375,11 +403,15 @@ def bot_data_sources(
# ── 用户 ──
@router.get("/users")
@risk_level("L1")
def bot_users(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
users = db.query(User).all()
# 用户是全局实体,通过 user_entities 授权表按企业过滤(安全修复 2026-08-31)
users = db.query(User).join(UserEntity, UserEntity.user_id == User.id)\
.filter(UserEntity.entity_id == entity_id).all()
return {
"total": len(users),
"items": [
@@ -393,24 +425,26 @@ def bot_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),
entity_id: int = Depends(get_entity_id),
):
"""统一查询入口 — BOT用这个一次拿完需要的数据"""
result = {"bot": bot["name"], "role": bot["role"], "timestamp": datetime.now().isoformat()}
"""统一查询入口 — BOT用这个一次拿完需要的数据(账套隔离 2026-08-31"""
result = {"bot": bot["name"], "role": bot["role"], "entity_id": entity_id, "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,
"kpis": db.query(func.count(KPIDefinition.id)).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).scalar() or 0,
"alerts_open": db.query(func.count(KPIAlert.id)).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id).scalar() or 0,
"maps": db.query(func.count(StrategicMap.id)).filter(StrategicMap.entity_id == entity_id).scalar() or 0,
"budget_plans": db.query(func.count(BudgetPlan.id)).filter(BudgetPlan.entity_id == entity_id).scalar() or 0,
}
if q in ("kpis", "all"):
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").limit(100).all()
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id).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}
@@ -418,7 +452,7 @@ def bot_query(
]
if q in ("alerts", "all"):
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending")\
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.entity_id == entity_id)\
.order_by(KPIAlert.created_at.desc()).limit(20).all()
result["alerts"] = [
{"id": a.id, "level": a.alert_level, "message": a.alert_message,
@@ -427,7 +461,7 @@ def bot_query(
]
if q in ("maps", "all"):
maps = db.query(StrategicMap).limit(10).all()
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).limit(10).all()
result["maps"] = [
{"id": m.id, "title": m.title, "status": m.status,
"version": m.version, "created_at": _safe_iso(m.created_at)}
@@ -435,7 +469,7 @@ def bot_query(
]
if q in ("budget", "all"):
plans = db.query(BudgetPlan).limit(50).all()
plans = db.query(BudgetPlan).filter(BudgetPlan.entity_id == entity_id).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,
@@ -444,7 +478,7 @@ def bot_query(
]
if q in ("cost", "all"):
sc = db.query(StandardCost).limit(50).all()
sc = db.query(StandardCost).filter(StandardCost.entity_id == entity_id).limit(50).all()
result["costs"] = [
{"id": c.id, "product": c.product_name, "type": c.cost_type,
"standard": _float(c.standard_cost), "unit": c.unit}
@@ -452,22 +486,24 @@ def bot_query(
]
if q in ("okr", "all"):
objs = db.query(Objective).filter(Objective.status == "active").all()
objs = db.query(Objective).filter(Objective.status == "active", Objective.entity_id == entity_id).all()
result["okr"] = []
for o in objs:
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == o.id).all()
# 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}
{"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()
acts = db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).limit(30).all()
result["actions"] = [
{"id": a.id, "title": a.title, "status": a.status,
"progress": a.progress, "assignee": a.assignee}
@@ -478,12 +514,14 @@ def bot_query(
@router.post("/import")
@risk_level("L3")
def bot_import_excel(
file: UploadFile = File(...),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""Bot上传Excel导入KPI数据到CMA"""
"""Bot上传Excel导入KPI数据到CMA(账套隔离 2026-08-31: 仅导入当前企业KPI"""
import pandas as pd, io, hashlib
from app.models import KPIValue
try:
@@ -527,9 +565,12 @@ def bot_import_excel(
if not kpi:
errors.append(f"{idx+2}行: KPI编码 '{kpi_code}' 不存在,跳过")
continue
if kpi.entity_id != entity_id:
errors.append(f"{idx+2}行: KPI编码 '{kpi_code}' 不属于当前企业(entity={kpi.entity_id}),跳过")
continue
kv = KPIValue(kpi_id=kpi.id, period=period, actual_value=val,
batch_id=hashlib.md5(f"{datetime.now()}".encode()).hexdigest()[:12])
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:
@@ -542,16 +583,18 @@ def bot_import_excel(
# ── 自然语言查询 ──
@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),
entity_id: int = Depends(get_entity_id),
):
"""Bot创建OKR目标"""
"""Bot创建OKR目标(账套隔离 2026-08-31: 写入token企业)"""
from app.models import Objective
obj = Objective(title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
obj = Objective(entity_id=entity_id, title=title, quarter=quarter, dimension=dimension, owner=bot["name"])
db.add(obj)
db.commit()
db.refresh(obj)
@@ -559,14 +602,16 @@ def bot_okr_create(
@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),
entity_id: int = Depends(get_entity_id),
):
"""Bot列出OKR(含KR进度)"""
"""Bot列出OKR(含KR进度)(账套隔离 2026-08-31"""
from app.models import Objective
q = db.query(Objective)
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()).all()
@@ -580,10 +625,12 @@ def bot_okr_list(
@router.get("/nlp")
@risk_level("L1")
def bot_nlp(
intent: str = Query("overview"),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""
自然语言意图映射:
@@ -600,4 +647,152 @@ def bot_nlp(
"okr": "okr", "目标": "okr", "季度目标": "okr",
}
resolved = m.get(intent, intent)
return bot_query(q=resolved, bot=bot, db=db)
return bot_query(q=resolved, bot=bot, db=db, entity_id=entity_id)
# ════════════════════════════════════════════════════════════
# 聚合接口(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拦截",
}
+13 -1
View File
@@ -23,6 +23,7 @@ from app.models import (
KPIDefinition, KPIValue, KPIAlert,
ActionPlan, Entity,
)
from app.risk_levels import risk_level
logger = logging.getLogger("cma.bot_bridge_v2")
@@ -279,6 +280,7 @@ ALERT_THRESHOLDS = {
# ═══════════════════════════════════════════════
@router.post("/mpm-result")
@risk_level("L3")
def receive_mpm_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
@@ -404,6 +406,7 @@ def receive_mpm_result(
@router.post("/kpi-result")
@risk_level("L2")
def push_kpi_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
@@ -521,6 +524,7 @@ def push_kpi_result(
@router.post("/verify/{action_plan_id}")
@risk_level("L2")
def verify_action_plan(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
@@ -528,7 +532,14 @@ def verify_action_plan(
):
"""
验证ActionPlan的执行结果
⚠️ 双 verify 入口关系(2026-08-30 评审收敛,暂不重构):
- 本函数(/api/cma/bot-bridge/verify/{action_plan_id}): 轻量版 — 仅读取KPI并按condition
校验,记录 verify_log,不写KPIValue回填、不联动OKR、不发企微通知。供Bot桥接通道调用。
- verify.py/api/cma/verify/{plan_id}: 完整链路 — 回填KPIValue + OKR progress联动
+ 企微通知。业务侧手动/自动重验走那个入口。
- 两者行为不一致,勿混用。
1. 读取ActionPlan的auto_verify_rule
2. 读取关联KPI的当前值
3. 按condition校验
@@ -599,6 +610,7 @@ def verify_action_plan(
@router.get("/verify/{action_plan_id}/history")
@risk_level("L1")
def verify_history(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
+3
View File
@@ -9,6 +9,7 @@ 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")
@@ -150,6 +151,7 @@ def _query_action_plan_verify(db: Session):
# ═══════════════ 端点 ═══════════════
@router.get("/iron-law")
@risk_level("L1")
def get_iron_law_kpis(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
@@ -224,6 +226,7 @@ def get_iron_law_kpis(
@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),
+4
View File
@@ -7,6 +7,7 @@ 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)],
@@ -46,6 +47,7 @@ def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
@router.get("")
@risk_level("L1")
def list_bot_kpis(
source: str = Query("finance-bot", description="Bot标识"),
period: Optional[str] = None,
@@ -132,6 +134,7 @@ def list_bot_kpis(
@router.post("/{kpi_id}/value")
@risk_level("L2")
def update_bot_kpi_value(
kpi_id: int,
data: dict,
@@ -161,6 +164,7 @@ def update_bot_kpi_value(
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",
+780 -62
View File
File diff suppressed because it is too large Load Diff
+787 -2
View File
@@ -1,8 +1,15 @@
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 (资金管理智能体)"""
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 + 网银流水导入 (资金管理智能体)"""
import io
import json
import logging
import os
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends, Query, Request
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import or_
import pandas as pd
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_role
@@ -23,18 +30,54 @@ router = APIRouter(
)
def _aging_bucket(days_late: int) -> str:
"""账龄区间(按逾期天数):未到期 / 0-30天 / 30-60天 / 60-90天 / 90天以上"""
if days_late <= 0:
return "未到期"
if days_late <= 30:
return "0-30天"
if days_late <= 60:
return "30-60天"
if days_late <= 90:
return "60-90天"
return "90天以上"
def _plan_dict(p: CashPlan) -> dict:
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
due = p.plan_date.replace(hour=0, minute=0, second=0, microsecond=0) if p.plan_date else today
days_late = (today - due).days
paid = round(p.paid_amount or 0, 2)
balance = round((p.amount or 0) - paid, 2)
if p.plan_type == "receive":
overdue = p.status == "pending" and due < today
if p.status == "pending":
aging = _aging_bucket(days_late)
elif p.status == "completed":
aging = "已结清"
else:
aging = "已取消"
else:
overdue = False
aging = ""
return {
"id": p.id,
"entity_id": p.entity_id,
"plan_type": p.plan_type,
"plan_type_label": "收款" if p.plan_type == "receive" else "付款",
"amount": round(p.amount or 0, 2),
"paid_amount": paid,
"receivable_balance": max(balance, 0),
"plan_date": p.plan_date.strftime("%Y-%m-%d") if p.plan_date else "",
"counterparty": p.counterparty or "",
"description": p.description or "",
"status": p.status,
"status_label": {"pending": "待执行", "completed": "已完成", "cancelled": "已取消"}.get(p.status, p.status),
"owner": p.owner or "",
"source": p.source or "manual",
"overdue": overdue,
"overdue_days": max(days_late, 0) if overdue else 0,
"aging_bucket": aging,
"completed_at": p.completed_at.strftime("%Y-%m-%d %H:%M") if p.completed_at else None,
"created_at": p.created_at.strftime("%Y-%m-%d %H:%M") if p.created_at else None,
}
@@ -143,6 +186,9 @@ def api_create_plan(request: Request, data: dict, db: Session = Depends(get_db))
counterparty=(data.get("counterparty") or "").strip(),
description=(data.get("description") or "").strip(),
status=data.get("status", "pending"),
owner=(data.get("owner") or "").strip() or None,
source=(data.get("source") or "manual").strip(),
paid_amount=float(data.get("paid_amount") or 0),
)
db.add(plan)
db.commit()
@@ -175,6 +221,12 @@ def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
plan.counterparty = (data["counterparty"] or "").strip()
if "description" in data:
plan.description = (data["description"] or "").strip()
if "owner" in data:
plan.owner = (data["owner"] or "").strip() or None
if "source" in data:
plan.source = (data["source"] or "manual").strip()
if "paid_amount" in data:
plan.paid_amount = float(data["paid_amount"] or 0)
if "status" in data:
plan.status = data["status"]
if data["status"] == "completed" and not plan.completed_at:
@@ -183,6 +235,15 @@ def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
plan.completed_at = None
db.commit()
db.refresh(plan)
# 完成收款时联动:消除到期未收款预警 + 催收行动 + F_AR_DAYS
if plan.plan_type == "receive" and plan.status == "completed":
try:
_resolve_plan_alerts(db, plan.entity_id, plan.id, plan.paid_amount or plan.amount)
_sync_collection_action_plan(db, plan.entity_id)
_update_ar_days_kpi(db, plan.entity_id)
db.commit()
except Exception as e:
logger.warning(f"完成收款联动失败: {e}")
return {"message": "计划已更新", "data": _plan_dict(plan)}
@@ -205,8 +266,18 @@ def api_complete_plan(plan_id: int, db: Session = Depends(get_db)):
raise HTTPException(404, "计划不存在")
plan.status = "completed"
plan.completed_at = datetime.now()
if plan.plan_type == "receive" and not plan.paid_amount:
plan.paid_amount = plan.amount
db.commit()
db.refresh(plan)
if plan.plan_type == "receive":
try:
_resolve_plan_alerts(db, plan.entity_id, plan.id, plan.paid_amount or plan.amount)
_sync_collection_action_plan(db, plan.entity_id)
_update_ar_days_kpi(db, plan.entity_id)
db.commit()
except Exception as e:
logger.warning(f"完成收款联动失败: {e}")
return {"message": "已标记完成", "data": _plan_dict(plan)}
@@ -327,3 +398,717 @@ def api_cash_alert_status(entity_id: int = Depends(get_entity_id), db: Session =
"min_cash_date": result["min_cash_date"],
"summary": result["summary"],
}
# ══════════════════════════════════════════════════════════
# 5. 应收催收闭环 — 催收视图 / 回款登记 / 博海数据录入
# 唯一应收载体:cash_plans(plan_type=receive)
# ══════════════════════════════════════════════════════════
# 博海应收款汇总表(2026H1期末余额,按业务员)— 录入源数据
# 来源: 应收款汇总表(业务员+客户).xlsx / bohai_comprehensive_report.md
BOHAI_AR_DATA = [
# (业务员, 期末余额元, 业务说明)
("董均国", 387676, "IT业务-重点催收"),
("蒋亚文", 529786, "IT业务-重点催收"),
("陈艳", 89949, "IT业务"),
("李亚玲", 92450, "IT业务"),
("李巧玲", 45006, "IT业务"),
("贾妮", 27820, "IT业务"),
("王平安", 31298, "IT业务"),
("任富海", 26978, "IT业务"),
("其他", 6559, "IT业务"),
("王婧", 1065000, "酣客酒类(独立核算)"),
]
def _resolve_plan_alerts(db: Session, entity_id: int, plan_id: int, amount: float) -> int:
"""回款登记后自动消除该计划的【到期未收款】预警(kpi_alerts.alert_type=cash_plan"""
from app.models import KPIAlert
alerts = db.query(KPIAlert).filter(
KPIAlert.alert_type == "cash_plan",
KPIAlert.status.in_(["pending", "processing"]),
).all()
resolved = 0
for a in alerts:
try:
sug = json.loads(a.suggestion or "{}")
except Exception:
continue
if sug.get("plan_id") == plan_id:
a.status = "resolved"
a.resolution = f"回款登记+{amount:.2f}万,系统自动消除"
a.resolved_at = datetime.now()
resolved += 1
if resolved:
db.commit()
return resolved
def _sync_collection_action_plan(db: Session, entity_id: int):
"""逾期应收 → 『应收账款催收行动』联动:有逾期→in_progress,全部结清→completed"""
from app.models import ActionPlan
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
plans = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
).all()
pending = [p for p in plans if p.status == "pending"]
overdue = [p for p in pending if p.plan_date < today]
total_amount = sum(p.amount or 0 for p in plans)
paid_amount = sum(p.paid_amount or 0 for p in plans)
progress = round(paid_amount / total_amount * 100) if total_amount > 0 else 0
if overdue:
new_status = "in_progress"
elif not pending:
new_status = "completed"
progress = 100
else:
new_status = "in_progress"
ap = db.query(ActionPlan).filter(
ActionPlan.title.like("%催收%"),
ActionPlan.status != "cancelled",
).order_by(ActionPlan.id.asc()).first()
if not ap:
return
changed = False
if ap.status != new_status:
ap.status = new_status
changed = True
if ap.progress is None or progress > (ap.progress or 0):
ap.progress = progress
changed = True
if changed:
db.commit()
logger.info(f"催收行动#{ap.id} 联动: status={new_status} progress={progress}%")
def _update_ar_days_kpi(db: Session, entity_id: int):
"""联动F_AR_DAYS(应收周转天数KPI)— 按当前应收余额重算当期值 = 应收余额/月营收×30"""
from app.models import KPIDefinition, KPIValue
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == "F_AR_DAYS",
).first()
if not kpi:
return
plans = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.status == "pending",
).all()
ar_balance_wan = round(sum((p.amount or 0) - (p.paid_amount or 0) for p in plans), 2)
period = datetime.now().strftime("%Y-%m")
monthly_rev = 100.0 # 缺省月营收(万元)
rev_kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == "F_REVENUE",
).first()
if rev_kpi:
val = db.query(KPIValue).filter(
KPIValue.kpi_id == rev_kpi.id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.id.desc()).first()
if val and val.actual_value:
monthly_rev = float(val.actual_value)
ar_days = round(ar_balance_wan / monthly_rev * 30, 1) if monthly_rev > 0 else None
if ar_days is None:
return
remark = f"应收催收闭环联动: 应收余额{ar_balance_wan:.2f}万/月营收{monthly_rev:.1f}万×30天"
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).order_by(KPIValue.id.desc()).first()
if existing:
existing.actual_value = ar_days
existing.source_type = "cash_plan"
existing.remark = remark
existing.calculated_at = datetime.now()
else:
db.add(KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=ar_days,
source_type="cash_plan",
data_status="calculated",
remark=remark,
))
db.commit()
logger.info(f"F_AR_DAYS联动更新: {period} = {ar_days}天 (应收{ar_balance_wan}万)")
@router.get("/receivables")
def api_receivables(
entity_id: int = Depends(get_entity_id),
owner: str = Query(None, description="按负责人/业务员筛选"),
status: str = Query(None, description="pending/completed/cancelled/overdue"),
aging: str = Query(None, description="账龄: 未到期/0-30天/30-60天/60-90天/90天以上/已结清"),
keyword: str = Query(None, description="客户/说明关键字"),
db: Session = Depends(get_db),
):
"""应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选"""
query = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
)
if owner:
query = query.filter(CashPlan.owner == owner)
if keyword:
kw = f"%{keyword}%"
query = query.filter(or_(CashPlan.counterparty.like(kw), CashPlan.description.like(kw)))
plans = query.order_by(CashPlan.plan_date.asc(), CashPlan.id.asc()).all()
items = []
total_amount = total_paid = 0.0
pending_balance = 0.0
overdue_count = 0
overdue_amount = 0.0
aging_amount = {}
completed_amount = 0.0
for p in plans:
d = _plan_dict(p)
if status:
if status == "overdue":
if not d["overdue"]:
continue
elif d["status"] != status:
continue
if aging and d["aging_bucket"] != aging:
continue
items.append(d)
total_amount += d["amount"]
total_paid += d["paid_amount"]
if d["status"] == "pending":
pending_balance += d["receivable_balance"]
if d["overdue"]:
overdue_count += 1
overdue_amount += d["receivable_balance"]
if d["status"] == "pending":
aging_amount[d["aging_bucket"]] = aging_amount.get(d["aging_bucket"], 0) + d["receivable_balance"]
elif d["status"] == "completed":
completed_amount += d["amount"]
owners = [r[0] for r in db.query(CashPlan.owner).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.owner.isnot(None),
CashPlan.owner != "",
).distinct().order_by(CashPlan.owner.asc()).all()]
return {
"entity_id": entity_id,
"total": len(items),
"owners": owners,
"summary": {
"total_amount": round(total_amount, 2),
"total_paid": round(total_paid, 2),
"total_balance": round(total_amount - total_paid, 2),
"pending_balance": round(pending_balance, 2),
"overdue_count": overdue_count,
"overdue_amount": round(overdue_amount, 2),
"completed_amount": round(completed_amount, 2),
"aging": {k: round(v, 2) for k, v in sorted(aging_amount.items(), key=lambda x: x[0])},
},
"data": items,
}
@router.post("/receivables/{plan_id}/payment")
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db)):
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "应收计划不存在")
if plan.plan_type != "receive":
raise HTTPException(400, "仅应收(receive)计划支持回款登记")
if plan.status == "cancelled":
raise HTTPException(400, "已取消的计划不能登记回款")
amount = float(data.get("amount", 0))
if amount <= 0:
raise HTTPException(400, "回款金额必须大于0")
paid_date_str = str(data.get("paid_date") or "")[:10]
if not paid_date_str:
paid_date_str = datetime.now().strftime("%Y-%m-%d")
try:
paid_date = datetime.strptime(paid_date_str, "%Y-%m-%d")
except Exception:
raise HTTPException(400, "paid_date格式应为YYYY-MM-DD")
balance = round((plan.amount or 0) - (plan.paid_amount or 0), 2)
if amount > balance + 1e-9:
raise HTTPException(400, f"回款金额{amount}万超过应收余额{balance}")
plan.paid_amount = round((plan.paid_amount or 0) + amount, 2)
if plan.paid_amount >= (plan.amount or 0) - 1e-9:
plan.paid_amount = plan.amount
plan.status = "completed"
plan.completed_at = paid_date
else:
plan.status = "pending"
plan.completed_at = None
db.commit()
entity_id = plan.entity_id
resolved = _resolve_plan_alerts(db, entity_id, plan.id, amount)
_sync_collection_action_plan(db, entity_id)
_update_ar_days_kpi(db, entity_id)
db.commit()
db.refresh(plan)
logger.info(f"回款登记 #{plan.id} {plan.counterparty or ''} +{amount}万 → {plan.status}, 消除预警{resolved}")
return {
"message": "回款登记成功",
"resolved_alerts": resolved,
"data": _plan_dict(plan),
}
@router.post("/import/bohai-ar")
def api_import_bohai_ar(data: dict = None, entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
"""录入博海应收汇总表数据(¥2.3M, 按业务员)到cash_plans — 幂等,重复调用不重复导入"""
if data is None:
data = {}
existing = db.query(CashPlan).filter(
CashPlan.source == "bohai_ar",
CashPlan.entity_id == entity_id,
).count()
if existing and not data.get("force"):
return {"message": "博海应收已录入,未重复导入", "existing": existing, "imported": 0}
if existing:
db.query(CashPlan).filter(
CashPlan.source == "bohai_ar",
CashPlan.entity_id == entity_id,
).delete()
db.commit()
plan_date = datetime(2026, 6, 30) # 2026H1期末余额基准日
imported = 0
total_wan = 0.0
for sp, yuan, note in BOHAI_AR_DATA:
wan = round(yuan / 10000, 2)
total_wan += wan
db.add(CashPlan(
entity_id=entity_id,
plan_type="receive",
amount=wan,
plan_date=plan_date,
counterparty=sp,
description=f"博海应收款汇总表2026H1期末余额({note})",
status="pending",
owner=sp,
source="bohai_ar",
paid_amount=0,
))
imported += 1
db.commit()
# 触发预警检查生成到期未收款预警 + 联动催收行动/F_AR_DAYS
try:
check_cash_alerts(db, entity_id=entity_id)
except Exception as e:
logger.warning(f"导入后预警检查失败: {e}")
_sync_collection_action_plan(db, entity_id)
_update_ar_days_kpi(db, entity_id)
db.commit()
return {
"message": "博海应收已录入",
"imported": imported,
"total_amount_wan": round(total_wan, 2),
"entity_id": entity_id,
}
# ══════════════════════════════════════════════════════════
# 6. 网银流水标准导入 — 三校验规则 + 现金流余额联动 (P1方案② 2026-08-28)
# 模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要
# ══════════════════════════════════════════════════════════
_TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "templates" / "网银流水导入模板.xlsx"
_TEMPLATE_PATH = str(_TEMPLATE_PATH) # FileResponse/os.path.exists 兼容 str
# 模板列别名映射(兼容中英文列名)
_VOUCHER_COL_ALIASES = {
"voucher_date": ["凭证日期", "日期", "voucher_date", "date"],
"voucher_no": ["凭证号", "凭证编号", "凭证字号", "voucher_no"],
"subject_code": ["科目编码", "科目代码", "subject_code", "code"],
"subject_name": ["科目名称", "subject_name", "name"],
"debit_amount": ["借方金额", "借方", "debit_amount", "debit"],
"credit_amount": ["贷方金额", "贷方", "credit_amount", "credit"],
"summary": ["摘要", "备注", "summary", "remark"],
}
def _resolve_voucher_cols(cols: list) -> dict:
"""列名归一化:返回 {字段: 实际列名}(不区分大小写/空格)"""
col_map = {}
for c in cols:
key = str(c).strip().lower()
if key and key not in col_map:
col_map[key] = str(c)
resolved = {}
for field, aliases in _VOUCHER_COL_ALIASES.items():
for alias in aliases:
if alias.lower() in col_map:
resolved[field] = col_map[alias.lower()]
break
return resolved
def _norm_str(raw) -> str:
"""单元格→干净字符串:NaN/None→空;整数float→去.0(如1002.0→1002"""
if raw is None:
return ""
if isinstance(raw, float) and pd.isna(raw):
return ""
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
f = float(raw)
return str(int(f)) if f.is_integer() else str(f)
s = str(raw).strip()
return "" if s.lower() in ("nan", "none") else s
def _is_carry_forward(summary: str, subject_name: str) -> bool:
"""结转行识别:摘要含'结转' 或 科目名称含'本年利润'/'结转'"""
return ("结转" in summary) or ("本年利润" in subject_name) or ("结转" in subject_name)
def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float:
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)"""
from app.models import VoucherDetail
from app.utils.cash_forecast_engine import set_current_cash_balance
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
balance_yuan = round(sum(
(r.debit_amount or 0) - (r.credit_amount or 0)
for r in rows
if r.carry_forward == 0
and r.subject_code
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
), 2)
cash_wan = round(balance_yuan / 10000, 4)
set_current_cash_balance(db, cash_wan)
logger.info(f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 → 现金余额联动")
return cash_wan
def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> list:
"""现金流KPI联动:
EXT_现金类KPI 货币资金科目期末余额与存量口径一致source_type=ledger
F_CASH_SAFETY 现金安全垫万元 = 货币资金余额 - 短期借款EXT_139entity_id隔离不存在则创建
"""
from app.models import KPIDefinition, KPIValue, VoucherDetail
updates = []
period = periods[-1] if periods else datetime.now().strftime("%Y-%m")
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
monetary = [
r for r in rows
if r.carry_forward == 0 and r.subject_code
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
]
balance_yuan = round(sum((r.debit_amount or 0) - (r.credit_amount or 0) for r in monetary), 2)
cash_1001 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0)
for r in monetary if r.subject_code.startswith("1001")), 2)
cash_1002 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0)
for r in monetary if r.subject_code.startswith("1002")), 2)
cash_wan = round(balance_yuan / 10000, 2)
# ── ① EXT_现金类KPI(名称含'现金'/'货币资金'active;排除F_CASH_SAFETY,由②专用逻辑按万元口径处理)──
cash_kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
or_(KPIDefinition.kpi_name.like("%现金%"), KPIDefinition.kpi_name.like("%货币资金%")),
KPIDefinition.status == "active",
KPIDefinition.kpi_code != "F_CASH_SAFETY",
).all()
for k in cash_kpis:
if "库存现金" in k.kpi_name:
val = cash_1001
elif "银行" in k.kpi_name:
val = cash_1002
else:
val = balance_yuan
remark = f"网银流水导入[{batch}]联动: 货币资金科目期末余额{balance_yuan}元(库存现金{cash_1001}/银行存款{cash_1002})"
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period,
KPIValue.source_type == "ledger",
).order_by(KPIValue.id.desc()).first()
if existing:
existing.actual_value = val
existing.source_batch = batch # type: ignore[assignment] # SQLAlchemy Column类型推断噪音
existing.remark = remark
existing.calculated_at = datetime.now()
else:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=k.id,
period=period,
actual_value=val,
source_type="ledger",
source_batch=batch,
data_status="verified",
remark=remark,
))
updates.append({"kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "period": period, "value": val})
# ── ② F_CASH_SAFETY 现金安全垫(万元)──
safety_kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
or_(KPIDefinition.kpi_code == "F_CASH_SAFETY", KPIDefinition.kpi_name.like("%安全垫%")),
).first()
if not safety_kpi:
safety_kpi = KPIDefinition(
entity_id=entity_id,
kpi_code="F_CASH_SAFETY",
kpi_name="现金安全垫",
dimension="finance",
category="cash_risk",
formula="货币资金余额-短期借款",
formula_desc="货币资金科目(1001/1002)期末余额 - 短期借款(EXT_139),单位万元",
unit="万元",
target_value=0, # kpi_definitions.target_value NOT NULL DEFAULT 0.00ORM显式传None会绕过默认值导致IntegrityError
data_source_type="ledger",
data_source="网银流水导入联动",
data_owner="财务Bot",
frequency="monthly",
status="active",
kpi_level="operational",
important_flag=1,
data_level="core",
epic="Epic2",
)
db.add(safety_kpi)
db.flush()
logger.info(f"新增KPI F_CASH_SAFETY 现金安全垫 (entity_id={entity_id})")
short_debt_wan = None
debt_kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == "EXT_139",
).first()
if debt_kpi:
dv = db.query(KPIValue).filter(
KPIValue.kpi_id == debt_kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
if dv and dv.actual_value is not None:
short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元
if short_debt_wan is not None:
safety_value = round(cash_wan - short_debt_wan, 2)
remark = f"网银流水导入[{batch}]联动: 货币资金{cash_wan}万 - 短期借款{short_debt_wan}万 = 安全垫{safety_value}"
else:
safety_value = cash_wan
remark = f"网银流水导入[{batch}]联动: 无短期借款(EXT_139)数据,现金安全垫=货币资金余额{cash_wan}"
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == safety_kpi.id,
KPIValue.period == period,
KPIValue.source_type == "ledger",
).order_by(KPIValue.id.desc()).first()
if existing:
existing.actual_value = safety_value
existing.remark = remark
existing.calculated_at = datetime.now()
else:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=safety_kpi.id,
period=period,
actual_value=safety_value,
source_type="ledger",
source_batch=batch,
data_status="verified",
remark=remark,
))
updates.append({"kpi_code": "F_CASH_SAFETY", "kpi_name": "现金安全垫", "period": period, "value": safety_value})
return updates
@router.get("/import/template")
def api_get_voucher_template():
"""下载网银流水导入模板xlsx"""
if not os.path.exists(_TEMPLATE_PATH):
raise HTTPException(404, "模板文件不存在,请联系管理员生成")
return FileResponse(
_TEMPLATE_PATH,
filename="网银流水导入模板.xlsx",
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
@router.post("/import/vouchers")
async def api_import_vouchers(
file: UploadFile = File(...),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""网银流水标准导入 — 三校验(借贷平衡/期间合计/结转行) → voucher_details/import_logs → 现金流余额联动"""
from app.models import VoucherDetail, ImportLog
content = await file.read()
fname = (file.filename or "网银流水.xlsx").strip()
try:
df = pd.read_excel(io.BytesIO(content))
except Exception as e:
raise HTTPException(400, f"无法读取Excel文件: {e}")
if df is None or len(df) == 0:
raise HTTPException(400, "Excel文件为空(无数据行)")
colmap = _resolve_voucher_cols(list(df.columns))
missing = [f for f in ("voucher_date", "voucher_no", "subject_code") if f not in colmap]
if missing:
raise HTTPException(400, f"缺少必要列: {', '.join(missing)}(模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要)")
# ── 逐行校验 ──
errors = []
valid_rows = []
for idx, row in df.iterrows():
excel_row = idx + 2 # 表头占第1行
raw_date = row.get(colmap["voucher_date"])
if raw_date is None or (isinstance(raw_date, float) and pd.isna(raw_date)):
errors.append({"row": excel_row, "field": "voucher_date", "reason": "凭证日期为空"})
continue
try:
voucher_date = pd.to_datetime(raw_date).to_pydatetime()
except Exception:
errors.append({"row": excel_row, "field": "voucher_date", "reason": f"日期无法解析: {raw_date}"})
continue
voucher_no = _norm_str(row.get(colmap["voucher_no"]))
if not voucher_no:
errors.append({"row": excel_row, "field": "voucher_no", "reason": "凭证号为空"})
continue
subject_code = _norm_str(row.get(colmap["subject_code"]))
if not subject_code:
errors.append({"row": excel_row, "field": "subject_code", "reason": "科目编码为空"})
continue
subject_name = _norm_str(row.get(colmap["subject_name"]) if "subject_name" in colmap else "")
if not subject_name:
errors.append({"row": excel_row, "field": "subject_name", "reason": "科目名称为空"})
continue
def _parse_amount(raw) -> float:
"""金额解析:空→0;数字→float;字符串去逗号→float;失败→None"""
if raw is None or (isinstance(raw, float) and pd.isna(raw)):
return 0.0
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
return float(raw)
s = str(raw).strip().replace(",", "")
try:
return float(s)
except Exception:
return None
debit = _parse_amount(row.get(colmap["debit_amount"]) if "debit_amount" in colmap else None)
credit = _parse_amount(row.get(colmap["credit_amount"]) if "credit_amount" in colmap else None)
if debit is None:
errors.append({"row": excel_row, "field": "debit_amount", "reason": f"借方金额不是有效数字: {row.get(colmap['debit_amount'])}"})
continue
if credit is None:
errors.append({"row": excel_row, "field": "credit_amount", "reason": f"贷方金额不是有效数字: {row.get(colmap['credit_amount'])}"})
continue
if debit < 0 or credit < 0:
errors.append({"row": excel_row, "field": "amount", "reason": "金额不能为负"})
continue
if debit == 0 and credit == 0:
errors.append({"row": excel_row, "field": "amount", "reason": "借贷金额不能同时为0"})
continue
summary = _norm_str(row.get(colmap["summary"]) if "summary" in colmap else "")
carry_forward = 1 if _is_carry_forward(summary, subject_name) else 0
valid_rows.append({
"voucher_no": voucher_no,
"voucher_date": voucher_date,
"period": voucher_date.strftime("%Y-%m"),
"subject_code": subject_code,
"subject_name": subject_name,
"debit_amount": round(debit, 2),
"credit_amount": round(credit, 2),
"summary": summary,
"carry_forward": carry_forward,
})
total = len(df)
success = len(valid_rows)
failed = len(errors)
# ── 校验规则① 借贷平衡(Σ借 vs Σ贷,容差0.01)──
debit_total = round(sum(r["debit_amount"] for r in valid_rows), 2)
credit_total = round(sum(r["credit_amount"] for r in valid_rows), 2)
diff = round(debit_total - credit_total, 2)
balance_ok = abs(diff) <= 0.01
balance_check = {
"passed": balance_ok,
"debit_total": debit_total,
"credit_total": credit_total,
"diff": diff,
"tolerance": 0.01,
}
if not balance_ok:
errors.append({"row": 0, "field": "balance", "reason": f"借贷不平衡: 借方合计{debit_total} ≠ 贷方合计{credit_total},差额{diff}"})
# ── 校验规则② 期间合计(按period汇总,供对账)──
period_totals = {}
for r in valid_rows:
pt = period_totals.setdefault(r["period"], {"debit_total": 0.0, "credit_total": 0.0})
pt["debit_total"] = round(pt["debit_total"] + r["debit_amount"], 2)
pt["credit_total"] = round(pt["credit_total"] + r["credit_amount"], 2)
# ── 校验规则③ 结转行 ──
carry_forward_count = sum(1 for r in valid_rows if r["carry_forward"])
# ── 入库(部分成功模式:失败行不阻断整体)──
batch = f"{os.path.splitext(fname)[0]}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
for r in valid_rows:
db.add(VoucherDetail(
entity_id=entity_id,
voucher_no=r["voucher_no"],
voucher_date=r["voucher_date"],
subject_code=r["subject_code"],
subject_name=r["subject_name"],
debit_amount=r["debit_amount"],
credit_amount=r["credit_amount"],
summary=r["summary"],
carry_forward=r["carry_forward"],
period=r["period"],
batch=batch,
))
periods_in = sorted(set(r["period"] for r in valid_rows))
db.add(ImportLog(
entity_id=entity_id,
filename=fname,
batch=batch,
total_rows=total,
success_rows=success,
failed_rows=failed,
errors=errors or None,
period=periods_in[0] if periods_in else None,
import_type="vouchers",
created_by="finance-bot",
))
db.commit()
logger.info(f"网银流水导入[{batch}] entity={entity_id}: 总{total}/成功{success}/失败{failed}, 借贷平衡={'通过' if balance_ok else '失败'}")
# ── 现金流联动 ──
cash_balance = None
kpi_updates = []
try:
cash_balance = _sync_cash_balance_from_ledger(db, entity_id, batch)
kpi_updates = _sync_cash_kpis(db, entity_id, batch, periods_in)
db.commit()
except Exception as e:
db.rollback()
logger.error(f"网银流水导入[{batch}] 现金流联动失败: {e}", exc_info=True)
return {
"success": True,
"total": total,
"success_rows": success,
"failed_rows": failed,
"errors": errors,
"balance_check": balance_check,
"period_totals": period_totals,
"carry_forward_count": carry_forward_count,
"cash_balance": cash_balance,
"kpi_updates": kpi_updates,
"batch": batch,
"entity_id": entity_id,
}
+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": "已忽略"}
+7 -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:
+60 -13
View File
@@ -52,6 +52,24 @@ 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), entity_id: int = Depends(get_entity_id)):
@@ -143,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",
@@ -198,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",
@@ -251,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,
@@ -369,8 +390,9 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
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
@@ -384,22 +406,24 @@ def my_dashboard(
}
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:
q = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code.in_(preset_codes),
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
)
if assigned_ids:
q = q.filter(~KPIDefinition.id.in_(assigned_ids))
@@ -414,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,
@@ -427,20 +462,32 @@ 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过滤导致工作台显示空)
# 2026-08-31 隔离补漏: CEO 看全部也须按 entity_id 过滤,防跨账套待办串数据
my_plans = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id,
).order_by(ActionPlan.updated_at.desc()).all()
else:
my_plans = db.query(ActionPlan).filter(
or_(
ActionPlan.assignee == username,
ActionPlan.assignee == name,
),
ActionPlan.entity_id == entity_id,
).order_by(ActionPlan.updated_at.desc()).all()
plan_list = []
for p in my_plans:
+17 -6
View File
@@ -39,6 +39,7 @@ async def import_excel(file: UploadFile = File(...),
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
@@ -59,6 +60,7 @@ async def import_excel(file: UploadFile = File(...),
db.add(KPIValue(
kpi_id=kid,
entity_id=kpi_entity_map.get(kpi_code),
period=period,
actual_value=float(value),
source_type="excel",
@@ -80,11 +82,11 @@ async def import_excel(file: UploadFile = File(...),
# ── 智能导入(BOT自动识别,无需手动映射) ──
_SMART_MAP = {
# KPI编码列匹配模式 → 标准kpi_code
# KPI名称/编码列匹配模式(顺序重要:名称类列优先,避免科目编码被当名称)
"kpi_code_patterns": [
re.compile(r'^(kpi_?code|指标编码|编码)$', re.I),
re.compile(r'^(科目|项目|账户|报表项目|项目名称)$'),
re.compile(r'^(指标名称?|kpi名称?|name)$', re.I),
re.compile(r'^(科目名称|项目名称|指标名称?|kpi名称?|name|名称)$', re.I),
re.compile(r'^(科目编码|科目代码|kpi_?code|指标编码|编码)$', re.I),
re.compile(r'^(科目|项目|账户|报表项目)$'),
],
# 期间列匹配
"period_patterns": [
@@ -218,11 +220,19 @@ async def import_excel_smart(
skipped_rows = []
for idx, row in df.iterrows():
raw_kpi = str(row.get(kpi_col, "")).strip()
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 "")
if not raw_kpi or pd.isna(raw_val):
# 名称防护: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:
@@ -290,6 +300,7 @@ async def import_excel_smart(
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",
+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}
+253 -1
View File
@@ -3,7 +3,7 @@
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from sqlalchemy import func, and_, text
from typing import Optional
from datetime import datetime, timedelta
import json
@@ -322,3 +322,255 @@ def quality_stats(db: Session = Depends(get_db)):
"threshold_days": 180,
},
}
# ============================================================
# DAMA 数据治理规则检查(财务七规则)— 2026-08-30 P1
# ============================================================
RULES_META = {
"unit_check": {"name": "单位校验", "level": "error", "desc": "cash_plans.amount > 10000(万元口径可疑,疑似单位错乱)"},
"dup_alert": {"name": "重复预警", "level": "error", "desc": "同一plan_id存在多条pending应收预警(去重键错误)"},
"orphan_check": {"name": "孤儿预警", "level": "error", "desc": "预警suggestion.plan_id指向不存在的cash_plans记录"},
"virtual_pollution": {"name": "虚拟污染", "level": "error", "desc": "cash_plans.source含test/虚拟等测试标识混入真实数据"},
"entity_check": {"name": "实体归属", "level": "error", "desc": "kpi_values.entity_id与kpi_definitions.entity_id不一致"},
"kpi_completeness": {"name": "KPI完整性", "level": "warning", "desc": "active状态KPI无任何实际值的数量"},
"reconciliation": {"name": "勾稽验证", "level": "warning", "desc": "预算月度合计 vs 年度目标差异>20%"},
}
DETAIL_LIMIT = 10 # 每条规则detail最多列出的条数(避免响应过大)
@router.get("/check-governance")
def check_governance(
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"),
db: Session = Depends(get_db),
):
"""DAMA数据治理规则检查(财务七规则)— 返回质量评分+异常清单。
评分规则: 满分100error级规则每条扣10分warning级规则每条扣5分
每条规则最多扣一次分按规则是否命中不按count累扣最低0分
收敛说明2026-08-31 安全修复 P1-2规则执行统一复用
_run_governance_checksPython 解析 suggestion JSONSQLite 兼容
删除原 _run_rule_checksMySQL JSON_EXTRACT/JSON_VALID SQLite 不兼容
本端点保留原评分口径与响应结构governance-check 端点使用新的扣分口径
"""
gov = _run_governance_checks(db, entity_id or 0)
issues = gov["issues"]
# 计算评分(check-governance 原口径:error 扣10 / warning 扣5,每规则最多扣一次)
score = 100
for item in issues:
if item["count"] > 0:
score -= 10 if item["level"] == "error" else 5
score = max(0, score)
passed = [item["rule"] for item in issues if item["count"] == 0]
return {
"checked_at": datetime.now().isoformat(timespec="seconds"),
"entity_id": entity_id or 0,
"score": score,
"total_rules": len(issues),
"issues": issues,
"passed": passed,
"rules_meta": RULES_META,
}
# ============================================================
# 财务七规则检查(governance-check)— 2026-08-30 P1 最终方案
# 与 /check-governance 的区别:
# * 评分规则不同:error 扣 min(15, count*3)warning 扣 min(10, count*1)
# * 规则2/3 用 Python 解析 suggestion JSON(不依赖 MySQL JSON 函数)
# * 规则4 区分 error(test/sync/虚拟) 与 manual(待人工确认 warning)
# * 规则7 按 status='active' 口径(与 budget.py 一致,避免多版本叠加失真)
# 只读幂等:不写库、不创建 KpiDataQualityLog
# ============================================================
def _extract_plan_id(suggestion: str):
"""从 kpi_alerts.suggestion (Text 存 JSON) 解析 plan_id;解析失败返回 None"""
if not suggestion:
return None
try:
data = json.loads(suggestion)
return data.get("plan_id")
except Exception:
return None
def _run_governance_checks(db: Session, entity_id: int = 0):
"""执行财务七规则,返回 issues 列表(含 deducted 扣分)。entity_id=0 表示全部实体。"""
eid = entity_id or 0
ent = " AND cp.entity_id = :eid" if eid else ""
issues = []
# ── 规则1 单位校验:amount > 10000(万元口径可疑)──
rows = db.execute(text(
"SELECT cp.id, cp.entity_id, cp.amount, cp.source, cp.description "
"FROM cash_plans cp WHERE cp.amount > 10000" + ent + " ORDER BY cp.amount DESC LIMIT 200"
), {"eid": eid}).fetchall()
issues.append({
"rule": "unit_check", "level": "error",
"count": len(rows),
"detail": [f"plan#{r.id} 金额{r.amount}(疑似元)" for r in rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
})
# ── 规则2/3 共用数据:pending cash_plan 预警(实体筛选经 kpi_definitions)──
alert_sql = (
"SELECT a.id, a.kpi_id, a.suggestion, d.entity_id AS kpi_entity_id "
"FROM kpi_alerts a JOIN kpi_definitions d ON d.id = a.kpi_id "
"WHERE a.alert_type = 'cash_plan' AND a.status = 'pending'"
)
if eid:
alert_sql += " AND d.entity_id = :eid"
alert_rows = db.execute(text(alert_sql), {"eid": eid}).fetchall()
# Python 侧解析 suggestion → plan_id(不依赖 MySQL JSON 函数)
parsed = [] # [(alert_id, kpi_id, plan_id)]
for r in alert_rows:
pid = _extract_plan_id(r.suggestion)
if pid is not None:
parsed.append((r.id, r.kpi_id, pid))
# 已存在的 cash_plans id 集合(规则3 判断孤儿用;实体筛选时仅看该实体下 plan)
plan_ids_sql = "SELECT id FROM cash_plans" + (" WHERE entity_id = :eid" if eid else "")
plan_id_set = {row[0] for row in db.execute(text(plan_ids_sql), {"eid": eid}).fetchall()}
# ── 规则2 重复预警:同 plan_id 多条 pending 预警 ──
group_map = {}
for alert_id, kpi_id, pid in parsed:
group_map.setdefault(pid, []).append(alert_id)
dup_groups = [(pid, ids) for pid, ids in group_map.items() if len(ids) > 1]
dup_groups.sort(key=lambda x: -len(x[1]))
issues.append({
"rule": "dup_alert", "level": "error",
"count": len(dup_groups),
"detail": [f"plan#{pid} 重复预警×{len(ids)}" for pid, ids in dup_groups[:DETAIL_LIMIT]]
+ (["…等%d个plan" % len(dup_groups)] if len(dup_groups) > DETAIL_LIMIT else []),
})
# ── 规则3 孤儿预警:plan_id 指向不存在的 cash_plans ──
orphan_rows = [(aid, kid, pid) for aid, kid, pid in parsed if pid not in plan_id_set]
issues.append({
"rule": "orphan_check", "level": "error",
"count": len(orphan_rows),
"detail": [f"预警#{aid}(kpi#{kid}) → plan#{pid} 不存在" for aid, kid, pid in orphan_rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(orphan_rows)] if len(orphan_rows) > DETAIL_LIMIT else []),
})
# ── 规则4 虚拟污染:source 含 test/sync/虚拟 → errorsource='manual' → 待人工确认 warning ──
rows = db.execute(text(
"SELECT cp.id, cp.entity_id, cp.source, cp.description FROM cash_plans cp "
"WHERE (cp.source LIKE '%test%' OR cp.source LIKE '%sync%' OR cp.source LIKE '%虚拟%' OR cp.source LIKE '%demo%')"
+ ent + " ORDER BY cp.id LIMIT 200"
), {"eid": eid}).fetchall()
manual_rows = db.execute(text(
"SELECT cp.id, cp.entity_id, cp.source, cp.description FROM cash_plans cp "
"WHERE cp.source = 'manual'" + ent + " ORDER BY cp.id LIMIT 200"
), {"eid": eid}).fetchall()
issues.append({
"rule": "virtual_pollution", "level": "error",
"count": len(rows),
"detail": [f"plan#{r.id} source={r.source} desc={r.description or ''}" for r in rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(rows)] if len(rows) > DETAIL_LIMIT else []),
"manual_count": len(manual_rows),
"manual_detail": [f"plan#{r.id} source=manual(待人工确认)" for r in manual_rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(manual_rows)] if len(manual_rows) > DETAIL_LIMIT else []),
})
# ── 规则5 实体归属:kpi_values.entity_id IS NOT NULL 且 != kpi_definitions.entity_id ──
ent_sql = (
"SELECT v.id, v.kpi_id, d.kpi_code, v.entity_id AS v_eid, d.entity_id AS d_eid "
"FROM kpi_values v JOIN kpi_definitions d ON v.kpi_id = d.id "
"WHERE v.entity_id IS NOT NULL AND v.entity_id != d.entity_id"
)
if eid:
ent_sql += " AND v.entity_id = :eid"
ent_sql += " ORDER BY v.id LIMIT 200"
ent_rows = db.execute(text(ent_sql), {"eid": eid}).fetchall()
issues.append({
"rule": "entity_check", "level": "error",
"count": len(ent_rows),
"detail": [f"值#{r.id} {r.kpi_code} 实体{r.v_eid}≠定义实体{r.d_eid}" for r in ent_rows[:DETAIL_LIMIT]]
+ (["…等%d" % len(ent_rows)] if len(ent_rows) > DETAIL_LIMIT else []),
})
# ── 规则6 KPI完整性:active KPI 无任何实际值 ──
comp_sql = (
"SELECT d.id, d.kpi_code, d.kpi_name FROM kpi_definitions d "
"WHERE d.status='active' AND NOT EXISTS (SELECT 1 FROM kpi_values v WHERE v.kpi_id = d.id)"
)
if eid:
comp_sql += " AND d.entity_id = :eid"
comp_sql += " ORDER BY d.id LIMIT 300"
comp_rows = db.execute(text(comp_sql), {"eid": eid}).fetchall()
issues.append({
"rule": "kpi_completeness", "level": "warning",
"count": len(comp_rows),
"detail": [f"{r.kpi_code} {r.kpi_name}(无值)" for r in comp_rows[:DETAIL_LIMIT]]
+ (["…等%d个KPI" % len(comp_rows)] if len(comp_rows) > DETAIL_LIMIT else []),
})
# ── 规则7 勾稽验证:预算(active口径)年度合计 vs 年度目标 差异>20% ──
recon_sql = (
"SELECT d.kpi_code, d.kpi_name, d.target_yearly, "
"COALESCE(SUM(b.budget_value),0) AS monthly_sum, "
"ROUND((COALESCE(SUM(b.budget_value),0) - d.target_yearly) / d.target_yearly * 100, 1) AS diff_pct "
"FROM kpi_definitions d JOIN budget_plans b ON b.kpi_id = d.id "
"WHERE d.status='active' AND d.target_yearly > 0 AND b.status='active'"
)
if eid:
recon_sql += " AND d.entity_id = :eid"
recon_sql += " GROUP BY d.id HAVING ABS(diff_pct) > 20 ORDER BY ABS(diff_pct) DESC LIMIT 200"
recon_rows = db.execute(text(recon_sql), {"eid": eid}).fetchall()
issues.append({
"rule": "reconciliation", "level": "warning",
"count": len(recon_rows),
"detail": [f"{r.kpi_code} 预算合计{round(r.monthly_sum, 1)} vs 年度目标{r.target_yearly} 差异{r.diff_pct}%" for r in recon_rows[:DETAIL_LIMIT]]
+ (["…等%d个KPI" % len(recon_rows)] if len(recon_rows) > DETAIL_LIMIT else []),
})
# ── 评分:error 扣 min(15, count*3)warning 扣 min(10, count*1);规则4 manual 按 warning 附加扣 ──
total_deduct = 0
for item in issues:
ded = 0
if item["count"] > 0:
ded += min(15, item["count"] * 3) if item["level"] == "error" else min(10, item["count"] * 1)
# 规则4 附加:manual 待人工确认(warning 性质)
manual_cnt = item.get("manual_count") or 0
if item["rule"] == "virtual_pollution" and manual_cnt > 0:
ded += min(10, manual_cnt * 1)
item["deducted"] = ded
total_deduct += ded
score = max(0, 100 - total_deduct)
passed = [item["rule"] for item in issues if item["count"] == 0]
return {
"checked_at": datetime.now().isoformat(timespec="seconds"),
"entity_id": eid,
"score": score,
"total_deduct": total_deduct,
"total_rules": len(issues),
"issues": issues,
"passed": passed,
"rules_meta": RULES_META,
}
@router.get("/governance-check")
def governance_check(
entity_id: Optional[int] = Query(0, description="实体ID: 0=全部, 1=酣客, 2=博海"),
db: Session = Depends(get_db),
):
"""财务七规则检查(DAMA数据治理)— 只读幂等,不写库。
7条规则: unit_check/dup_alert/orphan_check/virtual_pollution/entity_check/kpi_completeness/reconciliation
评分: 满分100error min(15, count*3)warning min(10, count*1)score=max(0, 100-总扣分)
实体筛选: entity_id 参数0=全部
"""
return _run_governance_checks(db, entity_id or 0)
+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": "派生规则已删除"}
+3
View File
@@ -16,6 +16,7 @@ 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
@@ -120,6 +121,7 @@ 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),
):
"""查询费用规则列表"""
@@ -397,6 +399,7 @@ def list_reimbursements(
applicant: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
+140 -19
View File
@@ -1,6 +1,7 @@
"""KPI因果链建模 — 任务2
KPI间因果关系网络 + 模拟推演
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
@@ -8,8 +9,14 @@ 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")
@@ -28,9 +35,9 @@ def _to_dict(obj):
# ============================================================
@router.get("/full-network")
def get_full_network(db: Session = Depends(get_db)):
"""获取全局因果网络数据(用于力导向图)"""
edges = db.query(KPICausality).all()
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:
@@ -60,14 +67,19 @@ def get_full_network(db: Session = Depends(get_db)):
@router.get("/kpi/{kpi_id}/network")
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
"""获取KPI的因果网络(上游驱动 + 下游影响)"""
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).all()
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()
@@ -77,10 +89,16 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
"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).all()
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()
@@ -90,6 +108,9 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
"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 {
@@ -100,7 +121,7 @@ def get_kpi_network(kpi_id: int, db: Session = Depends(get_db)):
@router.post("/simulate")
def simulate_causality(data: dict, db: Session = Depends(get_db)):
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 }
"""
@@ -114,6 +135,8 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
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
@@ -141,9 +164,10 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
continue
visited.add(current_kpi_id)
# 查找从current_kpi_id出发的下游因果链
# 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链)
downstream = db.query(KPICausality).filter(
KPICausality.source_kpi_id == current_kpi_id
KPICausality.source_kpi_id == current_kpi_id,
KPICausality.entity_id == entity_id,
).all()
for edge in downstream:
@@ -211,14 +235,18 @@ def simulate_causality(data: dict, db: Session = Depends(get_db)):
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),
):
"""获取因果链列表"""
query = db.query(KPICausality)
"""获取因果链列表(账套隔离: 仅当前企业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 = []
@@ -234,11 +262,51 @@ def list_causalities(
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)):
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()
@@ -249,7 +317,7 @@ def get_causality(causality_id: int, db: Session = Depends(get_db)):
@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:
@@ -260,6 +328,8 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
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,
@@ -268,40 +338,91 @@ def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES
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}"))
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):
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, "因果链不存在")
for field in ("strength", "lag_months", "formula", "direction"):
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):
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": "已删除"}
+7 -2
View File
@@ -27,7 +27,9 @@ router = APIRouter(
# 维度 → 编码前缀
DIM_PREFIX = {"finance": "F", "customer": "C", "process": "P", "learning": "L"}
# 历史遗留兼容前缀: EXT_ = 科目余额表导入的财务科目KPI(仅限 finance 维度)
LEGACY_PREFIX_DIM = {"EXT": "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")
@@ -37,6 +39,7 @@ META_FIELDS = [
("formula", "计算公式"),
("data_source", "数据来源"),
("data_owner", "数据责任人"),
("kpi_name", "KPI名称"),
]
@@ -106,7 +109,9 @@ def validate_kpi_payload(
prefix = code.split("_")[0] if "_" in code else code
if prefix not in ("F", "C", "P", "L"):
# 兼容历史遗留 EXT_ 前缀(科目余额表导入的财务科目KPI,仅限finance维度)
if not (LEGACY_PREFIX_DIM.get(prefix) and dimension == LEGACY_PREFIX_DIM[prefix]):
# 兼容 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]
+275 -10
View File
@@ -9,7 +9,7 @@ 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, Entity, KPICausality, KPIHierarchy
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字典"],
@@ -107,7 +107,8 @@ def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depen
# ============================================================
REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RATIO',
'P_QUALITY_RATE', 'F_COST_RATIO', 'F_INTEREST_COVER', 'F_QUICK_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):
@@ -474,12 +475,141 @@ def get_kpi_causality_chain(
# 动态路由(必须在静态路由之后)
# ============================================================
@router.get("/{kpi_id}")
def get_kpi(kpi_id: int, db: Session = Depends(get_db)):
@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不存在")
return kpi_to_dict(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):
@@ -488,16 +618,115 @@ def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] =
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()
@@ -506,15 +735,37 @@ def create_kpi(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
return kpi_to_dict(kpi)
# 可更新字段白名单(安全修复 2026-08-31):update_kpi 只允许更新业务属性。
# status / important_flag 等敏感字段及主键/归属字段(id/kpi_code/entity_id/map_id/created_*)一律忽略,
# 防越权修改(如借 update 篡改重要标记/上下架状态)。
UPDATE_KPI_WHITELIST = {
"kpi_name", "dimension", "objective", "formula", "formula_desc",
"data_source_type", "data_source_config", "data_source", "data_owner",
"frequency", "unit", "target_value", "target_monthly", "target_quarterly",
"target_yearly", "target_calc_type", "threshold_green", "threshold_yellow",
"threshold_red", "category", "data_level", "data_category",
"responsible_dept", "responsible_user", "kpi_level", "bot_source", "epic",
}
@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)
# 白名单过滤(安全修复 2026-08-31):白名单外字段(status/important_flag/主键等)忽略不修改
for k in list(data.keys()):
if k not in UPDATE_KPI_WHITELIST:
data.pop(k, None)
for k, v in data.items():
if hasattr(kpi, k) and v is not None:
setattr(kpi, k, v)
@@ -524,18 +775,24 @@ 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):
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": "已恢复"}
@@ -543,6 +800,14 @@ def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
def kpi_to_dict(k):
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
+429 -27
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, MapObjective
from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User
import json
router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"],
@@ -60,12 +62,24 @@ 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()
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()
@@ -75,12 +89,13 @@ def create_map(data: dict, db: Session = Depends(get_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": []},
)
@@ -92,19 +107,29 @@ def create_map_with_template(data: dict, db: Session = Depends(get_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":
@@ -117,25 +142,27 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
@router.delete("/{map_id}")
def delete_map(map_id: int, db: Session = Depends(get_db)):
"""删除战略地图"""
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)):
"""批量删除战略地图"""
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).first()
m = db.query(StrategicMap).filter(StrategicMap.id == mid, StrategicMap.entity_id == entity_id).first()
if m:
db.delete(m)
deleted += 1
@@ -159,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, "战略地图不存在")
@@ -186,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, "战略地图不存在")
@@ -277,10 +310,12 @@ def _sync_map_objectives(m, db):
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", ""),
@@ -293,13 +328,18 @@ def _sync_map_objectives(m, db):
def _merge_map_objectives(m, db):
"""读取时:将map_objectives表的数据合并进dimensions JSON"""
"""读取时:将map_objectives表的数据合并进dimensions JSON
注意安全审查 2026-08-31本函数是只读合并只读 map_objectives 并合并到
dimensions JSON不写库无事务提交需求 commit 不影响勿误判为写操作
"""
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)
@@ -323,24 +363,261 @@ def _merge_map_objectives(m, db):
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, level: Optional[str] = None, db: Session = Depends(get_db)):
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()
@@ -349,9 +626,10 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
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}
@@ -417,14 +695,24 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
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"
@@ -452,6 +740,13 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
"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)
@@ -494,3 +789,110 @@ def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depen
"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(
+387 -65
View File
@@ -1,29 +1,185 @@
"""
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
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
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目标"""
q = db.query(Objective)
"""列出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:
@@ -33,9 +189,11 @@ def list_objectives(
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
results = []
for o in objs:
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == o.id).all()
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}
{"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({
@@ -45,7 +203,7 @@ def list_objectives(
"confidence": o.confidence,
"key_results": kr_summary,
"kr_count": len(krs),
"kr_completed": sum(1 for kr in krs if kr.status == "completed"),
"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}
@@ -55,9 +213,9 @@ def list_objectives(
def create_objective(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""创建OKR目标(支持JSON Body和Query参数两种方式)"""
# 兼容旧版Query参数
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
title = data.get("title") or ""
quarter = data.get("quarter") or ""
description = data.get("description")
@@ -66,7 +224,7 @@ def create_objective(
if not title or not quarter:
raise HTTPException(422, "缺少必填字段: title, quarter")
obj = Objective(title=title, quarter=quarter, description=description,
dimension=dimension, owner=owner)
dimension=dimension, owner=owner, entity_id=entity_id)
db.add(obj)
db.commit()
db.refresh(obj)
@@ -74,12 +232,10 @@ def create_objective(
@router.get("/{obj_id}")
def get_objective(obj_id: int, db: Session = Depends(get_db)):
"""获取单个OKR详情"""
obj = db.query(Objective).filter(Objective.id == obj_id).first()
if not obj:
raise HTTPException(404, "目标不存在")
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
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,
@@ -87,31 +243,195 @@ def get_objective(obj_id: int, db: Session = Depends(get_db)):
"owner": obj.owner, "status": obj.status, "progress": obj.progress,
"confidence": obj.confidence,
},
"key_results": [
{"id": kr.id, "title": kr.title, "kpi_id": kr.kpi_id,
"status": kr.status, "progress": kr.progress,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
"assignee": kr.assignee}
for kr in krs
]
"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)):
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进度"""
obj = db.query(Objective).filter(Objective.id == okr_id).first()
if not obj:
raise HTTPException(404, "OKR不存在")
kr = db.query(ActionPlan).filter(
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
).first()
_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 必须是数组")
# 规范化:只保留 month/label/status/target 字段
normalized = []
for ms in milestones:
normalized.append({
@@ -121,44 +441,45 @@ def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depe
"target": ms.get("target"),
})
kr.monthly_milestones = normalized
# 里程碑完成 → KR进度同步更新(完成数/总数)
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 = "completed"
elif kr.status == "completed" and 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)):
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
obj = db.query(Objective).filter(Objective.id == okr_id).first()
if not obj:
raise HTTPException(404, "OKR不存在")
_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(ActionPlan).filter(
ActionPlan.id == kr_id, ActionPlan.objective_id == okr_id
).first()
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 = _build_auto_milestones(kr)
kr.monthly_milestones = milestones
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: ActionPlan) -> list:
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)
# 计算前3个月份(从最早的月份开始)
month_keys = []
for i in range(3, 0, -1):
y, m = base.year, base.month - i
@@ -177,34 +498,35 @@ def _build_auto_milestones(kr: ActionPlan) -> list:
]
@router.patch("/{obj_id}")
def update_objective(obj_id: int, db: Session = Depends(get_db)):
"""更新OKR进度(通过查询ActionPlan自动计算)"""
obj = db.query(Objective).filter(Objective.id == obj_id).first()
if not obj:
raise HTTPException(404, "目标不存在")
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == obj_id).all()
if krs:
obj.progress = sum(kr.progress for kr in krs) // len(krs)
db.commit()
return {"ok": True, "id": obj_id, "progress": obj.progress}
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)):
"""获取OKR的时间分解视图数据"""
okr = db.query(Objective).filter(Objective.id == okr_id).first()
if not okr:
raise HTTPException(404, "OKR不存在")
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.quarter.is_(None),
Objective.entity_id == entity_id,
).first()
# 2. 本OKR的所有KR关联到该Objective的ActionPlan
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == okr_id).all()
# 2. 本OKR的所有KRkrs表
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
# 3. 当前周的ActionPlan(本周行动计划)
now = datetime.now()
+14 -1
View File
@@ -68,6 +68,7 @@ def list_okr_templates(
"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,
@@ -93,6 +94,7 @@ def get_okr_template(template_id: int, db: Session = Depends(get_db)):
"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,
@@ -101,7 +103,7 @@ def get_okr_template(template_id: int, db: Session = Depends(get_db)):
@router.post("")
def create_okr_template(data: dict, db: Session = Depends(get_db)):
"""用户自定义 OKR 模板"""
"""用户自定义 OKR 模板(治理: 同名去重 + owner 标记)"""
name = data.get("name", "").strip()
if not name:
raise HTTPException(400, "模板名称不能为空")
@@ -111,6 +113,14 @@ def create_okr_template(data: dict, db: Session = Depends(get_db)):
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,
@@ -120,6 +130,7 @@ def create_okr_template(data: dict, db: Session = Depends(get_db)):
industry_tag=data.get("industry_tag", "general"),
preset_krs=preset_krs,
source="user",
owner="用户自定义",
sort_order=data.get("sort_order", 0),
)
db.add(t)
@@ -164,6 +175,8 @@ def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_d
"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
],
+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}
+206 -1
View File
@@ -1,6 +1,6 @@
"""预测模拟API — 管理会计OS"""
import logging
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi import APIRouter, HTTPException, Depends, Request, Query
from app.utils.predict_engine import (
cvp_analysis, npv, irr,
sensitivity_analysis, scenario_analysis,
@@ -161,6 +161,97 @@ def api_cvp_detailed(data: dict):
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事前预警) ────────────────────────────────────
@@ -714,3 +805,117 @@ def api_growth_quality(request: Request, data: dict):
}
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,
}
+620 -113
View File
File diff suppressed because it is too large Load Diff
+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")
+52 -11
View File
@@ -8,6 +8,12 @@ POST /api/cma/verify/{plan_id} 手动验证行动计划执行结果
验证通过 所属OKR progress +15%
通知任总 (send_wecom_message)
verify 入口关系2026-08-30 评审收敛暂不重构:
- 本文件: /api/cma/verify/{plan_id} 完整链路回填KPIValue + OKR联动 + 企微通知
- bot_bridge_v2.py: /api/cma/bot-bridge/verify/{action_plan_id} 轻量版仅记 verify_log
不回填KPIValue不联动OKR不通知供财务Bot/研学Bot桥接通道调用
- 两者行为不一致勿混用Bot 通道走 bot_bridge_v2业务侧手动/自动重验走本文件
规则格式:
{
"kpi_code": "C_REBATE_RATE",
@@ -86,14 +92,22 @@ def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool:
return False
def update_okr_progress(db: Session, plan: ActionPlan) -> dict:
"""验证通过 → 所属OKR progress +15%(每通过1个KR"""
def update_okr_progress(db: Session, plan: ActionPlan, already_verified: bool = False, force_recalc: bool = False) -> dict:
"""验证通过 → 所属OKR progress +15%(每通过1个KR
缺陷1修复2026-08-30幂等防重复累加
- already_verified=True调用前 plan verify_status=='passed' verified_at 非空
跳过累加返回当前值保持原值
- force_recalc=True 时强制重新累加业务确需重验场景由调用方显式开启默认 False
"""
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
if already_verified and not force_recalc:
return {"updated": False, "reason": "already_verified", "objective_id": obj.id, "before": before, "after": before}
obj.progress = min(100, before + 15)
db.flush()
return {"updated": True, "objective_id": obj.id, "before": before, "after": obj.progress}
@@ -148,12 +162,26 @@ def build_auto_verify_rule(kpi, baseline_value=None, verify_after_days: int = 7)
def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source: str = "verify"):
"""回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录"""
"""回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录
缺陷2修复2026-08-30多租户隔离
- KPIValue 创建时设置 entity_id plan 关联 KPI 定义取 plan.kpi_id kpi_definitions.entity_id
- kpi_code 查询 KPIDefinition 时带 entity_id 过滤防跨租户误匹配 kpi_code
"""
kpi = None
kpi_code = rule.get("kpi_code") if rule else None
# 优先按验证规则指定的KPI编码查询;无规则时才回退到plan.kpi_id
# 确定 plan 所属 entity_id(从 plan.kpi_id → KPIDefinition.entity_id 向上取)
entity_id = None
if plan.kpi_id:
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
if pkpi:
entity_id = pkpi.entity_id
# 优先按验证规则指定的KPI编码查询(带 entity_id 过滤);无规则时才回退到plan.kpi_id
if kpi_code:
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
if entity_id is not None:
q = q.filter(KPIDefinition.entity_id == entity_id)
kpi = q.first()
elif plan.kpi_id:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
@@ -166,6 +194,7 @@ def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source
if kpi:
new_val = KPIValue(
kpi_id=kpi.id,
entity_id=kpi.entity_id, # 缺陷2修复:多租户回填 entity_id
period=datetime.now().strftime("%Y-%m"),
actual_value=actual,
source_type="verify",
@@ -215,19 +244,27 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
db.commit()
return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed}
# 2. 缺省actual → 取KPI最新值
# 2. 缺省actual → 取KPI最新值(缺陷3修复:按 period <= 当前月过滤,跨月验证不取历史期间;支持调用方显式传 period 覆盖,默认当前月)
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()
# 缺陷2修复:kpi_code 查询带 entity_id 过滤(防跨租户误匹配)
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
if plan.kpi_id:
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
if pkpi and pkpi.entity_id is not None:
q = q.filter(KPIDefinition.entity_id == pkpi.entity_id)
kpi = q.first()
if kpi:
period_limit = payload.get("period") or datetime.now().strftime("%Y-%m")
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()
KPIValue.period <= period_limit, # 缺陷3修复:只取当前月及之前的期间
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
if latest:
actual = latest.actual_value
@@ -237,6 +274,10 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
# 4. 回写KPI当前值
backfill = backfill_kpi_value(db, plan, actual, rule, source=source)
# 缺陷1修复:调用前先记录 plan 是否已处于"验证通过"状态(防止重复累加 OKR progress
already_verified = bool(plan.verify_status == "passed" and plan.verified_at is not None)
force_recalc = bool(payload.get("force_recalc", False))
# 5. 更新状态
plan.verify_status = "passed" if passed else "failed"
plan.verify_result = "pass" if passed else "fail"
@@ -250,14 +291,14 @@ def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db
"note": note,
}]
if passed:
plan.status = "done"
plan.status = "completed" # 缺陷4修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
plan.progress = 100
plan.verified_at = datetime.now()
# 6. OKR进度联动(验证通过 → +15%)
# 6. OKR进度联动(验证通过 → +15%;缺陷1修复:已通过过的 plan 不再重复累加,force_recalc 可强制重算
okr_update = None
if passed:
okr_update = update_okr_progress(db, plan)
okr_update = update_okr_progress(db, plan, already_verified=already_verified, force_recalc=force_recalc)
db.commit()
+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,
}
+41
View File
@@ -97,6 +97,47 @@ def init_db():
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层级组织示例数据"""
+13 -1
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, 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, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, ai_suggestions, 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,6 +31,9 @@ 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)
@@ -39,6 +43,7 @@ app.include_router(dashboard.router)
app.include_router(data.router)
app.include_router(alerts.router)
app.include_router(ai_analysis.router)
app.include_router(ai_suggestions.router)
app.include_router(alert_rules.router)
app.include_router(users.router)
app.include_router(thresholds.router)
@@ -50,9 +55,14 @@ 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)
@@ -74,10 +84,12 @@ 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)
+273 -5
View File
@@ -1,11 +1,12 @@
"""管理会计OS 数据模型"""
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, UniqueConstraint
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):
@@ -47,6 +48,7 @@ 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")
@@ -75,11 +77,18 @@ class KPIDefinition(Base):
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")
@@ -95,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="实际值")
@@ -109,6 +119,7 @@ class DataSourceConfig(Base):
"""数据源配置"""
__tablename__ = "data_source_config"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
name = Column(String(200), nullable=False, comment="数据源名称")
source_type = Column(String(20), nullable=False, comment="erp/business/excel")
api_endpoint = Column(String(500), nullable=True, comment="API地址")
@@ -124,6 +135,7 @@ class KPIAlert(Base):
"""预警记录"""
__tablename__ = "kpi_alerts"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
kpi_id = Column(Integer, ForeignKey("kpi_definitions.id"), nullable=False)
kpi_value_id = Column(Integer, ForeignKey("kpi_values.id"), nullable=True)
alert_level = Column(String(20), default="yellow", comment="green/yellow/red")
@@ -189,6 +201,7 @@ 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")
@@ -206,9 +219,11 @@ class ActionPlan(Base):
"""改善行动计划"""
__tablename__ = "action_plans"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
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="负责人")
@@ -235,6 +250,7 @@ class OrgNode(Base):
"""组织节点: 集团→事业部→区域→部门→班组 5级"""
__tablename__ = "org_nodes"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
parent_id = Column(Integer, ForeignKey("org_nodes.id"), nullable=True, comment="父节点ID")
name = Column(String(100), nullable=False, comment="节点名称")
code = Column(String(50), unique=True, nullable=True, comment="编码")
@@ -251,6 +267,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="维度数据快照")
@@ -260,10 +277,27 @@ 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="目标名称")
@@ -278,12 +312,17 @@ 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())
@@ -299,6 +338,21 @@ class MpmResult(Base):
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"
@@ -327,6 +381,7 @@ 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="报表配置")
@@ -340,6 +395,7 @@ 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="报表配置(行/列/值)")
@@ -406,6 +462,7 @@ class OKRTemplate(Base):
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)
@@ -427,16 +484,21 @@ class CashForecast(Base):
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="计划日期")
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())
@@ -456,6 +518,7 @@ 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="预算值")
@@ -465,6 +528,9 @@ class BudgetDeviationAlert(Base):
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())
@@ -472,6 +538,7 @@ class ReportHistory(Base):
"""自动生成的经营分析报告记录"""
__tablename__ = "report_history"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-31 安全修复)")
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="报告标题")
@@ -543,6 +610,9 @@ class Subject(Base):
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())
@@ -550,9 +620,10 @@ class Subject(Base):
class VoucherDetail(Base):
"""凭证明细 — 新30号准则分类"""
"""凭证明细 — 新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="科目编码")
@@ -560,8 +631,27 @@ class VoucherDetail(Base):
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())
@@ -573,6 +663,7 @@ 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="维度值: 部门名/人员名/费用类型(空=全局)")
@@ -590,6 +681,7 @@ 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="部门")
@@ -672,3 +764,179 @@ class SocialSecurity(Base):
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)
class AISuggestion(Base):
"""AI决策建议 — 一键应用到KPI/预算/行动方案 (路线图R1 2026-08-30)"""
__tablename__ = "ai_suggestions"
id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, default=1, comment="企业ID")
user_id = Column(Integer, nullable=True, comment="建议创建人ID")
source = Column(String(30), default="dashboard", comment="来源: dashboard/kpi/budget/manual/rule")
suggestion_type = Column(String(30), nullable=False, comment="kpi_target/budget_adjust/action_plan")
target_type = Column(String(30), nullable=False, comment="kpi/budget/action_plan")
category = Column(String(20), default="decision", comment="分类: decision决策类 / alert预警类(预警类不推送)")
pushed = Column(Integer, default=0, comment="决策类建议是否已推送企微 0/1(防轰炸)")
target_id = Column(Integer, nullable=True, comment="目标ID (KPI ID/预算KPI ID等)")
title = Column(String(300), nullable=False, comment="建议标题")
content = Column(Text, nullable=True, comment="建议内容/理由")
suggestion_data = Column(JSON, nullable=True, comment="应用参数: {target_value, period, budget_value, plan_title, ...}")
status = Column(String(20), default="unapplied", comment="unapplied/applied/dismissed")
applied_by = Column(String(100), nullable=True, comment="应用人姓名")
applied_user_id = Column(Integer, nullable=True, comment="应用人ID")
applied_at = Column(DateTime, nullable=True, comment="应用时间")
apply_detail = Column(JSON, nullable=True, comment="应用结果明细: [{target_type,target_id,action,before,after}]")
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+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="产品/服务编码")
+1
View File
@@ -23,6 +23,7 @@ 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")
+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
@@ -0,0 +1,238 @@
"""因果链验证核心逻辑 — 数据验证(Pearson相关性)+ 状态机 (2026-08-27 P2)
三层验证
1. 数据验证自动化kpi_values 历史值 Pearson 相关系数 + 方向一致性 + 滞后对齐
2. AI/人工验证战略回顾会人工打标 human_verifiedAPI PUT /verify
3. 状态机流转
pending初始 data_verified / disputed数据验证 cron
pending/data_verified/disputed human_verified人工确认最终
任何矛盾 disputed待检
判定规则可解释可测试
- 对齐后数据点 < MIN_POINTS(4) pending数据不足无法统计验证
- |r| >= CORR_THRESHOLD(0.5) 且方向与 direction 一致 data_verified
- 否则点数足够但弱相关/方向矛盾 disputed
- human_verified 为人工最终确认脚本默认不覆盖respect_human=True
但若数据矛盾会在报告中给出警示disputed_note
脚本入口: scripts/correlation-check.py
测试入口: tests/test_causality_verification.py
"""
from __future__ import annotations
import logging
import math
import re
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("causality-verification")
# 验证状态
STATUS_PENDING = "pending"
STATUS_DATA_VERIFIED = "data_verified"
STATUS_HUMAN_VERIFIED = "human_verified"
STATUS_DISPUTED = "disputed"
ALL_STATUSES = (STATUS_PENDING, STATUS_DATA_VERIFIED, STATUS_HUMAN_VERIFIED, STATUS_DISPUTED)
# 建链来源
SOURCE_MANUAL = "manual"
SOURCE_AI = "AI_suggested"
SOURCE_IMPORTED = "imported"
ALL_SOURCE_TYPES = (SOURCE_MANUAL, SOURCE_AI, SOURCE_IMPORTED)
# 判定参数
MIN_POINTS = 4 # 最少对齐数据点(少于则无法统计验证)
CORR_THRESHOLD = 0.5 # |r| 阈值:达到且方向一致 → 数据证实
VERIFIER_SCRIPT = "correlation-check"
# period 粒度(对齐时只允许同粒度配对,避免月度/年度量纲混用)
GRANULARITY_ORDER = ("month", "half", "year")
_PERIOD_RE = {
"month": re.compile(r"^(\d{4})-(\d{2})$"),
"half": re.compile(r"^(\d{4})-H([12])$"),
"year": re.compile(r"^(\d{4})$"),
}
def parse_period(period: str) -> Optional[Tuple[str, int]]:
"""解析 period 为 (granularity, seq)。
seq = year*12 + 月序号0-indexed可比较/做滞后偏移
- '2026-07' ('month', 2026*12+6)
- '2026-H1' ('half', 2026*12+5) (H16)
- '2026-H2' ('half', 2026*12+11) (H212)
- '2026' ('year', 2026*12+5) (年中)
无法解析 None
"""
if not period:
return None
s = str(period).strip()
m = _PERIOD_RE["month"].match(s)
if m:
year, mon = int(m.group(1)), int(m.group(2))
if 1 <= mon <= 12:
return ("month", year * 12 + (mon - 1))
m = _PERIOD_RE["half"].match(s)
if m:
year, half = int(m.group(1)), int(m.group(2))
return ("half", year * 12 + (5 if half == 1 else 11))
m = _PERIOD_RE["year"].match(s)
if m:
return ("year", int(m.group(1)) * 12 + 5)
return None
def pearson(xs: List[float], ys: List[float]) -> Tuple[Optional[float], int]:
"""Pearson 相关系数。点数 < 2 返回 (None, n)。"""
n = len(xs)
if n < 2:
return None, n
mx = sum(xs) / n
my = sum(ys) / n
sxy = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
sxx = sum((x - mx) ** 2 for x in xs)
syy = sum((y - my) ** 2 for y in ys)
if sxx <= 0 or syy <= 0: # 常数列 → 无相关
return None, n
r = sxy / math.sqrt(sxx * syy)
# 数值保护:浮点误差可能略超 [-1,1]
return max(-1.0, min(1.0, r)), n
def align_series(
source_values: List[Tuple[str, float]],
target_values: List[Tuple[str, float]],
lag_months: int = 0,
) -> Tuple[Optional[str], List[Tuple[float, float]]]:
"""按滞后期对齐 source/target 时间序列,返回 (granularity, pairs)。
语义source 是因先发生target 是果滞后 lag 月出现
pair = (source[t], target[t + lag])
只使用同粒度month/half/year数据配对避免量纲混用
按粒度优先级 month > half > year 选取数据点最多的粒度
"""
parsed_src: Dict[str, Dict[int, float]] = {g: {} for g in GRANULARITY_ORDER}
parsed_tgt: Dict[str, Dict[int, float]] = {g: {} for g in GRANULARITY_ORDER}
for period, val in source_values:
if val is None:
continue
r = parse_period(period)
if r:
g, seq = r
parsed_src[g][seq] = float(val)
for period, val in target_values:
if val is None:
continue
r = parse_period(period)
if r:
g, seq = r
parsed_tgt[g][seq] = float(val)
best_g, best_pairs = None, []
for g in GRANULARITY_ORDER:
src_map, tgt_map = parsed_src[g], parsed_tgt[g]
pairs = []
for seq, sv in sorted(src_map.items()):
tv = tgt_map.get(seq + lag_months)
if tv is not None:
pairs.append((sv, tv))
if len(pairs) > len(best_pairs):
best_g, best_pairs = g, pairs
return best_g, best_pairs
def evaluate_chain(
source_values: List[Tuple[str, float]],
target_values: List[Tuple[str, float]],
lag_months: int = 0,
direction: str = "positive",
min_points: int = MIN_POINTS,
corr_threshold: float = CORR_THRESHOLD,
) -> dict:
"""对单条因果链做数据验证。
返回:
{
granularity, n, r, expected_sign, actual_sign, direction_consistent,
status (pending/data_verified/disputed), reason
}
"""
granularity, pairs = align_series(source_values, target_values, lag_months)
n = len(pairs)
r = None
if n >= 2:
r, _ = pearson([p[0] for p in pairs], [p[1] for p in pairs])
expected_sign = 1 if direction == "positive" else -1
actual_sign = 1 if r is not None and r > 0 else (-1 if r is not None and r < 0 else 0)
direction_consistent = r is not None and actual_sign == expected_sign
if n < min_points or r is None:
return {
"granularity": granularity, "n": n, "r": r,
"expected_sign": expected_sign, "actual_sign": actual_sign,
"direction_consistent": direction_consistent,
"status": STATUS_PENDING,
"reason": f"数据不足(对齐后{n}点,需≥{min_points}点)" if n < min_points else "序列无方差,无法计算相关性",
}
abs_r = abs(r)
if abs_r >= corr_threshold and direction_consistent:
return {
"granularity": granularity, "n": n, "r": r,
"expected_sign": expected_sign, "actual_sign": actual_sign,
"direction_consistent": True,
"status": STATUS_DATA_VERIFIED,
"reason": f"|r|={abs_r:.3f}{corr_threshold} 且方向一致({direction}) → 数据证实",
}
if not direction_consistent:
return {
"granularity": granularity, "n": n, "r": r,
"expected_sign": expected_sign, "actual_sign": actual_sign,
"direction_consistent": False,
"status": STATUS_DISPUTED,
"reason": f"方向矛盾: 声明{direction}但实际相关方向{'' if r > 0 else ''} (r={r:.3f})",
}
return {
"granularity": granularity, "n": n, "r": r,
"expected_sign": expected_sign, "actual_sign": actual_sign,
"direction_consistent": True,
"status": STATUS_DISPUTED,
"reason": f"弱相关: |r|={abs_r:.3f}<{corr_threshold},数据暂不能证实该因果强度",
}
def apply_state_machine(
current_status: str,
eval_status: str,
respect_human: bool = True,
) -> Tuple[str, Optional[str]]:
"""状态机:根据数据验证结果流转状态。
规则:
- human_verified 是人工最终确认: respect_human=True 时不被脚本覆盖
返回原状态 + disputed_note 警示
- 数据不足(pending) 保持当前状态不降级已有结论
- data_verified 覆盖为非 human_verified 的当前状态
- disputed 覆盖为非 human_verified 的当前状态
"""
if current_status == STATUS_HUMAN_VERIFIED and respect_human:
if eval_status == STATUS_DISPUTED:
return current_status, "人工已确认但数据复核矛盾,建议重新核对"
return current_status, None
if eval_status == STATUS_PENDING:
return current_status, None
return eval_status, None
def summarize(results: List[dict]) -> dict:
"""验证结果汇总统计。"""
counter = {s: 0 for s in ALL_STATUSES}
for r in results:
counter[r.get("status", STATUS_PENDING)] = counter.get(r.get("status", STATUS_PENDING), 0) + 1
return {
"total": len(results),
"by_status": counter,
"data_verified_ratio": round(counter[STATUS_DATA_VERIFIED] / len(results), 3) if results else 0,
}
+203
View File
@@ -0,0 +1,203 @@
"""告警归因引擎 — 管理会计OS (P1-③ 2026-08-28)
告警从"差多少""差在哪+怎么办"
- 子KPI维度拆解: kpi_hierarchy 下级KPI各自差异量差方向
- 科目明细拆解: kpi_subject_map voucher_details 汇总科目发生额价差方向
- 趋势归因: 复用 deviation_engine.check_trend_anomaly 连续3期检测
- 场景建议: alert_type 联查 scenario_suggestions
attribution JSON 结构:
{
"dimensions": [{"kpi_id":1,"kpi_name":"销售费用","deviation_value":-3.2,"deviation_rate":-18.6,"weight":0.5}],
"subjects": [{"subject_code":"6601","subject_name":"销售费用","amount_diff":2.1,"share_pct":34.5}],
"variance_type": "quantity_diff|price_diff|mixed",
"trend": {"anomaly":true,"type":"continuous_decline","periods":["2026-06","2026-07","2026-08"],"message":"连续3期下滑"}
}
"""
import logging
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.models import KPIHierarchy, KPISubjectMap, VoucherDetail, ScenarioSuggestion, KPIDefinition
logger = logging.getLogger("cma.alert_attribution")
# 收入型KPI特征(量差方向: 子KPI量级偏离)
REVENUE_TYPE_CODES = (
"SALES_TOTAL", "REVENUE", "F_REVENUE", "SALES_PROFIT_RATE",
"CUSTOMER_COUNT", "NEW_CUSTOMER", "TURNOVER_RATE",
)
def build_dimension_attribution(db: Session, kpi_id: int, period: str) -> list:
"""子KPI维度拆解 — 查 kpi_hierarchy 下级KPI各自差异(实际vs预算)"""
from app.models import KPIValue, BudgetPlan
children = db.query(KPIHierarchy).filter(
KPIHierarchy.parent_kpi_id == kpi_id
).all()
if not children:
return []
result = []
for rel in children:
child_id = rel.child_kpi_id
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == child_id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
budget = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == child_id,
BudgetPlan.period == period,
BudgetPlan.status == "active",
).order_by(BudgetPlan.updated_at.desc()).first()
av = actual.actual_value if actual else None
bv = budget.budget_value if budget else None
dev_value = None
dev_rate = None
if av is not None and bv is not None and bv != 0:
dev_value = round(av - bv, 2)
dev_rate = round(dev_value / bv * 100, 2)
child_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == child_id).first()
result.append({
"kpi_id": child_id,
"kpi_name": child_kpi.kpi_name if child_kpi else f"KPI-{child_id}",
"actual_value": av,
"budget_value": bv,
"deviation_value": dev_value,
"deviation_rate": dev_rate,
"weight": float(rel.weight or 0),
})
# 按偏差绝对值降序,最异常的排前面
result.sort(key=lambda x: -(abs(x["deviation_value"]) if x["deviation_value"] is not None else 0))
return result
def build_subject_attribution(db: Session, kpi_id: int, period: str) -> list:
"""科目明细拆解 — 查 kpi_subject_map → voucher_details 汇总科目发生额"""
mappings = db.query(KPISubjectMap).filter(KPISubjectMap.kpi_id == kpi_id).all()
if not mappings:
return []
result = []
for m in mappings:
q = db.query(
func.coalesce(func.sum(VoucherDetail.debit_amount), 0),
func.coalesce(func.sum(VoucherDetail.credit_amount), 0),
).filter(
VoucherDetail.subject_code == m.subject_code,
VoucherDetail.period == period,
)
row = q.first()
debit_sum = float(row[0] or 0)
credit_sum = float(row[1] or 0)
# 方向: credit贷方(收入/流入) / debit借方(费用/流出)
if m.calc_type == "ratio":
amount = credit_sum - debit_sum
elif m.calc_type in ("avg", "other"):
amount = (credit_sum - debit_sum) / 2
else: # sum
amount = credit_sum - debit_sum
amount = round(amount * float(m.weight or 1.0), 2)
result.append({
"subject_code": m.subject_code,
"subject_name": m.remark or m.subject_code,
"amount_diff": amount,
"calc_type": m.calc_type,
"weight": float(m.weight or 1.0),
})
total = sum(abs(r["amount_diff"]) for r in result) or 0
for r in result:
r["share_pct"] = round(abs(r["amount_diff"]) / total * 100, 1) if total else 0
result.sort(key=lambda x: -abs(x["amount_diff"]))
return result
def detect_variance_type(kpi_code: str, dimensions: list, subjects: list) -> str:
"""量价差判定简化版:
成本型KPI科目发生额偏离 price_diff价差
收入型KPI子KPI量级偏离 quantity_diff量差
两者都有 mixed
"""
has_dimension_dev = any(d.get("deviation_value") is not None and abs(d["deviation_value"]) > 0.01 for d in dimensions)
has_subject_dev = any(abs(s.get("amount_diff", 0)) > 0.01 for s in subjects)
is_revenue = any(code in (kpi_code or "").upper() for code in REVENUE_TYPE_CODES)
if is_revenue:
# 收入型: 子KPI(量)偏离为主 → quantity_diff
if has_dimension_dev:
return "quantity_diff"
if has_subject_dev:
return "price_diff"
return "mixed"
else:
# 成本型: 科目发生额(价)偏离为主 → price_diff
if has_subject_dev:
return "price_diff"
if has_dimension_dev:
return "quantity_diff"
return "mixed"
def match_scenario(db: Session, alert_type: Optional[str]) -> Optional[dict]:
"""按 alert_type 取 scenario_suggestions 建议(四类模板)"""
if not alert_type:
return None
s = db.query(ScenarioSuggestion).filter(
ScenarioSuggestion.alert_type == alert_type
).order_by(ScenarioSuggestion.sort_order.asc(), ScenarioSuggestion.id.asc()).first()
if not s:
return None
return {
"scenario_id": s.id,
"alert_type": s.alert_type,
"title": s.title,
"description": s.description,
"action_template": s.action_template,
"priority": s.priority,
}
def build_attribution(db: Session, kpi_id: int, period: str, alert_type: Optional[str] = None) -> dict:
"""组装完整归因JSON(供告警生成/详情接口共用)"""
from app.utils.deviation_engine import check_trend_anomaly
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi_code = kpi.kpi_code if kpi else ""
dimensions = build_dimension_attribution(db, kpi_id, period)
subjects = build_subject_attribution(db, kpi_id, period)
variance_type = detect_variance_type(kpi_code, dimensions, subjects)
trend = check_trend_anomaly(db, kpi_id, period, consecutive=3)
# 默认场景归类(未显式传入时按KPI名称特征推断)
if not alert_type:
alert_type = infer_alert_type(kpi_code, kpi.kpi_name if kpi else "")
attribution = {
"dimensions": dimensions,
"subjects": subjects,
"variance_type": variance_type,
"trend": trend,
}
return attribution, alert_type
def infer_alert_type(kpi_code: str = "", kpi_name: str = "") -> str:
"""按KPI特征推断告警场景类型(四类: cash_low/cash_critical/cost_high/revenue_drop"""
text = (kpi_code or "").upper() + (kpi_name or "")
if any(k in text for k in ("CASH", "现金", "货币资金", "资金")):
return "cash_low"
if any(k in text for k in ("COST", "费用", "成本", "支出")):
return "cost_high"
if any(k in text for k in ("REVENUE", "收入", "销售", "营收")):
return "revenue_drop"
return "cost_high"
+1 -1
View File
@@ -5,7 +5,7 @@ from app.database import get_session_local
from app.models import KPIDefinition, KPIValue
ERP_API = "http://127.0.0.1:8300"
ERP_KEY = os.environ.get("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
ERP_KEY = os.environ.get("ERP_API_KEY", "")
async def _get(url: str, params: dict = None):
async with httpx.AsyncClient(timeout=20) as c:
+17 -2
View File
@@ -320,6 +320,7 @@ def calculate_accuracy(entity_id: int, db: Session) -> list:
def generate_scenario_suggestion(alert_type: str, kpi_name: str, extra: dict = None) -> dict:
"""根据预警类型生成情景建议"""
extra = extra or {}
suggestions = {
"cash_low": {
"title": "现金流紧张缓解方案",
@@ -624,7 +625,7 @@ def forecast_cash_flow_with_plans(
def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
"""资金预警 — 缺口前3天预警 + 到期未收款提醒,写入预警中心(kpi_alerts)"""
import json as _json
from app.models import KPIAlert, CashPlan
from app.models import KPIAlert, CashPlan, KPIDefinition
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
new_alerts = []
@@ -679,7 +680,21 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
days_late = (today - p.plan_date).days
msg = (f"【到期未收款】应收款{p.counterparty or '客户'} {p.amount:.1f}"
f"原计划{p.plan_date.strftime('%Y-%m-%d')}到期,已逾期{days_late}天未收回")
if _exists(msg):
# 2026-08-30修复: 按plan_id去重(消息含"已逾期X天"动态文本,全文匹配会每天生成重复预警)
existing_plan_alert = db.query(KPIAlert).filter(
KPIAlert.alert_type == "cash_plan",
KPIAlert.status.in_(["pending", "processing"]),
).all()
dup = False
for ea in existing_plan_alert:
try:
es = _json.loads(ea.suggestion) if ea.suggestion else {}
if isinstance(es, dict) and es.get("plan_id") == p.id:
dup = True
break
except Exception:
continue
if dup:
continue
alert = KPIAlert(
kpi_id=ar_kpi.id,
+1 -1
View File
@@ -11,7 +11,7 @@ from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation, KPI
logger = logging.getLogger("cma.cost")
ERP_API = "http://127.0.0.1:8300"
ERP_KEY = os.environ.get("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
ERP_KEY = os.environ.get("ERP_API_KEY", "")
# ============================================================
+110 -31
View File
@@ -8,15 +8,40 @@
4. 差异预警触发集成到现有预警系统
"""
import logging
import json
from datetime import datetime
from typing import Optional
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
logger = logging.getLogger("cma.deviation")
# 越高越好型KPI默认列表(P2-⑤ 2026-08-28: 提为 system_configs 可配置)
DEFAULT_HIGHER_BETTER = [
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
]
CONFIG_KEY_HIGHER_BETTER = "kpi_alert_higher_better"
def get_higher_better_codes(db, entity_id: int = None) -> list:
"""读取越高越好型KPI编码列表(system_configs 可维护,无配置回落默认)"""
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == CONFIG_KEY_HIGHER_BETTER
).first()
if cfg and cfg.config_value:
try:
codes = json.loads(cfg.config_value)
if isinstance(codes, list):
return [str(c) for c in codes]
except Exception:
logger.warning("system_configs[%s] 解析失败, 回落默认", CONFIG_KEY_HIGHER_BETTER)
return list(DEFAULT_HIGHER_BETTER)
# ============================================================
# 差异计算
# ============================================================
@@ -210,8 +235,83 @@ def check_trend_anomaly(db, kpi_id: int, period: str, consecutive: int = 3) -> d
# 差异预警触发
# ============================================================
def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: float = 10.0) -> dict:
"""统一告警构建 — 双出口共享一套逻辑 (P2-⑤ 2026-08-28)
预算告警(budget_deviation_alerts) KPIAlert 都调用本函数差异仅级别映射
- budget 出口: warning/critical @ 20/50
- KPIAlert 出口: yellow/red @ 10/30
归因(P1-): attribution 拆解 + 场景建议 alert_attribution 组装
返回:
triggered: bool 是否触发
level: budget出口级别 warning/critical
kpi_alert_level: KPIAlert出口级别 yellow/red
deviation: calc_period_deviation 结果
suggestion: 模板建议文案
alert_type: 归因场景类型
attribution: 归因JSON dict
scenario_id: 场景建议ID
"""
from app.utils.alert_attribution import build_attribution, match_scenario
deviation = calc_period_deviation(db, kpi.id, period)
if deviation.get("deviation_rate") is None:
return {"triggered": False}
rate = abs(deviation["deviation_rate"])
actual = deviation.get("actual_value")
budget = deviation.get("budget_value")
# 方向性:越高越好型(配置化,system_configs.kpi_alert_higher_better
higher_better = kpi.kpi_code in get_higher_better_codes(db, entity_id)
if higher_better:
# 实际低于预算才是问题
if not (actual is not None and budget is not None and actual < budget and rate >= min_rate):
return {"triggered": False}
suggestion = (
f"实际值低于预算 {rate}%,建议分析业务量未达预期的原因(子KPI拆解见归因),"
f"制定增量获客或转化提升计划"
)
else:
# 实际高于预算才是问题(成本型)
if not (actual is not None and budget is not None and actual > budget and rate >= min_rate):
return {"triggered": False}
suggestion = (
f"实际值超出预算 {rate}%,建议核查超支原因(科目明细拆解见归因)并采取控制措施"
)
# 级别映射(双出口)
budget_level = "critical" if rate > 50 else "warning"
kpi_alert_level = "red" if rate >= 30 else "yellow"
# 归因组装 (P1-③)
alert_type = None
attribution = None
scenario_id = None
try:
attribution, alert_type = build_attribution(db, kpi.id, period)
scenario = match_scenario(db, alert_type)
if scenario:
scenario_id = scenario["scenario_id"]
except Exception as e: # 归因失败不阻断告警主流程
logger.warning("归因组装失败 kpi=%s: %s", kpi.kpi_code, e)
return {
"triggered": True,
"level": budget_level,
"kpi_alert_level": kpi_alert_level,
"deviation": deviation,
"suggestion": suggestion,
"alert_type": alert_type,
"attribution": attribution,
"scenario_id": scenario_id,
}
def run_deviation_check(db_session, period: str = None) -> int:
"""运行差异预警检查,返回新增预警数"""
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alertP2-⑤)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
@@ -221,33 +321,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
new_count = 0
for kpi in kpis:
# 1. 差异预警:实际 vs 预算
deviation = calc_period_deviation(db_session, kpi.id, period)
if deviation.get("deviation_rate") is not None:
rate = abs(deviation["deviation_rate"])
# 差异化阈值:越高越好型 vs 越低越好型
higher_better = kpi.kpi_code in [
"SALES_TOTAL", "CUSTOMER_COUNT", "SALES_PROFIT_RATE",
"RECEIVABLE_TURNOVER", "TURNOVER_RATE",
"CUSTOMER_SATISFACTION", "ORDER_DELIVERY_RATE",
]
if higher_better:
# 实际低于预算才是问题
if deviation["actual_value"] < deviation["budget_value"] and rate >= 10:
level = "yellow" if rate >= 10 else "green"
level = "red" if rate >= 30 else level
else:
continue
else:
# 实际高于预算才是问题(成本型)
if deviation["actual_value"] > deviation["budget_value"] and rate >= 10:
level = "yellow" if rate >= 10 else "green"
level = "red" if rate >= 30 else level
else:
continue
# 1. 差异预警:实际 vs 预算(统一逻辑)
result = build_deviation_alert(db_session, kpi, period)
if result["triggered"]:
deviation = result["deviation"]
alert_msg = (
f"{kpi.kpi_name}[{period}] 差异预警: 实际{deviation['actual_value']} "
f"vs 预算{deviation['budget_value']}"
@@ -265,13 +342,15 @@ def run_deviation_check(db_session, period: str = None) -> int:
if not existing:
alert = KPIAlert(
kpi_id=kpi.id,
alert_level=level,
alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}",
alert_type=result["alert_type"] or "actual",
suggestion=result["suggestion"],
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增差异预警 [{level}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
# 2. 趋势异常检测(每期检查连续3期)
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
+529
View File
@@ -0,0 +1,529 @@
"""KPI预测引擎 — 基于历史KPI值做趋势预测(预测性成本智能 MVP)
模型MVP原则简单可用不上深度学习
- linear 线性回归最小二乘 y = a + b·x输出95%预测区间
- moving_average 简单移动平均默认窗口3期输出均值±波动区间
置信度诚实标注基于历史数据量 + 拟合优度 / 波动率CV综合打分
数据不足时明确给出 low不做虚假高置信
复用 cash_forecast_engine.get_entity_kpi_history 取历史数据不重复写查询
"""
import logging
import math
from typing import Optional
from sqlalchemy.orm import Session
from app.utils.cash_forecast_engine import get_entity_kpi_history, find_kpi
logger = logging.getLogger("cma.kpi_forecast")
MODELS = ("linear", "moving_average")
DEFAULT_PERIODS = 3
TREND_THRESHOLD_PCT = 3.0 # |趋势百分比| ≥ 3% 判定为有明确趋势方向
# 置信度档位
CONF_LEVELS = {3: "high", 2: "medium", 1: "low"}
# 中文映射(供 summary 使用)
TREND_CN = {"up": "上升", "down": "下降", "flat": "基本平稳"}
CONF_CN = {"high": "", "medium": "", "low": ""}
def next_period(period: str, steps: int = 1) -> str:
"""期数递增:"2026-05" + 1 → "2026-06";解析失败时退化为 period+N"""
try:
y, m = str(period).split("-")
total = int(y) * 12 + (int(m) - 1) + steps
return f"{total // 12:04d}-{total % 12 + 1:02d}"
except Exception:
return f"{period}+{steps}"
def _t_crit(n: int) -> float:
"""95%双尾学生t临界值近似(小样本查表取保守值,大样本趋近1.96)"""
table = {
2: 12.71, 3: 4.30, 4: 3.18, 5: 2.78, 6: 2.57, 7: 2.45,
8: 2.31, 9: 2.26, 10: 2.23, 12: 2.18, 15: 2.13,
20: 2.09, 30: 2.04, 60: 2.00,
}
for k in sorted(table):
if n <= k:
return table[k]
return 1.96
def _std(values: list) -> float:
"""样本标准差(n>=2),n==1 返回0"""
n = len(values)
if n < 2:
return 0.0
mean = sum(values) / n
return math.sqrt(sum((v - mean) ** 2 for v in values) / (n - 1))
def _rel_trend_pct(values: list) -> float:
"""趋势百分比 = 线性回归斜率 / |均值| × 100(与 cash_forecast_engine.calc_trend 同口径)"""
n = len(values)
if n < 2:
return 0.0
xbar = (n - 1) / 2.0
ybar = sum(values) / n
sxx = sum((i - xbar) ** 2 for i in range(n))
if sxx == 0:
return 0.0
slope = sum((i - xbar) * (values[i] - ybar) for i in range(n)) / sxx
return slope / max(abs(ybar), 1.0) * 100
def judge_trend(trend_pct: float, threshold: float = TREND_THRESHOLD_PCT) -> str:
"""趋势方向判定:up / down / flat"""
if trend_pct > threshold:
return "up"
if trend_pct < -threshold:
return "down"
return "flat"
def _compute_r2(values: list, pred_fn) -> float:
"""拟合优度 R²(0~1),数据无波动时视为完全拟合"""
ybar = sum(values) / len(values)
ss_tot = sum((v - ybar) ** 2 for v in values)
if ss_tot == 0:
return 1.0
ss_res = sum((v - pred_fn(i)) ** 2 for i, v in enumerate(values))
return max(0.0, 1.0 - ss_res / ss_tot)
def compute_confidence(n: int, model: str, r2: Optional[float] = None,
cv: Optional[float] = None) -> str:
"""置信度诚实标注:数据量基数 + 拟合优度修正
- 数据量n>=12 3n>=6 2否则 1
- linear>=0.7 +1<0.3 -1
- moving_averageCV<0.3 +1低波动更可信CV>0.6 -1
"""
score = 3 if n >= 12 else (2 if n >= 6 else 1)
# 拟合度修正仅在样本量足够时生效:
# n<4 时 R² 无统计意义(2点直线必然R²=1.0),CV 也噪声大,不做上调,避免虚假高置信
if n >= 4:
if model == "linear" and r2 is not None:
if r2 >= 0.7:
score += 1
elif r2 < 0.3:
score -= 1
elif model == "moving_average" and cv is not None:
if cv < 0.3:
score += 1
elif cv > 0.6:
score -= 1
score = max(1, min(3, score))
return CONF_LEVELS[score]
def linear_forecast(values: list, periods: int = 3) -> dict:
"""线性回归预测 — 返回未来periods期预测值 + 95%预测区间 + 拟合统计量"""
n = len(values)
x = list(range(n))
xbar = (n - 1) / 2.0
ybar = sum(values) / n
sxx = sum((i - xbar) ** 2 for i in x)
slope = sum((i - xbar) * (values[i] - ybar) for i in x) / sxx if sxx else 0.0
intercept = ybar - slope * xbar
def pred(i: int) -> float:
return intercept + slope * i
# 残差标准误(n>=3 用 n-2 自由度;n==2 用样本标准差近似)
if n >= 3:
resid = [values[i] - pred(i) for i in x]
se = math.sqrt(sum(r * r for r in resid) / (n - 2))
else:
se = _std(values)
if se == 0:
se = max(abs(ybar) * 0.05, 1e-9) # 完全拟合时给最小带,避免零宽区间
t_crit = _t_crit(n)
forecast = []
for k in range(periods):
x0 = n + k
predicted = pred(x0)
se_pred = se * math.sqrt(1.0 + 1.0 / n + (x0 - xbar) ** 2 / max(sxx, 1e-9)) * t_crit
band = max(se_pred, abs(predicted) * 0.02)
forecast.append({
"predicted": round(predicted, 2),
"lower": round(predicted - band, 2),
"upper": round(predicted + band, 2),
})
r2 = _compute_r2(values, pred)
trend_pct = slope / max(abs(ybar), 1.0) * 100
return {
"forecast": forecast,
"slope": slope,
"intercept": intercept,
"r2": round(r2, 3),
"trend_pct": round(trend_pct, 2),
"se": round(se, 4),
}
def moving_average_forecast(values: list, periods: int = 3, window: int = 3) -> dict:
"""简单移动平均预测 — 未来各期预测值 = 最近window期均值;区间=均值±1.96×波动"""
n = len(values)
w = max(1, min(window, n))
base = sum(values[-w:]) / w
std = _std(values)
if std == 0:
std = max(abs(base) * 0.05, 1e-9)
band = max(1.96 * std, abs(base) * 0.02)
forecast = [{
"predicted": round(base, 2),
"lower": round(base - band, 2),
"upper": round(base + band, 2),
} for _ in range(periods)]
cv = std / abs(base) if base else 0.0
trend_pct = _rel_trend_pct(values)
return {
"forecast": forecast,
"window": w,
"mean": round(base, 2),
"std": round(std, 4),
"cv": round(cv, 3),
"trend_pct": round(trend_pct, 2),
}
def build_summary(kpi_name: str, unit: str, trend: str, next_target: Optional[float],
periods: int, n_history: int, confidence: str, model: str) -> str:
"""中文一句话解读"""
trend_cn = TREND_CN.get(trend, trend)
conf_cn = CONF_CN.get(confidence, confidence)
unit_txt = unit or ""
if periods <= 0:
return f"基于{n_history}期历史数据,{kpi_name}当前趋势{trend_cn}(模型:{model},置信度:{conf_cn}),未请求未来期数预测"
target_txt = f"{next_target:,.2f}{unit_txt}" if next_target is not None else ""
return (
f"基于{n_history}期历史数据,{kpi_name}未来{periods}期预计{trend_cn}"
f"下一期预测值约{target_txt}(模型:{model},置信度:{conf_cn}"
)
def forecast_kpi(entity_id: int, kpi_code: str, db: Session,
periods: int = DEFAULT_PERIODS, model: str = "linear") -> Optional[dict]:
"""单个KPI预测(多租户隔离:历史数据通过 entity_id 维度查询)
返回 None 表示 KPI 不存在或历史数据不足<2
"""
if model not in MODELS:
model = "linear"
history = get_entity_kpi_history(entity_id, kpi_code, db, limit_months=120)
if not history:
return None
hist_asc = list(reversed(history)) # 按 period 升序
values = [float(v.actual_value) for v in hist_asc if v.actual_value is not None]
if len(values) < 2:
return None
kpi_def = find_kpi(db, entity_id, [kpi_code])
kpi_name = str(kpi_def.kpi_name) if kpi_def else kpi_code
unit = str(kpi_def.unit or "") if kpi_def else ""
if model == "moving_average":
res = moving_average_forecast(values, periods)
confidence = compute_confidence(len(values), model, cv=res["cv"])
else:
res = linear_forecast(values, periods)
confidence = compute_confidence(len(values), model, r2=res["r2"])
trend = judge_trend(res["trend_pct"])
# 未来期数(基于最近一期 period 递增)
last_period = hist_asc[-1].period
forecast = []
for k in range(periods):
fp = res["forecast"][k]
forecast.append({
"period": next_period(last_period, k + 1),
"predicted": fp["predicted"],
"lower": fp["lower"],
"upper": fp["upper"],
})
next_target = forecast[0]["predicted"] if forecast else None
summary = build_summary(kpi_name, unit, trend, next_target, periods,
len(values), confidence, model)
return {
"entity_id": entity_id,
"kpi": {"code": kpi_code, "name": kpi_name, "unit": unit},
"model": model,
"periods": periods,
"trend": trend,
"trend_pct": res["trend_pct"],
"confidence": confidence,
"history_count": len(values),
"history": [{"period": v.period, "value": round(float(v.actual_value), 2)} for v in hist_asc],
"forecast": forecast,
"next_target": next_target,
"summary": summary,
}
def forecast_finance_kpis(entity_id: int, db: Session,
periods: int = DEFAULT_PERIODS, model: str = "linear",
min_history: int = 3) -> list:
"""批量预测该企业全部财务维度KPI(历史≥min_history条),按可预测性排序"""
from app.models import KPIDefinition
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.dimension == "finance",
KPIDefinition.status == "active",
).all()
results = []
for kpi in kpis:
r = forecast_kpi(entity_id, str(kpi.kpi_code), db, periods=periods, model=model)
if r and r["history_count"] >= min_history:
results.append(r)
# 可预测性排序:置信度(high=3/medium=2/low=1) 优先,其次历史数据量
score = {"high": 3, "medium": 2, "low": 1}
results.sort(key=lambda r: (score.get(r["confidence"], 0), r["history_count"]), reverse=True)
return results
def save_forecast_logs(entity_id: int, results: list, db: Session, model: str = "linear") -> int:
"""预测结果落库 kpi_forecast_log(预测偏差告警数据源, 2026-08-25 升级2a
存每KPI的下一期预测同KPI同预测期覆盖保留最新"""
from app.models import KpiForecastLog, KPIDefinition
# 预加载 KPI id 映射(返回结果里的 kpi 无 id 字段,需从DB查)
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id).all()}
saved = 0
for r in results:
kpi_info = r.get("kpi", {})
fc = r.get("forecast") or []
if not fc:
continue
first = fc[0]
period = first.get("period", "")
val = first.get("predicted") or first.get("value") or first.get("next_value")
if not period or val is None:
continue
kpi_code = kpi_info.get("code", "")
kpi_id = kpi_info.get("id") or kpi_map.get(kpi_code)
if not kpi_id:
continue
# 同KPI同预测期覆盖
existing = db.query(KpiForecastLog).filter(
KpiForecastLog.entity_id == entity_id,
KpiForecastLog.kpi_id == kpi_id,
KpiForecastLog.period == period,
).first()
if existing:
existing.forecast_value = float(val)
existing.model = model
existing.confidence = r.get("confidence")
existing.trend = r.get("trend")
else:
db.add(KpiForecastLog(
entity_id=entity_id, kpi_id=kpi_id, kpi_code=kpi_code,
period=period, forecast_value=float(val),
model=model, confidence=r.get("confidence"), trend=r.get("trend"),
))
saved += 1
db.commit()
return saved
# ════════════════════════════════════════════════════════════
# 宏观敏感性因素联动(IMA 2026.7 Predictive Cost Intelligence 完整版)
# 内置宏观因素 → 按KPI类型推断弹性系数 → 调整预测值
# MVP:弹性系数为规则推断+可调,非历史回归(诚实标注"模型弹性")
# v2(2026-08-25): 内置宏观历史数据 → 变化率弹性校准(有数据用回归,无数据回退规则)
# ════════════════════════════════════════════════════════════
MACRO_FACTORS = [
{"key": "oil", "name": "原油价格", "unit": "美元/桶",
"desc": "油价↑ → 运输/能源成本↑ → 成本类KPI↑、利润类KPI↓"},
{"key": "usd", "name": "美元汇率", "unit": "USD/CNY",
"desc": "美元↑ → 进口成本↑(成本类↑)、出口收入↑(营收类↑)"},
{"key": "cpi", "name": "CPI通胀率", "unit": "%",
"desc": "CPI↑ → 成本↑、名义营收↑"},
]
# 内置宏观因素历史数据(月度,2026-01 ~ 2026-07,供变化率弹性校准)
MACRO_FACTOR_HISTORY = {
"oil": [
{"period": "2026-01", "value": 74.0}, {"period": "2026-02", "value": 78.0},
{"period": "2026-03", "value": 76.0}, {"period": "2026-04", "value": 82.0},
{"period": "2026-05", "value": 79.0}, {"period": "2026-06", "value": 85.0},
{"period": "2026-07", "value": 88.0},
],
"usd": [
{"period": "2026-01", "value": 7.05}, {"period": "2026-02", "value": 7.08},
{"period": "2026-03", "value": 7.06}, {"period": "2026-04", "value": 7.10},
{"period": "2026-05", "value": 7.12}, {"period": "2026-06", "value": 7.15},
{"period": "2026-07", "value": 7.18},
],
"cpi": [
{"period": "2026-01", "value": 1.8}, {"period": "2026-02", "value": 1.9},
{"period": "2026-03", "value": 1.9}, {"period": "2026-04", "value": 2.0},
{"period": "2026-05", "value": 2.1}, {"period": "2026-06", "value": 2.1},
{"period": "2026-07", "value": 2.2},
],
}
# KPI 类别关键词 → 因素方向/弹性 (direction: +因素涨KPI涨, -因素涨KPI跌)
FACTOR_RULES = {
"cost": { # 成本/费用类: 宏观涨 → 成本涨
"oil": {"direction": "+", "elasticity": 0.15},
"usd": {"direction": "+", "elasticity": 0.10},
"cpi": {"direction": "+", "elasticity": 0.10},
},
"revenue": { # 营收类: 通胀涨→名义营收涨
"oil": {"direction": "-", "elasticity": 0.05},
"usd": {"direction": "+", "elasticity": 0.08},
"cpi": {"direction": "+", "elasticity": 0.08},
},
"profit": { # 利润类: 宏观涨 → 成本挤压利润
"oil": {"direction": "-", "elasticity": 0.12},
"usd": {"direction": "-", "elasticity": 0.08},
"cpi": {"direction": "-", "elasticity": 0.08},
},
"cash": { # 现金流类
"oil": {"direction": "-", "elasticity": 0.06},
"usd": {"direction": "-", "elasticity": 0.04},
"cpi": {"direction": "-", "elasticity": 0.05},
},
}
# 类别关键词匹配(长词优先)
CATEGORY_KEYWORDS = [
("profit", ["净利润", "净利", "利润", "毛利", "ROE", "ROI", "EVA", "收益率", "报酬率"]),
("revenue", ["营收", "收入", "销售额", "销售", "产值", "客单"]),
("cost", ["费用率", "成本率", "费用", "成本", "费率", "应付", "返利", "渠补", ""]),
("cash", ["现金流", "现金", "回款", "FCF", "资金"]),
]
def infer_kpi_category(kpi_name: str, kpi_code: str = "") -> str:
"""按KPI名称/编码推断类别: profit/revenue/cost/cash,兜底 profit(保守)"""
n = (kpi_name or "") + " " + (kpi_code or "")
for cat, kws in CATEGORY_KEYWORDS:
if any(kw in n for kw in kws):
return cat
return "profit"
def factor_sensitivity_for_kpi(kpi_name: str, kpi_code: str = "") -> list:
"""返回该KPI对3个宏观因素的敏感性(方向+弹性)— 规则推断版"""
cat = infer_kpi_category(kpi_name, kpi_code)
rules = FACTOR_RULES.get(cat, FACTOR_RULES["profit"])
out = []
for f in MACRO_FACTORS:
r = rules.get(f["key"], {"direction": "-", "elasticity": 0.05})
out.append({
"factor_key": f["key"],
"factor_name": f["name"],
"factor_unit": f["unit"],
"factor_desc": f["desc"],
"direction": r["direction"],
"elasticity": r["elasticity"],
"category": cat,
"elasticity_source": "rule",
})
return out
def _rate_of_change(series: list) -> list:
"""相邻期变化率列表 [(period, pct), ...]"""
out = []
for i in range(1, len(series)):
prev, cur = series[i - 1], series[i]
if prev and prev.get("value"):
pct = (cur["value"] - prev["value"]) / prev["value"] * 100
out.append((cur["period"], pct))
return out
def elasticity_from_history(kpi_history: list, factor_key: str,
direction: str) -> Optional[dict]:
"""变化率弹性校准:KPI历史 vs 宏观因素历史(同period匹配)
弹性 = mean(KPI变化率 / 因素变化率)符号由实际数据决定
匹配期数 < 2 或无因素数据 返回 None回退规则
"""
factor_hist = MACRO_FACTOR_HISTORY.get(factor_key)
if not factor_hist or not kpi_history:
return None
kpi_by_period = {h.get("period"): h.get("value") for h in kpi_history if h.get("value") is not None}
ratios = []
# 因素相邻期变化率
for i in range(1, len(factor_hist)):
fp = factor_hist[i]["period"]
fv = factor_hist[i]["value"]
fv_prev = factor_hist[i - 1]["value"]
if not fv_prev:
continue
f_chg = (fv - fv_prev) / fv_prev * 100
# KPI 同期值(以及上一期,用于算KPI变化)
k_cur = kpi_by_period.get(fp)
# KPI 在因素上一期的值(模糊匹配上一月度)
k_prev = kpi_by_period.get(factor_hist[i - 1]["period"])
if k_cur is not None and k_prev not in (None, 0) and abs(f_chg) > 0.01:
k_chg = (k_cur - k_prev) / k_prev * 100
ratios.append(k_chg / f_chg)
if len(ratios) < 2:
return None
import statistics
raw_elasticity = statistics.median(ratios)
# 弹性合理性校验: |弹性| 超出 [0.01, 0.5] 视为数据噪声 → 回退规则推断(诚实标注,不用失真校准)
if not (0.01 <= abs(raw_elasticity) <= 0.5):
return None
elasticity = round(raw_elasticity, 4)
# 方向由数据符号决定;数据符号与规则方向冲突时以数据为准(标注)
data_direction = "+" if elasticity >= 0 else "-"
return {
"elasticity": abs(elasticity),
"direction": data_direction,
"matched_periods": len(ratios),
"elasticity_source": "history",
"rule_direction": direction,
}
def factor_sensitivity_with_history(kpi_name: str, kpi_code: str = "",
kpi_history: Optional[list] = None) -> list:
"""增强版敏感性:有历史数据用变化率弹性校准,无数据回退规则推断"""
base = factor_sensitivity_for_kpi(kpi_name, kpi_code)
out = []
for s in base:
hist_el = elasticity_from_history(kpi_history or [], s["factor_key"], s["direction"]) if kpi_history else None
if hist_el:
out.append({
**s,
"elasticity": hist_el["elasticity"],
"direction": hist_el["direction"],
"elasticity_source": hist_el["elasticity_source"],
"matched_periods": hist_el["matched_periods"],
"rule_direction": hist_el["rule_direction"],
})
else:
out.append(s)
return out
def adjusted_next_with_factor(next_target: Optional[float], pct: float,
direction: str, elasticity: float) -> Optional[float]:
"""因素变动 pct% → 调整后预测值: 方向+ 因素涨预测涨; 方向- 因素涨预测跌
负值KPI(亏损)方向反转: 方向- 时因素涨 更亏(更负)"""
if next_target is None:
return None
factor_change = pct * 0.01 # ±5% → 0.05
sign = 1.0 if direction == "+" else -1.0
if next_target < 0:
sign = -sign # 负值(亏损): 因素涨 → 更亏
return round(next_target * (1 + sign * factor_change * elasticity), 2)
@@ -0,0 +1,27 @@
# 预算↔现金流↔行动 闭环自检报告
**检查时间**: 2026-08-30 12:06:44
## 账套 #1 · 期间 2026-08
- 🔴 营业收入(2026-08
预算 75 / 实际 150000 = 执行率 200000.0%(超预算)
现金流计划: 0 条 | 行动方案: 11 条
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
💡 预算执行率200000%异常,请同步现金流情况核对(营业收入 2026-08)
- 🟡 净利润(2026-08
预算 16.67 / 实际 0 = 执行率 0.0%(低执行)
现金流计划: 0 条 | 行动方案: 3 条
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
💡 预算执行率0%异常,请同步现金流情况核对(净利润 2026-08)
- 🔴 渠补率(2026-08
预算 12.78 / 实际 75 = 执行率 586.9%(超预算)
现金流计划: 0 条 | 行动方案: 4 条
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)
💡 预算执行率587%异常,请同步现金流情况核对(渠补率 2026-08)
- 🟡 经营性现金流(2026-08
预算 16.67 / 实际 -93000 = 执行率 -557888.4%(低执行)
现金流计划: 0 条 | 行动方案: 0 条
⚠️ 缺失: 现金流(本期间有其他计划但未关联本KPI)、行动方案
💡 预算执行率-557888%异常,请同步现金流情况核对、行动方案(经营性现金流 2026-08)
---
共发现异常 4 项
+1 -1
View File
@@ -28,7 +28,7 @@ try:
else:
s = DataSourceConfig(name='ERP系统 - 博海网络', source_type='erp',
api_endpoint='http://127.0.0.1:8300/api/v1',
api_key='erp-gateway-key-bhwl-2026', sync_type='batch', status='active')
api_key=os.getenv("ERP_API_KEY", ""), sync_type='batch', status='active')
db.add(s); db.commit(); db.refresh(s)
print(f" [OK] 插入成功: id={s.id}")
for r in db.query(DataSourceConfig).all():
+1 -1
View File
@@ -31,7 +31,7 @@ try:
name='ERP系统 - 博海网络',
source_type='erp',
api_endpoint='http://127.0.0.1:8300/api/v1',
api_key='erp-gateway-key-bhwl-2026',
api_key=os.getenv("ERP_API_KEY", ""),
sync_type='batch',
status='active',
)
+1 -1
View File
@@ -35,7 +35,7 @@ try:
name='ERP系统 - 博海网络',
source_type='erp',
api_endpoint='http://127.0.0.1:8300/api/v1',
api_key='erp-gateway-key-bhwl-2026',
api_key=os.getenv("ERP_API_KEY", ""),
sync_type='batch',
status='active',
)
+1 -1
View File
@@ -36,7 +36,7 @@ try:
name='ERP系统 - 博海网络',
source_type='erp',
api_endpoint='http://127.0.0.1:8300/api/v1',
api_key='erp-gateway-key-bhwl-2026',
api_key=os.getenv("ERP_API_KEY", ""),
sync_type='batch',
status='active',
)
+1 -1
View File
@@ -25,7 +25,7 @@ try:
name='ERP系统 - 博海网络',
source_type='erp',
api_endpoint='http://127.0.0.1:8300/api/v1',
api_key='erp-gateway-key-bhwl-2026',
api_key=os.getenv("ERP_API_KEY", ""),
sync_type='batch',
status='active',
)
+1 -1
View File
@@ -27,7 +27,7 @@ def generate_weekly_report():
failed = sum(1 for p in plans if p.verify_status == "failed")
escalated = sum(1 for p in plans if p.verify_status == "escalated")
pending = sum(1 for p in plans if p.verify_status in ("pending", "retrying"))
done = sum(1 for p in plans if p.status == "done")
done = sum(1 for p in plans if p.status == "completed") # done 不在枚举(pending/in_progress/completed/cancelled),改 completed
msg = (
f"📋 行动计划执行周报({datetime.now().strftime('%Y-%m-%d')}\n"
+36 -14
View File
@@ -52,14 +52,25 @@ def run_auto_verify():
continue
# 查最新KPI值
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code
).order_by(KPIDefinition.id.desc()).first()
# 缺陷2残留修复(对齐 verify.py):从 plan 关联 KPI 向上取 entity_idkpi_code 查询带 entity_id 过滤(防跨租户误匹配)
entity_id = None
if plan.kpi_id:
pkpi = db.query(KPIDefinition).filter(KPIDefinition.id == plan.kpi_id).first()
if pkpi:
entity_id = pkpi.entity_id
q = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code)
if entity_id is not None:
q = q.filter(KPIDefinition.entity_id == entity_id)
kpi = q.order_by(KPIDefinition.id.desc()).first()
if not kpi:
continue
# 缺陷3残留修复(对齐 verify.py):KPIValue 按 period <= 当前月过滤,跨月验证不取未来期间
period_limit = now.strftime("%Y-%m")
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id
).order_by(KPIValue.calculated_at.desc()).first()
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
KPIValue.period <= period_limit,
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
if not latest or latest.actual_value is None:
continue
@@ -74,11 +85,14 @@ def run_auto_verify():
plan.kpi_current_after = actual
if passed:
# 缺陷1残留修复(对齐 verify.py):plan 已处于"验证通过"状态(passed + verified_at 非空)则跳过 OKR 累加,防重复累加
already_verified = bool(plan.verify_status == "passed" and plan.verified_at is not None)
plan.verify_status = "passed"
plan.verified_at = now
plan.status = "done"
plan.status = "completed" # 缺陷4残留修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
plan.progress = 100
# 阶段3: OKR progress更新
update_okr_progress(db, plan, +15)
update_okr_progress(db, plan, +15, already_verified=already_verified)
messages.append(f"✅ 行动计划#{plan.id}验证通过: {plan.title} ({kpi_code}: {rule.get('baseline_value')}{actual})")
verified_count += 1
else:
@@ -107,16 +121,24 @@ def run_auto_verify():
db.close()
def update_okr_progress(db, plan, delta):
"""验证通过→更新所属OKR progress"""
def update_okr_progress(db, plan, delta, already_verified=False):
"""验证通过→更新所属OKR progress
缺陷1残留修复对齐 verify.py 2026-08-30幂等防重复累加
- already_verified=Trueplan 已处于验证通过状态且 verified_at 非空 跳过累加保持原值
"""
okr_id = getattr(plan, "okr_id", None) or getattr(plan, "objective_id", None)
if not okr_id:
return
return {"updated": False, "reason": "no_objective"}
obj = db.query(Objective).filter(Objective.id == okr_id).first()
if obj:
current = obj.progress or 0
obj.progress = min(current + delta, 100)
db.add(obj)
if not obj:
return {"updated": False, "reason": "objective_not_found"}
if already_verified:
return {"updated": False, "reason": "already_verified", "objective_id": obj.id, "progress": obj.progress or 0}
current = obj.progress or 0
obj.progress = min(current + delta, 100)
db.add(obj)
return {"updated": True, "objective_id": obj.id, "progress": obj.progress}
if __name__ == "__main__":
+93
View File
@@ -0,0 +1,93 @@
"""CDP e2e 续:预算管理页 → 年度分解弹窗实测"""
import json
import sys
import time
import urllib.request
import websocket
CDP_HTTP = "http://127.0.0.1:9222"
def http_get(path):
with urllib.request.urlopen(CDP_HTTP + path, timeout=5) as r:
return json.loads(r.read().decode())
class CDP:
def __init__(self, ws_url):
self.ws = websocket.create_connection(ws_url, timeout=30)
self.msg_id = 0
def call(self, method, params=None):
self.msg_id += 1
mid = self.msg_id
self.ws.send(json.dumps({"id": mid, "method": method, "params": params or {}}))
while True:
resp = json.loads(self.ws.recv())
if resp.get("id") == mid:
if "error" in resp:
raise RuntimeError(f"{method}: {resp['error']}")
return resp.get("result", {})
if resp.get("method") in ("Page.loadEventFired", "Page.frameStoppedLoading"):
pass
def eval(self, expr):
r = self.call("Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True})
if r.get("exceptionDetails"):
return "EXC: " + json.dumps(r["exceptionDetails"], ensure_ascii=False)[:200]
return r.get("result", {}).get("value")
def close(self):
try:
self.ws.close()
except Exception:
pass
def new_page(url):
ver = http_get("/json/version")
bws = CDP(ver["webSocketDebuggerUrl"])
t = bws.call("Target.createTarget", {"url": url})
tid = t["targetId"]
bws.close()
time.sleep(2)
for p in http_get("/json"):
if p["id"] == tid:
return tid, p["webSocketDebuggerUrl"]
return tid, None
def main():
tid, ws = new_page("https://cma.sxbh.ltd/")
if not ws:
print("FAIL: no ws")
sys.exit(1)
c = CDP(ws)
c.call("Page.enable")
c.call("Runtime.enable")
time.sleep(6)
# 点击侧边栏"预算管理"
r = c.eval("""(() => {
const els = Array.from(document.querySelectorAll('a, li, span, div'));
const target = els.find(e => e.innerText && e.innerText.trim() === '预算管理' && e.offsetParent !== null);
if (!target) return 'NO_MENU';
target.click();
return 'CLICKED';
})()""")
print("MENU_CLICK:", r)
time.sleep(4)
print("URL_NOW:", c.eval("location.href"))
body = c.eval("document.body ? document.body.innerText.slice(0,400) : ''")
print("BODY:", body.replace("\n", " | ")[:400])
# 检查是否有 预算管理 页面关键元素:年份选择 + 年度分解按钮
btns = c.eval("""Array.from(document.querySelectorAll('button')).map((b,idx)=>({idx, text:(b.innerText||'').trim().slice(0,20)})).filter(x=>x.text)""")
print("BUTTONS:", json.dumps(btns, ensure_ascii=False))
c.close()
print("TARGET:", tid)
if __name__ == "__main__":
main()
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CMA 数据治理 P0-2 数据清洗脚本2026-08-15
================================================
背景: CMA 系统数据治理 P0 任务, 财务Bot已拍板执行 P0-2 数据清洗
操作清单:
1. 备份 kpi_values -> kpi_values_bak_20260815先备份再动数据
2. 删除异常周期数据: source_batch='986360a37b99'26, id 1323-1348,
period 列是金额乱码: 2459.2/1021.21/1016.12/71981.55/103833.02/nan , 非有效期间
3. 176 active target_value=0 KPI: formula_desc 追加目标值待补充不删除
任务书 SQL SET remark=..., kpi_definitions 表无 remark SHOW COLUMNS 核实;
实际落点为 formula_desc 经查该 176 formula_desc 全部非空且为说明文字
EXT_071"待财务提供旧科目表核对归属"是事实上的备注/说明字段
4. EXT_071 (id=196, kpi_name=未命名科目(历史导入数据), entity_id=1):
历史Excel导入残留(source_batch=0720ca516636) -> 归档 status='archived',
kpi_values 数据保留不动
5. FB_ ×11(process维度) / BH_SALES_ ×10(finance维度) 编码保留不改只加注释:
- FB_ = 财务Bot自身工作KPI, 涉及前端引用, 改码会破坏财务Bot链路
- BH_SALES_ = 博海销售真实数据, 涉及前端引用
- 本脚本仅输出只读清单确认未改动
约束铁律:
- 不删除 kpi_definitions 任何行; 不删除 EXT_071 kpi_values
- 可重复执行幂等: 备份表先 DROP IF EXISTS 再重建; 其余操作以 WHERE 条件天然幂等
用法: python3 clean_kpi_data_governance.py
"""
import pymysql
def main():
conn = pymysql.connect(host="127.0.0.1", port=3306, user="cma_user",
password="cma_pass_2026", database="cma", charset="utf8mb4")
cur = conn.cursor(pymysql.cursors.DictCursor)
def q(sql, args=None):
cur.execute(sql, args)
return cur.fetchall()
def c(sql, args=None):
return q(sql, args)[0]["c"]
print("=" * 64)
print("CMA 数据治理 P0-2 数据清洗 开始")
print("=" * 64)
# ---- 步骤1: 备份 kpi_values(先备份再动数据) ----
print("\n[步骤1] 备份 kpi_values -> kpi_values_bak_20260815")
before = c("SELECT COUNT(*) AS c FROM kpi_values")
print(f" 备份前 kpi_values 总行数: {before}")
cur.execute("DROP TABLE IF EXISTS kpi_values_bak_20260815")
cur.execute("CREATE TABLE kpi_values_bak_20260815 AS SELECT * FROM kpi_values")
bak = c("SELECT COUNT(*) AS c FROM kpi_values_bak_20260815")
print(f" 备份表 kpi_values_bak_20260815 行数: {bak}(应等于 {before}")
assert bak == before, "备份行数不一致, 中止!"
conn.commit()
# ---- 步骤2: 删除异常周期数据 ----
print("\n[步骤2] 删除异常周期数据 source_batch='986360a37b99'")
rows = q("SELECT id, period FROM kpi_values WHERE source_batch='986360a37b99' ORDER BY id")
print(f" 删除前命中 {len(rows)}", end="")
if rows:
print(f"id {rows[0]['id']} ~ {rows[-1]['id']}")
for r in rows:
print(f" id={r['id']} period={r['period']!r}")
else:
print(" —— 该批次已无数据(可能上次执行已删除), 跳过打印")
cur.execute("DELETE FROM kpi_values WHERE source_batch='986360a37b99'")
remain = c("SELECT COUNT(*) AS c FROM kpi_values WHERE source_batch='986360a37b99'")
total_after = c("SELECT COUNT(*) AS c FROM kpi_values")
print(f" 删除后该批次剩余 {remain} 条(应为 0")
print(f" kpi_values 总行数: {before} -> {total_after}(应减少 {len(rows)}")
conn.commit()
# ---- 步骤3: active 且 target_value=0 标注【目标值待补充】 ----
print("\n[步骤3] active 且 target_value=0 的 KPI 标注『目标值待补充』(落点 formula_desc")
n = c("SELECT COUNT(*) AS c FROM kpi_definitions WHERE status='active' AND target_value=0")
print(f" 命中 {n} 条(任务书预期 176")
assert n == 176, f"与任务书预期 176 不符(实际 {n}, 中止!"
already = c("SELECT COUNT(*) AS c FROM kpi_definitions WHERE status='active' AND target_value=0 AND formula_desc LIKE '%【目标值待补充】%'")
print(f" 已带标记 {already} 条(防重复追加)")
if already == 0:
cur.execute("""
UPDATE kpi_definitions
SET formula_desc = CONCAT(IFNULL(NULLIF(formula_desc,''),''), '【目标值待补充】')
WHERE status='active' AND target_value=0
""")
print(f" UPDATE 影响行数: {cur.rowcount}")
else:
print(f" 已存在标记 {already} 条, 跳过追加(幂等)")
marked = c("SELECT COUNT(*) AS c FROM kpi_definitions WHERE status='active' AND target_value=0 AND formula_desc LIKE '%【目标值待补充】%'")
n_after = c("SELECT COUNT(*) AS c FROM kpi_definitions WHERE status='active' AND target_value=0")
print(f" 标注后带标记 {marked} 条; active 且 target_value=0 仍为 {n_after} 条(只标注, 不删除)")
conn.commit()
# ---- 步骤4: EXT_071 归档 ----
print("\n[步骤4] EXT_071id=196)归档 status='archived'")
ext_before = q("SELECT id,kpi_code,kpi_name,entity_id,status FROM kpi_definitions WHERE id=196 AND kpi_code='EXT_071'")
print(f" 归档前: {ext_before}")
cur.execute("UPDATE kpi_definitions SET status='archived' WHERE id=196 AND kpi_code='EXT_071'")
print(f" UPDATE 影响行数: {cur.rowcount}")
ext_after = q("SELECT id,kpi_code,kpi_name,entity_id,status FROM kpi_definitions WHERE id=196 AND kpi_code='EXT_071'")
print(f" 归档后: {ext_after}")
kv = c("SELECT COUNT(*) AS c FROM kpi_values WHERE kpi_id=196")
print(f" EXT_071 关联 kpi_values 行数(保留不动): {kv}")
# 只读引用检查(不修改; 引用表不存在则跳过)
for label, sql in (
("kpi_causality", "SELECT COUNT(*) AS c FROM kpi_causality WHERE source_kpi_id=196 OR target_kpi_id=196"),
("kpi_alerts", "SELECT COUNT(*) AS c FROM kpi_alerts WHERE kpi_id=196"),
("alert_rules", "SELECT COUNT(*) AS c FROM alert_rules WHERE kpi_id=196"),
("kpi_hierarchy", "SELECT COUNT(*) AS c FROM kpi_hierarchy WHERE parent_kpi_id=196 OR child_kpi_id=196"),
):
try:
print(f" 引用表 {label}: {c(sql)} 条(只读检查)")
except pymysql.err.ProgrammingError as e:
print(f" 引用表 {label}: 表不存在, 跳过({e}")
conn.commit()
# ---- 步骤5: FB_/BH_SALES_ 编码保留(不改) ----
print("\n[步骤5] FB_/BH_SALES_ 编码保留(不改)— 只读确认")
print(" 保留原因: FB_=财务Bot自身工作KPI; BH_SALES_=博海销售真实数据; 均涉及前端引用")
fb = q("SELECT kpi_code,kpi_name,entity_id,dimension,status FROM kpi_definitions WHERE kpi_code LIKE 'FB\\_%' ORDER BY kpi_code")
bh = q("SELECT kpi_code,kpi_name,entity_id,dimension,status FROM kpi_definitions WHERE kpi_code LIKE 'BH\\_%' ORDER BY kpi_code")
print(f" FB_ 共 {len(fb)} 条:")
for r in fb:
print(f" {r['kpi_code']} | {r['kpi_name']} | entity={r['entity_id']} | {r['dimension']} | {r['status']}")
print(f" BH_SALES_ 共 {len(bh)} 条:")
for r in bh:
print(f" {r['kpi_code']} | {r['kpi_name']} | entity={r['entity_id']} | {r['dimension']} | {r['status']}")
print(" 本轮未对 FB_/BH_SALES_ 做任何修改")
print("\n" + "=" * 64)
print("P0-2 数据清洗完成(步骤1-4 已提交; 步骤5 仅只读)")
print("=" * 64)
conn.close()
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
"""预算↔现金流↔行动 三闭环异常自检 — 路线图R5 (2026-08-30)
预算闭环加固预算执行率异常<70% >110%的KPI
检查是否同步了 现金流计划(CashPlan) 行动方案(ActionPlan)
缺失则输出提示防止"预算改了,现金流/行动没跟上"
输出控制台 + reports/closed_loop_check_YYYYMMDD.md
用法: /root/cma-management/backend/venv/bin/python3 scripts/closed_loop_check.py [--period 2026-08] [--push]
"""
import sys
import os
import json
import argparse
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, BudgetPlan, CashPlan, ActionPlan
LOW_RATIO = 0.7
HIGH_RATIO = 1.1
REPORTS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "reports")
def check_entity(db, entity_id: int, period: str) -> dict:
"""检测一个账套的闭环状态"""
issues = []
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status == "active",
BudgetPlan.period == period,
BudgetPlan.budget_value > 0,
).all()
seen = set()
for b in rows:
key = (b.kpi_id, b.period)
if key in seen:
continue
seen.add(key)
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == b.kpi_id,
KPIValue.period == b.period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
actual_val = actual.actual_value if actual else None
if actual_val is None:
continue
ratio = actual_val / b.budget_value
abnormal = ratio < LOW_RATIO or ratio > HIGH_RATIO
if not abnormal:
continue
# 现金流检查:该KPI该期间是否有收付款计划(related_kpi_id 或 budget_plan_id 关联)
period_start = datetime.strptime(period + "-01", "%Y-%m-%d")
if period.endswith("-12"):
period_end = datetime(period_start.year + 1, 1, 1)
else:
period_end = datetime(period_start.year, period_start.month + 1, 1)
cash_plans = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status.in_(["pending", "completed"]),
CashPlan.plan_date >= period_start,
CashPlan.plan_date < period_end,
).filter(
(CashPlan.related_kpi_id == b.kpi_id) | (CashPlan.budget_plan_id == b.id)
).count()
# 兜底:无关联但期间内有任意现金流计划也算基本闭环
any_cash = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status.in_(["pending", "completed"]),
CashPlan.plan_date >= period_start,
CashPlan.plan_date < period_end,
).count()
# 行动检查:该KPI是否有非完成的行动方案
actions = db.query(ActionPlan).filter(
ActionPlan.kpi_id == b.kpi_id,
ActionPlan.status.in_(["pending", "in_progress"]),
).count()
missing = []
if cash_plans == 0:
if any_cash > 0:
missing.append("现金流(本期间有其他计划但未关联本KPI)")
else:
missing.append("现金流")
if actions == 0:
missing.append("行动方案")
level = "critical" if ratio > HIGH_RATIO else "warning"
issues.append({
"kpi_id": b.kpi_id,
"kpi_name": kpi_name,
"period": period,
"budget_value": b.budget_value,
"actual_value": actual_val,
"exec_ratio": round(ratio * 100, 1),
"abnormal_type": "超预算" if ratio > HIGH_RATIO else "低执行",
"level": level,
"cash_plan_count": cash_plans,
"action_plan_count": actions,
"missing": missing,
"suggestion": (
f"预算执行率{ratio*100:.0f}%异常,请同步"
+ ("现金流计划" if "现金流" in missing else "现金流情况核对")
+ ("、行动方案" if "行动方案" in missing else "")
+ f"{kpi_name} {period}"
),
})
return {"entity_id": entity_id, "period": period, "issues": issues}
def build_report(results: list, checked_at: str) -> str:
lines = [f"# 预算↔现金流↔行动 闭环自检报告", f"**检查时间**: {checked_at}", ""]
total_issues = 0
for r in results:
lines.append(f"## 账套 #{r['entity_id']} · 期间 {r['period']}")
if not r["issues"]:
lines.append("- ✅ 无预算执行率异常")
for it in r["issues"]:
total_issues += 1
icon = "🔴" if it["level"] == "critical" else "🟡"
lines.append(f"- {icon} {it['kpi_name']}{it['period']}")
lines.append(f" 预算 {it['budget_value']:g} / 实际 {it['actual_value']:g} = 执行率 {it['exec_ratio']}%{it['abnormal_type']}")
lines.append(f" 现金流计划: {it['cash_plan_count']} 条 | 行动方案: {it['action_plan_count']}")
if it["missing"]:
lines.append(f" ⚠️ 缺失: {''.join(it['missing'])}")
lines.append(f" 💡 {it['suggestion']}")
else:
lines.append(f" ✅ 三闭环已同步")
lines.append("")
lines.append(f"---")
lines.append(f"共发现异常 {total_issues}")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--period", default=datetime.now().strftime("%Y-%m"))
parser.add_argument("--entity-id", type=int, default=1)
parser.add_argument("--push", action="store_true", help="异常时推送企微(8800/send)")
args = parser.parse_args()
os.makedirs(REPORTS_DIR, exist_ok=True)
checked_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
db = get_session_local()()
try:
result = check_entity(db, args.entity_id, args.period)
report = build_report([result], checked_at)
print(report)
# 写报告文件
fname = f"closed_loop_check_{datetime.now().strftime('%Y%m%d')}.md"
fpath = os.path.join(REPORTS_DIR, fname)
with open(fpath, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n📄 报告已写入: {fpath}")
# 异常推送
if args.push and result["issues"]:
try:
import urllib.request
import urllib.parse
content = f"## 🔄 预算闭环自检({args.period})\n"
for it in result["issues"][:10]:
content += f"- {it['kpi_name']} 执行率{it['exec_ratio']}% 缺{'/'.join(it['missing']) or ''}\n"
content += f"\n{len(result['issues'])}项异常,详见系统报告"
data = urllib.parse.urlencode({"msg": content, "source": "管理会计OS"}).encode("utf-8")
req = urllib.request.Request("http://127.0.0.1:8800/send", data=data)
with urllib.request.urlopen(req, timeout=15) as resp:
print("推送:", resp.read().decode()[:200])
except Exception as e:
print(f"推送失败: {e}")
finally:
db.close()
if __name__ == "__main__":
main()
+1 -1
View File
@@ -21,7 +21,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me
logger = logging.getLogger("erp_schema_collect")
ERP_API_BASE = "http://127.0.0.1:8300/api/v1"
ERP_API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
ERP_API_KEY = os.getenv("ERP_API_KEY", "")
HEADERS = {
"X-API-Key": ERP_API_KEY,
+1 -1
View File
@@ -14,7 +14,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me
logger = logging.getLogger("erp_schema_v2")
API_BASE = "http://127.0.0.1:8300/api/v1"
API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
API_KEY = os.getenv("ERP_API_KEY", "")
HEADERS = {"X-API-Key": API_KEY}
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""因果链数据验证脚本 — 每月 cron 自动跑 (2026-08-27 P2)
kpi_causality 每条链
source/target KPI kpi_values 历史值
Pearson 相关系数 + 方向一致性 + 滞后对齐(lag_months)
更新 verify_status: data_verified / disputed / pending
输出验证报告 JSON + 控制台摘要
用法:
python3 scripts/correlation-check.py # 全部企业,写库
python3 scripts/correlation-check.py --entity-id 1 # 指定企业
python3 scripts/correlation-check.py --dry-run # 只算不写库
月度 cron: 0 9 1 * * cd /root/cma-management/backend && python3 scripts/correlation-check.py
"""
import argparse
import json
import logging
import sys
from datetime import datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlalchemy import text # noqa: E402
from app.database import get_engine # noqa: E402
from app.services.causality_verification import ( # noqa: E402
STATUS_DATA_VERIFIED,
STATUS_DISPUTED,
STATUS_HUMAN_VERIFIED,
STATUS_PENDING,
VERIFIER_SCRIPT,
apply_state_machine,
evaluate_chain,
summarize,
)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger("correlation-check")
REPORT_DIR = Path(__file__).resolve().parent / "reports"
def load_chains(engine, entity_id: int = None) -> list:
"""加载因果链 + 两端KPI信息。"""
q = """
SELECT c.id, c.entity_id, c.source_kpi_id, c.target_kpi_id,
c.strength, c.lag_months, c.direction, c.source_type, c.verify_status,
s.kpi_code AS src_code, s.kpi_name AS src_name,
t.kpi_code AS tgt_code, t.kpi_name AS tgt_name
FROM kpi_causality c
JOIN kpi_definitions s ON s.id = c.source_kpi_id
JOIN kpi_definitions t ON t.id = c.target_kpi_id
"""
if entity_id is not None:
q += " WHERE c.entity_id = :eid"
with engine.connect() as conn:
rows = conn.execute(text(q), {"eid": entity_id} if entity_id is not None else {}).mappings().all()
return [dict(r) for r in rows]
def load_values(engine, kpi_ids: list) -> dict:
"""加载 KPI 历史值: {kpi_id: [(period, actual_value), ...]}"""
if not kpi_ids:
return {}
ids = list(set(int(i) for i in kpi_ids))
q = """
SELECT kpi_id, period, actual_value
FROM kpi_values
WHERE kpi_id IN :ids AND actual_value IS NOT NULL
ORDER BY period
"""
with engine.connect() as conn:
rows = conn.execute(text(q).bindparams(ids=ids), {"ids": ids}).mappings().all()
result = {}
for r in rows:
result.setdefault(r["kpi_id"], []).append((r["period"], r["actual_value"]))
return result
def main():
ap = argparse.ArgumentParser(description="因果链数据验证")
ap.add_argument("--entity-id", type=int, default=None, help="只验证指定企业(默认全部)")
ap.add_argument("--dry-run", action="store_true", help="只计算不写库")
args = ap.parse_args()
engine = get_engine()
chains = load_chains(engine, args.entity_id)
if not chains:
logger.info("无因果链,退出")
return 0
kpi_ids = [c["source_kpi_id"] for c in chains] + [c["target_kpi_id"] for c in chains]
values = load_values(engine, kpi_ids)
now = datetime.now()
results = []
updated = {"data_verified": 0, "disputed": 0, "unchanged": 0}
notes = []
with engine.begin() as conn:
for c in chains:
src_vals = values.get(c["source_kpi_id"], [])
tgt_vals = values.get(c["target_kpi_id"], [])
ev = evaluate_chain(
src_vals, tgt_vals,
lag_months=c["lag_months"] or 0,
direction=c["direction"] or "positive",
)
new_status, note = apply_state_machine(c["verify_status"], ev["status"], respect_human=True)
if note:
notes.append({"causality_id": c["id"], "note": note})
changed = new_status != c["verify_status"]
if changed:
updated[new_status if new_status in updated else "unchanged"] = \
updated.get(new_status if new_status in updated else "unchanged", 0) + 1
else:
updated["unchanged"] += 1
if not args.dry_run:
conn.execute(text(
"UPDATE kpi_causality SET verify_status = :st, verified_at = :va, verified_by = :vb "
"WHERE id = :cid"
), {
"st": new_status, "va": now, "vb": VERIFIER_SCRIPT, "cid": c["id"],
})
results.append({
"causality_id": c["id"],
"source": f'{c["src_code"]}({c["src_name"]})',
"target": f'{c["tgt_code"]}({c["tgt_name"]})',
"direction": c["direction"],
"lag_months": c["lag_months"],
"strength": c["strength"],
"granularity": ev["granularity"],
"n_points": ev["n"],
"r": round(ev["r"], 4) if ev["r"] is not None else None,
"direction_consistent": ev["direction_consistent"],
"old_status": c["verify_status"],
"new_status": new_status,
"reason": ev["reason"],
})
summary = summarize([{"status": r["new_status"]} for r in results])
report = {
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"script": VERIFIER_SCRIPT,
"dry_run": args.dry_run,
"entity_id": args.entity_id,
"summary": summary,
"updated": updated,
"human_verified_notes": notes,
"chains": results,
}
REPORT_DIR.mkdir(exist_ok=True)
report_path = REPORT_DIR / f"causality_verification_{now.strftime('%Y%m%d_%H%M%S')}.json"
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
# 控制台摘要(cron 输出即消息)
lines = [
f"因果链数据验证{'[dry-run]' if args.dry_run else ''} {now.strftime('%Y-%m-%d %H:%M')}",
f"总数: {summary['total']} | 数据证实: {summary['by_status'][STATUS_DATA_VERIFIED]} | "
f"存疑: {summary['by_status'][STATUS_DISPUTED]} | 待检(数据不足): {summary['by_status'][STATUS_PENDING]} | "
f"人工确认: {summary['by_status'][STATUS_HUMAN_VERIFIED]}",
f"本次更新: data_verified={updated['data_verified']} disputed={updated['disputed']} unchanged={updated['unchanged']}",
]
verified = [r for r in results if r["new_status"] == STATUS_DATA_VERIFIED]
disputed = [r for r in results if r["new_status"] == STATUS_DISPUTED]
if verified:
lines.append("── 数据证实 ──")
for r in verified:
lines.append(f" #{r['causality_id']} {r['source']}{r['target']} r={r['r']} n={r['n_points']}")
if disputed:
lines.append("── 数据存疑 ──")
for r in disputed:
lines.append(f" #{r['causality_id']} {r['source']}{r['target']} r={r['r']} n={r['n_points']} ({r['reason']})")
if notes:
lines.append("── 人工确认链的数据警示 ──")
for n in notes:
lines.append(f" #{n['causality_id']}: {n['note']}")
lines.append(f"报告: {report_path}")
print("\n".join(lines))
logger.info("报告已写入 %s", report_path)
return 0
if __name__ == "__main__":
sys.exit(main())
+125
View File
@@ -0,0 +1,125 @@
"""每日数据找人推送 — 路线图R2 (2026-08-30)
北极星③主动推送扩大 异常 + 机会两类
- 异常类待处理预警kpi_alerts pending
- 机会类KPI向好 / 预算余量 / 预测上行opportunity_detector
复用企微通道 8800/send公司群中继服务
用法: /root/cma-management/backend/venv/bin/python3 scripts/daily_push.py [--dry-run]
cron: 15 9 * * * (alert_generator 9:00 之后)
"""
import sys
import os
import json
import logging
import argparse
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIAlert
from scripts.opportunity_detector import detect_all, flatten
logger = logging.getLogger("cma.daily_push")
RELAY_URL = "http://127.0.0.1:8800/send"
SOURCE = "管理会计OS"
def collect_exceptions(db, limit: int = 10) -> list:
"""异常类:待处理预警(red/yellow)"""
out = []
alerts = db.query(KPIAlert).filter(
KPIAlert.status == "pending",
KPIAlert.alert_level.in_(["red", "yellow"]),
).order_by(KPIAlert.created_at.desc()).limit(limit).all()
for a in alerts:
k = db.query(KPIDefinition).filter(KPIDefinition.id == a.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{a.kpi_id}"
icon = "🔴" if a.alert_level == "red" else "🟡"
out.append({
"type": "exception",
"title": f"{icon} {kpi_name} 预警",
"detail": f"({a.alert_level}) {a.alert_message}",
"kpi_id": a.kpi_id,
"kpi_name": kpi_name,
"period": "",
})
return out
def build_message(exceptions: list, opportunities: list) -> str:
"""组装 markdown 推送内容"""
now = datetime.now().strftime("%Y-%m-%d %H:%M")
lines = [f"## 📊 管理会计OS · 每日经营播报", f"**{now}**", ""]
lines.append("### ⚠️ 异常关注")
if exceptions:
for e in exceptions:
lines.append(f"- {e['title']}")
lines.append(f" {e['detail']}")
else:
lines.append("- 今日无待处理预警 ✅")
lines.append("")
lines.append("### 🎯 机会发现")
if opportunities:
for o in opportunities:
lines.append(f"- {o['title']}")
lines.append(f" {o['detail']}")
else:
lines.append("- 今日暂无显著机会")
lines.append("")
lines.append("---")
lines.append("💡 数据找人:异常要处理,机会要把握。详情见 CMA 系统。")
return "\n".join(lines)
def push_wecom(msg: str) -> dict:
"""通过8800中继推送企微"""
import urllib.request
import urllib.parse
data = urllib.parse.urlencode({
"msg": msg,
"source": SOURCE,
"msgtype": "markdown",
}).encode("utf-8")
req = urllib.request.Request(RELAY_URL, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
result = json.loads(resp.read().decode("utf-8"))
return result
except Exception as e:
return {"ok": False, "error": f"推送异常: {e}"}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true", help="只打印不推送")
parser.add_argument("--entity-id", type=int, default=1)
args = parser.parse_args()
db = get_session_local()()
try:
exceptions = collect_exceptions(db)
opportunities = flatten(detect_all(db, args.entity_id))
msg = build_message(exceptions, opportunities)
if args.dry_run:
print(msg)
print(f"\n[DRY-RUN] 异常{len(exceptions)}条 / 机会{len(opportunities)}")
return
result = push_wecom(msg)
print(f"推送结果: {json.dumps(result, ensure_ascii=False)}")
print(f"统计: 异常{len(exceptions)}条 / 机会{len(opportunities)}")
finally:
db.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
main()
+1 -1
View File
@@ -28,7 +28,7 @@ logger = logging.getLogger("erp_sync")
# ERP API 配置
ERP_API_BASE = os.getenv("ERP_API_BASE", "http://127.0.0.1:8300/api/v1")
ERP_API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
ERP_API_KEY = os.getenv("ERP_API_KEY", "")
# 无DB会话时使用的静态映射(保底,使用真实KPI编码)
# 对应 data_source_config 表的 active 端点(id=1,2,3,4
+1 -1
View File
@@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me
logger = logging.getLogger("schema_fix")
API_BASE = "http://127.0.0.1:8300/api/v1"
API_KEY = os.getenv("ERP_API_KEY", "erp-gateway-key-bhwl-2026")
API_KEY = os.getenv("ERP_API_KEY", "")
HEADERS = {"X-API-Key": API_KEY}
def api_get(path):
@@ -0,0 +1,76 @@
"""生成网银流水标准导入模板 xlsx — 列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要
用法: python scripts/gen_voucher_import_template.py
输出: backend/scripts/templates/网银流水导入模板.xlsx表头 + 1行示例
"""
import os
import sys
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter
OUT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates", "网银流水导入模板.xlsx")
HEADERS = ["凭证日期", "凭证号", "科目编码", "科目名称", "借方金额", "贷方金额", "摘要"]
# 1行示例(借贷平衡)
EXAMPLE = ["2026-08-01", "记-001", "1002", "银行存款-工行", 50000, 0, "收到客户回款"]
# 附赠一行结转示例行(注释说明用,不写入数据行)
def main():
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
wb = Workbook()
ws = wb.active
ws.title = "网银流水"
# 表头样式
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill("solid", fgColor="409EFF")
for col, h in enumerate(HEADERS, start=1):
cell = ws.cell(row=1, column=col, value=h)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center", vertical="center")
for col, val in enumerate(EXAMPLE, start=1):
cell = ws.cell(row=2, column=col, value=val)
cell.alignment = Alignment(horizontal="center" if col in (1, 3, 5, 6) else "left")
# 列宽
widths = [14, 12, 12, 22, 12, 12, 30]
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = w
# 说明sheet
note = wb.create_sheet("填写说明")
notes = [
["网银流水标准导入模板 — 填写说明"],
[""],
["1. 列说明(与凭证明细表 voucher_details 对齐):"],
[" 凭证日期: YYYY-MM-DD(必填,用于提取期间period)"],
[" 凭证号: 字符串(必填,如 记-001 / 银收-20260801-001"],
[" 科目编码: 必填,如 1001库存现金 / 1002银行存款"],
[" 科目名称: 必填,如 银行存款-工行 / 库存现金"],
[" 借方金额: 数字,无则留空或0(与贷方二选一)"],
[" 贷方金额: 数字,无则留空或0(与借方二选一)"],
[" 摘要: 可选,含'结转'或科目名含'本年利润'的行将标记为结转行,不参与现金流计算"],
[""],
["2. 三校验规则(导入时自动执行):"],
[" ① 借贷平衡: 全文件Σ借方 = Σ贷方(容差0.01),不平衡将提示差额"],
[" ② 期间合计: 按期间(YYYY-MM)汇总借贷合计,供对账"],
[" ③ 结转行识别: 摘要含'结转' 或 科目名含'本年利润'/'结转' → carry_forward标记"],
[""],
["3. 现金流联动:货币资金科目(1001/1002开头)期末余额自动更新现金余额与EXT_现金类KPI、F_CASH_SAFETY现金安全垫"],
["4. 示例行(第2行)请删除后填入真实流水;不要修改表头列名"],
]
for row in notes:
note.append(row)
note.column_dimensions["A"].width = 90
wb.save(OUT_PATH)
print(f"模板已生成: {OUT_PATH}")
if __name__ == "__main__":
sys.exit(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""导入酣客1-8月商品销售排行榜到product_sales表"""
import openpyxl
import glob
import os
import re
import pymysql
# DB连接
DB_CONFIG = {
'host': '127.0.0.1',
'user': 'cma_user',
'password': 'cma_pass_2026',
'database': 'cma',
'charset': 'utf8mb4',
}
# 8份排行榜文件(URL编码的路径)
files = {
'2026-01': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_7c0e51888b10_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B41%E6%9C%88%EF%BC%89.xlsx',
'2026-02': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_309cee3d3d78_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B42%E6%9C%88%EF%BC%89.xlsx',
'2026-03': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_fdc1228e7173_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B43%E6%9C%88%EF%BC%89.xlsx',
'2026-04': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_da10cb16765c_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B44%E6%9C%88%EF%BC%89.xlsx',
'2026-05': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_bda951a0f6e3_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B45%E6%9C%88%EF%BC%89.xlsx',
'2026-06': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_6fcb80d2a02d_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B46%E6%9C%88%EF%BC%89.xlsx',
'2026-07': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_a449da246d17_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B47%E6%9C%88%EF%BC%89.xlsx',
'2026-08': '/root/.hermes/profiles/wecom-finance/cache/documents/doc_ac2b807b1517_%E5%95%86%E5%93%81%E9%94%80%E5%94%AE%E6%8E%92%E8%A1%8C%E6%A6%9C%EF%BC%88%E9%99%95%E8%A5%BF%E9%85%A3%E5%AE%A22026%E5%B9%B48%E6%9C%88%EF%BC%89.xlsx',
}
def import_file(conn, cursor, period, path):
"""导入单个月份Excel"""
if not os.path.exists(path):
print(f" ⚠️ 文件不存在: {period}")
return 0
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
ws = wb[wb.sheetnames[0]]
count = 0
for r in range(2, ws.max_row + 1):
code = ws.cell(r, 2).value # 商品编码
name = ws.cell(r, 3).value # 商品名称
sales = ws.cell(r, 4).value # 销售金额
cost = ws.cell(r, 6).value # 成本金额
gross = ws.cell(r, 7).value # 毛利
margin = ws.cell(r, 10).value # 毛利率(%)
qty = ws.cell(r, 13).value # 销售数量
unit = ws.cell(r, 14).value # 单位
# 跳过合计行(编码为空)和空行
if code is None or str(code).strip() == '':
continue
if name is None or str(name).strip() == '':
continue
# 跳过汇总行(如"上期库存")
if '上期' in str(name) or '合计' in str(name) or '总计' in str(name):
continue
code = str(code).strip()
name = str(name).strip()
sales = float(sales or 0)
cost = float(cost or 0)
gross = float(gross or 0)
margin = float(margin or 0)
qty = int(qty or 0)
unit = str(unit or '').strip()
cursor.execute("""
INSERT INTO product_sales (entity_id, product_code, product_name, period_month,
sales_amount, cost_amount, gross_profit, gross_margin_rate, sales_qty, unit)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
product_name=VALUES(product_name),
sales_amount=VALUES(sales_amount),
cost_amount=VALUES(cost_amount),
gross_profit=VALUES(gross_profit),
gross_margin_rate=VALUES(gross_margin_rate),
sales_qty=VALUES(sales_qty),
unit=VALUES(unit)
""", (1, code, name, period, sales, cost, gross, margin, qty, unit))
count += 1
wb.close()
return count
def main():
conn = pymysql.connect(**DB_CONFIG)
cursor = conn.cursor()
total = 0
for period, path in files.items():
n = import_file(conn, cursor, period, path)
print(f" {period}: {n}")
total += n
conn.commit()
print(f"\n✅ 共导入 {total}")
# 验证
cursor.execute("SELECT period_month, COUNT(*), ROUND(SUM(sales_amount)) FROM product_sales GROUP BY period_month ORDER BY period_month")
for row in cursor.fetchall():
print(f" {row[0]}: {row[1]}商品, 销售合计{row[2]:,.0f}")
cursor.close()
conn.close()
if __name__ == '__main__':
main()
+233
View File
@@ -0,0 +1,233 @@
"""KPI实际值自动归集采集器 — 管理会计OS (P1-④ 2026-08-28)
kpi_value_sources 取数映射从源头表(科目余额/进销存/现金流水)自动汇总写入 kpi_values
- 源头: voucher_details(网银凭证明细) / product_inventory(库存汇总) / product_inventory_detail(库存明细) / cash_plans(收付款计划)
- 严格按 entity_id + period 过滤避免跨账套/跨期串数
- 幂等: kpi_id+period 已有 auto_collect 记录则更新人工 excel/manual 写入不覆盖
- 调度: 系统 crontab 每日 06:30 (参考 auto_verify_cron.py 模式)
用法:
/usr/bin/python3 scripts/kpi_value_collector.py # 全量采集当月
/usr/bin/python3 scripts/kpi_value_collector.py 2026-08 # 指定期间
/usr/bin/python3 scripts/kpi_value_collector.py 2026-08 5 # 指定期间+KPI
"""
import sys
import logging
from datetime import datetime
from typing import Optional, Tuple
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("cma.kpi_collector")
# 保证从 backend 目录直接运行时能 import app
if __name__ == "__main__":
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import func
from app.database import get_session_local
from app.models import (
KPIValueSource, KPIValueCollectLog, KPIValue, KPIDefinition,
VoucherDetail, CashPlan,
)
# 源头表 → ORM模型映射(动态 import 避免循环依赖)
def _source_model(table: str):
if table == "voucher_details":
return VoucherDetail
if table == "cash_plans":
return CashPlan
# product_inventory / product_inventory_detail 无ORM模型 → SQLAlchemy Table 反射
from sqlalchemy import Table, MetaData
from app.database import get_engine
md = MetaData()
return Table(table, md, autoload_with=get_engine())
def _field_expression(model, field: str, aggregate: str = "sum"):
"""聚合表达式: sum/avg/count/max/min"""
col = getattr(model, field)
if aggregate == "count":
return func.count(col)
if aggregate == "avg":
return func.avg(col)
if aggregate == "max":
return func.max(col)
if aggregate == "min":
return func.min(col)
return func.sum(col)
def collect_for_mapping(db, mapping: KPIValueSource, period: str, write_kpi: bool = True) -> Tuple[Optional[float], str]:
"""执行单条取数映射,返回 (采集值, 说明)。write_kpi=False 时为试跑模式(不写库)。"""
table = mapping.source_table
field = mapping.source_field
aggregate = mapping.aggregate or "sum"
filter_rule = mapping.filter_rule or {}
period_field = mapping.period_field or "period"
unit = mapping.unit_conversion or 1
model = _source_model(table)
# 构建查询
col = getattr(model, field, None)
if col is None:
return None, f"字段 {field} 不存在于表 {table}"
q = db.query(_field_expression(model, field, aggregate))
# entity_id 过滤(所有源头表都有)
q = q.filter(model.entity_id == mapping.entity_id)
# 期间过滤
if period_field == "voucher_date":
# voucher_date 是 DATE 类型 → 按 %Y-%m 前缀匹配
q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period)
else:
pf = getattr(model, period_field, None)
if pf is None:
return None, f"期间字段 {period_field} 不存在于表 {table}"
q = q.filter(pf == period)
# 过滤规则: subject_code / direction / plan_type / carry_forward
subject_code = filter_rule.get("subject_code")
if subject_code and hasattr(model, "subject_code"):
q = q.filter(model.subject_code == subject_code)
direction = filter_rule.get("direction")
if direction:
# direction 覆盖: credit→只算贷方, debit→只算借方
if direction == "credit" and hasattr(model, "credit_amount"):
q = db.query(_field_expression(model, "credit_amount", aggregate))
q = q.filter(model.entity_id == mapping.entity_id)
if period_field == "voucher_date":
q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period)
else:
q = q.filter(getattr(model, period_field) == period)
field = "credit_amount"
elif direction == "debit" and hasattr(model, "debit_amount"):
q = db.query(_field_expression(model, "debit_amount", aggregate))
q = q.filter(model.entity_id == mapping.entity_id)
if period_field == "voucher_date":
q = q.filter(func.date_format(model.voucher_date, "%Y-%m") == period)
else:
q = q.filter(getattr(model, period_field) == period)
field = "debit_amount"
plan_type = filter_rule.get("plan_type")
if plan_type and hasattr(model, "plan_type"):
q = q.filter(model.plan_type == plan_type)
if filter_rule.get("exclude_carry_forward") and hasattr(model, "carry_forward"):
q = q.filter(model.carry_forward == 0)
value = q.scalar()
value = float(value or 0)
value = round(value * unit, 2)
message = f"{table}.{field} {aggregate}(period={period}) × {unit}"
if subject_code:
message += f", 科目{subject_code}"
if direction:
message += f", 方向{direction}"
if plan_type:
message += f", 类型{plan_type}"
if write_kpi:
# upsert kpi_values: 同 kpi_id+period 已有 auto_collect 记录则更新
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == mapping.kpi_id,
KPIValue.period == period,
KPIValue.source_type == "auto_collect",
).first()
if existing:
existing.actual_value = value
existing.source_batch = _batch_no()
existing.remark = f"自动归集: {table}"
existing.data_status = "pending"
else:
db.add(KPIValue(
entity_id=mapping.entity_id,
kpi_id=mapping.kpi_id,
period=period,
actual_value=value,
source_type="auto_collect",
source_batch=_batch_no(),
data_status="pending",
remark=f"自动归集: {table}",
))
return value, message
def _batch_no() -> str:
return f"auto-{datetime.now().strftime('%Y%m%d%H%M%S')}"
def run_collector(db, entity_id: Optional[int] = None, period: Optional[str] = None, kpi_id: Optional[int] = None) -> dict:
"""运行采集器:遍历 active 映射 → 汇总 → upsert kpi_values → 写采集日志"""
if period is None:
period = datetime.now().strftime("%Y-%m")
query = db.query(KPIValueSource).filter(KPIValueSource.status == "active")
if entity_id is not None:
query = query.filter(KPIValueSource.entity_id == entity_id)
if kpi_id is not None:
query = query.filter(KPIValueSource.kpi_id == kpi_id)
mappings = query.all()
if not mappings:
return {"success": True, "collected": 0, "failed": 0, "message": "无激活取数映射"}
collected, failed = 0, 0
errors = []
for m in mappings:
try:
value, message = collect_for_mapping(db, m, period, write_kpi=True)
db.add(KPIValueCollectLog(
entity_id=m.entity_id,
kpi_id=m.kpi_id,
period=period,
source_table=m.source_table,
collected_value=value,
status="success",
message=message,
))
collected += 1
except Exception as e:
failed += 1
errors.append({"kpi_id": m.kpi_id, "source_table": m.source_table, "error": str(e)})
db.add(KPIValueCollectLog(
entity_id=m.entity_id,
kpi_id=m.kpi_id,
period=period,
source_table=m.source_table,
collected_value=None,
status="failed",
message=str(e)[:500],
))
logger.error("采集失败 kpi=%s table=%s: %s", m.kpi_id, m.source_table, e)
db.commit()
logger.info("采集完成: 成功%s 失败%s (period=%s)", collected, failed, period)
return {
"success": failed == 0,
"collected": collected,
"failed": failed,
"period": period,
"errors": errors[:20],
}
if __name__ == "__main__":
period_arg = sys.argv[1] if len(sys.argv) > 1 else None
kpi_arg = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2].isdigit() else None
db = get_session_local()()
try:
r = run_collector(db, period=period_arg, kpi_id=kpi_arg)
print(f"实际值自动归集完成: 成功{r['collected']} 失败{r['failed']} (period={r.get('period')})")
for e in r.get("errors", []):
print(f" 失败: kpi={e['kpi_id']} table={e['source_table']} -> {e['error']}")
finally:
db.close()
@@ -0,0 +1,95 @@
"""kpi_causality 因果链验证机制迁移脚本 (2026-08-27 P2)
加列:
- source_type: varchar(20) 建链来源 AI_suggested/manual/imported
- verify_status: varchar(20) 验证状态 pending/data_verified/human_verified/disputed
- verified_at: datetime 验证时间
- verified_by: varchar(50) 验证人/AI/脚本
- entity_id: int 多租户隔离 (2026-08-27 收官补齐)
幂等: 列已存在则跳过; entity_id 回填只更新 NULL/0
用法: python scripts/migrate_causality_verification.py
"""
import logging
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from sqlalchemy import text
from app.database import get_engine
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger("migrate-causality-verification")
COLUMNS = [
("source_type", "ALTER TABLE kpi_causality ADD COLUMN source_type VARCHAR(20) NOT NULL DEFAULT 'manual' COMMENT '建链来源 AI_suggested/manual/imported'"),
("verify_status", "ALTER TABLE kpi_causality ADD COLUMN verify_status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '验证状态 pending/data_verified/human_verified/disputed'"),
("verified_at", "ALTER TABLE kpi_causality ADD COLUMN verified_at DATETIME NULL COMMENT '验证时间'"),
("verified_by", "ALTER TABLE kpi_causality ADD COLUMN verified_by VARCHAR(50) NULL COMMENT '验证人/AI/脚本'"),
("entity_id", "ALTER TABLE kpi_causality ADD COLUMN entity_id INT NOT NULL DEFAULT 1 COMMENT '企业ID (多租户隔离 2026-08-27)'"),
]
def run():
engine = get_engine()
with engine.connect() as conn:
# 1. 检查表是否存在
exists = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'kpi_causality'"
)).scalar()
if not exists:
logger.error("kpi_causality 表不存在,跳过")
return 1
# 2. 现有列
existing = {r[0] for r in conn.execute(text(
"SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'kpi_causality'"
))}
logger.info("现有列: %s", sorted(existing))
# 3. 加列(幂等)
for col, ddl in COLUMNS:
if col in existing:
logger.info("%s 已存在,跳过", col)
else:
conn.execute(text(ddl))
logger.info("已添加列 %s", col)
# 4. 回填 entity_id(无条件从 source KPI 对齐,纠正默认值偏差)
# 仅当来源KPI存在才回填;无来源KPI的孤儿链保持原值
conn.execute(text(
"UPDATE kpi_causality c JOIN kpi_definitions k ON k.id = c.source_kpi_id "
"SET c.entity_id = k.entity_id"
))
orphan = conn.execute(text(
"SELECT COUNT(*) FROM kpi_causality c LEFT JOIN kpi_definitions k ON k.id = c.source_kpi_id "
"WHERE k.id IS NULL"
)).scalar()
if orphan:
logger.warning("%d 条因果链无来源KPI(孤儿链)", orphan)
else:
logger.info("entity_id 已全部按来源KPI回填")
# 5. 验证
cols = {r[0] for r in conn.execute(text(
"SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'kpi_causality'"
))}
missing = {c for c, _ in COLUMNS} - cols
if missing:
logger.error("仍有缺失列: %s", missing)
return 1
row = conn.execute(text(
"SELECT COUNT(*) FROM kpi_causality WHERE entity_id IS NULL OR entity_id = 0"
)).scalar()
if row:
logger.error("仍有 %d 行 entity_id 为空", row)
return 1
total = conn.execute(text("SELECT COUNT(*) FROM kpi_causality")).scalar()
logger.info("迁移完成: kpi_causality %d 条, 新列: source_type/verify_status/verified_at/verified_by/entity_id", total)
return 0
if __name__ == "__main__":
sys.exit(run())
+37
View File
@@ -0,0 +1,37 @@
"""
KR完整修复(2026-08-27) 迁移脚本:
strategic_maps.dimensions JSON 同步 objectives + krs
复用 maps.py _sync_objectives_krs 逻辑
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.database import get_session_local
from app.models import StrategicMap, KR, Objective
from app.api.maps import _sync_objectives_krs
def migrate():
db = get_session_local()()
try:
maps = db.query(StrategicMap).all()
total_kr = 0
for m in maps:
before = db.query(KR).filter(KR.entity_id == (m.entity_id or 1)).count()
_sync_objectives_krs(m, db)
after = db.query(KR).filter(KR.entity_id == (m.entity_id or 1)).count()
total_kr += (after - before)
print(f"地图[{m.id}] {m.title} (entity={m.entity_id}): KR {before}{after}")
# 汇总
print(f"\n迁移完成: krs表总记录 = {db.query(KR).count()}")
print(f"objectives表总记录 = {db.query(Objective).count()}")
# 显示样本
rows = db.query(KR).order_by(KR.id).limit(5).all()
for kr in rows:
print(f" KR#{kr.id}: {kr.title} | op={kr.operator} | target={kr.target_value} | weight={kr.weight} | obj={kr.objective_id}")
finally:
db.close()
if __name__ == "__main__":
migrate()
@@ -0,0 +1,84 @@
"""verify-engine-fix-20260830 DB 迁移脚本
1. kpi_values.entity_id 回填kpi_id kpi_definitions.entity_id现有 67 NULL
2. action_plans.status 'done' 'completed'枚举修正当前 0 防御性执行
执行前先 SELECT 预览影响行数再执行 UPDATE最后回查验证
用法: venv/bin/python3 scripts/migrate_verify_fix_20260830.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from app.database import get_engine
def main():
engine = get_engine()
with engine.connect() as conn:
# ── 1. 预览 ──
preview_null = conn.execute(text(
"SELECT COUNT(*) AS cnt FROM kpi_values kv "
"LEFT JOIN kpi_definitions kd ON kv.kpi_id = kd.id "
"WHERE kv.entity_id IS NULL AND kd.entity_id IS NOT NULL"
)).fetchone()
preview_orphan = conn.execute(text(
"SELECT COUNT(*) AS cnt FROM kpi_values kv "
"LEFT JOIN kpi_definitions kd ON kv.kpi_id = kd.id "
"WHERE kv.entity_id IS NULL AND kd.id IS NULL"
)).fetchone()
preview_done = conn.execute(text(
"SELECT COUNT(*) AS cnt FROM action_plans WHERE status = 'done'"
)).fetchone()
print(f"[预览] 可回填(entity_id NULL 且 kpi 存在): {preview_null.cnt}")
print(f"[预览] 无法回填(kpi 不存在): {preview_orphan.cnt}")
print(f"[预览] action_plans status='done': {preview_done.cnt}")
# ── 2. 执行回填 ──
r = conn.execute(text(
"UPDATE kpi_values kv "
"JOIN kpi_definitions kd ON kv.kpi_id = kd.id "
"SET kv.entity_id = kd.entity_id "
"WHERE kv.entity_id IS NULL AND kd.entity_id IS NOT NULL"
))
print(f"[执行] kpi_values entity_id 回填 {r.rowcount}")
# 无法关联的孤儿行(kpi 不存在)→ 置默认 entity_id=1 并留 remark
r2 = conn.execute(text(
"UPDATE kpi_values kv "
"LEFT JOIN kpi_definitions kd ON kv.kpi_id = kd.id "
"SET kv.entity_id = 1, kv.remark = CONCAT(COALESCE(kv.remark, ''), '; verify-fix-20260830 孤儿行默认entity_id=1') "
"WHERE kv.entity_id IS NULL AND kd.id IS NULL"
))
if r2.rowcount:
print(f"[执行] 孤儿行置默认 entity_id=1: {r2.rowcount}")
else:
print("[执行] 无孤儿行需处理")
# ── 3. status done → completed ──
r3 = conn.execute(text(
"UPDATE action_plans SET status = 'completed' WHERE status = 'done'"
))
print(f"[执行] action_plans status done→completed: {r3.rowcount}")
conn.commit()
# ── 4. 验证 ──
after_null = conn.execute(text(
"SELECT COUNT(*) AS cnt FROM kpi_values WHERE entity_id IS NULL"
)).fetchone()
after_done = conn.execute(text(
"SELECT COUNT(*) AS cnt FROM action_plans WHERE status = 'done'"
)).fetchone()
print(f"[验证] kpi_values entity_id IS NULL 残留: {after_null.cnt}(目标 0")
print(f"[验证] action_plans status='done' 残留: {after_done.cnt}(目标 0")
if after_null.cnt != 0:
print("[结果] ❌ 回填不彻底,请人工检查")
sys.exit(1)
print("[结果] ✅ 迁移完成")
if __name__ == "__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
"""机会检测器 — 路线图R2 数据找人扩大 (2026-08-30)
北极星③主动推送扩大 异常 + 机会两类
本脚本检测三类机会复用 budget/kpi 数据不新建表
1. KPI向好 (kpi_improving) : 最近3期执行率>110% 且最新期呈上升趋势
2. 预算余量 (budget_headroom): 可用预算>30%预算执行率<70%
3. 滚动机会 (rolling_up) : 预测值上升kpi_forecast_log 最新>上期
输出机会列表 [{type, title, detail, kpi_id, kpi_name, period}]
"""
import sys
import os
import json
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, BudgetPlan, KpiForecastLog
HIGH_RATIO = 1.1 # 执行率>110% = 超预期
LOW_EXEC_RATIO = 0.7 # 执行率<70% = 预算余量大(可用>30%
def _exec_ratio(actual, target):
if target is None or target == 0:
return None
return actual / target
def detect_kpi_improving(db, entity_id: int, min_ratio: float = HIGH_RATIO) -> list:
"""KPI向好:最近3期执行率均>110%,且最新期>上期(上升中)"""
out = []
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).all()
now = datetime.now()
for k in kpis:
if not k.target_value or k.target_value <= 0:
continue
vals = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).limit(3).all()
if len(vals) < 3:
continue
ratios = [_exec_ratio(v.actual_value, k.target_value) for v in vals]
if any(r is None or r < min_ratio for r in ratios):
continue
# 最新期 > 上期(上升趋势);若最新期低于上期但整体仍>110%,也算(持续向好)
latest, prev = vals[0], vals[1]
trend = "上升" if latest.actual_value > prev.actual_value else "高位"
out.append({
"type": "kpi_improving",
"title": f"📈 {k.kpi_name} 持续向好",
"detail": (f"{latest.period}实际{latest.actual_value:g}/目标{k.target_value:g}"
f" 达成率{ratios[0]*100:.0f}%({trend}),近3期均超110%"),
"kpi_id": k.id,
"kpi_name": k.kpi_name,
"period": latest.period,
})
return out
def detect_budget_headroom(db, entity_id: int, max_ratio: float = LOW_EXEC_RATIO) -> list:
"""预算余量:当月预算执行率<70%(可用预算>30%
注意跳过实际值为负的行现金流/利润为负是异常不是余量
KPI 同期间多版本预算只取一条去重
"""
out = []
period = datetime.now().strftime("%Y-%m")
rows = db.query(BudgetPlan).filter(
BudgetPlan.entity_id == entity_id,
BudgetPlan.status == "active",
BudgetPlan.period == period,
BudgetPlan.budget_value > 0,
).all()
seen = set()
for b in rows:
key = (b.kpi_id, b.period)
if key in seen:
continue
seen.add(key)
actual = db.query(KPIValue).filter(
KPIValue.kpi_id == b.kpi_id,
KPIValue.period == b.period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.calculated_at.desc()).first()
if not actual or actual.actual_value is None or actual.actual_value <= 0:
continue
ratio = actual.actual_value / b.budget_value
if ratio < max_ratio:
k = db.query(KPIDefinition).filter(KPIDefinition.id == b.kpi_id).first()
kpi_name = k.kpi_name if k else f"KPI#{b.kpi_id}"
headroom = (1 - ratio) * 100
out.append({
"type": "budget_headroom",
"title": f"💼 {kpi_name} 预算余量充足",
"detail": (f"{period}预算{b.budget_value:g}/实际{actual.actual_value:g}"
f" 执行率{ratio*100:.0f}%,可用预算余量约{headroom:.0f}%"),
"kpi_id": b.kpi_id,
"kpi_name": kpi_name,
"period": period,
})
return out
def detect_rolling_up(db, entity_id: int) -> list:
"""滚动机会:预测值上升(最新预测 > 上期预测)"""
out = []
# 每个KPI取最近两条预测记录
kpi_ids = [r[0] for r in db.query(KpiForecastLog.kpi_id).filter(
KpiForecastLog.entity_id == entity_id).distinct().limit(50).all()]
for kid in kpi_ids:
rows = db.query(KpiForecastLog).filter(
KpiForecastLog.entity_id == entity_id,
KpiForecastLog.kpi_id == kid,
KpiForecastLog.forecast_value.isnot(None),
).order_by(KpiForecastLog.created_at.desc(), KpiForecastLog.id.desc()).limit(2).all()
if len(rows) < 2:
continue
latest, prev = rows[0], rows[1]
if latest.forecast_value > prev.forecast_value:
k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
kpi_name = k.kpi_name if k else f"KPI#{kid}"
pct = (latest.forecast_value / prev.forecast_value - 1) * 100 if prev.forecast_value else 0
out.append({
"type": "rolling_up",
"title": f"🔮 {kpi_name} 预测上行",
"detail": (f"预测值 {prev.forecast_value:g}{latest.forecast_value:g}"
f" (+{pct:.1f}%){latest.period}期间"),
"kpi_id": kid,
"kpi_name": kpi_name,
"period": latest.period,
})
return out
def detect_all(db, entity_id: int = 1) -> dict:
"""检测全部机会,按类型分组"""
return {
"kpi_improving": detect_kpi_improving(db, entity_id),
"budget_headroom": detect_budget_headroom(db, entity_id),
"rolling_up": detect_rolling_up(db, entity_id),
}
def flatten(detected: dict) -> list:
out = []
for cat in ("kpi_improving", "budget_headroom", "rolling_up"):
out.extend(detected.get(cat, []))
return out
def main():
db = get_session_local()()
try:
detected = detect_all(db)
total = sum(len(v) for v in detected.values())
print(json.dumps(detected, ensure_ascii=False, indent=2))
print(f"\n机会总数: {total}")
finally:
db.close()
if __name__ == "__main__":
main()
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Schema 一致性检查 — 部署门禁
检查 ORM model 与数据库表结构是否一致输出差异并给出修复建议
运行: python3 scripts/schema_check.py
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import inspect, text
from app.database import get_engine
import app.models
def main():
engine = get_engine()
insp = inspect(engine)
db_tables = set(insp.get_table_names())
model_tables = set(app.models.Base.metadata.tables.keys())
print("===== CMA Schema 一致性检查 =====")
print(f"数据库表: {len(db_tables)} | ORM模型表: {len(model_tables)}")
print()
issues = 0
# 1. 表缺失
missing_tables = model_tables - db_tables
if missing_tables:
issues += len(missing_tables)
print(f"❌ ORM有但数据库缺失的表: {missing_tables}")
print(" 修复: 运行 Base.metadata.create_all() 或手动建表")
else:
print("✅ 表结构齐全")
# 2. 字段差异
for tbl in sorted(model_tables & db_tables):
model_cols = set(app.models.Base.metadata.tables[tbl].columns.keys())
db_cols = set(c["name"] for c in insp.get_columns(tbl))
missing = model_cols - db_cols
extra = db_cols - model_cols
if missing:
issues += len(missing)
print(f"❌ [{tbl}] 数据库缺ORM字段: {missing}")
print(f" 修复: ALTER TABLE {tbl} ADD COLUMN ...")
if extra:
# 数据库有但ORM没有的字段 — 检查是否NOT NULL且ORM创建时不填
print(f"⚠️ [{tbl}] ORM未定义但数据库有: {extra}")
# 3. 关键表字段检查(只报真实结构差异:ORM有DB缺,或DB有ORM缺)
critical = ["kpi_definitions", "kpi_values", "strategic_maps", "budget_plans", "action_plans"]
for tbl in critical:
if tbl not in db_tables:
continue
model_cols = set(app.models.Base.metadata.tables[tbl].columns.keys())
db_cols = set(c["name"] for c in insp.get_columns(tbl))
missing = model_cols - db_cols
extra = db_cols - model_cols
if missing:
issues += len(missing)
print(f"❌ [{tbl}] 数据库缺ORM字段: {missing}")
print(f" 修复: ALTER TABLE {tbl} ADD COLUMN ...")
if extra:
# DB有ORM无 → 可能影响ORM写入/读取,提示但不阻塞
print(f"⚠️ [{tbl}] ORM未定义但数据库有: {extra}")
print()
if issues:
print(f"🚨 发现 {issues} 处差异,需处理后再部署")
sys.exit(1)
else:
print("✅ Schema 一致,无阻塞问题")
sys.exit(0)
if __name__ == "__main__":
main()
+181
View File
@@ -0,0 +1,181 @@
-- CMA本体三支柱: 科目↔KPI↔OKR 三层互联 DDL (2026-08-19)
USE cma;
CREATE TABLE IF NOT EXISTS kpi_subject_map (
id INT AUTO_INCREMENT PRIMARY KEY,
kpi_id INT NOT NULL,
subject_code VARCHAR(20) NOT NULL,
calc_type VARCHAR(20) DEFAULT 'sum', -- sum/avg/ratio/other
weight DECIMAL(5,2) DEFAULT 1.00, -- 权重(负=扣减项)
remark VARCHAR(200),
UNIQUE KEY uk_kpi_subject (kpi_id, subject_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='科目↔KPI映射(指标计算依赖的底层科目)';
CREATE TABLE IF NOT EXISTS objective_kpi (
id INT AUTO_INCREMENT PRIMARY KEY,
objective_id INT NOT NULL,
kpi_id INT NOT NULL,
weight DECIMAL(5,2) DEFAULT 1.00,
UNIQUE KEY uk_obj_kpi (objective_id, kpi_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='KPI↔O支撑(目标由哪些KPI度量)';
CREATE TABLE IF NOT EXISTS krs (
id INT AUTO_INCREMENT PRIMARY KEY,
objective_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
metric_kpi_id INT, -- 关联的度量KPI
target_value DECIMAL(15,2),
current_value DECIMAL(15,2),
progress INT DEFAULT 0, -- 0-100
status VARCHAR(20) DEFAULT 'pending',
due_date DATE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键结果KR(OKR完整化)';
-- kpi_subject_map 种子数据: 核心财务KPI → 会计科目映射 (2026-08-19)
-- weight: 1.00=加项 -1.00=减项; calc_type: sum=加总 ratio=比率
USE cma;
INSERT INTO kpi_subject_map (kpi_id, subject_code, calc_type, weight, remark) VALUES
-- F_REVENUE 营收 = 主营业务收入 + 利息收入 + 其他业务收入 + 投资收益
(1, '6001', 'sum', 1.00, '主营业务收入'),
(1, '6011', 'sum', 0.02, '利息收入(银行存款)'),
(1, '6051', 'sum', 0.05, '其他业务收入'),
(1, '6111', 'sum', 0.03, '投资收益'),
-- F_GROSS_MARGIN 毛利率 = (收入-成本)/收入
(2, '6001', 'ratio', 1.00, '毛利率分子-收入'),
(2, '6401', 'ratio', -1.00, '毛利率分子-主营业务成本'),
(2, '6402', 'ratio', -1.00, '毛利率分子-其他业务成本'),
-- F_NET_PROFIT 净利润 = 收入 - 成本 - 费用
(3, '6001', 'sum', 1.00, '净利润-主营业务收入'),
(3, '6051', 'sum', 0.05, '净利润-其他业务收入'),
(3, '6011', 'sum', 0.02, '净利润-利息收入'),
(3, '6111', 'sum', 0.03, '净利润-投资收益'),
(3, '6401', 'sum', -1.00, '净利润-主营业务成本'),
(3, '6402', 'sum', -1.00, '净利润-其他业务成本'),
(3, '6601', 'sum', -1.00, '净利润-销售费用'),
(3, '6602', 'sum', -1.00, '净利润-管理费用'),
(3, '660204','sum', -0.30, '净利润-研发费用'),
(3, '6603', 'sum', -1.00, '净利润-财务费用'),
(3, '6701', 'sum', -1.00, '净利润-资产减值损失'),
(3, '6801', 'sum', -1.00, '净利润-所得税费用'),
-- F_OP_CFLOW 经营性现金流 = 现金类科目净变动
(4, '1001', 'sum', 1.00, '经营性现金流-库存现金'),
(4, '1002', 'sum', 1.00, '经营性现金流-银行存款'),
(4, '1122', 'sum', 1.00, '经营性现金流-应收账款收回'),
(4, '2202', 'sum', 1.00, '经营性现金流-应付账款支付'),
-- F_COST_RATIO 费用率 = 期间费用/收入
(5, '6601', 'ratio', 1.00, '费用率-销售费用'),
(5, '6602', 'ratio', 1.00, '费用率-管理费用'),
(5, '6603', 'ratio', 1.00, '费用率-财务费用'),
(5, '6001', 'ratio', -1.00, '费用率分母-收入'),
-- F_AR_DAYS 应收账款周转天数 = 应收/收入*360
(6, '1122', 'ratio', 1.00, '应收周转-应收账款'),
(6, '6001', 'ratio', -1.00, '应收周转分母-主营业务收入'),
-- F_ASSET_TURNOVER 总资产周转率 = 收入/总资产
(13, '6001', 'ratio', 1.00, '资产周转分子-收入'),
(13, '1001', 'ratio', -1.00, '资产周转分母-库存现金'),
(13, '1002', 'ratio', -1.00, '资产周转分母-银行存款'),
(13, '1122', 'ratio', -1.00, '资产周转分母-应收账款'),
(13, '1405', 'ratio', -1.00, '资产周转分母-库存商品'),
(13, '1601', 'ratio', -1.00, '资产周转分母-固定资产'),
-- F_REVENUE_GROWTH 收入增长率 = 本期收入/上期收入-1
(14, '6001', 'ratio', 1.00, '收入增长-主营业务收入'),
-- F_CURRENT_RATIO 流动比率 = 流动资产/流动负债
(17, '1001', 'ratio', 1.00, '流动比率-库存现金'),
(17, '1002', 'ratio', 1.00, '流动比率-银行存款'),
(17, '1122', 'ratio', 1.00, '流动比率-应收账款'),
(17, '1405', 'ratio', 1.00, '流动比率-库存商品'),
(17, '2001', 'ratio', -1.00, '流动比率-短期借款'),
(17, '2202', 'ratio', -1.00, '流动比率-应付账款'),
(17, '2203', 'ratio', -1.00, '流动比率-预收账款'),
(17, '2211', 'ratio', -1.00, '流动比率-应付职工薪酬'),
-- F_QUICK_RATIO 速动比率 = (流动资产-存货)/流动负债
(18, '1001', 'ratio', 1.00, '速动比率-库存现金'),
(18, '1002', 'ratio', 1.00, '速动比率-银行存款'),
(18, '1122', 'ratio', 1.00, '速动比率-应收账款'),
(18, '1405', 'ratio', -1.00, '速动比率-存货扣减'),
(18, '2202', 'ratio', -1.00, '速动比率-应付账款'),
(18, '2203', 'ratio', -1.00, '速动比率-预收账款'),
(18, '2211', 'ratio', -1.00, '速动比率-应付职工薪酬'),
-- F_INV_DAYS 存货周转天数 = 存货/成本*360
(19, '1405', 'ratio', 1.00, '存货周转-库存商品'),
(19, '6401', 'ratio', -1.00, '存货周转分母-主营业务成本'),
-- F_ROI 总资产报酬率 = 利润/资产
(20, '4103', 'ratio', 1.00, 'ROI分子-本年利润'),
(20, '4001', 'ratio', -1.00, 'ROI分母-实收资本'),
(20, '4002', 'ratio', -1.00, 'ROI分母-资本公积'),
-- F_DEBT_RATIO 资产负债率 = 负债/资产
(30, '2001', 'ratio', 1.00, '负债率-短期借款'),
(30, '2202', 'ratio', 1.00, '负债率-应付账款'),
(30, '2203', 'ratio', 1.00, '负债率-预收账款'),
(30, '2211', 'ratio', 1.00, '负债率-应付职工薪酬'),
(30, '2221', 'ratio', 1.00, '负债率-应交税费'),
(30, '2241', 'ratio', 1.00, '负债率-其他应付款'),
(30, '2501', 'ratio', 1.00, '负债率-长期借款'),
(30, '2502', 'ratio', 1.00, '负债率-应付债券'),
(30, '4001', 'ratio', -1.00, '负债率分母-实收资本'),
(30, '4002', 'ratio', -1.00, '负债率分母-资本公积'),
-- F_INTEREST_COVER 利息保障倍数 = 利润/利息支出
(31, '4103', 'ratio', 1.00, '利息保障分子-本年利润'),
(31, '660301','ratio', -1.00, '利息保障分母-利息支出(借款)'),
-- F_EVA 经济增加值 = 利润 - 资本成本
(32, '4103', 'sum', 1.00, 'EVA-本年利润'),
(32, '4001', 'sum', -0.06, 'EVA-资本成本(实收资本×6%)'),
-- F_FCF 自由现金流
(45, '1002', 'sum', 1.00, 'FCF-银行存款'),
(45, '2202', 'sum', 1.00, 'FCF-应付账款'),
-- F_OP_PROFIT_MARGIN 经营利润率 = (收入-成本-期间费用)/收入
(183, '6001', 'ratio', 1.00, '经营利润率-收入'),
(183, '6401', 'ratio', -1.00, '经营利润率-主营业务成本'),
(183, '6601', 'ratio', -1.00, '经营利润率-销售费用'),
(183, '6602', 'ratio', -1.00, '经营利润率-管理费用'),
(183, '6603', 'ratio', -1.00, '经营利润率-财务费用'),
-- F_ROE 净资产收益率 = 净利润/净资产
(44, '4103', 'ratio', 1.00, 'ROE分子-本年利润'),
(44, '4001', 'ratio', -1.00, 'ROE分母-实收资本'),
(44, '4002', 'ratio', -1.00, 'ROE分母-资本公积'),
-- P_COST_CUT 招待费砍半
(54, '6601', 'sum', 1.00, '招待费-销售费用(业务招待子目)'),
-- P_CHANNEL_NEG 渠补谈判完成率(以渠道收入为基数)
(48, '6001', 'ratio', 1.00, '渠补谈判-主营业务收入'),
(48, '6051', 'ratio', 1.00, '渠补谈判-其他业务收入'),
-- C_MARKET_SHARE 市场份额(以收入为口径)
(33, '6001', 'ratio', 1.00, '市场份额-主营业务收入');
-- objective_kpi + krs 种子数据 (2026-08-19)
USE cma;
-- ① KPI↔O支撑: 现有3个O各关联5个KPI
INSERT INTO objective_kpi (objective_id, kpi_id, weight) VALUES
-- O11 优化成本结构——渠补谈判+管理费压缩
(11, 5, 1.00), -- F_COST_RATIO 费用率
(11, 54, 1.00), -- P_COST_CUT 招待费砍半
(11, 183, 1.00), -- F_OP_PROFIT_MARGIN 经营利润率
(11, 3, 1.00), -- F_NET_PROFIT 净利润
(11, 48, 1.00), -- P_CHANNEL_NEG 渠补谈判完成率
-- O12 保障现金流安全——应收催收+厂补确认
(12, 4, 1.00), -- F_OP_CFLOW 经营性现金流
(12, 6, 1.00), -- F_AR_DAYS 应收账款周转天数
(12, 45, 1.00), -- F_FCF 自由现金流
(12, 408, 1.00), -- F_FACTORY_REBATE_RATE 上游厂补率
(12, 17, 1.00), -- F_CURRENT_RATIO 流动比率
-- O13 渠道关系改善——用数据谈判渠补
(13, 46, 1.00), -- C_REBATE_RATE 渠补率
(13, 48, 1.00), -- P_CHANNEL_NEG 渠补谈判完成率
(13, 407, 1.00), -- F_REBATE_RATE 返利率
(13, 7, 1.00), -- C_SATISFACTION 客户满意度
(13, 24, 1.00); -- C_RETENTION_RATE 客户保留率
-- ② krs: 现有3个O各配3个KR
INSERT INTO krs (objective_id, title, metric_kpi_id, target_value, current_value, progress, status, due_date) VALUES
-- O11 优化成本结构
(11, '完成渠补谈判,渠道B渠补率降至72%', 48, 100.00, 40.00, 40, 'in_progress', '2026-09-30'),
(11, '管理费用率压缩至15%以内', 5, 15.00, 18.00, 60, 'in_progress', '2026-09-30'),
(11, '经营利润率提升至8%', 183, 8.00, 5.20, 40, 'in_progress', '2026-09-30'),
-- O12 保障现金流安全
(12, '应收账款周转天数降至45天', 6, 45.00, 62.00, 30, 'in_progress', '2026-09-30'),
(12, '经营性现金流季度回正至100万元', 4, 100.00, 40.00, 35, 'in_progress', '2026-09-30'),
(12, '上游厂补率确认至90%', 408, 90.00, 60.00, 40, 'in_progress', '2026-09-30'),
-- O13 渠道关系改善
(13, '渠道渠补率降至70%以下', 46, 70.00, 78.00, 50, 'in_progress', '2026-09-30'),
(13, '完成10场数据化渠补谈判', 48, 10.00, 2.00, 20, 'in_progress', '2026-09-30'),
(13, '客户满意度提升至90分', 7, 90.00, 85.00, 33, 'in_progress', '2026-09-30');
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""my-dashboard 隔离补漏验证: 登录 entity1 -> 工作台 -> 回查DB归属全为 entity1"""
import json
import urllib.request
import pymysql
BASE = "http://127.0.0.1:8010"
def post(path, data):
req = urllib.request.Request(
BASE + path,
data=json.dumps(data).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def get(path, token):
req = urllib.request.Request(
BASE + path, headers={"Authorization": "Bearer " + token}
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def main():
# 1. 登录 entity1
r = post("/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1})
token = r.get("access_token") or r.get("token")
assert token, f"登录失败: {r}"
print("1. 登录 entity1 OK")
# 2. 工作台
d = get("/api/cma/dashboard/my-dashboard", token)
plans = d.get("action_plans", [])
reminders = d.get("reminders", [])
print(f"2. my-dashboard OK: action_plans={len(plans)} 条, reminders={len(reminders)}")
conn = pymysql.connect(host="127.0.0.1", user="cma_user", password="cma_pass_2026",
database="cma", charset="utf8mb4")
cur = conn.cursor()
# 3. action_plans 全部归属 entity1
ids = [p["id"] for p in plans]
if ids:
fmt = ",".join(["%s"] * len(ids))
cur.execute(f"SELECT id, entity_id FROM action_plans WHERE id IN ({fmt})", ids)
rows = cur.fetchall()
bad = [r for r in rows if r[1] != 1]
print(f"3. action_plans 回查DB归属: 非entity1 = {bad if bad else ''}")
else:
print("3. action_plans 返回 0 条 (跳过)")
# 4. reminders 中 action_plan 的 related_id 归属
plan_rids = [r["related_id"] for r in reminders if r.get("related_type") == "action_plan"]
isolation_bad = False
if plan_rids:
fmt = ",".join(["%s"] * len(plan_rids))
cur.execute(f"SELECT id, entity_id FROM action_plans WHERE id IN ({fmt})", plan_rids)
rows = cur.fetchall()
bad = [r for r in rows if r[1] != 1]
isolation_bad = bool(bad)
print(f"4. reminders.action_plan related_id 共 {len(plan_rids)} 个, 非entity1 = {bad if bad else ''}")
else:
print("4. reminders 无 action_plan 类型 (跳过)")
# 5. reminders 类型/严重度分布 (前端标签/排序数据源)
dist = {}
for r in reminders:
key = (r.get("related_type"), r.get("type"), r.get("severity"))
dist[key] = dist.get(key, 0) + 1
print("5. reminders 类型分布:", dist)
cur.close()
conn.close()
assert not isolation_bad, "发现跨账套数据泄漏"
if __name__ == "__main__":
main()
print("PASS: entity1 工作台数据无跨账套泄漏")
@@ -0,0 +1,79 @@
"""budget-decompose-dialog-fix 独立复核:API 级实测 auto-decompose 全链路"""
import json
import sys
import urllib.request
BASE = "http://127.0.0.1:8010"
def post(path, body, token=None, method="POST"):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode("utf-8"),
method=method,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}" if token else "",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode("utf-8"))
except Exception:
return e.code, {"detail": e.read().decode("utf-8", "ignore")}
def main():
# 1. 登录(账套模式 entity_id=1
status, login = post("/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1})
token = login.get("token") or login.get("access_token")
if status != 200 or not token:
print("FAIL login:", status, login)
sys.exit(1)
print("PASS 登录成功, token 前缀:", token[:12], "...")
# 2. 调用 auto-decomposeequal 均分)
status, r = post("/api/cma/budget/auto-decompose", {"year": 2026, "method": "equal", "version": "v1.0"}, token)
print("auto-decompose status:", status)
if status != 200:
print(" detail:", r.get("detail", r))
print("FAIL auto-decompose 非200(可能该年无年度预算数据)")
sys.exit(2)
print(" message:", r.get("message"))
results = r.get("results") or []
print(" created:", r.get("created"), " results数:", len(results))
for res in results[:5]:
print(" -", res.get("kpi_code"), res.get("kpi_name"),
"annual=", res.get("annual_budget"), "method=", res.get("method"),
"monthly_count=", len(res.get("monthly") or []))
if not results:
print("FAIL results 为空")
sys.exit(3)
# 3. 验证每条结果字段完整(前端表格依赖)
required = ["kpi_code", "kpi_name", "annual_budget", "method", "monthly"]
for res in results:
missing = [k for k in required if k not in res]
if missing:
print("FAIL 结果缺字段:", missing, res)
sys.exit(4)
if not res.get("monthly"):
print("FAIL monthly 为空:", res.get("kpi_code"))
sys.exit(5)
print("PASS 所有结果字段完整(kpi_code/kpi_name/annual_budget/method/monthly")
# 4. 幂等抽查:再调一次,结果一致
status2, r2 = post("/api/cma/budget/auto-decompose", {"year": 2026, "method": "equal", "version": "v1.0"}, token)
snap1 = {res["kpi_id"]: tuple(res.get("monthly") or []) for res in results}
snap2 = {res["kpi_id"]: tuple(res.get("monthly") or []) for res in (r2.get("results") or [])}
print("PASS 二次调用幂等一致" if snap1 == snap2 else "WARN 二次调用结果不同(非幂等)")
print("\nRESULT: API 全链路通过")
if __name__ == "__main__":
main()
@@ -0,0 +1,56 @@
"""验证年度预算分解幂等 — R5 (2026-08-30)
调用 /api/cma/budget/auto-decompose 3 对比月度预算值是否不变
用法: cd /root/cma-management/backend && ./venv/bin/python3 scripts/verify_decompose_idempotent.py
"""
import sys
import os
import json
import urllib.request
BASE = os.getenv("CMA_BASE", "http://127.0.0.1:8010")
def post(path, body, token=None):
req = urllib.request.Request(
BASE + path,
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {token}" if token else ""},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def main():
# 登录(账套模式必须 entity_id)
login = post("/api/cma/auth/login", {"username": "admin", "password": "admin123", "entity_id": 1})
token = login.get("token") or login.get("access_token")
if not token:
print("❌ 登录失败:", login)
sys.exit(1)
print("✅ 登录成功")
runs = []
for i in range(3):
r = post("/api/cma/budget/auto-decompose", {"year": 2026, "method": "equal", "version": "v1.0"}, token)
print(f"{i+1}次: {r.get('message', '')} created={r.get('created', 0)}")
# 提取 (kpi_id -> monthly tuple)
snap = {}
for res in r.get("results", []):
snap[res["kpi_id"]] = tuple(res.get("monthly") or [])
runs.append(snap)
# 对比三次结果
same = runs[0] == runs[1] == runs[2]
print(f"\n三次结果一致: {'✅ 是(幂等)' if same else '❌ 否(不幂等)'}")
if not same:
for i in range(1, 3):
for kid in runs[0]:
if runs[0].get(kid) != runs[i].get(kid):
print(f" KPI {kid} 第1次={runs[0].get(kid)}{i+1}次={runs[i].get(kid)}")
sys.exit(0 if same else 1)
if __name__ == "__main__":
main()
Binary file not shown.
+59 -2
View File
@@ -35,6 +35,38 @@ TEST_ENGINE = create_engine(
)
TEST_SESSION_LOCAL = sessionmaker(autocommit=False, autoflush=False, bind=TEST_ENGINE)
# MySQL-only 的 date_format() 在 SQLite 下注册等价实现(仅测试库)
# 生产用 MySQL 原生函数;此处仅为让测试能跑通 expenses 月度累计校验/stats 统计
def _sqlite_date_format(dt_val, fmt):
if dt_val is None:
return None
import datetime as _dt
if isinstance(dt_val, str):
for f in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y-%m"):
try:
dt_val = _dt.datetime.strptime(str(dt_val)[:19], f)
break
except ValueError:
continue
else:
return None
if isinstance(dt_val, _dt.datetime):
d = dt_val
elif isinstance(dt_val, _dt.date):
d = _dt.datetime(dt_val.year, dt_val.month, dt_val.day)
else:
return None
return {
"%Y": f"{d.year:04d}",
"%Y-%m": f"{d.year:04d}-{d.month:02d}",
"%Y-%m-%d": f"{d.year:04d}-{d.month:02d}-{d.day:02d}",
}.get(fmt)
from sqlalchemy import event # noqa: E402
event.listen(TEST_ENGINE, "connect", lambda dbapi_conn, rec: dbapi_conn.create_function("date_format", 2, _sqlite_date_format))
# 替换 database 模块的全局引擎
db_module._engine = TEST_ENGINE
db_module._SessionLocal = TEST_SESSION_LOCAL
@@ -50,9 +82,23 @@ import hashlib
@pytest.fixture(autouse=True)
def setup_db():
"""每个测试函数自动初始化和清理数据库"""
from app.utils import cache as cache_util
cache_util.delete("ai") # 清AI分析缓存,防测试间Redis污染(dashboard-analysis缓存全局共享)
Base.metadata.create_all(bind=TEST_ENGINE)
yield
Base.metadata.drop_all(bind=TEST_ENGINE)
cache_util.delete("ai")
@pytest.fixture(autouse=True)
def _disable_ai_suggestion_push(monkeypatch):
"""R1触达修复(2026-08-31): 测试库把企微推送替换为 no-op,防测试建议推真实企微群
生产环境真实推送8800 relay测试只验证推送逻辑决策类推/预警不推/幂等不打真实企微
测试类如需断言推送内容可自行 monkeypatch.setattr 覆盖本 no-op
"""
from app.api import ai_analysis
monkeypatch.setattr(ai_analysis, "_push_decision_suggestion", lambda s: True)
@pytest.fixture
@@ -69,6 +115,13 @@ def db() -> Generator[Session, None, None]:
def client(db) -> Generator[TestClient, None, None]:
"""提供测试 HTTP 客户端"""
from app.main import app
from app.models import Entity
# 账套模式:确保测试库存在 entity_id=1 的active实体
ent = db.query(Entity).filter(Entity.id == 1).first()
if not ent:
db.add(Entity(id=1, name="测试企业", short_name="测试", status="active"))
db.commit()
# 重写依赖,使用测试数据库
app.dependency_overrides[db_module.get_db] = lambda: db
@@ -98,12 +151,16 @@ def create_test_user(db: Session, **kwargs) -> User:
def get_token_for_user(client: TestClient, username: str = "testadmin", password: str = "admin123") -> str:
"""获取测试用户的token"""
"""获取测试用户的token(账套模式:需entity_id"""
resp = client.post("/api/cma/auth/login", json={
"username": username,
"password": password,
"entity_id": 1,
})
return resp.json()["token"]
if resp.status_code != 200:
raise RuntimeError(f"登录失败: {resp.status_code} {resp.text[:300]}")
data = resp.json()
return data.get("token") or data.get("access_token")
def auth_header(token: str) -> dict:
+117 -4
View File
@@ -6,7 +6,32 @@ from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
from app.models import ActionPlan
from app.models import ActionPlan, Objective, KR
def create_test_objective_kr(db: Session, **kwargs):
"""创建测试目标+KR(行动挂KR 2026-08-27"""
obj = Objective(
entity_id=kwargs.get("entity_id", 1),
title=kwargs.get("objective_title", "测试目标"),
quarter="2026Q3",
status="active",
)
db.add(obj)
db.commit()
db.refresh(obj)
kr = KR(
entity_id=kwargs.get("entity_id", 1),
objective_id=obj.id,
title=kwargs.get("kr_title", "测试KR"),
operator=">=",
weight=50,
target_value=100,
)
db.add(kr)
db.commit()
db.refresh(kr)
return obj, kr
def create_test_plan(db: Session, **kwargs) -> ActionPlan:
@@ -109,7 +134,7 @@ class TestActionPlans:
assert data["data"][0]["title"] == "进行中"
def test_filter_by_keyword(self, client: TestClient, db: Session):
"""关键词搜索"""
"""关键词搜索keyword 应过滤 title(期望行为,2026-08-27 TDD示范)"""
user = create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
@@ -119,8 +144,9 @@ class TestActionPlans:
resp = client.get("/api/cma/action-plans?keyword=营收", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
# API当前未实现keyword过滤,返回全部2
assert len(data["data"]) == 2
# 期望行为:keyword 过滤生效,只返回标题含"营收"的1
assert len(data["data"]) == 1
assert data["data"][0]["title"] == "提升营收计划"
def test_update_plan(self, client: TestClient, db: Session):
"""更新行动计划"""
@@ -226,3 +252,90 @@ class TestActionPlans:
assert data["pending"] == 1
assert data["in_progress"] == 1
assert data["completed"] == 1
# ── 行动挂KR (2026-08-27) ──
def test_create_plan_with_kr_id(self, client: TestClient, db: Session):
"""创建行动时挂KR:返回kr_idobjective_id从KR自动继承"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj, kr = create_test_objective_kr(db)
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={
"title": "降低费用率行动",
"kpi_id": kpi.id,
"kr_id": kr.id,
"assignee": "张三",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["kr_id"] == kr.id
# 未显式传objective_id时从KR继承
assert data["objective_id"] == obj.id
def test_create_plan_kr_not_found(self, client: TestClient, db: Session):
"""kr_id不存在 → 404"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
resp = client.post(
"/api/cma/action-plans",
headers=auth_header(token),
json={"title": "行动", "kpi_id": kpi.id, "kr_id": 99999},
)
assert resp.status_code == 404
def test_list_includes_kr_title(self, client: TestClient, db: Session):
"""列表返回kr_title"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj, kr = create_test_objective_kr(db)
create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="挂KR的行动")
resp = client.get("/api/cma/action-plans", headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()["data"]
item = data[0]
assert item["kr_id"] == kr.id
assert item["kr_title"] == "测试KR"
def test_update_plan_kr_id(self, client: TestClient, db: Session):
"""更新行动可改挂KR"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj1, kr1 = create_test_objective_kr(db, kr_title="KR一")
obj2, kr2 = create_test_objective_kr(db, kr_title="KR二")
plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr1.id, title="行动")
resp = client.put(
f"/api/cma/action-plans/{plan.id}",
headers=auth_header(token),
json={"kr_id": kr2.id},
)
assert resp.status_code == 200
data = resp.json()
assert data["kr_id"] == kr2.id
def test_update_plan_kr_clear(self, client: TestClient, db: Session):
"""更新行动可置空kr_id"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj, kr = create_test_objective_kr(db)
plan = create_test_plan(db, kpi_id=kpi.id, kr_id=kr.id, title="行动")
resp = client.put(
f"/api/cma/action-plans/{plan.id}",
headers=auth_header(token),
json={"kr_id": None},
)
assert resp.status_code == 200
assert resp.json()["kr_id"] is None
+508
View File
@@ -0,0 +1,508 @@
"""
路线图R1AI建议一键落地 测试
建议CRUD + 应用到KPI/预算/行动方案 + OperationLog留痕 + 已应用/未应用状态
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from datetime import datetime
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
from app.models import AISuggestion, KPIDefinition, BudgetPlan, ActionPlan, OperationLog, KPIValue
def _create_suggestion(client, token, kpi_id, **kw):
body = {
"suggestion_type": "kpi_target",
"target_type": "kpi",
"target_id": kpi_id,
"title": "上调测试KPI目标",
"content": "达成率超预期",
"suggestion_data": {"kpi_id": kpi_id, "target_value": 150.0},
}
body.update(kw)
return client.post("/api/cma/ai/suggestions", json=body, headers=auth_header(token))
class TestSuggestionCRUD:
def test_create_and_list(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id)
assert r.status_code == 200, r.text
data = r.json()["data"]
assert data["status"] == "unapplied"
assert data["suggestion_type"] == "kpi_target"
# 列表含未应用
lst = client.get("/api/cma/ai/suggestions", headers=auth_header(token)).json()
assert lst["total"] == 1
assert lst["data"][0]["id"] == data["id"]
# 详情
det = client.get(f"/api/cma/ai/suggestions/{data['id']}", headers=auth_header(token)).json()
assert det["data"]["title"] == "上调测试KPI目标"
def test_create_missing_fields(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
r = client.post("/api/cma/ai/suggestions", json={"title": "无类型"}, headers=auth_header(token))
assert r.status_code == 400
r2 = client.post("/api/cma/ai/suggestions", json={"suggestion_type": "kpi_target"}, headers=auth_header(token))
assert r2.status_code == 400
def test_apply_kpi_target(self, client, db):
"""应用建议→改KPI目标→操作日志可查"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
r = _create_suggestion(client, token, kpi.id)
sug_id = r.json()["data"]["id"]
# 应用:改KPI目标为150
app = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "kpi_target", "target_value": 150.0},
headers=auth_header(token),
)
assert app.status_code == 200, app.text
app_data = app.json()["data"]
assert app_data["status"] == "applied"
assert app_data["applied_by"] == "测试管理员"
assert app_data["apply_detail"][0]["before"] == 100.0
assert app_data["apply_detail"][0]["after"] == 150.0
# KPI目标已变更
db.refresh(kpi)
assert kpi.target_value == 150.0
# OperationLog留痕
logs = db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply").all()
assert len(logs) == 1
assert logs[0].target_type == "kpi"
assert logs[0].target_id == kpi.id
assert logs[0].detail["suggestion_id"] == sug_id
assert logs[0].detail["before"] == 100.0
assert logs[0].detail["after"] == 150.0
# 重复应用被拒绝
app2 = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "kpi_target", "target_value": 200.0},
headers=auth_header(token),
)
assert app2.status_code == 400
def test_apply_budget_adjust(self, client, db):
"""应用建议→调预算(新建/更新BudgetPlan)→操作日志"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
title="调整预算", suggestion_data={"kpi_id": kpi.id})
sug_id = r.json()["data"]["id"]
app = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 8888.0},
headers=auth_header(token),
)
assert app.status_code == 200, app.text
plan = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id, BudgetPlan.period == "2026-09").first()
assert plan is not None
assert plan.budget_value == 8888.0
assert plan.source_type == "ai_suggestion"
# 同期间再应用→更新而非新增
app2 = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 9999.0},
headers=auth_header(token),
)
# 已applied被拒;用新建议验证upsert
r2 = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
title="调整预算2", suggestion_data={"kpi_id": kpi.id})
sug_id2 = r2.json()["data"]["id"]
app3 = client.post(
f"/api/cma/ai/suggestions/{sug_id2}/apply",
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 9999.0},
headers=auth_header(token),
)
assert app3.status_code == 200
plans = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == kpi.id, BudgetPlan.period == "2026-09").all()
assert len(plans) == 1
assert plans[0].budget_value == 9999.0
assert app3.json()["data"]["apply_detail"][0]["before"] == 8888.0
def test_apply_action_plan(self, client, db):
"""应用建议→建行动方案→操作日志"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id, suggestion_type="action_plan",
title="建行动方案", suggestion_data={"kpi_id": kpi.id})
sug_id = r.json()["data"]["id"]
app = client.post(
f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "action_plan", "title": "营收提升专项", "assignee": "张三",
"priority": "high", "due_date": "2026-09-30"},
headers=auth_header(token),
)
assert app.status_code == 200, app.text
plan = db.query(ActionPlan).filter(ActionPlan.kpi_id == kpi.id, ActionPlan.title == "营收提升专项").first()
assert plan is not None
assert plan.assignee == "张三"
assert plan.priority == "high"
assert plan.created_by == "测试管理员"
logs = db.query(OperationLog).filter(OperationLog.action == "ai_suggestion_apply",
OperationLog.target_type == "action_plan").all()
assert len(logs) == 1
assert logs[0].target_id == plan.id
def test_dismiss(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id)
sug_id = r.json()["data"]["id"]
d = client.post(f"/api/cma/ai/suggestions/{sug_id}/dismiss", headers=auth_header(token))
assert d.status_code == 200
det = client.get(f"/api/cma/ai/suggestions/{sug_id}", headers=auth_header(token)).json()
assert det["data"]["status"] == "dismissed"
# 忽略后应用被拒
app = client.post(f"/api/cma/ai/suggestions/{sug_id}/apply",
json={"action": "kpi_target", "target_value": 1}, headers=auth_header(token))
assert app.status_code == 400
def test_apply_not_found(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
app = client.post("/api/cma/ai/suggestions/9999/apply", json={}, headers=auth_header(token))
assert app.status_code == 404
class TestRuleSuggestions:
"""dashboard-analysis 自动生成建议(规则驱动)"""
def test_generate_low_ratio_action(self, client, db):
"""执行率<70% → 生成建行动方案建议"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
db.commit()
# 直接调规则生成
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
assert resp.status_code == 200
s = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
assert len(s) >= 1
assert any(x.suggestion_type == "action_plan" for x in s)
# 幂等:再调一次不重复建
resp2 = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
s2 = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
assert len(s2) == len(s)
def test_generate_high_ratio_target(self, client, db):
"""执行率>110% → 生成上调目标建议"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=150.0))
db.commit()
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
assert resp.status_code == 200
s = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
assert any(x.suggestion_type == "kpi_target" for x in s)
assert "suggestions" in resp.json()
def test_generate_budget_overrun(self, client, db):
"""预算执行率>110% → 生成调预算建议"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-08", actual_value=200.0))
db.add(BudgetPlan(entity_id=1, kpi_id=kpi.id, period="2026-08", budget_value=100.0,
budget_year=2026, budget_month=8, status="active"))
db.commit()
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
assert resp.status_code == 200
s = db.query(AISuggestion).filter(AISuggestion.suggestion_type == "budget_adjust").all()
assert len(s) >= 1
class TestSuggestionCategoryPreview:
"""R1触达修复(2026-08-31):建议分级(alert/decision) + 列表过滤 + 应用前预览 + 推送开关"""
def test_create_marks_category(self, client, db):
"""手动创建:target_type=alert → category=alert;其余 → decision"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r_alert = _create_suggestion(client, token, kpi.id, target_type="alert",
suggestion_type="action_plan", title="预警类建议")
assert r_alert.json()["data"]["category"] == "alert"
r_decision = _create_suggestion(client, token, kpi.id, title="决策类建议")
assert r_decision.json()["data"]["category"] == "decision"
def test_category_filter(self, client, db):
"""列表接口 category 过滤"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
_create_suggestion(client, token, kpi.id, target_type="alert",
suggestion_type="action_plan", title="预警A")
_create_suggestion(client, token, kpi.id, title="决策B")
lst_alert = client.get("/api/cma/ai/suggestions", params={"category": "alert"},
headers=auth_header(token)).json()
assert lst_alert["total"] == 1
assert all(x["category"] == "alert" for x in lst_alert["data"])
lst_decision = client.get("/api/cma/ai/suggestions", params={"category": "decision"},
headers=auth_header(token)).json()
assert lst_decision["total"] == 1
assert all(x["category"] == "decision" for x in lst_decision["data"])
def test_generate_marks_decision(self, client, db):
"""规则生成:执行率<70%建议(target_type=kpi)→ category=decision"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
db.commit()
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
sug = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).first()
assert sug is not None
assert sug.category == "decision"
def test_push_disabled_in_test_env(self, client, db):
"""conftest no-op 推送(monkeypatch)→ 生成决策建议不真推企微,pushed 标记置 1"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
db.commit()
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
sug = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).first()
assert sug is not None
assert sug.pushed == 1
def test_preview_kpi_target(self, client, db):
"""previewkpi_target 返回 当前目标 → 新目标"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
r = _create_suggestion(client, token, kpi.id,
suggestion_data={"kpi_id": kpi.id, "target_value": 150.0})
sug_id = r.json()["data"]["id"]
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token))
assert pv.status_code == 200, pv.text
data = pv.json()["data"]
assert data["type"] == "kpi_target"
assert data["kpi_name"] == "测试KPI"
assert data["current_target"] == 100.0
assert data["new_target"] == 150.0
def test_preview_budget_adjust(self, client, db):
"""previewbudget_adjust 返回 当前预算 → 新预算"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
db.add(BudgetPlan(entity_id=1, kpi_id=kpi.id, period="2026-09", budget_value=8000.0,
budget_year=2026, budget_month=9, status="active"))
db.commit()
r = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
title="调预算预览", suggestion_data={"kpi_id": kpi.id, "period": "2026-09",
"budget_value": 9999.0})
sug_id = r.json()["data"]["id"]
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token))
assert pv.status_code == 200, pv.text
data = pv.json()["data"]
assert data["type"] == "budget_adjust"
assert data["period"] == "2026-09"
assert data["current_budget"] == 8000.0
assert data["new_budget"] == 9999.0
def test_preview_action_plan(self, client, db):
"""previewaction_plan 返回计划信息"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id, suggestion_type="action_plan",
title="建行动方案预览", suggestion_data={"kpi_id": kpi.id,
"title": "专项改善", "priority": "high",
"due_date": "2026-09-30"})
sug_id = r.json()["data"]["id"]
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token))
assert pv.status_code == 200, pv.text
data = pv.json()["data"]
assert data["type"] == "action_plan"
assert data["plan_title"] == "专项改善"
assert data["priority"] == "high"
assert data["due_date"] == "2026-09-30"
class TestCategoryAndPreview:
"""R1触达修复(2026-08-31):建议分级 + 应用前预览"""
def _generate(self, client, db, kpi_id, actual, target=100.0):
"""造一条KPI数据并触发 dashboard-analysis 规则生成(避开缓存)"""
db.add(KPIValue(kpi_id=kpi_id, period="2026-07", actual_value=actual))
db.commit()
from app.utils.cache import delete as cache_delete
cache_delete("ai", f"dashboard_analysis:ceo:{kpi_id}")
resp = client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(get_token_for_user(client)))
assert resp.status_code == 200
return resp.json()
def test_generate_marks_category(self, client, db, monkeypatch):
"""生成建议时: target_type=alert → category=alert;其余 → decision"""
from app.api import ai_analysis
pushed = []
ai_analysis._push_decision_suggestion = lambda s: pushed.append(s) or True
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
db.commit()
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
kpi_sugs = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
assert len(kpi_sugs) >= 1
for s in kpi_sugs:
assert s.category == "decision", f"KPI建议应决策类: {s.title}"
# 建一条预警 → 规则4生成 alert 类建议
from app.models import KPIAlert
db.add(KPIAlert(kpi_id=kpi.id, alert_level="yellow", alert_message="测试预警",
alert_type="threshold", status="pending"))
db.commit()
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
alert_sugs = db.query(AISuggestion).filter(AISuggestion.target_type == "alert").all()
assert len(alert_sugs) >= 1
for s in alert_sugs:
assert s.category == "alert", f"预警建议应alert类: {s.title}"
def test_alert_not_pushed_decision_pushed(self, client, db, monkeypatch):
"""推送只发决策类:预警类不推,决策类推且只推一次(pushed=1)"""
from app.api import ai_analysis
pushed = []
ai_analysis._push_decision_suggestion = lambda s: pushed.append(s) or True
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=50.0))
db.commit()
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
kpi_sugs = db.query(AISuggestion).filter(AISuggestion.target_id == kpi.id).all()
assert len(pushed) >= 1
assert all(s.category == "decision" for s in pushed)
for s in pushed:
assert s.pushed == 1
# 预警类建议不在推送流
from app.models import KPIAlert
db.add(KPIAlert(kpi_id=kpi.id, alert_level="red", alert_message="测试预警2",
alert_type="threshold", status="pending"))
db.commit()
before = len(pushed)
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
alert_sugs = db.query(AISuggestion).filter(AISuggestion.target_type == "alert").all()
assert len(alert_sugs) >= 1
assert len(pushed) == before, "预警类不应触发推送"
# 幂等:重复生成不重推(同title建议不重建)
client.get("/api/cma/ai/dashboard-analysis", headers=auth_header(token))
assert len(pushed) == before
def test_list_category_filter(self, client, db):
"""列表接口 category 过滤"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
_create_suggestion(client, token, kpi.id, title="决策类A")
_create_suggestion(client, token, kpi.id, title="决策类B")
_create_suggestion(client, token, kpi.id, title="预警类C", target_type="alert")
lst = client.get("/api/cma/ai/suggestions?category=decision", headers=auth_header(token)).json()
assert lst["total"] == 2
assert all(x["category"] == "decision" for x in lst["data"])
lst2 = client.get("/api/cma/ai/suggestions?category=alert", headers=auth_header(token)).json()
assert lst2["total"] == 1
assert lst2["data"][0]["category"] == "alert"
def test_preview_kpi_target(self, client, db):
"""preview: kpi_target 返回 current_target → new_target"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, target_value=100.0)
r = _create_suggestion(client, token, kpi.id, suggestion_data={"kpi_id": kpi.id, "target_value": 150.0})
sug_id = r.json()["data"]["id"]
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token)).json()["data"]
assert pv["type"] == "kpi_target"
assert pv["kpi_name"] == kpi.kpi_name
assert pv["current_target"] == 100.0
assert pv["new_target"] == 150.0
def test_preview_budget_adjust(self, client, db):
"""preview: budget_adjust 返回 current_budget → new_budget"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
db.add(BudgetPlan(entity_id=1, kpi_id=kpi.id, period="2026-09", budget_value=5000.0,
budget_year=2026, budget_month=9, status="active"))
db.commit()
r = _create_suggestion(client, token, kpi.id, suggestion_type="budget_adjust",
title="调预算", suggestion_data={"kpi_id": kpi.id, "period": "2026-09", "budget_value": 8888.0})
sug_id = r.json()["data"]["id"]
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token)).json()["data"]
assert pv["type"] == "budget_adjust"
assert pv["current_budget"] == 5000.0
assert pv["new_budget"] == 8888.0
assert pv["period"] == "2026-09"
def test_preview_action_plan(self, client, db):
"""preview: action_plan 返回计划参数"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
r = _create_suggestion(client, token, kpi.id, suggestion_type="action_plan",
title="建行动方案", suggestion_data={"kpi_id": kpi.id, "title": "改善专项",
"assignee": "李四", "priority": "high", "due_date": "2026-10-01"})
sug_id = r.json()["data"]["id"]
pv = client.get(f"/api/cma/ai/suggestions/{sug_id}/preview", headers=auth_header(token)).json()["data"]
assert pv["type"] == "action_plan"
assert pv["plan_title"] == "改善专项"
assert pv["assignee"] == "李四"
assert pv["priority"] == "high"
assert pv["due_date"] == "2026-10-01"
def test_preview_not_found(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
r = client.get("/api/cma/ai/suggestions/99999/preview", headers=auth_header(token))
assert r.status_code == 404
+2
View File
@@ -17,6 +17,7 @@ class TestAuth:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": "admin123",
"entity_id": 1,
})
assert resp.status_code == 200
@@ -78,6 +79,7 @@ class TestAuth:
login_resp = client.post("/api/cma/auth/login", json={
"username": "newuser",
"password": "newpass123",
"entity_id": 1,
})
assert login_resp.status_code == 200
+196
View File
@@ -0,0 +1,196 @@
"""auto-verify 验证引擎修复测试 (2026-08-30 P1)
覆盖 verify.py 4 项缺陷修复:
1. OKR progress 防重复累加同一 plan 重复 verify 不再 +15%
2. KPIValue 回填 entity_id 多租户隔离
3. KPI 最新值按 period <= 当前月 过滤跨月验证不取未来/历史期间
4. status "done" "completed"枚举外值修正
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import ActionPlan, KPIValue, Objective
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
BASE = "/api/cma/verify"
def _seed_objective(db: Session, progress: int = 10) -> Objective:
obj = Objective(
entity_id=1,
title="测试目标",
quarter="2026Q3",
owner="任富海",
progress=progress,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def _seed_plan(db: Session, kpi_id: int, objective_id: int, rule: dict, status: str = "pending") -> ActionPlan:
plan = ActionPlan(
kpi_id=kpi_id,
objective_id=objective_id,
title="测试行动计划",
status=status,
priority="high",
auto_verify_rule=rule,
)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _make_rule(kpi_code: str) -> dict:
return {
"kpi_code": kpi_code,
"condition": "LESS_THAN", # actual < target → passed
"target_value": 80,
"notify": False, # 测试不发企微
}
class TestOkrIdempotent:
def test_repeat_verify_does_not_accumulate(self, client: TestClient, db: Session):
"""缺陷1修复:同一 plan 重复 verifyOKR progress 只累加一次"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="IDEM_001", kpi_name="幂等KPI")
obj = _seed_objective(db, progress=10)
plan = _seed_plan(db, kpi.id, obj.id, _make_rule("IDEM_001"))
# 第一次验证通过 → +15%
r1 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 50})
assert r1.status_code == 200
assert r1.json()["passed"] is True
assert r1.json()["okr_progress"]["updated"] is True
assert r1.json()["okr_progress"]["after"] == 25 # 10 + 15
# 第二次验证通过 → 不再累加(保持 25)
r2 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 40})
assert r2.status_code == 200
assert r2.json()["okr_progress"]["updated"] is False
assert r2.json()["okr_progress"]["reason"] == "already_verified"
assert r2.json()["okr_progress"]["after"] == 25 # 保持原值
db.expire_all()
assert db.query(Objective).filter(Objective.id == obj.id).first().progress == 25
def test_force_recalc_accumulates(self, client: TestClient, db: Session):
"""缺陷1修复:force_recalc=True 保留强制重新累加入口"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="IDEM_002", kpi_name="幂等KPI2")
obj = _seed_objective(db, progress=10)
plan = _seed_plan(db, kpi.id, obj.id, _make_rule("IDEM_002"))
client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 50})
r2 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token),
json={"actual_value": 40, "force_recalc": True})
assert r2.json()["okr_progress"]["updated"] is True
assert r2.json()["okr_progress"]["after"] == 40 # 25 + 15
class TestEntityBackfill:
def test_kpi_value_gets_entity_id(self, client: TestClient, db: Session):
"""缺陷2修复:回填的 KPIValue 带 entity_id(与 KPI 定义一致,非默认1)"""
create_test_user(db)
token = get_token_for_user(client)
# entity_id=2 的 KPI(模拟第二个账套)
kpi = create_test_kpi(db, kpi_code="ENT_001", kpi_name="多租户KPI", entity_id=2)
obj = _seed_objective(db)
plan = _seed_plan(db, kpi.id, obj.id, _make_rule("ENT_001"))
r = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 60})
assert r.status_code == 200
val = db.query(KPIValue).filter(KPIValue.source_batch == f"verify-plan-{plan.id}").first()
assert val is not None
assert val.entity_id == 2 # 从 KPI 定义继承,而非默认 1
assert val.kpi_id == kpi.id
class TestPeriodFilter:
def test_latest_value_respects_period_limit(self, client: TestClient, db: Session):
"""缺陷3修复:缺省 actual 时只取 period <= 当前月的值,不取未来期间"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="PER_001", kpi_name="期间KPI")
# 未来月(2026-09)有值 200 → GREATER_THAN 100 会通过;但当前月前(2026-07)值为 50 → 应取到 50
from datetime import datetime
db.add_all([
KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-07", actual_value=50,
source_type="manual", data_status="verified"),
KPIValue(kpi_id=kpi.id, entity_id=1, period="2099-12", actual_value=200,
source_type="manual", data_status="verified"),
])
db.commit()
obj = _seed_objective(db)
plan = _seed_plan(db, kpi.id, obj.id, {
"kpi_code": "PER_001",
"condition": "GREATER_THAN", # actual > target
"target_value": 100,
"notify": False,
})
# 缺省 actual → 应取 2026-07 的 50 → 不通过(若错误取到 2099-12 的 200 则会通过)
r = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={})
assert r.status_code == 200
assert r.json()["passed"] is False
assert r.json()["kpi_current_after"] == 50
def test_explicit_period_override(self, client: TestClient, db: Session):
"""缺陷3修复:调用方显式传 period 覆盖默认当前月"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="PER_002", kpi_name="期间KPI2")
db.add_all([
KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-06", actual_value=30,
source_type="manual", data_status="verified"),
KPIValue(kpi_id=kpi.id, entity_id=1, period="2026-07", actual_value=90,
source_type="manual", data_status="verified"),
])
db.commit()
obj = _seed_objective(db)
plan = _seed_plan(db, kpi.id, obj.id, {
"kpi_code": "PER_002",
"condition": "GREATER_THAN",
"target_value": 50,
"notify": False,
})
# 显式 period=2026-06 → 取 30 → 不通过
r1 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"period": "2026-06"})
assert r1.json()["kpi_current_after"] == 30
assert r1.json()["passed"] is False
# 显式 period=2026-07 → 取 90 → 通过
r2 = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"period": "2026-07"})
assert r2.json()["kpi_current_after"] == 90
assert r2.json()["passed"] is True
class TestStatusEnum:
def test_passed_plan_status_is_completed(self, client: TestClient, db: Session):
"""缺陷4修复:验证通过后 plan.status 写入枚举内值 completed,而非 done"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="ST_001", kpi_name="状态KPI")
obj = _seed_objective(db)
plan = _seed_plan(db, kpi.id, obj.id, _make_rule("ST_001"))
r = client.post(f"{BASE}/{plan.id}", headers=auth_header(token), json={"actual_value": 50})
assert r.status_code == 200
assert r.json()["passed"] is True
db.expire_all()
refreshed = db.query(ActionPlan).filter(ActionPlan.id == plan.id).first()
assert refreshed.status == "completed"
assert refreshed.status != "done"
assert refreshed.progress == 100
+370
View File
@@ -0,0 +1,370 @@
"""BOT桥接层测试 — CMA供财务/研学Bot调用的主通道(X-BOT-KEY鉴权)
覆盖 bot_bridge.py 全部18个端点
ping / overview / kpis / kpis{id}/history / strategic-maps / alerts /
budget/plans / cost/standard / cost/actual / actions / organization /
data-sources / users / query / import / okr/create / okr/list / nlp
"""
import io
import hashlib
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from openpyxl import Workbook
from sqlalchemy.orm import Session
from app.models import (
User, KPIDefinition, KPIValue, KPIAlert, StrategicMap, MapObjective,
ActionPlan, OrgNode, DataSourceConfig, Objective,
)
from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost
from tests.conftest import create_test_user, get_token_for_user, auth_header
BOT_KEY = {"X-BOT-KEY": "cma-bot-finance-2026"}
def _make_excel(kpi_code: str, period: str, value: float) -> bytes:
"""生成Excel导入文件(列: kpi_code, period, actual_value"""
wb = Workbook()
ws = wb.active
ws.append(["kpi_code", "period", "actual_value"])
ws.append([kpi_code, period, value])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _seed_kpi(db: Session, **kwargs) -> KPIDefinition:
defaults = dict(
kpi_code="BH_REVENUE",
kpi_name="营业收入",
dimension="finance",
status="active",
target_value=100.0,
unit="万元",
frequency="monthly",
)
defaults.update(kwargs)
kpi = KPIDefinition(**defaults)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
class TestPingAndAuth:
def test_ping_no_key(self, client: TestClient):
"""ping 无需鉴权"""
resp = client.get("/api/cma/bot/ping")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_invalid_bot_key(self, client: TestClient):
"""无效BOT Key → 401"""
resp = client.get("/api/cma/bot/overview", headers={"X-BOT-KEY": "wrong-key"})
assert resp.status_code == 401
def test_missing_bot_key(self, client: TestClient):
"""缺BOT Key → 401"""
resp = client.get("/api/cma/bot/overview")
assert resp.status_code == 401
class TestOverviewAndKpis:
def test_overview_stats(self, client: TestClient, db: Session):
"""总览统计:造数后计数正确"""
_seed_kpi(db)
db.add(KPIAlert(kpi_id=1, alert_level="red", alert_message="收入下滑", status="pending"))
db.commit()
resp = client.get("/api/cma/bot/overview", headers=BOT_KEY)
assert resp.status_code == 200
data = resp.json()
assert data["bot"]["name"] == "财务BOT"
assert data["stats"]["kpis_total"] == 1
assert data["stats"]["alerts_open"] == 1
def test_kpis_filter_by_dimension(self, client: TestClient, db: Session):
"""KPI列表:按维度过滤 + 关联最新实际值"""
k1 = _seed_kpi(db, kpi_code="BH_REVENUE", dimension="finance")
_seed_kpi(db, kpi_code="BH_CUSTOMER", dimension="customer")
db.add(KPIValue(kpi_id=k1.id, period="2026-06", actual_value=88.0, data_status="verified"))
db.commit()
resp = client.get("/api/cma/bot/kpis?dimension=finance", headers=BOT_KEY)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["code"] == "BH_REVENUE"
assert data["items"][0]["latest_value"] == 88.0
assert data["items"][0]["latest_period"] == "2026-06"
def test_kpis_status_filter(self, client: TestClient, db: Session):
"""KPI列表:status过滤(默认activeinactive被过滤)"""
_seed_kpi(db, kpi_code="BH_ACTIVE")
_seed_kpi(db, kpi_code="BH_INACTIVE", status="inactive")
resp = client.get("/api/cma/bot/kpis", headers=BOT_KEY)
assert resp.status_code == 200
codes = {i["code"] for i in resp.json()["items"]}
assert "BH_ACTIVE" in codes
assert "BH_INACTIVE" not in codes
def test_kpi_history(self, client: TestClient, db: Session):
"""KPI历史值"""
k = _seed_kpi(db, kpi_code="BH_REVENUE")
db.add(KPIValue(kpi_id=k.id, period="2026-07", actual_value=95.0, source_type="manual", data_status="verified"))
db.add(KPIValue(kpi_id=k.id, period="2026-06", actual_value=88.0))
db.commit()
resp = client.get(f"/api/cma/bot/kpis/{k.id}/history", headers=BOT_KEY)
assert resp.status_code == 200
data = resp.json()
assert data["kpi"]["code"] == "BH_REVENUE"
# 按期间倒序,最新在前
assert data["values"][0]["period"] == "2026-07"
assert len(data["values"]) == 2
def test_kpi_history_not_found(self, client: TestClient):
"""不存在的KPI → 404"""
resp = client.get("/api/cma/bot/kpis/99999/history", headers=BOT_KEY)
assert resp.status_code == 404
class TestMapsAlertsBudgetCost:
def test_strategic_maps(self, client: TestClient, db: Session):
"""战略地图列表(含目标)"""
m = StrategicMap(title="博海战略地图", version="v1.0", status="published",
dimensions=[{"key": "finance", "name": "财务"}])
db.add(m)
db.commit()
db.refresh(m)
db.add(MapObjective(map_id=m.id, dimension_key="finance", name="提升收入"))
db.commit()
resp = client.get("/api/cma/bot/strategic-maps", headers=BOT_KEY)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["title"] == "博海战略地图"
assert data["items"][0]["objectives"]["finance"][0]["name"] == "提升收入"
def test_alerts_filter(self, client: TestClient, db: Session):
"""预警列表:按状态/等级过滤"""
_seed_kpi(db)
db.add(KPIAlert(kpi_id=1, alert_level="red", alert_message="严重", status="pending"))
db.add(KPIAlert(kpi_id=1, alert_level="yellow", alert_message="关注", status="resolved"))
db.commit()
resp = client.get("/api/cma/bot/alerts?status=pending&level=red", headers=BOT_KEY)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["level"] == "red"
assert data["items"][0]["message"] == "严重"
def test_budget_plans(self, client: TestClient, db: Session):
"""预算计划(按年过滤)"""
k = _seed_kpi(db)
db.add(BudgetPlan(kpi_id=k.id, period="2026-06", budget_value=50000.0,
budget_year=2026, budget_month=6, status="active"))
db.commit()
resp = client.get("/api/cma/bot/budget/plans?year=2026", headers=BOT_KEY)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["budget_value"] == 50000.0
def test_budget_plans_year_filter(self, client: TestClient, db: Session):
"""预算计划:其他年份被过滤"""
k = _seed_kpi(db)
db.add(BudgetPlan(kpi_id=k.id, period="2025-12", budget_value=100.0,
budget_year=2025, budget_month=12, status="active"))
db.commit()
resp = client.get("/api/cma/bot/budget/plans?year=2024", headers=BOT_KEY)
assert resp.status_code == 200
assert resp.json()["total"] == 0
def test_cost_standard(self, client: TestClient, db: Session):
"""标准成本"""
db.add(StandardCost(product_code="P001", product_name="产品A", cost_type="material",
item_name="原料", standard_quantity=2.0, unit="kg",
standard_price=10.0, standard_cost=20.0, status="active"))
db.commit()
resp = client.get("/api/cma/bot/cost/standard", headers=BOT_KEY)
assert resp.status_code == 200
assert resp.json()["total"] == 1
assert resp.json()["items"][0]["standard_cost"] == 20.0
def test_cost_actual_period(self, client: TestClient, db: Session):
"""实际成本(按期间过滤)"""
db.add(ActualCost(period="2026-06", product_code="P001", product_name="产品A",
cost_type="material", item_name="原料",
actual_quantity=3.0, actual_price=12.0, actual_cost=36.0))
db.commit()
resp = client.get("/api/cma/bot/cost/actual?period=2026-06", headers=BOT_KEY)
assert resp.status_code == 200
assert resp.json()["total"] == 1
assert resp.json()["items"][0]["actual_cost"] == 36.0
class TestActionsOrgSourcesUsers:
def test_actions(self, client: TestClient, db: Session):
"""行动方案列表(按状态过滤)"""
k = _seed_kpi(db)
db.add(ActionPlan(kpi_id=k.id, title="提升收入", status="in_progress", priority="high", progress=50))
db.add(ActionPlan(kpi_id=k.id, title="已关闭", status="completed", priority="low", progress=100))
db.commit()
resp = client.get("/api/cma/bot/actions?status=in_progress", headers=BOT_KEY)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["title"] == "提升收入"
def test_organization(self, client: TestClient, db: Session):
"""组织架构"""
db.add(OrgNode(name="测试组织", code="TEST_ORG_001", level=1, sort_order=1, enabled=1))
db.commit()
resp = client.get("/api/cma/bot/organization", headers=BOT_KEY)
assert resp.status_code == 200
codes = [i["code"] for i in resp.json()["items"]]
assert "TEST_ORG_001" in codes
def test_data_sources(self, client: TestClient, db: Session):
"""数据源"""
db.add(DataSourceConfig(name="ERP", source_type="erp", api_endpoint="http://erp",
sync_type="batch", status="active"))
db.commit()
resp = client.get("/api/cma/bot/data-sources", headers=BOT_KEY)
assert resp.status_code == 200
assert resp.json()["total"] == 1
assert resp.json()["items"][0]["name"] == "ERP"
def test_users(self, client: TestClient, db: Session):
"""用户列表(不返回密码等敏感字段)"""
user = create_test_user(db)
# 多租户隔离(2026-08-31):bot_users 按 user_entities 授权表过滤,需先授权
from app.models import UserEntity
db.add(UserEntity(user_id=user.id, entity_id=1))
db.commit()
resp = client.get("/api/cma/bot/users", headers=BOT_KEY)
assert resp.status_code == 200
assert resp.json()["total"] >= 1
user = resp.json()["items"][0]
assert "username" in user
assert "password" not in user
class TestUnifiedQuery:
def test_query_overview(self, client: TestClient, db: Session):
"""统一查询 overview"""
_seed_kpi(db)
resp = client.get("/api/cma/bot/query?q=overview", headers=BOT_KEY)
assert resp.status_code == 200
assert resp.json()["overview"]["kpis"] == 1
def test_query_kpis_and_budget(self, client: TestClient, db: Session):
"""统一查询 kpis / budget"""
k = _seed_kpi(db)
db.add(BudgetPlan(kpi_id=k.id, period="2026-06", budget_value=10.0,
budget_year=2026, budget_month=6, status="active"))
db.commit()
r1 = client.get("/api/cma/bot/query?q=kpis", headers=BOT_KEY)
assert r1.status_code == 200
assert len(r1.json()["kpis"]) == 1
r2 = client.get("/api/cma/bot/query?q=budget", headers=BOT_KEY)
assert r2.status_code == 200
assert len(r2.json()["budget"]) == 1
def test_query_all(self, client: TestClient, db: Session):
"""统一查询 all:返回全部分组"""
_seed_kpi(db)
resp = client.get("/api/cma/bot/query?q=all", headers=BOT_KEY)
assert resp.status_code == 200
data = resp.json()
assert "overview" in data and "kpis" in data and "alerts" in data
assert "maps" in data and "budget" in data and "costs" in data
assert "actions" in data and "okr" in data
class TestImport:
def test_import_excel(self, client: TestClient, db: Session):
"""Excel导入KPI实际值"""
_seed_kpi(db, kpi_code="BH_REVENUE")
files = {"file": ("kpi.xlsx", _make_excel("BH_REVENUE", "2026-08", 99.5),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["imported"] == 1
# 验证入库
val = db.query(KPIValue).filter(KPIValue.period == "2026-08").first()
assert val is not None and val.actual_value == 99.5
def test_import_excel_unknown_kpi(self, client: TestClient, db: Session):
"""导入不存在的KPI编码 → 跳过并记录错误"""
files = {"file": ("kpi.xlsx", _make_excel("NO_SUCH_KPI", "2026-08", 10.0),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
assert resp.status_code == 200
data = resp.json()
assert data["imported"] == 0
assert data["errors"] == 1
def test_import_bad_file(self, client: TestClient):
"""非Excel文件 → 400"""
files = {"file": ("bad.txt", b"not an excel", "text/plain")}
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
assert resp.status_code == 400
def test_import_missing_value_col(self, client: TestClient, db: Session):
"""缺少数值列 → 400"""
wb = Workbook()
ws = wb.active
ws.append(["kpi_code"])
ws.append(["BH_REVENUE"])
buf = io.BytesIO()
wb.save(buf)
files = {"file": ("kpi.xlsx", buf.getvalue(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
resp = client.post("/api/cma/bot/import", headers=BOT_KEY, files=files)
assert resp.status_code == 400
class TestOkrAndNlp:
def test_okr_create_and_list(self, client: TestClient, db: Session):
"""Bot创建OKR目标 + 列表"""
resp = client.post("/api/cma/bot/okr/create?title=提升净利润&quarter=2026Q3&dimension=finance",
headers=BOT_KEY)
assert resp.status_code == 200
obj_id = resp.json()["id"]
assert obj_id > 0
list_resp = client.get("/api/cma/bot/okr/list?quarter=2026Q3", headers=BOT_KEY)
assert list_resp.status_code == 200
assert list_resp.json()["total"] == 1
assert list_resp.json()["items"][0]["title"] == "提升净利润"
def test_okr_create_missing_quarter(self, client: TestClient):
"""缺quarter → 422"""
resp = client.post("/api/cma/bot/okr/create?title=无季度目标", headers=BOT_KEY)
assert resp.status_code == 422
def test_nlp_intent_mapping(self, client: TestClient, db: Session):
"""自然语言意图映射"""
_seed_kpi(db)
# 中文意图 → 映射到预算
resp = client.get("/api/cma/bot/nlp?intent=预算", headers=BOT_KEY)
assert resp.status_code == 200
assert "budget" in resp.json()
resp2 = client.get("/api/cma/bot/nlp?intent=总览", headers=BOT_KEY)
assert resp2.status_code == 200
assert "overview" in resp2.json()
+20 -9
View File
@@ -10,7 +10,7 @@ from tests.conftest import (
create_test_user, get_token_for_user, auth_header,
create_test_kpi, create_test_map,
)
from app.models import Objective, ActionPlan, KPIDefinition, BscLayerConfig
from app.models import Objective, ActionPlan, KPIDefinition, BscLayerConfig, KR
# ============================================================
@@ -333,13 +333,21 @@ class TestOKRFullLifecycle:
assert kr_data["status"] == "pending"
def test_get_objective_with_krs(self, client: TestClient, db: Session):
"""TC11: 查看OKR详情包含关联KR"""
"""TC11: 查看OKR详情包含关联KRKR完整修复2026-08-27: 从krs表读取)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="C_CHANNEL_REBATE")
obj = create_test_objective(db, title="优化成本结构")
kr = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
title="渠补率降到75%")
# KR落krs表(KR完整修复后,ActionPlan不再是KR存储)
kr = db.query(KR).filter(KR.objective_id == obj.id).first()
if not kr:
from app.models import KR as KRModel
kr = KRModel(entity_id=1, objective_id=obj.id, title="渠补率降到75%",
metric_kpi_id=kpi.id, operator="<=", target_value=75,
weight=33, status="pending", progress=0)
db.add(kr)
db.commit()
db.refresh(kr)
resp = client.get(
f"/api/cma/okr/{obj.id}",
@@ -353,15 +361,18 @@ class TestOKRFullLifecycle:
assert kr_found, "KR应出现在OKR详情中"
def test_objective_progress_from_krs(self, client: TestClient, db: Session):
"""TC12: OKR进度随KR进度自动计算"""
"""TC12: OKR进度随KR进度自动计算KR完整修复2026-08-27: krs表)"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db)
obj = create_test_objective(db)
kr1 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
title="KR1", progress=80)
kr2 = create_test_kr(db, kpi_id=kpi.id, objective_id=obj.id,
title="KR2", progress=40)
from app.models import KR as KRModel
kr1 = KRModel(entity_id=1, objective_id=obj.id, title="KR1",
metric_kpi_id=kpi.id, progress=80, weight=50)
kr2 = KRModel(entity_id=1, objective_id=obj.id, title="KR2",
metric_kpi_id=kpi.id, progress=40, weight=50)
db.add_all([kr1, kr2])
db.commit()
# 触发progress重算
resp = client.patch(
File diff suppressed because it is too large Load Diff
+462
View File
@@ -0,0 +1,462 @@
"""预算系统技术改进测试 (2026-08-28 yanxue-budget-tech-improve)
覆盖:
P1- 告警归因(alert_type/attribution/scenario_id + 详情接口)
P1- 实际值自动归集(映射CRUD/采集器/覆盖率)
P2- 真零基逐项论证(CRUD/generate/method-comparison is_demo)
P2- 派生规则可配置(规则CRUD/apply-method rule_source)
P2- 双路径合并(两出口级别一致, 无第二套阈值逻辑)
P2- 现金流分类规则(待分类队列/一键归类)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
from app.models import (
BudgetPlan, KPIValue, BudgetDeviationAlert, KPIAlert,
KPIValueSource, KPIValueCollectLog,
BudgetZeroBasedItem, BudgetDerivationRule,
CashPlanClassifyRule, CashPlanUnclassified, CashPlan,
ScenarioSuggestion, KPIDefinition,
)
class TestP1AlertAttribution:
"""P1-③ 告警归因: 告警从'差多少''差在哪+怎么办'"""
BASE = "/api/cma/budget"
def _setup_alert(self, client, db, kpi_code="ATTRIB_KPI", kpi_name="销售费用", actual=150.0, budget=100.0):
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code=kpi_code, kpi_name=kpi_name)
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=budget,
budget_year=2026, budget_month=6, status="active"))
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=actual))
db.commit()
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
json={"period": "2026-06", "threshold": 20})
assert resp.status_code == 200
return token, kpi, resp
def test_deviation_check_writes_attribution(self, client: TestClient, db: Session):
"""生成告警时同步写 alert_type/attribution/scenario_id"""
token, kpi, resp = self._setup_alert(client, db, actual=150.0, budget=100.0)
assert resp.json()["alerts_generated"] == 1
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first()
assert alert is not None
assert alert.alert_level == "warning"
# 归因JSON结构
assert alert.attribution is not None
attr = alert.attribution
assert "dimensions" in attr and "subjects" in attr
assert "variance_type" in attr and "trend" in attr
assert attr["variance_type"] in ("quantity_diff", "price_diff", "mixed")
assert "anomaly" in attr["trend"]
# 场景建议关联(费用类KPI → cost_high 模板)
if alert.scenario_id:
s = db.query(ScenarioSuggestion).filter(ScenarioSuggestion.id == alert.scenario_id).first()
assert s is not None
assert s.alert_type in ("cash_low", "cash_critical", "cost_high", "revenue_drop")
def test_attribution_detail_endpoint(self, client: TestClient, db: Session):
"""GET /deviation-alerts/{id}/attribution 返回归因+场景建议"""
token, kpi, _ = self._setup_alert(client, db, actual=200.0, budget=100.0)
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.kpi_id == kpi.id).first()
resp = client.get(f"{self.BASE}/deviation-alerts/{alert.id}/attribution",
headers=auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert data["attribution"] != {}
assert "dimensions" in data["attribution"]
# scenario 建议联查(无匹配时可空, 有模板时必须带文本)
if data["scenario"]:
assert data["scenario"]["title"]
def test_list_alerts_has_attribution_fields(self, client: TestClient, db: Session):
"""列表响应新增 alert_type/attribution/scenario_id 字段(可空)"""
token, kpi, _ = self._setup_alert(client, db)
resp = client.get(f"{self.BASE}/deviation-alerts", headers=auth_header(token))
row = resp.json()["data"][0]
assert "alert_type" in row
assert "attribution" in row
assert "scenario_id" in row
def test_alert_direction_config(self, client: TestClient, db: Session):
"""P2-⑤ 方向配置 GET/PUT system_configs"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token))
assert resp.status_code == 200
assert "SALES_TOTAL" in resp.json()["codes"]
resp2 = client.put(f"{self.BASE}/alert-direction", headers=auth_header(token),
json={"codes": ["SALES_TOTAL", "CUSTOM_COUNT"]})
assert resp2.status_code == 200
assert resp2.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"]
resp3 = client.get(f"{self.BASE}/alert-direction", headers=auth_header(token))
assert resp3.json()["codes"] == ["SALES_TOTAL", "CUSTOM_COUNT"]
assert resp3.json()["is_configured"] is True
class TestP1ValueCollect:
"""P1-④ 实际值自动归集"""
BASE = "/api/cma/budget"
def test_value_source_crud_and_collect(self, client: TestClient, db: Session):
"""映射CRUD → 采集器 → kpi_values 出现 auto_collect"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="COLLECT_KPI")
# 建映射
resp = client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
"kpi_id": kpi.id,
"source_table": "voucher_details",
"source_field": "credit_amount",
"aggregate": "sum",
"filter_rule": {"direction": "credit"},
"period_field": "period",
"unit_conversion": 1,
})
assert resp.status_code == 200
# 采集器试跑(不写库)
test_resp = client.post(f"{self.BASE}/value-sources/test", headers=auth_header(token), json={
"kpi_id": kpi.id,
"source_table": "voucher_details",
"source_field": "credit_amount",
"aggregate": "sum",
"filter_rule": {"direction": "credit"},
"period_field": "period",
})
assert test_resp.status_code == 200
assert test_resp.json()["value"] is not None
# 手动触发采集
run_resp = client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token),
json={"period": "2026-06"})
assert run_resp.status_code == 200
assert run_resp.json()["collected"] >= 1
# 验证 kpi_values 落库
val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == "2026-06",
KPIValue.source_type == "auto_collect",
).first()
assert val is not None
assert val.actual_value is not None
assert val.remark and "自动归集" in val.remark
# 采集日志
logs = db.query(KPIValueCollectLog).filter(KPIValueCollectLog.kpi_id == kpi.id).all()
assert len(logs) >= 1
# 覆盖率
cov = client.get(f"{self.BASE}/value-sources/coverage", headers=auth_header(token))
assert cov.status_code == 200
assert cov.json()["mapped_count"] >= 1
def test_collector_idempotent(self, client: TestClient, db: Session):
"""同kpi+period 重复采集 → 更新不新增"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="COLLECT_IDEMP")
client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
"kpi_id": kpi.id, "source_table": "voucher_details",
"source_field": "credit_amount", "aggregate": "sum",
})
client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
rows = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == "2026-06",
KPIValue.source_type == "auto_collect",
).all()
assert len(rows) == 1
def test_collector_logs_filter(self, client: TestClient, db: Session):
"""采集日志 status 过滤"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="COLLECT_LOG")
client.post(f"{self.BASE}/value-sources", headers=auth_header(token), json={
"kpi_id": kpi.id, "source_table": "voucher_details",
"source_field": "credit_amount", "aggregate": "sum",
})
client.post(f"{self.BASE}/value-collect/run", headers=auth_header(token), json={"period": "2026-06"})
resp = client.get(f"{self.BASE}/value-collect/logs", headers=auth_header(token),
params={"status": "success"})
assert resp.json()["total"] >= 1
class TestP2ZeroBased:
"""P2-① 真零基逐项论证"""
BASE = "/api/cma/budget"
def _setup_kpi_with_plans(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
# 核心4KPIapply-method 需要)
for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"):
create_test_kpi(db, kpi_code=code, kpi_name=code)
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first()
return token, kpi
def test_zero_based_items_crud_and_generate(self, client: TestClient, db: Session):
"""录入3个科目 → 逐项论证 → generate → budget_plans 出现且金额=Σ建议值"""
token, kpi = self._setup_kpi_with_plans(client, db)
# 录入3个论证项
items = [
{"item_name": "房租", "item_category": "fixed", "base_value": 15, "proposed_value": 15, "justification": "合同锁定"},
{"item_name": "招待费", "item_category": "discretionary", "base_value": 16, "proposed_value": 8, "justification": "压缩50%"},
{"item_name": "杂项", "item_category": "discretionary", "base_value": 12, "proposed_value": 8, "justification": "压缩30%"},
]
for it in items:
r = client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
"kpi_id": kpi.id, "period": "2026-06", **it,
})
assert r.status_code == 200
# 列表+合计
lst = client.get(f"{self.BASE}/zero-based/items", headers=auth_header(token),
params={"kpi_id": kpi.id, "period": "2026-06"})
assert lst.json()["total"] == 3
assert lst.json()["total_proposed"] == 31.0
# generate → budget_plans
gen = client.post(f"{self.BASE}/zero-based/generate", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06"})
assert gen.status_code == 200
assert gen.json()["total"] == 31.0
plan = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi.id,
BudgetPlan.period == "2026-06",
BudgetPlan.version.like("zbb-%"),
).first()
assert plan is not None
assert plan.budget_value == 31.0
assert plan.calc_logic == "zero_based_itemized"
def test_method_comparison_zero_based_is_demo_false(self, client: TestClient, db: Session):
"""method-comparison 传论证KPI → is_demo=false; 不传 → is_demo=true"""
token, kpi = self._setup_kpi_with_plans(client, db)
client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
"kpi_id": kpi.id, "period": "2026-06",
"item_name": "房租", "item_category": "fixed",
"base_value": 15, "proposed_value": 15,
})
# 有论证项 → 真零基
r1 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={
"zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06",
})
zbb1 = [m for m in r1.json()["methods"] if m["id"] == "zero_based"][0]
assert zbb1["is_demo"] is False
assert zbb1["item_count"] == 1
# 无论证项 → demo fallback
r2 = client.post(f"{self.BASE}/method-comparison", headers=auth_header(token), json={})
zbb2 = [m for m in r2.json()["methods"] if m["id"] == "zero_based"][0]
assert zbb2["is_demo"] is True
def test_apply_method_zero_based_writes_plan(self, client: TestClient, db: Session):
"""apply-method zero_based → 落库 zbb 版本"""
token, kpi = self._setup_kpi_with_plans(client, db)
client.post(f"{self.BASE}/zero-based/items", headers=auth_header(token), json={
"kpi_id": kpi.id, "period": "2026-06",
"item_name": "房租", "item_category": "fixed",
"base_value": 15, "proposed_value": 15,
})
r = client.post(f"{self.BASE}/apply-method", headers=auth_header(token), json={
"method": "zero_based", "year": 2026,
"zero_based_kpi_id": kpi.id, "zero_based_period": "2026-06",
})
assert r.status_code == 200
class TestP2DerivationRules:
"""P2-② 派生规则可配置"""
BASE = "/api/cma/budget"
def _setup(self, client, db):
create_test_user(db)
token = get_token_for_user(client)
for code in ("F_REVENUE", "F_NET_PROFIT", "F_COST_RATIO", "F_GROSS_MARGIN"):
create_test_kpi(db, kpi_code=code, kpi_name=code)
return token
def test_rule_crud_and_apply(self, client: TestClient, db: Session):
"""配置 F_NET_PROFIT 派生率 5% → apply-method → rule_source=configured 且结果变化"""
token = self._setup(client, db)
rev = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_REVENUE").first()
np_kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == "F_NET_PROFIT").first()
# 无规则时 apply → default 比例(2%)
r_default = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
json={"method": "incremental", "year": 2026})
np_default = [a for a in r_default.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0]
assert np_default["rule_source"] == "default"
assert r_default.json()["rule_source"] == "default"
# 建规则: percentage_of → 来源F_REVENUE × 5%
# 先给来源KPI实际值(真实链路: base_kpi实际值 × rate
db.add(KPIValue(kpi_id=rev.id, period="2026-05", actual_value=2000.0))
db.commit()
r_rule = client.post(f"{self.BASE}/derivation-rules", headers=auth_header(token), json={
"kpi_id": np_kpi.id,
"rule_type": "percentage_of",
"base_kpi_id": rev.id,
"params": {"rate": 0.05},
"formula_text": "净利润 = 营业收入 × 5%",
})
assert r_rule.status_code == 200
# 配置后 apply → rule_source=configured, 金额=2000×5%=100
r2 = client.post(f"{self.BASE}/apply-method", headers=auth_header(token),
json={"method": "incremental", "year": 2026})
assert r2.json()["rule_source"] == "configured"
np_after = [a for a in r2.json()["applied"] if a["kpi_code"] == "F_NET_PROFIT"][0]
assert np_after["rule_source"] == "configured"
assert np_after["budget_value"] == 100.0
# 规则列表
lst = client.get(f"{self.BASE}/derivation-rules", headers=auth_header(token))
assert lst.json()["total"] == 1
assert lst.json()["data"][0]["rule_type"] == "percentage_of"
class TestP2SingleAlertPath:
"""P2-⑤ 双路径合并: 单一告警逻辑, 两出口级别一致"""
BASE = "/api/cma/budget"
def test_single_build_function_two_exits(self, client: TestClient, db: Session):
"""run_deviation_check 走统一逻辑写 KPIAlert; deviation-check 写 budget_deviation_alerts"""
from app.utils.deviation_engine import build_deviation_alert, run_deviation_check
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="SINGLE_PATH_KPI", kpi_name="测试成本")
db.add(BudgetPlan(kpi_id=kpi.id, period="2026-06", budget_value=100.0,
budget_year=2026, budget_month=6, status="active"))
db.add(KPIValue(kpi_id=kpi.id, period="2026-06", actual_value=160.0)) # 60% 超支
db.commit()
# KPIAlert 出口: 级别 red(≥30)
r = build_deviation_alert(db, kpi, "2026-06")
assert r["triggered"] is True
assert r["kpi_alert_level"] == "red"
assert r["level"] == "critical" # >50
# budget 出口 API: deviation-check
resp = client.post(f"{self.BASE}/deviation-check", headers=auth_header(token),
json={"period": "2026-06", "threshold": 20})
assert resp.json()["alerts_generated"] == 1
# run_deviation_check 写 KPIAlert
n = run_deviation_check(db, "2026-06")
assert n >= 1
kpi_alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[差异预警]"),
).first()
assert kpi_alert is not None
assert kpi_alert.alert_level == "red"
assert kpi_alert.suggestion # 非模板空文案
def test_no_second_threshold_logic(self, client: TestClient, db: Session):
"""deviation_engine 中不应再有独立阈值/方向列表(grep 验证在代码review, 此处测函数可用)"""
from app.utils.deviation_engine import build_deviation_alert, get_higher_better_codes
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="HB_KPI", kpi_name="营业收入")
assert "SALES_TOTAL" in get_higher_better_codes(db)
class TestP2CashClassify:
"""P2-⑥ 现金流分类规则表"""
BASE = "/api/cma/budget"
def test_unclassified_queue_and_classify(self, client: TestClient, db: Session):
"""无关键词KPI → sync-cash-plans → 待分类队列(不静默跳过) → 一键归类 → CashPlan"""
create_test_user(db)
token = get_token_for_user(client)
# 无任何关键词的KPI(不会命中默认关键词)
kpi = create_test_kpi(db, kpi_code="MYSTERY_KPI", kpi_name="部门专项投入待定")
# 移除'投入'关键词冲突: 名称改无关键词
kpi.kpi_name = "神秘专项"
db.commit()
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 300.0})
# sync → 进待分类队列
resp = client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["unclassified_count"] >= 1
item = db.query(CashPlanUnclassified).filter(
CashPlanUnclassified.kpi_id == kpi.id,
CashPlanUnclassified.status == "pending",
).first()
assert item is not None
assert item.reason == "未匹配任何分类规则"
# 队列列表
lst = client.get(f"{self.BASE}/cash-unclassified", headers=auth_header(token),
params={"status": "pending"})
assert any(r["kpi_id"] == kpi.id for r in lst.json()["data"])
# 一键归类 receive
cls = client.post(f"{self.BASE}/cash-unclassified/{item.id}/classify", headers=auth_header(token),
json={"plan_type": "receive"})
assert cls.status_code == 200
assert cls.json()["rule_created"] is True
# 规则自动补建 + CashPlan 生成
rule = db.query(CashPlanClassifyRule).filter(
CashPlanClassifyRule.entity_id == 1,
CashPlanClassifyRule.kpi_id == kpi.id,
).first()
assert rule is not None and rule.plan_type == "receive"
plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first()
assert plan is not None and plan.plan_type == "receive"
# 队列状态 → classified
db.refresh(item)
assert item.status == "classified"
def test_rule_priority_over_keyword(self, client: TestClient, db: Session):
"""规则表精确匹配优先于默认关键词"""
create_test_user(db)
token = get_token_for_user(client)
kpi = create_test_kpi(db, kpi_code="OVERRIDE_KPI", kpi_name="营业收入") # 默认会命中 receive
# 规则表强制 pay
r = client.post(f"{self.BASE}/cash-classify-rules", headers=auth_header(token), json={
"kpi_id": kpi.id, "plan_type": "pay", "priority": 1,
})
assert r.status_code == 200
client.post(f"{self.BASE}/plans", headers=auth_header(token),
json={"kpi_id": kpi.id, "period": "2026-06", "budget_value": 500.0})
client.post(f"{self.BASE}/sync-cash-plans", headers=auth_header(token))
plan = db.query(CashPlan).filter(CashPlan.related_kpi_id == kpi.id).first()
assert plan is not None
assert plan.plan_type == "pay" # 规则覆盖关键词

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