Author SHA1 Message Date
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
71 changed files with 9911 additions and 565 deletions
+259 -30
View File
@@ -1,7 +1,8 @@
"""改善行动计划 API — 管理会计OS""" """改善行动计划 API — 管理会计OS"""
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Body, Depends, HTTPException, Request
from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from typing import Optional from typing import Optional
import re import re
import logging import logging
@@ -14,6 +15,9 @@ from app.api.verify import build_auto_verify_rule
logger = logging.getLogger("cma.action_plans") logger = logging.getLogger("cma.action_plans")
# 区分「未传该字段」与「显式传 null」的哨兵(update_plan 的 objective_id 口径,2026-09-18)
_UNSET = object()
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"], router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))], dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
) )
@@ -59,6 +63,32 @@ def _validate_due_date_against_quarter(due_date: datetime, quarter: str):
f"KR截止日期({due.date()})超出本季度范围({q_end.date()}),最大截止为{q_end.date()}") 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 < 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 共用)。"""
return query.filter(
ActionPlan.status.in_(list(OVERDUE_STATUSES)),
ActionPlan.due_date < datetime.now(),
)
def plan_to_dict(p: ActionPlan) -> dict: def plan_to_dict(p: ActionPlan) -> dict:
return { return {
"id": p.id, "id": p.id,
@@ -97,7 +127,12 @@ def list_plans(
"""获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)""" """获取行动计划列表(账套隔离: 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()) 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: 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) query = query.filter(ActionPlan.status == status)
if kpi_id: if kpi_id:
query = query.filter(ActionPlan.kpi_id == kpi_id) query = query.filter(ActionPlan.kpi_id == kpi_id)
@@ -124,7 +159,10 @@ def list_plans(
item["kpi_dimension"] = kpi.dimension if kpi else None item["kpi_dimension"] = kpi.dimension if kpi else None
# 附带KR信息(行动挂KR 2026-08-27) # 附带KR信息(行动挂KR 2026-08-27)
if p.kr_id is not None: if p.kr_id is not None:
kr = db.query(KR).filter(KR.id == p.kr_id).first() # 账套隔离(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_title"] = kr.title if kr else None
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
else: else:
@@ -164,14 +202,25 @@ def create_plan(
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
# 校验截止日期与关联Objective的季度匹配 # 账套隔离: 关联Objective必须属于当前企业(2026-09-18 batch3 派生单)
# 旧实现只按 Objective.id 查询(仅用于季度校验),不校验 entity_id,
# 随后 ActionPlan(objective_id=...) 直接落库 → 本账套计划可挂到对端账套目标
objective_id = data.get("objective_id") objective_id = data.get("objective_id")
if objective_id and due_date: if objective_id:
obj = db.query(Objective).filter(Objective.id == objective_id).first() obj = db.query(Objective).filter(
if obj and obj.quarter: 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) _validate_due_date_against_quarter(due_date, obj.quarter)
plan = ActionPlan( 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"), alert_id=data.get("alert_id"),
kpi_id=data["kpi_id"], kpi_id=data["kpi_id"],
objective_id=objective_id, objective_id=objective_id,
@@ -241,9 +290,11 @@ def update_plan(
plan_id: int, plan_id: int,
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""更新行动计划""" """更新行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first() 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: if not plan:
raise HTTPException(404, "计划不存在") raise HTTPException(404, "计划不存在")
@@ -255,21 +306,35 @@ def update_plan(
plan.assignee = data["assignee"] plan.assignee = data["assignee"]
if "priority" in data: if "priority" in data:
plan.priority = data["priority"] plan.priority = data["priority"]
if "kr_id" in data: # 目标/KR 归属口径(2026-09-18 batch3 派生单):
# 行动挂KR: 支持置空(null)或改挂 # ① 显式传 objective_id(含 null=显式置空)→ 以入参为准(优先于 kr_id 推导值)
if data["kr_id"] is None: # ② 未传 objective_id、且改挂合法 KR、且计划当前无目标 → 继承该 KR 的目标
plan.kr_id = None # ③ 未传 objective_id 且计划已有目标 → 保持不动
else: # 两者都必须是当前账套的数据,否则 404;校验全部放在改动 plan 之前,
kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first() # 保证 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: if not kr_ent:
raise HTTPException(404, "关联KR不存在") raise HTTPException(404, "关联KR不存在")
plan.kr_id = kr_ent.id
if data.get("objective_id") is not None: objective_change = _UNSET
plan.objective_id = data["objective_id"]
elif plan.objective_id is None:
plan.objective_id = kr_ent.objective_id
if "objective_id" in data: if "objective_id" in data:
plan.objective_id = data["objective_id"] 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: if "due_date" in data:
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
if "status" in data: if "status" in data:
@@ -285,9 +350,10 @@ def update_plan(
@router.delete("/{plan_id}") @router.delete("/{plan_id}")
def delete_plan(plan_id: int, db: Session = Depends(get_db)): 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).filter(ActionPlan.id == plan_id).first() 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: if not plan:
raise HTTPException(404, "计划不存在") raise HTTPException(404, "计划不存在")
db.delete(plan) db.delete(plan)
@@ -296,9 +362,19 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db)):
@router.get("/stats") @router.get("/stats")
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth)): def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth),
"""行动计划统计""" entity_id: int = Depends(get_entity_id)):
query = db.query(ActionPlan) """行动计划统计(账套隔离: 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": if current_user.role == "business":
query = query.filter( query = query.filter(
(ActionPlan.assignee == current_user.username) | (ActionPlan.assignee == current_user.username) |
@@ -308,8 +384,7 @@ def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(requi
pending = query.filter(ActionPlan.status == "pending").count() pending = query.filter(ActionPlan.status == "pending").count()
in_progress = query.filter(ActionPlan.status == "in_progress").count() in_progress = query.filter(ActionPlan.status == "in_progress").count()
completed = query.filter(ActionPlan.status == "completed").count() completed = query.filter(ActionPlan.status == "completed").count()
from datetime import datetime overdue = _apply_overdue(query).count()
overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.due_date < datetime.now()).count()
return { return {
"total": total, "total": total,
"pending": pending, "pending": pending,
@@ -446,3 +521,157 @@ def get_coso_checklist(
if not data: if not data:
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据") raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
return data 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",
}
+38 -9
View File
@@ -98,9 +98,13 @@ def list_suggestions(
@router.get("/{suggestion_id}") @router.get("/{suggestion_id}")
def get_suggestion(suggestion_id: int, db: Session = Depends(get_db)): 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).first() s = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).first()
if not s: if not s:
raise HTTPException(404, "建议不存在") raise HTTPException(404, "建议不存在")
return {"data": _sug_dict(s)} return {"data": _sug_dict(s)}
@@ -114,7 +118,10 @@ def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user
kpi_id = params.get("kpi_id") or sug.target_id kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id: if not kpi_id:
raise HTTPException(400, "缺少 kpi_id") raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
).first()
if not kpi: if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在") raise HTTPException(404, f"KPI {kpi_id} 不存在")
before = kpi.target_value before = kpi.target_value
@@ -153,7 +160,10 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u
kpi_id = params.get("kpi_id") or sug.target_id kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id: if not kpi_id:
raise HTTPException(400, "缺少 kpi_id") raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套
).first()
if not kpi: if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在") raise HTTPException(404, f"KPI {kpi_id} 不存在")
@@ -226,7 +236,10 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
kpi_id = params.get("kpi_id") or sug.target_id kpi_id = params.get("kpi_id") or sug.target_id
if not kpi_id: if not kpi_id:
raise HTTPException(400, "缺少 kpi_id") raise HTTPException(400, "缺少 kpi_id")
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单 2030b: 目标KPI须归属建议账套
).first()
if not kpi: if not kpi:
raise HTTPException(404, f"KPI {kpi_id} 不存在") raise HTTPException(404, f"KPI {kpi_id} 不存在")
@@ -238,6 +251,7 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
due_date = None due_date = None
plan = ActionPlan( plan = ActionPlan(
entity_id=sug.entity_id, # 2026-09-18 派生单: 写建议归属账套(=会话账套,端点已校验)
kpi_id=kpi_id, kpi_id=kpi_id,
title=title, title=title,
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}", description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
@@ -290,14 +304,21 @@ def preview_suggestion(suggestion_id: int, db: Session = Depends(get_db),
- budget_adjust:{kpi_name, period, current_budget, new_budget} - budget_adjust:{kpi_name, period, current_budget, new_budget}
- action_plan: {kpi_name, plan_title, assignee, priority, due_date} - action_plan: {kpi_name, plan_title, assignee, priority, due_date}
""" """
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first() sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 预览须归属会话账套(原依赖未用于过滤)
).first()
if not sug: if not sug:
raise HTTPException(404, "建议不存在") raise HTTPException(404, "建议不存在")
sd = sug.suggestion_data or {} sd = sug.suggestion_data or {}
kpi = None kpi = None
kpi_id = sd.get("kpi_id") or sug.target_id kpi_id = sd.get("kpi_id") or sug.target_id
if kpi_id: if kpi_id:
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() # 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": if sug.suggestion_type == "kpi_target":
return {"data": { return {"data": {
@@ -340,6 +361,7 @@ def apply_suggestion(
suggestion_id: int, suggestion_id: int,
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth), current_user=Depends(require_auth),
): ):
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕) """应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
@@ -349,7 +371,10 @@ def apply_suggestion(
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000} {"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"} {"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
""" """
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first() sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).first()
if not sug: if not sug:
raise HTTPException(404, "建议不存在") raise HTTPException(404, "建议不存在")
if sug.status == "applied": if sug.status == "applied":
@@ -385,10 +410,14 @@ def apply_suggestion(
def dismiss_suggestion( def dismiss_suggestion(
suggestion_id: int, suggestion_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth), current_user=Depends(require_auth),
): ):
"""忽略建议""" """忽略建议"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first() sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).first()
if not sug: if not sug:
raise HTTPException(404, "建议不存在") raise HTTPException(404, "建议不存在")
sug.status = "dismissed" sug.status = "dismissed"
+52 -5
View File
@@ -14,6 +14,9 @@ from app.database import get_db, Base
from app.deps import get_entity_id from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
# 裁定 R1(2026-09-19 财务口径):demo 账套不参与预警生成/遍历(单一事实源)
# 裁定 R5(2026-09-19 财务口径):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库(生成侧守卫)
from app.utils.alert_entity_scope import guard_alert_period, is_excluded_entity
logger = logging.getLogger("alert_rules") logger = logging.getLogger("alert_rules")
@@ -257,13 +260,23 @@ def generate_default_rules(db: Session = Depends(get_db), entity_id: int = Depen
@router.post("/check-all") @router.post("/check-all")
def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)""" """执行当前企业所有KPI的预警检查 — 生成新的预警记录(账套隔离 2026-08-31)
R1(裁定 2026-09-19):demo 演示账套不参与预警生成 → 直接返回 skipped。
"""
if is_excluded_entity(db, entity_id):
logger.info("预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成", "generated": 0,
"skipped": True, "reason": "R1_demo_entity_excluded"}
rules = db.query(AlertRule).filter( rules = db.query(AlertRule).filter(
AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all() AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all()
kpi_cache = {} kpi_cache = {}
value_cache = {} value_cache = {}
alerts_generated = 0 alerts_generated = 0
# R5(裁定 2026-09-19):期间非规整月(含伪期间 2026-H1/H2)→ 该行不入库,跳过并记数
skipped_non_month_period = 0
skipped_non_month_period_kpis = []
for rule in rules: for rule in rules:
try: try:
@@ -332,6 +345,14 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db) alert_level, alert_message = _check_trend(kpi.id, value, "down", params, db)
if alert_level and alert_level != "green": if alert_level and alert_level != "green":
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 YYYY-MM → 该 kpi_alerts 行不入库。
# 注:`latest_value` 按 period 字符串倒序取,`2026-H1` 会排在 `2026-09` 之前 →
# 伪期间确实可能被取为「最新值」,此处是伪期间入库唯一的拦截点。
if not guard_alert_period(period, source="alert_rules.run_all_alert_checks",
detail="kpi=%s rule_id=%s" % (kpi.kpi_code, rule.id)):
skipped_non_month_period += 1
skipped_non_month_period_kpis.append("%s[%s]" % (kpi.kpi_code, period))
continue
# 检查是否已有相同预警 # 检查是否已有相同预警
existing_alert = db.query(KPIAlert).filter( existing_alert = db.query(KPIAlert).filter(
KPIAlert.kpi_id == rule.kpi_id, KPIAlert.kpi_id == rule.kpi_id,
@@ -357,7 +378,12 @@ def run_all_alert_checks(db: Session = Depends(get_db), entity_id: int = Depends
continue continue
db.commit() db.commit()
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated} if skipped_non_month_period:
logger.info("R5 期间守卫:本次跳过 %s 条不可归期预警(%s)",
skipped_non_month_period, skipped_non_month_period_kpis)
return {"message": f"预警检查完成: 生成{alerts_generated}条", "generated": alerts_generated,
"skipped_non_month_period": skipped_non_month_period,
"skipped_non_month_period_kpis": skipped_non_month_period_kpis}
@router.get("/dynamic-thresholds") @router.get("/dynamic-thresholds")
@@ -642,7 +668,14 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
@router.post("/check-forecast") @router.post("/check-forecast")
def run_forecast_alert_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): def run_forecast_alert_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)""" """执行预测值预警检查 — 检查未来7天预测值是否超限(账套隔离 2026-08-31)
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
"""
if is_excluded_entity(db, entity_id):
logger.info("预测值预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
generated = _check_forecast_alerts(db, entity_id=entity_id) generated = _check_forecast_alerts(db, entity_id=entity_id)
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated} return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
@@ -690,7 +723,14 @@ def run_forecast_deviation_check(
): ):
"""预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation """预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation
对每条偏差规则: 取最新预测log(kpi_forecast_log) vs 该期实际值(kpi_values), 对每条偏差规则: 取最新预测log(kpi_forecast_log) vs 该期实际值(kpi_values),
偏差 > threshold_pct → 生成/更新 pending 预警(去重)""" 偏差 > threshold_pct → 生成/更新 pending 预警(去重)
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
"""
if is_excluded_entity(db, entity_id):
logger.info("预测偏差检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
from app.models import KpiForecastLog from app.models import KpiForecastLog
rules = db.query(AlertRule).filter( rules = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id, AlertRule.entity_id == entity_id,
@@ -861,7 +901,14 @@ def run_customer_concentration_check(
entity_id: int = Depends(get_entity_id), entity_id: int = Depends(get_entity_id),
): ):
"""客户集中度检查(P2 2026-09-10)— 遍历本企业 customer_concentration 规则, """客户集中度检查(P2 2026-09-10)— 遍历本企业 customer_concentration 规则,
计算客户收入占比,超阈值生成/更新 pending 预警(去重)。""" 计算客户收入占比,超阈值生成/更新 pending 预警(去重)。
R1(裁定 2026-09-19):demo 演示账套不参与预警生成。
"""
if is_excluded_entity(db, entity_id):
logger.info("客户集中度检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"message": f"账套 {entity_id} 为 demo 演示账套,按 R1 不参与预警生成",
"generated": 0, "skipped": True, "reason": "R1_demo_entity_excluded"}
rules = db.query(AlertRule).filter( rules = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id, AlertRule.entity_id == entity_id,
AlertRule.rule_type == "customer_concentration", AlertRule.rule_type == "customer_concentration",
+37 -16
View File
@@ -5,7 +5,7 @@ from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug 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.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition from app.models import KPIAlert, OperationLog, ActionPlan
import logging import logging
logger = logging.getLogger("cma.alerts") logger = logging.getLogger("cma.alerts")
@@ -16,8 +16,10 @@ router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
@router.get("") @router.get("")
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b) # 账套隔离(2026-09-19 dama-20260919 C1):按**预警行自身** entity_id 过滤
query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == 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: if status:
query = query.filter(KPIAlert.status == status) query = query.filter(KPIAlert.status == status)
total = query.count() total = query.count()
@@ -26,8 +28,10 @@ def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = De
@router.post("/{alert_id}/resolve") @router.post("/{alert_id}/resolve")
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter( # 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first() # 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
if not alert: if not alert:
raise HTTPException(404, "预警不存在") raise HTTPException(404, "预警不存在")
alert.status = "resolved" alert.status = "resolved"
@@ -45,9 +49,12 @@ def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), enti
@router.post("/{alert_id}/process") @router.post("/{alert_id}/process")
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): 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)"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first() # 账套隔离(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: if not alert:
raise HTTPException(404, "预警不存在") raise HTTPException(404, "预警不存在")
if alert.status == "resolved": if alert.status == "resolved":
@@ -63,9 +70,12 @@ def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
@router.post("/{alert_id}/escalate") @router.post("/{alert_id}/escalate")
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): 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)"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first() # 账套隔离(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: if not alert:
raise HTTPException(404, "预警不存在") raise HTTPException(404, "预警不存在")
assignee = data.get("assignee") assignee = data.get("assignee")
@@ -82,11 +92,18 @@ def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
@router.get("/check-timeout") @router.get("/check-timeout")
def check_alert_timeout(db: Session = Depends(get_db)): def check_alert_timeout(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色""" """超时预警检测 — 超过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 from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24) cutoff = datetime.now() - timedelta(hours=24)
timeout_alerts = db.query(KPIAlert).filter( timeout_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending", KPIAlert.status == "pending",
KPIAlert.created_at < cutoff, KPIAlert.created_at < cutoff,
KPIAlert.alert_level != "red", KPIAlert.alert_level != "red",
@@ -102,14 +119,18 @@ def check_alert_timeout(db: Session = Depends(get_db)):
@router.post("/{alert_id}/create-action-plan") @router.post("/{alert_id}/create-action-plan")
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): 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)"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first() # 账套隔离(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: if not alert:
raise HTTPException(404, "预警不存在") raise HTTPException(404, "预警不存在")
if alert.action_plan_linked_id: if alert.action_plan_linked_id:
raise HTTPException(400, "已关联改善计划") raise HTTPException(400, "已关联改善计划")
plan = ActionPlan( plan = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
alert_id=alert.id, alert_id=alert.id,
kpi_id=alert.kpi_id, kpi_id=alert.kpi_id,
title=f"改善: {alert.alert_message}", title=f"改善: {alert.alert_message}",
+62 -14
View File
@@ -10,6 +10,7 @@ import json
from app.database import get_db from app.database import get_db
from app.auth_middleware import require_auth, require_role, require_auth_401 from app.auth_middleware import require_auth, require_role, require_auth_401
from app.deps import get_entity_id
from app.models import KPIDefinition, OperationLog, RolePermission from app.models import KPIDefinition, OperationLog, RolePermission
router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"], router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
@@ -19,7 +20,30 @@ router = APIRouter(prefix="/api/cma/alignment", tags=["KPI目标对齐"],
dependencies=[Depends(require_auth_401)], 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 = [ ALIGNMENT_MODES = [
{ {
"key": "vertical_decomposition", "key": "vertical_decomposition",
@@ -49,18 +73,25 @@ def list_modes():
@router.get("/config") @router.get("/config")
def get_alignment_config(db: Session = Depends(get_db)): def get_alignment_config(entity_id: int = Depends(get_entity_id),
"""获取当前系统对齐模式配置(公开接口,无需认证)""" db: Session = Depends(get_db)):
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() """获取**当前账套**的对齐模式配置(B3(a) 拆账套,2026-09-22)
修复前:读全局唯一键 `alignment_config` → 任一账套改模式全平台生效。
修复后:只读 `alignment_config.<entity_id>`;未配置 → configured=false。
"""
perm = _get_alignment_perm(db, entity_id)
if not perm: if not perm:
return { return {
"mode": None, "mode": None,
"configured": False, "configured": False,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES, "modes": ALIGNMENT_MODES,
} }
return { return {
"mode": perm.value, "mode": perm.value,
"configured": True, "configured": True,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES, "modes": ALIGNMENT_MODES,
} }
@@ -68,45 +99,62 @@ def get_alignment_config(db: Session = Depends(get_db)):
@router.post("/config") @router.post("/config")
def set_alignment_config( def set_alignment_config(
data: dict, data: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user = Depends(require_role("ceo", "it")), 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") mode_key = data.get("mode")
if mode_key not in [m["key"] for m in ALIGNMENT_MODES]: if mode_key not in [m["key"] for m in ALIGNMENT_MODES]:
raise HTTPException(400, f"无效的对齐模式: {mode_key}") 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: 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: 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.add(perm)
db.commit() 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") @router.get("/tree")
def get_alignment_tree( def get_alignment_tree(
kpi_id: Optional[int] = Query(None), kpi_id: Optional[int] = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user = Depends(require_auth), user = Depends(require_auth),
): ):
"""获取KPI对齐关系树 """获取KPI对齐关系树(按当前账套隔离)
根据当前系统配置的对齐模式,返回KPI的父子层级关系。 根据当前系统配置的对齐模式,返回KPI的父子层级关系。
如果指定kpi_id,返回该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。
""" """
# 获取当前模式 # 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.<entity_id>)
config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first() config_perm = _get_alignment_perm(db, entity_id)
mode = config_perm.value.get("mode") if config_perm else None mode = config_perm.value.get("mode") if config_perm else None
if not mode: if not mode:
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化") raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
# 获取所有KPI # 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI)
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").order_by(KPIDefinition.kpi_code).all() kpis = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id, KPIDefinition.status == "active")
.order_by(KPIDefinition.kpi_code)
.all())
kpi_map = {k.id: k for k in kpis} kpi_map = {k.id: k for k in kpis}
# 构建父子关系 # 构建父子关系
+21 -3
View File
@@ -20,6 +20,8 @@ from app.models.budget_plan import BudgetPlan
from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation from app.models.cost_model import StandardCost, ActualCost, AbcActivity, AbcAllocation
from app.models import KPICausality from app.models import KPICausality
from app.risk_levels import risk_level 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
import json import json
logger = logging.getLogger("cma.bot_bridge") logger = logging.getLogger("cma.bot_bridge")
@@ -673,6 +675,10 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
period = period or datetime.now().strftime("%Y-%m") period = period or datetime.now().strftime("%Y-%m")
# R1(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge.kpi-value-with-check",
detail="kpi_id=%s" % kpi_id)
# ① 写值(upsert) # ① 写值(upsert)
existing = db.query(KPIValue).filter( existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, KPIValue.period == period).first() KPIValue.kpi_id == kpi_id, KPIValue.period == period).first()
@@ -689,7 +695,9 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
# ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量) # ② 跑该KPI关联的预警规则(复用 alert_rules 检查函数,非全量)
alerts = [] alerts = []
if run_check: # R5(裁定 2026-09-19):期间非规整月(含伪期间)→ 不入库,跳过并记数
skipped_non_month_period = 0
if run_check and alerts_allowed:
from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend from app.api.alert_rules import AlertRule, _check_static, _check_dynamic, _check_trend
rules = db.query(AlertRule).filter( rules = db.query(AlertRule).filter(
AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id, AlertRule.kpi_id == kpi_id, AlertRule.entity_id == entity_id,
@@ -709,6 +717,11 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
else: else:
continue continue
if level and level != "green": if level and level != "green":
# R5 生成侧守卫(裁定 2026-09-19):期间非规整月 → 该 kpi_alerts 行不入库
if not guard_alert_period(period, source="bot_bridge.kpi-value-with-check",
detail="kpi_id=%s rule_id=%s" % (kpi_id, rule.id)):
skipped_non_month_period += 1
continue
dup = db.query(KPIAlert).filter( dup = db.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id, KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
KPIAlert.alert_level == level, KPIAlert.status == "pending").first() KPIAlert.alert_level == level, KPIAlert.status == "pending").first()
@@ -720,8 +733,13 @@ def bot_kpi_value_with_check(data: dict, db: Session = Depends(get_db), bot: dic
except Exception as e: except Exception as e:
logger.warning(f"聚合检查失败 rule={rule.id}: {e}") logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
db.commit() db.commit()
return {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value), resp = {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
"period": period, "alerts": alerts, "status": "ok"} "period": period, "alerts": alerts, "status": "ok",
"skipped_non_month_period": skipped_non_month_period}
if not alerts_allowed:
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警(值照写、预警跳过)
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
return resp
@router.post("/kpis/create-with-links") @router.post("/kpis/create-with-links")
+61 -4
View File
@@ -21,9 +21,11 @@ from app.database import get_db
from app.models import ( from app.models import (
MpmResult, BotBridgeConfig, MpmResult, BotBridgeConfig,
KPIDefinition, KPIValue, KPIAlert, KPIDefinition, KPIValue, KPIAlert,
ActionPlan, Entity, ActionPlan, Entity, OperationLog,
) )
from app.risk_levels import risk_level 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") logger = logging.getLogger("cma.bot_bridge_v2")
@@ -307,6 +309,12 @@ def receive_mpm_result(
if not entity: if not entity:
raise HTTPException(404, f"实体entity_id={entity_id}不存在") 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结果 ── # ── 1. 写入MPM结果 ──
record = MpmResult( record = MpmResult(
entity_id=entity_id, entity_id=entity_id,
@@ -356,6 +364,7 @@ def receive_mpm_result(
# ── 3. 触发预警 ── # ── 3. 触发预警 ──
alerts = [] alerts = []
alerts_skipped_no_kpi = [] # B3(b): KPI 定义缺失而跳过的预警(留痕用)
for field, config in ALERT_THRESHOLDS.items(): for field, config in ALERT_THRESHOLDS.items():
field_value = results.get(field) field_value = results.get(field)
if field_value is None: if field_value is None:
@@ -367,7 +376,20 @@ def receive_mpm_result(
if (operator == ">" and field_value > threshold) or \ if (operator == ">" and field_value > threshold) or \
(operator == ">=" and field_value >= threshold): (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 # 查找关联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 kpi_id = None
if kpi_code: if kpi_code:
kpi_def = db.query(KPIDefinition).filter( kpi_def = db.query(KPIDefinition).filter(
@@ -377,11 +399,38 @@ def receive_mpm_result(
if kpi_def: if kpi_def:
kpi_id = kpi_def.id kpi_id = kpi_def.id
# 创建预警
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]" 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( alert = KPIAlert(
entity_id=entity_id, # 2026-09-13 F1: 显式写归属 entity_id=entity_id, # 2026-09-13 F1: 显式写归属
kpi_id=kpi_id or 1, # fallback to first KPI if not found kpi_id=kpi_id, # B3(b): 显式校验后的本账套 KPI(禁止 or 1 兜底)
alert_level="red", alert_level="red",
alert_message=alert_msg, alert_message=alert_msg,
alert_type="actual", alert_type="actual",
@@ -394,16 +443,24 @@ def receive_mpm_result(
db.commit() db.commit()
db.refresh(record) db.refresh(record)
return { resp = {
"success": True, "success": True,
"kpi_updated": len(kpi_updates), "kpi_updated": len(kpi_updates),
"alerts_triggered": len(alerts), "alerts_triggered": len(alerts),
# B3(b): 因本账套无对应 KPI 定义而跳过的预警(已落 OperationLog,非静默丢失)
"alerts_skipped_missing_kpi": len(alerts_skipped_no_kpi),
"mpm_record_id": record.id, "mpm_record_id": record.id,
"details": { "details": {
"kpi_codes": kpi_updates, "kpi_codes": kpi_updates,
"alerts": alerts, "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") @router.post("/kpi-result")
+3 -1
View File
@@ -62,11 +62,13 @@ def list_bot_kpis(
source: str = Query("finance-bot", description="Bot标识"), source: str = Query("finance-bot", description="Bot标识"),
period: Optional[str] = None, period: Optional[str] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""获取某Bot的所有KPI(含评分)""" """获取某Bot的所有KPI(含评分)(账套隔离 2026-09-21:仅本账套,对齐 update_bot_kpi_value)"""
kpis = db.query(KPIDefinition).filter( kpis = db.query(KPIDefinition).filter(
KPIDefinition.bot_source == source, KPIDefinition.bot_source == source,
KPIDefinition.status == "active", KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).order_by(KPIDefinition.kpi_code).all() ).order_by(KPIDefinition.kpi_code).all()
if not kpis: if not kpis:
+55 -15
View File
@@ -77,8 +77,9 @@ def create_budget_plan(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(require_auth), current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
): ):
"""创建或更新单条预算计划""" """创建或更新单条预算计划(账套隔离 2026-09-21:KPI 归属校验 + 写会话账套)"""
kpi_id = data.get("kpi_id") kpi_id = data.get("kpi_id")
period = data.get("period") period = data.get("period")
budget_value = data.get("budget_value") budget_value = data.get("budget_value")
@@ -86,8 +87,11 @@ def create_budget_plan(
if not all([kpi_id, period, budget_value is not None]): if not all([kpi_id, period, budget_value is not None]):
raise HTTPException(400, "缺少必要参数: kpi_id, period, budget_value") raise HTTPException(400, "缺少必要参数: kpi_id, period, budget_value")
# 检查KPI是否存在 # 检查KPI是否存在(账套隔离 2026-09-21:仅本账套 KPI,防把预算挂到对端 KPI)
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: if not kpi:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
@@ -112,6 +116,7 @@ def create_budget_plan(
return {"message": "预算已更新", "id": existing.id} return {"message": "预算已更新", "id": existing.id}
else: else:
plan = BudgetPlan( plan = BudgetPlan(
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1
kpi_id=kpi_id, kpi_id=kpi_id,
map_id=map_id, map_id=map_id,
period=period, period=period,
@@ -145,9 +150,12 @@ def update_budget_plan(
plan_id: int, plan_id: int,
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""更新预算计划""" """更新预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first() plan = db.query(BudgetPlan).filter(
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
).first()
if not plan: if not plan:
raise HTTPException(404, "预算计划不存在") raise HTTPException(404, "预算计划不存在")
@@ -168,9 +176,12 @@ def update_budget_plan(
def delete_budget_plan( def delete_budget_plan(
plan_id: int, plan_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""删除预算计划""" """删除预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first() plan = db.query(BudgetPlan).filter(
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
).first()
if not plan: if not plan:
raise HTTPException(404, "预算计划不存在") raise HTTPException(404, "预算计划不存在")
db.delete(plan) db.delete(plan)
@@ -306,7 +317,9 @@ def auto_decompose_budget(
if annual_budget is None: if annual_budget is None:
raise HTTPException(400, "缺少必要参数: annual_budget(单KPI分解需传年度预算值)") raise HTTPException(400, "缺少必要参数: annual_budget(单KPI分解需传年度预算值)")
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: if not kpi:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
@@ -333,6 +346,7 @@ def auto_decompose_budget(
existing = db.query(BudgetPlan).filter( existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id, BudgetPlan.kpi_id == kpi_id,
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period, BudgetPlan.period == period,
BudgetPlan.version == version, BudgetPlan.version == version,
BudgetPlan.status == "active", BudgetPlan.status == "active",
@@ -342,6 +356,7 @@ def auto_decompose_budget(
existing.budget_value = monthly_value existing.budget_value = monthly_value
else: else:
bp = BudgetPlan( bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id, period=period, kpi_id=kpi_id, period=period,
budget_value=monthly_value, budget_year=year, budget_value=monthly_value, budget_year=year,
budget_month=m, version=version, status="active", budget_month=m, version=version, status="active",
@@ -357,6 +372,7 @@ def auto_decompose_budget(
period = f"{year}-{m:02d}" period = f"{year}-{m:02d}"
existing = db.query(BudgetPlan).filter( existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id, BudgetPlan.kpi_id == kpi_id,
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period, BudgetPlan.period == period,
BudgetPlan.version == version, BudgetPlan.version == version,
BudgetPlan.status == "active", BudgetPlan.status == "active",
@@ -365,6 +381,7 @@ def auto_decompose_budget(
existing.budget_value = monthly existing.budget_value = monthly
else: else:
bp = BudgetPlan( bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id, period=period, kpi_id=kpi_id, period=period,
budget_value=monthly, budget_year=year, budget_value=monthly, budget_year=year,
budget_month=m, version=version, status="active", budget_month=m, version=version, status="active",
@@ -394,6 +411,7 @@ def get_deviation_report(
dimension: Optional[str] = Query(None), dimension: Optional[str] = Query(None),
alert_level: Optional[str] = Query(None), alert_level: Optional[str] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""获取差异分析报告(汇总多个KPI的实际vs预算差异)""" """获取差异分析报告(汇总多个KPI的实际vs预算差异)"""
if period is None: if period is None:
@@ -404,7 +422,10 @@ def get_deviation_report(
else: else:
period = datetime.now().strftime("%Y-%m") period = datetime.now().strftime("%Y-%m")
query = db.query(KPIDefinition).filter(KPIDefinition.status == "active") query = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
)
if kpi_id: if kpi_id:
query = query.filter(KPIDefinition.id == kpi_id) query = query.filter(KPIDefinition.id == kpi_id)
if dimension: if dimension:
@@ -534,12 +555,14 @@ def set_budget_config(
def budget_roll_forward( def budget_roll_forward(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(require_auth), current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
): ):
""" """
滚动预算自动延展: 滚动预算自动延展:
- 删除最早一个月的预测数据 - 删除最早一个月的预测数据
- 新增未来一个月的预测数据(取最近三个月均值) - 新增未来一个月的预测数据(取最近三个月均值)
- 返回延展结果 - 返回延展结果
账套隔离:只处理会话账套(join kpi_definitions 按企业过滤),2026-09-18
""" """
from app.models import SystemConfig from app.models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first() cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
@@ -557,8 +580,11 @@ def budget_roll_forward(
now = datetime.now() now = datetime.now()
current_year, current_month = now.year, now.month current_year, current_month = now.year, now.month
# 获取所有active的预算记录 # 获取本账套 active 的预算记录(账套隔离: join KPI 按企业过滤, 2026-09-18 — 原为全账套)
plans = db.query(BudgetPlan).filter(BudgetPlan.status == "active").all() plans = (db.query(BudgetPlan)
.join(KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id)
.filter(BudgetPlan.status == "active", KPIDefinition.entity_id == entity_id)
.all())
# 按KPI分组 # 按KPI分组
from collections import defaultdict from collections import defaultdict
@@ -598,6 +624,7 @@ def budget_roll_forward(
if not existing: if not existing:
bp = BudgetPlan( bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id, kpi_id=kpi_id,
period=new_period, period=new_period,
budget_value=avg_value, budget_value=avg_value,
@@ -634,6 +661,7 @@ def get_budget_comparison(
kpi_id: Optional[int] = Query(None), kpi_id: Optional[int] = Query(None),
year: Optional[int] = Query(None), year: Optional[int] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
""" """
获取实际值vs预测值对比数据 获取实际值vs预测值对比数据
@@ -679,7 +707,11 @@ def get_budget_comparison(
) )
if kpi_id: if kpi_id:
query = query.filter(BudgetPlan.kpi_id == kpi_id) query = query.filter(BudgetPlan.kpi_id == kpi_id)
query = query.filter(BudgetPlan.period.in_(periods), BudgetPlan.status == "active") query = query.filter(
BudgetPlan.period.in_(periods),
BudgetPlan.status == "active",
BudgetPlan.entity_id == entity_id, # 账套隔离 2026-09-21
)
budget_plans = query.all() budget_plans = query.all()
# 按KPI+期间索引 # 按KPI+期间索引
@@ -755,6 +787,7 @@ def get_kpi_comparison(
kpi_id: int, kpi_id: int,
year: Optional[int] = Query(None), year: Optional[int] = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
""" """
获取单个KPI的实际vs预测对比数据(用于图表展示) 获取单个KPI的实际vs预测对比数据(用于图表展示)
@@ -765,7 +798,10 @@ def get_kpi_comparison(
now = datetime.now() now = datetime.now()
y = year or now.year y = year or now.year
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
).first()
if not kpi: if not kpi:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
@@ -1095,10 +1131,14 @@ def update_deviation_alert(
alert_id: int, alert_id: int,
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""更新偏差预警(如标记已解决)""" """更新偏差预警(如标记已解决)(账套隔离 2026-09-21:跨账套 id → 404)"""
from app.models import BudgetDeviationAlert from app.models import BudgetDeviationAlert
alert = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == alert_id).first() alert = db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.id == alert_id,
BudgetDeviationAlert.entity_id == entity_id,
).first()
if not alert: if not alert:
raise HTTPException(404, "预警记录不存在") raise HTTPException(404, "预警记录不存在")
if "status" in data: if "status" in data:
+7 -2
View File
@@ -5,6 +5,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
import json import json
@@ -75,12 +76,16 @@ def _calc_budget(kpi: KPIDefinition) -> dict:
def get_kpi_budget_candidates( def get_kpi_budget_candidates(
year: int = None, year: int = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""获取可用于生成预算的KPI列表,按类型分类""" """获取本账套可用于生成预算的KPI列表,按类型分类(账套隔离 2026-09-21)"""
if not year: if not year:
year = datetime.now().year year = datetime.now().year
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all() kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
# 获取每个KPI的最新实际值 # 获取每个KPI的最新实际值
latest_values = {} latest_values = {}
+44 -18
View File
@@ -116,15 +116,21 @@ def api_get_balance(
@router.post("/balance") @router.post("/balance")
def api_set_balance( def api_set_balance(
data: dict, data: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""设置当前现金余额(万元),作为预测基线""" """设置当前现金余额(万元),作为预测基线
账套隔离(B1,2026-09-22):基线落 `cash.current_balance.<entity_id>`,
entity 来自会话 token(显式传他账套 entity_id → get_entity_id 403)。
修复前本端点无 entity 依赖 → 任一账套设基线会覆盖全平台唯一键。
"""
from app.utils.cash_forecast_engine import set_current_cash_balance from app.utils.cash_forecast_engine import set_current_cash_balance
value = float(data.get("current_cash", 0)) value = float(data.get("current_cash", 0))
if value < 0: if value < 0:
raise HTTPException(400, "现金余额不能为负") raise HTTPException(400, "现金余额不能为负")
set_current_cash_balance(db, value) set_current_cash_balance(db, value, entity_id)
return {"message": "当前现金余额已更新", "current_cash": value} return {"message": "当前现金余额已更新", "entity_id": entity_id, "current_cash": value}
# ══════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════
@@ -198,9 +204,12 @@ def api_create_plan(request: Request, data: dict, db: Session = Depends(get_db))
@router.put("/plans/{plan_id}") @router.put("/plans/{plan_id}")
def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)): def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db),
"""更新收付款计划""" entity_id: int = Depends(get_entity_id)):
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first() """更新收付款计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
if not plan: if not plan:
raise HTTPException(404, "计划不存在") raise HTTPException(404, "计划不存在")
if "plan_type" in data: if "plan_type" in data:
@@ -248,9 +257,12 @@ def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
@router.delete("/plans/{plan_id}") @router.delete("/plans/{plan_id}")
def api_delete_plan(plan_id: int, db: Session = Depends(get_db)): def api_delete_plan(plan_id: int, db: Session = Depends(get_db),
"""删除收付款计划""" entity_id: int = Depends(get_entity_id)):
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first() """删除收付款计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
if not plan: if not plan:
raise HTTPException(404, "计划不存在") raise HTTPException(404, "计划不存在")
db.delete(plan) db.delete(plan)
@@ -259,9 +271,12 @@ def api_delete_plan(plan_id: int, db: Session = Depends(get_db)):
@router.post("/plans/{plan_id}/complete") @router.post("/plans/{plan_id}/complete")
def api_complete_plan(plan_id: int, db: Session = Depends(get_db)): def api_complete_plan(plan_id: int, db: Session = Depends(get_db),
"""标记计划为已完成(收款到账/付款完成)""" entity_id: int = Depends(get_entity_id)):
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first() """标记计划为已完成(收款到账/付款完成)(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
if not plan: if not plan:
raise HTTPException(404, "计划不存在") raise HTTPException(404, "计划不存在")
plan.status = "completed" plan.status = "completed"
@@ -616,9 +631,13 @@ def api_receivables(
@router.post("/receivables/{plan_id}/payment") @router.post("/receivables/{plan_id}/payment")
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db)): def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db),
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS""" entity_id: int = Depends(get_entity_id)):
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first() """回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS
(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
if not plan: if not plan:
raise HTTPException(404, "应收计划不存在") raise HTTPException(404, "应收计划不存在")
if plan.plan_type != "receive": if plan.plan_type != "receive":
@@ -772,7 +791,11 @@ def _is_carry_forward(summary: str, subject_name: str) -> bool:
def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float: def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float:
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)""" """货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)
账套隔离(B1,2026-09-22):本函数已按 entity_id 计算余额,写回时**带上该账套**
(修复前丢账套 → 任一家导入网银流水就覆盖全平台唯一键的基线)。
"""
from app.models import VoucherDetail from app.models import VoucherDetail
from app.utils.cash_forecast_engine import set_current_cash_balance from app.utils.cash_forecast_engine import set_current_cash_balance
from app.utils.voucher_guard import apply_voucher_guard from app.utils.voucher_guard import apply_voucher_guard
@@ -787,8 +810,11 @@ def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> f
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002")) and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
), 2) ), 2)
cash_wan = round(balance_yuan / 10000, 4) cash_wan = round(balance_yuan / 10000, 4)
set_current_cash_balance(db, cash_wan) set_current_cash_balance(db, cash_wan, entity_id)
logger.info(f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 → 现金余额联动") logger.info(
f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 "
f"→ 现金余额联动(账套 {entity_id})"
)
return cash_wan return cash_wan
+33 -12
View File
@@ -176,21 +176,34 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db),
# ============================================================ # ============================================================
# ABC 作业成本 # ABC 作业成本
# 注:ABC 作业中心/分配为单实体演示数据(ab_activities/ab_allocations 全为 entity 1, # 账套隔离(2026-09-18,0-P1-project-20260918-entity-isolation-batch2):
# 历史演示用途),OpenCode 多租户审查 2026-09-07 判定暂不强制按账套隔离;若启用多企业 ABC 需补 entity 过滤。 # 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") @router.get("/abc/activities")
def list_abc_activities(db: Session = Depends(get_db)): def list_abc_activities(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""查询ABC作业中心列表""" """查询ABC作业中心列表(账套隔离:只返回本账套)"""
items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all() items = (db.query(AbcActivity)
.filter(AbcActivity.entity_id == entity_id)
.order_by(AbcActivity.activity_code)
.all())
return {"data": items} return {"data": items}
@router.post("/abc/activities") @router.post("/abc/activities")
def create_abc_activity(data: dict, db: Session = Depends(get_db)): def create_abc_activity(data: dict, db: Session = Depends(get_db),
"""创建ABC作业中心""" entity_id: int = Depends(get_entity_id)):
"""创建ABC作业中心(账套隔离:按会话账套写入归属)"""
act = AbcActivity( act = AbcActivity(
entity_id=entity_id,
activity_code=data["activity_code"], activity_code=data["activity_code"],
activity_name=data["activity_name"], activity_name=data["activity_name"],
activity_desc=data.get("activity_desc"), activity_desc=data.get("activity_desc"),
@@ -206,14 +219,21 @@ def create_abc_activity(data: dict, db: Session = Depends(get_db)):
@router.post("/abc/allocate") @router.post("/abc/allocate")
def do_allocate(data: dict, db: Session = Depends(get_db)): def do_allocate(data: dict, db: Session = Depends(get_db),
"""执行ABC成本分配""" 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( result = allocate_cost(
activity_id=data["activity_id"], activity_id=data["activity_id"],
period=data.get("period", datetime.now().strftime("%Y-%m")), period=data.get("period", datetime.now().strftime("%Y-%m")),
product_code=data["product_code"], product_code=data["product_code"],
product_name=data.get("product_name", ""), product_name=data.get("product_name", ""),
driver_consumed=data["driver_consumed"], driver_consumed=data["driver_consumed"],
entity_id=entity_id,
) )
return result return result
@@ -221,9 +241,10 @@ def do_allocate(data: dict, db: Session = Depends(get_db)):
@router.get("/abc/allocations") @router.get("/abc/allocations")
def list_allocations(period: Optional[str] = Query(None), def list_allocations(period: Optional[str] = Query(None),
product_code: Optional[str] = Query(None), product_code: Optional[str] = Query(None),
db: Session = Depends(get_db)): db: Session = Depends(get_db),
"""查询ABC分配记录""" entity_id: int = Depends(get_entity_id)):
query = db.query(AbcAllocation) """查询ABC分配记录(账套隔离:只返回本账套)"""
query = db.query(AbcAllocation).filter(AbcAllocation.entity_id == entity_id)
if period: if period:
query = query.filter(AbcAllocation.period == period) query = query.filter(AbcAllocation.period == period)
if product_code: if product_code:
+3
View File
@@ -5,6 +5,7 @@ from sqlalchemy import func, desc
from typing import Optional from typing import Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, User from app.models import KPIDefinition, KPIValue, KPIAlert, User
import logging import logging
@@ -45,6 +46,7 @@ def list_customer_kpis(
end_date: str = Query(None), end_date: str = Query(None),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_auth), current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
): ):
"""获取客户维度KPI列表(含最新值、预警、趋势)""" """获取客户维度KPI列表(含最新值、预警、趋势)"""
start, end = parse_period(period, start_date, end_date) start, end = parse_period(period, start_date, end_date)
@@ -54,6 +56,7 @@ def list_customer_kpis(
kpis = db.query(KPIDefinition).filter( kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active", KPIDefinition.status == "active",
KPIDefinition.dimension == "customer", KPIDefinition.dimension == "customer",
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
).order_by(KPIDefinition.kpi_code).all() ).order_by(KPIDefinition.kpi_code).all()
result = [] result = []
+56 -5
View File
@@ -1,7 +1,7 @@
"""驾驶舱 API v2 — 支持时间区间""" """驾驶舱 API v2 — 支持时间区间"""
from fastapi import APIRouter, Depends, Query, Request, HTTPException from fastapi import APIRouter, Depends, Query, Request, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func, or_ from sqlalchemy import case, func, or_
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from app.database import get_db from app.database import get_db
@@ -360,6 +360,7 @@ def get_my_kpis(
current_user: User = Depends(require_auth), current_user: User = Depends(require_auth),
period: str = Query("month"), period: str = Query("month"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""获取当前用户负责的KPI """获取当前用户负责的KPI
- business角色:只看自己负责的KPI - business角色:只看自己负责的KPI
@@ -370,7 +371,10 @@ def get_my_kpis(
name = current_user.name name = current_user.name
period_str = datetime.now().strftime("%Y-%m") period_str = datetime.now().strftime("%Y-%m")
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all() kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
).all()
result = [] result = []
for k in kpis: for k in kpis:
@@ -565,6 +569,55 @@ def predict_kpis(db: Session = Depends(get_db), entity_id: int = Depends(get_ent
# ── 个人工作台 ────────────────────────────── # ── 个人工作台 ──────────────────────────────
# KPIValue 取数口径(2026-09-18 工作台「我的KPI」期间缺口修正)
# 原实现(my_dashboard 内联)按 calculated_at 倒序取「最近写入」的一条:批量补录/复采会把
# **旧期间**的行写得更晚 → 卡片显示过期期间。实测 entity1:F_REVENUE/F_NET_PROFIT 的
# 2026-07 行写入时刻(2026-09-14 17:01:22, id=2128/2130)晚于 2026-08 行(17:00:30, id=2125/2127)
# → 取到 7 月;C_REBATE_RATE 1–4 月补录行 calculated_at 并列(2026-08-24 17:17:38) → 取到 4 月。
# 新口径:优先取「月期间 YYYY-MM」中最新的一期(月期是采集/录入的常规粒度);若该 KPI 只有
# H1/H2/FY 等汇总期行,则回退到原按 calculated_at 的取法(不改变无月期 KPI 的行为)。
# 注意:**不能**只按 period 字典序排 —— '2026-H1'/'2026-H2' 字典序大于 '2026-08',会把
# 季度汇总行/半年度预估值(forecast)当成"最新"。
_MONTH_PERIOD_REGEX = r"^[0-9]{4}-(0[1-9]|1[0-2])$"
# 主值优先(2026-09-18 同期间重复行判据收口,项目单 project-20260918-kpi-row-priority)
# 同一期间可能并存两行:①主值(source_type='ledger' / data_status='verified',结账/复核口径)
# ②自动归集重复行(source_type='auto_collect',备注自述「与主值为同一事实重复行」)。
# 原判据仅看写入时刻:重复行由复采批量写入、calculated_at 更晚 ⇒ 会被选中(实测 entity1
# 2026-08 的 F_REVENUE/F_NET_PROFIT:2136/2127 auto_collect 写于 09-14,晚于主值 1822/2102)。
# 今日两行数值一致(Δ=0)故用户不可见;一旦重复行与主值漂移,卡片会静默显示重复行的值。
# 故在**同一期间内**加「主值优先」:verified(已复核)> ledger(账面主值)> 其余。
# 注意:跨期间仍以 period 倒序为第一判据(不改变 d33b5e1 的期间口径——当期只有未复核行时,
# 不会回退到上一期已复核行)。
_MAIN_VALUE_PRIORITY = case(
(KPIValue.data_status == "verified", 0),
(KPIValue.source_type == "ledger", 1),
else_=2,
)
def latest_kpi_value(db: Session, kpi_id: int) -> Optional[KPIValue]:
"""该 KPI 的最新可用值:月期间优先(同期内主值优先),无月期间行时回退 calculated_at 倒序。"""
monthly = (
db.query(KPIValue)
.filter(KPIValue.kpi_id == kpi_id, KPIValue.period.op("REGEXP")(_MONTH_PERIOD_REGEX))
.order_by(
KPIValue.period.desc(),
_MAIN_VALUE_PRIORITY,
KPIValue.calculated_at.desc(),
KPIValue.id.desc(),
)
.first()
)
if monthly is not None:
return monthly
return (
db.query(KPIValue)
.filter(KPIValue.kpi_id == kpi_id)
.order_by(KPIValue.calculated_at.desc(), KPIValue.id.desc())
.first()
)
@router.get("/my-dashboard") @router.get("/my-dashboard")
def my_dashboard( def my_dashboard(
@@ -613,9 +666,7 @@ def my_dashboard(
kpi_list = [] kpi_list = []
for k in all_kpis: for k in all_kpis:
latest_v = db.query(KPIValue).filter( latest_v = latest_kpi_value(db, k.id)
KPIValue.kpi_id == k.id
).order_by(KPIValue.calculated_at.desc()).first()
actual = latest_v.actual_value if latest_v else None actual = latest_v.actual_value if latest_v else None
target = kpi_target_by_frequency(k) target = kpi_target_by_frequency(k)
+51 -16
View File
@@ -10,7 +10,7 @@ import json
import logging import logging
from app.database import get_db from app.database import get_db
from app.deps import assert_entity_scope from app.deps import assert_entity_scope, get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog from app.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog
from app.api.kpis import kpi_to_dict from app.api.kpis import kpi_to_dict
@@ -36,9 +36,12 @@ def _log_to_dict(log):
# ============================================================ # ============================================================
@router.get("/check") @router.get("/check")
def run_quality_check(db: Session = Depends(get_db)): def run_quality_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""扫描全部KPI,生成数据质量报告""" """扫描本账套KPI,生成数据质量报告(账套隔离 2026-09-21)"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all() kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
issues = [] issues = []
current_period = datetime.now().strftime("%Y-%m") current_period = datetime.now().strftime("%Y-%m")
@@ -174,9 +177,19 @@ def list_quality_logs(
check_type: Optional[str] = None, check_type: Optional[str] = None,
status: Optional[str] = None, status: Optional[str] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""获取数据质量日志""" """获取数据质量日志(账套隔离 2026-09-21)
query = db.query(KpiDataQualityLog)
kpi_data_quality_log 表无 entity_id 列(见本文件 :235 说明),按本单选项(1)
经 kpi_id join kpi_definitions 以 kpi_definitions.entity_id 收口(零迁移)。
join 为内连接:kpi_id 无对应 KPI 行的孤儿日志无法归属账套,一律不返回。
"""
query = (
db.query(KpiDataQualityLog)
.join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id)
.filter(KPIDefinition.entity_id == entity_id)
)
if kpi_id: if kpi_id:
query = query.filter(KpiDataQualityLog.kpi_id == kpi_id) query = query.filter(KpiDataQualityLog.kpi_id == kpi_id)
if severity: if severity:
@@ -198,10 +211,22 @@ def list_quality_logs(
return {"data": result, "total": len(result)} return {"data": result, "total": len(result)}
def _scoped_quality_log(db: Session, log_id: int, entity_id: int):
"""按会话账套取质量日志行(表无 entity_id 列 → 经 kpi_id join kpi_definitions 归属)"""
return (
db.query(KpiDataQualityLog)
.join(KPIDefinition, KPIDefinition.id == KpiDataQualityLog.kpi_id)
.filter(KpiDataQualityLog.id == log_id,
KPIDefinition.entity_id == entity_id)
.first()
)
@router.put("/logs/{log_id}") @router.put("/logs/{log_id}")
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
"""更新质量日志(解决/忽略)""" entity_id: int = Depends(get_entity_id)):
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first() """更新质量日志(解决/忽略)(账套隔离 2026-09-21:跨账套 → 404)"""
log = _scoped_quality_log(db, log_id, entity_id)
if not log: if not log:
raise HTTPException(404, "日志不存在") raise HTTPException(404, "日志不存在")
if "status" in data: if "status" in data:
@@ -215,22 +240,29 @@ def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), u
@router.delete("/logs/{log_id}") @router.delete("/logs/{log_id}")
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES): def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first() entity_id: int = Depends(get_entity_id)):
if log: """删除质量日志(账套隔离 2026-09-21:跨账套 → 404;不存在 → 404,对齐 PUT 语义)"""
log = _scoped_quality_log(db, log_id, entity_id)
if not log:
raise HTTPException(404, "日志不存在")
db.delete(log) db.delete(log)
db.commit() db.commit()
return {"message": "已删除"} return {"message": "已删除"}
# ============================================================ # ============================================================
# 数据质量看板统计 # 数据质量看板统计
# ============================================================ # ============================================================
@router.get("/stats") @router.get("/stats")
def quality_stats(db: Session = Depends(get_db)): def quality_stats(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""数据质量统计""" """数据质量统计(账套隔离 2026-09-21:KPI 口径按会话账套;质量日志表无 entity_id 列,见回报)"""
total_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").count() total_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).count()
total_logs = db.query(KpiDataQualityLog).count() total_logs = db.query(KpiDataQualityLog).count()
open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count() open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count()
@@ -256,7 +288,10 @@ def quality_stats(db: Session = Depends(get_db)):
# ── 数据审计看板统计 ── # ── 数据审计看板统计 ──
# KPI完整度评分 # KPI完整度评分
all_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all() all_kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
).all()
total = len(all_kpis) total = len(all_kpis)
complete_kpis = 0 complete_kpis = 0
missing_metadata_count = 0 missing_metadata_count = 0
+47 -11
View File
@@ -6,10 +6,12 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.auth_middleware import require_auth, require_role 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 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 json
import logging import logging
from datetime import datetime
logger = logging.getLogger("cma.deviation_push") logger = logging.getLogger("cma.deviation_push")
@@ -22,6 +24,7 @@ router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
def push_deviation_to_map( def push_deviation_to_map(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth), current_user=Depends(require_auth),
): ):
"""从差异分析回写实际值到战略地图节点 """从差异分析回写实际值到战略地图节点
@@ -47,9 +50,25 @@ def push_deviation_to_map(
if new_value is None: if new_value is None:
raise HTTPException(400, "缺少 newValue") raise HTTPException(400, "缺少 newValue")
# 1. 查找战略地图 # 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() m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m: if not m or (m.entity_id or 1) != entity_id:
raise HTTPException(404, "战略地图不存在") raise HTTPException(404, "战略地图不存在")
# 2. 解析 node_id 格式: "finance-0" # 2. 解析 node_id 格式: "finance-0"
@@ -89,13 +108,12 @@ def push_deviation_to_map(
kpi_code = kpi_codes[0] kpi_code = kpi_codes[0]
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first() kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
if not kpi: if not kpi or (kpi.entity_id or 1) != entity_id:
raise HTTPException(404, f"KPI {kpi_code} 不存在") raise HTTPException(404, f"KPI {kpi_code} 不存在")
# 3. 更新实际值到 KPIValue 表 # 3. 更新实际值到 KPIValue 表
if not period: #(R5-补1:period 已在上方强制校验,原「缺 period 用当月兜底」分支已删除——
period = datetime.now().strftime("%Y-%m") # 该兜底会伪造期间,正是 R5「不可归期」的成因之一)
existing_value = db.query(KPIValue).filter( existing_value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.kpi_id == kpi.id,
KPIValue.period == period, KPIValue.period == period,
@@ -104,20 +122,22 @@ def push_deviation_to_map(
if existing_value: if existing_value:
existing_value.actual_value = new_value existing_value.actual_value = new_value
existing_value.source_type = "manual" existing_value.source_type = "manual"
kv_row = existing_value
else: else:
kv = KPIValue( kv_row = KPIValue(
kpi_id=kpi.id, kpi_id=kpi.id,
period=period, period=period,
actual_value=new_value, actual_value=new_value,
source_type="manual", source_type="manual",
) )
db.add(kv) db.add(kv_row)
db.flush() db.flush()
# 4. 检查是否触发预警 # 4. 检查是否触发预警
alert_created = False alert_created = False
alert_id = None alert_id = None
alert_skipped_reason = None
if kpi.target_value and kpi.target_value > 0: if kpi.target_value and kpi.target_value > 0:
ratio = new_value / kpi.target_value ratio = new_value / kpi.target_value
if ratio < 0.7: if ratio < 0.7:
@@ -130,9 +150,20 @@ def push_deviation_to_map(
alert_level = None alert_level = None
if alert_level: 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( alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属 entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id, kpi_id=kpi.id,
kpi_value_id=kv_row.id, # R5-补1: 期间关联(同 kpi 同期间行 → 可派生)
alert_level=alert_level, alert_level=alert_level,
alert_message=alert_msg, alert_message=alert_msg,
status="pending", status="pending",
@@ -147,11 +178,13 @@ def push_deviation_to_map(
if create_review_topic: if create_review_topic:
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}" topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
existing_topic = db.query(ActionPlan).filter( existing_topic = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id, # 2026-09-18 派生单: 去重按账套隔离
ActionPlan.title == topic_title, ActionPlan.title == topic_title,
ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.status.in_(["pending", "in_progress"]),
).first() ).first()
if not existing_topic: if not existing_topic:
topic = ActionPlan( topic = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
kpi_id=kpi.id, kpi_id=kpi.id,
title=topic_title, title=topic_title,
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}", description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
@@ -176,6 +209,7 @@ def push_deviation_to_map(
"new_value": new_value, "new_value": new_value,
"period": period, "period": period,
"alert_created": alert_created, "alert_created": alert_created,
"alert_skipped_reason": alert_skipped_reason,
"review_topic_created": review_topic_created, "review_topic_created": review_topic_created,
}, ensure_ascii=False), }, ensure_ascii=False),
) )
@@ -190,15 +224,17 @@ def push_deviation_to_map(
"new_value": new_value, "new_value": new_value,
"alert_created": alert_created, "alert_created": alert_created,
"alert_id": alert_id, "alert_id": alert_id,
"alert_skipped_reason": alert_skipped_reason,
"review_topic_created": review_topic_created, "review_topic_created": review_topic_created,
} }
@router.get("/map-nodes/{map_id}") @router.get("/map-nodes/{map_id}")
def get_map_nodes(map_id: int, db: Session = Depends(get_db)): 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() m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m: if not m or (m.entity_id or 1) != entity_id: # 2026-09-18 派生单: NULL 归属按 e1 兼容(同 push 护栏)
raise HTTPException(404, "战略地图不存在") raise HTTPException(404, "战略地图不存在")
dims = m.dimensions dims = m.dimensions
+8 -2
View File
@@ -7,6 +7,7 @@ from typing import Optional, List
from datetime import datetime from datetime import datetime
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import SystemConfig, OperationLog from app.models import SystemConfig, OperationLog
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
@@ -254,6 +255,7 @@ def driver_calculate(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(require_auth), current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
): ):
""" """
驱动因子计算预算。 驱动因子计算预算。
@@ -313,6 +315,7 @@ def driver_calculate(
# 记录计算结果 # 记录计算结果
budget_record = DriverFactorBudget( budget_record = DriverFactorBudget(
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
name=name, name=name,
industry=industry, industry=industry,
template_id=template_id, template_id=template_id,
@@ -457,9 +460,12 @@ def driver_sensitivity(
def list_driver_history( def list_driver_history(
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
): ):
"""查看最近的驱动因子预算计算记录""" """查看本账套最近的驱动因子预算计算记录(账套隔离 2026-09-21)"""
records = db.query(DriverFactorBudget).order_by( records = db.query(DriverFactorBudget).filter(
DriverFactorBudget.entity_id == entity_id,
).order_by(
DriverFactorBudget.created_at.desc() DriverFactorBudget.created_at.desc()
).limit(limit).all() ).limit(limit).all()
+45 -17
View File
@@ -290,7 +290,9 @@ def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list
}) })
else: else:
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次) # 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
# 账套隔离 2026-09-21:累计口径限本账套(他账套在途不得污染本账套额度判定)
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter( q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
ExpenseReimbursement.entity_id == reimb.entity_id,
ExpenseReimbursement.expense_type == reimb.expense_type, ExpenseReimbursement.expense_type == reimb.expense_type,
ExpenseReimbursement.id != reimb.id, ExpenseReimbursement.id != reimb.id,
ExpenseReimbursement.status.in_(["pending", "approved"]), ExpenseReimbursement.status.in_(["pending", "approved"]),
@@ -343,6 +345,7 @@ def submit_reimbursement(
data: dict, data: dict,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(require_auth), 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) applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
@@ -366,6 +369,7 @@ def submit_reimbursement(
expense_date = None expense_date = None
reimb = ExpenseReimbursement( 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)}", reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
applicant=applicant, applicant=applicant,
department=data.get("department", ""), department=data.get("department", ""),
@@ -402,8 +406,9 @@ def list_reimbursements(
entity_id: int = Depends(get_entity_id), entity_id: int = Depends(get_entity_id),
_=Depends(require_auth), _=Depends(require_auth),
): ):
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)""" """查询报销单列表(支持状态/类型/申请人/关键字筛选)(账套隔离 2026-09-21:列表按会话账套)"""
q = db.query(ExpenseReimbursement) q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.entity_id == entity_id)
if status: if status:
q = q.filter(ExpenseReimbursement.status == status) q = q.filter(ExpenseReimbursement.status == status)
if expense_type: if expense_type:
@@ -425,20 +430,29 @@ def list_reimbursements(
def get_reimbursement( def get_reimbursement(
reimb_id: int, reimb_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth), _=Depends(require_auth),
): ):
"""报销单详情""" """报销单详情(账套隔离 2026-09-21:跨账套 → 404)"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first() r = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == reimb_id,
ExpenseReimbursement.entity_id == entity_id,
).first()
if not r: if not r:
raise HTTPException(404, "报销单不存在") raise HTTPException(404, "报销单不存在")
return _reimb_to_dict(r) return _reimb_to_dict(r)
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str): def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str,
"""执行审批动作: approve/reject/return""" entity_id: int = None):
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first() """执行审批动作: 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: if not r:
raise HTTPException(404, "报销单不存在") raise HTTPException(404, "报销单不存在")
if r.status not in ("pending", "returned"): if r.status not in ("pending", "returned"):
raise HTTPException(400, f"当前状态({r.status})不可审批") raise HTTPException(400, f"当前状态({r.status})不可审批")
@@ -462,10 +476,11 @@ def approve_reimbursement(
data: dict = None, data: dict = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")), 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 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) r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver, entity_id)
return {"message": "已审批通过", "data": _reimb_to_dict(r)} return {"message": "已审批通过", "data": _reimb_to_dict(r)}
@@ -475,13 +490,14 @@ def reject_reimbursement(
data: dict = None, data: dict = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")), current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
): ):
"""审批拒绝""" """审批拒绝(账套隔离 2026-09-21:跨账套 → 404)"""
comment = (data or {}).get("comment", "") comment = (data or {}).get("comment", "")
if not comment: if not comment:
raise HTTPException(400, "拒绝时必须填写审批意见") raise HTTPException(400, "拒绝时必须填写审批意见")
approver = current_user.name if hasattr(current_user, "name") else current_user.username approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "reject", comment, approver) r = _do_approve(db, reimb_id, "reject", comment, approver, entity_id)
return {"message": "已拒绝", "data": _reimb_to_dict(r)} return {"message": "已拒绝", "data": _reimb_to_dict(r)}
@@ -491,10 +507,11 @@ def return_reimbursement(
data: dict = None, data: dict = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")), 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 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) r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver, entity_id)
return {"message": "已打回", "data": _reimb_to_dict(r)} return {"message": "已打回", "data": _reimb_to_dict(r)}
@@ -504,9 +521,13 @@ def resubmit_reimbursement(
data: dict = None, data: dict = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_=Depends(require_auth), _=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
): ):
"""被驳回/打回后重新提交 → 重新自动校验""" """被驳回/打回后重新提交 → 重新自动校验(账套隔离 2026-09-21:跨账套 → 404)"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first() r = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == reimb_id,
ExpenseReimbursement.entity_id == entity_id,
).first()
if not r: if not r:
raise HTTPException(404, "报销单不存在") raise HTTPException(404, "报销单不存在")
if r.status not in ("returned", "rejected"): if r.status not in ("returned", "rejected"):
@@ -537,6 +558,7 @@ def resubmit_reimbursement(
def expense_stats( def expense_stats(
period: str = Query(None, description="期间 YYYY-MM,默认当前月"), period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth), _=Depends(require_auth),
): ):
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额""" """费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
@@ -544,7 +566,9 @@ def expense_stats(
period = period or now.strftime("%Y-%m") period = period or now.strftime("%Y-%m")
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0} counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).group_by( 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 ExpenseReimbursement.status
).all(): ).all():
if st in counts: if st in counts:
@@ -560,6 +584,7 @@ def expense_stats(
) )
.filter( .filter(
ExpenseReimbursement.status.in_(["pending", "approved"]), ExpenseReimbursement.status.in_(["pending", "approved"]),
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period, func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
) )
.group_by(ExpenseReimbursement.expense_type) .group_by(ExpenseReimbursement.expense_type)
@@ -583,6 +608,7 @@ def expense_stats(
.filter( .filter(
ExpenseReimbursement.status == "returned", ExpenseReimbursement.status == "returned",
ExpenseReimbursement.check_result == "fail", ExpenseReimbursement.check_result == "fail",
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
) )
.order_by(ExpenseReimbursement.id.desc()) .order_by(ExpenseReimbursement.id.desc())
.limit(50) .limit(50)
@@ -604,7 +630,9 @@ def expense_stats(
for r in over_limit_rows for r in over_limit_rows
] ]
recent_rows = db.query(ExpenseReimbursement).order_by(ExpenseReimbursement.id.desc()).limit(10).all() 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] recent = [_reimb_to_dict(r) for r in recent_rows]
# 本月预算使用率(招待费15万/月 管理费91万/月) # 本月预算使用率(招待费15万/月 管理费91万/月)
+7 -2
View File
@@ -15,6 +15,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition from app.models import KPIDefinition
@@ -166,9 +167,13 @@ def validate_kpi(
def governance_audit( def governance_audit(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user=Depends(require_auth), current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
): ):
"""全量校验所有活跃KPI,输出按4条规则分组的不合规清单。""" """校验本账套活跃KPI,输出按4条规则分组的不合规清单(账套隔离 2026-09-21)。"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all() kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
non_compliant = [] non_compliant = []
by_rule: dict = {1: [], 2: [], 3: [], 4: []} by_rule: dict = {1: [], 2: [], 3: [], 4: []}
+32 -10
View File
@@ -437,9 +437,13 @@ def get_kpi_causality_chain(
kpi_id: int, kpi_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user = Depends(require_auth), current_user = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
): ):
"""KPI因果链追踪 — 返回单个KPI的上下游因果链""" """KPI因果链追踪 — 返回单个KPI的上下游因果链(账套隔离 2026-09-21:跨账套 id → 404)"""
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: if not kpi:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
@@ -884,14 +888,21 @@ def kpi_to_dict(k):
@router.put("/{kpi_id}/associate-map") @router.put("/{kpi_id}/associate-map")
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES): def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
"""关联KPI到战略地图""" entity_id: int = Depends(get_entity_id)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() """关联KPI到战略地图(账套隔离 2026-09-21:KPI 与地图均须属本账套,跨账套 → 404)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi: if not kpi:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
map_id = data.get("map_id") map_id = data.get("map_id")
if map_id is not None: if map_id is not None:
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: if not m:
raise HTTPException(404, "战略地图不存在") raise HTTPException(404, "战略地图不存在")
kpi.map_id = map_id kpi.map_id = map_id
@@ -907,9 +918,16 @@ def _log(db, user_id, action, target_type, target_id, detail):
@router.get("/{kpi_id}/objectives") @router.get("/{kpi_id}/objectives")
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)): def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db),
"""查看KPI所属的目标和战略地图""" entity_id: int = Depends(get_entity_id)):
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() """查看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: if not kpi:
raise HTTPException(404, "KPI不存在") raise HTTPException(404, "KPI不存在")
@@ -922,15 +940,19 @@ def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
} }
if kpi.map_id: 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: if m:
result["map"] = {"id": m.id, "title": m.title, "status": m.status} result["map"] = {"id": m.id, "title": m.title, "status": m.status}
if kpi.objective: if kpi.objective:
objs = db.query(MapObjective).filter( objs = db.query(MapObjective).filter(
MapObjective.map_id == kpi.map_id, MapObjective.map_id == kpi.map_id,
MapObjective.entity_id == entity_id,
MapObjective.name == kpi.objective, MapObjective.name == kpi.objective,
).all() ).all()
result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs] result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs]
return result return result
+39 -5
View File
@@ -2,10 +2,21 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id
from app.models import NotificationChannel, NotificationLog from app.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role, require_auth_401 from app.auth_middleware import require_role, require_auth_401
from datetime import datetime from datetime import datetime
# ── 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) # 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook # 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。 # 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 require_auth_401。
@@ -52,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() ch = db.query(NotificationChannel).filter(NotificationChannel.id == ch_id).first()
if not ch: if not ch:
raise HTTPException(404, "渠道不存在") raise HTTPException(404, "渠道不存在")
# 字段白名单(2026-09-21 mass-assignment 收口):仅业务字段,其余键忽略
_updatable = ("name", "config", "enabled")
for k, v in data.items(): 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) setattr(ch, k, v)
db.commit() db.commit()
db.refresh(ch) db.refresh(ch)
@@ -95,14 +108,35 @@ def test_channel(ch_id: int, db: Session = Depends(get_db)):
@router.get("/logs") @router.get("/logs")
def list_logs(page: int = 1, db: Session = Depends(get_db)): def list_logs(page: int = 1,
"""通知历史""" entity_id: int = Depends(get_entity_id),
total = db.query(NotificationLog).count() db: Session = Depends(get_db)):
logs = db.query(NotificationLog).order_by( """通知历史(**按会话账套收口**,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()
logs = scoped.order_by(
NotificationLog.created_at.desc() NotificationLog.created_at.desc()
).offset((page - 1) * 20).limit(20).all() ).offset((page - 1) * 20).limit(20).all()
return { return {
"total": total, "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": [{ "data": [{
"id": l.id, "id": l.id,
"alert_id": l.alert_id, "alert_id": l.alert_id,
+5
View File
@@ -547,6 +547,11 @@ def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
week_end = week_start + timedelta(days=7) week_end = week_start + timedelta(days=7)
action_plans = db.query(ActionPlan).filter( action_plans = db.query(ActionPlan).filter(
ActionPlan.objective_id == okr_id, 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) ActionPlan.due_date.between(week_start, week_end)
).all() ).all()
+17 -1
View File
@@ -1,10 +1,24 @@
""" """
OKR模板库 API — 按维度筛选 + 用户自定义模板 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 fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role from app.auth_middleware import require_role
from app.models import OKRTemplate, StrategicMap from app.models import OKRTemplate, StrategicMap
from app.utils.kr_name import sanitize_kr_name from app.utils.kr_name import sanitize_kr_name
@@ -253,7 +267,8 @@ def increment_use_count(template_id: int, db: Session = Depends(get_db)):
@router.post("/{template_id}/apply") @router.post("/{template_id}/apply")
def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db)): def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""应用 OKR 模板 — 创建战略地图并填入 O+KR""" """应用 OKR 模板 — 创建战略地图并填入 O+KR"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first() t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t: if not t:
@@ -293,6 +308,7 @@ def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_d
}) })
m = StrategicMap( m = StrategicMap(
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1(对齐 maps.py)
title=map_title, title=map_title,
version=data.get("version", "v1.0"), version=data.get("version", "v1.0"),
status="draft", status="draft",
+148 -61
View File
@@ -10,7 +10,7 @@ from app.utils.cash_forecast_engine import (
calculate_accuracy, generate_scenario_suggestion, calculate_accuracy, generate_scenario_suggestion,
) )
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request from app.deps import get_entity_id, resolve_entity_for_request, assert_entity_scope
from app.auth_middleware import require_auth_401 from app.auth_middleware import require_auth_401
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -724,95 +724,182 @@ def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
return indicators return indicators
@router.post("/growth-quality") # ── 增长质量诊断取数(账套隔离 2026-09-21)────────────────────────
def api_growth_quality(request: Request, data: dict): # 输入 ← 库内 KPI 实际值(kpi_values),编码口径沿用 kpi_definitions 既有编码
"""增长质量诊断 — 五维度评分+综合评分+诊断结论""" # (同 app/api/growth_quality.py 的 KPI_CODES),不新增计算口径。
try: _GQ_KPI_CODE_MAP = {
entity_id = resolve_entity_for_request(request, data.get("entity_id")) "C_REBATE_RATE": "rebateRate", # 渠补率(%)
ENTITY_DATA = { "F_GROSS_MARGIN": "trueGrossMargin", # 毛利率(%)
1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447}, "F_REVENUE_GROWTH": "revenueGrowthRate", # 收入增长率(%)
2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4}, }
}
entity = ENTITY_DATA.get(entity_id, data.get("entity", data))
entity_name = entity.get("entity", entity.get("name", "该企业"))
period = entity.get("period", data.get("period", "当前"))
# 五维度评分 _GQ_DIM_SCORERS = {
dim_scores = { "revenueStructure": _score_revenue_structure,
"revenueStructure": _score_revenue_structure(entity), "profitStructure": _score_profit_structure,
"profitStructure": _score_profit_structure(entity), "cashAssets": _score_cash_assets,
"cashAssets": _score_cash_assets(entity), "growthDriver": _score_growth_driver,
"growthDriver": _score_growth_driver(entity), "orgEfficiency": _score_org_efficiency,
"orgEfficiency": _score_org_efficiency(entity), }
}
overall = round(sum(dim_scores.values()) / 5, 1) # 各维度所需输入键。缺任一输入 → 该维度不评分(避免用缺省 0 编造低分)
_GQ_DIM_INPUTS = {
"revenueStructure": ("rebateRate",),
"profitStructure": ("trueGrossMargin",),
"cashAssets": ("cashRatio",),
"growthDriver": ("expenseGrowthRate", "revenueGrowthRate"),
"orgEfficiency": ("mgmtRatio",),
}
# 综合等级
if overall >= 4:
level = "🟢 越增长越轻"
level_type = "excellent"
elif overall >= 3:
level = "🟡 增长质量中等"
level_type = "medium"
elif overall >= 2:
level = "🟠 需关注"
level_type = "warning"
else:
level = "🔴 越增长越重"
level_type = "danger"
# 诊断结论 def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None):
dimensions_payload = {} """按会话账套从库内 KPI 实际值取增长质量输入。
detail_payload = {}
for dim, score in dim_scores.items(): 返回 (entity_dict|None, collected):
dimensions_payload[dim] = {"score": score, "weight": 20} - entity_dict 只含库内真实取到的指标(缺的键不填,绝不用请求体/硬编码兜底)
detail_payload[dim] = { - 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.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, "score": score,
"indicators": _get_dim_detail_indicators(dim, entity), "indicators": _get_dim_detail_indicators(dim, entity),
"suggestions": _generate_improvement_suggestions(dim, score, entity), "suggestions": _generate_improvement_suggestions(dim, score, entity),
} }
return scores, detail, missing
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
# 对比数据(如果请求中包含多个实体) def _gq_level_of(overall: float):
compare = data.get("compare", None) if overall is None:
compare_result = None return None, None
if compare: if overall >= 4:
compare_entity = compare return "🟢 越增长越轻", "excellent"
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业")) if overall >= 3:
cdims = { return "🟡 增长质量中等", "medium"
"revenueStructure": _score_revenue_structure(compare_entity), if overall >= 2:
"profitStructure": _score_profit_structure(compare_entity), return "🟠 需关注", "warning"
"cashAssets": _score_cash_assets(compare_entity), return "🔴 越增长越重", "danger"
"growthDriver": _score_growth_driver(compare_entity),
"orgEfficiency": _score_org_efficiency(compare_entity),
@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,
} }
compare_overall = round(sum(cdims.values()) / 5, 1)
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 = { compare_result = {
"entity_name": compare_name, "entity_id": int(compare_entity_id),
"overall": compare_overall, "entity_name": c_entity.get("entity", "对比企业"),
"overall": c_overall,
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()}, "dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
"level": ("🟢 越增长越轻" if compare_overall >= 4 else "level": _gq_level_of(c_overall)[0],
"🟡 增长质量中等" if compare_overall >= 3 else
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
} }
return { return {
"entity_id": entity_id,
"entity_name": entity_name, "entity_name": entity_name,
"period": period, "period": period_label,
"collected": True,
"data_source": "kpi_values",
"overall": overall, "overall": overall,
"level": level, "level": level,
"level_type": level_type, "level_type": level_type,
"dimensions": dimensions_payload, "dimensions": dimensions_payload,
"detail": detail_payload, "detail": detail_payload,
"dimensions_missing": dims_missing,
"diagnosis": diagnosis, "diagnosis": diagnosis,
"compare": compare_result, "compare": compare_result,
} }
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(400, f"增长质量诊断失败: {str(e)}") raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
# ── KPI趋势预测(预测性成本智能 MVP) ──────────────────────────── # ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
from app.utils.kpi_forecast_engine import ( # noqa: E402 from app.utils.kpi_forecast_engine import ( # noqa: E402
MODELS, forecast_kpi, forecast_finance_kpis, MODELS, forecast_kpi, forecast_finance_kpis,
+6 -2
View File
@@ -831,7 +831,7 @@ def seed_demo_data(
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first() exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
if exists: if exists:
continue continue
db.add(ExpenseReimbursement(**d)) db.add(ExpenseReimbursement(entity_id=eid, **d))
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张 # 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
invoice_demo = [ invoice_demo = [
@@ -854,7 +854,11 @@ def seed_demo_data(
) )
if exists: if exists:
continue continue
inv = InvoiceCheck(entity_id=eid, **d) 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) _check_invoice(db, inv)
db.add(inv) db.add(inv)
created["invoice"] += 1 created["invoice"] += 1
+71 -9
View File
@@ -1,10 +1,36 @@
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务""" """租户状态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`。
"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.auth_middleware import require_auth_401 from app.auth_middleware import require_auth_401, require_role
from app.models import SystemConfig, Entity from app.models import SystemConfig, Entity, OperationLog
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914) # 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写 # 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
@@ -25,8 +51,15 @@ def _tenant_name(entity_id: int) -> str:
@router.get("/current") @router.get("/current")
def get_current_tenant(db: Session = Depends(get_db)): 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() cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg and cfg.config_value: if cfg and cfg.config_value:
import json import json
@@ -38,13 +71,25 @@ def get_current_tenant(db: Session = Depends(get_db)):
@router.post("/switch") @router.post("/switch")
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)): def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
"""切换当前租户(CMA前端企业切换器调用)""" 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() ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
if not ent: if not ent:
raise HTTPException(404, "企业不存在") raise HTTPException(404, "企业不存在")
import json import json
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)
except Exception:
previous = None
state = { state = {
"tenant_id": _tenant_name(data.entity_id), "tenant_id": _tenant_name(data.entity_id),
"entity_id": data.entity_id, "entity_id": data.entity_id,
@@ -54,14 +99,31 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
"switched_at": __import__("datetime").datetime.now().isoformat(), "switched_at": __import__("datetime").datetime.now().isoformat(),
} }
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg: if cfg:
cfg.config_value = json.dumps(state, ensure_ascii=False) cfg.config_value = json.dumps(state, ensure_ascii=False)
else: else:
db.add(SystemConfig( db.add(SystemConfig(
config_key=TENANT_KEY, config_key=TENANT_KEY,
config_value=json.dumps(state, ensure_ascii=False), config_value=json.dumps(state, ensure_ascii=False),
description="当前租户状态(CMA企业切换联动)", 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)。
db.add(OperationLog(
user_id=user.id,
action="switch_tenant_state",
target_type="entity",
target_id=data.entity_id,
detail={
"entity_id": data.entity_id,
"entity_name": ent.name,
"source": data.source,
"previous": previous,
"note": "current_tenant 状态记录变更(非数据维度)",
},
)) ))
db.commit() db.commit()
+135
View File
@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""预警「参与范围」口径 —— 单一事实源(财务裁定 R1/R2/R3,2026-09-19)
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`(任总 2026-09-18「按建议执行」)
- **R1 账套范围**:`entities.status = 'demo'` 的账套(演示账套)**不参与预警生成/遍历**
—— demo 仅用于演示展示,不产生业务预警。
- **R2 期间窗口**:预警遍历期间 = **当月往前 6 个自然月(含当月)**;更早期间**不再新生成**。
与裁定 2b(各 KPI 最近可用期间)叠加:`max(可用期间下限, 当月-6月)`。
- **R3 规整月期间**:仅 `YYYY-MM` 参与;`H1/H2/FY`、纯年份等伪期间**一律不参与**。
约定:所有预警生成入口(alert_generator / deviation_engine / kpi_bep_deriver /
alert_rules 生成端点 / cash_forecast_engine)**只调本模块的入口函数**,
禁止在调用点各写一份 `status == 'demo'` / 期间正则 / 窗口算术(避免多处漂移)。
"""
import logging
import re
from datetime import datetime
from typing import Optional
from app.models import Entity, KPIDefinition
logger = logging.getLogger("cma.alert_scope")
#: R1:不参与预警的账套状态
ALERT_EXCLUDED_ENTITY_STATUSES = ("demo",)
#: R2:期间窗口长度(含当月,6 = 当月 + 往前 5 个月)
ALERT_WINDOW_MONTHS = 6
#: R3:规整月期间(严格校验 01–12,`2026-13` 之类非法月同样不参与)
MONTH_PERIOD_RE = r"^[0-9]{4}-(?:0[1-9]|1[0-2])$"
_MONTH_PERIOD_RE = re.compile(MONTH_PERIOD_RE)
def excluded_entity_ids(db_session) -> set:
"""R1:返回不参与预警遍历的账套 id 集合(当前 status='demo' → entity 3/4/5/6)。
fail-open:读不到 entities 表(测试库/权限异常)时返回空集并记 WARNING——
口径缺失不应把预警主流程整条打断(生成链路另有 fail-safe)。
"""
try:
rows = db_session.query(Entity.id).filter(
Entity.status.in_(ALERT_EXCLUDED_ENTITY_STATUSES)
).all()
return {int(r[0]) for r in rows}
except Exception as e: # pragma: no cover - 防御分支
logger.warning("读取 entities.status=%s 失败,本次按「不排除任何账套」处理: %s",
list(ALERT_EXCLUDED_ENTITY_STATUSES), e)
return set()
def is_excluded_entity(db_session, entity_id: Optional[int]) -> bool:
"""R1:单个账套是否被排除(None → 按默认账套 1 处理,与 KPIAlert.entity_id 缺省一致)"""
return int(entity_id or 1) in excluded_entity_ids(db_session)
def kpi_excluded(db_session, kpi: KPIDefinition, excluded: Optional[set] = None) -> bool:
"""R1:KPI 归属账套是否被排除(可传入预取的 excluded 集合,避免循环内重复查询)"""
eids = excluded if excluded is not None else excluded_entity_ids(db_session)
return int(getattr(kpi, "entity_id", None) or 1) in eids
def window_lower_period(now: Optional[datetime] = None) -> str:
"""R2:期间窗口下限 = 当月往前 6 个自然月(含当月)→ 当月-5 月。
2026-09 → `2026-04`(窗口 = 2026-04 ~ 2026-09)。
"""
now = now or datetime.now()
months = now.month - (ALERT_WINDOW_MONTHS - 1)
year = now.year
while months <= 0:
months += 12
year -= 1
return "%04d-%02d" % (year, months)
def is_month_period(period) -> bool:
"""R3:是否规整月期间(YYYY-MM)"""
return bool(_MONTH_PERIOD_RE.fullmatch(str(period or "")))
def period_in_window(period, now: Optional[datetime] = None) -> bool:
"""R3 + R2:期间是否可参与预警生成(规整月 + 落在窗口内)"""
if not is_month_period(period):
return False
return str(period) >= window_lower_period(now)
# ── R5(裁定 2026-09-19,R3 的一致延伸):可归期原则 + 生成侧守卫 ──
def is_attributable_period(period) -> bool:
"""R5:期间是否「可归期」= 规整月 `YYYY-MM`。
`period IS NULL` 与 `2026-H1/H2/FY` 同属「不可归期」→ 不参与预警
(不可复核、不可归责、不可驱动行动)。
"""
return is_month_period(period)
def guard_alert_period(period, *, source: str = "", detail: str = "") -> bool:
"""R5 生成侧守卫:期间非规整月(含 None)→ 该 `kpi_alerts` 行**不入库**(跳过 + 记日志)。
返回 True = 放行(可入库);False = 跳过(**调用点必须不落库**)。
裁定原文:「任何生成路径产出的 kpi_alerts 行,period 非 YYYY-MM 一律不入库(跳过并记日志),防复发。」
"""
if is_attributable_period(period):
return True
logger.info(
"R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r source=%s detail=%s",
period, source, detail)
return False
def entity_alert_guard(db_session, entity_id, *, source: str = "", detail: str = "") -> bool:
"""R1 通用生成侧守卫:demo 账套在任何写通道上都不得产生业务预警。
「R1 与入口无关」——包含按显式 `entity_id` 写入的 Bot 通道(bot_bridge / bot_bridge_v2 等)。
返回 True = 放行;False = 跳过(原因 = R1 demo 账套排除,沿用 `excluded_entity_ids`)。
"""
if is_excluded_entity(db_session, entity_id):
logger.info(
"R1 账套守卫:跳过不入库(demo 演示账套不产生业务预警)entity_id=%s source=%s detail=%s",
entity_id, source, detail)
return False
return True
def scope_summary(db_session, now: Optional[datetime] = None) -> dict:
"""口径证据(写日志/报告用):被排除账套 + 窗口下限"""
return {
"excluded_entities": sorted(excluded_entity_ids(db_session)),
"window_lower_period": window_lower_period(now),
"window_months": ALERT_WINDOW_MONTHS,
}
+52 -8
View File
@@ -221,10 +221,38 @@ def calc_trend(values: list) -> float:
return slope / max(abs(avg_y), 1.0) * 100 # 趋势百分比 return slope / max(abs(avg_y), 1.0) * 100 # 趋势百分比
# ── B1 现金基线拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
# 背景:`SystemConfig.config_key` 为 unique 单行键 → `cash.current_balance` 全库仅一行,
# 所有账套共用;任一账套导入网银流水(`cash.py::_sync_cash_balance_from_ledger` 按 entity
# 算完余额后丢账套写回)即覆盖其他账套的预测基线。实测事故值 -9.3 被全账套共用。
#
# 治本(方案 A,零 DDL):键名带账套后缀 → `cash.current_balance.<entity_id>`。
# · 读:只读本账套键;本账套未设值 → None(由 KPI 实际值兜底,**不跨账套借用**)。
# 不做「回退旧全局键」——那正是本次污染通路(其余账套会读到别家的基线)。
# · 写:只写本账套键(调用方必须传会话 entity)。
# · 旧值搬运:一次性落到「当前生产账套」名下(脚本
# `scripts/migrate_cash_balance_key_entity_20260922.py`,--check/--apply/--down)。
# 回退路径:--down 删新键(旧全局键原样保留,回退老码即可继续读)。
CASH_BALANCE_KEY_PREFIX = "cash.current_balance"
def cash_balance_key(entity_id: int) -> str:
"""账套维度的现金基线键名:cash.current_balance.<entity_id>"""
return f"{CASH_BALANCE_KEY_PREFIX}.{int(entity_id)}"
def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]: def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
"""获取当前现金余额(万元)— 优先级:KPI实际值 > SystemConfig > None""" """获取当前现金余额(万元)— 优先级:KPI实际值 > 本账套SystemConfig键 > None
账套隔离(B1,2026-09-22):只读 `cash.current_balance.<entity_id>`。
本账套未设基线 → 返回 None(由 KPI 实际值兜底 / 预测引擎默认值),
**不回退旧全局键 `cash.current_balance`**——回退即恢复跨账套借用。
"""
from app.models import SystemConfig, KPIValue from app.models import SystemConfig, KPIValue
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"]) # C3(2026-09-19 dama-20260919):补传实体前缀后缀候选(与 F2 的应收/应付路径一致)
# 修复前漏传 → 博海/演示企业一条候选都不命中 → 回退 SystemConfig.cash.current_balance(全库唯一键 -9.3)
kpi = find_kpi(db, entity_id, KPI_CODE_CANDIDATES["cash_balance"],
KPI_CODE_SUFFIXES["cash_balance"])
if kpi: if kpi:
latest_cash = db.query(KPIValue).filter( latest_cash = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.kpi_id == kpi.id,
@@ -233,7 +261,7 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
if latest_cash and latest_cash.actual_value is not None: if latest_cash and latest_cash.actual_value is not None:
return float(latest_cash.actual_value) return float(latest_cash.actual_value)
cfg = db.query(SystemConfig).filter( cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == "cash.current_balance" SystemConfig.config_key == cash_balance_key(entity_id)
).first() ).first()
if cfg and cfg.config_value: if cfg and cfg.config_value:
try: try:
@@ -243,19 +271,27 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
return None return None
def set_current_cash_balance(db: Session, value: float) -> float: def set_current_cash_balance(db: Session, value: float, entity_id: int) -> float:
"""设置当前现金余额(写入SystemConfig,供预测引擎使用)""" """设置当前现金余额(写入本账套键 `cash.current_balance.<entity_id>`,供预测引擎使用)
entity_id 为**必传**(2026-09-22 B1):签名中不设默认值,强制调用方带上会话账套,
防「漏传 → 落默认账套」重演本次污染。全部调用点见 cash.py(POST /balance、
网银流水导入联动)。
"""
from app.models import SystemConfig from app.models import SystemConfig
if entity_id is None:
raise ValueError("set_current_cash_balance 必须传 entity_id(账套隔离,2026-09-22 B1)")
key = cash_balance_key(entity_id)
cfg = db.query(SystemConfig).filter( cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == "cash.current_balance" SystemConfig.config_key == key
).first() ).first()
if cfg: if cfg:
cfg.config_value = str(value) cfg.config_value = str(value)
else: else:
cfg = SystemConfig( cfg = SystemConfig(
config_key="cash.current_balance", config_key=key,
config_value=str(value), config_value=str(value),
description="当前现金余额(万元),资金预测基线", description=f"当前现金余额(万元),资金预测基线 — 账套 {int(entity_id)}",
) )
db.add(cfg) db.add(cfg)
db.commit() db.commit()
@@ -803,9 +839,17 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
(此前博海走无排序兜底 → 命中 uk_entity_kpi 索引必中 BH_ADM_EXPENSE(437 管理费用)) (此前博海走无排序兜底 → 命中 uk_entity_kpi 索引必中 BH_ADM_EXPENSE(437 管理费用))
F3 去重命中时刷新文本/等级/逾期天数/归属(此前直接 continue → 金额与天数永久冻结在首版) F3 去重命中时刷新文本/等级/逾期天数/归属(此前直接 continue → 金额与天数永久冻结在首版)
F4 余额快照批次行按开关处理(默认 skip,不再逐户生成伪"到期未收款") F4 余额快照批次行按开关处理(默认 skip,不再逐户生成伪"到期未收款")
R1(裁定 2026-09-19):demo 演示账套不产生业务预警 → 直接返回 skipped。
""" """
import json as _json import json as _json
from app.models import KPIAlert, CashPlan, KPIDefinition from app.models import KPIAlert, CashPlan, KPIDefinition
from app.utils.alert_entity_scope import is_excluded_entity
if is_excluded_entity(db, entity_id):
logger.info("资金预警检查跳过(demo 账套 R1): entity_id=%s", entity_id)
return {"entity_id": entity_id, "skipped": True, "reason": "R1_demo_entity_excluded",
"new_alerts": [], "refreshed": []}
result = forecast_cash_flow_with_plans(entity_id, db, days=30) result = forecast_cash_flow_with_plans(entity_id, db, days=30)
new_alerts = [] new_alerts = []
+17 -4
View File
@@ -158,11 +158,21 @@ def calc_driver_rate(activity_id: int) -> dict:
def allocate_cost(activity_id: int, period: str, product_code: str, def allocate_cost(activity_id: int, period: str, product_code: str,
product_name: str, driver_consumed: float) -> dict: product_name: str, driver_consumed: float,
"""按动因分配成本到产品""" entity_id: Optional[int] = None) -> dict:
"""按动因分配成本到产品
账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):
- entity_id 非 None 时,作业中心查询按本账套过滤(跨账套 activity_id 查不到 → 返回错误字典,
端点侧已先行 404);
- 新建 AbcAllocation 显式写入 entity_id(不传则维持原语义:不写归属,落模型默认)。
"""
db = get_session_local()() db = get_session_local()()
try: try:
act = db.query(AbcActivity).filter(AbcActivity.id == activity_id).first() q_act = db.query(AbcActivity).filter(AbcActivity.id == activity_id)
if entity_id is not None:
q_act = q_act.filter(AbcActivity.entity_id == entity_id)
act = q_act.first()
if not act or act.driver_rate == 0: if not act or act.driver_rate == 0:
# 自动计算分配率 # 自动计算分配率
if act and act.driver_volume > 0: if act and act.driver_volume > 0:
@@ -171,7 +181,7 @@ def allocate_cost(activity_id: int, period: str, product_code: str,
if not act or act.driver_rate == 0: if not act or act.driver_rate == 0:
return {"error": "分配率未设置"} return {"error": "分配率未设置"}
allocated = round(driver_consumed * act.driver_rate, 2) allocated = round(driver_consumed * act.driver_rate, 2)
alloc = AbcAllocation( alloc_kwargs = dict(
period=period, period=period,
activity_id=activity_id, activity_id=activity_id,
product_code=product_code, product_code=product_code,
@@ -179,6 +189,9 @@ def allocate_cost(activity_id: int, period: str, product_code: str,
driver_consumed=driver_consumed, driver_consumed=driver_consumed,
allocated_cost=allocated, allocated_cost=allocated,
) )
if entity_id is not None:
alloc_kwargs["entity_id"] = entity_id
alloc = AbcAllocation(**alloc_kwargs)
db.add(alloc) db.add(alloc)
db.commit() db.commit()
return { return {
+93 -9
View File
@@ -14,6 +14,11 @@ from typing import Optional
from app.database import get_session_local from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig from app.models import KPIDefinition, KPIValue, KPIAlert, BudgetPlan, SystemConfig
# 裁定 R1/R2/R3(2026-09-19 财务口径):demo 账套排除 / 期间窗口 / 规整月期间
from app.utils.alert_entity_scope import (
excluded_entity_ids, kpi_excluded, period_in_window, window_lower_period,
guard_alert_period, # R5-补1(2026-09-19):期间可归期 + 关联 kpi_values
)
logger = logging.getLogger("cma.deviation") logger = logging.getLogger("cma.deviation")
@@ -76,13 +81,46 @@ def get_budget_for_kpi(db, kpi_id: int, period: str, version: str = None) -> Opt
return plan.budget_value if plan else None return plan.budget_value if plan else None
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]: def get_kpi_value_row(db, kpi_id: int, period: str):
"""获取指定KPI在指定期间的实际值""" """取 (kpi_id, period) 的 KPIValue 行(取行口径唯一来源)。
val = db.query(KPIValue).filter(
与 get_actual_for_kpi 同源:预警行关联的 kpi_values 行 = 数值来源那一行。
"""
return db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id, KPIValue.kpi_id == kpi_id,
KPIValue.period == period, KPIValue.period == period,
).order_by(KPIValue.calculated_at.desc()).first() ).order_by(KPIValue.calculated_at.desc()).first()
return val.actual_value if val else None
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
"""获取指定KPI在指定期间的实际值"""
row = get_kpi_value_row(db, kpi_id, period)
return row.actual_value if row else None
def resolve_alert_kpi_value(db, kpi, period: str):
"""R5-补1(财务裁定 2026-09-19):解析差异/趋势预警行必须关联的 `kpi_values` 行。
裁定原文:「此类预警行必须在落库时写入 `kpi_value_id`,指向 **同 `entity_id` +
同 `kpi_id` + 文案所示规整月期间** 的 `kpi_values` 行。文案期间不可解析为 `YYYY-MM`,
或该 (kpi_id, period) 无 `kpi_values` 行 → 按 R5 `guard_alert_period` 跳过不入库
(不得以 `kpi_value_id=NULL` 落行——『文案里有期间』不构成可归期)。」
- 账套约束由 `kpi_id` 传递:KPIDefinition 归属单一账套,故 kpi_id 已唯一确定 entity_id;
取行口径与 `get_kpi_value_row`(= `get_actual_for_kpi` 的来源行)完全一致,
保证「预警关联的那一行」就是该预警数值的来源行,不会关联到别的账套/别的期间。
- 返回 `(kpi_value_row | None, skip_reason | None)`;跳过原因供调用点计数与日志。
"""
if not guard_alert_period(period, source="deviation_engine.resolve_alert_kpi_value",
detail="kpi=%s" % getattr(kpi, "kpi_code", "")):
return None, "R5_non_attributable_period"
row = get_kpi_value_row(db, kpi.id, period)
if row is None:
logger.info(
"R5-补1 期间关联:跳过不入库(该 (kpi, period) 无 kpi_values 行,期间不可派生)"
" kpi=%s period=%s", getattr(kpi, "kpi_code", ""), period)
return None, "no_kpi_value_row"
return row, None
def calc_period_deviation(db, kpi_id: int, period: str) -> dict: def calc_period_deviation(db, kpi_id: int, period: str) -> dict:
@@ -311,16 +349,37 @@ def build_deviation_alert(db, kpi, period: str, entity_id: int = 1, min_rate: fl
def run_deviation_check(db_session, period: str = None) -> int: def run_deviation_check(db_session, period: str = None) -> int:
"""运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)""" """运行差异预警检查,返回新增预警数(统一走 build_deviation_alert,P2-⑤)
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope`):
· R1 —— `status='demo'` 的账套不参与遍历(demo 仅演示,不产生业务预警);
· R2/R3 —— 期间须为规整月 `YYYY-MM` 且落在窗口(当月往前 6 个自然月,含当月)内。
· R5-补1 —— 差异/趋势分支**必须落 `kpi_value_id` 关联**(同 kpi 同期间的 `kpi_values` 行);
文案期间不可解析为 `YYYY-MM` 或该 (kpi, period) 无 `kpi_values` 行 → 跳过不入库;
趋势类以「计算基准期(趋势末端当期)」为关联期间;差异类去重键
= `(entity_id, kpi_id, kpi_value_id, alert_type)`。
"""
if period is None: if period is None:
period = datetime.now().strftime("%Y-%m") period = datetime.now().strftime("%Y-%m")
if not period_in_window(period):
logger.warning(" 差异预警跳过(R2/R3): 期间 %s 非规整月或早于窗口下限 %s",
period, window_lower_period())
return 0
excluded = excluded_entity_ids(db_session)
kpis = db_session.query(KPIDefinition).filter( kpis = db_session.query(KPIDefinition).filter(
KPIDefinition.status == "active" KPIDefinition.status == "active"
).all() ).all()
new_count = 0 new_count = 0
skipped_demo = 0
skipped_period_linkage = 0
skipped_linkage_kpis = []
for kpi in kpis: for kpi in kpis:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if kpi_excluded(db_session, kpi, excluded):
skipped_demo += 1
continue
# 1. 差异预警:实际 vs 预算(统一逻辑) # 1. 差异预警:实际 vs 预算(统一逻辑)
result = build_deviation_alert(db_session, kpi, period) result = build_deviation_alert(db_session, kpi, period)
if result["triggered"]: if result["triggered"]:
@@ -330,12 +389,22 @@ def run_deviation_check(db_session, period: str = None) -> int:
f"vs 预算{deviation['budget_value']}," f"vs 预算{deviation['budget_value']},"
f"差异率{deviation['deviation_rate']}%" f"差异率{deviation['deviation_rate']}%"
) )
alert_type = result["alert_type"] or "actual"
# 检查是否已有同KPI同期间的差异预警 # R5-补1(裁定 2026-09-19):期间可归期 + 必须落 kpi_value_id 关联
#(文案里的期间不构成可归期;无 kpi_values 行 → 跳过不入库)
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, period)
if linked_kv is None:
skipped_period_linkage += 1
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, period))
else:
# 去重键(R5-补1 变更):(entity_id, kpi_id, kpi_value_id, alert_type)
#(原按 `alert_message contains period` 判断,改关联后按可归期目标去重)
existing = db_session.query(KPIAlert).filter( existing = db_session.query(KPIAlert).filter(
KPIAlert.entity_id == (kpi.entity_id or 1),
KPIAlert.kpi_id == kpi.id, KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[差异预警]"), KPIAlert.kpi_value_id == linked_kv.id,
KPIAlert.alert_message.contains(period), KPIAlert.alert_type == alert_type,
KPIAlert.status.in_(["pending", "processing"]), KPIAlert.status.in_(["pending", "processing"]),
).first() ).first()
@@ -343,9 +412,10 @@ def run_deviation_check(db_session, period: str = None) -> int:
alert = KPIAlert( alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套) entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
kpi_id=kpi.id, kpi_id=kpi.id,
kpi_value_id=linked_kv.id, # R5-补1: 期间经该行可派生(可归期)
alert_level=result["kpi_alert_level"], alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}", alert_message=f"[差异预警] {alert_msg}",
alert_type=result["alert_type"] or "actual", alert_type=alert_type,
suggestion=result["suggestion"], suggestion=result["suggestion"],
status="pending", status="pending",
) )
@@ -357,6 +427,14 @@ def run_deviation_check(db_session, period: str = None) -> int:
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3) trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"): if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
trend_alert_msg = trend["message"] trend_alert_msg = trend["message"]
# R5-补1:趋势类以「计算基准期(趋势末端当期)」为关联期间;该期无行 → 跳过
trend_periods = trend.get("periods") or []
base_period = trend_periods[-1] if trend_periods else period
linked_kv, _skip_reason = resolve_alert_kpi_value(db_session, kpi, base_period)
if linked_kv is None:
skipped_period_linkage += 1
skipped_linkage_kpis.append("%s[%s]" % (kpi.kpi_code, base_period))
else:
existing_trend = db_session.query(KPIAlert).filter( existing_trend = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id, KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[趋势预警]"), KPIAlert.alert_message.contains("[趋势预警]"),
@@ -367,6 +445,7 @@ def run_deviation_check(db_session, period: str = None) -> int:
alert = KPIAlert( alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属 entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id, kpi_id=kpi.id,
kpi_value_id=linked_kv.id, # R5-补1: 趋势末端当期关联
alert_level=trend["level"], alert_level=trend["level"],
alert_message=f"[趋势预警] {trend_alert_msg}", alert_message=f"[趋势预警] {trend_alert_msg}",
status="pending", status="pending",
@@ -375,6 +454,11 @@ def run_deviation_check(db_session, period: str = None) -> int:
new_count += 1 new_count += 1
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}") logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
if skipped_demo:
logger.info(" 差异预警跳过 demo 账套 KPI %s 项(R1,裁定 2026-09-19)", skipped_demo)
if skipped_period_linkage:
logger.info(" 差异/趋势预警跳过(R5-补1 期间不可归期或无 kpi_values 行)KPI %s 项: %s",
skipped_period_linkage, skipped_linkage_kpis)
db_session.commit() db_session.commit()
return new_count return new_count
+174 -11
View File
@@ -15,11 +15,16 @@ from datetime import datetime, timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import get_session_local from app.database import get_session_local
from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan from app.models import KPIDefinition, KPIValue, KPIAlert, ActionPlan, OperationLog
from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY from app.utils.threshold_parser import eval_threshold, LEVEL_PRIORITY
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100), # 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
# 未核对/疑似单位错配的项降级待核(不落预警、不推送) # 未核对/疑似单位错配的项降级待核(不落预警、不推送)
from app.utils.caliber_units import resolve_caliber from app.utils.caliber_units import resolve_caliber
# 裁定 R1/R2/R3(2026-09-19 财务口径):账套范围(demo 排除)/ 期间窗口 / 规整月期间
from app.utils.alert_entity_scope import (
ALERT_WINDOW_MONTHS, excluded_entity_ids, is_month_period, kpi_excluded,
period_in_window, window_lower_period,
)
logger = logging.getLogger("cma.alert_gen") logger = logging.getLogger("cma.alert_gen")
@@ -85,8 +90,55 @@ def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
return True, msg return True, msg
def action_plan_entity_guard(db_session, *, kpi, alert, stats: Optional[dict] = None) -> bool:
"""写前同源校验(2026-09-22 派生单 B4)—— 建行动计划的账套归属门。
口径照抄同族守卫姿势(`app/api/bot_bridge_v2.py` 的 R1 账套守卫 / B3(b) `skip_alert` 留痕):
· 来源账套 = KPI 定义归属(`kpi.entity_id`);
· 目标账套 = **同一次事务里刚建的预警归属**(`alert.entity_id`,即本次遍历账套);
· 两者都存在且相等 → 返回 True(调用方建计划并**显式写** `entity_id=kpi.entity_id`);
· 任一为空 / 不相等 → 返回 False:**不建计划**、落 `OperationLog(action="skip_plan")` 留痕、
计入统计输出;**不 raise、不中断流水线**(预警与后续 KPI 照常处理)。
为什么必须设门而不是「照传 None」:`action_plans.entity_id` 为 NOT NULL DEFAULT 1
(模型 `default=1` 与生产 DDL 双默认)——传空值会被**静默兜底到账套 1**,
正是「酣客(e1) 工作台渲染博海(e2)/演示(e6) KPI 名」的成因(同事务内预警归属正确、
计划归属落 1,只有 KPI 不属于 e1 时暴露)。
"""
kpi_entity_id = getattr(kpi, "entity_id", None)
target_entity_id = getattr(alert, "entity_id", None)
if (kpi_entity_id is not None and target_entity_id is not None
and int(kpi_entity_id) == int(target_entity_id)):
return True
st = stats if stats is not None else {}
st["skipped_plan_entity_mismatch"] = st.get("skipped_plan_entity_mismatch", 0) + 1
st.setdefault("skipped_plan_kpis", []).append(
"%s[kpi_e=%s→plan_e=%s]" % (getattr(kpi, "kpi_code", None), kpi_entity_id, target_entity_id))
db_session.add(OperationLog(
user_id=None,
action="skip_plan",
target_type="action_plan",
target_id=getattr(alert, "id", None),
detail={
"reason": "plan_entity_attribution_mismatch",
"kpi_id": getattr(kpi, "id", None),
"kpi_code": getattr(kpi, "kpi_code", None),
"kpi_entity_id": kpi_entity_id,
"entity_id": target_entity_id,
"alert_id": getattr(alert, "id", None),
"source": "scripts.alert_generator.run_alert_check",
},
))
logger.warning(
"B4 账套守卫:跳过建计划(KPI 归属=%s / 目标账套=%s 不一致或为空;"
"不再静默落默认账套 1)kpi_code=%s alert_id=%s",
kpi_entity_id, target_entity_id, getattr(kpi, "kpi_code", None), getattr(alert, "id", None))
return False
def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = False, def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = False,
stats: Optional[dict] = None) -> int: stats: Optional[dict] = None, now: Optional[datetime] = None) -> int:
"""检查所有KPI的实际值是否触发预警,返回(将)新生成的预警数 """检查所有KPI的实际值是否触发预警,返回(将)新生成的预警数
2026-09-17 财务口径裁定 2a/2b: 2026-09-17 财务口径裁定 2a/2b:
@@ -95,12 +147,35 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
· **单位(2a)**:判定前经 resolve_caliber() 归一到「百分比(%)」口径(取数×100, · **单位(2a)**:判定前经 resolve_caliber() 归一到「百分比(%)」口径(取数×100,
与阈值 150/80 同量纲);**未核对/疑似单位错配**的项降级待核 → 不落预警、不推送; 与阈值 150/80 同量纲);**未核对/疑似单位错配**的项降级待核 → 不落预警、不推送;
· dry_run=True → 只计算不写库(抽查复核与放行前证据用)。 · dry_run=True → 只计算不写库(抽查复核与放行前证据用)。
2026-09-19 财务口径裁定 R1/R2/R3(`alert_entity_scope` 单一事实源):
· **R1 账套**:`status='demo'` 的账套(演示账套)不参与遍历 → 跳过并计
`skipped_demo_kpis`;
· **R2 期间窗口**:仅生成「当月往前 6 个自然月(含当月)」内的预警;更早期间跳过
(`out_of_window`),与 2b 叠加 = max(各 KPI 最近可用期间, 当月-6月);
· **R3 伪期间**:仅规整月期间 YYYY-MM 参与,H1/H2/FY 跳过(`skipped_pseudo_period`)。
· now 参数仅用于口径复算/测试(缺省=系统当前时间)。
2026-09-22(本单·第 4 处写入点):red/yellow 自动建计划前经 `action_plan_entity_guard`
写前同源校验,并**显式写** `ActionPlan.entity_id = kpi.entity_id` —— 修复前不传归属、
由模型 default=1 / 生产 DDL DEFAULT 1 静默兜底,导致非 e1 账套的 KPI 触发时落跨账套计划。
""" """
st = stats if stats is not None else {} st = stats if stats is not None else {}
window_lower = window_lower_period(now)
excluded_eids = excluded_entity_ids(db_session)
for k, v in (("period_source", "fixed" if period else "per_kpi_latest"), ("period_fixed", period), for k, v in (("period_source", "fixed" if period else "per_kpi_latest"), ("period_fixed", period),
("periods_used", {}), ("normalized", 0), ("normalized_kpis", []), ("periods_used", {}), ("normalized", 0), ("normalized_kpis", []),
("dimension_suspect", 0), ("suspect_kpis", []), ("no_period", 0), ("dimension_suspect", 0), ("suspect_kpis", []), ("no_period", 0),
("no_value", 0), ("planned", []), ("skipped_existing", 0)): ("no_value", 0), ("planned", []), ("skipped_existing", 0),
# R1/R2/R3 口径证据
("excluded_demo_entities", sorted(excluded_eids)),
("window_lower_period", window_lower),
("window_months", ALERT_WINDOW_MONTHS),
("skipped_demo_kpis", 0), ("demo_kpis", []),
("skipped_pseudo_period", 0), ("pseudo_period_kpis", []),
("out_of_window", 0), ("out_of_window_kpis", []),
# 2026-09-22 计划归属守卫(B4)证据
("skipped_plan_entity_mismatch", 0), ("skipped_plan_kpis", [])):
st.setdefault(k, v) st.setdefault(k, v)
kpis = db_session.query(KPIDefinition).filter( kpis = db_session.query(KPIDefinition).filter(
@@ -109,6 +184,12 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
new_count = 0 new_count = 0
for kpi in kpis: for kpi in kpis:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if kpi_excluded(db_session, kpi, excluded_eids):
st["skipped_demo_kpis"] += 1
st["demo_kpis"].append("%s/e%s" % (kpi.kpi_code, kpi.entity_id or 1))
continue
# 跳过无阈值的KPI # 跳过无阈值的KPI
thr = { thr = {
"red": kpi.threshold_red, "red": kpi.threshold_red,
@@ -125,6 +206,21 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
logger.warning(f" 跳过(无可用期间): {kpi.kpi_name} 无任何 YYYY-MM 实测值") logger.warning(f" 跳过(无可用期间): {kpi.kpi_name} 无任何 YYYY-MM 实测值")
continue continue
# R3(裁定 2026-09-19):仅规整月期间 YYYY-MM 参与,伪期间一律不参与
if not is_month_period(p):
st["skipped_pseudo_period"] += 1
st["pseudo_period_kpis"].append("%s[%s]" % (kpi.kpi_code, p))
logger.warning(" 跳过(伪期间 R3): %s 期间=%s 非 YYYY-MM" % (kpi.kpi_name, p))
continue
# R2(裁定 2026-09-19):期间窗口 = 当月往前 6 个自然月(含当月),更早不新生成
if str(p) < window_lower:
st["out_of_window"] += 1
st["out_of_window_kpis"].append("%s[%s]" % (kpi.kpi_code, p))
logger.info(" 跳过(期间超出窗口 R2,窗口下限 %s): %s[%s]" % (
window_lower, kpi.kpi_name, p))
continue
# 获取该KPI该期间的最新值 # 获取该KPI该期间的最新值
latest = db_session.query(KPIValue).filter( latest = db_session.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id, KPIValue.kpi_id == kpi.id,
@@ -214,7 +310,13 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
plan_title = f"渠补谈判: {msg} —— 下游渠道返利谈判" plan_title = f"渠补谈判: {msg} —— 下游渠道返利谈判"
elif kpi.kpi_code == "F_COST_RATIO": elif kpi.kpi_code == "F_COST_RATIO":
plan_title = f"费用压缩: {msg} —— 管理费削减计划" plan_title = f"费用压缩: {msg} —— 管理费削减计划"
# 2026-09-22(本单主修·第 4 处写入点):写前同源校验 + 显式写归属。
# 修复前构造 ActionPlan 不传 entity_id → 模型 default=1 / DDL DEFAULT 1
# 静默兜底,非 e1 账套的 KPI 触发即产生「预警归属正确、计划归属落 1」的
# 跨账套计划(酣客工作台渲染博海/demo KPI 名的唯一活写入点)。
if action_plan_entity_guard(db_session, kpi=kpi, alert=alert, stats=st):
plan = ActionPlan( plan = ActionPlan(
entity_id=kpi.entity_id, # 归属唯一事实源:来源 KPI 的账套
alert_id=alert.id, alert_id=alert.id,
kpi_id=kpi.id, kpi_id=kpi.id,
title=plan_title, title=plan_title,
@@ -261,7 +363,8 @@ def run_rule_engine_checks(db_session) -> dict:
AlertRule, run_forecast_deviation_check, run_customer_concentration_check, AlertRule, run_forecast_deviation_check, run_customer_concentration_check,
) )
stats = {"forecast_deviation": 0, "customer_concentration": 0} stats = {"forecast_deviation": 0, "customer_concentration": 0,
"skipped_demo_entities": 0, "excluded_entities": sorted(excluded_entity_ids(db_session))}
try: try:
rows = db_session.query(AlertRule.entity_id).filter( rows = db_session.query(AlertRule.entity_id).filter(
AlertRule.enabled == 1, AlertRule.enabled == 1,
@@ -272,6 +375,11 @@ def run_rule_engine_checks(db_session) -> dict:
return stats return stats
for (eid,) in rows: for (eid,) in rows:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if int(eid or 1) in set(stats["excluded_entities"]):
stats["skipped_demo_entities"] += 1
logger.info(f" 规则引擎跳过(demo 账套 R1): entity={eid}")
continue
for key, fn in (("forecast_deviation", run_forecast_deviation_check), for key, fn in (("forecast_deviation", run_forecast_deviation_check),
("customer_concentration", run_customer_concentration_check)): ("customer_concentration", run_customer_concentration_check)):
try: try:
@@ -285,24 +393,42 @@ def run_rule_engine_checks(db_session) -> dict:
return stats return stats
def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool = False) -> dict: def _enabled_channel_count(db_session) -> int:
"""R4(裁定 2026-09-19):推送出口计数 —— 生成/推送解耦,空通道只记录不报错。"""
from app.models import NotificationChannel
try:
return int(db_session.query(NotificationChannel).filter(
NotificationChannel.enabled == True # noqa: E712 - 与其他调用点一致
).count())
except Exception as e: # pragma: no cover - 防御分支
logger.warning(f"读取通知渠道失败(按无渠道处理): {e}")
return 0
def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool = False,
now: Optional[datetime] = None) -> dict:
"""生成预警并推送,返回统计 """生成预警并推送,返回统计
2026-09-17 财务口径裁定 2a/2b: 2026-09-17 财务口径裁定 2a/2b:
· **period=None** → 判定期间 = 各 KPI「最近可用期间」(裁定2b,不再硬编码当月); · **period=None** → 判定期间 = 各 KPI「最近可用期间」(裁定2b,不再硬编码当月);
显式给出 period → 固定该期间(抽查复核用); 显式给出 period → 固定该期间(抽查复核用);
· **dry_run=True** → 零写入、零推送:返回将生成的预警清单与口径证据(放行前抽查复核)。 · **dry_run=True** → 零写入、零推送:返回将生成的预警清单与口径证据(放行前抽查复核)。
2026-09-19 裁定 R1–R4:
· R1/R2/R3 在 run_alert_check 内落地(demo 账套排除 / 期间窗口 / 规整月期间);
· **R4 生成与推送解耦**:`notification_channels` 为空时仅入库、不报错、不阻断;
""" """
cur_month = datetime.now().strftime("%Y-%m") cur_month = (now or datetime.now()).strftime("%Y-%m")
st = {} st = {}
logger.info("开始预警检查(当月=%s;判定期间=%s)..." % ( logger.info("开始预警检查(当月=%s;判定期间=%s;窗口下限=%s;排除账套=%s)..." % (
cur_month, ("固定 %s" % period) if period else "各 KPI 最近可用期间(裁定2b)")) cur_month, ("固定 %s" % period) if period else "各 KPI 最近可用期间(裁定2b)",
window_lower_period(now), sorted(excluded_entity_ids(db_session))))
# 0. Bug1修复: 先清理历史green预警(dry-run 不写) # 0. Bug1修复: 先清理历史green预警(dry-run 不写)
cleaned = 0 if dry_run else cleanup_green_alerts(db_session) cleaned = 0 if dry_run else cleanup_green_alerts(db_session)
# 1. 生成预警(期间口径 + 单位口径均在此落地) # 1. 生成预警(期间口径 + 单位口径均在此落地)
new_count = run_alert_check(db_session, period=period, dry_run=dry_run, stats=st) new_count = run_alert_check(db_session, period=period, dry_run=dry_run, stats=st, now=now)
logger.info(f"预警检查完成: {'将新增' if dry_run else '新增'} {new_count} 条") logger.info(f"预警检查完成: {'将新增' if dry_run else '新增'} {new_count} 条")
# 1a. 覆盖度自检(2026-09-16 项3;2026-09-17 裁定2b 过渡期保留并扩展「期间回落」明细): # 1a. 覆盖度自检(2026-09-16 项3;2026-09-17 裁定2b 过渡期保留并扩展「期间回落」明细):
@@ -338,19 +464,29 @@ def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool =
else run_rule_engine_checks(db_session) else run_rule_engine_checks(db_session)
# 2. 推送(dry-run 零推送) # 2. 推送(dry-run 零推送)
# R4(裁定 2026-09-19):生成与推送解耦 —— notification_channels 为空时
# **仅入库、不报错、不阻断**(推送出口缺失只是"未触达",不等于"未生成")。
channels_enabled = _enabled_channel_count(db_session)
push_skipped_no_channel = channels_enabled == 0
pushed = 0 pushed = 0
if new_count > 0 and not dry_run: if new_count > 0 and not dry_run:
if push_skipped_no_channel:
logger.info("推送出口缺失(notification_channels 启用数=0)→ 仅入库、不推送、不阻断(裁定 R4)")
else:
try: try:
from app.utils.notifier import push_pending_alerts from app.utils.notifier import push_pending_alerts
pushed = push_pending_alerts(db_session) pushed = push_pending_alerts(db_session)
logger.info(f"推送完成: {pushed} 条") logger.info(f"推送完成: {pushed} 条")
except Exception as e: except Exception as e:
logger.error(f"推送失败: {e}") logger.error(f"推送失败(不影响已入库预警,生成/推送解耦 R4): {e}")
return {"period": cur_month, "dry_run": dry_run, return {"period": cur_month, "dry_run": dry_run,
"period_source": st.get("period_source"), "period_fixed": st.get("period_fixed"), "period_source": st.get("period_source"), "period_fixed": st.get("period_fixed"),
"periods_used": st.get("periods_used", {}), "periods_used": st.get("periods_used", {}),
"new_alerts": new_count, "pushed": pushed, "new_alerts": new_count, "pushed": pushed,
# R4:推送出口(生成/推送解耦证据)
"channels_enabled": channels_enabled,
"push_skipped_no_channel": push_skipped_no_channel,
"cleaned_green": cleaned, "rule_engine": rule_stats, "cleaned_green": cleaned, "rule_engine": rule_stats,
"coverage": coverage, "coverage": coverage,
# 裁定2a 口径证据 # 裁定2a 口径证据
@@ -358,7 +494,17 @@ def generate_and_push(db_session, period: Optional[str] = None, dry_run: bool =
"dimension_suspect": st.get("dimension_suspect", 0), "suspect_kpis": st.get("suspect_kpis", []), "dimension_suspect": st.get("dimension_suspect", 0), "suspect_kpis": st.get("suspect_kpis", []),
# 期间/取值覆盖明细 # 期间/取值覆盖明细
"no_period": st.get("no_period", 0), "no_value": st.get("no_value", 0), "no_period": st.get("no_period", 0), "no_value": st.get("no_value", 0),
"skipped_existing": st.get("skipped_existing", 0), "planned": st.get("planned", [])} "skipped_existing": st.get("skipped_existing", 0), "planned": st.get("planned", []),
# 裁定 R1/R2/R3 口径证据
"excluded_demo_entities": st.get("excluded_demo_entities", []),
"skipped_demo_kpis": st.get("skipped_demo_kpis", 0),
"window_lower_period": st.get("window_lower_period"), "window_months": st.get("window_months"),
"out_of_window": st.get("out_of_window", 0), "out_of_window_kpis": st.get("out_of_window_kpis", []),
"skipped_pseudo_period": st.get("skipped_pseudo_period", 0),
"pseudo_period_kpis": st.get("pseudo_period_kpis", []),
# 2026-09-22 计划归属守卫(B4):归属不一致/为空 → 跳过建计划的留痕计数
"skipped_plan_entity_mismatch": st.get("skipped_plan_entity_mismatch", 0),
"skipped_plan_kpis": st.get("skipped_plan_kpis", [])}
def coverage_note(result: dict) -> str: def coverage_note(result: dict) -> str:
@@ -380,6 +526,23 @@ def coverage_note(result: dict) -> str:
% (result["dimension_suspect"], (result.get("suspect_kpis") or [])[:3])) % (result["dimension_suspect"], (result.get("suspect_kpis") or [])[:3]))
if result.get("normalized"): if result.get("normalized"):
parts.append(" | 口径归一 ×100 %s 项" % result["normalized"]) parts.append(" | 口径归一 ×100 %s 项" % result["normalized"])
# 裁定 R1/R2/R3/R4(2026-09-19)证据
if result.get("skipped_demo_kpis"):
parts.append(" | R1 排除 demo 账套 %s(跳过 KPI %s 项)"
% (result.get("excluded_demo_entities"), result["skipped_demo_kpis"]))
if result.get("out_of_window"):
parts.append(" | R2 窗口(下限 %s)外不新生成 %s 项"
% (result.get("window_lower_period"), result["out_of_window"]))
if result.get("skipped_pseudo_period"):
parts.append(" | R3 伪期间跳过 %s 项" % result["skipped_pseudo_period"])
if result.get("push_skipped_no_channel"):
parts.append(" | R4 推送出口缺失(启用渠道 %s) → 仅入库、不阻断"
% result.get("channels_enabled"))
# 2026-09-22 计划归属守卫(B4):跨账套计划不再静默落默认账套 1
if result.get("skipped_plan_entity_mismatch"):
parts.append(" | B4 计划归属守卫:跳过建计划 %s 项 %s(KPI 归属与目标账套不一致/为空)"
% (result["skipped_plan_entity_mismatch"],
(result.get("skipped_plan_kpis") or [])[:3]))
return "".join(parts) return "".join(parts)
+7 -1
View File
@@ -38,6 +38,8 @@ import re
from sqlalchemy import func from sqlalchemy import func
from app.database import get_session_local from app.database import get_session_local
from app.models import KPIValue, KPIDefinition, VoucherDetail, KPIAlert from app.models import KPIValue, KPIDefinition, VoucherDetail, KPIAlert
# 裁定 R1(2026-09-19 财务口径):demo 账套不产生业务预警(派生值仍落库,仅跳过预警同步)
from app.utils.alert_entity_scope import is_excluded_entity
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("cma.bep_deriver") logger = logging.getLogger("cma.bep_deriver")
@@ -320,7 +322,11 @@ def run_derive(db, entity_id: int, period: str, rebate_rate_pct: float,
# 5. 同步预警: 仅对本派生KPI评估 threshold(green=正常不落 pending,yellow/red 落 pending)。 # 5. 同步预警: 仅对本派生KPI评估 threshold(green=正常不落 pending,yellow/red 落 pending)。
# 幂等规则:同一 KPIValue 已有同级别 pending → 保持不动(值未变不重建); # 幂等规则:同一 KPIValue 已有同级别 pending → 保持不动(值未变不重建);
# 其它/旧级别 pending → resolve;最新派生行永远只留一条当前级别。 # 其它/旧级别 pending → resolve;最新派生行永远只留一条当前级别。
for code, _ in TARGET_KPIS: # R1(裁定 2026-09-19):demo 账套不产生业务预警 → 整段预警同步跳过(派生值照落库)。
target_sync = [] if is_excluded_entity(db, entity_id) else TARGET_KPIS
if not target_sync:
logger.info("demo 账套(entity=%s)跳过预警同步(R1,裁定 2026-09-19)", entity_id)
for code, _ in target_sync:
kpi = kpis[code] kpi = kpis[code]
kpi_value = value_rows.get(kpi.id) kpi_value = value_rows.get(kpi.id)
if not kpi_value: if not kpi_value:
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""全局键拆账套 — 一次性搬运(数据迁移,非 DDL)
任务:`dama-workbench-entity-isolation-fullstack-20260922` §三 B1/B3(a)
(DAMA 诊断 / 项目Bot batch5 §B 方案段 / 任总 2026-09-22「都批」)
背景
----
`system_configs.config_key` 与 `role_permissions.key` 均为 **unique 单行键** ⇒
下列两个「全局键」全库只有一行,被所有账套共用(本次「酣客工作台显示博海数据」
污染的同源结构性缺陷):
键 处置
------------------------------- ------------------------------------------
system_configs.cash.current_balance → cash.current_balance.<entity_id>
role_permissions.alignment_config → alignment_config.<entity_id>
代码侧(读写)已改为**只认带账套后缀的新键**(方案 A,零 DDL):
backend/app/utils/cash_forecast_engine.py (get_/set_current_cash_balance)
backend/app/api/cash.py (POST /balance、网银流水联动)
backend/app/api/alignment.py (GET/POST /config、GET /tree)
本脚本 = **旧值一次性搬运**(把原来唯一的那一行值落到「当前生产账套」名下)。
「当前生产账套」判定:默认 `--target-entity 1`(entity 1 = 陕西酣客,生产账套),
`--check` 会打印**逐账套凭证推导的货币资金期末余额**与旧键值的比对,供人工确认
(哪个账套的 1001/1002 期末余额 == 旧键值 ⇒ 该值本来就是它写的)。
口径(须与代码注释一致)
------------------------
· 新键值 = 旧键值(一次性搬运)**只落到 target-entity**;**其余账套留空**
⇒ 留空语义 = 该账套现金基线走 KPI 实际值兜底 / 预测引擎默认值,
**不跨账套借用**(正是本次污染的成因)。
· 旧键**原样保留不删**(回退杠杆:回退老代码即可继续读旧键)。
· alignment_config 同理:旧全局值落到 target-entity;其余账套 configured=false。
· 幂等:新键已存在 → 不覆盖(除非 `--force`);二跑 `--apply` 零副作用。
用法
----
# 只读预检(默认动作,零写入)
python3 scripts/migrate_cash_balance_key_entity_20260922.py --check
# 写库(**需审批 / 生产写**;--apply 才写)
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --target-entity 2
python3 scripts/migrate_cash_balance_key_entity_20260922.py --apply --force # 覆盖已存在的新键
# 回退(删新键 + 把旧全局键写回原值)
python3 scripts/migrate_cash_balance_key_entity_20260922.py --down
选项
----
--target-entity N 旧值落哪个账套(默认 1;--check 会给出证据建议)
--force 新键已存在时允许覆盖(默认拒绝,保留既有值)
--json 机器可读输出
--allow-db NAME 生产库名保护豁免(默认只允许库名 `cma`)
退出码
------
0 成功 | 1 执行失败 | 2 用法/保护拒绝(库名不符、目标账套不存在)
⚠️ 本脚本写的是**生产业务库**:不得在开发级会话直接 `--apply`。
依据《生产资产派单纪律》(skills-shared/operations/prod-asset-dispatch-discipline):
碰生产资产一律走派单 + 审批;`--check` 同样是生产读,须留单痕。
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text # noqa: E402
CASH_KEY = "cash.current_balance" # 旧全局键(保留不删)
ALIGN_KEY = "alignment_config" # 旧全局键(保留不删)
PROD_DB_NAME = "cma" # 生产库名(保护口径)
CASH_DESC = "当前现金余额(万元),资金预测基线 — 账套 {eid}"
ALIGN_DESC = "对齐模式配置 — 账套 {eid}(B3(a) 拆账套 2026-09-22)"
def _engine():
from app.database import get_engine
return get_engine()
def _scalar(conn, sql, **params):
row = conn.execute(text(sql), params).fetchone()
return row[0] if row else None
def _detect_db_name(conn) -> str:
try:
return _scalar(conn, "SELECT DATABASE()") or ""
except Exception:
return ""
def _cash_ledger_by_entity(conn):
"""逐账套:货币资金(1001/1002, carry_forward=0) 期末净额(元 → 万元)
与 `cash.py::_sync_cash_balance_from_ledger` 同口径(该函数正是写旧键的调用方)
——用于回答「-9.3 这个值原本是哪家写的」。
"""
out = {}
try:
rows = conn.execute(text(
"SELECT entity_id, "
" ROUND(SUM(COALESCE(debit_amount,0) - COALESCE(credit_amount,0))/10000, 4) AS wan, "
" COUNT(*) AS n "
"FROM voucher_details "
"WHERE carry_forward = 0 AND subject_code IS NOT NULL "
" AND (subject_code LIKE '1001%' OR subject_code LIKE '1002%') "
"GROUP BY entity_id ORDER BY entity_id"
)).fetchall()
for r in rows:
out[int(r[0])] = {"cash_wan": float(r[1] or 0), "voucher_rows": int(r[2] or 0)}
except Exception as e: # 表缺失/权限 → 只报告,不阻断
out["_error"] = str(e)[:300]
return out
def _snapshot(conn):
"""只读快照:新旧键现状 + 逐账套凭证推导余额"""
snap = {
"db": _detect_db_name(conn),
"legacy": {
"cash.current_balance": None,
"alignment_config": None,
},
"new_keys": {"cash": {}, "alignment": {}},
"entities": [],
"cash_ledger_by_entity": _cash_ledger_by_entity(conn),
}
row = conn.execute(text(
"SELECT config_value, description FROM system_configs WHERE config_key = :k"
), {"k": CASH_KEY}).fetchone()
if row:
snap["legacy"]["cash.current_balance"] = {"config_value": row[0], "description": row[1]}
row = conn.execute(text(
"SELECT value FROM role_permissions WHERE `key` = :k"
), {"k": ALIGN_KEY}).fetchone()
if row:
snap["legacy"]["alignment_config"] = {"value": row[0]}
for r in conn.execute(text(
"SELECT id, name, short_name, status FROM entities ORDER BY id"
)).fetchall():
snap["entities"].append({"id": int(r[0]), "name": r[1], "short_name": r[2], "status": r[3]})
for r in conn.execute(text(
"SELECT config_key, config_value FROM system_configs "
"WHERE config_key LIKE :p ORDER BY config_key"
), {"p": CASH_KEY + ".%"}).fetchall():
snap["new_keys"]["cash"][r[0]] = r[1]
for r in conn.execute(text(
"SELECT `key`, value FROM role_permissions WHERE `key` LIKE :p ORDER BY `key`"
), {"p": ALIGN_KEY + ".%"}).fetchall():
snap["new_keys"]["alignment"][r[0]] = r[1]
# 建议目标账套(只读推断):旧值 == 某账套凭证推导余额
suggestion = None
legacy_cash = (snap["legacy"]["cash.current_balance"] or {}).get("config_value")
if legacy_cash is not None:
try:
want = float(legacy_cash)
for eid, info in snap["cash_ledger_by_entity"].items():
if isinstance(eid, int) and abs(float(info["cash_wan"]) - want) < 1e-6:
suggestion = eid
break
except Exception:
suggestion = None
snap["suggested_target_entity"] = suggestion
return snap
def cmd_check(args):
eng = _engine()
with eng.connect() as conn:
snap = _snapshot(conn)
if args.json:
print(json.dumps(snap, ensure_ascii=False, indent=2))
return 0
print(f"目标库: {snap['db']}")
print(f"旧全局键 cash.current_balance : {snap['legacy']['cash.current_balance']}")
print(f"旧全局键 alignment_config : {snap['legacy']['alignment_config']}")
print(f"已存在新键(cash) : {snap['new_keys']['cash']}")
print(f"已存在新键(alignment) : {snap['new_keys']['alignment']}")
print("逐账套凭证推导货币资金期末余额(万元):")
for eid, info in snap["cash_ledger_by_entity"].items():
print(f" entity {eid}: {info}")
print(f"建议 --target-entity : {snap['suggested_target_entity']}"
f"(旧值与他家凭证推导余额一致时给出;None = 无匹配,按默认 1 或人工裁定)")
print("【只读预检,零写入】")
return 0
def _apply_cash(conn, target: int, force: bool, dry: bool):
new_key = f"{CASH_KEY}.{target}"
row = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": CASH_KEY}).fetchone()
if not row:
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
legacy_value = row[0]
exist = conn.execute(text(
"SELECT config_value FROM system_configs WHERE config_key = :k"
), {"k": new_key}).fetchone()
if exist and not force:
return {"key": new_key, "action": "noop",
"reason": "新键已存在且未 --force(保留既有值,幂等)",
"existing_value": exist[0]}
if dry:
return {"key": new_key, "action": "would-insert-or-update",
"value": legacy_value, "existing_value": exist[0] if exist else None}
if exist:
conn.execute(text(
"UPDATE system_configs SET config_value = :v, description = :d WHERE config_key = :k"
), {"v": legacy_value, "d": CASH_DESC.format(eid=target), "k": new_key})
action = "updated"
else:
conn.execute(text(
"INSERT INTO system_configs (config_key, config_value, description) "
"VALUES (:k, :v, :d)"
), {"k": new_key, "v": legacy_value, "d": CASH_DESC.format(eid=target)})
action = "inserted"
return {"key": new_key, "action": action, "value": legacy_value}
def _apply_alignment(conn, target: int, force: bool, dry: bool):
new_key = f"{ALIGN_KEY}.{target}"
row = conn.execute(text(
"SELECT value FROM role_permissions WHERE `key` = :k"
), {"k": ALIGN_KEY}).fetchone()
if not row:
return {"key": new_key, "action": "skip", "reason": "旧全局键不存在(无可搬运值)"}
exist = conn.execute(text(
"SELECT value FROM role_permissions WHERE `key` = :k"
), {"k": new_key}).fetchone()
if exist and not force:
return {"key": new_key, "action": "noop",
"reason": "新键已存在且未 --force(保留既有值,幂等)",
"existing_value": exist[0]}
if dry:
return {"key": new_key, "action": "would-insert", "value": row[0]}
conn.execute(text(
"INSERT INTO role_permissions (`key`, value) VALUES (:k, :v)"
), {"k": new_key, "v": row[0]})
return {"key": new_key, "action": "inserted", "value": row[0]}
def _guard_db(args, conn):
db = _detect_db_name(conn)
if args.allow_db:
return db
if db != PROD_DB_NAME:
print(f"[拒绝] 库名 {db!r} ≠ 生产库 {PROD_DB_NAME!r};"
f"如确为预期,请显式加 --allow-db {db}", file=sys.stderr)
sys.exit(2)
return db
def cmd_apply(args):
eng = _engine()
results = []
with eng.begin() as conn:
db = _guard_db(args, conn)
# 目标账套存在性校验(防写出孤儿键)
ent = conn.execute(text("SELECT id FROM entities WHERE id = :i"),
{"i": args.target_entity}).fetchone()
if not ent:
print(f"[拒绝] 目标账套 entity_id={args.target_entity} 不存在", file=sys.stderr)
sys.exit(2)
results.append(_apply_cash(conn, args.target_entity, args.force, dry=False))
results.append(_apply_alignment(conn, args.target_entity, args.force, dry=False))
out = {"mode": "apply", "db": db, "target_entity": args.target_entity, "results": results}
print(json.dumps(out, ensure_ascii=False, indent=2) if args.json else
"\n".join(f" {r}" for r in results))
return 0
def cmd_down(args):
"""回退:删除新键(旧全局键原样保留,老代码即可继续读)"""
eng = _engine()
removed = []
with eng.begin() as conn:
db = _guard_db(args, conn)
r1 = conn.execute(text(
"DELETE FROM system_configs WHERE config_key LIKE :p"), {"p": CASH_KEY + ".%"})
r2 = conn.execute(text(
"DELETE FROM role_permissions WHERE `key` LIKE :p"), {"p": ALIGN_KEY + ".%"})
removed = [f"system_configs(+{CASH_KEY}.*) deleted={r1.rowcount}",
f"role_permissions({ALIGN_KEY}.*) deleted={r2.rowcount}"]
print(json.dumps({"mode": "down", "db": db, "removed": removed},
ensure_ascii=False, indent=2) if args.json else "\n".join(removed))
print("旧全局键未改动 → 代码回退到老版本即可继续读旧键。")
return 0
def main():
p = argparse.ArgumentParser(description="全局键拆账套 · 一次性搬运(cash.current_balance / alignment_config)")
g = p.add_mutually_exclusive_group()
g.add_argument("--check", action="store_true", help="只读预检(默认)")
g.add_argument("--apply", action="store_true", help="写库(需审批)")
g.add_argument("--down", action="store_true", help="回退:删除新键")
p.add_argument("--target-entity", type=int, default=1,
help="旧值落到哪个账套(默认 1 = 陕西酣客,生产账套)")
p.add_argument("--force", action="store_true", help="允许覆盖已存在的新键")
p.add_argument("--json", action="store_true", help="机器可读输出")
p.add_argument("--allow-db", default=None, help="生产库名保护豁免(默认仅允许 cma)")
args = p.parse_args()
if args.apply:
return cmd_apply(args)
if args.down:
return cmd_down(args)
return cmd_check(args)
if __name__ == "__main__":
sys.exit(main())
+224 -16
View File
@@ -8,8 +8,9 @@ G1 修正项 :P0-1 up() 语句过滤器 / P0-2 计数口径 / P1-3 down() 部
用法: 用法:
python3 treasury_migrate.py --parse-only # 零 DB:只解析 DDL,打印命中语句与期望计数(P0-1 复算证据) python3 treasury_migrate.py --parse-only # 零 DB:只解析 DDL,打印命中语句与期望计数(P0-1 复算证据)
python3 treasury_migrate.py --grant-plan # 零 DB:打印授权收敛语句计划(授权面幂等自证证据)
python3 treasury_migrate.py --check # 只读预检:缺表/缺列/缺索引/种子行数(零写入,默认动作) python3 treasury_migrate.py --check # 只读预检:缺表/缺列/缺索引/种子行数(零写入,默认动作)
python3 treasury_migrate.py --apply # 建表 + 加列 + 索引 + 种子(幂等) python3 treasury_migrate.py --apply # 建表 + 加列 + 索引 + 种子 + 授权收敛(幂等)
python3 treasury_migrate.py --down # 回滚(先 dump 后删;有数据时要求 --yes) python3 treasury_migrate.py --down # 回滚(先 dump 后删;有数据时要求 --yes)
幂等契约(P0-2 计数口径 = 新增对象数,不是执行语句数): 幂等契约(P0-2 计数口径 = 新增对象数,不是执行语句数):
@@ -17,12 +18,28 @@ G1 修正项 :P0-1 up() 语句过滤器 / P0-2 计数口径 / P1-3 down() 部
二跑 --apply 期望:created_new=0 / added_cols=0 / added_indexes=0 / 种子行数不变 二跑 --apply 期望:created_new=0 / added_cols=0 / added_indexes=0 / 种子行数不变
指纹行:import_logs(batch=treasury-ddl-<sha1[:10]>),按 (import_type,batch) 先查后写, 指纹行:import_logs(batch=treasury-ddl-<sha1[:10]>),按 (import_type,batch) 先查后写,
不依赖表上是否有唯一键(P2-7:entity_id=1 为约定——迁移指纹属实体无关元数据) 不依赖表上是否有唯一键(P2-7:entity_id=1 为约定——迁移指纹属实体无关元数据)
边界:只动 6 张 treasury_* 新表 + cash_plans 加列/加索引;不碰 cash.py / 既有端点契约 / 其他模块表结构。
授权面契约(2026-09-18 F-1 根因侧:部署管道不再扩面,而是「按白名单最小面 + 收敛」):
旧形态(本文件 2026-09-18 前)= 每次 --apply 无条件 `GRANT SELECT,INSERT,UPDATE,DELETE`
于 6 表 × 2 host = 12 条 → 白名单外的权限(表级 SELECT / balances·payments 的 DELETE /
hits 的 UPDATE,DELETE)每次部署回涨,基线巡检恒 BASELINE_MISMATCH。
新形态 = 授权集合取自白名单文件 table_ops(授权面唯一事实源),并对白名单外权限发 REVOKE:
· 语句计划 grant_plan() 是**纯函数**(只依赖 spec,不读现网)⇒ 连跑两次逐字相同;
· 重复执行结果一致(幂等):白名单内 GRANT 幂等,白名单外 REVOKE 在收敛态报 1141
(no such grant)→ 视为幂等空操作记账,不报错;
· 执行后回读 information_schema.TABLE_PRIVILEGES 断言逐表 == 白名单,不符则非零退出。
白名单文件路径:env `CMA_WRITE_WHITELIST` 优先,默认 /root/WB_WS/_reports/_cma_write_table_whitelist.json;
文件不可用时退回 TREASURY_GRANT_SPEC_FALLBACK(须与白名单一致,否则 stderr 告警);文件在位但
不合规(缺表裁定 / 不可解析 / 空集 / 含表级 SELECT)→ fail-closed,不猜不降级。
边界:只动 6 张 treasury_* 新表 + cash_plans 加列/加索引 + cma_api 授权收敛;
不碰 cash.py / 既有端点契约 / 其他模块表结构 / 司库业务读写路径。
""" """
import argparse import argparse
import hashlib import hashlib
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -60,6 +77,21 @@ CASH_PLAN_ALTER = """ALTER TABLE cash_plans
DEFAULT_SOCKET = "/var/run/mysqld/mysqld.sock" DEFAULT_SOCKET = "/var/run/mysqld/mysqld.sock"
FORBIDDEN_RUN_ACCOUNTS = {"cma_api"} FORBIDDEN_RUN_ACCOUNTS = {"cma_api"}
GRANT_APP_ACCOUNT = "cma_api" GRANT_APP_ACCOUNT = "cma_api"
GRANT_HOSTS = ("localhost", "127.0.0.1")
# 授权面域:本迁移步只可能授出这 4 种(旧形态的全集);收敛 = 该域内白名单外的项一律 REVOKE
GRANT_PRIV_DOMAIN = ("SELECT", "INSERT", "UPDATE", "DELETE")
# 白名单文件 = 授权面唯一事实源(后续白名单变更只改该文件一处)
WHITELIST_PATH = os.getenv(
"CMA_WRITE_WHITELIST", "/root/WB_WS/_reports/_cma_write_table_whitelist.json")
# 兜底常量:与白名单 table_ops 的 6 表裁定逐表一致(文件不可用时的退路;不一致时 stderr 告警)
TREASURY_GRANT_SPEC_FALLBACK = {
"treasury_accounts": ("INSERT", "UPDATE", "DELETE"),
"treasury_control_rules": ("INSERT", "UPDATE", "DELETE"),
"treasury_limits": ("INSERT", "UPDATE", "DELETE"),
"treasury_balances": ("INSERT", "UPDATE"),
"treasury_payments": ("INSERT", "UPDATE"),
"treasury_control_hits": ("INSERT",),
}
def get_engine(): def get_engine():
@@ -87,19 +119,161 @@ def get_engine():
pool_pre_ping=True) pool_pre_ping=True)
def grant_app_account(conn): def _resolve_dbn():
"""授权应用运行账号 cma_api 对 6 张 treasury 新表的 DML(幂等 GRANT,双 host)。 """库名:env CMA_MIGRATE_DB_NAME 优先,否则应用配置(app.database.DB_NAME),最后 'cma'。"""
必须由特权连接执行;否则应用端点读不到/写不进新表(1142/1044)。""" name = (os.getenv("CMA_MIGRATE_DB_NAME") or "").strip()
if name:
return name
try:
from app import database as db from app import database as db
dbn = (os.getenv("CMA_MIGRATE_DB_NAME") or getattr(db, "DB_NAME", None) or "cma") return getattr(db, "DB_NAME", None) or "cma"
granted = [] except Exception:
for host in ("localhost", "127.0.0.1"): return "cma"
def _parse_priv_text(raw):
"""白名单 table_ops 值 → 权限集合。值形如 'INSERT,UPDATE,DELETE (中文说明(含嵌套括号))'。"""
head = str(raw)
for sep in ("(", "("):
i = head.find(sep)
if i >= 0:
head = head[:i]
return {p.strip().upper() for p in head.split(",") if p.strip()}
def load_grant_spec():
"""读白名单 table_ops 的 6 张 treasury 表裁定 → (spec, source, note)。
spec : {table: (priv, ...)},权限按 GRANT_PRIV_DOMAIN 顺序归一(确定性)
source : 'whitelist' | 'fallback-constant'
note : 常量兜底与白名单不一致时的告警文本(否则 None)
fail-closed:文件在位但不可解析 / 缺表裁定 / 空集 / 含表级 SELECT → SystemExit,不猜不降级。
"""
fallback = {t: tuple(TREASURY_GRANT_SPEC_FALLBACK[t]) for t in NEW_TABLES}
if not os.path.exists(WHITELIST_PATH):
return fallback, "fallback-constant", None
try:
with open(WHITELIST_PATH, encoding="utf-8") as fh:
ops = (json.load(fh) or {}).get("table_ops") or {}
except Exception as exc:
raise SystemExit(f"[FAIL] 白名单不可解析 {WHITELIST_PATH}: {exc}")
spec = {}
for t in NEW_TABLES: for t in NEW_TABLES:
conn.execute(text( raw = ops.get(t)
f"GRANT SELECT, INSERT, UPDATE, DELETE ON `{dbn}`.`{t}` " if raw is None:
f"TO '{GRANT_APP_ACCOUNT}'@{host}")) raise SystemExit(f"[FAIL] 白名单 table_ops 缺 {t} 裁定({WHITELIST_PATH})")
granted.append(f"{t}@{host}") privs = tuple(p for p in GRANT_PRIV_DOMAIN if p in _parse_priv_text(raw))
return granted if not privs:
raise SystemExit(f"[FAIL] 白名单 {t} 授权集为空({raw!r})")
if "SELECT" in privs:
raise SystemExit(
f"[FAIL] 白名单 {t} 含表级 SELECT —— 读面由库级 SELECT ON cma.* 覆盖,"
"表级 SELECT 正是本单要收敛的冗余;如需改基线口径请回报调度方另单")
spec[t] = privs
note = None
if spec != fallback:
note = (f"兜底常量与白名单不一致(以白名单为准): fallback={fallback} whitelist={spec}")
return spec, "whitelist", note
def grant_plan(spec, dbn):
"""授权收敛语句计划(纯函数:只依赖 spec/dbn,不读现网)⇒ 连跑两次逐字相同。
每 (host, 表):先 REVOKE 白名单外权限(部署成为「收敛」通道),再 GRANT 白名单集合。
收敛态下 REVOKE 会报 1141(no such grant)→ 执行侧记为幂等空操作,非错误。
"""
stmts = []
for host in GRANT_HOSTS:
for t in sorted(NEW_TABLES):
wanted = tuple(p for p in GRANT_PRIV_DOMAIN if p in spec[t])
extras = tuple(p for p in GRANT_PRIV_DOMAIN if p not in wanted)
if extras:
stmts.append(
f"REVOKE {', '.join(extras)} ON `{dbn}`.`{t}` FROM '{GRANT_APP_ACCOUNT}'@{host}")
stmts.append(
f"GRANT {', '.join(wanted)} ON `{dbn}`.`{t}` TO '{GRANT_APP_ACCOUNT}'@{host}")
return stmts
def _is_nonexistent_grant(exc) -> bool:
"""MySQL 1141/1147(no such grant):REVOKE 未持有权限 —— 收敛态的正常返回。"""
orig = getattr(exc, "orig", exc)
args = getattr(orig, "args", ()) or ()
if args and args[0] in (1141, 1147):
return True
return "no such grant" in str(exc).lower()
_GRANT_STMT_RE = re.compile(
r"^(?P<verb>REVOKE|GRANT)\s+(?P<privs>.+?)\s+ON\s+`(?P<db>[^`]+)`\.`(?P<table>[^`]+)`"
r"\s+(?:FROM|TO)\s+'(?P<user>[^']+)'@(?P<host>\S+)$")
def _stmt_entry(stmt: str) -> dict:
"""授权语句 → {verb, privs, db, table, user, host}(解析失败即抛,不静默)。"""
m = _GRANT_STMT_RE.match(stmt.strip())
if not m:
raise RuntimeError(f"授权语句形态非预期,无法解析: {stmt!r}")
d = m.groupdict()
d["privs"] = ", ".join(p.strip() for p in d["privs"].split(","))
return d
def _current_table_privs(conn, dbn):
"""只读回查 information_schema.TABLE_PRIVILEGES:{(host, table): {priv}}(仅 cma_api)。"""
rows = conn.execute(text(
"SELECT GRANTEE, TABLE_NAME, PRIVILEGE_TYPE FROM information_schema.TABLE_PRIVILEGES "
"WHERE TABLE_SCHEMA = :d"), {"d": dbn}).fetchall()
out = {}
for grantee, table, priv in rows:
if table not in NEW_TABLES:
continue
user, _sep, host = str(grantee).rpartition("@")
if user.strip().strip("'") != GRANT_APP_ACCOUNT:
continue
out.setdefault((host.strip().strip("'"), table), set()).add(str(priv).upper())
return out
def verify_grant_sync(conn, spec, dbn):
"""收敛断言:逐 (host, 表) 现网表级权限集合 == 白名单集合;返回差异清单(空 = 已收敛)。"""
current = _current_table_privs(conn, dbn)
bad = []
for host in GRANT_HOSTS:
for t in sorted(NEW_TABLES):
have = current.get((host, t), set())
want = set(spec[t])
if have != want:
bad.append(f"{t}@{host}: 现持={sorted(have)} 期望={sorted(want)}")
return bad
def apply_grant_sync(conn, spec, source):
"""执行授权收敛(按白名单最小面 + 幂等):REVOKE 白名单外 + GRANT 白名单,回读断言。"""
dbn = _resolve_dbn()
granted, revoked, absent = [], [], []
for stmt in grant_plan(spec, dbn):
try:
conn.execute(text(stmt))
except Exception as exc:
if stmt.startswith("REVOKE") and _is_nonexistent_grant(exc):
absent.append(_stmt_entry(stmt))
continue
raise
(granted if stmt.startswith("GRANT") else revoked).append(_stmt_entry(stmt))
conn.commit()
mismatch = verify_grant_sync(conn, spec, dbn)
return {
"spec_source": source,
"spec": {t: ",".join(spec[t]) for t in sorted(spec)},
"granted": [f"{e['table']}@{e['host']}" for e in granted],
"revoked": [f"{e['table']}@{e['host']} ({e['privs']}) [REVOKE]" for e in revoked],
"revoke_absent": [f"{e['table']}@{e['host']} ({e['privs']}) [REVOKE-ABSENT]"
for e in absent],
"statements": len(granted) + len(revoked) + len(absent),
"converged": not mismatch,
"converged_mismatch": mismatch,
}
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@@ -279,8 +453,13 @@ def up(conn):
if CASH_PLAN_NEW_INDEX not in before_idx: if CASH_PLAN_NEW_INDEX not in before_idx:
conn.execute(text(p["index"])) conn.execute(text(p["index"]))
# ③b 授权应用账号 cma_api 对本 6 表 DML(幂等 GRANT,须特权连接) # ③b 授权收敛(按白名单最小面 + 幂等,须特权连接):REVOKE 白名单外 + GRANT 白名单
granted = grant_app_account(conn) # 旧形态为无条件 12 条全量 GRANT(含表级 SELECT 等白名单外权限)→ 每次部署扩面,
# 基线巡检恒 BASELINE_MISMATCH(2026-09-18 F-1)。现改为收敛:重复执行结果不变。
spec, spec_source, spec_note = load_grant_spec()
if spec_note:
print("[warn] " + spec_note, file=sys.stderr)
grant_sync = apply_grant_sync(conn, spec, spec_source)
# ④ 种子(ON DUPLICATE KEY UPDATE,数据级幂等) # ④ 种子(ON DUPLICATE KEY UPDATE,数据级幂等)
for stmt in p["seeds"]: for stmt in p["seeds"]:
@@ -315,10 +494,14 @@ def up(conn):
"limits_rows": counts["limits_rows"], "limits_rows": counts["limits_rows"],
"import_logs_row": action, "import_logs_row": action,
"batch": batch, "batch": batch,
"granted": granted, "granted": grant_sync["granted"],
"grant_sync": grant_sync,
"missing_tables": post["missing_tables"], "missing_tables": post["missing_tables"],
"cash_plans_rows": post["cash_plans_rows"], "cash_plans_rows": post["cash_plans_rows"],
}, ensure_ascii=False)) }, ensure_ascii=False))
if not grant_sync["converged"]:
print(f"❌ 授权收敛未达成 {grant_sync['converged_mismatch']}(非零退出)", file=sys.stderr)
return 6
return 0 return 0
@@ -380,6 +563,28 @@ def down(conn):
# 入口 # 入口
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
def grant_plan_only():
"""零 DB:打印授权收敛语句计划(幂等自证证据:连跑两次逐字相同)。"""
spec, source, note = load_grant_spec()
dbn = _resolve_dbn()
stmts = grant_plan(spec, dbn)
out = {
"mode": "grant-plan",
"whitelist_path": WHITELIST_PATH,
"whitelist_present": os.path.exists(WHITELIST_PATH),
"spec_source": source,
"spec_note": note,
"spec": {t: ",".join(spec[t]) for t in sorted(spec)},
"hosts": list(GRANT_HOSTS),
"db_name": dbn,
"statement_count": len(stmts),
"statements": stmts,
"statements_md5": hashlib.md5("\n".join(stmts).encode("utf-8")).hexdigest(),
}
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
def parse_only(): def parse_only():
"""零 DB 的 DDL 解析复算(P0-1 证据):命中 6 条 CREATE + 表名清单 + 种子语句。""" """零 DB 的 DDL 解析复算(P0-1 证据):命中 6 条 CREATE + 表名清单 + 种子语句。"""
p = parse_ddl() p = parse_ddl()
@@ -407,6 +612,7 @@ def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
g = ap.add_mutually_exclusive_group() g = ap.add_mutually_exclusive_group()
g.add_argument("--parse-only", action="store_true", help="零 DB:只解析 DDL(P0-1 复算证据)") g.add_argument("--parse-only", action="store_true", help="零 DB:只解析 DDL(P0-1 复算证据)")
g.add_argument("--grant-plan", action="store_true", help="零 DB:打印授权收敛语句计划")
g.add_argument("--check", action="store_true", help="只读预检(默认)") g.add_argument("--check", action="store_true", help="只读预检(默认)")
g.add_argument("--apply", action="store_true") g.add_argument("--apply", action="store_true")
g.add_argument("--down", action="store_true") g.add_argument("--down", action="store_true")
@@ -415,6 +621,8 @@ def main():
if args.parse_only: if args.parse_only:
return parse_only() return parse_only()
if args.grant_plan:
return grant_plan_only()
engine = get_engine() engine = get_engine()
with engine.connect() as conn: with engine.connect() as conn:
+107
View File
@@ -15,6 +15,54 @@ os.environ.setdefault("CMA_DB_HOST", "localhost")
os.environ.setdefault("CMA_DB_PORT", "3306") os.environ.setdefault("CMA_DB_PORT", "3306")
os.environ.setdefault("CMA_DB_NAME", "test") os.environ.setdefault("CMA_DB_NAME", "test")
# ── 测试期生产 Redis 隔离(project-20260918-P1-test-redis-token-fix)────────────
# 机制(详见任务单/报告):
# app.auth_middleware 在 import 时构造 redis 客户端(127.0.0.1:6379 db=1 = 生产库),
# ping 成功即 _redis_available=True → create_token() 走 setex("cma:token:<hex>", 86400)
# 把测试签发的 token 写进生产 Redis;而 require_auth() 只按 token 里的 user_id 反查用户、
# 不比对口令,测试库首个用户 id=1 与生产 users.id=1 撞号 —— 每跑一轮套件都会在生产 Redis
# 留下 24h 有效、可直接当生产会话使用的 token。
# 处置(只改测试侧,不动 app/auth_middleware.py 的任何业务逻辑):
# ① 在导入任何 app 模块之前,把 redis.Redis 构造器替换成阻断器(计数 + 抛错)——
# app 侧既有的 `except Exception → _redis=None / _redis_available=False` 降级分支
# 自动生效,测试期对生产 Redis **零连接**(连 TCP 探测都不发生);
# ② 显式强制 _redis_available=False(进程内 _token_store 兜底,用例语义不变);
# ③ app.utils.cache 同样硬编码 db=1(测试夹具会调 delete("ai")),一并关闭,
# 使整轮测试对生产 Redis 零写命令;
# ④ pytest_sessionfinish 落盘隔离审计 JSON(构造尝试次数 / 通道状态)作为可复核证据。
_REDIS_GUARD = {"constructor_attempts": 0, "callers": []}
class _BlockedProductionRedis:
"""测试期阻断器:生产 Redis 客户端构造被计数并拒绝。
抛出的异常与真实连接失败同形(Exception),由 app 侧既有 except 分支兜底降级,
因此不会改变任何鉴权/缓存语义,只保证测试进程不碰生产 Redis。
"""
def __init__(self, *args, **kwargs):
import traceback
_REDIS_GUARD["constructor_attempts"] += 1
frames = [f for f in traceback.extract_stack()[:-1] if "conftest" not in f.filename]
if frames:
_REDIS_GUARD["callers"].append(f"{frames[-1].filename}:{frames[-1].lineno}")
raise RuntimeError(
"test-mode: production Redis (127.0.0.1:6379 db=1) blocked by backend/tests/conftest.py"
)
def _install_production_redis_guard() -> None:
"""替换 redis.Redis 构造器(进程级,覆盖整轮 pytest 会话)。"""
try:
import redis as redis_lib
except Exception: # redis 未安装:app 侧本来就会降级
return
redis_lib.Redis = _BlockedProductionRedis
_install_production_redis_guard()
import pytest import pytest
from typing import Generator from typing import Generator
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -71,6 +119,17 @@ event.listen(TEST_ENGINE, "connect", lambda dbapi_conn, rec: dbapi_conn.create_f
db_module._engine = TEST_ENGINE db_module._engine = TEST_ENGINE
db_module._SessionLocal = TEST_SESSION_LOCAL db_module._SessionLocal = TEST_SESSION_LOCAL
# ── 测试期强制关闭 Redis 通道(生产 Redis 零写入硬隔离)──────────────────────
# 不修改 app/auth_middleware.py:只置其模块级开关,走既有进程内 _token_store 降级分支。
# 导入点上方的 _BlockedProductionRedis 已生效 → 这两次 import 不会连生产 Redis。
import app.auth_middleware as auth_middleware # noqa: E402
from app.utils import cache as cache_util # noqa: E402
auth_middleware._redis_available = False
auth_middleware._redis = None # 导入期即被阻断,显式置空防后续被复活
cache_util._available = False
cache_util._client = None
# 然后导入models(它们依赖Base) # 然后导入models(它们依赖Base)
from app.models import ( from app.models import (
User, StrategicMap, KPIDefinition, KPIValue, KPIAlert, User, StrategicMap, KPIDefinition, KPIValue, KPIAlert,
@@ -101,6 +160,54 @@ def _disable_ai_suggestion_push(monkeypatch):
monkeypatch.setattr(ai_analysis, "_push_decision_suggestion", lambda s: True) monkeypatch.setattr(ai_analysis, "_push_decision_suggestion", lambda s: True)
@pytest.fixture(autouse=True)
def _enforce_production_redis_isolation():
"""每个用例前后强制断言:测试期 Redis 通道始终关闭(防用例把它翻回 True)。
project-20260918-P1-test-redis-token-fix:生产 Redis 写入的唯一入口是
auth_middleware.create_token() 的 `if _redis_available` 分支 —— 用例级复检保证
「一轮全量测试 = 零生产 Redis 写命令」,任何把开关翻回 True 的用例都会当场暴露。
"""
auth_middleware._redis_available = False
cache_util._available = False
yield
assert auth_middleware._redis_available is False, "测试期禁止开启生产 Redis token 通道"
assert cache_util._available is False, "测试期禁止开启生产 Redis 缓存通道"
@pytest.fixture
def production_redis_isolation_state() -> dict:
"""暴露测试期 Redis 隔离状态(构造阻断计数 + 通道开关),供断言用例使用。"""
return _REDIS_GUARD
def pytest_sessionfinish(session, exitstatus):
"""落盘本轮测试的 Redis 隔离审计(零生产 Redis 连接/写入的可复核证据)。"""
import json
import datetime as _dt
audit = {
"generated_at": _dt.datetime.now().isoformat(timespec="seconds"),
"task": "project-20260918-P1-test-redis-token-fix",
"redis_constructor_attempts": _REDIS_GUARD["constructor_attempts"],
"redis_constructor_blocked": _REDIS_GUARD["constructor_attempts"],
"blocked_callsites": _REDIS_GUARD["callers"],
"auth_middleware_redis_available": bool(auth_middleware._redis_available),
"auth_middleware_redis_client_is_none": auth_middleware._redis is None,
"cache_available": bool(cache_util._available),
"cache_client_is_none": cache_util._client is None,
"token_store_size": len(auth_middleware._token_store),
"pytest_exitstatus": int(exitstatus),
"violation": bool(auth_middleware._redis_available or cache_util._available),
}
path = os.environ.get("CMA_TEST_REDIS_AUDIT", "/tmp/cma-test-redis-isolation-audit.json")
try:
with open(path, "w", encoding="utf-8") as fh:
json.dump(audit, fh, ensure_ascii=False, indent=2)
except Exception:
pass
@pytest.fixture @pytest.fixture
def db() -> Generator[Session, None, None]: def db() -> Generator[Session, None, None]:
"""提供数据库 session""" """提供数据库 session"""
@@ -0,0 +1,268 @@
"""CMA 跨实体隔离 — ActionPlan 归属写侧缺失 + OKR 分解视图读侧缺失(2026-09-18 batch3 派生单)
任务:fullstack-20260918-1905b-actionplan-entity-write-and-okr-read
父单:fullstack-20260918-1905-actionplan-objective-id-scope(OBS1/OBS2 只读侦查,
证据 _reports/evidence-fullstack-20260918-1905-probe.txt)
缺陷(两处必须同单做,顺序铁律:先修缺陷1再修缺陷2):
1. action_plans.py:create_plan 构造 ActionPlan(...) 不写 entity_id
→ 落模型默认值 1。实测 e2 会话经 API 创建计划后库内 entity_id=1(OBS1)。
现状 list_plans/stats/update/delete 全靠 join KPIDefinition 判账套,
ActionPlan.entity_id 属"不可信字段"(已埋雷)。
2. okr.py:get_okr_decomposition 的 weekly_actions 仅按 objective_id + due_date
过滤,无账套条件 → 对端账套计划挂本账套目标时出现在本账套分解视图(OBS2)。
若只修 2 不修 1,则 e2 自己经 API 新建的计划(entity_id=1)会从 e2 自己的
分解视图消失(把脏归属变成用户可见的假阴性)。
本用例(每条双向 + 同账套正对照;改前失败条目见
_reports/evidence-fullstack-20260918-1905b-red.txt):
- T1 create_plan 落库写入会话账套(e1→1 / e2→2,双向正对照)【改前 e2 侧失败】
- T2 本账套分解视图不含"对端账套计划挂本账套目标"的脏行;本账套合法计划仍出现
(双向,正对照内嵌)【改前失败】
- T3 本账套经 API 创建的计划出现在自己的分解视图(防缺陷1修好后被读侧条件误杀)【改前通过,
仅修缺陷2不修缺陷1时失败 —— 顺序铁律的机器守卫】
- T4 鉴权底线:无 token → 401(改前改后都应通过)
"""
import datetime as _dt
from datetime import time as _time
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import ActionPlan, Entity, KPIDefinition, Objective
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
PLANS = "/api/cma/action-plans"
OKR = "/api/cma/okr"
E1_KPI = "ZZ_APENT_E1_KPI_001"
E2_KPI = "ZZ_APENT_E2_KPI_001"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=0,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _mk_objective(db: Session, eid: int, title: str, quarter: str = "2026Q3") -> Objective:
obj = Objective(entity_id=eid, title=title, quarter=quarter, status="active")
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
return k1, k2
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _plans_by_title(db: Session, title: str):
db.expire_all()
return db.query(ActionPlan).filter(ActionPlan.title == title).all()
def _this_week_due() -> _dt.datetime:
"""本周内的时间点(分解视图只取 week_start..week_start+7d)"""
today = _dt.date.today()
return _dt.datetime.combine(today, _time(12, 0))
def _today_iso() -> str:
return _dt.date.today().isoformat()
def _mk_dirty_plan(db: Session, kpi_id: int, dirty_entity_id: int, objective_id: int,
title: str) -> ActionPlan:
"""构造脏行:计划归属 dirty_entity_id,却挂在 objective_id(属别的账套)
直插模拟历史遗留行(写侧校验上线前落库的跨账套归属),不经过 API。
"""
plan = ActionPlan(
entity_id=dirty_entity_id,
kpi_id=kpi_id,
objective_id=objective_id,
title=title,
assignee="张三",
priority="medium",
status="pending",
progress=0,
due_date=_this_week_due(),
)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _weekly_titles(client: TestClient, token: str, okr_id: int):
r = client.get(f"{OKR}/{okr_id}/decomposition", headers=auth_header(token))
assert r.status_code == 200, f"分解视图应 200,实际 {r.status_code} {r.text[:200]}"
return {row["title"] for row in r.json()["weekly_actions"]}
# ──────────────────────────────────────────────
# T1. create_plan 落库写入会话账套(双向正对照)
# ──────────────────────────────────────────────
def test_create_plan_writes_session_entity_both_directions(client: TestClient, db: Session):
"""e1 会话 → 库内 entity_id=1;e2 会话 → 库内 entity_id=2(不得落模型默认值 1)"""
k1, k2 = _seed_two_entities(db)
t1 = "ZZ_APENT 归属 e1 创建"
r1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": t1, "kpi_id": k1.id, "due_date": _today_iso()})
assert r1.status_code == 200, f"e1 创建应 200,实际 {r1.status_code} {r1.text[:200]}"
rows1 = _plans_by_title(db, t1)
assert len(rows1) == 1, f"e1 创建的计划未落库: {rows1}"
assert rows1[0].entity_id == 1, (
f"e1 会话创建的计划 entity_id 应为 1,实际 {rows1[0].entity_id}"
)
t2 = "ZZ_APENT 归属 e2 创建"
r2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": t2, "kpi_id": k2.id, "due_date": _today_iso()})
assert r2.status_code == 200, f"e2 创建应 200,实际 {r2.status_code} {r2.text[:200]}"
rows2 = _plans_by_title(db, t2)
assert len(rows2) == 1, f"e2 创建的计划未落库: {rows2}"
assert rows2[0].entity_id == 2, (
f"e2 会话创建的计划 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
" —— create_plan 未写 entity_id,落模型默认值 1"
)
# ──────────────────────────────────────────────
# T2. 分解视图不含对端账套脏行、含本账套合法计划(双向)
# ──────────────────────────────────────────────
def test_okr_decomposition_excludes_dirty_cross_entity_row(client: TestClient, db: Session):
"""e1 视图:脏行(entity_id=2 挂 e1 目标)不出现,e1 合法计划仍出现(正对照)
e2 视图:脏行(entity_id=1 挂 e2 目标)不出现,e2 合法计划仍出现"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
# e1 侧:脏行 = 归属 e2 的计划挂 e1 目标
dirty_in_e1 = "ZZ_APENT 脏行e2挂e1目标"
_mk_dirty_plan(db, k2.id, 2, obj1.id, dirty_in_e1)
own_in_e1 = "ZZ_APENT e1合法计划"
ro1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": own_in_e1, "kpi_id": k1.id,
"objective_id": obj1.id, "due_date": _today_iso()})
assert ro1.status_code == 200, ro1.text[:200]
titles1 = _weekly_titles(client, _login(client, 1), obj1.id)
assert dirty_in_e1 not in titles1, (
f"对端账套(entity_id=2)脏行出现在 e1 分解视图: {sorted(titles1)}"
)
assert own_in_e1 in titles1, (
f"e1 本账套合法计划未出现在自己视图(正对照失效): {sorted(titles1)}"
)
# e2 侧(反向):脏行 = 归属 e1 的计划挂 e2 目标
dirty_in_e2 = "ZZ_APENT 脏行e1挂e2目标"
_mk_dirty_plan(db, k1.id, 1, obj2.id, dirty_in_e2)
own_in_e2 = "ZZ_APENT e2合法计划"
ro2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": own_in_e2, "kpi_id": k2.id,
"objective_id": obj2.id, "due_date": _today_iso()})
assert ro2.status_code == 200, ro2.text[:200]
titles2 = _weekly_titles(client, _login(client, 2), obj2.id)
assert dirty_in_e2 not in titles2, (
f"对端账套(entity_id=1)脏行出现在 e2 分解视图: {sorted(titles2)}"
)
assert own_in_e2 in titles2, (
f"e2 本账套合法计划未出现在自己视图(正对照失效): {sorted(titles2)}"
)
# ──────────────────────────────────────────────
# T3. 本账套经 API 创建的计划出现在自己的分解视图(顺序铁律守卫)
# ──────────────────────────────────────────────
def test_okr_decomposition_includes_own_api_created_plan(client: TestClient, db: Session):
"""e2 经 API 建计划(写侧落 entity_id=2)→ 出现在 e2 自己的分解视图(双向)
这是缺陷 1 与缺陷 2 的顺序守卫:只修读侧条件而不修写侧归属时,
e2 自己新建的计划(entity_id=1)会被读侧条件过滤掉(假阴性)。
"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
t1 = "ZZ_APENT e1自建计划自见"
assert client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": t1, "kpi_id": k1.id, "objective_id": obj1.id,
"due_date": _today_iso()}).status_code == 200
t2 = "ZZ_APENT e2自建计划自见"
assert client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": t2, "kpi_id": k2.id, "objective_id": obj2.id,
"due_date": _today_iso()}).status_code == 200
titles1 = _weekly_titles(client, _login(client, 1), obj1.id)
assert t1 in titles1, f"e1 自建计划未出现在 e1 视图: {sorted(titles1)}"
titles2 = _weekly_titles(client, _login(client, 2), obj2.id)
assert t2 in titles2, (
f"e2 自建计划未出现在 e2 视图(读侧条件是否漏了写侧归属?): {sorted(titles2)}"
)
# ──────────────────────────────────────────────
# T4. 鉴权底线
# ──────────────────────────────────────────────
def test_decomposition_without_token_401(client: TestClient, db: Session):
"""无 token → 401(改前改后都应通过)"""
_k1, _k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
r = client.get(f"{OKR}/{obj1.id}/decomposition")
assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"
@@ -0,0 +1,375 @@
"""CMA 跨实体隔离 — ActionPlan 归属写侧「残余三处构造点」(2026-09-18 派生单)
任务:fullstack-20260918-2015-actionplan-entity-write-residual-sites
父单:fullstack-20260918-1905b-actionplan-entity-write-and-okr-read
(其 §五 范围外只读侦查登记的三处同族缺口)
缺陷(`ActionPlan.entity_id` 模型默认 1,以下三处构造均不写会话账套):
1. app/api/alerts.py::create_action_plan_from_alert — 预警关联生成改善计划
2. app/api/deviation_push.py::push_deviation_to_map — 差异反打自动生成回顾会议题
3. app/api/ai_suggestions.py::_apply_action_plan — AI 建议转行动计划
后果:非 e1 账套经这三个入口创建的计划落 `entity_id=1`(不可信字段)。一旦读路径按
`ActionPlan.entity_id` 过滤(1905b 已在 OKR 分解视图加该条件),即出现跨账套泄漏或
「自己建的计划看不见」的假阴性。
本用例(改前失败条目见 _reports/evidence-fullstack-20260918-2015-red.txt):
- T1 预警关联生成计划:e2 会话 → 库内 entity_id=2;e1 会话 → 1(双向 + 正对照);
并附跨账套负对照(e2 token 打 e1 预警 → 404 且不落行)
- T2 差异反打生成议题:e2 会话 → 库内 entity_id=2(kpi_id 亦须落在本账套 KPI 上);
e1 会话 → 1(双向)
- T3 差异反打去重按账套隔离:对端账套存在同名 pending 议题,不得抑制本账套创建
- T4 AI 建议转计划:e2 会话 → 库内 entity_id=2;e1 会话 → 1(双向)
- T5 AI 建议越权负对照:对端账套的建议不得被本账套会话应用(404 + 零落行)
- T6 鉴权底线:三个入口无 token → 401(改前改后都应通过)
"""
import datetime as _dt
from datetime import time as _time
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import ActionPlan, AISuggestion, Entity, KPIAlert, KPIDefinition, StrategicMap
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
ALERTS = "/api/cma/alerts"
DPUSH = "/api/cma/deviation-push/push-to-map"
SUGG = "/api/cma/ai/suggestions"
# kpi_code 全局唯一(models.KPIDefinition.kpi_code unique=True)→ 每个账套用独立 code
E1_ALERT_KPI = "ZZ_APRES_E1_ALERT_KPI"
E2_ALERT_KPI = "ZZ_APRES_E2_ALERT_KPI"
E1_DPUSH_KPI = "ZZ_APRES_E1_DPUSH_KPI"
E2_DPUSH_KPI = "ZZ_APRES_E2_DPUSH_KPI"
E1_SUG_KPI = "ZZ_APRES_E1_SUG_KPI"
E2_SUG_KPI = "ZZ_APRES_E2_SUG_KPI"
# ──────────────────────────────────────────────
# 夹具(沿用 1905b 同族风格:双向登录 + 同账套正对照)
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int, target: float = 1000.0) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=target,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _mk_alert(db: Session, eid: int, kpi_id: int, message: str) -> KPIAlert:
alert = KPIAlert(
entity_id=eid,
kpi_id=kpi_id,
alert_level="red",
alert_message=message,
status="pending",
)
db.add(alert)
db.commit()
db.refresh(alert)
return alert
def _mk_map(db: Session, eid: int, title: str, kpi_code: str, obj_name: str) -> StrategicMap:
m = StrategicMap(
entity_id=eid,
title=title,
version="v1.0",
status="draft",
dimensions=[{
"key": "finance", "name": "财务维度", "icon": "F", "color": "#409eff",
"objectives": [{"name": obj_name, "kpis": [kpi_code]}],
}],
canvas_data={"connections": []},
)
db.add(m)
db.commit()
db.refresh(m)
return m
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _plans_by_title(db: Session, title: str):
db.expire_all()
return db.query(ActionPlan).filter(ActionPlan.title == title).all()
def _alert_plan_title(message: str) -> str:
return f"改善: {message}"
def _dpush_topic_title(kpi: KPIDefinition, obj_name: str) -> str:
return f"【差异反打】{kpi.kpi_name}偏差回写 — {obj_name}"
def _period() -> str:
return _dt.date.today().strftime("%Y-%m")
# ──────────────────────────────────────────────
# T1. 预警关联生成计划 → 会话账套(双向 + 跨账套负对照)
# ──────────────────────────────────────────────
def test_alert_create_action_plan_writes_session_entity(client: TestClient, db: Session):
"""e1 会话 → 库内 entity_id=1;e2 会话 → 库内 entity_id=2(不得落模型默认值 1)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_ALERT_KPI, 1)
k2 = _mk_kpi(db, E2_ALERT_KPI, 2)
a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 营收严重下滑")
a2 = _mk_alert(db, 2, k2.id, "ZZ_APRES e2 营收严重下滑")
t1 = _alert_plan_title(a1.alert_message)
r1 = client.post(f"{ALERTS}/{a1.id}/create-action-plan",
headers=auth_header(_login(client, 1)), json={"assignee": "甲"})
assert r1.status_code == 200, f"e1 创建应 200,实际 {r1.status_code} {r1.text[:200]}"
rows1 = _plans_by_title(db, t1)
assert len(rows1) == 1, f"e1 预警未生成计划: {rows1}"
assert rows1[0].entity_id == 1, (
f"e1 会话经预警生成计划的 entity_id 应为 1,实际 {rows1[0].entity_id}"
)
t2 = _alert_plan_title(a2.alert_message)
r2 = client.post(f"{ALERTS}/{a2.id}/create-action-plan",
headers=auth_header(_login(client, 2)), json={"assignee": "乙"})
assert r2.status_code == 200, f"e2 创建应 200,实际 {r2.status_code} {r2.text[:200]}"
rows2 = _plans_by_title(db, t2)
assert len(rows2) == 1, f"e2 预警未生成计划: {rows2}"
assert rows2[0].entity_id == 2, (
f"e2 会话经预警生成计划的 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
" —— alerts.py 构造 ActionPlan 未写 entity_id,落模型默认值 1"
)
assert rows2[0].kpi_id == k2.id, (
f"e2 计划应挂 e2 自己的 KPI#{k2.id},实际 #{rows2[0].kpi_id}"
)
def test_alert_create_action_plan_cross_entity_404(client: TestClient, db: Session):
"""e2 token 打 e1 预警 → 404(读侧既有护栏,不得因写侧修复而退化)+ 零落行"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_ALERT_KPI, 1)
a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 跨账套负对照")
r = client.post(f"{ALERTS}/{a1.id}/create-action-plan",
headers=auth_header(_login(client, 2)), json={"assignee": "乙"})
assert r.status_code == 404, f"跨账套应 404,实际 {r.status_code} {r.text[:200]}"
assert _plans_by_title(db, _alert_plan_title(a1.alert_message)) == [], (
"跨账套 create-action-plan 竟为对端账套预警建了计划"
)
# ──────────────────────────────────────────────
# T2. 差异反打生成议题 → 会话账套(双向)
# ──────────────────────────────────────────────
def test_deviation_push_writes_session_entity(client: TestClient, db: Session):
"""e1 会话 → 议题 entity_id=1;e2 会话 → 议题 entity_id=2(不得落默认值 1)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_DPUSH_KPI, 1)
k2 = _mk_kpi(db, E2_DPUSH_KPI, 2)
m1 = _mk_map(db, 1, "ZZ_APRES e1 地图", E1_DPUSH_KPI, "e1 目标")
m2 = _mk_map(db, 2, "ZZ_APRES e2 地图", E2_DPUSH_KPI, "e2 目标")
t1 = _dpush_topic_title(k1, "e1 目标")
r1 = client.post(DPUSH, headers=auth_header(_login(client, 1)), json={
"mapId": m1.id, "nodeId": "finance-0", "newValue": 900.0,
"period": _period(), "createReviewTopic": True,
})
assert r1.status_code == 200, f"e1 反打应 200,实际 {r1.status_code} {r1.text[:300]}"
assert r1.json()["review_topic_created"] is True, r1.text[:200]
rows1 = _plans_by_title(db, t1)
assert len(rows1) == 1, f"e1 反打未生成议题: {rows1}"
assert rows1[0].entity_id == 1, (
f"e1 会话经差异反打生成议题的 entity_id 应为 1,实际 {rows1[0].entity_id}"
)
t2 = _dpush_topic_title(k2, "e2 目标")
r2 = client.post(DPUSH, headers=auth_header(_login(client, 2)), json={
"mapId": m2.id, "nodeId": "finance-0", "newValue": 900.0,
"period": _period(), "createReviewTopic": True,
})
assert r2.status_code == 200, f"e2 反打应 200,实际 {r2.status_code} {r2.text[:300]}"
assert r2.json()["review_topic_created"] is True, r2.text[:200]
rows2 = _plans_by_title(db, t2)
assert len(rows2) == 1, f"e2 反打未生成议题: {rows2}"
assert rows2[0].entity_id == 2, (
f"e2 会话经差异反打生成议题的 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
" —— deviation_push.py 构造 ActionPlan 未写 entity_id,落模型默认值 1"
)
assert rows2[0].kpi_id == k2.id, (
f"e2 议题应挂 e2 自己的 KPI#{k2.id},实际 #{rows2[0].kpi_id}"
)
# ──────────────────────────────────────────────
# T3. 差异反打去重按账套隔离(对端同名议题不得抑制本账套创建)
# ──────────────────────────────────────────────
def test_deviation_push_dedupe_scoped_to_entity(client: TestClient, db: Session):
"""e1 已有同名 pending 议题(含历史遗留 entity_id=1 行)不得抑制 e2 创建自己的议题"""
_seed_two_entities(db)
_mk_kpi(db, E1_DPUSH_KPI, 1)
k2 = _mk_kpi(db, E2_DPUSH_KPI, 2)
m2 = _mk_map(db, 2, "ZZ_APRES e2 地图去重", E2_DPUSH_KPI, "e2 去重目标")
t2 = _dpush_topic_title(k2, "e2 去重目标")
# 对端账套(e1)先落一条同标题的 pending 议题(模拟历史遗留/另一账套真实办理中)
foreign = ActionPlan(
entity_id=1, kpi_id=db.query(KPIDefinition).filter(
KPIDefinition.kpi_code == E1_DPUSH_KPI).first().id,
title=t2, assignee="甲", priority="medium", status="pending", progress=0,
)
db.add(foreign)
db.commit()
r2 = client.post(DPUSH, headers=auth_header(_login(client, 2)), json={
"mapId": m2.id, "nodeId": "finance-0", "newValue": 900.0,
"period": _period(), "createReviewTopic": True,
})
assert r2.status_code == 200, f"e2 反打应 200,实际 {r2.status_code} {r2.text[:300]}"
assert r2.json()["review_topic_created"] is True, (
"对端账套同名议题抑制了本账套创建(去重条件未按账套隔离)"
)
rows = _plans_by_title(db, t2)
assert len(rows) == 2, f"应有 e1 遗留 + e2 新建共 2 条,实际 {len(rows)}"
own = [x for x in rows if x.entity_id == 2]
assert len(own) == 1, (
f"e2 本账套议题未落库或落错账套: {[(x.id, x.entity_id) for x in rows]}"
)
# ──────────────────────────────────────────────
# T4. AI 建议转行动计划 → 会话账套(双向)
# ──────────────────────────────────────────────
def _mk_suggestion(client: TestClient, token: str, kpi_id: int, title: str):
body = {
"suggestion_type": "action_plan",
"target_type": "kpi",
"target_id": kpi_id,
"title": title,
"content": "达成率低于阈值",
"suggestion_data": {"kpi_id": kpi_id},
}
r = client.post(SUGG, json=body, headers=auth_header(token))
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
return r.json()["data"]["id"]
def test_ai_suggestion_action_plan_writes_session_entity(client: TestClient, db: Session):
"""e1 会话 → 计划 entity_id=1;e2 会话 → 计划 entity_id=2(不得落默认值 1)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_SUG_KPI, 1)
k2 = _mk_kpi(db, E2_SUG_KPI, 2)
tok1 = _login(client, 1)
s1 = _mk_suggestion(client, tok1, k1.id, "ZZ_APRES e1 建议转计划")
r1 = client.post(f"{SUGG}/{s1}/apply", headers=auth_header(tok1),
json={"action": "action_plan", "title": "ZZ_APRES e1 转出的计划"})
assert r1.status_code == 200, f"e1 应用应 200,实际 {r1.status_code} {r1.text[:300]}"
rows1 = _plans_by_title(db, "ZZ_APRES e1 转出的计划")
assert len(rows1) == 1, f"e1 应用未生成计划: {rows1}"
assert rows1[0].entity_id == 1, (
f"e1 会话经 AI 建议生成的计划 entity_id 应为 1,实际 {rows1[0].entity_id}"
)
tok2 = _login(client, 2)
s2 = _mk_suggestion(client, tok2, k2.id, "ZZ_APRES e2 建议转计划")
r2 = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok2),
json={"action": "action_plan", "title": "ZZ_APRES e2 转出的计划"})
assert r2.status_code == 200, f"e2 应用应 200,实际 {r2.status_code} {r2.text[:300]}"
rows2 = _plans_by_title(db, "ZZ_APRES e2 转出的计划")
assert len(rows2) == 1, f"e2 应用未生成计划: {rows2}"
assert rows2[0].entity_id == 2, (
f"e2 会话经 AI 建议生成的计划 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
" —— ai_suggestions.py 构造 ActionPlan 未写 entity_id,落模型默认值 1"
)
def test_ai_suggestion_apply_cross_entity_404(client: TestClient, db: Session):
"""e1 会话应用 e2 的建议 → 404(建议须归属会话账套)+ 零落行;本账套应用正常(正对照)"""
_seed_two_entities(db)
_mk_kpi(db, E1_SUG_KPI, 1)
k2 = _mk_kpi(db, E2_SUG_KPI, 2)
tok2 = _login(client, 2)
s2 = _mk_suggestion(client, tok2, k2.id, "ZZ_APRES e2 私有建议")
tok1 = _login(client, 1)
r = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok1),
json={"action": "action_plan", "title": "ZZ_APRES 越权转出的计划"})
assert r.status_code == 404, f"跨账套应用应 404,实际 {r.status_code} {r.text[:300]}"
assert _plans_by_title(db, "ZZ_APRES 越权转出的计划") == [], (
"跨账套应用竟为对端账套建议建了计划"
)
db.expire_all()
sug = db.query(AISuggestion).filter(AISuggestion.id == s2).first()
assert sug.status == "unapplied", f"越权应用改动了建议状态: {sug.status}"
# 正对照:本账套会话应用自己账套的建议仍然正常
r_ok = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok2),
json={"action": "action_plan", "title": "ZZ_APRES e2 自救计划"})
assert r_ok.status_code == 200, f"本账套应用应 200,实际 {r_ok.status_code} {r_ok.text[:300]}"
assert len(_plans_by_title(db, "ZZ_APRES e2 自救计划")) == 1
# ──────────────────────────────────────────────
# T5. 鉴权底线(三个入口无 token → 401)
# ──────────────────────────────────────────────
def test_residual_sites_without_token_401(client: TestClient, db: Session):
"""三个入口无 token 一律 401(改前改后都应通过)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_ALERT_KPI, 1)
a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 鉴权底线")
m1 = _mk_map(db, 1, "ZZ_APRES e1 鉴权地图", E1_ALERT_KPI, "e1 鉴权目标")
r1 = client.post(f"{ALERTS}/{a1.id}/create-action-plan", json={})
assert r1.status_code == 401, f"预警入口无 token 应 401,实际 {r1.status_code}"
r2 = client.post(DPUSH, json={"mapId": m1.id, "nodeId": "finance-0",
"newValue": 800.0, "createReviewTopic": False})
assert r2.status_code == 401, f"差异反打无 token 应 401,实际 {r2.status_code}"
r3 = client.post(SUGG, json={"suggestion_type": "action_plan", "title": "无 token"})
assert r3.status_code == 401, f"AI 建议无 token 应 401,实际 {r3.status_code}"
@@ -0,0 +1,297 @@
"""行动方案库「已逾期」口径一致性测试
任务:project-20260921-actionplan-overdue-filter
(卡片 7 条 → 列表 0 条)
缺陷
前端统计卡片把 key='overdue' 直接当 status 传给
GET /api/cma/action-plans(ActionPlanLibrary.vue:414 卡片 key / :26 filterStatus / :422 params.status),
而 list_plans 原实现按 `ActionPlan.status == 'overdue'` 直查 —— status 取值域只有
pending / in_progress / completed / cancelled,**没有 overdue** → 恒 0 条。
/stats 的 overdue 是派生口径:status in (pending, in_progress) AND due_date < now。
两处口径分家 ⇒ 同一张卡片「数 7 条 / 列表 0 条」。
修复
list_plans 在 status == 'overdue' 时走 _apply_overdue(),与 plan_stats **共用同一实现**。
本文件覆盖
① 已逾期列表条数 == stats.overdue(HTTP 全链路:鉴权 + 账套 + 查询参数)
② 其余卡片(total/pending/in_progress/completed)列表条数与 stats 逐项相符
③ 口径同源:派生集合 ⊆ {pending,in_progress} 且 due_date < now;status=completed/cancelled
即使已过期也不计入
④ 边界:刚过期(-60s)计入 / 未到期(+60s)不计入 / due_date 为空不计入
⑤ 账套隔离:e1 会话只看 e1 KPI 下的计划,e2 逾期计划不出现在 e1 的逾期列表
⑥ business 角色过滤:只统计/返回自己的计划(列表与统计同时收窄)
⑦ 负对照:同一批夹具下「旧口径」status=='overdue' 确为 0 条(证明夹具真能复现缺陷)
"""
import inspect
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.api import action_plans as ap
from app.models import ActionPlan, Entity, KPIDefinition
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user, create_test_kpi
E1_KPI_CODE = "ZZ_APOVD_E1_KPI_001"
E2_KPI_CODE = "ZZ_APOVD_E2_KPI_001"
T_OVERDUE_PENDING = "逾期待处理"
T_OVERDUE_INPROG = "逾期进行中"
T_FUTURE_PENDING = "未到期待处理"
T_OVERDUE_COMPLETED = "已完成且已过期"
T_OVERDUE_CANCELLED = "已取消且已过期"
T_NO_DUE_PENDING = "无截止日待处理"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
return create_test_kpi(db, kpi_code=code, kpi_name=f"{code}-名称", entity_id=eid)
def _mk_plan(db: Session, kpi_id: int, title: str, status: str, due_delta_sec,
assignee: str = "张三") -> ActionPlan:
due = None if due_delta_sec is None else datetime.now() + timedelta(seconds=due_delta_sec)
plan = ActionPlan(
entity_id=1,
kpi_id=kpi_id,
title=title,
status=status,
due_date=due,
assignee=assignee,
priority="medium",
progress=0,
created_by="testadmin",
)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _login(client: TestClient, entity_id: int, username: str = "testadmin") -> str:
resp = client.post("/api/cma/auth/login", json={
"username": username,
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _seed_e1(db: Session):
"""e1 夹具:2 条逾期 + 4 条不计入(未到期 / 已完成 / 已取消 / 无截止日)"""
_ensure_entity(db, 1, "酣客")
create_test_user(db)
kpi = _mk_kpi(db, E1_KPI_CODE, 1)
_mk_plan(db, kpi.id, T_OVERDUE_PENDING, "pending", -86400) # 逾期
_mk_plan(db, kpi.id, T_OVERDUE_INPROG, "in_progress", -3600) # 逾期
_mk_plan(db, kpi.id, T_FUTURE_PENDING, "pending", +86400) # 未到期
_mk_plan(db, kpi.id, T_OVERDUE_COMPLETED, "completed", -86400) # 已完成→不算逾期
_mk_plan(db, kpi.id, T_OVERDUE_CANCELLED, "cancelled", -86400) # 已取消→不算逾期
_mk_plan(db, kpi.id, T_NO_DUE_PENDING, "pending", None) # 无截止日
return kpi
def _titles(resp_json) -> set:
return {row["title"] for row in resp_json["data"]}
# ──────────────────────────────────────────────
# ① 已逾期列表条数 == stats.overdue(HTTP 全链路)
# ──────────────────────────────────────────────
def test_overdue_list_count_equals_stats(client: TestClient, db: Session):
"""核心验收:?status=overdue 的条数 == /stats 的 overdue,且只含派生口径内的行"""
_seed_e1(db)
token = _login(client, 1)
h = auth_header(token)
stats = client.get("/api/cma/action-plans/stats", headers=h).json()
listed = client.get("/api/cma/action-plans?status=overdue", headers=h).json()
assert stats["overdue"] == 2, f"夹具期望 overdue=2,实际 {stats['overdue']}"
assert len(listed["data"]) == stats["overdue"], (
f"卡片 overdue={stats['overdue']} 与列表 {len(listed['data'])} 条不一致"
"(缺陷复现:卡片数≠列表数)"
)
assert _titles(listed) == {T_OVERDUE_PENDING, T_OVERDUE_INPROG}, (
f"逾期列表内容应为 2 条逾期待办,实际 {sorted(_titles(listed))}"
)
# 口径内容断言:全部落在 status ∈ {pending, in_progress} 且 due_date < now
for row in listed["data"]:
assert row["status"] in ("pending", "in_progress"), row
assert row["due_date"] and datetime.fromisoformat(row["due_date"]) < datetime.now(), row
# ──────────────────────────────────────────────
# ② 其余卡片逐项相符
# ──────────────────────────────────────────────
def test_other_cards_match_stats(client: TestClient, db: Session):
"""回归:total/pending/in_progress/completed 列表条数与 stats 逐项相符"""
_seed_e1(db)
token = _login(client, 1)
h = auth_header(token)
stats = client.get("/api/cma/action-plans/stats", headers=h).json()
assert stats == {"total": 6, "pending": 3, "in_progress": 1,
"completed": 1, "overdue": 2}, stats
cards = {"total": "", "pending": "pending", "in_progress": "in_progress",
"completed": "completed", "overdue": "overdue"}
for key, param in cards.items():
url = "/api/cma/action-plans" + (f"?status={param}" if param else "")
rows = client.get(url, headers=h).json()["data"]
assert len(rows) == stats[key], f"卡片 {key}: stats={stats[key]} 列表={len(rows)}"
# ──────────────────────────────────────────────
# ③ 口径同源(派生集合 + 源码单一来源)
# ──────────────────────────────────────────────
def test_overdue_excludes_completed_and_cancelled(client: TestClient, db: Session):
"""已完成/已取消即使早已过期也不计入逾期(口径 = status ∈ {pending,in_progress})"""
_seed_e1(db)
token = _login(client, 1)
h = auth_header(token)
titles = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h).json())
assert T_OVERDUE_COMPLETED not in titles
assert T_OVERDUE_CANCELLED not in titles
assert T_NO_DUE_PENDING not in titles
# 字面 status 过滤未被改动(仍按 status 直查)
assert _titles(client.get("/api/cma/action-plans?status=cancelled", headers=h).json()) == {
T_OVERDUE_CANCELLED}
assert len(client.get("/api/cma/action-plans?status=pending", headers=h).json()["data"]) == 3
def test_overdue_caliber_is_single_source():
"""统计与列表共用同一实现:plan_stats 与 list_plans 都调用 _apply_overdue"""
stats_src = inspect.getsource(ap.plan_stats)
list_src = inspect.getsource(ap.list_plans)
assert "_apply_overdue(query)" in stats_src, "plan_stats 未走 _apply_overdue(口径分家)"
assert "_apply_overdue(query)" in list_src, "list_plans 未走 _apply_overdue(口径分家)"
assert ap.OVERDUE_STATUSES == ("pending", "in_progress")
assert ap.OVERDUE_KEY == "overdue"
# ──────────────────────────────────────────────
# ④ 边界
# ──────────────────────────────────────────────
def test_overdue_boundary_and_null_due_date(client: TestClient, db: Session):
"""边界:-60s 计入、+60s 不计入、due_date 为空不计入"""
_ensure_entity(db, 1, "酣客")
create_test_user(db)
kpi = _mk_kpi(db, E1_KPI_CODE, 1)
_mk_plan(db, kpi.id, "刚过期60秒", "pending", -60)
_mk_plan(db, kpi.id, "还有60秒到期", "pending", +60)
_mk_plan(db, kpi.id, "无截止日", "pending", None)
token = _login(client, 1)
h = auth_header(token)
titles = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h).json())
assert titles == {"刚过期60秒"}, titles
stats = client.get("/api/cma/action-plans/stats", headers=h).json()
assert stats["overdue"] == 1 and stats["pending"] == 3 and stats["total"] == 3, stats
# ──────────────────────────────────────────────
# ⑤ 账套隔离
# ──────────────────────────────────────────────
def test_overdue_entity_isolation(client: TestClient, db: Session):
"""e1/e2 各自只看到本账套 KPI 下的计划;逾期口径同样按账套收窄"""
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
k1 = _mk_kpi(db, E1_KPI_CODE, 1)
k2 = _mk_kpi(db, E2_KPI_CODE, 2)
_mk_plan(db, k1.id, "e1逾期", "pending", -3600)
p2 = _mk_plan(db, k2.id, "e2逾期", "pending", -3600)
p2.entity_id = 2
db.commit()
h1 = auth_header(_login(client, 1))
h2 = auth_header(_login(client, 2))
s1 = client.get("/api/cma/action-plans/stats", headers=h1).json()
s2 = client.get("/api/cma/action-plans/stats", headers=h2).json()
l1 = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h1).json())
l2 = _titles(client.get("/api/cma/action-plans?status=overdue", headers=h2).json())
assert s1["overdue"] == 1 and s2["overdue"] == 1, (s1, s2)
assert l1 == {"e1逾期"}, l1
assert l2 == {"e2逾期"}, l2
assert len(client.get("/api/cma/action-plans", headers=h1).json()["data"]) == s1["total"] == 1
assert len(client.get("/api/cma/action-plans", headers=h2).json()["data"]) == s2["total"] == 1
# ──────────────────────────────────────────────
# ⑥ business 角色过滤
# ──────────────────────────────────────────────
def test_overdue_business_role_filter(client: TestClient, db: Session):
"""business 角色:列表与统计同时只收窄到自己名下(逾期口径也随之下沉)"""
_ensure_entity(db, 1, "酣客")
create_test_user(db)
create_test_user(db, username="业务小王", name="业务小王", role="business")
kpi = _mk_kpi(db, E1_KPI_CODE, 1)
_mk_plan(db, kpi.id, "小王的逾期", "pending", -3600, assignee="业务小王")
_mk_plan(db, kpi.id, "张三的逾期", "pending", -3600, assignee="张三")
_mk_plan(db, kpi.id, "小王的未到期", "pending", +3600, assignee="业务小王")
h = auth_header(_login(client, 1, username="业务小王"))
stats = client.get("/api/cma/action-plans/stats", headers=h).json()
assert stats == {"total": 2, "pending": 2, "in_progress": 0,
"completed": 0, "overdue": 1}, stats
listed = client.get("/api/cma/action-plans?status=overdue", headers=h).json()["data"]
assert len(listed) == stats["overdue"] == 1
assert listed[0]["title"] == "小王的逾期"
assert len(client.get("/api/cma/action-plans", headers=h).json()["data"]) == stats["total"]
def test_overdue_with_other_filters_compose(client: TestClient, db: Session):
"""派生口径与 kpi_id / keyword 组合时仍成立(不覆盖既有筛选)"""
kpi = _seed_e1(db)
token = _login(client, 1)
h = auth_header(token)
hit = client.get(f"/api/cma/action-plans?status=overdue&kpi_id={kpi.id}", headers=h).json()
assert len(hit["data"]) == 2
miss = client.get(f"/api/cma/action-plans?status=overdue&kpi_id={kpi.id + 999}", headers=h).json()
assert miss["data"] == []
kw = client.get("/api/cma/action-plans?status=overdue&keyword=逾期", headers=h).json()
assert _titles(kw) == {T_OVERDUE_PENDING, T_OVERDUE_INPROG}
# ──────────────────────────────────────────────
# ⑦ 负对照:旧口径在同一夹具上确为 0
# ──────────────────────────────────────────────
def test_negative_control_legacy_caliber_returns_zero(db: Session):
"""负对照:同一批夹具下 `status=='overdue'` 的行数 = 0、派生口径 = 2
证明夹具真的能复现该缺陷(若这里两边相等,用例就没有鉴别力)。
"""
_seed_e1(db)
literal = db.query(ActionPlan).filter(ActionPlan.status == "overdue").count()
derived = ap._apply_overdue(db.query(ActionPlan)).count()
assert literal == 0, "status 域不应存在 'overdue' 值"
assert derived == 2, derived
@@ -0,0 +1,291 @@
"""CMA 跨实体隔离 — AI 建议同文件残余两处 KPI 归属(2026-09-18 派生单 2030b)
任务:fullstack-20260918-2030b-aisuggestion-kpi-attribution-residual
父单:fullstack-20260918-2030-dismiss-suggestion-entity-scope
(其执行期对本文件 app/api/ai_suggestions.py 的只读复查 §五 范围外发现)
缺陷(同文件 ai_suggestions.py 两处 KPIDefinition 只按 id 直取、不校验归属):
1. `_apply_action_plan` (原 239 行) — 建议自身归属校验通过后,其
suggestion_data.kpi_id / target_id 指向对端账套 KPI 时,会建出
ActionPlan(entity_id=本账套, kpi_id=对端账套KPI) 的跨账套引用行
(计划列表按账套可见、KPI 名解析取到对端账套对象)。
2. `preview_suggestion` (原 314 行) — 同一写法;建议自身已被账套收敛(读侧单 2025),
但其 suggestion_data.kpi_id 指向对端 KPI 时,预览会回显对端账套的 KPI 名与
current_target(只读泄漏)。
修复口径:
- `_apply_action_plan`:KPI 查询补 `entity_id == sug.entity_id`,
不匹配抛 404「KPI {id} 不存在」(对齐 2030 单 `_apply_kpi_target`/`_apply_budget_adjust`
写法),且零落行。
- `preview_suggestion`:沿用现有降级语义(kpi=None → 展示 `KPI#<id>`、current_target=None),
不抛 404 —— 理由见报告 §三取舍说明(读侧端点不应因数据不自洽而整体不可用)。
对端记录口径 = 404「KPI {id} 不存在」(写侧)/ 降级占位(读侧)。
本用例(改前 RED 原文见 _reports/evidence-fullstack-20260918-2030b-red.txt):
- T1 应用 action_plan:目标 KPI 非本账套 → 404 且零落 ActionPlan 行(含对端 kpi_id 归属行),
建议状态保持 unapplied;两条取数路径(suggestion_data 回退 / 请求体显式 kpi_id)均 404
- T2 应用 action_plan 正对照:本账套建议 + 本账套 KPI → 200 且落行 entity_id=会话账套、
kpi_id=本账套 KPI
- T3 预览:目标 KPI 非本账套 → 不回显对端 KPI 名与 current_target,降级为 KPI#<id>
- T4 预览正对照:本账套 KPI → kpi_name / current_target 完整回显
- T5 鉴权底线:preview / apply(action_plan) 无 token → 401(改前改后都应通过)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import AISuggestion, ActionPlan, Entity, KPIDefinition
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
SUGG = "/api/cma/ai/suggestions"
# kpi_code 全局唯一 → 每账套独立 code(ZZ_ 前缀为本单专用,避免与同族用例撞名)
E1_KPI = "ZZ_AKR_E1_KPI"
E2_KPI = "ZZ_AKR_E2_KPI"
E1_SUG_TITLE = "ZZ_AKR e1 私有建议"
E2_SUG_TITLE = "ZZ_AKR e2 私有建议"
CROSS_SUG_TITLE = "ZZ_AKR e2 指向对端 KPI 的建议"
E1_TARGET = 1000.0
E2_TARGET = 3000.0
# ──────────────────────────────────────────────
# 夹具(沿用 2030 单风格:双向登录 + 同账套正对照)
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int, target: float = E1_TARGET) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=target,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _mk_suggestion(client: TestClient, token: str, title: str,
target_id: int, kpi_id: int,
target_value: float = 2000.0,
suggestion_type: str = "kpi_target") -> int:
body = {
"suggestion_type": suggestion_type,
"target_type": "kpi",
"target_id": target_id,
"title": title,
"content": "目标偏低,建议上调",
"suggestion_data": {"kpi_id": kpi_id, "target_value": target_value},
}
r = client.post(SUGG, json=body, headers=auth_header(token))
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
return r.json()["data"]["id"]
def _sug_status(db: Session, sid: int) -> str:
db.expire_all()
row = db.query(AISuggestion).filter(AISuggestion.id == sid).first()
assert row is not None, f"建议 #{sid} 不存在"
return row.status
def _plan_rows(db: Session, kpi_id: int = None):
db.expire_all()
q = db.query(ActionPlan)
if kpi_id is not None:
q = q.filter(ActionPlan.kpi_id == kpi_id)
return q.all()
# ──────────────────────────────────────────────
# T1. 应用 action_plan:目标 KPI 归属校验(两条取数路径)
# ──────────────────────────────────────────────
def test_apply_action_plan_foreign_kpi_404(client: TestClient, db: Session):
"""建议属本账套但其目标 KPI 属对端 → 404 且零落行;对端 KPI 行数逐值未变"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
k2 = _mk_kpi(db, E2_KPI, 2, target=E2_TARGET)
tok2 = _login(client, 2)
# e2 会话创建建议(归属 e2),但 target_id/suggestion_data.kpi_id 指向 e1 的 KPI
# (模拟手填数据 / 迁移遗留 / Bot 通道写入的不自洽数据)
s_cross = _mk_suggestion(client, tok2, CROSS_SUG_TITLE, k1.id, k1.id)
rows_before_e1 = len(_plan_rows(db, k1.id))
rows_before_all = len(_plan_rows(db))
assert rows_before_e1 == 0, f"前置:e1 KPI 已有行动方案 {rows_before_e1} 行"
# 路径 A:请求体不给 kpi_id → 回退 suggestion_data.kpi_id(对端账套)
r_a = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
json={"action": "action_plan", "title": "ZZ_AKR 跨账套计划A"})
assert r_a.status_code == 404, (
f"action_plan 目标 KPI 属对端账套应 404,实际 {r_a.status_code} {r_a.text[:200]}"
" —— _apply_action_plan 只按 id 查 KPIDefinition,未校验归属会话账套"
)
# 路径 B:请求体显式给 kpi_id(对端账套)
r_b = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
json={"action": "action_plan", "kpi_id": k1.id,
"title": "ZZ_AKR 跨账套计划B"})
assert r_b.status_code == 404, (
f"显式 kpi_id 指向对端账套应 404,实际 {r_b.status_code} {r_b.text[:200]}"
)
rows_after_e1 = len(_plan_rows(db, k1.id))
rows_after_all = len(_plan_rows(db))
assert rows_after_e1 == rows_before_e1, (
f"越权 action_plan 落了跨账套引用行(对端 kpi_id={k1.id}):"
f"{rows_before_e1} → {rows_after_e1}"
)
assert rows_after_all == rows_before_all, (
f"越权 action_plan 落了行(总数):{rows_before_all} → {rows_after_all}"
)
assert _sug_status(db, s_cross) == "unapplied", (
"越权应用竟改动了建议状态(应保持 unapplied)"
)
# 正对照前置:本账套 KPI 未被误伤
assert len(_plan_rows(db, k2.id)) == 0
# ──────────────────────────────────────────────
# T2. 应用 action_plan 正对照:本账套正常落行
# ──────────────────────────────────────────────
def test_apply_action_plan_own_kpi_ok(client: TestClient, db: Session):
"""本账套建议 + 本账套 KPI → 200,落行 entity_id=会话账套、kpi_id=本账套 KPI"""
_seed_two_entities(db)
_mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
k2 = _mk_kpi(db, E2_KPI, 2, target=E2_TARGET)
tok2 = _login(client, 2)
s_ok = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id)
r_ok = client.post(f"{SUGG}/{s_ok}/apply", headers=auth_header(tok2),
json={"action": "action_plan", "title": "ZZ_AKR 本账套计划",
"assignee": "张三", "priority": "high",
"due_date": "2026-09-30"})
assert r_ok.status_code == 200, (
f"本账套 action_plan 应用应 200,实际 {r_ok.status_code} {r_ok.text[:300]}"
)
rows = _plan_rows(db, k2.id)
assert len(rows) == 1, f"本账套应用未落行(kpi_id={k2.id} 现有 {len(rows)} 行)"
plan = rows[0]
assert plan.entity_id == 2, (
f"落行 entity_id 应为会话账套 2,实际 {plan.entity_id}"
)
assert plan.kpi_id == k2.id, f"落行 kpi_id 应为 {k2.id},实际 {plan.kpi_id}"
assert plan.title == "ZZ_AKR 本账套计划"
assert _sug_status(db, s_ok) == "applied", "本账套应用未落建议状态 applied"
# ──────────────────────────────────────────────
# T3. 预览:对端 KPI 不回显
# ──────────────────────────────────────────────
def test_preview_foreign_kpi_not_leaked(client: TestClient, db: Session):
"""建议属本账套、其 kpi_id 属对端 → 预览不回显对端 KPI 名/current_target"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
_mk_kpi(db, E2_KPI, 2, target=E2_TARGET)
tok2 = _login(client, 2)
s_cross = _mk_suggestion(client, tok2, CROSS_SUG_TITLE, k1.id, k1.id)
r = client.get(f"{SUGG}/{s_cross}/preview", headers=auth_header(tok2))
assert r.status_code == 200, f"预览应 200(降级语义),实际 {r.status_code} {r.text[:200]}"
data = r.json().get("data") or {}
blob = str(r.json())
assert f"{E1_KPI}-名称" not in blob, (
"预览回显了对端账套的 KPI 名(只读泄漏)"
" —— preview_suggestion 的 KPIDefinition 查询未校验归属会话账套"
)
assert f"KPI#{k1.id}" == data.get("kpi_name"), (
f"对端 KPI 应降级为占位名 KPI#{k1.id},实际 {data.get('kpi_name')!r}"
)
assert data.get("current_target") is None, (
f"预览泄漏了对端账套 KPI 目标值:{data.get('current_target')!r}"
)
# ──────────────────────────────────────────────
# T4. 预览正对照:本账套 KPI 字段完整
# ──────────────────────────────────────────────
def test_preview_own_kpi_complete(client: TestClient, db: Session):
"""本账套建议 + 本账套 KPI → kpi_name / current_target 完整回显"""
_seed_two_entities(db)
_mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
k2 = _mk_kpi(db, E2_KPI, 2, target=E2_TARGET)
tok2 = _login(client, 2)
s_ok = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id, target_value=4500.0)
r = client.get(f"{SUGG}/{s_ok}/preview", headers=auth_header(tok2))
assert r.status_code == 200, f"本账套预览应 200,实际 {r.status_code} {r.text[:200]}"
data = r.json().get("data") or {}
assert data.get("kpi_name") == f"{E2_KPI}-名称", (
f"本账套预览未回显 KPI 名,实际 {data.get('kpi_name')!r}"
)
assert data.get("current_target") == E2_TARGET, (
f"本账套预览 current_target 应为 {E2_TARGET},实际 {data.get('current_target')!r}"
)
assert data.get("new_target") == 4500.0, (
f"本账套预览 new_target 应为 4500.0,实际 {data.get('new_target')!r}"
)
# ──────────────────────────────────────────────
# T5. 鉴权底线(preview / apply 无 token → 401)
# ──────────────────────────────────────────────
def test_preview_and_apply_without_token_401(client: TestClient, db: Session):
"""无 token 一律 401(改前改后都应通过)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1, target=E1_TARGET)
tok2 = _login(client, 2)
s = _mk_suggestion(client, tok2, CROSS_SUG_TITLE, k1.id, k1.id)
r1 = client.get(f"{SUGG}/{s}/preview")
assert r1.status_code == 401, f"preview 无 token 应 401,实际 {r1.status_code}"
r2 = client.post(f"{SUGG}/{s}/apply", json={"action": "action_plan"})
assert r2.status_code == 401, f"apply 无 token 应 401,实际 {r2.status_code}"
@@ -0,0 +1,366 @@
# -*- coding: utf-8 -*-
"""财务预警口径裁定 R1–R4 落地测试(2026-09-19)
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`(任总 2026-09-18「按建议执行」)
· R1 —— `entities.status='demo'` 的账套不参与预警生成/遍历(demo 仅演示展示);
· R2 —— 预警遍历期间 = 当月往前 6 个自然月(含当月),更早期间不再新生成;
· R3 —— 仅规整月期间 `YYYY-MM` 参与,`H1/H2/FY` 等伪期间一律不参与;
· R4 —— 生成与推送解耦:`notification_channels` 为空时仅入库、不报错、不阻断。
口径唯一事实源:`app/utils/alert_entity_scope.py`(本套件同时锁该模块的入口函数)。
"""
from datetime import datetime
import pytest
from sqlalchemy.orm import Session
from tests.conftest import create_test_kpi
from app.models import Entity, KPIValue, KPIAlert, NotificationChannel
from app.utils.alert_entity_scope import (
ALERT_EXCLUDED_ENTITY_STATUSES, ALERT_WINDOW_MONTHS, excluded_entity_ids,
is_excluded_entity, is_month_period, kpi_excluded, period_in_window, window_lower_period,
)
from scripts.alert_generator import generate_and_push, run_alert_check
#: 固定「当月」= 2026-09 → 窗口 = 2026-04 ~ 2026-09(口径复算不依赖真实时钟)
NOW = datetime(2026, 9, 19, 10, 0)
RED_EXPR = "<58.4" # 0<实测<10 比值形态不触发 dimension_suspect(见裁定2a 降级门)
def _ent(db, eid, status, name=None):
db.add(Entity(id=eid, name=name or ("账套%d" % eid), short_name="e%d" % eid, status=status))
db.commit()
def _kpi_value(db, kpi, period, value, entity_id=1):
db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value))
db.commit()
def _demo_and_active(db):
_ent(db, 1, "active", "陕西酣客文化传媒")
_ent(db, 6, "demo", "演示企业(虚构数据)")
# ───────────────────────── R1:demo 账套排除 ─────────────────────────
class TestR1DemoEntityExcluded:
def test_excluded_ids_only_demo(self, db: Session):
_ent(db, 1, "active")
_ent(db, 3, "demo")
_ent(db, 4, "demo")
_ent(db, 5, "demo")
_ent(db, 6, "demo")
assert excluded_entity_ids(db) == {3, 4, 5, 6}
assert is_excluded_entity(db, 6) is True
assert is_excluded_entity(db, 1) is False
assert is_excluded_entity(db, None) is False, "None → 默认账套 1(不被排除)"
assert ALERT_EXCLUDED_ENTITY_STATUSES == ("demo",)
def test_demo_entity_missing_table_fail_open(self, db: Session, monkeypatch):
"""口径读取失败时 fail-open(不阻断预警主流程),返回空集"""
import app.utils.alert_entity_scope as scope
def _boom(*a, **kw):
raise RuntimeError("simulated db error")
monkeypatch.setattr(db, "query", _boom)
assert scope.excluded_entity_ids(db) == set()
def test_demo_kpi_not_traversed_active_kpi_still_is(self, db: Session):
"""同一形态两份 KPI:demo 账套(e6) 跳过、active 账套(e1) 正常判定(正对照)"""
_demo_and_active(db)
k_demo = create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入",
entity_id=6, unit="%", threshold_red=RED_EXPR)
k_real = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
entity_id=1, unit="%", threshold_red=RED_EXPR)
_kpi_value(db, k_demo, "2026-09", 14.45, entity_id=6)
_kpi_value(db, k_real, "2026-09", 14.45, entity_id=1)
st = {}
n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW)
assert n == 1, "只有 active 账套的 KPI 参与判定"
assert st["skipped_demo_kpis"] == 1
assert st["demo_kpis"] == ["DEMO_REVENUE/e6"]
assert st["excluded_demo_entities"] == [6]
assert [p["kpi"].split("/")[0] for p in st["planned"]] == ["F_REVENUE"]
assert kpi_excluded(db, k_demo) is True and kpi_excluded(db, k_real) is False
def test_demo_kpi_writes_no_alert(self, db: Session):
"""非 dry-run:demo KPI 不落库;active KPI 落库(前后计数差 =1)"""
_demo_and_active(db)
k_demo = create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入",
entity_id=6, unit="%", threshold_red=RED_EXPR)
k_real = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
entity_id=1, unit="%", threshold_red=RED_EXPR)
_kpi_value(db, k_demo, "2026-09", 14.45, entity_id=6)
_kpi_value(db, k_real, "2026-09", 14.45, entity_id=1)
before = db.query(KPIAlert).count()
st = {}
n = run_alert_check(db, dry_run=False, stats=st, now=NOW)
db.expire_all()
rows = db.query(KPIAlert).all()
assert n == 1 and len(rows) == before + 1
assert [r.entity_id for r in rows] == [1], "demo 账套零预警"
def test_rule_engine_skips_demo_entity(self, db: Session, monkeypatch):
"""规则引擎(按 AlertRule.entity_id 遍历)跳过 demo 账套"""
from app.api import alert_rules as ar
from scripts import alert_generator as ag
ar.AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True)
_demo_and_active(db)
called = []
monkeypatch.setattr(ar, "run_forecast_deviation_check",
lambda db, entity_id: called.append(("fd", entity_id)) or {"generated": 0})
monkeypatch.setattr(ar, "run_customer_concentration_check",
lambda db, entity_id: called.append(("cc", entity_id)) or {"generated": 0})
for eid in (1, 6):
db.add(ar.AlertRule(entity_id=eid, kpi_id=1, rule_type="forecast_deviation", enabled=1))
db.commit()
stats = ag.run_rule_engine_checks(db)
assert stats["excluded_entities"] == [6]
assert stats["skipped_demo_entities"] == 1
assert sorted(e for _, e in called) == [1, 1], "demo 账套一条规则都不跑"
def test_alert_rules_endpoints_skip_demo(self, db: Session):
"""生成端点对 demo 账套直接返回 skipped(不起规则遍历)"""
from app.api.alert_rules import (
AlertRule, run_all_alert_checks, run_forecast_alert_check,
run_forecast_deviation_check, run_customer_concentration_check,
)
AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True)
_demo_and_active(db)
for fn in (run_all_alert_checks, run_forecast_alert_check,
run_forecast_deviation_check, run_customer_concentration_check):
r = fn(db=db, entity_id=6)
assert r.get("skipped") is True, fn.__name__
assert r.get("reason") == "R1_demo_entity_excluded"
assert r.get("generated") == 0
def test_cash_alerts_skip_demo(self, db: Session):
from app.utils.cash_forecast_engine import check_cash_alerts
_demo_and_active(db)
r = check_cash_alerts(db, entity_id=6)
assert r["skipped"] is True and r["reason"] == "R1_demo_entity_excluded"
assert db.query(KPIAlert).count() == 0
def test_deviation_check_excludes_demo_kpi(self, db: Session, monkeypatch):
"""差异预警遍历(daily_sync 第4步)同样排除 demo 账套 KPI"""
import app.utils.deviation_engine as de
_demo_and_active(db)
k_demo = create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入",
entity_id=6, unit="%", threshold_red=RED_EXPR)
k_real = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
entity_id=1, unit="%", threshold_red=RED_EXPR)
# R5-补1(2026-09-19):差异预警须落 kpi_value_id 关联,故夹具补该期间的 kpi_values 行
#(否则按补1 跳过不入库,本用例将观察不到「遍历范围」这一被测口径)
_kpi_value(db, k_real, "2026-08", 14.45, entity_id=1)
# 让所有 KPI 都「差异触发」,只观察遍历范围(不依赖预算数据形态)
monkeypatch.setattr(de, "build_deviation_alert",
lambda db_s, kpi, period, *a, **kw: {
"triggered": True, "kpi_alert_level": "red", "alert_type": "budget",
"suggestion": None,
"deviation": {"actual_value": 14.45, "budget_value": 100.0,
"deviation_rate": -85.6}})
monkeypatch.setattr(de, "check_trend_anomaly", lambda *a, **kw: {"anomaly": False})
n = de.run_deviation_check(db, period="2026-08")
db.expire_all()
rows = db.query(KPIAlert).all()
assert n == 1 and [r.entity_id for r in rows] == [1]
# ───────────────────────── R2:期间窗口(6 个自然月) ─────────────────────────
class TestR2Window:
def test_window_lower_period_math(self):
assert ALERT_WINDOW_MONTHS == 6
assert window_lower_period(NOW) == "2026-04" # 2026-09 往前 5 个月
assert window_lower_period(datetime(2026, 9, 1)) == "2026-04"
assert window_lower_period(datetime(2026, 6, 30)) == "2026-01"
assert window_lower_period(datetime(2026, 1, 5)) == "2025-08" # 跨年
assert window_lower_period(datetime(2026, 3, 31)) == "2025-10"
def test_period_in_window(self):
assert period_in_window("2026-04", NOW) is True # 窗口下限(含)
assert period_in_window("2026-09", NOW) is True # 当月(含)
assert period_in_window("2026-03", NOW) is False # 窗口外
assert period_in_window("2026-01", NOW) is False
assert period_in_window("2026-H1", NOW) is False # R3
assert period_in_window(None, NOW) is False
def test_out_of_window_kpi_not_generated(self, db: Session):
"""博海形态:最新可用期间 2026-01/03(窗口外)→ 不再新生成"""
_demo_and_active(db)
for code, period in (("BH_SALES_1", "2026-03"), ("BH_SALES_5", "2026-01")):
k = create_test_kpi(db, kpi_code=code, kpi_name=code, entity_id=2,
unit="%", threshold_red=RED_EXPR)
_kpi_value(db, k, period, 63070.3, entity_id=2)
st = {}
n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW)
assert n == 0
assert st["out_of_window"] == 2
assert sorted(st["out_of_window_kpis"]) == ["BH_SALES_1[2026-03]", "BH_SALES_5[2026-01]"]
assert st["window_lower_period"] == "2026-04"
def test_window_boundary_generates_inside_only(self, db: Session):
"""边界:窗口下限 2026-04 生成;2026-03 不生成"""
_demo_and_active(db)
k_in = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1,
unit="%", threshold_red=RED_EXPR)
k_out = create_test_kpi(db, kpi_code="F_COST_RATIO", kpi_name="费用率", entity_id=1,
unit="%", threshold_red=RED_EXPR)
_kpi_value(db, k_in, "2026-04", 14.45)
_kpi_value(db, k_out, "2026-03", 14.45)
st = {}
n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW)
assert n == 1
assert st["periods_used"] == {"2026-04": 1}
assert st["out_of_window"] == 1
assert st["planned"][0]["kpi"].startswith("F_REVENUE")
def test_no_new_alert_written_out_of_window(self, db: Session):
"""非 dry-run 回查:窗口外 KPI 不落库(前后计数一致)"""
_demo_and_active(db)
k = create_test_kpi(db, kpi_code="BH_SALES_1", kpi_name="销售-陈艳", entity_id=2,
unit="%", threshold_red=RED_EXPR)
_kpi_value(db, k, "2026-01", 63070.3, entity_id=2)
before = db.query(KPIAlert).count()
run_alert_check(db, dry_run=False, now=NOW)
db.expire_all()
assert db.query(KPIAlert).count() == before == 0
# ───────────────────────── R3:伪期间不参与 ─────────────────────────
class TestR3PseudoPeriod:
def test_is_month_period(self):
assert is_month_period("2026-08") is True and is_month_period("2025-12") is True
for bad in ("2026-H1", "2026-H2", "2025-FY", "2025", "2026-13", None, ""):
assert is_month_period(bad) is False, bad
def test_explicit_pseudo_period_skipped(self, db: Session):
"""显式传入 2026-H1 → 伪期间跳过(不放行、不落库)"""
_demo_and_active(db)
k = create_test_kpi(db, kpi_code="F_GROSS_MARGIN", kpi_name="毛利率",
entity_id=1, unit="%", threshold_red="<15")
_kpi_value(db, k, "2026-08", 5.0)
before = db.query(KPIAlert).count()
st = {}
n = run_alert_check(db, period="2026-H1", dry_run=False, stats=st, now=NOW)
db.expire_all()
assert n == 0 and db.query(KPIAlert).count() == before
assert st["skipped_pseudo_period"] == 1
assert st["pseudo_period_kpis"] == ["F_GROSS_MARGIN[2026-H1]"]
def test_only_pseudo_period_means_no_period(self, db: Session):
"""KPI 只有 2026-H1 数据 → 无可用期间(no_period,不静默)"""
_demo_and_active(db)
k = create_test_kpi(db, kpi_code="F_GROSS_MARGIN", kpi_name="毛利率",
entity_id=1, unit="%", threshold_red="<15")
_kpi_value(db, k, "2026-H1", 5.0)
st = {}
n = run_alert_check(db, period=None, dry_run=True, stats=st, now=NOW)
assert n == 0 and st["no_period"] == 1 and st["skipped_pseudo_period"] == 0
# ───────────────────────── R4:生成与推送解耦 ─────────────────────────
class TestR4PushDecoupled:
def _triggering_kpi(self, db):
_demo_and_active(db)
k = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入", entity_id=1,
unit="%", threshold_red=RED_EXPR)
_kpi_value(db, k, "2026-09", 14.45)
return k
def test_empty_channels_only_store_no_error(self, db: Session):
"""notification_channels=0 → 仅入库、不报错、不阻断(生成结果照常返回)"""
self._triggering_kpi(db)
assert db.query(NotificationChannel).count() == 0
r = generate_and_push(db, dry_run=False, now=NOW)
db.expire_all()
assert r["new_alerts"] == 1, "预警照常生成入库"
assert r["channels_enabled"] == 0 and r["push_skipped_no_channel"] is True
assert r["pushed"] == 0
assert db.query(KPIAlert).count() == 1
assert db.query(KPIAlert).first().status == "pending"
def test_push_failure_does_not_block_generation(self, db: Session, monkeypatch):
"""有渠道但推送抛异常 → 生成结果不受影响(try/except 解耦)"""
import app.utils.notifier as notifier
self._triggering_kpi(db)
db.add(NotificationChannel(name="企微群", channel_type="wecom",
config={"webhook_url": "https://example.invalid/hook"},
enabled=True))
db.commit()
def _boom(*a, **kw):
raise RuntimeError("simulated push failure")
monkeypatch.setattr(notifier, "push_pending_alerts", _boom)
r = generate_and_push(db, dry_run=False, now=NOW)
assert r["new_alerts"] == 1 and r["pushed"] == 0
assert r["channels_enabled"] == 1 and r["push_skipped_no_channel"] is False
def test_push_called_when_channel_present(self, db: Session, monkeypatch):
import app.utils.notifier as notifier
self._triggering_kpi(db)
db.add(NotificationChannel(name="企微群", channel_type="wecom",
config={"webhook_url": "https://example.invalid/hook"},
enabled=True))
db.commit()
monkeypatch.setattr(notifier, "push_pending_alerts", lambda db_: 1)
r = generate_and_push(db, dry_run=False, now=NOW)
assert r["new_alerts"] == 1 and r["pushed"] == 1 and r["channels_enabled"] == 1
def test_dry_run_zero_write_and_zero_push(self, db: Session, monkeypatch):
"""dry-run:零写入、零推送,且返回 R1/R2/R3 口径证据"""
import app.utils.notifier as notifier
self._triggering_kpi(db)
k_old = create_test_kpi(db, kpi_code="BH_SALES_1", kpi_name="销售-陈艳", entity_id=2,
unit="%", threshold_red=RED_EXPR)
_kpi_value(db, k_old, "2026-01", 63070.3, entity_id=2)
create_test_kpi(db, kpi_code="DEMO_REVENUE", kpi_name="演示收入", entity_id=6,
unit="%", threshold_red=RED_EXPR)
called = []
monkeypatch.setattr(notifier, "push_pending_alerts", lambda db_: called.append(1) or 9)
before = db.query(KPIAlert).count()
r = generate_and_push(db, dry_run=True, now=NOW)
db.expire_all()
assert db.query(KPIAlert).count() == before == 0, "dry-run 零写入"
assert called == [], "dry-run 零推送"
assert r["new_alerts"] == 1 and r["pushed"] == 0
assert r["skipped_demo_kpis"] == 1 and r["out_of_window"] == 1
assert r["window_lower_period"] == "2026-04" and r["excluded_demo_entities"] == [6]
assert r["push_skipped_no_channel"] is True
@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
"""财务预警口径裁定 R5 落地测试(2026-09-19,任务 finance-20260919-alert-caliber-r5-noperiod)
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md` §R5(R3 的一致延伸)
- **可归期原则**:预警生效判据 = 期间为规整月 `YYYY-MM`;`period IS NULL` 与 `2026-H1/H2/FY`
同属「不可归期」→ 不参与预警(不可复核 / 不可归责 / 不可驱动行动)。
- **生成侧守卫**:任何生成路径产出的 `kpi_alerts` 行,期间非 `YYYY-MM` 一律不入库(跳过 + 记日志)。
- **R1 与入口无关**:demo 账套(`status='demo'`)在**任何**能产出 `kpi_alerts` 行的路径上
都不得产生业务预警,含按显式 `entity_id` 写入的 **Bot 通道**。
口径唯一事实源仍为 `app/utils/alert_entity_scope.py`:本套件既锁该模块入口函数,
也锁各生成路径的**落库行为**(不入库 = 交付判据)。
类A(可落地守卫,本套件 RED→GREEN 对象):期间已知、可能为伪期间的生成路径
—— 规则引擎(`latest_value.period` 按字符串 desc 取,`2026-H1` 会排在 `2026-09` 之前)、
Bot 写通道(`bot_bridge` / `bot_bridge_v2` 按显式 period 写)。
类B(现状登记,见 `TestR5ClassBStatusQuo`):设计上不携带月度期间的族(集中度/到期未收款/
资金缺口预测),其期间**无从从库派生**(`kpi_value_id IS NULL`)。
2026-09-19 补充裁定 R5-补(财务,收口两个残口)后本套件的口径变化:
- **补1**:差异/趋势/差异反打路径改为**必须落 `kpi_value_id`**(不可归期即跳过),
差异类去重键改 `(entity_id, kpi_id, kpi_value_id, alert_type)` → **差异/趋势已由类B 转入类A**,
其守卫与负对照用例落在 `tests/test_alert_caliber_r5_residual_20260919.py`;
- **补2**:类B「无月度期间族」**不屏蔽(现状保留)**,登记为已知例外,不再作为待决策项挂账
→ `TestR5ClassBStatusQuo` 由「待产品侧决策」的登记,改为「按补2 保留」的登记。
"""
from datetime import datetime
import pytest
from sqlalchemy.orm import Session
from tests.conftest import create_test_kpi
from app.models import Entity, KPIValue, KPIAlert
from app.utils.alert_entity_scope import (
entity_alert_guard, guard_alert_period, is_attributable_period,
)
NOW = datetime(2026, 9, 19, 10, 0)
#: 触发形态说明:静态规则 red="<15" + 实测 5.0 → 必然触发红灯(不依赖真实阈值表)
RED_EXPR = "<15"
def _ent(db, eid, status, name=None):
db.add(Entity(id=eid, name=name or ("账套%d" % eid), short_name="e%d" % eid, status=status))
db.commit()
def _kpi_value(db, kpi, period, value, entity_id=1):
db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value))
db.commit()
def _alert_rule(db, kpi_id, entity_id=1, rule_type="static", params=None):
from app.api.alert_rules import AlertRule
AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True)
r = AlertRule(entity_id=entity_id, kpi_id=kpi_id, rule_type=rule_type, enabled=1,
params=params if params is not None else {"red": RED_EXPR})
db.add(r)
db.commit()
return r
# ─────────────────── R5 单一事实源入口(守卫语义) ───────────────────
class TestR5SingleSourceGuard:
def test_is_attributable_period(self):
"""可归期 = 规整月 YYYY-MM;NULL / 伪期间 / 非法月 / 非月粒度一律不可归期"""
for ok in ("2026-09", "2026-04", "2025-12"):
assert is_attributable_period(ok) is True, ok
for bad in (None, "", "2026-H1", "2026-H2", "2025-FY", "2025", "2026-13",
"2026-9", "2026-09-01", "2026-09-19", "本月"):
assert is_attributable_period(bad) is False, bad
def test_guard_alert_period_semantics(self):
"""guard 返回 True=放行入库;False=跳过(调用点不得落库)"""
assert guard_alert_period("2026-09", source="unit-test") is True
assert guard_alert_period(None, source="unit-test") is False
assert guard_alert_period("2026-H1", source="unit-test") is False
def test_entity_alert_guard_demo_vs_active(self, db: Session):
"""R1 通用守卫:demo 账套 False(不产生业务预警),active 账套 True"""
_ent(db, 1, "active")
_ent(db, 6, "demo")
assert entity_alert_guard(db, 6, source="unit-test") is False
assert entity_alert_guard(db, 1, source="unit-test") is True
assert entity_alert_guard(db, None, source="unit-test") is True, "None → 默认账套 1"
# ─────────────────── 类A-1:规则引擎路径(伪期间不得入库) ───────────────────
class TestR5RuleEnginePath:
def _pseudo_latest(self, db):
"""KPI 的最新(字符串排序最大)值是伪期间 2026-H1 → 旧码会据此落库一条 2026-H1 预警"""
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
entity_id=1, unit="%", threshold_red=RED_EXPR)
_kpi_value(db, k, "2026-06", 50.0)
_kpi_value(db, k, "2026-H1", 5.0) # '2026-H1' 字符串序 > '2026-06' → 被取为 latest
_alert_rule(db, k.id)
return k
def test_pseudo_period_latest_not_inserted(self, db: Session):
"""latest_value=2026-H1 → 该 kpi_alerts 行不入库(R5 生成侧守卫)"""
from app.api.alert_rules import run_all_alert_checks
self._pseudo_latest(db)
before = db.query(KPIAlert).count()
r = run_all_alert_checks(db=db, entity_id=1)
db.expire_all()
assert db.query(KPIAlert).count() == before, "期间非 YYYY-MM → 不入库"
assert r["generated"] == 0
assert r.get("skipped_non_month_period") == 1
assert r.get("skipped_non_month_period_kpis") == ["F_REVENUE[2026-H1]"]
def test_month_period_still_inserted(self, db: Session):
"""正对照:规整月期间照常入库(守卫只拦「不可归期」,不误伤正常预警)"""
from app.api.alert_rules import run_all_alert_checks
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="F_REVENUE", kpi_name="营业收入",
entity_id=1, unit="%", threshold_red=RED_EXPR)
_kpi_value(db, k, "2026-09", 5.0)
_alert_rule(db, k.id)
r = run_all_alert_checks(db=db, entity_id=1)
db.expire_all()
rows = db.query(KPIAlert).all()
assert r["generated"] == 1 and len(rows) == 1
assert rows[0].kpi_value_id is not None, "规整月预警带 kpi_value 关联(期间可归期)"
# ─────────────────── 类A-2 / R1:Bot 写通道 ───────────────────
class TestR5BotWritePaths:
def _bot_kpi(self, db, entity_id=1, code="F_REVENUE"):
_ent(db, 1, "active")
_ent(db, 6, "demo", "演示企业(虚构数据)")
return create_test_kpi(db, kpi_code=code, kpi_name="营业收入", entity_id=entity_id,
unit="%", threshold_red=RED_EXPR)
def test_bot_bridge_pseudo_period_no_alert(self, db: Session):
"""bot_bridge 写端点:period=2026-H1 → 不产生预警行(值照写,不动 kpi_values 口径)"""
from app.api.bot_bridge import bot_kpi_value_with_check
k = self._bot_kpi(db)
_alert_rule(db, k.id)
before = db.query(KPIAlert).count()
r = bot_kpi_value_with_check(
data={"kpi_id": k.id, "actual_value": 5.0, "period": "2026-H1", "entity_id": 1},
db=db, bot={"name": "test-bot"})
db.expire_all()
assert db.query(KPIAlert).count() == before, "Bot 通道伪期间不入库"
assert r["alerts"] == []
assert r.get("skipped_non_month_period") == 1
def test_bot_bridge_demo_entity_no_alert(self, db: Session):
"""R1:bot_bridge 写 demo 账套(显式 entity_id=6)→ 不产生业务预警"""
from app.api.bot_bridge import bot_kpi_value_with_check
k = self._bot_kpi(db, entity_id=6, code="DEMO_REVENUE")
_alert_rule(db, k.id, entity_id=6)
before = db.query(KPIAlert).count()
r = bot_kpi_value_with_check(
data={"kpi_id": k.id, "actual_value": 5.0, "period": "2026-09", "entity_id": 6},
db=db, bot={"name": "test-bot"})
db.expire_all()
assert db.query(KPIAlert).count() == before
assert r["alerts"] == [] and r.get("reason") == "R1_demo_entity_excluded"
def test_bot_bridge_v2_pseudo_period_no_alert(self, db: Session):
"""bot_bridge_v2(mpm-result):period=2026-H1 → 不产生预警行"""
from app.api.bot_bridge_v2 import receive_mpm_result
from app.models import MpmResult
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
_ent(db, 1, "active")
create_test_kpi(db, kpi_code="C_REBATE_RATE", kpi_name="渠补率", entity_id=1, unit="%")
before = db.query(KPIAlert).count()
r = receive_mpm_result(
data={"entity_id": 1, "period": "2026-H1",
"results": {"channel_rebate_rate": 80.0}},
bridge_bot="test-bridge", db=db)
db.expire_all()
assert db.query(KPIAlert).count() == before, "Bot 通道伪期间不入库"
assert r["alerts_triggered"] == 0
assert r.get("skipped_non_month_period") is True
def test_bot_bridge_v2_demo_entity_no_alert(self, db: Session):
"""R1:bot_bridge_v2 写 demo 账套 → 不产生业务预警(规整月也不放行)"""
from app.api.bot_bridge_v2 import receive_mpm_result
from app.models import MpmResult
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
_ent(db, 6, "demo", "演示企业(虚构数据)")
create_test_kpi(db, kpi_code="C_REBATE_RATE", kpi_name="渠补率", entity_id=6, unit="%")
before = db.query(KPIAlert).count()
r = receive_mpm_result(
data={"entity_id": 6, "period": "2026-09",
"results": {"channel_rebate_rate": 80.0}},
bridge_bot="test-bridge", db=db)
db.expire_all()
assert db.query(KPIAlert).count() == before
assert r["alerts_triggered"] == 0 and r.get("reason") == "R1_demo_entity_excluded"
# ─────────────────── 类B:无月度期间族(现状登记,待产品侧) ───────────────────
class TestR5ClassBStatusQuo:
"""类B 现状登记(非守卫断言)——1b 实测证据:这些生成路径产出的行**期间无从从库派生**。
裁定演进(2026-09-19 §补充裁定 R5-补2):类B「无月度期间族」**不屏蔽(现状保留)**,
理由 = 驱动量是日期/占比/趋势,可行动性不以月度期间为前提(属 R5 边界条款所指
「无期间合法预警」业务需求);屏蔽会一次性灭掉「到期未收款」等既有能力(存量 423 行族)
并触发既有套件失败 → **登记为已知例外,不再作为待决策项挂账**。
⚠ 差异路径原属本类,**已由补1 转入类A**(必须落 kpi_value_id,不可归期即跳过)——
其守卫与负对照用例见 `tests/test_alert_caliber_r5_residual_20260919.py`,
本类不再保留其「无期间关联」的现状断言(该断言与补1 冲突,已随裁定删除)。
类B 余族的现状登记同样落在该新套件(`TestR5ResidualClassBStatusQuo`)。
"""
# 原 `test_deviation_path_row_has_no_derivable_period`(差异路径「无期间关联」现状断言)
# 已随 §补充裁定 R5-补1 删除:差异路径转为类A,必须落 kpi_value_id,不可归期即跳过。
# 替代用例(含负对照:旧逻辑取 NULL / 新逻辑取该期间行)见
# tests/test_alert_caliber_r5_residual_20260919.py::TestR5ResidualDeviationLinkage(6 例)。
@@ -0,0 +1,317 @@
# -*- coding: utf-8 -*-
"""预警「期间可归期」残口收敛测试(2026-09-19,任务 fullstack-20260919-alert-period-linkage-r5-residual)
依据:`wecom-finance/reports/CMA_预警口径裁定_20260919.md`
- **§R5**(2026-09-19):预警生效判据 = 期间为规整月 `YYYY-MM`;不可归期者不参与预警。
- **§补充裁定 R5-补1**(同日,收口残口一):差异/趋势/差异反打路径**必须落 `kpi_value_id`**
(指向同账套 + 同 KPI + 文案所示规整月期间的 `kpi_values` 行);文案期间不可解析为 `YYYY-MM`
或该 (kpi_id, period) 无 `kpi_values` 行 → 跳过不入库;趋势类以**趋势末端当期**为关联期间;
差异类去重键改为 `(entity_id, kpi_id, kpi_value_id, alert_type)`;
`deviation_push` 同源接入 `guard_alert_period` + `entity_alert_guard`,请求体无 `period` 一律不落库。
- **§补充裁定 R5-补2**(同日,收口残口二):类B「无月度期间族」**不屏蔽(现状保留)**,
登记为已知例外,不与类A 混算。
本套件每个「期间关联」用例均带**负对照**:夹具同时给出「旧逻辑会取到的目标(未关联 / 别期行)」
与「新逻辑应取到的目标」,断言新逻辑取新目标、且**不等于**旧目标——否则判据未生效也能通过。
"""
from datetime import datetime
import pytest
from sqlalchemy.orm import Session
from tests.conftest import create_test_kpi, create_test_user
from app.models import (Entity, KPIValue, KPIAlert, KPIDefinition, StrategicMap,
ActionPlan, OperationLog)
NOW = datetime(2026, 9, 19, 10, 0)
def _ent(db, eid, status, name=None):
db.add(Entity(id=eid, name=name or ("账套%d" % eid), short_name="e%d" % eid, status=status))
db.commit()
def _kpi_value(db, kpi, period, value, entity_id=None):
row = KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value)
db.add(row)
db.commit()
return row
def _trigger_deviation(db, monkeypatch):
"""隔离趋势分支:只观察差异分支的落库形态"""
import app.utils.deviation_engine as de
monkeypatch.setattr(de, "build_deviation_alert",
lambda db_s, kpi, period, *a, **kw: {
"triggered": True, "kpi_alert_level": "red", "alert_type": "budget",
"suggestion": "探针建议",
"deviation": {"actual_value": 88.8, "budget_value": 10.0,
"deviation_rate": 788.0}})
monkeypatch.setattr(de, "check_trend_anomaly", lambda *a, **kw: {"anomaly": False})
return de
# ─────────────────── 残口一:差异/趋势路径期间关联(补1) ───────────────────
class TestR5ResidualDeviationLinkage:
def test_diff_alert_links_current_period_row_not_other_period(self, db: Session, monkeypatch):
"""差异预警必须关联「文案所示期间」那一行(负对照:不得关联别期行,也不得留空)
夹具两个 kpi_values 行:2026-06(旧逻辑在别处的可能目标 / 干扰项)与 2026-07(文案期间)。
"""
de = _trigger_deviation(db, monkeypatch)
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
kv_prev = _kpi_value(db, k, "2026-06", 60.0)
kv_cur = _kpi_value(db, k, "2026-07", 88.8)
n = de.run_deviation_check(db, period="2026-07")
db.expire_all()
rows = db.query(KPIAlert).all()
assert n == 1 and len(rows) == 1
assert rows[0].kpi_value_id == kv_cur.id, "新逻辑取新目标(文案期间 2026-07 的 kpi_values 行)"
assert rows[0].kpi_value_id != kv_prev.id, "负对照:不得关联别期行"
assert rows[0].kpi_value_id is not None, "负对照:旧逻辑取旧目标(NULL,期间不可派生)"
# 派生期间可复算 = 可归期
derived = db.query(KPIValue).filter(KPIValue.id == rows[0].kpi_value_id).first()
assert derived.period == "2026-07"
def test_diff_alert_skipped_when_no_kpi_value_row(self, db: Session, monkeypatch):
"""该 (kpi, period) 无 kpi_values 行 → 跳过不入库(不得以 NULL 关联落行)"""
de = _trigger_deviation(db, monkeypatch)
_ent(db, 1, "active")
create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
n = de.run_deviation_check(db, period="2026-07")
db.expire_all()
assert n == 0
assert db.query(KPIAlert).count() == 0
def test_trend_alert_links_trend_end_period(self, db: Session, monkeypatch):
"""趋势类以「计算基准期(趋势末端当期)」为关联期间(负对照:不得关联趋势起点期)"""
import app.utils.deviation_engine as de
monkeypatch.setattr(de, "build_deviation_alert", lambda *a, **kw: {"triggered": False})
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="SALES_TOTAL", kpi_name="销售额", entity_id=1, unit="元")
kv_start = _kpi_value(db, k, "2026-05", 30.0)
_kpi_value(db, k, "2026-06", 20.0)
kv_end = _kpi_value(db, k, "2026-07", 10.0) # 连续 3 期下滑 → 趋势末端
n = de.run_deviation_check(db, period="2026-07")
db.expire_all()
rows = db.query(KPIAlert).all()
assert n == 1 and len(rows) == 1
assert "[趋势预警]" in rows[0].alert_message
assert rows[0].kpi_value_id == kv_end.id, "新逻辑:关联趋势末端当期"
assert rows[0].kpi_value_id != kv_start.id, "负对照:不得关联趋势起点期"
assert rows[0].kpi_value_id is not None, "负对照:旧逻辑取旧目标(NULL)"
def test_trend_alert_skipped_when_base_period_not_attributable(self, db: Session, monkeypatch):
"""趋势末端期间不可归期(非 YYYY-MM)→ 跳过不入库(补1:文案期间不可解析即跳过)"""
import app.utils.deviation_engine as de
monkeypatch.setattr(de, "build_deviation_alert", lambda *a, **kw: {"triggered": False})
monkeypatch.setattr(de, "check_trend_anomaly",
lambda *a, **kw: {"anomaly": True, "level": "yellow",
"message": "连续3期下滑", "periods": ["2026-H1"]})
_ent(db, 1, "active")
create_test_kpi(db, kpi_code="SALES_TOTAL", kpi_name="销售额", entity_id=1, unit="元")
n = de.run_deviation_check(db, period="2026-07")
db.expire_all()
assert n == 0 and db.query(KPIAlert).count() == 0
def test_diff_dedupe_key_is_linkage_tuple(self, db: Session, monkeypatch):
"""去重键改为 (entity_id, kpi_id, kpi_value_id, alert_type)
负对照:预置一条「文案含同期间、但 kpi_value_id 为空」的历史行(= 旧去重键会命中的形态),
新逻辑按可归期目标去重 → 仍应补一条带关联的预警(而非被文案去重吃掉)。
"""
de = _trigger_deviation(db, monkeypatch)
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
_kpi_value(db, k, "2026-07", 88.8)
# 旧形态遗留行:文案带期间、无期间关联
db.add(KPIAlert(entity_id=1, kpi_id=k.id, kpi_value_id=None, alert_level="red",
alert_type="budget", status="pending",
alert_message="[差异预警] 新客户数[2026-07] 差异预警: 实际88.8 vs 预算10.0,差异率788.0%"))
db.commit()
n = de.run_deviation_check(db, period="2026-07")
db.expire_all()
rows = db.query(KPIAlert).order_by(KPIAlert.id).all()
assert n == 1 and len(rows) == 2, "新去重键按可归期目标判定 → 补一条带关联的行"
linked = [r for r in rows if r.kpi_value_id is not None]
assert len(linked) == 1 and linked[0].alert_type == "budget"
def test_diff_dedupe_does_not_duplicate_same_target(self, db: Session, monkeypatch):
"""正对照:同一可归期目标重复触发不重复入库(幂等)"""
de = _trigger_deviation(db, monkeypatch)
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="C_NEW_CLIENTS", kpi_name="新客户数", entity_id=1, unit="家")
_kpi_value(db, k, "2026-07", 88.8)
assert de.run_deviation_check(db, period="2026-07") == 1
assert de.run_deviation_check(db, period="2026-07") == 0
assert db.query(KPIAlert).count() == 1
# ─────────────────── 残口三:deviation_push 的 R1/R5 缺口(补1) ───────────────────
class TestR5ResidualDeviationPush:
def _fixture(self, db, entity_id=1, code="TEST_001"):
k = create_test_kpi(db, kpi_code=code, kpi_name="探针KPI", entity_id=entity_id,
unit="%", target_value=100.0)
db.add(StrategicMap(entity_id=entity_id, title="探针地图", status="published",
dimensions=[{"key": "finance", "name": "财务维度",
"objectives": [{"name": "探针目标", "kpis": [code]}]}]))
db.commit()
m = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).first()
return k, m
def _call(self, db, m, data, entity_id=1):
from app.api.deviation_push import push_deviation_to_map
from app.models import User
uname = "r5res%d" % entity_id
user = db.query(User).filter(User.username == uname).first()
if user is None:
user = create_test_user(db, username=uname, role="ceo")
return push_deviation_to_map(data=data, db=db, entity_id=entity_id, current_user=user)
def test_demo_entity_alert_skipped_value_still_written(self, db: Session):
"""R1:demo 会话账套不产生业务预警(KPI 值照写,与 bot_bridge 同口径)"""
_ent(db, 6, "demo", "演示企业(虚构数据)")
k, m = self._fixture(db, entity_id=6, code="DEMO_PUSH")
r = self._call(db, m, {"mapId": m.id, "nodeId": "finance-0", "newValue": 10.0,
"period": "2026-09", "createReviewTopic": False}, entity_id=6)
db.expire_all()
assert r["alert_created"] is False
assert r["alert_skipped_reason"] == "R1_demo_entity_excluded"
assert db.query(KPIAlert).count() == 0
assert db.query(KPIValue).filter(KPIValue.kpi_id == k.id).count() == 1, "值照写"
def test_pseudo_period_alert_skipped(self, db: Session):
"""R5:伪期间请求不产生预警行(返回体登记跳过原因)"""
_ent(db, 1, "active")
k, m = self._fixture(db, code="PSEUDO_PUSH")
r = self._call(db, m, {"mapId": m.id, "nodeId": "finance-0", "newValue": 10.0,
"period": "2026-H1", "createReviewTopic": False})
db.expire_all()
assert r["alert_created"] is False
assert r["alert_skipped_reason"] == "R5_non_attributable_period"
assert db.query(KPIAlert).count() == 0
def test_missing_period_zero_write(self, db: Session):
"""补1:「请求体无 period 一律不落库」——整请求零业务写入"""
_ent(db, 1, "active")
self._fixture(db, code="NOPERIOD_PUSH")
r = self._call(db, {"id": 1}, {"mapId": 1, "nodeId": "finance-0", "newValue": 10.0})
db.expire_all()
assert r["alert_created"] is False and r["zero_write"] is True
assert r["alert_skipped_reason"] == "no_period"
assert db.query(KPIValue).count() == 0, "不得用当月兜底冒充期间写值"
assert db.query(KPIAlert).count() == 0
assert db.query(ActionPlan).count() == 0
assert db.query(OperationLog).count() == 0
def test_valid_request_alert_links_request_period_row(self, db: Session):
"""正对照 + 期间关联:规整月 + active 账套 → 预警落库且关联本次请求期间的行"""
_ent(db, 1, "active")
k, m = self._fixture(db, code="VALID_PUSH")
kv_other = _kpi_value(db, k, "2026-08", 50.0) # 干扰项:别期行
r = self._call(db, m, {"mapId": m.id, "nodeId": "finance-0", "newValue": 10.0,
"period": "2026-09", "createReviewTopic": False})
db.expire_all()
assert r["alert_created"] is True and r["alert_skipped_reason"] is None
alert = db.query(KPIAlert).filter(KPIAlert.id == r["alert_id"]).first()
linked = db.query(KPIValue).filter(KPIValue.id == alert.kpi_value_id).first()
assert linked is not None and linked.period == "2026-09"
assert alert.kpi_value_id != kv_other.id, "负对照:不得关联别期行"
def test_repeat_push_reuses_value_row_linkage(self, db: Session):
"""同一 (kpi, period) 二次反打 → kpi_values 行复用(值就地更新,不新增行)
现状登记:本端点**无预警去重**(第二次反打仍落一条 pending 预警,关联同一 `kpi_values` 行)——
裁定 R5-补1 未规定该端点的去重语义(只规定了差异/趋势类的去重键),故按现状登记,
见报告「观察项」;本用例只锁「期间关联一致 + kpi_values 不重复」这两条已定口径。
"""
_ent(db, 1, "active")
k, m = self._fixture(db, code="REPEAT_PUSH")
d = {"mapId": 1, "nodeId": "finance-0", "newValue": 10.0,
"period": "2026-09", "createReviewTopic": False}
r1 = self._call(db, m, dict(d))
r2 = self._call(db, m, dict(d))
db.expire_all()
assert db.query(KPIValue).filter(KPIValue.kpi_id == k.id).count() == 1, "值行复用"
rows = db.query(KPIAlert).order_by(KPIAlert.id).all()
assert len(rows) == 2, "现状登记:该端点无预警去重"
assert rows[0].kpi_value_id == rows[1].kpi_value_id, "两次反打关联同一期间行"
assert r1["alert_id"] != r2["alert_id"]
# ─────────────────── 残口二:类B 不屏蔽(补2,现状登记) ───────────────────
class TestR5ResidualClassBStatusQuo:
"""补2 裁定:类B「无月度期间族」**不屏蔽(现状保留)**,登记为已知例外。
本类为**现状登记**(非守卫断言):这些族的驱动量是日期/占比/趋势,其可行动性不以月度期间
为前提;屏蔽会灭掉「到期未收款」等既有能力(存量 423 行族)并触发既有套件失败。
若产品侧日后改判屏蔽,本类应随之更新(与 `test_alert_caliber_r5_20260919.py` 同口径)。
"""
def test_customer_concentration_site_still_writes_without_linkage(self, db: Session, monkeypatch):
"""集中度(类B,alert_rules:940)落库仍**不经** R5 期间守卫 → 行内无 kpi_value 关联
夹具以 monkeypatch 替代占比计算,观察点 = 「落库是否被期间守卫拦截」。
"""
from app.api import alert_rules as ar
ar.AlertRule.__table__.create(bind=db.get_bind(), checkfirst=True)
_ent(db, 1, "active")
k = create_test_kpi(db, kpi_code="CONC_KPI", kpi_name="客户集中度", entity_id=1, unit="%")
db.add(ar.AlertRule(entity_id=1, kpi_id=k.id, rule_type="customer_concentration", enabled=1,
params={"threshold": 30}))
db.commit()
monkeypatch.setattr(ar, "_check_customer_concentration",
lambda *a, **kw: ("red", "[客户集中度] 探针 占比 88%"))
r = ar.run_customer_concentration_check(db=db, entity_id=1)
db.expire_all()
rows = db.query(KPIAlert).filter(KPIAlert.alert_type == "concentration").all()
assert r["generated"] == 1 and len(rows) == 1, "类B 现状保留:集中度预警照常入库"
assert rows[0].kpi_value_id is None, "类B 行不携带月度期间(登记形态)"
def test_class_b_creation_sites_have_no_r5_period_guard(self):
"""登记:类B 其余创建点(集中度/预测值/资金缺口/到期未收款)**未接入** R5 期间守卫
判据 = 源码级登记(这些文件在本单中零改动),与 `git diff --stat` 互为佐证。
"""
import inspect
from app.api import alert_rules as ar
from app.utils import cash_forecast_engine as cfe
for fn in (ar.run_customer_concentration_check,
ar.run_forecast_alert_check,
cfe.check_cash_alerts):
src = inspect.getsource(fn)
assert "guard_alert_period" not in src, fn.__name__
@@ -0,0 +1,182 @@
# -*- coding: utf-8 -*-
"""CMA 行动计划自动生成写入点 —— 补账套 + 写前同源校验(2026-09-22 派生单)
任务:0-P1-project-20260922-actionplan-entity-write-fullstack
父单:project-20260921-cma-entity-isolation-batch5(§B 治本 = commit 0c2e57a)
同族已修(勿重做):app/api/alerts.py · deviation_push.py · ai_suggestions.py
—— commit d03e322(2026-09-18 派生单,已发布)
缺陷(代码级):
scripts/alert_generator.py::run_alert_check 为 red/yellow 预警自动建 ActionPlan 时
**不传 entity_id**(models.ActionPlan.entity_id default=1 + 生产 DDL `DEFAULT 1` 双默认静默兜底)
⇒ 非 e1 账套的 KPI 触发阈值时,同一次事务里「预警归属正确、计划归属落 1」。
读侧 dashboard.py 工作台按 `ActionPlan.entity_id == 会话账套` 取出计划、
KPI 名不做账套过滤 → 酣客(e1) 工作台直接渲染博海(e2)/演示(e6) 的 KPI 名。
实测:action_plans 中 entity_id ≠ 其 KPI 的 entity_id 的行 19/83,且 09-17/09-18/09-22
07:00 各新增 6/7/6 行(每日复发)。
本套件(RED/GREEN 两态原始输出见 pending-tasks/_reports/ 同名单回报):
· T1(改前 RED)e2 的 KPI 触发 → 计划 entity_id == 2;**同用例内**断言
`alert.entity_id == plan.entity_id`(防只断言一侧)
· T2(改前 RED)KPI 归属为空 → 不建计划 + `OperationLog(action='skip_plan')` 恰 1 条,
且**流水线不中断**(预警仍按既有行为落库),统计计数 `skipped_plan_entity_mismatch == 1`
· T3(改前 RED)守卫单元级:`kpi.entity_id(2) != 目标账套(1)` → 不建计划 + 1 条 skip_plan
· T4(改前即绿、不构成本单判据,仅作正对照)e1 的 KPI 触发 → 计划 entity_id == 1,
守卫零误杀、零 skip_plan
"""
from datetime import datetime
from sqlalchemy.orm import Session
from app.models import (
ActionPlan, KPIAlert, KPIDefinition, KPIValue, OperationLog,
)
from tests.conftest import create_test_kpi
from scripts import alert_generator
#: 固定「当月」= 2026-09(口径复算不依赖真实时钟;窗口下限 = 2026-03)
NOW = datetime(2026, 9, 22, 10, 0)
PERIOD = "2026-09"
#: 阈值/实测形态沿用同族既有用例(test_alert_caliber_r1r4_20260919):
#: 避免命中裁定2a「疑似单位错配」降级门(阈值百分数写法 + 实测比率量级)
RED_EXPR = "<58.4"
ACTUAL = 14.45
def _kpi(db: Session, code: str, eid, **kw) -> KPIDefinition:
"""本账套 KPI(entity_id 可为 None —— 模拟「归属缺失」的生产形态)"""
return create_test_kpi(db, kpi_code=code, kpi_name=f"{code}-名称", entity_id=eid,
unit="%", threshold_red=RED_EXPR, **kw)
def _value(db: Session, kpi: KPIDefinition, period: str, value: float, entity_id: int):
db.add(KPIValue(entity_id=entity_id, kpi_id=kpi.id, period=period, actual_value=value))
db.commit()
def _plans(db: Session, kpi: KPIDefinition):
db.expire_all()
return db.query(ActionPlan).filter(ActionPlan.kpi_id == kpi.id).all()
def _alerts(db: Session, kpi: KPIDefinition):
db.expire_all()
return db.query(KPIAlert).filter(KPIAlert.kpi_id == kpi.id).all()
def _skip_logs(db: Session):
db.expire_all()
return db.query(OperationLog).filter(OperationLog.action == "skip_plan").all()
# ──────────────────────────────────────────────
# T1. 写入点补账套:计划归属 = 来源 KPI 的账套(改前 RED)
# ──────────────────────────────────────────────
def test_t1_plan_entity_follows_kpi_entity_of_other_tenant(db: Session):
"""e2 的 KPI 触发 → 计划 entity_id 必须是 2,且与同事务预警归属一致"""
k2 = _kpi(db, "ZZ_APEN_E2_KPI", 2)
_value(db, k2, PERIOD, ACTUAL, entity_id=2)
st = {}
alert_generator.run_alert_check(db, period=PERIOD, stats=st, now=NOW)
alerts = _alerts(db, k2)
plans = _plans(db, k2)
assert len(alerts) == 1, f"e2 KPI 应生成 1 条预警,实际 {len(alerts)}"
assert len(plans) == 1, f"e2 KPI 应生成 1 条行动计划,实际 {len(plans)}"
assert plans[0].entity_id == 2, (
f"计划 entity_id 应为来源 KPI 的账套 2,实际 {plans[0].entity_id}"
" —— alert_generator 构造 ActionPlan 未写 entity_id,落模型/DDL 默认值 1"
)
# 同用例内同时断言两侧归属(防「只断言一侧」漏掉预警侧回归)
assert alerts[0].entity_id == plans[0].entity_id == 2, (
f"预警与计划归属必须同源:alert.entity_id={alerts[0].entity_id} "
f"plan.entity_id={plans[0].entity_id}"
)
assert plans[0].alert_id == alerts[0].id, "计划未挂到同事务预警上"
# ──────────────────────────────────────────────
# T2. 写前同源校验:归属缺失 → 不建计划 + skip_plan 留痕(改前 RED)
# ──────────────────────────────────────────────
def test_t2_missing_kpi_owner_skips_plan_and_logs(db: Session):
"""KPI 归属为空(任一为空即命中守卫)→ 不建计划 + 1 条 skip_plan + 流水线不中断
夹具说明:`KPIDefinition.entity_id` 有 ORM 侧 `default=1`,经 ORM 建行时 None 会被兜底成 1,
故「归属为空」只能用 **Core UPDATE 显式置 NULL** 构造(对应生产里绕过 ORM 的直插/导入行)。
"""
k = _kpi(db, "ZZ_APEN_NO_OWNER_KPI", 2)
db.query(KPIDefinition).filter(KPIDefinition.id == k.id).update({"entity_id": None})
db.commit()
db.expire_all()
k = db.query(KPIDefinition).filter(KPIDefinition.id == k.id).first()
assert k.entity_id is None, "夹具失败:KPI 归属未置空"
_value(db, k, PERIOD, ACTUAL, entity_id=2)
st = {}
alert_generator.run_alert_check(db, period=PERIOD, stats=st, now=NOW)
assert _plans(db, k) == [], (
"KPI 归属为空时不得建计划(否则静默落默认账套 1 = 跨账套污染)"
)
logs = _skip_logs(db)
assert len(logs) == 1, f"应恰有 1 条 skip_plan 留痕,实际 {len(logs)}"
log = logs[0]
assert log.target_type == "action_plan", f"target_type 应为 action_plan,实际 {log.target_type}"
detail = log.detail or {}
assert detail.get("kpi_code") == "ZZ_APEN_NO_OWNER_KPI", detail
assert "kpi_entity_id" in detail and "entity_id" in detail, detail
assert detail.get("kpi_entity_id") is None, detail
# 不中断流水线:预警侧既有行为不变(本单不改预警归属,仍按 `kpi.entity_id or 1` 落库)
assert len(_alerts(db, k)) == 1, "守卫不得中断预警生成(只跳过计划)"
# 统计输出必须计入(不得静默丢弃)
assert st.get("skipped_plan_entity_mismatch") == 1, st
assert st.get("skipped_plan_kpis"), st
# ──────────────────────────────────────────────
# T3. 守卫单元级:KPI 账套 ≠ 目标账套(改前 RED:守卫函数尚不存在)
# ──────────────────────────────────────────────
def test_t3_guard_rejects_kpi_entity_not_matching_target(db: Session):
"""kpi.entity_id(=2) != 目标账套(=1) → 守卫返回 False + 1 条 skip_plan + 零计划"""
k2 = _kpi(db, "ZZ_APEN_GUARD_KPI", 2)
alert = KPIAlert(entity_id=1, kpi_id=k2.id, alert_level="red",
alert_message="ZZ_APEN 跨账套守卫", status="pending")
db.add(alert)
db.commit()
db.refresh(alert)
st = {}
ok = alert_generator.action_plan_entity_guard(db, kpi=k2, alert=alert, stats=st)
db.commit() # 守卫只 add 留痕,落库由调用方提交(此处模拟 run_alert_check 的 commit;会话 autoflush=False)
assert ok is False, "KPI 账套与目标账套不一致时不得建计划"
assert db.query(ActionPlan).count() == 0
logs = _skip_logs(db)
assert len(logs) == 1, f"应恰有 1 条 skip_plan 留痕,实际 {len(logs)}"
detail = logs[0].detail or {}
assert detail.get("kpi_entity_id") == 2 and detail.get("entity_id") == 1, detail
assert st.get("skipped_plan_entity_mismatch") == 1, st
# ──────────────────────────────────────────────
# T4. 正对照(改前即绿、不构成本单判据):e1 本账套计划不得被守卫误杀
# ──────────────────────────────────────────────
def test_t4_same_tenant_plan_still_created(db: Session):
"""e1 的 KPI 触发 → 计划 entity_id == 1,且零 skip_plan(守卫零误杀)"""
k1 = _kpi(db, "ZZ_APEN_E1_KPI", 1)
_value(db, k1, PERIOD, ACTUAL, entity_id=1)
st = {}
alert_generator.run_alert_check(db, period=PERIOD, stats=st, now=NOW)
plans = _plans(db, k1)
assert len(plans) == 1, f"e1 KPI 应生成 1 条计划,实际 {len(plans)}"
assert plans[0].entity_id == 1, f"e1 计划 entity_id 应为 1,实际 {plans[0].entity_id}"
assert _skip_logs(db) == [], "本账套同源不得产生 skip_plan"
assert st.get("skipped_plan_entity_mismatch", 0) == 0, st
@@ -0,0 +1,194 @@
"""战略执行看板(/alignment)账套隔离回归测试 — 2026-09-18
任务:project-20260918-alignment-entity-leak(任总 2026-09-18 派单)
缺陷:GET /api/cma/alignment/tree 未接账套(entity_id)→ 返回全实体 active KPI,
三种对齐模式共用同一份 KPI 列表 → 酣客账套视图混入博海 KPI。
本用例覆盖(三模式 × 双向隔离):
1. 纵向分解 / 横向支撑 / BSC瀑布链 三种模式,均只返回会话账套的 KPI;
2. 双向:entity1 会话看不到 entity2 的 KPI,entity2 会话看不到 entity1 的 KPI;
3. 跨实体显式传参 ?entity_id=<对方> → 403(对齐 get_entity_id 既有语义);
4. 鉴权语义未改:无 token → 401;
5. total 计数 = 本账套 active KPI 数(不是全库)。
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Entity, KPIDefinition, RolePermission
from tests.conftest import (
TEST_USER_PASSWORD,
auth_header,
create_test_user,
get_token_for_user,
)
ALIGN = "/api/cma/alignment"
MODES = ["vertical_decomposition", "horizontal_support", "bsc_chain"]
# 两个账套各自的 KPI 编码(kpi_code 库内唯一,故两侧编码前缀不同)
E1_CODES = ["HK_REVENUE_001", "HK_REVENUE_002", "HK_PROFIT_001"]
E2_CODES = ["BH_REVENUE_001", "BH_PROFIT_001"]
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _set_mode(db: Session, mode: str, entity_ids=(1, 2)) -> None:
"""写入对齐模式配置 —— 2026-09-22 B3(a) 拆账套后为 `alignment_config.<entity_id>`
本文件断言的语义 =「两账套处在同一对齐模式时,各自只返回本账套 KPI」,
故对 e1/e2 各写一份(修复前为全局唯一键,一份两账套共用)。
"""
for eid in entity_ids:
key = f"alignment_config.{eid}"
perm = db.query(RolePermission).filter(RolePermission.key == key).first()
if perm:
perm.value = {"mode": mode}
else:
db.add(RolePermission(key=key, value={"mode": mode}))
db.commit()
def _mk_kpi(db: Session, code: str, eid: int, dimension: str, category: str) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension=dimension,
category=category,
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=0,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _seed(db: Session) -> None:
"""两个账套各 2-3 个 active KPI(三模式均可构建出层级)"""
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
for c in E1_CODES:
_mk_kpi(db, c, 1, "finance", "revenue_growth")
for c in E2_CODES:
_mk_kpi(db, c, 2, "finance", "revenue_growth")
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _collect_codes(nodes) -> set:
"""递归收集树内所有 kpi_code(维度组/类别组节点无 kpi_code,跳过)"""
found = set()
for node in nodes or []:
if not isinstance(node, dict):
continue
if node.get("kpi_code"):
found.add(node["kpi_code"])
found |= _collect_codes(node.get("children"))
return found
@pytest.mark.parametrize("mode", MODES)
def test_tree_isolated_by_entity_both_directions(client: TestClient, db: Session, mode: str):
"""三模式 × 双向:只返回本账套 KPI,不含对方账套 code"""
_seed(db)
create_test_user(db)
_set_mode(db, mode)
token1 = _login(client, 1)
token2 = _login(client, 2)
r1 = client.get(f"{ALIGN}/tree", headers=auth_header(token1))
assert r1.status_code == 200, r1.text
body1 = r1.json()
codes1 = _collect_codes(body1["tree"])
r2 = client.get(f"{ALIGN}/tree", headers=auth_header(token2))
assert r2.status_code == 200, r2.text
body2 = r2.json()
codes2 = _collect_codes(body2["tree"])
# 酣客(e1):只见酣客 code,绝不含博海 code
assert codes1 == set(E1_CODES), f"[{mode}] entity1 返回 {sorted(codes1)}"
assert not any(c.startswith("BH_") for c in codes1), f"[{mode}] entity1 混入博海 KPI"
# 博海(e2):反向同理
assert codes2 == set(E2_CODES), f"[{mode}] entity2 返回 {sorted(codes2)}"
assert not any(c.startswith("HK_") for c in codes2), f"[{mode}] entity2 混入酣客 KPI"
# total = 本账套 active KPI 数(修复前 = 全库 5)
assert body1["total"] == len(E1_CODES), f"[{mode}] entity1 total={body1['total']}"
assert body2["total"] == len(E2_CODES), f"[{mode}] entity2 total={body2['total']}"
assert body1["mode"] == mode and body2["mode"] == mode
def test_tree_cross_entity_explicit_param_403(client: TestClient, db: Session):
"""跨实体显式传参 → 403(会话 token 绑定账套为准,未改鉴权语义外的任何行为)"""
_seed(db)
create_test_user(db)
_set_mode(db, "horizontal_support")
token1 = _login(client, 1)
token2 = _login(client, 2)
r = client.get(f"{ALIGN}/tree?entity_id=2", headers=auth_header(token1))
assert r.status_code == 403, f"entity1 会话读 entity2 应 403,实际 {r.status_code}"
assert "无权访问" in r.json()["detail"]
r = client.get(f"{ALIGN}/tree?entity_id=1", headers=auth_header(token2))
assert r.status_code == 403, f"entity2 会话读 entity1 应 403,实际 {r.status_code}"
def test_tree_without_token_401(client: TestClient, db: Session):
"""鉴权底线不变:无 Bearer token → 401"""
_seed(db)
_set_mode(db, "bsc_chain")
r = client.get(f"{ALIGN}/tree")
assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"
def test_tree_same_entity_explicit_param_ok(client: TestClient, db: Session):
"""同账套显式传参与 token 一致 → 200(不是一律拒绝)"""
_seed(db)
create_test_user(db)
_set_mode(db, "bsc_chain")
token1 = get_token_for_user(client) # entity1
r = client.get(f"{ALIGN}/tree?entity_id=1", headers=auth_header(token1))
assert r.status_code == 200, r.text
assert _collect_codes(r.json()["tree"]) == set(E1_CODES)
def test_tree_kpi_id_of_other_entity_not_returned(client: TestClient, db: Session):
"""kpi_id 指向对方账套的 KPI → 不返回该 KPI(隔离后树内查无此 id)"""
_seed(db)
create_test_user(db)
_set_mode(db, "vertical_decomposition")
other = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == E2_CODES[0]).first()
token1 = _login(client, 1)
r = client.get(f"{ALIGN}/tree?kpi_id={other.id}", headers=auth_header(token1))
assert r.status_code == 200
body = r.json()
assert body is None or not _collect_codes([body]), f"entity1 不应取到 entity2 的 KPI: {body}"
@@ -0,0 +1,278 @@
"""COSO内控自检「生成改善行动」端点测试
任务:project-20260921-coso-generate-actions
(自检「未通过」条目 → action_plans 写路径,前端「生成改善行动」按钮的后端实现)
本文件覆盖
① 生成:e1(hanke)会话点击 → 200;created == 未通过条目数(从 COSO_CHECKLIST_DATA
现算,不写字面量);新建行 entity_id=1 / status=pending / kpi 属于 e1 /
title 前缀 [COSO] / description 含「来源: COSO内控自检」/ auto_verify_rule is None;
passed=True 的条目没有对应行
② 幂等:同一会话再调一次 → created=0、skipped=上次 created、DB 行数不变
③ 账套隔离:e2(bohai)会话生成的行 entity_id=2;e1/e2 列表互不可见
④ 未认证:无 token → 401
⑤ 显式 kpi_id 归属校验:e1 会话传 e2 的 kpi_id → 404(不得跨账套写)
⑥ 无未通过条目:monkeypatch 全 passed=True → 200 / created=0 / failed_total=0 / 不建行
"""
import copy
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.api import action_plans as ap
from app.models import ActionPlan, Entity, KPIDefinition
from tests.conftest import (
TEST_USER_PASSWORD,
auth_header,
create_test_kpi,
create_test_user,
get_token_for_user,
)
E1_KPI_CODE = "ZZ_COSO_E1_KPI_001"
E2_KPI_CODE = "ZZ_COSO_E2_KPI_001"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _login(client: TestClient, entity_id: int, username: str = "testadmin") -> str:
"""按账套登录并返回 token(e2 会话用;e1 用 conftest 的 get_token_for_user)"""
resp = client.post("/api/cma/auth/login", json={
"username": username,
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _failed_items(coso: dict) -> list:
"""自检未通过条目 [(element, item)](与端点同一口径:not item.get('passed'))"""
out = []
for el in coso["elements"]:
for item in el.get("items") or []:
if not item.get("passed"):
out.append((el, item))
return out
def _failed_titles(slug: str) -> set:
"""按端点生成规则计算的标题集合([COSO] {el}·{item},截断 200)"""
coso = ap.COSO_CHECKLIST_DATA[slug]
return {
f"[COSO] {el['name']}·{item['text']}"[:200]
for el, item in _failed_items(coso)
}
def _seed_e1(db: Session) -> KPIDefinition:
"""e1(酣客)基础夹具:实体 + ceo 用户 + 1 个本账套 KPI"""
_ensure_entity(db, 1, "酣客")
create_test_user(db)
return create_test_kpi(db, kpi_code=E1_KPI_CODE, kpi_name=f"{E1_KPI_CODE}-名称", entity_id=1)
# ──────────────────────────────────────────────
# ① 生成
# ──────────────────────────────────────────────
def test_generate_creates_rows_for_failed_items(client: TestClient, db: Session):
"""生成:未通过条目各建一行、字段口径全部达标;passed=True 条目不建行
断言依据:hanke 自检共 13 条(未通过 10 条)——条数用代码从 COSO_CHECKLIST_DATA
现算而非写字面量,防自检文案/条目数将来调整时用例失真。
"""
kpi = _seed_e1(db)
token = get_token_for_user(client)
h = auth_header(token)
resp = client.post("/api/cma/action-plans/coso-generate-actions", headers=h, json={})
assert resp.status_code == 200, resp.text[:300]
body = resp.json()
coso = ap.COSO_CHECKLIST_DATA["hanke"]
failed = _failed_items(coso)
expected_titles = {f"[COSO] {el['name']}·{it['text']}"[:200] for el, it in failed}
passed_titles = {
f"[COSO] {el['name']}·{it['text']}"[:200]
for el in coso["elements"]
for it in (el.get("items") or [])
if it.get("passed")
}
assert len(failed) > 0, "夹具失配:hanke 自检应存在未通过条目"
assert body["failed_total"] == len(failed), body
assert body["created"] == len(failed), body # 首轮无已存在行,created == 未通过总数
assert body["skipped"] == 0, body
assert body["entity_id"] == 1, body
assert body["entity_slug"] == "hanke", body
assert body["kpi_id"] == kpi.id, body # 自动选取落在本账套唯一 KPI
assert body["source"] == "coso", body
rows = db.query(ActionPlan).filter(ActionPlan.title.like("[COSO]%")).all()
assert len(rows) == body["created"] == len(body["created_ids"]), body
actual_titles = {r.title for r in rows}
assert actual_titles == expected_titles, "新建标题集合与未通过条目集合不一致"
assert not (actual_titles & passed_titles), "passed=True 条目不应生成对应行"
for r in rows:
assert r.entity_id == 1, r.title # 写库账套只来自 token(get_entity_id)
assert r.status == "pending", r.title
assert r.progress == 0, r.title
assert r.kpi_id == kpi.id, r.title
# kpi 归属校验:挂的 KPI 必须属于本账套(列表按 KPI 归属做账套 join 的口径)
kpi_ent = db.query(KPIDefinition).filter(KPIDefinition.id == r.kpi_id).first()
assert kpi_ent is not None and kpi_ent.entity_id == 1, r.title
assert r.title.startswith("[COSO]"), r.title
assert "来源: COSO内控自检" in (r.description or ""), r.title
assert r.auto_verify_rule is None, r.title # 显式不生成验证规则
assert r.due_date is not None, r.title # 默认 +30 天
# ──────────────────────────────────────────────
# ② 幂等
# ──────────────────────────────────────────────
def test_idempotent_second_call_skips_existing(client: TestClient, db: Session):
"""幂等:同一会话第二次调用 created=0、skipped=首次 created、DB 行数不变
判重键 (entity_id, title):重复点击「生成改善行动」不得重复造行。
"""
_seed_e1(db)
h = auth_header(get_token_for_user(client))
first = client.post("/api/cma/action-plans/coso-generate-actions", headers=h, json={})
assert first.status_code == 200
body1 = first.json()
count_after_first = db.query(ActionPlan).count()
assert count_after_first == body1["created"] > 0
second = client.post("/api/cma/action-plans/coso-generate-actions", headers=h, json={})
assert second.status_code == 200
body2 = second.json()
assert body2["created"] == 0, body2
assert body2["skipped"] == body1["created"], body2
assert body2["failed_total"] == body1["failed_total"], body2
assert db.query(ActionPlan).count() == count_after_first, "幂等调用不得新增行"
# ──────────────────────────────────────────────
# ③ 账套隔离
# ──────────────────────────────────────────────
def test_entity_isolation_e1_e2(client: TestClient, db: Session):
"""账套隔离:e2 生成的行 entity_id=2 且挂 e2 KPI;e1/e2 列表互不可见
两个账套存在同名标题(如「控制环境·授权审批制度」两个账套都未通过)——
这正好验证判重键带账套过滤:对端账套同标题行不得抑制本账套创建,
列表也不能把对端行算进来。
"""
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
k1 = create_test_kpi(db, kpi_code=E1_KPI_CODE, kpi_name="e1内控KPI", entity_id=1)
k2 = create_test_kpi(db, kpi_code=E2_KPI_CODE, kpi_name="e2内控KPI", entity_id=2)
h1 = auth_header(_login(client, 1))
h2 = auth_header(_login(client, 2))
r1 = client.post("/api/cma/action-plans/coso-generate-actions", headers=h1, json={})
r2 = client.post("/api/cma/action-plans/coso-generate-actions", headers=h2, json={})
assert r1.status_code == 200, r1.text[:300]
assert r2.status_code == 200, r2.text[:300]
# e2 落库行:entity_id=2 且挂 e2 自己的 KPI
e2_rows = db.query(ActionPlan).filter(ActionPlan.entity_id == 2).all()
assert len(e2_rows) == r2.json()["created"] == len(_failed_items(ap.COSO_CHECKLIST_DATA["bohai"]))
for row in e2_rows:
assert row.entity_id == 2, row.title
assert row.kpi_id == k2.id, row.title
# 列表互不可见:各自列表 == 各自自检未通过标题全集(含同名标题,也绝不含对端专属行)
e1_list = {row["title"] for row in client.get("/api/cma/action-plans", headers=h1).json()["data"]}
e2_list = {row["title"] for row in client.get("/api/cma/action-plans", headers=h2).json()["data"]}
assert e1_list == _failed_titles("hanke"), e1_list
assert e2_list == _failed_titles("bohai"), e2_list
e1_only = _failed_titles("hanke") - _failed_titles("bohai")
e2_only = _failed_titles("bohai") - _failed_titles("hanke")
assert not (e1_list & e2_only), "e1 列表不应出现 e2 专属条目"
assert not (e2_list & e1_only), "e2 列表不应出现 e1 专属条目"
# 差异集非空才说明上面的断言有鉴别力(两账套未通过集合确有重叠与差异)
assert e1_only and e2_only
# ──────────────────────────────────────────────
# ④ 未认证
# ──────────────────────────────────────────────
def test_unauthenticated_returns_401(client: TestClient, db: Session):
"""未认证:无 token 调生成端点 → 401(写路径必须鉴权)"""
_seed_e1(db)
resp = client.post("/api/cma/action-plans/coso-generate-actions", json={})
assert resp.status_code == 401, resp.text[:300]
# ──────────────────────────────────────────────
# ⑤ 显式 kpi_id 归属校验
# ──────────────────────────────────────────────
def test_explicit_kpi_id_must_belong_to_session_entity(client: TestClient, db: Session):
"""显式 kpi_id 归属校验:e1 会话传 e2 的 kpi_id → 404 且不落任何行
kpi_id 是本账套隔离的唯一 join 锚点——若允许挂对端 KPI,
本账套列表会按 KPI 归属 join 而丢失该行,形成「孤儿行」。
"""
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
create_test_kpi(db, kpi_code=E1_KPI_CODE, kpi_name="e1 KPI", entity_id=1)
k2 = create_test_kpi(db, kpi_code=E2_KPI_CODE, kpi_name="e2 KPI", entity_id=2)
h = auth_header(_login(client, 1))
resp = client.post("/api/cma/action-plans/coso-generate-actions",
headers=h, json={"kpi_id": k2.id})
assert resp.status_code == 404, resp.text[:300]
assert db.query(ActionPlan).count() == 0, "跨账套 kpi_id 被拒后不得残留任何行"
# ──────────────────────────────────────────────
# ⑥ 无未通过条目
# ──────────────────────────────────────────────
def test_no_failed_items_returns_zero_not_error(client: TestClient, db: Session, monkeypatch):
"""无未通过条目:全 passed=True → 200 / created=0 / failed_total=0 / 不建行
monkeypatch 模块级 COSO_CHECKLIST_DATA(深拷贝,测后自动还原)——
端点必须把它当「空结果」而不是报错。
"""
_seed_e1(db)
original = ap.COSO_CHECKLIST_DATA # patch 前持有原始引用,用于测后还原校验
patched = copy.deepcopy(ap.COSO_CHECKLIST_DATA)
for entity_data in patched.values():
for el in entity_data["elements"]:
for it in el["items"]:
it["passed"] = True
monkeypatch.setattr(ap, "COSO_CHECKLIST_DATA", patched)
h = auth_header(get_token_for_user(client))
resp = client.post("/api/cma/action-plans/coso-generate-actions", headers=h, json={})
assert resp.status_code == 200, resp.text[:300]
body = resp.json()
assert body["failed_total"] == 0, body
assert body["created"] == 0 and body["skipped"] == 0, body
assert body["created_ids"] == [], body
assert db.query(ActionPlan).count() == 0, "无未通过条目时不得新建行"
# 还原校验:patch 基于深拷贝,原始数据未被改坏(仍含未通过条目)
assert len(_failed_items(original["hanke"])) > 0
@@ -0,0 +1,424 @@
"""CMA 跨实体隔离收口(第二批)账套隔离回归测试 — 2026-09-18
任务:0-P1-project-20260918-entity-isolation-batch2(项目Bot 派单,任总 2026-09-18 17:59 放行)
缺陷(本单范围):
1. 写型·列表批处理
- POST /api/cma/budget/roll-forward 全账套 active 预算一起删最早月/加新月
- GET /api/cma/alerts/check-timeout 全账套 pending 超时预警一起升级为 red
2. 写型/读型·按 id 直取(无归属校验 → 跨账套可达)
- POST /api/cma/alerts/{alert_id}/process | /escalate | /create-action-plan
- PUT /api/cma/action-plans/{plan_id} | DELETE /api/cma/action-plans/{plan_id}
3. 读型·已在发生
- GET /api/cma/cost/abc/activities 全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心
本用例覆盖(每条均双向 + 同账套正对照):
- 写型批处理:会话账套触发只影响本账套行,对端账套行前后逐字段不变;
- 按 id 直取:跨账套 id → 404(对齐同文件既有语义),本账套 id → 正常;且跨账套请求后对端行未被修改/删除;
- 读型:会话账套只返回本账套行,不含对端行。
修复前(旧码)本文件在下列断言上必失败 —— 即「改前/改后对照」中的改前证据(见回报 §三)。
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import json
from app.models import (
AbcActivity,
ActionPlan,
BudgetPlan,
Entity,
KPIAlert,
KPIDefinition,
SystemConfig,
)
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
ALERTS = "/api/cma/alerts"
BUDGET = "/api/cma/budget"
COST = "/api/cma/cost"
PLANS = "/api/cma/action-plans"
# 账套 1/2 各自的资源编码前缀(kpi_code / activity_code 库内唯一)
E1_KPI = "ZZ_B2_E1_KPI_001"
E2_KPI = "ZZ_B2_E2_KPI_001"
E1_ACT = "ZZ_B2_E1_ACT_001"
E2_ACT = "ZZ_B2_E2_ACT_001"
E2_ACT2 = "ZZ_B2_E2_ACT_002"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=0,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _set_rolling(db: Session) -> None:
"""滚动预算模式(全局 SystemConfig,配置值为 JSON 文本,与 /budget/config 写入口径一致)"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
val = json.dumps({"mode": "rolling", "rolling_months": 12}, ensure_ascii=False)
if cfg:
cfg.config_value = val
else:
db.add(SystemConfig(config_key="budget_mode", config_value=val,
description="预算模式: fixed=固定预算, rolling=滚动预算"))
db.commit()
def _seed_plans(db: Session, kpi_id: int, eid: int, months: int, base: float) -> None:
for m in range(1, months + 1):
db.add(BudgetPlan(
entity_id=eid, kpi_id=kpi_id, period=f"2026-{m:02d}",
budget_value=base + m, budget_year=2026, budget_month=m,
version="v1.0", status="active",
))
db.commit()
def _plan_snapshot(db: Session, eid: int) -> list:
"""某账套全部预算行(按 period 排序,逐字段快照)"""
rows = (db.query(BudgetPlan)
.filter(BudgetPlan.entity_id == eid)
.order_by(BudgetPlan.period)
.all())
return [
(r.id, r.kpi_id, r.period, round(r.budget_value, 4), r.budget_year,
r.budget_month, r.status, r.version)
for r in rows
]
def _mk_alert(db: Session, kpi_id: int, eid: int, **kw) -> KPIAlert:
alert = KPIAlert(
entity_id=eid,
kpi_id=kpi_id,
alert_level=kw.get("alert_level", "yellow"),
alert_message=kw.get("alert_message", f"e{eid} 测试预警"),
status=kw.get("status", "pending"),
created_at=kw.get("created_at", datetime.now()),
)
db.add(alert)
db.commit()
db.refresh(alert)
return alert
def _mk_plan(db: Session, kpi_id: int, eid: int, title: str) -> ActionPlan:
plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, title=title,
assignee="张三", priority="medium", status="pending", progress=0)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _mk_activity(db: Session, code: str, eid: int) -> AbcActivity:
act = AbcActivity(
entity_id=eid, activity_code=code, activity_name=f"{code}-作业",
cost_driver="动因", driver_unit="次", total_cost=1000.0, driver_volume=10,
driver_rate=100.0, status="active",
)
db.add(act)
db.commit()
db.refresh(act)
return act
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
return k1, k2
# ──────────────────────────────────────────────
# 1. 写型·列表批处理:budget/roll-forward 按会话账套
# ──────────────────────────────────────────────
def test_roll_forward_only_touches_session_entity(client: TestClient, db: Session):
"""酣客会话滚动延展 → 只动 e1 预算,e2 预算前后逐字段不变(含行数/最早月/新月份)"""
k1, k2 = _seed_two_entities(db)
_set_rolling(db)
_seed_plans(db, k1.id, 1, months=12, base=1000.0) # e1: 12 个月
_seed_plans(db, k2.id, 2, months=3, base=2000.0) # e2: 3 个月
before_e1 = _plan_snapshot(db, 1)
before_e2 = _plan_snapshot(db, 2)
assert len(before_e1) == 12 and len(before_e2) == 3
token1 = _login(client, 1)
r = client.post(f"{BUDGET}/roll-forward", headers=auth_header(token1))
assert r.status_code == 200, r.text
data = r.json()
after_e1 = _plan_snapshot(db, 1)
after_e2 = _plan_snapshot(db, 2)
# 响应只含本账套 KPI
assert len(data["rolled_kpis"]) == 1, f"应只延展 1 个 KPI,实际 {data['rolled_kpis']}"
assert data["rolled_kpis"][0]["kpi_id"] == k1.id
# e1:删 1 行加 1 行(行数不变,最早月被删)
assert len(after_e1) == 12
e1_periods = [p for _, _, p, *_ in after_e1]
assert "2026-01" not in e1_periods
assert data["rolled_kpis"][0]["added_period"] in e1_periods
# e2:逐字段完全未变(这是缺陷核心:跨账套批量写)
assert after_e2 == before_e2, (
f"entity2 预算被跨账套修改\nbefore={before_e2}\nafter={after_e2}"
)
def test_roll_forward_reverse_direction(client: TestClient, db: Session):
"""博海会话滚动延展 → 只动 e2 预算,e1 预算前后不变"""
k1, k2 = _seed_two_entities(db)
_set_rolling(db)
_seed_plans(db, k1.id, 1, months=12, base=1000.0)
_seed_plans(db, k2.id, 2, months=3, base=2000.0)
before_e1 = _plan_snapshot(db, 1)
token2 = _login(client, 2)
r = client.post(f"{BUDGET}/roll-forward", headers=auth_header(token2))
assert r.status_code == 200, r.text
data = r.json()
assert len(data["rolled_kpis"]) == 1
assert data["rolled_kpis"][0]["kpi_id"] == k2.id
assert _plan_snapshot(db, 1) == before_e1, "entity1 预算被 e2 会话跨账套修改"
# ──────────────────────────────────────────────
# 2. 写型·列表批处理:alerts/check-timeout 按会话账套
# ──────────────────────────────────────────────
def _alert_state(db: Session, eid: int):
rows = db.query(KPIAlert).filter(KPIAlert.entity_id == eid).order_by(KPIAlert.id).all()
return [(a.id, a.status, a.alert_level) for a in rows]
def test_check_timeout_only_upgrades_session_entity(client: TestClient, db: Session):
"""酣客会话超时检测 → 只升级 e1 的 pending 预警,e2 保持 pending/yellow"""
k1, k2 = _seed_two_entities(db)
old = datetime.now() - timedelta(hours=25)
_mk_alert(db, k1.id, 1, created_at=old)
_mk_alert(db, k1.id, 1, created_at=old)
_mk_alert(db, k2.id, 2, created_at=old)
_mk_alert(db, k2.id, 2, created_at=old)
token1 = _login(client, 1)
r = client.get(f"{ALERTS}/check-timeout", headers=auth_header(token1))
assert r.status_code == 200, r.text
body = r.json()
assert body["total_timeout"] == 2, f"应只命中本账套 2 条,实际 {body}"
assert body["upgraded_count"] == 2
# e1 升级为 red/processing
for _id, status, level in _alert_state(db, 1):
assert (status, level) == ("processing", "red"), f"e1 预警 {_id} 未升级: {status}/{level}"
# e2 完全未动(缺陷核心:一次调用把所有账套升级)
for _id, status, level in _alert_state(db, 2):
assert (status, level) == ("pending", "yellow"), f"e2 预警 {_id} 被跨账套升级: {status}/{level}"
def test_check_timeout_reverse_direction(client: TestClient, db: Session):
"""博海会话超时检测 → 只升级 e2,e1 保持 pending/yellow"""
k1, k2 = _seed_two_entities(db)
old = datetime.now() - timedelta(hours=25)
_mk_alert(db, k1.id, 1, created_at=old)
_mk_alert(db, k2.id, 2, created_at=old)
token2 = _login(client, 2)
r = client.get(f"{ALERTS}/check-timeout", headers=auth_header(token2))
assert r.status_code == 200, r.text
assert r.json()["upgraded_count"] == 1
for _id, status, level in _alert_state(db, 1):
assert (status, level) == ("pending", "yellow"), f"e1 预警 {_id} 被 e2 会话跨账套升级"
for _id, status, level in _alert_state(db, 2):
assert (status, level) == ("processing", "red")
# ──────────────────────────────────────────────
# 3. 按 id 直取:alerts 三条(跨账套 → 404,同账套 → 正常)
# ──────────────────────────────────────────────
def test_alert_process_entity_scoped(client: TestClient, db: Session):
k1, k2 = _seed_two_entities(db)
mine = _mk_alert(db, k1.id, 1)
other = _mk_alert(db, k2.id, 2)
token1 = _login(client, 1)
# 跨账套 → 404(对齐 /resolve 既有语义:查无此记录)
r = client.post(f"{ALERTS}/{other.id}/process", headers=auth_header(token1),
json={"assignee": "李四"})
assert r.status_code == 404, f"跨账套 process 应 404,实际 {r.status_code} {r.text[:200]}"
db.refresh(other)
assert other.status == "pending", "跨账套 process 竟改动了对端账套记录"
# 同账套 → 正常
r = client.post(f"{ALERTS}/{mine.id}/process", headers=auth_header(token1),
json={"assignee": "李四"})
assert r.status_code == 200, r.text
db.refresh(mine)
assert mine.status == "processing" and mine.assignee == "李四"
def test_alert_escalate_entity_scoped(client: TestClient, db: Session):
k1, k2 = _seed_two_entities(db)
mine = _mk_alert(db, k1.id, 1)
other = _mk_alert(db, k2.id, 2)
token1 = _login(client, 1)
r = client.post(f"{ALERTS}/{other.id}/escalate", headers=auth_header(token1),
json={"assignee": "李四"})
assert r.status_code == 404, f"跨账套 escalate 应 404,实际 {r.status_code} {r.text[:200]}"
db.refresh(other)
assert other.alert_level == "yellow", "跨账套 escalate 竟升级了对端账套预警"
r = client.post(f"{ALERTS}/{mine.id}/escalate", headers=auth_header(token1),
json={"assignee": "李四"})
assert r.status_code == 200, r.text
db.refresh(mine)
assert mine.alert_level == "red"
def test_alert_create_action_plan_entity_scoped(client: TestClient, db: Session):
k1, k2 = _seed_two_entities(db)
mine = _mk_alert(db, k1.id, 1)
other = _mk_alert(db, k2.id, 2)
token1 = _login(client, 1)
r = client.post(f"{ALERTS}/{other.id}/create-action-plan", headers=auth_header(token1),
json={"assignee": "李四"})
assert r.status_code == 404, f"跨账套 create-action-plan 应 404,实际 {r.status_code} {r.text[:200]}"
assert db.query(ActionPlan).filter(ActionPlan.alert_id == other.id).count() == 0, \
"跨账套 create-action-plan 竟为对端账套预警建了计划"
db.refresh(other)
assert other.action_plan_linked_id is None
r = client.post(f"{ALERTS}/{mine.id}/create-action-plan", headers=auth_header(token1),
json={"assignee": "李四"})
assert r.status_code == 200, r.text
db.refresh(mine)
assert mine.action_plan_linked_id is not None
# ──────────────────────────────────────────────
# 4. 按 id 直取:action-plans 改/删(跨账套 → 404,同账套 → 正常)
# ──────────────────────────────────────────────
def test_update_plan_entity_scoped(client: TestClient, db: Session):
k1, k2 = _seed_two_entities(db)
mine = _mk_plan(db, k1.id, 1, "e1 计划")
other = _mk_plan(db, k2.id, 2, "e2 计划")
token1 = _login(client, 1)
r = client.put(f"{PLANS}/{other.id}", headers=auth_header(token1),
json={"title": "被跨账套改名", "status": "completed"})
assert r.status_code == 404, f"跨账套 update 应 404,实际 {r.status_code} {r.text[:200]}"
db.refresh(other)
assert other.title == "e2 计划" and other.status == "pending", "跨账套 update 竟改动了对端计划"
r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1),
json={"title": "本账套改名", "status": "in_progress"})
assert r.status_code == 200, r.text
db.refresh(mine)
assert mine.title == "本账套改名" and mine.status == "in_progress"
def test_delete_plan_entity_scoped(client: TestClient, db: Session):
k1, k2 = _seed_two_entities(db)
mine = _mk_plan(db, k1.id, 1, "e1 计划")
other = _mk_plan(db, k2.id, 2, "e2 计划")
token1 = _login(client, 1)
r = client.delete(f"{PLANS}/{other.id}", headers=auth_header(token1))
assert r.status_code == 404, f"跨账套 delete 应 404,实际 {r.status_code} {r.text[:200]}"
assert db.query(ActionPlan).filter(ActionPlan.id == other.id).count() == 1, \
"跨账套 delete 竟删除了对端账套计划"
r = client.delete(f"{PLANS}/{mine.id}", headers=auth_header(token1))
assert r.status_code == 200, r.text
assert db.query(ActionPlan).filter(ActionPlan.id == mine.id).count() == 0
# ──────────────────────────────────────────────
# 5. 读型:cost/abc/activities 按会话账套
# ──────────────────────────────────────────────
def _abc_codes(client: TestClient, token: str) -> set:
r = client.get(f"{COST}/abc/activities", headers=auth_header(token))
assert r.status_code == 200, r.text
return {row["activity_code"] for row in r.json()["data"]}
def test_abc_activities_isolated_by_entity(client: TestClient, db: Session):
"""酣客会话不应看到博海 ABC 作业中心(生产:10 行全属 entity2)"""
_seed_two_entities(db)
_mk_activity(db, E1_ACT, 1)
_mk_activity(db, E2_ACT, 2)
_mk_activity(db, E2_ACT2, 2)
token1 = _login(client, 1)
token2 = _login(client, 2)
codes1 = _abc_codes(client, token1)
codes2 = _abc_codes(client, token2)
assert codes1 == {E1_ACT}, f"entity1 会话应只见本账套作业中心,实际 {sorted(codes1)}"
assert not any(c.startswith("ZZ_B2_E2_") for c in codes1), "酣客会话混入博海 ABC 作业中心"
assert codes2 == {E2_ACT, E2_ACT2}, f"entity2 会话应只见本账套,实际 {sorted(codes2)}"
def test_abc_activities_without_token_401(client: TestClient, db: Session):
"""鉴权底线不变:无 Bearer token → 401"""
_seed_two_entities(db)
_mk_activity(db, E1_ACT, 1)
r = client.get(f"{COST}/abc/activities")
assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"
@@ -0,0 +1,465 @@
"""CMA 跨实体隔离收口(第三批)账套隔离回归测试 — 2026-09-18
任务:project-20260918-entity-isolation-batch3(项目Bot 派单)
缺陷(本单范围,均与 batch2 同类残留):
A. 写型未接账套
1. POST /api/cma/cost/abc/activities 函数签名无 get_entity_id 依赖;AbcActivity(...) 不写归属
→ 落模型默认 entity_id=1(非 e1 账套新建的作业中心落进 e1)
2. POST /api/cma/cost/abc/allocate 无账套依赖;cost_engine.allocate_cost() 内 AbcAllocation(...)
不写 entity_id → 分配记录全落 e1;activity_id 也不校验归属
B. 读型未接账套
3. GET /api/cma/cost/abc/allocations 查询只按 period/product_code 过滤 → 跨账套读泄漏
(与 batch2 已修的 GET /abc/activities 同族)
4. GET /api/cma/action-plans/stats 统计 db.query(ActionPlan) 无账套过滤
→ 非 e1 账套用户统计数含其它账套计划(同文件既有先例=join KPIDefinition)
C. 归属校验缺失(写型)
5. PUT /api/cma/action-plans/{plan_id} plan_id 已按账套校验(batch2),但 kr_id 分支
db.query(KR).filter(KR.id == data["kr_id"]) 无账套校验
→ 可把本账套计划挂到对端账套的 KR(同文件 create_plan:158 有先例)
本用例覆盖(每条均双向 + 同账套正对照;改前必失败条目见回报 §三 RED 原文):
- 写型:会话账套新建 ABC 作业中心 → 库内该行 entity_id = 会话账套;对端账套看不到;
- 写型:本账套 activity_id 分配 → 200 且新建 abc_allocations 行 entity_id = 会话账套;
对端 activity_id → 404 且不产生任何分配行;
- 读型:GET /abc/allocations 只返回本账套行;
- 归属:PUT /{plan_id} 传对端 kr_id → 404 且对端 KR/本账套计划均未被改动;同账套改挂 → 200;
- 统计:GET /action-plans/stats 只统计本账套计划;
- 鉴权底线:无 token → 401(改前改后都应通过)。
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import (
AbcActivity,
AbcAllocation,
ActionPlan,
Entity,
KR,
KPIDefinition,
Objective,
)
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
COST = "/api/cma/cost"
PLANS = "/api/cma/action-plans"
# 账套 1/2 各自的资源编码前缀(activity_code 库内唯一)
E1_KPI = "ZZ_B3_E1_KPI_001"
E2_KPI = "ZZ_B3_E2_KPI_001"
E1_ACT = "ZZ_B3_E1_ACT_001"
E2_ACT = "ZZ_B3_E2_ACT_001"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=0,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _mk_activity(db: Session, code: str, eid: int) -> AbcActivity:
"""直插作业中心(显式 entity_id,用于构造「对端账套既有数据」)"""
act = AbcActivity(
entity_id=eid, activity_code=code, activity_name=f"{code}-作业",
cost_driver="动因", driver_unit="次", total_cost=1000.0, driver_volume=10,
driver_rate=100.0, status="active",
)
db.add(act)
db.commit()
db.refresh(act)
return act
def _mk_allocation(db: Session, activity: AbcActivity, eid: int, product_code: str) -> AbcAllocation:
alloc = AbcAllocation(
entity_id=eid, period="2026-06", activity_id=activity.id,
product_code=product_code, product_name=f"{product_code}-产品",
driver_consumed=3, allocated_cost=300.0,
)
db.add(alloc)
db.commit()
db.refresh(alloc)
return alloc
def _mk_plan(db: Session, kpi_id: int, eid: int, title: str, **kw) -> ActionPlan:
plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, kr_id=kw.get("kr_id"), title=title,
assignee=kw.get("assignee", "张三"), priority="medium",
status=kw.get("status", "pending"), progress=0)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _mk_objective_kr(db: Session, eid: int, title: str) -> KR:
obj = Objective(entity_id=eid, title=f"{title}-目标", quarter="2026Q3", status="active")
db.add(obj)
db.commit()
db.refresh(obj)
kr = KR(entity_id=eid, objective_id=obj.id, title=title, operator=">=",
weight=50, target_value=100)
db.add(kr)
db.commit()
db.refresh(kr)
return kr
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
return k1, k2
# ──────────────────────────────────────────────
# A1. 写型:POST /cost/abc/activities 按会话账套写入
# ──────────────────────────────────────────────
def test_create_abc_activity_writes_session_entity(client: TestClient, db: Session):
"""博海(e2)会话新建作业中心 → 库内该行 entity_id=2;酣客(e1)会话看不到它"""
_seed_two_entities(db)
token2 = _login(client, 2)
r = client.post(f"{COST}/abc/activities", headers=auth_header(token2), json={
"activity_code": E2_ACT,
"activity_name": "e2 新建作业",
"cost_driver": "动因",
"driver_unit": "次",
"total_cost": 2000.0,
"driver_volume": 20,
})
assert r.status_code == 200, r.text
new_id = r.json()["id"]
db.expire_all()
row = db.query(AbcActivity).filter(AbcActivity.id == new_id).first()
assert row is not None, "作业中心未落库"
assert row.entity_id == 2, (
f"会话账套=2 新建的作业中心落库归属 entity_id={row.entity_id}(缺陷:未写归属→模型默认 1)"
)
# e1 会话看不到 e2 的作业中心;e2 会话能看到
codes1 = _abc_codes(client, _login(client, 1))
codes2 = _abc_codes(client, _login(client, 2))
assert E2_ACT not in codes1, f"酣客(e1)会话看到了博海(e2)新建的作业中心: {sorted(codes1)}"
assert E2_ACT in codes2, f"博海(e2)会话看不到自己新建的作业中心: {sorted(codes2)}"
def test_create_abc_activity_e1_still_defaults_to_own_entity(client: TestClient, db: Session):
"""酣客(e1)会话新建 → entity_id=1 且对端(e2)看不到(同账套正对照,防「一律写死某账套」)"""
_seed_two_entities(db)
token1 = _login(client, 1)
r = client.post(f"{COST}/abc/activities", headers=auth_header(token1), json={
"activity_code": E1_ACT,
"activity_name": "e1 新建作业",
"cost_driver": "动因",
"driver_unit": "次",
"total_cost": 1000.0,
"driver_volume": 10,
})
assert r.status_code == 200, r.text
new_id = r.json()["id"]
db.expire_all()
row = db.query(AbcActivity).filter(AbcActivity.id == new_id).first()
assert row.entity_id == 1, f"会话账套=1 新建行归属 {row.entity_id}"
codes2 = _abc_codes(client, _login(client, 2))
assert E1_ACT not in codes2, f"博海(e2)会话看到了酣客(e1)作业中心: {sorted(codes2)}"
def _abc_codes(client: TestClient, token: str) -> set:
r = client.get(f"{COST}/abc/activities", headers=auth_header(token))
assert r.status_code == 200, r.text
return {row["activity_code"] for row in r.json()["data"]}
# ──────────────────────────────────────────────
# A2. 写型:POST /cost/abc/allocate 按会话账套 + activity_id 归属校验
# ──────────────────────────────────────────────
def test_allocate_cross_entity_activity_rejected(client: TestClient, db: Session):
"""酣客(e1)会话用博海(e2)的 activity_id 分配 → 404,且不产生任何分配行"""
_seed_two_entities(db)
other = _mk_activity(db, E2_ACT, 2)
token1 = _login(client, 1)
r = client.post(f"{COST}/abc/allocate", headers=auth_header(token1), json={
"activity_id": other.id,
"period": "2026-06",
"product_code": "ZZ_B3_E1_PROD",
"product_name": "跨账套产品",
"driver_consumed": 5,
})
assert r.status_code == 404, (
f"跨账套 allocate 应 404,实际 {r.status_code} {r.text[:200]}"
)
db.expire_all()
assert db.query(AbcAllocation).count() == 0, "跨账套 allocate 竟写入了分配记录"
# 对端作业中心未被改动(分配率计算会回写 driver_rate)
db.refresh(other)
assert other.driver_rate == 100.0, "跨账套 allocate 竟改动了对端作业中心"
def test_allocate_writes_session_entity(client: TestClient, db: Session):
"""双向:e1/e2 各自用本账套 activity_id 分配 → 200 且新建行 entity_id = 会话账套"""
_seed_two_entities(db)
act1 = _mk_activity(db, E1_ACT, 1)
act2 = _mk_activity(db, E2_ACT, 2)
# e2 会话分配本账套作业中心
token2 = _login(client, 2)
r = client.post(f"{COST}/abc/allocate", headers=auth_header(token2), json={
"activity_id": act2.id,
"period": "2026-06",
"product_code": "ZZ_B3_E2_PROD",
"product_name": "e2 产品",
"driver_consumed": 4,
})
assert r.status_code == 200, f"同账套 allocate 应 200,实际 {r.status_code} {r.text[:200]}"
assert r.json()["allocated_cost"] == 400.0 # 4 × 100.0
db.expire_all()
row2 = (db.query(AbcAllocation)
.filter(AbcAllocation.product_code == "ZZ_B3_E2_PROD").first())
assert row2 is not None, "e2 会话分配未落库"
assert row2.entity_id == 2, (
f"e2 会话分配行归属 entity_id={row2.entity_id}(缺陷:AbcAllocation 未写归属→默认 1)"
)
# e1 会话分配本账套作业中心(反向对照)
token1 = _login(client, 1)
r = client.post(f"{COST}/abc/allocate", headers=auth_header(token1), json={
"activity_id": act1.id,
"period": "2026-06",
"product_code": "ZZ_B3_E1_PROD",
"product_name": "e1 产品",
"driver_consumed": 2,
})
assert r.status_code == 200, r.text
db.expire_all()
row1 = (db.query(AbcAllocation)
.filter(AbcAllocation.product_code == "ZZ_B3_E1_PROD").first())
assert row1 is not None and row1.entity_id == 1, f"e1 分配行归属异常: {row1.entity_id if row1 else None}"
# ──────────────────────────────────────────────
# B3. 读型:GET /cost/abc/allocations 按会话账套
# ──────────────────────────────────────────────
def _alloc_products(client: TestClient, token: str) -> set:
r = client.get(f"{COST}/abc/allocations", headers=auth_header(token))
assert r.status_code == 200, r.text
return {row["product_code"] for row in r.json()["data"]}
def test_allocations_list_isolated_by_entity(client: TestClient, db: Session):
"""酣客(e1)会话只返本账套分配行,不含博海(e2)行;e2 会话同理(双向)"""
_seed_two_entities(db)
act1 = _mk_activity(db, E1_ACT, 1)
act2 = _mk_activity(db, E2_ACT, 2)
_mk_allocation(db, act1, 1, "ZZ_B3_E1_ALLOC")
_mk_allocation(db, act2, 2, "ZZ_B3_E2_ALLOC")
prods1 = _alloc_products(client, _login(client, 1))
prods2 = _alloc_products(client, _login(client, 2))
assert prods1 == {"ZZ_B3_E1_ALLOC"}, f"e1 会话分配记录集应为本账套,实际 {sorted(prods1)}"
assert prods2 == {"ZZ_B3_E2_ALLOC"}, f"e2 会话分配记录集应为本账套,实际 {sorted(prods2)}"
# ──────────────────────────────────────────────
# C5. PUT /action-plans/{plan_id} 的 kr_id 归属校验
# ──────────────────────────────────────────────
def test_update_plan_kr_id_cross_entity_404(client: TestClient, db: Session):
"""酣客(e1)计划改挂博海(e2)KR → 404,且计划 kr_id 未变、对端 KR 未被改动"""
k1, k2 = _seed_two_entities(db)
mine = _mk_plan(db, k1.id, 1, "e1 计划")
kr2 = _mk_objective_kr(db, 2, "e2 KR")
token1 = _login(client, 1)
r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1),
json={"kr_id": kr2.id})
assert r.status_code == 404, (
f"跨账套 kr_id 应 404,实际 {r.status_code} {r.text[:200]}"
)
db.expire_all()
after = db.query(ActionPlan).filter(ActionPlan.id == mine.id).first()
assert after.kr_id is None, f"跨账套 kr_id 竟被写入: kr_id={after.kr_id}"
assert after.objective_id is None, f"跨账套改挂竟改动了 objective_id={after.objective_id}"
db.refresh(kr2)
assert kr2.entity_id == 2 and kr2.title == "e2 KR", "对端 KR 被改动"
def test_update_plan_kr_id_same_entity_200(client: TestClient, db: Session):
"""同账套正对照:酣客(e1)计划改挂本账套 KR → 200 且 kr_id/objective_id 生效"""
k1, _ = _seed_two_entities(db)
mine = _mk_plan(db, k1.id, 1, "e1 计划")
kr1 = _mk_objective_kr(db, 1, "e1 KR")
token1 = _login(client, 1)
r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1),
json={"kr_id": kr1.id})
assert r.status_code == 200, f"同账套 kr_id 应 200,实际 {r.status_code} {r.text[:200]}"
body = r.json()
assert body["kr_id"] == kr1.id
assert body["objective_id"] == kr1.objective_id
# ──────────────────────────────────────────────
# B4. GET /action-plans/stats 按会话账套
# ──────────────────────────────────────────────
def _stats(client: TestClient, token: str) -> dict:
r = client.get(f"{PLANS}/stats", headers=auth_header(token))
assert r.status_code == 200, r.text
return r.json()
def test_plan_stats_isolated_by_entity(client: TestClient, db: Session):
"""酣客(e1)会话统计只含本账套计划(e2 造 2 条做对照)"""
k1, k2 = _seed_two_entities(db)
_mk_plan(db, k1.id, 1, "e1 待开始")
_mk_plan(db, k1.id, 1, "e1 已完成", status="completed")
_mk_plan(db, k2.id, 2, "e2 待开始")
_mk_plan(db, k2.id, 2, "e2 进行中", status="in_progress")
s1 = _stats(client, _login(client, 1))
assert s1["total"] == 2, f"e1 统计应只含本账套 2 条,实际 {s1}"
assert s1["pending"] == 1 and s1["completed"] == 1 and s1["in_progress"] == 0
def test_plan_stats_reverse_direction(client: TestClient, db: Session):
"""博海(e2)会话统计只含本账套计划(防止「一律统计 e1」或全局口径)"""
k1, k2 = _seed_two_entities(db)
_mk_plan(db, k1.id, 1, "e1 待开始")
_mk_plan(db, k1.id, 1, "e1 待开始2")
_mk_plan(db, k2.id, 2, "e2 待开始")
s2 = _stats(client, _login(client, 2))
assert s2["total"] == 1, f"e2 统计应只含本账套 1 条,实际 {s2}"
assert s2["pending"] == 1
# ──────────────────────────────────────────────
# D6. GET /action-plans 的 KR 富化按会话账套(batch3 读侧同族残余)
# ──────────────────────────────────────────────
# 缺陷:list_plans 富化 kr_title 的查询 db.query(KR).filter(KR.id == p.kr_id) 无账套校验
# → 存量脏行(action_plans.kr_id 指向对端账套 KR)会让列表跨账套回显对端 KR 标题。
# 写入侧已在 batch3 修(update_plan/create_plan 校验 kr_id 归属),本用例覆盖读侧。
def _list_plans(client: TestClient, token: str) -> dict:
r = client.get(PLANS, headers=auth_header(token))
assert r.status_code == 200, r.text
return {row["id"]: row for row in r.json()["data"]}
def test_list_plans_kr_enrichment_isolated_by_entity(client: TestClient, db: Session):
"""双向:本账套计划挂对端 KR(存量脏行)→ 列表不回显对端 KR 标题;挂本账套 KR → 正常回显"""
k1, k2 = _seed_two_entities(db)
kr1 = _mk_objective_kr(db, 1, "e1 KR")
kr2 = _mk_objective_kr(db, 2, "e2 KR")
# e1 账套:一条挂本账套 KR(正对照)+ 一条脏行挂 e2 的 KR
good1 = _mk_plan(db, k1.id, 1, "zz e1 挂本账套 KR", kr_id=kr1.id)
dirty1 = _mk_plan(db, k1.id, 1, "zz e1 脏行挂对端 KR", kr_id=kr2.id)
# e2 账套:反向同理
good2 = _mk_plan(db, k2.id, 2, "zz e2 挂本账套 KR", kr_id=kr2.id)
dirty2 = _mk_plan(db, k2.id, 2, "zz e2 脏行挂对端 KR", kr_id=kr1.id)
rows1 = _list_plans(client, _login(client, 1))
assert good1.id in rows1 and dirty1.id in rows1, f"e1 会话看不到本账套计划: {sorted(rows1)}"
assert rows1[good1.id]["kr_title"] == "e1 KR", (
f"e1 本账套 KR 标题未回显: {rows1[good1.id].get('kr_title')!r}"
)
assert rows1[dirty1.id]["kr_title"] is None, (
f"e1 会话跨账套回显了 e2 的 KR 标题 {rows1[dirty1.id]['kr_title']!r}"
"(缺陷:list_plans 富化 kr 查询缺 KR.entity_id 校验)"
)
assert rows1[dirty1.id]["kr_metric_kpi_id"] is None, (
f"e1 会话跨账套回显了 e2 KR 的 metric_kpi_id = {rows1[dirty1.id]['kr_metric_kpi_id']!r}"
)
rows2 = _list_plans(client, _login(client, 2))
assert good2.id in rows2 and dirty2.id in rows2, f"e2 会话看不到本账套计划: {sorted(rows2)}"
assert rows2[good2.id]["kr_title"] == "e2 KR", (
f"e2 本账套 KR 标题未回显: {rows2[good2.id].get('kr_title')!r}"
)
assert rows2[dirty2.id]["kr_title"] is None, (
f"e2 会话跨账套回显了 e1 的 KR 标题 {rows2[dirty2.id]['kr_title']!r}"
)
assert rows2[dirty2.id]["kr_metric_kpi_id"] is None
# 计划本体归属未被本改动影响:e1 列表不含 e2 的两条计划
assert good2.id not in rows1 and dirty2.id not in rows1, "e1 列表混入了 e2 账套计划"
# ──────────────────────────────────────────────
# 鉴权底线(改前改后都应通过)
# ──────────────────────────────────────────────
def test_batch3_endpoints_without_token_401(client: TestClient, db: Session):
"""无 Bearer token → 401(四个端点鉴权底线不变)"""
_seed_two_entities(db)
assert client.post(f"{COST}/abc/activities", json={
"activity_code": "ZZ_B3_NOTOKEN", "activity_name": "x",
"cost_driver": "d", "total_cost": 1, "driver_volume": 1,
}).status_code == 401
assert client.post(f"{COST}/abc/allocate", json={
"activity_id": 1, "product_code": "p", "driver_consumed": 1,
}).status_code == 401
assert client.get(f"{COST}/abc/allocations").status_code == 401
assert client.get(f"{PLANS}/stats").status_code == 401
@@ -0,0 +1,376 @@
"""CMA 跨实体隔离 — action_plans.objective_id 归属校验回归测试(2026-09-18 batch3 二次派生)
任务:fullstack-20260918-1905-actionplan-objective-id-scope
来源:fullstack-20260918-1845-batch3-followup-listplans-kr(读侧已修,写侧 objective_id 未修)
缺陷(本单范围,与 batch3 已修的 kr_id 缺口同源:写的归属不校验 → 读侧跨账套回显):
1. POST /api/cma/action-plans(create_plan)
`db.query(Objective).filter(Objective.id == objective_id).first()` 只用于季度校验,
不校验 `Objective.entity_id`,随后 `ActionPlan(objective_id=...)` 直接落库
→ 本账套计划可挂到对端账套 Objective。
2. PUT /api/cma/action-plans/{plan_id}(update_plan)
`if "objective_id" in data: plan.objective_id = data["objective_id"]` 既无归属校验,
又覆盖同函数内 kr_id 分支推导出的 objective_id(先按合法 KR 推导出正确目标,再被入参改掉)。
3. 可观测后果:okr.py:get_okr_decomposition 的 weekly_actions 仅按
ActionPlan.objective_id == okr_id + due_date 过滤(无账套条件)
→ 对端账套计划可出现在本账套 OKR 分解视图(下方 T8 覆盖该读侧后果)。
本用例覆盖(每条均双向 + 同账套正对照;改前失败条目 = 5 条,RED 原文见
_reports/evidence-fullstack-20260918-1905-red.txt):
- T1 create_plan 跨账套 objective_id → 404 且不落库半成品行(双向)【改前失败】
- T2 create_plan 同账套 objective_id → 200 且落库正确(双向,正对照)
- T3a objective_id 归属改造后季度校验仍在(越季 → 422,防重构掉既有校验)
- T3b create_plan 不存在的 objective_id → 404【改前失败】
- T4 update_plan 跨账套 objective_id → 404 且计划未被改动(双向)【改前失败】
- T5 update_plan 同账套 objective_id → 200(正对照)
- T6 update_plan 的「入参 objective_id vs kr_id 推导值」显式优先级(推导 / 入参覆盖 / 入参 null 置空)
—— 三条在改前均通过(旧代码同样实现「入参覆盖」),此处作为语义锁用例防后续重构反转口径
- T7 update_plan 同请求内 kr_id 合法 + objective_id 跨账套 → 整体 404 且零改动【改前失败】
- T8 读侧后果:对端账套经 API 无法把计划注入本账套 OKR 分解视图【改前失败】(本账套合法计划仍可见=正对照)
- T9 鉴权底线:无 token → 401(改前改后都应通过)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import ActionPlan, Entity, KR, KPIDefinition, Objective
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
PLANS = "/api/cma/action-plans"
OKR = "/api/cma/okr"
E1_KPI = "ZZ_B3O_E1_KPI_001"
E2_KPI = "ZZ_B3O_E2_KPI_001"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=0,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _mk_objective(db: Session, eid: int, title: str, quarter: str = "2026Q3") -> Objective:
obj = Objective(entity_id=eid, title=title, quarter=quarter, status="active")
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def _mk_kr(db: Session, eid: int, objective_id: int, title: str) -> KR:
kr = KR(entity_id=eid, objective_id=objective_id, title=title, operator=">=",
weight=50, target_value=100)
db.add(kr)
db.commit()
db.refresh(kr)
return kr
def _mk_plan(db: Session, kpi_id: int, eid: int, title: str, **kw) -> ActionPlan:
plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, objective_id=kw.get("objective_id"),
kr_id=kw.get("kr_id"), title=title, assignee="张三",
priority="medium", status=kw.get("status", "pending"), progress=0)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
return k1, k2
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _plans_by_title(db: Session, title: str):
db.expire_all()
return db.query(ActionPlan).filter(ActionPlan.title == title).all()
def _cur_quarter() -> str:
import datetime as _dt
now = _dt.datetime.now()
return f"{now.year}Q{(now.month - 1) // 3 + 1}"
def _today_iso() -> str:
import datetime as _dt
return _dt.date.today().isoformat()
# ──────────────────────────────────────────────
# T1. create_plan 跨账套 objective_id → 404 且不落库
# ──────────────────────────────────────────────
def test_create_plan_objective_id_cross_entity_404(client: TestClient, db: Session):
"""双向:e1 会话挂 e2 目标 / e2 会话挂 e1 目标 → 均 404 且库内无半成品行"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
t_fwd = "ZZ_B3O 跨账套目标 e1挂e2"
r1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": t_fwd, "kpi_id": k1.id, "objective_id": obj2.id})
assert r1.status_code == 404, (
f"e1 会话挂 e2 目标应 404,实际 {r1.status_code} {r1.text[:200]}"
)
assert _plans_by_title(db, t_fwd) == [], "跨账套 objective_id 竟落库(半成品行)"
t_rev = "ZZ_B3O 跨账套目标 e2挂e1"
r2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": t_rev, "kpi_id": k2.id, "objective_id": obj1.id})
assert r2.status_code == 404, (
f"e2 会话挂 e1 目标应 404,实际 {r2.status_code} {r2.text[:200]}"
)
assert _plans_by_title(db, t_rev) == [], "跨账套 objective_id 竟落库(半成品行)"
def test_create_plan_objective_id_same_entity_200(client: TestClient, db: Session):
"""同账套正对照(双向):本账套目标 → 200 且落库 objective_id 正确"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
t1 = "ZZ_B3O 同账套目标 e1"
r1 = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": t1, "kpi_id": k1.id, "objective_id": obj1.id})
assert r1.status_code == 200, f"同账套 objective_id 应 200,实际 {r1.status_code} {r1.text[:200]}"
assert r1.json()["objective_id"] == obj1.id
t2 = "ZZ_B3O 同账套目标 e2"
r2 = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": t2, "kpi_id": k2.id, "objective_id": obj2.id})
assert r2.status_code == 200, f"同账套 objective_id 应 200,实际 {r2.status_code} {r2.text[:200]}"
assert r2.json()["objective_id"] == obj2.id
def test_create_plan_objective_quarter_validation_preserved(client: TestClient, db: Session):
"""归属校验改造不得吃掉既有季度校验:同账套目标 + 越季截止 → 422"""
import datetime as _dt
k1, _ = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标", quarter=_cur_quarter())
far_due = (_dt.date.today() + _dt.timedelta(days=200)).isoformat()
r = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": "ZZ_B3O 越季截止", "kpi_id": k1.id,
"objective_id": obj1.id, "due_date": far_due})
assert r.status_code == 422, (
f"越季截止应 422(既有季度校验被重构吃掉?),实际 {r.status_code} {r.text[:200]}"
)
assert _plans_by_title(db, "ZZ_B3O 越季截止") == []
def test_create_plan_objective_id_unknown_404(client: TestClient, db: Session):
"""不存在的 objective_id → 404(不得静默落库)"""
k1, _ = _seed_two_entities(db)
r = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": "ZZ_B3O 幽灵目标", "kpi_id": k1.id,
"objective_id": 999999})
assert r.status_code == 404, f"不存在的目标应 404,实际 {r.status_code} {r.text[:200]}"
assert _plans_by_title(db, "ZZ_B3O 幽灵目标") == []
# ──────────────────────────────────────────────
# T4/T5. update_plan 的 objective_id 归属校验
# ──────────────────────────────────────────────
def test_update_plan_objective_id_cross_entity_404(client: TestClient, db: Session):
"""双向:e1 计划改挂 e2 目标 / e2 计划改挂 e1 目标 → 404 且计划零改动"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
obj2 = _mk_objective(db, 2, "e2 目标")
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O e1 计划")
r1 = client.put(f"{PLANS}/{p1.id}", headers=auth_header(_login(client, 1)),
json={"objective_id": obj2.id})
assert r1.status_code == 404, (
f"e1 计划挂 e2 目标应 404,实际 {r1.status_code} {r1.text[:200]}"
)
db.expire_all()
assert db.query(ActionPlan).filter(ActionPlan.id == p1.id).first().objective_id is None, \
"跨账套 objective_id 竟被写入"
p2 = _mk_plan(db, k2.id, 2, "ZZ_B3O e2 计划")
r2 = client.put(f"{PLANS}/{p2.id}", headers=auth_header(_login(client, 2)),
json={"objective_id": obj1.id})
assert r2.status_code == 404, (
f"e2 计划挂 e1 目标应 404,实际 {r2.status_code} {r2.text[:200]}"
)
db.expire_all()
assert db.query(ActionPlan).filter(ActionPlan.id == p2.id).first().objective_id is None, \
"跨账套 objective_id 竟被写入"
def test_update_plan_objective_id_same_entity_200(client: TestClient, db: Session):
"""同账套正对照:e1 计划改挂本账套目标 → 200 且落库"""
k1, _ = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标")
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O e1 计划")
r = client.put(f"{PLANS}/{p1.id}", headers=auth_header(_login(client, 1)),
json={"objective_id": obj1.id})
assert r.status_code == 200, f"同账套 objective_id 应 200,实际 {r.status_code} {r.text[:200]}"
assert r.json()["objective_id"] == obj1.id
db.expire_all()
assert db.query(ActionPlan).filter(ActionPlan.id == p1.id).first().objective_id == obj1.id
# ──────────────────────────────────────────────
# T6. 入参 objective_id 与 kr_id 推导值的显式优先级
# ──────────────────────────────────────────────
def test_update_plan_objective_id_priority_over_kr_derived(client: TestClient, db: Session):
"""三条口径:① 未传入参 → 按 KR 推导;② 同传则入参优先(不被推导覆盖);③ 入参 null → 置空"""
k1, _ = _seed_two_entities(db)
obj_a = _mk_objective(db, 1, "e1 目标A")
obj_b = _mk_objective(db, 1, "e1 目标B")
kr_a = _mk_kr(db, 1, obj_a.id, "e1 KR-A")
kr_b = _mk_kr(db, 1, obj_b.id, "e1 KR-B")
token1 = _login(client, 1)
# ① 未传 objective_id + 改挂 KR + 计划当前无目标 → 推导自 KR
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O 优先级-推导")
r1 = client.put(f"{PLANS}/{p1.id}", headers=auth_header(token1), json={"kr_id": kr_a.id})
assert r1.status_code == 200, r1.text[:200]
assert r1.json()["objective_id"] == obj_a.id, (
f"未传入参时应按 KR 推导 objective_id={obj_a.id},实际 {r1.json()['objective_id']}"
)
# ② 同传入参 objective_id(属本账套另一目标)+ 改挂 KR-B → 入参优先,不被 KR-B 推导覆盖
p2 = _mk_plan(db, k1.id, 1, "ZZ_B3O 优先级-入参覆盖")
r2 = client.put(f"{PLANS}/{p2.id}", headers=auth_header(token1),
json={"kr_id": kr_b.id, "objective_id": obj_a.id})
assert r2.status_code == 200, r2.text[:200]
assert r2.json()["kr_id"] == kr_b.id
assert r2.json()["objective_id"] == obj_a.id, (
f"入参 objective_id 应优先于 kr_id 推导值(期望 {obj_a.id},实际 {r2.json()['objective_id']})"
)
# ③ 显式传 objective_id=null → 置空(不回落推导值)
p3 = _mk_plan(db, k1.id, 1, "ZZ_B3O 优先级-null置空", objective_id=obj_a.id)
r3 = client.put(f"{PLANS}/{p3.id}", headers=auth_header(token1),
json={"kr_id": kr_a.id, "objective_id": None})
assert r3.status_code == 200, r3.text[:200]
assert r3.json()["objective_id"] is None, (
f"显式 null 应置空 objective_id,实际 {r3.json()['objective_id']}"
)
def test_update_plan_kr_ok_objective_cross_entity_404(client: TestClient, db: Session):
"""同请求内 KR 合法 + objective_id 跨账套 → 404 且 KR/目标零改动"""
k1, _ = _seed_two_entities(db)
obj_a = _mk_objective(db, 1, "e1 目标A")
obj2 = _mk_objective(db, 2, "e2 目标")
kr_a = _mk_kr(db, 1, obj_a.id, "e1 KR-A")
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O 混合载荷")
r = client.put(f"{PLANS}/{p1.id}", headers=auth_header(_login(client, 1)),
json={"kr_id": kr_a.id, "objective_id": obj2.id})
assert r.status_code == 404, (
f"跨账套 objective_id 应 404,实际 {r.status_code} {r.text[:200]}"
)
db.expire_all()
after = db.query(ActionPlan).filter(ActionPlan.id == p1.id).first()
assert after.kr_id is None, f"404 时 kr_id 竟被写入: {after.kr_id}"
assert after.objective_id is None, f"404 时 objective_id 竟被写入: {after.objective_id}"
# ──────────────────────────────────────────────
# T8. 读侧后果:OKR 分解视图不得出现对端账套计划
# ──────────────────────────────────────────────
def _weekly_titles(client: TestClient, token: str, okr_id: int):
r = client.get(f"{OKR}/{okr_id}/decomposition", headers=auth_header(token))
assert r.status_code == 200, f"分解视图应 200,实际 {r.status_code} {r.text[:200]}"
return {row["title"] for row in r.json()["weekly_actions"]}
def test_okr_decomposition_excludes_cross_entity_plan(client: TestClient, db: Session):
"""e2 会话无法把计划挂进 e1 目标(404)→ e1 分解视图不含该计划;本账套合法计划仍可见"""
k1, k2 = _seed_two_entities(db)
obj1 = _mk_objective(db, 1, "e1 目标", quarter=_cur_quarter())
due = _today_iso()
# 对端(e2)尝试挂 e1 目标 + 本周截止:写入侧拒绝 → 视图不应出现
cross_title = "ZZ_B3O 对端注入计划"
rc = client.post(PLANS, headers=auth_header(_login(client, 2)),
json={"title": cross_title, "kpi_id": k2.id,
"objective_id": obj1.id, "due_date": due})
assert rc.status_code == 404, (
f"对端账套挂本账套目标应 404,实际 {rc.status_code} {rc.text[:200]}"
)
# 本账套合法计划(同目标 + 本周截止)= 正对照
own_title = "ZZ_B3O 本账套合法计划"
ro = client.post(PLANS, headers=auth_header(_login(client, 1)),
json={"title": own_title, "kpi_id": k1.id,
"objective_id": obj1.id, "due_date": due})
assert ro.status_code == 200, ro.text[:200]
titles = _weekly_titles(client, _login(client, 1), obj1.id)
assert cross_title not in titles, f"对端账套计划出现在本账套分解视图: {sorted(titles)}"
assert own_title in titles, f"本账套合法计划未出现在分解视图(正对照失效): {sorted(titles)}"
# 对端账套查本账套目标 → 404(账套边界本身)
rq = client.get(f"{OKR}/{obj1.id}/decomposition", headers=auth_header(_login(client, 2)))
assert rq.status_code == 404, f"对端账套查本账套 OKR 应 404,实际 {rq.status_code}"
# ──────────────────────────────────────────────
# T9. 鉴权底线
# ──────────────────────────────────────────────
def test_objective_id_endpoints_without_token_401(client: TestClient, db: Session):
"""无 token → 401(POST 创建 / PUT 更新),改前改后都应通过"""
k1, _ = _seed_two_entities(db)
p1 = _mk_plan(db, k1.id, 1, "ZZ_B3O 无token")
r1 = client.post(PLANS, json={"title": "ZZ_B3O 无token创建", "kpi_id": k1.id,
"objective_id": 1})
assert r1.status_code == 401, f"创建无 token 应 401,实际 {r1.status_code}"
r2 = client.put(f"{PLANS}/{p1.id}", json={"objective_id": 1})
assert r2.status_code == 401, f"更新无 token 应 401,实际 {r2.status_code}"
@@ -0,0 +1,615 @@
"""CMA 跨实体隔离收口(第四批)账套隔离回归测试 — 2026-09-21
任务:project-20260921-cma-entity-isolation-batch4(项目Bot 派单;来源 OpenCode 周审 20260921)
缺陷(本单范围):
P0-A cash.py 4 端点无账套校验(跨租户改/删/完成/回款)
PUT /api/cma/cash/plans/{plan_id}
DELETE /api/cma/cash/plans/{plan_id}
POST /api/cma/cash/plans/{plan_id}/complete
POST /api/cma/cash/receivables/{plan_id}/payment
P0-B budget.py create_budget_plan 建行未写 entity_id(落默认 1,错归属)
P1 notifications.py update_channel 无字段白名单(mass-assignment)
P1 跨实体只读泄漏点(bot_kpis / data_quality / kpis / dashboard /
customer_dashboard / kpi_governance / budget_generate / budget 读 /
driver_budget / expenses)
P2 okr_templates.py apply_okr_template 建 StrategicMap 未写 entity_id
每条均「跨账套负例 + 本账套正对照」;改前 RED 原文见回报 §三。
本文件只跑内存 SQLite(tests/conftest.py 机制),零生产库 / 零生产 Redis 连接。
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import (
BudgetPlan,
CashPlan,
Entity,
ExpenseReimbursement,
KPIDefinition,
KpiDataQualityLog,
NotificationChannel,
OKRTemplate,
StrategicMap,
DriverFactorBudget,
)
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
CASH = "/api/cma/cash"
BUDGET = "/api/cma/budget"
NOTIF = "/api/cma/notifications"
KPIS = "/api/cma/kpis"
DQ = "/api/cma/data-quality"
DASH = "/api/cma/dashboard"
CUST = "/api/cma/customer-dashboard"
GOV = "/api/cma/kpi"
OKRT = "/api/cma/okr-templates"
EXPENSES = "/api/cma/expenses"
BOT_SRC = "batch4-bot"
# 两账套资源编码前缀(kpi_code 库内唯一)
E1_KPI = "ZZ_B4_E1_KPI_001"
E2_KPI = "ZZ_B4_E2_KPI_001"
E1_CUST = "ZZ_B4_E1_CUST_001"
E2_CUST = "ZZ_B4_E2_CUST_001"
E1_BOT = "ZZ_B4_E1_BOT_001"
E2_BOT = "ZZ_B4_E2_BOT_001"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int, dimension: str = "finance", **kw) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension=dimension,
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=0,
kpi_level="operational",
status="active",
**kw,
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _mk_plan(db: Session, eid: int, amount: float = 10.0, plan_type: str = "receive",
status: str = "pending", paid_amount: float = 0.0, **kw) -> CashPlan:
from datetime import datetime
plan = CashPlan(
entity_id=eid, plan_type=plan_type, amount=amount,
plan_date=datetime(2026, 9, 30), counterparty=f"e{eid}客户",
description=f"e{eid}计划", status=status, source="manual",
paid_amount=paid_amount, **kw,
)
db.add(plan)
db.commit()
db.refresh(plan)
return plan
def _mk_map(db: Session, eid: int, title: str) -> StrategicMap:
m = StrategicMap(entity_id=eid, title=title, status="draft",
dimensions=[], canvas_data={"connections": []})
db.add(m)
db.commit()
db.refresh(m)
return m
def _mk_channel(db: Session, name: str) -> NotificationChannel:
ch = NotificationChannel(name=name, channel_type="wecom", config={"webhook": "x"}, enabled=True)
db.add(ch)
db.commit()
db.refresh(ch)
return ch
def _mk_budget(db: Session, kpi_id: int, eid: int, period: str = "2026-09", value: float = 100.0):
bp = BudgetPlan(entity_id=eid, kpi_id=kpi_id, period=period, budget_value=value,
budget_year=2026, budget_month=9, version="v1.0", status="active")
db.add(bp)
db.commit()
db.refresh(bp)
return bp
def _mk_template(db: Session, name: str) -> OKRTemplate:
t = OKRTemplate(name=name, description="批4测试模板", dimension="finance",
layer="level1", preset_krs=[{"name": "KR测试", "target_value": 100, "weight": 50}],
source="system", is_active=1)
db.add(t)
db.commit()
db.refresh(t)
return t
def _mk_reimb(db: Session, eid: int, no: str, amount: float = 100.0):
from datetime import datetime
r = ExpenseReimbursement(entity_id=eid, reimb_no=no, applicant="张三", department="财务部",
expense_type="office", title=f"{no}-报销", amount=amount,
expense_date=datetime(2026, 9, 10), status="pending",
check_result="pass")
db.add(r)
db.commit()
db.refresh(r)
return r
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "测试企业1")
_ensure_entity(db, 2, "测试企业2")
create_test_user(db)
# ──────────────────────────────────────────────
# P0-A. cash.py 4 端点按账套(跨账套 404)
# ──────────────────────────────────────────────
def test_cash_update_plan_cross_entity_404(client: TestClient, db: Session):
_seed_two_entities(db)
other = _mk_plan(db, 2, amount=50.0)
token1 = _login(client, 1)
r = client.put(f"{CASH}/plans/{other.id}", headers=auth_header(token1), json={"amount": 999.0})
assert r.status_code == 404, f"跨账套改计划应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
row = db.query(CashPlan).filter(CashPlan.id == other.id).first()
assert row is not None, "对端计划被删除"
assert row.amount == 50.0, f"跨账套改计划生效了:amount={row.amount}"
def test_cash_update_plan_own_entity_ok(client: TestClient, db: Session):
_seed_two_entities(db)
mine = _mk_plan(db, 1, amount=50.0)
token1 = _login(client, 1)
r = client.put(f"{CASH}/plans/{mine.id}", headers=auth_header(token1), json={"amount": 88.0})
assert r.status_code == 200, r.text[:200]
db.expire_all()
assert db.query(CashPlan).filter(CashPlan.id == mine.id).first().amount == 88.0
def test_cash_delete_plan_cross_entity_404(client: TestClient, db: Session):
_seed_two_entities(db)
other = _mk_plan(db, 2)
token1 = _login(client, 1)
r = client.delete(f"{CASH}/plans/{other.id}", headers=auth_header(token1))
assert r.status_code == 404, f"跨账套删计划应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
assert db.query(CashPlan).filter(CashPlan.id == other.id).first() is not None, \
"跨账套删除竟生效(对端计划已消失)"
def test_cash_delete_plan_own_entity_ok(client: TestClient, db: Session):
_seed_two_entities(db)
mine = _mk_plan(db, 1)
token1 = _login(client, 1)
r = client.delete(f"{CASH}/plans/{mine.id}", headers=auth_header(token1))
assert r.status_code == 200, r.text[:200]
db.expire_all()
assert db.query(CashPlan).filter(CashPlan.id == mine.id).first() is None
def test_cash_complete_plan_cross_entity_404(client: TestClient, db: Session):
_seed_two_entities(db)
other = _mk_plan(db, 2, amount=50.0)
token1 = _login(client, 1)
r = client.post(f"{CASH}/plans/{other.id}/complete", headers=auth_header(token1), json={})
assert r.status_code == 404, f"跨账套完成计划应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
row = db.query(CashPlan).filter(CashPlan.id == other.id).first()
assert row.status == "pending", f"跨账套完成生效了:status={row.status}"
assert (row.paid_amount or 0) == 0, f"跨账套完成写入了 paid_amount={row.paid_amount}"
def test_cash_complete_plan_own_entity_ok(client: TestClient, db: Session):
_seed_two_entities(db)
mine = _mk_plan(db, 1, amount=50.0)
token1 = _login(client, 1)
r = client.post(f"{CASH}/plans/{mine.id}/complete", headers=auth_header(token1), json={})
assert r.status_code == 200, r.text[:300]
db.expire_all()
row = db.query(CashPlan).filter(CashPlan.id == mine.id).first()
assert row.status == "completed" and row.paid_amount == 50.0
def test_cash_register_payment_cross_entity_404(client: TestClient, db: Session):
_seed_two_entities(db)
other = _mk_plan(db, 2, amount=50.0, plan_type="receive")
token1 = _login(client, 1)
r = client.post(f"{CASH}/receivables/{other.id}/payment", headers=auth_header(token1),
json={"amount": 5.0, "paid_date": "2026-09-20"})
assert r.status_code == 404, f"跨账套回款登记应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
row = db.query(CashPlan).filter(CashPlan.id == other.id).first()
assert (row.paid_amount or 0) == 0, f"跨账套回款登记生效了:paid_amount={row.paid_amount}"
assert row.status == "pending", f"跨账套回款登记改了 status={row.status}"
def test_cash_register_payment_own_entity_ok(client: TestClient, db: Session):
_seed_two_entities(db)
mine = _mk_plan(db, 1, amount=50.0, plan_type="receive")
token1 = _login(client, 1)
r = client.post(f"{CASH}/receivables/{mine.id}/payment", headers=auth_header(token1),
json={"amount": 5.0, "paid_date": "2026-09-20"})
assert r.status_code == 200, r.text[:300]
db.expire_all()
assert db.query(CashPlan).filter(CashPlan.id == mine.id).first().paid_amount == 5.0
# ──────────────────────────────────────────────
# P0-B. budget.py 建预算写会话账套
# ──────────────────────────────────────────────
def test_create_budget_plan_writes_session_entity(client: TestClient, db: Session):
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
token2 = _login(client, 2)
r = client.post(f"{BUDGET}/plans", headers=auth_header(token2),
json={"kpi_id": k2.id, "period": "2026-09", "budget_value": 123.0})
assert r.status_code == 200, r.text[:300]
new_id = r.json()["id"]
db.expire_all()
row = db.query(BudgetPlan).filter(BudgetPlan.id == new_id).first()
assert row is not None, "预算未落库"
assert row.entity_id == 2, (
f"会话账套=2 新建预算落库归属 entity_id={row.entity_id}(缺陷:未写归属→模型默认 1)"
)
def test_create_budget_plan_e1_control(client: TestClient, db: Session):
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
token1 = _login(client, 1)
r = client.post(f"{BUDGET}/plans", headers=auth_header(token1),
json={"kpi_id": k1.id, "period": "2026-09", "budget_value": 55.0})
assert r.status_code == 200, r.text[:300]
db.expire_all()
row = db.query(BudgetPlan).filter(BudgetPlan.id == r.json()["id"]).first()
assert row.entity_id == 1, f"e1 会话建预算 entity_id={row.entity_id}"
# ──────────────────────────────────────────────
# P1. notifications.py 字段白名单
# ──────────────────────────────────────────────
def test_update_channel_rejects_non_whitelist_fields(client: TestClient, db: Session):
_seed_two_entities(db)
ch = _mk_channel(db, "批4渠道")
token1 = _login(client, 1)
r = client.put(f"{NOTIF}/channels/{ch.id}", headers=auth_header(token1), json={
"name": "批4渠道-改名",
"channel_type": "email",
"id": 999999,
"created_at": None,
})
assert r.status_code in (200, 400), f"非白名单字段应被忽略或 400,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
row = db.query(NotificationChannel).filter(NotificationChannel.id == ch.id).first()
assert row.name == "批4渠道-改名", "白名单字段 name 未更新"
assert row.channel_type == "wecom", f"非白名单字段 channel_type 被篡改 → {row.channel_type}"
assert row.id == ch.id, "主键被篡改"
def test_update_channel_allows_business_fields(client: TestClient, db: Session):
_seed_two_entities(db)
ch = _mk_channel(db, "批4渠道2")
token1 = _login(client, 1)
r = client.put(f"{NOTIF}/channels/{ch.id}", headers=auth_header(token1), json={
"name": "批4渠道2-新", "config": {"webhook": "y"}, "enabled": False,
})
assert r.status_code == 200, r.text[:200]
db.expire_all()
row = db.query(NotificationChannel).filter(NotificationChannel.id == ch.id).first()
assert row.name == "批4渠道2-新"
assert row.enabled in (False, 0)
assert (row.config or {}).get("webhook") == "y"
# ──────────────────────────────────────────────
# P1. 跨实体只读泄漏点
# ──────────────────────────────────────────────
def test_bot_kpis_list_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
_mk_kpi(db, E1_BOT, 1, bot_source=BOT_SRC)
_mk_kpi(db, E2_BOT, 2, bot_source=BOT_SRC)
token2 = _login(client, 2)
r = client.get(f"/api/cma/bot-kpis?source={BOT_SRC}", headers=auth_header(token2))
assert r.status_code == 200, r.text[:200]
codes = {k["kpi_code"] for k in r.json()["kpis"]}
assert E1_BOT not in codes, f"e2 会话读到 e1 Bot KPI: {sorted(codes)}"
assert E2_BOT in codes, f"e2 会话读不到自己的 Bot KPI: {sorted(codes)}"
def test_data_quality_check_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
_mk_kpi(db, E2_KPI, 2)
token2 = _login(client, 2)
r = client.get(f"{DQ}/check", headers=auth_header(token2))
assert r.status_code == 200, r.text[:200]
body = r.json()
assert body["total_kpis"] == 1, f"e2 会话扫描了 {body['total_kpis']} 个 KPI(应只扫本账套 1 个)"
db.expire_all()
leaked = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.kpi_id == k1.id).count()
assert leaked == 0, f"e2 会话的质量检查为 e1 的 KPI 写了 {leaked} 条日志"
def test_data_quality_stats_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
_mk_kpi(db, E1_KPI, 1)
_mk_kpi(db, E2_KPI, 2)
token2 = _login(client, 2)
r = client.get(f"{DQ}/stats", headers=auth_header(token2))
assert r.status_code == 200, r.text[:200]
assert r.json()["total_kpis"] == 1, f"e2 统计口径含他账套:total_kpis={r.json()['total_kpis']}"
def test_kpis_causality_chain_cross_entity_404(client: TestClient, db: Session):
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
token1 = _login(client, 1)
r = client.get(f"{KPIS}/{k2.id}/causality-chain", headers=auth_header(token1))
assert r.status_code == 404, f"跨账套读因果链应 404,实际 {r.status_code} {r.text[:200]}"
assert E2_KPI not in r.text, "跨账套响应体泄漏了对端 kpi_code"
def test_kpis_causality_chain_own_entity_ok(client: TestClient, db: Session):
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
token1 = _login(client, 1)
r = client.get(f"{KPIS}/{k1.id}/causality-chain", headers=auth_header(token1))
assert r.status_code == 200, r.text[:200]
assert r.json()["kpi"]["kpi_code"] == E1_KPI
def test_kpis_associate_map_cross_entity_kpi_404(client: TestClient, db: Session):
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
m1 = _mk_map(db, 1, "e1地图")
token1 = _login(client, 1)
r = client.put(f"{KPIS}/{k2.id}/associate-map", headers=auth_header(token1), json={"map_id": m1.id})
assert r.status_code == 404, f"跨账套改 KPI 归属应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
assert db.query(KPIDefinition).filter(KPIDefinition.id == k2.id).first().map_id is None, \
"跨账套 associate-map 竟改动了对端 KPI"
def test_kpis_associate_map_cross_entity_map_404(client: TestClient, db: Session):
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
m2 = _mk_map(db, 2, "e2地图")
token1 = _login(client, 1)
r = client.put(f"{KPIS}/{k1.id}/associate-map", headers=auth_header(token1), json={"map_id": m2.id})
assert r.status_code == 404, f"本账套 KPI 关联对端地图应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
assert db.query(KPIDefinition).filter(KPIDefinition.id == k1.id).first().map_id is None
def test_kpis_associate_map_own_entity_ok(client: TestClient, db: Session):
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
m1 = _mk_map(db, 1, "e1地图")
token1 = _login(client, 1)
r = client.put(f"{KPIS}/{k1.id}/associate-map", headers=auth_header(token1), json={"map_id": m1.id})
assert r.status_code == 200, r.text[:200]
db.expire_all()
assert db.query(KPIDefinition).filter(KPIDefinition.id == k1.id).first().map_id == m1.id
def test_dashboard_my_kpis_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
_mk_kpi(db, E1_KPI, 1)
_mk_kpi(db, E2_KPI, 2)
token2 = _login(client, 2)
r = client.get(f"{DASH}/my-kpis", headers=auth_header(token2))
assert r.status_code == 200, r.text[:200]
assert E1_KPI not in r.text, "e2 会话在 /dashboard/my-kpis 看到 e1 KPI 数据"
assert E2_KPI in r.text, "e2 会话看不到自己的 KPI"
def test_customer_dashboard_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
_mk_kpi(db, E1_CUST, 1, dimension="customer")
_mk_kpi(db, E2_CUST, 2, dimension="customer")
token2 = _login(client, 2)
r = client.get(CUST, headers=auth_header(token2))
assert r.status_code == 200, r.text[:200]
assert E1_CUST not in r.text, "e2 会话在客户看板看到 e1 的客户维度 KPI"
assert E2_CUST in r.text, "e2 会话看不到自己的客户维度 KPI"
def test_kpi_governance_audit_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
_mk_kpi(db, E1_KPI, 1)
_mk_kpi(db, E2_KPI, 2)
token2 = _login(client, 2)
r = client.get(f"{GOV}/governance/audit", headers=auth_header(token2))
assert r.status_code == 200, r.text[:200]
body = r.json()
assert body["total"] == 1, f"e2 治理审计扫了 {body['total']} 个 KPI(应只含本账套 1 个)"
assert E1_KPI not in r.text, "e2 治理审计泄漏 e1 KPI"
def test_budget_generate_candidates_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
_mk_kpi(db, E1_KPI, 1)
_mk_kpi(db, E2_KPI, 2)
token2 = _login(client, 2)
r = client.get(f"{BUDGET}/kpi-budget-candidates?year=2026", headers=auth_header(token2))
assert r.status_code == 200, r.text[:200]
assert E1_KPI not in r.text, "e2 会话在预算候选里看到 e1 KPI"
assert E2_KPI in r.text, "e2 会话看不到自己的预算候选 KPI"
def test_budget_comparison_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
_mk_budget(db, k1.id, 1)
token2 = _login(client, 2)
r = client.get(f"{BUDGET}/comparison?year=2026", headers=auth_header(token2))
assert r.status_code == 200, r.text[:200]
assert r.json()["total_kpis"] == 0, (
f"e2 会话的预算对比含他账套预算:total_kpis={r.json()['total_kpis']}"
)
def test_budget_kpi_comparison_cross_entity_404(client: TestClient, db: Session):
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
token1 = _login(client, 1)
r = client.get(f"{BUDGET}/comparison/kpi/{k2.id}?year=2026", headers=auth_header(token1))
assert r.status_code == 404, f"跨账套读预算对比应 404,实际 {r.status_code} {r.text[:200]}"
def test_budget_deviation_report_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
_mk_kpi(db, E1_KPI, 1)
token2 = _login(client, 2)
r = client.get(f"{BUDGET}/deviation-report?period=2026-09", headers=auth_header(token2))
assert r.status_code == 200, r.text[:200]
assert E1_KPI not in r.text, "e2 会话在预算差异报告看到 e1 KPI"
assert r.json()["summary"]["total_kpis"] == 0, (
f"e2 差异报告含他账套 KPI:total_kpis={r.json()['summary']['total_kpis']}"
)
def test_driver_budget_write_and_history_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
token2 = _login(client, 2)
payload = {
"name": "批4驱动预算",
"formula_text": "a * b",
"factor_defs": [{"key": "a", "label": "A", "default": 2}, {"key": "b", "label": "B", "default": 3}],
"factors": {},
}
r = client.post(f"{BUDGET}/driver/calculate", headers=auth_header(token2), json=payload)
assert r.status_code == 200, r.text[:300]
rec_id = r.json()["id"]
db.expire_all()
rec = db.query(DriverFactorBudget).filter(DriverFactorBudget.id == rec_id).first()
assert rec is not None and rec.entity_id == 2, (
f"e2 会话的驱动因子预算落库归属 entity_id={getattr(rec, 'entity_id', None)}(应为 2)"
)
# 历史列表只含本账套
r1 = client.get(f"{BUDGET}/driver/history", headers=auth_header(_login(client, 1)))
assert r1.status_code == 200, r1.text[:200]
assert "批4驱动预算" not in r1.text, "e1 会话在驱动因子历史里看到 e2 记录"
r2 = client.get(f"{BUDGET}/driver/history", headers=auth_header(token2))
assert "批4驱动预算" in r2.text, "e2 会话看不到自己的驱动因子历史"
def test_expenses_write_and_stats_scoped(client: TestClient, db: Session):
_seed_two_entities(db)
_mk_reimb(db, 1, "ZZ_B4_E1_BX001", amount=111.0)
token2 = _login(client, 2)
r = client.post(f"{EXPENSES}/reimbursements", headers=auth_header(token2), json={
"applicant": "李四", "department": "财务部", "expense_type": "office",
"title": "批4 e2 报销", "amount": 222.0, "expense_date": "2026-09-15",
})
assert r.status_code == 200, r.text[:300]
new_id = r.json()["data"]["id"]
db.expire_all()
row = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == new_id).first()
assert row.entity_id == 2, f"e2 会话报销单落库归属 entity_id={row.entity_id}(应为 2)"
r2 = client.get(f"{EXPENSES}/stats", headers=auth_header(token2))
assert r2.status_code == 200, r2.text[:200]
counts = r2.json()["counts"]
assert counts["total"] == 1, (
f"e2 费用看板统计含他账套:total={counts['total']}(应只统计本账套 1 条)"
)
# ──────────────────────────────────────────────
# P2. okr_templates 应用模板写会话账套
# ──────────────────────────────────────────────
def test_apply_okr_template_writes_session_entity(client: TestClient, db: Session):
_seed_two_entities(db)
t = _mk_template(db, "批4模板")
token2 = _login(client, 2)
r = client.post(f"{OKRT}/{t.id}/apply", headers=auth_header(token2), json={})
assert r.status_code == 200, r.text[:300]
map_id = r.json().get("map_id") or (r.json().get("data") or {}).get("id")
db.expire_all()
m = None
if map_id:
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if m is None:
m = db.query(StrategicMap).order_by(StrategicMap.id.desc()).first()
assert m is not None, f"应用模板未创建地图:{r.text[:300]}"
assert m.entity_id == 2, (
f"会话账套=2 应用 OKR 模板生成的地图归属 entity_id={m.entity_id}(缺陷:未传 entity_id→默认 1)"
)
@@ -0,0 +1,625 @@
"""CMA 跨实体隔离残余收口(第五批)账套隔离回归测试 — 2026-09-21
任务:project-20260921-cma-entity-isolation-batch5(项目Bot 派单;
来源 = batch4 执行中反向推送的残余单 fullstack-20260921-batch5-residual-entity-isolation.md)
本单范围(逐条):
P0 budget.py update_budget_plan / delete_budget_plan / update_deviation_alert
无账套校验 → 本账套会话可改/删他企预算与预警(跨租户写)
P1 kpis.py get_kpi_objectives 无账套收口(KPI/StrategicMap/MapObjective 按 id 直取)
P1 data_quality.py 质量日志族 GET/PUT/DELETE /logs 无账套收口(表无 entity_id 列,
按选项(1) 经 kpi_id join kpi_definitions 按 kpi_definitions.entity_id 收口)
P2 budget.py auto_decompose 单 KPI 分支不写 entity_id(落默认 1)
P2 predict.py growth-quality 硬编码 ENTITY_DATA + 请求体 data.get("entity", data) 回退
P2 expenses.py 列表假隔离 + 详情/审批无归属校验;tax_compliance.py 演示播种不写 entity_id
每条均「跨账套负例 + 本账套正对照」。改前 RED 原文见回报 §三。
本文件只跑内存 SQLite(tests/conftest.py 机制),零生产库 / 零生产 Redis 连接。
"""
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import (
BudgetDeviationAlert,
BudgetPlan,
Entity,
ExpenseReimbursement,
KPIDefinition,
KPIValue,
KpiDataQualityLog,
MapObjective,
StrategicMap,
)
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
BUDGET = "/api/cma/budget"
KPIS = "/api/cma/kpis"
DQ = "/api/cma/data-quality"
PREDICT = "/api/cma/predict"
EXPENSES = "/api/cma/expenses"
TAX = "/api/cma/tax"
# 两账套资源编码前缀(kpi_code 库内唯一)
E1_KPI = "ZZ_B5_E1_KPI_001"
E2_KPI = "ZZ_B5_E2_KPI_001"
E1_KPI_TREE = "ZZ_B5_E1_KPI_TREE"
E1_KPI_OTHER_MAP = "ZZ_B5_E1_KPI_XMAP"
E1_GQ_KPI = "ZZ_B5_E1_GQ"
E2_GQ_KPI = "ZZ_B5_E2_GQ"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int, **kw) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=0,
kpi_level="operational",
status="active",
**kw,
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _mk_plan(db: Session, kpi_id: int, eid: int, period: str = "2026-09",
value: float = 100.0, status: str = "active") -> BudgetPlan:
bp = BudgetPlan(entity_id=eid, kpi_id=kpi_id, period=period, budget_value=value,
budget_year=2026, budget_month=9, version="v1.0", status=status)
db.add(bp)
db.commit()
db.refresh(bp)
return bp
def _mk_alert(db: Session, kpi_id: int, eid: int, status: str = "open") -> BudgetDeviationAlert:
a = BudgetDeviationAlert(entity_id=eid, kpi_id=kpi_id, period="2026-09",
budget_value=100.0, actual_value=130.0,
deviation_rate=30.0, deviation_value=30.0,
alert_level="warning", status=status)
db.add(a)
db.commit()
db.refresh(a)
return a
def _mk_map(db: Session, eid: int, title: str) -> StrategicMap:
m = StrategicMap(entity_id=eid, title=title, status="draft",
dimensions=[], canvas_data={"connections": []})
db.add(m)
db.commit()
db.refresh(m)
return m
def _mk_objective(db: Session, eid: int, map_id: int, name: str, dimension: str = "finance") -> MapObjective:
o = MapObjective(entity_id=eid, map_id=map_id, dimension_key=dimension, name=name)
db.add(o)
db.commit()
db.refresh(o)
return o
def _mk_log(db: Session, kpi_id: int, status: str = "open") -> KpiDataQualityLog:
lg = KpiDataQualityLog(kpi_id=kpi_id, check_type="flat_data", severity="warning",
detail={"note": "batch5"}, suggestion="检查采集", status=status)
db.add(lg)
db.commit()
db.refresh(lg)
return lg
def _mk_reimb(db: Session, eid: int, no: str, amount: float = 100.0,
status: str = "pending") -> ExpenseReimbursement:
r = ExpenseReimbursement(entity_id=eid, reimb_no=no, applicant="张三", department="财务部",
expense_type="office", title=f"{no}-报销", amount=amount,
expense_date=datetime(2026, 9, 10), status=status,
check_result="pass")
db.add(r)
db.commit()
db.refresh(r)
return r
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "测试企业1")
_ensure_entity(db, 2, "测试企业2")
create_test_user(db)
# ──────────────────────────────────────────────
# P0. budget.py 预算/预警写端点按账套(跨账套 404)
# ──────────────────────────────────────────────
def test_budget_update_plan_cross_entity_404(client: TestClient, db: Session):
"""e1 会话改 e2 预算计划 → 404 且对端计划未被改动"""
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
other = _mk_plan(db, k2.id, 2, value=50.0)
token1 = _login(client, 1)
r = client.put(f"{BUDGET}/plans/{other.id}", headers=auth_header(token1),
json={"budget_value": 999.0, "status": "inactive"})
assert r.status_code == 404, f"跨账套改预算应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
row = db.query(BudgetPlan).filter(BudgetPlan.id == other.id).first()
assert row is not None, "对端预算行被删除"
assert row.budget_value == 50.0, f"跨账套改预算生效了:budget_value={row.budget_value}"
assert row.status == "active", f"跨账套改了 status={row.status}"
def test_budget_update_plan_own_entity_ok(client: TestClient, db: Session):
"""本账套改自家预算计划 → 200 且落库"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
mine = _mk_plan(db, k1.id, 1, value=50.0)
token1 = _login(client, 1)
r = client.put(f"{BUDGET}/plans/{mine.id}", headers=auth_header(token1),
json={"budget_value": 88.0})
assert r.status_code == 200, r.text[:200]
db.expire_all()
assert db.query(BudgetPlan).filter(BudgetPlan.id == mine.id).first().budget_value == 88.0
def test_budget_delete_plan_cross_entity_404(client: TestClient, db: Session):
"""e1 会话删 e2 预算计划 → 404 且对端计划仍在"""
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
other = _mk_plan(db, k2.id, 2)
token1 = _login(client, 1)
r = client.delete(f"{BUDGET}/plans/{other.id}", headers=auth_header(token1))
assert r.status_code == 404, f"跨账套删预算应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
assert db.query(BudgetPlan).filter(BudgetPlan.id == other.id).first() is not None, \
"跨账套删除竟生效(对端预算行已消失)"
def test_budget_delete_plan_own_entity_ok(client: TestClient, db: Session):
"""本账套删自家预算计划 → 200 且行消失"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
mine = _mk_plan(db, k1.id, 1)
token1 = _login(client, 1)
r = client.delete(f"{BUDGET}/plans/{mine.id}", headers=auth_header(token1))
assert r.status_code == 200, r.text[:200]
db.expire_all()
assert db.query(BudgetPlan).filter(BudgetPlan.id == mine.id).first() is None
def test_budget_update_deviation_alert_cross_entity_404(client: TestClient, db: Session):
"""e1 会话改 e2 偏差预警 → 404 且对端预警状态未变"""
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
other = _mk_alert(db, k2.id, 2)
token1 = _login(client, 1)
r = client.put(f"{BUDGET}/deviation-alerts/{other.id}", headers=auth_header(token1),
json={"status": "resolved"})
assert r.status_code == 404, f"跨账套改预警应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
row = db.query(BudgetDeviationAlert).filter(BudgetDeviationAlert.id == other.id).first()
assert row is not None, "对端预警行被删除"
assert row.status == "open", f"跨账套改预警生效了:status={row.status}"
def test_budget_update_deviation_alert_own_entity_ok(client: TestClient, db: Session):
"""本账套改自家预警 → 200 且落库"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
mine = _mk_alert(db, k1.id, 1)
token1 = _login(client, 1)
r = client.put(f"{BUDGET}/deviation-alerts/{mine.id}", headers=auth_header(token1),
json={"status": "resolved"})
assert r.status_code == 200, r.text[:200]
db.expire_all()
assert db.query(BudgetDeviationAlert).filter(
BudgetDeviationAlert.id == mine.id).first().status == "resolved"
# ──────────────────────────────────────────────
# P1. kpis.py get_kpi_objectives 账套收口
# ──────────────────────────────────────────────
def test_kpi_objectives_requires_auth(client: TestClient, db: Session):
"""无 token → 401(鉴权面口径)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI_TREE, 1)
r = client.get(f"{KPIS}/{k1.id}/objectives")
assert r.status_code == 401, f"未鉴权访问 KPI 目标应 401,实际 {r.status_code} {r.text[:200]}"
def test_kpi_objectives_cross_entity_404(client: TestClient, db: Session):
"""e1 会话取 e2 KPI 的目标 → 404"""
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
token1 = _login(client, 1)
r = client.get(f"{KPIS}/{k2.id}/objectives", headers=auth_header(token1))
assert r.status_code == 404, f"跨账套取 KPI 目标应 404,实际 {r.status_code} {r.text[:200]}"
def test_kpi_objectives_own_entity_ok(client: TestClient, db: Session):
"""本账套 KPI 的目标/地图正常返回(正对照)"""
_seed_two_entities(db)
m1 = _mk_map(db, 1, "e1地图")
_mk_objective(db, 1, m1.id, "提升收入")
k1 = _mk_kpi(db, E1_KPI_TREE, 1, map_id=m1.id, objective="提升收入")
token1 = _login(client, 1)
r = client.get(f"{KPIS}/{k1.id}/objectives", headers=auth_header(token1))
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body["map"] and body["map"]["id"] == m1.id, f"本账套地图未返回: {body}"
assert len(body["objectives"]) == 1 and body["objectives"][0]["name"] == "提升收入"
def test_kpi_objectives_map_and_objectives_scoped_by_entity(client: TestClient, db: Session):
"""本账套 KPI 挂了他账套地图/目标 → 不得带出他账套地图与目标"""
_seed_two_entities(db)
m2 = _mk_map(db, 2, "e2地图")
_mk_objective(db, 2, m2.id, "e2目标")
# e1 的 KPI,但 map_id 指向 e2 地图(历史脏挂)→ 不应带出 e2 地图/目标
k1 = _mk_kpi(db, E1_KPI_OTHER_MAP, 1, map_id=m2.id, objective="e2目标")
token1 = _login(client, 1)
r = client.get(f"{KPIS}/{k1.id}/objectives", headers=auth_header(token1))
assert r.status_code == 200, r.text[:300]
body = r.json()
assert not body.get("map"), f"带出了他账套战略地图: {body.get('map')}"
assert body["objectives"] == [], f"带出了他账套战略目标: {body['objectives']}"
# ──────────────────────────────────────────────
# P1. data_quality.py 质量日志族按 KPI 归属收口
# ──────────────────────────────────────────────
def test_quality_logs_list_cross_entity_invisible(client: TestClient, db: Session):
"""e1 会话列表看不到 e2 KPI 的质量日志;e2 会话看得到自己的"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
mine = _mk_log(db, k1.id)
other = _mk_log(db, k2.id)
token1 = _login(client, 1)
token2 = _login(client, 2)
r1 = client.get(f"{DQ}/logs", headers=auth_header(token1))
assert r1.status_code == 200, r1.text[:200]
ids1 = {x["id"] for x in r1.json()["data"]}
assert other.id not in ids1, f"e1 会话看到 e2 账套质量日志(id={other.id})"
assert mine.id in ids1, f"e1 会话未看到本账套日志(id={mine.id})"
r2 = client.get(f"{DQ}/logs", headers=auth_header(token2))
ids2 = {x["id"] for x in r2.json()["data"]}
assert other.id in ids2, "e2 会话看不到本账套日志"
def test_quality_logs_list_kpi_filter_cross_entity_empty(client: TestClient, db: Session):
"""e1 会话用他账套 kpi_id 过滤 → 空结果"""
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
other = _mk_log(db, k2.id)
token1 = _login(client, 1)
r = client.get(f"{DQ}/logs?kpi_id={k2.id}", headers=auth_header(token1))
assert r.status_code == 200, r.text[:200]
assert {x["id"] for x in r.json()["data"]} == set(), \
f"按他账套 kpi_id 过滤竟返回日志: {r.json()['data']}"
# 同一过滤器在 e2 会话下命中(正对照,证明过滤本身有效)
token2 = _login(client, 2)
r2 = client.get(f"{DQ}/logs?kpi_id={k2.id}", headers=auth_header(token2))
assert other.id in {x["id"] for x in r2.json()["data"]}
def test_quality_log_update_cross_entity_404(client: TestClient, db: Session):
"""e1 会话改 e2 KPI 的质量日志 → 404 且状态未变"""
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
other = _mk_log(db, k2.id)
token1 = _login(client, 1)
r = client.put(f"{DQ}/logs/{other.id}", headers=auth_header(token1),
json={"status": "resolved"})
assert r.status_code == 404, f"跨账套改质量日志应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
row = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == other.id).first()
assert row is not None and row.status == "open", f"跨账套改日志生效了:{row and row.status}"
def test_quality_log_delete_cross_entity_404(client: TestClient, db: Session):
"""e1 会话删 e2 KPI 的质量日志 → 404 且行仍在"""
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
other = _mk_log(db, k2.id)
token1 = _login(client, 1)
r = client.delete(f"{DQ}/logs/{other.id}", headers=auth_header(token1))
assert r.status_code == 404, f"跨账套删质量日志应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
assert db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == other.id).first() is not None, \
"跨账套删除竟生效(对端日志行已消失)"
def test_quality_log_update_delete_own_entity_ok(client: TestClient, db: Session):
"""本账套改/删自家质量日志 → 200(正对照)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
a = _mk_log(db, k1.id)
b = _mk_log(db, k1.id)
token1 = _login(client, 1)
r = client.put(f"{DQ}/logs/{a.id}", headers=auth_header(token1), json={"status": "resolved"})
assert r.status_code == 200, r.text[:200]
db.expire_all()
assert db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == a.id).first().status == "resolved"
r2 = client.delete(f"{DQ}/logs/{b.id}", headers=auth_header(token1))
assert r2.status_code == 200, r2.text[:200]
db.expire_all()
assert db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == b.id).first() is None
# ──────────────────────────────────────────────
# P2. budget.py auto_decompose 单 KPI 分支账套归属
# ──────────────────────────────────────────────
def test_auto_decompose_single_kpi_writes_session_entity(client: TestClient, db: Session):
"""e2 会话单 KPI 分解 → 12 行月度预算归属 e2(改前落默认 1)"""
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
token2 = _login(client, 2)
r = client.post(f"{BUDGET}/auto-decompose", headers=auth_header(token2),
json={"kpi_id": k2.id, "annual_budget": 120000, "year": 2026, "method": "equal"})
assert r.status_code == 200, r.text[:300]
db.expire_all()
rows = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k2.id).all()
assert len(rows) == 12, f"应生成 12 行月度预算,实际 {len(rows)}"
bad = [x.id for x in rows if x.entity_id != 2]
assert not bad, f"单 KPI 分支未写会话账套 entity_id(错归属行 id={bad})"
def test_auto_decompose_single_kpi_own_entity_ok(client: TestClient, db: Session):
"""e1 会话单 KPI 分解 → 200 且 12 行归属 e1(正对照)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
token1 = _login(client, 1)
r = client.post(f"{BUDGET}/auto-decompose", headers=auth_header(token1),
json={"kpi_id": k1.id, "annual_budget": 120000, "year": 2026, "method": "equal"})
assert r.status_code == 200, r.text[:300]
db.expire_all()
rows = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).all()
assert len(rows) == 12 and all(x.entity_id == 1 for x in rows)
def test_auto_decompose_single_kpi_cross_entity_404(client: TestClient, db: Session):
"""e1 会话对他账套 KPI 做单 KPI 分解 → 404 且不落任何行"""
_seed_two_entities(db)
k2 = _mk_kpi(db, E2_KPI, 2)
token1 = _login(client, 1)
r = client.post(f"{BUDGET}/auto-decompose", headers=auth_header(token1),
json={"kpi_id": k2.id, "annual_budget": 120000, "year": 2026, "method": "equal"})
assert r.status_code == 404, f"跨账套单 KPI 分解应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
assert db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k2.id).count() == 0, \
"跨账套分解竟写出了预算行"
# ──────────────────────────────────────────────
# P2. predict.py growth-quality 按会话账套读库
# ──────────────────────────────────────────────
def _mk_kpi_gq(db: Session, eid: int, code: str, value: float, period: str = "2026-09"):
kpi = _mk_kpi(db, code, eid)
db.add(KPIValue(kpi_id=kpi.id, entity_id=eid, period=period, actual_value=value))
db.commit()
return kpi
def test_growth_quality_body_entity_ignored(client: TestClient, db: Session):
"""请求体注入 entity 经营数据不得被采信(原 data.get("entity", data) 回退)"""
_seed_two_entities(db)
token1 = _login(client, 1)
r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), json={
"period": "2026-09",
"entity": {"entity": "伪造企业", "rebateRate": 82.8, "trueGrossMargin": 18.6,
"cashRatio": 0.6, "expenseGrowthRate": 2.2, "revenueGrowthRate": 1.0,
"mgmtRatio": 447},
})
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body.get("entity_name") != "伪造企业", f"请求体 entity 被采信: {body.get('entity_name')}"
assert body.get("collected") is False, f"库内无数据却给出评分: {body}"
assert body.get("dimensions") == {}, f"未采集却输出维度评分: {body.get('dimensions')}"
assert body.get("overall") is None, f"未采集却输出综合评分: {body.get('overall')}"
# e1 已有同名账套实体 → entity_name 来自库内 Entity 表
ent1 = db.query(Entity).filter(Entity.id == 1).first()
assert body.get("entity_name") == ent1.name
def test_growth_quality_body_explicit_other_entity_403(client: TestClient, db: Session):
"""请求体显式 entity_id 与会话账套不一致 → 403"""
_seed_two_entities(db)
token1 = _login(client, 1)
r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1),
json={"entity_id": 2})
assert r.status_code == 403, f"跨账套显式 entity_id 应 403,实际 {r.status_code} {r.text[:200]}"
def test_growth_quality_reads_own_entity_kpi(client: TestClient, db: Session):
"""库内存在本账套 KPI 实际值 → collected=true 且维度评分来自库内数据"""
_seed_two_entities(db)
# 库内既有编码口径:C_REBATE_RATE / F_GROSS_MARGIN / F_REVENUE_GROWTH
_mk_kpi_gq(db, 1, "C_REBATE_RATE", 82.8)
_mk_kpi_gq(db, 1, "F_GROSS_MARGIN", 18.6)
_mk_kpi_gq(db, 1, "F_REVENUE_GROWTH", 1.0)
token1 = _login(client, 1)
r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), json={"period": "2026-09"})
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body.get("collected") is True, f"库内有数据却判未采集: {body}"
assert body.get("overall") is not None, f"未给出综合评分: {body}"
assert "revenueStructure" in body.get("dimensions", {}), f"营收结构维度缺失: {body.get('dimensions')}"
def test_growth_quality_compare_body_not_trusted(client: TestClient, db: Session):
"""请求体 compare 数据不得被采信"""
_seed_two_entities(db)
token1 = _login(client, 1)
r = client.post(f"{PREDICT}/growth-quality", headers=auth_header(token1), json={
"compare": {"entity": "伪造对比企业", "rebateRate": 0, "mgmtRatio": 1.4},
})
assert r.status_code == 200, r.text[:300]
body = r.json()
assert not body.get("compare") or body["compare"].get("entity_name") != "伪造对比企业", \
f"请求体 compare 被采信: {body.get('compare')}"
# ──────────────────────────────────────────────
# P2. expenses.py 列表/详情/审批账套归属
# ──────────────────────────────────────────────
def test_expenses_list_cross_entity_invisible(client: TestClient, db: Session):
"""e1 会话列表看不到 e2 报销单(原声明了 entity_id 依赖但未生效)"""
_seed_two_entities(db)
mine = _mk_reimb(db, 1, "ZZB5E1BX001")
other = _mk_reimb(db, 2, "ZZB5E2BX001")
token1 = _login(client, 1)
r = client.get(f"{EXPENSES}/reimbursements", headers=auth_header(token1))
assert r.status_code == 200, r.text[:200]
ids = {x["id"] for x in r.json()["data"]}
assert other.id not in ids, f"e1 会话看到 e2 报销单(id={other.id})"
assert mine.id in ids, "e1 会话看不到本账套报销单"
# 正对照:e2 会话看得到自己的
token2 = _login(client, 2)
r2 = client.get(f"{EXPENSES}/reimbursements", headers=auth_header(token2))
assert other.id in {x["id"] for x in r2.json()["data"]}
def test_expenses_detail_cross_entity_404(client: TestClient, db: Session):
"""e1 会话读 e2 报销单详情 → 404"""
_seed_two_entities(db)
other = _mk_reimb(db, 2, "ZZB5E2BX002")
token1 = _login(client, 1)
r = client.get(f"{EXPENSES}/reimbursements/{other.id}", headers=auth_header(token1))
assert r.status_code == 404, f"跨账套读报销单详情应 404,实际 {r.status_code} {r.text[:200]}"
def test_expenses_detail_own_entity_ok(client: TestClient, db: Session):
"""本账套报销单详情 → 200(正对照)"""
_seed_two_entities(db)
mine = _mk_reimb(db, 1, "ZZB5E1BX002")
token1 = _login(client, 1)
r = client.get(f"{EXPENSES}/reimbursements/{mine.id}", headers=auth_header(token1))
assert r.status_code == 200, r.text[:200]
assert r.json()["reimb_no"] == "ZZB5E1BX002"
@pytest.mark.parametrize("action,payload", [
("approve", {}),
("reject", {"comment": "不合规"}),
("return", {"comment": "请补充材料"}),
])
def test_expenses_approval_cross_entity_404(client: TestClient, db: Session, action: str, payload: dict):
"""e1 会话审批 e2 报销单(approve/reject/return)→ 404 且状态未变"""
_seed_two_entities(db)
other = _mk_reimb(db, 2, f"ZZB5E2BX_{action.upper()}")
token1 = _login(client, 1)
r = client.post(f"{EXPENSES}/reimbursements/{other.id}/{action}",
headers=auth_header(token1), json=payload)
assert r.status_code == 404, f"跨账套{action}应 404,实际 {r.status_code} {r.text[:200]}"
db.expire_all()
row = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == other.id).first()
assert row is not None and row.status == "pending", f"跨账套{action}生效了:{row and row.status}"
def test_expenses_approve_own_entity_ok(client: TestClient, db: Session):
"""本账套审批自家报销单 → 200(正对照)"""
_seed_two_entities(db)
mine = _mk_reimb(db, 1, "ZZB5E1BX003")
token1 = _login(client, 1)
r = client.post(f"{EXPENSES}/reimbursements/{mine.id}/approve",
headers=auth_header(token1), json={})
assert r.status_code == 200, r.text[:300]
db.expire_all()
assert db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == mine.id).first().status == "approved"
def test_tax_demo_seed_reimbursement_entity_id(client: TestClient, db: Session):
"""tax_compliance 演示播种的报销单须写会话账套 entity_id"""
_seed_two_entities(db)
token2 = _login(client, 2)
r = client.post(f"{TAX}/demo-data", headers=auth_header(token2))
assert r.status_code == 200, r.text[:300]
db.expire_all()
rows = db.query(ExpenseReimbursement).all()
assert rows, "演示播种未产生报销单"
bad = [(x.id, x.entity_id) for x in rows if x.entity_id != 2]
assert not bad, f"演示报销单未写会话账套(落默认 1): {bad}"
@@ -0,0 +1,225 @@
"""CMA expenses 残余跨账套面收口回归测试 — 2026-09-21
任务:fullstack-20260921-expenses-residual-entity-scope(batch5 派生单)
本单范围(仅此两处,均在 backend/app/api/expenses.py):
F1 (P1 跨租户写) resubmit_reimbursement 仅按 id 取行、无账套归属校验
→ 本账套会话持他企报销单 id 即可改写金额并触发重新校验
F2 (P2 跨租户读污染判定) validate_reimbursement 月度/年度累计分支的累计查询未按
entity_id 过滤 → 他账套同费用类型在途金额计入本账套「月度/年度累计是否超限」判定
每条均「跨账套负例 + 本账套正对照」。
只跑内存 SQLite(tests/conftest.py 机制),零生产库 / 零生产 Redis 连接。
"""
from datetime import datetime
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Entity, ExpenseReimbursement, ExpenseRule
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
EXPENSES = "/api/cma/expenses"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_rule(db: Session, **kw) -> ExpenseRule:
defaults = {
"rule_name": "ZZEXP_RESIDUAL_RULE", "dimension": "expense_type",
"dimension_value": "", "expense_type": "office",
"limit_type": "monthly", "limit_amount": 1000.0,
"cycle": "monthly", "status": "active", "remark": "",
}
defaults.update(kw)
r = ExpenseRule(**defaults)
db.add(r)
db.commit()
db.refresh(r)
return r
def _mk_reimb(db: Session, eid: int, no: str, amount: float = 100.0,
status: str = "pending", expense_type: str = "office",
expense_date: datetime = datetime(2026, 9, 10)) -> ExpenseReimbursement:
r = ExpenseReimbursement(entity_id=eid, reimb_no=no, applicant="张三",
department="财务部", expense_type=expense_type,
title=f"{no}-报销", amount=amount,
expense_date=expense_date, status=status,
check_result="pass")
db.add(r)
db.commit()
db.refresh(r)
return r
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "测试企业1")
_ensure_entity(db, 2, "测试企业2")
create_test_user(db)
def _check_entry(detail, rule_type: str) -> dict:
for c in (detail or []):
if c.get("rule_type") == rule_type:
return c
raise AssertionError(f"check_detail 缺少 {rule_type} 条目: {detail}")
# ──────────────────────────────────────────────
# F1. resubmit_reimbursement 账套归属(跨账套 → 404)
# ──────────────────────────────────────────────
def test_resubmit_cross_entity_404(client: TestClient, db: Session):
"""e1 会话持 e2 报销单 id 重新提交(含改金额)→ 404,且 e2 行零变更"""
_seed_two_entities(db)
other = _mk_reimb(db, 2, "ZZEXP_E2_RESUBMIT_1", amount=100.0, status="returned")
token1 = _login(client, 1)
r = client.post(f"{EXPENSES}/reimbursements/{other.id}/resubmit",
headers=auth_header(token1), json={"amount": 9999.0})
assert r.status_code == 404, (
f"跨账套 resubmit 应 404,实际 {r.status_code} {r.text[:200]}")
assert "报销单不存在" in r.json()["detail"]
# 跨租户写必须零生效:金额/状态/校验结果均未改
db.expire_all()
row = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == other.id).first()
assert row is not None
assert row.amount == 100.0, f"跨账套 resubmit 改写了金额: {row.amount}"
assert row.status == "returned", f"跨账套 resubmit 改写了状态: {row.status}"
def test_resubmit_own_entity_ok(client: TestClient, db: Session):
"""本账套报销单重新提交仍可用 → 200(正对照,未被收口误杀)"""
_seed_two_entities(db)
mine = _mk_reimb(db, 1, "ZZEXP_E1_RESUBMIT_1", amount=100.0, status="returned")
token1 = _login(client, 1)
r = client.post(f"{EXPENSES}/reimbursements/{mine.id}/resubmit",
headers=auth_header(token1), json={"amount": 80.0})
assert r.status_code == 200, r.text[:300]
assert r.json()["message"] == "已重新提交"
d = r.json()["data"]
assert d["amount"] == 80.0
assert d["status"] == "pending"
db.expire_all()
row = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == mine.id).first()
assert row.amount == 80.0 and row.status == "pending"
# ──────────────────────────────────────────────
# F2. validate_reimbursement 累计判定按账套(月度/年度)
# ──────────────────────────────────────────────
def test_monthly_cumulative_cross_entity_excluded(client: TestClient, db: Session):
"""e2 在途金额不得计入 e1 月度累计:e1 单笔 200 / 月限额 1000 应通过"""
_seed_two_entities(db)
_mk_rule(db, rule_name="ZZEXP月限额1000", expense_type="office",
limit_type="monthly", limit_amount=1000.0, cycle="monthly")
_mk_reimb(db, 2, "ZZEXP_E2_MONTH_900", amount=900.0, status="pending",
expense_date=datetime(2026, 9, 5))
token1 = _login(client, 1)
r = client.post(EXPENSES + "/reimbursements", headers=auth_header(token1),
json={"expense_type": "office", "title": "e1月度单",
"amount": 200.0, "expense_date": "2026-09-15"})
assert r.status_code == 200, r.text[:300]
d = r.json()["data"]
assert d["auto_check_passed"] is True, (
f"他账套在途金额污染了本账套月度判定: {d['check_detail']}")
assert d["status"] == "pending"
entry = _check_entry(d["check_detail"], "月度累计")
assert entry["used"] == 0.0, f"月度累计 used 含他账套金额: {entry}"
assert entry["actual"] == 200.0
assert entry["passed"] is True
def test_monthly_cumulative_own_entity_still_counted(client: TestClient, db: Session):
"""本账套月度累计仍照常计入:e1 已有 500,再报 600 → 超 1000 自动打回"""
_seed_two_entities(db)
_mk_rule(db, rule_name="ZZEXP月限额1000b", expense_type="office",
limit_type="monthly", limit_amount=1000.0, cycle="monthly")
_mk_reimb(db, 1, "ZZEXP_E1_MONTH_500", amount=500.0, status="pending",
expense_date=datetime(2026, 9, 5))
token1 = _login(client, 1)
r = client.post(EXPENSES + "/reimbursements", headers=auth_header(token1),
json={"expense_type": "office", "title": "e1月度超限",
"amount": 600.0, "expense_date": "2026-09-16"})
assert r.status_code == 200, r.text[:300]
d = r.json()["data"]
assert d["auto_check_passed"] is False, (
f"本账套累计未生效(收口过度): {d['check_detail']}")
assert d["status"] == "returned"
entry = _check_entry(d["check_detail"], "月度累计")
assert entry["used"] == 500.0, f"本账套月度累计 used 应为 500: {entry}"
assert entry["actual"] == 1100.0
def test_yearly_cumulative_cross_entity_excluded(client: TestClient, db: Session):
"""e2 在途金额不得计入 e1 年度累计(跨月同年度):e1 报 200 / 年限额 1000 应通过"""
_seed_two_entities(db)
_mk_rule(db, rule_name="ZZEXP年限额1000", expense_type="office",
limit_type="yearly", limit_amount=1000.0, cycle="yearly")
_mk_reimb(db, 2, "ZZEXP_E2_YEAR_900", amount=900.0, status="approved",
expense_date=datetime(2026, 3, 5))
token1 = _login(client, 1)
r = client.post(EXPENSES + "/reimbursements", headers=auth_header(token1),
json={"expense_type": "office", "title": "e1年度单",
"amount": 200.0, "expense_date": "2026-09-15"})
assert r.status_code == 200, r.text[:300]
d = r.json()["data"]
assert d["auto_check_passed"] is True, (
f"他账套在途金额污染了本账套年度判定: {d['check_detail']}")
entry = _check_entry(d["check_detail"], "年度累计")
assert entry["used"] == 0.0, f"年度累计 used 含他账套金额: {entry}"
assert entry["actual"] == 200.0
def test_yearly_cumulative_own_entity_still_counted(client: TestClient, db: Session):
"""本账套年度累计仍照常计入:e1 已有 900(3月),再报 200 → 超 1000 自动打回"""
_seed_two_entities(db)
_mk_rule(db, rule_name="ZZEXP年限额1000b", expense_type="office",
limit_type="yearly", limit_amount=1000.0, cycle="yearly")
_mk_reimb(db, 1, "ZZEXP_E1_YEAR_900", amount=900.0, status="approved",
expense_date=datetime(2026, 3, 5))
token1 = _login(client, 1)
r = client.post(EXPENSES + "/reimbursements", headers=auth_header(token1),
json={"expense_type": "office", "title": "e1年度超限",
"amount": 200.0, "expense_date": "2026-09-16"})
assert r.status_code == 200, r.text[:300]
d = r.json()["data"]
assert d["auto_check_passed"] is False, (
f"本账套年度累计未生效(收口过度): {d['check_detail']}")
entry = _check_entry(d["check_detail"], "年度累计")
assert entry["used"] == 900.0, f"本账套年度累计 used 应为 900: {entry}"
assert entry["actual"] == 1100.0
@@ -0,0 +1,275 @@
"""CMA 跨实体隔离 — AI 建议「忽略」与「应用目标 KPI」账套收敛(2026-09-18 派生单)
任务:fullstack-20260918-2030-dismiss-suggestion-entity-scope
父单:fullstack-20260918-2025-read-side-entity-scope-gaps
(其 §五 范围外发现 第 1/2 条:写侧同族漏网点)
缺陷(同文件 ai_suggestions.py 的写侧漏网点):
1. dismiss_suggestion — POST /{suggestion_id}/dismiss 按 id 直取、无账套条件,
且会改状态(status="dismissed")
⇒ 本账套会话带对端账套建议 id 可跨账套忽略对端建议(数据被改写)
2. _apply_kpi_target — 目标 KPIDefinition 只按 id 查,不校验归属会话账套
3. _apply_budget_adjust — 同上(BudgetPlan 侧已按 sug.entity_id,KPI 侧未校验)
⇒ 建议自身归属校验通过后,其 suggestion_data 指向对端账套 KPI 时,
应用动作会改到对端账套的 target_value / 预算行(纵深防御缺口)
对端记录口径 = 404「建议不存在」/「KPI {id} 不存在」(对齐同文件
apply_suggestion 与 1905b/2015 的写法)。
本用例(改前失败条目见 _reports/evidence-fullstack-20260918-2030-red.txt):
- T1 忽略建议:对端账套 → 404 且建议状态保持 unapplied(双向);本账套 → 200
且状态 dismissed(双向正对照)
- T2 忽略幂等:同一建议重复忽略仍 200、状态仍 dismissed(既有语义不得改动)
- T3 应用 kpi_target:目标 KPI 非本账套 → 404,对端 KPI target_value 逐值未变,
建议状态保持 unapplied;本账套应用正常(正对照,目标值已写入)
- T4 应用 budget_adjust:目标 KPI 非本账套 → 404,不落任何预算行(对端/本账套都不落)
- T5 鉴权底线:dismiss 无 token → 401(改前改后都应通过)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import AISuggestion, BudgetPlan, Entity, KPIDefinition
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
SUGG = "/api/cma/ai/suggestions"
# kpi_code 全局唯一 → 每账套独立 code
E1_KPI = "ZZ_DSE1_KPI"
E2_KPI = "ZZ_DSE2_KPI"
E1_SUG_TITLE = "ZZ_DS e1 私有建议"
E2_SUG_TITLE = "ZZ_DS e2 私有建议"
E1_CROSS_TITLE = "ZZ_DS e2 指向对端 KPI 的建议"
# ──────────────────────────────────────────────
# 夹具(沿用同族风格:双向登录 + 同账套正对照)
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int, target: float = 1000.0) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=target,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _mk_suggestion(client: TestClient, token: str, title: str,
target_id: int, kpi_id: int, target_value: float = 2000.0) -> int:
body = {
"suggestion_type": "kpi_target",
"target_type": "kpi",
"target_id": target_id,
"title": title,
"content": "目标偏低,建议上调",
"suggestion_data": {"kpi_id": kpi_id, "target_value": target_value},
}
r = client.post(SUGG, json=body, headers=auth_header(token))
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
return r.json()["data"]["id"]
def _sug_status(db: Session, sid: int) -> str:
db.expire_all()
row = db.query(AISuggestion).filter(AISuggestion.id == sid).first()
assert row is not None, f"建议 #{sid} 不存在"
return row.status
def _kpi_target(db: Session, kid: int) -> float:
db.expire_all()
row = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first()
assert row is not None, f"KPI #{kid} 不存在"
return row.target_value
# ──────────────────────────────────────────────
# T1. 忽略建议按会话账套限定(双向 + 正对照)
# ──────────────────────────────────────────────
def test_dismiss_suggestion_scoped_to_session_entity(client: TestClient, db: Session):
"""对端账套建议 → 404 且状态保持 unapplied;本账套 → 200 且 dismissed(双向)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
tok1 = _login(client, 1)
tok2 = _login(client, 2)
s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id)
s2 = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id)
# 方向 A:e2 会话忽略 e1 建议 → 404 且 e1 建议状态未改
r_a = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok2))
assert r_a.status_code == 404, (
f"e2 会话忽略 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}"
" —— dismiss_suggestion 按 id 直取、未按会话账套限定"
)
assert _sug_status(db, s1) == "unapplied", (
"跨账套忽略竟改动了对端账套建议状态(应为 unapplied)"
)
# 方向 B:e1 会话忽略 e2 建议 → 404 且 e2 建议状态未改
r_b = client.post(f"{SUGG}/{s2}/dismiss", headers=auth_header(tok1))
assert r_b.status_code == 404, (
f"e1 会话忽略 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}"
)
assert _sug_status(db, s2) == "unapplied", (
"跨账套忽略竟改动了对端账套建议状态(应为 unapplied)"
)
# 正对照:本账套忽略自己的建议 → 200 且状态 dismissed
r_ok1 = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok1))
assert r_ok1.status_code == 200, f"e1 忽略自己的建议应 200,实际 {r_ok1.status_code}"
assert _sug_status(db, s1) == "dismissed", "本账套忽略未落状态 dismissed"
r_ok2 = client.post(f"{SUGG}/{s2}/dismiss", headers=auth_header(tok2))
assert r_ok2.status_code == 200, f"e2 忽略自己的建议应 200,实际 {r_ok2.status_code}"
assert _sug_status(db, s2) == "dismissed", "本账套忽略未落状态 dismissed"
# ──────────────────────────────────────────────
# T2. 忽略幂等:既有语义不得改动
# ──────────────────────────────────────────────
def test_dismiss_suggestion_repeat_keeps_existing_semantics(client: TestClient, db: Session):
"""已 dismissed 的建议重复忽略仍 200、状态仍 dismissed(加账套条件不得改动既有语义)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
tok1 = _login(client, 1)
s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id)
assert client.post(f"{SUGG}/{s1}/dismiss",
headers=auth_header(tok1)).status_code == 200
r2 = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok1))
assert r2.status_code == 200, (
f"重复忽略应维持既有 200 语义,实际 {r2.status_code} {r2.text[:200]}"
)
assert _sug_status(db, s1) == "dismissed", "重复忽略后状态异常"
# ──────────────────────────────────────────────
# T3. 应用 kpi_target:目标 KPI 归属校验
# ──────────────────────────────────────────────
def test_apply_kpi_target_foreign_kpi_404(client: TestClient, db: Session):
"""建议自身属本账套、但其目标 KPI 属对端 → 404;对端 KPI target_value 逐值未变"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0)
_mk_kpi(db, E2_KPI, 2, target=3000.0)
tok1 = _login(client, 1)
tok2 = _login(client, 2)
# e2 会话创建建议(归属 e2),但 suggestion_data/target_id 指向 e1 的 KPI
# (模拟手填数据 / 迁移遗留 / Bot 通道写入的不自洽数据)
s_cross = _mk_suggestion(client, tok2, E1_CROSS_TITLE, k1.id, k1.id, target_value=88888.0)
before_k1 = _kpi_target(db, k1.id)
assert before_k1 == 1000.0, f"前置:e1 KPI 目标值异常 {before_k1}"
r = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
json={"action": "kpi_target", "target_value": 99999.0})
assert r.status_code == 404, (
f"目标 KPI 属对端账套应 404,实际 {r.status_code} {r.text[:200]}"
" —— _apply_kpi_target 只按 id 查 KPIDefinition,未校验归属会话账套"
)
assert _kpi_target(db, k1.id) == before_k1, (
f"对端账套 KPI target_value 被改写:{before_k1} → {_kpi_target(db, k1.id)}"
)
assert _sug_status(db, s_cross) == "unapplied", (
"越权应用竟改动了建议状态(应保持 unapplied)"
)
# 正对照:本账套建议 + 本账套 KPI → 200 且目标值已写入
k2 = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == E2_KPI).first()
s_ok = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id, target_value=4500.0)
r_ok = client.post(f"{SUGG}/{s_ok}/apply", headers=auth_header(tok2),
json={"action": "kpi_target", "target_value": 4500.0})
assert r_ok.status_code == 200, f"本账套应用应 200,实际 {r_ok.status_code} {r_ok.text[:200]}"
assert _kpi_target(db, k2.id) == 4500.0, "本账套应用未写入目标值"
# ──────────────────────────────────────────────
# T4. 应用 budget_adjust:目标 KPI 归属校验
# ──────────────────────────────────────────────
def test_apply_budget_adjust_foreign_kpi_404(client: TestClient, db: Session):
"""budget_adjust 目标 KPI 属对端 → 404 且不落任何预算行(含对端账套)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0)
_mk_kpi(db, E2_KPI, 2, target=3000.0)
tok2 = _login(client, 2)
s_cross = _mk_suggestion(client, tok2, E1_CROSS_TITLE, k1.id, k1.id, target_value=7777.0)
db.expire_all()
plans_before = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).count()
r = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2),
json={"action": "budget_adjust", "period": "2026-09", "budget_value": 7777.0})
assert r.status_code == 404, (
f"budget_adjust 目标 KPI 属对端账套应 404,实际 {r.status_code} {r.text[:200]}"
" —— _apply_budget_adjust 的 KPI 查询未校验归属会话账套"
)
db.expire_all()
plans_after = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).count()
assert plans_after == plans_before, (
f"越权 budget_adjust 落了预算行:{plans_before} → {plans_after}"
)
assert _sug_status(db, s_cross) == "unapplied", "越权应用竟改动了建议状态"
# ──────────────────────────────────────────────
# T5. 鉴权底线(dismiss / apply 无 token → 401)
# ──────────────────────────────────────────────
def test_dismiss_and_apply_without_token_401(client: TestClient, db: Session):
"""无 token 一律 401(改前改后都应通过)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
tok1 = _login(client, 1)
s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id)
r1 = client.post(f"{SUGG}/{s1}/dismiss")
assert r1.status_code == 401, f"dismiss 无 token 应 401,实际 {r1.status_code}"
r2 = client.post(f"{SUGG}/{s1}/apply", json={"action": "kpi_target", "target_value": 1})
assert r2.status_code == 401, f"apply 无 token 应 401,实际 {r2.status_code}"
+27 -10
View File
@@ -2,7 +2,7 @@
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from tests.conftest import create_test_user, get_token_for_user, auth_header from tests.conftest import create_test_user, create_test_kpi, get_token_for_user, auth_header
class TestNotificationChannels: class TestNotificationChannels:
@@ -149,16 +149,32 @@ class TestNotificationLogs:
assert resp.json()["total"] == 0 assert resp.json()["total"] == 0
def test_logs_with_data(self, client: TestClient, db: Session): def test_logs_with_data(self, client: TestClient, db: Session):
"""有了日志记录后可查询""" """有了日志记录后可查询
from app.models import NotificationLog
2026-09-22(dama-workbench-entity-isolation-fullstack-20260922 §四 B2)行为变更:
`notification_logs` 无 entity_id 列 → 改为**经父键 join 归属**
(`alert_id → kpi_alerts.id → kpi_alerts.entity_id`)。
故日志必须挂到**本账套真实存在**的 KPIAlert 上才可见;
`alert_id IS NULL` 或指向不存在父键的孤儿行**不返回**(宁少勿错)。
"""
from app.models import NotificationLog, KPIAlert
from datetime import datetime from datetime import datetime
# 直接插入日志 kpi = create_test_kpi(db, kpi_code="NOTIF_LOG_KPI", entity_id=1)
log = NotificationLog( alert = KPIAlert(entity_id=1, kpi_id=kpi.id, alert_level="yellow",
alert_id=1, channel="wecom", recipient="测试群", alert_message="通知日志用例预警", status="pending")
db.add(alert)
db.commit()
db.refresh(alert)
db.add_all([
NotificationLog(
alert_id=alert.id, channel="wecom", recipient="测试群",
title="测试通知", content="测试内容", status="sent", sent_at=datetime.now(), title="测试通知", content="测试内容", status="sent", sent_at=datetime.now(),
) ),
db.add(log) # 孤儿行:无父键可归属 → 一律不返回
NotificationLog(alert_id=None, channel="wecom", title="无归属通知", status="sent"),
])
db.commit() db.commit()
create_test_user(db) create_test_user(db)
@@ -166,8 +182,9 @@ class TestNotificationLogs:
resp = client.get("/api/cma/notifications/logs", headers=auth_header(token)) resp = client.get("/api/cma/notifications/logs", headers=auth_header(token))
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["total"] >= 1 assert resp.json()["total"] == 1, resp.json()
assert len(resp.json()["data"]) >= 1 assert len(resp.json()["data"]) == 1
assert resp.json()["data"][0]["title"] == "测试通知"
class TestAlertPush: class TestAlertPush:
+19 -9
View File
@@ -252,16 +252,27 @@ class TestGrowthQuality:
assert "overall" in data or "level" in data or "score" in data assert "overall" in data or "level" in data or "score" in data
def test_growth_quality_entity2(self, client: TestClient, db: Session): def test_growth_quality_entity2(self, client: TestClient, db: Session):
"""博海增长质量(需绑定 entity2 的会话 token,鉴权收口后不再靠 body entity_id)""" """博海增长质量:企业名取库内 Entity.name,且不采信请求体指标(账套隔离 2026-09-21)
契约变更:原断言硬编码 ENTITY_DATA 的 "陕西博海网络科技"(该硬编码兜底已按
batch5 派单要求移除)→ 改为断言库内来源;无 kpi_values 时如实返回未采集。
"""
token2 = _token_for_entity(client, db, 2) token2 = _token_for_entity(client, db, 2)
resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2}, resp = client.post(f"{BASE}/growth-quality", json={"entity_id": 2, "rebateRate": 99},
headers=auth_header(token2)) headers=auth_header(token2))
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["entity_name"] == "陕西博海网络科技" assert data["entity_id"] == 2
assert data["entity_name"] == "企业2", "企业名应取库内 Entity.name,而非硬编码"
assert data["collected"] is False
assert data["overall"] is None, "库内无数据不得编造评分"
def test_growth_quality_custom(self, client: TestClient, db: Session): def test_growth_quality_custom(self, client: TestClient, db: Session):
"""自定义企业数据(低分场景)""" """自定义企业数据(低分场景)→ 契约变更:请求体指标不再被采信
原用例靠请求体 rebateRate/mgmtRatio 等打分得 danger;该回退已按 batch5 派单
要求移除 → 改为断言请求体指标被忽略、无库内数据即未采集(不编造评分)。
"""
token99 = _token_for_entity(client, db, 99) token99 = _token_for_entity(client, db, 99)
resp = client.post(f"{BASE}/growth-quality", json={ resp = client.post(f"{BASE}/growth-quality", json={
"entity_id": 99, "entity_id": 99,
@@ -271,8 +282,7 @@ class TestGrowthQuality:
}, headers=auth_header(token99)) }, headers=auth_header(token99))
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert data["entity_name"] == "测试企业" assert data["entity_name"] == "企业99", "企业名取库内,不取请求体 name"
assert data["overall"] < 2 assert data["collected"] is False
assert data["level_type"] == "danger" assert data["overall"] is None and data["level_type"] is None
# 低分诊断 assert data["dimensions"] == {} and data["diagnosis"] is None
assert "越增长越重" in data["diagnosis"]
@@ -0,0 +1,276 @@
"""CMA 跨实体隔离 — 读侧(只读端点)账套收敛缺口(2026-09-18 派生单)
任务:fullstack-20260918-2025-read-side-entity-scope-gaps
父单:fullstack-20260918-2015-actionplan-entity-write-residual-sites
(其范围外只读侦查登记的三处同族只读缺口)
缺陷(三处按 id 直取、未按会话账套限定):
1. app/api/ai_suggestions.py::get_suggestion — GET /{suggestion_id} 无账套条件
2. app/api/ai_suggestions.py::preview_suggestion — GET /{suggestion_id}/preview
已有 entity_id 依赖但未用于过滤
3. app/api/deviation_push.py::get_map_nodes — GET /map-nodes/{map_id}
无账套校验且端点无 get_entity_id 依赖
后果:本账套会话带任意 id 即可读到对端账套的建议全文 / 应用前预览参数 /
战略地图全部节点(维度·目标·KPI 编码)。对端记录口径 = 404「不存在」
(对齐 alerts.py::create_action_plan_from_alert 与 1905b/2015 的写侧写法)。
本用例(改前失败条目见 _reports/evidence-<task>-red.txt):
- T1 建议详情:对端账套 → 404 且响应体不含对端字段;本账套 → 200(双向正对照)
- T2 应用前预览:对端账套 → 404 且不泄漏对端 KPI 名;本账套 → 200 字段完整(双向)
- T3 地图节点:对端账套 → 404 且不泄漏对端目标名;本账套 → 200 节点正常(双向)
- T4 鉴权底线:三处无 token → 401(改前改后都应通过)
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models import Entity, KPIDefinition, StrategicMap
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
SUGG = "/api/cma/ai/suggestions"
MAP_NODES = "/api/cma/deviation-push/map-nodes"
# kpi_code 全局唯一 → 每账套独立 code
E1_KPI = "ZZ_RSG_E1_KPI"
E2_KPI = "ZZ_RSG_E2_KPI"
E1_MAP_KPI = "ZZ_RSG_E1_MAP_KPI"
E2_MAP_KPI = "ZZ_RSG_E2_MAP_KPI"
E1_OBJ = "ZZ_RSG e1 目标"
E2_OBJ = "ZZ_RSG e2 目标"
E1_SUG_TITLE = "ZZ_RSG e1 私有建议"
E2_SUG_TITLE = "ZZ_RSG e2 私有建议"
# ──────────────────────────────────────────────
# 夹具(沿用 1905b/2015 同族风格:双向登录 + 同账套正对照)
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str) -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status="active")
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int, target: float = 1000.0) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
target_value=target,
kpi_level="operational",
status="active",
)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _mk_map(db: Session, eid: int, title: str, kpi_code: str, obj_name: str) -> StrategicMap:
m = StrategicMap(
entity_id=eid,
title=title,
version="v1.0",
status="draft",
dimensions=[{
"key": "finance", "name": "财务维度", "icon": "F", "color": "#409eff",
"objectives": [{"name": obj_name, "kpis": [kpi_code]}],
}],
canvas_data={"connections": []},
)
db.add(m)
db.commit()
db.refresh(m)
return m
def _seed_two_entities(db: Session):
_ensure_entity(db, 1, "酣客")
_ensure_entity(db, 2, "博海")
create_test_user(db)
def _login(client: TestClient, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": "testadmin",
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _mk_suggestion(client: TestClient, token: str, kpi: KPIDefinition, title: str,
target_value: float = 2000.0) -> int:
body = {
"suggestion_type": "kpi_target",
"target_type": "kpi",
"target_id": kpi.id,
"title": title,
"content": "目标偏低,建议上调",
"suggestion_data": {"kpi_id": kpi.id, "target_value": target_value},
}
r = client.post(SUGG, json=body, headers=auth_header(token))
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
return r.json()["data"]["id"]
# ──────────────────────────────────────────────
# T1. 建议详情 GET /{suggestion_id}
# ──────────────────────────────────────────────
def test_suggestion_detail_scoped_to_session_entity(client: TestClient, db: Session):
"""对端账套建议 → 404 且响应体不泄漏;本账套建议 → 200(双向)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
tok1 = _login(client, 1)
tok2 = _login(client, 2)
s1 = _mk_suggestion(client, tok1, k1, E1_SUG_TITLE)
s2 = _mk_suggestion(client, tok2, k2, E2_SUG_TITLE)
# 方向 A:e2 会话读 e1 建议 → 404,响应体不得含 e1 字段
r_a = client.get(f"{SUGG}/{s1}", headers=auth_header(tok2))
assert r_a.status_code == 404, (
f"e2 会话读 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}"
" —— get_suggestion 按 id 直取、未按会话账套限定"
)
assert E1_SUG_TITLE not in r_a.text, "越权响应体泄漏了对端账套建议标题"
# 方向 B:e1 会话读 e2 建议 → 404
r_b = client.get(f"{SUGG}/{s2}", headers=auth_header(tok1))
assert r_b.status_code == 404, (
f"e1 会话读 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}"
)
assert E2_SUG_TITLE not in r_b.text, "越权响应体泄漏了对端账套建议标题"
# 正对照:本账套读自己的建议正常
r_ok1 = client.get(f"{SUGG}/{s1}", headers=auth_header(tok1))
assert r_ok1.status_code == 200, f"e1 读自己的建议应 200,实际 {r_ok1.status_code}"
assert r_ok1.json()["data"]["id"] == s1
assert r_ok1.json()["data"]["title"] == E1_SUG_TITLE
r_ok2 = client.get(f"{SUGG}/{s2}", headers=auth_header(tok2))
assert r_ok2.status_code == 200, f"e2 读自己的建议应 200,实际 {r_ok2.status_code}"
assert r_ok2.json()["data"]["title"] == E2_SUG_TITLE
# ──────────────────────────────────────────────
# T2. 应用前预览 GET /{suggestion_id}/preview
# ──────────────────────────────────────────────
def test_suggestion_preview_scoped_to_session_entity(client: TestClient, db: Session):
"""对端账套预览 → 404 且不泄漏对端 KPI 名;本账套预览 → 200 字段完整(双向)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0)
k2 = _mk_kpi(db, E2_KPI, 2, target=3000.0)
tok1 = _login(client, 1)
tok2 = _login(client, 2)
s1 = _mk_suggestion(client, tok1, k1, E1_SUG_TITLE, target_value=2500.0)
s2 = _mk_suggestion(client, tok2, k2, E2_SUG_TITLE, target_value=4500.0)
# 方向 A:e2 预览 e1 建议 → 404 + 不泄漏 e1 KPI 名
r_a = client.get(f"{SUGG}/{s1}/preview", headers=auth_header(tok2))
assert r_a.status_code == 404, (
f"e2 预览 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}"
" —— preview_suggestion 有 entity_id 依赖但未用于过滤"
)
assert str(k1.kpi_name) not in r_a.text, "越权预览泄漏了对端账套 KPI 名"
# 方向 B:e1 预览 e2 建议 → 404
r_b = client.get(f"{SUGG}/{s2}/preview", headers=auth_header(tok1))
assert r_b.status_code == 404, (
f"e1 预览 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}"
)
assert str(k2.kpi_name) not in r_b.text, "越权预览泄漏了对端账套 KPI 名"
# 正对照:本账套预览自己的建议,字段完整
r_ok = client.get(f"{SUGG}/{s1}/preview", headers=auth_header(tok1))
assert r_ok.status_code == 200, f"e1 预览自己的建议应 200,实际 {r_ok.status_code}"
data = r_ok.json()["data"]
assert data["type"] == "kpi_target", f"预览类型异常: {data}"
assert data["kpi_name"] == k1.kpi_name, f"预览 KPI 名异常: {data}"
assert data["current_target"] == 1000.0, f"预览当前值异常: {data}"
assert data["new_target"] == 2500.0, f"预览新值异常: {data}"
r_ok2 = client.get(f"{SUGG}/{s2}/preview", headers=auth_header(tok2))
assert r_ok2.status_code == 200, f"e2 预览自己的建议应 200,实际 {r_ok2.status_code}"
assert r_ok2.json()["data"]["new_target"] == 4500.0
# ──────────────────────────────────────────────
# T3. 战略地图节点 GET /map-nodes/{map_id}
# ──────────────────────────────────────────────
def test_map_nodes_scoped_to_session_entity(client: TestClient, db: Session):
"""对端账套地图节点 → 404 且不泄漏对端目标名;本账套 → 200 节点正常(双向)"""
_seed_two_entities(db)
_mk_kpi(db, E1_MAP_KPI, 1)
_mk_kpi(db, E2_MAP_KPI, 2)
m1 = _mk_map(db, 1, "ZZ_RSG e1 地图", E1_MAP_KPI, E1_OBJ)
m2 = _mk_map(db, 2, "ZZ_RSG e2 地图", E2_MAP_KPI, E2_OBJ)
tok1 = _login(client, 1)
tok2 = _login(client, 2)
# 方向 A:e2 读 e1 地图节点 → 404 + 不泄漏对端目标名
r_a = client.get(f"{MAP_NODES}/{m1.id}", headers=auth_header(tok2))
assert r_a.status_code == 404, (
f"e2 会话读 e1 地图节点应 404,实际 {r_a.status_code} {r_a.text[:200]}"
" —— get_map_nodes 无账套校验、端点无 get_entity_id 依赖"
)
assert E1_OBJ not in r_a.text, "越权响应体泄漏了对端账套目标名"
assert E1_MAP_KPI not in r_a.text, "越权响应体泄漏了对端账套 KPI 编码"
# 方向 B:e1 读 e2 地图节点 → 404
r_b = client.get(f"{MAP_NODES}/{m2.id}", headers=auth_header(tok1))
assert r_b.status_code == 404, (
f"e1 会话读 e2 地图节点应 404,实际 {r_b.status_code} {r_b.text[:200]}"
)
assert E2_OBJ not in r_b.text, "越权响应体泄漏了对端账套目标名"
# 正对照:本账套读自己的地图节点正常
r_ok = client.get(f"{MAP_NODES}/{m1.id}", headers=auth_header(tok1))
assert r_ok.status_code == 200, f"e1 读自己的地图节点应 200,实际 {r_ok.status_code}"
nodes = r_ok.json()["data"]
assert len(nodes) == 1, f"节点数异常: {nodes}"
assert nodes[0]["objective_name"] == E1_OBJ, f"节点目标名异常: {nodes}"
assert nodes[0]["kpi_codes"] == [E1_MAP_KPI], f"节点 KPI 编码异常: {nodes}"
r_ok2 = client.get(f"{MAP_NODES}/{m2.id}", headers=auth_header(tok2))
assert r_ok2.status_code == 200, f"e2 读自己的地图节点应 200,实际 {r_ok2.status_code}"
assert r_ok2.json()["data"][0]["objective_name"] == E2_OBJ
# ──────────────────────────────────────────────
# T4. 鉴权底线(三处无 token → 401)
# ──────────────────────────────────────────────
def test_read_side_endpoints_without_token_401(client: TestClient, db: Session):
"""三处只读端点无 token 一律 401(改前改后都应通过)"""
_seed_two_entities(db)
k1 = _mk_kpi(db, E1_KPI, 1)
m1 = _mk_map(db, 1, "ZZ_RSG e1 鉴权地图", E1_KPI, E1_OBJ)
tok1 = _login(client, 1)
s1 = _mk_suggestion(client, tok1, k1, E1_SUG_TITLE)
r1 = client.get(f"{SUGG}/{s1}")
assert r1.status_code == 401, f"建议详情无 token 应 401,实际 {r1.status_code}"
r2 = client.get(f"{SUGG}/{s1}/preview")
assert r2.status_code == 401, f"建议预览无 token 应 401,实际 {r2.status_code}"
r3 = client.get(f"{MAP_NODES}/{m1.id}")
assert r3.status_code == 401, f"地图节点无 token 应 401,实际 {r3.status_code}"
@@ -0,0 +1,109 @@
"""测试期生产 Redis 隔离 —— 回归断言(project-20260918-P1-test-redis-token-fix)
背景:测试套件通过真实 /api/cma/auth/login 签发 token,而 app.auth_middleware 在 import 时
连接 127.0.0.1:6379 db=1(生产 Redis)、ping 成功即 _redis_available=True → create_token()
把 token setex 进生产库(TTL 24h)。测试库首个用户 id=1 与生产 users.id=1 撞号,且
require_auth() 只按 user_id 反查用户,故每轮全量测试都会留下可直接使用的生产会话 token。
本文件断言隔离生效(可复现、无副作用):
1) 测试期 auth_middleware / utils.cache 的 Redis 通道均关闭(客户端对象为 None);
2) 生产 Redis 客户端构造被 conftest 阻断器全部拦下(计数 ≥ 2:auth_middleware + cache);
3) 登录签发的 token 只落进程内 _token_store,且经 require_auth 仍可正常鉴权(语义不变)。
"""
import json
import pytest
from app import auth_middleware
from app.utils import cache as cache_util
from tests.conftest import create_test_user, get_token_for_user, auth_header
def test_auth_middleware_redis_channel_is_off_in_tests():
"""测试期 auth_middleware 必须走进程内 _token_store 降级分支(生产 Redis 零写入的前提)"""
assert auth_middleware._redis_available is False
assert auth_middleware._redis is None
def test_cache_redis_channel_is_off_in_tests():
"""测试夹具会调 cache.delete('ai'),同源 db=1 —— 一并关闭,保证整轮零写命令"""
assert cache_util._available is False
assert cache_util._client is None
def test_production_redis_constructor_is_blocked(production_redis_isolation_state):
"""redis.Redis 构造器被阻断器替换:导入期的两次构造尝试全部被拒(无生产连接)"""
state = production_redis_isolation_state
assert state["constructor_attempts"] >= 2, state
# 阻断即无客户端实例 → 不存在可用的生产 Redis 通道
assert auth_middleware._redis is None
assert cache_util._client is None
def test_login_token_lands_in_process_store_only(db, client):
"""登录 token 只落进程内 _token_store(不产生任何生产 Redis 键)"""
user = create_test_user(db)
token = get_token_for_user(client)
assert token and len(token) == 64
assert token in auth_middleware._token_store
assert json.loads(auth_middleware._token_store[token]) == {
"user_id": user.id,
"entity_id": 1,
}
# token 只能从进程内存储解析(Redis 通道关闭)
assert auth_middleware._resolve_token_data(token) == {"user_id": user.id, "entity_id": 1}
assert auth_middleware._resolve_user_id(token) == user.id
def test_process_store_token_still_authenticates(db, client):
"""降级分支不影响鉴权语义:进程内 token 经 require_auth 正常通过(/me 200)"""
create_test_user(db)
token = get_token_for_user(client)
resp = client.get("/api/cma/auth/me", headers=auth_header(token))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["username"] == "testadmin"
assert body["role"] == "ceo"
assert body["entity_id"] == 1
# 无效 token 仍按原语义 401(隔离未放宽鉴权)
assert client.get("/api/cma/auth/me", headers=auth_header("0" * 64)).status_code == 401
def test_create_token_writes_process_store_not_redis(db):
"""create_token() 直接调用:写入进程内存储、解析一致、Redis 侧无客户端可写"""
user = create_test_user(db, username="redis_iso_user")
token = auth_middleware.create_token(user.id, 1)
assert token in auth_middleware._token_store
assert auth_middleware.get_token_entity_id(token) == 1
assert auth_middleware._redis is None, "测试期不应存在生产 Redis 客户端实例"
def test_token_store_is_process_local(db, client):
"""token 仅存于本进程:模拟另一个进程/worker 的独立存储看不到它(跨 worker 不可见即无共享 Redis)"""
create_test_user(db)
token = get_token_for_user(client)
fresh_store: dict = {} # 模拟另一个 worker 的独立进程内存储
assert token not in fresh_store
assert token in auth_middleware._token_store # 本进程可见
# 有 Redis 通道时 token 会在生产库跨 worker 共享 —— 这正是被本单关闭的路径
assert auth_middleware._redis is None
def test_production_redis_boundary_invariant():
"""不变量:只要 _redis_available 为 False,create_token 不可能触达 Redis(唯一写入分支)"""
assert auth_middleware._redis_available is False
# 观察既有代码结构:写入分支受 _redis_available 单点控制(本断言防未来改动绕开开关)
import inspect
src = inspect.getsource(auth_middleware.create_token)
assert "if _redis_available:" in src
assert src.count("_redis.setex(") == 1
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))
+1 -2
View File
@@ -222,9 +222,8 @@ class TestDashboardDemo:
data = resp.json() data = resp.json()
assert "burden" in data and "invoice" in data and "ss" in data assert "burden" in data and "invoice" in data and "ss" in data
@pytest.mark.xfail(reason="SQLite DateTime 不接受字符串日期(生产MySQL可隐式转换);demo-data 为MySQL-only端点", strict=False)
def test_demo_data(self, client: TestClient, db: Session): def test_demo_data(self, client: TestClient, db: Session):
"""生成演示数据(MySQL-only:demo日期为字符串,SQLite DateTime严格模式不兼容)""" """生成演示数据(batch5 起 invoice_date 经 _parse_date,SQLite 亦可跑通)"""
create_test_user(db) create_test_user(db)
token = get_token_for_user(client) token = get_token_for_user(client)
resp = client.post(f"{BASE}/demo-data", headers=auth_header(token), json={}) resp = client.post(f"{BASE}/demo-data", headers=auth_header(token), json={})
@@ -0,0 +1,636 @@
"""工作台跨实体污染 · 治本回归测试 — 2026-09-22
任务:`dama-workbench-entity-isolation-fullstack-20260922`
(DAMA 诊断 / 项目Bot batch5 §B 方案段 / 任总 2026-09-22「都批」)
事故:任总 2026-09-22 报「陕西酣客工作台大量显示陕西博海数据」。
根因族 = `system_configs` / `role_permissions` 的**全局唯一键**没有账套维度
(`config_key` / `key` 均 unique)→ 一次写入全平台生效。
本单五项(逐条对应一个测试类):
P0 前端取数修正 — 工作台取数按**会话 entity**,不得用 `current_tenant` 全局键
(含 current_tenant 收敛为「仅 it/ceo 可读」的项目Bot A2A 状态记录)
B1 `cash.current_balance` → `cash.current_balance.<entity_id>`(零 DDL)
B3a `alignment_config` → `alignment_config.<entity_id>`(零 DDL)
B3b `bot_bridge_v2` 预警 KPI 归属:删 `kpi_id or 1` 兜底,改显式校验(跳过错挂 + 留痕)
B2 `notification_logs` 经父键 join 归属(→ kpi_alerts.entity_id);零迁移
外加:迁移脚本 `scripts/migrate_cash_balance_key_entity_20260922.py` 的
check/apply/down 幂等与「原值只落目标账套、其余留空」口径。
全部用例只跑内存 SQLite / 临时文件 SQLite(tests/conftest.py 机制),
**零生产库 / 零生产 Redis 连接**(conftest 已硬阻断)。
"""
import os
import sys
import json
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session
from app.models import (
Entity,
KPIDefinition,
KPIAlert,
KPIValue,
NotificationLog,
OperationLog,
RolePermission,
SystemConfig,
)
from app.database import Base
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
DASH = "/api/cma/dashboard"
CASH = "/api/cma/cash"
ALIGN = "/api/cma/alignment"
NOTIF = "/api/cma/notifications"
TENANT = "/api/cma/tenant"
# 两账套的资源编码(kpi_code 库内唯一)
E1_KPI = "ZZ_WB_E1_KPI"
E2_KPI = "ZZ_WB_E2_KPI"
# ──────────────────────────────────────────────
# 夹具
# ──────────────────────────────────────────────
def _ensure_entity(db: Session, eid: int, name: str, status: str = "active") -> Entity:
ent = db.query(Entity).filter(Entity.id == eid).first()
if not ent:
ent = Entity(id=eid, name=name, short_name=name, status=status)
db.add(ent)
db.commit()
db.refresh(ent)
return ent
def _mk_kpi(db: Session, code: str, eid: int, **kw) -> KPIDefinition:
kpi = KPIDefinition(
entity_id=eid,
kpi_code=code,
kpi_name=f"{code}-名称",
dimension="finance",
category="revenue_growth",
formula="-",
data_source_type="excel",
data_source="Excel导入",
data_owner="财务部",
frequency="monthly",
unit="元",
status="active",
)
kpi.__dict__.update(kw)
db.add(kpi)
db.commit()
db.refresh(kpi)
return kpi
def _login(client: TestClient, entity_id: int) -> str:
return _login_as(client, "testadmin", entity_id)
def _login_as(client: TestClient, username: str, entity_id: int) -> str:
resp = client.post("/api/cma/auth/login", json={
"username": username,
"password": TEST_USER_PASSWORD,
"entity_id": entity_id,
})
assert resp.status_code == 200, f"登录 {username}@entity{entity_id} 失败: {resp.text[:300]}"
return resp.json()["token"]
def _seed_two_entities(db: Session):
"""两账套各一个 active KPI(双会话双实体互不串的最小底座)"""
_ensure_entity(db, 1, "陕西酣客文化传媒")
_ensure_entity(db, 2, "陕西博海网络科技")
k1 = _mk_kpi(db, E1_KPI, 1)
k2 = _mk_kpi(db, E2_KPI, 2)
# 各写一个最新值(便于断言「实际值」口径也隔离)
for k, v in ((k1, 111.0), (k2, 222.0)):
db.add(KPIValue(kpi_id=k.id, period="2026-09", actual_value=v,
source_type="test", data_status="verified"))
db.commit()
return k1, k2
def _write_current_tenant(db: Session, entity_id: int, name: str) -> None:
"""模拟事故现场:把全局键 current_tenant 写成某个账套"""
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "current_tenant").first()
payload = json.dumps({"tenant_id": f"company_{entity_id}", "entity_id": entity_id,
"name": name, "short_name": name,
"source": "test-polluter",
"switched_at": "2026-09-04T09:28:38"}, ensure_ascii=False)
if cfg:
cfg.config_value = payload
else:
db.add(SystemConfig(config_key="current_tenant", config_value=payload,
description="当前租户状态记录"))
db.commit()
# ══════════════════════════════════════════════
# P0 前端取数修正:工作台按会话 entity,全局键不得当数据维度
# ══════════════════════════════════════════════
class TestP0WorkbenchSessionEntity:
"""事故直接触发点:工作台取数必须用**会话 entity**,与 current_tenant 全局键无关
「双会话双实体互不串」= 同一进程内两个独立 Bearer token(e1 / e2),
即使全局键被写成对方账套,各自仍只看到本账套数据。
"""
def test_my_kpis_isolated_while_global_tenant_key_points_elsewhere(
self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db) # role=ceo
_write_current_tenant(db, 2, "陕西博海网络科技") # 事故现场:全局键=博海
t1 = _login(client, 1)
t2 = _login(client, 2)
r1 = client.get(f"{DASH}/my-kpis", headers=auth_header(t1))
assert r1.status_code == 200, r1.text
codes1 = {k["kpi_code"] for k in r1.json()["data"]}
assert E1_KPI in codes1 and E2_KPI not in codes1, \
f"酣客(e1)会话不应看到博海 KPI(全局键指向 e2 时也不许):{sorted(codes1)}"
r2 = client.get(f"{DASH}/my-kpis", headers=auth_header(t2))
assert r2.status_code == 200, r2.text
codes2 = {k["kpi_code"] for k in r2.json()["data"]}
assert E2_KPI in codes2 and E1_KPI not in codes2, \
f"博海(e2)会话不应看到酣客 KPI:{sorted(codes2)}"
# 反转全局键 → 两会话结果必须**不变**(证明取数不依赖全局键)
_write_current_tenant(db, 1, "陕西酣客文化传媒")
codes1b = {k["kpi_code"] for k in
client.get(f"{DASH}/my-kpis", headers=auth_header(t1)).json()["data"]}
codes2b = {k["kpi_code"] for k in
client.get(f"{DASH}/my-kpis", headers=auth_header(t2)).json()["data"]}
assert codes1b == codes1, "全局键翻转后 e1 工作台数据变了 ⇒ 取数依赖了全局键"
assert codes2b == codes2, "全局键翻转后 e2 工作台数据变了 ⇒ 取数依赖了全局键"
def test_my_dashboard_and_summary_scoped_by_session(
self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
_write_current_tenant(db, 2, "陕西博海网络科技")
t1 = _login(client, 1)
t2 = _login(client, 2)
d1 = client.get(f"{DASH}/my-dashboard", headers=auth_header(t1))
d2 = client.get(f"{DASH}/my-dashboard", headers=auth_header(t2))
assert d1.status_code == 200 and d2.status_code == 200
codes1 = {k["kpi_code"] for k in d1.json()["kpis"]}
codes2 = {k["kpi_code"] for k in d2.json()["kpis"]}
assert not (codes1 & codes2), f"两会话工作台 KPI 出现交集:{codes1 & codes2}"
s1 = client.get(f"{DASH}/summary", headers=auth_header(t1))
s2 = client.get(f"{DASH}/summary", headers=auth_header(t2))
assert s1.status_code == 200 and s2.status_code == 200
def test_workbench_endpoints_all_use_session_entity(self, client: TestClient, db: Session):
"""工作台类端点逐个复核:带 token 时按 token 账套取数(不看全局键)"""
_seed_two_entities(db)
create_test_user(db)
_write_current_tenant(db, 2, "陕西博海网络科技")
t1 = _login(client, 1)
for path in (f"{DASH}/summary", f"{DASH}/kpis", f"{DASH}/my-kpis",
f"{DASH}/my-dashboard"):
r = client.get(path, headers=auth_header(t1))
assert r.status_code == 200, f"{path} 期望 200,实际 {r.status_code}: {r.text[:200]}"
# 显式传他账套 entity_id → 403(会话账套为准)
r = client.get(f"{DASH}/my-kpis?entity_id=2", headers=auth_header(t1))
assert r.status_code == 403, r.text
assert "无权访问" in r.json()["detail"]
#: 工作台类端点矩阵(§二.3「前端/接口两侧都要复核」的接口侧证据)
WORKBENCH_ENDPOINTS = [
"/api/cma/dashboard/summary",
"/api/cma/dashboard/kpis",
"/api/cma/dashboard/my-kpis",
"/api/cma/dashboard/my-dashboard",
"/api/cma/dashboard/finance-analysis",
"/api/cma/dashboard/predict",
"/api/cma/kpis?page_size=5",
"/api/cma/alerts",
"/api/cma/cash/dashboard",
"/api/cma/cash/balance",
"/api/cma/budget/plans",
"/api/cma/budget/config",
"/api/cma/expenses/reimbursements",
"/api/cma/expenses/stats",
"/api/cma/alignment/config",
"/api/cma/alignment/tree",
"/api/cma/notifications/logs",
"/api/cma/data-quality/logs",
]
class TestP0WorkbenchEndpointMatrix:
"""工作台类端点「双会话双实体」矩阵复核:任一端点都不得把对方账套数据带出来
方法论(可复算):两个独立 Bearer token(e1 / e2)逐个打同一端点,
断言 ①两会话状态码一致(不得出现「一方 200 一方 403」的不对称)
②任一响应体里**不含**对方账套的独有标记(kpi_code)。
同时把三个全局键都写成 e2(模拟事故现场),证明取数与全局键无关。
"""
def test_no_cross_entity_marker_in_any_workbench_endpoint(
self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
_write_current_tenant(db, 2, "陕西博海网络科技")
db.add(SystemConfig(config_key="cash.current_balance", config_value="-9.3",
description="历史全局键(事故值)"))
db.commit()
t1 = _login(client, 1)
t2 = _login(client, 2)
report = []
violations = []
for path in WORKBENCH_ENDPOINTS:
r1 = client.get(path, headers=auth_header(t1))
r2 = client.get(path, headers=auth_header(t2))
report.append(f"{path} → e1:{r1.status_code} e2:{r2.status_code}")
if r1.status_code != r2.status_code:
violations.append(f"{path} 状态码不对称 e1={r1.status_code} e2={r2.status_code}")
if E2_KPI in r1.text:
violations.append(f"{path} e1 会话响应含 e2 标记 {E2_KPI}")
if E1_KPI in r2.text:
violations.append(f"{path} e2 会话响应含 e1 标记 {E1_KPI}")
print("\n[工作台端点矩阵]\n" + "\n".join(report))
assert not violations, "跨实体泄漏/不对称:\n" + "\n".join(violations)
class TestP0CurrentTenantConvergence:
def test_no_token_401(self, client: TestClient, db: Session):
assert client.get(f"{TENANT}/current").status_code == 401
def test_non_admin_role_403(self, client: TestClient, db: Session):
_ensure_entity(db, 1, "测试企业")
create_test_user(db) # testadmin / ceo
create_test_user(db, username="fin_user", role="finance")
tk_fin = _login_as(client, "fin_user", 1)
tk_ceo = _login(client, 1)
r = client.get(f"{TENANT}/current", headers=auth_header(tk_fin))
assert r.status_code == 403, f"finance 会话读 current_tenant 应 403,实际 {r.status_code}"
# ceo(业务负责人)与 it(admin 动作权限持有者)放行
assert client.get(f"{TENANT}/current", headers=auth_header(tk_ceo)).status_code == 200
def test_switch_writes_audit_log_and_is_not_a_data_dimension(
self, client: TestClient, db: Session):
"""POST /switch 落 operation_logs(修复前无审计);写入后不影响他账套取数"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
r = client.post(f"{TENANT}/switch", headers=auth_header(t1),
json={"entity_id": 2, "source": "test"})
assert r.status_code == 200, r.text
db.expire_all()
n = db.query(OperationLog).filter(OperationLog.action == "switch_tenant_state").count()
assert n == 1, f"switch 应落 1 条审计,实际 {n}"
# 审计行必须可归属(2026-09-22 DAMA 独立回查 §4b):操作人 + 账套目标维度
log = (db.query(OperationLog)
.filter(OperationLog.action == "switch_tenant_state")
.order_by(OperationLog.id.desc()).first())
assert log.user_id is not None, "审计行 user_id 不得为 None(事后须能追操作人)"
assert log.target_type == "entity" and log.target_id == 2, (
f"审计须按账套可过滤(target_type/target_id),实际 {log.target_type}/{log.target_id}")
assert (log.detail or {}).get("entity_id") == 2, f"detail 须含 entity_id:{log.detail}"
# 全局键虽被改成 e2,e1 会话工作台仍只见 e1 数据
codes1 = {k["kpi_code"] for k in
client.get(f"{DASH}/my-kpis", headers=auth_header(t1)).json()["data"]}
assert E2_KPI not in codes1
# ══════════════════════════════════════════════
# B1 现金基线拆账套
# ══════════════════════════════════════════════
class TestB1CashBalancePerEntity:
def test_two_entities_do_not_overwrite_each_other(self, client: TestClient, db: Session):
"""e1 / e2 分别设基线后各自读回本账套值(修复前共用一个唯一键)"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
t2 = _login(client, 2)
assert client.post(f"{CASH}/balance", headers=auth_header(t1),
json={"current_cash": 11.5}).status_code == 200
assert client.post(f"{CASH}/balance", headers=auth_header(t2),
json={"current_cash": 22.5}).status_code == 200
v1 = client.get(f"{CASH}/balance", headers=auth_header(t1)).json()
v2 = client.get(f"{CASH}/balance", headers=auth_header(t2)).json()
assert v1["current_cash"] == 11.5, f"e1 基线被覆盖:{v1}"
assert v2["current_cash"] == 22.5, f"e2 基线被覆盖:{v2}"
assert v1["entity_id"] == 1 and v2["entity_id"] == 2
db.expire_all()
keys = {r[0] for r in db.query(SystemConfig.config_key)
.filter(SystemConfig.config_key.like("cash.current_balance%")).all()}
assert keys == {"cash.current_balance.1", "cash.current_balance.2"}, keys
# 不再写全局唯一键
assert "cash.current_balance" not in keys
def test_no_cross_entity_fallback_to_legacy_key(self, client: TestClient, db: Session):
"""旧全局键有值(事故值 -9.3),本账套未设基线 → 返回 None,**不借用**他账套值"""
_seed_two_entities(db)
create_test_user(db)
db.add(SystemConfig(config_key="cash.current_balance", config_value="-9.3",
description="历史全局键(事故值)"))
db.commit()
t2 = _login(client, 2)
body = client.get(f"{CASH}/balance", headers=auth_header(t2)).json()
assert body["current_cash"] is None, \
f"e2 未设基线却读到旧全局键(跨账套借用):{body}"
def test_body_entity_id_not_trusted(self, client: TestClient, db: Session):
"""带 token 时 body 里的 entity_id 不可信(仍写会话账套)"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
r = client.post(f"{CASH}/balance", headers=auth_header(t1),
json={"current_cash": 33.0, "entity_id": 2})
assert r.status_code == 200, r.text
assert r.json()["entity_id"] == 1, r.json()
db.expire_all()
assert db.query(SystemConfig).filter(
SystemConfig.config_key == "cash.current_balance.2").first() is None
assert db.query(SystemConfig).filter(
SystemConfig.config_key == "cash.current_balance.1").first() is not None
def test_set_helper_requires_entity(self, db: Session):
"""set_current_cash_balance 必须传 entity_id(防漏传落默认账套)"""
from app.utils.cash_forecast_engine import set_current_cash_balance
with pytest.raises(TypeError):
set_current_cash_balance(db, 1.0) # 缺 entity_id
with pytest.raises(ValueError):
set_current_cash_balance(db, 1.0, None)
# ══════════════════════════════════════════════
# B3(a) 对齐模式拆账套
# ══════════════════════════════════════════════
class TestB3aAlignmentPerEntity:
def test_config_isolated_per_entity(self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
t2 = _login(client, 2)
assert client.post(f"{ALIGN}/config", headers=auth_header(t1),
json={"mode": "vertical_decomposition"}).status_code == 200
assert client.post(f"{ALIGN}/config", headers=auth_header(t2),
json={"mode": "bsc_chain"}).status_code == 200
c1 = client.get(f"{ALIGN}/config", headers=auth_header(t1)).json()
c2 = client.get(f"{ALIGN}/config", headers=auth_header(t2)).json()
assert c1["mode"]["mode"] == "vertical_decomposition", c1
assert c2["mode"]["mode"] == "bsc_chain", c2
assert c1["entity_id"] == 1 and c2["entity_id"] == 2
db.expire_all()
keys = {r[0] for r in db.query(RolePermission.key)
.filter(RolePermission.key.like("alignment_config%")).all()}
assert keys == {"alignment_config.1", "alignment_config.2"}, keys
assert "alignment_config" not in keys
def test_unconfigured_entity_not_inheriting_other_entity(self, client: TestClient, db: Session):
"""e1 已配置、e2 未配置 → e2 不得继承 e1 的模式(修复前全局键必然继承)"""
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
t2 = _login(client, 2)
assert client.post(f"{ALIGN}/config", headers=auth_header(t1),
json={"mode": "bsc_chain"}).status_code == 200
c2 = client.get(f"{ALIGN}/config", headers=auth_header(t2)).json()
assert c2["configured"] is False and c2["mode"] is None, \
f"e2 未配置却继承了 e1 的对齐模式:{c2}"
# e2 的 /tree 应提示先初始化(400),而不是拿到 e1 的模式
r = client.get(f"{ALIGN}/tree", headers=auth_header(t2))
assert r.status_code == 400, f"e2 未配置应 400,实际 {r.status_code}: {r.text[:200]}"
# e1 正常
assert client.get(f"{ALIGN}/tree", headers=auth_header(t1)).status_code == 200
def test_tree_uses_own_entity_mode(self, client: TestClient, db: Session):
_seed_two_entities(db)
create_test_user(db)
t1 = _login(client, 1)
t2 = _login(client, 2)
client.post(f"{ALIGN}/config", headers=auth_header(t1),
json={"mode": "vertical_decomposition"})
client.post(f"{ALIGN}/config", headers=auth_header(t2), json={"mode": "bsc_chain"})
b1 = client.get(f"{ALIGN}/tree", headers=auth_header(t1)).json()
b2 = client.get(f"{ALIGN}/tree", headers=auth_header(t2)).json()
assert b1["mode"] == "vertical_decomposition"
assert b2["mode"] == "bsc_chain"
# ══════════════════════════════════════════════
# B3(b) 预警 KPI 归属:删 kpi_id or 1 兜底
# ══════════════════════════════════════════════
class TestB3bAlertKpiAttribution:
def test_missing_kpi_definition_skips_alert_instead_of_kpi_id_1(self, db: Session):
"""e2 无 C_REBATE_RATE 定义时:不建预警(不挂 kpi_id=1)+ 落 OperationLog 留痕"""
from app.api.bot_bridge_v2 import receive_mpm_result
from app.models import MpmResult
_ensure_entity(db, 1, "陕西酣客文化传媒")
_ensure_entity(db, 2, "陕西博海网络科技")
first = _mk_kpi(db, "ZZ_WB_FIRST_KPI", 1) # 全库第 1 个 KPI(旧兜底目标)
assert first.id == 1, f"夹具前提:第一个 KPI id 应为 1,实际 {first.id}"
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
before_alerts = db.query(KPIAlert).count()
before_logs = db.query(OperationLog).count()
r = receive_mpm_result(
data={"entity_id": 2, "period": "2026-09",
"results": {"channel_rebate_rate": 80.0}},
bridge_bot="test-bridge", db=db)
db.expire_all()
assert r["alerts_triggered"] == 0, r
assert r["alerts_skipped_missing_kpi"] == 1, r
assert db.query(KPIAlert).count() == before_alerts, "KPI 定义缺失不应建预警"
assert db.query(KPIAlert).filter(
KPIAlert.entity_id == 2, KPIAlert.kpi_id == 1).first() is None, \
"不得把 e2 的预警错挂到 kpi_id=1(可能是他账套的 KPI)"
logs = db.query(OperationLog).filter(OperationLog.action == "skip_alert").all()
assert len(logs) == 1 and db.query(OperationLog).count() == before_logs + 1
assert logs[0].detail["reason"] == "kpi_definition_not_found_for_entity"
assert logs[0].detail["entity_id"] == 2
def test_present_kpi_definition_still_creates_alert(self, db: Session):
"""正对照:本账套有 KPI 定义 → 照常建预警且归属正确"""
from app.api.bot_bridge_v2 import receive_mpm_result
from app.models import MpmResult
_ensure_entity(db, 1, "陕西酣客文化传媒")
k = _mk_kpi(db, "C_REBATE_RATE", 1)
MpmResult.__table__.create(bind=db.get_bind(), checkfirst=True)
r = receive_mpm_result(
data={"entity_id": 1, "period": "2026-09",
"results": {"channel_rebate_rate": 80.0}},
bridge_bot="test-bridge", db=db)
db.expire_all()
assert r["alerts_triggered"] == 1, r
assert r["alerts_skipped_missing_kpi"] == 0
alert = db.query(KPIAlert).filter(KPIAlert.entity_id == 1).first()
assert alert is not None and alert.kpi_id == k.id
# ══════════════════════════════════════════════
# B2 缺列五表:Log 类经父键 join 归属(零迁移)
# ══════════════════════════════════════════════
class TestB2NotificationLogScoping:
def test_logs_scoped_by_alert_entity(self, client: TestClient, db: Session):
k1, k2 = _seed_two_entities(db)
create_test_user(db)
a1 = KPIAlert(entity_id=1, kpi_id=k1.id, alert_level="red",
alert_message="e1 预警", status="pending")
a2 = KPIAlert(entity_id=2, kpi_id=k2.id, alert_level="red",
alert_message="e2 预警", status="pending")
db.add_all([a1, a2])
db.commit()
db.refresh(a1)
db.refresh(a2)
db.add_all([
NotificationLog(alert_id=a1.id, channel="wecom", title="e1 通知", status="sent"),
NotificationLog(alert_id=a2.id, channel="wecom", title="e2 通知", status="sent"),
NotificationLog(alert_id=None, channel="wecom", title="无归属通知", status="sent"),
])
db.commit()
t1 = _login(client, 1)
t2 = _login(client, 2)
l1 = client.get(f"{NOTIF}/logs", headers=auth_header(t1)).json()
l2 = client.get(f"{NOTIF}/logs", headers=auth_header(t2)).json()
titles1 = {x["title"] for x in l1["data"]}
titles2 = {x["title"] for x in l2["data"]}
assert titles1 == {"e1 通知"}, f"e1 会话通知日志混入他账套/无归属行:{titles1}"
assert titles2 == {"e2 通知"}, f"e2 会话通知日志混入他账套/无归属行:{titles2}"
assert l1["total"] == 1 and l2["total"] == 1
assert l1["entity_id"] == 1 and l2["entity_id"] == 2
# 口径字段(2026-09-22 DAMA 回查 §5):空页要能解释成口径而非故障
for l in (l1, l2):
assert l["policy"] == "alert-linked-only", l
assert "无父键" in l["note"] and "okr_review_reminder" in l["note"], l
# ══════════════════════════════════════════════
# 迁移脚本:原值搬运口径(目标账套落值 / 其余留空 / 幂等 / 可回退)
# ══════════════════════════════════════════════
MIGRATION = "scripts.migrate_cash_balance_key_entity_20260922"
class TestMigrationScript:
"""在**临时文件 SQLite**上跑迁移脚本(monkeypatch 引擎,绝不碰 app.database → 生产库)"""
@pytest.fixture
def mig_env(self, tmp_path, monkeypatch):
import importlib
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
mod = importlib.import_module(MIGRATION)
eng = create_engine(f"sqlite:///{tmp_path/'mig.db'}")
Base.metadata.create_all(bind=eng)
with eng.begin() as conn:
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
"VALUES (1, '酣客', '酣客', 'active')"))
conn.execute(text("INSERT INTO entities (id, name, short_name, status) "
"VALUES (2, '博海', '博海', 'active')"))
conn.execute(text("INSERT INTO system_configs (config_key, config_value, description) "
"VALUES ('cash.current_balance', '-9.3', '历史全局键')"))
conn.execute(text("INSERT INTO role_permissions (`key`, value) "
"VALUES ('alignment_config', '{\"mode\": \"bsc_chain\"}')"))
monkeypatch.setattr(mod, "_engine", lambda: eng)
return mod, eng
def _run(self, mod, argv):
import sys as _sys
old = _sys.argv
_sys.argv = ["migrate"] + argv
try:
return mod.main()
finally:
_sys.argv = old
def test_check_is_readonly(self, mig_env):
mod, eng = mig_env
assert self._run(mod, ["--check", "--allow-db", "sqlite"]) == 0
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'cash.current_balance.%'")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
"WHERE `key` LIKE 'alignment_config.%'")).scalar() == 0
def test_apply_moves_value_to_target_only_and_is_idempotent(self, mig_env):
mod, eng = mig_env
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
"--target-entity", "1", "--json"]) == 0
with eng.connect() as conn:
v = conn.execute(text("SELECT config_value FROM system_configs "
"WHERE config_key = 'cash.current_balance.1'")).scalar()
assert v == "-9.3"
# 其余账套留空(不跨账套借用)
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key = 'cash.current_balance.2'")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
"WHERE `key` = 'alignment_config.1'")).scalar() == 1
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
"WHERE `key` = 'alignment_config.2'")).scalar() == 0
# 旧键保留(回退杠杆)
assert conn.execute(text("SELECT config_value FROM system_configs "
"WHERE config_key = 'cash.current_balance'")).scalar() == "-9.3"
# 二跑零副作用(幂等)
assert self._run(mod, ["--apply", "--allow-db", "sqlite",
"--target-entity", "1", "--json"]) == 0
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'cash.current_balance.%'")).scalar() == 1
assert conn.execute(text("SELECT config_value FROM system_configs "
"WHERE config_key = 'cash.current_balance.1'")).scalar() == "-9.3"
def test_down_removes_new_keys_keeps_legacy(self, mig_env):
mod, eng = mig_env
self._run(mod, ["--apply", "--allow-db", "sqlite", "--json"])
assert self._run(mod, ["--down", "--allow-db", "sqlite", "--json"]) == 0
with eng.connect() as conn:
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key LIKE 'cash.current_balance.%'")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM role_permissions "
"WHERE `key` LIKE 'alignment_config.%'")).scalar() == 0
assert conn.execute(text("SELECT COUNT(*) FROM system_configs "
"WHERE config_key = 'cash.current_balance'")).scalar() == 1
def test_prod_db_name_guard(self, mig_env):
"""库名保护:非 cma 库未显式豁免 → 拒绝(exit 2)"""
mod, eng = mig_env
with pytest.raises(SystemExit) as e:
self._run(mod, ["--apply", "--json"]) # sqlite → DATABASE() 不可用 → db=''
assert e.value.code == 2
def test_target_entity_must_exist(self, mig_env):
mod, eng = mig_env
with pytest.raises(SystemExit) as e:
self._run(mod, ["--apply", "--allow-db", "sqlite", "--target-entity", "99", "--json"])
assert e.value.code == 2
+222 -127
View File
@@ -1,154 +1,206 @@
#!/bin/bash #!/bin/bash
# 管理会计OS — 一键拉取、构建并部署到生产 # 管理会计OS — 一键拉取、构建并部署到生产
#
# 部署源 = **已提交的 origin/main 树**(独立检出,与开发工作区解耦)
# cma-deploy-source-decouple-20260921(任总 2026-09-21 10:28「根治」;单 project-20260921-deploy-dirtyguard-rootfix)
#
# 为什么改:开发区 /root/cma-management → /srv/hermes/shared/cma-management 过去**既是编辑区又是部署源**
# (SCRIPT_DIR 即部署根),而 [0] 对**任何** tracked modified 即 exit 1 ⇒ 多 agent 并行下必然反复自锁:
# 2026-09-18 frontend/package.json(corepack pnpm build 自加行);
# 2026-09-21 10:09 batch5 在途 6 文件;
# 2026-09-21 11:07 构建又写出 frontend/components.d.ts(unplugin-vue-components 生成物)。
# 现在:部署内容 = 已提交的 origin/main(fetch → checkout --detach -f → clean -fdx),
# 开发区只作「入口 + 运行时资产单源(.env / venv / node_modules)」,其脏净**不参与**部署判定,
# 部署过程也**不** pull / stash / checkout 开发区(在途改动既不上线、也不被丢弃)。
#
# 保留不变(不得弱化):[1b] 鉴权面审计闸门(B 类缺口即中止)、构建→铺站→重启→冒烟全流程、一行回滚能力。
#
# 可选环境变量:
# CMA_DEPLOY_SRC 部署树路径(默认 /srv/hermes/shared/cma-deploy)
# CMA_WWW_DIR 前端铺站目录(默认 /var/www/cma)
# CMA_DEPLOY_MODE deploy(默认)| drill(离线演练:www 必须在 /var/www 之外、systemctl/nginx 必须是桩、
# 迁移只跑 --check、不装依赖、不改 unit)
# CMA_DEPLOY_SKIP_FETCH=1 跳过 fetch,沿用本地 origin/main 引用(离线演练用)
# SMOKE_HEALTH_URL / SMOKE_READY_MAX 冒烟就绪探测覆盖(见下方 cma-readiness-poll 块)
set -e set -e
# pnpm/corepack 需要 /root 下的缓存(profile HOME 缺 bin/pnpm.cjs → pnpm build 报 MODULE_NOT_FOUND)
export HOME="${CMA_DEPLOY_HOME:-/root}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$SCRIPT_DIR" # 开发区/入口工作区(可脏:仅作入口与资产单源)
DEPLOY_SRC="${CMA_DEPLOY_SRC:-/srv/hermes/shared/cma-deploy}" # 部署树:恒等于已提交的 origin/main
WWW_DIR="${CMA_WWW_DIR:-/var/www/cma}"
MODE="${CMA_DEPLOY_MODE:-deploy}"
UNIT_FILE="${CMA_DEPLOY_UNIT_FILE:-/etc/systemd/system/cma-backend.service}"
ENV_SRC="$REPO_DIR/backend/.env"
VENV_SRC="$REPO_DIR/backend/venv"
echo "===== 管理会计OS 生产部署 =====" echo "===== 管理会计OS 生产部署 ====="
echo " 入口工作区(开发区): $REPO_DIR"
echo " 部署树(已提交树) : $DEPLOY_SRC"
echo " 模式 : $MODE"
echo "" echo ""
# 0. 提交纪律检查:本地有未提交修改则中止,防止 git pull 覆盖丢失 # ── 演练模式安全闸(必须最先判定:drill 绝不允许碰到生产面)──
echo "[0/5] 提交纪律检查..." if [ "$MODE" = "drill" ]; then
cd "$SCRIPT_DIR" case "$WWW_DIR" in
DIRTY=$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20) /var/www|/var/www/*) echo "❌ 演练模式禁止写生产 www 目录: $WWW_DIR(请用 CMA_WWW_DIR 指向临时目录)"; exit 1;;
if [ -n "$DIRTY" ]; then esac
echo "❌ 检测到本地未提交的修改,中止部署(防止 git pull 覆盖丢失代码):" for c in systemctl nginx; do
echo "$DIRTY" p="$(command -v "$c" 2>/dev/null || true)"
case "$p" in
""|/usr/bin/*|/bin/*|/usr/sbin/*|/sbin/*)
echo "❌ 演练模式要求 $c 指向桩脚本(PATH 前置沙箱 bin 目录),当前='${p:-<缺失>}' —— 中止以免误触生产"
exit 1;;
esac
done
echo " ℹ️ 演练模式:www=$WWW_DIR;systemctl=$(command -v systemctl);nginx=$(command -v nginx)"
echo "" echo ""
echo "请先提交代码: git add -A && git commit -m '...' && git push origin main" fi
# 0. 部署源解析:部署内容 = 已提交的 origin/main(开发区的脏净降级为**告警**,不再 exit)
# 旧 [0] 的初衷是「防 git pull 覆盖丢代码」;改为已提交树后该风险不存在(部署树永远从 origin/main 重建,
# 开发区不再被 pull)——故不得保留「开发区任何 modified 即中止」。
echo "[0/5] 部署源解析(origin/main 已提交树)..."
cd "$REPO_DIR"
DEV_BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
DEV_HEAD="$(git rev-parse HEAD)"
DIRTY="$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20)"
if [ -n "$DIRTY" ]; then
echo " ⚠️ 开发区存在未提交改动(不阻断部署;这些改动不会上线,也不会被丢弃/覆盖):"
echo "$DIRTY" | sed 's/^/ /'
else
echo " ✓ 开发区工作区干净"
fi
if [ "${CMA_DEPLOY_SKIP_FETCH:-0}" = "1" ]; then
echo " ℹ️ CMA_DEPLOY_SKIP_FETCH=1 → 跳过 fetch,沿用本地 origin/main 引用"
else
if ! git fetch --quiet origin main; then
echo "❌ git fetch origin main 失败 —— 无法确定部署源(失败关闭)"
exit 1
fi
fi
DEPLOY_SHA="$(git rev-parse origin/main)"
DEPLOY_SUBJECT="$(git log -1 --pretty=%s "$DEPLOY_SHA" 2>/dev/null || echo '')"
echo " ✓ 部署源: origin/main = ${DEPLOY_SHA:0:8} ${DEPLOY_SUBJECT}"
if [ "$DEV_HEAD" != "$DEPLOY_SHA" ]; then
AHEAD="$(git rev-list --count origin/main..HEAD 2>/dev/null || echo '?')"
echo " ⚠️ 开发区 HEAD($DEV_BRANCH ${DEV_HEAD:0:8}) ≠ 部署源(${DEPLOY_SHA:0:8}),本地领先 $AHEAD 笔"
echo " ⇒ 未推送提交不会上线;要上线请先 git push origin main"
fi
# 1. 同步部署树到 origin/main(部署树恒等于已提交树;开发区零触碰)
echo "[1/5] 同步部署树 → origin/main ..."
if [ ! -e "$DEPLOY_SRC/.git" ]; then
echo " ℹ️ 首次使用:创建部署检出 $DEPLOY_SRC(git worktree,detached)"
mkdir -p "$(dirname "$DEPLOY_SRC")"
if ! git -C "$REPO_DIR" worktree add --detach "$DEPLOY_SRC" "$DEPLOY_SHA"; then
echo "❌ 部署树创建失败: $DEPLOY_SRC"
exit 1
fi
else
if ! git -C "$DEPLOY_SRC" rev-parse --git-dir > /dev/null 2>&1; then
echo "❌ $DEPLOY_SRC 已存在但不是 git 检出 —— 请人工确认后再部署"
exit 1
fi
git -C "$DEPLOY_SRC" checkout --detach -f "$DEPLOY_SHA" > /dev/null
# -e 保留运行时资产(依赖/密钥/日志/迁移 journal),其余一律还原为已提交态
git -C "$DEPLOY_SRC" clean -fdx -q \
-e backend/venv -e backend/.env -e backend/logs \
-e backend/scripts/backups \
-e frontend/node_modules -e frontend/.env
fi
# 1.1 部署树自检(起点):必须恒等于已提交树 —— 旧的「脏即中止」只保留在这里(对象是**部署树**,不是开发区)
DT_DIRTY="$(git -C "$DEPLOY_SRC" status --porcelain 2>/dev/null | grep -vE "^\?\?" | head -20)"
if [ -n "$DT_DIRTY" ]; then
echo "❌ 部署树存在未提交修改(部署树必须恒等于已提交树):"
echo "$DT_DIRTY" | sed 's/^/ /'
exit 1 exit 1
fi fi
echo " ✓ 工作区干净,无未提交修改" DT_HEAD="$(git -C "$DEPLOY_SRC" rev-parse HEAD)"
if [ "$DT_HEAD" != "$DEPLOY_SHA" ]; then
echo "❌ 部署树 HEAD(${DT_HEAD:0:8}) ≠ 部署源(${DEPLOY_SHA:0:8})"; exit 1
fi
echo " ✓ 部署树 = 已提交树(HEAD=${DEPLOY_SHA:0:8},无 tracked 修改)"
# 1. 拉取最新代码 # 1.2 运行时资产接线(单源、幂等):.env / venv 指向开发区主副本,避免密钥与依赖双份漂移
echo "[1/5] 拉取最新代码..." if [ ! -e "$ENV_SRC" ]; then
git pull origin main echo "❌ 运行时密钥文件缺失: $ENV_SRC(部署树需要它)"; exit 1
echo " ✓ 代码已更新" fi
if [ ! -e "$VENV_SRC/bin/python3" ]; then
echo "❌ 运行时 venv 缺失: $VENV_SRC/bin/python3(部署树需要它)"; exit 1
fi
ln -sfn "$ENV_SRC" "$DEPLOY_SRC/backend/.env"
ln -sfn "$VENV_SRC" "$DEPLOY_SRC/backend/venv"
mkdir -p "$DEPLOY_SRC/backend/logs" "$DEPLOY_SRC/backend/scripts/backups"
if [ -f "$REPO_DIR/frontend/.env" ]; then
ln -sfn "$REPO_DIR/frontend/.env" "$DEPLOY_SRC/frontend/.env"
fi
if [ ! -e "$DEPLOY_SRC/frontend/node_modules" ]; then
echo " ℹ️ 部署树缺 frontend/node_modules → 尝试 pnpm install --frozen-lockfile --prefer-offline"
if ! ( cd "$DEPLOY_SRC/frontend" && pnpm install --frozen-lockfile --prefer-offline ); then
if [ -d "$REPO_DIR/frontend/node_modules" ]; then
echo " ⚠️ 部署树 pnpm install 失败 → 回退为链接开发区 node_modules(依赖内容与开发区一致;锁定文件仍以已提交为准)"
ln -sfn "$REPO_DIR/frontend/node_modules" "$DEPLOY_SRC/frontend/node_modules"
else
echo "❌ 部署树无 node_modules 且开发区也没有 → 无法构建前端"
exit 1
fi
fi
fi
# 1d. 后端可导入自检(接线完成后执行;缺 .env/venv/依赖会让服务起不来 → 重启前失败关闭)
if ! timeout 60 sh -c "cd '$DEPLOY_SRC/backend' && ./venv/bin/python3 -c 'import app.main'"; then
echo "❌ 部署树后端导入自检失败(app.main 无法导入)→ 中止(生产未被触碰)"
exit 1
fi
echo " ✓ 部署树后端可导入(app.main)+ 运行时资产已接线"
# 1b. 鉴权面审计闸门(T1, 2026-09-15):B 类缺口(用户端点无鉴权)存在即中止部署 # 1b. 鉴权面审计闸门(T1, 2026-09-15):B 类缺口(用户端点无鉴权)存在即中止部署
echo "[1b/5] 鉴权面审计闸门..." echo "[1b/5] 鉴权面审计闸门..."
cd "$SCRIPT_DIR/backend" cd "$DEPLOY_SRC/backend"
if ! ./venv/bin/python3 scripts/audit_api_auth_surface.py > /tmp/cma-auth-audit.log 2>&1; then if ! ./venv/bin/python3 scripts/audit_api_auth_surface.py > /tmp/cma-auth-audit.log 2>&1; then
echo "❌ 鉴权面审计发现 B 类缺口(用户端点缺鉴权),中止部署。详见 /tmp/cma-auth-audit.log" echo "❌ 鉴权面审计发现 B 类缺口(用户端点缺鉴权),中止部署。详见 /tmp/cma-auth-audit.log"
tail -20 /tmp/cma-auth-audit.log tail -20 /tmp/cma-auth-audit.log
exit 1 exit 1
fi fi
cd "$SCRIPT_DIR"
echo " ✓ 鉴权面审计通过(无 B 类缺口)" echo " ✓ 鉴权面审计通过(无 B 类缺口)"
# 2. 构建前端 # 2. 构建前端(在部署树内构建:pnpm/corepack 的生成物落在这里,污染不到开发区)
echo "[2/5] 构建前端..." echo "[2/5] 构建前端..."
cd "$SCRIPT_DIR/frontend" cd "$DEPLOY_SRC/frontend"
pnpm build pnpm build
echo " ✓ 构建完成" echo " ✓ 构建完成"
# 构建工具链(corepack/pnpm、unplugin-auto-import/vue-components)会在部署树里写生成物 ——
# 部署树可弃、下次部署 checkout -f + clean 重置,故仅提示;旧版这些生成物落在**开发区**,
# 正是发布链自锁的来源之一(2026-09-18 package.json / 2026-09-21 components.d.ts)。
DT_POST="$(git -C "$DEPLOY_SRC" status --porcelain 2>/dev/null | head -5)"
if [ -n "$DT_POST" ]; then
echo " ℹ️ 构建后部署树生成物(仅提示,不影响开发区):"
echo "$DT_POST" | sed 's/^/ /'
fi
# 3. 部署前端到 Nginx # 3. 部署前端到 Nginx
echo "[3/5] 部署前端..." echo "[3/5] 部署前端..."
rm -rf /var/www/cma/assets/ rm -rf "$WWW_DIR/assets/"
cp -r dist/* /var/www/cma/ cp -r dist/* "$WWW_DIR/"
DIFF_OUT="$(diff -rq dist "$WWW_DIR" 2>&1 | head -5 || true)"
if [ -n "$DIFF_OUT" ]; then
echo " ⚠️ 铺站后 dist 与 $WWW_DIR 存在差异(仅提示):"
echo "$DIFF_OUT" | sed 's/^/ /'
else
echo " ✓ 铺站一致性: dist == $WWW_DIR"
fi
echo " ✓ 部署完成" echo " ✓ 部署完成"
# 3b. 前端层一致性断言(T2, 2026-09-18):部署链静默替换门
# 背景:[3] 只有 rm -rf + cp,缓存层吐旧 index、或 index 指向已被删的旧 chunk 时,现象是
# 「首页 200、点进页面 404/白屏」,而其后的冒烟 5 项全为后端项(127.0.0.1:8010)→ 整轮部署全绿。
# 三条断言各自独立输出,任一不一致即 exit 1。离线演练只覆盖下面三个变量,无需 build。
# >>> cma-frontend-assert(最小可抽取块:scripts/tests 按行首标记 awk 抽取后原样执行,改结构请沿用标记)
FRONTEND_DIST_DIR="${FRONTEND_DIST_DIR:-$SCRIPT_DIR/frontend/dist}"
CMA_WEBROOT="${CMA_WEBROOT:-/var/www/cma}"
SITE_URL="${SITE_URL:-https://cma.sxbh.ltd}"
echo "[3b/5] 前端层一致性断言(dist ↔ webroot ↔ 引用链)..."
FRONTEND_ASSERT_FAIL=0
# ① 逐文件 md5 对账:dist/assets 与 webroot/assets 文件集合一致(无多、无少)且同名文件 md5 相同
DIST_ASSETS="$FRONTEND_DIST_DIR/assets"
WEB_ASSETS="$CMA_WEBROOT/assets"
DIST_FILES="$(ls -1 "$DIST_ASSETS" 2>/dev/null | sort)"
WEB_FILES="$(ls -1 "$WEB_ASSETS" 2>/dev/null | sort)"
MISSING_FILES="$(comm -23 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))"
EXTRA_FILES="$(comm -13 <(printf '%s\n' "$DIST_FILES") <(printf '%s\n' "$WEB_FILES"))"
MD5_DIFF=""
while IFS= read -r F; do
[ -n "$F" ] || continue
D_MD5="$(md5sum "$DIST_ASSETS/$F" 2>/dev/null | awk '{print $1}')"
W_MD5="$(md5sum "$WEB_ASSETS/$F" 2>/dev/null | awk '{print $1}')"
[ "$D_MD5" = "$W_MD5" ] || MD5_DIFF="${MD5_DIFF}${MD5_DIFF:+, }$F(dist=$D_MD5 web=$W_MD5)"
done <<< "$DIST_FILES"
if [ -n "$MISSING_FILES" ] || [ -n "$EXTRA_FILES" ] || [ -n "$MD5_DIFF" ]; then
echo " ❌ 断言① 逐文件 md5 对账失败(dist=$DIST_ASSETS)"
[ -z "$MISSING_FILES" ] || echo " webroot 缺失: $(printf '%s' "$MISSING_FILES" | tr '\n' ' ')"
[ -z "$EXTRA_FILES" ] || echo " webroot 多出: $(printf '%s' "$EXTRA_FILES" | tr '\n' ' ')"
[ -z "$MD5_DIFF" ] || echo " md5 不一致: $MD5_DIFF"
FRONTEND_ASSERT_FAIL=1
else
echo " ✅ 断言① 逐文件 md5 对账通过($(printf '%s\n' "$DIST_FILES" | awk 'NF{n++} END{print n+0}') 个文件全部一致)"
fi
# ② 主包一致:webroot/index.html 解析出的入口 chunk 必须在 dist 存在且 md5 相同
INDEX_HTML="$CMA_WEBROOT/index.html"
MAIN_BUNDLE="$(grep -o '/assets/index-[A-Za-z0-9._-]*\.js' "$INDEX_HTML" 2>/dev/null | head -n 1)"
if [ -z "$MAIN_BUNDLE" ]; then
echo " ❌ 断言② 主包一致失败: 未能从 $INDEX_HTML 解析出入口 chunk(/assets/index-*.js)"
FRONTEND_ASSERT_FAIL=1
elif [ ! -f "$FRONTEND_DIST_DIR$MAIN_BUNDLE" ]; then
echo " ❌ 断言② 主包一致失败: index.html 引用的 $MAIN_BUNDLE 在 dist 不存在($FRONTEND_DIST_DIR)"
FRONTEND_ASSERT_FAIL=1
else
MAIN_D_MD5="$(md5sum "$FRONTEND_DIST_DIR$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')"
MAIN_W_MD5="$(md5sum "$CMA_WEBROOT$MAIN_BUNDLE" 2>/dev/null | awk '{print $1}')"
if [ -n "$MAIN_D_MD5" ] && [ "$MAIN_D_MD5" = "$MAIN_W_MD5" ]; then
echo " ✅ 断言② 主包一致通过($MAIN_BUNDLE dist=web=$MAIN_D_MD5)"
else
echo " ❌ 断言② 主包一致失败: $MAIN_BUNDLE dist=$MAIN_D_MD5 web=$MAIN_W_MD5"
FRONTEND_ASSERT_FAIL=1
fi
fi
# ③ 引用链可 200:主包内引用的懒加载 chunk 逐个请求 $SITE_URL,必须全部 HTTP 200(无静默跳过分支)
# 字面量形态取并集:现网 Vite 产物实测为相对形态 from"./Login-CCqwmqG_.js",另有 "/assets/*.js" 形态;
# 两者都归一到 /assets/<name> 再请求。解析不到任何引用 → 显式判失败(不得静默放行)。
MAIN_JS_FILE="$CMA_WEBROOT$MAIN_BUNDLE"
CHUNKS="$(grep -o -E '(\./|/assets/)[A-Za-z0-9._-]+\.js' "$MAIN_JS_FILE" 2>/dev/null | sed -E 's#^\./#/assets/#' | sort -u)"
CHUNK_TOTAL="$(printf '%s\n' "$CHUNKS" | awk 'NF{n++} END{print n+0}')"
if [ ! -f "$MAIN_JS_FILE" ]; then
echo " ❌ 断言③ 引用链可 200 失败: 主包在 webroot 不存在($MAIN_JS_FILE)"
FRONTEND_ASSERT_FAIL=1
elif [ "$CHUNK_TOTAL" -eq 0 ]; then
echo " ❌ 断言③ 引用链可 200 失败: 主包 $MAIN_BUNDLE 内未解析到任何 chunk 引用(./*.js 或 /assets/*.js)"
FRONTEND_ASSERT_FAIL=1
else
CHUNK_OK=0
CHUNK_BAD=""
while IFS= read -r C; do
[ -n "$C" ] || continue
C_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$SITE_URL$C" 2>/dev/null || echo 000)"
if [ "$C_CODE" = "200" ]; then
CHUNK_OK=$((CHUNK_OK + 1))
else
CHUNK_BAD="${CHUNK_BAD}${CHUNK_BAD:+, }$C(HTTP=$C_CODE)"
fi
done <<< "$CHUNKS"
if [ -n "$CHUNK_BAD" ]; then
echo " ❌ 断言③ 引用链可 200 失败($SITE_URL): $CHUNK_BAD"
FRONTEND_ASSERT_FAIL=1
else
echo " ✅ 断言③ 引用链可 200 通过($CHUNK_OK/$CHUNK_TOTAL chunk @ $SITE_URL)"
fi
fi
# 三条均为硬门:任一不一致即中止部署(nginx 直接服务静态文件,无需 reload)
if [ "$FRONTEND_ASSERT_FAIL" -ne 0 ]; then
echo ""
echo "🚨 前端层一致性断言未通过 → 中止部署(webroot 可能停留在旧包 / 半包)"
echo " dist=$FRONTEND_DIST_DIR webroot=$CMA_WEBROOT site=$SITE_URL"
exit 1
fi
echo " ✓ 前端层一致性断言通过(① 逐文件 md5 ② 主包 ③ 引用链 200)"
# <<< cma-frontend-assert
# 4. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署) # 4. 更新后端依赖(失败不阻断 — 依赖已在系统Python中,避免PEP668中断部署)
echo "[4/5] 更新后端依赖..." echo "[4/5] 更新后端依赖..."
cd "$SCRIPT_DIR/backend" cd "$DEPLOY_SRC/backend"
if [ -f venv/bin/activate ]; then if [ "$MODE" = "drill" ]; then
echo " ℹ️ 演练模式:跳过依赖安装(共享 venv 只读)"
elif [ -f venv/bin/activate ]; then
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt --quiet --no-cache-dir || echo " ⚠️ 依赖更新失败(不阻断)" pip install -r requirements.txt --quiet --no-cache-dir || echo " ⚠️ 依赖更新失败(不阻断)"
deactivate deactivate
@@ -162,18 +214,58 @@ echo " ✓ 依赖检查完成"
# 新模型必须由**有 DDL 权限的账号**在重启前建表;此处失败即中止部署(后端保持旧码运行), # 新模型必须由**有 DDL 权限的账号**在重启前建表;此处失败即中止部署(后端保持旧码运行),
# 避免 2026-09-16 那种「启动 create_all 被 cma_api 拒 → 崩溃循环」。 # 避免 2026-09-16 那种「启动 create_all 被 cma_api 拒 → 崩溃循环」。
echo "[4b/5] 数据库结构迁移(特权账号,重启前)..." echo "[4b/5] 数据库结构迁移(特权账号,重启前)..."
cd "$SCRIPT_DIR/backend" cd "$DEPLOY_SRC/backend"
if ! ./venv/bin/python3 scripts/db_migrate.py --apply; then MIGRATE_ARG="--apply"
if [ "$MODE" = "drill" ]; then MIGRATE_ARG="--check"; echo " ℹ️ 演练模式:只跑 --check(只读,不落 DDL)"; fi
if ! ./venv/bin/python3 scripts/db_migrate.py "$MIGRATE_ARG"; then
echo "❌ 数据库结构迁移失败 → 中止部署(后端未重启,旧码继续服务)" echo "❌ 数据库结构迁移失败 → 中止部署(后端未重启,旧码继续服务)"
echo " 排查: cd $SCRIPT_DIR/backend && ./venv/bin/python3 scripts/db_migrate.py --check" echo " 排查: cd $DEPLOY_SRC/backend && ./venv/bin/python3 scripts/db_migrate.py --check"
echo " 回滚: cd $SCRIPT_DIR/backend && ./venv/bin/python3 scripts/db_migrate.py --rollback latest" echo " 回滚: cd $DEPLOY_SRC/backend && ./venv/bin/python3 scripts/db_migrate.py --rollback latest"
exit 1 exit 1
fi fi
cd "$SCRIPT_DIR" cd "$DEPLOY_SRC"
echo " ✓ 结构迁移完成(幂等;journal: backend/scripts/backups/db-migrate-journal/)" echo " ✓ 结构迁移完成(幂等;journal: $DEPLOY_SRC/backend/scripts/backups/db-migrate-journal/)"
# 5. 重启后端服务 # 5. 重启后端服务(运行树 = 部署树:幂等改写 unit,改前备份,附一行回滚)
echo "[5/5] 重启后端服务..." echo "[5/5] 重启后端服务..."
if [ "$MODE" = "drill" ]; then
echo " ℹ️ 演练模式:跳过 unit 改写/服务重启(下列 systemctl/nginx 为 PATH 桩,仅留调用证据)"
else
# >>> cma-runtime-unit-switch(最小可抽取块:drill/test 按行首标记 awk 抽取后原样执行,改结构请沿用标记)
DESIRED_WD="$DEPLOY_SRC/backend"
if grep -qF "WorkingDirectory=$DESIRED_WD" "$UNIT_FILE" 2>/dev/null; then
echo " ✓ 运行树已是部署树(WorkingDirectory=$DESIRED_WD)"
else
if [ ! -r "$UNIT_FILE" ]; then
echo "❌ 找不到 systemd unit: $UNIT_FILE"; exit 1
fi
UNIT_BAK="${UNIT_FILE}.bak-$(date +%Y%m%d-%H%M%S)"
cp -a "$UNIT_FILE" "$UNIT_BAK"
cat > "$UNIT_FILE" <<UNITEOF
[Unit]
Description=管理会计OS后端(运行树=部署树,见 deploy.sh cma-deploy-source-decouple-20260921)
After=network-online.target mysql.service
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=$DESIRED_WD
EnvironmentFile=$DESIRED_WD/.env
ExecStart=$DESIRED_WD/venv/bin/python3 -m uvicorn app.main:app --host 127.0.0.1 --port 8010 --workers 1 --limit-max-requests 10000 --timeout-keep-alive 30 --no-access-log
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
UNITEOF
echo " ℹ️ 运行树切到部署树(WorkingDirectory=$DESIRED_WD)"
echo " 一行回滚: cp $UNIT_BAK $UNIT_FILE && systemctl daemon-reload && systemctl restart cma-backend"
fi
# <<< cma-runtime-unit-switch
fi
systemctl daemon-reload systemctl daemon-reload
systemctl restart cma-backend systemctl restart cma-backend
systemctl reload nginx 2>/dev/null || nginx -s reload systemctl reload nginx 2>/dev/null || nginx -s reload
@@ -227,10 +319,10 @@ else
echo " ❌ BOT桥接API失败"; SMOKE_FAIL=1 echo " ❌ BOT桥接API失败"; SMOKE_FAIL=1
fi fi
# Schema 一致性检查(数据库与ORM结构对齐) # Schema 一致性检查(数据库与ORM结构对齐)
if /root/cma-management/backend/venv/bin/python3 /root/cma-management/backend/scripts/schema_check.py > /dev/null 2>&1; then if "$DEPLOY_SRC/backend/venv/bin/python3" "$DEPLOY_SRC/backend/scripts/schema_check.py" > /dev/null 2>&1; then
echo " ✅ Schema一致性通过" echo " ✅ Schema一致性通过"
else else
echo " ❌ Schema不一致,请检查: cd /root/cma-management/backend && venv/bin/python3 scripts/schema_check.py" echo " ❌ Schema不一致,请检查: cd $DEPLOY_SRC/backend && venv/bin/python3 scripts/schema_check.py"
SMOKE_FAIL=1 SMOKE_FAIL=1
fi fi
# 凭据同源(2026-09-14 cma-defaultcred-code-fix):登录冒烟不再硬编码默认账号/口令 —— 轮换后必须自动跟随。 # 凭据同源(2026-09-14 cma-defaultcred-code-fix):登录冒烟不再硬编码默认账号/口令 —— 轮换后必须自动跟随。
@@ -267,12 +359,15 @@ fi
if [ "$SMOKE_FAIL" -eq 1 ]; then if [ "$SMOKE_FAIL" -eq 1 ]; then
echo "" echo ""
echo "🚨 冒烟测试未全部通过!请检查后端日志: journalctl -u cma-backend -n 50" echo "🚨 冒烟测试未全部通过!请检查后端日志: journalctl -u cma-backend -n 50"
echo " 一行回滚: cp ${UNIT_FILE}.bak-* ${UNIT_FILE} && systemctl daemon-reload && systemctl restart cma-backend"
exit 1 exit 1
fi fi
echo " ✅ 全部冒烟测试通过" echo " ✅ 全部冒烟测试通过"
echo "" echo ""
echo "===== 部署完成 =====" echo "===== 部署完成 ====="
echo " 部署源 : origin/main = ${DEPLOY_SHA:0:8} ${DEPLOY_SUBJECT}"
echo " 部署树 : $DEPLOY_SRC"
echo " 前端: https://cma.sxbh.ltd" echo " 前端: https://cma.sxbh.ltd"
echo " 后端: http://127.0.0.1:8010/docs" echo " 后端: http://127.0.0.1:8010/docs"
echo " 后端状态: systemctl status cma-backend" echo " 后端状态: systemctl status cma-backend"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# ============================================================
# CMA 部署前「部署源」前置检查(宿主侧,由 gitea-cma-deploy-hook.sh 调用)
# ------------------------------------------------------------
# 版本: cma-deploy-source-decouple-20260921(单 project-20260921-deploy-dirtyguard-rootfix)
#
# 旧行为(cma-deploy-dirty-guard-20260918):开发工作区出现**任何** tracked modified 即 exit 1。
# 初衷是「防 deploy.sh 里的 `git pull` 覆盖未提交改动」。但开发区同时是部署源 ⇒ 多 agent 并行下
# 必然反复自锁(corepack 生成物、在途任务文件、unplugin-vue-components 生成物…),发布链被自己的
# 纪律闸门卡死(09-18 / 09-21 两次实测)。
#
# 新行为:部署内容 = **已提交的 origin/main 树**(deploy.sh 用独立检出 $CMA_DEPLOY_SRC 重建;
# 部署过程不再对开发区做 pull/stash/checkout)。开发区的脏净与部署内容无关 ⇒ 本闸门**降级为告警**:
# 打印开发区状态供留痕,不因 modified 中止部署。真正的硬闸门仍有三道,且都在 deploy.sh 里:
# [0] 部署源解析(fetch 失败即中止) [1c] 部署树 ≠ 已提交树即中止 [1b] 鉴权面审计 B 类缺口即中止。
# 本脚本只在「仓库不可用」这一种情况失败关闭(部署本身需要这个仓库)。
#
# 用法: cma-deploy-dirty-guard.sh [repo_dir] # 默认 /root/cma-management
# 退出码: 0 = 可继续部署(无论开发区脏净); 1 = 仓库不可用
# 依赖: git。本脚本**不写**开发区任何文件(旧版会把 corepack 生成物 checkout 回去,已移除)。
# ============================================================
set -uo pipefail
REPO="${1:-/root/cma-management}"
cd "$REPO" 2>/dev/null || { echo "❌ 仓库不可用: $REPO"; exit 1; }
if ! git rev-parse --git-dir > /dev/null 2>&1; then
echo "❌ 不是 git 仓库: $REPO"
exit 1
fi
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
HEAD_SHA="$(git rev-parse HEAD 2>/dev/null || echo '?')"
ORIGIN_SHA="$(git rev-parse origin/main 2>/dev/null || echo '?')"
DIRTY="$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20)"
DIRTY_N="$(git status --porcelain 2>/dev/null | grep -vcE "__pycache__|^\?\?")"
echo " ℹ️ 开发区 $REPO: 分支=$BRANCH HEAD=${HEAD_SHA:0:8} origin/main=${ORIGIN_SHA:0:8}"
if [ "$DIRTY_N" -gt 0 ]; then
echo " ⚠️ 开发区有 $DIRTY_N 处未提交 tracked 改动 —— 不阻断部署(这些改动不会上线,也不会被丢弃):"
echo "$DIRTY" | sed 's/^/ /'
else
echo " ✓ 开发区无未提交 tracked 改动"
fi
echo " ℹ️ 部署内容 = 已提交的 origin/main 树(与开发区脏净解耦)"
exit 0
+105
View File
@@ -0,0 +1,105 @@
#!/bin/bash
# Gitea post-receive 部署触发器(由 /root/.ssh/authorized_keys 的 forced command 调用)
# 落地:ops-20260918-gitea-deploy-hook-ssh-fix
# 容器侧私钥只能触发本脚本(不能执行任意 root 命令),且来源 IP 限定在 gitea 容器网段。
# HOME 必须显式为 /root:profile HOME 下 corepack 缓存缺 bin/pnpm.cjs → pnpm build 报 MODULE_NOT_FOUND。
# 2026-09-18(0-P1-project-20260918-deploy-dirty-guard):deploy.sh 之前增加「提交纪律」前置闸门
# —— frontend/package.json 的 corepack 生成物(packageManager 单字段)不再永久卡死发布链,
# 其它任何脏改动仍照旧拦截(deploy.sh 第 0 步原闸门保留,双重保险)。
# 2026-09-21(cma-deploy-source-decouple-20260921,单 project-20260921-deploy-dirtyguard-rootfix):
# 根治「开发区任何 modified 即中止」——部署内容改为**已提交的 origin/main 树**(deploy.sh 用独立检出
# 重建;部署过程不再 pull 开发区)⇒ 本前置闸门降级为**告警**:打印开发区状态供留痕,不因 modified 拦截;
# 仅在「仓库不可用」时失败关闭(部署本身仍需这个仓库)。
set -uo pipefail
export HOME=/root
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export LANG=C.UTF-8
# ============================================================
# [gate-trail] 动作前留痕(树外执行体入口,协议 v1.0.0)
# chokepoint : gitea-cma-deploy-hook(channel=remote-trigger)
# 注册表 : /etc/gate-trail/governed-entries.json
# 留痕 : /var/log/gate-trail/trail.jsonl(append-only)
# 覆盖语义 : 父级入口覆盖下游子步骤(deploy.sh → db_migrate.py --apply),
# 单点挂载即覆盖整条链,下游不再各自留痕(见注册表 also_covered_by)
# 依据 : 派单 yanxue-20260921-deploy-channel-gate-trail-mount
# (协议母版 yanxue-20260918-051623-tree-external-unit-gate-trail)
# ⚠️ 失败关闭 : begin 返回 2(HIGH/CRITICAL 且无有效审批)→ 中止部署
# ⚠️ 取值纪律 : 必须先接住输出再取 $?;写成 `gate-trail ... | python3` 会把 exit 2 读成 0
# ⚠️ 收尾纪律 : 用 EXIT trap 闭合(含 dirty-guard 拦截等提前 exit 路径)
# ============================================================
TRAIL_CHANNEL="remote-trigger"
TRAIL_ACTION="Gitea push → post-receive → SSH forced-command → CMA 部署链(deploy.sh)"
TRAIL_CMD="bash /root/cma-management/deploy.sh (trigger: gitea post-receive / authorized_keys forced-command)"
TRAIL_TASK="${GATE_TRAIL_TASK:-}"
TRAIL_ID=""
TRAIL_CLOSED=""
# 收尾:任何退出路径都闭合留痕(BEGIN 之后才注册 trap,避免无 ID 时空转)
trail_closeup() {
local _rc=$?
if [ -z "${TRAIL_CLOSED:-}" ] && [ -n "${TRAIL_ID:-}" ] && command -v gate-trail >/dev/null 2>&1; then
if [ "$_rc" -eq 0 ]; then
gate-trail end --id "$TRAIL_ID" --result pass \
--evidence "deploy-hook exit=0 @ $(date '+%F %T %Z')" >/dev/null 2>&1 || true
else
gate-trail end --id "$TRAIL_ID" --result fail \
--evidence "deploy-hook exit=$_rc @ $(date '+%F %T %Z')(含前置闸门拦截等中止路径)" >/dev/null 2>&1 || true
fi
TRAIL_CLOSED=1
fi
return "$_rc"
}
if command -v gate-trail >/dev/null 2>&1; then
TRAIL_JSON=$(gate-trail begin --channel "$TRAIL_CHANNEL" --action "$TRAIL_ACTION" \
--cmd "$TRAIL_CMD" --task "$TRAIL_TASK" \
--asset /root/cma-management --json 2>/dev/null)
rc=$?
if [ "$rc" -eq 2 ]; then
echo "[deploy-hook] ⛔ gate-trail 拒绝执行(高危且无有效审批)—— 中止部署" >&2
exit 2
fi
TRAIL_ID=$(printf '%s' "$TRAIL_JSON" | python3 -c 'import json,sys
try:
print(json.load(sys.stdin).get("trail_id",""))
except Exception:
print("")')
if [ -z "$TRAIL_ID" ]; then
echo "[deploy-hook] ⛔ gate-trail 未返回 trail_id —— 按失败关闭处理,中止部署" >&2
exit 2
fi
echo "[deploy-hook] 🧾 gate-trail 留痕已开: $TRAIL_ID"
trap trail_closeup EXIT
else
echo "[deploy-hook] ⚠️ gate-trail 不可用,本次动作将无留痕(违反 gate-trail 协议)" >&2
fi
echo "[deploy-hook] 宿主机已接受 SSH(key=gitea-deploy-hook@sxbh.ltd, forced-command)"
echo "[deploy-hook] 请求命令(被忽略/仅记录)=${SSH_ORIGINAL_COMMAND:-<none>}"
cd /root/cma-management || { echo "[deploy-hook] ✗ /root/cma-management 不可用"; exit 1; }
# [0/5] 部署源前置检查(实现见 cma-deploy-dirty-guard.sh)
# 2026-09-21 起:开发区脏净**不再**中止部署(部署内容 = 已提交的 origin/main 树,见 deploy.sh);
# 本步只打印开发区状态供留痕,仅「仓库不可用」失败关闭。
DIRTY_GUARD=/usr/local/sbin/cma-deploy-dirty-guard.sh
echo "[deploy-hook] [0/5] 部署源前置检查(开发区脏净仅告警)..."
if [ ! -r "$DIRTY_GUARD" ]; then
echo "[deploy-hook] ✗ 前置检查脚本缺失或不可读: $DIRTY_GUARD(按失败关闭中止部署)"
exit 1
fi
if ! bash "$DIRTY_GUARD" /root/cma-management; then
echo "[deploy-hook] ✗ 仓库不可用 → 中止部署"
exit 1
fi
bash deploy.sh 2>&1
rc=$?
if [ "$rc" -eq 0 ]; then
echo "[deploy-hook] ✓ 部署成功"
else
echo "[deploy-hook] ✗ 部署失败 (exit: $rc)"
fi
exit "$rc"
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# ============================================================
# 宿主部署链组件安装/升级(CMA)
# cma-deploy-source-decouple-20260921(单 project-20260921-deploy-dirtyguard-rootfix)
#
# 把本仓 deploy/ 下的宿主侧组件安装到 /usr/local/sbin/(改前自动备份、改后校验 md5)。
# 组件(宿主侧,容器内 gitea post-receive → SSH forced-command 进入本仓库时执行):
# gitea-cma-deploy-hook.sh → /usr/local/sbin/gitea-cma-deploy-hook.sh
# cma-deploy-dirty-guard.sh → /usr/local/sbin/cma-deploy-dirty-guard.sh
#
# 用法:
# bash deploy/install-host-deploy-hook.sh # 安装/升级(含备份 + 校验)
# bash deploy/install-host-deploy-hook.sh --check # 只比对(不写任何文件)
#
# 说明:
# - 备份目录 /root/backups/cma-deploy-source-decouple-<YYYYMMDD-HHMMSS>/
# - 一行回滚: cp <备份目录>/<文件名> /usr/local/sbin/<文件名> && md5sum -c <备份目录>/MD5SUMS.rollback
# ============================================================
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SRC_DIR="$REPO_DIR/deploy"
DST_DIR="/usr/local/sbin"
FILES=(gitea-cma-deploy-hook.sh cma-deploy-dirty-guard.sh)
MODE="${1:-install}"
for f in "${FILES[@]}"; do
[ -r "$SRC_DIR/$f" ] || { echo "❌ 源文件缺失: $SRC_DIR/$f"; exit 1; }
done
if [ "$MODE" = "--check" ]; then
rc=0
for f in "${FILES[@]}"; do
if [ -r "$DST_DIR/$f" ] && cmp -s "$SRC_DIR/$f" "$DST_DIR/$f"; then
echo "✓ 一致: $DST_DIR/$f"
else
echo "≠ 不一致: $DST_DIR/$f(需要安装)"; rc=1
fi
done
exit "$rc"
fi
TS="$(date +%Y%m%d-%H%M%S)"
BAK_DIR="/root/backups/cma-deploy-source-decouple-$TS"
mkdir -p "$BAK_DIR"
for f in "${FILES[@]}"; do
if [ -e "$DST_DIR/$f" ]; then
cp -a "$DST_DIR/$f" "$BAK_DIR/$f"
md5sum "$DST_DIR/$f" >> "$BAK_DIR/MD5SUMS.rollback"
else
echo "<absent>" >> "$BAK_DIR/MD5SUMS.rollback"
fi
done
for f in "${FILES[@]}"; do
install -m 0755 -o root -g root "$SRC_DIR/$f" "$DST_DIR/$f"
done
echo "备份目录: $BAK_DIR"
for f in "${FILES[@]}"; do
echo "已安装: $DST_DIR/$f md5=$(md5sum "$DST_DIR/$f" | awk '{print $1}')"
done
bash -n "$DST_DIR/gitea-cma-deploy-hook.sh" && echo "✓ 语法检查通过"
echo "✓ 安装完成(生效于下一次 push 触发的部署)"
@@ -0,0 +1,67 @@
# 口径登记:审计留痕(`operation_logs`)与通知发送记录(`notification_logs`)
- **立档**: wecom-fullstack,2026-09-22
- **来源**: DAMA 独立回查 `dama-recheck-workbench-entity-isolation-20260922`(2026-09-22 11:07–11:20)
§4b(审计字面与库内既有口径不一致 / 无 `entity_id` 列 / `user_id=None`)与
§5(B2 上线后 `/notifications/logs` 在 e1/e2 均为 0 条 = 口径副作用)两处待办
- **性质**: 口径登记 + 代码收口说明。**不含**任何数据订正(数据订正另立单 + 审批)
---
## 一、`operation_logs` 审计字面字典(换账套类)
| action 字面 | 写入端点 | 语义 | target_type | target_id |
|---|---|---|---|---|
| `switch_entity` | `post /api/cma/auth/switch-entity`(`app/api/auth.py`) | 改**用户自己的会话账套**(重签 token、旧 token 下线) | `entity` | 目标 `entity_id` |
| `switch_tenant_state` | `post /api/cma/tenant/switch`(`app/api/tenant.py`) | 改**平台全局状态记录** `system_configs.current_tenant`(项目Bot 分发 A2A 任务用;**非数据维度**) | `entity` | 目标 `entity_id` |
**为什么两个字面不合并**:两者作用域不同(个人会话 vs 全局单值键),合并后审计上不可分辨
「谁把自己切走了」与「谁改了全平台状态」。DAMA 复核提出「统一为 `switch_entity` 或登记字典」——
本档取**后者(登记不合并)**,原因是前者会丢语义可分辨性;若后续裁定要求合并,改动面为
`app/api/tenant.py` 一行字面 + 本表一行(可逆)。
**可归属与可过滤(2026-09-22 收口,随本档同一提交落地)**:
- `operation_logs` **无** `entity_id` 列(仅 `user_id / action / target_type / target_id / detail / created_at`)。
故「按账套查换账套记录」不靠新列,而靠**目标维度**:
```sql
SELECT * FROM operation_logs WHERE target_type='entity' AND target_id=? ORDER BY id DESC;
```
- `/tenant/switch` 原写 `user_id=None`(端点无当前用户注入)⇒ 事后无法追操作人。
已改为注入会话用户(`user=Depends(require_auth_401)` → `user_id=user.id`),
并把 `target_type='entity'` / `target_id=entity_id` 与 `switch_entity` 对齐。
- `detail` JSON 仍保留 `entity_id / entity_name / source / previous`(人读明细)。
**现网实况(只读核对,2026-09-22 11:15,`cma_reader` 16/16 PASS)**:
`switch_entity` = 49 行、`switch_tenant_state` = 0 行(本单尚未部署 ⇒ 字面是新引入的,
上线前库里无该字面属正常)。ops 止血那次 `POST /tenant/switch`(10:36:01,部署码)**没有**审计行——
部署码该端点无审计,正是本单补齐项。
---
## 二、`/api/cma/notifications/logs` 口径(发送记录页)
- **归属口径**:`notification_logs` 无 `entity_id` 列 ⇒ 经**父键** join 归属:
`notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id`;按会话账套过滤,
与 batch5 对 `kpi_data_quality_log` 的处置同口径(**宁少勿错**)。
- **`alert_id IS NULL` 的行**:无可归属父键 ⇒ **不返回给任何账套**(不跨账套暴露)。
现库 8 行历史记录(2026-09-11/12「OKR 季度复盘提醒」)**100% 属此类**,
写入方 = `backend/scripts/okr_review_reminder.py`(复用本表留存审计,`alert_id` 留空)。
- **上线后现象(须预期,不是故障)**:`/notifications/logs` 在 e1/e2 均返回 `total=0`,
前端发送记录页为空。现役预警推送写入器 `app/utils/notifier.py` **必带** `alert_id`,
故此后由预警产生的新记录会正常显示;只有「无父键历史行」永不出现在该页。
- **页面与接口的提示(随本档同一提交落地)**:
- 接口返回 `policy="alert-linked-only"` + `note`(说明「仅显示与预警关联的推送」);
- 前端 `NotificationManage.vue` 发送记录表 `empty-text` 写明同一口径。
- **未做(留待 P2 裁定)**:为无父键通知单列只读视图(需先给 `notification_logs` 补账套维度,
属 DDL/迁移,超出「零迁移 B2」范围)。
---
## 三、本档未覆盖(另立单,见对应任务文件)
1. `kpi_alerts` id 589/590 跨账套错挂(`entity_id=1` 挂 e2 的 `BH_ADM_EXPENSE`):数据订正须
另立单 + 任总放行(生产写)。
2. `kpi_id=1` 兜底残留清点(9 行、其中 1 行 `pending`)与归属裁定。
3. 同源全局键继续拆账套:`kpi_alert_higher_better`、`kpi_visibility`(DAMA §7 反向挑错新增漏项)、
`budget_mode` / `budget_driver_mode`(batch5 §七 已记录未施工)。
+1
View File
@@ -202,6 +202,7 @@ export const actionPlanApi = {
update: (id: number, data: any) => api.put(`/action-plans/${id}`, data), update: (id: number, data: any) => api.put(`/action-plans/${id}`, data),
delete: (id: number) => api.delete(`/action-plans/${id}`), delete: (id: number) => api.delete(`/action-plans/${id}`),
cosoChecklist: (params?: any) => api.get('/action-plans/coso-checklist', { params }), cosoChecklist: (params?: any) => api.get('/action-plans/coso-checklist', { params }),
cosoGenerateActions: (data?: any) => api.post('/action-plans/coso-generate-actions', data || {}),
stats: () => api.get('/action-plans/stats'), stats: () => api.get('/action-plans/stats'),
} }
@@ -61,21 +61,24 @@
<div v-if="prevRevenue" class="pb-compare"> <div v-if="prevRevenue" class="pb-compare">
<div class="pb-compare-item"> <div class="pb-compare-item">
<span class="pb-compare-label">营收较上期</span> <span class="pb-compare-label">营收较上期</span>
<span :class="revChg >= 0 ? 'pb-pos' : 'pb-neg'"> <span v-if="revChg != null" :class="revChg >= 0 ? 'pb-pos' : 'pb-neg'">
{{ revChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(revChg) * 100).toFixed(1) }}% {{ revChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(revChg) * 100).toFixed(1) }}%
</span> </span>
<span v-else style="color:#909399">缺上期数据</span>
</div> </div>
<div class="pb-compare-item"> <div class="pb-compare-item">
<span class="pb-compare-label">净利润较上期</span> <span class="pb-compare-label">净利润较上期</span>
<span :class="profitChg >= 0 ? 'pb-pos' : 'pb-neg'"> <span v-if="profitChg != null" :class="profitChg >= 0 ? 'pb-pos' : 'pb-neg'">
{{ profitChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(profitChg) * 100).toFixed(1) }}% {{ profitChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(profitChg) * 100).toFixed(1) }}%
</span> </span>
<span v-else style="color:#909399">缺上期数据</span>
</div> </div>
<div class="pb-compare-item"> <div class="pb-compare-item">
<span class="pb-compare-label">净利率较上期</span> <span class="pb-compare-label">净利率较上期</span>
<span :class="rateChg >= 0 ? 'pb-pos' : 'pb-neg'"> <span v-if="rateChg != null" :class="rateChg >= 0 ? 'pb-pos' : 'pb-neg'">
{{ rateChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(rateChg) * 100).toFixed(1) }}% {{ rateChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(rateChg) * 100).toFixed(1) }}%
</span> </span>
<span v-else style="color:#909399">缺上期数据</span>
</div> </div>
</div> </div>
@@ -129,19 +132,22 @@ const costRate = computed(() => {
return (props.cost / props.revenue * 100).toFixed(1) + '%' return (props.cost / props.revenue * 100).toFixed(1) + '%'
}) })
const revChg = computed(() => { // 缺失值 NULL 语义(财务裁定 2026-09-21 §2):上期缺失/为 0 时返回 null,
if (!props.prevRevenue) return 0 // 不得回落 0(0 会被显示成「持平」,把「无数据」当真实 0 参与同环比)。
const revChg = computed<number | null>(() => {
if (props.prevRevenue == null || props.prevRevenue === 0) return null
return (props.revenue - props.prevRevenue) / props.prevRevenue return (props.revenue - props.prevRevenue) / props.prevRevenue
}) })
const profitChg = computed(() => { const profitChg = computed<number | null>(() => {
if (!props.prevProfit) return 0 if (props.prevProfit == null || props.prevProfit === 0) return null
return (props.profit - props.prevProfit) / Math.abs(props.prevProfit) return (props.profit - props.prevProfit) / Math.abs(props.prevProfit)
}) })
const rateChg = computed(() => { const rateChg = computed<number | null>(() => {
if (!props.prevRevenue || !props.prevProfit) return 0 if (props.prevRevenue == null || props.prevProfit == null || props.prevRevenue === 0) return null
const cur = props.profit / props.revenue const cur = props.profit / props.revenue
const prev = props.prevProfit / props.prevRevenue const prev = props.prevProfit / props.prevRevenue
return (cur - prev) / Math.abs(prev || 0.01) if (prev === 0) return null
return (cur - prev) / Math.abs(prev)
}) })
const detailItems = computed(() => { const detailItems = computed(() => {
+34 -1
View File
@@ -294,7 +294,8 @@
<!-- 企业只读标识(不可点):账套由登录窗口选定,页内无切换入口 --> <!-- 企业只读标识(不可点):账套由登录窗口选定,页内无切换入口 -->
<el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag> <el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag>
<el-button size="small" @click="loadCosoChecklist" :loading="cosoLoading">刷新</el-button> <el-button size="small" @click="loadCosoChecklist" :loading="cosoLoading">刷新</el-button>
<el-button v-if="cosoData" type="primary" size="small" style="margin-left:12px;">生成改善行动</el-button> <el-button v-if="cosoData" type="primary" size="small" style="margin-left:12px;"
:loading="cosoGenerating" @click="generateCosoActions">生成改善行动<span v-if="cosoFailedCount">({{ cosoFailedCount }})</span></el-button>
</div> </div>
<div v-if="cosoData"> <div v-if="cosoData">
@@ -667,6 +668,38 @@ async function loadCosoChecklist() {
cosoLoading.value = false cosoLoading.value = false
} }
const cosoGenerating = ref(false)
// 自检未通过(passed=false)条目数——即「生成改善行动」将覆盖的范围
const cosoFailedCount = computed(() => {
const els = cosoData.value?.elements || []
return els.reduce((n: number, el: any) => n + (el.items || []).filter((it: any) => !it.passed).length, 0)
})
async function generateCosoActions() {
if (!cosoData.value) return
const n = cosoFailedCount.value
if (n === 0) {
ElMessage.info('当前自检没有未通过条目,无需生成改善行动')
return
}
try {
await ElMessageBox.confirm(
`将把自检中 ${n} 条未通过条目生成改善行动(已存在的不会重复生成),是否继续?`,
'生成改善行动'
)
} catch { return }
cosoGenerating.value = true
try {
const r: any = await actionPlanApi.cosoGenerateActions({})
const created = r?.created ?? 0
const skipped = r?.skipped ?? 0
ElMessage.success(`已生成 ${created} 条改善行动${skipped ? `,跳过 ${skipped} 条已存在` : ''}`)
await Promise.all([loadData(), loadStats()])
if (created > 0) activeTab.value = 'plans' // 切到行动方案列表即可确认
} catch { ElMessage.error('生成改善行动失败') }
cosoGenerating.value = false
}
// ── 初始化 ── // ── 初始化 ──
onMounted(async () => { onMounted(async () => {
loadStats() loadStats()
+2 -2
View File
@@ -200,7 +200,7 @@
</template></el-table-column> </template></el-table-column>
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column> <el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column> <el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column> <el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress v-if="row.execution_rate != null" :percentage="row.execution_rate" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /><span v-else style="color:#909399">-</span></template></el-table-column>
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column> <el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table> </el-table>
@@ -1094,7 +1094,7 @@ async function loadVersionDiff() { if (selectedVersions.value.length !== 2) retu
const execFilterYear = ref(currentYear); const execFilterMonth = ref(new Date().getMonth()); const execFilterDim = ref(''); const execLoading = ref(false); const execReport = ref<any[]>([]); const execSummaryCards = ref<any[]>([]) const execFilterYear = ref(currentYear); const execFilterMonth = ref(new Date().getMonth()); const execFilterDim = ref(''); const execLoading = ref(false); const execReport = ref<any[]>([]); const execSummaryCards = ref<any[]>([])
async function loadExecutionReport() { async function loadExecutionReport() {
execLoading.value = true execLoading.value = true
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true, entity_id: getEntityId() }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: i.budget_value && i.budget_value > 0 ? Math.round((i.actual_value || 0) / i.budget_value * 100) : 0, deviation_value: (i.actual_value || 0) - (i.budget_value || 0) })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const ar = hb.length > 0 ? Math.round(hb.reduce((s, i) => s + (i.execution_rate || 0), 0) / hb.length) : 0; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: `${ar}%`, color: ar > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') } try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true, entity_id: getEntityId() }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: (i.budget_value && i.budget_value > 0 && i.actual_value != null) ? Math.round(i.actual_value / i.budget_value * 100) : null, deviation_value: i.actual_value != null ? i.actual_value - (i.budget_value ?? 0) : null })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const hbR = hb.filter((i: any) => i.execution_rate != null); const ar = hbR.length > 0 ? Math.round(hbR.reduce((s: number, i: any) => s + i.execution_rate, 0) / hbR.length) : null; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: ar == null ? '-' : `${ar}%`, color: (ar ?? 0) > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
execLoading.value = false execLoading.value = false
} }
+6 -5
View File
@@ -342,19 +342,20 @@ async function loadTrend() {
const keyB = b.period || '' const keyB = b.period || ''
return keyA.localeCompare(keyB) return keyA.localeCompare(keyB)
}) })
// 缺失值 NULL 语义(财务裁定 2026-09-21 §2):actual/budget 缺失保留 null(不回落 0)
trendData.value = monthly.map((i: any) => ({ trendData.value = monthly.map((i: any) => ({
period: i.period, period: i.period,
actual: i.actual_value || 0, actual: i.actual_value ?? null,
budget: i.budget_value || 0, budget: i.budget_value ?? null,
deviation: i.deviation_rate, deviation: i.deviation_rate,
})) }))
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual, d.budget]), 1) maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual ?? 0, d.budget ?? 0]), 1)
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
trendLoading.value = false trendLoading.value = false
} }
function barHeight(val: number, max: number) { function barHeight(val: number | null, max: number) {
if (!max) return 0 if (!max || val == null) return 0 // 缺失值 NULL 语义:不画柱,不以 0 参与
return Math.max(4, (val / max) * 150) return Math.max(4, (val / max) * 150)
} }
+11 -1
View File
@@ -15,6 +15,8 @@
<!-- 模式选择器 --> <!-- 模式选择器 -->
<div v-if="showSelector" class="align-setup"> <div v-if="showSelector" class="align-setup">
<el-alert v-if="!alignConfigured" title="系统尚未配置KPI对齐模式" type="warning" :closable="false" show-icon class="section-gap" /> <el-alert v-if="!alignConfigured" title="系统尚未配置KPI对齐模式" type="warning" :closable="false" show-icon class="section-gap" />
<el-alert type="info" :closable="false" show-icon title="系统级设置,影响全部账套"
description="对齐模式保存在系统级配置中,切换后将同时作用于全部账套。" style="margin-bottom:12px;" />
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<el-card v-for="mode in alignModes" :key="mode.key" <el-card v-for="mode in alignModes" :key="mode.key"
:class="{ 'mode-card': true, 'selected-mode': selectedMode === mode.key }" :class="{ 'mode-card': true, 'selected-mode': selectedMode === mode.key }"
@@ -136,7 +138,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
// 统一走 api/index.ts 的 axios 实例(拦截器自动带 Bearer / 401 跳登录) // 统一走 api/index.ts 的 axios 实例(拦截器自动带 Bearer / 401 跳登录)
import { alignmentApi, mapApi } from '../api/index' import { alignmentApi, mapApi } from '../api/index'
@@ -286,6 +288,14 @@ async function loadConfig() {
async function saveAlignConfig() { async function saveAlignConfig() {
if (!selectedMode.value) return if (!selectedMode.value) return
// 系统级设置二次确认:对齐模式存于全局配置(role_permissions 无 entity_id),切换影响全部账套
try {
await ElMessageBox.confirm(
`对齐模式为系统级设置,切换后将立即作用于全部账套。确认切换为「${selectedModeName.value}」?`,
'系统级设置二次确认',
{ type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }
)
} catch { return }
saving.value = true saving.value = true
try { try {
// 修复:原 `'Bearer ' + token` 模板字面量 bug(header 值被写死成字面量 → 必 401) // 修复:原 `'Bearer ' + token` 模板字面量 bug(header 值被写死成字面量 → 必 401)
+34 -3
View File
@@ -56,6 +56,7 @@
<div class="section-header"> <div class="section-header">
<span class="section-icon">📊</span> <span class="section-icon">📊</span>
<span class="section-title">我的KPI</span> <span class="section-title">我的KPI</span>
<span v-if="dataAsOf" class="section-asof">数据截至 {{ dataAsOf }}</span>
<span class="section-action" @click="$router.push('/kpis')">查看全部 →</span> <span class="section-action" @click="$router.push('/kpis')">查看全部 →</span>
</div> </div>
<div v-if="kpis.length === 0" class="empty-card"> <div v-if="kpis.length === 0" class="empty-card">
@@ -70,17 +71,22 @@
<span class="kpi-tag" :class="'tag-' + k.level">{{ k.level === 'green' ? '达标' : k.level === 'yellow' ? '预警' : k.level === 'red' ? '危险' : '未评' }}</span> <span class="kpi-tag" :class="'tag-' + k.level">{{ k.level === 'green' ? '达标' : k.level === 'yellow' ? '预警' : k.level === 'red' ? '危险' : '未评' }}</span>
</div> </div>
<div class="kpi-value-row"> <div class="kpi-value-row">
<div class="kpi-actual" :class="{ 'kpi-actual-empty': k.actual_value == null }"> <div class="kpi-actual" :class="{ 'kpi-actual-empty': k.actual_value == null, 'kpi-actual-stale': isStale(k) }">
{{ k.actual_value == null ? '暂无数据' : periodActual(k) }} {{ k.actual_value == null ? '暂无数据' : periodActual(k) }}
</div> </div>
<div class="kpi-target">{{ targetLabel(k) }} {{ k.target_value ?? '-' }}</div> <div class="kpi-target">
<span v-if="isStale(k)" class="kpi-stale-tag">非当期</span>{{ targetLabel(k) }} {{ k.target_value ?? '-' }}
</div> </div>
</div>
<!-- 缺失值 NULL 语义(财务裁定 2026-09-21 §2):actual 缺失时不得显示 0% 进度 -->
<el-progress <el-progress
:percentage="k.target_value ? Math.min(100, Math.round((k.actual_value || 0) / k.target_value * 100)) : 0" v-if="k.actual_value != null && k.target_value"
:percentage="Math.min(100, Math.round(k.actual_value / k.target_value * 100))"
:status="k.level === 'green' ? 'success' : k.level === 'red' ? 'exception' : undefined" :status="k.level === 'green' ? 'success' : k.level === 'red' ? 'exception' : undefined"
:stroke-width="6" :stroke-width="6"
:show-text="false" :show-text="false"
/> />
<div v-else style="height:6px;line-height:6px;color:#c0c4cc;font-size:11px">—</div>
</div> </div>
</div> </div>
</div> </div>
@@ -174,6 +180,23 @@ function targetLabel(k: any) {
return '目标(月)' return '目标(月)'
} }
// 2026-09-18 口径小改(工作台期间缺口):月期间正则 + 页面最新可得月
const MONTH_PERIOD_RE = /^\d{4}-(0[1-9]|1[0-2])$/
const latestMonth = computed(() => {
const months = kpis.value
.map((k: any) => String(k.period || ''))
.filter((p: string) => MONTH_PERIOD_RE.test(p))
.sort()
return months.length ? months[months.length - 1] : ''
})
// 页头「数据截至 X 月」= 本页各卡中最新的月期间(无月期间时不出该文案,不编造时点)
const dataAsOf = computed(() => (latestMonth.value ? fmtPeriod(latestMonth.value) : ''))
// 非当期 = 该卡的值期间早于页面最新可得月(仍保留原期间标签,只加灰标提示,不隐藏)
function isStale(k: any): boolean {
const p = String(k.period || '')
return MONTH_PERIOD_RE.test(p) && !!latestMonth.value && p < latestMonth.value
}
function planIcon(p: any) { function planIcon(p: any) {
if (p.status === 'completed') return '✅' if (p.status === 'completed') return '✅'
if (p.overdue) return '🔴' if (p.overdue) return '🔴'
@@ -262,6 +285,8 @@ onMounted(async () => {
} }
.section-action { font-size: 13px; color: #409eff; cursor: pointer; } .section-action { font-size: 13px; color: #409eff; cursor: pointer; }
.section-action:hover { text-decoration: underline; } .section-action:hover { text-decoration: underline; }
/* 数据截至 X 月(2026-09-18):说明本页各卡的最新可得期间,不改渲染结构 */
.section-asof { font-size: 12px; color: #999; margin-right: 10px; }
.loading-wrap { padding: 40px; } .loading-wrap { padding: 40px; }
/* ── 空状态 ── */ /* ── 空状态 ── */
@@ -310,6 +335,12 @@ onMounted(async () => {
.kpi-value-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; } .kpi-value-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
.kpi-actual { font-size: 24px; font-weight: 700; } .kpi-actual { font-size: 24px; font-weight: 700; }
.kpi-actual-empty { font-size: 15px; font-weight: 500; color: #999; } .kpi-actual-empty { font-size: 15px; font-weight: 500; color: #999; }
/* 非当期灰标(2026-09-18):值期间落后于页面最新可得月时置灰,期间标签照旧显示 */
.kpi-actual-stale { color: #999; }
.kpi-stale-tag {
font-size: 11px; color: #999; background: #f0f0f0;
padding: 0 6px; border-radius: 4px; margin-right: 6px;
}
.kpi-target { font-size: 12px; color: #999; } .kpi-target { font-size: 12px; color: #999; }
/* ── 改善行动 ── */ /* ── 改善行动 ── */
+2 -1
View File
@@ -42,7 +42,8 @@
<!-- 发送记录 --> <!-- 发送记录 -->
<h3 class="section-title">发送记录</h3> <h3 class="section-title">发送记录</h3>
<el-table :data="logs" style="width:100%;margin-top:8px;" size="small"> <el-table :data="logs" style="width:100%;margin-top:8px;" size="small"
empty-text="仅显示与预警推送关联的记录;无账套归属的历史通知不在此列">
<el-table-column prop="created_at" label="时间" width="160" /> <el-table-column prop="created_at" label="时间" width="160" />
<el-table-column prop="title" label="标题" min-width="200" /> <el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="channel" label="渠道" width="80" /> <el-table-column prop="channel" label="渠道" width="80" />