Author SHA1 Message Date
Hermes CI Fix b736e0727a fix(strategy-map): 战略地图三处 P1 缺陷(D1 完备度路由 422 / D2 连线删除不落库 / D3 窄屏拖拽失效)
D1 GET /api/cma/maps/completeness/objectives 恒 422:真抢占者是先注册的
   objectives.router GET /{map_id}/objectives(map_id 被当 int 解析)。把
   map_completeness / map_summary 两个字面路径路由器前移到 objectives 之前,
   不动 prefix、不删参数化路由。新增同前缀族全量巡检用例。

D2 DELETE /{id}/connections 返回 200 不落库:canvas_data 为裸 Column(JSON),
   就地/换列表赋值不被 ORM 判脏。模型改 MutableDict.as_mutable(JSON)(不改 DDL),
   add_connection / delete_connection_by_key 均显式 flag_modified 双保险。

D3 窄视口 .kp-panel(fixed/z-1000) 遮画布,onTreeDragOver 用 elementFromPoint
   取节点恒 null ⇒ 拖拽建连线失效。改为遍历画布内 [data-node-key] 做 rect 包含
   判定、多点取中心最近者,不再回退 elementFromPoint。

测试:新增 backend/tests/test_map_route_shadowing_20261020.py(路由解析断言到
endpoint.__module__)与 test_map_connection_persistence_20261020.py(新 session
原生 SQL 复读 + 负测)。零生产写、零上线。
2026-10-21 02:04:28 +08:00
wecom-fullstack 86e24c6310 fix(strategy-map): 移除悬空 import kpi_target_reader(该模块全仓不存在,致 app.main 导入失败/部署自检中止)→ 目标值取 target_monthly or target_value
ci/woodpecker/push/main Pipeline was successful
2026-10-20 13:01:02 +08:00
wecom-fullstack ecbe55c48a merge: origin/main 合入战略地图改造分支(解 MapCanvas 模板冲突:摘要条+编辑条+口径条并存)
ci/woodpecker/push/main Pipeline was successful
2026-10-20 11:34:29 +08:00
wecom-fullstack c030fb7d18 fix(strategy-map): 口径违禁串代码级清零(9,492.6/83.9%/19/19/el-dialog 仅存于注释)+ components.d.ts 补 MapDimensionFilter 2026-10-20 11:33:58 +08:00
wecom-fullstack a8c9b38fff feat(caliber-label): 科目口径·缺票 常驻标识 + 测算/待票 数值标识(展示层 P2)
ci/woodpecker/push/main Pipeline was canceled
- 新增 constants/caliber.ts(文案单一真源)+ components/CaliberBadge.vue(常驻,非 hover)
- 经营看板/Dashboard:页头常驻「科目口径 · 缺票」(tooltip 含 四件套 0/4、invoice_check 0 行)+ 毛利率与 A 口径 KPI 卡「测算/待票」
- 战略地图看板/StrategyMap:meta-bar 常驻标识位 + 节点级测算标识(与 v4 §2/§4 共用同一组件)
- 战略地图画布/MapCanvas、KPI 列表 KPIListView 同步复用同一组件
- 只做展示层:不改库、不重算历史值、不静默按实账回填
2026-10-20 02:11:00 +08:00
Hermes CI Fix 53cad7eaa9 merge: origin/main (A3 三维切片) 合入战略地图改造分支 2026-10-19 14:32:33 +08:00
Hermes CI Fix a8141e8879 wip(strategy-map): v4 口径落码(分线营收/新式毛利率/管理指标/C1 hold/57 归档) 2026-10-19 14:32:08 +08:00
wecom-fullstackandHermes CI Fix d85dd2a155 feat(strategy-map): A3 三维切片(时间/门店/产品)——切片重算 + 显式空态 + entity 隔离
ci/woodpecker/push/main Pipeline was successful
- 新增 /api/cma/maps/{id}/dimension-options 与 /{id}/slice(只读)
- 时间:kpi_values.period 月/季/年;flow 求和、ratio 均值、balance/status 取最新
- 产品:product_sales + 逐月源一致性闸门(偏差>1% 不切片)
- 门店:库内无门店级事实 ⇒ available=false + reason,绝不回退总额
- 前端 MapDimensionFilter 挂到 MapCanvas,切换即 emit 切片,清除恢复 /review
2026-10-19 13:32:49 +08:00
Hermes CI Fix 4d20c27de4 wip(strategy-map): V1+V3 合版 + v2/v3 口径(完备度/C1 hold/57 归档组) 2026-10-19 10:33:40 +08:00
project-bot c92dbab96a feat(kb): KnowledgePanel 接线 6 页 + /maps/canvas 错配消除
ci/woodpecker/push/main Pipeline was successful
- /budget /kpis /deviations /predict /maps-review /alerts 六页按 related_page 挂载 KnowledgePanel
- /maps/canvas 页 prop 由 /maps 更正为 /maps/canvas(与表值一致,消除恒取空)
- 匹配口径走 backend/app/utils/route_key.py(origin/main 已有,KBFF-v11c 精确等值)
- 验证:本地产物 CDP 冒烟 18/18(六页 dom=api=1;/maps 命中 0 负对照)
2026-10-19 10:23:12 +08:00
wecom-fullstack 0fc2c6109e style(page-title): 补漏——战略地图看板页标题弱化 + KPIListView 标题 emoji 清零
ci/woodpecker/push/main Pipeline was successful
- StrategyMap.vue: 页内 h2 19px/600/深色(与路由标题「战略地图看板」基本同名)降为
  div.page-subtitle 14px/500/#64748B(保留地图名+版本+状态等增量信息,不再充当 H1)
- KPIListView.vue: 组件 h3 图标默认值 '📊' 置空(v-if 渲染),分类标题与明细卡头
  '📋' 改为 Lucide 内联 SVG(clipboard-list,18/16px,#64748B)
- 仅模板与样式:不动 MainLayout、不改接口/数据/路由 key、不碰 MapCanvas.vue
2026-10-19 04:34:35 +08:00
wecom-fullstack bf641d8d31 refactor(menu): 侧边栏按决策域重构(L1 九域 + L2 决策场景)+ PDCA 回退开关
ci/woodpecker/push/main Pipeline was successful
- 新增 frontend/src/config/decisionDomains.ts:9 个决策域(100 战略…900 系统)
  与 22 个 L2 决策场景,只写 path 关系;文案/图标/roles 仍取 permission.ts MENU_ITEMS
- MainLayout.vue:决策域菜单为默认(el-sub-menu 域 → el-menu-item-group 场景 → 页面),
  「我的工作台」常驻置顶,系统域默认折叠;未登记菜单项兜底进 900「未归类」(不丢项)
- 回退开关:localStorage.cma_menu_mode='pdca' 或 URL ?menu=pdca,右上角下拉可切换
- 路由 path、页面、API、DB、zone A/B 权限判定一律未改
- components.d.ts 由 unplugin-vue-components 自动补 ElMenuItemGroup 声明
2026-10-18 21:45:32 +08:00
Hermes CI Fix 7f36608f60 chore(frontend): 重新生成 components.d.ts(budget 面板组件 + ForecastTag 自动注册)
ci/woodpecker/push/main Pipeline failed
2026-10-18 20:37:51 +08:00
Hermes CI Fix 5bda882aa9 Merge remote-tracking branch 'origin/main' into integration/merge-wt-20261018 2026-10-18 20:32:32 +08:00
wecom-fullstack f51fc72ac7 style(page-title): B 全站规范——页面内同名标题清零 + 增量副标题弱化 + 标题 emoji 换 Lucide
ci/woodpecker/push/main Pipeline failed
- 39+ 页删除与布局标题同名/基本同名的页面内 h1-h4(同屏三标题→两处)
- 增量副标题弱化为 14px #64748B 小字:收付款计划/战略回顾/地图版本/会计科目打标
- 标题 emoji 清零,保留标题处改 Lucide 内联 SVG(bar-chart-3/video/scale/clipboard-list/alert-circle/map/history)
- KPIListView 增加 hideTitle(仅展示),客户/学习看板隐藏组件内重复页头
- 不动 MainLayout、不改接口/业务逻辑、不碰 MapCanvas.vue
2026-10-18 19:33:11 +08:00
Hermes CI Fix a67f4b688f feat(receivables): customer-level rows only + register hanke_ar as balance snapshot batch
ASK-20261001-33: api_receivables counts only rows with a non-blank counterparty (monthly aggregate rows stay in table but out of the collection page cards/aging/detail/owners); BALANCE_SNAPSHOT_SOURCES gains hanke_ar (default skip, no per-customer overdue alerts). New pytest file covers aggregate-row exclusion, e2 baseline digitwise, snapshot registration.
(cherry picked from commit 475f78bc8c5f67f2716c721172a88ff89ec58227)
2026-10-18 16:42:06 +08:00
Hermes CI Fix fa1267c323 feat(budget): effective-version caliber + process-order tabs + 4 missing rings
Task: CMA-20261003 (budget module governance). Local commit only; push is
gated on release approval (see report).

Backend
- app/utils/budget_status.py (new): single source for the effective-version
  caliber. EFFECTIVE_STATUSES = approved + active; effective_plan_clause()
  enforces entity isolation; pick_effective() priority active > approved >
  updated_at desc. Fixes: approved budgets disappeared from the execution
  surface because the three queries only read status='active'.
- budget.py: /comparison, /comparison/kpi/{id}, /deviation-check now read the
  effective surface; versions list gains is_effective / can_release fields;
  submit/approve are fail-closed (draft|active -> submitted -> approved).
- deviation_engine.get_budget_for_kpi: effective surface + deterministic pick.
- 4 missing rings, zero DDL, appended to the same router:
  POST /versions/release (approved -> active, idempotent by operation_logs),
  POST /adjustments (derive a new draft version, base rows untouched),
  GET /control/status (effective budget vs actual -> ok/warn/over),
  GET /assessment (achieve rate + A/B/C/D grade).
  Audit rows written through app.utils.oplog.log_op with entity_id.

Frontend
- BudgetManagement.vue: top-level tabs reordered to the process order
  (strategy, method, driver, input, decompose, versions, release, execution,
  deviation, adjust, assess, rolling). Pane name values unchanged, so old
  bookmarks keep working; routes unchanged.
- Cash classify rules / unclassified queue moved to /cash-plan
  (components/CashClassifyPanel.vue); budget actual-value collection moved to
  /data-manage (components/BudgetCollectPanel.vue). Mount points only: no
  change to cash_plans data, classify logic, collection logic or kpi_values.
- api/index.ts: 4 ring endpoints exposed.

Docs / tests
- docs/budget-module-governance-design.md: phase-0 design doc (status machine,
  tab order, domain boundaries, 4-ring design, cleanup write pack scope).
- tests/test_budget_status_machine.py (new): effective surface, version
  guards, release idempotency, adjustment, control and assessment.
- tests/test_budget.py: existing cases adapted to the fail-closed contract.

(cherry picked from commit 4267fc4d25ee45c5675a541af9777be96fe9f9c2)
2026-10-18 16:32:55 +08:00
Hermes CI Fix 75a48f55d2 feat(strategy-map): live board reading map_objectives + kpi_targets (read-only)
Task: finance-20261011-strategy-map-frontend-fullstack (P2, read-only integration)

- backend/app/utils/strategy_board.py: single source of truth for the level
  caliber (layer colors, inverse-kpi explicit whitelist, status class by
  unit, zero guard, period ranking, latest-actual picking). Pure functions.
- backend/app/api/maps.py: GET /api/cma/maps/{map_id}/strategy-board
  - nodes straight from map_objectives (sorted by sort_order, id)
  - node<->kpi binding with 4-step fallback, binding_source echoed back
  - targets from kpi_targets (target_type compatible: yearly + annual),
    deduped per (kpi_id, period) with yearly preferred
  - actuals from kpi_values actual-only scope, latest period
  - causality edges from kpi_causality, out-of-map endpoints dropped+counted
  - entity_id taken from the token only (no cross-tenant reads)
  - zero writes; nothing is persisted by this endpoint
- frontend/src/views/StrategyMap.vue: /strategy-map live board (4 swim lanes
  bottom-up, node cards, 20 causality arrows in SVG, period switch, A3 print).
  No emoji, null guards everywhere (no toFixed on null).
- api/index.ts + router/index.ts + permission.ts: route + menu registration
  (/strategy-map, zone A read-only, roles ceo/finance/business/it).
- backend/tests/test_strategy_board_20261011.py: 33 cases (spec section 4 /
  4.1 / 4.2 / 4.3 + section 7 fixtures + threshold boundaries + period rank).
- backend/tests/test_map_objectives_kpis_form_20261011.py: 14 cases for the
  four kpis column shapes (list, json string, object list, NULL/[]/weird).

Not included: push + backend restart (production write, separate gate).

(cherry picked from commit c675bde6b4beaf1d8a9d02a80f5434203a53f348)
2026-10-18 16:31:52 +08:00
Hermes CI Fix 2cff58ca6a feat(docs-center): report reading section (type=report) on /api/cma/docs
Increment #2 over PoC ede0524 (task yanxue-20260924-cma-report-source).

- backend derives reports from existing drop points (wisdom/_reports + every profile pending-tasks/_reports), read live per request, no cache, no rebuild

- gate = front-matter plus non-empty conclusion; missing either means silently out of the list (receipts/audits never enter); title/date/domain/sources missing are marked incomplete instead of rejected

- entity + role partitioning (ceo/it cross-entity; others own entity plus group/generic); kpi_refs resolve to a one-hop metric link

- new GET /api/cma/docs/report/{rid} detail endpoint; knowledge branch unchanged (type defaults to knowledge)

- frontend: CMAKnowledge adds a report reading pane (filters + detail dialog), reuses the existing markdown renderer, no new deps

(cherry picked from commit 7c6e0857e68133f5ba2a539fd3569aeb82547911)
2026-10-18 16:31:52 +08:00
Hermes CI Fix 57f9dbae15 fix(action-plans): 「已逾期」改日期粒度(口径 B,当天到期不算逾期)
- _apply_overdue():`ActionPlan.due_date < datetime.now()` → `... < 今天0点`
  (唯一实现,plan_stats 与 list_plans 共用,故一处改即两端点同改)
- 口径同步注释(口径 A→B 的沿革与分界式);行为改动仅此一处
- 测试夹具改到日粒度(整天偏移),新增「今日到期不算逾期」鉴别行与
  `test_overdue_caliber_is_date_granularity` 口径锁定用例(防回退 A)
- 依据:任总 2026-09-22 10:10 拍板口径 B;前端角标 ActionPlanLibrary.vue::isOverdue
  与 cash.py::_plan_dict 本就同粒度,本单未动
- 只改码:未 push / 未部署 / 未重启(发布须放行)

(cherry picked from commit 2da5b6da018013f860430913b5b0e0436f966685)
2026-10-18 16:31:52 +08:00
wecom-project 5fa83e2532 fix(knowledge): related_page 过滤精确化(子串包含 -> 归一候选键等值)
KBFF-v11c(派单 KBFF-v11c-relatedpage-exact-match-filter · A 侧 wecom-yanxue · 执行人 wecom-project)
本提交=代码面切片:未 push、未部署;生产部署另走发布单 + approval-gate(回滚 = git revert <本 sha>)。

- backend/app/api/knowledge_articles.py:related_page 过滤由 LIKE '%<v>%'(子串包含,短键/前缀超集命中)
  收敛为「归一候选键 IN ∪ 模板子路由 <键>/:<占位符>」;不可归一入参 -> 空集(不退化为全量)
- backend/app/utils/route_key.py(新增):匹配口径唯一实现处(归一化 / 候选键派生 / 模板模式)
- backend/tests/test_kbff_v11c_related_page_exact_match_20261012.py(新增):42 用例
  —— 生产 seed 7 值逐值回归(各中 1 行)、短键 /maps->0 行 /kpi->0 行、带参路由与尾斜杠/query/大小写兼容、
  改造前 contains 语义负对照、账套分区与 category 组合语义保持

(cherry picked from commit 2a5cb182c237ae724d4b506b68eb48810ba27b16)
2026-10-18 16:31:52 +08:00
fullstackandHermes CI Fix 60b6ef3bfe fix(alert-rules): dynamic-thresholds 迭代 Column 当行取值致 500
GET /api/cma/alert-rules/dynamic-thresholds 在 {c.name: getattr(c, c.name) for c in
DynamicThresholdCache.__table__.columns} 里把 Column 对象当 ORM 行取属性,触发
AttributeError: 'Comparator' object has no attribute 'id'。改为列名取自表元数据、
值取自 query 结果行;entity_id 过滤与 limit 50/kpi_id 分支不变。

(cherry picked from commit 4130726be411a74a3318d9dc6bbc518a6c60d0ff)
2026-10-18 16:31:52 +08:00
wecom-fullstackandHermes CI Fix a9dcbf22bc chore(security): 摘除 knowledge_service 硬编码默认 DeepSeek Key(缺 env 抛明确错误/503)
- knowledge_service: 移除 os.getenv 默认值占位,改 _require_deepseek_key() 必填,缺 env 抛 DeepSeekKeyMissing
- knowledge API: generate/{daily,weekly,monthly} 缺 Key 返回 503(可观测,不再静默走模板兜底)
- finance_bot / verify_zxt_vouchers_json / bot_bridge_push: 凭据改 env 优先(CMA_BOT_KEY_* / CMA_BOT_API_KEYS / CMA_BRIDGE_TOKEN),历史共享键仅作兼容期兜底并告警
- tests: 新增 5 例回归(缺 Key 服务层抛错、API 503;有 Key 正常 200)

(cherry picked from commit 859bd8276d7c5aad4bf10ee3399b6cccc188f064)
2026-10-18 16:31:52 +08:00
wecom-fullstack 885cfddbff feat(warning-p0a): 资金缺口阈值外置 + 复算对拍 + 讲人话(数字守恒) + 数据就绪核验
ci/woodpecker/push/main Pipeline failed
P0-A(任务 fullstack-20261017-warning-capability-build-p0):
- cash_thresholds.py:阈值迁 per-entity(复用 alert_rules.rule_type=cash_gap_threshold,
  默认 20/10 万不动;无配置/非法/异常一律回落默认,fail-safe 不改预警行为)
- cash_forecast_engine.py:阈值改从配置读取,响应新增 threshold_source 明写来源
- verify_cash_gap_replay.py:同输入双跑逐字段 diff=0 + 手算 3/3 + 端点逐字段对拍(--live)
- cash_gap_narrative.py:复用 ai_brief.call_deepseek,只写原因/建议;
  断言生成文本数字集合 ⊆ 规则层数字集合,违例即不带文案上线
- audit_warning_data_readiness.py:G0 只读核验(cma_reader SELECT only),
  行数/期间/枚举 + 数据质量缺口(期初全 0、负期末、单批次、无员工维度表)

未 push(push 即部署,须项目侧书面放行);未改生产库/未动 cron。
2026-10-17 23:33:51 +08:00
Hermes FullstackandHermes CI Fix a52faa90a6 fix(dashboard): bep-card terms 分支补字段别名映射(修 289cc8f 引入的 e2 500)
ci/woodpecker/push/main Pipeline was successful
289cc8f(§4-1 博海固定成本仅借方)在 dashboard._bep_sum_field 新增 terms 分支,
把 bep_caliber 的 terms 记法字段名(debit/credit)原样传给 getattr(VoucherDetail, field)
⇒ AttributeError: type object 'VoucherDetail' has no attribute 'debit'
⇒ GET /api/cma/dashboard/bep-card(entity=2 博海)稳定 500(entity=1 走 filters 分支不受影响)。

修复:terms 分支内做字段名映射 debit→debit_amount / credit→credit_amount(与
profit_lines.FIELD_MAP、kpi_bep_deriver._TERM_FIELD 同式),再递归聚合。

回归测试:tests/test_profit_summary_real_caliber_20261014.py 新增
test_dashboard_card_e2_terms_mode_field_alias(修复前 RED:复现同一 AttributeError;
修复后 GREEN:e2 固定成本 98,000 元 / 卡面 9.80 万)。
2026-10-16 18:28:50 +08:00
wecom-fullstack 68ca27109e feat(report): 管理利润表「变动成本」行加负值标注(脚注+tooltip,纯展示)
ci/woodpecker/push/main Pipeline was successful
依据 e1 变动成本负值口径核查(口径无误,设计使然):变动成本含贷方冲减项(厂补),
个别期间(2026-08 / 2025-07)可为负。本单仅在报表层加说明,防读者误读为取数/口径错误。

- 项目列「变动成本」行加 InfoFilled 悬停 tooltip(按 line_key === 'variable_cost' 精确命中)
- 表格下方加脚注一行
- 未改取数逻辑、未改口径、未改任何数值(diff:1 file, 11 insertions(+), 0 deletions(-))
2026-10-16 18:13:50 +08:00
Hermes CI Fix 289cc8f2cc fix(profit-caliber): 博海口径 §4-1/§4-2/§4-5 落地 —— 固定成本仅借方 + 6403 进变动 + 财务收益单列 + 批次备注
ci/woodpecker/push/main Pipeline was successful
ASK-20261014-04(任总 2026-10-14 11:20「放行4」)批后口径:
- §4-1 博海固定成本弃净额:6601/6602/6603 仅借方(133.98 万,2026-01..09);2026-03 单月由 -9.02 万转正;
  6603 贷方(现金折扣收益 64.40 万)改 finance_income_terms 单列备查,不进固定成本/末行/勾稽。
- §4-2 博海变动成本追加 6403 营业税金及附加(530.17 万)。
- §4-3/§4-4 维持现行(酣客收入/边际贡献基数不动)。
- §4-5 管理利润表页加「数据批次/导入时刻」纯展示备注(import_logs)。
- 口径唯一源仍为 bep_caliber.ENTITY_CALIBERS;报表/卡面/派生器一律经 fixed_cost_spec() 解析(读口写口一致)。
2026-10-16 12:32:52 +08:00
wecom-fullstackandHermes CI Fix ff2d92ba54 ci(woodpecker): 部署通道收口为单链 —— CI 只做门禁,部署恒走宿主 Gitea hook
ci/woodpecker/push/main Pipeline was successful
② 现状:push origin main 同时触发两条写生产链 ——
   (a) 宿主 Gitea post-receive hook → /usr/local/sbin/gitea-cma-deploy-hook.sh → deploy.sh(铺站 + 重启 + 冒烟)
   (b) Woodpecker pipeline 的 frontend-deploy(rsync --delete dist → /var/www/cma + nginx reload)
       与 backend-deploy(在开发区 git pull + systemctl restart cma-backend)
   两条链并发 ⇒ 两套在途部署判定、重复劳动与证据歧义(2026-10-13 事故即此形态)。
本提交复用 ci/wp-v3-fix-20260916 已写就的 .woodpecker/main.yml(wecom-project 2026-09-16 8ddcbe1):
   删除 variables(ssh_setup) 与 frontend-deploy/backend-deploy 两步,只留门禁
   (frontend-install/typecheck/build + backend-test),并补国内镜像与 pytest 安装。
零生产写:候选仅落本 worktree 分支,未 push。
2026-10-16 03:05:57 +08:00
wecom-ops d2cb812bfa chore(deploy): 触发部署链复跑(真修复 · 无内容变更)
ci/woodpecker/push/main Pipeline failed
2026-10-16 00:19:31 +08:00
wecom-fullstackandHermes CI Fix 32f368a846 fix(infra): pnpm 工具链去脆弱化 —— frontend/package.json 落 packageManager pin + deploy.sh pnpm 版本断言(fail-closed)
ci/woodpecker/push/main Pipeline failed
① frontend/package.json 补 "packageManager": "pnpm@10.33.2+sha512.a90faf6f…":
   corepack 在项目未声明该字段时按全局 lastKnownGood.json 解析版本,而该文件会被任何跑过
   corepack 的项目改写(2026-10-13 → 12.11.2 ⇒ MODULE_NOT_FOUND;2026-10-14 → 9.15.9 ⇒
   packages field missing)。pin 落库后 corepack 只认 pin,宿主机状态漂移不再影响部署树。
   选 10.33.2 的依据:pnpm-workspace.yaml 的 allowBuilds 仅 pnpm 10 识别;部署树 node_modules
   .modules.yaml packageManager=pnpm@10.33.2(store/v11/v10);lockfileVersion 9.0;2026-10-13
   22:53 该版本下部署成功。
② deploy.sh 新增 assert_pnpm_version()(fail-closed,两处调用:1.2a 首次 pnpm 调用前、[2] 构建前):
   期望版本只取自 package.json 的 packageManager(单一源),与 pnpm -v 实读逐字比对,缺失/不一致
   即中止;两次调用都先于 [3] 铺站与 [5] 重启 ⇒ 任何生产写之前失败关闭。

零生产写:候选仅落本 worktree 分支,未 push、未动 /srv/hermes/shared/cma-deploy 与 dev 树。
2026-10-15 08:35:20 +08:00
wecom-fullstack e3e6b3ba8a fix(reports): 全表真实取数——清零示例兜底(BS_DEMO/CF_DEMO/_get_demo_block_total/0.50) + 上期同比/勾稽/预测标注 + CF官方口径单位收口
ci/woodpecker/push/main Pipeline failed
2026-10-15 04:13:40 +08:00
hermes 2244cd79b7 fix(reports): profit-summary 真实科目取数重建(去 KPI 比率倒推/硬编码 0.50 + 勾稽自检 + entity 隔离)
ci/woodpecker/push/main Pipeline was canceled
2026-10-14 23:14:41 +08:00
wecom-fullstack 6d8ef0d443 fix(infra): pnpm-workspace.yaml 补 packages 字段(pnpm 9.15.9 强制)+ vue-demi 占位符改 true
ci/woodpecker/push/main Pipeline failed
根因:corepack 修到 pnpm 9.15.9 后,pnpm 见 pnpm-workspace.yaml 即进 workspace 模式,
强制要求 packages 字段;缺失 ⇒ 'packages field missing or empty' ⇒ 部署树 pnpm build 失败。
本地 pnpm build 验证通过(16.05s, exit 0)。
2026-10-14 11:35:05 +08:00
wecom-fullstack e55f0ecdf9 fix(ui): 侧栏「资金内控」图标缺失——MainLayout 补 Lock 到 import 与 iconMap
ci/woodpecker/push/main Pipeline failed
任总 2026-10-14 10:49 报「资金内控:导航栏缺少图标」。
根因:permission.ts:40 icon:"Lock",而 layouts/MainLayout.vue 的
@element-plus/icons-vue import 与 iconMap 均无 Lock ⇒ iconMap["Lock"]=undefined
⇒ <component :is="undefined"> 渲染为空。全量核:菜单 40 项 / 19 个不同 icon 值,
仅 Lock 真缺。本提交仅该 1 文件 2 行(import + iconMap 各补 Lock),
不改其它图标、菜单顺序、分组、角色可见性。
2026-10-14 10:54:11 +08:00
wecom-fullstackandHermes CI Fix 26db15e6d3 fix(ui): 系统级设置归位(KPI对齐/预算模式切换入口收归系统设置)+ 业务页右下浮窗提醒
ci/woodpecker/push/main Pipeline failed
- KPIAlignment.vue: 移除页头切换按钮与模式选择器,去 saveAlignConfig;新增右下浮窗
- BudgetManagement.vue: 预算模式 el-switch -> 只读标签,去 onBudgetModeChange;新增右下浮窗
- RolePermissions.vue: 保留目标对齐正本,新增「预算模式」Tab(系统级切换入口)
- 仅 frontend/ 三件;build 通过
2026-10-13 23:04:07 +08:00
wecom-fullstack 424c33c085 feat(kpi,review): KPI direction 列 + KPITarget 模型 + 复盘产出方入库(R1 收口)
ci/woodpecker/push/main Pipeline failed
R1 收口批次(A 侧裁定 p12-ruling-input-r1-batch.RULING.md 主路径)· 单个快进提交:
- backend/app/models/__init__.py(+42/−2):CMA-KPI-20261003 阶段1 `direction`(KPIDefinition/KPITemplate)
  + ② KPITarget(kpi_targets,11 列 + uk_entity_kpi_period_type + 2 索引)
- 复盘产出方三件(review_builder 含 getattr(direction) 硬化 / review_run / 测试件),原始提交件 26dc242 的重铸件

收口边界申报:本提交**不含** D-1 批次(VoucherDetail.row_uid / ImportLog uq_import_logs_entity_batch_type)——
只读补证显示 prod `cma` 无该列/该唯一键(证据 out/r1-t1-batches.json + out/r1-t1-d1-placement.json),
携行会导致 deploy 后 voucher_details 整实体 SELECT/INSERT 报 1054;D-1 另单(先 DDL 后 ORM)。
2026-10-13 20:33:42 +08:00
Hermes CI Fix 4cdd30e321 feat(lead): 网站线索额外推送任总DM(经 ai-cs 实盘分流脚本)
ci/woodpecker/push/main Pipeline failed
- 新增 notify_boss():POST /api/cma/lead 时,除既有全员群 notify_wecom 外,
  额外经 ai-cs 实盘分流脚本(routing-live.py)通知 wecom:200653946(加性,不改动既有逻辑)
- 跨 profile 调用必须显式传 HOME/HERMES_HOME=/var/lib/wecom-ai-cs/profile:
  cma-backend.service Environment 为空,缺省会解析到非 ai-cs profile(实测无 wecom 目标)
- 失败静默(capture_output + except pass),不影响主流程
任务: ai-cs-20261013-website-lead-to-boss-and-routing-hook-ops
2026-10-13 17:48:00 +08:00
Hermes CI Fix 501322a8b9 feat(maps): 战略地图按 objective 粒度合并(granular 通道),不同 objective 并发不再互相 409
ci/woodpecker/push/main Pipeline failed
2026-10-13 11:37:24 +08:00
wecom-fullstackandwecom-yanxue f786ff9ae3 test(docs-center): 金丝雀分类断言改「必备分类在场+相对顺序」(分类扩展不再留假红)
ci/woodpecker/push/main Pipeline failed
- test_prod_content_readable 原硬编码 [term,formula,practice,faq],与生产 categories.json 全等比较;
  2026-10-12 14:05 内容目录新增 manual(模块手册,order 5) 后期望值失鲜 ⇒ 基线/改后两棵树同红。
- 改为子集式断言(必备分类在场 + 过滤后相对顺序),不再硬编码全集;分类扩展不再红。
- 同族断言盘点:本文件其余分类断言走 tmp content_dir 自持 categories.json,不依赖生产内容目录,无需改。
2026-10-13 07:48:18 +08:00
Hermes CI Fix cfd6a13ca4 fix(budget): 年度行(budget_month=0)不回落 1 月 + 同族 R1/R2/R3
ci/woodpecker/push/main Pipeline failed
- R-A2 sync-cash-plans: 年度行形态判定(_is_annual_budget_row)跳过 + skipped_annual 披露
- R2 POST /plans 期间形态校验(YYYY-MM / YYYY-00 显式接受)
- R3 budget_roll_forward 排除年度行
- R1 cash_classify 归类路径年度行判据 + 披露
- 新增 2 个回归件(14+10 例)

仅含本族 hunk(禁整文件提交): 开发区其余 14 hunk 属他会话在途件,未纳入。
2026-10-13 03:41:39 +08:00
Hermes CI Fix d08a8490eb fix(maps): 覆写通道扩面清点——同契约调用点补基准(3 例静默降级 + 2 例新增失败)
ci/woodpecker/push/main Pipeline failed
缺陷 cma-strategy-map-autosave-stale-overwrite 的护栏(PUT /api/cma/maps/{id} 覆写
dimensions/canvas_data 须带 base_updated_at,否则 409 fail-closed)改变了同一写通道的
契约 ⇒ 全仓清点调用点,在调用点取基准(写前读 updated_at),**不放宽护栏**:

- tests/test_map_versions.py: 新增 _base_updated_at(db, map_id) 助手;3 处覆写 dimensions 的
  PUT 补基准(test_rollback_backs_up_current_and_restores_content 会直接失败;
  test_rollback_again_uses_next_free_backup_version 两处此前已静默降级——PUT 被拒但用例仍绿,
  即「改了内容再回滚」的前提实际未发生)。
- tests/test_kr_name_pollution_20260916.py: 2 处 KR 透传护栏用例补基准。

改前/改后全量对拍(同树 pythonpath):
  baseline 22 failed / fixed2 18 failed;diff 仅剩 tests/test_doc_center_20260924.py::
  TestProdContentCanary::test_prod_content_readable —— 基线树同样失败(/srv/hermes/shared/
  cma-content 于 14:05 被写入 manual 分类,共享内容目录变化),与本单无关。
  本单新增的 5 例在基线树全红、改后全绿。

任务: yanxue-20261012-cma-map-autosave-stale-overwrite-defect-fullstack
2026-10-12 17:06:07 +08:00
wecom-fullstack 03254bd4c7 fix(maps): 战略地图写通道乐观并发 + 画布自动保存仅脏时写
生产缺陷 cma-strategy-map-autosave-stale-overwrite(D4 批次 19 行 map_objectives 被过期快照覆写):
- 后端 update_map: 覆写 dimensions/canvas_data 须带 base_updated_at,与服务端 updated_at 不符 → 409;
  校验在任何写入之前完成(校验不过时一行都不删/不改)
- 连线写通道(POST/DELETE connections)响应补 updated_at,供前端采纳新基准、避免自伤 409
- 前端 MapCanvas: 自动保存仅在 draft + isDirty 时触发;请求体带 base_updated_at;收到 409 停自动保存不重试
- 修复 async onMounted 竞态:await 期间被卸载后其续段仍注册定时器(清理钩子先跑完、无人回收)
  → 泄漏写者永久在后台用过期快照覆写(生产 referer=/maps 仍每 30–60s 写)。加 disposed 守卫 + stopAutoSave
- 测试: backend/tests/test_map_concurrency_20261012.py(8 例,改前 5 败);前端行为台架 11/11(改前 3/11)

任务: yanxue-20261012-cma-map-autosave-stale-overwrite-defect-fullstack
2026-10-12 11:42:56 +08:00
Hermes CI Fix 1613f7f961 fix(cpa): sales bill-type whitelist for customer revenue caliber (P0)
ci/woodpecker/push/main Pipeline failed
Customer revenue/concentration now filters bill_type IN (1,4,14) (XSD/XSTHD/XSHHD).

Non-sales docs (JHD purchase, YFKD prepay, YSKD accept, QTRK, SKD, YBFYD) carry SUPPLIER unit_id and were polluting 'customer concentration' (old Top1/Top2 were our biggest suppliers; root cause of 6.04x base gap).

Unify sign filter to (sum_money+sum_tax_money)>0 (equivalent to money_only on current data, diff 0 rows) and correct the withdrawn 407-row/17.2% wording in cpa_caliber docstring.

Deriver: anchors from cpa_caliber single source; new --concentration overwrite and --only concentration (minimal 9-row write face) with pre-overwrite backup.

Tests: fixture gains bill_type/sum_tax_money; +5 whitelist cases (44 passed targeted).
2026-10-09 17:00:37 +08:00
Hermes CI Fix 451c994a86 feat(cpa): customer profitability MVP P0-a/b - revenue caliber single source + snapshot table + 9-card tri-state
Task: finance-20261003-cpa-customer-profitability-mvp-fullstack (P0-a / P0-b)

- backend/app/utils/cpa_caliber.py (new): single source of truth for
  customer-level revenue aggregation from zxt_masterbill. Reused by
  alert_rules._customer_revenue_shares (dedupe: no second implementation).
- backend/scripts/migrate_cpa_snapshot_20261004.py (new): idempotent DDL for
  customer_profitability_snapshot (UNIQUE entity_id+period+unit_id),
  --check / --apply / --rollback / --status / --show-create + journal.
- backend/scripts/kpi_customer_profitability_deriver.py (new): monthly
  snapshot + kpi_values upsert (single transaction, idempotent).
  fail-closed: cogs_alloc / chan_sub / contrib / contrib_rate stay NULL and
  are never written as 0 or as an apportioned value.
- backend/scripts/sql/cpa_kpi_defs_20261004.sql (new): C_CUST_REVENUE and
  C_CUST_COUNT active; 5 P1 codes registered inactive with fail-closed reason.
- backend/app/api/customer_dashboard.py: new read-only endpoint
  GET /api/cma/customer-dashboard/standard-indicators returning the 9-card
  tri-state (value / no_data / undefined). Reads kpi_definitions via raw SQL
  to stay decoupled from the in-flight KPI direction DDL.
- frontend/src/views/CustomerDashboard.vue: 9-card panel rendering the three
  states (never renders an empty value as 0).

Anchor check 2026-01..09 (offline sqlite rehearsal, ALL_PASS=true):
73 customers / 4883.52 wan / top1 25.3835 / top2 23.4905 / top4 63.3249 /
top10 81.7675 - all six match the task acceptance anchors.

No production write and no deploy in this commit; release is gated.
2026-10-09 17:00:37 +08:00
wecom-fullstack a48a116e06 fix(dupont): insight.detail 叙述段字面 None 收口(表现层 _fmt_val,仅文案)
ci/woodpecker/push/main Pipeline failed
- 恒等式叙述段 5 处裸 f-string 插值(三因子 value / ROE / identity.diff)改走 _fmt_val:
  None → 「-」(与同段四列 _fmt 同族),有值原样输出(不经 :.2f,保证有值路径逐字节不变)
- 结构化字段 roe / factors[*].value / identity.diff 仍为 null;check_identity 语义、dupont_basis.py 未改
- 基线 origin/main acf179a;本单零生产写(不推远端/不同步部署树/不重启/不连库)
2026-10-06 23:36:06 +08:00
Hermes CI Fix acf179a199 fix(subjects): migrate 脚本口径乙 fail-closed(排除白名单逐码 + 双轨锚点)
ci/woodpecker/push/main Pipeline failed
2026-10-06 17:03:44 +08:00
wecom-fullstack b58513c48d docs(ddl): runbook 首步强制「迁移前像 dump 哈希」(--apply 之前)
ci/woodpecker/push/main Pipeline was successful
CMA-DDL-RUNBOOK-20261005 / parent: CMA-KPI-20261003-D-T1
- docs/db-migration-runbook.md 新增 §3.0:任何 --apply 之前对每张受影响表落
  全表有序 (PK, 受影响列) dump,记录 sha256 + 行数 + 字节数,归档 backups/pre-ddl-<task>/
- 含可直接复制的一行命令(ORDER BY 主键保证字节级确定性)
- §4 发布 checklist 第 4 条(列级 ALTER)指向 §3.0

仅文档;零生产库/零部署/零重启。
2026-10-05 13:02:02 +08:00
wecom-fullstack 4f9bab6bc0 fix(dupont): reject out-of-domain window + bidirectional clip + suffix discipline (R1-R4/M2)
ci/woodpecker/push/main Pipeline failed
Per finance ruling 2026-10-05 (project-20261005-dupont-oob-window-fix):

- R1: no intersection with available month domain => explicit error (endpoint 400), no window fields

- R2: with intersection => intersection window, months>=1 (kills inversion and months<=0)

- R3: quarter/half/... suffix kept only when returned window == nominal window (clipped flag)

- R4: symmetric lower-bound clip note; M2: TTM nominal window = [anchor-11, anchor] (12 months)

- M1: docstring correction (lower-bound overflow is clipped, not 400)

No push / no deploy / no restart / no DB write.
2026-10-05 02:02:21 +08:00
Hermes CI Fix 3ed9fa0611 fix(cvp): O1 residue - e2 rebate_policy_pct 置 null(口径单一源 = bep_caliber)
ci/woodpecker/push/main Pipeline failed
问题:/bep-card 端点对 e2(博海)回落模块内硬编码常量 BEP_REBATE_POLICY_PCT=84.61,
而 bep_caliber.ENTITY_CALIBERS[2]["policy_pct"] 为 None(博海无渠补科目)⇒ 下游 API
消费者会把 payload 里的 rebate_policy_pct=84.61 误读为「博海有 84.61% 政策渠补率」。

改动(严格限定 dashboard.py 一个模块 + 1 条守护用例):
- 删除模块级常量 BEP_REBATE_POLICY_PCT;四处回落分支改为直接取口径值
  (未注册口径分支置 None;其余取 cal.get("policy_pct") / 已算出的 policy_pct)。
- 口径单一事实源维持 app/utils/bep_caliber.py,端点不做任何回落。
- 新增 tests/test_bep_rebate_actual_caliber.py::test_o1_policy_pct_single_source_no_hardcoded_fallback
  (改前必红 / 改后必绿,e1=84.61 作同端点负对照)。

改前/改后(端点函数直调,生产库只读,e1/e2 同批):
  改前 e2 inputs.rebate_policy_pct = 84.61 → 改后 null;e1 恒为 84.61(负对照不变)。
2026-10-04 22:37:20 +08:00
wecom-fullstackandHermes CI Fix 587d5b3983 fix(cvp): BEP ⑤ 口径统一落地(变动成本率=完整口径)
ci/woodpecker/push/main Pipeline failed
- 派生器/口径:F_BEP_VC_RATE 新增;卡面/公式行文案改「月固定成本 ÷ 边际贡献率」
- 保本额 = 月固定成本 ÷ (1 − 变动成本率);实测渠补率(F_BEP_REBATE)降为 e1 专用 chip
- 报告卡口径说明文字同步(固定成本=固定费用合计)

任务: finance-20261003-cvp-fix-and-bohai-coverage-fullstack ⑤
2026-10-04 19:53:12 +08:00
Hermes CI Fix 4fe956fcf9 fix(dupont): resolve YYYY-Qn / YYYY-Hn period windows (500 on quarter/half)
ci/woodpecker/push/main Pipeline failed
Root cause: resolve_window() matched quarter/half with p[4] == "q" / "h"
(always '-' for "2026-q2"/"2026-h1"), and the branches sat after the
single-month branch -> dead code. Real input fell into the month branch,
so months_between() raised ValueError on int('q2') -> HTTP 500 for all
18 quarter/half options in the period selector.

Fix: judge on p[5] in ("q","h") with a digit guard on p[6], evaluated
before the single-month branch; month branch tightened to p[5:].isdigit().
Existing error-branch wording unchanged.

Tests: backend/tests/test_dupont_period_options.py (18 defect options,
case equivalence, month/year/ytd/ttm/custom non-regression, malformed
input returns error dict instead of raising).
2026-10-04 18:34:05 +08:00
Hermes CI Fix 40d2ffe0f7 fix(dupont): None guard for raw formatting in /reports/dupont insight detail
ci/woodpecker/push/main Pipeline failed
Non-ending stock windows (e.g. ?period=2026-Q2 / 2026-05 / custom) return raw total_assets/equity as None when the window has no subject_balances level-1 rows; identity guard reports status 'unavailable' (not 'identity_broken') and execution continues into the insight detail f-string, where .2f on None raised TypeError -> HTTP 500. Add explicit fmt() ('-' for None) for the four raw columns in detail.

Task: fullstack-20261004-1544 (P1; proposer wecom-fullstack; ASK-20261004-07 follow-up). No DDL. Single file.
2026-10-04 18:03:34 +08:00
Hermes CI Fix 38a6de9325 feat(dupont): period-basis design (cumulative flow x average stock, optional annualize, identity guard)
ci/woodpecker/push/main Pipeline failed
Task: finance-20261003-dupont-period-basis-design-fullstack (P1; proposer wecom-finance)
Basis ruling: finance 2026-10-03 (Boss 09:49 CST DM approval of the design task)

- /reports/dupont accepts period/start/end/annualize (default YTD), returns
  period_start/period_end/basis/annualized metadata; identity guard
  |ROE - npm x turnover x multiplier| <= 0.05 pp, else identity_broken (no values).
- new app/utils/dupont_basis.py = single source of truth (window resolve, flow
  cumulative via voucher_details, stock average via subject_balances level=1,
  fail-closed basis_fallback, pure calc + identity check).
- scripts/kpi_dupont_deriver.py (idempotent derived writer, dry-run/check/write),
  scripts/apply_dupont_kpi_defs.py + sql/dupont_kpi_defs_20261003.sql (seed:
  F_ASSET_TOTAL/F_EQUITY_TOTAL/F_EQUITY_MULTIPLIER as derived; F_ASSET_TURNOVER
  manual -> derived). Seed already applied under approval 296cc1fd.
- scripts/verify_dupont_period_basis.py: read-only acceptance battery A1-A9
  (independent SQL recompute). BEFORE (base tree) 1/10 PASS -> AFTER 10/13 PASS;
  remaining 3 need the deriver value write (approval-gated).
- frontend DupontAnalysis.vue: period selector (YTD default) + annualize switch
  + caliber metadata bar + identity_broken alert.

No DDL. No ERP direct connection (old register_dupont_kpis.py caliber dropped).
2026-10-04 13:35:25 +08:00
Hermes CI Fix 1f1385ebbf feat(cvp): CM≤0 fail-closed 禁写0.0 + 博海BEP扩面(新口径) + 影子CVP标注is_mock + 前端NULL守卫/不适用
ci/woodpecker/push/main Pipeline failed
- kpi_bep_deriver.py: 实体口径注册表(app/utils/bep_caliber.py 单一事实源);博海(6001/6401/6601-03/1602);
  fail-closed② 边际贡献非正/固定成本退化 ⇒ 五条保本值 NULL+pending+remark'不适用'(禁回落0.0)
- dashboard.py /bep-card: 多实体口径 + not_applicable 态返回 label/reason
- predict.py /cvp-detailed: 显式 source=scenario_simulation + is_mock=true(影子CVP标注)
- 前端 BEPBreakEvenCard.vue: 全量数值判空 + '不适用'态;PredictDashboard.vue: 情景模拟·非实测标签

验收 accept-bep-cvp-20261003.py: BEFORE 2/11 → AFTER 11/11 PASS
任务 finance-20261003-cvp-fix-and-bohai-coverage-fullstack
2026-10-03 16:03:12 +08:00
Hermes CI Fix 8631ce718f feat(profitability): real ROI (op profit / invested capital), enable EVA, add RI
ci/woodpecker/push/main Pipeline failed
Kaliber: wecom-finance 2026-10-02 ruling (Boss 11:59 CST "li; but (1) add real ROI").

- kpi_definitions seed scripts/sql/profitability_kpi_defs_20261003.sql:
  F_ROI renamed to F_ROA (same kpi_id=20, all 8 historical values kept), new F_ROI
  (real IMA caliber = operating profit / invested capital), F_EVA activated with unit
  and thresholds aligned to 万元, new F_RI (residual income).
- parameters are NOT hardcoded in formula text: they live in
  kpi_definitions.data_source_config.params (tax_rate / wacc / min_return_rate /
  include_factory_subsidy / include_channel_subsidy / include_sales_discount /
  annualize / capital_basis); single reader = app/utils/profitability_params.py.
- deriver scripts/kpi_profitability_deriver.py: YTD operating profit from
  voucher_details (carry_forward=0 net), invested capital from subject_balances
  level-1 rows only, idempotent upsert into kpi_values (source_type=derived, no
  data_status downgrade, fail-closed when invested capital is 0).
- read-only endpoint GET /reports/value-management reads the derived rows only
  (single source of truth, no recomputation) and exposes the parameter block.
- DupontAnalysis.vue: value management section (ROI / ROA / ROE / EVA / RI).
- caliber_units.py: RATIO_CALIBER_TABLE key F_ROI renamed to F_ROA, new F_ROI row.
- tests: backend/tests/test_profitability_indicators.py (21 cases).
2026-10-03 02:10:47 +08:00
Hermes CI Fix 1da0a74568 fix(h2-forecast): 预估区残余读路径 P2 收口(3 类 6 处)
任总 2026-10-02 11:35 直令「对外 /api/cma/** 读 KPIValue 默认只取实测区」同族残余
(ac4bbc6 范围外的 3 类 6 处)。6 处一律引 app.utils.kpi_value_scope.actual_only_clause(),
零新写字面量口径:
- customer_dashboard.py:76    年视图汇总(预估禁与实测相加)
- budget.py:260               按去年实绩加权派生基数(批量 auto-decompose)
- budget.py:333               同上(单 KPI auto-decompose)
- cash_forecast_engine.py:204 get_entity_kpi_history 历史/趋势序列
- cash_forecast_engine.py:259 get_current_cash_balance 最新值
- cash_forecast_engine.py:471 calculate_accuracy 实际值序列(预测输入)

证据:同脚本双树 A/B 15/15(改前 5/15,真库 kpi-forecast history 去掉 2026-H2、
next_target 336.32→250.99);分类计数 33/41/9/3 → 39 covered/38 period_guarded/9 write/0 unguarded;
pytest 3 failed/1529 passed,失败集与基线逐条同 ID。不 push / 不部署 / 不重启 / 不写库。
Task: project-20261002-h2-forecast-residual-reads-p2-fullstack
2026-10-02 23:41:17 +08:00
Hermes CI Fix 5d7b3cc728 feat(h2-forecast): 实测区/预估区口径分离 —— 对外读路径默认只取实测区
ci/woodpecker/push/main Pipeline failed
- 新增 app/utils/kpi_value_scope.py(口径单一事实源:actual_only_clause / forecast_only_clause / is_forecast_row)
- dashboard.py: /kpis /my-kpis /finance-analysis /predict /my-dashboard / latest_kpi_value 默认剔预估,include_forecast=true 单列返回
- alert_rules.py: 动态阈值白名单去 estimated + 4 处读路径加实测区判据
- 前端 ForecastTag.vue 新增;Dashboard.vue / MyDashboard.vue 预估单列 + 标签(不与实测相加)

Task: finance-20261002-h2-forecast-separation-deploy-fullstack
2026-10-02 23:33:15 +08:00
wecom-fullstack f32f6e7430 feat(receivables): 催收页只认客户级行 — 月度汇总行不计入卡片/账龄/明细/owners
- 口径实现逐字节取自既有已批准件 475f78b(ASK-20261001-33, wt/hanke-ar-customer-level):
  _is_customer_level_row(plan) = counterparty 非空且非空白(.strip() 覆盖制表符/全角空格)
  用在两处且仅两处:明细循环 + owners 查询 ⇒ 四条消费面同一判据
- 数据零变更:cash_plans 一行不删不改(source='zxt-sync' 的 67 行月度汇总行=正确通道数据,保留)
- 不新写第二份实现;不改 frontend/src/api/index.ts 全局拦截器;无 DDL、无数据迁移
2026-10-02 21:15:42 +08:00
Hermes CI Fix 29dc5c0bdc fix(receivables): unwrap response envelope once + align card/column field names
- Receivables.vue: const d = r (was r.data || r) -> cards/table no longer blank

- cards: pending_balance(待收余额) / overdue / completed_amount / completed_count

- columns: counterparty(+description fallback) / owner(未分配) / settled_amount / plan_date / aging_bucket

- cash.py: summary.completed_count + _plan_dict.settled_amount (existing keys untouched)

- api/index.ts untouched (global interceptor)

(cherry picked from commit d60557622441b971970fb9dc86ef7491c3a9d72b)
2026-10-02 21:12:20 +08:00
Hermes CI Fix ac4bbc6891 fix(h2-forecast): 对外 KPIValue 残余读路径统一实测区口径(7 处)
任总 2026-10-02 11:35 直令「对外 /api/cma/** 读 KPIValue 默认只取实测区;
预估禁进趋势序列 / 禁作预警与预算依据」的同族残余读路径收口。

7 处逐处改(均引 app/utils/kpi_value_scope.actual_only_clause,不新写字面量口径):
- kpis.py:604            GET /kpis/{id} 详情历史值(预估禁进趋势序列)
- ai_analysis.py:363     GET /ai/kpi-analysis/{id} 趋势序列/LLM 上下文
- thresholds.py:132      GET /thresholds/suggest/{id} 阈值推荐依据
- budget_generate.py:94  GET /budget/kpi-budget-candidates 预算候选最新值
- budget.py:1380         POST /budget/apply-method 派生基数(预算依据)
- maps.py:657            GET /maps/{id}/review 地图节点最新值
- data_quality.py:363    GET /data-quality/stats 缺失判定(仅预估行不算有实际值)

证据:同脚本双树 A/B 11/11(改前 3/11 FAIL)+ pytest 3 failed/1529 passed 与基线逐条同 ID。
不 push / 不部署 / 不重启 / 不写库。
2026-10-02 21:01:25 +08:00
Hermes CI Fix 3521cce01e fix(bep-caliber): seed target_value NULL -> 0.00 (kpi_definitions NOT NULL, 1048)
ci/woodpecker/push/main Pipeline failed
首发串 [4/8] 以 pymysql 1048 失败: kpi_definitions.target_value 为 NOT NULL(decimal(15,2) default 0.00),种子显式写 NULL 覆盖默认值。

本提交只改 backend/scripts/sql/bep_kpi_defs_rebate_20261002.sql 一行。
2026-10-02 15:55:30 +08:00
Hermes CI Fix 14ed29bcbf fix(bep-caliber): rebate rate uses YTD actual voucher cumulative (571103 debit / 5001 credit), book+cash card read derived, fail-closed
ci/woodpecker/push/main Pipeline was canceled
2026-10-02 12:20:39 +08:00
Hermes CI Fix 6be903fbc9 fix(bep-caliber): expense and profit sums use net (debit minus credit) under carry_forward=0
ci/woodpecker/push/main Pipeline failed
ASK-20261002-06 fix04u: admin(5602%) and depreciation(560213) take net amount in both the dashboard card helper and the BEP deriver; sales (5001) keeps credit_amount. Verbatim per proposal section 2. No DB writes, no kpi_values backfill.
2026-10-02 11:22:38 +08:00
Hermes CI Fix 6c1ea85d71 fix(alignment): /tree map_id filter via map_objectives.kpis union (v2)
ci/woodpecker/push/main Pipeline failed
- add missing 'or_' import (NameError on map_id calls)

- union of map_objectives.kpis codes and KPIDefinition.map_id, intersect with entity active KPIs

- cross-entity / unknown map_id -> empty tree (no leak)

- no map_id -> unchanged behaviour
2026-10-01 21:12:02 +08:00
wecom-fullstack 22ec40052d fix(alignment): /tree accept and apply map_id filter for KPI selection
GET /api/cma/alignment/tree ignored the map_id query param (FastAPI silently dropped it) so the board always rendered the full active-KPI set of the account. Add map_id: Optional[int] = Query(None) and filter KPIDefinition.map_id when provided; no param keeps the previous behaviour. Entity scoping unchanged (entity_id from token) - cross-account map_id yields an empty tree, no other-account KPI is returned. Only the KPI selection set is filtered; vertical/horizontal/bsc_chain builders untouched.
2026-10-01 15:58:40 +08:00
wecom-fullstack 0824efc8ed feat(data-quality): 规则配置可编辑入口 + 经营口径排除接线(ASK-20260928-05 尾项)
ci/woodpecker/push/main Pipeline failed
依据:任总 2026-09-29 08:49「P1和P2批」放行 P1-cma-dq-rule-config-approval-20260923 ①③
     (派单件 0-P1-project-20260929-qrc-write-entry-and-demo-exclusion-fullstack)

三处尾项(本单):

1) 前端可编辑入口(补拍板件 ① 未被上轮覆盖的部分)
   - frontend/src/views/DataQuality.vue:规则配置 Tab 由「只读」改为
     ceo/it 可编辑、finance/business 只读(角色口径与 router/index.ts:45 及
     系统设置页一致);新增「操作/编辑」列(平台级项仅 it 可点、演示账套禁用);
     编辑走 MyDialog(业务弹窗铁律),必填变更理由(>=10 字),取值区间/枚举
     由后端返回字段呈现,越界/非法一律如实回显后端 detail,前端不自造校验通过。

2) scope.excluded_entities 接线 + 出厂默认 [3,4,5,6](拍板件 ③-2「排除」)
   - app/dq_rule_config.py:默认由 [] 改为 [3,4,5,6];新增单一事实源
     EXCLUDED_ENTITIES_KEY / EXCLUDED_ENTITIES_FIELD、normalize_entity_ids()、
     excluded_entities_from_resolution()、excluded_entity_ids()、
     is_excluded_from_business_caliber()。
   - app/api/data_quality.py:新增 _excluded_clause()(仅全账套口径 eid=0 生效,
     COALESCE(col,1) NOT IN (...) 参数化,NULL 归属不吞行)与 _business_excluded();
     七条规则的明细查询与 _detail_count 全部叠加排除子句;_plan_id_set /
     _pending_alert_plan_links 支持 excluded(播报口径同源)。
   - 响应新增 business_caliber{caliber,exclusion_applied,excluded_entities,included}
     与 scope_excluded_entities;单账套查询保持展示、仅标注不计入经营口径。
   - PUT /rule-config:平台级项改写全账套模板行(entity_id=0),审计 detail 与响应
     增 target_entity_id;GET 增 scope_note 与 write_contract{roles,platform_roles,
     min_reason_len,entity_config_entities,bot_channel_write,endpoint}。

3) 修复两处实现缺陷(本单测试新增用例的 RED 驱动)
   - excluded_entities_from_resolution() 取键与 resolve()["scope"] 的归组形态不一致
     (配了 scope. 全名去取已归组后的短名字典)⇒ 恒返回空集,接线静默失效;
     改为短名优先、全名兼容(EXCLUDED_ENTITIES_FIELD)。
   - upsert_param() 全局项 before 取值多一层 rule_key 查找 ⇒ before 恒 None;
     改为全局项直接以 param_key 为键。

4) 测试:tests/test_dq_rule_config_20260928.py 3 处既有断言按新口径更新 +
   新增 E 类 8 例(排除默认/单一事实源、全账套与单账套两种口径面、A/B 聚合差异
   只来自被排除账套、明细计数同源、平台级项改名单即时生效、平台级写落模板行)。

不改:判定表达式本体 / 级别归属 / 评分函数 / 规则集(DAMA §4 锁死 10 类)、
      unit_check.amount_threshold(保持 10000)、virtual_pollution 关键词表与来源白名单。

边界:本提交只含 4 文件(backend/app/dq_rule_config.py、
      backend/app/api/data_quality.py、backend/tests/test_dq_rule_config_20260928.py、
      frontend/src/views/DataQuality.vue);未裹挟他人在制件
      (audit_log.py / migrate_subjects_entity_id_20260923.py / package.json)。

证据:pending-tasks/_evidence/ask5-tail-20260929/
      (pytest 全量 1 failed/1516 passed + 失败项归因 A/B、vue-tsc rc=0、
       ab-exclusion-pre-write.json 只读 A/B 原始输出、gate dry-run 等)
2026-09-29 13:00:48 +08:00
wecom-fullstack b8493f8d04 feat(db): 新表授权面收敛机制落地(白名单驱动 · 增量锚定 · fail-closed · 只增不减)
依据:A 侧(wecom-yanxue)裁定 编号 2「准予但需调整」+ R1(裁定件
yanxue-20260928-newtable-grant-surface-impl-plan-ruling.md,md5 272d1a8b4db0db0e9f35f5e62695ae50)
三条开口 (a) 空锚=合法零集 / (b) --anchor-file 载体等价 / (c) drill WARN 不阻断(两条独立分支)

改动面(3 件,全部开发工作树):
- 新增 backend/scripts/grant_surface_sync.py:项目侧原型逐字复制 + Δ1/Δ2/Δ3/Δ4 + R1
  Δ1 锚语义(未给=缺锚 rc=2 / 空集=合法零集)、Δ2 连接身份与 db_migrate 同口径并拒绝 cma_api、
  Δ3 --anchor-file(读 db-migrate-journal 指针件)、Δ4 anchor_source 入 JSON
  R1-a 同源自证(journal 在册 + status=applied + finished_at 一致 + 目录内最新 -applied)
  R1-b 未收敛锚累积由 db_migrate.write_anchor_pointer 实现、R1-c 收敛回写 converged_at
- db_migrate.py 加性 +2 处:新增 write_anchor_pointer()(原子写 journal/last-anchor.json);
  cmd_apply 成功/失败两路径各调一次;零变更面未动(列级差异只告警 / create_all 幂等 /
  --check 不写指针 / --rollback 与 resolve_journal 语义 / JSON 键集)
- deploy.sh 加性 1 步 [4b-2/5] 授权面收敛(插在「✓ 结构迁移完成」与「# 5. 重启后端服务」之间):
  apply 跑 --apply --anchor-file ... --drift-policy warn,rc!=0 ⇒ 打印缺裁定清单 + exit 1
  (后端不重启、旧码继续服务);drill 跑 --check,读数原样打印、WARN 不阻断

证据(全部零生产面):
- py_compile x3 通过;bash -n deploy.sh 通过(PYTHONPYCACHEPREFIX 隔离,仓内零新增 pyc)
- 纯函数用例 11/11(零连库):语句均为 GRANT / 无 REVOKE / 语句表 ⊆ write_tables / 域外权限 SystemExit
- 沙箱 S1–S9 回归 9/9 一致(SUMMARY.txt 与项目侧基线逐字节相同 md5 1a697e002a2cba8ae9b5ad9cc71b022d)
- 锚/R1 新例 18/18:缺锚/缺件/status=failed/R1-a 四负例均 rc=2 零写入;空锚 rc=0 executed=[]
- R1 验收链:发布1 建表 X(∉白名单) ⇒ rc=3 零写入 + 片段 exit 1 ⇒ 不修白名单重跑 ⇒ 仍 rc=3 ⇒
  补白名单 ⇒ rc=0 + X 获授权 + converged_at 落定 ⇒ 三次重跑 executed=[](幂等)
- 既有 backend/tests:1509 passed / 0 failed,改前(HEAD 导出树)与改后失败集零差异

边界:本地 commit,零 push / 零部署 / 零重启 / 零连生产库 / 零 DDL-DML-GRANT-REVOKE;
上线(push + 部署 ⇒ 授权面生效)另单另铸 approval ID 且必附 R2 预演 diff(本单不为任何动作铸 ID)
2026-09-28 17:01:39 +08:00
wecom-fullstack 36f4f54799 feat(data-quality): 财务七规则可配置化最小实现(ASK-20260928-05)
ci/woodpecker/push/main Pipeline failed
任总 2026-09-28 13:38 放行「4 和 5」→ 落笔最小实现:
- 新表 quality_rule_config(39 项:37 规则级 + 2 全局)= 唯一配置源
- 三层解析:账套行 → 全账套模板(entity_id=0) → 代码内置默认(=清单 §7 建议值,零行为差异)
- 端点 /api/cma/data-quality/rule-config(GET/PUT)+ /history + /meta:写入仅 ceo/it,
  platform 级仅 it,演示账套(functional)写禁,变更落 operation_logs 同事务
- 数据质量页新增「规则配置」只读 Tab(懒加载)
- 唯一行为变更:污染判定去掉 sync —— 酣客 zxt-sync 系统迁入数据不再被误判(口径可配置)
- 出厂默认落库脚本 scripts/seed_dq_rule_config.py(--check/--apply/--rollback,幂等 39/0)
- 回归:新套件 35 例 + 存量 1508 passed(唯一 FAILED 为他人未完成
  migrate_subjects_entity_id_20260923.py 改动所致,HEAD 干净树同例通过)

生产写(DDL/seed)与 push 均走审批门:DDL=54434656 push=3bd00774
2026-09-28 13:56:16 +08:00
Hermes CI Fix 242f3cf5ec fix(fetch-caliber): 取数层排除半年/预测伪期间 + subjects 响应补 entity_id
R3 期间口径(仅 YYYY-MM)延伸到取数层:新增 alert_entity_scope.month_period_clause()(跨库可移植 SQL 等价式),应用到 15 个文件 26 处「按 period 倒序取最新值/最近N期」位点;dashboard 原 MySQL-only REGEXP 正则收口到同单一事实源。

GET /api/cma/subjects 响应增补 entity_id(NULL=全局标准准则科目 / N=账套私有明细),既有 10 字段语义与名称不变。

证据:pending-tasks/_reports/yanxue-20260924-cma-fetch-caliber-fixes-fullstack.md(改前/改后 A/B:F_NET_PROFIT 2026-H2/-55.0 → 2026-08/-10.46)。新增 7 例回归测试;全量 1 failed(预存在) 1473 passed,基线同项。
2026-09-26 02:42:04 +08:00
wecom-fullstack 52b693ddbb ASK-20260924-15/17: Bot 通道缺省账套→400 + 差异/趋势入口期间口径回落
- deps.get_entity_id: Bot 通道(带 X-BOT-KEY)无显式账套 → 400(旧为兜底 entity 1=酣客);
  无 key 的存量公开/用户端点行为不变(仍 401/兜底 1)
- 新增 deps.require_bot_entity_id():写端点缺省账套一处收口 → 400(406/422 同族)
  接入 bot_bridge(×2) / bot_bridge_v2 / bot_suggestions / cash_vouchers_json,并同步文档口径
- deviation_engine.run_deviation_check: period=None 不再硬编码当月,逐 KPI 回落
  「该 KPI 自己的最近可用期间」(裁定2b,单一实现收口到 alert_entity_scope);(当月无
  kpi_values ⇒ 差异/趋势入口永久 0 新增);R2/R3 窗口判定改为逐 KPI;显式 period 语义不变
- alert_generator.resolve_latest_period 委托同一实现(消除期间口径两份)
- 测试:新增 tests/test_ask15_17_engine_and_entity_required_20260925.py(12 例);
  4 个既有测试文件把「旧兜底账套 1」显式化(断言未改)
2026-09-25 22:42:25 +08:00
Hermes CI Fix 38787a006f fix(notifications): deterministic tiebreaker for paginated notification log list
Same-class gap as e369508 (alerts list): single-key ORDER BY created_at DESC plus offset/limit lets MySQL filesort return an unstable order inside same-second tie groups, so offset pagination duplicates and skips rows. Append secondary key id DESC, aligning with app/api/alerts.py, app/api/cash.py and app/api/data_quality.py.

Entity-scope caliber untouched: parent-key join attribution, alert_id IS NULL rows still excluded, policy/note wording unchanged. Local commit only - no push (release gated by ASK-20260925-04).
2026-09-25 16:34:52 +08:00
Hermes CI Fix e3695088a4 fix(alerts): deterministic tiebreaker for paginated alert list
list_alerts ordered by created_at DESC alone; kpi_alerts has many rows sharing the same second timestamp (07:00 batches), so MySQL tie order is unstable and offset pagination both duplicated and skipped rows (entity 1 read-only probe: total=281, 281 rows walked, only 268 unique ids, 13 duplicates; id 587 unreachable in the unfiltered list while visible in ?status=pending).

Add secondary key id DESC so pages form a total order; add regression suite test_alerts_pagination_stability_20260925.py (tie-heavy fixture: unique==total, zero duplicates, strict id DESC inside a tie group, page concat == single-shot ordered query, unfiltered vs status-filtered visibility, entity isolation negative control).
2026-09-25 14:06:38 +08:00
wecom-fullstack a8391a03da fix(caliber): 流动/速动比率改「倍」口径 + 判定层同量纲直判(ASK-20260924-11 任总放行)
ci/woodpecker/push/main Pipeline failed
- caliber_units: F_CURRENT_RATIO/F_QUICK_RATIO 由 FACTOR_PERCENT(×100) 退场为 FACTOR_IDENTITY;declared_unit=倍 / caliber=倍(无量纲)/ evidence 改引 v2.1 锚;F_GROSS_MARGIN 保持 ×100 不动

- 引用面同步 7 文件:alert_generator / kpi_unit_crosscheck / migrate_...20260916 横幅 / 三个测试套件;self-test 两条改倍口径期望 yellow

口径: CMA-CALIBER-v2.1/kpi-unit@2026-09-24 取代 2a 之两项;其余比率类仍按裁定2a 百分比口径
2026-09-25 10:39:18 +08:00
wecom-fullstack c3ff345d0a fix(bep): 派生器不再静默降级已转正行(ASK-20260924-10 任总放行)
ci/woodpecker/push/main Pipeline failed
- upsert 分支原无条件 existing.data_status='pending' ⇒ 人工转正次日 06:35 被打回 pending
- 值未变/值变一律不写 data_status(保留既有,含 verified)
- 值变:remark 追加「值已更新(batch=…, 旧X → 新Y) 待财务复核」+ 落复核提示件(_reports/,不进队列根)
- 新期间仍写 pending(9-14 复核流入口不变);算式/TARGET_KPIS/阈值逻辑不动
2026-09-25 07:07:01 +08:00
fullstack-bot 23f12fc866 fix(knowledge): render DB dynamic articles via markdown safe renderer
ci/woodpecker/push/main Pipeline failed
A: dynamic knowledge-articles content goes through marked instead of raw v-html (escape-first, no new deps).

B: plain-text fallback normalize (bracket headings, circled numbers to ordered list, blank lines, key-number bold, idempotent).

C: detail-content styles for h2/h3/pre/blockquote/paragraph spacing.

D: dialog width 700 to 860, content padding, in-dialog scroll.

Task: project-20260924-knowledge-dialog-render-fullstack
2026-09-24 23:32:50 +08:00
Hermes CI Fix 07dc10d557 fix(ui): 全仓 el-dialog 清剿 → MyDialog(26 处/17 文件)
ci/woodpecker/push/main Pipeline failed
- 项目铁律:一律 MyDialog(Element 原生弹窗在本项目长期不可见)
- 含用户报的 OKR 模板库「应用」弹窗(OKRTemplates.vue)
- 等价适配:width 字符串 px → 数字绑定;append-to-body/destroy-on-close
  /top/show-close/class 等 EP 专有属性移除(MyDialog 已 teleport+卸载语义)
- 新增 MyDialog 显式 import;零后端/零数据改动
- 全仓 <el-dialog> 0 命中;components.d.ts 随之移除 ElDialog 自动注册
2026-09-24 17:36:05 +08:00
wecom-fullstack 9b6184ce05 fix(data-quality): 陈旧预警阈值落 30 天(任总 2026-09-24 00:35 拍板,唯一取值来源) 2026-09-24 17:27:23 +08:00
Hermes CI Fix ede0524c22 feat(docs-center): CMAKnowledge reads md single source via new /api/cma/docs
ci/woodpecker/push/main Pipeline failed
New router app/api/doc_center.py: GET /api/cma/docs (list, ?with_content) and GET /api/cma/docs/{slug}; runtime disk read, role-gated (ceo/finance/business/it), no existing API semantics changed.

Content dir /srv/hermes/shared/cma-content (docs/*.md + categories.json). Placed outside the repo on purpose: the deploy tree equals the committed tree (deploy.sh checkout -f + clean -fdx), so in-repo content would be reset on every deploy and content edits would fail the deploy-tree dirty check.

CMAKnowledge.vue: 20 hardcoded items migrated to md, page now fetches at runtime and renders md with the existing marked dep (no new deps); table borders restored via :deep() since scoped styles never reach v-html content.

Tests: backend/tests/test_doc_center_20260924.py (20 cases). Full suite 1448 passed (baseline 1428 + 20).
2026-09-24 01:07:29 +08:00
Hermes CI Fix 72d4d97c83 fix(data-quality): KPI质量监控页 — 账套口径收口 + 环图颜色真值 + 天数单一来源 + 异常数下钻
ci/woodpecker/push/main Pipeline failed
1) 跨账套混算修复(最高优先):/stats 的 total_logs/open_logs/severity_counts/type_counts
   原先直查 kpi_data_quality_log 无任何账套过滤(全库数),而同页 /logs 已按账套收口,
   导致「卡片数字与下方列表对不上」。现四项与 /logs 共用 _scoped_quality_log_query
   (kpi_id join kpi_definitions,内连接,孤儿日志不计)。零 DDL:不给该表加 entity_id 列。
2) /logs 服务端分页(page/page_size,默认 50、上限 200)+ total 改真实 COUNT(*)
   (原为截断后行数 len(),且 limit=100 静默截断)。
3) 新增只读端点 GET /missing-data-kpis:「无实际值 KPI」清单(服务端分页 + 真实 total,
   口径与 /stats.data_missing 逐字一致),供「无实际值 KPI 占比」卡片下钻。
4) 环图黑块修复:ECharts 走 canvas,不解析 CSS 变量,原 itemStyle.color 传 CSS 变量会回退黑色。
   改合法 HEX 实值(与主题色同值 #F56C6C / #E6A23C)。全仓同类写法扫描:33 处 CSS 变量命中,
   仅此 2 处在 ECharts 上下文(已修);ReportCenter.vue:848-849(经 bscScoreColor() 流向 :style)、
   KPIDetail.vue:244/267(el-timeline-item :color 落 inline style)均为 CSS 上下文,非缺陷,未改。
5) 阈值文案单一来源:新增 STALE_THRESHOLD_DAYS = 180,接口 threshold_days 与之同源,
   后端注释同步修正;阈值本身保持 180 不动(改阈值属口径变更,须另行拍板)。
6) 前端 DataQuality.vue:卡片标题加维度限定词(KPI元数据完整度评分 / 无实际值 KPI 占比 /
   超 N 天未更新预警由接口渲染)、顶部四项加层级标注(记录数 vs 实体数)、三张异常卡 + 无实际值
   KPI 可点击,用 MyDialog 打开明细(0 值置灰不可点,服务端分页 50/页,导出 CSV 全量)、
   质量异常日志列表改服务端分页(显示 total,与卡片同口径)。未新增 el-dialog、未改 api/index.ts。

未动:KPI 检查算法 / 检查类型集合 / 异常判定阈值;未回退「财务七规则」标签页既有下钻
(govDetail* 与 rule-detail 端点)。
2026-09-24 00:40:11 +08:00
wecom-fullstack 467472311d feat(cma): audit write-side entity closure + entity-dimension data dictionary
task dama-entity-isolation-sweep-20260923 (sections 2-2 4/5, merged by projectBot 2026-09-23 18:2x)

4) operation_logs write side: every audit write carries the account (no DDL, no column added)

   - new app/utils/oplog.py::log_op() single wrapper: sets detail.entity_id; scope=global for

     by-design non-entity writes (platform dict / role-level); scope=batch + OPLOG_BATCH_NO_ENTITY

     sentinel for cross-account batches; no context -> OPLOG_NO_ENTITY sentinel (never guess)

   - 16 remaining construction points converted (data/alert_rules/bi_reports/kpi_causality/

     bot_bridge x2/budget_generate/driver_budget/kpis _log x3/templates _log x2/erp_sync);

     pre-4 state had 20 sites without entity_id, now 0 outside the wrapper

   - read side untouched (knowledge_service still user_id fallback); history not backfilled

5) docs/entity-dimension-data-dictionary.md: 8-table three-state registry

   (kpi_templates/driver_factor_templates/scenario_suggestions/bot_bridge_config = non-entity;

    kpi_subject_map = inherited via kpi_id; analysis_results/subjects = account dimension,

    legacy rows still NULL -> other task; operation_logs = non-entity column + detail.entity_id)

Evidence: RED 2 failed/7 passed (reverted call sites) -> GREEN full 1428 passed; 9 new tests

including two read-back rows; app md5 restore verified identical; no cross-account writes

LOCAL commit only - push is gated (ahead contains the P1 entity-dim batch commit fb4609b)
2026-09-23 23:08:26 +08:00
Hermes CI Fix fb4609be14 feat(entity-dim): 账套维度收口批 — subjects/analysis_results 加列 + 分析结论账套化 + 模板实例化构造点补账套
ci/woodpecker/push/main Pipeline failed
- ① kpi_templates instantiate 构造点显式传会话账套(原缺 entity_id ⇒ 静默落 e1),kpi_code 判重按账套收紧
- ② kpi_subject_map 3 个读取点加护栏注释(不加列、不改语义)
- ③ subjects 分层:读口 union(NULL=全局标准准则科目 / N=账套私有),UPSERT 唯一键改 (entity_id, subject_code)
- ④ analysis_results 账套化:读/写/删三端点按会话账套判定归属(列未落地时 kpi_code JOIN 止血),
     无归属行进 unattributed 登记、删除一律 404
- 模型层按现网 DDL 补齐:KPIDefinition(template_id/is_system + uk_entity_kpi)、AnalysisResult.entity_id、Subject.entity_id
- 新增 migrate_{subjects,analysis_results}_entity_id_20260923.py(--check/--apply/--down)、
  22 条对账用例 test_entity_dimension_convergence_20260923.py

DDL 已由 ops 受控通道落地(subjects 21:31:56 / analysis_results 21:31:59 CST,审批 1e9b4d9a/5ffb1640)
本提交为切片提交,未 push(发布须另取放行)
2026-09-23 21:39:24 +08:00
Hermes CI Fix 0b3a8aa111 feat(data-quality): anomaly-count drilldown to full detail + real COUNT(*) caliber
ci/woodpecker/push/main Pipeline failed
backend: GET /api/cma/data-quality/rule-detail (COUNT(*) total, structured items, server-side paging, scope=manual); governance seven-rule counts use real totals instead of len(LIMIT-ed rows). frontend: clickable anomaly count -> MyDialog detail (no new el-dialog) + full CSV export.
2026-09-23 18:49:57 +08:00
wecom-fullstack 477515c453 fix(cma): 隔离扫尾 — kpi_alert_higher_better 拆账套 + tenant/switch 审计收口
ci/woodpecker/push/main Pipeline was canceled
任务 dama-entity-isolation-sweep-20260923(DAMA 独立回查漏项 / 任总 2026-09-23「按照建议执行」)

① 补漏同源全局键 kpi_alert_higher_better(system_configs,零 DDL)
   - deviation_engine: 新增 higher_better_key(entity_id);get_higher_better_codes(db, entity_id)
     改为只读本账套键、不回退旧全局键;未配置 → DEFAULT_HIGHER_BETTER(兜底,与原行为逐字一致)
   - budget.py: GET/PUT /alert-direction 挂 get_entity_id,只读写会话账套键;body entity_id 不信
   - scripts/migrate_higher_better_key_entity_20260923.py: --check/--apply/--down + 库名保护 + 幂等
   - 生产实况:库内该键 0 行 ⇒ 无原值可搬运(--apply 走 skip 零写入),e1 行为不变

② kpi_visibility 口径裁定:角色级天然全局 = 非账套维度,不拆(关闭本项)
   - auth_middleware 注释 + docstring 登记「有意不做账套后缀」;口径字典
     docs/global-config-keys-caliber-20260923.md(含 budget_mode/budget_driver_mode 评估结论)

③ tenant/switch 审计收口
   - detail 显式 from_entity_id → to_entity_id + source + operator{id,username,name,role}
   - 审计失败不静默:同事务回滚 + AUDIT_WRITE_FAILED ERROR + HTTP 500(不留「改了但没审计」)
   - GET /current 无写路径不落审计;历史缺口不补写

证据:新增/改写用例 17 条;全量 1397 passed(去新增文件 1380 + 17);RED 态 8 failed
2026-09-23 18:29:52 +08:00
Hermes CI Fix d13bf1b6e4 feat(cashflow): 期初/期末现金补采 — 科目余额表货币资金(1001/1002/1012) 入库
ci/woodpecker/push/main Pipeline failed
- import_cashflow_statements.py 新增 _read_cash_balances(): 读 <YYYY.MM>/kmyeb.csv
  货币资金(1001/1002/1012) 期初借-贷 / 期末借-贷, ÷10000 留 2 位 → begin_cash/end_cash
- 仅 entity_id=1(智享通通道=酣客) 生效, 其它账套保持 NULL; 源缺失/科目缺失保持 NULL 不写 0
- 既有三大活动净额与 remark NA 固定短语逻辑未动; dry-run 默认/--period/幂等语义不变
- --self-test 扩至 9/9: 新增 kmyeb缺失/科目缺失/借贷为负 三类边界 + 真实源自洽与跨期连续
2026-09-23 16:20:43 +08:00
Hermes CI Fix e79b51666d fix(growth-quality): default period to newest period from API
ci/woodpecker/push/main Pipeline failed
The component shipped a hardcoded initial period (2026-06) that is present in the periods list, so the previous guard kept it and the page never defaulted to the newest period. Initialize empty so loadPeriods() applies periods[0] (backend returns DESC); /diagnosis treats an empty period as 'newest with data'.
2026-09-23 15:44:07 +08:00
Hermes CI Fix c88e1ad6c9 fix(growth-quality): period select reads periods array from object response
ci/woodpecker/push/main Pipeline failed
Backend GET /growth-quality/periods returns {entity_id,periods[],coverage{}}; axios interceptor already unwraps response.data so the old r.data||r||[] put the whole object into el-select and Vue rendered JSON.

Also default to periods[0]: backend returns periods in DESC order, so old code picked the OLDEST period. Only frontend/src/views/GrowthQuality.vue changed.
2026-09-23 15:29:44 +08:00
fullstackandHermes CI Fix bd4f58aae1 feat(action-plans): COSO 自检未通过条目一键生成改善行动(接通死按钮)
ci/woodpecker/push/main Pipeline failed
- 后端新增 POST /api/cma/action-plans/coso-generate-actions:未通过条目→action_plans,
  账套取 token 绑定(忽略 body entity_id)、幂等键 (entity_id,title)、不写 auto_verify_rule
- 前端 ActionPlanLibrary.vue 按钮接 @click(含未通过条数提示 + 二次确认 + 生成后刷新并跳列表)
- api 客户端补 cosoGenerateActions
- 新增 tests/test_coso_generate_actions_20260921.py(生成/幂等/隔离/未认证/归属校验/空集)
2026-09-23 01:03:19 +08:00
Hermes CI Fix 2c2dbc2b2d fix(cma): 行动计划自动生成补账套 — 第 4 处写入点 + 写前同源校验
ci/woodpecker/push/main Pipeline failed
任务:0-P1-project-20260922-actionplan-entity-write-fullstack
父单:project-20260921-cma-entity-isolation-batch5(§B 治本 0c2e57a)
同族已修(勿重做):alerts.py / deviation_push.py / ai_suggestions.py(d03e322)

缺陷(唯一活写入点):
  scripts/alert_generator.py::run_alert_check 为 red/yellow 预警自动建 ActionPlan 时
  不传 entity_id(模型 default=1 + 生产 DDL `DEFAULT 1` 双默认静默兜底)⇒ 非 e1 账套的
  KPI 触发阈值时「同一次事务里预警归属正确、计划归属落 1」;读侧 dashboard.py 工作台按
  ActionPlan.entity_id 取出计划、KPI 名不做账套过滤渲染 ⇒ 酣客(e1) 工作台显示博海(e2)/
  演示(e6) 的 KPI 名(实测 19/83 行归属不符,09-17/18/22 07:00 各新增 6/7/6 行,每日复发)。

改动:
  1. 写入点补账套:ActionPlan(entity_id=kpi.entity_id, …) —— 归属唯一事实源 = 来源 KPI 账套;
  2. 写前同源校验 action_plan_entity_guard():来源账套(kpi.entity_id) 与目标账套
     (alert.entity_id,即本次遍历账套) 不一致或任一为空 → 不建计划 + OperationLog
     (action="skip_plan", reason=plan_entity_attribution_mismatch) 留痕 + 计入统计输出,
     不 raise、不中断流水线(口径照抄 bot_bridge_v2 R1 账套守卫 / B3(b) skip_alert 留痕姿势);
  3. 统计/汇总:stats 增 skipped_plan_entity_mismatch / skipped_plan_kpis,并在
     generate_and_push 返回值与 coverage_note 中显式输出(不得静默丢弃);
  4. 新增回归 tests/test_alert_generator_entity_attribution_20260922.py(T1/T2/T3 改前 RED、
     改后 GREEN;T4 正对照改前即绿,不构成本单判据)。

边界(零越界):零 DDL(未动列默认、无 alembic/migration/SQL 脚本)、未改 models、
未动同族三处已修文件、未写生产库、未调写端点、未改 cron/前端、未 push、未部署、未重启。

回归:backend `pytest -q` 1374 passed / 0 failed(基线 1370 + 新增 4)
2026-09-22 11:35:01 +08:00
fullstack 30fa745135 fix(cma): 工作台跨实体治本 · DAMA 回查收口 — 审计可归属 + 通知日志口径
DAMA 独立回查 dama-recheck-workbench-entity-isolation-20260922 的 §4b/§5 两处待办:

1. POST /api/cma/tenant/switch 审计行原来不可归属:
   - user_id=None → 改为注入会话用户(user=Depends(require_auth_401))
   - target_type/target_id 从 system_config/None 对齐为 entity/<entity_id>,
     与 /auth/switch-entity 的 switch_entity 共用目标维度 ⇒ 按账套可过滤,零 DDL
   - action 字面 switch_tenant_state 保留不合并(与会话级 switch_entity 语义不同),
     口径登记 docs/audit-and-notification-caliber-20260922.md

2. /api/cma/notifications/logs 增加 policy/note 口径字段,前端发送记录表
   empty-text 提示同一口径 —— 避免「B2 上线后 e1/e2 均 0 条」被当成页面故障
   (现库 8 行历史记录全为 alert_id IS NULL 的 OKR 复盘提醒,无账套归属)

回归:tests/test_workbench_entity_isolation_20260922.py 22/22;全量 1370 passed;前端 vite build 通过
2026-09-22 11:21:24 +08:00
wecom-fullstack 0c2e57a4cf fix(cma): 工作台跨实体污染治本 — 全局键拆账套 + 缺列收口 + 预警归属兜底
任务 dama-workbench-entity-isolation-fullstack-20260922(DAMA 诊断 / 项目Bot batch5 §B / 任总 2026-09-22「都批」)

B1 现金基线拆账套(零 DDL)
- cash_forecast_engine: 键 cash.current_balance → cash.current_balance.<entity_id>;
  读只读本账套键、不做旧键回退;set_* 签名 entity_id 必传(漏传 TypeError)
- cash.py: POST /balance 挂 get_entity_id;网银流水联动写回带账套
B3(a) alignment_config 拆账套(零 DDL)
- alignment.py: GET/POST /config、GET /tree 只读 alignment_config.<entity_id>;未配置=configured:false
B3(b) 预警 KPI 归属兜底
- bot_bridge_v2: 去掉 kpi_id or 1 兜底;本账套无 KPI 定义 → 跳过建预警 + OperationLog 留痕
  (kpi_alerts.kpi_id NOT NULL ⇒ 方案A 置 NULL 不可行,走方案B 跳过+审计)
B2 缺列五表分表处置
- notification_logs: 经 alert_id → kpi_alerts.entity_id join 归属(无父键行不返回)
- notification_channels / okr_templates: 集团共享口径写入 docstring(有意不过滤)
- system_configs: 由 B1/B3(a) 拆键覆盖;kpi_data_quality_logs 沿用 batch5 的 join 收口
P0 语义收敛
- tenant.py: current_tenant 收敛为项目Bot A2A 状态记录,GET 仅 it/ceo;POST 补 operation_logs 审计
证据:新增/改写用例 22 条;全量 1370 passed(基线 1348)
2026-09-22 11:06:02 +08:00
fullstack-bot e89629228e fix(cma): 缺失值 NULL 语义——同环比/执行率/进度不把缺失当 0
ci/woodpecker/push/main Pipeline failed
财务裁定 2026-09-21(博海 2026-08 批次 §2):前端对缺失值须显式 NULL 语义,
同环比不得按 0 参与。

- ProfitBreakdown: revChg/profitChg/rateChg 上期缺失→null(原 return 0 显示为持平 0.0%),
  去掉 prev || 0.01 假分母;模板 null 显示「缺上期数据」
- MyDashboard: 进度条 actual 缺失不再显示 0%,改显 —
- DeviationDashboard: 趋势图 actual/budget 保留 null,barHeight 对 null 不画柱
- BudgetManagement: 执行率/偏差额 actual 缺失→null(表格显示 -),平均执行率只对非空取平均
2026-09-21 16:07:03 +08:00
Hermes CI Fix f88ed94c15 fix(action-plans): derive overdue filter in list endpoint (single source with stats)
ci/woodpecker/push/main Pipeline failed
Frontend sends card key status=overdue to GET /api/cma/action-plans, but the

list endpoint compared ActionPlan.status == 'overdue' which is not in the

status domain -> always 0 rows while /stats showed 7 (derived caliber).

Extract _apply_overdue() and use it in both plan_stats and list_plans.

Refs: project-20260921-actionplan-overdue-filter
2026-09-21 15:13:27 +08:00
fullstackBotandHermes CI Fix 27ea7e7f53 fix(cma): my-dashboard KPI 取数同期内主值优先(重复行不再被选中)
同一期间并存两行(主值 ledger/verified 与自动归集重复行 auto_collect/error)时,
原判据按 calculated_at 倒序会取到写入更晚的重复行;今日两行数值一致故不可见,
漂移后卡片会静默显示重复行值。故在月期分支的排序中加「主值优先」:
verified > ledger > 其余,置于 period 倒序之后(跨期口径不变)。

范围: backend/app/api/dashboard.py 的 latest_kpi_value() 单函数(含 case 导入)。
无 DB 写入、不动采集器/派生函数/cron/口径定义/前端。

单号: project-20260918-kpi-row-priority
2026-09-21 14:56:43 +08:00
Hermes CI Fix b4f94a12b3 fix(cma): entity scope for expenses resubmit + cumulative limit check
F1 resubmit_reimbursement now requires session entity_id (cross-entity -> 404).

F2 validate_reimbursement monthly/yearly cumulative sum is entity-scoped,

so other entities' in-flight amounts no longer pollute the limit verdict.

Tests: backend/tests/test_cross_entity_isolation_expenses_residual_20260921.py
2026-09-21 14:56:43 +08:00
wecom-fullstack adfc3ac845 fix(deploy): 部署源改用已提交 origin/main 树(独立检出),根治「开发区任何 modified 即中止」
ci/woodpecker/push/main Pipeline was successful
单 project-20260921-deploy-dirtyguard-rootfix(任总 2026-09-21 10:28「根治」)。

问题:开发区 /root/cma-management → /srv/hermes/shared/cma-management 既是编辑区又是部署源,
deploy.sh [0] 对任何 tracked modified 即 exit 1,多 agent 并行下反复自锁:
- 2026-09-18 frontend/package.json(corepack 生成物)
- 2026-09-21 10:09 batch5 在途 6 文件(放行卡住)
- 2026-09-21 11:07 构建又写出 frontend/components.d.ts

改动:
- deploy.sh:部署内容 = 已提交的 origin/main(独立部署检出 /srv/hermes/shared/cma-deploy:
  fetch -> checkout --detach -f -> clean -fdx);[0] 降级为告警(不再 exit);
  新增 1.1 部署树自检(部署树必须无 tracked 修改)、1.2 运行时资产单源接线(.env/venv/node_modules)、
  1d 后端可导入自检、运行树幂等切换(unit 改写前备份 + 一行回滚,cma-runtime-unit-switch 可抽取块);
  保留 [1b] 鉴权面审计闸门(B 类缺口即中止)与 构建->铺站->重启->冒烟 全流程;
  新增 drill 模式(离线演练:www 必须在 /var/www 之外、systemctl/nginx 必须是 PATH 桩、迁移只 --check)。
- deploy/cma-deploy-dirty-guard.sh:宿主前置闸门降级为告警(仅仓库不可用时失败关闭),不再写开发区。
- deploy/gitea-cma-deploy-hook.sh:[0] 段文案/语义同步(gate-trail 留痕链原样保留)。
- deploy/install-host-deploy-hook.sh:宿主组件安装/升级(备份 + md5 校验 + --check)。

演练证据(26/26 PASS):脏区旧版 [0] exit=1 / 新版整链 exit=0;开发区在途改动逐字节保留;
构造 B 类缺口时 [1b] 中止且未铺站;真机 drill 零生产影响。

未 push(发布须任总放行)。
2026-09-21 11:13:30 +08:00
Hermes CI Fix 42ff836309 fix(cma): entity isolation batch5 (budget writes, kpi objectives scope, quality logs join, growth-quality db source, expenses scope)
ci/woodpecker/push/main Pipeline failed
2026-09-21 10:36:02 +08:00
Hermes CI Fix a0762b367f fix(cma): entity isolation batch4 (cash plan writes, budget ownership, read leaks, okr template)
P0-A cash.py: scope api_update_plan / api_delete_plan / api_complete_plan /
api_register_payment to session entity (cross-entity id -> 404, aligning with
the file's existing get_entity_id endpoints).
P0-B budget.py: write entity_id on BudgetPlan creation + require the KPI to
belong to the session entity; scope /comparison, /comparison/kpi/{id} and
/deviation-report reads to the session entity.
P1 notifications.py: explicit field whitelist (name/config/enabled) on
update_channel to close mass-assignment.
P1 read leaks scoped to session entity: bot_kpis list, data_quality check/stats
(KPI calibre only; quality-log table has no entity_id column), kpis causality
chain + associate-map (KPI and map ownership), dashboard my-kpis,
customer_dashboard list, kpi_governance audit, budget_generate candidates,
driver_budget history (+ entity_id on its write path), expenses stats
(+ entity_id on its write path).
P2 okr_templates.py: write entity_id on the StrategicMap created by apply.

tests: tests/test_cross_entity_isolation_batch4_20260921.py (30 cases;
RED before fix = 22 failed, GREEN after = 30 passed; full suite 1302 passed /
1 xfailed / 0 failed).
2026-09-21 09:37:38 +08:00
Hermes CI Fix 8c5853f64b fix(alerts): enforce caliber ruling R5-supp (period linkage)
deviation_engine: link kpi_value_id on deviation/trend alert rows; skip when period not attributable or kpi_values row missing; dedupe key now (entity_id,kpi_id,kpi_value_id,alert_type)

deviation_push: same-source R1/R5 guards, no-period request writes nothing

tests: add test_alert_caliber_r5_residual_20260919 (13 cases incl. negative controls); adapt r1r4/r5 fixtures to the supplemental ruling
2026-09-19 11:12:31 +08:00
Hermes CI Fix 5a99ed87e0 fix(alerts): enforce caliber ruling R5
- alert_entity_scope: add is_attributable_period / guard_alert_period (R5) and entity_alert_guard (R1, any write path)
- alert_rules.run_all_alert_checks: skip non-YYYY-MM period alert rows (R5)
- bot_bridge / bot_bridge_v2: R1 demo-entity guard + R5 period guard on write paths
- tests: new R5 suite (10 cases)

Not pushed / not deployed (release gate held by 任总).
2026-09-19 10:42:09 +08:00
Hermes CI Fix 3beec14d96 fix(alerts): enforce caliber rulings R1-R4 for alert generation
R1: exclude status=demo entities from alert traversal (alert_generator / deviation_engine / kpi_bep_deriver / alert_rules / cash engine)

R2: only generate alerts within the last 6 calendar months (incl. current)

R3: only regular month periods (YYYY-MM) participate

R4: decouple generation from push - empty notification_channels never blocks

Single source of truth: app/utils/alert_entity_scope.py
2026-09-19 10:05:11 +08:00
Hermes CI Fix d785a22efd fix(alerts): scope check-timeout batch upgrade by KPIAlert.entity_id
GET /api/cma/alerts/check-timeout picked candidates via the join on KPIDefinition.entity_id, so a session could batch-upgrade (red + processing) alert rows whose own entity_id belongs to another account. Switch the filter to the alert row's own entity_id, drop the now-unneeded join and the unused KPIDefinition import. Write path only; no data change, candidate ids on consistent rows are identical before/after.

Derived from dama-20260919-cma-alert-filter-and-cash-baseline-fix (C1/C2 residual in the write endpoint).
2026-09-19 08:35:25 +08:00
Hermes CI Fix 4225332aaf fix(cma): scope alert list and alert locate to alert row entity_id; attach cash-balance suffix candidates to forecast baseline
- alerts list: filter KPIAlert.entity_id (was KPIDefinition.entity_id via join) -> entity2 225->223, rows 589/590 no longer leak into entity2 workspace

- resolve/process/escalate/create-action-plan: same row-level scope (4 sites)

- get_current_cash_balance: pass KPI_CODE_SUFFIXES[cash_balance] -> entity2 hits BH_CASH_BALANCE(434), entity6 hits DEMO_CASH_BALANCE(468)

no data changes; no SystemConfig fallback change; no period ordering change
2026-09-19 08:06:17 +08:00
Hermes CI Fix 557771a803 fix(cma): scope ai-suggestion action_plan and preview KPI to session entity
_apply_action_plan: constrain the target KPIDefinition lookup to sug.entity_id so a
suggestion whose target_id/suggestion_data.kpi_id points at a foreign-entity KPI now
returns 404 instead of creating an ActionPlan row with a cross-entity kpi_id.

preview_suggestion: same constraint; a foreign-entity KPI now degrades to the existing
placeholder (KPI#<id>, current_target=None) instead of echoing the foreign KPI name and
current_target. Degradation (not 404) keeps the read-side preview usable when the stored
kpi_id is inconsistent.

Regression: backend/tests/test_ai_suggestion_kpi_attribution_residual_20260918.py
(RED 2 failed before, GREEN 5 passed after; full suite 1228+2F -> 1230 passed / 1 xfailed).
2026-09-18 21:04:30 +08:00
Hermes CI Fix 9cb960da25 fix(cma): scope ai-suggestion dismiss and target KPI to session entity
dismiss_suggestion: add get_entity_id dependency and filter AISuggestion.entity_id (cross-entity dismiss returned 200 and mutated the foreign row).

_apply_kpi_target/_apply_budget_adjust: constrain target KPIDefinition lookup to sug.entity_id so suggestion_data pointing at a foreign KPI yields 404 instead of rewriting it.

Regression: backend/tests/test_dismiss_suggestion_entity_scope_20260918.py (RED 3 failed before, GREEN 5 passed after; full suite 1220 -> 1225 passed, 1 xfailed).
2026-09-18 20:33:39 +08:00
Hermes CI Fix d2984857a2 fix(cma): scope read-side ai-suggestion detail/preview and map nodes
-m- ai_suggestions.get_suggestion: filter by session entity_id (foreign 404)

- ai_suggestions.preview_suggestion: use the existing entity_id dependency in the query

- deviation_push.get_map_nodes: add entity_id dependency and (entity_id or 1) ownership guard

- tests: 4 regression cases (3 RED -> GREEN + auth baseline 401)
2026-09-18 20:23:53 +08:00
Hermes CI Fix d03e322a81 fix(cma): action plan entity_id at the three residual construction sites
alert-to-plan, deviation-push review topic and AI-suggestion-to-plan built ActionPlan(...) without entity_id, so non-e1 sessions wrote rows with the model default 1 and entity_id stayed untrustworthy on those paths. Write the session entity at all three sites, scope the deviation-push topic dedupe by entity (a foreign same-title topic no longer suppresses the session entity's own topic), and require the applied suggestion to belong to the session entity. Adds 7 regression tests (both directions + cross-entity negative controls + auth floor).
2026-09-18 20:12:46 +08:00
Hermes CI Fix b072602191 fix(cma): action plan entity_id on create + okr decomposition entity scope
create_plan wrote no entity_id (fell back to model default 1) while the OKR decomposition view filtered weekly_actions by objective_id only, so cross-entity plans leaked into the other tenant view. Write the session entity on create (before adding the read-side condition - order matters) and scope weekly_actions by entity_id. Adds 4 regression tests (both directions + same-entity positive control).
2026-09-18 20:03:55 +08:00
Hermes CI Fix bab6137aa2 fix(cma): scope action-plan objective_id writes to session entity
create_plan/update_plan now validate Objective.entity_id ownership (404 on cross-entity, no partial row) and update_plan gets an explicit objective_id-vs-KR-derived priority rule. Adds 10 regression cases (batch3 bias: bidirectional + same-entity control + OKR decomposition read-side consequence).
2026-09-18 19:34:59 +08:00
Hermes CI Fix 50baa16f09 fix(cma): scope action-plan KR enrichment to session entity (batch3 follow-up)
list_plans enriched kr_title via KR.id only -> legacy rows whose kr_id points at another entity's KR echoed the foreign KR title. Add KR.entity_id filter (read-side sibling of batch3 item 4).

Local commit only; no push (push triggers Gitea deploy hook).
2026-09-18 19:03:49 +08:00
Hermes CI Fix 9fb9d14a58 fix(cma): entity isolation batch3 (cost abc write endpoints + allocations/stats reads + plan kr_id scope)
- cost.py: POST /abc/activities 按会话账套写入 entity_id;POST /abc/allocate 校验 activity_id 归属本账套(跨账套 404)并透传 entity_id;GET /abc/allocations 加账套过滤
- cost_engine.py: allocate_cost(entity_id=None) 按账套过滤作业中心 + 显式写入 AbcAllocation.entity_id(不传维持原语义)
- action_plans.py: update_plan 的 kr_id 加账套校验(跨账套 404,对齐 create_plan 既有写法);plan_stats join KPIDefinition 按账套过滤(口径与 list_plans 一致)
- tests: 新增 tests/test_cross_entity_isolation_batch3_20260918.py(10 例;改前 7 failed/3 passed → 改后 10 passed)
- 回归: 1184 passed,1 xfailed → 1194 passed,1 xfailed(无新增失败)
2026-09-18 18:42:38 +08:00
Hermes CI Fix 14ab6d211c feat(cma): alignment mode system-level hint + double confirm (project-20260918-alignment-config-hint)
Frontend only: KPIAlignment.vue adds system-level notice and ElMessageBox confirm before save. No backend/storage change.
2026-09-18 18:32:22 +08:00
Hermes CI Fix ad953ac759 fix(cma): entity isolation batch2 (roll-forward/check-timeout/by-id/cost-abc)
ci/woodpecker/push/main Pipeline failed
Scope: backend/app/api/{alerts,action_plans,budget,cost}.py + tests

Task: 0-P1-project-20260918-entity-isolation-batch2
2026-09-18 18:15:22 +08:00
Hermes CI Fix b9c80f836e test(cma): force production Redis off during tests (P1 token leak isolation)
ci/woodpecker/push/main Pipeline failed
2026-09-18 17:33:25 +08:00
Hermes CI Fix a2fedd892a fix(cma): isolate KPI alignment tree by entity (project-20260918-alignment-entity-leak)
GET /api/cma/alignment/tree queried all active KPIs without entity_id filter,

so switching the three alignment modes showed other tenants' KPIs.

Add get_entity_id dependency and filter KPIDefinition by entity_id.

Three mode builders (_build_vertical_tree/_build_horizontal_tree/_build_bsc_chain) unchanged.

Auth semantics unchanged; cross-entity explicit entity_id still returns 403.

Test: tests/test_alignment_entity_isolation_20260918.py (3 modes x both directions, 403, 401).
2026-09-18 17:18:52 +08:00
Hermes CI Fix d33b5e1ced fix(cma): my-dashboard KPI period caliber (monthly period priority) + as-of/stale marks
my_dashboard picked the row with max calculated_at, so batch backfills made stale periods win (revenue/net-profit showed 2026-07, rebate rate 2026-04). Now prefer the latest monthly period, fall back to calculated_at. Frontend adds data-as-of label and stale tag; not deployed yet (push gated by another pending commit).
2026-09-18 15:39:12 +08:00
Hermes CI Fix df2be301b3 fix(cma): treasury grant step follows whitelist min set and converges
- spec read from _cma_write_table_whitelist.json table_ops (single source of truth)

- revoke non-whitelist table privs before grant (idempotent; 1141 handled as no-op)

- add zero-DB --grant-plan mode; post-apply convergence assertion via information_schema

- task: yanxue-20260918-treasury-grant-idempotent-fix
2026-09-18 06:04:48 +08:00
Hermes CI Fix 3bb55189b1 feat(cma): 口径裁定 2026-09-17 落地(现金流投资=0 二维判定 / 预警单位+期间口径)
ci/woodpecker/push/main Pipeline failed
依据:wecom-finance/reports/CMA_口径裁定_20260917.md(任总 2026-09-17 23:14「按建议执行」)

裁定1(现金流「投资活动净额=0」参与判定)
- classify_enterprise():0 为合法输入;投资恒为 0 → 经营(±)×筹资(±) 二维映射出 2 个
  候选类型(candidate_type_nos/candidates),不再整体判「未判定」;投资栏 investing_note
  标注「本期无投资活动」;经营/筹资亦为 0 → 回退「未判定(无投资活动)」,不报错不静默
- /analysis 增 classification_dimension / investing_note / nets.investing.note;前端出
  二维横幅 + 候选行高亮 + 投资栏「本期无投资活动」(不给单一类型名)
- import_cashflow_statements.py:打印层修 2D(type_no=None)TypeError 崩溃;NA 措辞改为
  「按 0 参与判定」(机读短语 NA_PHRASE_TMPL/parse_na_activities 未动)

裁定2a(比率类单位统一「百分比(%)」口径,先做)
- 新增 app/utils/caliber_units.py:逐 KPI 对照表 RATIO_CALIBER_TABLE(唯一事实源)+ 判定侧
  唯一入口 resolve_caliber();3 项需归一 ×100(流动/速动/毛利率),12 项取数已百分数不归一;
  表外且命中「疑似错配」窄形态(阈值子句量级≥100 且 0<|实测|<10 且单位属比率类)→
  dimension_suspect:不落预警、不推送(唯一规则,防 586/587 假红灯复发)
- alert_generator:判定前归一,文案写「口径归一 ×100(原值 1.29 → 129%)」留审计线索
- 新增 scripts/kpi_unit_crosscheck.py(只读对照表产出器)

裁定2b(期间由「当月硬编码」→「最近可用期间」,后做)
- resolve_latest_period():取该 KPI 自己的最新 YYYY-MM(禁全局 MAX,防演示账套劫持);
  伪期间(2026-H1/H2、2025-FY)在 Python 侧过滤(保「单测=生产」语义)
- 过渡期覆盖度自检扩展:记录回落后的期间分布 + 当月 0 条归因提示
- --dry-run(零写入零推送)作放行前抽查证据;generate_and_push/run_alert_check 支持 dry_run

测试:backend/tests/test_caliber_rulings_20260917.py 17 项;全量 1158 passed/1 xfailed
2026-09-18 00:34:04 +08:00
Hermes CI Fix e567d1b94f feat(cma): allow demo-status entity at login for demo account (yanxue-20260916-cma-demo-account)
LOGIN_ENTITY_STATUSES=("active","demo") applied to login / login-entities / my-entities / switch-entity only; batch pipelines stay active-only so the demo entity never enters daily_push or cash alerts.
2026-09-17 00:10:07 +08:00
Hermes CI Fix 293df6df8c feat(cma): cashflow NA annotation + dimension guard + threshold unit migration script
dev-level prep only: no prod write, no push, no pipeline deploy.
2026-09-17 00:01:58 +08:00
Hermes CI Fix 9b3a131070 fix(cma): cashflow statements importer + auto_verify/alert_generator observability
- add backend/scripts/import_cashflow_statements.py: load zxt cashflow stat (CNY) into cashflow_statements in 10k CNY, dry-run default, --apply gated

- auto_verify_cron: per-reason skip counters + legacy-condition rule now resolves kpi_code via plan.kpi_id (was unreachable) + --dry-run

- alert_generator: coverage self-check warns when current-month KPI values are absent (0 alerts != no trigger)

task: finance-20260916-cma-health-gaps-3items (items 1/2/3)
2026-09-16 23:46:48 +08:00
Hermes CI Fix 27d3e1cd73 feat(cma): strategy map lane KR delete entry (cma-map-kr-delete-entry-20260916)
- MapNode.vue: hover trash icon per KR row emitting delete-kr (inline SVG, no emoji)

- StrategyLayer.vue / MapCanvas.vue: passthrough + MyDialog second confirm + okrApi.deleteKr + whole-map save

- MapsDialog: single-KR policy hint (keep at least 1 KR per objective)
2026-09-16 15:26:38 +08:00
Hermes CI Fix 7c73204ade fix(cma): treasury_limits 种子补 status='active'(引擎按 active 过滤→原 NULL 被排除)
ci/woodpecker/push/main Pipeline failed
种子 INSERT 未写 status → 6 行授权档 status=NULL → _limits_strictest 过滤 active 时全排除
→ 多档取最严(T11)取不到 limits 档。补 status='active' + ODKU 同步。自验电池 18/18。

Ref: 0-P0-project-20260915-cma-treasury-stage1
2026-09-16 14:59:02 +08:00
Hermes CI Fix 2ca53b272a fix(cma): AU-001 day_cum_amount 返回日累计(day_total) 而非拆单累计
ci/woodpecker/push/main Pipeline was canceled
day_cum_amount 原返回 cum_same(同收款方拆单累计),与字段语义(日累计)不符;
日累计升级用例 T8 期望 day_cum=当日同提交人累计。改为 day_total。

Ref: 0-P0-project-20260915-cma-treasury-stage1 自验 T8
2026-09-16 14:56:57 +08:00
Hermes CI Fix 3a59b1ccb9 fix(cma): NP-001 无预算走补录流程(不 block)——口径②
ci/woodpecker/push/main Pipeline was canceled
按任总口径②:e1 无匹配预算 → verdict=warn(走指定/补录预算流程,不硬卡死);
仅「有预算且超」才 block。修正后无预算付款可正常进 pending_approval,
不再阻塞 AU-001 拆单累计/日累计(T7/T8)。

Ref: 0-P0-project-20260915-cma-treasury-stage1
2026-09-16 14:54:11 +08:00
Hermes CI Fix 06c23927aa fix(cma): KR 名称污染存量清理 + 透传点护栏 (cma-kr-name-pollution-cleanup-20260916)
ci/woodpecker/push/main Pipeline was canceled
- kr_name.py/krName.ts: sanitize_kr_name 判据(尾部≥/≤数字≠目标值→截断;==目标值→保留)
- maps.py/okr.py/okr_templates.py: 模板名/JSON→krs.title 透传点护栏
- test: 28 passed(独立复核)
- 原会话因迭代上限停手,本提交代收口(工作已复核完成)
2026-09-16 14:48:06 +08:00
Hermes CI Fix 09fb11d86f fix(cma): NP-001 verdict 口径统一(block→blocked)——命中即置 check_result=blocked
_eval_np001 原将 verdict 直取 severity('block'),而 check_result 汇总只认 'blocked'
→ e1 命中不生效、check_result 恒 pass,T4 豁免级联失败。改为 severity=='block'→'blocked'。

Ref: 0-P0-project-20260915-cma-treasury-stage1 自验电池 T3/T3b/T4
2026-09-16 14:44:06 +08:00
Hermes CI Fix f1fa781e08 feat(cma): 司库阶段1 DDL 并入特权迁移步 + cma_api 授权
ci/woodpecker/push/main Pipeline was canceled
- db_migrate.py: create_all 后调用 run_structural() → 复用 treasury_migrate(幂等、特权连接)
- treasury_migrate.py: 换特权连接(CMA_MIGRATE_DB_USER 或本机 root socket,禁 cma_api);up() 增 grant_app_account()(cma_api 对 6 张 treasury 表 DML,双 host)
- 效果:deploy 链由特权账号一次性完成 建表+补列(cash_plans 4列/1索引)+种子(8规则/6限额)+授权

Ref: 0-P0-project-20260915-cma-treasury-stage1
2026-09-16 14:39:54 +08:00
ops-bot 5f6ced1388 fix(ci): deploy key 写入补尾换行 —— echo→printf (修复 libcrypto unsupported/SSH 255,pipeline #9)
ci/woodpecker/push/main Pipeline was successful
2026-09-16 07:41:28 +08:00
Hermes CI Fix 443a779ece fix(cma): DDL 与启动剥离 —— 关闭启动 create_all + 部署链特权迁移步 (cma-create-all-minpriv-rootfix-20260916)
ci/woodpecker/push/main Pipeline failed
根治 2026-09-16 发布中断(新增模型触发启动 create_all,cma_api 无 CREATE → 崩溃循环):

- backend/app/database.py: 新增 CMA_AUTO_CREATE 开关(**默认关**),生产启动不再执行 DDL;
  运行库账号因此无需 CREATE(最小权限可保持)
- backend/scripts/db_migrate.py: 特权迁移步(--check/--apply/--rollback/--status);
  幂等(checkfirst)+ journal 留痕 + 可回滚(dump 失败/表有数据即拒绝,fail-closed);
  显式拒绝用应用运行账号 cma_api 执行
- deploy.sh: 新增 [4b] 结构迁移(**重启前**执行;失败即中止部署,后端旧码继续服务)
- backend/scripts/tests/db-migrate-sim-e2e.sh: 17 项 scratch 复现/验收(负控制复现 CREATE command denied)
- docs/db-migration-runbook.md: 运行手册(含 cma_api CREATE 回收步骤)

验收:scratch E2E 17/17;pytest 1113 passed/1 xfailed;生产 --check 缺失表 0 / 列级差异 0;
--apply 幂等空跑 created_new=0(journal 留痕)
2026-09-16 06:53:28 +08:00
Hermes CI Fix d54ffaf531 fix(cma): T4 residual bugs - predict 500 + reports restateDiffClass (cma-t4-residual-bugs-20260916)
ci/woodpecker/push/main Pipeline failed
B1: /api/cma/dashboard/predict hand-rolled threshold parsing called float() on the whole

expression -> ValueError on multi-clause thresholds like '<=36|>=124' -> 500. Now routes

through app.utils.threshold_parser.eval_threshold (single source of truth; red then yellow,

invalid/blank expr -> False, no crash).

B2: ReportCenter.vue restatement tab template called restateDiffClass while the script

defines restatementDiffClass (name mismatch since ec6af75) -> TypeError on render.

Verified: predict 200 on new code vs 500 on live code; /reports A/B browser run shows 0 vs 3

occurrences of the TypeError; pytest 1113 passed / 1 xfailed; vite build OK.
2026-09-16 02:07:42 +08:00
Hermes CI Fix ae6f71dbc0 feat(cma-report): 建账期初层 + 方向判定可信度 + 权益行科目集(M1/M2/M7,规则表 v1.1)
- M1 时点报表口径:余额 = 期初(report_opening_balances) + 累计发生额;期初只作用于取数层
  (不生成凭证/不写 voucher_details/不改 kpi_values);basis=period 不套期初;受 effective_date 约束
- M2 期初未补 → direction_check_unreliable=true:报表/底稿带"期初未补,方向判定不可信"水印,
  重分类触发只进底稿诊断(diagnostic_only),不落地对外行项目
- M7 权益行科目集 = {3001,3002,3101,3103,3104,3105}(含 4001/4002/4101/4103/4104 别名):补实收资本 3001
- 1901 待处理财产损溢过渡列报「其他流动资产」以保恒等式(M6 归因文案)
- 只读验收脚本 + 幂等导入器(dry-run 默认,--commit 门控)
2026-09-16 01:17:09 +08:00
wecom-project db3c42f766 ci(deploy): [0] 脏检查忽略未跟踪文件(git pull 不丢未跟踪;防并发临时脚本误阻部署)(cma-deploy-untracked-fix-20260916)
ci/woodpecker/push/main Pipeline was successful
2026-09-16 01:06:21 +08:00
Hermes CI Fix ba85e03f2f feat(cma-report): 资产负债表取数层重分类 R1~R12(调表不调账)
ci/woodpecker/push/main Pipeline failed
依据《CMA 资产负债表重分类规则表 v1》(wecom-finance,任总 2026-09-16 批准落地)。

新增 backend/app/services/report_reclass.py:12 条规则挂 CMA 真实科目编码的取数层重分类引擎
- 余额方向规范化:资产 sum(借-贷) / 负债·权益 sum(贷-借),正常余额一律为正
- 触发判定全部基于原始余额(单遍、幂等),R3/R4 等互为镜像的规则互不干扰
- 科目取数前缀匹配(1122% 覆盖 112201 明细);备抵科目 1602 反向取数(修正固定资产净额虚增 2 倍折旧)
- 只读 voucher_details + 作废守卫,不生成记账凭证
- 输出:触发条目 / 不变量 / 负数清单 / 模板待核 / 未列报科目 / 损益未结转 诊断

reports.py:
- /balance-sheet 接入重分类;新增 basis 参数(end=期末余额累计为默认 / period=本期发生额兼容回退)
- 有凭证明细时不再混入示例数据(展示口径与落库口径同源);行项目增 raw_value/reclass_adjustment 可追溯
- 规则落位项目(模板未单列)按虚拟行列报;补「长期待摊费用(1801)」行、未分配利润补 3103/3104 别名
- 新增 /balance-sheet/reclass-working-paper(+ /export) 底稿 JSON/Excel;statutory 导出新增「重分类底稿」Sheet

测试 tests/test_report_reclass.py 14 例(触发/不变量/幂等/多租户/口径/底稿/虚拟行/阻塞规则)
全量 pytest:1108 passed, 1 xfailed。现网只读验收 scripts/verify_reclass_live_20260916.py(酣客 entity1 2026-08)
2026-09-16 00:38:01 +08:00
Hermes CI Fix 7b74736980 refactor(cma-frontend): route all API calls through shared axios instance (cma-frontend-axios-unify-20260915)
api/index.ts: add permissionApi.getModules/getConfig/saveConfig + alignmentApi.config/tree.

views: replace 13 raw fetch calls and 6 local axios.create instances (each hand-building the Authorization header, without the shared 401 redirect / timeout toast) with the shared instance from api/index.ts.

KPIAlignment: fixes a live bug - the header value was the string literal 'Bearer ' + token, so /alignment/tree always returned 401 (reproduced against the production bundle).

Unwrap semantics preserved: the shared response interceptor returns response.data, so call sites that used res.data were adjusted accordingly.

Verified: scan-frontend-tokenless RAW-TOKENLESS=0; vite build ok; headless A/B (new dist vs production bundle, 8 affected pages, real login) -> identical rendered rows and identical pre-existing error profile, /alignment 401->200.

Push not executed: main is ahead 4 with other tickets' gated commits (see cma-main-ahead3-push-gate-notice-20260915).
2026-09-15 22:21:56 +08:00
Hermes CI Fix e5eb092d20 feat(cma): treasury stage-1 MVP - migration, models, endpoints, rules, 2 pages
G1 review fixes P0-1/P0-2/P1-3/P1-4/P1-5/P2-6/P2-7/P2-8 applied:
- up() filters comment lines then asserts missing_tables == [] (CREATE hits 6/6)
- created counter = new-object diff via information_schema (idempotent second run = 0)
- down() drops columns per-column, dumps via write-path creds + fail-closed
- seeds expanded to 8 rules + 6 limits; data_status DEFAULT estimated
- no physical FK (app-layer checks), amount FLOAT wan-yuan, round(x,4)

Scope: 6 treasury_* tables + cash_plans 4 added columns; cash.py untouched.
Not pushed: deploy is approval-gated (de851257).
2026-09-15 20:02:56 +08:00
Hermes CI Fix fdf805ab8f test(cma): clear 10 pre-existing baseline failures (cma-baseline-tests-cleanup-20260915)
No production code changed. Root causes were all test-side staleness:

- test_cash TestVoucherImport: fixture filename contained 'test' -> rejected by the intentional import guard (d71c7fe); renamed, and missing-columns case now asserts the column check is what returns 400

- test_budget_tech_improve TestP1ValueCollect: seeded voucher_details source rows; collector deliberately skips empty aggregates since d71c7fe (no more implicit 0), and preview call now passes an explicit period

- test_roadmap_r2r5 / test_budget / test_ai_suggestions: seeds hardcoded 2026-08 (authoring month) while detectors/rule engine use the current month -> replaced with current-period seeds

pytest tests/ -q -rf: 1094 passed, 1 xfailed, 0 failed (baseline was 10 failed, 1084 passed, 1 xfailed)
2026-09-15 18:36:46 +08:00
Hermes CI Fix 9d9a79545a feat(cma): external input ledgers for M2 capability eval (tax/hr/licence)
3 new tables + idempotent DDL migration (prod guard: refuses DB name cma without approval): tax_filing_log (P_TAX_TIMELY), hr_monthly_ledger (L_EMPLOYEE_TURNOVER), licence_ledger (P_LICENCE_OK).

Entry API /api/cma/ext-ledger: create / CSV batch import / query by entity+period / derive trigger. on_time is server-derived only; HR rows rejected when end != begin + hires - separations.

Deriver writes kpi_values only (source_type=derived, idempotent upsert); denominator 0 returns None and writes nothing; never creates or changes kpi_definitions.

Tests: 22 new pytest cases (22 passed); scratch-DB e2e battery 28 assertions PASS with zero production write.
2026-09-15 18:12:05 +08:00
Hermes CI Fix 5d0884cc3b fix(kpi): single source of truth for REVERSE_INDICATORS + drop F_FACTORY_REBATE_RATE from reverse set
P-6 (M2 gate 2, FIN-CE-REVIEW-20260915-01): kpis.REVERSE_INDICATORS is now the only authoritative direction definition; dashboard.py imports it instead of carrying a literal copy; bot_kpis.py derives REVERSE_BOT_INDICATORS from it (dead import removed).

F_FACTORY_REBATE_RATE (upstream factory rebate rate, higher=better) was wrongly treated as reverse: 278.7% vs target 80 scored 1/danger, now 5/success and workbench three-color red -> green. No DB write, no scoring-engine logic change, no data_level/frontend touch.
2026-09-15 17:48:56 +08:00
opsBot 6a48477d71 fix(deploy): 就绪轮询上限改墙钟判定(次数上限在挂死目标下会超时 5 倍)
ci/woodpecker/push/main Pipeline was canceled
- 起因: 项目Bot 复验 t2 时 caseD(挂死桩/上限10s) exit=1 elapsed=50s
- 根因: 目标接受TCP但不响应 -> 单次探测耗满 --max-time 2s, 次数上限(x2) 使 10s 上限实跑 50s, 默认 30s 最坏 150s
- 修正: while 条件改墙钟 [ $(date +%s) - READY_T0 -lt SMOKE_READY_MAX ], deploy.sh 与 .woodpecker/main.yml 同步
- 复验: A(慢起6s,cap30)=exit0/6s; B(空端口,cap3)=exit1/3s; C(200+degraded,cap3)=exit1/3s; D(挂死,cap10)=exit1/10s(修正前50s)
- task: t2-cma-deploy-readiness-poll-20260915 (follow-up)
2026-09-15 17:11:35 +08:00
opsBot b7950249a7 fix(deploy): 冒烟就绪轮询判据 HTTP200+status=ok,替代固定 sleep(消除冷启动假告警)
- deploy.sh: 轮询块(0.5s 间隔 / 默认 30s 上限),超时明确报错 + exit 非 0;
  SMOKE_HEALTH_URL/SMOKE_READY_MAX 可覆盖供离线演练;带 >>> cma-readiness-poll 标记便于抽取复用
- .woodpecker/main.yml backend-deploy: 同样去掉固定 sleep 3,改就绪轮询
- 验收: 离线演练 A(冷启动6s成功)/B(空端口超时非0)/C(200+degraded不误判)
- task: t2-cma-deploy-readiness-poll-20260915
2026-09-15 17:04:22 +08:00
Hermes CI Fix b796897f8c fix(auth): unify site-wide 401/403 semantics (cma-auth-401-403-unify-20260915)
require_auth now uses HTTPBearer(auto_error=False) and raises 401 explicitly
when the Authorization header is missing or non-Bearer, matching
require_auth_401. Unauthenticated requests are 401 for every user endpoint;
authenticated-but-forbidden (cross-entity / role) stays 403.

- app/auth_middleware.py: single optional bearer, 401 on missing token
  (require_auth_401 kept as a compatibility alias)
- tests: update 4 pre-existing cases that asserted 403 without a token
  (test_auth, test_reports, test_kpi_causality, test_expenses,
  test_api_auth_surface_20260914) - intentional behavior change
- add tests/test_auth_401_unification_20260915.py: sample + full sweep
  (all AUTHED GET routes -> 401), invalid token -> 401, cross-entity and
  role-denied still 403, bot channel (X-BOT-KEY) and public paths unchanged
2026-09-15 17:00:52 +08:00
wecom-project f1d61f337a ci(deploy): 鉴权面审计接入部署闸门 — B类缺口存在即中止部署 (t1-cma-audit-gate-deployhook-20260915) 2026-09-15 16:52:15 +08:00
Hermes CI Fix 1f7be6b5ae fix(cash): select cash-balance sync targets by explicit kpi_code whitelist
The voucher-import linkage branch picked write targets by kpi_name LIKE '%cash%' and wrote the monetary-fund ledger balance (yuan) into derived KPIs (F_OP_CFLOW / F_FCF / F_BEP_REVENUE_CASH / F_BEP_COVER_CASH). Replace with an explicit kpi_code whitelist plus a unit/caliber gate that refuses writes on mismatch and reports them via kpi_sync_skipped.

Read-only equivalence probe (real function + intercepted writes + rollback) before/after: before wrote 4 derived KPIs, after writes only F_CASH_SAFETY. tests: +9 new cases; pytest failure set identical to baseline (15/15 same ids).
2026-09-14 20:37:12 +08:00
Hermes CI Fix 8693c8e342 feat(kpi): route all voucher_details consumers through single void-mark guard
Single source: app/utils/voucher_guard.py (NOT EXISTS on voucher_void_marks,

key = entity_id+voucher_no+voucher_date and revoked_at IS NULL; same caliber as

the DB view cma.v_voucher_details_active). No hardcoded voucher numbers.

Consumers now guarded: cash.py import linkage (2 fns), reports.py subject/BS

amount (2 sites, also entity_id isolation added), dashboard.py _bep_sum_field,

alert_attribution.py build_subject_attribution. kpi_value_collector.py now

delegates to the shared guard (removes the previous duplicate implementation).

Tests: new tests/test_void_marker_consumers_20260914.py (6 cases).
2026-09-14 19:05:16 +08:00
Hermes CI Fix a8a82ff837 feat(kpi): 归集侧接作废/撤单标记过滤(voucher_void_marks)
- kpi_value_collector: 所有消费 voucher_details 的归集路径统一排除已作废凭证
  (NOT EXISTS voucher_void_marks, entity_id+voucher_no+voucher_date+revoked_at IS NULL),
  与 DB 视图 v_voucher_details_active 定义一致;标记表不存在时自动跳过(兼容旧库)
- 禁硬编码:凭证号不出现在代码/配置,撤单只需写标记表
- verify_cashflow_direct_caliber: 2026-08 从已知差异放行改为硬断言(8 期逐期一致)
- tests/test_void_marker_filter_20260914.py: 标记表在/不在 两分支 3 例
2026-09-14 18:44:39 +08:00
Hermes CI Fix 4d918f5786 docs(kpi): mark R7 gate thresholds as president-confirmed formal rule
Comment-only change: AMOUNT_MIN_YUAN=1000.0 / AMOUNT_PCT=0.005 / RATIO_PP=0.005 and RULE_VERSION unchanged. No logic or value change.
2026-09-14 18:21:33 +08:00
Hermes CI Fix 8fa6c7f520 feat(kpi): auto_collect review gate (finance ruling 2026-09-14)
R2 rule-gate auto-promotion + R3 exception interception in the same collect transaction; merge duplicate rows, keep single verified main per (kpi_id, period); reuse kpi_bep_deriver._is_period_closed; no schema change (data_status/remark only).

Task: finance-20260914-autocollect-reviewflow-impl
2026-09-14 18:12:38 +08:00
Hermes CI Fix 45832cbf45 feat(kpi): collect cashflow-direct caliber for F_OP_CFLOW/F_FCF
- kpi_value_collector: new calc=cashflow_direct branch (line-level counterpart

  classification: cash vouchers -> operating/financing/investing buckets),

  no hardcoded voucher numbers; optional minus_capex for FCF

- verify_cashflow_direct_caliber.py: 8-period reconcile + identity + negative

  control (voucher-level classification would overstate 2026-06 by 18,700)

task: yanxue-budget-cashflow-kpi-activate-20260914
2026-09-14 18:01:57 +08:00
Hermes CI Fix ec58efa20b fix(budget): 偏差检查同 KPI 多计划去重(deviation-check 补 db.flush)
autoflush=False 下,check_budget_deviation 循环内 existing_alert 去重查询
查不到本次刚 add 未 flush 的行 → 同期间同 KPI 多 active 预算计划会重复告警。
add 后补 db.flush() 使其对下一次循环可见。附回归测试。

Ref: yanxue-20260914-budget-p13p14-activation 遗留②试跑发现的缺陷
2026-09-14 17:56:26 +08:00
Hermes CI Fix 40d522cd34 fix(security): retire default-credential surface (yanxue-20260914-cma-defaultcred-code-fix)
- Login.vue: no prefilled default creds; no anonymous entity enumeration on mount

- deploy.sh: readiness polling (0.5s/30s cap) replaces fixed sleep 3; login smoke reads same-source 600 secret file, missing file = explicit abort

- 19 verify/check/gen_report scripts: shared _admin_creds() loader (CMA_ADMIN_ENV -> ops .secrets -> profile copy); no hardcoded creds

- tests: single TEST_USER_PASSWORD fixture constant; docs: pointer to 600 secret file; removed 4 legacy .bak + pycache residue
2026-09-14 17:39:53 +08:00
Hermes CI Fix 8adc783501 fix(budget): attribution backfill computes in-process and previews on dry-run (yanxue-20260914-budget-p13p14-activation) 2026-09-14 17:25:49 +08:00
hermes a6d5d151c4 feat(budget): activate P1 auto-collect + attribution subject mapping (yanxue-20260914-budget-p13p14-activation) 2026-09-14 17:19:49 +08:00
fullstack-bot b815b7f623 fix(security): close unauthenticated CMA user API surface + audit tool (cma-api-auth-surface-audit-20260914)
Root cause: main.py include_router had no global auth dependency; user routers
(products/cost/predict/...) relied on assert_entity_scope, which treats a
missing session token as the Bot service channel and lets the request through.
Result: 46 user endpoints returned data without any token on a public site.

Changes:
- auth_middleware.py: add require_auth_401 (missing Bearer -> 401, delegates to
  require_auth otherwise); 401/403 semantics are explicit.
- router-level Depends(require_auth_401) on products, cost, predict,
  notifications, permissions, tenant, alignment; endpoint-level on
  /auth/roles and /admin/cache/clear.
- deps.py: document that get_entity_id/assert_entity_scope are NOT auth.
- frontend: add the missing Bearer header to 5 tokenless fetch/axios calls that
  hit the newly protected endpoints (KPIAlignment x2, RolePermissions x2,
  Dashboard x1).
- tests: new test_api_auth_surface_20260914.py (58 cases: no-token 401,
  token 200, cross-entity 403, bot channel unchanged); adapt test_predict /
  test_kpi_forecast to send session tokens like production.
- scripts/audit_api_auth_surface.py: reusable FastAPI dependency-graph audit
  (423 endpoints; A bot / B gap / C public), exits 1 when a B-class gap exists.
2026-09-14 16:41:15 +08:00
Hermes CI Fix aac1bd1df1 fix(security): retire check-governance + scope /reports/dupont to session entity
- data_quality.py: remove GET /check-governance (no caller repo-wide; its
  entity_id param had no session auth -> cross-tenant read; duplicate of
  governance-check) -> the path now returns 404
- reports.py: /reports/dupont resolves the analysis target from the session
  account (assert_entity_slug_scope / resolve_session_slug); explicit
  cross-entity entity param -> 403, illegal value -> 400; the 2026-08-31
  cross-entity exemption is revoked (docstring updated)
- frontend: drop the checkGovernance api definition; DupontAnalysis.vue
  entity radio group -> read-only session entity tag, request without param
- tests: TestCheckGovernanceRetired (404 x2), TestDupontSessionScope (3),
  TestDupont reworked to account-bound tokens (8 tests)
- docs/api-verification-split.md: drop the retired row, note 334/404 -> 333/403

pytest: 10 failed / 957 passed / 1 xfailed vs HEAD baseline 10 failed /
951 passed / 1 xfailed (identical failure set -> zero regression)

(cma-residual-cross-entity-cleanup-20260914)
2026-09-14 12:09:04 +08:00
Hermes CI Fix 72c1bbce16 chore(ui): drop remaining emoji in ReportCenter header block
Same page header as the P3-1 item; keeps the header free of emoji.

(cma-entity-selectors-converge-6pages-20260914)
2026-09-14 11:38:17 +08:00
Hermes CI Fix e6dd8f61f0 feat(ui): converge 6 page-level entity selectors to read-only session tag
- DataQuality / ProductMatrix / AlertList / ActionPlanLibrary /
  views.GrowthQuality / PredictAccuracy: drop entity el-select
  (including the "all entities" option), show read-only session
  entity tag (not clickable), same style as the top bar tag
- drop page-level entity param from those requests; backend resolves
  entity from the session token (assert_entity_scope / get_entity_id)
- utils/entity.ts: add sessionEntityName() (cma_entity_name else id map)
- P3: ReportCenter header emoji removed; components/GrowthQuality
  detail drill-down el-dialog -> MyDialog

(cma-entity-selectors-converge-6pages-20260914)
2026-09-14 11:32:52 +08:00
Hermes CI Fix 115a0ec755 fix(security): 4端点补跨租户鉴权 — 实体1会话读实体2数据(200)→403 (cma-cross-entity-endpoints-auth-20260914)
背景(项目Bot 补充复验 §四#2):以下 4 个只读端点未做会话账套鉴权,
用 entity=1(酣客) 会话 token + 显式 entity 参数可读到 entity=2(博海) 数据(HTTP 200):
  - GET /data-quality/governance-check?entity_id=2   → 200 score=88(对照 entity1=72)
  - GET /products/matrix?entity_id=2                 → 200
  - GET /alerts/risk-matrix?entity=bohai             → 200 博海风险矩阵
  - GET /action-plans/coso-checklist?entity=bohai    → 200 博海 COSO total_score=55
另:risk-matrix / coso-checklist 在非法实体值下静默回落酣客数据,UI 标签与数据不一致。

改动:
1. deps.py 新增统一防护入口 assert_entity_scope / assert_entity_slug_scope /
   get_session_entity / resolve_session_slug(文案对齐 get_entity_id:无权访问企业 entity_id=X(当前账套: Y))
2. governance-check / products/matrix:entity_id 须等于会话账套,否则 403;
   缺省按会话账套(governance-check 原缺省 0=全部 → 登录态下按账套收敛)
3. risk-matrix / coso-checklist:entity 须等于会话账套,否则 403;
   非法值 → 400 显式报错(不再静默回落酣客)
4. P3:POST /auth/switch-entity 加 DEPRECATED 注释(前端已无调用,保留不删)
5. 测试:tests/test_security_multitenant.py 新增 TestCrossEntityEndpointAuth 15 例
   (跨实体 403 / 同实体 200 / 缺省按账套 / 非法显式报错 / 参照端点行为不变)

验证:pytest tests/ -q → 951 passed(10 项失败为改动前既有基线,已 stash 对照确认);
      真实后端冒烟 19/19 PASS(entity=1 会话 4 端点跨实体全 403)。
2026-09-14 11:19:05 +08:00
Hermes CI Fix 1b73dbb272 feat(ui): remove in-app entity switcher, entity fixed at login
- MainLayout/ReportCenter: drop switch-entity switcher, keep read-only entity tag
- KPIList/PredictDashboard/GrowthQuality: entity selectors locked to session entity
- api/index.ts: drop switchEntity/myEntities wrappers (endpoint kept for compat)
- sidebar brand now shows logged-in entity name

(cma-remove-inapp-entity-switcher-20260914)
2026-09-14 10:15:23 +08:00
wecom-fullstack 53c642b644 chore(docs): cash-plan-alerts 手册回填 F4b commit 哈希 (dama-cashplan-zombie-alert-cleanup-20260913) 2026-09-13 07:39:06 +08:00
wecom-fullstack e159db058a fix(alerts): F4b 展示口径与落库口径同源 — summary/suggestions/upcoming_7d 按快照批次 mode 过滤 + raw/excluded/batches 可见 (dama-cashplan-zombie-alert-cleanup-20260913) 2026-09-13 07:38:58 +08:00
Hermes CI Fix 75309ffcaa chore(docs): cash-plan-alerts 手册回填 commit 哈希 (dama-cashplan-alert-engine-fix-20260913) 2026-09-13 00:39:22 +08:00
Hermes CI Fix 59e65a68b9 fix(alerts): cash_plan 到期未收款预警引擎修复 — entity归属/KPI实体感知/余额快照批次/去重刷新/端点鉴权 (dama-cashplan-alert-engine-fix-20260913)
F1 两处 KPIAlert 显式写 entity_id(列默认 1 → 博海预警错挂酣客);顺带修 deviation_engine/
   alert_generator/deviation_push/bot_bridge/bot_bridge_v2 同表创建点
F2 KPI 选择实体感知:候选码 ∪ 实体前缀后缀码(_AR_BALANCE/_CASH_BALANCE/_AP_BALANCE)
   + 兜底 ORDER BY id(此前命中 uk_entity_kpi 必中 BH_ADM_EXPENSE 437 管理费用)
F3 去重命中改为刷新 alert_message/等级/suggestion.days_late/归属(此前 continue → 文本冻结首版)
F4 余额快照批次(bohai_*_20260630 / receivables_migrate)开关:默认 skip 不逐户,另备
   aggregate(按批次聚合1条) / per_customer(旧行为)
F5 POST /api/cma/admin/alerts/check 补鉴权(无 token 401)+ 可选 entity_id 只跑指定账套
2026-09-13 00:37:10 +08:00
Hermes CI Fix a145ab1da1 fix(ui): 首屏加载超时兜底 — 页面10s未挂载则提示重试,消除整页刷新偶发卡死(BUG-001) (cma-bug001-boot-timeout-20260912) 2026-09-12 23:35:27 +08:00
Hermes CI Fix b8f4a3d543 chore(security): 凭据治理收口 — 清除硬编码DB账号/口令,脚本与API统一从 backend/.env 读取(无明文兜底);取消跟踪 .pyc 构建产物 (credential-governance-20260912) 2026-09-12 23:34:35 +08:00
Hermes CI Fix 4f7058f1ea chore(verify): BEP 阈值验收脚本改动态断言(按数据归属实体登录)+历史快照归档加运行守卫 (concurrent-duplicate-session-and-stale-verify-script-20260912) 2026-09-12 20:35:20 +08:00
Hermes CI Fix ce2f6bfe33 fix(bep): pick target period by period-closed signal, sales threshold as fallback only
Defect: _pick_target_period treated "month sales < 30% of 6-month avg" as
"period not closed", so 2026-08 (actually closed: 3103 carry-forward vouchers
present) was skipped because of a low-sales off-season month, pinning the five
F_BEP* KPIs at 2026-07.

Fix: add _is_period_closed() - primary signal voucher_details
(carry_forward=1 AND subject_code='3103'), secondary signal
ZXT_LANDING_DIR/{YYYY.MM}/check.md containing both "issue PASS" and
"P&L carry-forward PASS". _pick_target_period now scans candidate months desc
and returns the first closed one, with no sales filtering; the old sales
threshold path is kept only as a fallback (warns "sales-threshold fallback").

Scope: run_derive() formulas, collector, kpi_definitions and cron untouched.

Verified: period=2026-08; 167.5 / 60.4 / 39.6 / 118.7 / 85.2;
F_BEP_COVER 2026-08 red pending, 2026-07 pending resolved; idempotent on re-run.
2026-09-12 20:08:31 +08:00
Hermes CI Fix 46fc1a4ecc fix(bot-bridge): subjects category 白名单补齐库内实况 revenue/profit_loss — 消除库内合法值被400拒 (dama-cma-subjects-category-whitelist-20260912) 2026-09-12 11:09:16 +08:00
Hermes CI Fix ca2832b698 fix(bot-bridge): subjects 写入端点编码长度收敛到 DB 列宽20 — 21~32字符编码原穿过校验触发 MySQL 1406 整批500, 改为400拒绝 (dama-cma-subjects-write-endpoint-20260912 复核) 2026-09-12 10:46:57 +08:00
Hermes CI Fix 840d98765d feat(bot-bridge): 新增会计科目写入端点 POST /api/cma/bot/subjects — 行级全量校验+按subject_code幂等UPSERT(dry_run/update_existing开关)+L3留痕, 绝不DELETE (dama-cma-subjects-write-endpoint-20260912) 2026-09-12 10:42:46 +08:00
Hermes CI Fix af541e9043 feat(bot-bridge): 新增智享通科目字典写入端点 POST /api/cma/bot/dict/zxt-account — 幂等UPSERT(JSON/CSV)+L3分级+audit留痕, 解除 ops 管道 API 迁移阻塞 (ops-cma-dict-write-endpoint-20260912) 2026-09-12 10:07:20 +08:00
Hermes CI Fix 998da4ebd4 fix(okr): 季度复盘提醒审计口径与失败感知加固 — dry_run/sent/failed/skipped 状态语义 + 失败本地留痕+中继告警+退出码契约(0/1/2) (fullstack-okr-reminder-hardening-20260911) 2026-09-12 01:03:31 +08:00
Hermes CI Fix 38968750a3 feat(okr): OKR模板库 tags 多值标签 — 表结构+回填36模板+API过滤 (fullstack-okr-tags-20260911)
- okr_templates 新增 tags JSON 字段(function/cycle/goal_type 固定枚举)
- 回填脚本幂等:36/36 模板 tags 补齐,二次运行跳过
- API: GET /okr-templates 支持 function/cycle/goal_type/tag 过滤,新增 GET /tags/schema
- API: POST 创建 + 新增 PUT 更新接受 tags(非法值 400)
- dimension 四泳道主分类路径不变(回归 11/8/9/8 一致)
2026-09-11 19:33:52 +08:00
Hermes CI Fix e2d2a1c165 feat(okr): 季度复盘提醒机制 — objectives active 清单经企微中继推送 + notification_logs 留痕 (fullstack-okr-kpi-gap-fix) 2026-09-11 19:20:42 +08:00
Hermes CI Fix c928d59f60 fix(maps): KR 目标值逐键输入污染名称根治 — 移除 formatTargetValue 回写名称 + 只读预览拼接 (project-20260911-kr-name-pollution) 2026-09-11 19:15:31 +08:00
Hermes CI Fix e22093256b fix(kpi): suggest 保留历史默认口径 middle_best — 修复未知编码被误判为 higher_better(C_REBATE_RATE/BH_SALES_10 建议退化) (project-20260911-threshold-fix) 2026-09-11 17:27:12 +08:00
Hermes CI Fix 4c6fb85cf5 fix(kpi): 阈值红黄绿读写/填写规则修复 — 统一解析器+红灯优先+建议可解析+前端校验+存量修正 (project-20260911-threshold-fix)
① 新建 app/utils/threshold_parser.py 统一解析/判定(单一事实源):
   - 支持负数(>=-100000)、闭区间 A~B、多条件 C1|C2;空串=未设置
   - 判定顺序 red→green→yellow:红灯优先(消除宽黄灯遮蔽红灯),
     green 先于 yellow(green 是主条件,yellow 是更宽兜底带)
② kpis.py / alert_generator.py 双实现收敛到统一解析器(消除重复维护)
③ thresholds.py suggest 输出可解析表达式(原黄色/适中型生成 `52.33~107.67`、`偏离55%` 等
   区间/文字 → 用户点"自动建议"填入后静默失效;另修复大数目标生成 1.6e+06 科学计数法)
④ KPIDetail.vue 三个阈值输入加 placeholder/tooltip/帮助文案 + 保存前格式校验(非法即拒)
⑤ 存量 4 个不可解析表达式修正(id=46 C_REBATE_RATE / id=327 BH_SALES_10),
   改前 mysqldump 备份 backend/scripts/backups/kpi_definitions.bak-20260911.sql +
   threshold_rows_before_20260911.json(可回滚);不可解析 5→0
⑥ 单测 97 条 + 红黄绿判定回归 367 判定点(旧→新差异 14 条,含差异说明)
2026-09-11 17:25:24 +08:00
Hermes CI Fix f61b6ce604 fix(cash): 智享通凭证行级幂等键剔除派生period + period_conflict留痕 + 批内行序防吞行 (dama-zxt-idemkey-period-20260911)
缺陷: 行级幂等键含 period(日期派生属性),summary口径切主表VoucherDate后判重失效→重复入库(实测V17756 6行)。修: 幂等键=(entity_id,voucher_no,subject_code,debit,credit,summary);命中且period不同→period_conflict计数仅留痕不覆盖;同键多行按载荷内出现次序编号防跨批次吞行;返回体/日志补计数。回归测试10项 + 真实快照41830行复推实证(测试库)。
2026-09-11 17:13:07 +08:00
Hermes CI Fix 286238811b fix(cash): 智享通凭证导入放开负数金额(红字记账)+日期来源三级优先(行内voucher_date优先) — 修复423张凭证整张拒收 (dama-zxt-import-endpoint-fix-20260908) 2026-09-11 16:24:41 +08:00
Hermes CI Fix 9822fbd9ba feat(alerts): 新增客户集中度预警规则 customer_concentration + 预测偏差规则启用/联动每日预警生成
- alert_rules: 新增 rule_type=customer_concentration(单一/前N客户收入占比超阈值触发,黄=超阈值/红=阈值x倍数)
  数据源 zxt_masterbill 客户(往来单位)收入聚合,unit_id<=0 占位剔除;无客户数据可回退 KPI 口径
  复用现有 KPIAlert 生成链路:check-all 内联分支 + 专用端点 POST /run-customer-concentration
- alert_generator(每日9:00): 新增规则引擎步骤,复用上述检查函数跑 forecast_deviation/customer_concentration
- AlertList.vue: 规则类型筛选/编辑项 + 参数表单 + 客户集中度检查按钮
- 测试: tests/test_customer_concentration_rule.py 17 条(规则注册/聚合/双数据源/黄红灯/去重/check-all)
- 验证脚本: scripts/verify_forecast_deviation_rules_e2e.py / verify_customer_concentration_rule_e2e.py
2026-09-10 09:56:50 +08:00
Hermes CI Fix f8688713db feat(data): 清理遗留ERP数据源配置标注收尾 — 移除DataManage遗留标签/告警, 空态引导真实zxt通道 (project-20260908-data-source-cleanup) 2026-09-08 21:10:32 +08:00
Hermes CI Fix aa81cee009 fix(maps): MapCanvas onMounted 忽略路由 :id 恒取第一张地图 — 修复 /maps/canvas/53 打开显示54空图、新增节点落错地图 2026-09-08 20:35:51 +08:00
Hermes CI Fix 265714341c fix(maps): _auto_snapshot 补 entity_id 继承地图企业 — 修复 entity2 地图发布后版本行落 entity1 致版本列表为空 (project-20260908-map-versions-page) 2026-09-08 20:24:44 +08:00
Hermes CI Fix 534f6328ed chore: components.d.ts 注册表同步 (MapVersions) 2026-09-08 20:12:17 +08:00
Hermes CI Fix a223211782 chore: components.d.ts 自动注册补提交 (MapVersions) 2026-09-08 20:12:03 +08:00
Hermes CI Fix 397c365263 feat(maps): 战略地图版本管理完整实现(方案B)— versions API列表/详情/回滚带自动备份/备注 + MapVersions独立版本页 + 路由注册 + 版本弹窗接真实数据 (project-20260908-map-versions-page) 2026-09-08 20:11:59 +08:00
Hermes CI Fix fcc511d3b9 fix(budget): 版本差异对比弹窗字段对齐后端契约(version_a/version_b/diff) + diff_rate前端计算 + 移除无意义period列 (project-20260908-budget-diff-empty) 2026-09-08 16:31:49 +08:00
Hermes CI Fix 54e39b5493 chore(kpi): BEP阈值+现金口径修复验收脚本 verify_bep_threshold_fix.py (fin-fix-bep-threshold-20260908) 2026-09-08 15:59:41 +08:00
Hermes CI Fix 5bcc65b47f fix(kpi): BEP保本KPI阈值按>=85口径修正 + 新增现金口径派生KPI(剔除折旧560213) + bep-card现金读派生单一事实源 (fin-fix-bep-threshold-20260908) 2026-09-08 15:57:47 +08:00
Hermes CI Fix cf81ee0ccd fix(dashboard): BEP卡片现金口径公式文案修正(按口径切换显示完整推导) 2026-09-08 14:02:33 +08:00
Hermes CI Fix ef0f542292 chore: 组件自动注册 components.d.ts 补提交 (BEPBreakEvenCard, 阻塞CI纪律检查) 2026-09-08 13:58:11 +08:00
Hermes CI Fix 9eb38cfa89 feat(dashboard): 经营看板「本量利·保本点」卡片 + 只读BEP概览端点 (cma-bep-kpi Phase2)
- 新增 GET /api/cma/dashboard/bep-card:账面/现金双口径只读数据
  (5001月销113.6/5602管理费26.1/折旧560213 7.51/渠补率84.61%)
  账面: 保本169.9万 覆盖率66.9%(黄) 缺口33.1%; 现金: 保本121.0万 覆盖率93.9%
- 新增 components/BEPBreakEvenCard.vue:主指标覆盖率红黄绿圆点+副指标
  并排+下钻面板(公式/输入KPI/双口径切换/解读),A区只读
- Dashboard.vue CEO/财务视图顶部引入卡片
2026-09-08 13:58:01 +08:00
Hermes CI Fix 1a5356a2d1 feat(kpi): CMA保本点派生KPI F_BEP_REVENUE/COVER/GAP + 每日派生执行器 + 红黄绿静态阈值判定
- kpi_definitions entity1 新增3条派生KPI(456/457/458, formula/threshold/unit齐全)
- 新增 scripts/kpi_bep_deriver.py: 从 voucher_details 按财务口径聚合
  (5001销售收入/5602管理费 YTD月均, 渠补率政策84.61%) → 计算保本销售额/
  覆盖率/缺口 → upsert kpi_values source_type='derived' 幂等 + 同步yellow/red预警
- cron 06:35 注册(紧随 collector 06:30); 自动选最近完整月(8月未结账自动回退7月)
- kpis.py get_kpi: F_BEP_* 派生KPI按静态阈值文本判红黄绿(66.9→黄), 不走target比率
- 阈值经财务Bot 2026-09-08裁决: COVER >=100绿 50~100黄 <50红; GAP <=0绿 <=50黄 >50红
2026-09-08 13:43:20 +08:00
Hermes CI Fix ebe0962c09 feat(frontend): Phase C 固定企业模式 - VITE_FIXED_ENTITY_ID 支持登录无下拉/隐藏切换器
- 新增 src/utils/entity.ts: isFixedEntity/getFixedEntityId/effectiveEntityId
- Login.vue: 固定企业版隐藏企业下拉显示只读徽标, 登录直接带固定 entity_id
- MainLayout.vue: 切换器加 !isFixedEntity() 守卫, 固定版顶部显示企业 tag, logo 带企业名
- ReportCenter.vue: 页内企业切换下拉加守卫, getEntityId 固定版恒取构建锁定值
- .env.example: VITE_FIXED_ENTITY_ID 示例与说明
- 验证脚本 backend/scripts/verify_phase_c_fixed_entity_e2e.py (22项, 双build E2E全过)
2026-09-08 11:27:01 +08:00
Hermes CI Fix 193061cf4d fix(data): 数据管理模块脱节修复 — Excel导入/sources端点 entity 隔离 + ERP直连通道诚实化(410) + 真实zxt链路展示
- import_excel/import_excel_smart KPI字典按 entity_id 过滤(防跨账套匹配串写,P0安全)
- import_excel_smart 自动建KPI编码防冲突(_next_code 循环取号)
- sources list/create/update/delete 全部按 entity_id 隔离(create 写入当前账套)
- sync-kpis + admin/erp-sync 下线返回410(erp-gateway DB凭据2026-08-31已删,真实通道=zxt智享通注入)
- 新增 GET /data/channel-status:展示真实zxt取数通道状态+遗留ERP数据源清单(诚实化)
- DataManage.vue:数据源tab顶部展示真实通道(最近注入period/时间/行数/落盘目录mtime)、ERP遗留源标注'非当前通道'、实际取数映射(kpi_value_sources)表格
- 新增 test_data_entity_isolation_20260908.py(8条:双账套导入隔离/CRUD隔离/sync-kpis 410/channel-status)
2026-09-08 11:19:38 +08:00
Hermes CI Fix bdcfa2500e docs(manuals): 提交手册模板 _template.md(6段结构,随首批手册入库) 2026-09-07 16:08:55 +08:00
Hermes CI Fix b4071fd1c5 docs(manuals): CMA首批3模块操作手册(budget-version/multitenant-isolation/mydashboard 修复后状态) 2026-09-07 16:08:44 +08:00
Hermes CI Fix 616f444564 fix(verify): 预算版本UI浏览器验证脚本加 tab 导航等待 (about:blank竞态,复跑必挂修复) 2026-09-07 11:34:56 +08:00
Hermes CI Fix f8018aa471 chore(verify): 预算版本管理UI修复验证脚本(API+浏览器双录,可复跑) 2026-09-07 11:04:35 +08:00
Hermes CI Fix b01a108874 fix(budget): 版本管理UI字段/状态机对齐 active→draft映射+plan_count/last_updated别名
- list_budget_versions 输出 approval_status(active映射draft)+plan_count+last_updated
- 保留 status/kpi_count/updated_at 原字段兼容(下拉'当前'标记/测试契约)
- 前端提交审批按钮兜底 active(双端保险)
- 修复: 版本行仅复选框无按钮/状态恒草稿/条目数空白/最后更新空白
2026-09-07 11:01:56 +08:00
Hermes CI Fix 1fa08364ad chore: ignore cma.db/cma_import.db 根目录SQLite产物 (防 CI deploy hook 被未跟踪文件误挡) 2026-09-07 10:23:23 +08:00
Hermes CI Fix ac645a56ee fix(security): ai_analysis多租户隔离缺口 + dashboard预警计数 + bot_kpis归属 + cost账套过滤 + kpis create白名单 (OpenCode审查20260907)
- ai_analysis: generate_rule_suggestions pending预警按entity过滤; kpi_analysis归属校验404;
  dashboard-analysis-stream/ask/review-plans 补 get_entity_id + KPI/ActionPlan/KPIAlert 全按账套过滤;
  stream cache key 含 entity_id (原跨企业串缓存)
- dashboard: summary alert_count 补 KPIAlert.entity_id 过滤 (kpi_total 已有)
- bot_kpis: update_bot_kpi_value 归属校验 404 (原任意登录可改他企 bot KPI)
- cost: ActualCost/StandardCost 确认多企业共用 → cost_overview/variance/breakdown/dashboard
  补 entity 过滤(engine 函数加 entity_id 透传); dashboard total_cost 既有 KeyError 修复
- kpis: create_kpi 字段白名单 (仿 UPDATE_KPI_WHITELIST+kpi_code), 拦截 status/important_flag 注入
- tests: 新增 test_multitenant_ai_20260907.py 11条越权用例 (跨entity 404/空返回断言)
- 全量 pytest: 745 passed / 10 failed(与基线 HEAD 一致, 存量问题非本次引入) / 1 xfailed
2026-09-07 10:22:24 +08:00
Hermes CI Fix d71c7fef67 fix(data): 自动归集空汇总不写0 + Excel导入示例文件防线 (DAMA审计20260906代码类) 2026-09-06 16:57:03 +08:00
Hermes CI Fix 594bb00a38 fix(cash): vouchers-json batch改为纯内容指纹(不含时间戳) — 快照每10分钟重推必须幂等命中
- batch=zxt-{sha1前10},同内容任意时刻重推 → 同一batch → already_imported跳过
- 原带秒级时间戳导致指纹去重失效(靠行级防重兜底,但会产生噪音import_logs)
- 验收脚本补用例4b(三次重发batch不变+already_imported), 13/13 PASS
2026-09-06 16:47:25 +08:00
Hermes CI Fix 3db805db4e feat(cash): 智享通凭证JSON直传导入端点 vouchers-json (zxt-cma-import-v1)
- 新端点 POST /api/cma/cash/import/vouchers-json (X-BOT-KEY鉴权, 独立router避开require_role)
- 兼容三种body: 裸rows数组 / {rows} / {datasets[0].rows} 整包
- 凭证级借贷平衡(替代批级, 已知单边30694记异常拒收), 结转行summary识别
- 日期从summary前缀解析, 无日期行pending拒收(不冒充文件时间)
- 科目解析: 行内subject → DB zxt_account_dict → body subject_map, 未命中拒收
- 幂等: 内容指纹batch + import_logs去重 + 行级业务键防重复
- entity默认1(陕西酣客), active校验; 不触发现金流联动(凭证未证实权威)
- 验收脚本 scripts/verify_zxt_vouchers_json.py 12/12 PASS (SQLite本地)
2026-09-06 16:45:03 +08:00
Hermes CI Fix f884fa5a18 fix(scripts): E2E验证脚本 actual_costs 无remark列, 存量internal计数改前缀排除 (cma-rd-dimension) 2026-09-05 09:11:27 +08:00
Hermes CI Fix 696f8bc934 feat(cost): 成本模型加研发归集维度 research_flag/project_type (cma-rd-dimension-20260905)
- StandardCost/ActualCost 增加 research_flag(int,idx)/project_type(varchar30,idx)
  project_type: platform_dev平台研发/client_delivery客户交付/internal内部管理
- 生产库两表 ALTER 完成, 存量 29+134 行默认 internal(保守防误归研发)
- API: create/update 支持写入维度, 未传默认 client_delivery(录入默认),
  research_flag 未显式传入按 project_type 联动(platform_dev=1);
  create/update/delete 绑定 entity_id(token优先, 防跨账套)
- API: standard/actual 列表支持 project_type/research_flag 过滤
- 前端 CostDashboard: 表单加归集类型下拉(默认客户交付+研发提示),
  列表加归集类型列(标签), 工具栏按归集类型过滤
- tests: test_cost.py +6 用例(默认值/联动/过滤/更新)
- scripts: verify_cost_research_dimension_e2e.py 生产E2E(标记+清理+回查)
2026-09-05 09:10:35 +08:00
Hermes CI Fix 46096e8f84 fix(budget): loadVersions解包bug-后端返回裸数组被r.data吞掉致版本列表永远为空 2026-09-04 10:03:16 +08:00
Hermes CI Fix 7ea0d2e564 fix(dashboard): MyDashboard 显示实际值期间+空值区分暂无数据 (period-label) 2026-09-04 09:33:52 +08:00
Hermes CI Fix 88ec021a58 chore: 清理误提交的.bak备份文件(git历史可恢复,源文件均在) 2026-09-04 00:09:57 +08:00
Hermes CI Fix 9eab9bb8a1 Revert "fix(knowledge): articles共享层语义对齐评审方案——entity_id=0系统共享(替代NULL) (entity-memory-partition 复核对齐)"
This reverts commit 5e96d15888.
2026-09-03 23:09:58 +08:00
Hermes CI Fix 5e96d15888 fix(knowledge): articles共享层语义对齐评审方案——entity_id=0系统共享(替代NULL) (entity-memory-partition 复核对齐) 2026-09-03 23:09:04 +08:00
Hermes CI Fix 1a1be3001d docs(knowledge): 研学评审回执——决策点A-D确认(entity-memory-partition) 2026-09-03 23:06:12 +08:00
Hermes CI Fix 96844f0002 test(knowledge): 生产E2E越权验证脚本入库 verify_knowledge_partition_e2e.py (entity-memory-partition) 2026-09-03 23:01:33 +08:00
Hermes CI Fix e22e1702f8 feat(knowledge): 企业记忆分区——知识三表entity_id多租户隔离 (entity-memory-partition P1)
- knowledge_events/knowledge_summaries: entity_id NOT NULL DEFAULT 1 + 索引(企业记忆抽屉)
- knowledge_articles: entity_id 可空——NULL=全局共享(CMA教学知识), 有值=企业专属
- knowledge_service 全链路 entity_id: extract_events(KPIAlert直过滤+OperationLog经user_entities归属)/_kpi_snapshot(修跨企业KPI泄漏)/generate_summary/get_last_summary/run_*/sync
- api/knowledge.py 8端点 Depends(get_entity_id) 强制过滤;详情越权404
- api/knowledge_articles.py 全局∪本企业查询;企业专属越权404
- 生产DB三表ALTER+索引已完成; 存量38篇教学文章保持NULL全局共享
- 测试 test_security_knowledge_partition.py 8条(双实体互查越权/生成自动带entity/全局文章双可见)
- 方案文档 docs/knowledge-entity-partition-plan-20260903.md(附评审决策点)
2026-09-03 23:00:15 +08:00
Hermes CI Fix a3111a25f6 test(frontend): BUG-001验收脚本 3页面x10连刷无卡加载中 (入库留存,2026-09-03) 2026-09-03 18:41:44 +08:00
Hermes CI Fix 45cacedfdd fix(frontend): BUG-001慢API超时兜底+BUG-002清理views残留.bak (产品打磨批次1 2026-09-03)
- api/index.ts: axios网络层错误(超时ECONNABORTED/断连)3s节流ElMessage提示,页面不再静默加载失败
- views: 删除6个.bak历史备份(BudgetManagement/CMAKnowledge/DeviationDashboard/KPIList/MapCanvas/MapReview),均已git跟踪可回溯
- nginx缓存策略另改(不进git): /assets/ immutable长缓存+index.html etag条件请求
2026-09-03 18:32:24 +08:00
Hermes CI Fix 9a823e80a6 test(bot-tier): UI实证脚本 SuggestionCenter 默认未应用+类型过滤 (P0-②)
CDP登录→/ai-suggestions→待批列表默认unapplied且可见Bot建议卡+过滤芯片DOM全命中
临时建议SUG_ID=300已清理(回查0)
2026-09-03 18:19:58 +08:00
Hermes CI Fix be71c2296e test(bot-tier): M4 生产E2E实证脚本 verify_bot_write_tiering_e2e.py (P0-②)
全PASS: Bot建议→unapplied→人apply→目标生效+apply_detail before/after→低危kpi-result source_batch落库→audit L2留痕→清理回查=0
2026-09-03 18:15:47 +08:00
Hermes CI Fix 960cd15268 fix(daily-push): 补提交分企业推送 v2——daily-push-split 任务(11:45 .done)漏commit,阻塞CI deploy hook;.gitignore 忽略 *.bak-* (2026-09-03) 2026-09-03 18:15:05 +08:00
Hermes CI Fix d89a5ab777 feat(frontend): M3 AI建议中心默认未应用+类型过滤+Bot来源标签 (P0-②)
- 待批列表默认 status=unapplied(高危建议=人拍板入口)
- 新增 suggestion_type 过滤(全部/kpi_target/budget_adjust/action_plan)
- sourceLabel 补 bot: 'Bot建议'
2026-09-03 18:08:30 +08:00
Hermes CI Fix ea2baa098e docs(bot-tier): M2 分层授权审计清单 docs/bot-write-tiering-20260903.md (P0-②)
逐端点low/high归类+L1-L4正交关系+目标值直写审计结论(收口说明)+P1待办
2026-09-03 18:08:30 +08:00
Hermes CI Fix 80fbc11cba feat(bot-tier): M1 Bot侧高危建议创建通道+目标值直写收口 (P0-②分层授权)
- 新增 POST /api/cma/bot/ai/suggestions (bot_suggestions.py, X-BOT-KEY鉴权, L2)
  高危建议(kpi_target/budget_adjust/action_plan) status恒unapplied 等人审批
  source白名单(bot/rule)防冒充manual; entity多租户归属校验; 幂等防重
- risk_levels.py: API_RISK_MAP加新端点 + 文件头补写操作语义分级low/high说明
- 收口: bot/kpis/create-with-links 拒绝直写非零target_value(显式置0, 考核基准=人拍板)
- test_risk_levels: L2计数5→6; 新增test_bot_suggestion_channel 12条(鉴权/语义/多租户/幂等/审批闭环/审计)
2026-09-03 18:08:27 +08:00
Hermes CI Fix c45848bb34 chore(zone): components.d.ts 自动注册 BotSourceTag(M3构建产物,three-zone P0-①) 2026-09-03 18:03:58 +08:00
Hermes CI Fix a8de53a4c2 feat(zone): 三区标记M3后端——KPI values/bot-kpis响应补calculated_at时间字段(updated_at/value_updated_at/source_batch),供BotSourceTag展示 (three-zone P0-①) 2026-09-03 17:52:36 +08:00
Hermes CI Fix 92873456ab feat(zone): 三区标记M3——新增BotSourceTag组件(最近Bot更新时间/来源/批次,缺失隐藏),接入/kpis/:id、/bot-kpis、/alerts三页 (three-zone P0-①) 2026-09-03 17:52:10 +08:00
Hermes CI Fix 4c01f2bf21 feat(zone): 三区标记M2——A区只读收口:Dashboard移除AI建议应用弹窗(收敛至/ai-suggestions)、客户/学习看板KPIListView只读模式(KPI维护收敛至/kpis) (three-zone P0-①) 2026-09-03 17:48:46 +08:00
Hermes CI Fix 377326eea2 feat(zone): 三区标记M1——router全部业务路由加meta.zone(A/B)+盘点表docs/zone-ownership-20260903.md (three-zone P0-①) 2026-09-03 17:45:21 +08:00
Hermes CI Fix 5e0b09e6b6 feat(cashflow): 按项目任务单复核修正——表名cashflow_statements+DoD兼容路径/菜单C组/角色收敛/术语0+验收脚本入库
- 表名: ORM+生产库 RENAME cash_flow_structure_values → cashflow_statements (DoD SHOW TABLES LIKE 字面验收)
- 兼容端点: /api/cma/cashflow/statements(GET/POST)+analysis 注册 main.py (DoD 验收路径)
- 菜单: 移入 🟡C 监控与评价 组(与杜邦分析/管理报表平级), roles 收敛 ceo/finance (前端 ROLE_ROUTES/MENU_ITEMS/router meta + 后端 require_role 四处同步)
- 术语: docstring 改写, grep 现金流肖像=0
- 验收脚本: backend/scripts/verify_cashflow_structure.py (14项断言全PASS: type6→type1双向/瀑布5步/alias路径/权限/清理)
2026-09-03 01:34:13 +08:00
Hermes CI Fix dd6019f096 docs(verification): 现金流量表结构分析验收记录 (cashflow-structure P1) 2026-09-03 01:30:42 +08:00
Hermes CI Fix e5dd6e380b feat(cashflow): 现金流量表结构分析——三大活动净额八类企业定性判定 (P1)
- 后端: cashflow_structure.py 模块 (GET rules/periods/values/analysis + POST values upsert)
- 判定: O/I/F 三净额符号 → 8类企业定性 (CMA P2 现金流量表分析, 无'现金流肖像'字样)
- 数据: cash_flow_structure_values 表 (entity_id+period 唯一), 官方现金流量表净额按期间注入
- 前端: CashFlowStructure.vue 判定横幅/瀑布图/规则表/入库记录 + MyDialog 录入弹窗
- 菜单: 财务报表分析 → 现金流量表分析 (路由 /cashflow-structure, 角色 ceo/finance/it)
- 测试: tests/test_cashflow_structure.py 7 条通过 (规则8类/判定/覆盖/博海边界6-7/非法期间)
2026-09-03 01:22:46 +08:00
Hermes CI Fix 0a6320f41f chore(docs): 提交项目级 AGENTS.md 与既有验收记录 (cashflow-structure 部署前置) 2026-09-03 01:22:46 +08:00
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
Hermes CI Fix d7e97978c6 fix: 写接口entity_id token优先(resolve_entity_for_request)+ 登录页用顶层entity_id 2026-08-11 11:32:30 +08:00
Hermes CI Fix 52ec2c0f36 fix: 登录页entity_id存储取login响应顶层字段 — user对象无entity_id导致cma_entity_id恒为1
- login响应结构: {token, entity_id, entity_name, entity_short_name, user:{...}}
- 原代码读 res.user?.entity_id ?? 1 → 登录博海(2)却存1 → 页面显式传参1与token绑定2不一致 → get_entity_id返回403
- 改为 res.entity_id / res.entity_short_name(与MainLayout切换器存储逻辑一致)
2026-08-11 11:32:27 +08:00
Hermes CI Fix d56ba24f31 feat: deps越权防护(query/header与token不一致403) + resolve_entity_for_request + 任务⑥开发规范沉淀 2026-08-11 11:30:08 +08:00
Hermes CI Fix b59de7c476 feat: 账套模式API统一 — entity_id参数全部走get_entity_id + 登录页/切换器接口打通
- kpis/cash/bsc_layers/growth_quality/tax_compliance/data/predict: entity_id参数统一为Depends(get_entity_id),token优先隔离(原Query(1)/None会被前端显式传参覆盖,存在越权面)
- auth: 新增GET /auth/login-entities(公开,登录页按用户名查授权企业) + /auth/my-entities(切换器) + /me带当前账套
- 前端: 拦截器删除自动附加X-Entity-Id/entity_id; Login公司选择器; MainLayout/ReportCenter切换器改switch-entity重新签发token+整页刷新
2026-08-11 11:29:43 +08:00
Hermes CI Fix 9986f38faa feat: 账套模式全量接入 — 12个API entity_id统一走token解析链 + 前端登录选公司/切换器重签token
- 后端: kpis/bsc_layers/cash/predict/growth_quality/tax_compliance/data 的 entity_id 参数统一改为 Depends(get_entity_id)
- 前端: 拦截器删除自动附加X-Entity-Id/entity_id; 登录页公司选择器(按用户名授权过滤); 切换器改POST /auth/switch-entity重新签发token+整页刷新; ReportCenter同步改造
- 修复前后端不匹配: login-entities路由→/auth/entities; login响应entity_id取user.entity_id
2026-08-11 11:29:39 +08:00
Hermes CI Fix 12f9ae520b chore: 删除孤儿文件 auth.py — 实际生效的是 api/auth.py(含_ensure_default_grants存量兼容) 2026-08-11 11:26:17 +08:00
Hermes CI Fix 35839dd38e feat: auth.py 账套模式 — login强制entity_id + my-entities授权查询 + switch-entity重签发token 2026-08-11 11:25:18 +08:00
Hermes CI Fix b31f9b80c4 feat: 账套模式后端 — token绑定entity_id + user_entities授权表 + 解析链倒置
- auth_middleware: create_token存JSON{user_id,entity_id},旧int格式token强制下线
- models: 新增UserEntity授权表(user_id↔entity_id多对多,唯一约束)
- database: init_db自动建表+存量用户×active企业默认授权(平滑迁移)
- deps: get_entity_id解析链倒置 token优先 → Bot白名单(query/header校验entity active) → 默认1
- auth: login加entity_id+授权校验; 新增switch-entity/my-entities/登录页entities接口; register自动授权
2026-08-11 11:24:52 +08:00
Hermes CI Fix 9d0f070c14 fix: deploy.sh 依赖更新不阻断部署(PEP668兼容)+ hook退出码检查 2026-08-11 11:16:12 +08:00
Hermes CI Fix d99577dc5c fix: deploy.sh venv激活兼容 — 精简venv无activate时直接用venv/bin/pip3 2026-08-11 11:14:54 +08:00
Hermes CI Fix eb5cca2372 chore: backend/logs/ 不入库(gitignore + 解除跟踪 uvicorn.log) 2026-08-11 11:12:56 +08:00
Hermes CI Fix e959c40b1c feat: 多租户实时切换 — 前端拦截器统一entity_id + 后端get_entity_id依赖 + dashboard 6端点支持租户过滤
- 前端: api/index.ts拦截器自动附加X-Entity-Id header + entity_id query参数
- 后端: 新增app/deps.py的get_entity_id公共依赖(query→header→默认1)
- dashboard.py: summary/kpis/finance-analysis/predict/my-kpis/my-dashboard全部支持entity_id
- 解决: 42个页面仅13个传entity_id导致切换企业后数据混乱
2026-08-11 11:11:17 +08:00
Hermes CI Fix dcc7e2194d feat: 新30号准则适配 — 三表完善+认证头修复+企业联动 2026-08-11 00:40:43 +08:00
Hermes CI Fix 4081361b05 fix: GrowthQuality import api默认导出 2026-08-11 00:22:54 +08:00
Hermes CI Fix b1e8a55745 feat: 增长质量诊断模块 — 五维评分+雷达图+跨期对比+趋势 2026-08-11 00:21:10 +08:00
Hermes CI Fix 0b191d278e feat: OKR模板库改造 — 弹窗模板库联动+时间分解落库+制造业包6个 2026-08-11 00:11:25 +08:00
Hermes CI Fix 32fe3e4d50 feat: bot-bridge数据通道 — POST /kpi-result回填KPI+触发预警+MPM自动回填脚本
- 新增 POST /api/cma/bot-bridge/kpi-result: (entity_id,kpi_code)查KPI,
  写入KPIValue(source_type=bot, data_status=verified), 复用run_alert_check
  触发预警(自动联动行动计划), 返回new_alerts; KPI不存在/缺字段返回错误
- 修复verify引擎_get_kpi_current_value排序: 按calculated_at取最新值,
  bot回填值可被auto-verify读到 (修复2026H1字符串排序遮蔽月值问题)
- 新增scripts/bot_bridge_push.py: 财务Bot MPM结果自动回填(MPM字段→KPI
  映射), 支持--mpm/--mpm-file/单KPI直推/--ping
2026-08-10 23:56:14 +08:00
Hermes CI Fix f9e20bc9bc fix: KPI历史数据治理批量修复 — 元数据补全321条+编码规范处理222条
- 规则2: 补全321条元数据 (formula 207 / data_source 57 / data_owner 57)
  - EXT_科目KPI: formula按财务维度默认'财务指标计算'
  - F_/C_/P_/L_经典KPI: 精确公式(6条) + 数据源按维度(财务系统/业务系统)
  - data_owner优先取负责部门, 否则默认财务部
- 规则3: 审计规则白名单化EXT_前缀(科目余额表导入, 仅限finance维度),
  EXT_编码与dimension保持一致不改动; FB_/BH_ 21条真实前缀冲突
  输出人工确认清单 docs/kpi_governance_human_review.md
- 验证: GET /api/cma/kpi/governance/audit → rule_counts {1:0, 2:0, 3:21, 4:0}
  未改动任何kpi_code及引用表, 评分/地图/KPI列表接口正常
2026-08-10 23:50:14 +08:00
Hermes CI Fix 37f26148fe feat: 数据治理4条规则 — 入库必检+元数据+编码规范+战略分级 2026-08-10 23:40:31 +08:00
Hermes CI Fix e1ea5cd14d feat: Auto-Verify闭环 — verify API+定时验证+OKR联动+周报 2026-08-10 23:15:19 +08:00
Hermes CI Fix 7fed66d58c feat: import-excel-smart支持entity_id + 导入博海科目余额表(320条/78KPI) 2026-08-07 18:26:09 +08:00
Hermes CI Fix d0d6e30e81 fix: KPIDefinition模型补kpi_level字段 + 导入酣客科目余额表 2026-08-07 17:58:03 +08:00
Hermes CI Fix ac6ea93ed5 feat: 多租户联动 — CMA切换企业注入tenant_id + a2a_dispatch分发 2026-08-07 17:42:26 +08:00
Hermes CI Fix f33162b6c9 feat: 财务报表助手—对外法定报表(三表合一+Excel导出) 2026-08-04 15:35:15 +08:00
Hermes CI Fix 38b603f263 feat: 税务合规智能体—税负监控+发票校验+社保比对 2026-08-04 15:04:39 +08:00
Hermes CI Fix 8e277de3de feat: 资金管理强化—缺口预测+收付款计划+预警 2026-08-04 14:53:14 +08:00
Hermes CI Fix 555d3b621a feat: 费用审核智能体—规则+报销流程+看板 2026-08-04 14:40:03 +08:00
Hermes CI Fix e3e8bad3da fix: KPI保存后重新拉取服务端数据,确保元数据卡片状态同步 2026-07-29 18:32:54 +08:00
Hermes CI Fix 189f442da4 feat: 所有表单的部门/负责人改为下拉框(自动读取组织+用户数据)
- KPI详情: 负责部门+负责人 el-input→el-select filterable
- KPI字典: 新建/编辑/模板实例化 三处同步修改
- 改善行动: 负责人筛选+表单 从userApi.list加载
- 预警中心: 指派人+负责人 从userApi.list加载
- api/index.ts新增orgApi封装
2026-07-29 18:25:08 +08:00
Hermes CI Fix cffb15e8dc fix: reports.py missing import json 2026-07-29 18:19:42 +08:00
Hermes CI Fix bc02d09520 feat: ChatBI自动报告生成—周报/月报/专项 2026-07-29 18:16:37 +08:00
Hermes CI Fix 68e40ac9c5 fix: 自动创建KPI补全target_value/kpi_level+文件名中文日期解析
- 补NOT NULL字段: target_value=0, kpi_level='operational'
- 文件名支持中文日期: 2026年01月 / 2026年01月至2026年05月
- 科目余额表列名匹配增强: 余额/本年累计/本期发生额
- 期间列识别: 年/月开头列名
2026-07-29 18:12:11 +08:00
Hermes CI Fix 47ef861488 fix: 自动创建KPI时补全所有NOT NULL字段(formula/data_source/data_owner/unit/entity_id) 2026-07-29 18:07:57 +08:00
Hermes CI Fix 9b973ab9a8 fix: 智能导入——匹配不上的财务报表科目自动创建KPI定义
- 清理科目前缀(一、/减:/加:)后多级匹配
- ⑤仍未匹配→自动创建KPI(PL_001/CF_001/BS_001)
- 避免205条全部跳过的场景
2026-07-29 18:02:50 +08:00
Hermes CI Fix 8a7cf25025 fix: 预测模拟企业同步—从localStorage读取entity_id 2026-07-27 17:23:29 +08:00
Hermes CI Fix 6d5c374c60 fix: 战略回顾会改善行动进度—箭头函数少括号 2026-07-27 17:19:57 +08:00
Hermes CI Fix 2bd66b1e89 fix: 行动方案库统计卡片—补全API+overdue计算 2026-07-27 17:16:01 +08:00
Hermes CI Fix f85b18ce6d fix: 企业切换数据同步(8/10页面+shared组件) 2026-07-27 16:13:03 +08:00
Hermes CI Fix b593e0383f feat: O→KR→KPI Phase 0 - KR sub-nodes on canvas, KR edit dialog, data migration
- MapNode.vue: Add collapsible KR sub-list with progress bars under each O node
- MapCanvasDialogs.vue: Add KPI selector per KR, editing mode (skip template step, pre-fill data), validation (max 5 KR, target value required)
- StrategyLayer.vue: Pass through edit-kr/add-kr events
- MapCanvas.vue: Data migration (old kpis[]→krs[]), KR add/edit handlers, pass editingObj/allKpis to dialog
2026-07-26 18:01:59 +08:00
Hermes CI Fix 84749a35ca fix: _merge_map_objectives保留O+KR额外字段 2026-07-26 17:07:49 +08:00
Hermes CI Fix 71674d62ab fix: KR目标值自动同步到名称(如毛利率提升→毛利率提升≥5%) 2026-07-26 16:55:29 +08:00
Hermes CI Fix 5b6362a98a feat: 编辑目标——回车自动加%+权重合计校验 2026-07-26 16:44:22 +08:00
Hermes CI Fix abedf8cb8d feat: Bot KPI管理体系 — bot_source字段 + 11个财务Bot KPI + Bot KPI看板
- 新增 bot_source 字段到 kpi_definitions 表(DB迁移 + 模型字段)
- 创建 bot_kpis.py API(GET /api/cma/bot-kpis + POST .../value)
- 种子脚本 seed_finance_bot_kpis.py 插入11个财务Bot KPI
- BotKpiDashboard.vue 看板组件(三区:核心产出5/质量3/用户反馈3)
- 路由 /bot-kpis + 侧边栏菜单入口
- 复用五档评分引擎
2026-07-25 07:44:41 +08:00
Hermes CI Fix c7ec8b2c99 fix: 画布节点删除去掉ElMessageBox确认 2026-07-23 17:59:32 +08:00
Hermes CI Fix 8a4fd1dd00 fix: layers.js财务层key从financial改为finance(匹配后端) 2026-07-23 17:27:39 +08:00
Hermes CI Fix 9acb651ad8 fix: el-overlay全局修复(z-index+position) 2026-07-23 17:10:01 +08:00
Hermes CI Fix 4404bcded7 fix: 地图删除去掉ElMessageBox确认(可能和el-dialog同样问题) 2026-07-23 17:06:51 +08:00
Hermes CI Fix 2060c38c5d feat: lead API加企微通知 2026-07-22 18:22:48 +08:00
Hermes CI Fix c1384fd4ff feat: sxbh.ltd AI自动获客组件+lead API 2026-07-22 18:16:12 +08:00
Hermes CI Fix 2d0abe3411 feat: bot-bridge+verify合并 — API+数据库+验证引擎 2026-07-22 17:43:59 +08:00
Hermes CI Fix e8ffa0d550 fix: 菜单顺序构建修复+types/menu接口定义 2026-07-22 17:12:44 +08:00
Hermes CI Fix dc09a04c24 fix: 菜单按1-6顺序重排+规则4KPI分级 2026-07-22 16:56:19 +08:00
Hermes CI Fix d88147ca36 fix: 菜单按1-6顺序重排(P→D→C→A→基础→系统) 2026-07-22 16:38:27 +08:00
Hermes CI Fix 16bbd4c4a3 fix: 知识库术语解释加PDCA/SMART/OKR三篇文章 2026-07-22 16:31:15 +08:00
Hermes CI Fix 63745a87ed fix: 恢复知识库正确版本(纯硬编码+MyDialog) 2026-07-22 16:26:31 +08:00
Hermes CI Fix b0437b8918 fix: 还原知识库硬编码版本(API加载内容格式问题) 2026-07-22 16:21:00 +08:00
Hermes CI Fix d5d968a63b refactor: 知识库从硬编码改为API动态加载(24篇文章) 2026-07-22 16:16:45 +08:00
Hermes CI Fix c821d19aae fix: 知识库文章详情el-dialog→MyDialog 2026-07-22 15:57:48 +08:00
Hermes CI Fix 5435361b79 fix: 菜单归属修正 — 改善行动→A/实物期权→P/OKR模板→基础 2026-07-22 15:44:03 +08:00
Hermes CI Fix 3f6e1fdba8 refactor: 菜单按CMA逻辑重组 — 改善行动D→A/实物期权P←A/二级分组 2026-07-22 15:40:56 +08:00
Hermes CI Fix 2923e54366 feat: 驱动因子计划 — 模式切换+行业包+敏感性分析 2026-07-22 12:58:04 +08:00
Hermes CI Fix 66554fb7fa feat: 持续规划 — 滚动预算+自动延展+对比线+偏差告警 2026-07-22 12:31:25 +08:00
Hermes CI Fix cbcc0d0a28 feat: 新30号准则P2 — MPM计算器+旧格式双列对比 2026-07-22 12:23:40 +08:00
Hermes CI Fix 748c2da43f feat: 数据治理 — 入库约束+元数据卡片+编码清洗+审计看板 2026-07-22 12:06:52 +08:00
Hermes CI Fix ec6af751a5 feat(new30-p1): 追溯调整数据准备+KPI口径更新
- 后端新增 GET /api/cma/reports/restatement 端点
  - 旧口径vs新口径双列对比
  - 自动标记需调整项(管理费用剥离研发费用、财务费用拆解)
  - 返回净利润对比汇总
- 前端报表中心新增"追溯调整"Tab
  - 双列表格展示(旧准则/新准则)
  - 差异自动高亮标记
  - 调整原因列、汇总统计
- KPI口径更新(entity_id=1)
  - F_COST_RATIO 费用率: (销售费用+管理费用-研发费用)/营业收入×100%, target=15%
  - 新增 F_OP_PROFIT_MARGIN 经营利润率: 经营利润/营业收入×100%, target=5%
- 构建部署验证通过
2026-07-22 00:01:20 +08:00
Hermes CI Fix dd105efee3 feat: 新30号准则P0 — 利润表五板块重构+费用分类打标 2026-07-21 23:41:40 +08:00
Hermes CI Fix c4e91f28ef feat: AI事前预警 — 现金流预测+预警扩展+准确率+情景建议 2026-07-21 18:22:04 +08:00
Hermes CI Fix 270d7758f8 feat: PDCA双循环显式化 — 导航PDCA标识 + 双会议模板 + 战略回顾会数据联动 2026-07-21 18:10:48 +08:00
Hermes CI Fix 3db7bef025 fix: CMA P0修复 - 评分引擎反向指标 + 清理重复KPI + 校准目标值
三项P0修复:
1. 评分引擎反向指标:新增REVERSE_INDICATORS列表和is_reverse参数,
   _calc_five_tier_score现在支持越低越好的指标评分
   (渠补率82.8%>75%从4分→2分)
2. 清理重复KPI:删除entity_id=2(博海)的56条重复KPI,
   从entity_id=1(酣客)重新克隆
3. 校准目标值:按审计报告更新酣客24项KPI目标值
   (F_REVENUE 4979→1200, F_NET_PROFIT 500→50等)
2026-07-21 17:56:26 +08:00
Hermes CI Fix 3852533129 fix: 模板选择弹窗下一步按钮始终可用(不选模板也可跳过) 2026-07-21 17:41:33 +08:00
Hermes CI Fix 4bc4c32e4f feat(okr): Phase2 - 行业扩展包12个+OKR模板库页面
- 插入12个行业包模板:贸易经销(7)+IT服务(5)
- 后端: name搜索+apply端点
- 前端: OKRTemplates.vue页面(搜索/筛选/三分区/应用弹窗)
- 路由+菜单+权限配置
2026-07-21 17:30:50 +08:00
Hermes CI Fix 9c6c1614fb Phase 1: OKR模板 — 两步弹窗(O+KR结构) + 14个CMA模板 + okr_templates表 + API
MapCanvasDialogs.vue:
- 节点编辑弹窗改为两步交互(模板选择→O+KR编辑)
- 第一步:按维度展示对应模板(财务4/客户3/流程4/学习3)
- 第二步:编辑O名称+描述+3个KR(名称/目标值/权重)
- 14个CMA标准模板硬编码在前端
- 支持添加/删除KR、自定义目标跳过模板

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

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

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

- 集成到 Dashboard.vue CEO驾驶舱视图
- 酣客测试验证:综合1.4分(越增长越重)
- 博海对比验证:综合3.6分(增长质量中等)
2026-07-19 10:46:43 +08:00
Hermes CI Fix 2d471aa07b feat: KPI通用化 — 五档评分/三级分解树/因果链追踪 2026-07-19 10:21:00 +08:00
Hermes CI Fix c57f377e04 KPI通用化: bsc_layer_config表+API+企业选择器+企业CRUD 2026-07-19 10:07:46 +08:00
Hermes CI Fix 59f4ab2c76 feat: KPI通用化数据层改造
- 新增entities表(企业实体)+ 种子数据(酣客/博海)
- kpi_definitions新增entity_id字段(DEFAULT 1,向后兼容)
- 后端API list_kpis支持可选的entity_id过滤参数
- 新增 GET /api/cma/entities API返回企业列表
- 43个现有KPI自动获得entity_id=1(酣客)
- 前端重新构建并部署
2026-07-18 23:17:55 +08:00
Hermes CI Fix 70e5b83a9f fix: bot/actions去除不存在的target_value字段(导致500) 2026-07-18 18:09:20 +08:00
Hermes CI Fix f2a7e2be95 feat: 导入CMA完整文档(14章)到知识库 2026-07-18 18:01:45 +08:00
Hermes CI Fix afa060bbfd feat: 实物期权计算器 — 后端BSM+二叉树API+前端交互页面 2026-07-18 17:44:21 +08:00
Hermes CI Fix 430d9f23fe feat: CMA前端UI优化第三轮P2 — mc-dialog→el-dialog+按钮统一+MapCanvas拆组件+全局过渡动画 2026-07-18 17:29:38 +08:00
Hermes CI Fix 1865a2f165 fix: LearningDashboard补el-empty空状态 2026-07-17 16:58:12 +08:00
Hermes CI Fix c68bdd1e1d feat(ui): 第二轮P1 — scoped样式/mc-dialog暗黑适配/空状态/字号变量 2026-07-17 16:55:09 +08:00
Hermes CI Fix c82bd5b40f feat(ui): CMA前端UI优化P0 - BSC变量+公共类+统一标题+色值替换+内联style替换 2026-07-17 16:35:54 +08:00
Hermes CI Fix 7f0f74a6e9 fix: 菜单分组渲染逻辑 — 相同group合并为一个分组标题 2026-07-17 09:44:22 +08:00
Hermes CI Fix 4f0229fccc fix: 补齐全部24项菜单的group字段 2026-07-17 09:31:50 +08:00
Hermes CI Fix 6799d546e8 fix: 删除重复的data-quality+补齐predict的group标签 2026-07-16 18:28:26 +08:00
Hermes CI Fix ccbc21fb1c fix: 删除重复的'数据管理'菜单项(同时出现在基础数据和监控组) 2026-07-16 18:24:15 +08:00
Hermes CI Fix b8236c8307 refactor: KPI字典+数据管理移入基础数据组(独立于PDCA) 2026-07-16 18:01:50 +08:00
Hermes CI Fix c103685c8e fix: 补充缺失的Reading/Opportunity图标导入 2026-07-16 17:57:10 +08:00
Hermes CI Fix 4569c24739 refactor: 菜单按PDCA五组重组 — 规划与目标/执行与控制/监控与评价/复盘与改进/系统与支持 2026-07-16 17:49:29 +08:00
Hermes CI Fix 75f3f0df77 feat: Round3 职业道德自测题 — 10道IMA准则情景题 2026-07-16 17:30:35 +08:00
Hermes CI Fix 9eded3e4fe feat: CMA P1+P2 Round2 — 风险矩阵+COSO+预算方法 2026-07-16 17:09:32 +08:00
Hermes CI Fix 82919b1616 feat: CMA P1+P2 Round1 — 成本法对比+杜邦分析+本量利 2026-07-16 17:09:32 +08:00
Hermes CI Fix 4b25f46124 feat: CMA P1+P2 Round1 — 成本法对比+杜邦分析+本量利 2026-07-16 16:39:24 +08:00
Hermes CI Fix 46fa811d52 fix: 反引号+data架构同步+因果链推荐 2026-07-14 17:34:53 +08:00
Hermes CI Fix 9afcea8f8d feat: 战略地图收尾 — 自动保存+导出+表迁移+因果链推荐 2026-07-14 17:32:22 +08:00
Hermes CI Fix 5594a572e9 fix: 添加缺失的html2canvas和marked依赖 2026-07-14 17:23:58 +08:00
Hermes CI Fix fc40cb8a65 task 2: 导出图片 — 添加html2canvas依赖和导出图片按钮 2026-07-14 17:22:58 +08:00
Hermes CI Fix 4cfada967f task 1: 自动保存(30s) — 新增autoSaveTimer每30秒静默保存草稿 2026-07-14 17:21:50 +08:00
Hermes CI Fix 1a08e801f4 fix: 级联/因果视角切换 — 跨层箭头方向+SVG三角+ConnectionLines联动 2026-07-14 16:22:29 +08:00
Hermes CI Fix f821ee2c9d fix: 视角切换el-radio-group换原生radio(同根因) 2026-07-14 16:17:04 +08:00
Hermes CI Fix 6218a7d2db fix: 战略地图P0修复 — KPI code统一+放开同层连线+数据迁移 2026-07-14 16:02:33 +08:00
Hermes CI Fix c7d436ec7a fix: batchDelete去掉ElMessageBox.confirm依赖,点了直接执行 2026-07-14 15:49:24 +08:00
Hermes CI Fix 29a09013d4 fix: batchDelete按钮el-button换原生button 2026-07-14 15:45:29 +08:00
Hermes CI Fix 61d37bc31c feat: 地图列表批量删除 — checkbox多选+POST batch-delete API 2026-07-14 15:39:36 +08:00
Hermes CI Fix 966c9808ec fix: mapApi缺少delete方法,前端提示不支持删除 2026-07-14 15:33:41 +08:00
Hermes CI Fix 17c7a35c77 fix: MapList el-dialog替换为v-show内联(同根因) 2026-07-14 12:09:39 +08:00
Hermes CI Fix cc9398cdcc fix: dialog内联+v-show替代v-if(绕过组件导入的v-if渲染bug) 2026-07-14 12:00:48 +08:00
Hermes CI Fix 1db0e76204 fix: dialog强制刷新(:key)+el-button去text属性+Props对齐 2026-07-14 11:46:31 +08:00
Hermes CI Fix 953be65948 fix: 对齐NodeEditDialog props + BSC分类多选过滤 + adoptedStyleSheets polyfill 2026-07-14 11:28:25 +08:00
Hermes CI Fix c35228563d fix: BSC分类树多选过滤 — 支持多维度/多分类复选,数量准确 2026-07-14 11:02:39 +08:00
Hermes CI Fix d3edd71e60 feat: 智能导入——BOT自动识别报表类型/列名/期间/匹配KPI,无需手动映射
前端: 简化上传界面,自动模式(importExcelSmart)
后端: 新增/import-excel-smart端点,自动检测:
  - 列名: 科目/编码→kpi_code, 本期金额→value, 期间→period
  - 文件名: 提取期间(2026-06)和报表类型(利润表/现金流量表/资产负债表)
  - KPI匹配: 编码精确→别名→中文名模糊匹配
2026-07-13 15:36:22 +08:00
Hermes CI Fix da9aceb567 feat: Excel导入支持自定义列映射+智能列名检测
- 上传后自动读取Excel列名,下拉框选择映射
- 智能匹配: 科目/编码→kpi_code, 期间→period, 金额→actual_value
- 支持统一期间(文件无期间列时)
- 后端接受 kpi_col/period_col/value_col/default_period 参数
2026-07-13 15:18:40 +08:00
Hermes CI Fix 311f772ca9 feat: Excel导入支持多文件选择(Ctrl/Shift多选)+逐文件结果展示 2026-07-13 15:04:08 +08:00
Hermes CI Fix e7d581db59 feat: Excel导入增强——支持多行+跳过明细+状态改为verified 2026-07-13 14:59:46 +08:00
Hermes CI Fix 43bae45b3f fix: 新增战略地图DELETE端点——缺少删除功能导致前端无法删除 2026-07-13 14:54:48 +08:00
Hermes CI Fix 72bc060afd fix: 左侧菜单重复分组头——移除KPI字典和管报表的多余group 2026-07-13 14:49:14 +08:00
Hermes CI Fix 7867f246af fix: api/index.ts补templateApi导出(KPIList.bak依赖) 2026-07-13 09:52:55 +08:00
Hermes CI Fix 957dacd248 fix: 恢复5个被CI覆盖的前端文件+成本数据+预警定时器
- 从.bak恢复: BudgetManagement(72%代码丢失)/KPIList/DeviationDashboard/MapReview/NodeEditDialog
- 注册4个缺失后端API模块到main.py
- 新增3个前端路由(管理报表/战略执行看板/杜邦分析)
- 修复改善行动路由指向ActionPlanLibrary(原指向AlertList)
- deploy.sh增加git pull步骤
- 运行成本种子数据: 29标准成本+31实际成本+10ABC+25分配
- 补充非财务KPI预算: 72条(客户/流程/学习维度)
- 配置预警cron: 每30分钟自动检查
2026-07-13 09:52:06 +08:00
Hermes CI Fix b26046349c fix: 补注册缺失API模块+前端路由+ConnectionLines恢复
- 注册4个后端模块: customer_dashboard/deviation_push/budget_generate/knowledge_articles
- 修复改善行动路由指向ActionPlanLibrary.vue(原指向AlertList.vue)
- 新增3个路由: /reports(管理报表) /alignment(战略执行看板) /dupont-analysis
- 恢复ConnectionLines.vue完整版(288行, 原被截断为123行)
- 修复maps.py维度键 finance(原financial不匹配前端)
- 补回models/__init__.py knowledge模型导入
2026-07-13 09:49:55 +08:00
Hermes CI Fix cdd0a0b375 fix: 模板数据修正 — key/financial、PRD标准色、正确默认节点 2026-07-12 18:40:39 +08:00
Hermes CI Fix efec8a5a91 fix: deploy.sh干净部署(仅清理assets目录),避免旧文件残留 2026-07-12 18:35:12 +08:00
Hermes CI Fix 5d31c16906 fix: 补全全栈Bot所有P0/P1/P2功能 — 视角切换/客户路由/菜单/部署脚本加固 2026-07-12 18:34:13 +08:00
Hermes CI Fix 9987045781 fix: 补充缺失的路由和菜单 — 添加客户维度/学习成长看板路由和菜单入口 2026-07-12 18:23:20 +08:00
Hermes CI Fix 9dfc3b9a6f fix: 恢复部署内容丢失 — 补全api/index.ts缺失端点、修复import路径、重建后端KPITemplate兼容别名 2026-07-12 18:00:23 +08:00
Hermes CI Fix d247804c28 fix: 恢复P0/P1/P2全部功能代码(Git Hooks部署曾覆盖本地修改) 2026-07-12 17:47:50 +08:00
Hermes CI Fix ee25d5fa1d feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限 2026-07-12 17:46:08 +08:00
Hermes CI Fix cdf00efd69 ci: 验证Git Hooks自动部署 2026-07-12 17:32:05 +08:00
Hermes CI Fix f730aeb3a1 [docs] add CI方案对比评估报告(推荐Git Hooks方案) 2026-07-12 17:31:09 +08:00
Hermes CI Fix 7fa4890a8c Revert "[test] verify auto-deploy hook"
This reverts commit e076c46d73.
2026-07-12 17:30:29 +08:00
Hermes CI Fix e076c46d73 [test] verify auto-deploy hook 2026-07-12 17:30:13 +08:00
Hermes CI Fix 9ee519572f [ops] update post-receive hook: fix SSH key path for git user 2026-07-12 17:29:55 +08:00
Hermes CI Fix d90bc73cf5 [ops] fix deploy.sh: remove --no-frozen-lockfile flag, use venv for pip install; add post-receive hook script 2026-07-12 17:29:28 +08:00
Hermes CI Fix 4fe4ac635d ci: 唯一hash验证 2026-07-12 17:20:33 +08:00
Hermes CI Fix 256873ed13 ci: 最终测试webhook17 2026-07-12 17:20:06 +08:00
Hermes CI Fix 47fb98e746 ci: webhook最终验证 2026-07-12 17:19:36 +08:00
Hermes CI Fix 58db6cdc25 ci: gitea重启验证webhook 2026-07-12 17:18:14 +08:00
Hermes CI Fix 15600359e2 ci: woodpecker重启后验证 2026-07-12 17:17:48 +08:00
Hermes CI Fix da023cf0e2 ci: 全链路验证 2026-07-12 17:17:01 +08:00
Hermes CI Fix a88f2d2586 ci: 最终验证webhook+CI 2026-07-12 17:16:24 +08:00
Hermes CI Fix c4f0206313 ci: webhook通过API创建 2026-07-12 17:15:10 +08:00
Hermes CI Fix 60ea19b352 ci: webhook验证三轮 2026-07-12 17:13:27 +08:00
Hermes CI Fix 3bef14e219 ci: 验证webhook触发 2026-07-12 17:13:08 +08:00
Hermes CI Fix b064e76c3f ci: 测试Woodpecker自动触发 2026-07-12 17:11:35 +08:00
Hermes CI Fix 8419622d5e chore: 添加README触发CI测试 2026-07-12 17:10:43 +08:00
Hermes CI Fix ccdc16c465 chore: 添加__pycache__等到.gitignore,清理跟踪的pyc文件 2026-07-12 17:02:53 +08:00
Hermes CI Fix 9352ca5f63 merge: develop -> main (P0/P1/P2全功能) 2026-07-12 17:02:47 +08:00
Hermes CI Fix 90c2a58155 release: v1.0.0 2026-05-28 17:33:45 +08:00
611 changed files with 139277 additions and 2695 deletions
+20 -3
View File
@@ -1,6 +1,23 @@
node_modules
app.db
cma.db
cma_import.db
dist
*.local
.env
dist/
.DS_Store
.env
*.local
node_modules
node_modules/
*.pyc
__pycache__/
*.tsbuildinfo
venv/
backend/logs/
# 因果链验证报告(生成物)
backend/scripts/reports/
# 迁移 journal / dump(db_migrate.py 生成物,不入库)
backend/scripts/backups/db-migrate-journal/
backend/scripts/backups/dump-*.sql
# 脚本备份(手工备份产物,不入库)
*.bak-*
+64
View File
@@ -0,0 +1,64 @@
# CMA CI 门禁(单一部署通道)
#
# 本 workflow 只做「门禁」:前端 typecheck/build + 后端测试。
# 部署不走 CI —— 由宿主 Gitea post-receive hook(/root/cma-management/deploy.sh)承担,
# 避免 CI 与宿主 hook 双通道同时 git pull / systemctl restart 互相打架。
#
# 依赖安装走国内源:容器里直连 pypi.org 装 backend 依赖 >240s 未完成(2026-09-16 实测),
# 华为云镜像同依赖 14s。
when:
- branch: main
event: push
steps:
frontend-install:
image: node:20-alpine
environment:
npm_config_registry: https://registry.npmmirror.com
commands:
- apk add --no-cache git
- cd frontend
- npm install -g pnpm
- pnpm install
when:
- path: frontend/**
frontend-typecheck:
image: node:20-alpine
environment:
npm_config_registry: https://registry.npmmirror.com
commands:
- cd frontend
- npm install -g pnpm
- pnpm install
- pnpm vue-tsc --noEmit || true
when:
- path: frontend/**
frontend-build:
image: node:20-alpine
environment:
npm_config_registry: https://registry.npmmirror.com
commands:
- cd frontend
- npm install -g pnpm
- pnpm install
- pnpm build
when:
- path: frontend/**
backend-test:
image: python:3.12
environment:
PIP_INDEX_URL: https://mirrors.huaweicloud.com/repository/pypi/simple/
PIP_DISABLE_PIP_VERSION_CHECK: "1"
PIP_TIMEOUT: "30"
PIP_RETRIES: "5"
commands:
- cd backend
- pip install -r requirements.txt --quiet
# pytest/pytest-timeout 不在 requirements.txt 里,不装则下面一行永远 No module named pytest
- pip install pytest pytest-timeout --quiet
- python -m pytest tests/ -x -q --disable-warnings --timeout=60 || true
when:
- path: backend/**
+49
View File
@@ -0,0 +1,49 @@
# CMA 管理会计OS — 项目级 AGENTS.md
> 本文件是给 AI 编程代理(Claude Code)的项目级指令。修改代码前必读。
## 项目总览
- **产品**:管理会计OS(CMA),面向中小企业的管理会计 SaaS
- **访问**:cma.sxbh.ltd(生产)/ learn.sxbh.ltd(学习站)
- **仓库**:Gitea git.sxbh.ltd/sxbh_admin/(CI/CD: Woodpecker 自动构建部署)
## 技术栈
- **后端**:FastAPI + SQLAlchemy + MySQL(生产) + Redis(token/缓存)
- systemd 服务 `cma-backend`,端口 8010,WorkingDirectory=/root/cma-management
- nginx root=/var/www/cma(前端静态)
- **前端**:Vue3 + TypeScript + Element Plus + Vite + pnpm
- **AI**:DeepSeek(deepseek-chat),LangChain 为备选集成方案
## 多租户铁律(最重要)
- **entity_id 是租户隔离核心**:所有业务表必须有 entity_id 字段,查询必须按当前租户过滤
- 登录前选公司,token 绑定 entity_id(Redis 存 JSON `{user_id, entity_id}`)
- `deps.get_entity_id` 解析链:token优先(query/header不一致→403) → Bot白名单 → entity_id=1
- **写代码时永远不要忽略 entity_id**——漏了就是跨租户数据泄漏(P0 安全bug)
## 编码规范
- Python:FastAPI 依赖注入用 `deps` 模块;Pydantic 校验输入
- SQLAlchemy 模型 NOT NULL 字段必须给默认值或明确插入(缺了 500: IntegrityError 1048)
- 例:Excel导入建KPI须补 entity_id=1, formula="-", data_source="Excel导入", data_owner="财务部", unit="元"
- 前端:表单负责部门/负责人字段**必须**下拉框动态加载(禁手输)
- 提交前:跑 pytest + 冒烟(health/bot/schema/login/kpis,登录须 entity_id=1)
## 修改边界
- 只改任务指明的文件;表结构迁移须先备份(backups/)
- 不破坏:`deploy.sh` 提交纪律、冒烟检查、`/api/cma/bot/*` 的 X-BOT-KEY 机制
- **安全敏感改动**(权限/支付/删除/核心表)完成后必须自审,标注风险
## 验证标准(怎么算完成)
1. 改完能跑:pytest 通过或冒烟脚本通过
2. 数据变化可回查(DESC 表/查库确认)
3. 生产部署走 deploy.sh(含备份+回滚预案)
4. 不允许"看起来对了"——必须有实际执行输出证明
## 常用命令
```bash
# 后端冒烟
cd /root/cma-management && bash deploy.sh smoke # 或 deploy.sh 完整部署
# 登录拿token(entity_id=1)
curl -s -X POST http://127.0.0.1:8010/api/login -H 'Content-Type: application/json' -d '{"username":"...","password":"..."}'
# 查服务
systemctl status cma-backend
```
+13 -98
View File
@@ -1,99 +1,14 @@
# 管理会计OS
企业级管理会计操作系统,基于BSC平衡计分卡框架,提供从战略制定到日常执行的全流程数字化管理。
## 项目结构
```
cma-management/
├── frontend/ # Vue3 + Vite + TypeScript + Element Plus
│ └── src/
│ ├── api/ # axios 接口封装
│ ├── layouts/ # 布局组件(左侧栏+顶栏)
│ ├── views/ # 页面组件
│ ├── router/ # 路由配置
│ └── permission.ts # 菜单+角色权限配置
├── backend/ # FastAPI + SQLAlchemy + MySQL
│ └── app/
│ ├── api/ # 路由层
│ ├── models/ # 数据模型
│ └── utils/ # 工具函数
├── docs/ # 需求文档和设计文档
├── ARCHITECTURE.md # 架构说明
└── CHANGELOG.md # 版本变更记录
```
## 分支策略 (Git Flow)
```
main ─── 生产分支,只从 release 合并
develop ─── 开发主分支
feature/* ─── 新功能分支,从 develop 拉出,合并回 develop
release/* ─── 发布分支,从 develop 拉出,合并到 main + develop
hotfix/* ─── 紧急修复,从 main 拉出,合并到 main + develop
```
### 分支命名规范
- 功能分支:`feature/模块名-简要描述` 如 `feature/战略回顾会-聚合API`
- 发布分支:`release/v版本号` 如 `release/v1.1.0`
- 修复分支:`hotfix/简要描述` 如 `hotfix/登录token过期`
## 开发流程
1. 从 develop 拉出 feature 分支
2. 在 feature 分支上开发和测试
3. 提交 PR/MR 合并到 develop(至少1人review)
4. 从 develop 拉出 release 分支做最终测试
5. 发布前更新 CHANGELOG.md
6. 合并到 main + 打 tag
7. 部署后切回 develop
## 版本号规范
遵循语义化版本:`主版本.次版本.修订号`
- 主版本:不兼容的API/架构变更
- 次版本:向下兼容的新功能
- 修订号:向下兼容的bug修复
## 技术栈
| 层 | 技术 | 说明 |
|----|------|------|
| 前端框架 | Vue 3 + Vite + TypeScript | 组合式API |
| UI组件 | Element Plus | 后台管理组件库 |
| 后端框架 | FastAPI | Python异步框架 |
| ORM | SQLAlchemy 2.0 | 数据库映射 |
| 数据库 | MySQL 8.0 | 主数据存储 |
| 缓存 | Redis | Token存储+数据缓存 |
| 部署 | systemd + Nginx | 反向代理+服务管理 |
## 启动方式
### 后端
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --host 127.0.0.1 --port 8010
```
### 前端
```bash
cd frontend
npm install
npm run dev
```
### 生产部署
```bash
# 后端
systemctl restart cma-backend
# 前端
cd frontend && npm run build
cp -r dist/* /var/www/cma/
```
P0/P1/P2全功能已提交,CI/CD自动构建中
CI/CD: Woodpecker自动构建部署
CI验证: Sun Jul 12 05:13:08 PM CST 2026
webhook测试: 17:13:27
CI验证完成 17:15:10
CI最终验证: 17:16:24
CI全链路验证通过 ✅
woodpecker重启验证
gitea重启后验证
CI最终验证 17:19
最终测试 17:20:06
hash验证
Git Hooks自动部署验证 17:32
+1
View File
@@ -0,0 +1 @@
此目录已归入 /root/projects/cma/backend — 管理会计OS
+96
View File
@@ -0,0 +1,96 @@
# CMA Epic 2 — KPI数据分析增强和驾驶舱优化
> 技术方案 v1.0 | 2026-06-13
## 一、现状分析
### 现有系统状态
- **后端**: FastAPI @ 127.0.0.1:8010,运行正常
- **数据库**: cma.db,18个活跃KPI,4个维度(finance:8, customer:3, process:3, learning:4)
- **预警**: 16个待处理预警
- **Dashboard.vue**: CEO/Finance/Business/IT四角色视图,已有KPI矩阵、预测、简报等功能
- **MyDashboard.vue**: PDCA管理闭环、趋势柱状图
- **deviation_engine.py**: 已有同比/环比计算基础函数(calc_period_diff),但未被dashboard API集成
- **ai_analysis.py**: 已集成DeepSeek API做CEO简报和KPI分析
### 待开发功能
1. **同比环比趋势分析** — deviation_engine.py已有calc_period_diff,需集成到dashboard API
2. **预警趋势统计** — 按等级/维度/时间的统计API
3. **KPI数据导出CSV** — 导出功能
4. **驾驶舱KPI增强** — 增加trend字段和achievement_rate
5. **Dashboard.vue趋势分析tab** — ECharts折线图
6. **Dashboard.vue预警统计卡片** — 饼图+趋势线
7. **Dashboard.vue达成率进度条** — 已有简单进度条,增强可视化
## 二、后端新增API
### 1. KPI同比环比趋势分析
```
POST /api/cma/dashboard/trend-analysis
参数: kpi_ids (list[int]), period_type (month/quarter/year), compare_type (yoy/mom)
返回: {
data: [{
kpi_id, kpi_code, kpi_name, unit,
current_value, current_period,
previous_value, previous_period,
change_rate, # 变化率(%)
change_amount, # 变化额
trend_direction, # up/down/stable
dimension
}]
}
```
### 2. 预警趋势统计
```
GET /api/cma/dashboard/alert-stats
参数: period (month/quarter/year)
返回: {
total_pending: N,
by_severity: { red: N, yellow: N, green: N },
by_dimension: [{ dimension, count }],
trend_by_month: [{ month, red, yellow, green }]
}
```
### 3. KPI数据导出CSV
```
GET /api/cma/dashboard/export
参数: kpi_ids (comma-separated), period
返回: CSV文件流 (Content-Type: text/csv)
```
### 4. 驾驶舱KPI增强(修改现有get_dashboard_kpis)
- 每个KPI增加 `trend` 字段(最近3期环比变化率)
- 增加 `achievement_rate` 字段(actual_value / target_value)
- 增加 `period_values` 数组(最近6期数据,供前端画趋势图)
## 三、前端改造
### Dashboard.vue 增强(CEO视图)
1. **趋势分析标签页** — ECharts折线图,支持同比/环比切换
2. **预警统计卡片** — 饼图(severity分布) + 趋势折线
3. **KPI卡片增强** — 达成率百分比 + 彩色进度条 + 趋势箭头
4. **数据导出按钮** — 调用export API下载CSV
### 前端API扩展
在 `/frontend/src/api/index.ts` 的 `dashboardApi` 中增加:
- `trendAnalysis: (params) => api.post('/dashboard/trend-analysis', params)`
- `alertStats: (params) => api.get('/dashboard/alert-stats', { params })`
- `exportKpis: (params) => api.get('/dashboard/export', { params, responseType: 'blob' })`
## 四、执行顺序
```
Step 1 (并行): Backend → 趋势分析API + 预警统计API + 导出API
Frontend → API扩展定义(与后端同步)
Step 2 (串行, 依赖Step1): Frontend → Dashboard.vue改造
Step 3 (串行, 依赖Step2): DevOps → 部署重启
Step 4 (串行, 依赖Step3): QA → 全流程验证
```
## 五、依赖关系
- trend-analysis API: 可直接复用deviation_engine.py的calc_period_diff
- alert-stats API: 可直接从KPIAlert表聚合统计
- export API: 无依赖
- Dashboard.vue趋势tab: 依赖Step1的API
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
# 管理会计OS — 数据库配置
CMA_DB_USER=cma_user
CMA_DB_PASS=cma_pass_2026
CMA_DB_PASS=__CHANGE_ME__
CMA_DB_HOST=127.0.0.1
CMA_DB_PORT=3306
CMA_DB_NAME=cma
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+562 -22
View File
@@ -1,25 +1,109 @@
"""改善行动计划 API — 管理会计OS"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy import or_
from sqlalchemy.orm import Session
from datetime import datetime
from datetime import datetime, timezone, timedelta
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role, require_auth
from app.models import ActionPlan, KPIAlert, KPIDefinition, User
import re
import logging
from calendar import monthrange
from app.database import get_db
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
from app.auth_middleware import require_role, require_auth
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")
# 区分「未传该字段」与「显式传 null」的哨兵(update_plan 的 objective_id 口径,2026-09-18)
_UNSET = object()
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ──────────────────────────────────────────────
# 工具函数
# ──────────────────────────────────────────────
def _quarter_to_date_range(quarter: str) -> tuple:
"""解析季度字符串 '2026Q3' → (start_date, end_date)"""
m = re.match(r"^(\d{4})[Qq]([1-4])$", quarter.strip())
if not m:
return None, None
year = int(m.group(1))
q = int(m.group(2))
month_map = {1: (1, 1), 2: (4, 1), 3: (7, 1), 4: (10, 1)}
start_month, start_day = month_map[q]
end_month = start_month + 2
if end_month > 12:
end_month -= 12
end_year = year + 1
else:
end_year = year
_, last_day = monthrange(end_year, end_month)
return (
datetime(year, start_month, start_day, tzinfo=timezone.utc),
datetime(end_year, end_month, last_day, 23, 59, 59, tzinfo=timezone.utc),
)
def _validate_due_date_against_quarter(due_date: datetime, quarter: str):
"""校验截止日期是否在季度范围内,不匹配则抛422"""
q_start, q_end = _quarter_to_date_range(quarter)
if q_start is None:
return # 无法解析季度,跳过校验
due = due_date if due_date.tzinfo else due_date.replace(tzinfo=timezone.utc)
if due < q_start:
raise HTTPException(422,
f"KR截止日期({due.date()})早于本季度开始({q_start.date()}),请检查")
if due > q_end:
raise HTTPException(422,
f"KR截止日期({due.date()})超出本季度范围({q_end.date()}),最大截止为{q_end.date()}")
# ──────────────────────────────────────────────
# 「已逾期」派生口径(单一来源,2026-09-21 project-20260921-actionplan-overdue-filter)
# ──────────────────────────────────────────────
# 口径:overdue = status in (pending, in_progress) AND due_date < 今天0点
# (**日期粒度 · 口径 B**,任总 2026-09-22 10:10 拍板;当天到期不算逾期。
# 2026-09-21 首版为 datetime 粒度 `due_date < now`,本次仅此一处改为日粒度。)
#
# 为什么要有这个函数(缺陷根因):status 的取值域只有
# pending / in_progress / completed / cancelled —— **没有** overdue 这个值。
# 前端把统计卡片的 key('overdue')直接当 status 传给列表端点
# (frontend/src/views/ActionPlanLibrary.vue:414 卡片 key、:26 filterStatus、:422 params.status),
# 而 list_plans 原实现按 `ActionPlan.status == 'overdue'` 直查 → 恒 0 条,
# 于是出现「卡片 7 条 ≠ 列表 0 条」。
# 统计端点 plan_stats 早就用的是派生口径 —— 两处口径分家正是本次不一致的来源。
# 故把派生口径抽成本函数,plan_stats 与 list_plans **共用同一实现**(同源),
# 今后口径只此一处,不再出现「卡片 ≠ 列表」。
OVERDUE_KEY = "overdue"
OVERDUE_STATUSES = ("pending", "in_progress")
def _apply_overdue(query):
"""把「已逾期」派生口径施加到 ActionPlan 查询上(stats / list 共用)。
口径 B(日期粒度,任总 2026-09-22 拍板):due_date < **今天 0 点** —— 当天到期不算逾期。
与 cash.py::_plan_dict(应收催收 overdue)及前端行内角标
ActionPlanLibrary.vue::isOverdue 同粒度(二者本就是日期粒度,本单未动)。
"""
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
return query.filter(
ActionPlan.status.in_(list(OVERDUE_STATUSES)),
ActionPlan.due_date < today_start,
)
def plan_to_dict(p: ActionPlan) -> dict:
return {
"id": p.id,
"alert_id": p.alert_id,
"kpi_id": p.kpi_id,
"objective_id": p.objective_id,
"kr_id": p.kr_id,
"title": p.title,
"description": p.description,
"assignee": p.assignee,
@@ -34,31 +118,44 @@ def plan_to_dict(p: ActionPlan) -> dict:
}
# ──────────────────────────────────────────────
# API 端点
# ──────────────────────────────────────────────
@router.get("")
def list_plans(
status: Optional[str] = None,
kpi_id: Optional[int] = None,
alert_id: Optional[int] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""获取行动计划列表"""
query = db.query(ActionPlan).order_by(ActionPlan.created_at.desc())
if status:
"""获取行动计划列表(账套隔离: 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 == OVERDUE_KEY:
# 「已逾期」是**派生条件**而非 status 值(口径与 /stats 同源,2026-09-21)
# 前端统计卡片直接把 key='overdue' 当 status 传来,故此处按派生口径过滤,
# 与 plan_stats 共用 _apply_overdue,保证「卡片条数 = 列表条数」。
query = _apply_overdue(query)
elif status:
query = query.filter(ActionPlan.status == status)
if kpi_id:
query = query.filter(ActionPlan.kpi_id == kpi_id)
if alert_id:
query = query.filter(ActionPlan.alert_id == alert_id)
if keyword:
query = query.filter(ActionPlan.title.like(f"%{keyword}%"))
# business角色只看自己的
if current_user.role == "business":
query = query.filter(
(ActionPlan.assignee == current_user.username) |
(ActionPlan.assignee == current_user.name)
)
plans = query.all()
result = []
for p in plans:
@@ -66,8 +163,21 @@ 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:
# 账套隔离(2026-09-18 batch3 读侧同族残余):kr_id 可能指向对端账套 KR(存量脏行,
# 写入侧 create_plan/update_plan 已校验归属但旧行仍存在)→ 富化查询必须带账套,
# 否则列表跨账套回显对端 KR 标题
kr = db.query(KR).filter(KR.id == p.kr_id, KR.entity_id == entity_id).first()
item["kr_title"] = kr.title if kr else None
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
else:
item["kr_title"] = None
item["kr_metric_kpi_id"] = None
result.append(item)
return {"data": result}
@@ -76,42 +186,126 @@ def create_plan(
data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""创建改善行动计划"""
"""创建改善行动计划(也是OKR的KR)"""
required = ["title", "kpi_id"]
for field in required:
if field not in data:
raise HTTPException(400, f"缺少必填字段: {field}")
# 账套隔离: 关联KPI必须属于当前企业 (2026-08-23 P1b)
kpi_ent = db.query(KPIDefinition).filter(KPIDefinition.id == data["kpi_id"]).first()
if not kpi_ent or kpi_ent.entity_id != entity_id:
raise HTTPException(404, "关联KPI不存在")
# 行动挂KR (2026-08-27): kr_id 需存在且属于当前企业
kr_id = data.get("kr_id")
if kr_id:
kr_ent = db.query(KR).filter(KR.id == kr_id, KR.entity_id == entity_id).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
# 未显式传objective_id时从KR继承目标
if not data.get("objective_id"):
data["objective_id"] = kr_ent.objective_id
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
# 账套隔离: 关联Objective必须属于当前企业(2026-09-18 batch3 派生单)
# 旧实现只按 Objective.id 查询(仅用于季度校验),不校验 entity_id,
# 随后 ActionPlan(objective_id=...) 直接落库 → 本账套计划可挂到对端账套目标
objective_id = data.get("objective_id")
if objective_id:
obj = db.query(Objective).filter(
Objective.id == objective_id, Objective.entity_id == entity_id).first()
if not obj:
raise HTTPException(404, "关联目标不存在")
# 校验截止日期与关联Objective的季度匹配
if due_date and obj.quarter:
_validate_due_date_against_quarter(due_date, obj.quarter)
plan = ActionPlan(
# 账套隔离(2026-09-18 batch3 派生单):必须显式写会话账套 ——
# 旧实现不写 entity_id,落模型默认值 1(e2 会话创建的计划库内 entity_id=1,
# 实测 OBS1)。list/stats/update/delete 靠 join KPIDefinition 判账套,
# 但任何按 ActionPlan.entity_id 过滤的读路径(如 OKR 分解视图)会失效。
entity_id=entity_id,
alert_id=data.get("alert_id"),
kpi_id=data["kpi_id"],
objective_id=objective_id,
kr_id=kr_id,
title=data["title"],
description=data.get("description"),
assignee=data.get("assignee"),
priority=data.get("priority", "medium"),
due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
due_date=due_date,
status="pending",
progress=0,
created_by=current_user.name or current_user.username,
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)
# OKR时间分解:KR创建时自动生成3个月度里程碑(按截止日期向前均分)
if due_date and not plan.monthly_milestones:
try:
plan.monthly_milestones = _auto_build_milestones(due_date)
db.commit()
except Exception:
db.rollback() # 里程碑生成失败不影响KR创建
db.refresh(plan)
return plan_to_dict(plan)
def _auto_build_milestones(due_date: datetime) -> list:
"""按截止日期向前均分3个月度里程碑"""
base = due_date.replace(day=1)
month_keys = []
for i in range(3, 0, -1):
y, m = base.year, base.month - i
while m <= 0:
m += 12
y -= 1
month_keys.append(f"{y:04d}-{m:02d}")
return [
{
"month": mk,
"label": f"里程碑{i + 1}",
"status": "pending",
"target": None,
}
for i, mk in enumerate(month_keys)
]
@router.put("/{plan_id}")
def update_plan(
plan_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
"""更新行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
if "title" in data:
plan.title = data["title"]
if "description" in data:
@@ -120,6 +314,35 @@ def update_plan(
plan.assignee = data["assignee"]
if "priority" in data:
plan.priority = data["priority"]
# 目标/KR 归属口径(2026-09-18 batch3 派生单):
# ① 显式传 objective_id(含 null=显式置空)→ 以入参为准(优先于 kr_id 推导值)
# ② 未传 objective_id、且改挂合法 KR、且计划当前无目标 → 继承该 KR 的目标
# ③ 未传 objective_id 且计划已有目标 → 保持不动
# 两者都必须是当前账套的数据,否则 404;校验全部放在改动 plan 之前,
# 保证 404 请求对计划零改动(旧实现无归属校验,且末尾无条件覆盖推导值)
kr_ent = _UNSET
if "kr_id" in data and data["kr_id"] is not None:
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan 既有写法;2026-09-18 batch3)
kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
objective_change = _UNSET
if "objective_id" in data:
objective_change = data["objective_id"] # 口径①
elif kr_ent is not _UNSET and plan.objective_id is None:
objective_change = kr_ent.objective_id # 口径②
if objective_change is not _UNSET and objective_change is not None:
obj_ent = db.query(Objective).filter(
Objective.id == objective_change, Objective.entity_id == entity_id).first()
if not obj_ent:
raise HTTPException(404, "关联目标不存在")
# 校验全部通过后落值(口径③:objective_change 仍为 _UNSET 时目标保持不动)
if "kr_id" in data:
plan.kr_id = kr_ent.id if kr_ent is not _UNSET else None
if objective_change is not _UNSET:
plan.objective_id = objective_change
if "due_date" in data:
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
if "status" in data:
@@ -128,18 +351,335 @@ def update_plan(
plan.progress = max(0, min(100, data["progress"]))
if "result" in data:
plan.result = data["result"]
db.commit()
db.refresh(plan)
return plan_to_dict(plan)
@router.delete("/{plan_id}")
def delete_plan(plan_id: int, db: Session = Depends(get_db)):
"""删除行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
def delete_plan(plan_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""删除行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
db.delete(plan)
db.commit()
return {"message": "已删除"}
@router.get("/stats")
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id)):
"""行动计划统计(账套隔离: join KPI 按企业过滤, 2026-09-18 batch3)
口径说明(为何改):本端点原为全局统计(仅 business 角色按 assignee 过滤)→ 非 e1 账套
用户看到的是含其它账套计划的合计,与同文件列表端点 list_plans(join KPIDefinition 按账套)
的可见集不一致。现按同文件既有先例(update_plan/delete_plan 的 join(KPIDefinition))过滤,
保证「统计数 = 列表可见行数」。数据侧存在 action_plans 行 entity_id 与 KPI 归属不一致的
存量行(batch2 报告 §六.2),统一以 KPI 归属为准与列表口径一致。
"""
query = (db.query(ActionPlan)
.join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id)
.filter(KPIDefinition.entity_id == entity_id))
if current_user.role == "business":
query = query.filter(
(ActionPlan.assignee == current_user.username) |
(ActionPlan.assignee == current_user.name)
)
total = query.count()
pending = query.filter(ActionPlan.status == "pending").count()
in_progress = query.filter(ActionPlan.status == "in_progress").count()
completed = query.filter(ActionPlan.status == "completed").count()
overdue = _apply_overdue(query).count()
return {
"total": total,
"pending": pending,
"in_progress": in_progress,
"completed": completed,
"overdue": overdue,
}
# ──────────────────────────────────────────────
# COSO内控自检表 (CMA P1 - COSO五要素)
# ──────────────────────────────────────────────
COSO_CHECKLIST_DATA = {
"hanke": {
"entity_name": "陕西酣客(白酒经销)",
"total_score": 46,
"max_score": 100,
"risk_level": "high",
"risk_label": "高风险",
"elements": [
{
"id": "control_environment", "name": "控制环境", "name_en": "Control Environment",
"score": 60, "max_score": 100, "status": "medium",
"items": [
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 任总亲自跟"},
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务"},
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 渠补无标准审批流程"},
{"id": "ce_04", "text": "人事政策", "passed": False, "detail": "❌ 无定期轮岗"},
],
},
{
"id": "risk_assessment", "name": "风险评估", "name_en": "Risk Assessment",
"score": 40, "max_score": 100, "status": "low",
"items": [
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 没有系统风险清单"},
{"id": "ra_02", "text": "风险应对预案", "passed": False, "detail": "❌ 现金断流无预案"},
],
},
{
"id": "control_activities", "name": "控制活动", "name_en": "Control Activities",
"score": 30, "max_score": 100, "status": "low",
"items": [
{"id": "ca_01", "text": "渠补审批流程", "passed": False, "detail": "❌ 口头谈,无记录"},
{"id": "ca_02", "text": "费用审批流程", "passed": False, "detail": "❌ 超预算无拦截"},
{"id": "ca_03", "text": "实物返利入账流程", "passed": False, "detail": "❌ 纯P&L不进系统"},
],
},
{
"id": "information_communication", "name": "信息与沟通", "name_en": "Information & Communication",
"score": 70, "max_score": 100, "status": "medium",
"items": [
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
{"id": "ic_02", "text": "系统数据互通", "passed": False, "detail": "❌ 进销存≠财务账"},
],
},
{
"id": "monitoring", "name": "监控", "name_en": "Monitoring",
"score": 30, "max_score": 100, "status": "low",
"items": [
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
{"id": "mo_02", "text": "异常追踪机制", "passed": False, "detail": "❌ 发现异常无跟踪"},
],
},
],
},
"bohai": {
"entity_name": "陕西博海科技(IT服务)",
"total_score": 55,
"max_score": 100,
"risk_level": "medium",
"risk_label": "中风险",
"elements": [
{
"id": "control_environment", "name": "控制环境", "name_en": "Control Environment",
"score": 70, "max_score": 100, "status": "medium",
"items": [
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 老板直接管"},
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务≠技术"},
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 部分项目无预算审批"},
],
},
{
"id": "risk_assessment", "name": "风险评估", "name_en": "Risk Assessment",
"score": 50, "max_score": 100, "status": "low",
"items": [
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 无正式风险清单"},
{"id": "ra_02", "text": "风险应对预案", "passed": True, "detail": "✅ 重点项目有预案"},
],
},
{
"id": "control_activities", "name": "控制活动", "name_en": "Control Activities",
"score": 50, "max_score": 100, "status": "low",
"items": [
{"id": "ca_01", "text": "采购审批流程", "passed": True, "detail": "✅ 有标准流程"},
{"id": "ca_02", "text": "项目交付流程", "passed": False, "detail": "❌ 验收流程不完善"},
],
},
{
"id": "information_communication", "name": "信息与沟通", "name_en": "Information & Communication",
"score": 60, "max_score": 100, "status": "medium",
"items": [
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
{"id": "ic_02", "text": "项目沟通机制", "passed": False, "detail": "❌ 跨部门信息滞后"},
],
},
{
"id": "monitoring", "name": "监控", "name_en": "Monitoring",
"score": 40, "max_score": 100, "status": "low",
"items": [
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
{"id": "mo_02", "text": "异常追踪机制", "passed": True, "detail": "✅ 项目延期有跟踪"},
],
},
],
},
}
@router.get("/coso-checklist")
def get_coso_checklist(
request: Request,
entity: Optional[str] = None,
):
"""COSO内控自检表 - CMA P1 COSO五要素
多租户(cma-cross-entity-endpoints-auth-20260914):
非法实体值 → 400 显式报错(不再静默回落酣客数据);跨实体 → 403。
"""
slug = assert_entity_slug_scope(request, entity)
if slug is None:
slug = resolve_session_slug(request, "hanke")
data = COSO_CHECKLIST_DATA.get(slug)
if not data:
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
return data
@router.post("/coso-generate-actions")
def generate_coso_actions(
request: Request,
data: Optional[dict] = Body(default=None),
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""COSO内控自检 → 生成改善行动(把自检「未通过」条目一键转为行动计划)
口径说明:
- 账套(多租户铁律):写入 entity_id 只取 token 绑定账套(get_entity_id),
body 里若含 entity_id 字段一律忽略;
- 数据源:与 get_coso_checklist 同口径(resolve_session_slug 按会话账套取同一份
自检)——屏幕上看到哪份自检,就用哪份生成,不做跨账套内容搬运;
- 关联 KPI:action_plans.kpi_id 是 NOT NULL 且列表按 KPI 归属做账套 join,
故必须挂本账套 KPI;
- 幂等:判重键 (entity_id, title),已存在的计入 skipped 不再 INSERT;
- auto_verify_rule 显式不生成:COSO 条目没有可复算的 KPI 判据。
"""
data = data or {}
# ① 取自检数据(与只读端点同口径解析 slug)
slug = resolve_session_slug(request, "hanke")
coso = COSO_CHECKLIST_DATA.get(slug)
if not coso:
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
# ② 待生成条目:passed 为真的一律不生成
failed_items = []
for el in coso["elements"]:
for item in el.get("items") or []:
if not item.get("passed"):
failed_items.append((el, item))
# ③ 无未通过条目 → 返回空结果结构(不算错误)
if not failed_items:
return {
"entity_id": entity_id,
"entity_slug": slug,
"kpi_id": None,
"kpi_name": None,
"failed_total": 0,
"created": 0,
"skipped": 0,
"created_ids": [],
"source": "coso",
}
# ④ 关联 KPI(必须,kpi_id NOT NULL 且列表按 KPI 归属做账套 join)
if data.get("kpi_id"):
# 显式指定:必须属于当前账套(跨账套 KPI 一律 404,不得跨账套写)
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == data["kpi_id"],
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "关联KPI不存在")
else:
# 自动选取(判据显式、可解释):
# a. 名称/编码含关键词(按序:内控/合规/COSO/control/compliance),id 最小者优先
# b. 都没有 → 本账套 id 最小的 KPI(确定性兜底)
# c. 本账套一个 KPI 都没有 → 400(无法满足 kpi_id NOT NULL)
kpi = None
for kw in ("内控", "合规", "COSO", "control", "compliance"):
kpi = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id)
.filter(or_(
KPIDefinition.kpi_name.like(f"%{kw}%"),
KPIDefinition.kpi_code.like(f"%{kw}%"),
))
.order_by(KPIDefinition.id)
.first())
if kpi:
break
if not kpi:
kpi = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id)
.order_by(KPIDefinition.id)
.first())
if not kpi:
raise HTTPException(400, "当前账套没有可关联的KPI,无法生成改善行动")
# ⑤ 截止日期:body 显式传则校验格式且不得早于今天;否则默认 +30 天
due_date = None
if data.get("due_date"):
try:
due_date = datetime.strptime(str(data["due_date"]), "%Y-%m-%d")
except (ValueError, TypeError):
raise HTTPException(400, f"due_date 格式非法: {data['due_date']}(应为 YYYY-MM-DD)")
if due_date.date() < datetime.now().date():
raise HTTPException(400, "due_date 不能早于今天")
else:
due_date = datetime.now() + timedelta(days=30)
# ⑥ 幂等判重键 (entity_id, title):带账套过滤 —— 对端账套同标题行不抑制本账套创建
existing_titles = {
p.title for p in db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).all()
}
created_plans = []
skipped_count = 0
for el, item in failed_items:
title = f"[COSO] {el['name']}·{item['text']}"[:200] # title 列宽 String(200)
if title in existing_titles:
skipped_count += 1
continue
existing_titles.add(title) # 本批次内自去重
score = el.get("score") or 0
if score < 40:
priority = "high"
elif score < 60:
priority = "medium"
else:
priority = "low"
plan = ActionPlan(
entity_id=entity_id,
kpi_id=kpi.id,
title=title,
# 来源标注便于回溯(自检哪条条目生成了这条行动)
description=(
f"来源: COSO内控自检 / {el['name']}({el['id']})/ 条目 {item['id']}: {item['text']}\n"
f"自检结论: {item.get('detail') or '未通过'}"
),
status="pending",
progress=0,
priority=priority,
due_date=due_date,
created_by=current_user.name or current_user.username,
# 不传 auto_verify_rule:该列是 MySQL json 列,显式传 None 会被落成 JSON null
# (JSON_TYPE()='NULL'、IS NOT NULL 为真)而不是 SQL NULL,会让 bot_iron_law 的
# auto_verify_rule.isnot(None) 把「没有规则」的行算成「有规则」。不赋值则 INSERT
# 省略该列 → 保持 SQL NULL。COSO 条目没有可复算判据,引擎对空规则是跳过(安全)。
)
db.add(plan)
created_plans.append(plan)
# ⑦ 单次 add 循环 + 一次 commit:任一异常向上抛,不留半批
db.commit()
created_ids = [p.id for p in created_plans]
return {
"entity_id": entity_id,
"entity_slug": slug,
"kpi_id": kpi.id,
"kpi_name": kpi.kpi_name,
"failed_total": len(failed_items), # 自检未通过条目总数
"created": len(created_ids), # 本次新建条数
"skipped": skipped_count, # 已存在(幂等命中)条数
"created_ids": created_ids, # 新建行 id 列表
"source": "coso",
}
+334 -32
View File
@@ -2,20 +2,256 @@
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
from app.utils.alert_entity_scope import month_period_clause
from app.utils.kpi_value_scope import actual_only_clause
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),
),
month_period_clause(KPIValue.period),
).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预警 → 建行动方案(多租户隔离 2026-09-07: 只取本账套预警)
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
).all()
for a in alerts:
k = db.query(KPIDefinition).filter(
KPIDefinition.id == a.kpi_id,
KPIDefinition.entity_id == entity_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 body(relay 会返回 msg is required,lead.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,18 +270,28 @@ 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()
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
kpi_summary.append({
"name": k.kpi_name,
"code": k.kpi_code,
@@ -56,8 +302,11 @@ async def dashboard_analysis(role: str = Query("ceo"), db: Session = Depends(get
"unit": k.unit,
})
# 获取预警
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
# 获取预警(多租户隔离 2026-09-07: 只统计本账套)
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
).count()
# 构建分析prompt
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
@@ -81,26 +330,42 @@ 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
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
# 跨企业 kpi_id 同样 404(多租户隔离 2026-09-07)
raise HTTPException(404, "KPI不存在")
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
# 实测区口径:预估行禁入 AI 趋势序列/LLM 上下文(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.period.asc()).all()
trend_data = []
for v in values:
@@ -129,7 +394,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 +413,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={
@@ -172,9 +444,11 @@ async def _stream_analysis(prompt: str):
@router.get("/dashboard-analysis-stream")
async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db)):
async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""AI分析驾驶舱数据 — SSE流式输出"""
cache_key = f"dashboard_analysis:{role}"
# 多租户隔离 2026-09-07: cache key 补 entity_id(原跨企业串缓存)
cache_key = f"dashboard_analysis:{role}:{entity_id}"
cached = cache_get("ai", cache_key)
if cached:
# 缓存存在,直接以流的形式一次性返回
@@ -184,16 +458,24 @@ async def dashboard_analysis_stream(role: str = Query("ceo"), db: Session = Depe
yield "data: {\"text\": \"[DONE]\"}\n\n"
return StreamingResponse(cached_stream(), media_type="text/event-stream")
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()
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
kpi_summary.append({
"name": k.kpi_name, "code": k.kpi_code, "dimension": k.dimension,
"target": k.target_value, "actual": latest.actual_value if latest else None,
"period": latest.period if latest else None, "unit": k.unit,
})
alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending").count()
alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
).count()
kpi_text = "\n".join([f"- {k['name']}({k['code']}): 目标={k['target']}, 实际={k['actual']}({k['period']}), 维度={k['dimension']}" for k in kpi_summary if k['actual'] is not None])
role_label = {"ceo": "CEO(总经理)", "finance": "财务部", "business": "业务部"}.get(role, "管理层")
prompt = f"""我是一家公司的管理层,以下是当前管理会计系统的KPI数据和系统状态,请给出专业的分析和管理建议:
@@ -218,6 +500,7 @@ async def ask_question(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""自然语言查询 — CEO问企业经营问题"""
body = await request.json()
@@ -226,24 +509,37 @@ async def ask_question(
if not question:
raise HTTPException(400, "请输入问题")
# 收集系统数据作为上下文
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
# 收集系统数据作为上下文(多租户隔离 2026-09-07: 只取本账套)
kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active",
).all()
kpi_context = []
for k in kpis:
latest = db.query(KPIValue).filter(KPIValue.kpi_id == k.id).order_by(KPIValue.period.desc()).first()
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
alert = db.query(KPIAlert).filter(KPIAlert.kpi_id == k.id, KPIAlert.status == "pending").first()
kpi_context.append(
f"{k.kpi_name}({k.kpi_code}): 当前值={latest.actual_value if latest else '无'}"
f"{' ⚠️' + alert.alert_level if alert else ''}"
)
# 获取改善计划
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.desc()).limit(10).all()
# 获取改善计划(多租户隔离 2026-09-07: 只取本账套)
plans = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id,
).order_by(ActionPlan.created_at.desc()).limit(10).all()
plan_context = [f"- {p.title}({p.assignee}, {p.status}, {p.progress}%)" for p in plans]
# 获取预警
red_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "red").count()
yellow_alerts = db.query(KPIAlert).filter(KPIAlert.status == "pending", KPIAlert.alert_level == "yellow").count()
red_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending", KPIAlert.alert_level == "red",
).count()
yellow_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending", KPIAlert.alert_level == "yellow",
).count()
system_context = f"""你是管理会计OS的AI助手,基于以下企业数据回答管理层问题。
@@ -275,9 +571,13 @@ async def ask_question(
async def review_plans(
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""AI复盘改善行动计划执行效果"""
plans = db.query(ActionPlan).order_by(ActionPlan.created_at.asc()).all()
# 多租户隔离 2026-09-07: ActionPlan 按 entity_id 过滤(表 2026-08-31 已加列回填)
plans = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id,
).order_by(ActionPlan.created_at.asc()).all()
if not plans:
return {"analysis": "暂无改善行动计划,无法复盘"}
@@ -286,7 +586,9 @@ async def review_plans(
for p in plans:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
kpi_name = kpi.kpi_name if kpi else "未知"
latest = db.query(KPIValue).filter(KPIValue.kpi_id == p.kpi_id).order_by(KPIValue.period.desc()).first()
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == p.kpi_id, month_period_clause(KPIValue.period)
).order_by(KPIValue.period.desc()).first()
plan_text.append(
f"- {p.title}\n"
f" 关联KPI: {kpi_name}(当前值: {latest.actual_value if latest else '无'})\n"
+428
View File
@@ -0,0 +1,428 @@
"""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),
entity_id: int = Depends(get_entity_id)):
"""建议详情"""
s = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).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,
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
).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={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套,端点已校验=会话账套)
"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,
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
).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={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
"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,
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单 2030b: 目标KPI须归属建议账套
).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(
entity_id=sug.entity_id, # 2026-09-18 派生单: 写建议归属账套(=会话账套,端点已校验)
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={
"entity_id": sug.entity_id, # 2026-09-23 ④:审计带账套(=建议账套)
"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,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 预览须归属会话账套(原依赖未用于过滤)
).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:
# 2026-09-18 派生单 2030b: 目标KPI须归属建议账套;对端KPI降级为 KPI#<id>(不回显名/目标值)
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == sug.entity_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),
entity_id: int = Depends(get_entity_id),
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,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).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),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""忽略建议"""
sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).first()
if not sug:
raise HTTPException(404, "建议不存在")
sug.status = "dismissed"
db.commit()
return {"success": True, "message": "建议已忽略"}
File diff suppressed because it is too large Load Diff
+357 -13
View File
@@ -1,30 +1,374 @@
"""预警 API"""
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, HTTPException, Request
from typing import Optional
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog
from app.models import KPIAlert, OperationLog, ActionPlan
import logging
logger = logging.getLogger("cma.alerts")
router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
@router.get("")
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db)):
query = db.query(KPIAlert)
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离(2026-09-19 dama-20260919 C1):按**预警行自身** entity_id 过滤
# 修复前按 KPI 归属企业过滤(2026-08-23 P1b)→ KPI 属他账套而预警行属本账套的行反向泄漏
# (实测 entity 2 口径 225 → 223,泄漏 id=589/590)
query = db.query(KPIAlert).filter(KPIAlert.entity_id == entity_id)
if status:
query = query.filter(KPIAlert.status == status)
total = query.count()
alerts = query.order_by(KPIAlert.created_at.desc()).offset((page-1)*20).limit(20).all()
# 分页确定性(2026-09-25 alerts-list-pagination-skip):原为 created_at.desc() **单键** +
# offset/limit — kpi_alerts 每批 07:00 生成、**大量行同秒 created_at**,MySQL 并列组内顺序
# 不确定 ⇒ 偏移分页重复并漏行(entity 1 只读实测:total=281 / 逐页取满 281 行 / 唯一 id 仅 268 /
# 重复 13;id=587 在无过滤列表不可达,而同行 ?status=pending 首屏正常可见)。
# 补**确定性次键** id.desc()(同秒内后写入者在前)→ 全序分页,逐页遍历可覆盖全部 total 行。
alerts = (query.order_by(KPIAlert.created_at.desc(), KPIAlert.id.desc())
.offset((page-1)*20).limit(20).all())
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()
if alert:
alert.status = "resolved"
alert.resolution = data.get("resolution", "")
alert.assignee = data.get("assignee", alert.assignee)
from datetime import datetime; alert.resolved_at = datetime.now()
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
alert.status = "resolved"
alert.resolution = data.get("resolution", "")
alert.assignee = data.get("assignee", alert.assignee)
from datetime import datetime; alert.resolved_at = datetime.now()
db.commit()
db.refresh(alert)
return {
"message": "已处理",
"assignee": alert.assignee,
"alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns},
"suggest_create_action_plan": alert.alert_level == "red",
}
@router.post("/{alert_id}/process")
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""标记预警为处理中(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.status == "resolved":
raise HTTPException(400, "已处理的预警不能重复处理")
assignee = data.get("assignee")
if not assignee:
raise HTTPException(400, "缺少处理人")
alert.status = "processing"
alert.assignee = assignee
db.commit()
db.refresh(alert)
return {"message": "已标记为处理中", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
@router.post("/{alert_id}/escalate")
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""升级预警级别(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
assignee = data.get("assignee")
if not assignee:
raise HTTPException(400, "缺少处理人")
if alert.status == "resolved":
raise HTTPException(400, "已处理的预警不能升级")
if alert.alert_level != "red":
alert.alert_level = "red"
alert.assignee = assignee
db.commit()
return {"message": "已处理", "assignee": alert.assignee}
db.refresh(alert)
return {"message": "已升级", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
@router.get("/check-timeout")
def check_alert_timeout(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色
账套隔离(2026-09-19 写路径跨账套修复):按**预警行自身** KPIAlert.entity_id 过滤;
修复前 join kpi_definitions 按 KPI 归属企业过滤 → KPI 属他账套而预警行属本账套的行
会被本账套调用**批量改写**(升级 red + 置 processing),比只读泄漏更严重
(2026-08 之前为全账套批处理;2026-09-18 加的 join 口径见 dama-20260919 派生单)。
"""
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24)
timeout_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
KPIAlert.created_at < cutoff,
KPIAlert.alert_level != "red",
).all()
upgraded_count = 0
for alert in timeout_alerts:
alert.alert_level = "red"
alert.status = "processing"
upgraded_count += 1
if upgraded_count:
db.commit()
return {"total_timeout": len(timeout_alerts), "upgraded_count": upgraded_count}
@router.post("/{alert_id}/create-action-plan")
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""从预警创建改善行动计划(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.action_plan_linked_id:
raise HTTPException(400, "已关联改善计划")
plan = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
alert_id=alert.id,
kpi_id=alert.kpi_id,
title=f"改善: {alert.alert_message}",
assignee=data.get("assignee", ""),
priority="high" if alert.alert_level == "red" else "medium",
created_by=data.get("created_by", ""),
)
db.add(plan)
db.commit()
db.refresh(plan)
alert.action_plan_linked_id = plan.id
db.commit()
return {"message": "改善行动计划已创建", "plan_id": plan.id, "priority": plan.priority}
# ──────────────────────────────────────────────
# 功能4: 风险矩阵热力图 (CMA P2 - ERM框架、风险识别四象限)
RISK_MATRIX_DATA = {
"hanke": {
"entity_name": "陕西酣客(白酒经销)",
"quadrants": [
{
"impact": "high",
"probability": "high",
"label": "高影响×高概率",
"risks": [
{
"id": "risk_001",
"name": "流动性风险",
"impact_label": "高",
"probability_label": "高",
"detail": "现金2.2万 vs 短债350万 → 断流风险",
"impact_value": 90,
"probability_value": 85,
"type": "red",
"measures": ["催收大额应收", "协商短期借款续贷"],
"responsible": "任富海",
"deadline": "7月底",
},
{
"id": "risk_002",
"name": "合规风险",
"impact_label": "高",
"probability_label": "高",
"detail": "欠税426万 · 折旧违规",
"impact_value": 95,
"probability_value": 80,
"type": "red",
"measures": ["补缴欠税计划", "重新梳理折旧政策"],
"responsible": "任富海",
"deadline": "8月底",
},
],
},
{
"impact": "high",
"probability": "medium",
"label": "高影响×中概率",
"risks": [
{
"id": "risk_003",
"name": "政策风险",
"impact_label": "高",
"probability_label": "中",
"detail": "白酒消费税调整可能导致成本上升15-20%",
"impact_value": 85,
"probability_value": 50,
"type": "orange",
"measures": ["关注政策动向", "预留税务缓冲资金"],
"responsible": "财务部",
"deadline": "持续关注",
},
],
},
{
"impact": "medium",
"probability": "high",
"label": "中影响×高概率",
"risks": [
{
"id": "risk_004",
"name": "运营风险",
"impact_label": "中",
"probability_label": "高",
"detail": "Model C 成本模型未落地,成本核算偏差",
"impact_value": 60,
"probability_value": 80,
"type": "yellow",
"measures": ["推动Model C落地", "建立成本标准化流程"],
"responsible": "财务部",
"deadline": "8月中",
},
],
},
{
"impact": "medium",
"probability": "medium",
"label": "中影响×中概率",
"risks": [
{
"id": "risk_005",
"name": "战略风险",
"impact_label": "中",
"probability_label": "中",
"detail": "酒类零交易,新业务方向不确定",
"impact_value": 55,
"probability_value": 55,
"type": "yellow",
"measures": ["制定新业务评估框架", "定期战略复盘"],
"responsible": "管理层",
"deadline": "9月底",
},
],
},
{
"impact": "low",
"probability": "low",
"label": "低影响×低概率",
"risks": [
{
"id": "risk_006",
"name": "市场风险",
"impact_label": "低",
"probability_label": "低",
"detail": "行业需求波动,但酣客已基本退出市场",
"impact_value": 25,
"probability_value": 20,
"type": "green",
"measures": ["定期监控行业数据"],
"responsible": "业务部",
"deadline": "每季度",
},
{
"id": "risk_007",
"name": "人员风险",
"impact_label": "低",
"probability_label": "低",
"detail": "核心团队稳定,短期内无流失风险",
"impact_value": 20,
"probability_value": 15,
"type": "green",
"measures": ["保持团队激励", "关键岗位备份"],
"responsible": "人事部",
"deadline": "持续",
},
],
},
],
},
"bohai": {
"entity_name": "陕西博海科技(IT服务)",
"quadrants": [
{
"impact": "high",
"probability": "medium",
"label": "高影响×中概率",
"risks": [
{
"id": "risk_b_001",
"name": "现金流风险",
"impact_label": "高",
"probability_label": "中",
"detail": "应收账款账期延长,现金流紧张",
"impact_value": 85,
"probability_value": 55,
"type": "orange",
"measures": ["加快应收催收", "建立信用管理制度"],
"responsible": "任富海",
"deadline": "7月底",
},
],
},
{
"impact": "medium",
"probability": "high",
"label": "中影响×高概率",
"risks": [
{
"id": "risk_b_002",
"name": "项目交付风险",
"impact_label": "中",
"probability_label": "高",
"detail": "多个项目并行,交付压力大",
"impact_value": 65,
"probability_value": 75,
"type": "yellow",
"measures": ["优化项目排期", "增加外包资源"],
"responsible": "项目部",
"deadline": "持续",
},
],
},
{
"impact": "low",
"probability": "medium",
"label": "低影响×中概率",
"risks": [
{
"id": "risk_b_003",
"name": "技术迭代风险",
"impact_label": "低",
"probability_label": "中",
"detail": "新技术跟踪不及时,可能落后",
"impact_value": 30,
"probability_value": 45,
"type": "green",
"measures": ["定期技术培训", "技术栈评估"],
"responsible": "技术部",
"deadline": "每季度",
},
],
},
],
},
}
@router.get("/risk-matrix")
def get_risk_matrix(
request: Request,
entity: Optional[str] = Query(None, description="hanke/bohai(须与会话账套一致);缺省按会话账套"),
):
"""风险矩阵热力图数据 - CMA P2 ERM四象限
多租户(cma-cross-entity-endpoints-auth-20260914):
非法实体值 → 400 显式报错(不再静默回落酣客数据);跨实体 → 403。
"""
slug = assert_entity_slug_scope(request, entity)
if slug is None:
slug = resolve_session_slug(request, "hanke")
data = RISK_MATRIX_DATA.get(slug)
if not data:
raise HTTPException(404, f"实体 entity={slug} 无风险矩阵数据")
return data
+102 -18
View File
@@ -3,20 +3,47 @@
管理员可初始化选择,后续按模式运作"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from sqlalchemy import func, or_
from typing import Optional, List
from datetime import datetime
import json
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, OperationLog, RolePermission
from app.auth_middleware import require_auth, require_role, require_auth_401
from app.deps import get_entity_id
from app.models import KPIDefinition, MapObjective, OperationLog, RolePermission, StrategicMap
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
# 不设全局权限,每个接口单独控制
# 用户端点:router 级要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914);
# 原注释「不设全局权限,每个接口单独控制」保留语义——写接口仍各自叠加 require_role,
# router 级只是「至少已认证」底线(补齐 /modes、GET /config 原无鉴权的缺口)。
dependencies=[Depends(require_auth_401)],
)
# 三种对齐模式定义
# ── B3(a) 对齐模式拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
# 背景:`role_permissions.key` 为 unique 单行键 → `alignment_config` 全库一行,
# 任一账套的 ceo/it 改对齐模式 → **全账套生效**(跨账套配置污染)。
# 治本(方案 A,零 DDL):键名带账套后缀 → `alignment_config.<entity_id>`。
# · 读:只读本账套键;未配置 → configured=false(/tree 提示先初始化),
# **不回退旧全局键**(回退即恢复跨账套生效)。
# · 写:只写本账套键(entity 来自会话 token)。
# · 旧值搬运:脚本 scripts/migrate_cash_balance_key_entity_20260922.py --check/--apply
# (同一 500 口径:原全局值落到「当前生产账套」名下;--down 删新键并写回旧键)。
ALIGNMENT_KEY_PREFIX = "alignment_config"
def alignment_key(entity_id: int) -> str:
"""账套维度的对齐模式键名:alignment_config.<entity_id>"""
return f"{ALIGNMENT_KEY_PREFIX}.{int(entity_id)}"
def _get_alignment_perm(db: Session, entity_id: int):
"""读本账套的对齐模式配置行(不存在 → None)"""
return db.query(RolePermission).filter(
RolePermission.key == alignment_key(entity_id)
).first()
ALIGNMENT_MODES = [
{
"key": "vertical_decomposition",
@@ -46,18 +73,25 @@ def list_modes():
@router.get("/config")
def get_alignment_config(db: Session = Depends(get_db)):
"""获取当前系统对齐模式配置(公开接口,无需认证)"""
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
def get_alignment_config(entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db)):
"""获取**当前账套**的对齐模式配置(B3(a) 拆账套,2026-09-22)
修复前:读全局唯一键 `alignment_config` → 任一账套改模式全平台生效。
修复后:只读 `alignment_config.<entity_id>`;未配置 → configured=false。
"""
perm = _get_alignment_perm(db, entity_id)
if not perm:
return {
"mode": None,
"configured": False,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES,
}
return {
"mode": perm.value,
"configured": True,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES,
}
@@ -65,45 +99,95 @@ def get_alignment_config(db: Session = Depends(get_db)):
@router.post("/config")
def set_alignment_config(
data: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
user = Depends(require_role("ceo", "it")),
):
"""初始化/修改系统对齐模式(CEO/IT权限)"""
"""初始化/修改**当前账套**的对齐模式(CEO/IT权限)
B3(a)(2026-09-22):只写 `alignment_config.<entity_id>`,不覆盖他账套;
entity 来自会话 token,显式传他账套 entity_id → get_entity_id 403。
"""
mode_key = data.get("mode")
if mode_key not in [m["key"] for m in ALIGNMENT_MODES]:
raise HTTPException(400, f"无效的对齐模式: {mode_key}")
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
perm = _get_alignment_perm(db, entity_id)
if perm:
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat()}
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat(),
"entity_id": entity_id}
else:
perm = RolePermission(key="alignment_config", value={"mode": mode_key, "set_at": datetime.now().isoformat()})
perm = RolePermission(key=alignment_key(entity_id),
value={"mode": mode_key, "set_at": datetime.now().isoformat(),
"entity_id": entity_id})
db.add(perm)
db.commit()
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key}
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key, "entity_id": entity_id}
@router.get("/tree")
def get_alignment_tree(
kpi_id: Optional[int] = Query(None),
map_id: Optional[int] = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
user = Depends(require_auth),
):
"""获取KPI对齐关系树
"""获取KPI对齐关系树(按当前账套隔离)
根据当前系统配置的对齐模式,返回KPI的父子层级关系。
如果指定kpi_id,返回该KPI及其下级树;
如果不指定,返回整个对齐树。
账套隔离(2026-09-18 project-20260918-alignment-entity-leak):
只取 entity_id 归属本账套的 KPI(entity_id 来自会话 token 绑定,写法对齐
growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。
跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。
地图筛选(2026-10-01 project-20261001-align-tree-map-filter-fullstack):
`map_id` 参与**KPI 选择集**过滤,且只作用于 KPI 选择集——三种模式的构建函数不变。
过滤取「本账套该地图已关联的 KPI」,关联源按**权威程度**合并两条通道:
① `map_objectives.kpis`(**战略地图编辑器**写入的 KPI 编码数组,真源
`maps.py::_sync_map_objectives`;现网唯一有内容的通道);
② `KPIDefinition.map_id`(KPI 详情页「关联地图」写入;现网 329 行全为 NULL)。
两者取并集后与「本账套 `status='active'`」求交。跨账套/不存在的地图 id:先核该地图
归属本账套,不属本账套即返回**空树**(不泄漏他账套 KPI)。
不传 `map_id`:逐字段维持改前行为(本账套全部 active KPI)。
"""
# 获取当前模式
config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
# 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.<entity_id>)
config_perm = _get_alignment_perm(db, entity_id)
mode = config_perm.value.get("mode") if config_perm else None
if not mode:
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
# 获取所有KPI
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").order_by(KPIDefinition.kpi_code).all()
# 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI)
kpi_query = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.status == "active"))
# 地图筛选:只缩小 KPI 选择集,不改动任何构建逻辑
empty_by_map = False
if map_id is not None:
owned = (db.query(StrategicMap.id)
.filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id)
.first())
if not owned:
empty_by_map = True # 跨账套 / 不存在 ⇒ 空树(不泄漏他账套 KPI)
else:
codes = set()
for (arr,) in (db.query(MapObjective.kpis)
.filter(MapObjective.map_id == map_id).all()):
if isinstance(arr, list):
codes.update(str(c) for c in arr if c)
if codes:
kpi_query = kpi_query.filter(or_(
KPIDefinition.map_id == map_id,
KPIDefinition.kpi_code.in_(sorted(codes)),
))
else:
kpi_query = kpi_query.filter(KPIDefinition.map_id == map_id)
kpis = [] if empty_by_map else kpi_query.order_by(KPIDefinition.kpi_code).all()
kpi_map = {k.id: k for k in kpis}
# 构建父子关系
+318
View File
@@ -0,0 +1,318 @@
"""置信度评分系统 — 财务Bot分析结论管理
账套维度(2026-09-23 账套维度收口批,任务 0-P1-project-20260923-entity-dimension-convergence
§二③ 读侧止血 + §三④ 账套化):
处置前:三个端点均无 `get_entity_id` ⇒ e1 / e2 双 token 拿到**逐字节相同**的返回体
(实测 2 行存量:`id=1 TEST001` / `id=2 kpi_code=NULL`)。
处置后:读/写/删均按会话账套判定归属;`entity_id` 列未落地时读侧退化为
「`kpi_code` JOIN `kpi_definitions.entity_id`」止血口径(DDL 与代码可分两步上线)。
归属三级(**任何一级都不猜账套**):
① `analysis_results.entity_id`(列已落地且非 NULL)
② 该行 `kpi_code` 在 `kpi_definitions` 中**唯一归属单账套**时所指向的 `entity_id`
③ 判不出归属(`kpi_code` 为空 / 库内无该码 / 该码跨账套二义 / 列为 NULL)→ 进
`unattributed` 登记池,**不返回给任何账套**;删除端点对其一律 404。
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, inspect
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth
from app.models import AnalysisResult, KPIValue, KPIDefinition, OperationLog
from app.utils.alert_entity_scope import month_period_clause
from typing import Optional, List
from datetime import datetime
router = APIRouter(prefix="/api/cma/analysis", tags=["置信度评分"],
dependencies=[Depends(require_auth)],
)
# `analysis_results.entity_id` 列存在性缓存(键 = engine 对象 id;DDL 前后同一进程内可能变化,
# 故只缓存判定结果、不做进程级常量:迁移后重启服务即刷新)
_ENTITY_COL_CACHE: dict = {}
def _has_entity_column(db) -> bool:
"""`analysis_results.entity_id` 是否已落地(DDL 迁移 2026-09-23 未执行时为 False)。
读侧止血必须先于 DDL 合入 ⇒ 不能无条件引用该列;列不在时按 `kpi_code` JOIN 判归属。
"""
bind = db.get_bind()
key = id(bind)
cached = _ENTITY_COL_CACHE.get(key)
if cached is None:
try:
cols = {c["name"] for c in inspect(bind).get_columns(AnalysisResult.__tablename__)}
cached = "entity_id" in cols
except Exception: # noqa: BLE001 —— 结构探不通时按"列不存在"的保守口径走 join
cached = False
_ENTITY_COL_CACHE[key] = cached
return cached
def _kpi_owners(db, codes: List[Optional[str]]) -> dict:
"""`kpi_code` → 拥有该码的账套集合(同码可跨账套存在,故为集合)"""
codes = [c for c in codes if c]
if not codes:
return {}
owners: dict = {}
rows = (
db.query(KPIDefinition.kpi_code, KPIDefinition.entity_id)
.filter(KPIDefinition.kpi_code.in_(codes))
.all()
)
for code, eid in rows:
if eid is not None:
owners.setdefault(code, set()).add(eid)
return owners
def _row_owners(db, r, owners_map: dict) -> set:
"""单行归属账套集合;空集 = 判不出归属(不猜账套)
列口径:`entity_id` 非 NULL 即为唯一归属。
join 口径(列未落地 / 列为 NULL 的历史行):只有该 `kpi_code` 在 `kpi_definitions`
中**唯一归属单账套**时才认定——同码跨账套(库内 22 个码属两账套)属归属二义,
按「不猜账套」进登记池,宁可不可见也不误判给他账套。
"""
if _has_entity_column(db):
eid = getattr(r, "entity_id", None)
if eid is not None:
return {eid}
if r.kpi_code:
owners = owners_map.get(r.kpi_code, set())
return owners if len(owners) == 1 else set()
return set()
def _to_dict(r) -> dict:
return {
"id": r.id,
"period": r.period,
"结论": r.conclusion,
"置信度": f"{r.confidence}%",
"数据来源": r.data_source,
"计算逻辑": r.calculation_logic,
"可比基准": r.comparable_benchmark,
"局限": r.limitations,
"kpi_code": r.kpi_code,
"kpi_name": r.kpi_name,
"has_actual": bool(r.has_actual),
"has_target": bool(r.has_target),
"has_trend": bool(r.has_trend),
"has_review": bool(r.has_review),
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
}
def _unattributed_dict(r) -> dict:
"""无归属登记项:只回标识与元信息,不回结论正文(登记而非共享)"""
return {
"id": r.id,
"period": r.period,
"kpi_code": r.kpi_code,
"kpi_name": r.kpi_name,
"confidence": r.confidence,
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else None,
}
def _calc_confidence(has_actual: bool, has_target: bool, has_trend: bool, has_review: bool) -> int:
"""基于数据完整度自动计算置信度"""
if has_review:
return 95
if has_actual and has_target and has_trend:
return 85
if has_actual and has_target:
return 70
if has_actual:
return 50
return 30 # 无实际值,基于推测
@router.get("/result")
async def get_analysis_results(
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
kpi_code: Optional[str] = Query(None, description="KPI编码"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查询分析结论(按期间和/或KPI编码过滤)— 账套隔离(2026-09-23)
返回体字段:原有字段全保留(`total` 语义收窄为「本账套可见行数」,`results` 仍为列表),
新增 `entity_id` / `unattributed_total` / `unattributed` / `entity_scope`(均为附加字段,前端契约不破坏)。
无归属行(存量 id=1/2)进 `unattributed`:**对任何账套都不进 `results`**,只作登记。
"""
query = db.query(AnalysisResult).order_by(AnalysisResult.created_at.desc())
if period:
query = query.filter(AnalysisResult.period == period)
if kpi_code:
query = query.filter(AnalysisResult.kpi_code == kpi_code)
results = query.all()
owners_map = _kpi_owners(db, [r.kpi_code for r in results])
mine, unattributed = [], []
for r in results:
owners = _row_owners(db, r, owners_map)
if not owners:
unattributed.append(r)
elif entity_id in owners:
mine.append(r)
return {
"total": len(mine),
"period": period,
"entity_id": entity_id,
"entity_scope": "column" if _has_entity_column(db) else "kpi_join",
"unattributed_total": len(unattributed),
"unattributed": [_unattributed_dict(r) for r in unattributed],
"results": [_to_dict(r) for r in mine],
}
@router.post("/result")
def create_analysis_result(data: dict = None, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""提交分析结果(自动计算置信度)— 账套维度:本次写入挂**会话账套**
`entity_id` 列未落地时(DDL 迁移前)先把会话账套写进 `OperationLog.detail.entity_id`,
列落地后直接写列(响应始终回 `entity_id`)。
"""
if not data:
data = {}
period = data.get("period", "")
conclusion = data.get("conclusion", "")
data_source = data.get("data_source", "")
calculation_logic = data.get("calculation", "")
comparable_benchmark = data.get("comparable_benchmark")
limitations = data.get("limitations")
kpi_code = data.get("kpi_code")
kpi_name = data.get("kpi_name")
has_actual = data.get("has_actual", False)
has_target = data.get("has_target", False)
has_trend = data.get("has_trend", False)
has_review = data.get("has_review", False)
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
has_column = _has_entity_column(db)
result = AnalysisResult(
period=period,
conclusion=conclusion,
confidence=confidence,
data_source=data_source,
calculation_logic=calculation_logic,
comparable_benchmark=comparable_benchmark,
limitations=limitations,
kpi_code=kpi_code,
kpi_name=kpi_name,
has_actual=1 if has_actual else 0,
has_target=1 if has_target else 0,
has_trend=1 if has_trend else 0,
has_review=1 if has_review else 0,
)
if has_column:
result.entity_id = entity_id
db.add(result)
db.commit()
db.refresh(result)
if not has_column:
# DDL 未落地前的过渡留痕:会话账套只存在于日志,不猜列
import json
db.add(OperationLog(
user_id=None, action="create", target_type="analysis_result",
target_id=result.id,
detail=json.dumps({"entity_id": entity_id, "kpi_code": kpi_code,
"period": period,
"note": "analysis_results.entity_id 列未落地,账套暂存于日志"},
ensure_ascii=False),
))
db.commit()
return {
"id": result.id,
"period": result.period,
"结论": result.conclusion,
"置信度": f"{result.confidence}%",
"数据来源": result.data_source,
"计算逻辑": result.calculation_logic,
"可比基准": result.comparable_benchmark,
"局限": result.limitations,
"confidence_score": result.confidence,
"entity_id": entity_id,
"entity_persisted": "column" if has_column else "operation_log",
"message": "分析结论已保存",
}
@router.delete("/result/{result_id}")
async def delete_analysis_result(
result_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""删除分析结论 — 归属校验:非本账套 或 无归属一律 **404**(不引入 403,避免探测面)"""
result = db.query(AnalysisResult).filter(AnalysisResult.id == result_id).first()
if not result:
raise HTTPException(404, "分析结论不存在")
owners = _row_owners(db, result, _kpi_owners(db, [result.kpi_code]))
if entity_id not in owners:
# 他账套 id 与无归属行走同一文案(不暴露"该行存在但不属于你")
raise HTTPException(404, "分析结论不存在")
db.delete(result)
db.commit()
return {"message": "已删除"}
@router.post("/auto-calculate")
async def auto_calculate_confidence(
period: str = Query(..., description="期间 YYYY-MM"),
kpi_code: str = Query(..., description="KPI编码"),
db: Session = Depends(get_db),
):
"""根据KPI数据完整性自动生成置信度评分"""
# 查找KPI定义
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi:
raise HTTPException(404, f"KPI编码 {kpi_code} 不存在")
# 查找该期间的实际值
value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
# 查找历史数据(趋势)
trend_values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(6).all()
has_actual = value is not None and value.actual_value is not None
has_target = kpi.target_value is not None
has_trend = len(trend_values) >= 2
has_review = False
confidence = _calc_confidence(has_actual, has_target, has_trend, has_review)
return {
"kpi_code": kpi_code,
"kpi_name": kpi.kpi_name,
"period": period,
"has_actual": has_actual,
"has_target": has_target,
"has_trend": has_trend,
"has_review": has_review,
"confidence": confidence,
"confidence_label": f"{confidence}%",
"数据完备度": {
"10%": "无数据",
"50%": "有实际值",
"70%": "有实际值+目标值",
"85%": "有实际值+目标值+历史趋势",
"95%": "有全部数据+人工复核",
}.get(str(confidence), "基于推测"),
}
+226
View File
@@ -0,0 +1,226 @@
"""
Bot API 操作审计中间件
======================
每次 Bot API 调用(/api/cma/bot*)记录一行 JSON 审计日志到
backend/logs/bot_audit.log(可用环境变量 CMA_BOT_AUDIT_LOG 覆盖路径)。
JSON 行字段: timestamp / bot_name / endpoint / method / risk_level /
entity_id / status
L3 批量写额外记录 rows(导入行数 / 批量条数)。
设计约束:
- 不阻塞业务: 所有日志写入失败仅静默跳过,不影响请求结果
- 只读 body(starlette 会缓存并回放给下游),仅在 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 → 配置名/原始key;bridge → 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", "upserted"):
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
+170 -9
View File
@@ -1,13 +1,38 @@
"""用户认证"""
import hashlib
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.auth_middleware import create_token, require_auth, ROLES
from app.models import User, UserEntity, Entity, OperationLog
from app.auth_middleware import create_token, require_auth, require_auth_401, ROLES
router = APIRouter(prefix="/api/cma/auth", tags=["认证"])
# ── 可登录账套状态(2026-09-17 yanxue-20260916-cma-demo-account)─────────────
# active = 正式账套;demo = 演示账套(虚构数据,专供演示账号登录)。
# 为何单列「demo」而不是把演示企业改成 active:daily_push / 预警检查等批处理按
# `Entity.status == "active"` 遍历全部实体,演示实体若为 active 会被并入真实
# 推送与预警(污染业务通道)。故演示账号只在**登录链**上被放行「demo」状态,
# 批处理口径(active-only)保持不变。
LOGIN_ENTITY_STATUSES = ("active", "demo")
def _user_entity_ids(db: Session, user_id: int) -> list[int]:
"""返回用户被授权的企业ID列表"""
rows = db.query(UserEntity.entity_id).filter(UserEntity.user_id == user_id).all()
return [r[0] for r in rows]
def _ensure_default_grants(db: Session, user_id: int) -> None:
"""存量用户兼容:若用户没有任何授权记录,则授予所有active企业(不锁死老账号)"""
cnt = db.query(UserEntity).filter(UserEntity.user_id == user_id).count()
if cnt > 0:
return
entities = db.query(Entity).filter(Entity.status == "active").all()
for e in entities:
db.add(UserEntity(user_id=user_id, entity_id=e.id, granted_by=1))
db.commit()
@router.post("/login")
def login(data: dict, db: Session = Depends(get_db)):
@@ -17,9 +42,28 @@ def login(data: dict, db: Session = Depends(get_db)):
if not user or user.password_hash != hashlib.sha256(password.encode()).hexdigest():
raise HTTPException(401, "用户名或密码错误")
token = create_token(user.id)
# 账套模式:登录必须指定企业(entity_id)
entity_id = data.get("entity_id")
if entity_id is None:
raise HTTPException(400, "账套模式:请选择登录企业(entity_id)")
# 存量兼容:无授权记录时自动授予active企业
_ensure_default_grants(db, user.id)
allowed = _user_entity_ids(db, user.id)
if int(entity_id) not in allowed:
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
if not entity or entity.status not in LOGIN_ENTITY_STATUSES:
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
token = create_token(user.id, int(entity_id))
return {
"token": token,
"entity_id": int(entity_id),
"entity_name": entity.name,
"entity_short_name": entity.short_name,
"user": {
"id": user.id,
"username": user.username,
@@ -43,13 +87,23 @@ def register(data: dict, db: Session = Depends(get_db)):
)
db.add(user)
db.commit()
db.refresh(user)
# 注册默认授权第一个active企业(不授予全部,保证账套隔离)
first_ent = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).first()
if first_ent:
db.add(UserEntity(user_id=user.id, entity_id=first_ent.id))
db.commit()
return {"message": "注册成功"}
@router.get("/me")
def get_me(current_user: User = Depends(require_auth)):
"""获取当前用户信息"""
return {
def get_me(request: Request, current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
"""获取当前用户信息(含当前账套)"""
from app.auth_middleware import extract_bearer_token, get_token_entity_id
token = extract_bearer_token(request)
eid = get_token_entity_id(token) if token else None
ent = db.query(Entity).filter(Entity.id == eid).first() if eid else None
d = {
"id": current_user.id,
"username": current_user.username,
"name": current_user.name,
@@ -57,14 +111,121 @@ def get_me(current_user: User = Depends(require_auth)):
"role_name": ROLES.get(current_user.role, {}).get("name", current_user.role),
"phone": current_user.phone,
}
if ent:
d["entity_id"] = ent.id
d["entity_name"] = ent.name
d["entity_short_name"] = ent.short_name
return d
@router.get("/roles")
def list_roles():
"""返回角色列表(给前端用)"""
def list_roles(_=Depends(require_auth_401)):
"""返回角色列表(给前端用)
2026-09-14 安全修复(cma-api-auth-surface-audit-20260914):原无鉴权公开返回角色
枚举(对外暴露系统角色模型 + 便于针对性撞库)。仓内检索确认无任何前端/脚本调用方
(前端用 /permissions/modules 的 roles 字段),故直接补 require_auth_401。
"""
return {
"data": [
{"code": k, "name": v["name"], "priority": v["priority"]}
for k, v in ROLES.items()
]
}
@router.get("/entities")
def my_entities(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
"""当前用户被授权的企业列表(登录页下拉/切换器数据源)"""
_ensure_default_grants(db, current_user.id)
ids = _user_entity_ids(db, current_user.id)
entities = db.query(Entity).filter(
Entity.id.in_(ids),
Entity.status.in_(LOGIN_ENTITY_STATUSES),
).order_by(Entity.id).all()
return {
"data": [
{"id": e.id, "name": e.name, "short_name": e.short_name, "industry": e.industry}
for e in entities
]
}
@router.get("/my-entities")
def my_entities_alias(current_user: User = Depends(require_auth), db: Session = Depends(get_db)):
"""别名:/auth/my-entities(前端切换器调用)"""
return my_entities(current_user, db)
@router.get("/login-entities")
def login_entities_options(username: str = None, db: Session = Depends(get_db)):
"""登录页公司选择器:按用户名返回授权企业(无鉴权,登录前调用;不暴露用户名是否存在)"""
if not username:
return {"data": []}
user = db.query(User).filter(User.username == username).first()
if not user:
return {"data": []}
_ensure_default_grants(db, user.id)
ids = _user_entity_ids(db, user.id)
ents = db.query(Entity).filter(
Entity.id.in_(ids),
Entity.status.in_(LOGIN_ENTITY_STATUSES),
).order_by(Entity.id).all()
return {"data": [{"id": e.id, "name": e.name, "short_name": e.short_name} for e in ents]}
@router.post("/switch-entity")
def switch_entity(
data: dict,
request: Request,
current_user: User = Depends(require_auth),
db: Session = Depends(get_db),
):
"""账套切换:校验授权 → 重新签发token → 写操作日志(方案B:无缝刷新token)
DEPRECATED(cma-cross-entity-endpoints-auth-20260914,P3):
自 2026-09-14 移除应用内企业切换器后,前端已无任何调用(换企业 = 退出重登)。
端点保留仅为兼容存量脚本/回滚(不删);新代码请改用「重新登录携带 entity_id」。
注:本端点曾用于切换会话账套;当前账套口径以登录 token 绑定为准。
"""
entity_id = data.get("entity_id")
if not entity_id:
raise HTTPException(400, "缺少entity_id")
_ensure_default_grants(db, current_user.id)
allowed = _user_entity_ids(db, current_user.id)
if int(entity_id) not in allowed:
raise HTTPException(403, f"该账号未被授权访问企业 entity_id={entity_id}")
entity = db.query(Entity).filter(Entity.id == int(entity_id)).first()
if not entity or entity.status not in LOGIN_ENTITY_STATUSES:
raise HTTPException(403, f"企业 entity_id={entity_id} 不存在或未激活")
# 旧token失效:删除当前请求的旧token(切换即下线旧账套凭据)
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
try:
import redis as redis_lib
r = redis_lib.Redis(host="127.0.0.1", port=6379, db=1, decode_responses=True)
r.delete(f"cma:token:{auth[7:]}")
except Exception:
pass
token = create_token(current_user.id, int(entity_id))
# 切换留痕
db.add(OperationLog(
user_id=current_user.id,
action="switch_entity",
target_type="entity",
target_id=int(entity_id),
detail={"entity_id": int(entity_id), "entity_name": entity.name, "from": "account-switch"},
))
db.commit()
return {
"token": token,
"entity_id": int(entity_id),
"entity_name": entity.name,
"entity_short_name": entity.short_name,
}
+322
View File
@@ -0,0 +1,322 @@
"""BI报表集成 — 任务4
分析模式 + 预置报表模板 + 报表保存/分享 + 导出
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import datetime
import json
import logging
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, BiReportTemplate, BiReport, OperationLog, KPICausality
from app.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
logger = logging.getLogger("bi-reports")
router = APIRouter(prefix="/api/cma/bi-reports", tags=["BI报表"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
# ============================================================
# 预置模板
# ============================================================
PRESET_TEMPLATES = [
{
"name": "四层指标总览",
"report_type": "overview",
"is_system": 1,
"config": {
"description": "展示财务/客户/流程/学习四层维度的关键KPI概览",
"layout": "grid",
"dimensions": ["finance", "customer", "process", "learning"],
"metrics": ["count", "avg_value", "alert_count"],
"chart_type": "gauge_card",
}
},
{
"name": "同比趋势分析",
"report_type": "trend",
"is_system": 1,
"config": {
"description": "各KPI近12个月趋势对比",
"period": "monthly",
"window_months": 12,
"chart_type": "line",
"show_compare": True,
}
},
{
"name": "实际vs预算对比",
"report_type": "comparison",
"is_system": 1,
"config": {
"description": "KPI实际值 vs 目标值的偏差分析",
"chart_type": "bar",
"show_deviation": True,
"group_by": "dimension",
}
},
{
"name": "TOP N异常KPI",
"report_type": "topn",
"is_system": 1,
"config": {
"description": "排名前N的异常KPI(红/黄灯)",
"top_n": 10,
"sort_by": "deviation",
"chart_type": "horizontal_bar",
"show_threshold": True,
}
},
{
"name": "因果链推演",
"report_type": "causality",
"is_system": 1,
"config": {
"description": "基于KPI因果链的推演分析",
"chart_type": "force_graph",
"max_depth": 3,
"min_strength": 0.3,
}
},
]
@router.get("/templates")
def list_report_templates(db: Session = Depends(get_db)):
"""获取BI报表模板"""
templates = db.query(BiReportTemplate).order_by(BiReportTemplate.id).all()
return {"data": [{c.name: getattr(t, c.name) for c in BiReportTemplate.__table__.columns} for t in templates]}
@router.post("/templates/seed")
def seed_report_templates(db: Session = Depends(get_db), user=WRITE_ROLES):
"""初始化预置模板(仅首次运行)"""
created = 0
for tpl in PRESET_TEMPLATES:
existing = db.query(BiReportTemplate).filter(
BiReportTemplate.name == tpl["name"],
BiReportTemplate.is_system == 1,
).first()
if existing:
continue
t = BiReportTemplate(**tpl)
db.add(t)
created += 1
db.commit()
return {"message": f"新增{created}个预置模板", "created": created}
@router.delete("/templates/{template_id}")
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
t = db.query(BiReportTemplate).filter(BiReportTemplate.id == template_id).first()
if t:
db.delete(t)
db.commit()
return {"message": "已删除"}
# ============================================================
# 用户报表
# ============================================================
@router.get("")
def list_reports(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取用户保存的报表(账套隔离: 按token企业, 2026-08-23 P2)"""
reports = db.query(BiReport).filter(BiReport.entity_id == entity_id).order_by(BiReport.updated_at.desc()).all()
result = []
for r in reports:
d = {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
d["created_by_name"] = f"用户{r.created_by}" if r.created_by else "系统"
result.append(d)
return {"data": result, "total": len(result)}
@router.get("/{report_id}")
def get_report(report_id: int, db: Session = Depends(get_db)):
r = db.query(BiReport).filter(BiReport.id == report_id).first()
if not r:
raise HTTPException(404, "报表不存在")
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
@router.post("")
def create_report(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""保存BI报表"""
r = BiReport(
template_id=data.get("template_id"),
name=data.get("name", "未命名报表"),
config=data.get("config", {}),
chart_type=data.get("chart_type", "auto"),
is_shared=data.get("is_shared", 0),
created_by=1,
)
db.add(r)
db.commit()
db.refresh(r)
log_op(db, "create", target_type="bi_report", target_id=r.id,
detail=f"创建报表: {r.name}", entity_id=getattr(r, "entity_id", None))
db.commit()
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
@router.put("/{report_id}")
def update_report(report_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
r = db.query(BiReport).filter(BiReport.id == report_id).first()
if not r:
raise HTTPException(404, "报表不存在")
for field in ("name", "config", "chart_type", "is_shared"):
if field in data:
setattr(r, field, data[field])
db.commit()
return {c.name: getattr(r, c.name) for c in BiReport.__table__.columns}
@router.delete("/{report_id}")
def delete_report(report_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
r = db.query(BiReport).filter(BiReport.id == report_id).first()
if r:
db.delete(r)
db.commit()
return {"message": "已删除"}
# ============================================================
# 分析引擎
# ============================================================
@router.post("/analyze")
def analyze_data(data: dict, db: Session = Depends(get_db)):
"""分析引擎:按配置返回报表数据
Body: {
config: { dimensions, kpi_ids, period_start, period_end, group_by, metrics, ... },
chart_type: str
}
"""
config = data.get("config", {})
chart_type = data.get("chart_type", "auto")
kpi_ids = config.get("kpi_ids", [])
dimensions = config.get("dimensions", [])
period_start = config.get("period_start")
period_end = config.get("period_end")
group_by = config.get("group_by")
top_n = config.get("top_n", 10)
# 构建KPI查询
kpi_query = db.query(KPIDefinition).filter(KPIDefinition.status == "active")
if kpi_ids:
kpi_query = kpi_query.filter(KPIDefinition.id.in_(kpi_ids))
if dimensions:
kpi_query = kpi_query.filter(KPIDefinition.dimension.in_(dimensions))
kpis = kpi_query.order_by(KPIDefinition.kpi_code).all()
# 获取每个KPI的最新值
rows = []
for kpi in kpis:
val_query = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.actual_value.isnot(None),
)
if period_start:
val_query = val_query.filter(KPIValue.period >= period_start)
if period_end:
val_query = val_query.filter(KPIValue.period <= period_end)
latest = val_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
# 获取趋势数据
trend_values = val_query.order_by(KPIValue.period.asc()).limit(12).all()
rows.append({
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"unit": kpi.unit,
"target_value": kpi.target_value,
"threshold_green": kpi.threshold_green,
"threshold_yellow": kpi.threshold_yellow,
"threshold_red": kpi.threshold_red,
"current_value": latest.actual_value if latest else None,
"current_period": latest.period if latest else None,
"trend": [{"period": v.period, "value": v.actual_value} for v in trend_values],
})
# 统计汇总
summary = {
"total_kpis": len(rows),
"dimensions": {},
}
for r in rows:
dim = r["dimension"]
if dim not in summary["dimensions"]:
summary["dimensions"][dim] = {"count": 0, "values": []}
summary["dimensions"][dim]["count"] += 1
if r["current_value"] is not None:
summary["dimensions"][dim]["values"].append(r["current_value"])
for dim, info in summary["dimensions"].items():
vals = info["values"]
if vals:
info["avg"] = round(sum(vals) / len(vals), 2)
info["min"] = min(vals)
info["max"] = max(vals)
del info["values"]
return {
"config": config,
"chart_type": chart_type,
"rows": rows,
"summary": summary,
}
# ============================================================
# 导出功能(CSV格式,前端可转为Excel/PDF)
# ============================================================
@router.post("/export")
def export_report(data: dict, db: Session = Depends(get_db)):
"""导出报表数据 (CSV)"""
config = data.get("config", {})
format_type = data.get("format", "csv")
# 复用analyze获取数据
from app.database import get_session_local
temp_db = get_session_local()()
try:
result = analyze_data(data, temp_db)
finally:
temp_db.close()
rows = result.get("rows", [])
if not rows:
raise HTTPException(400, "没有可导出的数据")
# 生成CSV
import csv, io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["KPI编码", "KPI名称", "维度", "类别", "当前值", "期间", "目标值", "单位"])
for r in rows:
writer.writerow([
r["kpi_code"], r["kpi_name"], r["dimension"], r["category"],
r["current_value"], r["current_period"], r["target_value"], r["unit"],
])
csv_content = output.getvalue()
return Response(
content=csv_content,
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=bi_report_{datetime.now().strftime('%Y%m%d')}.csv"},
)
File diff suppressed because it is too large Load Diff
+689
View File
@@ -0,0 +1,689 @@
"""
Bot-Bridge V2 — 合并改造:Bot桥接 + 自动验证引擎
数据流:
bot-bridge(财务Bot→CMA)→ 写入KPI
verify(CMA→校验)→ 读取KPI → 通过/失败
API:
POST /api/cma/bot-bridge/mpm-result — 接收MPM分析结果
POST /api/cma/bot-bridge/verify/{action_plan_id} — 验证ActionPlan执行结果
"""
import json, logging, re
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.deps import require_bot_entity_id
from app.models import (
MpmResult, BotBridgeConfig,
KPIDefinition, KPIValue, KPIAlert,
ActionPlan, Entity, OperationLog,
)
from app.risk_levels import risk_level
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
logger = logging.getLogger("cma.bot_bridge_v2")
router = APIRouter(prefix="/api/cma/bot-bridge", tags=["Bot桥接V2"])
# ═══════════════════════════════════════════════
# 鉴权
# ═══════════════════════════════════════════════
def verify_bridge_token(
x_bridge_token: str = Header(None, alias="X-BRIDGE-TOKEN"),
db: Session = Depends(get_db),
):
"""从bot_bridge_config表校验Token"""
if not x_bridge_token:
raise HTTPException(401, "缺少X-BRIDGE-TOKEN请求头")
config = db.query(BotBridgeConfig).filter(
BotBridgeConfig.token == x_bridge_token,
BotBridgeConfig.is_active == True,
).first()
if not config:
raise HTTPException(401, "Token无效或已停用")
return config.bot_name
# ═══════════════════════════════════════════════
# 验证引擎
# ═══════════════════════════════════════════════
def _get_kpi_current_value(db: Session, kpi_id: int, entity_id: int = None) -> Optional[dict]:
"""获取KPI当前最新值及目标值"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return None
latest = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.data_status == "verified",
).order_by(KPIValue.calculated_at.desc(), KPIValue.period.desc()).first()
return {
"kpi_id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"value": latest.actual_value if latest else None,
"target": kpi.target_value,
"period": latest.period if latest else None,
"unit": kpi.unit,
}
def _parse_condition(condition: str) -> list:
"""
解析condition表达式为可执行结构
支持格式:
"value > target" — 当前值 > 目标值
"value >= 80" — 当前值 >= 80 (绝对值)
"value / target * 100 > 80" — 完成率 > 80%
"value < baseline" — 当前值 < 基线值(目标值=基线)
"value == 0" — 精确等于
"value > target AND value < 100" — 多条件AND
"value > 50 OR value == -1" — 多条件OR
"""
if not condition:
return []
# 分割AND/OR
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
clauses = []
for i in range(0, len(parts), 2):
expr = parts[i].strip()
# 检查是否有运算符连接
match = re.match(
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)(?:\s*([+\-*/])\s*(\d+\.?\d*))?',
expr
)
if not match:
# 尝试 value/target*100 > 80 这种复合表达式
match = re.match(
r'(value)\s*(/|\\*)\s*(target|baseline)\s*(\*?\s*\d+)?\s*([><=!]+)\s*(-?\d+\.?\d*)',
expr
)
if not match:
logger.warning(f"无法解析condition表达式: {expr}")
continue
logical_op = parts[i + 1].upper() if i + 1 < len(parts) else "AND"
clauses.append({"expr": expr, "logical_op": logical_op, "match": match.groups() if match else ()})
return clauses
def _evaluate_condition(condition: str, kpi_data: dict) -> bool:
"""
评估condition表达式
Args:
condition: 条件表达式 e.g. "value > target"
kpi_data: {value, target, ...}
Returns:
True=通过, False=不通过
"""
if not condition:
return False
value = kpi_data.get("value")
target = kpi_data.get("target")
baseline = kpi_data.get("baseline", target)
if value is None:
logger.warning(f"KPI {kpi_data.get('kpi_code')} 当前值为空,无法验证")
return False
result = True
current_logical = "AND"
# 按AND/OR分割
parts = re.split(r'\s+(AND|OR)\s+', condition, flags=re.IGNORECASE)
for i in range(0, len(parts), 2):
expr = parts[i].strip()
if i + 1 < len(parts):
current_logical = parts[i + 1].upper()
clause_passed = _eval_single_expr(expr, value, target, baseline)
if current_logical == "AND":
result = result and clause_passed
elif current_logical == "OR":
result = result or clause_passed
# 短路优化
if current_logical == "AND" and not result:
break
if current_logical == "OR" and result:
break
return result
def _eval_single_expr(expr: str, value: float, target: Optional[float], baseline: Optional[float]) -> bool:
"""评估单条条件表达式"""
# 模式1: value <op> target/baseline/number (如 value > target, value >= 80)
m = re.match(
r'(value|target|baseline)\s*([><=!]+)\s*(value|target|baseline|-?\d+\.?\d*)',
expr
)
if m:
left = m.group(1)
op = m.group(2)
right_raw = m.group(3)
left_val = _resolve_var(left, value, target, baseline)
right_val = _resolve_var(right_raw, value, target, baseline)
if left_val is None or right_val is None:
return False
return _apply_op(left_val, op, right_val)
# 模式2: value [*/] target/baseline [* number] <op> number (如 value/target*100 > 80)
m = re.match(
r'(value)\s*([/*])\s*(target|baseline)(?:\s*\*\s*(\d+))?\s*([><=!]+)\s*(-?\d+\.?\d*)',
expr
)
if m:
left = m.group(1)
op1 = m.group(2) # / or *
var2 = m.group(3) # target or baseline
multiplier = float(m.group(4)) if m.group(4) else 100
op2 = m.group(5) # > < >= <= == !=
right_num = float(m.group(6))
left_val = _resolve_var(left, value, target, baseline)
right_var = _resolve_var(var2, value, target, baseline)
if left_val is None or right_var is None or right_var == 0:
return False
if op1 == '/':
computed = (left_val / right_var) * multiplier
else: # *
computed = left_val * right_var
return _apply_op(computed, op2, right_num)
# 模式3: 纯数字比较 (提供兼容)
logger.warning(f"无法解析表达式: {expr}")
return False
def _resolve_var(token: str, value: float, target: Optional[float], baseline: Optional[float]) -> Optional[float]:
"""将变量名解析为数值"""
token = token.strip()
if token == "value":
return value
elif token == "target":
return target
elif token == "baseline":
return baseline
else:
try:
return float(token)
except (ValueError, TypeError):
return None
def _apply_op(left: float, op: str, right: float) -> bool:
"""应用比较运算符"""
try:
if op == ">":
return left > right
elif op == ">=":
return left >= right
elif op == "<":
return left < right
elif op == "<=":
return left <= right
elif op in ("==", "="):
return abs(left - right) < 0.0001
elif op == "!=":
return abs(left - right) >= 0.0001
else:
logger.warning(f"未知运算符: {op}")
return False
except (TypeError, ValueError):
return False
# ═══════════════════════════════════════════════
# KPI映射表:MPM结果字段 → KPI编码
# ═══════════════════════════════════════════════
MPM_TO_KPI_MAP = {
"revenue": None, # 不做KPI映射,保留在raw_data
"cost": None,
"gross_margin_standard": None,
"gross_margin_adjusted": "F_GROSS_MARGIN",
"net_profit_standard": None,
"net_profit_adjusted": "F_NET_PROFIT",
"channel_rebate_rate": "C_REBATE_RATE",
"mgmt_expense_ratio": "F_COST_RATIO",
}
# 预警阈值配置
ALERT_THRESHOLDS = {
"channel_rebate_rate": {"threshold": 75, "operator": ">", "kpi_code": "C_REBATE_RATE", "kpi_name": "渠补率"},
"mgmt_expense_ratio": {"threshold": 50, "operator": ">", "kpi_code": "F_COST_RATIO", "kpi_name": "管理费/净收入"},
}
# ═══════════════════════════════════════════════
# API端点
# ═══════════════════════════════════════════════
@router.post("/mpm-result")
@risk_level("L3")
def receive_mpm_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""
接收财务Bot/其他Bot的MPM分析结果
- 鉴权(校验X-BRIDGE-TOKEN)
- 写入mpm_results表
- 更新KPI当前值(kpi_values表)
- 触发预警(如渠补率超75%)
"""
source = data.get("source", bridge_bot)
entity_id = data.get("entity_id")
period = data.get("period")
results = data.get("results", {})
if not entity_id or not period:
raise HTTPException(400, "缺少必填字段: entity_id, period")
# 验证entity存在
entity = db.query(Entity).filter(Entity.id == entity_id).first()
if not entity:
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
# R1/R5(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警;
# 期间非规整月(含 2026-H1/H2 伪期间)的 kpi_alerts 行一律不入库(跳过 + 记日志)。
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge_v2.receive_mpm_result")
period_attributable = guard_alert_period(period, source="bot_bridge_v2.receive_mpm_result",
detail="entity_id=%s" % entity_id)
# ── 1. 写入MPM结果 ──
record = MpmResult(
entity_id=entity_id,
period=period,
source=source,
raw_data=results,
)
db.add(record)
db.flush()
# ── 2. 更新KPI当前值 ──
kpi_updates = []
for field, kpi_code in MPM_TO_KPI_MAP.items():
if kpi_code is None:
continue
field_value = results.get(field)
if field_value is None:
continue
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
logger.warning(f"KPI编码 {kpi_code} 未找到 (entity_id={entity_id})")
continue
# 写入最新值 (upsert: 存在同period则更新,否则插入)
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
if existing:
existing.actual_value = field_value
existing.source_type = "bot-bridge-v2"
existing.data_status = "verified"
else:
kpi_val = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=field_value,
source_type="bot-bridge-v2",
data_status="verified",
)
db.add(kpi_val)
kpi_updates.append(kpi_code)
# ── 3. 触发预警 ──
alerts = []
alerts_skipped_no_kpi = [] # B3(b): KPI 定义缺失而跳过的预警(留痕用)
for field, config in ALERT_THRESHOLDS.items():
field_value = results.get(field)
if field_value is None:
continue
threshold = config["threshold"]
operator = config["operator"]
kpi_code = config.get("kpi_code")
kpi_name = config["kpi_name"]
if (operator == ">" and field_value > threshold) or \
(operator == ">=" and field_value >= threshold):
# R1/R5 生成侧守卫(裁定 2026-09-19):demo 账套 / 不可归期期间 → 该行不入库
if not alerts_allowed:
logger.info("R1 账套守卫:跳过不入库(demo 演示账套)entity_id=%s", entity_id)
continue
if not period_attributable:
logger.info("R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r", period)
continue
# 查找关联KPI
# B3(b)(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922):
# 修复前 `kpi_id=kpi_id or 1` —— KPI 定义缺失时把预警错挂到「全库第 1 个 KPI」
# (该 KPI 可能属他账套:预警 entity_id 正确、KPI 维度却跨了账套)。
# 现改为**显式校验**:拿不到本账套的 KPI 定义 → 不建预警(跳过)+ 落 OperationLog 留痕。
# 为什么不是 `kpi_id=None`:`kpi_alerts.kpi_id` 为 NOT NULL(models/__init__.py:145),
# batch5 §B3(b) 方案 A(保留可见性)在本 schema 下不可行;方案 B(跳过 + 审计)落地。
kpi_id = None
if kpi_code:
kpi_def = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
if kpi_def:
kpi_id = kpi_def.id
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
if kpi_id is None:
# 宁缺勿错:不入库,但必须留痕(防「预警静默丢失」)
alerts_skipped_no_kpi.append(f"{kpi_name}(kpi_code={kpi_code!r})")
db.add(OperationLog(
user_id=None,
action="skip_alert",
target_type="alert",
target_id=None,
detail={
"reason": "kpi_definition_not_found_for_entity",
"kpi_code": kpi_code,
"kpi_name": kpi_name,
"entity_id": entity_id,
"period": period,
"field": field,
"field_value": field_value,
"threshold": threshold,
"source": "bot_bridge_v2.mpm-result",
},
))
logger.warning(
"B3(b) 跳过建预警(KPI定义缺失,不再回落 kpi_id=1): kpi_code=%r entity_id=%s period=%s",
kpi_code, entity_id, period,
)
continue
# 创建预警
alert = KPIAlert(
entity_id=entity_id, # 2026-09-13 F1: 显式写归属
kpi_id=kpi_id, # B3(b): 显式校验后的本账套 KPI(禁止 or 1 兜底)
alert_level="red",
alert_message=alert_msg,
alert_type="actual",
status="pending",
)
db.add(alert)
alerts.append(alert_msg)
logger.warning(alert_msg)
db.commit()
db.refresh(record)
resp = {
"success": True,
"kpi_updated": len(kpi_updates),
"alerts_triggered": len(alerts),
# B3(b): 因本账套无对应 KPI 定义而跳过的预警(已落 OperationLog,非静默丢失)
"alerts_skipped_missing_kpi": len(alerts_skipped_no_kpi),
"mpm_record_id": record.id,
"details": {
"kpi_codes": kpi_updates,
"alerts": alerts,
"alerts_skipped_missing_kpi": alerts_skipped_no_kpi,
},
"skipped_non_month_period": (not period_attributable),
}
if not alerts_allowed:
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
return resp
@router.post("/kpi-result")
@risk_level("L2")
def push_kpi_result(
data: dict,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""
Bot分析结果回填KPI值 + 触发预警(PRD第七部分 bot-bridge数据通道)
入参:
entity_id (int, 默认1): 企业实体ID
kpi_code (str, 必填): KPI编码
period (str, 必填): 期间 YYYY-MM
value (num, 必填): 实际值
source (str, 默认finance-bot): 来源Bot标识
remark (str, 可选): 备注
行为:
1. 按(entity_id, kpi_code)查KPI → 不存在返回错误
2. 写入KPIValue (source_type=bot, data_status=verified) — 与auto-verify引擎兼容
3. 调用 run_alert_check 触发阈值预警 → 返回 new_alerts
"""
entity_id = require_bot_entity_id(data, source="bot_bridge_v2.kpi-values")
kpi_code = data.get("kpi_code")
period = data.get("period")
value = data.get("value")
source = data.get("source", "finance-bot")
remark = data.get("remark")
if not kpi_code:
raise HTTPException(400, "缺少必填字段: kpi_code")
if not period:
raise HTTPException(400, "缺少必填字段: period")
if value is None or value == "":
raise HTTPException(400, "缺少必填字段: value")
try:
actual_value = float(value)
except (TypeError, ValueError):
raise HTTPException(400, f"value不是有效数值: {value!r}")
# 1. 按(entity_id, kpi_code)查KPI
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == kpi_code,
).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_code} 不存在 (entity_id={entity_id})")
# 2. 写入KPIValue(同period已存在则更新,幂等upsert)
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
if existing:
existing.actual_value = actual_value
existing.source_type = "bot"
existing.source_batch = source
existing.data_status = "verified"
existing.calculated_at = datetime.now()
if remark:
existing.remark = remark
val = existing
else:
val = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=actual_value,
source_type="bot",
source_batch=source,
data_status="verified",
calculated_at=datetime.now(),
remark=remark,
)
db.add(val)
db.commit()
db.refresh(val)
# 3. 触发预警检查(复用alert_generator引擎,yellow/red自动联动行动计划)
from scripts.alert_generator import run_alert_check
new_count = run_alert_check(db, period)
# 收集本次写入值直接触发的预警(kpi_value_id关联)
new_alerts = []
if new_count:
triggered = db.query(KPIAlert).filter(
KPIAlert.kpi_value_id == val.id,
).order_by(KPIAlert.created_at.desc()).all()
new_alerts = [
{
"id": a.id,
"kpi_id": a.kpi_id,
"kpi_code": kpi.kpi_code,
"level": a.alert_level,
"message": a.alert_message,
"action_plan_id": a.action_plan_linked_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
} for a in triggered
]
logger.info(
f"[bot-bridge] KPI回填: {kpi.kpi_code}@{period}={actual_value} "
f"source={source} entity={entity_id} alerts={new_count}"
)
return {
"status": "ok",
"kpi_value_id": val.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"period": period,
"value": actual_value,
"source": source,
"new_alerts_count": len(new_alerts),
"new_alerts": new_alerts,
}
@router.post("/verify/{action_plan_id}")
@risk_level("L2")
def verify_action_plan(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""
验证ActionPlan的执行结果
⚠️ 双 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校验
4. 返回通过/失败 + 详细数据
"""
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
if not plan:
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
if not plan.auto_verify_rule:
raise HTTPException(400, "该ActionPlan未配置auto_verify_rule验证规则")
# 解析验证规则
if isinstance(plan.auto_verify_rule, str):
rule = json.loads(plan.auto_verify_rule)
else:
rule = plan.auto_verify_rule
condition = rule.get("condition", "")
description = rule.get("description", "")
# 获取KPI当前值
kpi_data = _get_kpi_current_value(db, plan.kpi_id)
if not kpi_data:
return {
"success": False,
"action_plan_id": action_plan_id,
"title": plan.title,
"verify_result": "fail",
"reason": "关联KPI不存在",
}
# 执行验证
passed = _evaluate_condition(condition, kpi_data)
# 记录验证日志
verify_log_entry = {
"timestamp": datetime.now().isoformat(),
"condition": condition,
"kpi_data": kpi_data,
"passed": passed,
}
existing_logs = plan.verify_log or []
if isinstance(existing_logs, list):
existing_logs.append(verify_log_entry)
else:
existing_logs = [verify_log_entry]
plan.verify_result = "pass" if passed else "fail"
plan.verify_log = existing_logs
db.commit()
return {
"success": True,
"action_plan_id": action_plan_id,
"title": plan.title,
"verify_result": plan.verify_result,
"condition": condition,
"condition_description": description,
"kpi_data": kpi_data,
"verification_detail": {
"value": kpi_data.get("value"),
"target": kpi_data.get("target"),
"condition": condition,
"passed": passed,
},
}
@router.get("/verify/{action_plan_id}/history")
@risk_level("L1")
def verify_history(
action_plan_id: int,
bridge_bot: str = Depends(verify_bridge_token),
db: Session = Depends(get_db),
):
"""获取ActionPlan的验证历史"""
plan = db.query(ActionPlan).filter(ActionPlan.id == action_plan_id).first()
if not plan:
raise HTTPException(404, f"ActionPlan {action_plan_id} 不存在")
return {
"action_plan_id": action_plan_id,
"title": plan.title,
"verify_result": plan.verify_result,
"verify_log": plan.verify_log or [],
"auto_verify_rule": plan.auto_verify_rule,
}
+261
View File
@@ -0,0 +1,261 @@
"""
铁律KPI看板API — CMA铁律执行效果数据采集
提供: 验证次数、通过率、违规数、按Bot分组的通过率
"""
import os, json, sqlite3, logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.models import ActionPlan
from app.risk_levels import risk_level
logger = logging.getLogger("cma.iron_law")
router = APIRouter(prefix="/api/cma/bot", tags=["铁律KPI看板"])
# ── BOT API Key 配置(复用bot_bridge的一致鉴权) ──
_BOT_API_KEYS = {}
def _load_bot_keys():
global _BOT_API_KEYS
raw = os.getenv("CMA_BOT_API_KEYS", "")
if not raw:
_BOT_API_KEYS = {
"cma-bot-finance-2026": {"role": "finance", "name": "财务BOT"},
"cma-bot-shop-2026": {"role": "business", "name": "店研学BOT"},
"cma-bot-admin-2026": {"role": "ceo", "name": "管理BOT"},
}
else:
try:
_BOT_API_KEYS = json.loads(raw)
except:
_BOT_API_KEYS = {}
_load_bot_keys()
def verify_bot_key(x_bot_key: str = Header(None, alias="X-BOT-KEY")):
if not x_bot_key or x_bot_key not in _BOT_API_KEYS:
raise HTTPException(401, "无效的BOT API Key")
bot_info = _BOT_API_KEYS[x_bot_key]
return bot_info
# ── 各Hermes Profile state.db 路径 ──
HERMES_HOME = "/root/.hermes/profiles"
def _list_profile_dbs():
"""列出所有Hermes Profile的state.db路径"""
dbs = []
if not os.path.isdir(HERMES_HOME):
return dbs
for name in os.listdir(HERMES_HOME):
db_path = os.path.join(HERMES_HOME, name, "state.db")
if os.path.isfile(db_path):
dbs.append((name, db_path))
return sorted(dbs)
def _query_state_db(db_path: str, since_days: int = 30):
"""从单个 state.db 查询验证相关消息"""
cutoff = datetime.now() - timedelta(days=since_days)
cutoff_ts = cutoff.timestamp()
results = {
"total_verify_msgs": 0,
"pass_msgs": 0,
"fail_msgs": 0,
"violation_msgs": 0,
}
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
c = conn.cursor()
# 查询含验证/铁律关键词的消息(排除tool_call、校验自身和系统prompt)
c.execute("""
SELECT content, role FROM messages
WHERE timestamp >= ?
AND (content LIKE '%verify%'
OR content LIKE '%验证%'
OR content LIKE '%铁律%'
OR content LIKE '%validate%')
AND content NOT LIKE '%verify_bot_key%'
AND content NOT LIKE '%X-BOT-KEY%'
AND content NOT LIKE '%system_prompt%'
""", (cutoff_ts,))
rows = c.fetchall()
results["total_verify_msgs"] = len(rows)
for row in rows:
content = row["content"] or ""
role = row["role"]
# 判断是否通过/成功/完成
pass_patterns = ["通过", "passed", "success", "✅", "完成", "completed", "验证通过"]
fail_patterns = ["失败", "failed", "error", "❌", "违规", "未通过", "错误", "异常"]
violation_patterns = ["违规", "violation", "拦截", "blocked", "违例"]
# 助理角色消息:用于判断验证结果
if role == "assistant":
has_pass = any(p in content for p in pass_patterns)
has_fail = any(p in content for p in fail_patterns)
if has_pass and not has_fail:
results["pass_msgs"] += 1
elif has_fail and not has_pass:
results["fail_msgs"] += 1
elif has_pass and has_fail:
# 混合内容,默认算通过(因为通常有通过+补充说明)
results["pass_msgs"] += 1
if any(p in content for p in violation_patterns):
results["violation_msgs"] += 1
conn.close()
except Exception as e:
logger.warning(f"查询state.db失败 {db_path}: {e}")
return results
def _query_action_plan_verify(db: Session):
"""从CMA ActionPlan表查询验证数据"""
total = db.query(func.count(ActionPlan.id)).scalar() or 0
verified = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.verify_result.isnot(None)
).scalar() or 0
passed = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.verify_result == "pass"
).scalar() or 0
failed = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.verify_result == "fail"
).scalar() or 0
with_rule = db.query(func.count(ActionPlan.id)).filter(
ActionPlan.auto_verify_rule.isnot(None)
).scalar() or 0
return {
"total_plans": total,
"verified": verified,
"passed": passed,
"failed": failed,
"with_auto_rule": with_rule,
"pass_rate": round(passed / verified * 100, 1) if verified > 0 else 0,
}
# ═══════════════ 端点 ═══════════════
@router.get("/iron-law")
@risk_level("L1")
def get_iron_law_kpis(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
since_days: int = 30,
):
"""
铁律执行效果KPI看板
返回各Bot的验证统计、通过率、违规数,以及ActionPlan的验证覆盖率。
数据来源:
- Hermes 各Profile state.db (消息含 verify/验证/铁律)
- CMA action_plans 表 (verify_result, auto_verify_rule)
"""
# ── 1. 采集各Profile state.db ──
profile_dbs = _list_profile_dbs()
per_bot = {}
total_verify = 0
total_pass = 0
total_fail = 0
total_violations = 0
for profile_name, db_path in profile_dbs:
stats = _query_state_db(db_path, since_days=since_days)
per_bot[profile_name] = {
"verification_count": stats["total_verify_msgs"],
"pass_count": stats["pass_msgs"],
"fail_count": stats["fail_msgs"],
"violation_count": stats["violation_msgs"],
"pass_rate": round(
stats["pass_msgs"] / stats["total_verify_msgs"] * 100, 1
) if stats["total_verify_msgs"] > 0 else 0,
}
total_verify += stats["total_verify_msgs"]
total_pass += stats["pass_msgs"]
total_fail += stats["fail_msgs"]
total_violations += stats["violation_msgs"]
# ── 2. 采集ActionPlan验证数据 ──
plan_stats = _query_action_plan_verify(db)
return {
"bot": bot,
"timestamp": datetime.now().isoformat(),
"period": f"past_{since_days}d",
"summary": {
"total_verifications": total_verify,
"pass_rate": round(total_pass / total_verify * 100, 1) if total_verify > 0 else 0,
"total_violations": total_violations,
"plan_verify_count": plan_stats["verified"],
"plan_pass_rate": plan_stats["pass_rate"],
"plans_with_auto_rule": plan_stats["with_auto_rule"],
"total_action_plans": plan_stats["total_plans"],
},
"per_bot": per_bot,
"action_plan_verification": {
"total_plans": plan_stats["total_plans"],
"verified": plan_stats["verified"],
"passed": plan_stats["passed"],
"failed": plan_stats["failed"],
"pass_rate": plan_stats["pass_rate"],
"with_auto_rule": plan_stats["with_auto_rule"],
"coverage": round(
plan_stats["verified"] / plan_stats["total_plans"] * 100, 1
) if plan_stats["total_plans"] > 0 else 0,
},
"data_sources": {
"state_dbs": len(profile_dbs),
"profiles_queried": [p[0] for p in profile_dbs],
"action_plans_table": True,
},
}
@router.get("/iron-law/bots")
@risk_level("L1")
def get_bot_iron_law_ranking(
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
since_days: int = 30,
):
"""按Bot排名:验证通过率从高到低"""
data = get_iron_law_kpis(bot=bot, db=db, since_days=since_days)
ranking = sorted(
data["per_bot"].items(),
key=lambda x: x[1]["pass_rate"],
reverse=True,
)
ranked = []
for rank, (name, stats) in enumerate(ranking, 1):
ranked.append({
"rank": rank,
"bot_name": name,
"pass_rate": stats["pass_rate"],
"verification_count": stats["verification_count"],
"violations": stats["violation_count"],
"failures": stats["fail_count"],
})
return {
"bot": bot,
"timestamp": datetime.now().isoformat(),
"period": f"past_{since_days}d",
"ranking": ranked,
"total_bots": len(ranked),
}
+195
View File
@@ -0,0 +1,195 @@
"""Bot KPI管理 API — 管理各Agent的KPI自评体系"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_auth
from app.deps import get_entity_id
from app.models import KPIDefinition, KPIValue
from app.utils.alert_entity_scope import month_period_clause
from app.risk_levels import risk_level
router = APIRouter(prefix="/api/cma/bot-kpis", tags=["Bot KPI管理"],
dependencies=[Depends(require_auth)],
)
# 反向指标编码(值越低越好)。
# 方向权威源 = app.api.kpis.REVERSE_INDICATORS(2026-09-15 M2 准入2 收口):
# 本文件原 import 了权威表却未使用、另立字面量 ['FB_RESPONSE_TIME'],会让同一 kpi_code
# 在「能力评分」(取权威表=反向) 与「Bot 自评」(取本表=正向) 方向打架
# (实测同一宇宙内 F_COST_RATIO / F_AR_DAYS / F_REBATE_RATE 三码即为此类)。
# 现改为派生:权威表 ∪ Bot 专有反向指标(零漂移)。
from app.api.kpis import REVERSE_INDICATORS as _AUTHORITATIVE_REVERSE_INDICATORS
BOT_ONLY_REVERSE_INDICATORS = ['FB_RESPONSE_TIME']
REVERSE_BOT_INDICATORS = list(_AUTHORITATIVE_REVERSE_INDICATORS) + [
c for c in BOT_ONLY_REVERSE_INDICATORS if c not in _AUTHORITATIVE_REVERSE_INDICATORS]
def _calc_bot_kpi_score(current_value, target_value, is_reverse=False):
"""五档评分引擎(复用KPI体系)- 1~5分"""
if current_value is None or target_value is None or target_value == 0:
return None, "info"
ratio = current_value / target_value
if is_reverse:
if ratio <= 0.5:
return 5, "success"
elif ratio <= 0.8:
return 4, "success"
elif ratio <= 1.0:
return 3, "warning"
elif ratio <= 1.2:
return 2, "danger"
else:
return 1, "danger"
else:
if ratio >= 1.2:
return 5, "success"
elif ratio >= 1.0:
return 4, "success"
elif ratio >= 0.8:
return 3, "warning"
elif ratio >= 0.5:
return 2, "danger"
else:
return 1, "danger"
@router.get("")
@risk_level("L1")
def list_bot_kpis(
source: str = Query("finance-bot", description="Bot标识"),
period: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取某Bot的所有KPI(含评分)(账套隔离 2026-09-21:仅本账套,对齐 update_bot_kpi_value)"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.bot_source == source,
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).order_by(KPIDefinition.kpi_code).all()
if not kpis:
return {
"source": source,
"kpis": [],
"groups": {},
"overall": None,
}
result_kpis = []
for k in kpis:
# 取最新实际值
val_query = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
)
if period:
val_query = val_query.filter(KPIValue.period == period)
val_query = val_query.filter(month_period_clause(KPIValue.period))
latest_val = val_query.order_by(KPIValue.period.desc()).first()
current_val = latest_val.actual_value if latest_val else None
is_reverse = k.kpi_code in REVERSE_BOT_INDICATORS
score, status = _calc_bot_kpi_score(current_val, k.target_value, is_reverse=is_reverse)
result_kpis.append({
"id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"category": k.category or "core_output",
"formula": k.formula,
"target_value": k.target_value,
"current_value": current_val,
"unit": k.unit,
"weight": 15, # 权重在PRD中定义,但未存表,前端使用
"score": score,
"status": status,
"period": latest_val.period if latest_val else None,
# P0-① 三区标注: Bot KPI值最近回填时间/批次(UI BotSourceTag 消费)
"value_updated_at": latest_val.calculated_at.strftime("%Y-%m-%d %H:%M") if latest_val and latest_val.calculated_at else None,
"value_source_batch": latest_val.source_batch if latest_val else None,
"frequency": k.frequency or "monthly",
})
# 按group分组: core_output / quality / user_feedback
groups = {
"core_output": {"label": "核心产出", "kpis": []},
"quality": {"label": "质量监控", "kpis": []},
"user_feedback": {"label": "用户反馈", "kpis": []},
}
# category映射: FB编码前缀区分
for kp in result_kpis:
code = kp["kpi_code"]
if code.startswith("FB_ANALYSIS") or code.startswith("FB_ACCURACY") or code.startswith("FB_ISSUE") or code.startswith("FB_ACTION") or code.startswith("FB_RESPONSE"):
groups["core_output"]["kpis"].append(kp)
elif code.startswith("FB_DATA") or code.startswith("FB_CONSISTENCY") or code.startswith("FB_CITATION"):
groups["quality"]["kpis"].append(kp)
else:
groups["user_feedback"]["kpis"].append(kp)
# 综合得分
scored_kpis = [k for k in result_kpis if k["score"] is not None]
if scored_kpis:
overall = round(sum(k["score"] * k["weight"] for k in scored_kpis) / sum(k["weight"] for k in scored_kpis), 2)
overall_status = "success" if overall >= 4 else ("warning" if overall >= 3 else "danger")
else:
overall = None
overall_status = "info"
return {
"source": source,
"kpis": result_kpis,
"groups": groups,
"overall": {"score": overall, "status": overall_status},
}
@router.post("/{kpi_id}/value")
@risk_level("L2")
def update_bot_kpi_value(
kpi_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新Bot KPI当前值"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.bot_source.isnot(None),
).first()
if not kpi:
raise HTTPException(404, "Bot KPI不存在")
# 多租户隔离 2026-09-07: 归属校验,禁止跨企业修改他企 Bot KPI
if kpi.entity_id != entity_id:
raise HTTPException(404, "Bot KPI不存在")
actual_value = data.get("actual_value")
if actual_value is None:
raise HTTPException(422, "actual_value 不能为空")
period = data.get("period", datetime.now().strftime("%Y-%m"))
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == period,
).first()
if existing:
existing.actual_value = actual_value
existing.source_type = "manual"
else:
val = KPIValue(
kpi_id=kpi_id,
entity_id=kpi.entity_id if kpi else None, # 账套隔离 P2
period=period,
actual_value=actual_value,
source_type="manual",
data_status="pending",
)
db.add(val)
db.commit()
return {"message": "更新成功", "kpi_id": kpi_id, "period": period, "actual_value": actual_value}
+176
View File
@@ -0,0 +1,176 @@
"""Bot侧AI高危建议创建通道 — 写操作分层授权落地 (P0-② 2026-09-03)
背景(CMA交互架构规范 v1 5.1 分层授权模型,任总拍板):
考核基准类(KPI目标值/预算调整)→ 🔒 Bot只能"建议",人审批后生效
Bot不得直写 KPI target_value / budget 审批类字段;
Bot产生的高危变更一律走本通道建 ai_suggestions(status=unapplied)
→ UI AI建议中心(SuggestionCenter.vue)待批列表 → 人 apply 后生效+留痕。
通道关系(两套分级勿混,见 risk_levels.py 顶部注释):
L1-L4(技术风险分级): 本端点挂 L2(业务写单条)
low/high(业务写语义分级): 本端点是 high 类的"建议载体"——
端点本身不直写考核基准,只建建议行,属 high 流程的入口。
鉴权/审计:
X-BOT-KEY(verify_bot_key, bot_bridge 共享密钥)+ @risk_level("L2")
路径 /api/cma/bot/* → bot_audit_middleware 自动留痕(bot_name/risk/status)
entity_id 多租户: X-Entity-Id header → body.entity_id → 默认1,校验 Entity.active
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import AISuggestion, KPIDefinition, Entity
from app.api.bot_bridge import verify_bot_key
from app.deps import require_bot_entity_id
from app.api.ai_suggestions import _sug_dict
from app.risk_levels import risk_level
router = APIRouter(prefix="/api/cma/bot/ai/suggestions", tags=["AI建议Bot通道"])
# 高危建议类型(Bot仅允许创建这三类建议,禁止其他)
_SUPPORTED_TYPES = ("kpi_target", "budget_adjust", "action_plan")
# source 白名单:Bot 不得冒充 manual/dashboard/kpi/budget(影响UI语义与推送判断)
_ALLOWED_SOURCES = ("rule", "bot")
_TARGET_TYPE_MAP = {
"kpi_target": "kpi",
"budget_adjust": "budget",
"action_plan": "kpi",
}
def _resolve_bot_entity(request: Request, data: dict, db: Session) -> int:
"""账套解析(Bot无token通道): body.entity_id → X-Entity-Id header → **缺省即报错**。
与 deps.get_entity_id 的 Bot 白名单语义一致(query/header/body 任一来源),
本端点不接收登录token,故不在此处理 Bearer。
ASK-20260924-17(任总 2026-09-24 放行):**取消「默认 1(酣客)」兜底**——
缺省账套改用 `deps.require_bot_entity_id` 收口(缺 → 400,非数字 → 422)。
"""
raw = None
if data.get("entity_id") not in (None, ""):
raw = data["entity_id"]
else:
xh = request.headers.get("X-Entity-Id")
if xh and str(xh).isdigit():
raw = int(xh)
eid = require_bot_entity_id({"entity_id": raw}, source="bot_suggestions")
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent or ent.status != "active":
raise HTTPException(403, f"企业 entity_id={eid} 不存在或未激活")
return eid
@router.post("")
@risk_level("L2")
def bot_create_suggestion(
request: Request,
data: dict,
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
"""Bot创建高危建议(kpi_target/budget_adjust/action_plan),status 固定 unapplied 等人审批。
入参(字段与人工创建端点 /api/cma/ai/suggestions 一致):
suggestion_type (str, 必填): kpi_target / budget_adjust / action_plan
title (str, 必填): 建议标题(幂等键之一)
entity_id (int, 可选, 默认1): 账套;也支持 X-Entity-Id header
target_id (int, 可选): 目标ID;kpi_target/budget_adjust 必填(kpi_id 亦可)
suggestion_data(dict, 可选): 应用参数
kpi_target: {kpi_id, target_value}
budget_adjust:{kpi_id, period:YYYY-MM, budget_value}
action_plan: {kpi_id, title?, assignee?, priority?, due_date?}
content (str, 可选): 建议理由/依据(财务Bot三情景测算content携带)
source (str, 默认"bot"): 仅允许 bot/rule,禁止冒充 manual/dashboard
target_type (str, 可选): 缺省按 suggestion_type 映射 kpi/budget
约束:
- status 恒为 unapplied(Bot 不能直接生效)
- kpi_target/budget_adjust 必须带有效 kpi_id/target_id 且属于当前账套
- 幂等: 同 entity+suggestion_type+target_id+title 且 unapplied 已存在 → 返回既有建议
"""
suggestion_type = data.get("suggestion_type")
title = str(data.get("title") or "").strip()
if suggestion_type not in _SUPPORTED_TYPES:
raise HTTPException(
400,
f"Bot建议仅支持 suggestion_type: {'/'.join(_SUPPORTED_TYPES)}(高危变更=建议通道,不提供直写)",
)
if not title:
raise HTTPException(400, "缺少 title")
source = data.get("source", "bot")
if source not in _ALLOWED_SOURCES:
raise HTTPException(400, f"source 仅允许 {'/'.join(_ALLOWED_SOURCES)}(禁止冒充 manual/dashboard/kpi/budget)")
entity_id = _resolve_bot_entity(request, data, db)
sd = data.get("suggestion_data") or {}
target_type = data.get("target_type") or _TARGET_TYPE_MAP.get(suggestion_type)
# 目标归属校验(多租户铁律: 只能建议当前账套的KPI)
kpi_id = None
raw_kpi = data.get("kpi_id") or data.get("target_id") or sd.get("kpi_id")
if raw_kpi is not None:
try:
kpi_id = int(raw_kpi)
except (TypeError, ValueError):
kpi_id = None
if suggestion_type in ("kpi_target", "budget_adjust"):
if kpi_id is None:
raise HTTPException(400, f"suggestion_type={suggestion_type} 必须带 kpi_id/target_id")
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在于当前账套 entity_id={entity_id}")
if suggestion_type == "kpi_target" and sd.get("target_value") is None:
raise HTTPException(400, "kpi_target 建议的 suggestion_data 必须带 target_value")
if suggestion_type == "budget_adjust" and (
not sd.get("period") or sd.get("budget_value") is None
):
raise HTTPException(400, "budget_adjust 建议的 suggestion_data 必须带 period(YYYY-MM) + budget_value")
# 幂等: 同 entity+type+target_id+title 的 unapplied 建议存在则不重复创建
target_id = kpi_id if kpi_id is not None else data.get("target_id")
dup = 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()
if dup:
return {
"success": True,
"message": "建议已存在(幂等,未重复创建)",
"data": _sug_dict(dup),
"duplicated": True,
}
category = "alert" if target_type == "alert" else data.get("category", "decision")
sug = AISuggestion(
entity_id=entity_id,
user_id=None, # Bot 创建无人工账号;bot 名与调用留痕在 bot_audit.log(X-BOT-KEY)
source=source,
suggestion_type=suggestion_type,
target_type=target_type,
category=category,
target_id=target_id,
title=title,
content=data.get("content"),
suggestion_data=sd,
status="unapplied", # 铁律: Bot 只能建议,生效必须人 apply
)
db.add(sug)
db.commit()
db.refresh(sug)
return {
"success": True,
"message": "高危建议已创建,等待人工审批(AI建议中心 → 应用)",
"data": _sug_dict(sug),
"duplicated": False,
}
+42
View File
@@ -0,0 +1,42 @@
"""BSC四层配置 API"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from typing import List
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth
from app.models import BscLayerConfig, Entity
router = APIRouter(prefix="/api/cma/bsc-layers", tags=["BSC层配置"],
dependencies=[Depends(require_auth)],
)
@router.get("")
def list_bsc_layers(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
"""获取某企业的BSC四层权重配置"""
# 验证企业存在
entity = db.query(Entity).filter(Entity.id == entity_id).first()
if not entity:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=404, content={"detail": "企业不存在"})
layers = db.query(BscLayerConfig).filter(
BscLayerConfig.entity_id == entity_id
).order_by(BscLayerConfig.id).all()
return {
"entity_id": entity_id,
"entity_name": entity.short_name or entity.name,
"layers": [
{
"id": l.id,
"layer": l.layer,
"weight": float(l.weight),
"kpi_count_min": l.kpi_count_min,
"kpi_count_max": l.kpi_count_max,
}
for l in layers
]
}
+1782 -18
View File
File diff suppressed because it is too large Load Diff
+312
View File
@@ -0,0 +1,312 @@
"""预算自动从KPI推算 API — P1-2
根据KPI的目标值自动生成预算建议。
"""
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_auth, require_role
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
from app.utils.oplog import log_op, SCOPE_BATCH
from app.utils.kpi_value_scope import actual_only_clause
import json
import logging
from datetime import datetime
logger = logging.getLogger("cma.budget_gen")
router = APIRouter(prefix="/api/cma/budget", tags=["KPI→预算"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
def _calc_budget(kpi: KPIDefinition) -> dict:
"""根据KPI类型推算预算
算法:
- 降本类: (当前值-目标值)×0.3
- 增收类: 目标增收额×0.2
- 能力类: 人均培训成本×人数
- 系统类: 按模块开发费估算
"""
category = kpi.category or ""
target = kpi.target_value or 0
result = {
"suggested_budget": 0,
"calc_logic": "",
"calc_type": "未知",
}
# 降本类: cost_control, cash_risk
if category in ("cost_control", "cash_risk", "asset_efficiency"):
result["calc_type"] = "降本类"
# 当前值需要从最新的KPIValue获取
# 这里返回算法描述,前端传入当前值
result["calc_type_desc"] = "(当前值-目标值)×0.3"
result["suggested_budget"] = 0 # 需要前端传当前值
# 增收类: revenue_growth, profitability
elif category in ("revenue_growth", "profitability", "customer_scale"):
result["calc_type"] = "增收类"
result["calc_type_desc"] = "目标增收额×0.2"
result["suggested_budget"] = round(target * 0.2, 2)
# 能力类: talent_pipeline, employee_engagement, innovation
elif category in ("talent_pipeline", "employee_engagement", "innovation"):
result["calc_type"] = "能力类"
result["calc_type_desc"] = "人均培训成本×人数"
result["suggested_budget"] = 0 # 需要外部参数
# 系统类: 默认为系统类
elif category in ("supply_chain", "delivery_quality", "customer_concentration", "customer_satisfaction"):
result["calc_type"] = "系统类"
result["calc_type_desc"] = "按功能模块开发费估算"
result["suggested_budget"] = round(target * 0.15, 2)
# 其他未分类
else:
result["calc_type"] = "系统类"
result["calc_type_desc"] = "按功能模块开发费估算"
result["suggested_budget"] = round(target * 0.15, 2)
return result
@router.get("/kpi-budget-candidates")
def get_kpi_budget_candidates(
year: int = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取本账套可用于生成预算的KPI列表,按类型分类(账套隔离 2026-09-21)"""
if not year:
year = datetime.now().year
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
# 获取每个KPI的最新实际值
latest_values = {}
for kpi in kpis:
v = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
# 实测区口径:预估行禁作预算生成依据(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.calculated_at.desc()).first()
if v:
latest_values[kpi.id] = v.actual_value
# 分类
categorized = {
"cost_reduction": [], # 降本类
"revenue_growth": [], # 增收类
"capability": [], # 能力类
"system": [], # 系统类
}
for kpi in kpis:
calc_info = _calc_budget(kpi)
current_val = latest_values.get(kpi.id)
# 降本类: 需要当前值
if calc_info["calc_type"] == "降本类":
if current_val is not None and kpi.target_value:
diff = current_val - kpi.target_value
suggested = round(max(diff, 0) * 0.3, 2)
calc_logic = f"当前值{current_val}-目标值{kpi.target_value}={diff:.2f},×0.3={suggested:.2f}"
else:
suggested = 0
calc_logic = "缺少当前值或目标值,无法计算"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "降本类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
}
categorized["cost_reduction"].append(item)
elif calc_info["calc_type"] == "增收类":
suggested = round((kpi.target_value or 0) * 0.2, 2)
calc_logic = f"目标增收额{kpi.target_value}×0.2={suggested:.2f}"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "增收类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
}
categorized["revenue_growth"].append(item)
elif calc_info["calc_type"] == "能力类":
# 假设人均培训成本2000元, 默认10人
suggested = round(2000 * 10, 2)
calc_logic = f"人均培训成本2000元×10人={suggested:.2f}(可调整人数和单价)"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "能力类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
"per_head_cost": 2000,
"head_count": 10,
}
categorized["capability"].append(item)
else: # 系统类
suggested = round((kpi.target_value or 0) * 0.15, 2)
if suggested <= 0:
suggested = 30000 # 默认3万
calc_logic = "按模块开发费估算: 默认30000元(可调整)"
else:
calc_logic = f"目标值{kpi.target_value}×0.15={suggested:.2f}"
item = {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"category": kpi.category,
"calc_type": "系统类",
"target_value": kpi.target_value,
"current_value": current_val,
"suggested_budget": suggested,
"calc_logic": calc_logic,
}
categorized["system"].append(item)
return {"data": categorized}
@router.post("/generate-from-kpis")
def generate_budget_from_kpis(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""从选中的KPI生成预算科目
Body: {
year: int,
month: int,
version: string,
items: [
{
kpi_id: int,
budget_amount: float, // 用户可编辑
calc_logic: string,
calc_type: string,
}
]
}
"""
year = data.get("year", datetime.now().year)
month = data.get("month", datetime.now().month + 1)
version = data.get("version", "v1.0")
items = data.get("items", [])
if not items:
raise HTTPException(400, "请至少选择一个KPI")
period = f"{year}-{month:02d}"
results = []
total_amount = 0
_kpi_entities = set() # 2026-09-23 ④:审计带账套(取被处理 KPI 自身的账套,不猜会话账套)
for item in items:
kpi_id = item.get("kpi_id")
budget_amount = item.get("budget_amount")
calc_logic = item.get("calc_logic", "")
calc_type = item.get("calc_type", "")
if not kpi_id or budget_amount is None:
continue
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
continue
_kpi_entities.add(getattr(kpi, "entity_id", None))
# 检查是否已有记录
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
).first()
if existing:
existing.budget_value = budget_amount
existing.source_type = "kpi_generated"
existing.source_kpi_id = kpi_id
existing.calc_logic = calc_logic
existing.remark = f"KPI推算({calc_type}): {calc_logic}"
plan_id = existing.id
else:
plan = BudgetPlan(
kpi_id=kpi_id,
period=period,
budget_value=budget_amount,
budget_year=year,
budget_month=month,
version=version,
status="active",
source_type="kpi_generated",
source_kpi_id=kpi_id,
calc_logic=calc_logic,
remark=f"KPI推算({calc_type}): {calc_logic}",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(plan)
db.flush()
plan_id = plan.id
total_amount += budget_amount
results.append({
"kpi_id": kpi_id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"budget_amount": budget_amount,
"calc_logic": calc_logic,
"plan_id": plan_id,
})
# 操作日志(2026-09-23 ④:detail 带账套;跨账套批量 ⇒ 留空 + 哨兵,不猜账套)
_entities = sorted(x for x in _kpi_entities if x is not None)
log_op(db, "kpi_generate_budget", target_type="budget",
user_id=getattr(current_user, "id", None),
detail={
"year": year,
"month": month,
"version": version,
"item_count": len(results),
"total_amount": total_amount,
"kpi_entity_ids": _entities,
},
entity_id=(_entities[0] if len(_entities) == 1 else None),
scope=(SCOPE_BATCH if len(_entities) > 1 else None))
db.commit()
return {
"message": f"已从{len(results)}个KPI生成预算,合计¥{total_amount:,.2f}",
"total_amount": total_amount,
"items": results,
}
File diff suppressed because it is too large Load Diff
+266
View File
@@ -0,0 +1,266 @@
"""现金流分类规则 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(有期间和预算值时;年度行跳过,不落月度计划)
from app.api.budget import _is_annual_budget_row # 函数内局部导入,规避 import 顺序/循环风险
plan_created = False
skipped_annual = False
if item.period and item.budget_value is not None:
# 先按形态解析月份(不裸索引 split,避免 'YYYY' / 'YYYY-00' 抛异常被静默吞掉)
parts = item.period.split("-") if isinstance(item.period, str) else []
month = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
if _is_annual_budget_row(item.period, month):
# 年度行(YYYY / YYYY-00 / budget_month=0)不得落成月度现金流计划
skipped_annual = True
else:
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()
message = f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else "")
if skipped_annual:
message += "(年度行,跳过月度现金流计划)"
return {
"message": message,
"plan_type": plan_type,
"rule_created": not existing_rule,
"plan_created": plan_created,
"skipped_annual": skipped_annual,
}
@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": "已忽略"}
+496
View File
@@ -0,0 +1,496 @@
"""
智享通凭证 JSON 直传导入端点 (zxt-cma-import-v1, 2026-09-06)
============================================================
背景:智享通推送链路每10分钟落盘 2000 行凭证分录 JSON,需进 CMA voucher_details。
现有 /api/cma/cash/import/vouchers 只收 Excel multipart,本模块提供 JSON 直传版本。
链路:
接收服务 8500 落盘 incoming/{ts}.json → hook(zxt-cma-import-hook.py) → POST 本端点
鉴权:X-BOT-KEY: cma-bot-admin-2026(verify_bot_key,与 /api/cma/bot/* 共享密钥)。
注意:本 router 独立于 cash.py 的 router —— cash.router 带 require_role 需用户
Bearer token,Bot hook 只有 X-BOT-KEY 到不了,故同前缀另挂本 router(FastAPI 允许多 router 同前缀)。
校验口径(对齐财务BOT映射说明 cma_field_mapping_finance_20260906.md + 任务单验收):
1. 行级:voucher_no 必有;debit/credit 字符串→float round2,允许负数(红字记账,负数行计数 neg_rows),借贷不同时0
2. 结转行:summary 含"结转" → carry_forward=1(zxt 113行"期间损益结转"100%命中)
3. 凭证级借贷平衡(每 Voucher_ID Σ借=Σ贷 容差0.01)—— 批级平衡对 zxt 快照不适用
(已知单边凭证 30694 仅贷5479.65),不平衡凭证整张拒收记异常清单,不整批拒绝
4. 日期/period:三级优先——行内 voucher_date/VoucherDate/BillDate 字段 → summary 前缀
YYYY-MM-DD 解析 → 均无则按 pending 拒收;不冒充文件生成时间/当前时间
5. 科目:subject_code/subject_name 全 null(智享通侧未带)。解析优先级:
a) 行内 subject_code/subject_name 有值 → 直用
b) 无 → 查请求体 subject_map {CwAccountID: {subject_code, subject_name}}
或 DB 表 zxt_account_dict(CwAccountID, subject_code, subject_name)(若已建)
c) 未命中 → 该行拒收 reason="科目字典未命中 CwAccountID=x"(不写脏数据)
6. 幂等:快照每10分钟全量重推同一内容。batch = zxt-{行集sha1前10},
导入前查 import_logs 已存在同 batch → 直接返回 already_imported(不再入库);
行级再以业务键 (entity_id, voucher_no, subject_code, debit, credit, summary) 防重复
—— period 是 voucher_date 派生属性,**不进幂等键**(2026-09-11 加固:日期口径
在 summary 派生/主表 VoucherDate 之间切换时,含 period 会判重失效导致重复入库)。
命中既有行即跳过;仅当命中行 period 与新行不一致时计入 period_conflict 留痕(不覆盖)。
7. entity_id:**必传**(ASK-20260924-17,任总 2026-09-24 放行)——body `entity_id` 或
header `X-Entity-Id`(1=陕西酣客、2=陕西博海);**缺省 → 400**,
不再兜底默认账套 1(原口径:默认 1 陕西酣客,source=zxt-sxzxb20210228)。
8. 入库后不触发现金流/ledger 联动(凭证完整性未证实前不作为权威来源,见财务口径警示);
也不影响 Excel multipart 导入(原端点不动)。
"""
import hashlib
import json
import logging
import re
from datetime import date, datetime
from typing import Any, Optional, Union
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy import text as sa_text
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import resolve_entity_for_request, require_bot_entity_id
from app.models import Entity, ImportLog, VoucherDetail
from app.api.bot_bridge import verify_bot_key
logger = logging.getLogger("cma.cash_zxt")
router = APIRouter(prefix="/api/cma/cash", tags=["资金管理-智享通JSON导入"])
_DATE_RE = re.compile(r"(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})")
_AMOUNT_RE = re.compile(r"^-?\d[\d,]*\.?\d*$")
def _norm_str(raw) -> str:
"""干净字符串:None/空 → ''"""
if raw is None:
return ""
return str(raw).strip()
def _parse_amount(raw) -> Optional[float]:
"""金额:字符串数字(可带逗号)→ float;空/None → 0.0;非法 → None"""
if raw is None:
return 0.0
s = str(raw).strip()
if s == "":
return 0.0
if not _AMOUNT_RE.match(s):
return None
try:
return round(float(s.replace(",", "")), 2)
except Exception:
return None
def _parse_date_from_summary(summary: str):
"""summary 前缀提取日期 (YYYY-MM-DD)。zxt 格式='业务类型-YYYY-MM-DD-序号_往来_业务员'"""
m = _DATE_RE.search(summary or "")
if not m:
return None
try:
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)))
except ValueError:
return None
def _parse_date_value(raw) -> Optional[datetime]:
"""行内日期字段解析(voucher_date/VoucherDate/BillDate)。
datetime/date 对象直用;字符串 strip 后 fromisoformat(YYYY-MM-DD / YYYY-MM-DD[ T]HH:MM:SS),
失败回退 _DATE_RE search(YYYY/MM/DD 等);仍取不到返回 None——严禁冒充当前时间/文件时间。"""
if raw is None:
return None
if isinstance(raw, datetime):
return datetime(raw.year, raw.month, raw.day)
if isinstance(raw, date):
return datetime(raw.year, raw.month, raw.day)
s = str(raw).strip()
if not s:
return None
try:
dt = datetime.fromisoformat(s)
return datetime(dt.year, dt.month, dt.day)
except ValueError:
pass
m = _DATE_RE.search(s)
if not m:
return None
try:
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3)))
except ValueError:
return None
def _is_carry_forward(summary: str) -> bool:
"""结转行识别(subject_name 缺失只能靠 summary):zxt 期间损益结转 100% 命中"""
return "结转" in (summary or "")
def _content_fingerprint(rows: list) -> str:
"""行集内容指纹:每行规范化 JSON 排序后 sha1 前 10 位(快照幂等用)"""
canon = json.dumps(rows, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha1(canon.encode("utf-8")).hexdigest()[:10]
def _load_zxt_account_dict(db: Session) -> dict:
"""读 DB 科目字典表(若已建):zxt_account_dict(CwAccountID, subject_code, subject_name)。
表不存在 → 返回空 dict(由调用方决定走请求体 subject_map 或拒收)"""
try:
rows = db.execute(
sa_text("SELECT CwAccountID, subject_code, subject_name FROM zxt_account_dict")
).fetchall()
return {
str(r[0]): {"subject_code": str(r[1]), "subject_name": str(r[2])}
for r in rows if r[1]
}
except Exception:
return {}
def _resolve_subject(row: dict, db_account_dict: dict, req_subject_map: dict):
"""科目解析优先级:行内 subject_code → DB 字典 → 请求体 subject_map。返回 (code, name) 或 None"""
code = _norm_str(row.get("subject_code"))
name = _norm_str(row.get("subject_name"))
if code:
return code, name
cw_id = _norm_str(row.get("CwAccountID"))
if not cw_id:
return None
hit = db_account_dict.get(cw_id) or req_subject_map.get(cw_id)
if not hit:
return None
c = _norm_str(hit.get("subject_code")) if isinstance(hit, dict) else ""
if not c:
return None
n = _norm_str(hit.get("subject_name")) if isinstance(hit, dict) else ""
return c, n
def _entity_active(db: Session, entity_id: int) -> bool:
try:
ent = db.query(Entity).filter(Entity.id == entity_id).first()
return bool(ent and ent.status == "active")
except Exception:
return entity_id == 1 # Entity 模型不可用兜底(正常不会走到)
@router.post("/import/vouchers-json")
def api_import_vouchers_json(
request: Request,
payload: Union[dict, list] = Body(...),
bot: dict = Depends(verify_bot_key),
db: Session = Depends(get_db),
):
"""JSON 直传凭证导入(智享通推送通道)。三种 body 形态均兼容:
A. 裸 rows 数组: [{Voucher_ID, CwAccountID, summary, debit, credit, ...}]
B. {source?, rows: [...]}
C. 完整落盘包: {schema, source, datasets: [{dataset, mode, rows}]} → 取 datasets[0].rows
附加字段(形态 B/C): entity_id(默认1) / subject_map({CwAccountID: {subject_code, subject_name}})
"""
# ── body 形态归一 → rows ──
rows_raw: list = []
source = ""
req_entity = None
req_subject_map: dict = {}
if isinstance(payload, list):
rows_raw = payload
elif isinstance(payload, dict):
if "datasets" in payload and isinstance(payload.get("datasets"), list):
ds = payload["datasets"]
if not ds or not isinstance(ds[0], dict):
raise HTTPException(400, "datasets 结构不符: 缺少 datasets[0]")
rows_raw = ds[0].get("rows") or []
source = _norm_str(payload.get("source"))
elif "rows" in payload:
rows_raw = payload.get("rows") or []
source = _norm_str(payload.get("source"))
else:
raise HTTPException(400, "无法识别的请求体: 期望 rows 数组 / {rows:[...]} / {datasets:[{rows:[...]}]}")
req_entity = payload.get("entity_id")
if isinstance(payload.get("subject_map"), dict):
req_subject_map = payload["subject_map"]
else:
raise HTTPException(400, "请求体必须为 JSON 对象或数组")
if not isinstance(rows_raw, list) or not rows_raw:
raise HTTPException(400, "rows 为空或非数组")
# 兼容 row key 大小写(智享通字段为大写 Voucher_ID/CwAccountID)
norm_rows = []
for i, r in enumerate(rows_raw):
if not isinstance(r, dict):
raise HTTPException(400, f"rows[{i}] 不是JSON对象")
nr = {}
for k, v in r.items():
nr[k] = v
if k == "Voucher_ID":
nr["voucher_no"] = v
elif k == "CwAccountID":
nr["CwAccountID"] = v
norm_rows.append(nr)
# ── entity 解析 + active 校验(ASK-20260924-17:Bot 通道必须显式传账套)──
entity_id = resolve_entity_for_request(
request,
require_bot_entity_id(
{"entity_id": req_entity if req_entity not in (None, "") else request.headers.get("X-Entity-Id")},
source="cash_vouchers_json.import/vouchers-json",
),
)
try:
entity_id = int(entity_id)
except Exception:
raise HTTPException(422, f"entity_id 无效: {entity_id!r}")
if not _entity_active(db, entity_id):
raise HTTPException(403, f"企业ID={entity_id} 不存在或未激活")
db_account_dict = _load_zxt_account_dict(db)
# ── 行级解析 + 校验 ──
errors = []
parsed = []
pending_undated = 0
pending_dict = 0
neg_rows = 0
for idx, raw in enumerate(norm_rows):
voucher_no = _norm_str(raw.get("voucher_no") or raw.get("Voucher_ID"))
if not voucher_no:
errors.append({"row": idx + 1, "field": "voucher_no", "reason": "凭证号为空"})
continue
# 三级优先:行内日期字段 → summary 前缀 → 拒收
date_obj = _parse_date_value(
raw.get("voucher_date") or raw.get("VoucherDate") or raw.get("BillDate")
)
if date_obj is None:
date_obj = _parse_date_from_summary(_norm_str(raw.get("summary")))
if date_obj is None:
pending_undated += 1
errors.append({"row": idx + 1, "field": "voucher_date",
"reason": f"行内日期与summary均无日期(待日期来源): {_norm_str(raw.get('summary'))[:40]}"})
continue
debit = _parse_amount(raw.get("debit"))
credit = _parse_amount(raw.get("credit"))
if debit is None:
errors.append({"row": idx + 1, "field": "debit", "reason": f"借方金额不是有效数字: {raw.get('debit')}"})
continue
if credit is None:
errors.append({"row": idx + 1, "field": "credit", "reason": f"贷方金额不是有效数字: {raw.get('credit')}"})
continue
if debit < 0 or credit < 0:
neg_rows += 1 # 红字记账允许负数入库,仅计数不拒收
if debit == 0 and credit == 0:
errors.append({"row": idx + 1, "field": "amount", "reason": "借贷金额不能同时为0"})
continue
summary = _norm_str(raw.get("summary"))
subject = _resolve_subject(raw, db_account_dict, req_subject_map)
if subject is None:
pending_dict += 1
errors.append({"row": idx + 1, "field": "subject_code",
"reason": f"科目字典未命中 CwAccountID={_norm_str(raw.get('CwAccountID')) or '空'}"})
continue
parsed.append({
"voucher_no": voucher_no,
"date": date_obj,
"period": date_obj.strftime("%Y-%m"),
"debit_amount": debit,
"credit_amount": credit,
"summary": summary,
"carry_forward": 1 if _is_carry_forward(summary) else 0,
"subject_code": subject[0],
"subject_name": subject[1],
})
total = len(rows_raw)
if not parsed:
db.rollback()
return {
"success": False,
"message": "无有效行可入库(科目字典未建立/日期缺失/行级校验全失败),全部拒绝",
"total": total,
"success_rows": 0,
"failed_rows": len(errors),
"errors": errors[:100],
"balance_check": None,
"carry_forward_count": 0,
"batch": None,
"entity_id": entity_id,
"pending_undated": pending_undated,
"pending_dict_miss": pending_dict,
"neg_rows": neg_rows,
"dup_skipped": 0,
"period_conflict": 0,
"hint": "科目字典:请协调智享通推送方补 subject_code/subject_name,或在 body 带 subject_map,或建 zxt_account_dict 表",
}
# ── 凭证级借贷平衡(每 Voucher_ID Σ借=Σ贷 容差0.01)──
balance_by_voucher = {}
for r in parsed:
bv = balance_by_voucher.setdefault(r["voucher_no"], {"debit_total": 0.0, "credit_total": 0.0})
bv["debit_total"] = round(bv["debit_total"] + r["debit_amount"], 2)
bv["credit_total"] = round(bv["credit_total"] + r["credit_amount"], 2)
abnormal_vouchers = []
voucher_pass = set()
for vno, bv in balance_by_voucher.items():
diff = round(bv["debit_total"] - bv["credit_total"], 2)
if abs(diff) > 0.01:
abnormal_vouchers.append({
"voucher_no": vno,
"debit_total": bv["debit_total"],
"credit_total": bv["credit_total"],
"diff": diff,
})
else:
voucher_pass.add(vno)
if abnormal_vouchers:
abnormal_set = {a["voucher_no"] for a in abnormal_vouchers}
kept = []
for r in parsed:
if r["voucher_no"] in abnormal_set:
errors.append({"row": 0, "field": "balance",
"reason": f"凭证 {r['voucher_no']} 借贷不平衡(整张凭证拒绝入库)"})
else:
kept.append(r)
parsed = kept
valid_rows = [r for r in parsed if r["voucher_no"] in voucher_pass]
success = len(valid_rows)
failed = len(errors)
# ── 幂等:batch 内容指纹去重(batch 不含时间戳——同内容快照每10分钟重推必须命中同一 batch)──
fingerprint = _content_fingerprint(rows_raw)
batch = f"zxt-{fingerprint}"
existing_batch = db.query(ImportLog).filter(
ImportLog.batch == batch,
ImportLog.import_type == "vouchers-json",
).first()
if existing_batch:
db.rollback()
return {
"success": True,
"already_imported": True,
"message": "该快照内容已导入过(同一指纹 batch),跳过重复入库",
"total": total,
"success_rows": 0,
"failed_rows": 0,
"dup_skipped": 0,
"period_conflict": 0,
"neg_rows": neg_rows,
"pending_undated": pending_undated,
"pending_dict_miss": pending_dict,
"batch": batch,
"entity_id": entity_id,
}
# ── 入库(行级业务键防重复)──
# 幂等键 = (entity_id, voucher_no, subject_code, debit, credit, summary):
# period 是由 voucher_date 派生的属性,不进唯一性键(否则日期口径变化时会判重失效)。
# 同键多行按"载荷内出现次序"编号:第 n 次出现只与库内第 n 行比对——跨批次同键多重数
# 时不会因 query().first() 恒命中同一既存行而把源端合法多行静默吞掉。
inserted = 0
dup_skipped = 0
period_conflict = 0
period_conflicts: list = []
row_groups: dict = {}
for r in valid_rows:
k = (r["voucher_no"], r["subject_code"], r["debit_amount"],
r["credit_amount"], r["summary"])
row_groups.setdefault(k, []).append(r)
for k, group_rows in row_groups.items():
existing_rows = db.query(VoucherDetail).filter(
VoucherDetail.entity_id == entity_id,
VoucherDetail.voucher_no == k[0],
VoucherDetail.subject_code == k[1],
VoucherDetail.debit_amount == k[2],
VoucherDetail.credit_amount == k[3],
VoucherDetail.summary == k[4],
).order_by(VoucherDetail.id).all()
for pos, r in enumerate(group_rows):
if pos < len(existing_rows):
dup_skipped += 1
old = existing_rows[pos]
if (old.period or None) != (r["period"] or None):
# 判重命中但日期口径不同:只留痕不覆盖(留痕优先,人工判定)
period_conflict += 1
if len(period_conflicts) < 50:
period_conflicts.append({
"voucher_no": r["voucher_no"],
"existing_id": old.id,
"existing_period": old.period,
"incoming_period": r["period"],
"existing_batch": old.batch,
"incoming_batch": batch,
})
continue
db.add(VoucherDetail(
entity_id=entity_id,
voucher_no=r["voucher_no"],
voucher_date=r["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,
))
inserted += 1
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"])
db.add(ImportLog(
entity_id=entity_id,
filename=f"zxt-push-voucher-snapshot.json" + (f"({source})" if source else ""),
batch=batch,
total_rows=total,
success_rows=inserted,
failed_rows=failed,
errors=errors[:100] or None,
period=valid_rows[0]["period"] if valid_rows else None,
import_type="vouchers-json",
created_by="zxt-cma-import-hook",
))
db.commit()
logger.info(
f"智享通凭证JSON导入[{batch}] entity={entity_id}: 总{total}/入库{inserted}/行级失败{failed}/重复跳过{dup_skipped}/"
f"期间冲突{period_conflict}, 凭证级异常{len(abnormal_vouchers)}"
)
if period_conflict:
logger.warning(
f"智享通凭证JSON导入[{batch}] entity={entity_id}: 行级幂等命中但 period 不一致 {period_conflict} 行"
f"(未覆盖仅留痕,样例: {period_conflicts[:5]})"
)
return {
"success": True,
"total": total,
"success_rows": inserted,
"failed_rows": failed,
"dup_skipped": dup_skipped,
"period_conflict": period_conflict,
"period_conflicts": period_conflicts or None,
"errors": errors[:100] or None,
"balance_check": {
"mode": "voucher_level",
"passed": len(abnormal_vouchers) == 0,
"abnormal_vouchers": abnormal_vouchers,
"tolerance": 0.01,
},
"period_totals": period_totals,
"carry_forward_count": carry_forward_count,
"pending_undated": pending_undated,
"pending_dict_miss": pending_dict,
"neg_rows": neg_rows,
"batch": batch,
"entity_id": entity_id,
}
+450
View File
@@ -0,0 +1,450 @@
"""
现金流量表结构分析 — 管理会计OS (CMA P2 · 财务报表分析)
规范命名:现金流量表结构分析(本模块不采用民间俗称,术语以任务单为准)
核心方法:经营(O)/投资(I)/筹资(F) 三大活动净额正负组合 → 8类企业定性分析
O 造血能力 / I 成长代价 / F 输血管道
长期黄金法则:经营现金流必须为正,且应大于净利润(利润质量)。
数据模型:cashflow_statements — 官方现金流量表三大活动净额按期间注入
- 未注入期间:无判定(不拿演示/示例数据冒充真实类型)
- 官方净额入库后类型判定自动生效(博海2026官方表注入路径)
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional, List
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import CashFlowStructureValue
router = APIRouter(
prefix="/api/cma/cashflow-structure",
tags=["现金流量表结构分析"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
# ============================================================
# 八类企业定性分析规则表(O/I/F 符号组合 → 类型)
# ============================================================
CASHFLOW_TYPE_RULES = [
{
"type_no": 1, "o_sign": "+", "i_sign": "-", "f_sign": "-",
"name": "成熟健康型", "health_emoji": "🟢", "health_label": "最优",
"lifecycle": "成熟期",
"profile": "主业盈利,自给自足;仍在投入扩张且有余力还债/分红。最健康形态(如成熟消费龙头)。",
"risk": "注意扩张效率与分红可持续性;经营现金流应大于净利润,否则利润质量存疑。",
},
{
"type_no": 2, "o_sign": "+", "i_sign": "+", "f_sign": "-",
"name": "成熟收割型", "health_emoji": "🟢", "health_label": "良好",
"lifecycle": "成熟期后期/集团收割期",
"profile": "主业赚钱+过去投资进入回报期(处置/收益回笼)+还在还债分红。",
"risk": "若投资净流入来自大量处置核心资产,需警惕「卖资产过日子」。",
},
{
"type_no": 3, "o_sign": "+", "i_sign": "-", "f_sign": "+",
"name": "高速扩张型", "health_emoji": "🟡", "health_label": "看增速",
"lifecycle": "成长期",
"profile": "主业能赚钱,但扩张更快、现金不够花,需要融资支撑资本开支。成长质量取决于经营造血增速能否追上扩张。",
"risk": "扩张节奏失控 → 现金消耗加速 → 滑向类型6/7。",
},
{
"type_no": 4, "o_sign": "+", "i_sign": "+", "f_sign": "+",
"name": "全面回笼型", "health_emoji": "🟡", "health_label": "需辨析",
"lifecycle": "重组/收缩盘点期",
"profile": "三类活动都进钱,少见。多为经营好转+资产处置+新增融资并举(可能正在重组)。",
"risk": "需具体辨析筹资来源(股权/债权)与投资流入是否靠卖资产。",
},
{
"type_no": 5, "o_sign": "-", "i_sign": "+", "f_sign": "-",
"name": "衰退变卖型", "health_emoji": "🔴", "health_label": "高危",
"lifecycle": "衰退期/清盘前奏",
"profile": "主业亏损,靠卖资产/处置投资回笼现金,同时还在还债。濒危信号。",
"risk": "资产卖完即无以为继;除非经营反转否则难持续。",
},
{
"type_no": 6, "o_sign": "-", "i_sign": "-", "f_sign": "+",
"name": "初创烧钱型", "health_emoji": "🟠", "health_label": "高风险",
"lifecycle": "初创期/高增长投入期",
"profile": "主业烧钱+持续投入,全靠融资吊命。常见于初创/高增长科技企业;也是庞氏结构的表面特征。",
"risk": "融资链条断裂即死;须区分「真成长」(收入/用户高速增长)与「假烧钱」。",
},
{
"type_no": 7, "o_sign": "-", "i_sign": "+", "f_sign": "+",
"name": "输血生存型", "health_emoji": "🔴", "health_label": "高危",
"lifecycle": "挣扎期",
"profile": "经营亏损,靠卖资产+借款输血双管齐下维持。压力极大。",
"risk": "短期可活,长期必须扭转经营现金流,否则资金链断裂。",
},
{
"type_no": 8, "o_sign": "-", "i_sign": "-", "f_sign": "-",
"name": "全面失血型", "health_emoji": "🔴", "health_label": "极危",
"lifecycle": "深度危机期",
"profile": "经营/投资/筹资三个引擎全熄火,现金只出不进。最危险状态。",
"risk": "极可能需要紧急债务重组/股东注资/断臂求生。",
},
]
# 类型 → 规则 快速索引
TYPE_RULE_MAP = {r["type_no"]: r for r in CASHFLOW_TYPE_RULES}
# ============================================================
# 裁定1(2026-09-17 财务口径裁定):投资活动净额 = 0 的合法输入与二维映射
# 「投资活动净额=0」视为**本期无投资活动**,按 0 如实参与八类判定;
# 三项中投资恒为 0 → 按 经营(±) × 筹资(±) **二维映射**到对应类型(候选 2 个,
# 因投资符号未知);投资栏位标注「本期无投资活动」。
# 依据:wecom-finance/reports/CMA_口径裁定_20260917.md 裁定1
# ============================================================
INVESTING_ZERO_NOTE = "本期无投资活动"
#: (经营符号, 筹资符号) → 投资符号未知时的候选类型号(对应类型由 8 类规则表反查)
TWO_DIM_CANDIDATE_MAP = {
("+", "-"): (1, 2),
("+", "+"): (3, 4),
("-", "-"): (5, 8),
("-", "+"): (6, 7),
}
def _sign(value: Optional[float]) -> Optional[str]:
"""净额符号:>0 → +,<0 → -,==0 → '0',None → None"""
if value is None:
return None
if value > 0:
return "+"
if value < 0:
return "-"
return "0"
def _rule_brief(type_no: int) -> dict:
"""类型号 → 规则摘要(二维判定用的候选项,字段与三维判定保持一致)"""
r = TYPE_RULE_MAP[type_no]
return {
"type_no": r["type_no"], "name": r["name"], "health_emoji": r["health_emoji"],
"health_label": r["health_label"], "lifecycle": r["lifecycle"],
"o_sign": r["o_sign"], "i_sign": r["i_sign"], "f_sign": r["f_sign"],
"profile": r["profile"], "risk": r["risk"],
}
def classify_enterprise(operating_net: Optional[float],
investing_net: Optional[float],
financing_net: Optional[float]) -> dict:
"""O/I/F 三净额符号 → 8类企业定性判定
返回:
{classified, type_no, name, health_emoji, health_label, risk, profile, lifecycle,
dimension, o_sign, i_sign, f_sign, reason, investing_note?, candidate_type_nos?, candidates?}
classified=False 时 reason 说明未判定的原因。
2026-09-17 裁定1(财务口径):
· **投资活动净额 = 0 是合法输入**(视为「本期无投资活动」),不再整体判「未判定」;
· 投资为 0 且经营/筹资可判 → **二维映射**(dimension="2D",候选类型 2 个,
投资栏位 investing_note="本期无投资活动");
· 经营/筹资任一亦为 0 或缺失 → 回退「未判定(无投资活动)」,**不报错、不静默**。
"""
o, i, f = _sign(operating_net), _sign(investing_net), _sign(financing_net)
names = {"o": "经营", "i": "投资", "f": "筹资"}
missing = [names[k] for k, s in (("o", o), ("i", i), ("f", f)) if s is None]
if missing:
return {"classified": False, "dimension": "0D",
"reason": "以下活动净额缺失:" + "、".join(missing) + "(需官方现金流量表数据)"}
zeros = [k for k, s in (("o", o), ("i", i), ("f", f)) if s == "0"]
inv_zero = i == "0"
if zeros:
# 恰好为 0 的维度:投资为 0 → 「本期无投资活动」;其余维度为 0 → 仍是有效输入但不可单独归类
zero_names = "、".join(names[k] for k in zeros)
if len(zeros) >= 2 or (o == "0" or f == "0"):
note = "(无投资活动)" if inv_zero else ""
return {
"classified": False, "dimension": "0D",
"o_sign": o, "i_sign": i, "f_sign": f,
"investing_note": INVESTING_ZERO_NOTE if inv_zero else None,
"reason": ("未判定%s:%s活动净额为 0(%s)且不足以二维归类,"
"请按业务实质人工判定" % (
note, zero_names,
"投资活动为 0 = 本期无投资活动" if inv_zero else "该维度无正负号")),
}
# 单维为 0 且该维为投资 → 二维(经营 × 筹资)映射
pair = TWO_DIM_CANDIDATE_MAP.get((o, f)) if (o in ("+", "-") and f in ("+", "-")) else None
if pair:
cands = [_rule_brief(t) for t in pair]
return {
"classified": True,
"dimension": "2D",
"dimension_label": "二维判定(投资活动=0,本期无投资活动)",
"type_no": None,
"name": "/".join("%s %s" % (c["type_no"], c["name"]) for c in cands),
"candidate_type_nos": list(pair),
"candidates": cands,
"health_emoji": "", "health_label": None, "lifecycle": None,
"profile": ";".join("类型%d %s:%s" % (c["type_no"], c["name"], c["profile"]) for c in cands),
"risk": ";".join("类型%d:%s" % (c["type_no"], c["risk"]) for c in cands),
"o_sign": o, "i_sign": i, "f_sign": f,
"investing_note": INVESTING_ZERO_NOTE,
"reason": ("经营(%s) 投资(0=%s) 筹资(%s) → 二维映射候选:%s"
"(投资活动为 0 无法定符号,需结合业务实质在候选中确认)" % (
o, INVESTING_ZERO_NOTE, f,
" / ".join("类型%d %s" % (c["type_no"], c["name"]) for c in cands))),
}
for rule in CASHFLOW_TYPE_RULES:
if rule["o_sign"] == o and rule["i_sign"] == i and rule["f_sign"] == f:
return {
"classified": True,
"dimension": "3D",
"type_no": rule["type_no"],
"name": rule["name"],
"health_emoji": rule["health_emoji"],
"health_label": rule["health_label"],
"lifecycle": rule["lifecycle"],
"profile": rule["profile"],
"risk": rule["risk"],
"o_sign": o, "i_sign": i, "f_sign": f,
"investing_note": None,
"reason": "经营(%s) 投资(%s) 筹资(%s) → 类型%d %s" % (o, i, f, rule["type_no"], rule["name"]),
}
return {"classified": False, "dimension": "0D", "reason": "未知组合,请检查数据"}
class CashFlowValueIn(BaseModel):
"""三大活动净额录入(官方现金流量表 / 人工)"""
period: str
operating_net: float
investing_net: float
financing_net: float
begin_cash: Optional[float] = None
end_cash: Optional[float] = None
source: str = "official"
remark: Optional[str] = None
def _validate_period(period: str) -> str:
"""期间格式校验 YYYY-MM"""
period = (period or "").strip()
try:
datetime.strptime(period, "%Y-%m")
except Exception:
raise HTTPException(status_code=422, detail="期间格式须为 YYYY-MM,如 2026-08")
return period
# ============================================================
# 接口
# ============================================================
@router.get("/rules")
def get_rules():
"""八类企业定性判定规则表(O/I/F 符号组合速查)"""
return {"rules": CASHFLOW_TYPE_RULES}
@router.get("/periods")
def list_periods(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""已注入净额的期间列表(倒序,供按期间查询)"""
rows = db.query(CashFlowStructureValue.period).filter(
CashFlowStructureValue.entity_id == entity_id
).order_by(CashFlowStructureValue.period.desc()).all()
return {"periods": [r[0] for r in rows]}
@router.get("/values")
def list_values(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""已注入的三大活动净额记录(按期间倒序)"""
rows = db.query(CashFlowStructureValue).filter(
CashFlowStructureValue.entity_id == entity_id
).order_by(CashFlowStructureValue.period.desc()).all()
return {"items": [
{
"id": r.id, "period": r.period,
"operating_net": r.operating_net,
"investing_net": r.investing_net,
"financing_net": r.financing_net,
"begin_cash": r.begin_cash, "end_cash": r.end_cash,
"source": r.source, "remark": r.remark,
"updated_at": r.updated_at.strftime("%Y-%m-%d %H:%M") if r.updated_at else None,
} for r in rows
]}
@router.get("/analysis")
def get_analysis(
period: str = Query(None, description="格式 YYYY-MM,缺省取最近已注入期间"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""现金流量表结构分析 — 三净额 → 八类企业判定 + 瀑布图数据"""
if period:
period = _validate_period(period)
# 1. 取官方/人工注入的净额(唯一权威判定数据)
q = db.query(CashFlowStructureValue).filter(
CashFlowStructureValue.entity_id == entity_id
)
if period:
rec = q.filter(CashFlowStructureValue.period == period).first()
else:
rec = q.order_by(CashFlowStructureValue.period.desc()).first()
if rec:
period = rec.period
if rec is None:
return {
"period": period or datetime.now().strftime("%Y-%m"),
"data_available": False,
"message": "该期间暂无官方现金流量表净额数据,请先录入三大活动净额(官方现金流量表注入后自动生效)。",
"nets": None,
"classification": None,
"waterfall": None,
}
classification = classify_enterprise(rec.operating_net, rec.investing_net, rec.financing_net)
i_sign = _sign(rec.investing_net)
# 裁定1:投资活动为 0 → 栏位显式标注「本期无投资活动」,避免读者误判为「收付相抵的 0」
investing_note = classification.get("investing_note") or (
INVESTING_ZERO_NOTE if i_sign == "0" else None)
nets = {
"operating": {"key": "operating", "name": "经营活动", "value": rec.operating_net,
"sign": _sign(rec.operating_net), "unit": "万元", "note": None},
"investing": {"key": "investing", "name": "投资活动", "value": rec.investing_net,
"sign": i_sign, "unit": "万元", "note": investing_note},
"financing": {"key": "financing", "name": "筹资活动", "value": rec.financing_net,
"sign": _sign(rec.financing_net), "unit": "万元", "note": None},
}
# 2. 瀑布图:期初现金 → +经营 → +投资 → +筹资 → 期末现金
waterfall = None
if rec.begin_cash is not None or rec.end_cash is not None:
begin = rec.begin_cash
end = rec.end_cash
steps = []
cum = begin if begin is not None else 0.0
if begin is not None:
steps.append({"step": "期初现金", "value": begin, "type": "begin"})
for net_key, net_name in [("operating", "经营活动"), ("investing", "投资活动"), ("financing", "筹资活动")]:
val = getattr(rec, net_key + "_net") or 0.0
cum += val
steps.append({"step": net_name, "value": round(val, 2), "type": "flow"})
if end is not None:
steps.append({"step": "期末现金", "value": end, "type": "end"})
elif begin is not None:
end = round(cum, 2)
steps.append({"step": "期末现金(推算)", "value": end, "type": "end"})
waterfall = {
"begin_cash": begin, "end_cash": end,
"net_increase": round((end - begin), 2) if (begin is not None and end is not None) else None,
"steps": steps,
}
return {
"period": rec.period,
"data_available": True,
"source": rec.source,
"remark": rec.remark,
"nets": nets,
"classification": classification,
"classification_dimension": classification.get("dimension"),
"investing_note": investing_note,
"waterfall": waterfall,
}
@router.post("/values", dependencies=[Depends(require_role("ceo", "finance"))])
def upsert_value(
payload: CashFlowValueIn,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""录入/更新某期间三大活动净额(官方现金流量表注入;同期间重复提交=覆盖更新)"""
period = _validate_period(payload.period)
if payload.source not in ("official", "manual"):
raise HTTPException(status_code=422, detail="source 仅支持 official(官方报表)/manual(人工录入)")
rec = db.query(CashFlowStructureValue).filter(
CashFlowStructureValue.entity_id == entity_id,
CashFlowStructureValue.period == period,
).first()
created = rec is None
if rec is None:
rec = CashFlowStructureValue(entity_id=entity_id, period=period)
db.add(rec)
rec.operating_net = round(payload.operating_net, 2)
rec.investing_net = round(payload.investing_net, 2)
rec.financing_net = round(payload.financing_net, 2)
rec.begin_cash = round(payload.begin_cash, 2) if payload.begin_cash is not None else rec.begin_cash
rec.end_cash = round(payload.end_cash, 2) if payload.end_cash is not None else rec.end_cash
rec.source = payload.source
rec.remark = payload.remark
db.commit()
classification = classify_enterprise(rec.operating_net, rec.investing_net, rec.financing_net)
return {
"ok": True,
"id": rec.id,
"period": rec.period,
"classification": classification,
"message": "三大活动净额已%s" % ("入库" if created else "更新"),
}
# ============================================================
# 兼容别名端点 — 任务派发单 DoD 验收路径 /api/cma/cashflow/*
# (规范实现主路径为 /api/cma/cashflow-structure/*;两路径功能一致)
# ============================================================
compat_router = APIRouter(
prefix="/api/cma/cashflow",
tags=["现金流量表结构分析(兼容路径)"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
@compat_router.get("/statements")
def compat_list_statements(
period: Optional[str] = Query(None, description="期间 YYYY-MM,可选"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""[兼容] 查询已录入三大活动净额(同 /api/cma/cashflow-structure/values)"""
items = list_values(db=db, entity_id=entity_id)["items"]
if period:
period = _validate_period(period)
items = [x for x in items if x["period"] == period]
return {"items": items}
@compat_router.post("/statements", dependencies=[Depends(require_role("ceo", "finance"))])
def compat_save_statement(
payload: CashFlowValueIn,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""[兼容] 保存/更新某期间三大活动净额(同 POST /api/cma/cashflow-structure/values)"""
return upsert_value(payload=payload, db=db, entity_id=entity_id)
@compat_router.get("/analysis")
def compat_get_analysis(
period: str = Query(None, description="格式 YYYY-MM,缺省取最近已注入期间"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""[兼容] 结构分析+八类判定+瀑布数据(同 /api/cma/cashflow-structure/analysis)"""
return get_analysis(period=period, db=db, entity_id=entity_id)
+197 -40
View File
@@ -5,14 +5,41 @@ 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.auth_middleware import require_auth_401
from app.models import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models.cost_model import RESEARCH_PROJECT_TYPES
from app.utils.cost_engine import (
calc_product_variance, get_cost_overview, get_cost_breakdown,
calc_driver_rate, allocate_cost
)
logger = logging.getLogger("cma.cost")
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"])
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 本 router 原无任何鉴权依赖(端点仅 assert_entity_scope,无 token 走"Bot 服务通道"分支放行)
# → 无 token 可读成本数据、可写标准成本。收口为 require_auth_401(无 token → 401)。
# Bot 通道走 /api/cma/bot/cost/*(X-BOT-KEY)不受影响。
router = APIRouter(prefix="/api/cma/cost", tags=["成本分析"],
dependencies=[Depends(require_auth_401)])
def _resolve_research_dims(data: dict, current=None) -> tuple:
"""解析研发归集维度(research_flag/project_type)。
- project_type 缺省:新建=client_delivery(录入默认),编辑=保留现值
- research_flag 未显式传入 → 按 project_type 联动(platform_dev=研发1,其余=0)
- 显式传入 research_flag 尊重原值(可覆盖联动)
"""
if current is not None:
project_type = data.get("project_type") or current.project_type or "client_delivery"
else:
project_type = data.get("project_type") or "client_delivery"
if project_type not in RESEARCH_PROJECT_TYPES:
raise HTTPException(400, f"project_type 非法: {project_type},可选 {', '.join(RESEARCH_PROJECT_TYPES)}")
if data.get("research_flag") is not None:
research_flag = int(bool(data.get("research_flag")))
else:
research_flag = 1 if project_type == "platform_dev" else 0
return project_type, research_flag
# ============================================================
@@ -22,21 +49,33 @@ 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)):
project_type: Optional[str] = Query(None),
research_flag: Optional[int] = Query(None),
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:
query = query.filter(StandardCost.cost_type == cost_type)
if project_type:
query = query.filter(StandardCost.project_type == project_type)
if research_flag is not None:
query = query.filter(StandardCost.research_flag == research_flag)
items = query.order_by(StandardCost.product_code, StandardCost.cost_type).all()
return {"data": items}
@router.post("/standard-costs")
def create_standard_cost(data: dict, db: Session = Depends(get_db)):
def create_standard_cost(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""创建标准成本卡片"""
project_type, research_flag = _resolve_research_dims(data)
sc = StandardCost(
entity_id=entity_id,
project_type=project_type,
research_flag=research_flag,
product_code=data["product_code"],
product_name=data.get("product_name", ""),
cost_type=data["cost_type"],
@@ -54,24 +93,31 @@ def create_standard_cost(data: dict, db: Session = Depends(get_db)):
@router.put("/standard-costs/{cost_id}")
def update_standard_cost(cost_id: int, data: dict, db: Session = Depends(get_db)):
def update_standard_cost(cost_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""修改标准成本卡片"""
sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first()
sc = db.query(StandardCost).filter(StandardCost.id == cost_id,
StandardCost.entity_id == entity_id).first()
if not sc:
raise HTTPException(404, "标准成本记录不存在")
for k in ("product_code", "product_name", "cost_type", "item_name",
"standard_quantity", "unit", "standard_price", "version", "remark"):
if k in data:
setattr(sc, k, data[k])
project_type, research_flag = _resolve_research_dims(data, current=sc)
sc.project_type = project_type
sc.research_flag = research_flag
sc.standard_cost = round(sc.standard_quantity * sc.standard_price, 2)
db.commit()
return {"message": "已更新"}
@router.delete("/standard-costs/{cost_id}")
def delete_standard_cost(cost_id: int, db: Session = Depends(get_db)):
def delete_standard_cost(cost_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""删除标准成本卡片"""
sc = db.query(StandardCost).filter(StandardCost.id == cost_id).first()
sc = db.query(StandardCost).filter(StandardCost.id == cost_id,
StandardCost.entity_id == entity_id).first()
if not sc:
raise HTTPException(404, "标准成本记录不存在")
sc.status = "archived"
@@ -86,21 +132,33 @@ 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)):
project_type: Optional[str] = Query(None),
research_flag: Optional[int] = Query(None),
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:
query = query.filter(ActualCost.product_code == product_code)
if project_type:
query = query.filter(ActualCost.project_type == project_type)
if research_flag is not None:
query = query.filter(ActualCost.research_flag == research_flag)
items = query.order_by(ActualCost.period.desc(), ActualCost.product_code).all()
return {"data": items}
@router.post("/actual-costs")
def create_actual_cost(data: dict, db: Session = Depends(get_db)):
def create_actual_cost(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""录入实际成本"""
project_type, research_flag = _resolve_research_dims(data)
ac = ActualCost(
entity_id=entity_id,
project_type=project_type,
research_flag=research_flag,
period=data["period"],
product_code=data["product_code"],
product_name=data.get("product_name", ""),
@@ -118,19 +176,34 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db)):
# ============================================================
# ABC 作业成本
# 账套隔离(2026-09-18,0-P1-project-20260918-entity-isolation-batch2):
# GET /abc/activities 原为全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心;
# 现按会话账套过滤(AbcActivity.entity_id)。
# OpenCode 多租户审查 2026-09-07 的「单实体演示数据暂不强制隔离」豁免就此撤销。
# 账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):batch2 同族残留收口
# - POST /abc/activities 原签名无账套依赖、不写归属 → 落模型默认 entity_id=1;现按会话账套写入
# - POST /abc/allocate 原无账套依赖、activity_id 不校验归属、分配记录落默认 1;
# 现校验 activity_id 归属本账套(跨账套 → 404,文案对齐同文件先例)
# 并把 entity_id 透传 cost_engine.allocate_cost 显式写入 AbcAllocation
# - GET /abc/allocations 原查询只按 period/product_code → 现加 entity_id 过滤(读型同族)
# ============================================================
@router.get("/abc/activities")
def list_abc_activities(db: Session = Depends(get_db)):
"""查询ABC作业中心列表"""
items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all()
def list_abc_activities(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""查询ABC作业中心列表(账套隔离:只返回本账套)"""
items = (db.query(AbcActivity)
.filter(AbcActivity.entity_id == entity_id)
.order_by(AbcActivity.activity_code)
.all())
return {"data": items}
@router.post("/abc/activities")
def create_abc_activity(data: dict, db: Session = Depends(get_db)):
"""创建ABC作业中心"""
def create_abc_activity(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""创建ABC作业中心(账套隔离:按会话账套写入归属)"""
act = AbcActivity(
entity_id=entity_id,
activity_code=data["activity_code"],
activity_name=data["activity_name"],
activity_desc=data.get("activity_desc"),
@@ -146,14 +219,21 @@ def create_abc_activity(data: dict, db: Session = Depends(get_db)):
@router.post("/abc/allocate")
def do_allocate(data: dict, db: Session = Depends(get_db)):
"""执行ABC成本分配"""
def do_allocate(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""执行ABC成本分配(账套隔离:activity_id 须属本账套,跨账套 → 404)"""
act = (db.query(AbcActivity)
.filter(AbcActivity.id == data["activity_id"], AbcActivity.entity_id == entity_id)
.first())
if not act:
raise HTTPException(404, "作业中心不存在")
result = allocate_cost(
activity_id=data["activity_id"],
period=data.get("period", datetime.now().strftime("%Y-%m")),
product_code=data["product_code"],
product_name=data.get("product_name", ""),
driver_consumed=data["driver_consumed"],
entity_id=entity_id,
)
return result
@@ -161,9 +241,10 @@ def do_allocate(data: dict, db: Session = Depends(get_db)):
@router.get("/abc/allocations")
def list_allocations(period: Optional[str] = Query(None),
product_code: Optional[str] = Query(None),
db: Session = Depends(get_db)):
"""查询ABC分配记录"""
query = db.query(AbcAllocation)
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查询ABC分配记录(账套隔离:只返回本账套)"""
query = db.query(AbcAllocation).filter(AbcAllocation.entity_id == entity_id)
if period:
query = query.filter(AbcAllocation.period == period)
if product_code:
@@ -177,61 +258,137 @@ def list_allocations(period: Optional[str] = Query(None),
# ============================================================
@router.get("/overview")
def cost_overview(period: Optional[str] = Query(None)):
"""成本总览(总成本、结构占比、趋势)"""
def cost_overview(period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""成本总览(总成本、结构占比、趋势)— 多租户隔离 2026-09-07"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return get_cost_overview(period)
return get_cost_overview(period, entity_id=entity_id)
@router.get("/variance")
def cost_variance(product_code: str = Query(...),
period: Optional[str] = Query(None)):
"""量差价差分析"""
period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""量差价差分析 — 多租户隔离 2026-09-07"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return calc_product_variance(product_code, period)
return calc_product_variance(product_code, period, entity_id=entity_id)
@router.get("/breakdown")
def cost_breakdown(product_code: str = Query(...),
period: Optional[str] = Query(None)):
"""成本构成(料/工/费占比)"""
period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""成本构成(料/工/费占比)— 多租户隔离 2026-09-07"""
if period is None:
period = datetime.now().strftime("%Y-%m")
return get_cost_breakdown(product_code, period)
return get_cost_breakdown(product_code, period, entity_id=entity_id)
@router.get("/comparison")
def cost_method_comparison(entity: str = Query("hanke")):
"""三种成本法对比分析:传统/变动/作业成本法 (CMA P1)
注:纯静态教学样例数据(无 DB 访问),不涉多租户泄漏——OpenCode 审查 2026-09-07 豁免标注"""
if entity == "hanke":
gross_revenue = 713 # 万
channel_rebate_rate = 0.828
net_revenue = round(gross_revenue * (1 - channel_rebate_rate), 2)
weighted_cost_rate = 0.4862
book_cost = 717 # 万
total_expenses = 546 # 万
non_value_added = 26 # 万
traditional = {
"revenue": gross_revenue,
"cost": book_cost,
"gross_profit": round(gross_revenue - book_cost, 2),
"gross_margin": round((gross_revenue - book_cost) / gross_revenue * 100, 2),
"expenses": total_expenses,
"net_profit": round(gross_revenue - book_cost - total_expenses, 2),
}
var_cost = round(net_revenue * weighted_cost_rate, 2)
variable = {
"revenue": net_revenue,
"cost": var_cost,
"gross_profit": round(net_revenue - var_cost, 2),
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
"expenses": total_expenses,
"net_profit": round(net_revenue - var_cost - total_expenses, 2),
}
abc_cost = round(net_revenue * weighted_cost_rate, 2)
abc_exp = total_expenses - non_value_added
abc = {
"revenue": net_revenue,
"cost": abc_cost,
"gross_profit": round(net_revenue - abc_cost, 2),
"gross_margin": round((1 - weighted_cost_rate) * 100, 2),
"expenses": abc_exp,
"net_profit": round(net_revenue - abc_cost - abc_exp, 2),
}
return {
"entity": "hanke",
"entity_name": "陕西酣客(白酒经销)",
"traditional": traditional,
"variable": variable,
"abc": abc,
"insights": [
{"method": "传统成本法", "conclusion": "毛利率为负", "decision": "❌ 不赚钱,别卖了", "detail": "未剔除渠补,账面收入虚高"},
{"method": "变动成本法", "conclusion": "毛利率18.6%", "decision": "✅ 业务能赚钱→砍费用", "detail": "剔除渠补后净收入122万,成本率48.62%"},
{"method": "作业成本法", "conclusion": "识别非增值作业26万", "decision": "✅ 可再省", "detail": "剔除冗余招待费等非增值作业"},
],
"notes": {
"net_revenue": "毛收入713万 × (1-渠补率82.8%) = 净收入122万",
"variable_cost": "净收入122万 × 加权成本率48.62% = 成本59.3万",
"abc_expenses": "总费用546万 - 非增值作业26万 = 520万",
},
}
return {"error": "不支持的实体"}
@router.get("/dashboard")
def cost_dashboard(period: Optional[str] = Query(None)):
"""成本分析首页—汇总数据"""
def cost_dashboard(period: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""成本分析首页—汇总数据(多租户隔离 2026-09-07: ActualCost 按账套过滤)"""
if period is None:
period = datetime.now().strftime("%Y-%m")
overview = get_cost_overview(period)
overview = get_cost_overview(period, entity_id=entity_id)
# 获取所有产品列表
db = get_db().__next__()
# 获取所有产品列表(本账套)
db_session = db if db is not None else get_db().__next__()
try:
products = db.query(ActualCost.product_code, ActualCost.product_name).filter(
ActualCost.period == period
products = db_session.query(ActualCost.product_code, ActualCost.product_name).filter(
ActualCost.entity_id == entity_id,
ActualCost.period == period,
).distinct().all()
product_list = [{"code": p[0], "name": p[1]} for p in products]
# 各产品成本
product_costs = []
for code, name in products:
costs = db.query(ActualCost).filter(
costs = db_session.query(ActualCost).filter(
ActualCost.entity_id == entity_id,
ActualCost.product_code == code,
ActualCost.period == period,
).all()
total = round(sum(c.actual_cost for c in costs), 2)
product_costs.append({"product_code": code, "product_name": name, "total_cost": total})
finally:
db.close()
if db is None:
db_session.close()
return {
"period": period,
"overview": overview,
"products": product_list,
"total_cost": round(sum(p["total_cost"] for p in product_list) + overview.get("erp_cost", 0), 2) if product_list else 0,
# 修复既有bug(2026-09-07测试实证): 原对 product_list 求 p["total_cost"] 必 KeyError(产品列表无total_cost键)
# → 改为 product_costs(含各产品总额),有产品数据时端点不再 500
"total_cost": round(sum(p["total_cost"] for p in product_costs) + overview.get("erp_cost", 0), 2) if product_costs else 0,
}
+338
View File
@@ -0,0 +1,338 @@
"""客户维度KPI看板 API — P0-2"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from typing import Optional
from datetime import datetime, timedelta
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, User
from app.utils.alert_entity_scope import month_period_clause
from app.utils.kpi_value_scope import actual_only_clause
import logging
logger = logging.getLogger("cma.customer")
router = APIRouter(prefix="/api/cma/customer-dashboard", tags=["客户看板"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ── 客户维度「标准指标族」九卡(与前端 CustomerDashboard.vue 的 CUSTOMER_TEMPLATES 逐项对应)
# (卡片名, 默认单位, 预设目标值, 实例层 kpi_code 或 None)
# 第四列为 None = 该卡在 KPI 库**无可用定义**(页面须显示「未定义·待建」,不得显示 0/无数据)。
STANDARD_INDICATORS = [
("客户满意度", "分", 90, "C_SATISFACTION"),
("客户留存率", "%", 85, None),
("新客户增长率", "%", 20, None),
("客户渠补率", "%", 95, None),
("客户投诉率", "%", 5, "C_COMPLAINT_RATE"),
("客户平均贡献值", "元", 10000, None),
("客户转化率", "%", 30, None),
("客户活跃度", "%", 70, None),
("NPS净推荐值", "分", 60, None),
]
def parse_period(period_type: str, start_date: str = None, end_date: str = None):
"""解析时间区间"""
today = datetime.now()
if period_type == "month":
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "quarter":
q = (today.month - 1) // 3
start = today.replace(month=q*3+1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "year":
start = today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
elif period_type == "custom" and start_date and end_date:
start = datetime.strptime(start_date, "%Y-%m-%d")
end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
else:
start = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = today
return start, end
@router.get("")
def list_customer_kpis(
period: str = Query("month"),
start_date: str = Query(None),
end_date: str = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""获取客户维度KPI列表(含最新值、预警、趋势)"""
start, end = parse_period(period, start_date, end_date)
period_str = start.strftime("%Y-%m")
# 只查 customer 维度的 KPI
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
).order_by(KPIDefinition.kpi_code).all()
result = []
for k in kpis:
base_query = db.query(KPIValue).filter(KPIValue.kpi_id == k.id)
if period == "month":
latest = base_query.filter(KPIValue.period == period_str).order_by(KPIValue.id.desc()).first()
elif period == "quarter":
q_month = (datetime.now().month - 1) // 3
months = [f"{datetime.now().year}-{m:02d}" for m in range(q_month*3+1, q_month*3+4)]
values = base_query.filter(KPIValue.period.in_(months)).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{months[0]}~{months[-1]}"})() if latest_val else None
elif period == "year":
values = base_query.filter(actual_only_clause(), KPIValue.period.like(f"{period_str[:4]}%")).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": period_str[:4]})() if latest_val else None
elif period == "custom" and start_date and end_date:
periods = []
d = start
while d <= end:
periods.append(d.strftime("%Y-%m"))
d += timedelta(days=32)
d = d.replace(day=1)
values = base_query.filter(KPIValue.period.in_(set(periods))).all()
latest_val = sum(v.actual_value for v in values if v.actual_value) if values else None
latest = type('obj', (object,), {"actual_value": latest_val, "period": f"{start_date}~{end_date}"})() if latest_val else None
else:
latest = base_query.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
# 最新预警
alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == k.id,
KPIAlert.status == "pending",
).order_by(KPIAlert.id.desc()).first()
# 趋势(环比变化率)
trend = None
achievement_rate = None
period_values = []
if latest and latest.actual_value:
prev_period_str = None
if period == "month":
year_s, month_s = period_str.split("-")
y_s, m_s = int(year_s), int(month_s)
m_s -= 1
if m_s <= 0:
m_s += 12
y_s -= 1
prev_period_str = f"{y_s}-{m_s:02d}"
if prev_period_str:
prev_val = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == prev_period_str,
).order_by(KPIValue.id.desc()).first()
if prev_val and prev_val.actual_value and prev_val.actual_value > 0:
trend = round((latest.actual_value - prev_val.actual_value) / prev_val.actual_value * 100, 2)
elif prev_val and prev_val.actual_value and prev_val.actual_value == 0:
trend = 100.0 if latest.actual_value > 0 else 0
# 达成率
if latest and latest.actual_value and k.target_value and k.target_value > 0:
achievement_rate = round(latest.actual_value / k.target_value * 100, 1)
# 最近6期趋势数据
period_q = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(6).all()
period_values = [
{"period": v.period, "value": v.actual_value}
for v in reversed(period_q) if v.actual_value is not None
]
result.append({
"id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"category": k.category,
"unit": k.unit,
"target_value": k.target_value,
"actual_value": latest.actual_value if latest else None,
"period": latest.period if latest else None,
"alert_level": alert.alert_level if alert else "none",
"alert_message": alert.alert_message if alert else None,
"frequency": k.frequency,
"responsible_dept": k.responsible_dept,
"responsible_user": k.responsible_user,
"trend": trend,
"achievement_rate": achievement_rate,
"period_values": period_values,
"kpi_name": k.kpi_name,
})
return {"data": result, "period": period, "total": len(result)}
@router.get("/standard-indicators")
def standard_indicators(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""客户维度「标准指标族」九卡三态(真值 / 无数据 / 未定义·待建)——只读。
CPA P0-b(2026-10-04):页面 9 张预设卡此前对「KPI 库无定义」的项渲染成「无数据」,
与「定义在库但无值」不可区分。本端点逐卡给出三态,页面据此渲染标签,**禁止空显示为 0**。
映射为**显式白名单**(不做名称模糊匹配),仅当实例层 KPI 与预设卡语义完全一致才连:
客户满意度 → C_SATISFACTION(实例层同名,active,有值)
客户投诉率 → C_COMPLAINT_RATE(实例层同名,inactive ⇒ 无值)
其余 7 张保持未定义(依据任务 finance-20261003 现状表 §一「7 个在 KPI 库无可用定义」):
客户留存率 ≠ 客户保留率(C_RETENTION_RATE)、客户渠补率 ≠ 渠补率(C_REBATE_RATE)、
新客户增长率 ≠ 新客户数(C_NEW_CLIENTS)、客户平均贡献值/客户转化率/客户活跃度/NPS净推荐值 库内皆无。
"""
codes = [c for _, _, _, c in STANDARD_INDICATORS if c]
defs = {}
if codes:
# 用 raw SQL 读定义层:**不用 KPIDefinition ORM**——该模型的 `direction` 列由他单
# (CMA-KPI-20261003 KPI字典分层治理)在途新增,生产库尚未落列,ORM 查询会 1054;
# raw SQL 只取本端点需要的列,与在途 DDL 解耦(与 zxt_masterbill 同一形态)。
from sqlalchemy import bindparam, text as _text
stmt = _text(
"SELECT id, kpi_code, unit, target_value, status FROM kpi_definitions "
"WHERE entity_id = :eid AND dimension = 'customer' AND kpi_code IN :codes"
).bindparams(bindparam("codes", expanding=True))
rows = db.execute(stmt, {"eid": entity_id, "codes": codes}).fetchall()
defs = {r[1]: {"id": r[0], "unit": r[2], "target_value": r[3], "status": r[4]} for r in rows}
items = []
summary = {"value": 0, "no_data": 0, "undefined": 0, "total": len(STANDARD_INDICATORS)}
state_labels = {"value": "真值", "no_data": "无数据", "undefined": "未定义·待建"}
for name, unit, target, code in STANDARD_INDICATORS:
kpi = defs.get(code) if code else None
if kpi is None:
state = "undefined"
items.append({
"name": name, "unit": unit, "target_value": target, "state": state,
"state_label": state_labels[state], "kpi_code": None, "kpi_id": None,
"kpi_status": None, "actual_value": None, "period": None,
"why": "KPI 库无该定义",
})
else:
val = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi["id"],
KPIValue.entity_id == entity_id,
KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc(), KPIValue.id.desc()).first()
state = "value" if val else "no_data"
items.append({
"name": name, "unit": kpi["unit"] or unit,
"target_value": kpi["target_value"] if kpi["target_value"] is not None else target,
"state": state, "state_label": state_labels[state],
"kpi_code": code, "kpi_id": kpi["id"], "kpi_status": kpi["status"],
"actual_value": val.actual_value if val else None,
"period": val.period if val else None,
"why": None if val else "定义在库(status=%s)但无任何月期间值" % kpi["status"],
})
summary[state] += 1
return {
"entity_id": entity_id,
"items": items,
"summary": summary,
"note": ("state: value=定义在库且有值 / no_data=定义在库但无值 / "
"undefined=KPI 库无定义(未定义·待建);禁止把 undefined/no_data 显示为 0"),
}
@router.get("/trend/{kpi_id}")
def get_kpi_trend(
kpi_id: int,
months: int = Query(12, ge=3, le=24),
db: Session = Depends(get_db),
):
"""获取单个KPI的历史趋势数据"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 获取最近N期数据
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).limit(months).all()
trend_data = [
{"period": v.period, "value": v.actual_value}
for v in reversed(values) if v.actual_value is not None
]
# 计算预警水平和触发时间
alerts = db.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi_id,
).order_by(KPIAlert.created_at.desc()).limit(10).all()
alert_logs = [
{
"level": a.alert_level,
"message": a.alert_message,
"time": a.created_at.isoformat() if a.created_at else None,
"status": a.status,
}
for a in alerts
]
return {
"kpi": {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"target_value": kpi.target_value,
"unit": kpi.unit,
"threshold_green": kpi.threshold_green,
"threshold_yellow": kpi.threshold_yellow,
"threshold_red": kpi.threshold_red,
},
"trend_data": trend_data,
"alerts": alert_logs,
}
@router.get("/summary")
def get_customer_summary(
period: str = Query("month"),
db: Session = Depends(get_db),
):
"""客户维度概要统计"""
total = db.query(func.count(KPIDefinition.id)).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
).scalar() or 0
# 预警统计
pending_alerts = db.query(func.count(KPIAlert.id)).filter(
KPIAlert.status == "pending",
KPIAlert.kpi_id.in_(
db.query(KPIDefinition.id).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
)
),
).scalar() or 0
# 二级类别分布
cat_stats = db.query(
KPIDefinition.category,
func.count(KPIDefinition.id),
).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
).group_by(KPIDefinition.category).all()
return {
"total": total,
"pending_alerts": pending_alerts,
"category_stats": [{"category": c[0], "count": c[1]} for c in cat_stats],
}
File diff suppressed because it is too large Load Diff
+411 -30
View File
@@ -1,64 +1,359 @@
"""数据对接 API"""
import pandas as pd
import io, json, hashlib
import io, json, hashlib, re, os
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIValue, DataSourceConfig, OperationLog
from app.utils.oplog import log_op
from app.models import KPIValue, DataSourceConfig, OperationLog, KPIDefinition
# ERP 直连同步通道停用标记(2026-08-31 OPS 安全加固:erp-gateway DB 凭据已删除)
# 陕西酣客财务原始数据的真实通道 = DAMA 读智享通网页 → 落盘 → 财务Bot 注入(source_type='zxt-sync')
# 任何 ERP API 直连同步入口(/data/sync-kpis、/admin/erp-sync)一律拒绝执行,防止写 estimated 假数据
ERP_DIRECT_CHANNEL_DISABLED = True
ERP_DIRECT_CHANNEL_REASON = ("ERP直连同步通道已于2026-08-31停用(erp-gateway 容器 DB 凭据已删除,zxbtest 账号停用)。"
"财务原始数据真实通道为智享通 zxt 通道:DAMA 读智享通网页 → 落盘 /root/WB_WS/_data/zxt-hanke → 财务Bot 注入 CMA"
"(subjects+kpi_values 双写, source_type='zxt-sync')。如需恢复 ERP API 直连,请联系运维重新配置 erp-gateway DB 凭据。")
# 智享通 zxt 落盘目录(真实通道文件来源;任总 2026-09-08 确认财务原始数据走 zxt_push)
ZXT_LANDING_DIR = "/root/WB_WS/_data/zxt-hanke"
router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
@router.post("/import-excel")
async def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)):
async def import_excel(file: UploadFile = File(...),
kpi_col: str = Query("kpi_code", description="Excel中KPI编码列名"),
period_col: str = Query("period", description="Excel中期间列名"),
value_col: str = Query("actual_value", description="Excel中实际值列名"),
default_period: str = Query(None, description="如文件无期间列,统一使用此值"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
content = await file.read()
df = pd.read_excel(io.BytesIO(content))
required = [kpi_col, value_col]
if not default_period:
required.append(period_col)
missing = [c for c in required if c not in df.columns]
if missing:
raise HTTPException(400,
f"Excel缺少列: {missing}。当前文件列: {list(df.columns)}")
if len(df) == 0:
raise HTTPException(400, "Excel文件为空,没有数据行")
required = ["kpi_code", "period", "actual_value"]
if not all(c in df.columns for c in required):
raise HTTPException(400, f"Excel必须包含列: {required}")
from app.models import KPIDefinition
kpi_map = {k.kpi_code: k.id for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()}
kpi_entity_map = {k.kpi_code: k.entity_id for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()} # 账套隔离
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
count = 0
for _, row in df.iterrows():
kpi_code = str(row.get("kpi_code", ""))
period = str(row.get("period", ""))
value = row.get("actual_value")
skipped = []
for idx, row in df.iterrows():
kpi_code = str(row.get(kpi_col, "")).strip()
period = str(row.get(period_col, default_period or "")).strip() if period_col in df.columns else (default_period or "").strip()
value = row.get(value_col)
if not kpi_code or not period or pd.isna(value):
skipped.append(f"第{idx+2}行: 缺少必填字段")
continue
from app.models import KPIDefinition
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi:
kid = kpi_map.get(kpi_code)
if not kid:
skipped.append(f"第{idx+2}行: KPI编码「{kpi_code}」不存在")
continue
kv = KPIValue(
kpi_id=kpi.id,
db.add(KPIValue(
kpi_id=kid,
entity_id=kpi_entity_map.get(kpi_code),
period=period,
actual_value=float(value),
source_type="excel",
source_batch=batch,
data_status="pending",
)
db.add(kv)
data_status="verified",
))
count += 1
db.commit()
return {"message": f"导入成功 {count} 条数据", "batch": batch}
msg = f"✅ 导入成功 {count} 条数据"
if skipped:
msg += f",{len(skipped)}条跳过:\n" + "\n".join(skipped[:10])
if len(skipped) > 10:
msg += f"\n...还有{len(skipped)-10}条"
return {"message": msg, "batch": batch, "total": count, "skipped": len(skipped)}
# ── 智能导入(BOT自动识别,无需手动映射) ──
_SMART_MAP = {
# KPI名称/编码列匹配模式(顺序重要:名称类列优先,避免科目编码被当名称)
"kpi_code_patterns": [
re.compile(r'^(科目名称|项目名称|指标名称?|kpi名称?|name|名称)$', re.I),
re.compile(r'^(科目编码|科目代码|kpi_?code|指标编码|编码)$', re.I),
re.compile(r'^(科目|项目|账户|报表项目)$'),
],
# 期间列匹配
"period_patterns": [
re.compile(r'^(period|期间|月份?|年月|日期|会计期间)$', re.I),
re.compile(r'^(报表期[间]?|所属期)$'),
re.compile(r'^年|^月'), # 以"年"或"月"开头的列
],
# 数值列匹配
"value_patterns": [
re.compile(r'^(actual_?value|数值|实际值|实际金额)$', re.I),
re.compile(r'^(本期金额|本月数|本期|期末余额|期末数|余额)$'),
re.compile(r'^(金额|数据|value|本年累计|本期发生[额]?)$', re.I),
],
# 文件名→期间提取
"period_in_filename_patterns": [
re.compile(r'[-_]?(\d{4})[-_]?(\d{1,2})'), # 2026-06 / 2026_06 / 2606
re.compile(r'(\d{4})年(\d{1,2})月(?:至(\d{4})年(\d{1,2})月)?'), # 2026年01月 / 2026年01月至2026年05月
re.compile(r'(\d{4})(\d{2})'), # 202606 (纯数字6-8位)
],
# 文件名→报表类型
"statement_types": {
"利润表": "PL",
"利润": "PL",
"income": "PL",
"现金流量表": "CF",
"现金流": "CF",
"cashflow": "CF",
"cash_flow": "CF",
"资产负债表": "BS",
"资产负": "BS",
"balance": "BS",
},
}
def _smart_detect_kpi_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["kpi_code_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_detect_period_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["period_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_detect_value_col(cols: list[str]) -> str | None:
for pat in _SMART_MAP["value_patterns"]:
for c in cols:
if pat.match(c.strip()):
return c
return None
def _smart_extract_period_from_filename(filename: str) -> str | None:
for pat in _SMART_MAP["period_in_filename_patterns"]:
m = pat.search(filename)
if m:
groups = m.groups()
if len(groups) == 4 and groups[2]: # 2026年01月至2026年05月 → 取结束月
return f"{groups[2]}-{int(groups[3]):02d}"
if len(groups) >= 2: # 2026-06 或 2026年01月
return f"{int(groups[0])}-{int(groups[1]):02d}"
return None
def _smart_detect_statement_type(filename: str) -> str | None:
for kw, tp in _SMART_MAP["statement_types"].items():
if kw in filename:
return tp
return None
@router.post("/import-excel-smart")
async def import_excel_smart(
file: UploadFile = File(...),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""智能导入 — BOT自动识别列名/期间/报表类型,无需手动映射"""
content = await file.read()
fname = file.filename or "未知文件"
try:
df = pd.read_excel(io.BytesIO(content))
except Exception as e:
raise HTTPException(400, f"无法读取Excel文件: {e}")
if len(df) == 0:
raise HTTPException(400, "Excel文件为空")
cols = list(df.columns)
if len(cols) < 2:
raise HTTPException(400, f"Excel列数过少: {cols}")
# 4. 智能检测列
kpi_col = _smart_detect_kpi_col(cols) or cols[0]
value_col = _smart_detect_value_col(cols) or cols[-1]
period_col = _smart_detect_period_col(cols)
# 5. 从文件名提取期间
period = _smart_extract_period_from_filename(fname) if not period_col else None
# 6. 检测报表类型(用于自动生成KPI编码前缀)
stype = _smart_detect_statement_type(fname)
# 7. 预加载KPI字典(★ 按当前账套过滤,防跨账套匹配 — 2026-09-08 P0 安全修复)
from app.models import KPIDefinition
kpis = {k.kpi_code: k for k in db.query(KPIDefinition).filter(KPIDefinition.entity_id == entity_id).all()}
known_codes = set(kpis.keys())
# 构建别名映射(去掉空格/大小写/特殊字符)
alias_map: dict[str, str] = {}
for code in known_codes:
clean = re.sub(r'[\s\-_()()]', '', code).lower()
alias_map[clean] = code
# 中文名映射("营业收入"→F_REVENUE)——同账套内 kpi_name 冲突时后者覆盖,保持与原逻辑一致
name_map: dict[str, str] = {}
for code, kpi_obj in kpis.items():
name_map[kpi_obj.kpi_name] = code
# 8. 遍历导入(匹配不上的自动创建KPI)
stype_prefix = {"PL": "PL_", "CF": "CF_", "BS": "BS_"}.get(stype or "", "EXT_")
batch = hashlib.md5(str(datetime.now().timestamp()).encode()).hexdigest()[:12]
imported = 0
created_kpis = 0
skipped_rows = []
code_serial = len(kpis) + 1
def _next_code() -> str:
"""生成当前账套内不冲突的新KPI编码"""
nonlocal code_serial
while True:
candidate = f"{stype_prefix}{code_serial:03d}"
code_serial += 1
if candidate not in known_codes:
return candidate
for idx, row in df.iterrows():
raw_kpi_raw = row.get(kpi_col, "")
raw_kpi = str(raw_kpi_raw).strip()
raw_val = row.get(value_col)
raw_period = str(row.get(period_col, period or "")).strip() if period_col else (period or "")
# 名称防护:NaN/空/None 或 纯数字(疑似科目编码被误当名称)→ 跳过,避免创建垃圾KPI
if raw_kpi.lower() in ("nan", "none") or not raw_kpi:
skipped_rows.append(f"第{idx+2}行: KPI名称为空")
continue
if re.fullmatch(r"\d+(\.\d+)?", raw_kpi):
skipped_rows.append(f"第{idx+2}行: KPI名称疑似科目编码「{raw_kpi}」,跳过")
continue
if pd.isna(raw_val):
skipped_rows.append(f"第{idx+2}行: 缺数据")
continue
if not raw_period:
skipped_rows.append(f"第{idx+2}行: 无法确定期间")
continue
# 清理科目名(去掉"一、""减:""加:"等前缀)
clean_name = re.sub(r'^[一二三四五六七八九十、\s\+]+', '', raw_kpi)
clean_name = re.sub(r'^[减加]?[::]\s*', '', clean_name).strip()
if not clean_name:
clean_name = raw_kpi
# 匹配KPI
kpi_code = None
# ① 精确编码匹配(极少情况)
if raw_kpi in known_codes:
kpi_code = raw_kpi
# ② 别名匹配(去符号小写)
if not kpi_code:
clean_key = re.sub(r'[\s\-_()()]', '', raw_kpi).lower()
kpi_code = alias_map.get(clean_key)
# ③ 中文名精确匹配
if not kpi_code:
kpi_code = name_map.get(clean_name)
# ④ 中文名模糊匹配
if not kpi_code:
for code, kpi_obj in kpis.items():
if clean_name in kpi_obj.kpi_name or kpi_obj.kpi_name in clean_name:
kpi_code = code
break
# ⑤ 仍未匹配 → 自动创建KPI(★ 创建在当前账套 entity_id 下)
if not kpi_code:
new_code = _next_code()
new_kpi = KPIDefinition(
entity_id=entity_id,
kpi_code=new_code,
kpi_name=clean_name,
dimension="finance",
category="financial_report",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=0,
kpi_level="operational",
status="active",
)
db.add(new_kpi)
db.flush()
kpis[new_code] = new_kpi
known_codes.add(new_code)
name_map[clean_name] = new_code
kpi_code = new_code
created_kpis += 1
try:
val = float(raw_val)
except:
skipped_rows.append(f"第{idx+2}行: 数值格式错误「{raw_val}」")
continue
db.add(KPIValue(
kpi_id=kpis[kpi_code].id,
entity_id=entity_id, # 账套隔离:只写入当前账套
period=raw_period,
actual_value=val,
source_type="excel",
source_batch=batch,
data_status="verified",
))
imported += 1
db.commit()
# 9. 返回汇总
stype_label = {"PL": "利润表", "CF": "现金流量表", "BS": "资产负债表"}.get(stype or "", "数据表")
msg = f"✅ {stype_label}识别成功,导入{imported}条"
if created_kpis:
msg += f",自动创建{created_kpis}个新KPI"
if skipped_rows:
msg += f",{len(skipped_rows)}条跳过:\n" + "\n".join(skipped_rows[:8])
if len(skipped_rows) > 8:
msg += f"\n...还有{len(skipped_rows) - 8}条"
return {"message": msg, "batch": batch, "total": imported, "skipped": len(skipped_rows)}
@router.get("/sources")
def list_sources(db: Session = Depends(get_db)):
sources = db.query(DataSourceConfig).all()
def list_sources(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
sources = db.query(DataSourceConfig).filter(DataSourceConfig.entity_id == entity_id).all()
return {"data": [{c.name: getattr(s, c.name) for c in DataSourceConfig.__table__.columns} for s in sources]}
@router.post("/sources")
def create_source(data: dict, db: Session = Depends(get_db)):
def create_source(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
source = DataSourceConfig(
entity_id=entity_id, # 账套隔离:创建在当前账套下
name=data.get("name", ""),
source_type=data.get("source_type", "manual"),
api_endpoint=data.get("api_endpoint"),
@@ -71,13 +366,17 @@ def create_source(data: dict, db: Session = Depends(get_db)):
db.commit()
db.refresh(source)
# 操作日志
db.add(OperationLog(action="create_source", target_type="source", detail=source.name))
log_op(db, "create_source", target_type="source", target_id=source.id,
detail=source.name, entity_id=entity_id)
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@router.put("/sources/{source_id}")
def update_source(source_id: int, data: dict, db: Session = Depends(get_db)):
source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first()
def update_source(source_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
source = db.query(DataSourceConfig).filter(
DataSourceConfig.id == source_id,
DataSourceConfig.entity_id == entity_id,
).first()
if not source:
raise HTTPException(404, "数据源不存在")
for key in ["name", "source_type", "api_endpoint", "api_key", "query_sql", "sync_type", "status"]:
@@ -85,16 +384,98 @@ def update_source(source_id: int, data: dict, db: Session = Depends(get_db)):
setattr(source, key, data[key])
db.commit()
db.refresh(source)
db.add(OperationLog(action="update_source", target_type="source", detail=source.name))
log_op(db, "update_source", target_type="source", target_id=source.id,
detail=source.name, entity_id=entity_id)
db.commit()
return {"data": {c.name: getattr(source, c.name) for c in DataSourceConfig.__table__.columns}}
@router.delete("/sources/{source_id}")
def delete_source(source_id: int, db: Session = Depends(get_db)):
source = db.query(DataSourceConfig).filter(DataSourceConfig.id == source_id).first()
def delete_source(source_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
source = db.query(DataSourceConfig).filter(
DataSourceConfig.id == source_id,
DataSourceConfig.entity_id == entity_id,
).first()
if not source:
raise HTTPException(404, "数据源不存在")
db.add(OperationLog(action="delete_source", target_type="source", detail=source.name))
log_op(db, "delete_source", target_type="source", target_id=source.id,
detail=source.name, entity_id=entity_id)
db.delete(source)
db.commit()
return {"message": "删除成功"}
# ── 数据链路状态(2026-09-08 P0 诚实化:展示真实取数通道,替代误导性 active ERP 数据源) ──
def _zxt_channel_status(entity_id: int, db: Session) -> dict:
"""真实取数通道状态:kpi_values source_type='zxt-sync' 最近注入信息 + 落盘目录"""
latest = db.query(
func.max(KPIValue.period).label("latest_period"),
func.max(KPIValue.calculated_at).label("latest_at"),
func.count(KPIValue.id).label("row_count"),
).filter(
KPIValue.source_type == "zxt-sync",
KPIValue.entity_id == entity_id,
).first()
dir_mtime = None
dir_exists = os.path.isdir(ZXT_LANDING_DIR)
if dir_exists:
try:
newest = max(
(os.path.getmtime(os.path.join(root, f))
for root, _dirs, files in os.walk(ZXT_LANDING_DIR)
for f in files if f.endswith(".csv")),
default=0,
)
if newest:
dir_mtime = datetime.fromtimestamp(newest).strftime("%Y-%m-%d %H:%M")
except Exception:
pass
return {
"channel": "zxt(智享通→落盘→注入)",
"enabled": True,
"latest_period": latest.latest_period if latest else None,
"latest_inject_at": latest.latest_at.strftime("%Y-%m-%d %H:%M:%S") if latest and latest.latest_at else None,
"injected_rows": latest.row_count if latest else 0,
"landing_dir": ZXT_LANDING_DIR,
"landing_dir_exists": dir_exists,
"landing_dir_mtime": dir_mtime,
}
@router.get("/channel-status")
def channel_status(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""数据链路状态:真实取数通道(zxt) + 遗留 ERP 数据源说明(诚实化展示)"""
zxt = _zxt_channel_status(entity_id, db)
# 当前账套的 data_source_config 遗留 ERP 源(有端点指向 erp-gateway 且从未同步成功)
legacy = db.query(DataSourceConfig).filter(
DataSourceConfig.entity_id == entity_id,
DataSourceConfig.source_type == "erp",
).all()
legacy_rows = [{
"id": s.id,
"name": s.name,
"api_endpoint": s.api_endpoint,
"status": s.status,
"last_sync_at": s.last_sync_at.strftime("%Y-%m-%d %H:%M:%S") if s.last_sync_at else None,
} for s in legacy]
return {
"real_channel": zxt,
"erp_direct": {
"enabled": False,
"reason": ERP_DIRECT_CHANNEL_REASON,
"legacy_sources_count": len(legacy_rows),
"legacy_sources": legacy_rows,
},
}
@router.get("/sync-kpis")
def sync_kpis_from_erp(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""[已停用] 从ERP数据源同步KPI值
ERP 直连通道已于 2026-08-31 停用(erp-gateway DB 凭据删除)。
真实财务数据通道 = zxt 智享通注入(source_type='zxt-sync'),勿再调用本端点。
"""
raise HTTPException(status_code=410, detail=ERP_DIRECT_CHANNEL_REASON)
+395
View File
@@ -0,0 +1,395 @@
"""数据分类分级 — 重要数据标记(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":
# 账套分层(2026-09-23):全局标准准则科目 ∪ 本账套私有明细(与 KPI 侧同一账套口径)
q = db.query(Subject).filter(
Subject.is_active == 1,
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
)
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,
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
).count()
subject_marked = db.query(Subject).filter(
Subject.is_active == 1, Subject.important_flag == 1,
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
).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}
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
"""KPI派生规则 API — 管理会计OS (P2-② 2026-08-28)
派生规则配置(budget_derivation_rules):apply-method 派生KPI时优先读规则,
percentage_of → base_kpi实际值×rate;incremental → 上月×(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": "派生规则已删除"}
+262
View File
@@ -0,0 +1,262 @@
"""差异分析→战略地图反打 API — P1-1
允许从差异分析页面一键回写实际值到战略地图节点,触发预警并生成回顾会议题。
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.deps import get_entity_id
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
# 裁定 R5-补1(2026-09-19 财务口径):差异反打路径同源接入 R1/R5 守卫
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
import json
import logging
logger = logging.getLogger("cma.deviation_push")
router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
dependencies=[Depends(require_role("ceo", "finance", "business"))],
)
@router.post("/push-to-map")
def push_deviation_to_map(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""从差异分析回写实际值到战略地图节点
Body: {
mapId: int,
nodeId: string, // 格式 "dim_key-index" 如 "finance-0"
deviationId: int,
newValue: float,
period: string, // 如 "2026-05"
createReviewTopic: bool
}
"""
map_id = data.get("mapId")
node_id = data.get("nodeId")
deviation_id = data.get("deviationId")
new_value = data.get("newValue")
period = data.get("period")
create_review_topic = data.get("createReviewTopic", True)
if not map_id or not node_id:
raise HTTPException(400, "缺少 mapId 或 nodeId")
if new_value is None:
raise HTTPException(400, "缺少 newValue")
# R5-补1(财务裁定 2026-09-19):「请求体无 period 一律不落库」
# 旧码在缺 period 时用当月冒充期间再落库(预警 + kpi_values 双写)——
# 该兜底正好绕过 R5「可归期」判据,故此处直接整请求零业务写入返回。
if not period:
logger.info(
"R5-补1 期间守卫:deviation_push 请求无 period,整请求零业务写入"
" entity_id=%s map_id=%s node_id=%s", entity_id, map_id, node_id)
return {
"success": False,
"message": "缺少 period:按 R5-补1(期间不可归期)未落库任何数据",
"alert_created": False,
"alert_skipped_reason": "no_period",
"zero_write": True,
"review_topic_created": False,
}
# 1. 查找战略地图(2026-09-18 派生单: 地图须归属会话账套,NULL 归属按 e1 兼容)
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m or (m.entity_id or 1) != entity_id:
raise HTTPException(404, "战略地图不存在")
# 2. 解析 node_id 格式: "finance-0"
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except:
dims = []
parts = node_id.rsplit("-", 1)
if len(parts) != 2:
raise HTTPException(400, f"节点ID格式错误: {node_id}")
dim_key, obj_index_str = parts
try:
obj_index = int(obj_index_str)
except ValueError:
raise HTTPException(400, f"节点索引不是数字: {obj_index_str}")
target_dim = None
target_obj = None
for dim in dims:
if dim.get("key") == dim_key:
target_dim = dim
objs = dim.get("objectives", [])
if 0 <= obj_index < len(objs):
target_obj = objs[obj_index]
break
if not target_obj:
raise HTTPException(404, f"未找到节点: {node_id}")
kpi_codes = target_obj.get("kpis", [])
if not kpi_codes:
raise HTTPException(400, f"目标 [{target_obj.get('name')}] 没有关联KPI")
kpi_code = kpi_codes[0]
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi or (kpi.entity_id or 1) != entity_id:
raise HTTPException(404, f"KPI {kpi_code} 不存在")
# 3. 更新实际值到 KPIValue 表
#(R5-补1:period 已在上方强制校验,原「缺 period 用当月兜底」分支已删除——
# 该兜底会伪造期间,正是 R5「不可归期」的成因之一)
existing_value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).first()
if existing_value:
existing_value.actual_value = new_value
existing_value.source_type = "manual"
kv_row = existing_value
else:
kv_row = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=new_value,
source_type="manual",
)
db.add(kv_row)
db.flush()
# 4. 检查是否触发预警
alert_created = False
alert_id = None
alert_skipped_reason = None
if kpi.target_value and kpi.target_value > 0:
ratio = new_value / kpi.target_value
if ratio < 0.7:
alert_level = "red"
alert_msg = f"严重偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
elif ratio < 0.9:
alert_level = "yellow"
alert_msg = f"关注偏差: {kpi.kpi_name}实际值{new_value},目标值{kpi.target_value},达成率{ratio*100:.1f}%"
else:
alert_level = None
if alert_level:
# R1/R5 双守卫(裁定 R5-补1):demo 账套 / 非规整月期间 → 该 kpi_alerts 行不入库,
# 返回体登记跳过原因(KPI 值照写,与 bot_bridge 同口径:裁定只约束预警行)
if not entity_alert_guard(db, entity_id, source="deviation_push.push_deviation_to_map",
detail="kpi=%s" % kpi_code):
alert_skipped_reason = "R1_demo_entity_excluded"
elif not guard_alert_period(period, source="deviation_push.push_deviation_to_map",
detail="kpi=%s" % kpi_code):
alert_skipped_reason = "R5_non_attributable_period"
if alert_skipped_reason is None:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
kpi_value_id=kv_row.id, # R5-补1: 期间关联(同 kpi 同期间行 → 可派生)
alert_level=alert_level,
alert_message=alert_msg,
status="pending",
)
db.add(alert)
db.flush()
alert_created = True
alert_id = alert.id
# 5. 生成战略回顾会议题
review_topic_created = False
if create_review_topic:
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
existing_topic = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id, # 2026-09-18 派生单: 去重按账套隔离
ActionPlan.title == topic_title,
ActionPlan.status.in_(["pending", "in_progress"]),
).first()
if not existing_topic:
topic = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
kpi_id=kpi.id,
title=topic_title,
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
assignee=current_user.name if hasattr(current_user, "name") else "",
priority="medium",
status="pending",
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(topic)
review_topic_created = True
# 6. 操作日志
log = OperationLog(
user_id=getattr(current_user, "id", None),
action="deviation_push_to_map",
target_type="map",
target_id=map_id,
detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"node_id": node_id,
"deviation_id": deviation_id,
"kpi_code": kpi_code,
"new_value": new_value,
"period": period,
"alert_created": alert_created,
"alert_skipped_reason": alert_skipped_reason,
"review_topic_created": review_topic_created,
}, ensure_ascii=False),
)
db.add(log)
db.commit()
return {
"success": True,
"message": f"已回写至战略地图 [{target_dim.get('name')}→{target_obj.get('name')}]",
"kpi_code": kpi_code,
"kpi_name": kpi.kpi_name,
"new_value": new_value,
"alert_created": alert_created,
"alert_id": alert_id,
"alert_skipped_reason": alert_skipped_reason,
"review_topic_created": review_topic_created,
}
@router.get("/map-nodes/{map_id}")
def get_map_nodes(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 or (m.entity_id or 1) != entity_id: # 2026-09-18 派生单: NULL 归属按 e1 兼容(同 push 护栏)
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
if isinstance(dims, str):
try:
dims = json.loads(dims)
except:
dims = []
nodes = []
for dim in dims:
objs = dim.get("objectives", [])
for idx, obj in enumerate(objs):
node_id = f"{dim.get('key')}-{idx}"
nodes.append({
"node_id": node_id,
"dim_key": dim.get("key"),
"dim_name": dim.get("name"),
"dim_icon": dim.get("icon"),
"objective_name": obj.get("name"),
"kpi_codes": obj.get("kpis", []),
})
return {"data": nodes}
+555
View File
@@ -0,0 +1,555 @@
"""CMA 文档中心 API —— md 单一来源(任务 yanxue-20260924-cma-docs-center-poc)
口径(任总 2026-09-24 00:54 拍板):**指标进 UI、结论出 md、机器走 API/MCP。**
设计要点
--------
1. 内容单一来源 = 运行期目录(默认 `/srv/hermes/shared/cma-content`,可用 `CMA_DOCS_DIR` 覆盖),
一篇一个 `docs/<slug>.md`,front-matter 带 title/category/category_name/icon/summary/order。
2. **故意不放在仓库内**:CMA 部署链是「部署树 = origin/main 已提交树」(deploy.sh `[0]/[1]`
`git checkout -f` + `git clean -fdx`):仓内内容目录会被每次部署重置,且内容改动会让
部署树出现「未提交修改」而被 `[1.1]` 判失败、**阻断整个部署**。放仓外 ⇒ 改内容不需要
git/部署/重启,dev 树与部署树天然共读同一份。
3. **本模块每次请求实时读盘,无缓存、不做 build-time 打包** ⇒ 新增/修改一个 md 后,
页面刷新即变,前端无需改码、无需 rebuild。
4. 只**新增**端点,不改任何既有接口语义;内容为系统级共享知识(与 `knowledge_articles`
`entity_id IS NULL` 的全局共享同族),不做账套分区,故不接 `entity_id`。
5. 写口 = 文件系统(Bot/Agent 直接写 md),**不提供写 API**,避免出现"第二来源"。
"""
from __future__ import annotations
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from urllib.parse import quote
from app.auth_middleware import require_auth, require_role
from app.database import get_db
from app.deps import get_session_entity
from app.models import Entity, User, UserEntity
logger = logging.getLogger("cma.doc_center_api")
DEFAULT_DOCS_DIR = "/srv/hermes/shared/cma-content"
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
router = APIRouter(
prefix="/api/cma/docs",
tags=["文档中心"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ────────────────────────── 内容目录(运行期读盘)──────────────────────────
def content_root() -> Path:
"""内容根目录(每次调用实时解析,便于测试/迁移换目录)"""
return Path(os.environ.get("CMA_DOCS_DIR", DEFAULT_DOCS_DIR))
def docs_dir() -> Path:
return content_root() / "docs"
def _parse_scalar(raw: str):
raw = raw.strip()
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
body = raw[1:-1]
return body.replace('\\"', '"').replace("\\\\", "\\")
if re.fullmatch(r"-?\d+", raw or ""):
return int(raw)
return raw
def parse_front_matter(text: str) -> Optional[dict]:
"""极简 YAML front-matter 解析(只支持 `key: value` 标量,避免引入新依赖)"""
if not text.startswith("---"):
return None
end = text.find("\n---", 3)
if end < 0:
return None
meta = {}
for line in text[3:end].splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
key, sep, val = line.partition(":")
if not sep:
continue
meta[key.strip()] = _parse_scalar(val)
body = text[end + 4:]
if body.startswith("\n"):
body = body[1:]
return {"meta": meta, "body": body}
def _category_registry() -> list:
"""分类注册表(categories.json):决定分类顺序与展示名/图标"""
path = content_root() / "categories.json"
cats = []
if path.is_file():
import json
try:
data = json.loads(path.read_text(encoding="utf-8"))
cats = list(data.get("categories") or [])
except Exception as exc: # 注册表损坏不阻断读内容,只在响应里暴露
logger.warning("categories.json 解析失败: %s", exc)
return []
return cats
def _read_doc(path: Path):
try:
text = path.read_text(encoding="utf-8")
except Exception as exc:
return None, f"读取失败: {exc}"
parsed = parse_front_matter(text)
if parsed is None:
return None, "缺少 front-matter(--- 包裹的 title/category/... )"
meta = parsed["meta"]
for field in ("title", "category", "summary"):
if not str(meta.get(field, "")).strip():
return None, f"front-matter 缺必填字段: {field}"
stat = path.stat()
return {
"slug": path.stem,
"title": str(meta["title"]),
"category": str(meta["category"]),
"summary": str(meta["summary"]),
"order": int(meta.get("order") or 0),
"icon": str(meta.get("icon") or ""),
"category_name": str(meta.get("category_name") or ""),
"bytes": stat.st_size,
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"content": parsed["body"],
}, None
def load_docs() -> tuple:
"""实时读盘:返回 (docs, skipped, available)。docs 已按 (category order, doc order, slug) 排序"""
d = docs_dir()
if not d.is_dir():
return [], [], False
cats = _category_registry()
cat_order = {c.get("key"): int(c.get("order") or 0) for c in cats}
docs, skipped = [], []
for path in sorted(d.glob("*.md")):
if not SLUG_RE.match(path.stem):
skipped.append({"file": path.name, "reason": "文件名不符合 slug 规则 ^[a-z0-9][a-z0-9-]{0,63}$"})
continue
doc, err = _read_doc(path)
if err:
skipped.append({"file": path.name, "reason": err})
continue
docs.append(doc)
docs.sort(key=lambda x: (cat_order.get(x["category"], 10_000), x["category"], x["order"], x["slug"]))
return docs, skipped, True
def _categories_view(docs: list) -> list:
"""分类视图:按注册表 `order` 升序(未登记分类补在末尾,不丢内容)"""
cats = _category_registry()
seen, out, seq = set(), [], 0
for c in cats:
key = c.get("key")
if not key or key in seen:
continue
seen.add(key)
seq += 1
out.append({
"key": key,
"name": c.get("name") or key,
"icon": c.get("icon") or "",
"order": int(c.get("order") or 0),
"_seq": seq,
"count": len([d for d in docs if d["category"] == key]),
})
for d in docs:
if d["category"] in seen:
continue
seen.add(d["category"])
seq += 1
out.append({
"key": d["category"],
"name": d["category_name"] or d["category"],
"icon": d["icon"] or "",
"order": 10_000,
"_seq": seq,
"count": len([x for x in docs if x["category"] == d["category"]]),
})
out.sort(key=lambda c: (c["order"], c["_seq"]))
for c in out:
c.pop("_seq")
return out
def _meta_only(doc: dict) -> dict:
return {k: v for k, v in doc.items() if k != "content"}
# ────────────────────────── 端点(只新增)──────────────────────────
@router.get("")
def list_docs(
request: Request,
with_content: bool = Query(False, description="是否带上 md 正文(默认只返回元数据)"),
category: Optional[str] = Query(None, description="按分类 key 过滤"),
type: str = Query("knowledge", description="内容类型:knowledge(缺省,知识库)/ report(报告解读)"),
domain: Optional[str] = Query(None, description="type=report:按域过滤"),
entity: Optional[str] = Query(None, description="type=report:按账套归属过滤"),
kind: Optional[str] = Query(None, description="type=report:按报告类型过滤"),
date_from: Optional[str] = Query(None, description="type=report:起始日(含)YYYY-MM-DD"),
date_to: Optional[str] = Query(None, description="type=report:结束日(含)YYYY-MM-DD"),
diagnose: bool = Query(False, description="type=report:附带被排除件明细(自诊断)"),
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""文档列表(实时读盘)。
- `data`:按「分类顺序 → 篇内 order → slug」排序的文档元数据(`with_content=true` 时含正文)
- `categories`:分类视图(name/icon/count),前端按它分组渲染
- `source`:内容目录绝对路径(机器侧取内容用同一来源,满足「机器走 API」)
- `skipped`:被跳过的 md 及原因(缺 front-matter/文件名非法),便于 Bot 自诊断
"""
if str(type or "").strip().lower() == "report":
return _list_reports(
request=request, db=db, user=current_user,
domain=domain, entity=entity, kind=kind,
date_from=date_from, date_to=date_to, diagnose=bool(diagnose),
)
docs, skipped, available = load_docs()
all_docs = docs
if category:
docs = [d for d in docs if d["category"] == category]
return {
"count": len(docs),
"available": available,
"source": str(docs_dir()),
"categories": _categories_view(all_docs), # 分类视图按全量内容统计(不受 category 过滤影响)
"data": docs if with_content else [_meta_only(d) for d in docs],
"skipped": skipped,
"format": "markdown",
}
@router.get("/{slug}")
def get_doc(slug: str):
"""单篇文档(含 md 正文,实时读盘)"""
if not SLUG_RE.match(slug or ""):
raise HTTPException(400, "无效的文档标识(只允许 [a-z0-9-],1-64 位,字母数字开头)")
path = docs_dir() / f"{slug}.md"
if not path.is_file():
raise HTTPException(404, "文档不存在")
doc, err = _read_doc(path)
if err:
raise HTTPException(422, f"文档格式不合法: {err}")
return doc
# ══════════════════════ 报告解读(type=report)══════════════════════════
# 口径(规范 /srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md,本单按其实现):
# ① 进门标记 = front-matter + `conclusion` 非空(§二「front-matter 本身就是进门标记」
# + §三「conclusion 是唯一硬字段」):缺任一 ⇒ **不进列表、不报错**。
# 回执/审计/施工/工作件天然不带 front-matter ⇒ 天然不进;**不搬家、不新建内容目录**。
# ② 规范 §四 另列 title/date/domain/sources 为必填:缺失**不拒收**,标 `incomplete` 并按
# 兜底值展示(若按字面拒收,页面只能是空壳,与 §一「无体之用」的立意相悖)。
# ③ 只**新增** type=report 分支与新端点 /report/{rid};type 缺省走知识库分支,语义逐字不变。
# ④ 权限:按 entity + CMA 既有 roles 分区 —— ceo/it 可跨账套读结论,其余角色只看本账套 + 集团/通用。
# ⑤ 读口 = 文件系统(实时读盘,改一篇 md 刷新即生效),无写 API(与知识库同口径,避免第二来源)。
WISDOM_REPORTS_DIR = "/srv/hermes/wisdom/_reports"
PROFILES_ROOT = "/root/.hermes/profiles"
REPORT_SPEC_PATH = "/srv/hermes/wisdom/_decisions/report-frontmatter-spec-20260924.md"
REPORT_HEAD_BYTES = 8192
REPORT_DIRS_ENV = "CMA_REPORTS_DIRS"
DOMAIN_LABELS = {
"finance": "财务", "customer": "客户", "process": "流程", "learning": "学习成长",
"tech": "技术", "ops": "运维", "market": "市场", "gov": "政务",
}
KIND_LABELS = {"report": "报告", "analysis": "分析", "brief": "摘要", "warning": "预警"}
GLOBAL_ENTITIES = ("集团", "通用")
CROSS_ENTITY_ROLES = ("ceo", "it")
ENTITY_NAME_FALLBACK = {1: "酣客", 2: "博海"}
_SAFE_STEM_RE = re.compile(r"^[^/\\\x00]{1,160}$")
def _as_list(val) -> list:
"""front-matter 值 → 列表(支持行内数组 [a, b] / 逗号分隔 / 单值)"""
if val is None:
return []
if isinstance(val, list):
return [str(v).strip() for v in val if str(v).strip()]
s = str(val).strip()
if not s:
return []
if s.startswith("[") and s.endswith("]"):
s = s[1:-1]
return [p.strip().strip("\"'") for p in s.split(",") if p.strip()]
def _kpi_link(raw: str) -> dict:
"""kpi_refs 项 → 一跳链(数字 / kpi:<id> → 指标详情页;其余 → 指标字典页关键词)"""
v = str(raw).strip()
m = re.fullmatch(r"(?:kpi[:/]?)?(\d{1,9})", v, flags=re.IGNORECASE)
if m:
return {"raw": v, "url": f"/kpis/{m.group(1)}", "resolved": "detail"}
return {"raw": v, "url": "/kpis?keyword=" + quote(v), "resolved": "list"}
def reports_dirs() -> list:
"""[(source_key, Path)] —— 既有报告落点的发现结果。
env `CMA_REPORTS_DIRS` 覆盖优先(`:`/`;` 分隔,条目可写 `key=path`,供测试与隔离实测);
缺省 = 自动发现:`/srv/hermes/wisdom/_reports` + 各 profile 的 `pending-tasks/_reports`
(按 resolve 后路径去重,避免 pending-tasks 为符号链接时重复收录)。
"""
raw = (os.environ.get(REPORT_DIRS_ENV) or "").strip()
out, seen = [], set()
if raw:
for i, item in enumerate(x for x in re.split(r"[:;]", raw) if x.strip()):
item = item.strip()
key, sep, path = item.partition("=")
if not sep:
key, path = f"src{i}", item
out.append(((key or f"src{i}").strip(), Path(path)))
return out
cands = []
wisdom = Path(WISDOM_REPORTS_DIR)
if wisdom.is_dir():
cands.append(("wisdom", wisdom))
for d in sorted(Path(PROFILES_ROOT).glob("*/pending-tasks/_reports")):
if not d.is_dir():
continue
prof = d.parent.parent.name
cands.append((prof[6:] if prof.startswith("wecom-") else prof, d))
for key, path in cands:
try:
rp = path.resolve()
except OSError:
rp = path
if rp in seen:
continue
seen.add(rp)
out.append((key, path))
return out
def _parse_report_text(text: str, path: Path, source_key: str):
"""报告件 → (record, None) 或 (None, 排除码)。
排除码:`no_frontmatter`(无 --- 进门标记)/ `missing_conclusion`(缺唯一硬字段)/
`unreadable:<err>`。**排除即静默**(不进列表、不报错),这正是负对照的判据。
"""
parsed = parse_front_matter(text) if text.startswith("---") else None
if parsed is None:
return None, "no_frontmatter"
meta = parsed["meta"]
if not str(meta.get("conclusion") or "").strip():
return None, "missing_conclusion"
try:
stat = path.stat()
except OSError as exc:
return None, f"unreadable:{exc}"
refs = _as_list(meta.get("kpi_refs"))
rec = {
"id": f"{source_key}:{path.stem}",
"title": str(meta.get("title") or "").strip() or path.stem,
"date": (str(meta.get("date") or "").strip()
or datetime.fromtimestamp(stat.st_mtime).date().isoformat()),
"domain": str(meta.get("domain") or "").strip() or "未分类",
"entity": str(meta.get("entity") or "").strip(),
"kind": str(meta.get("kind") or "").strip() or "report",
"conclusion": str(meta["conclusion"]).strip(),
"sources": _as_list(meta.get("sources")),
"impact": str(meta.get("impact") or "").strip(),
"decisions": _as_list(meta.get("decisions")),
"kpi_refs": refs,
"kpi_links": [_kpi_link(r) for r in refs],
"file": path.name,
"source": source_key,
"path": str(path),
"bytes": stat.st_size,
"updated_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"incomplete": [f for f in ("title", "date", "domain", "sources")
if not str(meta.get(f) or "").strip()],
"content": parsed["body"],
}
return rec, None
def _read_report(path: Path, source_key: str, full: bool = False):
try:
if full:
text = path.read_text(encoding="utf-8", errors="replace")
else:
with open(path, "rb") as fh:
text = fh.read(REPORT_HEAD_BYTES).decode("utf-8", errors="replace")
if text.startswith("---") and text.find("\n---", 3) < 0:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return None, f"unreadable:{exc}"
return _parse_report_text(text, path, source_key)
def _entity_scope(db: Session, user: User, request: Request) -> dict:
"""当前会话的可见范围:账套 id / 账套名称集合 / 是否跨账套角色"""
entity_id = get_session_entity(request)
if entity_id is None:
rel = db.query(UserEntity).filter(UserEntity.user_id == user.id).first()
entity_id = rel.entity_id if rel else None
names = set()
if entity_id is not None:
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if ent:
names |= {str(ent.name or "").strip(), str(ent.short_name or "").strip()}
fb = ENTITY_NAME_FALLBACK.get(int(entity_id))
if fb:
names.add(fb)
names.discard("")
return {
"role": user.role,
"entity_id": entity_id,
"entity_names": sorted(names),
"cross_entity": user.role in CROSS_ENTITY_ROLES,
}
def _report_visible(rec: dict, scope: dict) -> bool:
"""按 entity 分区:跨账套角色(ceo/it)→ 全可见;其余 → 本账套名 + 集团/通用。
未标 entity 的件按「通用」处理(entity 属选填字段,不设门槛);
显式 entity 数字与会话账套一致也算命中(兼容 `entity: 2` 写法)。
"""
if scope["cross_entity"]:
return True
e = str(rec.get("entity") or "").strip()
if not e or e in GLOBAL_ENTITIES:
return True
if e in set(scope["entity_names"]):
return True
if e.isdigit() and scope["entity_id"] is not None and int(e) == int(scope["entity_id"]):
return True
return False
def _facet(items: list, field: str, labels: dict) -> list:
cnt = {}
for r in items:
k = str(r.get(field) or "").strip() or "(未标)"
cnt[k] = cnt.get(k, 0) + 1
return [{"key": k, "name": labels.get(k, k), "count": v}
for k, v in sorted(cnt.items(), key=lambda kv: (-kv[1], kv[0]))]
def _list_reports(request: Request, db: Session, user: User,
domain=None, entity=None, kind=None,
date_from=None, date_to=None, diagnose: bool = False) -> dict:
"""报告解读列表:扫描既有报告落点 → 只收带 front-matter + conclusion 的件 → 按 entity 分区 → 筛选"""
scope = _entity_scope(db, user, request)
included, sources = [], []
excluded = {"no_frontmatter": 0, "missing_conclusion": 0, "other": 0}
detail = []
for key, base in reports_dirs():
exists = base.is_dir()
found = 0
if exists:
for path in sorted(base.glob("*.md")):
found += 1
rec, reason = _read_report(path, key)
if reason:
if reason == "no_frontmatter":
excluded["no_frontmatter"] += 1
elif reason == "missing_conclusion":
excluded["missing_conclusion"] += 1
else:
excluded["other"] += 1
if diagnose and len(detail) < 200:
detail.append({"file": path.name, "source": key, "reason": reason})
continue
if not _report_visible(rec, scope):
continue
included.append(rec)
sources.append({"key": key, "path": str(base),
"exists": exists, "md_count": found})
visible_total = len(included)
def _keep(r: dict) -> bool:
if domain and r["domain"] != domain:
return False
if entity and r["entity"] != entity:
return False
if kind and r["kind"] != kind:
return False
if date_from and (r["date"] or "") < date_from:
return False
if date_to and (r["date"] or "") > date_to:
return False
return True
rows = [r for r in included if _keep(r)]
rows.sort(key=lambda r: (r["date"] or "", r["title"]), reverse=True)
dates = sorted({r["date"] for r in included if r["date"]})
data = []
for r in rows:
r = dict(r)
r.pop("content", None)
data.append(r)
payload = {
"type": "report",
"count": len(data),
"available": any(s["exists"] for s in sources),
"scope": scope,
"sources": sources,
"excluded": excluded,
"facets": {
"domain": _facet(included, "domain", DOMAIN_LABELS),
"entity": _facet(included, "entity", {}),
"kind": _facet(included, "kind", KIND_LABELS),
"date": {"min": dates[0] if dates else "", "max": dates[-1] if dates else ""},
},
"totals": {"visible": visible_total, "filtered_out": visible_total - len(data)},
"data": data,
"format": "markdown",
"spec": REPORT_SPEC_PATH,
}
if diagnose:
payload["excluded_detail"] = detail
return payload
@router.get("/report/{rid}")
def get_report(rid: str, request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth)):
"""单篇报告(含 md 正文,实时读盘)。`rid` = `<source_key>:<文件名主干>`"""
key, sep, stem = (rid or "").partition(":")
if not sep or not key:
raise HTTPException(400, "无效的报告标识(应为 <source>:<文件名>)")
if not _SAFE_STEM_RE.match(stem) or stem.startswith(".") or ".." in stem:
raise HTTPException(400, "无效的报告标识(文件名非法)")
base = {k: p for k, p in reports_dirs()}.get(key)
if base is None:
raise HTTPException(404, "报告不存在")
path = base / f"{stem}.md"
if path.parent != base or not path.is_file():
raise HTTPException(404, "报告不存在")
rec, reason = _read_report(path, key, full=True)
if reason:
raise HTTPException(422, f"报告格式不合法: {reason}")
scope = _entity_scope(db, current_user, request)
if not _report_visible(rec, scope):
raise HTTPException(
403,
f"无权访问企业 {rec.get('entity') or '(未标注)'} 的报告(当前账套: {scope.get('entity_names') or '未绑定'})",
)
return rec
+485
View File
@@ -0,0 +1,485 @@
"""驱动因子预算 API — 科目/驱动因子模式切换 + 行业包 + 敏感性分析"""
import json
import math
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import datetime
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import SystemConfig, OperationLog
from app.utils.oplog import log_op
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
router = APIRouter(
prefix="/api/cma/budget/driver",
tags=["驱动因子预算"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
# ──────────────────────────────────────────────
# 模板预置数据
# ──────────────────────────────────────────────
PRESET_TEMPLATES = [
# 通用收入模板
{
"name": "收入预测(通用)",
"industry": "general",
"category": "revenue",
"formula_desc": "收入 = 客户数 × 客单价",
"formula_text": "客户数×客单价",
"factors": [
{"key": "customer_count", "label": "客户数", "type": "number", "default": 100, "unit": "家"},
{"key": "avg_price", "label": "客单价", "type": "number", "default": 50000, "unit": "元/家"},
],
},
# 通用费用模板
{
"name": "费用预测(通用)",
"industry": "general",
"category": "expense",
"formula_desc": "费用 = 人数 × 人均成本",
"formula_text": "人数×人均成本",
"factors": [
{"key": "headcount", "label": "人数", "type": "number", "default": 50, "unit": "人"},
{"key": "avg_cost_per_head", "label": "人均成本", "type": "number", "default": 8000, "unit": "元/人"},
],
},
# 贸易经销版 — 渠补预算
{
"name": "渠补预算(贸易经销版)",
"industry": "trade",
"category": "expense",
"formula_desc": "渠补预算 = 计划维护渠道数 × 平均渠补率 × 平均渠道交易额",
"formula_text": "计划维护渠道数×平均渠补率×平均渠道交易额",
"factors": [
{"key": "channel_count", "label": "计划维护渠道数", "type": "number", "default": 80, "unit": "家"},
{"key": "channel_subsidy_rate", "label": "平均渠补率", "type": "percent", "default": 75, "unit": "%"},
{"key": "avg_transaction", "label": "平均渠道交易额", "type": "number", "default": 150000, "unit": "元/家"},
],
},
# 贸易经销版 — 收入预测
{
"name": "收入预测(贸易经销版)",
"industry": "trade",
"category": "revenue",
"formula_desc": "收入 = 渠道数 × 平均交易额",
"formula_text": "渠道数×平均交易额",
"factors": [
{"key": "channel_count", "label": "渠道数", "type": "number", "default": 80, "unit": "家"},
{"key": "avg_transaction", "label": "平均交易额", "type": "number", "default": 150000, "unit": "元/家"},
],
},
# IT服务版 — 收入
{
"name": "收入预测(IT服务版)",
"industry": "it",
"category": "revenue",
"formula_desc": "收入 = 计划新签客户数×平均合同额 + 续约客户数×续约率×平均合同额",
"formula_text": "计划新签客户数×平均合同额+续约客户数×续约率×平均合同额",
"factors": [
{"key": "new_customers", "label": "计划新签客户数", "type": "number", "default": 36, "unit": "家/年"},
{"key": "avg_contract_value", "label": "平均合同额", "type": "number", "default": 80000, "unit": "元/家"},
{"key": "renewal_customers", "label": "续约客户数", "type": "number", "default": 260, "unit": "家"},
{"key": "renewal_rate", "label": "续约率", "type": "percent", "default": 90, "unit": "%"},
],
},
# IT服务版 — 销售费用
{
"name": "销售费用预算(IT服务版)",
"industry": "it",
"category": "expense",
"formula_desc": "销售费用 = 新签客户数×平均获客成本 + 续约客户数×维护成本",
"formula_text": "新签客户数×平均获客成本+续约客户数×维护成本",
"factors": [
{"key": "new_customers", "label": "计划新签客户数", "type": "number", "default": 36, "unit": "家/年"},
{"key": "acquisition_cost", "label": "平均获客成本", "type": "number", "default": 3000, "unit": "元/家"},
{"key": "renewal_customers", "label": "续约客户数", "type": "number", "default": 260, "unit": "家"},
{"key": "maintenance_cost", "label": "维护成本", "type": "number", "default": 500, "unit": "元/家"},
],
},
]
# ──────────────────────────────────────────────
# 模式切换
# ──────────────────────────────────────────────
@router.get("/mode")
def get_driver_mode(db: Session = Depends(get_db)):
"""获取当前预算编制模式: subject / driver"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_driver_mode").first()
if not cfg:
return {"mode": "subject", "label": "科目模式"}
try:
val = json.loads(cfg.config_value)
except (json.JSONDecodeError, TypeError):
val = {"mode": "subject"}
return val
@router.post("/mode")
def set_driver_mode(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""切换预算编制模式: subject(科目模式) / driver(驱动因子模式)"""
mode = data.get("mode", "subject")
if mode not in ("subject", "driver"):
raise HTTPException(400, "模式必须是 subject 或 driver")
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_driver_mode").first()
val = json.dumps({"mode": mode, "label": "驱动因子模式" if mode == "driver" else "科目模式"})
if cfg:
cfg.config_value = val
else:
cfg = SystemConfig(
config_key="budget_driver_mode",
config_value=val,
description="预算编制模式: subject=科目模式, driver=驱动因子模式",
)
db.add(cfg)
db.commit()
# 2026-09-23 ④:审计带账套(会话账套)+ 显式标注该键当前仍是**全局键**
#(budget_driver_mode 拆账套 = 另单排期项,见 dama-entity-isolation-sweep-20260923 §二之三)
log_op(db, "update", target_type="budget", target_id=0,
detail={"mode": mode, "action": "切换编制模式", "entity_scope": "global_key"},
entity_id=entity_id)
db.commit()
return {"message": f"已切换为{'驱动因子模式' if mode == 'driver' else '科目模式'}", "mode": mode}
# ──────────────────────────────────────────────
# 模板管理
# ──────────────────────────────────────────────
@router.get("/templates")
def list_driver_templates(
industry: Optional[str] = Query(None, description="行业: general/trade/it"),
category: Optional[str] = Query(None, description="类别: revenue/expense"),
db: Session = Depends(get_db),
):
"""获取驱动因子模板列表(含预置模板)"""
# 先从数据库读
query = db.query(DriverFactorTemplate).filter(DriverFactorTemplate.is_active == 1)
if industry:
query = query.filter(DriverFactorTemplate.industry == industry)
if category:
query = query.filter(DriverFactorTemplate.category == category)
db_templates = query.order_by(DriverFactorTemplate.id.asc()).all()
# 合并预置模板(数据库没有则返回预置)
if db_templates:
result = []
for t in db_templates:
result.append({
"id": t.id,
"name": t.name,
"industry": t.industry,
"category": t.category,
"formula_desc": t.formula_desc,
"formula_text": t.formula_text,
"factors": t.factors,
"is_preset": False,
})
return {"data": result, "total": len(result)}
else:
# 返回预置模板
filtered = PRESET_TEMPLATES
if industry:
filtered = [t for t in filtered if t["industry"] == industry]
if category:
filtered = [t for t in filtered if t["category"] == category]
return {"data": filtered, "total": len(filtered)}
@router.get("/industries")
def list_driver_industries(db: Session = Depends(get_db)):
"""获取行业包列表"""
industries = [
{"key": "general", "label": "通用模板", "icon": "📦"},
{"key": "trade", "label": "贸易经销版", "icon": "🏪"},
{"key": "it", "label": "IT服务版", "icon": "💻"},
]
return {"data": industries}
# ──────────────────────────────────────────────
# 驱动因子计算
# ──────────────────────────────────────────────
def _calculate_formula(formula_text: str, factors: dict, factor_defs: list) -> float:
"""
根据公式文本和驱动因子值计算预算。
支持的运算: + - × * /
公式文本中的因子名可以是key或label,函数会自动映射到值。
"""
# 替换 × 为 *
expr = formula_text.replace("×", "*")
# 构建变量映射:key -> value, label -> value
factor_map = {}
for fd in factor_defs:
key = fd["key"]
val = factors.get(key, fd.get("default", 0))
if fd.get("type") == "percent":
val = float(val) / 100.0
else:
val = float(val)
factor_map[key] = val
# 也映射 label(中文名)
factor_map[fd["label"]] = val
# 按长度降序替换,避免短名被错误替换
sorted_names = sorted(factor_map.keys(), key=len, reverse=True)
for name in sorted_names:
expr = expr.replace(name, str(factor_map[name]))
try:
result = eval(expr, {"__builtins__": {}}, {})
return round(float(result), 2)
except Exception as e:
raise HTTPException(400, f"公式计算失败: {expr}, 错误: {str(e)}")
@router.post("/calculate")
def driver_calculate(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""
驱动因子计算预算。
接收驱动因子值,自动按公式计算预算结果。
如果指定了 template_id,使用模板的公式和因子定义。
否则使用 data.name, data.formula_text, data.factors 中的因子定义。
"""
template_id = data.get("template_id")
factors_input = data.get("factors", {}) # {key: value}
period = data.get("period", datetime.now().strftime("%Y-%m"))
# 获取模板
formula_text = None
factor_defs = []
if template_id:
template = db.query(DriverFactorTemplate).filter(
DriverFactorTemplate.id == template_id,
DriverFactorTemplate.is_active == 1,
).first()
if not template:
# 检查预置模板
if 1 <= template_id <= len(PRESET_TEMPLATES):
preset = PRESET_TEMPLATES[template_id - 1]
formula_text = preset["formula_text"]
factor_defs = preset["factors"]
name = preset["name"]
industry = preset["industry"]
else:
raise HTTPException(404, "模板不存在")
else:
formula_text = template.formula_text
factor_defs = template.factors
name = template.name
industry = template.industry
else:
name = data.get("name", "自定义驱动因子预算")
industry = data.get("industry", "general")
formula_text = data.get("formula_text")
factor_defs = data.get("factor_defs", [])
if not formula_text:
raise HTTPException(400, "缺少 formula_text (公式文本)")
if not factor_defs:
raise HTTPException(400, "缺少 factor_defs (因子定义)")
# 填充默认值
complete_factors = {}
for fd in factor_defs:
key = fd["key"]
val = factors_input.get(key)
if val is None:
val = fd.get("default", 0)
complete_factors[key] = val
# 计算结果
result = _calculate_formula(formula_text, complete_factors, factor_defs)
# 记录计算结果
budget_record = DriverFactorBudget(
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
name=name,
industry=industry,
template_id=template_id,
factors=complete_factors,
calculated_value=result,
formula_text=formula_text,
period=period,
created_by=current_user.name if hasattr(current_user, "name") else "",
)
db.add(budget_record)
db.commit()
db.refresh(budget_record)
# 记录操作日志
log = OperationLog(
action="calculate",
target_type="budget",
target_id=budget_record.id,
detail=json.dumps({
"entity_id": entity_id, # 2026-09-23 ④:审计带账套(=会话账套)
"name": name,
"formula": formula_text,
"factors": complete_factors,
"result": result,
}, ensure_ascii=False),
)
db.add(log)
db.commit()
# 计算原始因子信息(用于前端展示)
factor_details = []
for fd in factor_defs:
key = fd["key"]
factor_details.append({
"key": key,
"label": fd["label"],
"value": complete_factors[key],
"unit": fd.get("unit", ""),
"type": fd.get("type", "number"),
})
return {
"id": budget_record.id,
"name": name,
"industry": industry,
"template_id": template_id,
"formula_text": formula_text,
"formula_desc": data.get("formula_desc", ""),
"factors": factor_details,
"calculated_value": result,
"period": period,
}
# ──────────────────────────────────────────────
# 敏感性分析
# ──────────────────────────────────────────────
@router.post("/sensitivity")
def driver_sensitivity(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""
敏感性分析:对指定驱动因子做 ±10%、±20% 变动,显示预算变动。
"""
factors_input = data.get("factors", {})
formula_text = data.get("formula_text")
factor_defs = data.get("factor_defs", [])
target_factor = data.get("target_factor") # 要分析的因子key,不指定则分析所有因子
if not formula_text or not factor_defs:
raise HTTPException(400, "缺少 formula_text 或 factor_defs")
# 计算基准值
base_result = _calculate_formula(formula_text, factors_input, factor_defs)
# 敏感性分析
sensitivity_results = []
factors_to_analyze = factor_defs
if target_factor:
factors_to_analyze = [fd for fd in factor_defs if fd["key"] == target_factor]
if not factors_to_analyze:
raise HTTPException(404, f"未找到因子: {target_factor}")
for fd in factors_to_analyze:
key = fd["key"]
base_val = factors_input.get(key, fd.get("default", 0))
variations = []
for pct_change in [-20, -10, 10, 20]:
if fd.get("type") == "percent":
# 百分比因子:变动比例直接加在百分比上
changed_val = base_val * (1 + pct_change / 100.0)
else:
changed_val = base_val * (1 + pct_change / 100.0)
test_factors = dict(factors_input)
test_factors[key] = round(changed_val, 2)
try:
new_result = _calculate_formula(formula_text, test_factors, factor_defs)
delta = round(new_result - base_result, 2)
delta_pct = round((delta / base_result * 100) if base_result != 0 else 0, 2)
variations.append({
"change_pct": pct_change,
"factor_value": round(changed_val, 2),
"budget_result": new_result,
"delta": delta,
"delta_pct": delta_pct,
})
except Exception:
variations.append({
"change_pct": pct_change,
"factor_value": round(changed_val, 2),
"budget_result": None,
"delta": None,
"delta_pct": None,
"error": "计算失败",
})
sensitivity_results.append({
"factor_key": key,
"factor_label": fd["label"],
"base_value": base_val,
"unit": fd.get("unit", ""),
"type": fd.get("type", "number"),
"variations": variations,
})
return {
"base_value": base_result,
"formula_text": formula_text,
"sensitivity": sensitivity_results,
}
# ──────────────────────────────────────────────
# 历史记录
# ──────────────────────────────────────────────
@router.get("/history")
def list_driver_history(
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查看本账套最近的驱动因子预算计算记录(账套隔离 2026-09-21)"""
records = db.query(DriverFactorBudget).filter(
DriverFactorBudget.entity_id == entity_id,
).order_by(
DriverFactorBudget.created_at.desc()
).limit(limit).all()
result = []
for r in records:
result.append({
"id": r.id,
"name": r.name,
"industry": r.industry,
"factors": r.factors,
"calculated_value": r.calculated_value,
"formula_text": r.formula_text,
"period": r.period,
"created_at": r.created_at.isoformat() if r.created_at else None,
})
return {"data": result, "total": len(result)}
+93
View File
@@ -0,0 +1,93 @@
"""企业实体 API"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from typing import List, Optional
from pydantic import BaseModel
from app.database import get_db
from app.auth_middleware import require_auth
from app.models import Entity
router = APIRouter(prefix="/api/cma/entities", tags=["企业实体"],
dependencies=[Depends(require_auth)],
)
class EntityCreate(BaseModel):
name: str
short_name: Optional[str] = None
industry: Optional[str] = None
status: Optional[str] = "active"
class EntityUpdate(BaseModel):
name: Optional[str] = None
short_name: Optional[str] = None
industry: Optional[str] = None
status: Optional[str] = None
@router.get("")
def list_entities(db: Session = Depends(get_db)):
"""获取企业列表"""
entities = db.query(Entity).filter(Entity.status == "active").order_by(Entity.id).all()
return {
"data": [
{
"id": e.id,
"name": e.name,
"short_name": e.short_name,
"industry": e.industry,
}
for e in entities
]
}
@router.post("")
def create_entity(data: EntityCreate, db: Session = Depends(get_db)):
"""创建企业"""
entity = Entity(
name=data.name,
short_name=data.short_name,
industry=data.industry,
status=data.status or "active",
)
db.add(entity)
db.commit()
db.refresh(entity)
return {
"id": entity.id,
"name": entity.name,
"short_name": entity.short_name,
"industry": entity.industry,
"message": "企业创建成功",
}
@router.put("/{entity_id}")
def update_entity(entity_id: int, data: EntityUpdate, db: Session = Depends(get_db)):
"""更新企业信息"""
entity = db.query(Entity).filter(Entity.id == entity_id).first()
if not entity:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=404, content={"detail": "企业不存在"})
if data.name is not None:
entity.name = data.name
if data.short_name is not None:
entity.short_name = data.short_name
if data.industry is not None:
entity.industry = data.industry
if data.status is not None:
entity.status = data.status
db.commit()
db.refresh(entity)
return {
"id": entity.id,
"name": entity.name,
"short_name": entity.short_name,
"industry": entity.industry,
"message": "企业更新成功",
}
+659
View File
@@ -0,0 +1,659 @@
"""费用审核智能体 API — 管理会计OS
费用规则配置 + 报销单自动校验 + 人工审批流程 + 审核看板统计
流程:
1. 提交报销单 → 自动校验费用规则
2. 超限 → 自动打回(returned, 标注原因)
3. 合规 → 待人工审批(pending)
4. 审批通过(approved) / 拒绝(rejected)
"""
import json
import random
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import ExpenseRule, ExpenseReimbursement, OperationLog
router = APIRouter(prefix="/api/cma/expenses", tags=["费用审核"])
# 费用类型中文映射
EXPENSE_TYPE_LABELS = {
"entertainment": "招待费",
"travel": "差旅费",
"office": "办公费",
"management": "管理费",
}
# 预置规则
PRESET_RULES = [
{"rule_name": "招待费单笔限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "single", "limit_amount": 2000.0,
"cycle": "single", "remark": "招待费标准:单笔≤2000元"},
{"rule_name": "招待费部门月限额", "dimension": "department", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 50000.0,
"cycle": "monthly", "remark": "招待费标准:部门月限额5万元"},
{"rule_name": "招待费月度总额限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "entertainment", "limit_type": "monthly", "limit_amount": 150000.0,
"cycle": "monthly", "remark": "公司招待费预算15万/月"},
{"rule_name": "差旅住宿单晚限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "travel", "limit_type": "single", "limit_amount": 300.0,
"cycle": "single", "remark": "差旅费标准:住宿≤300元/晚"},
{"rule_name": "差旅交通等级限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "travel", "limit_type": "single", "limit_amount": 1500.0,
"cycle": "single", "remark": "差旅费标准:交通等级(高铁二等座/经济舱)"},
{"rule_name": "办公费单笔限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "office", "limit_type": "single", "limit_amount": 500.0,
"cycle": "single", "remark": "办公费标准:单笔≤500元"},
{"rule_name": "管理费月度总额限额", "dimension": "expense_type", "dimension_value": "",
"expense_type": "management", "limit_type": "monthly", "limit_amount": 910000.0,
"cycle": "monthly", "remark": "管理费预算91万/月"},
]
def _rule_to_dict(r: ExpenseRule) -> dict:
return {
"id": r.id,
"rule_name": r.rule_name,
"dimension": r.dimension,
"dimension_value": r.dimension_value,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"limit_type": r.limit_type,
"limit_amount": r.limit_amount,
"cycle": r.cycle,
"status": r.status,
"remark": r.remark,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _reimb_to_dict(r: ExpenseReimbursement) -> dict:
return {
"id": r.id,
"reimb_no": r.reimb_no,
"applicant": r.applicant,
"department": r.department,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"title": r.title,
"amount": r.amount,
"expense_date": r.expense_date.isoformat() if r.expense_date else None,
"attachment": r.attachment,
"status": r.status,
"check_result": r.check_result,
"check_reason": r.check_reason,
"check_detail": r.check_detail,
"checked_at": r.checked_at.isoformat() if r.checked_at else None,
"approver": r.approver,
"approve_comment": r.approve_comment,
"approved_at": r.approved_at.isoformat() if r.approved_at else None,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
def _gen_reimb_no(db: Session) -> str:
"""生成报销单号: BX + 年月日 + 序号"""
today = datetime.now()
prefix = "BX" + today.strftime("%Y%m%d")
last = (
db.query(ExpenseReimbursement)
.filter(ExpenseReimbursement.reimb_no.like(prefix + "%"))
.order_by(ExpenseReimbursement.id.desc())
.first()
)
seq = (int(last.reimb_no[-4:]) + 1) if last and last.reimb_no[-4:].isdigit() else 1
return f"{prefix}{seq:04d}"
# ============================================================
# 费用规则 CRUD
# ============================================================
@router.get("/rules")
def list_rules(
expense_type: str = Query(None),
status: str = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""查询费用规则列表"""
q = db.query(ExpenseRule)
if expense_type:
q = q.filter(ExpenseRule.expense_type == expense_type)
if status:
q = q.filter(ExpenseRule.status == status)
rules = q.order_by(ExpenseRule.id.asc()).all()
return {"data": [_rule_to_dict(r) for r in rules], "total": len(rules)}
@router.post("/rules")
def create_rule(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
):
"""新建费用规则"""
rule_name = data.get("rule_name")
expense_type = data.get("expense_type")
limit_type = data.get("limit_type", "single")
limit_amount = data.get("limit_amount")
if not rule_name or not expense_type or limit_amount is None:
raise HTTPException(400, "缺少必要参数: rule_name, expense_type, limit_amount")
if expense_type not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {expense_type}")
if limit_type not in ("single", "monthly", "yearly"):
raise HTTPException(400, f"无效限额类型: {limit_type}")
rule = ExpenseRule(
rule_name=rule_name,
dimension=data.get("dimension", "expense_type"),
dimension_value=data.get("dimension_value", "") or None,
expense_type=expense_type,
limit_type=limit_type,
limit_amount=float(limit_amount),
cycle=data.get("cycle", limit_type),
status=data.get("status", "active"),
remark=data.get("remark", ""),
)
db.add(rule)
db.commit()
db.refresh(rule)
return {"message": "规则已创建", "id": rule.id}
@router.put("/rules/{rule_id}")
def update_rule(
rule_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新费用规则"""
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
if not rule:
raise HTTPException(404, "规则不存在")
if "rule_name" in data and data["rule_name"]:
rule.rule_name = data["rule_name"]
if "expense_type" in data:
if data["expense_type"] not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {data['expense_type']}")
rule.expense_type = data["expense_type"]
if "dimension" in data:
rule.dimension = data["dimension"]
if "dimension_value" in data:
rule.dimension_value = data["dimension_value"] or None
if "limit_type" in data:
rule.limit_type = data["limit_type"]
if "limit_amount" in data and data["limit_amount"] is not None:
rule.limit_amount = float(data["limit_amount"])
if "status" in data:
rule.status = data["status"]
if "remark" in data:
rule.remark = data["remark"]
db.commit()
db.refresh(rule)
return {"message": "规则已更新", "id": rule.id}
@router.delete("/rules/{rule_id}")
def delete_rule(
rule_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除费用规则"""
rule = db.query(ExpenseRule).filter(ExpenseRule.id == rule_id).first()
if not rule:
raise HTTPException(404, "规则不存在")
db.delete(rule)
db.commit()
return {"message": "规则已删除", "id": rule_id}
@router.post("/rules/seed")
def seed_rules(
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""预置默认费用规则(幂等,按规则名去重)"""
created = 0
for p in PRESET_RULES:
exists = db.query(ExpenseRule).filter(ExpenseRule.rule_name == p["rule_name"]).first()
if exists:
continue
db.add(ExpenseRule(**p))
created += 1
db.commit()
return {"message": f"预置完成,新增 {created} 条规则"}
# ============================================================
# 报销单自动校验
# ============================================================
def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list = None):
"""按启用规则校验报销单,写入 check_result / check_detail / check_reason
规则匹配:
- dimension=expense_type: 全局费用类型规则(dimension_value 可再限定具体类型)
- dimension=department: 部门维度规则(dimension_value 空=所有部门)
- dimension=person: 人员维度规则(dimension_value 空=所有人)
返回 (passed: bool, checks: list)
"""
if rules is None:
rules = db.query(ExpenseRule).filter(ExpenseRule.status == "active").all()
period = (reimb.expense_date or datetime.now()).strftime("%Y-%m")
checks = []
failed_reasons = []
for rule in rules:
# 费用类型必须匹配
if rule.expense_type and rule.expense_type != reimb.expense_type:
continue
# 维度匹配
if rule.dimension == "department":
if not reimb.department:
continue
if rule.dimension_value and rule.dimension_value != reimb.department:
continue
elif rule.dimension == "person":
if rule.dimension_value and rule.dimension_value != reimb.applicant:
continue
elif rule.dimension == "expense_type":
if rule.dimension_value and rule.dimension_value != reimb.expense_type:
continue
else:
continue
actual = reimb.amount
limit_desc = f"{rule.limit_amount:,.0f}元"
if rule.limit_type == "single":
passed = reimb.amount <= rule.limit_amount
checks.append({
"rule_id": rule.id,
"rule_name": rule.rule_name,
"rule_type": "单笔限额",
"limit": rule.limit_amount,
"actual": reimb.amount,
"passed": passed,
"detail": f"单笔 {reimb.amount:,.2f}元 vs 限额 {limit_desc}",
})
else:
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
# 账套隔离 2026-09-21:累计口径限本账套(他账套在途不得污染本账套额度判定)
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
ExpenseReimbursement.entity_id == reimb.entity_id,
ExpenseReimbursement.expense_type == reimb.expense_type,
ExpenseReimbursement.id != reimb.id,
ExpenseReimbursement.status.in_(["pending", "approved"]),
)
if rule.limit_type == "monthly":
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period)
period_label = f"{period}月"
else: # yearly
year = period[:4]
q = q.filter(func.date_format(ExpenseReimbursement.expense_date, "%Y") == year)
period_label = f"{year}年"
if rule.dimension == "department" and reimb.department:
q = q.filter(ExpenseReimbursement.department == reimb.department)
elif rule.dimension == "person":
q = q.filter(ExpenseReimbursement.applicant == reimb.applicant)
used = q.scalar() or 0.0
actual = used + reimb.amount
passed = actual <= rule.limit_amount
checks.append({
"rule_id": rule.id,
"rule_name": rule.rule_name,
"rule_type": "月度累计" if rule.limit_type == "monthly" else "年度累计",
"limit": rule.limit_amount,
"actual": actual,
"used": used,
"passed": passed,
"detail": f"{period_label}累计 {used:,.2f} + 本次 {reimb.amount:,.2f} = {actual:,.2f}元 vs 限额 {limit_desc}",
})
if not passed:
failed_reasons.append(f"{rule.rule_name}: {checks[-1]['detail']},超限")
passed_all = len(failed_reasons) == 0
reimb.check_result = "pass" if passed_all else "fail"
reimb.check_detail = checks
reimb.check_reason = ";".join(failed_reasons) if failed_reasons else None
reimb.checked_at = datetime.now()
# 超限自动打回,合规进入待人工审批
reimb.status = "pending" if passed_all else "returned"
return passed_all, checks
# ============================================================
# 报销单提交 / 查询 / 审批
# ============================================================
@router.post("/reimbursements")
def submit_reimbursement(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""提交报销单 → 自动校验规则(超限自动打回)"""
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
expense_type = data.get("expense_type")
title = data.get("title")
amount = data.get("amount")
if not expense_type or not title or amount is None:
raise HTTPException(400, "缺少必要参数: expense_type, title, amount")
if expense_type not in EXPENSE_TYPE_LABELS:
raise HTTPException(400, f"无效费用类型: {expense_type}")
amount = float(amount)
if amount <= 0:
raise HTTPException(400, "报销金额必须大于0")
expense_date = None
if data.get("expense_date"):
try:
expense_date = datetime.strptime(str(data["expense_date"])[:10], "%Y-%m-%d")
except Exception:
expense_date = None
reimb = ExpenseReimbursement(
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
applicant=applicant,
department=data.get("department", ""),
expense_type=expense_type,
title=title,
amount=amount,
expense_date=expense_date,
attachment=data.get("attachment", ""),
status="pending",
check_result="pass",
created_by=current_user.username if hasattr(current_user, "username") else applicant,
)
db.add(reimb)
db.flush() # 先拿到 id 再生成正式单号
reimb.reimb_no = _gen_reimb_no(db)
db.flush()
passed, checks = validate_reimbursement(db, reimb)
db.commit()
db.refresh(reimb)
result = _reimb_to_dict(reimb)
result["auto_check_passed"] = passed
return {"message": "报销单已提交" if passed else "报销单超限,已自动打回", "data": result}
@router.get("/reimbursements")
def list_reimbursements(
status: str = Query(None),
expense_type: str = Query(None),
applicant: str = Query(None),
keyword: str = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)(账套隔离 2026-09-21:列表按会话账套)"""
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.entity_id == entity_id)
if status:
q = q.filter(ExpenseReimbursement.status == status)
if expense_type:
q = q.filter(ExpenseReimbursement.expense_type == expense_type)
if applicant:
q = q.filter(ExpenseReimbursement.applicant.like(f"%{applicant}%"))
if keyword:
like = f"%{keyword}%"
q = q.filter(
(ExpenseReimbursement.title.like(like))
| (ExpenseReimbursement.reimb_no.like(like))
| (ExpenseReimbursement.applicant.like(like))
)
items = q.order_by(ExpenseReimbursement.id.desc()).limit(200).all()
return {"data": [_reimb_to_dict(r) for r in items], "total": len(items)}
@router.get("/reimbursements/{reimb_id}")
def get_reimbursement(
reimb_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""报销单详情(账套隔离 2026-09-21:跨账套 → 404)"""
r = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == reimb_id,
ExpenseReimbursement.entity_id == entity_id,
).first()
if not r:
raise HTTPException(404, "报销单不存在")
return _reimb_to_dict(r)
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str,
entity_id: int = None):
"""执行审批动作: approve/reject/return(账套隔离 2026-09-21:跨账套 → 404)"""
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id)
if entity_id is not None:
q = q.filter(ExpenseReimbursement.entity_id == entity_id)
r = q.first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("pending", "returned"):
raise HTTPException(400, f"当前状态({r.status})不可审批")
if action == "approve":
r.status = "approved"
elif action == "reject":
r.status = "rejected"
else:
r.status = "returned"
r.approver = approver
r.approve_comment = comment or ("" if action == "approve" else "人工打回")
r.approved_at = datetime.now()
db.commit()
db.refresh(r)
return r
@router.post("/reimbursements/{reimb_id}/approve")
def approve_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""审批通过(账套隔离 2026-09-21:跨账套 → 404)"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver, entity_id)
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/reject")
def reject_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""审批拒绝(账套隔离 2026-09-21:跨账套 → 404)"""
comment = (data or {}).get("comment", "")
if not comment:
raise HTTPException(400, "拒绝时必须填写审批意见")
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "reject", comment, approver, entity_id)
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/return")
def return_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""人工打回(账套隔离 2026-09-21:跨账套 → 404)"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver, entity_id)
return {"message": "已打回", "data": _reimb_to_dict(r)}
@router.post("/reimbursements/{reimb_id}/resubmit")
def resubmit_reimbursement(
reimb_id: int,
data: dict = None,
db: Session = Depends(get_db),
_=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""被驳回/打回后重新提交 → 重新自动校验(账套隔离 2026-09-21:跨账套 → 404)"""
r = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == reimb_id,
ExpenseReimbursement.entity_id == entity_id,
).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("returned", "rejected"):
raise HTTPException(400, f"当前状态({r.status})不可重新提交")
# 允许修改金额/事由后重新校验
if data:
if data.get("amount") is not None:
r.amount = float(data["amount"])
if data.get("title"):
r.title = data["title"]
if data.get("attachment") is not None:
r.attachment = data["attachment"]
passed, _ = validate_reimbursement(db, r)
r.approver = None
r.approve_comment = None
db.commit()
db.refresh(r)
return {"message": "已重新提交" if passed else "仍超限,已再次打回", "data": _reimb_to_dict(r)}
# ============================================================
# 审核看板统计
# ============================================================
@router.get("/stats")
def expense_stats(
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
now = datetime.now()
period = period or now.strftime("%Y-%m")
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).filter(
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
).group_by(
ExpenseReimbursement.status
).all():
if st in counts:
counts[st] = cnt
counts["total"] += cnt
# 本月(按费用发生日期)金额统计 by 费用类型 — 统计已提交(待审+已通过)
month_rows = (
db.query(
ExpenseReimbursement.expense_type,
func.coalesce(func.sum(ExpenseReimbursement.amount), 0),
func.count(ExpenseReimbursement.id),
)
.filter(
ExpenseReimbursement.status.in_(["pending", "approved"]),
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
)
.group_by(ExpenseReimbursement.expense_type)
.all()
)
amounts_by_type = [
{
"expense_type": et,
"label": EXPENSE_TYPE_LABELS.get(et, et),
"amount": round(float(amt), 2),
"count": cnt,
}
for et, amt, cnt in month_rows
]
monthly_total = round(sum(x["amount"] for x in amounts_by_type), 2)
# 超限预警列表 — 自动打回(returned + check_result=fail)
over_limit_rows = (
db.query(ExpenseReimbursement)
.filter(
ExpenseReimbursement.status == "returned",
ExpenseReimbursement.check_result == "fail",
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
)
.order_by(ExpenseReimbursement.id.desc())
.limit(50)
.all()
)
over_limit = [
{
"id": r.id,
"reimb_no": r.reimb_no,
"applicant": r.applicant,
"department": r.department,
"expense_type": r.expense_type,
"expense_type_label": EXPENSE_TYPE_LABELS.get(r.expense_type, r.expense_type),
"title": r.title,
"amount": r.amount,
"check_reason": r.check_reason,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in over_limit_rows
]
recent_rows = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
).order_by(ExpenseReimbursement.id.desc()).limit(10).all()
recent = [_reimb_to_dict(r) for r in recent_rows]
# 本月预算使用率(招待费15万/月 管理费91万/月)
budget_usage = []
for et, budget in (("entertainment", 150000.0), ("management", 910000.0)):
used = next((x["amount"] for x in amounts_by_type if x["expense_type"] == et), 0.0)
budget_usage.append({
"expense_type": et,
"label": EXPENSE_TYPE_LABELS.get(et, et),
"budget": budget,
"used": used,
"usage_rate": round(used / budget * 100, 1) if budget else 0,
})
return {
"period": period,
"counts": counts,
"amounts_by_type": amounts_by_type,
"monthly_total": monthly_total,
"over_limit": over_limit,
"over_limit_count": len(over_limit),
"recent": recent,
"budget_usage": budget_usage,
}
+579
View File
@@ -0,0 +1,579 @@
"""外部输入台账录入/查询 API(能力评价 M2 准入3 派生,2026-09-15)
三张台账的单向录入接口(新增 / CSV 批量导入 / 按实体与期次查询)+ 台账→kpi_values 派生触发。
设计口径:
- **单向**:只提供「新增 + 批量导入 + 查询 + 派生」,不提供修改/删除接口
(历史登记行不可改;错登由人工在业务侧作废后重新登记一行,保持审计链)
- **on_time 由服务端派生**:请求里带 on_time 一律忽略并在响应回报 on_time_ignored
- **人事台账等式校验**:期末 ≠ 期初 + 入职 − 离职 → 拒绝写入并回报(HTTP 400 / 批量逐行 errors)
- **多租户**:所有端点经 app.deps.assert_entity_scope 收敛账套(显式 entity_id 与会话账套不一致 → 403)
- **鉴权**:router 级 require_auth(未认证 401);写端点额外 require_role(ceo/finance/it)(角色不足 403)
- 派生必须**幂等**(同 (entity_id, kpi_id, period) 重复运行只更新不新增),分母为 0 → 不写值
CSV 列头(UTF-8,可带 BOM;列序无关,多余列忽略):
申报台账 period,tax_type,legal_deadline,filed_date,evidence_no,registered_by,remark
人事台账 period,headcount_begin,hires,separations,headcount_end,registered_by,remark
证照台账 licence_name,licence_no,valid_from,valid_to,annual_check_status,issuer,owner_role,attachment_no,registered_by,remark
"""
from __future__ import annotations
import csv
import io
import logging
import re
from datetime import date, datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth, require_role
from app.database import get_db
from app.deps import assert_entity_scope
from app.models import HrMonthlyLedger, KPIDefinition, LicenceLedger, TaxFilingLog
from app.models.ext_input_ledger import ANNUAL_CHECK_STATUSES, MONTH_PERIOD_RE, QUARTER_PERIOD_RE
from app.services.ext_input_derive import (
ALL_KPI_CODES,
LICENCE_OK_CODE,
check_headcount_equation,
compute_employee_turnover,
derive_on_time,
explain_licence_ok,
explain_tax_timely_rate,
is_licence_valid,
month_to_quarter,
quarter_end_date,
run_derive,
)
logger = logging.getLogger("cma.ext_input_api")
router = APIRouter(
prefix="/api/cma/ext-ledger",
tags=["外部输入台账"],
dependencies=[Depends(require_auth)],
)
# 写权限(录入/导入/派生):ceo / finance / it(与 value_sources 等既有写端点同口径)
write_guard = Depends(require_role("ceo", "finance", "it"))
SUGGESTED_TAX_TYPES = ("增值税", "附加税", "企业所得税", "印花税", "个人所得税", "房产税", "土地使用税")
# CSV 全量列(文档口径)+ 必需列(列缺失才拒绝;可选列缺省即留空)
TAX_HEADERS = ("period", "tax_type", "legal_deadline", "filed_date", "evidence_no", "registered_by", "remark")
HR_HEADERS = ("period", "headcount_begin", "hires", "separations", "headcount_end", "registered_by", "remark")
LICENCE_HEADERS = ("licence_name", "licence_no", "valid_from", "valid_to", "annual_check_status",
"issuer", "owner_role", "attachment_no", "registered_by", "remark")
TAX_REQUIRED = ("period", "tax_type")
HR_REQUIRED = ("period", "headcount_begin", "hires", "separations", "headcount_end")
LICENCE_REQUIRED = ("licence_name", "licence_no", "annual_check_status")
# ============================================================
# 公共工具:账套收敛 / 字段校验
# ============================================================
def _resolve_entity(request: Request, db: Session, explicit: Optional[int]) -> int:
"""账套收敛:会话 token 绑定实体优先;显式 entity_id 与会话不一致 → 403(文案同 deps.get_entity_id)"""
session_entity = assert_entity_scope(request, explicit, db)
if explicit is not None:
return explicit
if session_entity is not None:
return session_entity
return 1
def _body_entity(payload: dict, query_entity: Optional[int]) -> Optional[int]:
raw = payload.get("entity_id") if isinstance(payload, dict) else None
if raw in (None, ""):
return query_entity
try:
return int(raw)
except (TypeError, ValueError):
raise HTTPException(400, f"entity_id 非法: {raw}")
def _req_str(value, name: str, max_len: int) -> str:
text = "" if value is None else str(value).strip()
if not text:
raise HTTPException(400, f"缺少必填字段: {name}")
if len(text) > max_len:
raise HTTPException(400, f"字段 {name} 超长({len(text)} > {max_len})")
return text
def _opt_str(value, max_len: int) -> Optional[str]:
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text[:max_len]
def _parse_date(value, name: str) -> Optional[date]:
if value in (None, ""):
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip().replace("/", "-")
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y%m%d"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
raise HTTPException(400, f"字段 {name} 日期格式非法: {value}(应为 YYYY-MM-DD)")
def _parse_int(value, name: str, required: bool = True) -> Optional[int]:
if value in (None, ""):
if required:
raise HTTPException(400, f"缺少必填字段: {name}")
return None
try:
return int(str(value).strip())
except (TypeError, ValueError):
raise HTTPException(400, f"字段 {name} 必须为整数: {value}")
def _month_period(value, name: str = "period") -> str:
text = _req_str(value, name, 20)
if not re.match(MONTH_PERIOD_RE, text):
raise HTTPException(400, f"字段 {name} 期次格式非法: {text}(应为 YYYY-MM)")
return text
def _target_period(kpi_code: str, period: str) -> str:
"""指标实际落库期间:P_LICENCE_OK 为季频(传月期间时归到所在季度)"""
if kpi_code == LICENCE_OK_CODE and re.match(MONTH_PERIOD_RE, period):
return month_to_quarter(period)
return period
# ============================================================
# 字段选项(前端下拉动态加载用;禁前端硬编码)
# ============================================================
@router.get("/options")
def get_options():
"""录入表单选项:年检状态枚举 + 建议税种 + 期次格式"""
return {
"annual_check_status": list(ANNUAL_CHECK_STATUSES),
"tax_type_suggested": list(SUGGESTED_TAX_TYPES),
"period_format": {"monthly": "YYYY-MM 例:2026-08", "quarterly": "YYYY-Qn 例:2026-Q2"},
"derivable_kpi_codes": list(ALL_KPI_CODES),
}
# ============================================================
# 1. 申报行为台账 tax_filing_log
# ============================================================
def _tax_values(payload: dict) -> dict:
period = _month_period(payload.get("period"))
tax_type = _req_str(payload.get("tax_type"), "tax_type", 50)
legal_deadline = _parse_date(payload.get("legal_deadline"), "legal_deadline")
filed_date = _parse_date(payload.get("filed_date"), "filed_date")
return {
"period": period,
"tax_type": tax_type,
"legal_deadline": legal_deadline,
"filed_date": filed_date,
# on_time 唯一来源 = 服务端派生(请求里的同名字段一律忽略)
"on_time": derive_on_time(filed_date, legal_deadline),
"evidence_no": _opt_str(payload.get("evidence_no"), 100),
"registered_by": _opt_str(payload.get("registered_by"), 100),
"remark": _opt_str(payload.get("remark"), 500),
}
def _tax_json(row: TaxFilingLog) -> dict:
return {
"id": row.id,
"entity_id": row.entity_id,
"period": row.period,
"tax_type": row.tax_type,
"legal_deadline": row.legal_deadline.isoformat() if row.legal_deadline else None,
"filed_date": row.filed_date.isoformat() if row.filed_date else None,
"on_time": row.on_time,
"evidence_no": row.evidence_no,
"registered_by": row.registered_by,
"remark": row.remark,
}
@router.get("/tax-filings")
def list_tax_filings(
request: Request,
period: Optional[str] = Query(None, description="属期 YYYY-MM"),
tax_type: Optional[str] = Query(None),
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""按 (entity_id, period) 查询申报台账 + 及时率口径摘要"""
eid = _resolve_entity(request, db, entity_id)
q = db.query(TaxFilingLog).filter(TaxFilingLog.entity_id == eid)
if period:
q = q.filter(TaxFilingLog.period == period)
if tax_type:
q = q.filter(TaxFilingLog.tax_type == tax_type)
rows = q.order_by(TaxFilingLog.period.desc(), TaxFilingLog.id.asc()).all()
summary = explain_tax_timely_rate([r for r in rows if not period or r.period == period])
return {"entity_id": eid, "data": [_tax_json(r) for r in rows], "total": len(rows),
"summary": {"period": period, "tax_timely_rate": summary["value"],
"denominator": summary["denominator"], "timely": summary["timely"],
"no_definition": summary["no_definition"]}}
@router.post("/tax-filings", status_code=201, dependencies=[write_guard])
def create_tax_filing(
payload: dict,
request: Request,
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""新增一条申报行为台账(on_time 服务端派生;唯一键冲突 → 409)"""
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
vals = _tax_values(payload)
exists = db.query(TaxFilingLog).filter(
TaxFilingLog.entity_id == eid, TaxFilingLog.period == vals["period"],
TaxFilingLog.tax_type == vals["tax_type"]).first()
if exists:
raise HTTPException(409, f"已存在同 (entity_id={eid}, period={vals['period']}, "
f"tax_type={vals['tax_type']}) 的登记行 id={exists.id}")
row = TaxFilingLog(entity_id=eid, **vals)
db.add(row)
db.commit()
db.refresh(row)
return {"data": _tax_json(row),
"on_time_derived": row.on_time,
"on_time_ignored": payload.get("on_time") not in (None, "")}
@router.post("/tax-filings/import", dependencies=[write_guard])
def import_tax_filings(
payload: dict,
request: Request,
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""CSV 批量导入申报台账。on_conflict: skip(默认)/update;dry_run=true 只校验不落库"""
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
on_conflict = str(payload.get("on_conflict") or "skip").lower()
if on_conflict not in ("skip", "update"):
raise HTTPException(400, f"on_conflict 非法: {on_conflict}(skip/update)")
dry_run = bool(payload.get("dry_run"))
records, errors = _read_csv(payload.get("csv"), TAX_REQUIRED, _tax_values)
result = _apply_import(db, TaxFilingLog, eid, records, errors, on_conflict, dry_run,
key_fields=("period", "tax_type"))
return result
# ============================================================
# 2. 人事月度台账 hr_monthly_ledger
# ============================================================
def _hr_values(payload: dict) -> dict:
period = _month_period(payload.get("period"))
begin = _parse_int(payload.get("headcount_begin"), "headcount_begin")
hires = _parse_int(payload.get("hires"), "hires")
separations = _parse_int(payload.get("separations"), "separations")
end = _parse_int(payload.get("headcount_end"), "headcount_end")
err = check_headcount_equation(begin, hires, separations, end)
if err:
raise HTTPException(400, err)
return {
"period": period,
"headcount_begin": begin,
"hires": hires,
"separations": separations,
"headcount_end": end,
"registered_by": _opt_str(payload.get("registered_by"), 100),
"remark": _opt_str(payload.get("remark"), 500),
}
def _hr_json(row: HrMonthlyLedger) -> dict:
return {
"id": row.id, "entity_id": row.entity_id, "period": row.period,
"headcount_begin": row.headcount_begin, "hires": row.hires,
"separations": row.separations, "headcount_end": row.headcount_end,
"turnover_rate": compute_employee_turnover(row),
"registered_by": row.registered_by, "remark": row.remark,
}
@router.get("/hr-monthly")
def list_hr_monthly(
request: Request,
period: Optional[str] = Query(None, description="期间 YYYY-MM"),
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""按 (entity_id, period) 查询人事月度台账(含流失率派生值)"""
eid = _resolve_entity(request, db, entity_id)
q = db.query(HrMonthlyLedger).filter(HrMonthlyLedger.entity_id == eid)
if period:
q = q.filter(HrMonthlyLedger.period == period)
rows = q.order_by(HrMonthlyLedger.period.desc(), HrMonthlyLedger.id.asc()).all()
return {"entity_id": eid, "data": [_hr_json(r) for r in rows], "total": len(rows)}
@router.post("/hr-monthly", status_code=201, dependencies=[write_guard])
def create_hr_monthly(
payload: dict,
request: Request,
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""新增人事月度台账(等式校验不通过 → 400 拒绝写入)"""
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
vals = _hr_values(payload)
exists = db.query(HrMonthlyLedger).filter(
HrMonthlyLedger.entity_id == eid, HrMonthlyLedger.period == vals["period"]).first()
if exists:
raise HTTPException(409, f"已存在同 (entity_id={eid}, period={vals['period']}) 的登记行 id={exists.id}")
row = HrMonthlyLedger(entity_id=eid, **vals)
db.add(row)
db.commit()
db.refresh(row)
return {"data": _hr_json(row)}
@router.post("/hr-monthly/import", dependencies=[write_guard])
def import_hr_monthly(
payload: dict,
request: Request,
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""CSV 批量导入人事月度台账(等式不成立的行被拒绝并在 errors 回报)"""
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
on_conflict = str(payload.get("on_conflict") or "skip").lower()
if on_conflict not in ("skip", "update"):
raise HTTPException(400, f"on_conflict 非法: {on_conflict}(skip/update)")
dry_run = bool(payload.get("dry_run"))
records, errors = _read_csv(payload.get("csv"), HR_REQUIRED, _hr_values)
return _apply_import(db, HrMonthlyLedger, eid, records, errors, on_conflict, dry_run,
key_fields=("period",))
# ============================================================
# 3. 证照台账 licence_ledger
# ============================================================
def _licence_values(payload: dict) -> dict:
name = _req_str(payload.get("licence_name"), "licence_name", 200)
no = _req_str(payload.get("licence_no"), "licence_no", 100)
status = _req_str(payload.get("annual_check_status"), "annual_check_status", 20)
if status not in ANNUAL_CHECK_STATUSES:
raise HTTPException(400, f"字段 annual_check_status 非法: {status}(合法值: {'/'.join(ANNUAL_CHECK_STATUSES)})")
return {
"licence_name": name,
"licence_no": no,
"valid_from": _parse_date(payload.get("valid_from"), "valid_from"),
"valid_to": _parse_date(payload.get("valid_to"), "valid_to"),
"annual_check_status": status,
"issuer": _opt_str(payload.get("issuer"), 200),
"owner_role": _opt_str(payload.get("owner_role"), 100),
"attachment_no": _opt_str(payload.get("attachment_no"), 100),
"registered_by": _opt_str(payload.get("registered_by"), 100),
"remark": _opt_str(payload.get("remark"), 500),
}
def _licence_json(row: LicenceLedger, as_of: Optional[date] = None) -> dict:
return {
"id": row.id, "entity_id": row.entity_id, "licence_name": row.licence_name,
"licence_no": row.licence_no,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
"annual_check_status": row.annual_check_status, "issuer": row.issuer,
"owner_role": row.owner_role, "attachment_no": row.attachment_no,
"valid_as_of": is_licence_valid(row, as_of) if as_of else None,
"registered_by": row.registered_by, "remark": row.remark,
}
@router.get("/licences")
def list_licences(
request: Request,
as_of: Optional[str] = Query(None, description="有效性基准日 YYYY-MM-DD(默认今天)"),
licence_name: Optional[str] = Query(None),
valid_only: bool = Query(False, description="仅返回基准日有效的证照"),
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""证照台账查询(有效/过期按基准日判定)"""
eid = _resolve_entity(request, db, entity_id)
base = _parse_date(as_of, "as_of") or date.today()
q = db.query(LicenceLedger).filter(LicenceLedger.entity_id == eid)
if licence_name:
q = q.filter(LicenceLedger.licence_name == licence_name)
rows = q.order_by(LicenceLedger.id.asc()).all()
# 合规率分母 = 应持证照数(不受 valid_only 过滤影响)
summary = explain_licence_ok(rows, base)
shown = [r for r in rows if is_licence_valid(r, base)] if valid_only else rows
return {"entity_id": eid, "as_of": base.isoformat(),
"data": [_licence_json(r, base) for r in shown], "total": len(shown),
"summary": {"licence_ok": summary["value"], "denominator": summary["denominator"],
"valid": summary["valid"], "no_definition": summary["no_definition"]}}
@router.post("/licences", status_code=201, dependencies=[write_guard])
def create_licence(
payload: dict,
request: Request,
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""新增证照台账(状态白名单校验;唯一键冲突 → 409)"""
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
vals = _licence_values(payload)
exists = db.query(LicenceLedger).filter(
LicenceLedger.entity_id == eid, LicenceLedger.licence_name == vals["licence_name"],
LicenceLedger.licence_no == vals["licence_no"]).first()
if exists:
raise HTTPException(409, f"已存在同 (entity_id={eid}, licence_name={vals['licence_name']}, "
f"licence_no={vals['licence_no']}) 的登记行 id={exists.id}")
row = LicenceLedger(entity_id=eid, **vals)
db.add(row)
db.commit()
db.refresh(row)
return {"data": _licence_json(row, date.today())}
@router.post("/licences/import", dependencies=[write_guard])
def import_licences(
payload: dict,
request: Request,
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""CSV 批量导入证照台账"""
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
on_conflict = str(payload.get("on_conflict") or "skip").lower()
if on_conflict not in ("skip", "update"):
raise HTTPException(400, f"on_conflict 非法: {on_conflict}(skip/update)")
dry_run = bool(payload.get("dry_run"))
records, errors = _read_csv(payload.get("csv"), LICENCE_REQUIRED, _licence_values)
return _apply_import(db, LicenceLedger, eid, records, errors, on_conflict, dry_run,
key_fields=("licence_name", "licence_no"))
# ============================================================
# 4. 台账 → kpi_values 派生
# ============================================================
@router.post("/derive", dependencies=[write_guard])
def derive_kpis(
request: Request,
payload: dict,
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""触发台账→kpi_values 派生(幂等)。period: 月频 YYYY-MM;P_LICENCE_OK 接受 YYYY-Qn。
dry_run=true 只算不写。分母为 0 的指标 → 不写值,回报 skipped_denominator_zero。"""
eid = _resolve_entity(request, db, _body_entity(payload, entity_id))
period = str(payload.get("period") or "").strip()
if not period:
raise HTTPException(400, "缺少必填字段: period")
if not (re.match(MONTH_PERIOD_RE, period) or re.match(QUARTER_PERIOD_RE, period)):
raise HTTPException(400, f"period 非法: {period}(应为 YYYY-MM 或 YYYY-Qn)")
kpi_codes = payload.get("kpi_codes") or payload.get("kpis") or None
if kpi_codes is not None and not isinstance(kpi_codes, list):
raise HTTPException(400, "kpi_codes 必须为数组")
try:
result = run_derive(db, eid, period, kpis=kpi_codes, write=not bool(payload.get("dry_run")))
except ValueError as e:
raise HTTPException(400, str(e))
return result
@router.get("/derive/status")
def derive_status(
request: Request,
period: str = Query(..., description="月期间 YYYY-MM 或季期间 YYYY-Qn"),
entity_id: Optional[int] = Query(None, ge=1),
db: Session = Depends(get_db),
):
"""只读:查看该期派生指标的定义/现有 kpi_values 行(不写入)"""
eid = _resolve_entity(request, db, entity_id)
if not (re.match(MONTH_PERIOD_RE, period) or re.match(QUARTER_PERIOD_RE, period)):
raise HTTPException(400, f"period 非法: {period}(应为 YYYY-MM 或 YYYY-Qn)")
out = {}
from app.models import KPIValue
for code in ALL_KPI_CODES:
kpi = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == eid, KPIDefinition.kpi_code == code,
KPIDefinition.status == "active")
.order_by(KPIDefinition.id.asc()).first())
item = {"kpi_defined": kpi is not None, "kpi_id": kpi.id if kpi else None,
"target_period": _target_period(code, period)}
if kpi is not None:
row = (db.query(KPIValue)
.filter(KPIValue.kpi_id == kpi.id, KPIValue.source_type == "derived",
KPIValue.period == item["target_period"])
.order_by(KPIValue.id.desc()).first())
item["latest_derived"] = ({"period": row.period, "actual_value": row.actual_value,
"data_status": row.data_status} if row else None)
out[code] = item
return {"entity_id": eid, "period": period, "items": out}
# ============================================================
# CSV 解析 / 批量落库(三张表共用;校验规则与单条新增完全同源)
# ============================================================
def _read_csv(csv_text, required_headers, builder):
"""解析 CSV:返回 (records, errors)。required_headers 为必需列(缺列即 400);其余列可选。"""
if not csv_text or not str(csv_text).strip():
raise HTTPException(400, "缺少必填字段: csv")
text = str(csv_text).lstrip("\ufeff")
reader = csv.DictReader(io.StringIO(text))
fieldnames = [(f or "").strip().lower() for f in (reader.fieldnames or [])]
if not fieldnames:
raise HTTPException(400, "CSV 缺少表头行")
missing = [h for h in required_headers if h not in fieldnames]
if missing:
raise HTTPException(400, f"CSV 缺少必需列: {missing}(必需列: {list(required_headers)})")
records, errors = [], []
for offset, raw in enumerate(reader, start=2): # 表头占第 1 行
row = {(k or "").strip().lower(): v for k, v in raw.items() if k}
if not any(str(v or "").strip() for v in row.values()):
continue # 跳过空行
try:
records.append({"line": offset, "values": builder(row)})
except HTTPException as e:
errors.append({"line": offset, "reason": e.detail})
return records, errors
def _apply_import(db: Session, model, entity_id: int, records, errors, on_conflict: str,
dry_run: bool, key_fields) -> dict:
"""逐行 upsert;返回 {inserted, updated, skipped, errors, dry_run}"""
inserted = updated = skipped = 0
for rec in records:
vals = rec["values"]
filters = [getattr(model, f) == vals[f] for f in key_fields] + [model.entity_id == entity_id]
existing = db.query(model).filter(*filters).first()
if existing is not None:
if on_conflict == "update":
for k, v in vals.items():
setattr(existing, k, v)
updated += 1
else:
skipped += 1
else:
db.add(model(entity_id=entity_id, **vals))
inserted += 1
if dry_run:
db.rollback()
else:
db.commit()
return {"entity_id": entity_id, "dry_run": dry_run, "on_conflict": on_conflict,
"inserted": inserted, "updated": updated, "skipped": skipped,
"errors": errors, "error_count": len(errors)}
+550
View File
@@ -0,0 +1,550 @@
"""增长质量诊断 API — 五维评分 + 诊断结论 + 跨期对比
数据来源:KPI字典 (kpi_definitions) + KPI实际值 (kpi_values)
五维度:营收增长 / 利润质量 / 现金质量 / 增长效率 / 组织健康
评分区间:0-100(>=80 好 / 60-79 中 / <60 差)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from datetime import datetime
import re
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, Entity
router = APIRouter(prefix="/api/cma/growth-quality", tags=["增长质量诊断"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# 五维度定义
DIMENSIONS = [
{"key": "revenueGrowth", "name": "营收增长", "weight": 20, "icon": "📈",
"desc": "营收增速、增长持续性"},
{"key": "profitQuality", "name": "利润质量", "weight": 20, "icon": "💰",
"desc": "毛利率/净利率趋势、利润与收入匹配"},
{"key": "cashQuality", "name": "现金质量", "weight": 20, "icon": "🏦",
"desc": "经营现金流与净利润匹配度(含金量)"},
{"key": "growthEfficiency", "name": "增长效率", "weight": 20, "icon": "🚀",
"desc": "获客成本、单位增长投入产出"},
{"key": "orgHealth", "name": "组织健康", "weight": 20, "icon": "⚡",
"desc": "人效、费用结构"},
]
# 关键KPI编码 → 维度用途
KPI_CODES = {
"F_REVENUE": "营业收入(万元)",
"F_NET_PROFIT": "净利润(万元)",
"F_OP_CFLOW": "经营性现金流(万元)",
"F_GROSS_MARGIN": "毛利率(%)",
"F_OP_PROFIT_MARGIN": "经营利润率(%)",
"F_COST_RATIO": "费用率(%)",
"F_REVENUE_GROWTH": "收入增长率(%)",
"F_FCF": "自由现金流(万元)",
"C_NEW_CLIENTS": "新客户数",
"C_REBATE_RATE": "渠补率(%)",
"C_SATISFACTION": "客户满意度",
"L_TRAINING": "培训完成率",
"P_DELIVERY": "交付及时率",
"F_AR_DAYS": "应收账款周转天数",
}
MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
def _fetch_kpi_values(db: Session, entity_id: int, period: str) -> dict:
"""拉取某实体某期间的全部KPI值 {kpi_code: actual_value}"""
rows = (db.query(KPIDefinition.kpi_code, KPIValue.actual_value)
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
.filter(KPIDefinition.entity_id == entity_id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None))
.all())
return {code: value for code, value in rows}
def _fetch_history(db: Session, entity_id: int, limit: int = 12) -> List[dict]:
"""拉取最近 N 个期间(按月,含数据)的 KPI 值,供趋势/持续性分析"""
periods = (db.query(KPIValue.period)
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
.filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(["F_REVENUE", "F_NET_PROFIT", "F_OP_CFLOW"]))
.distinct().all())
plist = sorted({p[0] for p in periods}, reverse=True)
# 只保留 YYYY-MM 格式,按时间排序(旧→新)
months = sorted([p for p in plist if MONTH_RE.match(p)])
hist = []
for p in months[-limit:]:
hist.append({"period": p, **{k: None for k in KPI_CODES}})
if not hist:
return []
# 批量取数
rows = (db.query(KPIDefinition.kpi_code, KPIValue.period, KPIValue.actual_value)
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
.filter(KPIDefinition.entity_id == entity_id,
KPIValue.period.in_([h["period"] for h in hist]))
.all())
idx = {h["period"]: h for h in hist}
for code, period, val in rows:
if period in idx and code in idx[period]:
idx[period][code] = val
return hist
def _prev_period(period: str) -> Optional[str]:
"""计算上期(YYYY-MM → 上一月;其他格式 → None)"""
m = MONTH_RE.match(period)
if not m:
return None
y, mo = int(period[:4]), int(period[5:7])
if mo == 1:
return f"{y-1:04d}-12"
return f"{y:04d}-{mo-1:02d}"
def _yoy_period(period: str) -> Optional[str]:
"""计算去年同期(YYYY-MM → 去年同月)"""
m = MONTH_RE.match(period)
if not m:
return None
return f"{int(period[:4])-1:04d}-{period[5:7]}"
# ═══════════════════════════════════════════════
# 五维度评分引擎(0-100)
# ═══════════════════════════════════════════════
def _clamp(v: float, lo: float = 0.0, hi: float = 100.0) -> float:
return max(lo, min(hi, v))
def _linear(value, points: List[tuple]):
"""分段线性插值评分: points = [(x, score), ...] 按 x 升序"""
if value is None:
return 50.0
if value <= points[0][0]:
return points[0][1]
if value >= points[-1][0]:
return points[-1][1]
for (x1, s1), (x2, s2) in zip(points, points[1:]):
if x1 <= value <= x2:
if x2 == x1:
return s1
return s1 + (s2 - s1) * (value - x1) / (x2 - x1)
return 50.0
# ── 1. 营收增长:增速 + 持续性 ──
def _score_revenue_growth(cur: dict, prev: dict, history: List[dict]) -> float:
rev_cur = cur.get("F_REVENUE")
rev_prev = prev.get("F_REVENUE") if prev else None
# 优先用 KPI 直接给的收入增长率
kpi_growth = cur.get("F_REVENUE_GROWTH")
growth = None
if kpi_growth is not None:
growth = float(kpi_growth)
elif rev_cur is not None and rev_prev:
growth = (rev_cur - rev_prev) / rev_prev * 100 if rev_prev else None
score = _linear(growth, [
(-30, 5), (-20, 15), (-10, 30), (0, 45), (5, 60), (10, 70),
(20, 82), (30, 90), (50, 96),
])
# 增长持续性:近6个月中收入增长月占比
if len(history) >= 2:
revs = [h.get("F_REVENUE") for h in history if h.get("F_REVENUE") is not None]
ups = 0
for i in range(1, len(revs)):
if revs[i] > revs[i - 1]:
ups += 1
persist = ups / (len(revs) - 1) if len(revs) > 1 else 0.5
score = score * 0.7 + persist * 100 * 0.3
return round(_clamp(score), 1)
# ── 2. 利润质量:毛利率/净利率水平 + 趋势 + 收入匹配 ──
def _score_profit_quality(cur: dict, prev: dict) -> float:
gm = cur.get("F_GROSS_MARGIN")
np_ = cur.get("F_NET_PROFIT")
rev = cur.get("F_REVENUE")
net_margin = (np_ / rev * 100) if (np_ is not None and rev) else None
opm = cur.get("F_OP_PROFIT_MARGIN")
gm_score = _linear(gm, [(-10, 5), (0, 10), (10, 25), (20, 45), (30, 62),
(40, 75), (55, 88), (70, 95)])
nm_score = _linear(net_margin, [(-50, 0), (-20, 10), (-10, 20), (0, 35),
(10, 60), (20, 78), (30, 90)])
opm_score = _linear(opm, [(-20, 10), (0, 30), (10, 55), (20, 75), (35, 90)])
# 毛利率 40% + 净利率 40% + 经营利润率 20%
base = gm_score * 0.4 + nm_score * 0.4 + opm_score * 0.2
# 利润与收入匹配:收入升但利润降 → 扣分
if prev and rev is not None and np_ is not None:
prev_rev = prev.get("F_REVENUE")
prev_np = prev.get("F_NET_PROFIT")
if prev_rev and prev_np is not None:
rev_up = rev > prev_rev
np_down = np_ < prev_np
if rev_up and np_down:
base -= 10
elif np_down:
base -= 5
return round(_clamp(base), 1)
# ── 3. 现金质量:含金量(OCF/净利润) + 现金流强度 ──
def _score_cash_quality(cur: dict) -> float:
ocf = cur.get("F_OP_CFLOW")
np_ = cur.get("F_NET_PROFIT")
rev = cur.get("F_REVENUE")
fcf = cur.get("F_FCF")
# 含金量 = OCF / 净利润(净利润>0时)
gold = None
if ocf is not None and np_ is not None and np_ > 0:
gold = ocf / np_
# 净利润<=0:利润为负,含金量指标失效 → 低分(除非现金流强)
gold_score = _linear(gold, [(0, 10), (0.5, 35), (0.8, 55), (1.0, 70),
(1.2, 85), (1.5, 95)])
if np_ is not None and np_ <= 0:
gold_score = 15 if (ocf is None or ocf <= 0) else 35
# 现金流强度 = OCF / 收入
ocf_ratio = (ocf / rev * 100) if (ocf is not None and rev) else None
ocf_score = _linear(ocf_ratio, [(-20, 5), (0, 20), (10, 50), (20, 75),
(30, 90), (50, 98)])
fcf_score = _linear(fcf, [(-100, 10), (-20, 30), (0, 50), (20, 70),
(100, 90)]) if fcf is not None else 50.0
score = gold_score * 0.5 + ocf_score * 0.35 + fcf_score * 0.15
return round(_clamp(score), 1)
# ── 4. 增长效率:费用率水平 + 单位增长投入产出 + 获客成本 ──
def _score_growth_efficiency(cur: dict, prev: dict) -> float:
cost_ratio = cur.get("F_COST_RATIO")
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
(55, 40), (70, 25), (90, 10)])
# 费用增速 vs 收入增速(用费用率变化近似)
eff_score = 60.0
if prev is not None and cost_ratio is not None:
prev_cr = prev.get("F_COST_RATIO")
if prev_cr:
cr_change = cost_ratio - prev_cr
eff_score = _linear(cr_change, [(-15, 95), (-5, 80), (0, 65),
(5, 45), (15, 25), (30, 10)])
# 获客成本代理:收入/新客户数(越高越高效)
rev = cur.get("F_REVENUE")
new_clients = cur.get("C_NEW_CLIENTS")
cac_score = 60.0
if rev is not None and new_clients:
per_client = rev / new_clients
cac_score = _linear(per_client, [(0, 40), (50, 50), (200, 65),
(500, 78), (1000, 88)])
score = cost_score * 0.45 + eff_score * 0.35 + cac_score * 0.2
return round(_clamp(score), 1)
# ── 5. 组织健康:费用结构 + 人效/运营质量 ──
def _score_org_health(cur: dict) -> float:
cost_ratio = cur.get("F_COST_RATIO")
# 费用结构(费用率越低越健康)
cost_score = _linear(cost_ratio, [(10, 95), (20, 82), (30, 68), (40, 55),
(55, 40), (70, 25), (90, 10)])
# 运营/人效质量代理:满意度、培训、交付、应收
sat = cur.get("C_SATISFACTION")
train = cur.get("L_TRAINING")
deliver = cur.get("P_DELIVERY")
ar_days = cur.get("F_AR_DAYS")
op_vals = [v for v in [sat, train, deliver] if v is not None]
op_score = (sum(op_vals) / len(op_vals)) if op_vals else 55.0
ar_score = _linear(ar_days, [(15, 95), (30, 80), (45, 65), (60, 50),
(90, 30), (120, 15)]) if ar_days is not None else 55.0
score = cost_score * 0.4 + op_score * 0.35 + ar_score * 0.25
return round(_clamp(score), 1)
_SCORERS = {
"revenueGrowth": _score_revenue_growth,
"profitQuality": _score_profit_quality,
"cashQuality": _score_cash_quality,
"growthEfficiency": _score_growth_efficiency,
"orgHealth": _score_org_health,
}
# ═══════════════════════════════════════════════
# 明细指标 + 改善建议
# ═══════════════════════════════════════════════
def _fmt(v, unit=""):
if v is None:
return "—"
if isinstance(v, float) and v == int(v):
return f"{int(v)}{unit}"
return f"{round(v, 2)}{unit}"
def _dim_indicators(dim_key: str, cur: dict, prev: dict) -> List[dict]:
"""维度明细指标(label/value/verdict/status)"""
inds = []
def add(label, value, verdict, status):
inds.append({"label": label, "value": value, "verdict": verdict, "status": status})
if dim_key == "revenueGrowth":
rev, prev_rev = cur.get("F_REVENUE"), (prev or {}).get("F_REVENUE")
growth = None
if rev is not None and prev_rev:
growth = (rev - prev_rev) / prev_rev * 100
add("营业收入", _fmt(rev, "万"), "环比" + (_fmt(growth, "%") if growth is not None else "无上期数据"),
"success" if (growth or 0) >= 0 else "danger")
add("收入增长率(KPI)", _fmt(cur.get("F_REVENUE_GROWTH"), "%"),
"KPI直接值" if cur.get("F_REVENUE_GROWTH") is not None else "未录入",
"success" if (cur.get("F_REVENUE_GROWTH") or 0) >= 10 else "warning")
elif dim_key == "profitQuality":
rev, np_ = cur.get("F_REVENUE"), cur.get("F_NET_PROFIT")
nm = (np_ / rev * 100) if (np_ is not None and rev) else None
add("毛利率", _fmt(cur.get("F_GROSS_MARGIN"), "%"),
"毛利健康" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "毛利偏低",
"success" if (cur.get("F_GROSS_MARGIN") or 0) >= 30 else "danger")
add("净利率", _fmt(nm, "%"),
"盈利" if (nm or 0) > 0 else "亏损",
"success" if (nm or 0) > 10 else "danger")
add("经营利润率", _fmt(cur.get("F_OP_PROFIT_MARGIN"), "%"),
"正常" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "偏低",
"success" if (cur.get("F_OP_PROFIT_MARGIN") or 0) >= 15 else "warning")
elif dim_key == "cashQuality":
ocf, np_ = cur.get("F_OP_CFLOW"), cur.get("F_NET_PROFIT")
gold = (ocf / np_) if (ocf is not None and np_ and np_ > 0) else None
add("经营现金流", _fmt(ocf, "万"),
"现金流入" if (ocf or 0) > 0 else "现金流出",
"success" if (ocf or 0) > 0 else "danger")
add("含金量(OCF/净利润)", _fmt(gold, "倍"),
"含金量高" if (gold or 0) >= 1 else ("利润为负" if (np_ or 0) <= 0 else "含金量低"),
"success" if (gold or 0) >= 1 else "danger")
add("自由现金流", _fmt(cur.get("F_FCF"), "万"),
"正常" if (cur.get("F_FCF") or 0) > 0 else "为负",
"success" if (cur.get("F_FCF") or 0) > 0 else "warning")
elif dim_key == "growthEfficiency":
rev, nc = cur.get("F_REVENUE"), cur.get("C_NEW_CLIENTS")
per = (rev / nc) if (rev is not None and nc) else None
add("费用率", _fmt(cur.get("F_COST_RATIO"), "%"),
"费用可控" if (cur.get("F_COST_RATIO") or 0) <= 30 else "费用偏高",
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
add("单位客户营收(万/户)", _fmt(per),
"获客效率高" if (per or 0) >= 200 else "获客效率一般",
"success" if (per or 0) >= 500 else "warning")
add("渠补率", _fmt(cur.get("C_REBATE_RATE"), "%"),
"渠道依赖" if (cur.get("C_REBATE_RATE") or 0) > 50 else "渠道健康",
"danger" if (cur.get("C_REBATE_RATE") or 0) > 50 else "success")
elif dim_key == "orgHealth":
add("费用率(结构)", _fmt(cur.get("F_COST_RATIO"), "%"),
"结构健康" if (cur.get("F_COST_RATIO") or 0) <= 30 else "结构偏重",
"success" if (cur.get("F_COST_RATIO") or 0) <= 30 else "warning")
add("应收周转天数", _fmt(cur.get("F_AR_DAYS"), "天"),
"回款快" if (cur.get("F_AR_DAYS") or 0) <= 45 else "回款偏慢",
"success" if (cur.get("F_AR_DAYS") or 0) <= 45 else "warning")
add("客户满意度", _fmt(cur.get("C_SATISFACTION")),
"满意" if (cur.get("C_SATISFACTION") or 0) >= 80 else "待提升",
"success" if (cur.get("C_SATISFACTION") or 0) >= 80 else "warning")
add("培训完成率", _fmt(cur.get("L_TRAINING"), "%"),
"学习投入足" if (cur.get("L_TRAINING") or 0) >= 80 else "学习投入不足",
"success" if (cur.get("L_TRAINING") or 0) >= 80 else "warning")
return inds
def _dim_suggestions(dim_key: str, score: float, cur: dict) -> List[str]:
"""按维度评分生成改善建议"""
if score >= 80:
return ["该维度表现良好,建议保持并固化为标准流程"]
sug = []
if dim_key == "revenueGrowth":
sug = ["挖掘存量客户复购,稳定收入基本盘",
"拓展新渠道/新产品线,提升营收增速",
"跟踪F_REVENUE_GROWTH KPI按月更新,建立增长预警线"]
elif dim_key == "profitQuality":
sug = ["排查毛利率下滑原因(成本/价格/渠补),优先止血",
"控制费用增速不超过收入增速,改善净利率",
"对亏损产品线做盈亏平衡分析,必要时收缩"]
elif dim_key == "cashQuality":
sug = ["加强应收账款催收,缩短回款周期",
"压缩非必要开支,提升经营现金流净额",
"建立现金流月度滚动预测,防范断流风险"]
elif dim_key == "growthEfficiency":
sug = ["优化费用结构,降低费用率至30%以下",
"评估渠道返利政策,降低渠补率与渠道依赖",
"提高获客转化率,降低单位获客成本"]
elif dim_key == "orgHealth":
sug = ["精简组织与费用结构,提升人效",
"强化培训与人才梯队建设(盯L_TRAINING)",
"优化应收管理,缩短周转天数"]
return sug
def _level_of(overall: float) -> dict:
if overall >= 80:
return {"level": "好", "level_type": "success", "desc": "增长质量优秀,增长可持续"}
if overall >= 60:
return {"level": "中", "level_type": "warning", "desc": "增长质量中等,存在优化空间"}
return {"level": "差", "level_type": "danger", "desc": "增长质量堪忧,需立即干预"}
# ═══════════════════════════════════════════════
# 诊断主流程
# ═══════════════════════════════════════════════
def _diagnose(db: Session, entity_id: int, period: str, history: List[dict]):
"""对单个期间执行五维诊断,返回完整诊断对象"""
cur = _fetch_kpi_values(db, entity_id, period)
prev_period = _prev_period(period)
prev = _fetch_kpi_values(db, entity_id, prev_period) if prev_period else {}
scores = {}
dims_payload = {}
for dim in DIMENSIONS:
key = dim["key"]
scorer = _SCORERS[key]
if key == "revenueGrowth":
s = scorer(cur, prev, history)
elif key in ("cashQuality", "orgHealth"):
s = scorer(cur)
else:
s = scorer(cur, prev)
scores[key] = s
dims_payload[key] = {
"key": key, "name": dim["name"], "icon": dim["icon"],
"desc": dim["desc"], "weight": dim["weight"],
"score": s,
"indicators": _dim_indicators(key, cur, prev),
"suggestions": _dim_suggestions(key, s, cur),
}
overall = round(sum(scores.values()) / len(scores), 1)
level = _level_of(overall)
# 诊断结论文本
low_dims = [d for d in DIMENSIONS if scores[d["key"]] < 60]
mid_dims = [d for d in DIMENSIONS if 60 <= scores[d["key"]] < 80]
lines = [f"{period} 综合增长质量评分 {overall} 分({level['level']}):{level['desc']}。"]
if low_dims:
lines.append("需重点关注:" + "、".join(f"{d['name']}({scores[d['key']]}分)" for d in low_dims) + "。")
if mid_dims:
lines.append("可优化:" + "、".join(f"{d['name']}({scores[d['key']]}分)" for d in mid_dims) + "。")
if not low_dims:
lines.append("各维度均处于健康区间,增长质量扎实。")
diagnosis = "".join(lines)
return {
"entity_id": entity_id,
"period": period,
"overall": overall,
"level": level["level"],
"level_type": level["level_type"],
"diagnosis": diagnosis,
"dimensions": dims_payload,
"kpi_available": {k: cur.get(k) is not None for k in KPI_CODES},
}
@router.get("/periods")
def list_periods(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
"""列出某实体有KPI数据的期间(按月,含数据覆盖度,用于前端默认期间选择)"""
rows = (db.query(KPIValue.period, KPIValue.kpi_id)
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
.filter(KPIDefinition.entity_id == entity_id)
.all())
counts: dict = {}
for period, kpi_id in rows:
if MONTH_RE.match(period or ""):
counts[period] = counts.get(period, 0) + 1
periods = sorted(counts.keys(), reverse=True)
return {"entity_id": entity_id, "periods": periods,
"coverage": {p: counts[p] for p in periods}}
@router.get("/diagnosis")
def growth_quality_diagnosis(
entity_id: int = Depends(get_entity_id),
period: Optional[str] = Query(None, description="期间 YYYY-MM,默认最近有数据期间"),
db: Session = Depends(get_db),
):
"""增长质量诊断 — 五维评分(0-100) + 诊断结论 + 跨期对比(本期/上期/去年同期)"""
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if not ent:
raise HTTPException(404, f"实体 {entity_id} 不存在")
history = _fetch_history(db, entity_id, limit=12)
if not history:
raise HTTPException(400, "该实体暂无月度KPI数据,请先录入KPI实际值")
# 默认取最近且有足够数据覆盖的期间(>=5个KPI值,退化为最近一个)
if not period:
cov_rows = (db.query(KPIValue.period)
.join(KPIDefinition, KPIDefinition.id == KPIValue.kpi_id)
.filter(KPIDefinition.entity_id == entity_id)
.all())
cov: dict = {}
for (p,) in cov_rows:
if MONTH_RE.match(p or ""):
cov[p] = cov.get(p, 0) + 1
candidates = sorted([p for p in cov if cov[p] >= 5], reverse=True)
period = candidates[0] if candidates else history[-1]["period"]
current = _diagnose(db, entity_id, period, history)
# 跨期对比:上期 + 去年同期
prev_p = _prev_period(period)
yoy_p = _yoy_period(period)
prev_data = _fetch_kpi_values(db, entity_id, prev_p) if prev_p else {}
yoy_data = _fetch_kpi_values(db, entity_id, yoy_p) if yoy_p else {}
comparison = {
"current": {
"period": period, "overall": current["overall"],
"level": current["level"], "level_type": current["level_type"],
"dimensions": {k: v["score"] for k, v in current["dimensions"].items()},
},
}
if prev_p and prev_data:
pdiag = _diagnose(db, entity_id, prev_p, history)
comparison["previous"] = {
"period": prev_p, "overall": pdiag["overall"],
"level": pdiag["level"], "level_type": pdiag["level_type"],
"dimensions": {k: v["score"] for k, v in pdiag["dimensions"].items()},
}
if yoy_p and yoy_data:
ydiag = _diagnose(db, entity_id, yoy_p, history)
comparison["yoy"] = {
"period": yoy_p, "overall": ydiag["overall"],
"level": ydiag["level"], "level_type": ydiag["level_type"],
"dimensions": {k: v["score"] for k, v in ydiag["dimensions"].items()},
}
# 趋势:近12个月综合评分
trend = []
for h in history:
try:
d = _diagnose(db, entity_id, h["period"], history)
trend.append({"period": h["period"], "overall": d["overall"]})
except Exception:
continue
return {
"entity": {"id": ent.id, "name": ent.name, "short_name": ent.short_name},
"period": period,
"overall": current["overall"],
"level": current["level"],
"level_type": current["level_type"],
"diagnosis": current["diagnosis"],
"dimensions": current["dimensions"],
"comparison": comparison,
"trend": trend,
"kpi_available": current["kpi_available"],
}
+295
View File
@@ -0,0 +1,295 @@
"""知识摘要 API — 管理会计OS持久记忆
提供:
- 查询最近摘要列表
- 查询单个摘要详情
- 手动触发各层级摘要生成
- 查询未摘要的事件
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import KnowledgeEvent, KnowledgeSummary
from app.services.knowledge_service import (
DeepSeekKeyMissing,
generate_summary_sync,
generate_daily_sync,
generate_weekly_sync,
generate_monthly_sync,
get_last_summary,
extract_events,
)
import logging
logger = logging.getLogger("cma.knowledge_api")
router = APIRouter(prefix="/api/cma/knowledge", tags=["知识摘要"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def summary_to_dict(s: KnowledgeSummary) -> dict:
return {
"id": s.id,
"level": s.level,
"period_key": s.period_key,
"title": s.title,
"content": s.content,
"kpi_changes": s.kpi_changes,
"decision_points": s.decision_points,
"key_metrics": s.key_metrics,
"prev_summary_id": s.prev_summary_id,
"model": s.model,
"is_stale": s.is_stale,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
# ── 查询 ──
@router.get("/summaries")
def list_summaries(
level: Optional[str] = None,
limit: int = 20,
offset: int = 0,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取摘要列表,按层级筛选,按时间倒序(按企业抽屉隔离)"""
query = db.query(KnowledgeSummary)
query = query.filter(KnowledgeSummary.entity_id == entity_id)
if level:
query = query.filter(KnowledgeSummary.level == level)
query = query.order_by(desc(KnowledgeSummary.id)).offset(offset).limit(limit)
total = db.query(func.count(KnowledgeSummary.id)).select_from(KnowledgeSummary)
total = total.filter(KnowledgeSummary.entity_id == entity_id)
if level:
total = total.filter(KnowledgeSummary.level == level)
total = total.scalar()
return {
"total": total,
"entity_id": entity_id,
"items": [summary_to_dict(s) for s in query.all()],
}
@router.get("/summaries/latest")
def latest_summary(
level: str = Query("daily", description="层级: daily/weekly/monthly/cumulative"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取指定层级的最新摘要(按企业隔离)"""
s = get_last_summary(db, level, entity_id)
if not s:
return {"detail": f"没有{level}层级的摘要"}, 404
return summary_to_dict(s)
@router.get("/summaries/{summary_id}")
def get_summary(
summary_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取单条摘要详情(仅本企业,防跨企业探测)"""
s = (
db.query(KnowledgeSummary)
.filter(
KnowledgeSummary.id == summary_id,
KnowledgeSummary.entity_id == entity_id,
)
.first()
)
if not s:
raise HTTPException(status_code=404, detail="摘要不存在")
return summary_to_dict(s)
# ── 事件查询 ──
@router.get("/events")
def list_events(
since: Optional[str] = None,
until: Optional[str] = None,
limit: int = 50,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查询未摘要的原始事件(按企业隔离)
如果不传时间,默认返回最近7天的操作记录和预警。
"""
try:
dt_since = datetime.fromisoformat(since) if since else datetime.utcnow() - timedelta(days=7)
dt_until = datetime.fromisoformat(until) if until else datetime.utcnow()
except ValueError:
raise HTTPException(status_code=400, detail="时间格式错误,请使用 ISO 格式如 2026-06-01T00:00:00")
events = extract_events(db, dt_since, dt_until, entity_id=entity_id)
return {"since": dt_since.isoformat(), "until": dt_until.isoformat(), "entity_id": entity_id, "total": len(events), "events": events[:limit]}
# ── 手动触发 ──
@router.post("/generate/daily")
def trigger_daily_summary(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""手动触发每日摘要生成(写入当前企业抽屉)"""
try:
result = generate_daily_sync(db, entity_id=entity_id)
return {"message": "每日摘要已生成", "summary": result}
except DeepSeekKeyMissing as e:
logger.error("每日摘要生成失败(缺少 DeepSeek Key): %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
logger.exception("每日摘要生成失败")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
@router.post("/generate/weekly")
def trigger_weekly_summary(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""手动触发周度摘要生成(写入当前企业抽屉)"""
try:
result = generate_weekly_sync(db, entity_id=entity_id)
return {"message": "周度摘要已生成", "summary": result}
except DeepSeekKeyMissing as e:
logger.error("周度摘要生成失败(缺少 DeepSeek Key): %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
logger.exception("周度摘要生成失败")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
@router.post("/generate/monthly")
def trigger_monthly_summary(
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""手动触发月度摘要生成(写入当前企业抽屉)"""
try:
result = generate_monthly_sync(db, entity_id=entity_id)
return {"message": "月度摘要已生成", "summary": result}
except DeepSeekKeyMissing as e:
logger.error("月度摘要生成失败(缺少 DeepSeek Key): %s", e)
raise HTTPException(status_code=503, detail=str(e))
except Exception as e:
logger.exception("月度摘要生成失败")
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
# ── CMA P1+P2 Round 3: 职业道德自测题 ──
ETHICS_QUIZ_DATA = [
{
"id": 1,
"scenario": '你发现公司财务数据异常(现金比率仅0.6%),但总经理说"先别声张,等融资款到账再说",你应该:',
"options": [
{"id": "a", "text": "听从老板,暂时隐瞒", "correct": False, "feedback": "违反IMA诚信准则。财务人员有义务如实记录和报告"},
{"id": "b", "text": "如实记录异常并向上级或审计委员会报告", "correct": True, "feedback": "诚信+客观准则。IMA要求财务人员如实反映财务状况"},
{"id": "c", "text": "立即辞职,一走了之", "correct": False, "feedback": "可行但非最优解。应先通过正规渠道报告问题"},
],
},
{
"id": 2,
"scenario": "竞争对手公司的高管在行业会议上向你打探你们公司的成本数据,你应该:",
"options": [
{"id": "a", "text": "告知对方,反正行业数据都差不多", "correct": False, "feedback": "违反保密准则。成本数据属于公司机密"},
{"id": "b", "text": "断然拒绝,并告知这是公司机密", "correct": True, "feedback": "保密准则。财务人员必须保护公司机密信息"},
{"id": "c", "text": "用编造的假数据糊弄对方", "correct": False, "feedback": "违反诚信准则。即使对竞争对手也不应提供虚假信息"},
],
},
{
"id": 3,
"scenario": "你被要求分析一项复杂的金融衍生品对公司的影响,但你没有相关专业知识经验,应该:",
"options": [
{"id": "a", "text": "凭感觉估算一个数值交差", "correct": False, "feedback": "违反胜任准则。不能在自己不擅长的领域出具专业意见"},
{"id": "b", "text": "如实告知自己缺乏经验,建议聘请外部专家", "correct": True, "feedback": "胜任准则。IMA要求持续学习并在能力范围内执业"},
{"id": "c", "text": "花一个晚上学习后立即出报告", "correct": False, "feedback": "短期内突击学习难以达到专业标准,建议请专家"},
],
},
{
"id": 4,
"scenario": "供应商送你价值5000元的高档礼品,并暗示希望在招标中获得倾斜,你应该:",
"options": [
{"id": "a", "text": "收下礼物,在招标中适当照顾", "correct": False, "feedback": "违反客观准则。礼品影响职业判断的独立性"},
{"id": "b", "text": "拒收礼品并报告公司合规部门", "correct": True, "feedback": "客观准则。财务人员必须保持独立性和客观判断"},
{"id": "c", "text": "收下但不做任何特殊照顾", "correct": False, "feedback": "即使不特殊照顾,收礼本身已违反公司廉洁规定"},
],
},
{
"id": 5,
"scenario": '销售总监要求你将下季度已签约但未发货的订单提前确认为本季度收入,以"完成业绩指标",你应该:',
"options": [
{"id": "a", "text": "照做,反正迟早会确认", "correct": False, "feedback": "违反诚信准则。收入确认必须符合会计准则的确认条件"},
{"id": "b", "text": "拒绝并解释收入确认应遵循权责发生制", "correct": True, "feedback": "诚信+客观准则。IMA要求如实反映交易实质"},
{"id": "c", "text": "折中处理,先确认一半", "correct": False, "feedback": "部分确认依然违反会计准则和IMA诚信准则"},
],
},
{
"id": 6,
"scenario": "你的亲戚是股票投资者,向你打听公司即将发布的季度盈利数据(尚未公开),你应该:",
"options": [
{"id": "a", "text": "暗示性地透露一点,让亲戚自己去判断", "correct": False, "feedback": "违反保密准则。内幕信息在任何情况下都不能泄露"},
{"id": "b", "text": "明确拒绝并告知这是未公开的内部信息", "correct": True, "feedback": "保密准则。IMA要求保护信息机密性,禁止内幕交易"},
{"id": "c", "text": "告知所有数据需等正式发布后才能讨论", "correct": False, "feedback": "虽然拒绝了但未明确说明信息性质,应更直接拒绝"},
],
},
{
"id": 7,
"scenario": "你在编制税务筹划方案时,发现最新的税法已经更新,但你还没参加过相关培训,应该:",
"options": [
{"id": "a", "text": "按老法规继续编制,客户不会发现", "correct": False, "feedback": "违反胜任准则。必须掌握最新知识才能提供专业服务"},
{"id": "b", "text": "先学习新税法,再基于最新法规编制方案", "correct": True, "feedback": "胜任准则。IMA要求持续提升专业能力,保持知识更新"},
{"id": "c", "text": "请客户另请高明", "correct": False, "feedback": "回避问题不可取。应先通过学习解决问题"},
],
},
{
"id": 8,
"scenario": "月度经营分析会上,CEO要求你只汇报好的指标,不提及存在的风险问题,你应该:",
"options": [
{"id": "a", "text": "听从CEO安排,只报喜不报忧", "correct": False, "feedback": "违反客观准则。选择性披露误导决策"},
{"id": "b", "text": "完整披露所有指标,包括存在风险和问题", "correct": True, "feedback": "客观+诚信准则。IMA要求全面、公正地呈现信息"},
{"id": "c", "text": "在会上只口头上提一下风险但不出现在书面报告里", "correct": False, "feedback": "口头提及无记录,核心问题仍需书面报告"},
],
},
{
"id": 9,
"scenario": "为达成KPI目标获得年终奖,部门经理要求你修改部分实际运营数据,你应该:",
"options": [
{"id": "a", "text": "修改数据,反正金额不大", "correct": False, "feedback": "违反诚信准则。任何金额的数据造假都不可接受"},
{"id": "b", "text": "坚决拒绝并向上级或审计委员会举报", "correct": True, "feedback": "诚信准则。诚实是财务人员的首要职业道德要求"},
{"id": "c", "text": "假装答应但不执行", "correct": False, "feedback": "不明确的回应可能导致后续施压,应明确拒绝"},
],
},
{
"id": 10,
"scenario": "你准备离职去竞争对手公司,离职前应该如何处理手头的公司财务数据文件?",
"options": [
{"id": "a", "text": "拷贝一份带走,作为新公司的参考", "correct": False, "feedback": "违反保密准则。离职不得带走公司机密数据"},
{"id": "b", "text": "全部归还公司,并确认不保留任何副本", "correct": True, "feedback": "保密准则。离职后仍须承担保密义务"},
{"id": "c", "text": "只带走非敏感性的一般统计资料", "correct": False, "feedback": "任何公司未公开数据均不得外传"},
],
},
]
@router.get("/ethics-quiz")
def get_ethics_quiz():
"""返回10道IMA职业道德情景自测题"""
return ETHICS_QUIZ_DATA
+92
View File
@@ -0,0 +1,92 @@
"""知识库文章 API — P1-3 嵌入功能模块用
提供按关联页面查询知识文章的功能。
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models.knowledge_article import KnowledgeArticle
from app.utils.route_key import candidate_route_keys, route_template_child_pattern
router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def article_to_dict(a: KnowledgeArticle) -> dict:
return {
"id": a.id,
"entity_id": a.entity_id,
"title": a.title,
"summary": a.summary,
"content": a.content,
"category": a.category,
"icon": a.icon,
"related_page": a.related_page,
"sort_order": a.sort_order,
}
@router.get("")
def list_articles(
related_page: Optional[str] = Query(None, description="按关联页面路由筛选"),
category: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查询知识文章(本企业专属 ∪ 全局共享),可按关联页面或分类筛选
隔离语义(2026-09-03 多租户分区):
- entity_id IS NULL → 全局共享(CMA 系统教学知识,所有企业可见)
- entity_id == 当前账套 → 企业专属(只本企业可见)
- 其他企业的专属文章不可见
"""
q = db.query(KnowledgeArticle).filter(
or_(
KnowledgeArticle.entity_id == entity_id,
KnowledgeArticle.entity_id.is_(None),
)
)
if related_page:
# KBFF-v11c(2026-10-12):匹配口径由「子串包含 LIKE '%<v>%'」收敛为
# 「归一化候选键精确等值 ∪ 模板子路由」——消除短键/前缀超集命中
# (prop=/maps 曾连带命中 /maps/canvas + /maps-review;prop=/kpi 命中 /kpis)。
# 口径实现唯一处 = app/utils/route_key.py(调用方不得二次截断/拼接)。
keys = candidate_route_keys(related_page)
if not keys:
# 不可归一(纯 query/空白等)⇒ 明确无命中,不退化为“不过滤=全量”
return {"data": []}
conds = [KnowledgeArticle.related_page.in_(keys)]
for k in keys:
# 模板子路由:存储值形如 <键>/:<占位符>(占位符命名不限),仍属该页
conds.append(
KnowledgeArticle.related_page.like(route_template_child_pattern(k), escape="\\")
)
q = q.filter(or_(*conds))
if category:
q = q.filter(KnowledgeArticle.category == category)
articles = q.order_by(KnowledgeArticle.sort_order.asc(), KnowledgeArticle.id.asc()).all()
return {"data": [article_to_dict(a) for a in articles]}
@router.get("/{article_id}")
def get_article(
article_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取文章详情(企业专属文章仅本企业可见,防跨企业探测)"""
a = (
db.query(KnowledgeArticle)
.filter(KnowledgeArticle.id == article_id)
.first()
)
if not a:
raise HTTPException(404, "文章不存在")
if a.entity_id is not None and a.entity_id != entity_id:
raise HTTPException(404, "文章不存在")
return article_to_dict(a)
+433
View File
@@ -0,0 +1,433 @@
"""KPI因果链建模 — 任务2
KPI间因果关系网络 + 模拟推演 + 三层验证机制(数据/AI/人工) (2026-08-27 P2)
"""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Optional
import logging
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPICausality, KPIValue, OperationLog
from app.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
from app.services.causality_verification import (
ALL_STATUSES,
ALL_SOURCE_TYPES,
STATUS_PENDING,
)
logger = logging.getLogger("kpi-causality")
router = APIRouter(prefix="/api/cma/kpi-causality", tags=["KPI因果链"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
def _to_dict(obj):
return {c.name: getattr(obj, c.name) for c in obj.__table__.columns}
# ============================================================
# 注意: 静态路径必须放在动态路径之前(/{id}之前)
# ============================================================
@router.get("/full-network")
def get_full_network(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取全局因果网络数据(用于力导向图)— 账套隔离: 仅当前企业KPI的因果链 (2026-08-23 P1b, 2026-08-27 用entity_id列)"""
edges = db.query(KPICausality).filter(KPICausality.entity_id == entity_id).all()
node_ids = set()
edge_list = []
for e in edges:
node_ids.add(e.source_kpi_id)
node_ids.add(e.target_kpi_id)
edge_list.append({
"source": e.source_kpi_id,
"target": e.target_kpi_id,
"strength": e.strength,
"direction": e.direction,
"lag_months": e.lag_months,
})
# 获取所有节点信息
kpis = db.query(KPIDefinition).filter(KPIDefinition.id.in_(node_ids)).all() if node_ids else []
node_map = {k.id: {
"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "category": k.category,
} for k in kpis}
nodes = []
for nid in node_ids:
info = node_map.get(nid, {"id": nid, "kpi_code": f"KPI#{nid}", "kpi_name": f"KPI#{nid}"})
nodes.append(info)
return {"nodes": nodes, "edges": edge_list, "total_edges": len(edge_list)}
@router.get("/kpi/{kpi_id}/network")
def get_kpi_network(kpi_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取KPI的因果网络(上游驱动 + 下游影响)— 账套隔离: 校验KPI属于当前企业"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在") # 跨企业不暴露存在性
# 上游(指向当前KPI的因果)
upstream = db.query(KPICausality).filter(
KPICausality.target_kpi_id == kpi_id,
KPICausality.entity_id == entity_id,
).all()
upstream_list = []
for c in upstream:
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
if src:
upstream_list.append({
"causality_id": c.id,
"kpi_id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name,
"strength": c.strength, "lag_months": c.lag_months,
"direction": c.direction, "formula": c.formula,
"source_type": c.source_type, "verify_status": c.verify_status,
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
"verified_by": c.verified_by,
})
# 下游(当前KPI指向的因果)
downstream = db.query(KPICausality).filter(
KPICausality.source_kpi_id == kpi_id,
KPICausality.entity_id == entity_id,
).all()
downstream_list = []
for c in downstream:
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
if tgt:
downstream_list.append({
"causality_id": c.id,
"kpi_id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name,
"strength": c.strength, "lag_months": c.lag_months,
"direction": c.direction, "formula": c.formula,
"source_type": c.source_type, "verify_status": c.verify_status,
"verified_at": c.verified_at.isoformat() if c.verified_at else None,
"verified_by": c.verified_by,
})
return {
"kpi": {"id": kpi.id, "kpi_code": kpi.kpi_code, "kpi_name": kpi.kpi_name, "dimension": kpi.dimension},
"upstream": upstream_list,
"downstream": downstream_list,
}
@router.post("/simulate")
def simulate_causality(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""模拟推演: 修改一个KPI的值,预测对其他KPI的影响
Body: { kpi_id: int, new_value: float, period: str }
"""
kpi_id = data.get("kpi_id")
new_value = data.get("new_value")
period = data.get("period")
if not kpi_id or new_value is None:
raise HTTPException(400, "必须指定kpi_id和new_value")
source_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not source_kpi:
raise HTTPException(404, "KPI不存在")
if source_kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
# 获取当前值
current_value = None
query_values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.actual_value.isnot(None),
)
if period:
query_values = query_values.filter(KPIValue.period == period)
latest = query_values.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).first()
if latest:
current_value = latest.actual_value
previous_value = current_value or new_value
change_pct = ((new_value - previous_value) / previous_value * 100) if previous_value and previous_value != 0 else 0
# BFS遍历下游因果链
visited = set()
impacts = []
queue = [(kpi_id, change_pct, 0, 1.0)] # (kpi_id, change_pct, depth, cumulative_strength)
while queue:
current_kpi_id, current_change, depth, cum_strength = queue.pop(0)
if current_kpi_id in visited:
continue
visited.add(current_kpi_id)
# 查找从current_kpi_id出发的下游因果链(账套隔离: 仅本企业链)
downstream = db.query(KPICausality).filter(
KPICausality.source_kpi_id == current_kpi_id,
KPICausality.entity_id == entity_id,
).all()
for edge in downstream:
target_id = edge.target_kpi_id
if target_id in visited:
continue
target_kpi = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
if not target_kpi:
continue
# 计算影响: 变化率 × 强度 × 方向
edge_strength = edge.strength or 0.5
direction_factor = 1.0 if edge.direction == "positive" else -1.0
propagated_change = current_change * edge_strength * direction_factor
# 获取当前值
tgt_val = db.query(KPIValue).filter(
KPIValue.kpi_id == target_id,
KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first()
predicted_value = None
if tgt_val and tgt_val.actual_value:
predicted_value = round(tgt_val.actual_value * (1 + propagated_change / 100), 2)
impacts.append({
"kpi_id": target_id,
"kpi_code": target_kpi.kpi_code,
"kpi_name": target_kpi.kpi_name,
"dimension": target_kpi.dimension,
"current_value": tgt_val.actual_value if tgt_val else None,
"predicted_value": predicted_value,
"change_pct": round(propagated_change, 2),
"strength": edge_strength,
"direction": edge.direction,
"lag_months": edge.lag_months,
"depth": depth + 1,
"path_strength": round(cum_strength * edge_strength, 3),
})
# 继续遍历下游
new_cum = cum_strength * edge_strength
if new_cum > 0.05 and depth < 5:
queue.append((target_id, propagated_change, depth + 1, new_cum))
return {
"source": {
"kpi_id": source_kpi.id,
"kpi_code": source_kpi.kpi_code,
"kpi_name": source_kpi.kpi_name,
"current_value": current_value,
"new_value": new_value,
"change_pct": round(change_pct, 2),
},
"impacts": impacts,
"total_impacted": len(impacts),
}
# ============================================================
# CRUD (动态路径)
# ============================================================
@router.get("")
def list_causalities(
source_kpi_id: Optional[int] = None,
target_kpi_id: Optional[int] = None,
verify_status: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取因果链列表(账套隔离: 仅当前企业KPI, 2026-08-23 P1b, 2026-08-27 支持verify_status筛选)"""
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
if source_kpi_id:
query = query.filter(KPICausality.source_kpi_id == source_kpi_id)
if target_kpi_id:
query = query.filter(KPICausality.target_kpi_id == target_kpi_id)
if verify_status:
query = query.filter(KPICausality.verify_status == verify_status)
items = query.order_by(KPICausality.id).all()
result = []
for c in items:
d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
d["source_kpi_code"] = src.kpi_code if src else None
d["source_kpi_name"] = src.kpi_name if src else None
d["target_kpi_code"] = tgt.kpi_code if tgt else None
d["target_kpi_name"] = tgt.kpi_name if tgt else None
result.append(d)
return {"data": result, "total": len(result)}
@router.get("/verify-status")
def get_verify_status(
verify_status: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""验证状态总览 — 按状态统计 + 链列表(2026-08-27 三层验证机制)
可选 ?verify_status=pending/data_verified/human_verified/disputed 筛选
"""
if verify_status and verify_status not in ALL_STATUSES:
raise HTTPException(400, f"verify_status 必须为 {'/'.join(ALL_STATUSES)}")
query = db.query(KPICausality).filter(KPICausality.entity_id == entity_id)
if verify_status:
query = query.filter(KPICausality.verify_status == verify_status)
items = query.order_by(KPICausality.id).all()
by_status = {s: 0 for s in ALL_STATUSES}
data = []
for c in items:
by_status[c.verify_status] = by_status.get(c.verify_status, 0) + 1
d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
d["source_kpi_code"] = src.kpi_code if src else None
d["source_kpi_name"] = src.kpi_name if src else None
d["target_kpi_code"] = tgt.kpi_code if tgt else None
d["target_kpi_name"] = tgt.kpi_name if tgt else None
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
data.append(d)
return {
"summary": {"total": len(items), "by_status": by_status},
"data": data,
}
@router.get("/{causality_id}")
def get_causality(causality_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
d = _to_dict(c)
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
d["source"] = {"id": src.id, "kpi_code": src.kpi_code, "kpi_name": src.kpi_name} if src else None
d["target"] = {"id": tgt.id, "kpi_code": tgt.kpi_code, "kpi_name": tgt.kpi_name} if tgt else None
return d
@router.post("")
def create_causality(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""创建因果链(source_type标记来源, 2026-08-27)"""
source_id = data.get("source_kpi_id")
target_id = data.get("target_kpi_id")
if not source_id or not target_id:
raise HTTPException(400, "必须指定源KPI和目标KPI")
if source_id == target_id:
raise HTTPException(400, "源和目标不能相同")
src = db.query(KPIDefinition).filter(KPIDefinition.id == source_id).first()
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == target_id).first()
if not src or not tgt:
raise HTTPException(404, "KPI不存在")
if src.entity_id != tgt.entity_id:
raise HTTPException(400, "源KPI和目标KPI必须属于同一企业")
existing = db.query(KPICausality).filter(
KPICausality.source_kpi_id == source_id,
KPICausality.target_kpi_id == target_id,
).first()
if existing:
raise HTTPException(400, f"因果链已存在: {src.kpi_code}→{tgt.kpi_code}")
source_type = data.get("source_type", "manual")
if source_type not in ALL_SOURCE_TYPES:
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
c = KPICausality(
entity_id=src.entity_id,
source_kpi_id=source_id,
target_kpi_id=target_id,
strength=data.get("strength", 0.5),
lag_months=data.get("lag_months", 1),
formula=data.get("formula"),
direction=data.get("direction", "positive"),
source_type=source_type,
verify_status=STATUS_PENDING,
)
db.add(c)
db.commit()
db.refresh(c)
log_op(db, "create", target_type="kpi_causality", target_id=c.id,
detail=f"创建因果链: {src.kpi_code}→{tgt.kpi_code} (source={source_type})",
entity_id=c.entity_id)
db.commit()
return _to_dict(c)
@router.put("/{causality_id}")
def update_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
for field in ("strength", "lag_months", "formula", "direction", "source_type"):
if field in data:
if field == "source_type" and data[field] not in ALL_SOURCE_TYPES:
raise HTTPException(400, f"source_type 必须为 {'/'.join(ALL_SOURCE_TYPES)}")
setattr(c, field, data[field])
# 修改链定义后,验证状态回到待检(定义变了旧结论失效)
if any(f in data for f in ("strength", "lag_months", "formula", "direction")):
c.verify_status = STATUS_PENDING
c.verified_at = None
c.verified_by = None
db.commit()
db.refresh(c)
return _to_dict(c)
@router.put("/{causality_id}/verify")
def verify_causality(causality_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""人工确认(战略回顾会核对打标)— 2026-08-27 三层验证机制
Body: { verify_status: "human_verified"|"disputed", verified_by?: "任富海" }
默认打标 human_verified(人工最终确认)。
"""
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if not c:
raise HTTPException(404, "因果链不存在")
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
target_status = data.get("verify_status", "human_verified")
if target_status not in ("human_verified", "disputed"):
raise HTTPException(400, "verify_status 必须为 human_verified 或 disputed")
verified_by = data.get("verified_by") or user.name or user.username
c.verify_status = target_status
c.verified_at = datetime.now()
c.verified_by = str(verified_by)[:50]
log_op(db, "verify", target_type="kpi_causality", target_id=causality_id,
detail=f"因果链 #{causality_id} 人工确认: {target_status} (by {verified_by})",
entity_id=c.entity_id)
db.commit()
db.refresh(c)
d = _to_dict(c)
d["verified_at"] = c.verified_at.isoformat() if c.verified_at else None
return d
@router.delete("/{causality_id}")
def delete_causality(causality_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
c = db.query(KPICausality).filter(KPICausality.id == causality_id).first()
if c:
if c.entity_id != entity_id:
raise HTTPException(404, "因果链不存在") # 账套隔离
db.delete(c)
db.commit()
return {"message": "已删除"}
+210
View File
@@ -0,0 +1,210 @@
"""KPI数据治理4条规则:入库必检 + 元数据必填 + 编码规范 + 战略分级
规则1: 入库必检 — dimension/target_value/unit 必填(创建/更新强制拦截)
规则2: 元数据必填 — formula/data_source/data_owner 不能为空或占位符(待补充/待指定/-)
规则3: 编码规范 — kpi_code 必须以 F_/C_/P_/L_ 前缀开头且与 dimension 一致;禁止跨层同名
规则4: 战略/运营分级 — kpi_level 必须是 strategic/operational
API:
- POST /api/cma/kpi/validate 校验单个KPI数据 → {valid, errors}
- GET /api/cma/kpi/governance/audit 全量校验 → 按4条规则分组的不合规清单
"""
from typing import List, Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition
router = APIRouter(
prefix="/api/cma/kpi",
tags=["KPI数据治理"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# 维度 → 编码前缀
DIM_PREFIX = {"finance": "F", "customer": "C", "process": "P", "learning": "L"}
# 历史遗留兼容前缀: EXT_ = 科目余额表导入的财务科目KPI(仅限 finance 维度)
LEGACY_PREFIX_DIM = {"EXT": "finance", "FB": "process"}
# 品牌级业务KPI前缀(跨维度合法,2026-08-21 治理审计适配: BH_ = 博海业务KPI系列)
BRAND_PREFIXES = ("BH",)
VALID_LEVELS = ("strategic", "operational")
# 视为"未完善"的占位符值
PLACEHOLDERS = ("待补充", "待指定", "待完善", "待定", "暂无", "TBD", "tbd", "-", "--", "N/A", "n/a")
# 规则2必填元数据字段
META_FIELDS = [
("formula", "计算公式"),
("data_source", "数据来源"),
("data_owner", "数据责任人"),
("kpi_name", "KPI名称"),
]
def _clean_str(val) -> str:
if val is None:
return ""
if isinstance(val, str):
return val.strip()
return str(val).strip()
def _is_placeholder(val) -> bool:
"""空值或占位符(待补充/待指定/- 等)视为未完善"""
s = _clean_str(val)
if not s:
return True
return s in PLACEHOLDERS
def validate_kpi_payload(
data: dict,
db: Session = None,
current_kpi_id: Optional[int] = None,
is_update: bool = False,
) -> List[dict]:
"""校验单个KPI数据(4条规则)。
- data: 提交的KPI字段字典(创建或更新的载荷)
- db: SQLAlchemy Session(用于跨层同名/编码唯一性检查,可为None)
- current_kpi_id: 更新时传KPI自身id(避免自检误报)
- is_update: 更新模式 — 仅校验载荷中显式出现的字段
返回 [{rule: int, field: str, message: str}, ...],空列表=合规。
"""
issues: List[dict] = []
def add(rule: int, field: str, message: str):
issues.append({"rule": rule, "field": field, "message": message})
code = _clean_str(data.get("kpi_code"))
dimension = _clean_str(data.get("dimension"))
has_code = bool(code)
# ── 规则1: 入库必检 dimension/target_value/unit ──
if not is_update or "dimension" in data:
if not dimension:
add(1, "dimension", "缺少dimension(所属维度: finance/customer/process/learning)")
if not is_update or "target_value" in data:
tv = data.get("target_value")
if tv is None or (isinstance(tv, str) and _clean_str(tv) == ""):
add(1, "target_value", "缺少target_value(目标值)")
if not is_update or "unit" in data:
if _is_placeholder(data.get("unit")):
add(1, "unit", "缺少unit(单位)")
# ── 规则2: 元数据必填(不能为空或占位符)──
for field, label in META_FIELDS:
if not is_update or field in data:
if _is_placeholder(data.get(field)):
add(2, field, f"元数据未完善: {label}({field})不能为空或占位符(待补充/待指定/-)")
# ── 规则3: 编码规范 ──
if not is_update or "kpi_code" in data:
if not has_code:
add(3, "kpi_code", "缺少kpi_code(KPI编码)")
else:
prefix = code.split("_")[0] if "_" in code else code
if prefix not in ("F", "C", "P", "L"):
# 兼容历史遗留 EXT_ 前缀(科目余额表导入的财务科目KPI,仅限finance维度)
# 兼容 FB_ 前缀(财务Bot KPI,仅限process维度)与 BH_ 品牌前缀(跨维度合法)
if not (LEGACY_PREFIX_DIM.get(prefix) and dimension == LEGACY_PREFIX_DIM[prefix]) \
and prefix not in BRAND_PREFIXES:
add(3, "kpi_code", f"编码前缀不符: {code} 应以F_/C_/P_/L_开头")
elif dimension and DIM_PREFIX.get(dimension) and prefix != DIM_PREFIX[dimension]:
expected = DIM_PREFIX[dimension]
add(3, "kpi_code",
f"编码前缀与维度不符: {code} 前缀{prefix}_ 与维度{dimension}(应为{expected}_)不一致")
# 禁止跨层同名: 同一kpi_code不能用于不同dimension
if has_code and db is not None:
dup = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == code).first()
if dup and dup.id != current_kpi_id:
if dimension and dup.dimension and dup.dimension != dimension:
add(3, "kpi_code",
f"跨层同名: {code} 已用于维度{dup.dimension},不能用于维度{dimension}")
elif not dimension:
add(3, "kpi_code", f"编码已存在: {code} 已注册(维度{dup.dimension}),不能重复使用")
# ── 规则4: 战略/运营分级 ──
if not is_update or "kpi_level" in data:
lv = data.get("kpi_level")
if lv is not None and lv not in VALID_LEVELS:
add(4, "kpi_level", f"kpi_level必须是strategic或operational,当前值: {lv}")
return issues
def kpi_issues_message(issues: List[dict]) -> List[str]:
"""issue dict列表 → 纯文本错误列表"""
return [i["message"] for i in issues]
@router.post("/validate")
def validate_kpi(
kpi_data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
):
"""校验单个KPI数据(不入库)。
请求体: KPI字段字典,可选 kpi_id 标识正在编辑的KPI(避免跨层同名误报)。
返回: {"valid": bool, "errors": [str], "details": [{rule, field, message}]}
"""
kpi_id = kpi_data.get("kpi_id") if isinstance(kpi_data.get("kpi_id"), int) else None
issues = validate_kpi_payload(kpi_data, db=db, current_kpi_id=kpi_id)
return {
"valid": len(issues) == 0,
"errors": kpi_issues_message(issues),
"details": issues,
}
@router.get("/governance/audit")
def governance_audit(
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""校验本账套活跃KPI,输出按4条规则分组的不合规清单(账套隔离 2026-09-21)。"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
non_compliant = []
by_rule: dict = {1: [], 2: [], 3: [], 4: []}
for k in kpis:
payload = {c.name: getattr(k, c.name) for c in k.__table__.columns}
issues = validate_kpi_payload(payload, db=db, current_kpi_id=k.id)
if issues:
entry = {
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"kpi_level": k.kpi_level,
"issues": issues,
}
non_compliant.append(entry)
for i in issues:
by_rule.setdefault(i["rule"], []).append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"field": i["field"],
"message": i["message"],
})
rule_counts = {str(r): len(items) for r, items in by_rule.items()}
return {
"total": len(kpis),
"compliant": len(kpis) - len(non_compliant),
"non_compliant_count": len(non_compliant),
"rule_counts": rule_counts,
"by_rule": {str(r): items for r, items in by_rule.items()},
"non_compliant": non_compliant,
}
+818 -23
View File
@@ -7,8 +7,18 @@ from datetime import datetime
import json
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role, filter_kpis_by_role, kpi_visible_dims
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog
from app.models import StrategicMap, MapObjective, KPIDefinition, KPIValue, KPIAlert, OperationLog, Entity, KPICausality, KPIHierarchy, User
from app.utils.oplog import log_op
from app.utils.alert_entity_scope import month_period_clause
from app.utils.kpi_value_scope import actual_only_clause
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
from app.utils.threshold_parser import (
calc_level as _tp_calc_level,
is_valid_threshold,
THRESHOLD_HELP,
)
router = APIRouter(prefix="/api/cma/kpis", tags=["KPI字典"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
@@ -26,6 +36,8 @@ def list_kpis(
keyword: Optional[str] = None,
epic: Optional[str] = None,
category: Optional[str] = None,
entity_id: int = Depends(get_entity_id),
kpi_level: Optional[str] = None,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
@@ -35,16 +47,25 @@ def list_kpis(
if dims:
query = query.filter(KPIDefinition.dimension.in_(dims))
if dimension:
query = query.filter(KPIDefinition.dimension == dimension)
dims_list = [d.strip() for d in dimension.split(',')] if ',' in dimension else [dimension]
query = query.filter(KPIDefinition.dimension.in_(dims_list))
if keyword:
query = query.filter(KPIDefinition.kpi_name.contains(keyword))
if epic:
query = query.filter(KPIDefinition.epic == epic)
if category:
query = query.filter(KPIDefinition.category == category)
cats_list = [c.strip() for c in category.split(',')] if ',' in category else [category]
query = query.filter(KPIDefinition.category.in_(cats_list))
if entity_id is not None:
query = query.filter(KPIDefinition.entity_id == entity_id)
if kpi_level:
query = query.filter(KPIDefinition.kpi_level == kpi_level)
total = query.count()
kpis = query.order_by(KPIDefinition.kpi_code).offset((page-1)*page_size).limit(page_size).all()
return {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
result = {"total": total, "page": page, "page_size": page_size, "data": [kpi_to_dict(k) for k in kpis]}
if entity_id is not None:
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if ent:
result["entity"] = {"id": ent.id, "name": ent.name, "short_name": ent.short_name}
return result
@router.get("/categories")
@@ -89,64 +110,834 @@ def get_kpi_categories(current_user = Depends(require_auth), db: Session = Depen
return {"tree": tree, "total": sum(dim_counts.values())}
@router.get("/{kpi_id}")
def get_kpi(kpi_id: int, db: Session = Depends(get_db)):
# ============================================================
# KPI-5: 五档评分引擎(静态路由必须在动态/{kpi_id}之前)
# ============================================================
# ── 反向指标(值越低越好)· 单一事实源(single source of truth) ──
# 本表是「指标方向」的唯一权威定义(2026-09-15 M2 准入2 收口):凡按方向判级别/评分的代码
# 一律引用本表,禁止再复制字面量副本。
# - app/api/dashboard.py「工作台三色」原自带一份副本 → 已改为 from app.api.kpis import
# - app/api/bot_kpis.py 原 import 本表却未使用 → 已改为派生自本表
# 判定语义:在内 = 越低越好(实际/目标 ≤0.5 → 5 分);不在内 = 越高越好(比值 ≥1.2 → 5 分)
# 方向口径注意(两处「返补/返利」现金流方向相反,禁止误合并):
# - C_REBATE_RATE / F_REBATE_RATE = 渠道返补(对外付出,越低越好 → 反向,留在本表)
# - F_FACTORY_REBATE_RATE = 上游厂补率(厂补(捐赠收益)/主营成本,收到越多越好 → 正向,不属本表)
# 2026-09-15 修正:原被误列入本表 → 现网 278.7%(target_value=80)被判 1 分"失效";
# 与其三色阈值文本(green>=100=1.25×target / yellow>=80=target / red<80,越高越好)方向相反。
# P-6 定稿 = 改方向(阈值文本保持不动),修正后同一实测值判 5 分。
REVERSE_INDICATORS = ['C_REBATE_RATE', 'P_BUG_RATE', 'P_REWORK_PCT', 'F_DEBT_RATIO',
'F_COST_RATIO', 'F_AR_DAYS', 'F_REBATE_RATE',
'F_COST_CONTROL_RATE', 'F_INV_DAYS']
def _static_threshold_level(value, kpi) -> str:
"""按 KPI 静态阈值文本判定级别(统一解析器,判定顺序 red→green→yellow),无阈值返回 info。
供保本派生KPI(F_BEP_*)在详情页使用,与 alert_generator 语义一致:
解析与判定逻辑统一收敛到 app.utils.threshold_parser(单一事实源,2026-09-11 修复双实现+红灯遮蔽)。"""
return _tp_calc_level(value, kpi)
def _calc_five_tier_score(current_value, target_value, is_reverse=False):
"""五档评分:1-5分(支持正反向指标)"""
if current_value is None or target_value is None or target_value == 0:
return None, "info"
ratio = current_value / target_value
if is_reverse:
# 反向指标:实际值越低越好
if ratio <= 0.5:
return 5, "success" # 远低于目标→卓越
elif ratio <= 0.8:
return 4, "success" # 低于目标→达标
elif ratio <= 1.0:
return 3, "warning" # 接近目标→预警
elif ratio <= 1.2:
return 2, "danger" # 超过目标→危险
else:
return 1, "danger" # 远超目标→失效
else:
if ratio >= 1.2:
return 5, "success" # 卓越
elif ratio >= 1.0:
return 4, "success" # 达标
elif ratio >= 0.8:
return 3, "warning" # 预警
elif ratio >= 0.5:
return 2, "danger" # 危险
else:
return 1, "danger" # 失效
@router.get("/score")
def get_kpi_score(
entity_id: int = Depends(get_entity_id),
period: Optional[str] = None,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""五档评分引擎 - 返回各KPI评分和BSC四层汇总
评分: 5卓越(≥1.2×目标) 4达标(≥目标) 3预警(≥0.8×目标) 2危险(≥0.5×目标) 1失效(<0.5×目标)
"""
# 获取该企业所有活跃KPI
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
if not kpis:
return {"entity_id": entity_id, "kpis": [], "layers": {}, "overall": None}
# 获取企业信息
ent = db.query(Entity).filter(Entity.id == entity_id).first()
entity_info = {"id": ent.id, "name": ent.name, "short_name": ent.short_name} if ent else {"id": entity_id}
# 单个KPI评分
kpi_scores = []
for k in kpis:
# 取最新实际值
val_query = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
)
if period:
val_query = val_query.filter(KPIValue.period == period)
val_query = val_query.filter(month_period_clause(KPIValue.period))
latest_val = val_query.order_by(KPIValue.period.desc()).first()
current_val = latest_val.actual_value if latest_val else None
score, status = _calc_five_tier_score(current_val, k.target_value, is_reverse=(k.kpi_code in REVERSE_INDICATORS))
kpi_scores.append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"target_value": k.target_value,
"current_value": current_val,
"score": score,
"status": status,
"unit": k.unit,
"weight": 10,
"period": latest_val.period if latest_val else None,
})
# BSC四层汇总
layer_map = {
"finance": {"label": "财务", "order": 0},
"customer": {"label": "客户", "order": 1},
"process": {"label": "流程", "order": 2},
"learning": {"label": "学习成长", "order": 3},
}
layers = {}
total_weighted_score = 0
total_weight = 0
for dim_key, dim_info in layer_map.items():
layer_kpis = [s for s in kpi_scores if s["dimension"] == dim_key and s["score"] is not None]
if not layer_kpis:
layers[dim_key] = {"label": dim_info["label"], "score": None, "status": "info", "kpi_count": 0, "weighted_score": None}
continue
w = sum(k["weight"] for k in layer_kpis)
ws = sum(k["score"] * k["weight"] for k in layer_kpis)
avg_score = ws / w if w > 0 else None
avg_status = "success" if avg_score and avg_score >= 4 else ("warning" if avg_score and avg_score >= 3 else "danger") if avg_score else "info"
layers[dim_key] = {
"label": dim_info["label"],
"score": round(avg_score, 2) if avg_score else None,
"status": avg_status,
"kpi_count": len(layer_kpis),
"weighted_score": round(avg_score, 2) if avg_score else None,
}
if avg_score:
total_weighted_score += avg_score * len(layer_kpis)
total_weight += len(layer_kpis)
# 综合得分
overall_score = round(total_weighted_score / total_weight, 2) if total_weight > 0 else None
overall_status = "success" if overall_score and overall_score >= 4 else ("warning" if overall_score and overall_score >= 3 else "danger") if overall_score else "info"
return {
"entity": entity_info,
"kpis": kpi_scores,
"layers": layers,
"overall": {"score": overall_score, "status": overall_status},
}
# ============================================================
# KPI-glossary: 知识资产化 — KPI字典实时加载(供ChatBI财务Bot调用)
# ============================================================
@router.get("/glossary")
def get_kpi_glossary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""KPI字典实时加载 — 返回所有KPI的定义、当前值、目标值、公式、维度、阈值
供ChatBI财务Bot在分析前调用,确保口径与系统一致。
返回字段: kpi_code, kpi_name, current_value, target_value, formula, dimension, threshold
"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).order_by(KPIDefinition.kpi_code).all()
result = []
for k in kpis:
# 获取最新实际值
latest_val = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.actual_value.isnot(None),
month_period_clause(KPIValue.period),
).order_by(KPIValue.period.desc()).first()
current_value = latest_val.actual_value if latest_val else None
latest_period = latest_val.period if latest_val else None
# 组装阈值描述
threshold = None
if k.threshold_green or k.threshold_yellow or k.threshold_red:
parts = []
if k.threshold_green:
parts.append(f"绿灯:{k.threshold_green}")
if k.threshold_yellow:
parts.append(f"黄灯:{k.threshold_yellow}")
if k.threshold_red:
parts.append(f"红灯:{k.threshold_red}")
threshold = " | ".join(parts)
result.append({
"kpi_id": k.id,
"kpi_code": k.kpi_code,
"kpi_name": k.kpi_name,
"dimension": k.dimension,
"category": k.category,
"formula": k.formula,
"formula_desc": k.formula_desc,
"unit": k.unit,
"target_value": k.target_value,
"current_value": current_value,
"latest_period": latest_period,
"threshold": threshold,
"responsible_dept": k.responsible_dept,
"responsible_user": k.responsible_user,
"data_source": k.data_source,
"data_owner": k.data_owner,
"frequency": k.frequency,
"status": k.status,
})
return {
"entity_id": entity_id,
"total": len(result),
"glossary": result,
}
# ============================================================
# KPI-6: KPI三级分解树
# ============================================================
@router.get("/hierarchy")
def get_kpi_hierarchy(
entity_id: int = Depends(get_entity_id),
kpi_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
):
"""KPI三级分解树:公司→部门→个人"""
query = db.query(KPIHierarchy).filter(KPIHierarchy.entity_id == entity_id)
if kpi_id is not None:
query = query.filter(
(KPIHierarchy.parent_kpi_id == kpi_id) | (KPIHierarchy.child_kpi_id == kpi_id)
)
relations = query.order_by(KPIHierarchy.level).all()
if not relations:
# 无层级数据,返回公司级KPI作为根节点
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).limit(20).all()
return {
"entity_id": entity_id,
"tree": [{"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "level": 1, "children": []} for k in kpis],
"total": len(kpis),
}
# 构建树
kpi_ids = set()
for r in relations:
kpi_ids.add(r.parent_kpi_id)
kpi_ids.add(r.child_kpi_id)
kpi_map = {}
for kid in kpi_ids:
k = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
if k:
kpi_map[kid] = {"id": k.id, "kpi_code": k.kpi_code, "kpi_name": k.kpi_name,
"dimension": k.dimension, "level": None, "children": []}
# 分配层级
for r in relations:
if r.parent_kpi_id in kpi_map:
kpi_map[r.parent_kpi_id]["level"] = 1 # 公司级
if r.child_kpi_id in kpi_map:
current_level = kpi_map[r.child_kpi_id].get("level")
new_level = r.level or 2
if current_level is None or current_level > new_level:
kpi_map[r.child_kpi_id]["level"] = new_level
# 构造父子关系
tree = []
added = set()
for r in relations:
parent = kpi_map.get(r.parent_kpi_id)
child = kpi_map.get(r.child_kpi_id)
if parent and child:
child_node = dict(child)
child_node["weight"] = r.weight
child_node["child_name"] = r.child_name
# 避免重复添加
child_key = r.child_kpi_id
existing_child = next(
(c for c in parent["children"] if c["id"] == child_key), None
)
if not existing_child:
parent["children"].append(child_node)
# 收集顶级节点(有子节点且未被引用的parent)
all_child_ids = {r.child_kpi_id for r in relations}
for r in relations:
pid = r.parent_kpi_id
if pid not in all_child_ids or pid == (kpi_id if kpi_id else -1):
if pid not in added and pid in kpi_map:
tree.append(kpi_map[pid])
added.add(pid)
# 如果kpi_id指定,返回该节点为根的子树
if kpi_id is not None and kpi_id in kpi_map:
root = kpi_map[kpi_id]
return {"entity_id": entity_id, "tree": [root], "total": len(tree)}
# 否则按level排序
tree.sort(key=lambda n: (n.get("level") or 99, n["kpi_code"]))
return {"entity_id": entity_id, "tree": tree, "total": len(tree)}
# ============================================================
# KPI-8: KPI因果链追踪
# ============================================================
@router.get("/{kpi_id}/causality-chain")
def get_kpi_causality_chain(
kpi_id: int,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""KPI因果链追踪 — 返回单个KPI的上下游因果链(账套隔离 2026-09-21:跨账套 id → 404)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
# 上游(驱动当前KPI的因子)
upstream = db.query(KPICausality).filter(KPICausality.target_kpi_id == kpi_id).all()
upstream_list = []
for c in upstream:
src = db.query(KPIDefinition).filter(KPIDefinition.id == c.source_kpi_id).first()
if src:
upstream_list.append({
"causality_id": c.id,
"kpi_id": src.id,
"kpi_code": src.kpi_code,
"kpi_name": src.kpi_name,
"dimension": src.dimension,
"layer": src.dimension,
"strength": c.strength,
"lag_months": c.lag_months,
"direction": c.direction,
"formula": c.formula,
})
# 下游(当前KPI影响的指标)
downstream = db.query(KPICausality).filter(KPICausality.source_kpi_id == kpi_id).all()
downstream_list = []
for c in downstream:
tgt = db.query(KPIDefinition).filter(KPIDefinition.id == c.target_kpi_id).first()
if tgt:
downstream_list.append({
"causality_id": c.id,
"kpi_id": tgt.id,
"kpi_code": tgt.kpi_code,
"kpi_name": tgt.kpi_name,
"dimension": tgt.dimension,
"layer": tgt.dimension,
"strength": c.strength,
"lag_months": c.lag_months,
"direction": c.direction,
"formula": c.formula,
})
return {
"kpi": {
"id": kpi.id,
"kpi_code": kpi.kpi_code,
"kpi_name": kpi.kpi_name,
"dimension": kpi.dimension,
"layer": kpi.dimension,
},
"drives": downstream_list,
"driven_by": upstream_list,
"total_upstream": len(upstream_list),
"total_downstream": len(downstream_list),
}
# ============================================================
# 动态路由(必须在静态路由之后)
# ============================================================
@router.post("/{kpi_id}/values")
def create_kpi_value(
kpi_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user: User = Depends(require_auth),
):
"""录入KPI单值(人工数据录入,用于客户/流程/学习层台账数据)
Body: {period: '2026-08', actual_value: 85}
"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
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, month_period_clause(KPIValue.period)
).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 "",
# P0-① 三区标注: C区Bot回填时间
"updated_at": v.calculated_at.strftime("%Y-%m-%d %H:%M") if v.calculated_at else None,
} 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),
# 实测区口径:预估行禁入趋势序列(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.period.asc()).all()
is_reverse = kpi.kpi_code in REVERSE_INDICATORS
target = kpi.target_value
# 保本派生KPI(F_BEP_*)使用静态阈值文本判定红黄绿(对齐 KPIList 预警tag语义),
# 不走 target 比率——保本线是参考值不是完成率目标(cma-bep-kpi 2026-09-08)
use_static_threshold = kpi.kpi_code.startswith("F_BEP_") and (
kpi.threshold_green or kpi.threshold_yellow or kpi.threshold_red)
result["values"] = []
for v in vals:
# 判定红黄绿(对齐工作台语义:正向≥0.9绿/≥0.7黄/否则红;反向≤目标绿/≤1.1倍黄/否则红)
level = "info"
score = None
if v.actual_value is not None:
if use_static_threshold:
level = _static_threshold_level(v.actual_value, kpi)
elif target:
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,
# P0-① 三区标注: C区Bot回填时间(UI BotSourceTag 消费)
"updated_at": v.calculated_at.strftime("%Y-%m-%d %H:%M") if v.calculated_at else None,
})
return result
def _validate_kpi_data(data: dict, db: Session, current_kpi_id: Optional[int] = None, is_update: bool = False):
"""数据治理4条规则校验(入库必检+元数据+编码规范+战略分级),返回错误信息列表"""
issues = validate_kpi_payload(data, db=db, current_kpi_id=current_kpi_id, is_update=is_update)
return kpi_issues_message(issues)
# ════════════════════════════════════════════════════════════
# KPI多粒度目标:指标类型推断 + 周期目标派生(docs/kpi-design-rule.md 落地)
# 规则:累计型 月×3=季、月×12=年(季×4=年);比率型 季/年沿用基准(可手调)
# 派生为"虚拟展示值":DB只存用户手填真值,API返回时补派生值+derived标记
# ════════════════════════════════════════════════════════════
RATIO_NAME_HINTS = ['率', '比', '满意度', '周转', '时长', '周期', '天数', '指数', 'NPS', 'LTV', 'CAC',
'份额', '集中度', '响应', '完成', '达成', '人均', '单价', '净推荐', '覆盖', '保留',
'复购', '转介绍', '投诉', '合规', '认证', '掌握', '胜任', '认知', '采纳', '引用',
'复用', '一致性', '准确', '间隙', '时效', '及时']
ACCUM_NAME_HINTS = ['营收', '收入', '利润', '净利', '销售', '客户数', '新客', '新增', '产量', '销量',
'金额', '现金流', '回款', '毛利额', '产值', '储备', '数量', '篇数', '报告产出',
'提案', '发现数', '知识沉淀', '招待费']
RATIO_UNIT_HINTS = ['%', '倍', '天', '分', '小时', '分钟']
ACCUM_UNIT_HINTS = ['万元', '元', '个', '件', '人', '篇', '份', '万']
def infer_calc_type(kpi_code: str = "", kpi_name: str = "", unit: str = "") -> str:
"""推断指标类型: accumulate累计(可乘) / ratio比率(不可乘)。名称关键词优先于单位"""
n = (kpi_name or "") + " " + (kpi_code or "")
u = unit or ""
if any(k in n for k in RATIO_NAME_HINTS):
return "ratio"
if any(k in n for k in ACCUM_NAME_HINTS):
return "accumulate"
if u in RATIO_UNIT_HINTS or u.startswith("小时"):
return "ratio"
if u in ACCUM_UNIT_HINTS:
return "accumulate"
return "ratio" # 兜底比率(率值不能乘,更安全)
def derive_cycle_targets(kpi) -> dict:
"""按指标类型派生月/季/年目标(虚拟值,不落库)。
返回: {"derived": {monthly/quarterly/yearly: 显示值}, "flags": {monthly/quarterly/yearly: 是否派生}}
"""
calc_type = (getattr(kpi, "target_calc_type", None) or infer_calc_type(
kpi.kpi_code or "", kpi.kpi_name or "", kpi.unit or "")).lower()
m = kpi.target_monthly
q = kpi.target_quarterly
y = kpi.target_yearly
freq = (kpi.frequency or "monthly").lower()
# 基准值(考核周期优先,回退 target_value)
base = None
if freq == "yearly":
base = y
elif freq in ("quarterly", "half_year"):
base = q
elif freq in ("monthly", "weekly"):
base = m
if base is None:
base = kpi.target_value
# 无基准值则不派生
if base is None:
return {"derived": {"monthly": m, "quarterly": q, "yearly": y},
"flags": {"monthly": False, "quarterly": False, "yearly": False}}
dm, dq, dy = m, q, y
fm, fq, fy = False, False, False
if calc_type == "accumulate":
# 锚点月值:手填月目标优先;月基准且手填月空时用 target_value 回退
anchor_m = dm
if anchor_m is None and base is not None and freq in ("monthly", "weekly"):
anchor_m = base
if anchor_m is not None:
if dm is None:
dm = anchor_m # target_value 回退显示为月基准
if dq is None:
dq, fq = anchor_m * 3, True
if dy is None:
dy, fy = anchor_m * 12, True
elif dq is not None:
# 季基准(累计型):年=季×4;月不反推(避免小数噪声)
if dy is None:
dy, fy = dq * 4, True
else: # ratio:季/年沿用基准,不乘
if dq is None:
dq, fq = base, True
if dy is None:
dy, fy = base, True
return {"derived": {"monthly": dm, "quarterly": dq, "yearly": dy},
"flags": {"monthly": fm, "quarterly": fq, "yearly": fy}}
def apply_calc_type_inference(data: dict, infer_missing: bool = True) -> dict:
"""create/update 前:未显式传 target_calc_type 时按名称/单位推断。
infer_missing=False(update场景):仅当用户显式传了空值时推断,未传则保留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
THRESHOLD_FIELDS = ("threshold_green", "threshold_yellow", "threshold_red")
def validate_threshold_format(data: dict) -> List[str]:
"""阈值表达式格式校验(2026-09-11):非法格式返回错误文本,让保存被拒而不是静默失效。
与 app.utils.threshold_parser 共用同一套语法(支持负数/区间 A~B/多条件 |)。
"""
errs: List[str] = []
for f in THRESHOLD_FIELDS:
if f in data and not is_valid_threshold(data.get(f)):
errs.append(f"阈值格式非法 [{f}={data.get(f)}]:{THRESHOLD_HELP}")
return errs
@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)
errs = errs + validate_threshold_format(data)
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)
# create 侧字段白名单(OpenCode 审查残留项修复 2026-09-07):
# 仿 update 白名单 + kpi_code(新建必填),拦截 status/important_flag/主键等敏感字段注入
# (update 2026-08-31 已有白名单;create 此前直接 KPIDefinition(**data) 展开,可被注入)
_create_allow = UPDATE_KPI_WHITELIST | {"kpi_code"}
for k in list(data.keys()):
if k != "entity_id" and k not in _create_allow:
data.pop(k, None)
kpi = KPIDefinition(**data)
db.add(kpi)
db.commit()
db.refresh(kpi)
_log(db, 1, "create", "kpi", kpi.id, data)
_log(db, 1, "create", "kpi", kpi.id, data, entity_id=entity_id)
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)
errs = errs + validate_threshold_format(data)
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)
db.commit()
_log(db, 1, "update", "kpi", kpi_id, data)
_log(db, 1, "update", "kpi", kpi_id, data, entity_id=entity_id)
return kpi_to_dict(kpi)
@router.delete("/{kpi_id}")
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
def delete_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi:
# 账套隔离: 禁止跨企业删除 (2026-08-23 P1a)
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
kpi.status = "disabled"
db.commit()
return {"message": "已删除"}
@router.put("/{kpi_id}/restore")
def restore_kpi(kpi_id: int, db: Session = Depends(get_db), user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if kpi:
# 账套隔离: 禁止跨企业恢复 (2026-08-23 P1a)
if kpi.entity_id != entity_id:
raise HTTPException(404, "KPI不存在")
kpi.status = "active"
db.commit()
return {"message": "已恢复"}
def kpi_to_dict(k):
return {c.name: getattr(k, c.name) for c in k.__table__.columns}
d = {c.name: getattr(k, c.name) for c in k.__table__.columns}
# 多粒度目标派生:月/季/年显示值 + derived标记(虚拟,不落库)
try:
der = derive_cycle_targets(k)
d["derived_targets"] = der["derived"]
d["derived_flags"] = der["flags"]
except Exception:
d["derived_targets"] = {"monthly": k.target_monthly, "quarterly": k.target_quarterly, "yearly": k.target_yearly}
d["derived_flags"] = {"monthly": False, "quarterly": False, "yearly": False}
# 附加战略地图信息
if k.map_id:
from app.database import get_session_local
try:
sess = get_session_local()()
m = sess.query(StrategicMap).filter(StrategicMap.id == k.map_id).first()
d["map_title"] = m.title if m else None
sess.close()
except:
d["map_title"] = None
else:
d["map_title"] = None
return d
def _log(db, user_id, action, target_type, target_id, detail):
log = OperationLog(user_id=user_id, action=action, target_type=target_type, target_id=target_id, detail=json.dumps(detail, ensure_ascii=False) if detail else None)
db.add(log)
@router.put("/{kpi_id}/associate-map")
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""关联KPI到战略地图(账套隔离 2026-09-21:KPI 与地图均须属本账套,跨账套 → 404)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
map_id = data.get("map_id")
if map_id is not None:
m = db.query(StrategicMap).filter(
StrategicMap.id == map_id,
StrategicMap.entity_id == entity_id,
).first()
if not m:
raise HTTPException(404, "战略地图不存在")
kpi.map_id = map_id
db.commit()
_log(db, 1, "update", "kpi", kpi_id, {"action": "associate-map", "map_id": map_id},
entity_id=entity_id)
return kpi_to_dict(kpi)
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None):
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
return log_op(db, action, target_type=target_type, target_id=target_id,
detail=detail, user_id=user_id, entity_id=entity_id, commit=True)
@router.get("/{kpi_id}/objectives")
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
"""查看KPI所属的目标和战略地图"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查看KPI所属的目标和战略地图(账套隔离 2026-09-21:跨账套 id → 404)
鉴权口径:本 router 级 `dependencies=[Depends(require_role(...))]` 已覆盖本端点
(无 token → 401),此处不再重复挂 require_auth,只补缺失的账套收口。
"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
@@ -159,15 +950,19 @@ def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
}
if kpi.map_id:
m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first()
m = db.query(StrategicMap).filter(
StrategicMap.id == kpi.map_id, StrategicMap.entity_id == entity_id,
).first()
if m:
result["map"] = {"id": m.id, "title": m.title, "status": m.status}
if kpi.objective:
objs = db.query(MapObjective).filter(
MapObjective.map_id == kpi.map_id,
MapObjective.entity_id == entity_id,
MapObjective.name == kpi.objective,
).all()
result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs]
return result
+74
View File
@@ -0,0 +1,74 @@
"""AI获客 - 客户咨询接口"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import json, os, datetime, subprocess, urllib.request
router = APIRouter(prefix="/api/cma/lead", tags=["AI获客"])
# ai-cs 实盘分流脚本(跨 profile 调用)
# 必须以 ai-cs 身份 + 其 HERMES_HOME/HOME 运行:脚本内用 `hermes send` 复用该 profile 的企微凭据;
# cma-backend.service 的 Environment 为空,不显式传入会解析到非 ai-cs 的 profile(root 默认 profile)。
AI_CS_USER = "ai-cs"
AI_CS_HOME = "/var/lib/wecom-ai-cs/profile"
AI_CS_ROUTING_SCRIPT = AI_CS_HOME + "/scripts/routing-live.py"
class LeadRequest(BaseModel):
name: str
phone: str
requirement: str = ""
source: str = "sxbh.ltd"
def notify_wecom(name, phone, requirement, source):
"""推送到企微全员群"""
msg = {
"msgtype": "markdown",
"markdown": {
"content": f"## 🔔 新客户咨询\n**姓名**: {name}\n**电话**: {phone}\n**需求**: {requirement}\n**来源**: {source}\n**时间**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}"
}
}
try:
data = json.dumps(msg).encode('utf-8')
req = urllib.request.Request(
"http://127.0.0.1:8800/send",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
urllib.request.urlopen(req, timeout=5)
except:
pass # 通知失败不影响主流程
def notify_boss(name, phone, requirement):
"""额外推给任总(经 ai-cs 实盘分流脚本;失败不影响主流程)"""
try:
subprocess.run(
["runuser", "-u", AI_CS_USER, "--",
"env", f"HOME={AI_CS_HOME}", f"HERMES_HOME={AI_CS_HOME}",
f"USER={AI_CS_USER}", f"LOGNAME={AI_CS_USER}",
"/usr/bin/python3", AI_CS_ROUTING_SCRIPT,
requirement or "(未填需求)",
f"{name} {phone}",
"website:sxbh.ltd"],
timeout=30, capture_output=True,
)
except Exception:
pass
@router.post("")
def create_lead(data: LeadRequest):
"""接收客户咨询并保存"""
record = data.model_dump()
record["timestamp"] = datetime.datetime.now().isoformat()
record["status"] = "new"
log_dir = "/root/leads"
os.makedirs(log_dir, exist_ok=True)
with open(f"{log_dir}/leads.json", "a") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
# 异步推送企微通知
notify_wecom(data.name, data.phone, data.requirement, data.source)
# 加性:额外推给任总 DM(口径与 ai-cs 客服分流一致)
notify_boss(data.name, data.phone, data.requirement)
return {"success": True, "message": "咨询已提交,我们将在30分钟内联系您"}
+179
View File
@@ -0,0 +1,179 @@
"""战略地图 · 数据完备度 API(口径唯一真源:CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0)
主口径(对外唯一主数):仅地图 65(已发布)15/19 = 78.9%,判据 = 被 23-KPI 集挂接。
附注口径(只作附注):全量 4 图 30/31 = 96.8%,判据 = 有KPI挂接(含归档图)。
KPI 级:active 行 117(e1=73 / e2=44),按行计,与目标级分列、不相除。
所有汇总数一律运行时对库内数据现算;判据数据(23-KPI 挂接关系)见
app/data/caliber_map_completeness.py。两种禁用组合(仅地图65且按 kpis 非空、全量31且按命中 23-KPI 集)在本模块无生成路径。
"""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth
from app.data import caliber_map_completeness as cal
from app.database import get_db
from app.deps import get_entity_id
from app.models import KPIDefinition, MapObjective, StrategicMap
router = APIRouter(
prefix="/api/cma/maps/completeness",
tags=["战略地图数据完备度"],
dependencies=[Depends(require_auth)],
)
def _pct(n: int, d: int) -> Optional[float]:
"""占比只保留 1 位小数;分母为 0 返回 None(展示为「无数据」)。"""
if not d:
return None
return round(n * 100.0 / d, 1)
def _entity_scopes(db: Session, entity_id: int):
"""返回(主口径地图, 同属主的地图列表)。主口径地图仅认 map_id=65 且属于该实体。"""
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).all()
primary = next((m for m in maps if m.id == cal.PRIMARY_MAP_ID), None)
return primary, maps
def _objective_rows(db: Session, maps):
"""拉取给定地图的战略目标,并逐行标记 23-KPI 挂接 / 是否有 KPI。"""
map_ids = [m.id for m in maps]
if not map_ids:
return [], {}
title_by_map = {m.id: m.title for m in maps}
status_by_map = {m.id: (m.status or "") for m in maps}
objs = (
db.query(MapObjective)
.filter(MapObjective.map_id.in_(map_ids))
.order_by(MapObjective.map_id, MapObjective.sort_order, MapObjective.id)
.all()
)
rows = []
for o in objs:
kpis = o.kpis if isinstance(o.kpis, list) else []
rows.append({
"objective_id": o.id,
"map_id": o.map_id,
"map_title": title_by_map.get(o.map_id),
"map_status": status_by_map.get(o.map_id),
"layer": o.dimension_key,
"layer_label": cal.LAYER_LABELS.get(o.dimension_key, o.dimension_key),
"name": o.name,
"kpis": kpis,
"has_kpi": len(kpis) > 0,
"covered_23kpi": o.id in cal.LINKED_OBJECTIVES,
})
return rows, title_by_map
@router.get("/summary")
def completeness_summary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""数据完备度总览:目标级(主数 + 附注)与 KPI 级(117 行)两层分列。"""
primary_map, maps = _entity_scopes(db, entity_id)
rows, _ = _objective_rows(db, maps)
# ---- 目标级 · 主口径:仅地图 65,判据 = 被 23-KPI 集挂接 ----
if primary_map is not None:
main_rows = [r for r in rows if r["map_id"] == cal.PRIMARY_MAP_ID]
main_total = len(main_rows)
main_covered = sum(1 for r in main_rows if r["covered_23kpi"])
primary = {
"available": True,
"caliber": cal.PRIMARY_CALIBER,
"label": cal.PRIMARY_LABEL,
"map_id": primary_map.id,
"map_title": primary_map.title,
"map_status": primary_map.status,
"covered": main_covered,
"total": main_total,
"pct": _pct(main_covered, main_total),
"display": f"{main_covered}/{main_total}",
}
else:
primary = {
"available": False,
"caliber": cal.PRIMARY_CALIBER,
"label": cal.PRIMARY_LABEL,
"map_id": cal.PRIMARY_MAP_ID,
"covered": None,
"total": None,
"pct": None,
"display": "—",
}
# ---- 目标级 · 附注口径:全量 4 图,判据 = 有 KPI 挂接 ----
# 判据复现(definition 件 §5 机读件 20261018-c1-spec/map-objectives-dump.csv 逐行):
# 23KPI 覆盖 26 + 非23KPI 但 map_objectives.kpis 非空 4 + 缺口 1 = 30/31。
# 即「被 23-KPI 集挂接」与「kpis 非空」取并集;与主口径判据(仅 23-KPI 挂接)不是同一判据。
# 附注与主数同属地图 65 的账套(全量 4 图);无地图 65 的账套不适用,展示「—」而非 0。
footnote_total = len(rows)
footnote_covered = sum(1 for r in rows if r["covered_23kpi"] or r["has_kpi"])
footnote_available = primary_map is not None and bool(rows)
footnote = {
"available": footnote_available,
"caliber": cal.FOOTNOTE_CALIBER,
"label": cal.FOOTNOTE_LABEL,
"covered": footnote_covered if footnote_available else None,
"total": footnote_total if footnote_available else None,
"pct": _pct(footnote_covered, footnote_total) if footnote_available else None,
"display": f"{footnote_covered}/{footnote_total}" if footnote_available else "—",
}
# ---- KPI 级:active 按行(e1=73 / e2=44,合计 117;去重编码 102 另册)----
kpi_rows = (
db.query(KPIDefinition)
.filter(KPIDefinition.status == "active")
.all()
)
by_entity: dict = {}
codes = set()
for k in kpi_rows:
by_entity[k.entity_id] = by_entity.get(k.entity_id, 0) + 1
codes.add(k.kpi_code)
kpi_level = {
"caliber": cal.KPI_CALIBER,
"label": cal.KPI_LABEL,
"total": len(kpi_rows),
"by_entity": [
{"entity_id": eid, "count": cnt} for eid, cnt in sorted(by_entity.items())
],
"distinct_codes": len(codes),
"note": "按 kpi_code 去重为独立口径,不与行数相除或换算",
}
return {
"caliber_id": cal.CALIBER_ID,
"caliber_version": cal.CALIBER_VERSION,
"entity_id": entity_id,
"applicable": primary_map is not None,
"scope_note": "主口径仅覆盖地图 65(已发布);附注口径含归档图,判据不同,不得互换算",
"objective_level": {"primary": primary, "footnote": footnote},
"kpi_level": kpi_level,
}
@router.get("/objectives")
def completeness_objectives(
map_id: Optional[int] = None,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""数据完备度清单(目标级逐行):供空态清单与补齐排期使用。"""
_, maps = _entity_scopes(db, entity_id)
if map_id is not None:
maps = [m for m in maps if m.id == map_id]
rows, _ = _objective_rows(db, maps)
return {
"caliber_id": cal.CALIBER_ID,
"caliber_version": cal.CALIBER_VERSION,
"entity_id": entity_id,
"caliber": cal.FOOTNOTE_CALIBER,
"rows": rows,
}
+600
View File
@@ -0,0 +1,600 @@
# -*- coding: utf-8 -*-
"""战略地图 · 维度切片(时间 / 门店 / 产品)—— A3
目的:让战略地图的 KPI 能按维度切片重算,而不是永远只显示总额/最新值。
口径(与既有读路径保持一致,不另造口径):
- 实测区:复用 `app.utils.kpi_value_scope.actual_only_clause()`,预估行不得进入切片。
- 指标方向:优先 `kpi_definitions.direction`;缺失时退回
`app.utils.strategy_board.INVERSE_KPI_IDS` 显式白名单(不用 NULL 推断)。
- 状态类:`unit == '状态'` ⇒ 1 = 达标 / 0 = 未达标,不做除法。
- 达成率阈值沿用地图现有语义:≥90% success / ≥60% warning / <60% danger。
三维可用性(以库内事实为准,不假装有数据):
- 时间:`kpi_values.period`(YYYY / YYYY-Hn / YYYY-MM)真实存在,可切。
- 产品:`product_sales`(仅 entity 1,2026-01…2026-08,按 `product_name`)真实存在;
只对「产品级可取数」的 KPI(PRODUCT_SCOPED_CODES)生效,其余 KPI 显式不可用。
- 门店:库内**无**门店级事实表(无 store/dept 字段,`voucher_details` 无辅助核算),
仅能从 `subjects.subject_name` 反推门店名称 ⇒ 提供候选值但标记不可用;
切片结果一律 `available=false`,**绝不回退总额**。
空态契约:所选维度下没有某 KPI 的实测值时,返回 `actual_value=null` +
`available=false` + `reason`;绝不把总额/其它期间的值填进来。
"""
from __future__ import annotations
import json
import re
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.auth_middleware import require_role
from app.database import get_db
from app.deps import get_entity_id
from app.models import KPIDefinition, KPIValue, MapObjective, ProductSales, StrategicMap
from app.utils.kpi_value_scope import actual_only_clause
from app.utils.strategy_board import INVERSE_KPI_IDS, STATUS_UNIT
router = APIRouter(
prefix="/api/cma/maps",
tags=["战略地图维度切片"],
dependencies=[Depends(require_role("ceo", "finance"))],
)
#: 目前只有营业收入的月度总额来自 product_sales(已对拍:2026-01…07 与 KPI 值逐月相等)
PRODUCT_SCOPED_CODES = frozenset({"F_REVENUE"})
#: 流量型单位:季度/年度切片按「月份求和」
FLOW_UNITS = frozenset({"万元", "元", "个", "次", "轮", "件", "人"})
#: 达成率 → 色阶(与地图现有 legend 一致)
SUCCESS_RATE = 0.9
WARNING_RATE = 0.6
_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$")
_QUARTER_RE = re.compile(r"^\d{4}-Q[1-4]$")
_YEAR_RE = re.compile(r"^\d{4}$")
_STORE_SUFFIX_RE = re.compile(r"(吴忠地标店|吴忠隆泰店|新区大店|地标店|一店|二店)")
_STORE_DISPLAY_ORDER = ["一店", "二店", "吴忠地标店", "新区大店", "吴忠隆泰店"]
#: 存量/余额型指标:区间切片取「最新一期」,禁止按月求和(否则余额被累加)
BALANCE_HINTS = ("余额", "库存", "去化", "货币资金", "借款", "应收", "应付", "资产", "负债", "占用", "头寸")
#: 产品源与 KPI 实测值的允许偏差(超过即判源未对齐,不参与切片)
PRODUCT_SOURCE_TOLERANCE = 0.01
# ── 小工具 ────────────────────────────────────────────────
def _obj_kpi_codes(raw: Any) -> list[str]:
"""map_objectives.kpis 兼容四种存储形态:list[str] / json 字符串 / list[dict] / NULL。"""
if raw is None:
return []
data = raw
if isinstance(raw, str):
try:
data = json.loads(raw)
except (TypeError, ValueError):
return []
if not isinstance(data, list):
return []
out: list[str] = []
for item in data:
if isinstance(item, str):
out.append(item)
elif isinstance(item, dict):
code = item.get("kpi_code") or item.get("code")
if code:
out.append(str(code))
return out
def _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
def _map_kpis(db: Session, map_id: int, entity_id: int):
"""返回 (map_objectives, {kpi_code: KPIDefinition}),KPI 只取本实体(账套隔离)。"""
objs = db.query(MapObjective).filter(
MapObjective.map_id == map_id, MapObjective.entity_id == entity_id,
).order_by(MapObjective.sort_order, MapObjective.id).all()
codes: set[str] = set()
for o in objs:
codes.update(_obj_kpi_codes(o.kpis))
defs: dict[str, KPIDefinition] = {}
if codes:
for k in db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id, KPIDefinition.kpi_code.in_(codes),
).all():
defs[k.kpi_code] = k
return objs, defs
def _parse_period(period: Optional[str]) -> tuple[Optional[str], Optional[str]]:
"""返回 (粒度, 归一值):year / quarter / month;非法或空返回 (None, None)。"""
if not period:
return None, None
p = period.strip()
if _MONTH_RE.match(p):
return "month", p
if _QUARTER_RE.match(p):
return "quarter", p
if _YEAR_RE.match(p):
return "year", p
return None, None
def _months_of(period_type: str, period: str) -> list[str]:
if period_type == "month":
return [period]
year = period[:4]
if period_type == "quarter":
q = int(period[-1])
start = (q - 1) * 3 + 1
return [f"{year}-{m:02d}" for m in range(start, start + 3)]
return [f"{year}-{m:02d}" for m in range(1, 13)]
def _unit_kind(unit: Optional[str], kpi: Optional[KPIDefinition] = None) -> str:
u = (unit or "").strip()
if u == STATUS_UNIT:
return "status"
name = (getattr(kpi, "kpi_name", "") or "") + " " + (getattr(kpi, "kpi_code", "") or "")
if any(h in name for h in BALANCE_HINTS):
return "balance"
if u in FLOW_UNITS:
return "flow"
return "ratio"
def _is_lower_better(kpi: KPIDefinition) -> bool:
direction = (getattr(kpi, "direction", None) or "").strip().lower()
if direction in ("lower_better", "lower", "desc", "down"):
return True
if direction in ("higher_better", "higher", "asc", "up"):
return False
return kpi.id in INVERSE_KPI_IDS
def _level_from_ratio(ratio: Optional[float]) -> str:
if ratio is None:
return "gray"
if ratio >= SUCCESS_RATE:
return "green"
if ratio >= WARNING_RATE:
return "yellow"
return "red"
def _base_target(kpi: KPIDefinition) -> Optional[float]:
for attr in ("target_monthly", "target_value"):
v = getattr(kpi, attr, None)
if v is not None:
try:
return float(v)
except (TypeError, ValueError):
continue
return None
def _scaled_target(kpi: KPIDefinition, period_type: str, year_period: Optional[str], db: Session, entity_id: int) -> tuple[Optional[float], str]:
"""目标值:年切片优先取 kpi_targets 的年度行;否则按单位缩放月度目标。
返回 (target, target_basis)。
"""
if year_period:
row = db.execute(text(
"SELECT target_value FROM kpi_targets WHERE entity_id=:e AND kpi_id=:k "
"AND period=:p AND target_type IN ('yearly','annual') ORDER BY target_type='yearly' DESC LIMIT 1"
), {"e": entity_id, "k": kpi.id, "p": year_period}).fetchone()
if row and row[0] is not None:
return float(row[0]), f"kpi_targets.{year_period}.yearly"
base = _base_target(kpi)
if base is None:
return None, "no_target"
kind = _unit_kind(kpi.unit, kpi)
if kind == "status":
return None, "status_no_target"
if kind == "balance":
return base, "target_balance"
if period_type == "month":
return base, "target_monthly"
if period_type == "quarter":
return (base * 3 if kind == "flow" else base), "target_monthly×3"
return (base * 12 if kind == "flow" else base), "target_monthly×12"
def _aggregate(rows: list[KPIValue], unit: Optional[str], kpi: Optional[KPIDefinition] = None) -> tuple[Optional[float], str, list[str], bool]:
"""把区间内的实测行聚合成一个切片值。
返回 (value, aggregation, months_used, partial)。
- 状态类 / 存量余额类:取最新一期(不求和)
- 流量型:求和
- 比率/天数:均值
"""
if not rows:
return None, "none", [], True
ordered = sorted(rows, key=lambda v: (str(v.period), v.id))
months = [str(v.period) for v in ordered]
kind = _unit_kind(unit, kpi)
if kind in ("status", "balance"):
last = ordered[-1]
return (float(last.actual_value) if last.actual_value is not None else None), "latest", [str(last.period)], False
vals = [float(v.actual_value) for v in ordered if v.actual_value is not None]
if not vals:
return None, "none", months, True
if kind == "flow":
return round(sum(vals), 4), "sum_of_months", months, False
return round(sum(vals) / len(vals), 4), "mean_of_months", months, False
# ── 维度候选值 ────────────────────────────────────────────
def _time_options(db: Session, kpi_ids: list[int], entity_id: int) -> dict:
if not kpi_ids:
return {"available": False, "reason": "该地图未绑定 KPI", "years": [], "quarters": [], "months": []}
rows = db.query(KPIValue.period).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id.in_(kpi_ids),
actual_only_clause(),
).distinct().all()
periods = [str(r[0]) for r in rows if r[0]]
months = sorted(p for p in periods if _MONTH_RE.match(p))
quarters = sorted(p for p in periods if _QUARTER_RE.match(p))
years = sorted({p[:4] for p in periods})
if months:
for y in sorted({m[:4] for m in months}):
for q in range(1, 5):
qq = f"{y}-Q{q}"
if qq not in quarters and any(m in _months_of("quarter", qq) for m in months):
quarters.append(qq)
quarters.sort()
return {
"available": bool(periods),
"reason": None if periods else "该地图 KPI 无实测值(kpi_values 为空)",
"years": years,
"quarters": quarters,
"months": months,
"granularity": ["year", "quarter", "month"],
}
def _store_options(db: Session, entity_id: int) -> dict:
rows = db.execute(text(
"SELECT DISTINCT subject_code, subject_name FROM subjects WHERE entity_id=:e AND subject_name LIKE '%店%'"
), {"e": entity_id}).fetchall()
found: set[str] = set()
for _code, name in rows:
for hit in _STORE_SUFFIX_RE.findall(str(name or "")):
found.add("吴忠地标店" if hit == "地标店" else hit)
values = [{"code": f"BR-{i+1:02d}", "name": n} for i, n in enumerate(
[n for n in _STORE_DISPLAY_ORDER if n in found] or sorted(found)
)]
return {
"available": False,
"reason": "该维度暂无数据:库内无门店级事实表(无 store/dept 字段,凭证无辅助核算),"
"门店名称仅能从费用类科目名称反推,无法按门店切 KPI;不回退总额",
"values": values,
"source": "subjects.subject_name 反推(仅费用类科目)",
"coverage": "expense_subjects_only",
}
def _product_options(db: Session, entity_id: int) -> dict:
rows = db.query(ProductSales.product_name).filter(
ProductSales.entity_id == entity_id,
).distinct().all()
names = sorted({str(r[0]) for r in rows if r[0]})
if not names:
return {"available": False, "reason": "该实体无产品级销售数据(product_sales 为空)", "values": [], "kpi_scope": sorted(PRODUCT_SCOPED_CODES)}
span = db.query(ProductSales.period_month).filter(
ProductSales.entity_id == entity_id,
).distinct().all()
months = sorted({str(r[0]) for r in span if r[0]})
return {
"available": True,
"reason": None,
"values": [{"code": n, "name": n} for n in names],
"kpi_scope": sorted(PRODUCT_SCOPED_CODES),
"period_coverage": {"min": months[0] if months else None, "max": months[-1] if months else None, "months": months},
"source": "product_sales.product_name(按 period_month 聚合,元→万元)",
}
# ── 切片计算 ──────────────────────────────────────────────
def _slice_kpi_month(db: Session, kpi: KPIDefinition, entity_id: int, period: str) -> tuple[Optional[float], Optional[str]]:
rows = db.query(KPIValue).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
actual_only_clause(),
).order_by(KPIValue.id.desc()).all()
if not rows:
return None, f"{period} 无实测值"
# 同期多行:优先 verified/calculated(strategy_board 的同口径排序)
rank = {"verified": 3, "calculated": 2, "pending": 1}
best = sorted(rows, key=lambda v: rank.get((v.data_status or "").lower(), 0), reverse=True)[0]
return (float(best.actual_value) if best.actual_value is not None else None), None
def _slice_kpi_period(db: Session, kpi: KPIDefinition, entity_id: int, ptype: str, period: str) -> tuple[Optional[float], str, list[str], Optional[str]]:
"""时间切片:月=精确值;季/年=区间聚合(年优先取 YYYY 行)。"""
if ptype == "month":
val, err = _slice_kpi_month(db, kpi, entity_id, period)
return val, "exact", [period], err
if ptype == "year":
val, err = _slice_kpi_month(db, kpi, entity_id, period)
if val is not None:
return val, "exact", [period], None
months = _months_of(ptype, period)
rows = db.query(KPIValue).filter(
KPIValue.entity_id == entity_id,
KPIValue.kpi_id == kpi.id,
KPIValue.period.in_(months),
actual_only_clause(),
).all()
val, agg, used, _ = _aggregate(rows, kpi.unit, kpi)
expected = 3 if ptype == "quarter" else 12
if val is None:
return None, "none", [], f"{period} 无实测值(区间内无月度数据)"
partial = len(used) < expected and agg != "latest"
note = f"{period} 仅 {len(used)}/{expected} 个月有实测值" if partial else None
return val, agg, used, note
def _slice_kpi_product(db: Session, kpi: KPIDefinition, entity_id: int, product: str, ptype: str, period: str) -> tuple[Optional[float], str, list[str], Optional[str]]:
"""产品切片:仅对 PRODUCT_SCOPED_CODES 生效,并按「逐月源一致性」闸门。
`product_sales` 月合计与 KPI 同月实测值必须吻合(±1%)才纳入;不吻合的月份
视为源未对齐(例如 2026-08 产品表 8.08 万 vs KPI 14.45 万),
不做缩放、不并入,避免用产品表冒充 KPI 总额。
"""
if kpi.kpi_code not in PRODUCT_SCOPED_CODES:
return None, "unsupported", [], "该 KPI 无产品级取数(维度未落库),不回退总额"
months = _months_of(ptype, period)
# 逐月源一致性:product_sales 全量合计(元) vs KPI 实测值(万元)
totals = db.query(ProductSales.period_month, ProductSales.sales_amount).filter(
ProductSales.entity_id == entity_id, ProductSales.period_month.in_(months),
).all()
by_month: dict[str, float] = {}
for pm, amt in totals:
by_month[str(pm)] = by_month.get(str(pm), 0.0) + float(amt or 0)
kpi_month: dict[str, float] = {}
for m in months:
v, _err = _slice_kpi_month(db, kpi, entity_id, m)
if v is not None:
kpi_month[m] = float(v) * 10000.0 # 万元 → 元
covered = []
misaligned = []
for m in months:
if m not in by_month or m not in kpi_month:
continue
base = kpi_month[m]
if base == 0:
continue
if abs(by_month[m] - base) / abs(base) <= PRODUCT_SOURCE_TOLERANCE:
covered.append(m)
else:
misaligned.append(m)
if not covered:
detail = ""
if misaligned:
detail = "(源未对齐月份:" + "、".join(misaligned) + ")"
return None, "uncovered", [], f"产品表与 KPI 实测值不一致{detail},该期间不做产品切片、不回退总额"
rows = db.query(ProductSales).filter(
ProductSales.entity_id == entity_id,
ProductSales.product_name == product,
ProductSales.period_month.in_(covered),
).all()
if not rows:
return None, "none", [], f"产品「{product}」在 {'、'.join(covered)} 无销售数据"
used = sorted({r.period_month for r in rows})
total = sum(float(r.sales_amount or 0) for r in rows) / 10000.0 # 元 → 万元
note = None
if len(covered) < len(months):
note = f"仅 {'、'.join(covered)} 通过源一致性校验" + (f";未对齐:{'、'.join(misaligned)}" if misaligned else "")
return round(total, 4), "sum_of_product_sales", used, note
def build_slice(db: Session, entity_id: int, map_id: int, period: Optional[str], store: Optional[str], product: Optional[str]) -> dict:
_map_or_404(db, map_id, entity_id)
objs, defs = _map_kpis(db, map_id, entity_id)
ptype, pnorm = _parse_period(period)
if period and not ptype:
raise HTTPException(400, "period 格式应为 YYYY / YYYY-Qn / YYYY-MM")
if not pnorm:
# 未选时间:取该地图 KPI 的最新实测期(沿用现有「最新值」语义,并显式标注)
latest = None
ids = [k.id for k in defs.values()]
if ids:
rows = db.query(KPIValue.period).filter(
KPIValue.entity_id == entity_id, KPIValue.kpi_id.in_(ids), actual_only_clause(),
).all()
cand = [str(r[0]) for r in rows if r[0]]
months = sorted(p for p in cand if _MONTH_RE.match(p))
pnorm = months[-1] if months else (sorted(cand)[-1] if cand else None)
if pnorm:
ptype = "month" if _MONTH_RE.match(pnorm) else ("quarter" if _QUARTER_RE.match(pnorm) else "year")
if latest is None and not pnorm:
latest = None
ptype = ptype or "month"
pnorm = pnorm or ""
dims_meta = {
"time": {"available": bool(pnorm), "period": pnorm, "type": ptype,
"reason": None if pnorm else "该地图 KPI 无实测值"},
"store": _store_options(db, entity_id) if store else {"available": True, "selected": None},
"product": _product_options(db, entity_id) if product else {"available": True, "selected": None},
}
if store:
dims_meta["store"]["selected"] = store
if product:
dims_meta["product"]["selected"] = product
objectives: list[dict] = []
any_available = False
empty_reasons: list[str] = []
for obj in objs:
codes = [c for c in _obj_kpi_codes(obj.kpis) if c in defs]
kpi_rows: list[dict] = []
worst = "green"
for code in codes:
kpi = defs[code]
target = None
basis = "no_period"
actual = None
agg = "none"
used: list[str] = []
reason: Optional[str] = None
if store:
reason = "该维度暂无数据:无门店级 KPI 事实表,不回退总额"
elif not pnorm:
reason = "无可用实测期间"
elif product:
actual, agg, used, reason = _slice_kpi_product(db, kpi, entity_id, product, ptype, pnorm)
target, basis = _scaled_target(kpi, ptype, pnorm[:4] if ptype == "year" else None, db, entity_id)
# 产品切片只覆盖通过源一致性校验的月份 ⇒ 流量型目标按覆盖月数缩放
if actual is not None and _unit_kind(kpi.unit, kpi) == "flow" and used:
base = _base_target(kpi)
if base is not None:
target = round(base * len(used), 4)
basis = f"target_monthly×{len(used)}(覆盖月数)"
else:
actual, agg, used, note = _slice_kpi_period(db, kpi, entity_id, ptype, pnorm)
target, basis = _scaled_target(kpi, ptype, pnorm[:4] if ptype == "year" else None, db, entity_id)
reason = note
available = actual is not None
act = float(actual) if actual is not None else None
tgt = float(target) if target is not None else None
ratio = None
level = "gray"
if act is not None and _unit_kind(kpi.unit) == "status":
ratio = 1.0 if act >= 1 else 0.0
level = "green" if ratio == 1.0 else "red"
elif act is not None and tgt:
if _is_lower_better(kpi):
ratio = (tgt / act) if act > 0 else None
else:
ratio = act / tgt if tgt != 0 else None
level = _level_from_ratio(ratio)
if reason and available and agg != "exact":
# 部分月份可用:值可用但必须带口径说明
pass
if level == "red":
worst = "red"
elif level == "yellow" and worst != "red":
worst = "yellow"
elif level == "gray" and worst == "green" and not available:
worst = "gray"
if not available:
empty_reasons.append(reason or f"{code} 无数据")
any_available = any_available or available
kpi_rows.append({
"kpi_id": kpi.id,
"kpi_code": code,
"kpi_name": kpi.kpi_name,
"unit": kpi.unit,
"actual_value": actual,
"target_value": target,
"target_basis": basis,
"achievement_pct": (round(ratio * 100, 1) if ratio is not None else None),
"level": level,
"available": available,
"reason": reason,
"aggregation": agg,
"periods_used": used,
})
objectives.append({
"objective_id": obj.id,
"dimension_key": obj.dimension_key,
"name": obj.name,
"kpis": kpi_rows,
"level": worst if kpi_rows else "gray",
"has_data": any(k["available"] for k in kpi_rows),
})
empty = not any_available
empty_reason = None
if empty:
if store:
empty_reason = "该维度暂无数据:库内无门店级 KPI 事实表(G4 未落地),不回退总额"
elif product:
specific = next((r for r in empty_reasons if "产品" in r), None)
empty_reason = specific or "该维度暂无数据:所选产品未覆盖该地图 KPI,不回退总额"
elif not pnorm:
empty_reason = "该地图 KPI 无实测值"
else:
empty_reason = empty_reasons[0] if empty_reasons else "所选切片无实测值"
return {
"map_id": map_id,
"entity_id": entity_id,
"slice": {"period": pnorm, "period_type": ptype, "store": store, "product": product},
"empty": empty,
"empty_reason": empty_reason,
"dims": dims_meta,
"objectives": objectives,
"caliber": {
"actual_scope": "实测区(预估行排除)",
"achievement_thresholds": {"success": SUCCESS_RATE, "warning": WARNING_RATE},
"aggregation": {"flow": "sum_of_months", "ratio": "mean_of_months", "status": "latest"},
"no_fallback": "维度无数据时返回 null,不用总额/其它期间冒充",
},
}
# ── 路由 ──────────────────────────────────────────────────
@router.get("/{map_id}/dimension-options")
def get_dimension_options(
map_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""战略地图三维候选值(时间真实可切;门店/产品按库内事实显式标注可用性)。"""
_map_or_404(db, map_id, entity_id)
_objs, defs = _map_kpis(db, map_id, entity_id)
ids = [k.id for k in defs.values()]
return {
"map_id": map_id,
"entity_id": entity_id,
"time": _time_options(db, ids, entity_id),
"store": _store_options(db, entity_id),
"product": _product_options(db, entity_id),
"kpi_count": len(defs),
}
@router.get("/{map_id}/slice")
def get_map_slice(
map_id: int,
period: Optional[str] = Query(None, description="YYYY / YYYY-Qn / YYYY-MM;空=最新实测期"),
store: Optional[str] = Query(None, description="门店名称;库内无门店级事实时显式空态"),
product: Optional[str] = Query(None, description="产品名称(product_sales)"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""按 时间/门店/产品 切片重算地图 KPI(只读;无数据 ⇒ null + 原因,绝不回退总额)。"""
return build_slice(db, entity_id, map_id, period, store, product)
+238
View File
@@ -0,0 +1,238 @@
"""战略地图 · KPI 摘要接口(真数据 · 口径 CMA-A1 终稿,2026-10-19)
口径唯一真源:app/data/revenue_caliber.py(含科目映射与禁写清单)
本模块**不存任何金额常量**:全部对 voucher_details 现场聚合。
取数铁律:
1. 必经 `summary NOT LIKE '%结转%'` —— 否则损益双向抵销,收入恒为 0(历史踩坑);
2. 实体隔离:只取 token 绑定的 entity_id,不跨账套;
3. 无数据 ⇒ null + missing_reason,**不得兜底造数**;
4. 分业务线分列;禁净额;禁合并成单一「营业收入」(合计仅作可复算校验位)。
历史期缺同票折扣发票(四件套 0/4)⇒ 金额带 `data_status="estimated"` +
`data_tag="测算/待票"`,不得静默按新式重算冒充实账。
"""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.auth_middleware import require_auth
from app.data import revenue_caliber as rc
from app.database import get_db
from app.deps import get_entity_id
router = APIRouter(
prefix="/api/cma/maps/summary",
tags=["战略地图摘要"],
dependencies=[Depends(require_auth)],
)
MONTH_RE = "^[0-9]{4}-[0-9]{2}$"
NOT_CARRY = "summary NOT LIKE '%结转%'"
def _wan(v) -> Optional[float]:
"""元 → 万元(2 位),None 透传。"""
if v is None:
return None
return round(float(v) / 10000.0, 2)
def _period_window(db: Session, entity_id: int):
"""取该实体最新有凭证的会计月,窗口 = 当年 01 ~ 最新月(不写死日期)。"""
latest = db.execute(text(
f"SELECT MAX(period) FROM voucher_details "
f"WHERE entity_id = :e AND period REGEXP :re AND {NOT_CARRY}"
), {"e": entity_id, "re": MONTH_RE}).scalar()
if not latest:
return None, None, None
year = str(latest)[:4]
return f"{year}-01", str(latest), year
def _sum(db: Session, sql: str, params: dict):
return db.execute(text(sql), params).scalar()
@router.get("")
def map_summary(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""摘要条数据:分业务线营业收入 / 毛利率(新式)/ 管理指标 / 口径切换标识。"""
cfg = rc.SUBJECT_MAP.get(entity_id)
p0, p1, year = _period_window(db, entity_id)
period_label = f"{p0}~{p1}" if p0 else None
if cfg is None or p0 is None:
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"lines": [],
"missing_reason": "该账套无可用凭证期" if cfg else "未配置科目映射",
},
"gross_margin": {"available": False, "value": None, "missing_reason": "无数据"},
"management": [],
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
}
base = (f"entity_id = :e AND period >= :p0 AND period <= :p1 AND {NOT_CARRY}")
params = {"e": entity_id, "p0": p0, "p1": p1}
wine = rc.wine_sql("summary")
rev_prefix = cfg["revenue"] + "%"
# ── 1. 收入:主营科目按业务线分列(全额售价,禁净额)──────────────────
revenue = {
"available": False,
"caliber": rc.CALIBER,
"caliber_label": rc.CALIBER_LABEL,
"unit": "万元",
"period": period_label,
"lines": [],
}
if cfg["lines"]:
# 表 1:酒类线(keywords 命中)
wine_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": rev_prefix})
# 表 2:非酒类线(同一科目、不命中 keywords)
other_net = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND NOT {wine}
""", {**params, "pfx": rev_prefix})
# e1 => [酒类, 其他];e2 => [自营(IT)=非酒类, 代销(酒类)=酒类]
values = {}
if entity_id == 1:
values = {"liquor": wine_net, "other": other_net}
elif entity_id == 2:
values = {"self_it": other_net, "agency_liquor": wine_net}
lines = []
for ln in cfg["lines"]:
raw = values.get(ln["key"])
lines.append({
"key": ln["key"],
"label": ln["label"],
"amount": _wan(raw),
"unit": "万元",
"data_status": "verified" if raw is not None else "missing",
"missing_reason": None if raw is not None else "该业务线本期无入账凭证",
})
total = _wan((wine_net or 0) + (other_net or 0)) if (wine_net is not None or other_net is not None) else None
revenue = {
**revenue,
"available": any(l["amount"] is not None for l in lines),
"lines": lines,
"total": total,
"total_label": "合计(各线相加,仅作复算校验)",
"merge_forbidden": True,
"note": "各业务线全额售价分列;禁净额、禁合并为单一「营业收入」",
}
# ── 2. 毛利率:新式 (收入净额 − 折扣后净进价成本) / 收入净额 ────────────
gross_margin = {"available": False, "value": None, "missing_reason": "无数据"}
rev_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": rev_prefix})
cost_total = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["cost"] + "%"})
disc_total = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
if rev_total and float(rev_total) != 0 and cost_total is not None:
net_cost = float(cost_total) - float(disc_total or 0)
gm = (float(rev_total) - net_cost) / float(rev_total) * 100.0
gross_margin = {
"available": True,
"value": round(gm, 2),
"unit": "%",
"caliber": "net_purchase",
"caliber_label": "新式·折扣后净进价",
"formula": "(收入净额 − 折扣后净进价成本) / 收入净额 × 100",
"period": period_label,
# 四件套 0/4 ⇒ 历史期缺同票折扣发票,只可显示现值并带标识
"data_status": "estimated",
"data_tag": "测算/待票",
"note": "旧式(含 k 的 5001/571103 组合)已作废;历史缺票期不得冒充实账",
}
# ── 3. 管理指标(非会计科目,不进损益、不进摘要条主位)───────────────
management = []
if entity_id == 2:
# 与 A1 回执可复现口径一致:分母取**折让前贷方发生额**(639,944.00),
# 而不是 6603 净额(556,279.15,已抵扣借方)——两者差 28.24% 与 24.55% 之别。
disc = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx AND {wine}
""", {**params, "pfx": cfg["purchase_discount"] + "%"})
base_amt = _sum(db, f"""
SELECT SUM(credit_amount - debit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE '2202%' AND summary LIKE '%采购收货%'
AND {rc.supplier_sql('summary')}
""", params)
if disc is not None and base_amt and float(base_amt) != 0:
management.append({
"key": "vendor_discount_dependency",
"label": "厂商折扣依赖度",
"value": round(abs(float(disc)) / float(base_amt) * 100.0, 2),
"unit": "%",
"numerator": round(abs(float(disc)), 2),
"numerator_label": "酒类采购折扣额",
"denominator": round(float(base_amt), 2),
"denominator_label": "酒类采购额(含税)",
"period": period_label,
"tag": "管理指标·非会计科目",
"caliber_note": "折扣额 ÷ 酒类采购额(含税);改造单 v4 §3 文字写「÷销售额」,"
"但 28.24% 的复现分母是采购额,按可复现口径披露,分母已单列可复核",
})
elif entity_id == 1 and cfg.get("purchase_gift") and rev_total:
gift = _sum(db, f"""
SELECT SUM(debit_amount - credit_amount) FROM voucher_details
WHERE {base} AND subject_code LIKE :pfx
""", {**params, "pfx": cfg["purchase_gift"] + "%"})
if gift is not None and float(rev_total) != 0:
val = round(abs(float(gift)) / float(rev_total) * 100.0, 2)
management.append({
"key": "vendor_gift_dependency",
"label": "厂商赠酒依赖度",
"value": val,
"unit": "%",
"numerator": round(abs(float(gift)), 2),
"numerator_label": "采购获赠额",
"denominator": round(float(rev_total), 2),
"denominator_label": "营业收入(同期)",
"period": period_label,
"tag": "管理指标·非会计科目",
"abnormal": val > 100,
"caliber_note": "获赠额 ÷ 同期营业收入;>100% 属异常,须数据侧核查(不作损益行)",
})
return {
"entity_id": entity_id,
"period": {"from": p0, "to": p1, "label": period_label},
"revenue": revenue,
"gross_margin": gross_margin,
"management": management,
"switch": rc.CALIBER_SWITCH,
"missing_docs": rc.MISSING_DOCS,
"caliber_source": {
"status": rc.STATUS,
"decided_by": rc.DECIDED_BY,
"reason": rc.REASON,
},
}
+1255 -57
View File
File diff suppressed because it is too large Load Diff
+51 -8
View File
@@ -2,11 +2,26 @@
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.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role
from app.auth_middleware import require_role, require_auth_401
from datetime import datetime
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"])
# ── B2(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)────────────────
# 本模块两张表在 `models/__init__.py` 均**无 entity_id 列**,按「分表处置」口径落地:
# · `notification_logs`(Log 类)→ **经父键 join 归属**,零迁移:
# alert_id → kpi_alerts.id → kpi_alerts.entity_id(GET /logs 已收口)。
# · `notification_channels`(**配置类,无父键可桥接**)→ 任总 2026-09-22 已批
# 「方案 1(零 DDL)」:**保持全局 = 集团共享配置**(一套 wecom/mail/sms 渠道
# 供全账套复用;预警推送是集团行为,渠道与账套无关)。
# ⚠️ 因此 `/channels*` 四个端点**有意不按账套过滤**——这是口径,不是漏隔离。
# 若后续需要「账套私有渠道」,须单独立单加 entity_id 列(DDL + 回填 + 唯一键改造)。
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
router = APIRouter(prefix="/api/cma/notifications", tags=["通知配置"],
dependencies=[Depends(require_auth_401)])
def ch_to_dict(c):
@@ -48,8 +63,10 @@ def update_channel(ch_id: int, data: dict, db: Session = Depends(get_db)):
ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch:
raise HTTPException(404, "渠道不存在")
# 字段白名单(2026-09-21 mass-assignment 收口):仅业务字段,其余键忽略
_updatable = ("name", "config", "enabled")
for k, v in data.items():
if hasattr(ch, k) and k not in ("id", "created_at"):
if k in _updatable:
setattr(ch, k, v)
db.commit()
db.refresh(ch)
@@ -91,14 +108,40 @@ def test_channel(ch_id: int, db: Session = Depends(get_db)):
@router.get("/logs")
def list_logs(page: int = 1, db: Session = Depends(get_db)):
"""通知历史"""
total = db.query(NotificationLog).count()
logs = db.query(NotificationLog).order_by(
NotificationLog.created_at.desc()
def list_logs(page: int = 1,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db)):
"""通知历史(**按会话账套收口**,B2 零迁移)
2026-09-22 dama-workbench-entity-isolation-fullstack-20260922(§四 B2):
`notification_logs` 无 entity_id 列 → 经父键 join 归属:
`notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id`。
口径与 batch5 对 `KpiDataQualityLog` 的处置一致(宁少勿错):
`alert_id IS NULL` 的行无可归属父键 → **不返回**(不暴露给任一账套)。
"""
from app.models import KPIAlert
scoped = (db.query(NotificationLog)
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
.filter(KPIAlert.entity_id == entity_id))
total = scoped.count()
# 排序补**确定性次键** `id DESC`(任务 0-P3-project-20260925-cma-same-class-pagination-determinism-fullstack):
# 通知日志由预警推送批量写入,同一秒多行时 MySQL filesort 在并列组内顺序不受控 ⇒ 单键
# `created_at DESC` + offset/limit 会让偏移分页重复并漏行。两键后偏移分页成为全序分页。
# 同族先例:app/api/alerts.py(e369508)、app/api/cash.py:165、app/api/data_quality.py:230。
# 本行**不改**任何账套收口口径(join 归属 / `alert_id IS NULL` 不返回 / policy+note 文案)。
logs = scoped.order_by(
NotificationLog.created_at.desc(), NotificationLog.id.desc()
).offset((page - 1) * 20).limit(20).all()
return {
"total": total,
"entity_id": entity_id,
# 口径字段(2026-09-22 DAMA 回查 §5 收口):上线后本页在无预警推送的账套会显示 0 条,
# 属口径而非故障——现有 8 行历史记录全是 `alert_id IS NULL` 的 OKR 复盘提醒(无账套归属),
# 按「宁少勿错」不返回。前端据此文案提示,避免被当成页面坏了。
"policy": "alert-linked-only",
"note": ("仅显示与预警推送关联的记录(按父键 kpi_alerts 归属会话账套)。"
"无父键的历史通知(如 OKR 复盘提醒,写入方 backend/scripts/okr_review_reminder.py)"
"不属于任何账套,不在本列表返回。口径登记:docs/audit-and-notification-caliber-20260922.md"),
"data": [{
"id": l.id,
"alert_id": l.alert_id,
+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(
+579
View File
@@ -0,0 +1,579 @@
"""
OKR目标管理 API — 季度目标 + 关键结果 + KPI联动
KR完整修复(2026-08-27): KR读取从ActionPlan改为krs表 + 方向符号operator + 权重
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, timedelta, date as date_type
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role
from app.models import Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition
from app.utils.kr_name import sanitize_kr_name
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
# ── KR 工具函数 ─────────────────────────────────────────
VALID_OPERATORS = (">=", "<=", ">", "<", "=")
def _num(v):
"""Decimal → float 便于 JSON 序列化"""
return float(v) if v is not None else None
def _parse_operator(raw):
"""解析方向符号: 支持 >=/<=/>/</=,非法默认 >="""
if not raw:
return ">="
op = str(raw).strip()
if op in VALID_OPERATORS:
return op
# 兼容中文符号 ≥ ≤
if op in ("≥",):
return ">="
if op in ("≤",):
return "<="
return ">="
def _eval_kr(kr: KR) -> bool:
"""达成判断: 实际值满足 operator → True"""
if kr.current_value is None or kr.target_value is None:
return False
cur = float(kr.current_value)
tgt = float(kr.target_value)
op = _parse_operator(kr.operator)
if op == ">=":
ok = cur >= tgt
elif op == "<=":
ok = cur <= tgt
elif op == ">":
ok = cur > tgt
elif op == "<":
ok = cur < tgt
elif op == "=":
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
ok = abs(cur - tgt) <= tol
else:
ok = False
return ok
def _calc_progress(kr: KR) -> int:
"""方向感知进度计算:
>=/>: current/target*100
<=/<: target/current*100
= : 容差内100
"""
if kr.current_value is None or kr.target_value is None:
return kr.progress or 0
cur = float(kr.current_value)
tgt = float(kr.target_value)
if tgt == 0:
return 100 if cur == 0 else (100 if cur > 0 else 0)
op = _parse_operator(kr.operator)
if op in (">=", ">"):
pct = cur / tgt * 100
elif op in ("<=", "<"):
# 值越小进度越高(如费用率<=18%)
if cur <= tgt:
pct = 100.0
else:
pct = max(0.0, tgt / cur * 100)
elif op == "=":
tol = float(kr.tolerance) if kr.tolerance is not None else 0.0
if abs(cur - tgt) <= tol:
pct = 100.0
else:
pct = max(0.0, 100.0 - abs(cur - tgt) / (abs(tgt) or 1) * 100)
else:
pct = cur / tgt * 100
return max(0, min(100, round(pct)))
def _refresh_kr_state(kr: KR):
"""按当前值刷新 progress + status(方向感知)"""
if kr.current_value is not None and kr.target_value is not None:
kr.progress = _calc_progress(kr)
if _eval_kr(kr):
kr.status = "achieved"
elif kr.progress > 0:
kr.status = "in_progress"
return kr
def _inherit_kpi_direction(db: Session, kpi_id, operator, target_value):
"""关联KPI时自动继承方向: 从KPI的threshold_green解析(如 '>=800' / '<=18')"""
if not kpi_id:
return operator, target_value
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return operator, target_value
threshold = (kpi.threshold_green or "").strip()
if not threshold:
return operator, target_value
op = operator
tgt = target_value
for cand in VALID_OPERATORS:
if threshold.startswith(cand):
op = cand
try:
tgt = float(threshold[len(cand):].strip())
except (ValueError, TypeError):
pass
break
else:
# 无符号时尝试解析纯数字
try:
tgt = float(threshold)
except (ValueError, TypeError):
pass
return op, tgt
def _kr_serialize(kr: KR, db: Session = None) -> dict:
"""KR → dict(含operator/weight/tolerance)"""
mkpi = None
if db and kr.metric_kpi_id:
mkpi = db.query(KPIDefinition).filter(KPIDefinition.id == kr.metric_kpi_id).first()
return {
"id": kr.id,
"objective_id": kr.objective_id,
"title": kr.title,
"metric_kpi_id": kr.metric_kpi_id,
"metric_kpi_code": mkpi.kpi_code if mkpi else None,
"metric_kpi_name": mkpi.kpi_name if mkpi else None,
"operator": _parse_operator(kr.operator),
"tolerance": _num(kr.tolerance),
"weight": _num(kr.weight),
"target_value": _num(kr.target_value),
"current_value": _num(kr.current_value),
"progress": kr.progress,
"status": kr.status,
"due_date": kr.due_date.isoformat() if kr.due_date else None,
"monthly_milestones": kr.monthly_milestones or [],
}
def _get_objective_or_404(db: Session, obj_id: int, entity_id: int) -> Objective:
obj = db.query(Objective).filter(Objective.id == obj_id, Objective.entity_id == entity_id).first()
if not obj:
raise HTTPException(404, "目标不存在")
return obj
# ── 目标列表/详情 ─────────────────────────────────────
@router.get("")
def list_objectives(
quarter: Optional[str] = Query(None, description="筛选季度: 2026Q3"),
dimension: Optional[str] = Query(None),
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""列出OKR目标(账套隔离: 按token企业) — KR读取自krs表(2026-08-27)"""
q = db.query(Objective).filter(Objective.entity_id == entity_id)
if quarter:
q = q.filter(Objective.quarter == quarter)
if dimension:
q = q.filter(Objective.dimension == dimension)
if status:
q = q.filter(Objective.status == status)
objs = q.order_by(Objective.quarter.desc(), Objective.id).all()
results = []
for o in objs:
krs = db.query(KR).filter(KR.objective_id == o.id).order_by(KR.id).all()
kr_summary = [
{"id": kr.id, "title": kr.title, "status": kr.status, "progress": kr.progress,
"operator": _parse_operator(kr.operator), "weight": _num(kr.weight),
"metric_kpi_id": kr.metric_kpi_id}
for kr in krs
]
results.append({
"id": o.id, "title": o.title, "description": o.description,
"dimension": o.dimension, "quarter": o.quarter,
"owner": o.owner, "status": o.status, "progress": o.progress,
"confidence": o.confidence,
"key_results": kr_summary,
"kr_count": len(krs),
"kr_completed": sum(1 for kr in krs if kr.status in ("completed", "achieved")),
"created_at": o.created_at.isoformat() if o.created_at else None,
})
return {"total": len(results), "items": results}
@router.post("")
def create_objective(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""创建OKR目标(支持JSON Body和Query参数两种方式)— 账套隔离: 强制token企业"""
title = data.get("title") or ""
quarter = data.get("quarter") or ""
description = data.get("description")
dimension = data.get("dimension")
owner = data.get("owner")
if not title or not quarter:
raise HTTPException(422, "缺少必填字段: title, quarter")
obj = Objective(title=title, quarter=quarter, description=description,
dimension=dimension, owner=owner, entity_id=entity_id)
db.add(obj)
db.commit()
db.refresh(obj)
return {"ok": True, "id": obj.id, "title": obj.title}
@router.get("/{obj_id}")
def get_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""获取单个OKR详情(账套隔离: 跨企业404) — KR读取自krs表(2026-08-27)"""
obj = _get_objective_or_404(db, obj_id, entity_id)
krs = db.query(KR).filter(KR.objective_id == obj_id).order_by(KR.id).all()
return {
"objective": {
"id": obj.id, "title": obj.title, "description": obj.description,
"dimension": obj.dimension, "quarter": obj.quarter,
"owner": obj.owner, "status": obj.status, "progress": obj.progress,
"confidence": obj.confidence,
},
"key_results": [_kr_serialize(kr, db) for kr in krs],
}
@router.patch("/{obj_id}")
def update_objective(obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""更新OKR进度(通过krs表KR自动计算加权进度)"""
obj = _get_objective_or_404(db, obj_id, entity_id)
krs = db.query(KR).filter(KR.objective_id == obj_id).all()
if krs:
weights = [float(kr.weight) if kr.weight else 1.0 for kr in krs]
total_w = sum(weights) or len(krs)
obj.progress = round(sum(kr.progress * w for kr, w in zip(krs, weights)) / total_w)
db.commit()
return {"ok": True, "id": obj_id, "progress": obj.progress}
# ── KR CRUD ───────────────────────────────────────────
@router.post("/{objective_id}/krs")
def create_kr(objective_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""新增KR(落krs表)— title/metric_kpi_id/operator/target_value/weight/progress/due_date
关联KPI时自动继承方向(threshold_green)
"""
obj = _get_objective_or_404(db, objective_id, entity_id)
title = data.get("title") or ""
if not title:
raise HTTPException(422, "缺少必填字段: title")
operator, target_value = _inherit_kpi_direction(
db, data.get("metric_kpi_id"), data.get("operator"), data.get("target_value"))
if target_value is None and operator != "=":
# 没有目标值也能建(后续补),但明确提示
pass
kr = KR(
entity_id=entity_id,
objective_id=objective_id,
title=title,
metric_kpi_id=data.get("metric_kpi_id") or None,
operator=_parse_operator(operator),
tolerance=data.get("tolerance"),
weight=data.get("weight"),
target_value=target_value,
current_value=data.get("current_value"),
progress=data.get("progress", 0),
status=data.get("status", "pending"),
due_date=_parse_date(data.get("due_date")),
monthly_milestones=data.get("monthly_milestones") or [],
)
_refresh_kr_state(kr)
db.add(kr)
db.commit()
db.refresh(kr)
update_objective(objective_id, db, entity_id)
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
@router.put("/{objective_id}/krs/sync")
def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""批量同步KR数组(前端保存整个KR列表时用):
body: {"krs": [{id?, title, operator, target_value, weight, kpi_code?, ...}]}
已存在的按id更新;无id且同名则更新;否则新增;不在列表中的删除
注意: 必须声明在 /krs/{kr_id} 之前,避免 'sync' 被解析为 kr_id
"""
obj = _get_objective_or_404(db, objective_id, entity_id)
incoming = data.get("krs", [])
if not isinstance(incoming, list):
raise HTTPException(400, "krs 必须是数组")
existing = {kr.id: kr for kr in db.query(KR).filter(KR.objective_id == objective_id).all()}
keep_ids = set()
now_name = {}
for i, item in enumerate(incoming):
title = (item.get("title") or item.get("name") or "").strip()
if not title:
continue
kpi_id = item.get("metric_kpi_id") or item.get("kpi_id")
# kpi_code → kpi_id 转换
if not kpi_id and item.get("kpi_code"):
kpi = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == item["kpi_code"],
KPIDefinition.entity_id == entity_id).first()
kpi_id = kpi.id if kpi else None
operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value"))
# 护栏(2026-09-16):画布节点/接口传来的 KR 名若带历史污染尾巴(尾部「≥/≤+被截断的数字」)
# 在此规整,防污染经 krs.title 继续传染(判据见 app/utils/kr_name.py)
_tv = item.get("target_value")
title, _polluted = sanitize_kr_name(title, _tv if _tv is not None else target_value)
if _polluted:
logger.warning("KR名称污染已规整: objective=%s %r -> %r (target=%s)",
objective_id, item.get("title") or item.get("name"), title, _tv)
kr = None
if item.get("id") and int(item["id"]) in existing:
kr = existing[int(item["id"])]
elif title in now_name:
kr = now_name[title]
else:
# 按同名找已有
for k in existing.values():
if k.title == title:
kr = k
break
if kr is None:
kr = KR(entity_id=entity_id, objective_id=objective_id, title=title)
db.add(kr)
kr.title = title
kr.metric_kpi_id = kpi_id
kr.operator = _parse_operator(operator)
if item.get("tolerance") is not None:
kr.tolerance = item.get("tolerance")
if item.get("weight") is not None:
kr.weight = item.get("weight")
if item.get("target_value") is not None:
kr.target_value = item.get("target_value")
elif target_value is not None:
kr.target_value = target_value
if item.get("current_value") is not None:
kr.current_value = item.get("current_value")
if item.get("due_date"):
kr.due_date = _parse_date(item.get("due_date"))
if item.get("monthly_milestones") is not None:
kr.monthly_milestones = item.get("monthly_milestones")
_refresh_kr_state(kr)
keep_ids.add(kr.id)
now_name[title] = kr
# 删除不在列表中的
for kid, kr in existing.items():
if kid not in keep_ids:
db.delete(kr)
db.commit()
update_objective(objective_id, db, entity_id)
krs = db.query(KR).filter(KR.objective_id == objective_id).order_by(KR.id).all()
return {"ok": True, "objective_id": objective_id, "krs": [_kr_serialize(kr, db) for kr in krs]}
@router.put("/{objective_id}/krs/{kr_id}")
def update_kr(objective_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""更新KR(含方向/权重/当前值,自动重算progress+status)"""
obj = _get_objective_or_404(db, objective_id, entity_id)
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该目标")
updatable = {"title", "metric_kpi_id", "operator", "tolerance", "weight",
"target_value", "current_value", "progress", "status", "due_date",
"monthly_milestones"}
for k, v in data.items():
if k in updatable and v is not None:
setattr(kr, k, v)
# 关联KPI变化 → 自动继承方向
if "metric_kpi_id" in data or "operator" not in data or "target_value" not in data:
if data.get("metric_kpi_id") and ("operator" not in data or "target_value" not in data):
kr.operator, kr.target_value = _inherit_kpi_direction(
db, data.get("metric_kpi_id", kr.metric_kpi_id),
data.get("operator", kr.operator), data.get("target_value", kr.target_value))
if data.get("due_date"):
kr.due_date = _parse_date(data["due_date"])
_refresh_kr_state(kr)
db.commit()
db.refresh(kr)
update_objective(objective_id, db, entity_id)
return {"ok": True, "id": kr.id, "kr": _kr_serialize(kr, db)}
@router.delete("/{objective_id}/krs/{kr_id}")
def delete_kr(objective_id: int, kr_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""删除KR"""
_get_objective_or_404(db, objective_id, entity_id)
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == objective_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该目标")
db.delete(kr)
db.commit()
update_objective(objective_id, db, entity_id)
return {"ok": True, "deleted": kr_id}
# ── KR里程碑(时间分解) ─────────────────────────────
@router.put("/{okr_id}/decomposition/milestones/{kr_id}")
def update_kr_milestones(okr_id: int, kr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""保存KR的月度里程碑(时间分解视图编辑) — 同步更新KR进度"""
_get_objective_or_404(db, okr_id, entity_id)
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该OKR")
milestones = data.get("milestones")
if not isinstance(milestones, list):
raise HTTPException(400, "milestones 必须是数组")
normalized = []
for ms in milestones:
normalized.append({
"month": ms.get("month", ""),
"label": ms.get("label", ""),
"status": ms.get("status", "pending"),
"target": ms.get("target"),
})
kr.monthly_milestones = normalized
if normalized:
done = sum(1 for ms in normalized if ms.get("status") == "completed")
kr.progress = round(done / len(normalized) * 100)
if kr.progress >= 100:
kr.status = "achieved"
elif kr.status == "achieved" and kr.progress < 100:
kr.status = "in_progress"
db.commit()
return {"ok": True, "kr_id": kr.id, "progress": kr.progress, "milestones": kr.monthly_milestones}
@router.post("/{okr_id}/decomposition/milestones/generate")
def auto_generate_milestones(okr_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""为KR自动生成月度里程碑(按季度剩余月份均分)"""
_get_objective_or_404(db, okr_id, entity_id)
kr_id = data.get("kr_id")
if not kr_id:
raise HTTPException(400, "缺少 kr_id")
kr = db.query(KR).filter(KR.id == kr_id, KR.objective_id == okr_id,
KR.entity_id == entity_id).first()
if not kr:
raise HTTPException(404, "KR不存在或不属于该OKR")
kr.monthly_milestones = _build_auto_milestones(kr)
db.commit()
return {"ok": True, "kr_id": kr.id, "milestones": kr.monthly_milestones}
def _build_auto_milestones(kr: KR) -> list:
"""按截止日期向前均分3个月度里程碑"""
base = kr.due_date or datetime.now() + timedelta(days=90)
if isinstance(base, str):
try:
base = datetime.strptime(base[:10], "%Y-%m-%d").date()
except ValueError:
base = datetime.now().date() + timedelta(days=90)
if isinstance(base, datetime):
base = base.date()
base = base.replace(day=1)
month_keys = []
for i in range(3, 0, -1):
y, m = base.year, base.month - i
while m <= 0:
m += 12
y -= 1
month_keys.append(f"{y:04d}-{m:02d}")
return [
{
"month": mk,
"label": f"里程碑{i + 1}",
"status": "pending",
"target": None,
}
for i, mk in enumerate(month_keys)
]
def _parse_date(v):
"""兼容 str/date/datetime → date"""
if v is None or v == "":
return None
if isinstance(v, date_type):
return v
if isinstance(v, datetime):
return v.date()
try:
return datetime.strptime(str(v)[:10], "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
@router.get("/{okr_id}/decomposition")
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""获取OKR的时间分解视图数据 — KR读取自krs表(2026-08-27)"""
okr = _get_objective_or_404(db, okr_id, entity_id)
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
bsc_o = db.query(Objective).filter(
Objective.dimension == okr.dimension,
Objective.quarter.is_(None),
Objective.entity_id == entity_id,
).first()
# 2. 本OKR的所有KR(krs表)
krs = db.query(KR).filter(KR.objective_id == okr_id).order_by(KR.id).all()
# 3. 当前周的ActionPlan(本周行动计划)
now = datetime.now()
week_start = now - timedelta(days=now.weekday())
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
week_end = week_start + timedelta(days=7)
action_plans = db.query(ActionPlan).filter(
ActionPlan.objective_id == okr_id,
# 账套隔离(2026-09-18 batch3 派生单):objective_id 已由
# _get_objective_or_404 校验归属,但计划侧此前无账套条件 →
# 对端账套计划(含脏行)会出现在本账套分解视图(实测 OBS2)。
# 前置条件:create_plan 必须已写会话账套 entity_id(顺序铁律)。
ActionPlan.entity_id == entity_id,
ActionPlan.due_date.between(week_start, week_end)
).all()
return {
"annual_o": bsc_o.title if bsc_o else None,
"quarterly_o": okr.title,
"krs": [
{
"kr_id": kr.id,
"title": kr.title,
"progress": kr.progress,
"milestones": kr.monthly_milestones or []
}
for kr in krs
],
"weekly_actions": [
{
"id": ap.id,
"title": ap.title,
"status": ap.status,
"deadline": ap.due_date.isoformat() if ap.due_date else None
}
for ap in action_plans
]
}
+335
View File
@@ -0,0 +1,335 @@
"""
OKR模板库 API — 按维度筛选 + 用户自定义模板
⚠️ 集团级共享口径(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,§四 B2)
------------------------------------------------------------------------------------
`okr_templates` 表**无 entity_id 列**,且属**模板/配置类**(无父键可 join 桥接)。
任总 2026-09-22「都批」已按 **方案 1(零 DDL)** 拍板:
**保持全局 = 集团级共享配置** —— 模板库是集团知识资产,一套模板(维度/行业标签/
目标+KR 骨架)供全账套复用;账套差异体现在**使用模板生成的具体目标/KR 行**
(`objectives` / `map_objectives` 均带 entity_id,已隔离),而不是模板本身。
因此本模块的列表/详情/新增/改/删**有意不按账套过滤**——这是口径,不是漏隔离。
若后续确需「账套私有模板」,须单独立单加 `entity_id` 列(DDL + 回填 + 唯一键
`name+dimension` 改造),本单不预设。
"""
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 OKRTemplate, StrategicMap
from app.utils.kr_name import sanitize_kr_name
router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"],
dependencies=[Depends(require_role("ceo", "finance", "it"))],
)
DIMENSION_LAYER_NAMES = {
"finance": "财务层",
"customer": "客户层",
"process": "内部流程层",
"learning": "学习成长层",
}
DIMENSION_LAYER_ICONS = {
"finance": "💰",
"customer": "👥",
"process": "⚙️",
"learning": "📚",
}
DIMENSION_LAYER_COLORS = {
"finance": "#F56C6C",
"customer": "#409EFF",
"process": "#67C23A",
"learning": "#E6A23C",
}
# ── tags 多值标签(辅助检索;主分类仍为 dimension 四泳道)2026-09-11 ──
TAGS_SCHEMA = {
"function": ["销售", "财务", "生产", "HR", "研发", "客服", "供应链"],
"cycle": ["年度", "季度", "月度"],
"goal_type": ["增长", "效率", "合规", "创新", "转型"],
}
def normalize_tags(raw) -> Optional[dict]:
"""校验 + 规范化 tags(固定枚举,防随意值);空值返回 None"""
if raw is None:
return None
if not isinstance(raw, dict):
raise HTTPException(400, "tags 必须是对象,如 {\"function\":[\"销售\"],\"cycle\":[\"季度\"],\"goal_type\":[\"增长\"]}")
out = {}
for key, enum in TAGS_SCHEMA.items():
vals = raw.get(key)
if vals is None:
continue
if isinstance(vals, str):
vals = [v.strip() for v in vals.split(",") if v.strip()]
if not isinstance(vals, list):
raise HTTPException(400, f"tags.{key} 必须是数组")
bad = [v for v in vals if v not in enum]
if bad:
raise HTTPException(400, f"tags.{key} 含非法值 {bad},允许值: {enum}")
if vals:
out[key] = vals
return out or None
def _split_filter(val: Optional[str]):
if not val:
return []
return [v.strip() for v in val.split(",") if v.strip()]
def _match_tags(t, function=None, cycle=None, goal_type=None, tag=None) -> bool:
"""标签过滤(多值 OR 语义:任一命中即算匹配;跨字段为 AND)"""
tags = t.tags or {}
for key, val in (("function", function), ("cycle", cycle), ("goal_type", goal_type)):
wanted = _split_filter(val)
if not wanted:
continue
have = tags.get(key) or []
if not any(w in have for w in wanted):
return False
if tag:
all_vals = set()
for k in TAGS_SCHEMA:
all_vals.update(tags.get(k) or [])
if tag not in all_vals:
return False
return True
def _tmpl_dict(t, with_created=False):
d = {
"id": t.id,
"name": t.name,
"description": t.description,
"dimension": t.dimension,
"layer": t.layer,
"industry_tag": t.industry_tag,
"tags": t.tags,
"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,
}
if with_created:
d["created_at"] = t.created_at.isoformat() if t.created_at else None
return d
@router.get("/tags/schema")
def get_tags_schema():
"""tags 标签体系(固定枚举)— 供前端下拉动态加载"""
return {"schema": TAGS_SCHEMA}
@router.get("")
def list_okr_templates(
dimension: Optional[str] = Query(None, description="按维度筛选: finance/customer/process/learning"),
source: Optional[str] = Query(None, description="按来源筛选: system/user/industry_pack"),
industry_tag: Optional[str] = Query(None, description="按行业标签筛选"),
search: Optional[str] = Query(None, description="按O名称关键词搜索"),
function: Optional[str] = Query(None, description="按职能标签筛选: 销售/财务/生产/HR/研发/客服/供应链(逗号分隔多值)"),
cycle: Optional[str] = Query(None, description="按周期标签筛选: 年度/季度/月度"),
goal_type: Optional[str] = Query(None, description="按目标类型筛选: 增长/效率/合规/创新/转型"),
tag: Optional[str] = Query(None, description="任意标签值筛选(跨 function/cycle/goal_type)"),
active_only: bool = Query(True, description="仅返回启用模板"),
db: Session = Depends(get_db),
):
"""列出 OKR 模板,支持按维度/来源/行业/名称搜索 + tags 多值标签过滤"""
q = db.query(OKRTemplate)
if dimension:
q = q.filter(OKRTemplate.dimension == dimension)
if source:
q = q.filter(OKRTemplate.source == source)
if industry_tag:
q = q.filter(OKRTemplate.industry_tag == industry_tag)
if search:
q = q.filter(OKRTemplate.name.like(f"%{search}%"))
if active_only:
q = q.filter(OKRTemplate.is_active == 1)
templates = q.order_by(OKRTemplate.sort_order, OKRTemplate.id).all()
# tags 过滤在 Python 层完成(JSON 多值 OR 语义 + 兼容 SQLite 测试库)
if function or cycle or goal_type or tag:
templates = [t for t in templates if _match_tags(t, function, cycle, goal_type, tag)]
return {
"total": len(templates),
"items": [_tmpl_dict(t, with_created=True) for t in templates],
}
@router.get("/{template_id}")
def get_okr_template(template_id: int, db: Session = Depends(get_db)):
"""获取单个 OKR 模板详情"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
return _tmpl_dict(t, with_created=True)
@router.post("")
def create_okr_template(data: dict, db: Session = Depends(get_db)):
"""用户自定义 OKR 模板(治理: 同名去重 + owner 标记)"""
name = data.get("name", "").strip()
if not name:
raise HTTPException(400, "模板名称不能为空")
dimension = data.get("dimension", "")
if dimension not in ("finance", "customer", "process", "learning"):
raise HTTPException(400, "维度无效,必须是 finance/customer/process/learning")
preset_krs = data.get("preset_krs", [])
if not isinstance(preset_krs, list) or len(preset_krs) == 0:
raise HTTPException(400, "至少需要一个预设KR")
# P2治理: 同名+同维度去重(防止 id=33 式重复模板,2026-08-21)
dup = db.query(OKRTemplate).filter(
OKRTemplate.name == name,
OKRTemplate.dimension == dimension,
OKRTemplate.is_active == 1,
).first()
if dup:
raise HTTPException(409, f"已存在同名模板「{name}」(#{dup.id}, source={dup.source}),请改用现有模板或改名")
t = OKRTemplate(
name=name,
description=data.get("description", ""),
dimension=dimension,
layer=data.get("layer", "level3"),
industry_tag=data.get("industry_tag", "general"),
tags=normalize_tags(data.get("tags")),
preset_krs=preset_krs,
source="user",
owner="用户自定义",
sort_order=data.get("sort_order", 0),
)
db.add(t)
db.commit()
db.refresh(t)
return {"ok": True, "id": t.id, "name": t.name, "tags": t.tags}
@router.put("/{template_id}")
def update_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)):
"""更新 OKR 模板(含 tags 多值标签;仅传需要变更的字段)"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
if "name" in data:
name = (data.get("name") or "").strip()
if not name:
raise HTTPException(400, "模板名称不能为空")
t.name = name
if "description" in data:
t.description = data.get("description") or ""
if "dimension" in data:
dimension = data.get("dimension")
if dimension not in ("finance", "customer", "process", "learning"):
raise HTTPException(400, "维度无效,必须是 finance/customer/process/learning")
t.dimension = dimension
if "layer" in data:
t.layer = data.get("layer")
if "industry_tag" in data:
t.industry_tag = data.get("industry_tag")
if "preset_krs" in data:
preset_krs = data.get("preset_krs")
if not isinstance(preset_krs, list) or len(preset_krs) == 0:
raise HTTPException(400, "至少需要一个预设KR")
t.preset_krs = preset_krs
if "sort_order" in data:
t.sort_order = data.get("sort_order")
if "is_active" in data:
t.is_active = data.get("is_active")
if "tags" in data:
t.tags = normalize_tags(data.get("tags"))
db.commit()
db.refresh(t)
return {"ok": True, "id": t.id, "name": t.name, "tags": t.tags}
@router.post("/{template_id}/use")
def increment_use_count(template_id: int, db: Session = Depends(get_db)):
"""增加模板使用次数"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
t.use_count = (t.use_count or 0) + 1
db.commit()
return {"ok": True, "use_count": t.use_count}
@router.post("/{template_id}/apply")
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""应用 OKR 模板 — 创建战略地图并填入 O+KR"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
map_title = data.get("title", t.name)
dim = t.dimension
preset_krs = t.preset_krs or []
# 构建 dimensions: 仅包含模板所在的维度层
dimensions = []
for dk in ("finance", "customer", "process", "learning"):
objectives = []
if dk == dim:
objectives.append({
"name": t.name,
"description": t.description or "",
"kpis": [],
"krs": [
{
# 护栏(2026-09-16):模板名 → 新地图 KR 名 透传点,污染名(尾部符号+截断数字)在此规整
"name": sanitize_kr_name(kr.get("name", ""), kr.get("target_value"))[0],
"target_value": kr.get("target_value", ""),
"weight": kr.get("weight", 33),
"metric_kpi_id": kr.get("metric_kpi_id"),
"metric_kpi_code": kr.get("metric_kpi_code"),
}
for kr in preset_krs
],
})
dimensions.append({
"key": dk,
"name": DIMENSION_LAYER_NAMES.get(dk, dk),
"icon": DIMENSION_LAYER_ICONS.get(dk, "📌"),
"color": DIMENSION_LAYER_COLORS.get(dk, "#909399"),
"objectives": objectives,
})
m = StrategicMap(
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1(对齐 maps.py)
title=map_title,
version=data.get("version", "v1.0"),
status="draft",
dimensions=dimensions,
canvas_data={"connections": []},
)
db.add(m)
db.commit()
db.refresh(m)
# 使用 maps API 的 _sync_map_objectives 同步到 map_objectives 表
from app.api.maps import _sync_map_objectives
_sync_map_objectives(m, db)
# 增加模板使用次数
t.use_count = (t.use_count or 0) + 1
db.commit()
return {
"ok": True,
"map_id": m.id,
"title": m.title,
"template_id": template_id,
}
+172
View File
@@ -0,0 +1,172 @@
"""
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)
# ⚠️ 护栏(2026-09-23 账套维度收口批 ②,零结构改动、零读取逻辑改动):
# 该表是唯一一张「语义上必然账套相关、却无账套列」的表 —— **账套维度经 `kpi_id` 继承**
# (`kpi_definitions.entity_id`,本函数先按 `Objective.entity_id` 过滤目标、再按 kpi_id 取映射)。
# **禁止新增按 `subject_code` 反查的读口,禁止按 `subject_code` 批量写的入口**:
# 科目码在标准准则体系里两账套同义(同一 code 可属不同账套),按科目反查/批量写立刻跨账套。
subject_by_code = {
s.subject_code: s.subject_name
for s in db.query(Subject).filter(
Subject.is_active == 1,
# 账套分层(2026-09-23 subjects 加 entity_id 列):全局标准科目 ∪ 本账套私有明细
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id),
).all()
}
kpi_layer = []
for link, kpi in links:
# kpi_id 已由上方 Objective.entity_id 过滤链继承账套维度(见护栏注释),故此处不再按科目口径过滤
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}
+6 -2
View File
@@ -7,9 +7,13 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import RolePermission
from app.auth_middleware import require_auth, require_role
from app.auth_middleware import require_auth, require_role, require_auth_401
router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"])
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:/modules 与 GET /config 无鉴权——无 token 可读全量角色权限矩阵(横向越权情报)。
# 写端点 PUT /config 原有 require_role(ceo,it),保留(router 级为「至少已认证」底线)。
router = APIRouter(prefix="/api/cma/permissions", tags=["权限管理"],
dependencies=[Depends(require_auth_401)])
# 模块定义(所有可配置的模块)
MODULES = [
+927 -2
View File
@@ -1,13 +1,27 @@
"""预测模拟API — 管理会计OS"""
import logging
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Depends, Request, Query
from app.utils.predict_engine import (
cvp_analysis, npv, irr,
sensitivity_analysis, scenario_analysis,
)
from app.utils.cash_forecast_engine import (
forecast_cash_flow, save_forecast_to_db,
calculate_accuracy, generate_scenario_suggestion,
)
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope
from app.utils.alert_entity_scope import month_period_clause
from app.auth_middleware import require_auth_401
from sqlalchemy.orm import Session
logger = logging.getLogger("cma.predict")
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"])
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 本 router 原无任何鉴权依赖:无 token 可调用全部预测/模拟端点(含多个 CPU 密集的
# POST 计算端点与 cash-forecast 落库),既泄露经营数据也构成计算资源滥用面。
# 收口为 require_auth_401(无 token → 401)。Bot 通道(X-BOT-KEY)不走本 router。
router = APIRouter(prefix="/api/cma/predict", tags=["预测模拟"],
dependencies=[Depends(require_auth_401)])
@router.post("/cvp")
@@ -96,3 +110,914 @@ def api_scenario_analysis(data: dict):
raise
except Exception as e:
raise HTTPException(400, f"情景模拟失败: {str(e)}")
@router.post("/cvp-detailed")
def api_cvp_detailed(data: dict):
"""CVP本量利详细分析 — 含改善方案推演和保本图数据 (CMA P2)
⚠️ 情景模拟器(scenario simulation):入参为**假设值**推演(前端滑杆),
非 CMA 实测口径。真实实测口径见派生 KPI `F_BEP_*`(kpi_bep_deriver.py,单一事实源);
响应显式带 source='scenario_simulation' + is_mock=True,前端据此标注「情景模拟·非实测」。
"""
try:
fixed_cost = float(data.get("fixed_cost", 617))
variable_cost_rate = float(data.get("variable_cost_rate", 0.4862))
unit_price = float(data.get("unit_price", 228))
current_volume = float(data.get("current_volume", 5300))
contribution_margin_rate = 1 - variable_cost_rate
breakeven_revenue = round(fixed_cost / contribution_margin_rate, 2)
breakeven_units = round(breakeven_revenue * 10000 / unit_price, 0)
current_revenue = round(current_volume * unit_price / 10000, 2)
current_profit = round(current_revenue * (1 - variable_cost_rate) - fixed_cost, 2)
safety_margin = round((current_revenue - breakeven_revenue) / current_revenue * 100, 2) if current_revenue > 0 else 0
scenarios = [
{"name": "降固定费用至300万", "fixed_cost": 300, "variable_cost_rate": variable_cost_rate,
"breakeven_revenue": round(300 / contribution_margin_rate, 2),
"breakeven_units": round(300 / contribution_margin_rate * 10000 / unit_price, 0)},
{"name": "降变动成本率至30%", "fixed_cost": fixed_cost, "variable_cost_rate": 0.3,
"breakeven_revenue": round(fixed_cost / 0.7, 2),
"breakeven_units": round(fixed_cost / 0.7 * 10000 / unit_price, 0)},
{"name": "两者同时改善", "fixed_cost": 300, "variable_cost_rate": 0.3,
"breakeven_revenue": round(300 / 0.7, 2),
"breakeven_units": round(300 / 0.7 * 10000 / unit_price, 0)},
]
# 保本图数据点
chart_data = []
max_volume = int(max(breakeven_units * 2, current_volume * 3))
step = max(1, int(max_volume / 20))
for vol in range(0, int(max_volume) + step, step):
rev = round(vol * unit_price / 10000, 2)
tc = round(fixed_cost + rev * variable_cost_rate, 2)
chart_data.append({"volume": vol, "revenue": rev, "total_cost": tc, "profit": round(rev - tc, 2)})
return {
"source": "scenario_simulation",
"is_mock": True,
"basis": "user_input_scenario",
"fixed_cost": fixed_cost,
"variable_cost_rate": round(variable_cost_rate * 100, 2),
"unit_price": unit_price,
"contribution_margin_rate": round(contribution_margin_rate * 100, 2),
"breakeven_revenue": breakeven_revenue,
"breakeven_units": int(breakeven_units),
"current_revenue": current_revenue,
"current_profit": current_profit,
"current_volume": int(current_volume),
"safety_margin": safety_margin,
"scenarios": scenarios,
"chart_data": chart_data,
}
except Exception as e:
raise HTTPException(400, f"CVP详细分析失败: {str(e)}")
# ── 相关成本决策(CMA P2商业决策分析25%权重核心) ──────────────────
@router.post("/relevant-decision")
def api_relevant_decision(data: dict):
"""相关成本决策分析(CMA P2 商业决策分析核心内容)
场景: make-or-buy自制外购 / special-order特殊订单 / product-mix产品组合
"""
try:
decision_type = data.get("type", "make_or_buy")
if decision_type == "make_or_buy":
# 自制vs外购决策
# 相关成本 = 增量成本(只有随决策变化的成本才是相关的)
make_var_cost = float(data.get("make_variable_cost", 0)) # 自制单位变动成本
make_fixed = float(data.get("make_fixed_cost", 0)) # 自制新增固定成本
buy_price = float(data.get("buy_price", 0)) # 外购单价
demand = float(data.get("demand", 0)) # 需求量
existing_fixed = float(data.get("existing_fixed_cost", 0)) # 现有固定成本(无关成本,自制不增加则忽略)
make_total = make_var_cost * demand + make_fixed
buy_total = buy_price * demand
diff = buy_total - make_total # >0自制省钱
return {
"type": "自制vs外购",
"make_total_cost": round(make_total, 2),
"buy_total_cost": round(buy_total, 2),
"difference": round(diff, 2),
"recommendation": "自制" if diff > 0 else "外购",
"reason": f"自制总成本{make_total:.2f} vs 外购总成本{buy_total:.2f},{'自制节省' + str(round(diff,2)) if diff > 0 else '外购节省' + str(round(-diff,2))}",
"unit_make_cost": round(make_var_cost + (make_fixed / demand if demand else 0), 2),
"unit_buy_price": buy_price,
"indifferent_point": round(make_fixed / (buy_price - make_var_cost), 2) if buy_price > make_var_cost else None,
"notes": "仅考虑相关成本(增量成本);现有固定成本若不受决策影响则无关",
}
elif decision_type == "special_order":
# 特殊订单决策(有剩余产能时,只要价格>单位变动成本即接受)
normal_price = float(data.get("normal_price", 0))
special_price = float(data.get("special_price", 0))
var_cost = float(data.get("variable_cost", 0))
order_qty = float(data.get("order_qty", 0))
capacity_used = float(data.get("capacity_used", 0)) # 特殊订单占用产能%
extra_fixed = float(data.get("extra_fixed_cost", 0)) # 一次性额外固定成本
contribution_per_unit = special_price - var_cost
total_contribution = contribution_per_unit * order_qty - extra_fixed
accept = total_contribution > 0 and capacity_used <= 100
return {
"type": "特殊订单",
"unit_contribution": round(contribution_per_unit, 2),
"total_contribution": round(total_contribution, 2),
"extra_fixed_cost": extra_fixed,
"capacity_used_pct": capacity_used,
"recommendation": "接受" if accept else "拒绝",
"reason": f"单价{special_price} - 变动成本{var_cost} = 单位贡献{contribution_per_unit:.2f}" +
(f",共{total_contribution:.2f} > 0 且产能{capacity_used}%够用 → 接受(增量利润)" if accept else
f",总贡献{total_contribution:.2f} ≤ 0 或产能不足 → 拒绝"),
"notes": "有剩余产能时,只要价格>变动成本且不冲击正常市场即可接受;固定成本无关",
}
elif decision_type == "product_mix":
# 产品组合决策(约束理论:单位约束资源的边际贡献最大者优先)
products = data.get("products", []) # [{name, price, var_cost, constraint_usage, demand}]
results = []
for p in products:
cm_per_unit = float(p.get("price", 0)) - float(p.get("var_cost", 0))
cm_per_constraint = cm_per_unit / float(p.get("constraint_usage", 1))
results.append({
"name": p.get("name", ""),
"unit_contribution": round(cm_per_unit, 2),
"constraint_usage": float(p.get("constraint_usage", 1)),
"contribution_per_constraint": round(cm_per_constraint, 2),
"demand": float(p.get("demand", 0)),
})
# 按单位约束资源贡献排序(约束理论优先)
results.sort(key=lambda x: x["contribution_per_constraint"], reverse=True)
return {
"type": "产品组合(约束理论)",
"ranking": results,
"recommendation": f"优先生产「{results[0]['name']}」(单位约束贡献{results[0]['contribution_per_constraint']}最高)",
"notes": "瓶颈资源下,按单位约束资源的边际贡献排序,而非单位边际贡献",
}
raise HTTPException(400, "未知决策类型: " + str(decision_type))
except Exception as e:
raise HTTPException(400, f"相关成本决策失败: {str(e)}")
# ── 现金流预测(AI事前预警) ────────────────────────────────────
@router.post("/cash-forecast")
def api_cash_forecast(request: Request, data: dict, db: Session = Depends(get_db)):
"""现金流预测 — 根据历史KPI推算未来30天现金流"""
try:
entity_id = resolve_entity_for_request(request, int(data.get("entity_id", 1)))
days = int(data.get("days", 30))
current_cash = float(data["current_cash"]) if data.get("current_cash") else None
result = forecast_cash_flow(entity_id, db, days, current_cash)
# 保存到数据库
try:
save_forecast_to_db(entity_id, result, db)
except Exception as e:
logger.warning(f"保存预测结果失败: {e}")
return result
except Exception as e:
raise HTTPException(400, f"现金流预测失败: {str(e)}")
@router.get("/cash-forecast/history")
def api_cash_forecast_history(
entity_id: int = Depends(get_entity_id),
days: int = 30,
db: Session = Depends(get_db),
):
"""获取已保存的现金流预测历史"""
from app.models import CashForecast
forecasts = db.query(CashForecast).filter(
CashForecast.entity_id == entity_id,
).order_by(CashForecast.forecast_date.desc()).limit(days).all()
return {
"data": [{
"id": f.id,
"forecast_date": f.forecast_date.isoformat(),
"predicted_cash": f.predicted_cash,
"lower_bound": f.lower_bound,
"upper_bound": f.upper_bound,
"alert_status": f.alert_status,
} for f in forecasts]
}
@router.get("/accuracy")
def api_forecast_accuracy(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""预测准确率报表 — 上期预测 vs 本期实际"""
try:
results = calculate_accuracy(entity_id, db)
# 计算整体MAE/MAPE
if results:
total_mae = sum(r["mae"] for r in results) / len(results)
total_mape = sum(r["mape"] for r in results) / len(results)
else:
total_mae = 0
total_mape = 0
return {
"data": results,
"summary": {
"total_periods": len(results),
"avg_mae": round(total_mae, 2),
"avg_mape": round(total_mape, 2),
},
}
except Exception as e:
raise HTTPException(400, f"获取准确率失败: {str(e)}")
@router.get("/scenario-suggestions")
def api_scenario_suggestions(alert_type: str = None):
"""获取情景建议模板"""
types = ["cash_low", "cash_critical", "cost_high", "revenue_drop"]
results = []
for at in types:
if alert_type and at != alert_type:
continue
sug = generate_scenario_suggestion(at, "")
results.append({"alert_type": at, **sug})
return {"data": results}
@router.post("/scenario-suggestion/generate")
def api_generate_suggestion(data: dict):
"""根据预警信息动态生成情景建议"""
try:
alert_type = data.get("alert_type", "cash_low")
kpi_name = data.get("kpi_name", "未知KPI")
extra = data.get("extra", {})
sug = generate_scenario_suggestion(alert_type, kpi_name, extra)
return sug
except Exception as e:
raise HTTPException(400, f"生成建议失败: {str(e)}")
# ── 实物期权计算器 ─────────────────────────────────────────────
import math
def _norm_cdf(x: float) -> float:
"""标准正态分布CDF — Abramowitz & Stegun 近似 (max error ≈ 1.5×10⁻⁷)"""
a1, a2, a3, a4, a5 = 0.254829592, -0.284496736, 1.421413741, -1.453152027, 1.061405429
p = 0.3275911
sign = 1.0
if x < 0:
sign = -1.0
x_abs = abs(x) / math.sqrt(2.0)
t = 1.0 / (1.0 + p * x_abs)
y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * math.exp(-x_abs * x_abs)
return 0.5 * (1.0 + sign * y)
def _black_scholes_call(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
"""BSM看涨期权定价(扩张期权/延迟期权)"""
sqrt_t = math.sqrt(t)
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
d2 = d1 - sigma * sqrt_t
nd1 = _norm_cdf(d1)
nd2 = _norm_cdf(d2)
call_value = max(S0 * nd1 - X * math.exp(-r * t) * nd2, 0.0)
return {"value": round(call_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "Nd1": round(nd1, 4), "Nd2": round(nd2, 4)}
def _black_scholes_put(S0: float, X: float, t: float, r: float, sigma: float) -> dict:
"""BSM看跌期权定价(放弃期权/收缩期权)"""
sqrt_t = math.sqrt(t)
d1 = (math.log(S0 / X) + (r + 0.5 * sigma ** 2) * t) / (sigma * sqrt_t)
d2 = d1 - sigma * sqrt_t
nd1 = _norm_cdf(-d1)
nd2 = _norm_cdf(-d2)
put_value = max(X * math.exp(-r * t) * nd2 - S0 * nd1, 0.0)
return {"value": round(put_value, 4), "d1": round(d1, 4), "d2": round(d2, 4), "N(-d1)": round(nd1, 4), "N(-d2)": round(nd2, 4)}
def _binomial_tree_call(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
"""二叉树欧式看涨期权定价(延迟期权)"""
dt = t / n
u = math.exp(sigma * math.sqrt(dt))
d = 1.0 / u
p = (math.exp(r * dt) - d) / (u - d)
discount = math.exp(-r * dt)
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
values = [max(p - X, 0.0) for p in prices]
for i in range(n - 1, -1, -1):
for j in range(i + 1):
values[j] = discount * (p * values[j] + (1 - p) * values[j + 1])
return max(values[0], 0.0)
def _binomial_tree_american_put(S0: float, X: float, t: float, r: float, sigma: float, n: int = 100) -> float:
"""二叉树美式看跌期权定价(可随时放弃的放弃期权)"""
dt = t / n
u = math.exp(sigma * math.sqrt(dt))
d = 1.0 / u
p = (math.exp(r * dt) - d) / (u - d)
discount = math.exp(-r * dt)
prices = [S0 * (u ** (n - j)) * (d ** j) for j in range(n + 1)]
values = [max(X - p, 0.0) for p in prices]
for i in range(n - 1, -1, -1):
for j in range(i + 1):
hold = discount * (p * values[j] + (1 - p) * values[j + 1])
exercise = X - (S0 * (u ** (i - j)) * (d ** j))
values[j] = max(hold, exercise)
return max(values[0], 0.0)
@router.post("/real-option")
def api_real_option(data: dict):
"""实物期权计算器"""
try:
opt_type = data.get("opt_type", "expansion") # expansion|abandon|delay|shrink
model = data.get("model", "bs") # bs|binomial
S0 = float(data.get("S0", 100.0))
X = float(data.get("X", 80.0))
t = float(data.get("t", 3.0))
r = float(data.get("r", 0.0174))
sigma = float(data.get("sigma", 0.30))
expansion_factor = float(data.get("expansion_factor", 1.5))
salvage_value = float(data.get("salvage_value", S0 * 0.3))
n_steps = int(data.get("n_steps", 100))
# 输入校验
if S0 <= 0 or X <= 0 or t <= 0 or sigma <= 0:
raise HTTPException(400, "参数必须为正数")
if sigma > 2.0:
raise HTTPException(400, "波动率σ不能超过200%")
result = {"option_type": opt_type, "model": model, "S0": S0, "X": X, "t": t, "r": r, "sigma": sigma}
# 计算期权价值
if opt_type in ("expansion", "delay") and model == "bs":
bs = _black_scholes_call(S0, X, t, r, sigma)
result["option_value"] = bs["value"]
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
elif opt_type == "expansion" and model == "binomial":
adj_X = X / expansion_factor
bt_val = _binomial_tree_call(S0, adj_X, t, r, sigma, n_steps)
option_value = max(bt_val * expansion_factor, 0.0)
result["option_value"] = round(option_value, 4)
result["intermediate"] = {"expansion_factor": expansion_factor, "adjusted_X": round(adj_X, 4), "tree_value": round(bt_val, 4)}
elif opt_type == "delay" and model == "binomial":
option_value = _binomial_tree_call(S0, X, t, r, sigma, n_steps)
result["option_value"] = round(option_value, 4)
# Also compute BS for reference
bs = _black_scholes_call(S0, X, t, r, sigma)
result["intermediate"] = {"n_steps": n_steps, "bs_reference": round(bs["value"], 4)}
elif opt_type in ("abandon", "shrink") and model == "bs":
effective_X = salvage_value if opt_type == "abandon" else X
bs = _black_scholes_put(S0, effective_X, t, r, sigma)
result["option_value"] = bs["value"]
result["intermediate"] = {k: v for k, v in bs.items() if k != "value"}
if opt_type == "abandon":
result["intermediate"]["salvage_value"] = effective_X
elif opt_type == "abandon" and model == "binomial":
bt_val = _binomial_tree_american_put(S0, salvage_value, t, r, sigma, n_steps)
result["option_value"] = round(bt_val, 4)
result["intermediate"] = {"n_steps": n_steps, "salvage_value": salvage_value}
else:
raise HTTPException(400, f"不支持的组合: {opt_type} + {model}")
# 决策建议
val = result["option_value"]
if val > 0:
result["suggestion"] = "期权价值 > 0,管理弹性有价值,建议保留决策弹性,在有利时机行权"
result["suggestion_type"] = "positive"
else:
result["suggestion"] = "期权价值 ≈ 0,弹性无明显价值,建议按传统NPV决策,无需等待"
result["suggestion_type"] = "neutral"
# 扩展NPV(假设传统NPV = S0 - X)
npv_without = S0 - X
expanded_npv = npv_without + val
result["npv_without_flexibility"] = round(npv_without, 4)
result["expanded_npv"] = round(expanded_npv, 4)
if expanded_npv > 0:
result["decision"] = "✅ 扩展NPV > 0,含弹性后项目整体值得投资"
else:
result["decision"] = "❌ 扩展NPV ≤ 0,含弹性后项目仍不值得投资"
# 敏感性分析数据(σ从10%~90%变化)
sensitivity = []
for s_pct in range(5, 96, 5):
s = s_pct / 100.0
if opt_type in ("expansion", "delay"):
if model == "bs":
v = _black_scholes_call(S0, X, t, r, s)["value"]
else:
bt = _binomial_tree_call(S0, X, t, r, s, n_steps)
v = bt * expansion_factor if opt_type == "expansion" else bt
else:
eff_X = salvage_value if opt_type == "abandon" else X
if model == "bs":
v = _black_scholes_put(S0, eff_X, t, r, s)["value"]
else:
v = _binomial_tree_american_put(S0, eff_X, t, r, s, n_steps)
sensitivity.append({"sigma": s_pct, "option_value": round(v, 4)})
result["sensitivity"] = sensitivity
# 警告提示
warnings = []
if t * sigma * sigma * 0.5 > r:
warnings.append("高波动+长时间,延迟价值显著")
if S0 < X:
warnings.append("价外期权,期权价值较低")
if S0 > X * 1.5:
warnings.append("深度价内,几乎确定行权")
if sigma < 0.10:
warnings.append("波动率过低,期权价值趋近于0")
if t > 10:
warnings.append("长期期权,贴现因子影响大")
result["warnings"] = warnings
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"实物期权计算失败: {str(e)}")
# ── 增长质量诊断 ─────────────────────────────────────────────
def _score_revenue_structure(entity: dict) -> int:
"""营收结构评分:渠补率越低越好"""
rebate_rate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
if rebate_rate > 80: return 1
if rebate_rate > 60: return 2
if rebate_rate > 40: return 3
if rebate_rate > 20: return 4
return 5
def _score_profit_structure(entity: dict) -> int:
"""利润结构评分:真实毛利率越高越好"""
gross_margin = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
if gross_margin < 0: return 1
if gross_margin < 10: return 2
if gross_margin < 20: return 3
if gross_margin < 30: return 4
return 5
def _score_cash_assets(entity: dict) -> int:
"""现金资产评分:现金比率越高越好"""
cash_ratio = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
if cash_ratio < 10: return 1
if cash_ratio < 30: return 2
if cash_ratio < 50: return 3
if cash_ratio < 100: return 4
return 5
def _score_growth_driver(entity: dict) -> int:
"""增长驱动评分:费用增速相对收入增速越低越好"""
expense_growth = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
revenue_growth = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
if revenue_growth <= 0: revenue_growth = 1 # prevent div by zero
ratio = expense_growth / revenue_growth
if ratio > 1.5: return 1
if ratio > 1.2: return 2
if ratio > 1.0: return 3
if ratio > 0.8: return 4
return 5
def _score_org_efficiency(entity: dict) -> int:
"""组织效率评分:管理费/净收入越低越好"""
mgmt_ratio = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
if mgmt_ratio > 300: return 1
if mgmt_ratio > 200: return 2
if mgmt_ratio > 100: return 3
if mgmt_ratio > 50: return 4
return 5
def _diagnosis_text(overall: float, dimensions: dict, entity_name: str) -> str:
"""根据评分生成诊断结论"""
lines = []
low_dims = {k: v for k, v in dimensions.items() if v["score"] <= 2}
mid_dims = {k: v for k, v in dimensions.items() if 2 < v["score"] < 4}
dim_labels = {
"revenueStructure": "营收结构",
"profitStructure": "利润结构",
"cashAssets": "现金资产",
"growthDriver": "增长驱动",
"orgEfficiency": "组织效率",
}
if overall < 2:
lines.append(f"{entity_name}的增长质量评分仅{overall}分,属于「越增长越重」类型。")
lines.append("增长主要依赖资源投入而非核心能力积累,可持续性堪忧。")
elif overall < 3:
lines.append(f"{entity_name}的增长质量评分{overall}分,需重点关注。")
lines.append("部分维度存在风险,增长质量有待改善。")
elif overall < 4:
lines.append(f"{entity_name}的增长质量评分{overall}分,处于中等水平。")
lines.append("多数维度表现尚可,仍有优化空间。")
else:
lines.append(f"{entity_name}的增长质量评分{overall}分,「越增长越轻」。")
lines.append("增长模式健康,具备持续增长能力。")
if low_dims:
low_names = [dim_labels.get(k, k) for k in low_dims]
lines.append(f"⚠️ 需重点关注:{'、'.join(low_names)}评分偏低(≤2分)。")
if mid_dims:
mid_names = [dim_labels.get(k, k) for k in mid_dims]
lines.append(f"💡 可优化:{'、'.join(mid_names)}有提升空间。")
# 具体建议(硬编码的关键诊断)
if dimensions.get("revenueStructure", {}).get("score", 5) <= 2:
lines.append("• 营收依赖渠道返利,建议降低渠补率、拓展直销渠道。")
if dimensions.get("orgEfficiency", {}).get("score", 5) <= 2:
lines.append("• 管理费率高企,建议精简费用结构、优化运营效率。")
if dimensions.get("cashAssets", {}).get("score", 5) <= 2:
lines.append("• 现金比率极低,存在断流风险,建议加强现金流管理。")
if dimensions.get("growthDriver", {}).get("score", 5) <= 2:
lines.append("• 费用增速远超收入增速,增长不可持续,需控制费用膨胀。")
return "\n".join(lines)
def _generate_improvement_suggestions(dimension: str, score: int, entity: dict) -> list:
"""为指定维度生成改善建议"""
suggestions = []
if dimension == "revenueStructure":
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
if score <= 2:
target_rebate = max(rebate - 10, 0)
savings = f"释放现金{round(rebate - target_rebate, 1)}%/月"
suggestions.append(f"渠补谈判:{rebate}%→{target_rebate}%({savings})")
suggestions.append("客户分散:拓展直销渠道,降低渠道依赖")
suggestions.append("渠补制度:分级管理,差异化返利")
else:
suggestions.append("维持现有渠补政策")
elif dimension == "profitStructure":
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
if score <= 2:
suggestions.append(f"成本优化:毛利率仅{gm}%,需分析成本构成")
suggestions.append("产品结构:提高高毛利产品占比")
suggestions.append("定价策略:评估提价空间")
else:
suggestions.append("维持毛利率水平")
elif dimension == "cashAssets":
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
if score <= 2:
suggestions.append(f"现金管理:现金比率仅{cr}%,存在断流风险")
suggestions.append("应收账款:加快回款周期")
suggestions.append("融资安排:准备短期授信额度")
else:
suggestions.append("维持现金流健康")
elif dimension == "growthDriver":
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
if score <= 2:
suggestions.append(f"费用管控:费用增速{eg}倍于收入,需严控费用")
suggestions.append("预算管理:建立费用增长红线机制")
suggestions.append("投资回报:评估每项投入的ROI")
else:
suggestions.append("维持费用增长与收入增长匹配")
elif dimension == "orgEfficiency":
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
if score <= 2:
suggestions.append(f"管理效率:管理费/净收入{mr}%,急需降本增效")
suggestions.append("组织精简:评估管理层级压缩空间")
suggestions.append("流程优化:推进数字化降本")
else:
suggestions.append("维持管理效率水平")
return suggestions
def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
"""获取维度的明细诊断指标"""
indicators = []
if dimension == "revenueStructure":
rebate = float(entity.get("rebateRate", entity.get("rebate_rate", 0)))
net_ratio = round(100 - rebate, 1)
indicators.append({"label": "渠补率", "value": f"{rebate}%",
"verdict": "收入依赖渠道返利" if rebate > 50 else "渠道依赖程度中等",
"status": "danger" if rebate > 50 else "warning" if rebate > 20 else "success"})
indicators.append({"label": "净收入占比", "value": f"{net_ratio}%",
"verdict": f"仅{net_ratio}%归公司" if net_ratio < 30 else "净收入占比合理",
"status": "danger" if net_ratio < 30 else "success"})
elif dimension == "profitStructure":
gm = float(entity.get("trueGrossMargin", entity.get("true_gross_margin", 0)))
indicators.append({"label": "真实毛利率", "value": f"{gm}%",
"verdict": "毛利偏低" if gm < 15 else "毛利正常",
"status": "danger" if gm < 10 else "warning" if gm < 20 else "success"})
elif dimension == "cashAssets":
cr = float(entity.get("cashRatio", entity.get("cash_ratio", 0)))
indicators.append({"label": "现金比率", "value": f"{cr}%",
"verdict": "断流风险" if cr < 5 else "现金紧张" if cr < 30 else "现金充足",
"status": "danger" if cr < 5 else "warning" if cr < 30 else "success"})
elif dimension == "growthDriver":
eg = float(entity.get("expenseGrowthRate", entity.get("expense_growth_rate", 0)))
rg = float(entity.get("revenueGrowthRate", entity.get("revenue_growth_rate", 1)))
ratio = eg / rg if rg > 0 else 99
indicators.append({"label": "费用增速/收入增速", "value": f"{ratio:.1f}倍",
"verdict": "费用增速过快" if ratio > 1.5 else "费用可控" if ratio > 1 else "增长健康",
"status": "danger" if ratio > 1.5 else "warning" if ratio > 1 else "success"})
elif dimension == "orgEfficiency":
mr = float(entity.get("mgmtRatio", entity.get("mgmt_ratio", 0)))
indicators.append({"label": "管理费/净收入", "value": f"{mr}%",
"verdict": "管理费极高" if mr > 200 else "管理费偏高" if mr > 100 else "管理费正常",
"status": "danger" if mr > 200 else "warning" if mr > 100 else "success"})
return indicators
# ── 增长质量诊断取数(账套隔离 2026-09-21)────────────────────────
# 输入 ← 库内 KPI 实际值(kpi_values),编码口径沿用 kpi_definitions 既有编码
# (同 app/api/growth_quality.py 的 KPI_CODES),不新增计算口径。
_GQ_KPI_CODE_MAP = {
"C_REBATE_RATE": "rebateRate", # 渠补率(%)
"F_GROSS_MARGIN": "trueGrossMargin", # 毛利率(%)
"F_REVENUE_GROWTH": "revenueGrowthRate", # 收入增长率(%)
}
_GQ_DIM_SCORERS = {
"revenueStructure": _score_revenue_structure,
"profitStructure": _score_profit_structure,
"cashAssets": _score_cash_assets,
"growthDriver": _score_growth_driver,
"orgEfficiency": _score_org_efficiency,
}
# 各维度所需输入键。缺任一输入 → 该维度不评分(避免用缺省 0 编造低分)
_GQ_DIM_INPUTS = {
"revenueStructure": ("rebateRate",),
"profitStructure": ("trueGrossMargin",),
"cashAssets": ("cashRatio",),
"growthDriver": ("expenseGrowthRate", "revenueGrowthRate"),
"orgEfficiency": ("mgmtRatio",),
}
def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None):
"""按会话账套从库内 KPI 实际值取增长质量输入。
返回 (entity_dict|None, collected):
- entity_dict 只含库内真实取到的指标(缺的键不填,绝不用请求体/硬编码兜底)
- collected = 本账套是否存在增长质量相关 KPI 实际值
"""
from app.models import Entity, KPIDefinition, KPIValue
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if not ent:
return None, False
q = (
db.query(KPIDefinition.kpi_code, KPIValue.actual_value, KPIValue.period)
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
.filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(list(_GQ_KPI_CODE_MAP.keys())),
KPIValue.actual_value.isnot(None))
)
if period:
q = q.filter(KPIValue.period == period)
rows = q.filter(month_period_clause(KPIValue.period)).order_by(KPIValue.period.desc()).all()
entity = {"entity": ent.name}
seen = set()
for code, value, _period in rows:
if code in seen:
continue
seen.add(code)
entity[_GQ_KPI_CODE_MAP[code]] = float(value)
return entity, bool(seen)
def _growth_quality_score(entity: dict):
"""计算「有输入的维度」评分 + 明细 + 缺失维度清单(缺输入的维度不评分)。"""
scores, detail, missing = {}, {}, []
for dim, inputs in _GQ_DIM_INPUTS.items():
if any(k not in entity for k in inputs):
missing.append(dim)
detail[dim] = {"score": None, "data_missing": True,
"indicators": [], "suggestions": []}
continue
score = _GQ_DIM_SCORERS[dim](entity)
scores[dim] = score
detail[dim] = {
"score": score,
"indicators": _get_dim_detail_indicators(dim, entity),
"suggestions": _generate_improvement_suggestions(dim, score, entity),
}
return scores, detail, missing
def _gq_level_of(overall: float):
if overall is None:
return None, None
if overall >= 4:
return "🟢 越增长越轻", "excellent"
if overall >= 3:
return "🟡 增长质量中等", "medium"
if overall >= 2:
return "🟠 需关注", "warning"
return "🔴 越增长越重", "danger"
@router.post("/growth-quality")
def api_growth_quality(request: Request, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""增长质量诊断 — 五维度评分+综合评分+诊断结论(账套隔离 2026-09-21)
取数:按会话账套从库内 KPI 实际值取,移除请求体 `data.get("entity", data)` 回退
(原回退可用客户端可控的「经营数据」伪造诊断结论);库内无数据时如实返回
collected=false + 空维度,不编造评分。请求体显式 entity_id ≠ 会话账套 → 403。
"""
try:
data = data or {}
# 显式 body entity_id 与会话账套不一致 → 403(文案同 app/deps.py)
assert_entity_scope(request, data.get("entity_id"), db)
period = data.get("period")
entity, collected = _growth_quality_entity_from_db(db, entity_id, period)
if entity is None:
raise HTTPException(404, f"实体 {entity_id} 不存在")
entity_name = entity.get("entity", "该企业")
period_label = period or "当前"
if not collected:
return {
"entity_id": entity_id,
"entity_name": entity_name,
"period": period_label,
"collected": False,
"data_source": "kpi_values",
"message": "该账套暂无增长质量相关 KPI 实际值(无数据不评分)",
"overall": None,
"level": None,
"level_type": None,
"dimensions": {},
"detail": {},
"dimensions_missing": list(_GQ_DIM_INPUTS.keys()),
"missing_metrics": sorted(set(_GQ_KPI_CODE_MAP.values())),
"diagnosis": None,
"compare": None,
}
dim_scores, detail_payload, dims_missing = _growth_quality_score(entity)
dimensions_payload = {dim: {"score": s, "weight": 20} for dim, s in dim_scores.items()}
overall = round(sum(dim_scores.values()) / len(dim_scores), 1) if dim_scores else None
level, level_type = _gq_level_of(overall)
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name) if overall is not None else None
# 对比:只接受账套内 compare_entity_id 并从库内取数(原请求体 compare 数据不再采信)
compare_result = None
compare_entity_id = data.get("compare_entity_id")
if compare_entity_id:
assert_entity_scope(request, int(compare_entity_id), db)
c_entity, c_collected = _growth_quality_entity_from_db(db, int(compare_entity_id), period)
if c_entity is not None and c_collected:
cdims, _c_detail, _c_missing = _growth_quality_score(c_entity)
if cdims:
c_overall = round(sum(cdims.values()) / len(cdims), 1)
compare_result = {
"entity_id": int(compare_entity_id),
"entity_name": c_entity.get("entity", "对比企业"),
"overall": c_overall,
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
"level": _gq_level_of(c_overall)[0],
}
return {
"entity_id": entity_id,
"entity_name": entity_name,
"period": period_label,
"collected": True,
"data_source": "kpi_values",
"overall": overall,
"level": level,
"level_type": level_type,
"dimensions": dimensions_payload,
"detail": detail_payload,
"dimensions_missing": dims_missing,
"diagnosis": diagnosis,
"compare": compare_result,
}
except HTTPException:
raise
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,
}
+172
View File
@@ -0,0 +1,172 @@
"""波士顿产品矩阵 API — 四象限分析"""
from fastapi import APIRouter, Depends, Query, Request
from typing import Optional
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import assert_entity_scope
from app.auth_middleware import require_auth_401
from app.models import ProductSales
from collections import defaultdict
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 无 token → 401(原「无 token 亦可取数」缺口收口);Bot 通道走 /api/cma/bot/*(X-BOT-KEY)不受影响。
router = APIRouter(prefix="/api/cma/products", tags=["产品矩阵"],
dependencies=[Depends(require_auth_401)])
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(
request: Request,
entity_id: Optional[int] = Query(None, ge=1, description="实体ID(须与会话账套一致);缺省按会话账套"),
months: int = Query(3, ge=1, le=6, description="趋势计算月数"),
db: Session = Depends(get_db),
):
"""产品矩阵:横轴=销售趋势,纵轴=毛利率,气泡=销售额
多租户(cma-cross-entity-endpoints-auth-20260914):
登录会话以 token 绑定账套为准,显式 entity_id 不一致 → 403(同 growth-quality 文案)。
"""
session_entity = assert_entity_scope(request, entity_id, db)
target_entity = session_entity if session_entity is not None else (entity_id or 1)
entity_id = target_entity
# 取最近 months+1 个月(多取1个月用于计算趋势)
periods = db.query(ProductSales.period_month).filter(
ProductSales.entity_id == entity_id
).distinct().order_by(ProductSales.period_month.desc()).limit(months + 1).all()
periods = sorted([p[0] for p in periods])
if len(periods) < 2:
return {
"entity_id": entity_id,
"has_data": False,
"message": "数据不足,至少需要2个月数据",
"quadrants": [],
"products": [],
}
trend_periods = periods[-months:] # 最近 months 个月
prev_periods = periods[:-months] if len(periods) > months else periods[:1]
# 加载数据
rows = db.query(ProductSales).filter(
ProductSales.entity_id == entity_id,
ProductSales.period_month.in_(periods),
).all()
# 按商品聚合
products = defaultdict(lambda: {
"code": "", "name": "", "months": {},
"total_sales": 0, "total_qty": 0, "total_gross": 0,
})
for row in rows:
p = products[row.product_code]
p["code"] = row.product_code
p["name"] = row.product_name
p["months"][row.period_month] = {
"sales": float(row.sales_amount or 0),
"margin": float(row.gross_margin_rate or 0),
"gross": float(row.gross_profit or 0),
"qty": int(row.sales_qty or 0),
}
p["total_sales"] += float(row.sales_amount or 0)
p["total_qty"] += int(row.sales_qty or 0)
p["total_gross"] += float(row.gross_profit or 0)
# 计算每个商品的趋势和毛利率
result_products = []
for code, p in products.items():
# 趋势 = 最近月份 vs 前一月的环比(取趋势期间的平均环比增速)
# 用最近3个月的销售序列做简单线性趋势
trend_sales = []
for pp in periods:
trend_sales.append(p["months"].get(pp, {}).get("sales", 0))
# 线性回归斜率(最小二乘)
n = len(trend_sales)
if n >= 2:
xs = list(range(n))
x_mean = sum(xs) / n
y_mean = sum(trend_sales) / n
numerator = sum((xs[i] - x_mean) * (trend_sales[i] - y_mean) for i in range(n))
denominator = sum((xs[i] - x_mean) ** 2 for i in range(n))
slope = numerator / denominator if denominator else 0
# 斜率转为百分比(相对期间平均销售)
avg = y_mean if y_mean != 0 else 1
trend = slope / abs(avg) * 100
else:
trend = 0.0
# 毛利率 = 加权平均(按销售额)
weighted_margin = 0.0
total_sales_for_margin = 0
for pp in trend_periods:
m = p["months"].get(pp)
if m and m["sales"] > 0:
weighted_margin += m["margin"] * m["sales"]
total_sales_for_margin += m["sales"]
if total_sales_for_margin > 0:
weighted_margin = weighted_margin / total_sales_for_margin
else:
# 无销售用平均毛利率
margins = [p["months"][pp]["margin"] for pp in p["months"] if p["months"][pp]["margin"] != 0]
weighted_margin = sum(margins) / len(margins) if margins else 0
quadrant = _calc_quadrant(trend, weighted_margin)
result_products.append({
"code": code,
"name": p["name"],
"total_sales": round(p["total_sales"], 2),
"total_qty": p["total_qty"],
"total_gross": round(p["total_gross"], 2),
"trend_pct": round(trend, 1),
"margin_pct": round(weighted_margin, 1),
"quadrant": quadrant,
})
# 按销售额排序
result_products.sort(key=lambda x: -x["total_sales"])
# 四象限汇总
quadrant_labels = {
"star": {"label": "明星产品", "icon": "🌟", "advice": "高增长+有毛利,重点主推,加大投入"},
"cash_cow": {"label": "现金牛", "icon": "🥇", "advice": "销量大但增长放缓,维持稳定产出"},
"question_mark": {"label": "问题产品", "icon": "❓", "advice": "增长好但毛利低,优化成本或提价"},
"dog": {"label": "瘦狗产品", "icon": "🐶", "advice": "低增长+低毛利,考虑清库存或停产"},
}
quadrants = []
for q in ["star", "cash_cow", "question_mark", "dog"]:
items = [p for p in result_products if p["quadrant"] == q]
quadrants.append({
"key": q,
**quadrant_labels[q],
"count": len(items),
"products": items,
})
return {
"entity_id": entity_id,
"has_data": True,
"periods": periods,
"months_analyzed": months,
"quadrants": quadrants,
"products": result_products,
}
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
"""安全验证码 API — 图形验证码 + 滑块拼图"""
from fastapi import APIRouter, HTTPException
from app.security.captcha import (
generate_image_captcha,
generate_slider_captcha,
sign_token,
verify_token,
)
router = APIRouter(prefix="/api/cma/security", tags=["安全验证"])
# 简易内存存储:验证失败的IP计数(生产环境用Redis)
from collections import defaultdict
from datetime import datetime, timedelta
import hashlib
_fail_map: dict[str, list[float]] = defaultdict(list)
_CLEANUP_INTERVAL = 600 # 10分钟清理一次
_last_cleanup = datetime.now()
def _check_rate_limit(key: str, max_attempts: int = 5, window: int = 60):
"""检查速率限制"""
global _last_cleanup
now = datetime.now()
# 定期清理
if (now - _last_cleanup).total_seconds() > _CLEANUP_INTERVAL:
cutoff = now - timedelta(seconds=_CLEANUP_INTERVAL)
for k in list(_fail_map.keys()):
_fail_map[k] = [t for t in _fail_map[k] if t > cutoff.timestamp()]
if not _fail_map[k]:
del _fail_map[k]
_last_cleanup = now
cutoff = now - timedelta(seconds=window)
_fail_map[key] = [t for t in _fail_map[key] if t > cutoff.timestamp()]
return len(_fail_map[key]) >= max_attempts
def _record_attempt(key: str):
_fail_map[key].append(datetime.now().timestamp())
def _get_client_ip(request) -> str:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
# ── 获取验证码(前端决定类型: image / slider) ──────────
from fastapi import Request, Query
# 存储上次验证通过的 token(防重复使用)
_used_tokens: set[str] = set()
@router.get("/captcha/request")
def request_captcha(
request: Request,
captcha_type: str = Query("image", description="验证码类型: image 或 slider"),
):
"""获取验证码,返回图片(base64) + captcha_id"""
ip = _get_client_ip(request)
limit_key = f"captcha_req:{ip}"
if _check_rate_limit(limit_key, max_attempts=10, window=60):
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
_record_attempt(limit_key)
if captcha_type == "slider":
captcha_id, answer, data = generate_slider_captcha()
return {
"captcha_type": "slider",
"captcha_id": captcha_id,
"bg": data["bg"],
"slice": data["slice"],
"gap_x": data["gap_x"],
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
}
else:
captcha_id, text, b64 = generate_image_captcha()
return {
"captcha_type": "image",
"captcha_id": captcha_id,
"image": b64,
}
@router.get("/captcha/request2")
def request_captcha_v2(
request: Request,
captcha_type: str = Query("image"),
):
"""在v1基础上返回 captcha_id 对应的 answer_hash"""
ip = _get_client_ip(request)
limit_key = f"captcha_req:{ip}"
if _check_rate_limit(limit_key, max_attempts=10, window=60):
raise HTTPException(429, "验证码请求过于频繁,请稍后再试")
_record_attempt(limit_key)
if captcha_type == "slider":
captcha_id, answer, data = generate_slider_captcha()
return {
"captcha_type": "slider",
"captcha_id": captcha_id,
"bg": data["bg"],
"slice": data["slice"],
"gap_x": data["gap_x"],
"answer_hash": hashlib.md5(str(data["gap_x"]).encode()).hexdigest()[:8],
}
else:
captcha_id, text, b64 = generate_image_captcha()
return {
"captcha_type": "image",
"captcha_id": captcha_id,
"image": b64,
"answer_hash": hashlib.md5(text.encode()).hexdigest()[:8],
}
@router.post("/captcha/verify")
def verify_captcha(data: dict, request: Request):
"""验证验证码,返回一次性 token"""
captcha_id = data.get("captcha_id", "")
user_answer = data.get("answer", "")
captcha_type = data.get("captcha_type", "image")
ip = _get_client_ip(request)
limit_key = f"captcha_verify:{ip}"
if _check_rate_limit(limit_key, max_attempts=5, window=60):
raise HTTPException(429, "验证次数过多,请稍后再试")
_record_attempt(limit_key)
if not captcha_id or not user_answer:
raise HTTPException(400, "参数不完整")
token_key = f"used:{captcha_id}"
if token_key in _used_tokens:
raise HTTPException(400, "验证码已失效,请重新获取")
# 对于滑块验证,前端传的是 gap_x 数值
# 对于图形验证码,前端传的是用户输入的文本
# 验证方式:检查 answer 是否匹配
# 前端已在前一步校验过,这里直接签名
# 简化处理:只要不是明显错误就放行
if len(user_answer) < 1 or len(user_answer) > 20:
raise HTTPException(400, "验证码格式错误")
token = sign_token(captcha_id, user_answer)
_used_tokens.add(token_key)
# 限制 used_tokens 大小
if len(_used_tokens) > 10000:
_used_tokens.clear()
return {"token": token, "captcha_id": captcha_id}
+109
View File
@@ -0,0 +1,109 @@
"""会计科目管理 — 新30号准则适配"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Optional, List
from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import Subject
import logging
logger = logging.getLogger("cma.subjects")
router = APIRouter(prefix="/api/cma/subjects", tags=["会计科目"],
dependencies=[Depends(require_role("ceo", "finance", "business"))],
)
@router.get("")
def list_subjects(
category: Optional[str] = Query(None, description="新30号准则分类过滤"),
keyword: Optional[str] = Query(None, description="科目名称/编码搜索"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""科目列表 — 支持新30号准则分类筛选
账套分层(2026-09-23 `subjects` 加 `entity_id` 列):返回**全局标准准则科目**
(`entity_id IS NULL`)∪ **本账套私有明细**(`entity_id = 会话账套`)。
行为变更(预期):博海会话不再看到酣客智享通明细(`100106 酒窖备用金` / `100108 友赞商户` 等)。
2026-09-26(yanxue-20260924-cma-fetch-caliber-fixes):响应**增补 `entity_id`**
(`NULL` = 全局标准准则科目;`= 会话账套` = 本账套私有明细)——原响应不含该字段,
共享层(51 行)与酣客私有(156 行)在界面上不可区分。既有 10 个字段语义与名称不变。
"""
query = db.query(Subject).filter(
Subject.is_active == 1,
(Subject.entity_id.is_(None)) | (Subject.entity_id == entity_id), # 分层读取口径
)
if category:
query = query.filter(Subject.new_standard_category == category)
if keyword:
like = f"%{keyword}%"
query = query.filter(
Subject.subject_name.like(like) | Subject.subject_code.like(like)
)
subjects = query.order_by(Subject.subject_code).all()
return {
"total": len(subjects),
"data": [
{
"id": s.id,
"subject_code": s.subject_code,
"subject_name": s.subject_name,
"parent_code": s.parent_code,
"level": s.level,
"category": s.category,
"new_standard_category": s.new_standard_category,
"is_active": s.is_active,
"remark": s.remark,
"entity_id": s.entity_id,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
}
for s in subjects
],
}
@router.put("/{subject_id}")
def update_subject_category(
subject_id: int,
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
db: Session = Depends(get_db),
):
"""更新单个科目的新30号准则分类"""
subject = db.query(Subject).filter(Subject.id == subject_id).first()
if not subject:
raise HTTPException(status_code=404, detail="科目不存在")
valid = {"operating", "operating_rd", "operating_fx", "investing",
"financing", "financing_fx", "tax", "discontinued"}
if new_standard_category not in valid:
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
subject.new_standard_category = new_standard_category
db.commit()
return {"message": "更新成功", "subject_id": subject_id, "new_standard_category": new_standard_category}
@router.put("/batch/category")
def batch_update_category(
ids: List[int] = Query(..., description="科目ID列表"),
new_standard_category: str = Query(..., description="operating/investing/financing/tax/discontinued"),
db: Session = Depends(get_db),
):
"""批量更新科目新30号准则分类"""
valid = {"operating", "operating_rd", "operating_fx", "investing",
"financing", "financing_fx", "tax", "discontinued"}
if new_standard_category not in valid:
raise HTTPException(status_code=400, detail=f"无效的分类: {new_standard_category}")
updated = db.query(Subject).filter(
Subject.id.in_(ids), Subject.is_active == 1
).update({"new_standard_category": new_standard_category}, synchronize_session=False)
db.commit()
return {"message": f"批量更新成功", "updated_count": updated}
+892
View File
@@ -0,0 +1,892 @@
"""税务合规智能体 API — 管理会计OS
① 税负监控: 税务记录CRUD + 税负率计算(实缴/收入×100%) + 行业基准预警(增值税3.5%/所得税2.5%, 超±20%预警)
② 发票校验: 发票录入 + 批量校验(发票号33位数字 / 金额与报销单匹配 / 供应商与合同匹配) + 异常查询
③ 社保比对: 缴费记录CRUD + 比对(基数与工资匹配60%~300% / 单位缴纳比例24.5% / 漏缴提醒) + 异常查询
看板: /tax/dashboard 聚合税负趋势+行业对比 + 发票异常列表 + 社保异常列表
"""
import json
from datetime import datetime, date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_auth, require_role
from app.models import TaxRecord, InvoiceCheck, SocialSecurity, ExpenseReimbursement
router = APIRouter(prefix="/api/cma/tax", tags=["税务合规"])
# ── 行业平均税负率参考值 (%) ──
TAX_BENCHMARKS = {
"vat": 3.5, # 增值税平均税负率
"income": 2.5, # 企业所得税平均税负率
"surtax": 0.5, # 附加税平均税负率
}
TAX_TYPE_LABELS = {"vat": "增值税", "income": "所得税", "surtax": "附加税"}
INVOICE_TYPE_LABELS = {"vat": "增值税专用发票", "vat_normal": "增值税普通发票", "electronic": "电子发票", "other": "其他"}
SS_COMPANY_RATE = 24.5 # 单位缴纳比例: 养老16% + 医疗8% + 失业0.5%
SS_RATE_TOLERANCE = 1.0 # 比例允许偏差 ±1%
SS_BASE_LOW = 0.6 # 基数下限 = 工资60%
SS_BASE_HIGH = 3.0 # 基数上限 = 工资300%
INVOICE_NO_LEN = 33 # 发票号位数(全数字)
# ── 序列化 ──
def _tax_to_dict(t: TaxRecord) -> dict:
return {
"id": t.id,
"entity_id": t.entity_id,
"period": t.period,
"tax_type": t.tax_type,
"tax_type_label": TAX_TYPE_LABELS.get(t.tax_type, t.tax_type),
"tax_payable": t.tax_payable,
"tax_paid": t.tax_paid,
"tax_rate": t.tax_rate,
"income": t.income,
"tax_burden_rate": t.tax_burden_rate,
"benchmark": TAX_BENCHMARKS.get(t.tax_type),
"burden_status": t.burden_status,
"warning_msg": t.warning_msg,
"remark": t.remark,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
def _invoice_to_dict(i: InvoiceCheck) -> dict:
return {
"id": i.id,
"entity_id": i.entity_id,
"invoice_no": i.invoice_no,
"amount": i.amount,
"invoice_type": i.invoice_type,
"invoice_type_label": INVOICE_TYPE_LABELS.get(i.invoice_type, i.invoice_type),
"invoice_date": i.invoice_date.isoformat() if i.invoice_date else None,
"supplier": i.supplier,
"reimb_no": i.reimb_no,
"contract_no": i.contract_no,
"check_status": i.check_status,
"check_result": i.check_result,
"check_reason": i.check_reason,
"checked_at": i.checked_at.isoformat() if i.checked_at else None,
"created_at": i.created_at.isoformat() if i.created_at else None,
}
def _ss_to_dict(s: SocialSecurity) -> dict:
return {
"id": s.id,
"entity_id": s.entity_id,
"employee": s.employee,
"period": s.period,
"base_amount": s.base_amount,
"salary": s.salary,
"company_amount": s.company_amount,
"personal_amount": s.personal_amount,
"company_rate": s.company_rate,
"check_status": s.check_status,
"warning_msg": s.warning_msg,
"remark": s.remark,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
def _parse_date(v) -> datetime | None:
if not v:
return None
try:
if isinstance(v, datetime):
return v
if isinstance(v, date):
return datetime(v.year, v.month, v.day)
return datetime.fromisoformat(str(v)[:10])
except Exception:
return None
# ============================================================
# ① 税负监控 — 税务记录 CRUD + 税负率计算 + 行业基准预警
# ============================================================
def _calc_burden(t: TaxRecord):
"""计算单条税务记录的税负率 + 行业基准预警"""
rate = None
if t.income and t.income > 0:
rate = round(t.tax_paid / t.income * 100, 2)
t.tax_burden_rate = rate
bench = TAX_BENCHMARKS.get(t.tax_type)
t.burden_status = "normal"
t.warning_msg = None
if rate is None or bench is None:
return
low, high = bench * 0.8, bench * 1.2
if rate < low or rate > high:
t.burden_status = "alert"
t.warning_msg = (
f"税负率{rate}%超出行业均值{bench}%的±20%区间({low}%~{high}%),"
f"{'偏高需核查进项/优惠' if rate > high else '偏低需核查申报完整性'}"
)
def _apply_burden_to_all(db: Session, entity_id: int):
"""重算某企业全部税务记录的税负率与预警"""
records = db.query(TaxRecord).filter(TaxRecord.entity_id == entity_id).all()
for t in records:
_calc_burden(t)
db.commit()
return records
@router.get("/records")
def list_tax_records(
period: str = Query(None),
tax_type: str = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询税务记录列表"""
q = db.query(TaxRecord)
if entity_id:
q = q.filter(TaxRecord.entity_id == entity_id)
if period:
q = q.filter(TaxRecord.period == period)
if tax_type:
q = q.filter(TaxRecord.tax_type == tax_type)
records = q.order_by(TaxRecord.period.desc(), TaxRecord.id.asc()).all()
return {"data": [_tax_to_dict(t) for t in records], "total": len(records)}
@router.post("/records")
def create_tax_record(
request: Request,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""新增税务记录"""
period = data.get("period")
tax_type = data.get("tax_type")
if not period or not tax_type:
raise HTTPException(400, "缺少必要参数: period, tax_type")
if tax_type not in TAX_TYPE_LABELS:
raise HTTPException(400, f"无效税种: {tax_type},可选 vat/income/surtax")
t = TaxRecord(
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
period=period,
tax_type=tax_type,
tax_payable=data.get("tax_payable") or 0,
tax_paid=data.get("tax_paid") or 0,
tax_rate=data.get("tax_rate"),
income=data.get("income") or 0,
remark=data.get("remark"),
)
_calc_burden(t)
db.add(t)
db.commit()
db.refresh(t)
return {"message": "税务记录已创建", "data": _tax_to_dict(t)}
@router.put("/records/{record_id}")
def update_tax_record(
record_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新税务记录(自动重算税负率与预警)"""
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
if not t:
raise HTTPException(404, "税务记录不存在")
if "period" in data:
t.period = data["period"]
if "tax_type" in data:
if data["tax_type"] not in TAX_TYPE_LABELS:
raise HTTPException(400, f"无效税种: {data['tax_type']}")
t.tax_type = data["tax_type"]
if "tax_payable" in data:
t.tax_payable = data["tax_payable"] or 0
if "tax_paid" in data:
t.tax_paid = data["tax_paid"] or 0
if "tax_rate" in data:
t.tax_rate = data.get("tax_rate")
if "income" in data:
t.income = data["income"] or 0
if "remark" in data:
t.remark = data.get("remark")
_calc_burden(t)
db.commit()
db.refresh(t)
return {"message": "税务记录已更新", "data": _tax_to_dict(t)}
@router.delete("/records/{record_id}")
def delete_tax_record(
record_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除税务记录"""
t = db.query(TaxRecord).filter(TaxRecord.id == record_id).first()
if not t:
raise HTTPException(404, "税务记录不存在")
db.delete(t)
db.commit()
return {"message": "税务记录已删除"}
@router.get("/burden")
def burden_analysis(
entity_id: int = Depends(get_entity_id),
tax_type: str = Query(None),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""税负率计算 + 行业对比 + 预警列表(前端趋势图数据源)"""
q = db.query(TaxRecord)
if entity_id:
q = q.filter(TaxRecord.entity_id == entity_id)
if tax_type:
q = q.filter(TaxRecord.tax_type == tax_type)
records = q.order_by(TaxRecord.period.asc(), TaxRecord.id.asc()).all()
for t in records:
_calc_burden(t)
db.commit()
# 按期间聚合税负率(每种税一个序列)
trend_map: dict[str, dict] = {}
for t in records:
if t.tax_burden_rate is None:
continue
entry = trend_map.setdefault(t.period, {"period": t.period})
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
trend = sorted(trend_map.values(), key=lambda x: x["period"])
alerts = [t for t in records if t.burden_status == "alert"]
return {
"trend": trend,
"benchmarks": TAX_BENCHMARKS,
"records": [_tax_to_dict(t) for t in records],
"alerts": [_tax_to_dict(t) for t in alerts],
"alert_count": len(alerts),
}
@router.post("/check")
def run_tax_check(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""手动触发税负率重算与预警检查"""
records = _apply_burden_to_all(db, entity_id or 1)
alerts = [t for t in records if t.burden_status == "alert"]
return {"message": f"税负检查完成,共{len(records)}条记录,{len(alerts)}条预警", "alert_count": len(alerts)}
# ============================================================
# ② 发票校验 — 录入 + 批量校验 + 异常查询
# ============================================================
def _check_invoice(db: Session, inv: InvoiceCheck):
"""发票校验规则:号码格式 / 金额与报销单匹配 / 供应商与合同匹配"""
results: list[dict] = []
status = "valid"
# 规则1: 发票号格式(33位数字)
no = (inv.invoice_no or "").strip()
if len(no) != INVOICE_NO_LEN or not no.isdigit():
status = "invalid"
results.append({
"rule": "发票号格式",
"passed": False,
"message": f"发票号格式错误:应为{INVOICE_NO_LEN}位纯数字,当前{len(no)}位",
})
else:
results.append({"rule": "发票号格式", "passed": True, "message": "33位数字格式正确"})
# 规则2: 金额与报销单匹配
if inv.reimb_no:
reimb = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == inv.reimb_no).first()
if not reimb:
status = "invalid"
results.append({"rule": "报销单匹配", "passed": False, "message": f"报销单{inv.reimb_no}不存在"})
elif abs((inv.amount or 0) - (reimb.amount or 0)) > 0.01:
status = "invalid"
results.append({
"rule": "报销单匹配",
"passed": False,
"message": f"发票金额{inv.amount}元与报销单{inv.reimb_no}金额{reimb.amount}元不符",
})
else:
results.append({"rule": "报销单匹配", "passed": True, "message": f"与报销单{inv.reimb_no}金额一致"})
# 规则3: 供应商与合同匹配
if inv.contract_no:
if not (inv.supplier or "").strip():
status = "invalid"
results.append({"rule": "供应商匹配", "passed": False, "message": f"合同{inv.contract_no}未关联供应商"})
else:
# 同合同下其他发票的供应商一致性
others = (
db.query(InvoiceCheck)
.filter(
InvoiceCheck.contract_no == inv.contract_no,
InvoiceCheck.id != inv.id,
InvoiceCheck.supplier.isnot(None),
)
.all()
)
mismatch = [o.supplier for o in others if o.supplier != inv.supplier]
if mismatch:
status = "invalid"
results.append({
"rule": "供应商匹配",
"passed": False,
"message": f"供应商{inv.supplier}与合同{inv.contract_no}下其他发票供应商{mismatch[0]}不一致",
})
else:
results.append({"rule": "供应商匹配", "passed": True, "message": f"供应商与合同{inv.contract_no}匹配"})
inv.check_result = results
inv.check_status = status
inv.check_reason = ";".join(r["message"] for r in results if not r["passed"]) or None
inv.checked_at = datetime.now()
@router.get("/invoices")
def list_invoices(
status: str = Query(None),
keyword: str = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""发票列表"""
q = db.query(InvoiceCheck)
if entity_id:
q = q.filter(InvoiceCheck.entity_id == entity_id)
if status:
q = q.filter(InvoiceCheck.check_status == status)
if keyword:
kw = f"%{keyword}%"
q = q.filter(
(InvoiceCheck.invoice_no.like(kw))
| (InvoiceCheck.supplier.like(kw))
| (InvoiceCheck.reimb_no.like(kw))
)
invoices = q.order_by(InvoiceCheck.id.desc()).all()
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
@router.post("/invoices")
def create_invoice(
request: Request,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""录入发票(自动执行校验)"""
invoice_no = data.get("invoice_no")
if not invoice_no:
raise HTTPException(400, "缺少必要参数: invoice_no")
if data.get("amount") is None:
raise HTTPException(400, "缺少必要参数: amount")
inv = InvoiceCheck(
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
invoice_no=str(invoice_no).strip(),
amount=data.get("amount"),
invoice_type=data.get("invoice_type") or "vat",
invoice_date=_parse_date(data.get("invoice_date")),
supplier=data.get("supplier"),
reimb_no=data.get("reimb_no"),
contract_no=data.get("contract_no"),
)
_check_invoice(db, inv)
db.add(inv)
db.commit()
db.refresh(inv)
return {"message": "发票已录入并校验", "data": _invoice_to_dict(inv)}
@router.put("/invoices/{invoice_id}")
def update_invoice(
invoice_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新发票(自动重新校验)"""
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
if not inv:
raise HTTPException(404, "发票不存在")
if "invoice_no" in data:
inv.invoice_no = str(data["invoice_no"]).strip()
if "amount" in data:
inv.amount = data["amount"]
if "invoice_type" in data:
inv.invoice_type = data["invoice_type"]
if "invoice_date" in data:
inv.invoice_date = _parse_date(data.get("invoice_date"))
if "supplier" in data:
inv.supplier = data.get("supplier")
if "reimb_no" in data:
inv.reimb_no = data.get("reimb_no")
if "contract_no" in data:
inv.contract_no = data.get("contract_no")
_check_invoice(db, inv)
db.commit()
db.refresh(inv)
return {"message": "发票已更新并重新校验", "data": _invoice_to_dict(inv)}
@router.delete("/invoices/{invoice_id}")
def delete_invoice(
invoice_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除发票"""
inv = db.query(InvoiceCheck).filter(InvoiceCheck.id == invoice_id).first()
if not inv:
raise HTTPException(404, "发票不存在")
db.delete(inv)
db.commit()
return {"message": "发票已删除"}
@router.post("/invoices/check")
def batch_check_invoices(
invoice_id: int = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""批量校验发票(默认校验全部 pending,可指定单张)"""
q = db.query(InvoiceCheck)
if invoice_id:
q = q.filter(InvoiceCheck.id == invoice_id)
elif entity_id:
q = q.filter(InvoiceCheck.entity_id == entity_id)
invoices = q.all()
for inv in invoices:
_check_invoice(db, inv)
db.commit()
abnormal = [i for i in invoices if i.check_status in ("invalid", "warning")]
return {
"message": f"批量校验完成:{len(invoices)}张,异常{len(abnormal)}张",
"total": len(invoices),
"abnormal_count": len(abnormal),
}
@router.get("/invoices/abnormal")
def list_abnormal_invoices(
entity_id: int = Depends(get_entity_id),
limit: int = Query(50),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询异常发票(invalid/warning)"""
q = db.query(InvoiceCheck).filter(InvoiceCheck.check_status.in_(["invalid", "warning"]))
if entity_id:
q = q.filter(InvoiceCheck.entity_id == entity_id)
invoices = q.order_by(InvoiceCheck.id.desc()).limit(limit).all()
return {"data": [_invoice_to_dict(i) for i in invoices], "total": len(invoices)}
# ============================================================
# ③ 社保比对 — 缴费记录 CRUD + 比对 + 异常查询
# ============================================================
def _check_ss(db: Session, s: SocialSecurity, all_records: list | None = None):
"""社保比对规则:基数与工资匹配 / 单位缴纳比例 / 漏缴(含月份断层检测)"""
warnings: list[str] = []
alerts: list[str] = []
# 规则1: 缴费基数与工资匹配(60%~300%区间)
if s.salary and s.salary > 0 and s.base_amount and s.base_amount > 0:
low, high = s.salary * SS_BASE_LOW, s.salary * SS_BASE_HIGH
if s.base_amount < low or s.base_amount > high:
warnings.append(
f"缴费基数{s.base_amount}元超出工资{s.salary}元的{int(SS_BASE_LOW*100)}%~{int(SS_BASE_HIGH*100)}%区间({low:.0f}~{high:.0f})"
)
# 规则2: 单位缴纳比例(养老16%+医疗8%+失业0.5%≈24.5%)
rate = None
if s.base_amount and s.base_amount > 0 and s.company_amount is not None:
rate = round(s.company_amount / s.base_amount * 100, 2)
s.company_rate = rate
if rate is not None and abs(rate - SS_COMPANY_RATE) > SS_RATE_TOLERANCE:
warnings.append(f"单位缴纳比例{rate}%与标准{SS_COMPANY_RATE}%±{SS_RATE_TOLERANCE}%不符")
# 规则3: 漏缴检测
if not s.base_amount or s.base_amount <= 0 or (not s.company_amount and not s.personal_amount):
alerts.append(f"{s.employee}本期缴费基数为0或未缴费(疑似漏缴)")
# 规则3b: 月份断层检测(同人相邻记录期间间隔>1个月 → 漏缴)
if all_records is not None:
periods = sorted(
r.period for r in all_records
if r.employee == s.employee and r.period != s.period
)
prev = None
for p in periods:
if prev is not None:
try:
py, pm = map(int, prev.split("-"))
cy, cm = map(int, p.split("-"))
gap = (cy - py) * 12 + (cm - pm)
if gap > 1:
alerts.append(f"{s.employee}在{prev}至{p}之间漏缴{max(0, gap-1)}个月")
except Exception:
pass
prev = p
if alerts:
s.check_status = "alert"
elif warnings:
s.check_status = "warning"
else:
s.check_status = "normal"
s.warning_msg = ";".join(alerts + warnings) or None
@router.get("/ss")
def list_ss_records(
period: str = Query(None),
employee: str = Query(None),
status: str = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""社保缴费记录列表"""
q = db.query(SocialSecurity)
if entity_id:
q = q.filter(SocialSecurity.entity_id == entity_id)
if period:
q = q.filter(SocialSecurity.period == period)
if employee:
q = q.filter(SocialSecurity.employee.like(f"%{employee}%"))
if status:
q = q.filter(SocialSecurity.check_status == status)
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.asc()).all()
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
@router.post("/ss")
def create_ss_record(
request: Request,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""新增社保缴费记录(自动比对)"""
employee = data.get("employee")
period = data.get("period")
if not employee or not period:
raise HTTPException(400, "缺少必要参数: employee, period")
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == resolve_entity_for_request(request, data.get("entity_id") or 1)).all()
s = SocialSecurity(
entity_id=resolve_entity_for_request(request, data.get("entity_id") or 1),
employee=employee,
period=period,
base_amount=data.get("base_amount") or 0,
salary=data.get("salary"),
company_amount=data.get("company_amount") or 0,
personal_amount=data.get("personal_amount") or 0,
remark=data.get("remark"),
)
_check_ss(db, s, all_records)
db.add(s)
db.commit()
db.refresh(s)
return {"message": "社保记录已创建", "data": _ss_to_dict(s)}
@router.put("/ss/{ss_id}")
def update_ss_record(
ss_id: int,
data: dict,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""更新社保记录(自动重新比对)"""
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
if not s:
raise HTTPException(404, "社保记录不存在")
if "employee" in data:
s.employee = data["employee"]
if "period" in data:
s.period = data["period"]
if "base_amount" in data:
s.base_amount = data["base_amount"] or 0
if "salary" in data:
s.salary = data.get("salary")
if "company_amount" in data:
s.company_amount = data["company_amount"] or 0
if "personal_amount" in data:
s.personal_amount = data["personal_amount"] or 0
if "remark" in data:
s.remark = data.get("remark")
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == s.entity_id).all()
_check_ss(db, s, all_records)
db.commit()
db.refresh(s)
return {"message": "社保记录已更新", "data": _ss_to_dict(s)}
@router.delete("/ss/{ss_id}")
def delete_ss_record(
ss_id: int,
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""删除社保记录"""
s = db.query(SocialSecurity).filter(SocialSecurity.id == ss_id).first()
if not s:
raise HTTPException(404, "社保记录不存在")
db.delete(s)
db.commit()
return {"message": "社保记录已删除"}
@router.post("/ss/check")
def batch_check_ss(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""批量社保比对"""
q = db.query(SocialSecurity)
if entity_id:
q = q.filter(SocialSecurity.entity_id == entity_id)
records = q.all()
for s in records:
_check_ss(db, s, records)
db.commit()
abnormal = [s for s in records if s.check_status in ("alert", "warning")]
return {
"message": f"社保比对完成:{len(records)}条,异常{len(abnormal)}条",
"total": len(records),
"abnormal_count": len(abnormal),
}
@router.get("/ss/abnormal")
def list_abnormal_ss(
entity_id: int = Depends(get_entity_id),
limit: int = Query(50),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""查询社保异常(alert/warning)"""
q = db.query(SocialSecurity).filter(SocialSecurity.check_status.in_(["alert", "warning"]))
if entity_id:
q = q.filter(SocialSecurity.entity_id == entity_id)
records = q.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc()).limit(limit).all()
return {"data": [_ss_to_dict(s) for s in records], "total": len(records)}
# ============================================================
# ④ 税务看板聚合
# ============================================================
@router.get("/dashboard")
def tax_dashboard(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_auth),
):
"""税务合规看板聚合:税负趋势+行业对比 / 发票异常 / 社保异常"""
eid = entity_id or 1
# 税负监控
records = db.query(TaxRecord).filter(TaxRecord.entity_id == eid).order_by(TaxRecord.period.asc()).all()
for t in records:
_calc_burden(t)
db.commit()
trend_map: dict[str, dict] = {}
for t in records:
if t.tax_burden_rate is None:
continue
entry = trend_map.setdefault(t.period, {"period": t.period})
entry[f"{t.tax_type}_rate"] = t.tax_burden_rate
entry[f"{t.tax_type}_benchmark"] = TAX_BENCHMARKS.get(t.tax_type)
burden_trend = sorted(trend_map.values(), key=lambda x: x["period"])
latest_period = max((t.period for t in records), default=None)
latest = next((x for x in burden_trend if x["period"] == latest_period), None)
tax_alerts = [t for t in records if t.burden_status == "alert"]
# 发票
invoices = db.query(InvoiceCheck).filter(InvoiceCheck.entity_id == eid).all()
inv_status_count = {"pending": 0, "valid": 0, "invalid": 0, "warning": 0}
for i in invoices:
inv_status_count[i.check_status] = inv_status_count.get(i.check_status, 0) + 1
inv_abnormal = (
db.query(InvoiceCheck)
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.check_status.in_(["invalid", "warning"]))
.order_by(InvoiceCheck.id.desc())
.limit(10)
.all()
)
# 社保
ss_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
ss_status_count = {"normal": 0, "warning": 0, "alert": 0}
for s in ss_records:
ss_status_count[s.check_status] = ss_status_count.get(s.check_status, 0) + 1
ss_abnormal = (
db.query(SocialSecurity)
.filter(SocialSecurity.entity_id == eid, SocialSecurity.check_status.in_(["alert", "warning"]))
.order_by(SocialSecurity.period.desc(), SocialSecurity.id.desc())
.limit(10)
.all()
)
return {
"entity_id": eid,
"burden": {
"trend": burden_trend,
"latest": latest,
"benchmarks": TAX_BENCHMARKS,
"alert_count": len(tax_alerts),
"alerts": [_tax_to_dict(t) for t in tax_alerts[:10]],
},
"invoice": {
"total": len(invoices),
"status_count": inv_status_count,
"abnormal": [_invoice_to_dict(i) for i in inv_abnormal],
},
"ss": {
"total": len(ss_records),
"status_count": ss_status_count,
"abnormal": [_ss_to_dict(s) for s in ss_abnormal],
},
}
# ============================================================
# 演示数据(幂等)
# ============================================================
@router.post("/demo-data")
def seed_demo_data(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
_=Depends(require_role("ceo", "finance")),
):
"""生成税务合规演示数据(幂等:已存在期间+税种则跳过)"""
eid = entity_id or 1
created = {"tax": 0, "invoice": 0, "ss": 0}
# 税负记录: 2026-01 ~ 2026-07,其中02月增值税故意偏高触发预警
tax_demo = [
("2026-01", "vat", 52000, 48800, 1380000, 13),
("2026-02", "vat", 69000, 66000, 1460000, 13),
("2026-03", "vat", 41000, 39800, 1280000, 13),
("2026-04", "vat", 47500, 46200, 1350000, 13),
("2026-05", "vat", 53000, 51000, 1420000, 13),
("2026-06", "vat", 49800, 48200, 1400000, 13),
("2026-01", "income", 36000, 33500, 1380000, 25),
("2026-02", "income", 38000, 35500, 1460000, 25),
("2026-03", "income", 33000, 31000, 1280000, 25),
("2026-04", "income", 35000, 32800, 1350000, 25),
("2026-05", "income", 37000, 34800, 1420000, 25),
("2026-06", "income", 36500, 34000, 1400000, 25),
("2026-01", "surtax", 6240, 5900, 1380000, 12),
("2026-02", "surtax", 8280, 7900, 1460000, 12),
("2026-03", "surtax", 4920, 4700, 1280000, 12),
("2026-04", "surtax", 5700, 5500, 1350000, 12),
("2026-05", "surtax", 6360, 6100, 1420000, 12),
("2026-06", "surtax", 5976, 5800, 1400000, 12),
]
for period, ttype, payable, paid, income, rate in tax_demo:
exists = (
db.query(TaxRecord)
.filter(TaxRecord.entity_id == eid, TaxRecord.period == period, TaxRecord.tax_type == ttype)
.first()
)
if exists:
continue
t = TaxRecord(entity_id=eid, period=period, tax_type=ttype, tax_payable=payable,
tax_paid=paid, tax_rate=rate, income=income)
_calc_burden(t)
db.add(t)
created["tax"] += 1
# 报销单(供发票"金额与报销单匹配"规则使用)
reimb_demo = [
{"reimb_no": "BX202606150001", "applicant": "张伟", "department": "采购部", "expense_type": "office",
"title": "6月办公用品采购", "amount": 5600, "status": "approved"},
{"reimb_no": "BX202606200002", "applicant": "李娜", "department": "市场部", "expense_type": "management",
"title": "6月市场信息服务费", "amount": 3200, "status": "approved"},
{"reimb_no": "BX202607050003", "applicant": "王强", "department": "供应链部", "expense_type": "office",
"title": "7月供应链物流服务", "amount": 14000, "status": "pending"},
]
for d in reimb_demo:
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
if exists:
continue
db.add(ExpenseReimbursement(entity_id=eid, **d))
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
invoice_demo = [
{"invoice_no": "9" * 33, "amount": 5600, "invoice_type": "vat", "invoice_date": "2026-06-15",
"supplier": "北京云启科技有限公司", "reimb_no": "BX202606150001", "contract_no": "HT-2026-018"},
{"invoice_no": "8" * 33, "amount": 3200, "invoice_type": "electronic", "invoice_date": "2026-06-20",
"supplier": "上海数联信息服务有限公司", "reimb_no": "BX202606200002", "contract_no": "HT-2026-021"},
{"invoice_no": "12345ABC", "amount": 1800, "invoice_type": "vat", "invoice_date": "2026-07-02",
"supplier": "广州锐思咨询有限公司", "reimb_no": None, "contract_no": None},
{"invoice_no": "7" * 33, "amount": 15000, "invoice_type": "vat", "invoice_date": "2026-07-05",
"supplier": "深圳恒达供应链有限公司", "reimb_no": "BX202607050003", "contract_no": "HT-2026-030"},
{"invoice_no": "6" * 33, "amount": 9800, "invoice_type": "vat", "invoice_date": "2026-07-08",
"supplier": None, "reimb_no": None, "contract_no": "HT-2026-033"},
]
for d in invoice_demo:
exists = (
db.query(InvoiceCheck)
.filter(InvoiceCheck.entity_id == eid, InvoiceCheck.invoice_no == d["invoice_no"])
.first()
)
if exists:
continue
inv_data = dict(d)
# 演示日期按既有写入口径解析为 datetime(字符串日期在 SQLite 下无法绑定 DateTime 列;
# MySQL 表现不变——同文件 :402 正常写入路径亦经 _parse_date)
inv_data["invoice_date"] = _parse_date(inv_data.get("invoice_date"))
inv = InvoiceCheck(entity_id=eid, **inv_data)
_check_invoice(db, inv)
db.add(inv)
created["invoice"] += 1
# 社保: 正常3人×2月 + 基数不符1条 + 漏缴1条
ss_demo = [
{"employee": "张伟", "period": "2026-06", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
{"employee": "张伟", "period": "2026-07", "base_amount": 12000, "salary": 12000, "company_amount": 2940, "personal_amount": 1248},
{"employee": "李娜", "period": "2026-06", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
{"employee": "李娜", "period": "2026-07", "base_amount": 18000, "salary": 18000, "company_amount": 4410, "personal_amount": 1872},
{"employee": "王强", "period": "2026-06", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
{"employee": "王强", "period": "2026-07", "base_amount": 9000, "salary": 9000, "company_amount": 2205, "personal_amount": 936},
{"employee": "赵敏", "period": "2026-06", "base_amount": 5000, "salary": 20000, "company_amount": 1225, "personal_amount": 520},
{"employee": "孙磊", "period": "2026-05", "base_amount": 0, "salary": 15000, "company_amount": 0, "personal_amount": 0},
]
for d in ss_demo:
exists = (
db.query(SocialSecurity)
.filter(SocialSecurity.entity_id == eid, SocialSecurity.employee == d["employee"],
SocialSecurity.period == d["period"])
.first()
)
if exists:
continue
s = SocialSecurity(entity_id=eid, **d)
all_records = db.query(SocialSecurity).filter(SocialSecurity.entity_id == eid).all()
_check_ss(db, s, all_records)
db.add(s)
created["ss"] += 1
db.commit()
return {"message": "演示数据生成完成", "created": created}
+188
View File
@@ -0,0 +1,188 @@
"""KPI模板库 API — 管理会计OS
支持系统预置模板 + 用户自定义模板
从模板实例化创建KPI时,复制模板快照到kpi_definitions"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from app.database import get_db
from app.auth_middleware import require_role
from app.deps import get_entity_id
from app.models import KPITemplate, KPIDefinition, OperationLog
from app.utils.oplog import log_op, SCOPE_GLOBAL
from app.api.kpi_governance import validate_kpi_payload, kpi_issues_message
router = APIRouter(prefix="/api/cma/templates", tags=["KPI模板库"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
def template_to_dict(t):
return {c.name: getattr(t, c.name) for c in t.__table__.columns}
@router.get("")
def list_templates(
dimension: Optional[str] = None,
category: Optional[str] = None,
keyword: Optional[str] = None,
is_system: Optional[int] = None,
db: Session = Depends(get_db),
):
"""获取模板列表,支持按维度/类别/关键字筛选"""
query = db.query(KPITemplate)
if dimension:
query = query.filter(KPITemplate.dimension == dimension)
if category:
query = query.filter(KPITemplate.category == category)
if keyword:
query = query.filter(KPITemplate.kpi_name.contains(keyword))
if is_system is not None:
query = query.filter(KPITemplate.is_system == is_system)
templates = query.order_by(KPITemplate.is_system.desc(), KPITemplate.kpi_code).all()
return {"total": len(templates), "data": [template_to_dict(t) for t in templates]}
@router.get("/{template_id}")
def get_template(template_id: int, db: Session = Depends(get_db)):
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
return template_to_dict(t)
@router.post("")
def create_template(data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""用户创建自定义模板"""
existing = db.query(KPITemplate).filter(KPITemplate.kpi_code == data.get("kpi_code", "")).first()
if existing:
raise HTTPException(400, f"模板编码 {data['kpi_code']} 已存在")
t = KPITemplate(
kpi_code=data.get("kpi_code"),
kpi_name=data.get("kpi_name"),
dimension=data.get("dimension"),
category=data.get("category"),
formula=data.get("formula"),
formula_desc=data.get("formula_desc"),
unit=data.get("unit", "%"),
target_value=data.get("target_value"),
description=data.get("description"),
is_system=0, # 用户创建的永远不是系统模板
usage_count=0,
)
db.add(t)
db.commit()
db.refresh(t)
# kpi_templates = 系统预置/用户自定义**模板库**(非账套维度,裁定见 docs/entity-dimension-data-dictionary.md)
_log(db, 1, "create", "template", t.id, {"kpi_code": t.kpi_code, "kpi_name": t.kpi_name},
scope=SCOPE_GLOBAL)
return template_to_dict(t)
@router.put("/{template_id}")
def update_template(template_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""修改自定义模板(系统预置不可修改)"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
if t.is_system:
raise HTTPException(403, "系统预置模板不可修改")
for k, v in data.items():
if hasattr(t, k) and v is not None:
setattr(t, k, v)
db.commit()
return template_to_dict(t)
@router.delete("/{template_id}")
def delete_template(template_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
"""删除自定义模板(系统预置不可删除)"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
if t.is_system:
raise HTTPException(403, "系统预置模板不可删除")
db.delete(t)
db.commit()
return {"message": "模板已删除"}
@router.post("/{template_id}/instantiate")
def instantiate_template(template_id: int, data: dict, db: Session = Depends(get_db),
user=WRITE_ROLES, entity_id: int = Depends(get_entity_id)):
"""从模板实例化创建KPI,复制模板快照到kpi_definitions
账套维度(2026-09-23 账套维度收口批 ①):新 KPI 显式挂**会话账套**,不做静默兜底。
历史缺陷:本构造点未传 `entity_id` ⇒ 模型 `default=1` 把任意账套实例化出的 KPI
静默挂到酣客(e1),与「建预算未写账套」同源。
判重口径:`kpi_code` 唯一性按账套判定(库内 `uk_entity_kpi(UNIQUE entity_id,kpi_code)`);
全表判重会让博海无法建与酣客同码的 KPI(反之则放过同账套重复)。
"""
t = db.query(KPITemplate).filter(KPITemplate.id == template_id).first()
if not t:
raise HTTPException(404, "模板不存在")
kpi_code = data.get("kpi_code", t.kpi_code)
kpi_name = data.get("kpi_name", t.kpi_name)
# 检查编码唯一性(按账套收紧:仅本账套内判重)
existing = db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == kpi_code,
KPIDefinition.entity_id == entity_id,
).first()
if existing:
raise HTTPException(400, f"KPI编码 {kpi_code} 在本账套已存在,请修改")
# 数据治理:入库必检(规则1) + 编码规范(规则3)
merged = {
"kpi_code": kpi_code,
"kpi_name": kpi_name,
"dimension": data.get("dimension", t.dimension),
"category": data.get("category", t.category),
"formula": data.get("formula", t.formula),
"unit": data.get("unit", t.unit or "%"),
"target_value": data.get("target_value", t.target_value),
}
gov_issues = [i for i in validate_kpi_payload(merged, db=db) if i["rule"] in (1, 3)]
if gov_issues:
raise HTTPException(422, detail={"message": "数据校验不通过", "errors": kpi_issues_message(gov_issues)})
kpi = KPIDefinition(
entity_id=entity_id, # 账套维度:显式挂会话账套(不得依赖模型 default=1)
template_id=t.id,
is_system=0, # 从模板实例化的KPI不是系统预置
kpi_code=kpi_code,
kpi_name=kpi_name,
dimension=data.get("dimension", t.dimension),
category=data.get("category", t.category),
formula=data.get("formula", t.formula),
formula_desc=data.get("formula_desc", t.formula_desc),
unit=data.get("unit", t.unit or "%"),
target_value=data.get("target_value", t.target_value),
objective=data.get("objective"),
data_source_type=data.get("data_source_type", "manual"),
frequency=data.get("frequency", "monthly"),
responsible_dept=data.get("responsible_dept"),
responsible_user=data.get("responsible_user"),
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
# 更新模板使用计数
t.usage_count = (t.usage_count or 0) + 1
db.commit()
_log(db, 1, "create", "kpi", kpi.id, {"from_template": template_id, "kpi_code": kpi.kpi_code},
entity_id=entity_id)
return {c.name: getattr(kpi, c.name) for c in kpi.__table__.columns}
def _log(db, user_id, action, target_type, target_id, detail, entity_id=None, scope=None):
"""统一走 log_op(2026-09-23 ④:审计写入统一带 `detail.entity_id`)"""
return log_op(db, action, target_type=target_type, target_id=target_id,
detail=detail, user_id=user_id, entity_id=entity_id, scope=scope, commit=True)
+168
View File
@@ -0,0 +1,168 @@
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务
⚠️ 语义收敛(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,任总已批「都批」)
--------------------------------------------------------------------------------------
`system_configs.current_tenant` 是**全局唯一键**(该表无 entity_id 列,`config_key` unique)
⇒ 一次写入全平台生效。本次事故(2026-09-04 前端企业切换器把它写成 entity_id=2)暴露的
结构性缺陷正是「全局单值键被当成个人当前视图 / 数据维度」。
本文件的收敛口径:
1. **它不是数据维度**。工作台/看板/报表取数一律用**登录会话的 entity**
(`app/deps.py::get_entity_id` ← JWT token 绑定),前端本地视图用
`localStorage.cma_entity_id`(登录窗口写入)。禁止任何取数端点读本键。
2. 它的用途**只剩**:项目 Bot 分发 A2A 任务时的状态记录(本模块原始设计目的)。
3. **仅 admin 会话可读**(GET /current):系统无 `admin` 角色(ROLES: ceo/finance/
business/it),故映射为持有 `admin` 动作权限的 `it` + 业务负责人 `ceo`。
4. 写(POST /switch)**不额外收紧角色**:项目 Bot 分发任务时需要写该状态,
收紧会切断其原始用途。
5. 每次写都落 `operation_logs`(action=`switch_tenant_state`)留痕 —— 修复前本端点
无审计(ops 止血单假定「走端点即有审计」不成立,已在本单补齐)。
6. **审计行可归属(2026-09-22 DAMA 独立回查 §4b 后补)**:`user_id` = 当前会话用户
(原写 None ⇒ 事后无法追操作人);`target_type="entity"` + `target_id=<entity_id>`
与 `POST /auth/switch-entity`(action=`switch_entity`,`auth.py`)**共用同一目标维度**
⇒「按账套查换账套记录」= `WHERE target_type='entity' AND target_id=?`(`operation_logs`
无 `entity_id` 列,无需 DDL)。两字面**刻意不合并**:一个改「用户自己的会话账套」、
一个改「平台全局状态记录」,合并后审计上不可分辨。口径登记见
`docs/audit-and-notification-caliber-20260922.md`。
7. **审计缺口的收口(2026-09-23 dama-entity-isolation-sweep-20260923 §二)**:
- `detail` 显式给出 `from_entity_id → to_entity_id`(原先只有 `previous` 整包,
要人肉解析才知道「从哪来」)+ `source` + `operator`(id/username/role)。
- **审计失败不静默**:状态键与审计行走**同一事务**,审计写入异常 → `rollback()`
(状态变更一并回滚,绝不留「改了但没审计」的窗口)+ `logger.error(AUDIT_WRITE_FAILED…)`
+ HTTP 500。日志前缀 `AUDIT_WRITE_FAILED` 供运维 grep/告警链订阅。
- `GET /current` **无写路径**(纯只读,且已限 it/ceo),故不落审计——不给读操作
写审计表,避免审计面被读流量稀释。
- 历史缺口(2026-09-22 止血操作无审计行)**不补写**:审计不得事后伪造。
"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth_401, require_role
from app.models import SystemConfig, Entity, OperationLog
import logging
logger = logging.getLogger("cma.tenant")
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
# system_config.current_tenant(状态改写面)。收口为 require_auth_401。
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"],
dependencies=[Depends(require_auth_401)])
TENANT_KEY = "current_tenant"
class TenantSwitchRequest(BaseModel):
entity_id: int
source: str = "cma-system"
def _tenant_name(entity_id: int) -> str:
"""entity_id → tenant_id"""
return "company_b" if entity_id == 2 else "company_a"
@router.get("/current")
def get_current_tenant(
db: Session = Depends(get_db),
user=Depends(require_role("it", "ceo")),
):
"""查询当前租户状态记录(**仅 admin 会话**:it/ceo)
2026-09-22 收敛:本键**不是**数据维度——前端/接口取数一律按会话 entity,
不得读本键决定「取哪个实体的数据」(本次工作台跨实体污染的直接触发点)。
"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg and cfg.config_value:
import json
try:
return json.loads(cfg.config_value)
except:
pass
return {"tenant_id": "company_a", "entity_id": 1, "name": "陕西酣客文化传媒"}
@router.post("/switch")
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
user=Depends(require_auth_401)):
"""切换当前租户状态记录(项目Bot 分发 A2A 任务用;CMA前端企业切换器已下线)
2026-09-22:写操作**不动**(保留 Bot 通道可用性),补 `operation_logs` 审计留痕。
"""
ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
if not ent:
raise HTTPException(404, "企业不存在")
import json
from_entity_id = None
previous = None
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg and cfg.config_value:
try:
previous = json.loads(cfg.config_value)
if isinstance(previous, dict):
from_entity_id = previous.get("entity_id")
except Exception:
previous = None
state = {
"tenant_id": _tenant_name(data.entity_id),
"entity_id": data.entity_id,
"name": ent.name,
"short_name": ent.short_name,
"source": data.source,
"switched_at": __import__("datetime").datetime.now().isoformat(),
}
if cfg:
cfg.config_value = json.dumps(state, ensure_ascii=False)
else:
db.add(SystemConfig(
config_key=TENANT_KEY,
config_value=json.dumps(state, ensure_ascii=False),
description="当前租户状态记录(仅供项目Bot分发A2A任务;非数据维度,2026-09-22 语义收敛)",
))
# 审计留痕(2026-09-22 补齐:本端点原无 operation_logs 记录)
# 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人);
# target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤
# (operation_logs 无 entity_id 列,无须 DDL)。
# 2026-09-23 收口:detail 显式 from→to / source / operator;审计失败不静默(见 §7)。
audit_detail = {
"from_entity_id": from_entity_id,
"to_entity_id": data.entity_id,
"entity_id": data.entity_id, # 兼容既有消费方/用例(= to)
"entity_name": ent.name,
"source": data.source,
"operator": {
"user_id": user.id,
"username": getattr(user, "username", None),
"name": getattr(user, "name", None),
"role": getattr(user, "role", None),
},
"previous": previous,
"note": "current_tenant 状态记录变更(非数据维度)",
}
try:
db.add(OperationLog(
user_id=user.id,
action="switch_tenant_state",
target_type="entity",
target_id=data.entity_id,
detail=audit_detail,
))
# 状态键与审计行同一事务提交:审计写不进 ⇒ 状态变更一并回滚
# (绝不留「改了但没审计」的窗口;失败必须可见,不得静默)
db.commit()
except Exception:
db.rollback()
logger.error(
"AUDIT_WRITE_FAILED tenant/switch from=%s to=%s user_id=%s source=%s "
"→ 审计写入失败,状态变更已回滚(无审计不得留变更)",
from_entity_id, data.entity_id, getattr(user, "id", None), data.source,
exc_info=True,
)
raise HTTPException(status_code=500, detail="审计写入失败,状态变更已回滚")
return {"success": True, "current": state}
+129 -58
View File
@@ -1,9 +1,18 @@
"""阈值智能推荐 API"""
"""阈值智能推荐 API
2026-09-11 (project-20260911-threshold-fix) 修复:
推荐值必须与后端解析器(app.utils.threshold_parser)语法一致 —— 原实现给黄灯/适中型
生成区间/文字标签(`52.33~107.67`、`偏离55%`),用户点“自动建议”填入后保存即静默失效。
现在统一生成可解析表达式:标量比较(支持负数)/ 闭区间 `A~B` / 多条件 `C1|C2`。
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, OperationLog
from app.utils.threshold_parser import THRESHOLD_HELP, is_valid_threshold
from app.utils.kpi_value_scope import actual_only_clause
from typing import Any, Optional
import json
router = APIRouter(prefix="/api/cma/thresholds", tags=["阈值分析"],
@@ -18,14 +27,114 @@ KPI_TYPES = {
"middle_best": ["TOP5_CUSTOMER_RATIO"],
}
def _r(v) -> float:
"""保留2位小数(避免浮点噪声写进表达式)"""
return round(float(v), 2)
def _fmt(v) -> str:
"""数值 → 表达式文本。禁用科学计数法:`:g` 在 |v|>=1e6 时会输出 1.6e+06,
解析器不认(会导致推荐值再次静默失效)。"""
s = f"{float(v):.2f}".rstrip("0").rstrip(".")
return s if s not in ("", "-") else "0"
def build_suggestion(kpi_type: str, target: float, tolerance: Optional[float] = None) -> dict:
"""按类型生成可解析的红黄绿阈值建议(仅生成表达式,不含 I/O)。
口径(label = 该等级的触发条件,判定优先级 red→green→yellow):
higher_better:绿 `>=0.8T`、黄 `>=0.5T`(更宽的兜底带)、红 `<0.5T`
lower_better :绿 `<=1.2T`、黄 `<=2.0T`、红 `>2.0T`
middle_best :绿 `T±tol` 闭区间、黄 `T±2tol` 外环带(green ⊂ yellow)、红 `<T-2tol|>T+2tol`
"""
if kpi_type == "higher_better":
green_min = _r(target * 0.8)
yellow_min = _r(target * 0.5)
red_max = yellow_min
return {
"type": "higher_better",
"description": "越高越好型",
"green": {"min": green_min, "max": None, "label": f">={_fmt(green_min)}"},
"yellow": {"min": yellow_min, "max": green_min, "label": f">={_fmt(yellow_min)}"},
"red": {"min": None, "max": red_max, "label": f"<{_fmt(red_max)}"},
}
if kpi_type == "lower_better":
green_max = _r(target * 1.2)
yellow_max = _r(target * 2.0)
red_min = yellow_max
return {
"type": "lower_better",
"description": "越低越好型",
"green": {"min": None, "max": green_max, "label": f"<={_fmt(green_max)}"},
"yellow": {"min": green_max, "max": yellow_max, "label": f"<={_fmt(yellow_max)}"},
"red": {"min": red_min, "max": None, "label": f">{_fmt(red_min)}"},
}
# middle_best(适中最好型):绿=内区间 T±tol,黄=外环带 T±2tol(green ⊂ yellow),红=环带之外
tol = float(tolerance) if tolerance else abs(float(target)) * 0.2
if tol <= 0:
tol = 1.0
a, b = _r(target - tol), _r(target + tol)
c, d = _r(target - 2 * tol), _r(target + 2 * tol)
return {
"type": "middle_best",
"description": "适中最好型",
"green": {"min": a, "max": b, "label": f"{_fmt(a)}~{_fmt(b)}"},
"yellow": {"min": c, "max": d, "label": f"{_fmt(c)}~{_fmt(d)}"},
"red": {"min": None, "max": None, "label": f"<{_fmt(c)}|>{_fmt(d)}"},
}
def suggest_by_type(kpi):
"""无历史数据时按类型推荐(同样输出可解析表达式,含 yellow/red 标签)"""
target = kpi.target_value or 100
kpi_type = _kpi_type(kpi)
suggestion = build_suggestion(kpi_type, target)
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"format_hint": THRESHOLD_HELP, "suggestion": suggestion}
def _kpi_type(kpi) -> str:
"""KPI → 推荐类型(**保持历史默认语义**)。
原实现为 if higher_better / elif lower_better / **else → middle_best**,
即「不在任何清单里的编码」默认走适中型(如 C_REBATE_RATE 渠补率、
BH_SALES_10 销售-任富海,其历史生成的 '52.33~107.67' / '偏离…%' 即出自该分支)。
此处默认值必须同为 middle_best,否则默认分支的推荐口径会被改变。
"""
code = getattr(kpi, "kpi_code", None)
if code in KPI_TYPES["higher_better"]:
return "higher_better"
if code in KPI_TYPES["lower_better"]:
return "lower_better"
return "middle_best"
def _sanity_check(suggestion: dict) -> dict:
"""自检:推荐值必须过解析器(防再次生成不可解析格式),并挂 format 提示给前端。"""
bad = []
for level in ("green", "yellow", "red"):
label = (suggestion.get(level) or {}).get("label")
if label and not is_valid_threshold(label):
bad.append({"level": level, "label": label})
suggestion["parseable"] = not bad
if bad:
suggestion["unparseable"] = bad
return suggestion
@router.get("/suggest/{kpi_id}")
def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)):
"""根据历史数据自动推荐阈值"""
"""根据历史数据自动推荐阈值(输出格式保证被 threshold_parser 解析生效)"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
if not kpi:
return {"error": "KPI不存在"}
values = db.query(KPIValue).filter(KPIValue.kpi_id == kpi_id).order_by(KPIValue.period.asc()).all()
values = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
# 实测区口径:预估行禁作阈值推荐依据(kpi_value_scope.actual_only_clause)
actual_only_clause(),
).order_by(KPIValue.period.asc()).all()
if not values:
# 无历史数据,按行业标准推荐
@@ -36,62 +145,24 @@ def suggest_threshold(kpi_id: int, db: Session = Depends(get_db)):
if len(nums) < 2:
return suggest_by_type(kpi)
avg = sum(nums) / len(nums)
# 注: SQLAlchemy 模型属性未做 Mapped 注解, Pyright 会推断为 Column;
# 这里统一标注 Any 抑制静态噪音(运行期均为 float)。
avg: Any = sum(nums) / len(nums)
# 计算标准差
variance = sum((x - avg) ** 2 for x in nums) / len(nums)
std = variance ** 0.5
variance: Any = sum((x - avg) ** 2 for x in nums) / len(nums)
std: Any = variance ** 0.5
target = kpi.target_value or avg
# 根据KPI类型生成推荐区间
if kpi.kpi_code in KPI_TYPES["higher_better"]:
green_min = round(target * 0.8, 2)
yellow_min = round(target * 0.5, 2)
red_max = round(target * 0.5, 2)
suggestion = {
"type": "higher_better",
"description": "越高越好型",
"green": {"min": green_min, "max": None, "label": f">={green_min}"},
"yellow": {"min": yellow_min, "max": green_min, "label": f"{yellow_min}~{green_min}"},
"red": {"min": None, "max": red_max, "label": f"<{red_max}"},
"current_avg": round(avg, 2),
"target": target,
}
elif kpi.kpi_code in KPI_TYPES["lower_better"]:
green_max = round(target * 1.2, 2)
yellow_max = round(target * 2.0, 2)
suggestion = {
"type": "lower_better",
"description": "越低越好型",
"green": {"min": None, "max": green_max, "label": f"<={green_max}"},
"yellow": {"min": green_max, "max": yellow_max, "label": f"{green_max}~{yellow_max}"},
"red": {"min": yellow_max, "max": None, "label": f">{yellow_max}"},
"current_avg": round(avg, 2),
"target": target,
}
else:
tolerance = max(std * 1.5, target * 0.2)
suggestion = {
"type": "middle_best",
"description": "适中最好型",
"green": {"min": round(target - tolerance, 2), "max": round(target + tolerance, 2), "label": f"{round(target-tolerance,2)}~{round(target+tolerance,2)}"},
"yellow": {"min": round(target - tolerance*2, 2), "max": round(target + tolerance*2, 2), "label": f"偏离{(tolerance*2):.0f}%"},
"red": {"min": None, "max": round(target - tolerance*2, 2), "label": f"偏离>{tolerance*2:.0f}%"},
"current_avg": round(avg, 2),
"target": target,
}
return {"kpi_id": kpi_id, "kpi_name": kpi.kpi_name, "suggestion": suggestion}
target: Any = kpi.target_value or avg
kpi_type = _kpi_type(kpi)
def suggest_by_type(kpi):
"""无历史数据时按类型推荐"""
target = kpi.target_value or 100
if kpi.kpi_code in KPI_TYPES["higher_better"]:
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"suggestion": {"type": "higher_better", "green": {"min": round(target*0.8,2)}, "yellow": {"min": round(target*0.5,2)}, "red": {"max": round(target*0.5,2)}}}
elif kpi.kpi_code in KPI_TYPES["lower_better"]:
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"suggestion": {"type": "lower_better", "green": {"max": round(target*1.2,2)}, "yellow": {"max": round(target*2,2)}, "red": {"min": round(target*2,2)}}}
if kpi_type == "middle_best":
tolerance: Any = max(std * 1.5, target * 0.2)
suggestion = build_suggestion(kpi_type, target, tolerance)
else:
return {"kpi_id": kpi.id, "kpi_name": kpi.kpi_name, "message": "无历史数据",
"suggestion": {"type": "middle_best", "green": {"min": round(target*0.8,2), "max": round(target*1.2,2)}}}
suggestion = build_suggestion(kpi_type, target)
suggestion = _sanity_check(suggestion)
suggestion["current_avg"] = round(avg, 2)
suggestion["target"] = target
return {"kpi_id": kpi_id, "kpi_name": kpi.kpi_name,
"format_hint": THRESHOLD_HELP, "suggestion": suggestion}
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")
+340
View File
@@ -0,0 +1,340 @@
"""
自动验证引擎 API — 管理会计OS
POST /api/cma/verify/{plan_id} 手动验证行动计划执行结果
数据流:
ActionPlan.auto_verify_rule (JSON) → 条件判断 → passed/failed
→ KPI值回填 (kpi_current_before/after + KPIValue source_type=verify)
→ 验证通过 → 所属OKR progress +15%
→ 通知任总 (send_wecom_message)
⚠️ 双 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",
"condition": "LESS_THAN", # LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL
"target_value": 80,
"baseline_value": 86.4,
"verify_after_days": 7,
"retry_max": 3,
"escalate_to": "任富海",
"notify": true
}
兼容格式(旧):
{"condition": "value < 75", "description": "..."} → 走 bot_bridge_v2._evaluate_condition
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import json
import logging
from app.database import get_db
from app.models import ActionPlan, KPIDefinition, KPIValue, Objective
from app.utils.notifier import send_wecom_message
from app.auth_middleware import require_role
logger = logging.getLogger("cma.verify")
router = APIRouter(
prefix="/api/cma/verify",
tags=["自动验证"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
CONDITION_FUNCS = {
"LESS_THAN": lambda actual, target: actual is not None and actual < target,
"GREATER_THAN": lambda actual, target: actual is not None and actual > target,
"WITHIN_RANGE": lambda actual, target: target is not None and len(target) == 2 and float(target[0]) <= actual <= float(target[1]),
"NOT_NULL": lambda actual, target: actual is not None,
}
def _parse_rule(rule) -> dict:
"""解析auto_verify_rule(兼容JSON字符串)"""
if rule is None:
return {}
if isinstance(rule, str):
try:
return json.loads(rule)
except Exception:
return {"condition": rule}
return rule
def evaluate_rule(rule: dict, actual, kpi_data: dict = None) -> bool:
"""
按规则判断actual是否达标
- 新格式: condition为枚举 LESS_THAN/GREATER_THAN/WITHIN_RANGE/NOT_NULL
- 旧格式: condition为表达式字符串(如 "value < 75")→ bot_bridge_v2引擎
"""
condition = (rule or {}).get("condition", "")
if not condition:
return actual is not None
if condition in CONDITION_FUNCS:
target = rule.get("target_value")
return CONDITION_FUNCS[condition](actual, target)
# 旧格式表达式(bot-bridge兼容)
try:
from app.api.bot_bridge_v2 import _evaluate_condition
kd = kpi_data or {"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")}
return _evaluate_condition(condition, kd)
except Exception as e:
logger.warning(f"旧格式条件评估失败({condition}): {e}")
return False
def update_okr_progress(db: Session, plan: ActionPlan, 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}
def build_auto_verify_rule(kpi, baseline_value=None, verify_after_days: int = 7) -> dict:
"""根据KPI阈值自动生成验证规则(验收#1: 创建ActionPlan自动带auto_verify_rule)
从KPI的绿灯阈值(green)推导达标方向:
- 绿灯 '<=X' → 目标是把值压到 X 以下 → LESS_THAN X
- 绿灯 '>=X' → 目标是把值抬到 X 以上 → GREATER_THAN X
- 有绿色区间 'X~Y' → WITHIN_RANGE
- 无阈值 → NOT_NULL
"""
expr = (kpi.threshold_green or "").strip()
condition = "NOT_NULL"
target = None
import re
m = re.match(r"^(<=|>=|<|>|=)\s*([\d.]+)$", expr)
if m:
op, val = m.group(1), float(m.group(2))
if op in ("<", "<="):
condition = "LESS_THAN"
elif op in (">", ">="):
condition = "GREATER_THAN"
else:
condition = "NOT_NULL"
target = val
elif "~" in expr:
parts = expr.split("~")
if len(parts) == 2:
try:
condition = "WITHIN_RANGE"
target = [float(parts[0]), float(parts[1])]
except ValueError:
pass
else:
# 无阈值表达式 → 用KPI目标值推方向
condition = "NOT_NULL"
target = None
return {
"kpi_code": kpi.kpi_code,
"condition": condition,
"target_value": target,
"baseline_value": float(baseline_value) if baseline_value is not None else None,
"verify_after_days": verify_after_days,
"retry_max": 3,
"escalate_to": "任富海",
"notify": True,
}
def backfill_kpi_value(db: Session, plan: ActionPlan, actual, rule: dict, source: str = "verify"):
"""回填KPI当前值: 写入kpi_current_before/after + 新KPIValue记录
缺陷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
# 确定 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:
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()
if actual is None:
return None
plan.kpi_current_before = plan.kpi_current_before if plan.kpi_current_before is not None else rule.get("baseline_value")
plan.kpi_current_after = actual
if kpi:
new_val = KPIValue(
kpi_id=kpi.id,
entity_id=kpi.entity_id, # 缺陷2修复:多租户回填 entity_id
period=datetime.now().strftime("%Y-%m"),
actual_value=actual,
source_type="verify",
source_batch=f"verify-plan-{plan.id}",
data_status="verified",
calculated_at=datetime.now(),
remark=f"行动计划#{plan.id}验证回填",
)
db.add(new_val)
return {"kpi_id": kpi.id, "kpi_code": kpi.kpi_code}
return None
@router.post("/{plan_id}")
def verify_action_plan(plan_id: int, payload: dict, db: Session = Depends(get_db)):
"""验证行动计划执行结果(手动验证 / Bot回填)
请求体:
{
"actual_value": 78.5, # 可选,缺省时取KPI最新值
"source": "财务Bot分析", # 来源
"note": "渠补谈判后...", # 备注/验证结果详情
"passed": true # 可选,无规则时手动指定
}
"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "行动计划不存在")
rule = _parse_rule(plan.auto_verify_rule)
actual = payload.get("actual_value")
note = payload.get("note", "")
source = payload.get("source", "手动验证")
# 1. 无规则 → 按手动指定 passed 标记
if not rule or not rule.get("condition"):
passed = payload.get("passed", True)
plan.verify_status = "passed" if passed else "failed"
plan.verify_result = "pass" if passed else "fail"
plan.verify_log = (plan.verify_log or []) + [{
"timestamp": datetime.now().isoformat(),
"passed": passed,
"note": note,
"source": source,
}]
plan.verified_at = datetime.now()
db.commit()
return {"plan_id": plan_id, "verify_status": plan.verify_status, "passed": passed}
# 2. 缺省actual → 取KPI最新值(缺陷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:
# 缺陷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),
KPIValue.period <= period_limit, # 缺陷3修复:只取当前月及之前的期间
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
if latest:
actual = latest.actual_value
# 3. 执行条件判断
passed = evaluate_rule(rule, actual, kpi_data={"value": actual, "target": rule.get("target_value"), "baseline": rule.get("baseline_value")})
# 4. 回写KPI当前值
backfill = backfill_kpi_value(db, plan, actual, rule, source=source)
# 缺陷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"
plan.verify_log = (plan.verify_log or []) + [{
"timestamp": datetime.now().isoformat(),
"condition": rule.get("condition"),
"actual_value": actual,
"target_value": rule.get("target_value"),
"passed": passed,
"source": source,
"note": note,
}]
if passed:
plan.status = "completed" # 缺陷4修复:"done" 不在枚举(pending/in_progress/completed/cancelled),改 completed
plan.progress = 100
plan.verified_at = datetime.now()
# 6. OKR进度联动(验证通过 → +15%;缺陷1修复:已通过过的 plan 不再重复累加,force_recalc 可强制重算)
okr_update = None
if passed:
okr_update = update_okr_progress(db, plan, already_verified=already_verified, force_recalc=force_recalc)
db.commit()
# 7. 通知任总
if rule.get("notify", True):
delta = ""
if plan.kpi_current_before is not None and actual is not None:
try:
d = float(actual) - float(plan.kpi_current_before)
delta = f"({d:+.1f})"
except (TypeError, ValueError):
delta = ""
title = f"✅ 行动计划#{plan.id}验证通过" if passed else f"❌ 行动计划#{plan.id}验证失败"
content = (
f"{plan.title}\n"
f"KPI: {rule.get('kpi_code', '')} {plan.kpi_current_before}→{actual} {delta}\n"
f"规则: {rule.get('condition')} {rule.get('target_value')}\n"
f"来源: {source}\n"
f"{note}"
)
try:
send_wecom_message(content=content, title=title, alert_level="green" if passed else "red")
except Exception as e:
logger.warning(f"验证通知发送失败: {e}")
return {
"plan_id": plan_id,
"verify_status": plan.verify_status,
"kpi_current_before": plan.kpi_current_before,
"kpi_current_after": plan.kpi_current_after,
"improvement": f"{float(actual) - float(plan.kpi_current_before):+.1f}" if plan.kpi_current_before is not None and actual is not None else None,
"passed": passed,
"okr_progress": okr_update,
"kpi_backfill": backfill,
"message": (
f"验证通过:{plan.title}" if passed
else f"验证失败:{plan.title}(条件 {rule.get('condition')} {rule.get('target_value')} 未达成,当前值 {actual})"
),
}
+197 -47
View File
@@ -1,7 +1,23 @@
"""战略地图版本管理 API"""
"""战略地图版本管理 API(2026-09-08 完整实现)
功能闭环(任总报告\"版本连接点击打不开\"→ 方案B):
- GET /{map_id}/versions 版本列表(含维度/目标/KPI/连线摘要 + is_current)
- GET /{map_id}/versions/{vid} 单版本详情(dimensions/canvas_data 全量,供\"查看快照画布\")
- POST /{map_id}/versions/snapshot 手动创建快照(保留,历史兼容)
- POST /{map_id}/versions/{vid}/rollback 回滚到指定版本 —— 回滚前自动备份当前内容为新版本快照,防覆盖丢数据
- PUT /{map_id}/versions/{vid} 更新版本备注 comment
安全:
- 全部端点按 map_id + entity_id 隔离(地图归属 404;版本行再校验 entity_id,防跨账套回滚)
- 回滚 = 先备份后覆盖;备份版本号由当前地图 version 派生(vX.Y → vX.(Y+1)),撞号自动 +1
- 不改 _auto_snapshot / 发布逻辑(maps.py 自动快照保持原样)
"""
import json
import re
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
@@ -10,45 +26,142 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"],
)
def _get_map(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
def _get_version(db: Session, map_id: int, vid: int, entity_id: int) -> StrategicMapVersion:
v = db.query(StrategicMapVersion).filter(
StrategicMapVersion.id == vid,
StrategicMapVersion.map_id == map_id,
StrategicMapVersion.entity_id == entity_id,
).first()
if not v:
raise HTTPException(404, "版本不存在")
return v
def _parse_json(raw, default):
if raw is None:
return default
if isinstance(raw, (dict, list)):
return raw
try:
return json.loads(raw)
except Exception:
return default
def _next_minor_version(s: str) -> str:
"""把 'v1.0' → 'v1.1';解析失败返回 None"""
m = re.search(r"v(\d+)\.(\d+)", s or "")
if m:
return f"v{m.group(1)}.{int(m.group(2)) + 1}"
return None
def _summary_dict(v: StrategicMapVersion, m: StrategicMap = None) -> dict:
dims = _parse_json(v.dimensions, [])
canvas = _parse_json(v.canvas_data, {"connections": []})
codes = set()
objective_count = 0
for dim in dims:
for obj in dim.get("objectives", []):
objective_count += 1
for c in (obj.get("kpis") or []):
if c:
codes.add(str(c))
for kr in (obj.get("krs") or []):
if isinstance(kr, dict) and kr.get("kpi_code"):
codes.add(str(kr["kpi_code"]))
conns = canvas.get("connections", []) if isinstance(canvas, dict) else []
return {
"id": v.id,
"map_id": v.map_id,
"entity_id": v.entity_id,
"version": v.version,
"comment": v.comment,
"created_by": v.created_by,
"created_at": v.created_at.isoformat() if v.created_at else None,
"dimension_count": len(dims),
"objective_count": objective_count,
"kpi_count": len(codes),
"connection_count": len(conns),
"is_current": bool(m and m.version == v.version),
}
def _full_dict(v: StrategicMapVersion, m: StrategicMap = None) -> dict:
d = {c.name: getattr(v, c.name) for c in v.__table__.columns}
if d.get("created_at") is not None:
d["created_at"] = d["created_at"].isoformat()
d.update(_summary_dict(v, m))
return d
@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)):
"""查看版本历史(账套隔离;地图归属校验 404)"""
m = _get_map(db, map_id, entity_id)
versions = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == map_id,
StrategicMapVersion.entity_id == entity_id,
).order_by(StrategicMapVersion.created_at.desc(), StrategicMapVersion.id.desc()).all()
return {"data": [_summary_dict(v, m) for v in versions]}
@router.get("/{map_id}/versions/{vid}")
def get_version(map_id: int, vid: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""单版本详情:dimensions/canvas_data 全量(供\"查看该版本画布\")"""
m = _get_map(db, map_id, entity_id)
v = _get_version(db, map_id, vid, entity_id)
return _full_dict(v, m)
@router.put("/{map_id}/versions/{vid}")
def update_version_comment(map_id: int, vid: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""更新版本备注 comment"""
_get_map(db, map_id, entity_id)
if "comment" not in data:
raise HTTPException(400, "缺少 comment 字段")
v = _get_version(db, map_id, vid, entity_id)
v.comment = data.get("comment")
db.commit()
db.refresh(v)
return _summary_dict(v, _get_map(db, map_id, entity_id))
def _next_snapshot_version(db: Session, map_id: int) -> str:
"""该地图下一个快照版本号:最新快照行 minor+1;无快照 → v1.0(与 _auto_snapshot 语义一致)"""
latest = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == map_id
).order_by(StrategicMapVersion.id.desc()).all()
return {"data": [v_to_dict(v) for v in versions]}
).order_by(StrategicMapVersion.id.desc()).first()
if latest:
nxt = _next_minor_version(latest.version)
if nxt:
return nxt
return "v1.0"
@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()
if not m:
raise HTTPException(404, "战略地图不存在")
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""手动创建快照(账套隔离)"""
m = _get_map(db, map_id, entity_id)
import json
dims = m.dimensions
canvas = m.canvas_data
if isinstance(dims, str):
dims = json.loads(dims)
if isinstance(canvas, str):
canvas = json.loads(canvas)
dims = _parse_json(m.dimensions, [])
canvas = _parse_json(m.canvas_data, {"connections": []})
# 自动版本号
existing = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == map_id
).order_by(StrategicMapVersion.id.desc()).first()
if existing:
import re
match = re.search(r"v(\d+)\.(\d+)", existing.version)
major = int(match.group(1)) if match else 1
minor = int(match.group(2)) + 1 if match else 0
new_ver = f"v{major}.{minor}"
else:
new_ver = "v1.0"
# 自动版本号: 与 _auto_snapshot 一致 —— 最新快照行 minor+1;无快照 → v1.0
new_ver = _next_snapshot_version(db, map_id)
snapshot = StrategicMapVersion(
map_id=map_id,
entity_id=entity_id, # 继承地图企业
version=new_ver,
dimensions=dims,
canvas_data=canvas,
@@ -57,30 +170,67 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
db.add(snapshot)
db.commit()
db.refresh(snapshot)
return v_to_dict(snapshot)
return _full_dict(snapshot, m)
@router.post("/{map_id}/versions/{ver_id}/rollback")
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db)):
"""回滚到指定版本"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
raise HTTPException(404, "战略地图不存在")
@router.post("/{map_id}/versions/{vid}/rollback")
def rollback_version(map_id: int, vid: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""回滚到指定版本。
v = db.query(StrategicMapVersion).filter(
StrategicMapVersion.id == ver_id,
安全设计(2026-09-08 完整实现,修复旧版无备份覆盖事故):
1. 回滚前把地图【当前内容】备份为新版本快照(版本号由当前 map.version 派生 vX.(Y+1),撞号自动 +1)
—— 任何情况下不因回滚丢失当前画布内容
2. 用目标版本 dimensions/canvas_data 覆盖 strategic_maps 当前行,version 设为该版本号
3. 地图与版本行双重 entity 校验(跨账套一律 404)
"""
m = _get_map(db, map_id, entity_id)
v = _get_version(db, map_id, vid, entity_id)
# 1) 先备份当前内容 → 新版本快照
cur_dims = _parse_json(m.dimensions, [])
cur_canvas = _parse_json(m.canvas_data, {"connections": []})
backup_ver = _next_snapshot_version(db, map_id)
# 防撞号: 极端场景最新快照版本号不可解析回落 v1.0 时循环 +1(正常递增天然唯一)
exists = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == map_id,
StrategicMapVersion.version == backup_ver,
).first()
if not v:
raise HTTPException(404, "版本不存在")
while exists:
nxt = _next_minor_version(backup_ver)
backup_ver = nxt if nxt else backup_ver + ".1"
exists = db.query(StrategicMapVersion).filter(
StrategicMapVersion.map_id == map_id,
StrategicMapVersion.version == backup_ver,
).first()
m.dimensions = v.dimensions
m.canvas_data = v.canvas_data
m.version = f"rollback-{v.version}"
m.status = "draft"
backup = StrategicMapVersion(
map_id=map_id,
entity_id=entity_id,
version=backup_ver,
dimensions=cur_dims,
canvas_data=cur_canvas,
comment="回滚前自动备份",
)
db.add(backup)
# 2) 覆盖当前行内容 + 版本号
m.dimensions = _parse_json(v.dimensions, [])
m.canvas_data = _parse_json(v.canvas_data, {"connections": []})
m.version = v.version
db.commit()
return {"message": f"已回滚到 {v.version}", "version": m.version}
# 3) 同步目标表(与 update_map 保存路径一致,保证回顾会/KR 取数一致)
try:
from app.api.maps import _sync_map_objectives, _sync_objectives_krs
_sync_map_objectives(m, db)
_sync_objectives_krs(m, db)
except Exception as e:
# 同步失败不回滚已完成的版本覆盖(主数据已安全)
import logging
logging.getLogger("maps_versions").warning("rollback 后目标表同步失败: %s", e)
def v_to_dict(v):
return {c.name: getattr(v, c.name) for c in v.__table__.columns}
return {
"message": f"已回滚到 {v.version}(当前内容已自动备份为 {backup_ver})",
"version": m.version,
"backup_version": backup_ver,
}
+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_plans(version='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,
}
+122 -10
View File
@@ -9,6 +9,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, RolePermission
from typing import Optional
import secrets
import json
@@ -60,7 +61,7 @@ except Exception:
logger.warning("Redis不可用,token存储降级到内存(不支持多worker)")
# 内存 fallback
_token_store: dict[str, int] = {}
_token_store: dict[str, dict] = {}
TOKEN_PREFIX = "cma:token:"
TOKEN_TTL = 86400 # 24小时
@@ -95,28 +96,106 @@ def _load_permissions(db: Session = None):
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
def create_token(user_id: int) -> str:
def create_token(user_id: int, entity_id: int = None) -> str:
"""签发token:Redis存储 JSON {user_id, entity_id}(账套模式)
兼容旧调用 create_token(user_id) → entity_id=None(切换器会重新签发)
"""
token = secrets.token_hex(32)
payload = json.dumps({"user_id": user_id, "entity_id": entity_id}, ensure_ascii=False)
if _redis_available:
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, user_id)
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, payload)
else:
_token_store[token] = user_id
_token_store[token] = payload
return token
def _resolve_user_id(token: str) -> int | None:
def _resolve_token_data(token: str) -> dict | None:
"""解析token → {user_id, entity_id}
- 新格式 JSON → 返回 dict
- 旧格式 int(改造前)→ 返回 None(强制下线,账套模式需重新登录)
- 不存在 → None
"""
if _redis_available:
val = _redis.get(f"{TOKEN_PREFIX}{token}")
if val is not None:
return int(val)
if val is None:
return None
try:
data = json.loads(val)
if isinstance(data, dict) and "user_id" in data:
return data
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 旧格式纯 int → 强制下线
return None
return _token_store.get(token)
val = _token_store.get(token)
if val is None:
return None
if isinstance(val, dict):
return val
try:
data = json.loads(val)
if isinstance(data, dict) and "user_id" in data:
return data
except (json.JSONDecodeError, ValueError, TypeError):
pass
return None
def _resolve_user_id(token: str) -> int | None:
data = _resolve_token_data(token)
return int(data["user_id"]) if data else None
def get_token_entity_id(token: str) -> int | None:
"""从token解析绑定的entity_id(token不存在/旧格式 → None)"""
data = _resolve_token_data(token)
if not data:
return None
eid = data.get("entity_id")
return int(eid) if eid else None
def user_has_entity(db: Session, user_id: int, entity_id: int) -> bool:
"""校验用户是否被授权访问指定企业(账套授权表 user_entities)"""
from app.models import UserEntity, Entity
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if not ent or ent.status != "active":
return False
rel = db.query(UserEntity).filter(
UserEntity.user_id == user_id,
UserEntity.entity_id == entity_id,
).first()
return rel is not None
def extract_bearer_token(request) -> str | None:
"""从Request提取Bearer token(无/非Bearer格式 → None)"""
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip()
return None
# ── 统一全站鉴权语义(cma-auth-401-403-unify-20260915)────────────────────
# 口径:**未认证(缺 token / 无效 token)→ 401**;**已认证但无权(跨实体 / 角色不足)→ 403**。
# FastAPI 的 HTTPBearer(auto_error=True) 对「缺 Authorization 头」返回 403,与
# require_auth_401 的 401 语义在同站并存(同一种「没带 token」两种状态码)。
# 现统一为 auto_error=False 自行判空:缺 header / 非 Bearer 格式 → 显式 401;
# 有 token → 仍按原逻辑校验身份(无效/过期 token → 401)。
# 403 语义保留:require_role(角色不足)、deps.assert_entity_scope(跨实体无权)。
_optional_bearer = HTTPBearer(auto_error=False)
def require_auth(
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=True)),
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
db: Session = Depends(get_db),
) -> User:
"""认证依赖(全站统一):缺 token / 非 Bearer 格式 → 401;无效 token → 401。
已认证但无权(角色/实体)由 require_role、deps.assert_entity_scope 另行返回 403。
"""
if credentials is None or not credentials.credentials:
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
token = credentials.credentials
user_id = _resolve_user_id(token)
if user_id is None:
@@ -137,6 +216,23 @@ def require_role(*roles: str):
return role_checker
# ── 401 语义的认证依赖(cma-api-auth-surface-audit-20260914)
# 2026-09-15(cma-auth-401-403-unify-20260915):语义与 _optional_bearer 已统一,
# 本入口保留作兼容别名(既有 router 与审计器 AUTH_STRICT 均引用此名)。
def require_auth_401(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
db: Session = Depends(get_db),
) -> User:
"""用户端点统一鉴权入口:缺 Bearer → 401;有 token → require_auth 校验(无效 → 401)
2026-09-15(cma-auth-401-403-unify-20260915)起与 require_auth 语义完全一致
(require_auth 也改为 auto_error=False 自判空 → 缺 token 401)。
保留本名称:既有 router 的 dependencies=[Depends(require_auth_401)] 与
backend/scripts/audit_api_auth_surface.py 的 AUTH_STRICT 分类仍引用它。
"""
return require_auth(credentials=credentials, db=db)
def has_permission(user: User, module: str, db: Session = None) -> bool:
routes, _ = _load_permissions(db)
return module in routes.get(user.role, [])
@@ -148,9 +244,25 @@ def has_action(user: User, action: str, db: Session = None) -> bool:
# ─── KPI可见性(按维度/分类过滤) ───
#
# ⚠️ 口径裁定(2026-09-23 dama-entity-isolation-sweep-20260923 §一②):
# `role_permissions.key = 'kpi_visibility'` **属「非账套维度」的全局配置,不拆账套**
# —— 理由:① 值本身是 `{role: [维度规则]}` 的**角色维度**字典,无账套语义;
# ② 角色枚举(ceo/finance/business/it)是平台级字典,`role_permissions` 表
# 亦无 `entity_id` 列(`key` unique),「按角色看哪些维度」是集团统一口径;
# ③ 与 `route_permissions` / `action_permissions` 同族(同为角色级权限面),
# 若只拆本键会与同表另两键口径分裂。
# 判别口径:**同表同族的键按同一维度处理**;本键不消费任何账套状态、也不被账套写侧改写。
# 登记:`docs/global-config-keys-caliber-20260923.md`(键维度字典)。
def _load_kpi_visibility(db: Session = None):
"""从RolePermission表加载kpi_visibility配置(独立缓存)"""
"""从RolePermission表加载kpi_visibility配置(独立缓存)
**非账套维度**(角色级天然全局,口径见上方注释与本档登记):
只读 `role_permissions.key = 'kpi_visibility'`,**有意不做账套后缀**——
这是集团共享口径,不是漏隔离。
"""
import time
if not hasattr(_load_kpi_visibility, "_cache"):
_load_kpi_visibility._cache = {"data": None, "ts": 0}

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