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
+261 -32
View File
@@ -1,7 +1,8 @@
"""改善行动计划 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 datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Optional
import re
import logging
@@ -14,6 +15,9 @@ from app.api.verify import build_auto_verify_rule
logger = logging.getLogger("cma.action_plans")
# 区分「未传该字段」与「显式传 null」的哨兵(update_plan 的 objective_id 口径,2026-09-18)
_UNSET = object()
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
@@ -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()}")
# ──────────────────────────────────────────────
# 「已逾期」派生口径(单一来源,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:
return {
"id": p.id,
@@ -97,7 +127,12 @@ def list_plans(
"""获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)"""
query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc())
if status:
if status == OVERDUE_KEY:
# 「已逾期」是**派生条件**而非 status 值(口径与 /stats 同源,2026-09-21)
# 前端统计卡片直接把 key='overdue' 当 status 传来,故此处按派生口径过滤,
# 与 plan_stats 共用 _apply_overdue,保证「卡片条数 = 列表条数」。
query = _apply_overdue(query)
elif status:
query = query.filter(ActionPlan.status == status)
if kpi_id:
query = query.filter(ActionPlan.kpi_id == kpi_id)
@@ -124,7 +159,10 @@ def list_plans(
item["kpi_dimension"] = kpi.dimension if kpi else None
# 附带KR信息(行动挂KR 2026-08-27)
if p.kr_id is not None:
kr = db.query(KR).filter(KR.id == p.kr_id).first()
# 账套隔离(2026-09-18 batch3 读侧同族残余):kr_id 可能指向对端账套 KR(存量脏行,
# 写入侧 create_plan/update_plan 已校验归属但旧行仍存在)→ 富化查询必须带账套,
# 否则列表跨账套回显对端 KR 标题
kr = db.query(KR).filter(KR.id == p.kr_id, KR.entity_id == entity_id).first()
item["kr_title"] = kr.title if kr else None
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
else:
@@ -164,14 +202,25 @@ def create_plan(
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")
if objective_id and due_date:
obj = db.query(Objective).filter(Objective.id == objective_id).first()
if obj and obj.quarter:
if objective_id:
obj = db.query(Objective).filter(
Objective.id == objective_id, Objective.entity_id == entity_id).first()
if not obj:
raise HTTPException(404, "关联目标不存在")
# 校验截止日期与关联Objective的季度匹配
if due_date and obj.quarter:
_validate_due_date_against_quarter(due_date, obj.quarter)
plan = ActionPlan(
# 账套隔离(2026-09-18 batch3 派生单):必须显式写会话账套 ——
# 旧实现不写 entity_id,落模型默认值 1(e2 会话创建的计划库内 entity_id=1,
# 实测 OBS1)。list/stats/update/delete 靠 join KPIDefinition 判账套,
# 但任何按 ActionPlan.entity_id 过滤的读路径(如 OKR 分解视图)会失效。
entity_id=entity_id,
alert_id=data.get("alert_id"),
kpi_id=data["kpi_id"],
objective_id=objective_id,
@@ -241,9 +290,11 @@ def update_plan(
plan_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
"""更新行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
@@ -255,21 +306,35 @@ def update_plan(
plan.assignee = data["assignee"]
if "priority" in data:
plan.priority = data["priority"]
if "kr_id" in data:
# 行动挂KR: 支持置空(null)或改挂
if data["kr_id"] is None:
plan.kr_id = None
else:
kr_ent = db.query(KR).filter(KR.id == data["kr_id"]).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
plan.kr_id = kr_ent.id
if data.get("objective_id") is not None:
plan.objective_id = data["objective_id"]
elif plan.objective_id is None:
plan.objective_id = kr_ent.objective_id
# 目标/KR 归属口径(2026-09-18 batch3 派生单):
# ① 显式传 objective_id(含 null=显式置空)→ 以入参为准(优先于 kr_id 推导值)
# ② 未传 objective_id、且改挂合法 KR、且计划当前无目标 → 继承该 KR 的目标
# ③ 未传 objective_id 且计划已有目标 → 保持不动
# 两者都必须是当前账套的数据,否则 404;校验全部放在改动 plan 之前,
# 保证 404 请求对计划零改动(旧实现无归属校验,且末尾无条件覆盖推导值)
kr_ent = _UNSET
if "kr_id" in data and data["kr_id"] is not None:
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan 既有写法;2026-09-18 batch3)
kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
objective_change = _UNSET
if "objective_id" in data:
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:
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
if "status" in data:
@@ -285,9 +350,10 @@ def update_plan(
@router.delete("/{plan_id}")
def delete_plan(plan_id: int, db: Session = Depends(get_db)):
"""删除行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first()
def delete_plan(plan_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""删除行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)"""
plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(
ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
db.delete(plan)
@@ -296,9 +362,19 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db)):
@router.get("/stats")
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth)):
"""行动计划统计"""
query = db.query(ActionPlan)
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id)):
"""行动计划统计(账套隔离: join KPI 按企业过滤, 2026-09-18 batch3)
口径说明(为何改):本端点原为全局统计(仅 business 角色按 assignee 过滤)→ 非 e1 账套
用户看到的是含其它账套计划的合计,与同文件列表端点 list_plans(join KPIDefinition 按账套)
的可见集不一致。现按同文件既有先例(update_plan/delete_plan 的 join(KPIDefinition))过滤,
保证「统计数 = 列表可见行数」。数据侧存在 action_plans 行 entity_id 与 KPI 归属不一致的
存量行(batch2 报告 §六.2),统一以 KPI 归属为准与列表口径一致。
"""
query = (db.query(ActionPlan)
.join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id)
.filter(KPIDefinition.entity_id == entity_id))
if current_user.role == "business":
query = query.filter(
(ActionPlan.assignee == current_user.username) |
@@ -308,8 +384,7 @@ def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(requi
pending = query.filter(ActionPlan.status == "pending").count()
in_progress = query.filter(ActionPlan.status == "in_progress").count()
completed = query.filter(ActionPlan.status == "completed").count()
from datetime import datetime
overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.due_date < datetime.now()).count()
overdue = _apply_overdue(query).count()
return {
"total": total,
"pending": pending,
@@ -446,3 +521,157 @@ def get_coso_checklist(
if not data:
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
return data
@router.post("/coso-generate-actions")
def generate_coso_actions(
request: Request,
data: Optional[dict] = Body(default=None),
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""COSO内控自检 → 生成改善行动(把自检「未通过」条目一键转为行动计划)
口径说明:
- 账套(多租户铁律):写入 entity_id 只取 token 绑定账套(get_entity_id),
body 里若含 entity_id 字段一律忽略;
- 数据源:与 get_coso_checklist 同口径(resolve_session_slug 按会话账套取同一份
自检)——屏幕上看到哪份自检,就用哪份生成,不做跨账套内容搬运;
- 关联 KPI:action_plans.kpi_id 是 NOT NULL 且列表按 KPI 归属做账套 join,
故必须挂本账套 KPI;
- 幂等:判重键 (entity_id, title),已存在的计入 skipped 不再 INSERT;
- auto_verify_rule 显式不生成:COSO 条目没有可复算的 KPI 判据。
"""
data = data or {}
# ① 取自检数据(与只读端点同口径解析 slug)
slug = resolve_session_slug(request, "hanke")
coso = COSO_CHECKLIST_DATA.get(slug)
if not coso:
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
# ② 待生成条目:passed 为真的一律不生成
failed_items = []
for el in coso["elements"]:
for item in el.get("items") or []:
if not item.get("passed"):
failed_items.append((el, item))
# ③ 无未通过条目 → 返回空结果结构(不算错误)
if not failed_items:
return {
"entity_id": entity_id,
"entity_slug": slug,
"kpi_id": None,
"kpi_name": None,
"failed_total": 0,
"created": 0,
"skipped": 0,
"created_ids": [],
"source": "coso",
}
# ④ 关联 KPI(必须,kpi_id NOT NULL 且列表按 KPI 归属做账套 join)
if data.get("kpi_id"):
# 显式指定:必须属于当前账套(跨账套 KPI 一律 404,不得跨账套写)
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == data["kpi_id"],
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "关联KPI不存在")
else:
# 自动选取(判据显式、可解释):
# a. 名称/编码含关键词(按序:内控/合规/COSO/control/compliance),id 最小者优先
# b. 都没有 → 本账套 id 最小的 KPI(确定性兜底)
# c. 本账套一个 KPI 都没有 → 400(无法满足 kpi_id NOT NULL)
kpi = None
for kw in ("内控", "合规", "COSO", "control", "compliance"):
kpi = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id)
.filter(or_(
KPIDefinition.kpi_name.like(f"%{kw}%"),
KPIDefinition.kpi_code.like(f"%{kw}%"),
))
.order_by(KPIDefinition.id)
.first())
if kpi:
break
if not kpi:
kpi = (db.query(KPIDefinition)
.filter(KPIDefinition.entity_id == entity_id)
.order_by(KPIDefinition.id)
.first())
if not kpi:
raise HTTPException(400, "当前账套没有可关联的KPI,无法生成改善行动")
# ⑤ 截止日期:body 显式传则校验格式且不得早于今天;否则默认 +30 天
due_date = None
if data.get("due_date"):
try:
due_date = datetime.strptime(str(data["due_date"]), "%Y-%m-%d")
except (ValueError, TypeError):
raise HTTPException(400, f"due_date 格式非法: {data['due_date']}(应为 YYYY-MM-DD)")
if due_date.date() < datetime.now().date():
raise HTTPException(400, "due_date 不能早于今天")
else:
due_date = datetime.now() + timedelta(days=30)
# ⑥ 幂等判重键 (entity_id, title):带账套过滤 —— 对端账套同标题行不抑制本账套创建
existing_titles = {
p.title for p in db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).all()
}
created_plans = []
skipped_count = 0
for el, item in failed_items:
title = f"[COSO] {el['name']}·{item['text']}"[:200] # title 列宽 String(200)
if title in existing_titles:
skipped_count += 1
continue
existing_titles.add(title) # 本批次内自去重
score = el.get("score") or 0
if score < 40:
priority = "high"
elif score < 60:
priority = "medium"
else:
priority = "low"
plan = ActionPlan(
entity_id=entity_id,
kpi_id=kpi.id,
title=title,
# 来源标注便于回溯(自检哪条条目生成了这条行动)
description=(
f"来源: COSO内控自检 / {el['name']}({el['id']})/ 条目 {item['id']}: {item['text']}\n"
f"自检结论: {item.get('detail') or '未通过'}"
),
status="pending",
progress=0,
priority=priority,
due_date=due_date,
created_by=current_user.name or current_user.username,
# 不传 auto_verify_rule:该列是 MySQL json 列,显式传 None 会被落成 JSON null
# (JSON_TYPE()='NULL'、IS NOT NULL 为真)而不是 SQL NULL,会让 bot_iron_law 的
# auto_verify_rule.isnot(None) 把「没有规则」的行算成「有规则」。不赋值则 INSERT
# 省略该列 → 保持 SQL NULL。COSO 条目没有可复算判据,引擎对空规则是跳过(安全)。
)
db.add(plan)
created_plans.append(plan)
# ⑦ 单次 add 循环 + 一次 commit:任一异常向上抛,不留半批
db.commit()
created_ids = [p.id for p in created_plans]
return {
"entity_id": entity_id,
"entity_slug": slug,
"kpi_id": kpi.id,
"kpi_name": kpi.kpi_name,
"failed_total": len(failed_items), # 自检未通过条目总数
"created": len(created_ids), # 本次新建条数
"skipped": skipped_count, # 已存在(幂等命中)条数
"created_ids": created_ids, # 新建行 id 列表
"source": "coso",
}
+38 -9
View File
@@ -98,9 +98,13 @@ def list_suggestions(
@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:
raise HTTPException(404, "建议不存在")
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
if not 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:
raise HTTPException(404, f"KPI {kpi_id} 不存在")
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
if not 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:
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
if not 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:
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
plan = ActionPlan(
entity_id=sug.entity_id, # 2026-09-18 派生单: 写建议归属账套(=会话账套,端点已校验)
kpi_id=kpi_id,
title=title,
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
@@ -290,14 +304,21 @@ def preview_suggestion(suggestion_id: int, db: Session = Depends(get_db),
- budget_adjust:{kpi_name, period, current_budget, new_budget}
- action_plan: {kpi_name, plan_title, assignee, priority, due_date}
"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 预览须归属会话账套(原依赖未用于过滤)
).first()
if not sug:
raise HTTPException(404, "建议不存在")
sd = sug.suggestion_data or {}
kpi = None
kpi_id = sd.get("kpi_id") or sug.target_id
if kpi_id:
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":
return {"data": {
@@ -340,6 +361,7 @@ def apply_suggestion(
suggestion_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
@@ -349,7 +371,10 @@ def apply_suggestion(
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).first()
if not sug:
raise HTTPException(404, "建议不存在")
if sug.status == "applied":
@@ -385,10 +410,14 @@ def apply_suggestion(
def dismiss_suggestion(
suggestion_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""忽略建议"""
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
sug = db.query(AISuggestion).filter(
AISuggestion.id == suggestion_id,
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
).first()
if not sug:
raise HTTPException(404, "建议不存在")
sug.status = "dismissed"
+52 -5
View File
@@ -14,6 +14,9 @@ from app.database import get_db, Base
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, OperationLog
# 裁定 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")
@@ -257,13 +260,23 @@ def generate_default_rules(db: Session = Depends(get_db), entity_id: int = Depen
@router.post("/check-all")
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(
AlertRule.enabled == 1, AlertRule.entity_id == entity_id).all()
kpi_cache = {}
value_cache = {}
alerts_generated = 0
# R5(裁定 2026-09-19):期间非规整月(含伪期间 2026-H1/H2)→ 该行不入库,跳过并记数
skipped_non_month_period = 0
skipped_non_month_period_kpis = []
for rule in rules:
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)
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(
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
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")
@@ -642,7 +668,14 @@ def _check_forecast_alerts(db: Session, entity_id: int = 1) -> int:
@router.post("/check-forecast")
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)
return {"message": f"预测值预警检查完成: 生成{generated}条", "generated": generated}
@@ -690,7 +723,14 @@ def run_forecast_deviation_check(
):
"""预测偏差检查(升级2b, 2026-08-25)— alert_rules type=forecast_deviation
对每条偏差规则: 取最新预测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
rules = db.query(AlertRule).filter(
AlertRule.entity_id == entity_id,
@@ -861,7 +901,14 @@ def run_customer_concentration_check(
entity_id: int = Depends(get_entity_id),
):
"""客户集中度检查(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(
AlertRule.entity_id == entity_id,
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.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog, ActionPlan, KPIDefinition
from app.models import KPIAlert, OperationLog, ActionPlan
import logging
logger = logging.getLogger("cma.alerts")
@@ -16,8 +16,10 @@ router = APIRouter(prefix="/api/cma/alerts", tags=["预警"],
@router.get("")
def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
# 账套隔离: join kpi_definitions 按企业过滤 (2026-08-23 P1b)
query = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(KPIDefinition.entity_id == entity_id)
# 账套隔离(2026-09-19 dama-20260919 C1):按**预警行自身** entity_id 过滤
# 修复前按 KPI 归属企业过滤(2026-08-23 P1b)→ KPI 属他账套而预警行属本账套的行反向泄漏
# (实测 entity 2 口径 225 → 223,泄漏 id=589/590)
query = db.query(KPIAlert).filter(KPIAlert.entity_id == entity_id)
if status:
query = query.filter(KPIAlert.status == status)
total = query.count()
@@ -26,8 +28,10 @@ def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = De
@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)):
alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter(
KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_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:
raise HTTPException(404, "预警不存在")
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")
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""标记预警为处理中"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""标记预警为处理中(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.status == "resolved":
@@ -63,9 +70,12 @@ def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
@router.post("/{alert_id}/escalate")
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""升级预警级别"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""升级预警级别(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
assignee = data.get("assignee")
@@ -82,11 +92,18 @@ def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
@router.get("/check-timeout")
def check_alert_timeout(db: Session = Depends(get_db)):
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色"""
def check_alert_timeout(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色
账套隔离(2026-09-19 写路径跨账套修复):按**预警行自身** KPIAlert.entity_id 过滤;
修复前 join kpi_definitions 按 KPI 归属企业过滤 → KPI 属他账套而预警行属本账套的行
会被本账套调用**批量改写**(升级 red + 置 processing),比只读泄漏更严重
(2026-08 之前为全账套批处理;2026-09-18 加的 join 口径见 dama-20260919 派生单)。
"""
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24)
timeout_alerts = db.query(KPIAlert).filter(
KPIAlert.entity_id == entity_id,
KPIAlert.status == "pending",
KPIAlert.created_at < cutoff,
KPIAlert.alert_level != "red",
@@ -102,14 +119,18 @@ def check_alert_timeout(db: Session = Depends(get_db)):
@router.post("/{alert_id}/create-action-plan")
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""从预警创建改善行动计划"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""从预警创建改善行动计划(账套隔离: 按预警行自身 entity_id, 2026-09-19)"""
# 账套隔离(2026-09-19 dama-20260919 C2):按预警行自身 entity_id 定位
# 修复前按 KPI 归属企业过滤 → KPI 属他账套的行可被本账套端点操作(跨账套泄漏/越权)
alert = db.query(KPIAlert).filter(
KPIAlert.id == alert_id, KPIAlert.entity_id == entity_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.action_plan_linked_id:
raise HTTPException(400, "已关联改善计划")
plan = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
alert_id=alert.id,
kpi_id=alert.kpi_id,
title=f"改善: {alert.alert_message}",
+62 -14
View File
@@ -10,6 +10,7 @@ import json
from app.database import get_db
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
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)],
)
# 三种对齐模式定义
# ── B3(a) 对齐模式拆账套(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922)──
# 背景:`role_permissions.key` 为 unique 单行键 → `alignment_config` 全库一行,
# 任一账套的 ceo/it 改对齐模式 → **全账套生效**(跨账套配置污染)。
# 治本(方案 A,零 DDL):键名带账套后缀 → `alignment_config.<entity_id>`。
# · 读:只读本账套键;未配置 → configured=false(/tree 提示先初始化),
# **不回退旧全局键**(回退即恢复跨账套生效)。
# · 写:只写本账套键(entity 来自会话 token)。
# · 旧值搬运:脚本 scripts/migrate_cash_balance_key_entity_20260922.py --check/--apply
# (同一 500 口径:原全局值落到「当前生产账套」名下;--down 删新键并写回旧键)。
ALIGNMENT_KEY_PREFIX = "alignment_config"
def alignment_key(entity_id: int) -> str:
"""账套维度的对齐模式键名:alignment_config.<entity_id>"""
return f"{ALIGNMENT_KEY_PREFIX}.{int(entity_id)}"
def _get_alignment_perm(db: Session, entity_id: int):
"""读本账套的对齐模式配置行(不存在 → None)"""
return db.query(RolePermission).filter(
RolePermission.key == alignment_key(entity_id)
).first()
ALIGNMENT_MODES = [
{
"key": "vertical_decomposition",
@@ -49,18 +73,25 @@ def list_modes():
@router.get("/config")
def get_alignment_config(db: Session = Depends(get_db)):
"""获取当前系统对齐模式配置(公开接口,无需认证)"""
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
def get_alignment_config(entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db)):
"""获取**当前账套**的对齐模式配置(B3(a) 拆账套,2026-09-22)
修复前:读全局唯一键 `alignment_config` → 任一账套改模式全平台生效。
修复后:只读 `alignment_config.<entity_id>`;未配置 → configured=false。
"""
perm = _get_alignment_perm(db, entity_id)
if not perm:
return {
"mode": None,
"configured": False,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES,
}
return {
"mode": perm.value,
"configured": True,
"entity_id": entity_id,
"modes": ALIGNMENT_MODES,
}
@@ -68,45 +99,62 @@ def get_alignment_config(db: Session = Depends(get_db)):
@router.post("/config")
def set_alignment_config(
data: dict,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
user = Depends(require_role("ceo", "it")),
):
"""初始化/修改系统对齐模式(CEO/IT权限)"""
"""初始化/修改**当前账套**的对齐模式(CEO/IT权限)
B3(a)(2026-09-22):只写 `alignment_config.<entity_id>`,不覆盖他账套;
entity 来自会话 token,显式传他账套 entity_id → get_entity_id 403。
"""
mode_key = data.get("mode")
if mode_key not in [m["key"] for m in ALIGNMENT_MODES]:
raise HTTPException(400, f"无效的对齐模式: {mode_key}")
perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
perm = _get_alignment_perm(db, entity_id)
if perm:
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat()}
perm.value = {"mode": mode_key, "set_at": datetime.now().isoformat(),
"entity_id": entity_id}
else:
perm = RolePermission(key="alignment_config", value={"mode": mode_key, "set_at": datetime.now().isoformat()})
perm = RolePermission(key=alignment_key(entity_id),
value={"mode": mode_key, "set_at": datetime.now().isoformat(),
"entity_id": entity_id})
db.add(perm)
db.commit()
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key}
return {"message": f"对齐模式已设置为: {mode_key}", "mode": mode_key, "entity_id": entity_id}
@router.get("/tree")
def get_alignment_tree(
kpi_id: Optional[int] = Query(None),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
user = Depends(require_auth),
):
"""获取KPI对齐关系树
"""获取KPI对齐关系树(按当前账套隔离)
根据当前系统配置的对齐模式,返回KPI的父子层级关系。
如果指定kpi_id,返回该KPI及其下级树;
如果不指定,返回整个对齐树。
账套隔离(2026-09-18 project-20260918-alignment-entity-leak):
只取 entity_id 归属本账套的 KPI(entity_id 来自会话 token 绑定,写法对齐
growth-quality / predict.accuracy)。三种模式共用本列表 → 三模式同时隔离。
跨实体显式传参(?entity_id=<他人账套>)由 get_entity_id 返回 403。
"""
# 获取当前模式
config_perm = db.query(RolePermission).filter(RolePermission.key == "alignment_config").first()
# 获取当前账套的对齐模式(B3(a) 拆账套 2026-09-22:只读 alignment_config.<entity_id>)
config_perm = _get_alignment_perm(db, entity_id)
mode = config_perm.value.get("mode") if config_perm else None
if not mode:
raise HTTPException(400, "系统未配置对齐模式,请先在系统设置中初始化")
# 获取所有KPI
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").order_by(KPIDefinition.kpi_code).all()
# 获取本账套所有KPI(账套隔离:仅当前 entity_id,不再返回全实体 KPI)
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}
# 构建父子关系
+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 import KPICausality
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
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不存在")
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)
existing = db.query(KPIValue).filter(
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 检查函数,非全量)
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
rules = db.query(AlertRule).filter(
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:
continue
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(
KPIAlert.kpi_id == kpi_id, KPIAlert.kpi_value_id == kv.id,
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:
logger.warning(f"聚合检查失败 rule={rule.id}: {e}")
db.commit()
return {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
"period": period, "alerts": alerts, "status": "ok"}
resp = {"kpi_id": kpi_id, "kpi_code": kpi.kpi_code, "value": float(actual_value),
"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")
+61 -4
View File
@@ -21,9 +21,11 @@ from app.database import get_db
from app.models import (
MpmResult, BotBridgeConfig,
KPIDefinition, KPIValue, KPIAlert,
ActionPlan, Entity,
ActionPlan, Entity, OperationLog,
)
from app.risk_levels import risk_level
# 裁定 R1/R5(2026-09-19 财务口径):demo 账套任何写通道不产生业务预警;期间非规整月不入库(单一事实源)
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
logger = logging.getLogger("cma.bot_bridge_v2")
@@ -307,6 +309,12 @@ def receive_mpm_result(
if not entity:
raise HTTPException(404, f"实体entity_id={entity_id}不存在")
# R1/R5(裁定 2026-09-19):「R1 与入口无关」——Bot 通道按显式 entity 写入同样不得产出业务预警;
# 期间非规整月(含 2026-H1/H2 伪期间)的 kpi_alerts 行一律不入库(跳过 + 记日志)。
alerts_allowed = entity_alert_guard(db, entity_id, source="bot_bridge_v2.receive_mpm_result")
period_attributable = guard_alert_period(period, source="bot_bridge_v2.receive_mpm_result",
detail="entity_id=%s" % entity_id)
# ── 1. 写入MPM结果 ──
record = MpmResult(
entity_id=entity_id,
@@ -356,6 +364,7 @@ def receive_mpm_result(
# ── 3. 触发预警 ──
alerts = []
alerts_skipped_no_kpi = [] # B3(b): KPI 定义缺失而跳过的预警(留痕用)
for field, config in ALERT_THRESHOLDS.items():
field_value = results.get(field)
if field_value is None:
@@ -367,7 +376,20 @@ def receive_mpm_result(
if (operator == ">" and field_value > threshold) or \
(operator == ">=" and field_value >= threshold):
# R1/R5 生成侧守卫(裁定 2026-09-19):demo 账套 / 不可归期期间 → 该行不入库
if not alerts_allowed:
logger.info("R1 账套守卫:跳过不入库(demo 演示账套)entity_id=%s", entity_id)
continue
if not period_attributable:
logger.info("R5 期间守卫:跳过不入库(不可归期,非规整月 YYYY-MM)period=%r", period)
continue
# 查找关联KPI
# B3(b)(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922):
# 修复前 `kpi_id=kpi_id or 1` —— KPI 定义缺失时把预警错挂到「全库第 1 个 KPI」
# (该 KPI 可能属他账套:预警 entity_id 正确、KPI 维度却跨了账套)。
# 现改为**显式校验**:拿不到本账套的 KPI 定义 → 不建预警(跳过)+ 落 OperationLog 留痕。
# 为什么不是 `kpi_id=None`:`kpi_alerts.kpi_id` 为 NOT NULL(models/__init__.py:145),
# batch5 §B3(b) 方案 A(保留可见性)在本 schema 下不可行;方案 B(跳过 + 审计)落地。
kpi_id = None
if kpi_code:
kpi_def = db.query(KPIDefinition).filter(
@@ -377,11 +399,38 @@ def receive_mpm_result(
if kpi_def:
kpi_id = kpi_def.id
# 创建预警
alert_msg = f"⚠️ {kpi_name}异常: {field_value:.1f}% (阈值: {threshold}%) [entity={entity.short_name or entity.name}, period={period}]"
if kpi_id is None:
# 宁缺勿错:不入库,但必须留痕(防「预警静默丢失」)
alerts_skipped_no_kpi.append(f"{kpi_name}(kpi_code={kpi_code!r})")
db.add(OperationLog(
user_id=None,
action="skip_alert",
target_type="alert",
target_id=None,
detail={
"reason": "kpi_definition_not_found_for_entity",
"kpi_code": kpi_code,
"kpi_name": kpi_name,
"entity_id": entity_id,
"period": period,
"field": field,
"field_value": field_value,
"threshold": threshold,
"source": "bot_bridge_v2.mpm-result",
},
))
logger.warning(
"B3(b) 跳过建预警(KPI定义缺失,不再回落 kpi_id=1): kpi_code=%r entity_id=%s period=%s",
kpi_code, entity_id, period,
)
continue
# 创建预警
alert = KPIAlert(
entity_id=entity_id, # 2026-09-13 F1: 显式写归属
kpi_id=kpi_id or 1, # fallback to first KPI if not found
kpi_id=kpi_id, # B3(b): 显式校验后的本账套 KPI(禁止 or 1 兜底)
alert_level="red",
alert_message=alert_msg,
alert_type="actual",
@@ -394,16 +443,24 @@ def receive_mpm_result(
db.commit()
db.refresh(record)
return {
resp = {
"success": True,
"kpi_updated": len(kpi_updates),
"alerts_triggered": len(alerts),
# B3(b): 因本账套无对应 KPI 定义而跳过的预警(已落 OperationLog,非静默丢失)
"alerts_skipped_missing_kpi": len(alerts_skipped_no_kpi),
"mpm_record_id": record.id,
"details": {
"kpi_codes": kpi_updates,
"alerts": alerts,
"alerts_skipped_missing_kpi": alerts_skipped_no_kpi,
},
"skipped_non_month_period": (not period_attributable),
}
if not alerts_allowed:
# R1(裁定 2026-09-19):demo 账套在 Bot 通道同样不产生业务预警
resp.update({"skipped": True, "reason": "R1_demo_entity_excluded"})
return resp
@router.post("/kpi-result")
+3 -1
View File
@@ -62,11 +62,13 @@ def list_bot_kpis(
source: str = Query("finance-bot", description="Bot标识"),
period: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取某Bot的所有KPI(含评分)"""
"""获取某Bot的所有KPI(含评分)(账套隔离 2026-09-21:仅本账套,对齐 update_bot_kpi_value)"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.bot_source == source,
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).order_by(KPIDefinition.kpi_code).all()
if not kpis:
+55 -15
View File
@@ -77,8 +77,9 @@ def create_budget_plan(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""创建或更新单条预算计划"""
"""创建或更新单条预算计划(账套隔离 2026-09-21:KPI 归属校验 + 写会话账套)"""
kpi_id = data.get("kpi_id")
period = data.get("period")
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]):
raise HTTPException(400, "缺少必要参数: kpi_id, period, budget_value")
# 检查KPI是否存在
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
# 检查KPI是否存在(账套隔离 2026-09-21:仅本账套 KPI,防把预算挂到对端 KPI)
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
@@ -112,6 +116,7 @@ def create_budget_plan(
return {"message": "预算已更新", "id": existing.id}
else:
plan = BudgetPlan(
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1
kpi_id=kpi_id,
map_id=map_id,
period=period,
@@ -145,9 +150,12 @@ def update_budget_plan(
plan_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
"""更新预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(BudgetPlan).filter(
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
@@ -168,9 +176,12 @@ def update_budget_plan(
def delete_budget_plan(
plan_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""删除预算计划"""
plan = db.query(BudgetPlan).filter(BudgetPlan.id == plan_id).first()
"""删除预算计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(BudgetPlan).filter(
BudgetPlan.id == plan_id, BudgetPlan.entity_id == entity_id,
).first()
if not plan:
raise HTTPException(404, "预算计划不存在")
db.delete(plan)
@@ -306,7 +317,9 @@ def auto_decompose_budget(
if annual_budget is None:
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:
raise HTTPException(404, "KPI不存在")
@@ -333,6 +346,7 @@ def auto_decompose_budget(
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
@@ -342,6 +356,7 @@ def auto_decompose_budget(
existing.budget_value = monthly_value
else:
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id, period=period,
budget_value=monthly_value, budget_year=year,
budget_month=m, version=version, status="active",
@@ -357,6 +372,7 @@ def auto_decompose_budget(
period = f"{year}-{m:02d}"
existing = db.query(BudgetPlan).filter(
BudgetPlan.kpi_id == kpi_id,
BudgetPlan.entity_id == entity_id,
BudgetPlan.period == period,
BudgetPlan.version == version,
BudgetPlan.status == "active",
@@ -365,6 +381,7 @@ def auto_decompose_budget(
existing.budget_value = monthly
else:
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id, period=period,
budget_value=monthly, budget_year=year,
budget_month=m, version=version, status="active",
@@ -394,6 +411,7 @@ def get_deviation_report(
dimension: Optional[str] = Query(None),
alert_level: Optional[str] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取差异分析报告(汇总多个KPI的实际vs预算差异)"""
if period is None:
@@ -404,7 +422,10 @@ def get_deviation_report(
else:
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:
query = query.filter(KPIDefinition.id == kpi_id)
if dimension:
@@ -534,12 +555,14 @@ def set_budget_config(
def budget_roll_forward(
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""
滚动预算自动延展:
- 删除最早一个月的预测数据
- 新增未来一个月的预测数据(取最近三个月均值)
- 返回延展结果
账套隔离:只处理会话账套(join kpi_definitions 按企业过滤),2026-09-18
"""
from app.models import SystemConfig
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first()
@@ -557,8 +580,11 @@ def budget_roll_forward(
now = datetime.now()
current_year, current_month = now.year, now.month
# 获取所有active的预算记录
plans = db.query(BudgetPlan).filter(BudgetPlan.status == "active").all()
# 获取本账套 active 的预算记录(账套隔离: join KPI 按企业过滤, 2026-09-18 — 原为全账套)
plans = (db.query(BudgetPlan)
.join(KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id)
.filter(BudgetPlan.status == "active", KPIDefinition.entity_id == entity_id)
.all())
# 按KPI分组
from collections import defaultdict
@@ -598,6 +624,7 @@ def budget_roll_forward(
if not existing:
bp = BudgetPlan(
entity_id=entity_id,
kpi_id=kpi_id,
period=new_period,
budget_value=avg_value,
@@ -634,6 +661,7 @@ def get_budget_comparison(
kpi_id: Optional[int] = Query(None),
year: Optional[int] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""
获取实际值vs预测值对比数据
@@ -679,7 +707,11 @@ def get_budget_comparison(
)
if 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()
# 按KPI+期间索引
@@ -755,6 +787,7 @@ def get_kpi_comparison(
kpi_id: int,
year: Optional[int] = Query(None),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""
获取单个KPI的实际vs预测对比数据(用于图表展示)
@@ -765,7 +798,10 @@ def get_kpi_comparison(
now = datetime.now()
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:
raise HTTPException(404, "KPI不存在")
@@ -1095,10 +1131,14 @@ def update_deviation_alert(
alert_id: int,
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""更新偏差预警(如标记已解决)"""
"""更新偏差预警(如标记已解决)(账套隔离 2026-09-21:跨账套 id → 404)"""
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:
raise HTTPException(404, "预警记录不存在")
if "status" in data:
+7 -2
View File
@@ -5,6 +5,7 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, BudgetPlan, KPIValue, OperationLog
import json
@@ -75,12 +76,16 @@ def _calc_budget(kpi: KPIDefinition) -> dict:
def get_kpi_budget_candidates(
year: int = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取可用于生成预算的KPI列表,按类型分类"""
"""获取本账套可用于生成预算的KPI列表,按类型分类(账套隔离 2026-09-21)"""
if not 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的最新实际值
latest_values = {}
+44 -18
View File
@@ -116,15 +116,21 @@ def api_get_balance(
@router.post("/balance")
def api_set_balance(
data: dict,
entity_id: int = Depends(get_entity_id),
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
value = float(data.get("current_cash", 0))
if value < 0:
raise HTTPException(400, "现金余额不能为负")
set_current_cash_balance(db, value)
return {"message": "当前现金余额已更新", "current_cash": value}
set_current_cash_balance(db, value, entity_id)
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}")
def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
"""更新收付款计划"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""更新收付款计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
if not plan:
raise HTTPException(404, "计划不存在")
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}")
def api_delete_plan(plan_id: int, db: Session = Depends(get_db)):
"""删除收付款计划"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
def api_delete_plan(plan_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""删除收付款计划(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
if not plan:
raise HTTPException(404, "计划不存在")
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")
def api_complete_plan(plan_id: int, db: Session = Depends(get_db)):
"""标记计划为已完成(收款到账/付款完成)"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
def api_complete_plan(plan_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""标记计划为已完成(收款到账/付款完成)(账套隔离 2026-09-21:跨账套 id → 404)"""
plan = db.query(CashPlan).filter(
CashPlan.id == plan_id, CashPlan.entity_id == entity_id,
).first()
if not plan:
raise HTTPException(404, "计划不存在")
plan.status = "completed"
@@ -616,9 +631,13 @@ def api_receivables(
@router.post("/receivables/{plan_id}/payment")
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db)):
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/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:
raise HTTPException(404, "应收计划不存在")
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:
"""货币资金类科目(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.utils.cash_forecast_engine import set_current_cash_balance
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"))
), 2)
cash_wan = round(balance_yuan / 10000, 4)
set_current_cash_balance(db, cash_wan)
logger.info(f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 → 现金余额联动")
set_current_cash_balance(db, cash_wan, entity_id)
logger.info(
f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 "
f"→ 现金余额联动(账套 {entity_id})"
)
return cash_wan
+33 -12
View File
@@ -176,21 +176,34 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db),
# ============================================================
# ABC 作业成本
# 注:ABC 作业中心/分配为单实体演示数据(ab_activities/ab_allocations 全为 entity 1,
# 历史演示用途),OpenCode 多租户审查 2026-09-07 判定暂不强制按账套隔离;若启用多企业 ABC 需补 entity 过滤。
# 账套隔离(2026-09-18,0-P1-project-20260918-entity-isolation-batch2):
# GET /abc/activities 原为全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心;
# 现按会话账套过滤(AbcActivity.entity_id)。
# OpenCode 多租户审查 2026-09-07 的「单实体演示数据暂不强制隔离」豁免就此撤销。
# 账套隔离(2026-09-18,project-20260918-entity-isolation-batch3):batch2 同族残留收口
# - POST /abc/activities 原签名无账套依赖、不写归属 → 落模型默认 entity_id=1;现按会话账套写入
# - POST /abc/allocate 原无账套依赖、activity_id 不校验归属、分配记录落默认 1;
# 现校验 activity_id 归属本账套(跨账套 → 404,文案对齐同文件先例)
# 并把 entity_id 透传 cost_engine.allocate_cost 显式写入 AbcAllocation
# - GET /abc/allocations 原查询只按 period/product_code → 现加 entity_id 过滤(读型同族)
# ============================================================
@router.get("/abc/activities")
def list_abc_activities(db: Session = Depends(get_db)):
"""查询ABC作业中心列表"""
items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all()
def list_abc_activities(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""查询ABC作业中心列表(账套隔离:只返回本账套)"""
items = (db.query(AbcActivity)
.filter(AbcActivity.entity_id == entity_id)
.order_by(AbcActivity.activity_code)
.all())
return {"data": items}
@router.post("/abc/activities")
def create_abc_activity(data: dict, db: Session = Depends(get_db)):
"""创建ABC作业中心"""
def create_abc_activity(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""创建ABC作业中心(账套隔离:按会话账套写入归属)"""
act = AbcActivity(
entity_id=entity_id,
activity_code=data["activity_code"],
activity_name=data["activity_name"],
activity_desc=data.get("activity_desc"),
@@ -206,14 +219,21 @@ def create_abc_activity(data: dict, db: Session = Depends(get_db)):
@router.post("/abc/allocate")
def do_allocate(data: dict, db: Session = Depends(get_db)):
"""执行ABC成本分配"""
def do_allocate(data: dict, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""执行ABC成本分配(账套隔离:activity_id 须属本账套,跨账套 → 404)"""
act = (db.query(AbcActivity)
.filter(AbcActivity.id == data["activity_id"], AbcActivity.entity_id == entity_id)
.first())
if not act:
raise HTTPException(404, "作业中心不存在")
result = allocate_cost(
activity_id=data["activity_id"],
period=data.get("period", datetime.now().strftime("%Y-%m")),
product_code=data["product_code"],
product_name=data.get("product_name", ""),
driver_consumed=data["driver_consumed"],
entity_id=entity_id,
)
return result
@@ -221,9 +241,10 @@ def do_allocate(data: dict, db: Session = Depends(get_db)):
@router.get("/abc/allocations")
def list_allocations(period: Optional[str] = Query(None),
product_code: Optional[str] = Query(None),
db: Session = Depends(get_db)):
"""查询ABC分配记录"""
query = db.query(AbcAllocation)
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查询ABC分配记录(账套隔离:只返回本账套)"""
query = db.query(AbcAllocation).filter(AbcAllocation.entity_id == entity_id)
if period:
query = query.filter(AbcAllocation.period == period)
if product_code:
+3
View File
@@ -5,6 +5,7 @@ from sqlalchemy import func, desc
from typing import Optional
from datetime import datetime, timedelta
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition, KPIValue, KPIAlert, User
import logging
@@ -45,6 +46,7 @@ def list_customer_kpis(
end_date: str = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""获取客户维度KPI列表(含最新值、预警、趋势)"""
start, end = parse_period(period, start_date, end_date)
@@ -54,6 +56,7 @@ def list_customer_kpis(
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.dimension == "customer",
KPIDefinition.entity_id == entity_id, # 账套隔离 2026-09-21
).order_by(KPIDefinition.kpi_code).all()
result = []
+56 -5
View File
@@ -1,7 +1,7 @@
"""驾驶舱 API v2 — 支持时间区间"""
from fastapi import APIRouter, Depends, Query, Request, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
from sqlalchemy import case, func, or_
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
@@ -360,6 +360,7 @@ def get_my_kpis(
current_user: User = Depends(require_auth),
period: str = Query("month"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取当前用户负责的KPI
- business角色:只看自己负责的KPI
@@ -370,7 +371,10 @@ def get_my_kpis(
name = current_user.name
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 = []
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")
def my_dashboard(
@@ -613,9 +666,7 @@ def my_dashboard(
kpi_list = []
for k in all_kpis:
latest_v = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id
).order_by(KPIValue.calculated_at.desc()).first()
latest_v = latest_kpi_value(db, k.id)
actual = latest_v.actual_value if latest_v else None
target = kpi_target_by_frequency(k)
+53 -18
View File
@@ -10,7 +10,7 @@ import json
import logging
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.models import KPIDefinition, KPIValue, KpiDataQualityLog, OperationLog
from app.api.kpis import kpi_to_dict
@@ -36,9 +36,12 @@ def _log_to_dict(log):
# ============================================================
@router.get("/check")
def run_quality_check(db: Session = Depends(get_db)):
"""扫描全部KPI,生成数据质量报告"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
def run_quality_check(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
"""扫描本账套KPI,生成数据质量报告(账套隔离 2026-09-21)"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
issues = []
current_period = datetime.now().strftime("%Y-%m")
@@ -174,9 +177,19 @@ def list_quality_logs(
check_type: Optional[str] = None,
status: Optional[str] = None,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""获取数据质量日志"""
query = db.query(KpiDataQualityLog)
"""获取数据质量日志(账套隔离 2026-09-21)
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:
query = query.filter(KpiDataQualityLog.kpi_id == kpi_id)
if severity:
@@ -198,10 +211,22 @@ def list_quality_logs(
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}")
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""更新质量日志(解决/忽略)"""
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
def update_quality_log(log_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""更新质量日志(解决/忽略)(账套隔离 2026-09-21:跨账套 → 404)"""
log = _scoped_quality_log(db, log_id, entity_id)
if not log:
raise HTTPException(404, "日志不存在")
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}")
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES):
log = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.id == log_id).first()
if log:
db.delete(log)
db.commit()
def delete_quality_log(log_id: int, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""删除质量日志(账套隔离 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.commit()
return {"message": "已删除"}
# ============================================================
# 数据质量看板统计
# ============================================================
@router.get("/stats")
def quality_stats(db: Session = Depends(get_db)):
"""数据质量统计"""
total_kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").count()
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",
KPIDefinition.entity_id == entity_id,
).count()
total_logs = db.query(KpiDataQualityLog).count()
open_logs = db.query(KpiDataQualityLog).filter(KpiDataQualityLog.status == "open").count()
@@ -256,7 +288,10 @@ def quality_stats(db: Session = Depends(get_db)):
# ── 数据审计看板统计 ──
# 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)
complete_kpis = 0
missing_metadata_count = 0
+58 -22
View File
@@ -6,10 +6,12 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.deps import get_entity_id
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
# 裁定 R5-补1(2026-09-19 财务口径):差异反打路径同源接入 R1/R5 守卫
from app.utils.alert_entity_scope import entity_alert_guard, guard_alert_period
import json
import logging
from datetime import datetime
logger = logging.getLogger("cma.deviation_push")
@@ -22,6 +24,7 @@ router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
def push_deviation_to_map(
data: dict,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
current_user=Depends(require_auth),
):
"""从差异分析回写实际值到战略地图节点
@@ -47,9 +50,25 @@ def push_deviation_to_map(
if new_value is None:
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()
if not m:
if not m or (m.entity_id or 1) != entity_id:
raise HTTPException(404, "战略地图不存在")
# 2. 解析 node_id 格式: "finance-0"
@@ -89,13 +108,12 @@ def push_deviation_to_map(
kpi_code = kpi_codes[0]
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} 不存在")
# 3. 更新实际值到 KPIValue 表
if not period:
period = datetime.now().strftime("%Y-%m")
#(R5-补1:period 已在上方强制校验,原「缺 period 用当月兜底」分支已删除——
# 该兜底会伪造期间,正是 R5「不可归期」的成因之一)
existing_value = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
@@ -104,20 +122,22 @@ def push_deviation_to_map(
if existing_value:
existing_value.actual_value = new_value
existing_value.source_type = "manual"
kv_row = existing_value
else:
kv = KPIValue(
kv_row = KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=new_value,
source_type="manual",
)
db.add(kv)
db.add(kv_row)
db.flush()
# 4. 检查是否触发预警
alert_created = False
alert_id = None
alert_skipped_reason = None
if kpi.target_value and kpi.target_value > 0:
ratio = new_value / kpi.target_value
if ratio < 0.7:
@@ -130,28 +150,41 @@ def push_deviation_to_map(
alert_level = None
if alert_level:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
alert_level=alert_level,
alert_message=alert_msg,
status="pending",
)
db.add(alert)
db.flush()
alert_created = True
alert_id = alert.id
# R1/R5 双守卫(裁定 R5-补1):demo 账套 / 非规整月期间 → 该 kpi_alerts 行不入库,
# 返回体登记跳过原因(KPI 值照写,与 bot_bridge 同口径:裁定只约束预警行)
if not entity_alert_guard(db, entity_id, source="deviation_push.push_deviation_to_map",
detail="kpi=%s" % kpi_code):
alert_skipped_reason = "R1_demo_entity_excluded"
elif not guard_alert_period(period, source="deviation_push.push_deviation_to_map",
detail="kpi=%s" % kpi_code):
alert_skipped_reason = "R5_non_attributable_period"
if alert_skipped_reason is None:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
kpi_value_id=kv_row.id, # R5-补1: 期间关联(同 kpi 同期间行 → 可派生)
alert_level=alert_level,
alert_message=alert_msg,
status="pending",
)
db.add(alert)
db.flush()
alert_created = True
alert_id = alert.id
# 5. 生成战略回顾会议题
review_topic_created = False
if create_review_topic:
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
existing_topic = db.query(ActionPlan).filter(
ActionPlan.entity_id == entity_id, # 2026-09-18 派生单: 去重按账套隔离
ActionPlan.title == topic_title,
ActionPlan.status.in_(["pending", "in_progress"]),
).first()
if not existing_topic:
topic = ActionPlan(
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
kpi_id=kpi.id,
title=topic_title,
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
@@ -176,6 +209,7 @@ def push_deviation_to_map(
"new_value": new_value,
"period": period,
"alert_created": alert_created,
"alert_skipped_reason": alert_skipped_reason,
"review_topic_created": review_topic_created,
}, ensure_ascii=False),
)
@@ -190,15 +224,17 @@ def push_deviation_to_map(
"new_value": new_value,
"alert_created": alert_created,
"alert_id": alert_id,
"alert_skipped_reason": alert_skipped_reason,
"review_topic_created": review_topic_created,
}
@router.get("/map-nodes/{map_id}")
def get_map_nodes(map_id: int, db: Session = Depends(get_db)):
def get_map_nodes(map_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""获取战略地图的全部节点(供反打选择使用)"""
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
if not m:
if not m or (m.entity_id or 1) != entity_id: # 2026-09-18 派生单: NULL 归属按 e1 兼容(同 push 护栏)
raise HTTPException(404, "战略地图不存在")
dims = m.dimensions
+8 -2
View File
@@ -7,6 +7,7 @@ from typing import Optional, List
from datetime import datetime
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import SystemConfig, OperationLog
from app.models.driver_budget import DriverFactorTemplate, DriverFactorBudget
@@ -254,6 +255,7 @@ def driver_calculate(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""
驱动因子计算预算。
@@ -313,6 +315,7 @@ def driver_calculate(
# 记录计算结果
budget_record = DriverFactorBudget(
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
name=name,
industry=industry,
template_id=template_id,
@@ -457,9 +460,12 @@ def driver_sensitivity(
def list_driver_history(
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
):
"""查看最近的驱动因子预算计算记录"""
records = db.query(DriverFactorBudget).order_by(
"""查看本账套最近的驱动因子预算计算记录(账套隔离 2026-09-21)"""
records = db.query(DriverFactorBudget).filter(
DriverFactorBudget.entity_id == entity_id,
).order_by(
DriverFactorBudget.created_at.desc()
).limit(limit).all()
+45 -17
View File
@@ -290,7 +290,9 @@ def validate_reimbursement(db: Session, reimb: ExpenseReimbursement, rules: list
})
else:
# 月度/年度累计: 统计同维度+同费用类型在周期内的已提交金额(含本次)
# 账套隔离 2026-09-21:累计口径限本账套(他账套在途不得污染本账套额度判定)
q = db.query(func.coalesce(func.sum(ExpenseReimbursement.amount), 0)).filter(
ExpenseReimbursement.entity_id == reimb.entity_id,
ExpenseReimbursement.expense_type == reimb.expense_type,
ExpenseReimbursement.id != reimb.id,
ExpenseReimbursement.status.in_(["pending", "approved"]),
@@ -343,6 +345,7 @@ def submit_reimbursement(
data: dict,
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""提交报销单 → 自动校验规则(超限自动打回)"""
applicant = data.get("applicant") or (current_user.name if hasattr(current_user, "name") else current_user.username)
@@ -366,6 +369,7 @@ def submit_reimbursement(
expense_date = None
reimb = ExpenseReimbursement(
entity_id=entity_id, # 账套隔离 2026-09-21:写侧先写归属(否则读侧过滤误杀本账套行)
reimb_no="BX" + datetime.now().strftime("%Y%m%d%H%M%S") + f"{random.randint(100, 999)}",
applicant=applicant,
department=data.get("department", ""),
@@ -402,8 +406,9 @@ def list_reimbursements(
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)"""
q = db.query(ExpenseReimbursement)
"""查询报销单列表(支持状态/类型/申请人/关键字筛选)(账套隔离 2026-09-21:列表按会话账套)"""
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.entity_id == entity_id)
if status:
q = q.filter(ExpenseReimbursement.status == status)
if expense_type:
@@ -425,20 +430,29 @@ def list_reimbursements(
def get_reimbursement(
reimb_id: int,
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""报销单详情"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
"""报销单详情(账套隔离 2026-09-21:跨账套 → 404)"""
r = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == reimb_id,
ExpenseReimbursement.entity_id == entity_id,
).first()
if not r:
raise HTTPException(404, "报销单不存在")
return _reimb_to_dict(r)
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str):
"""执行审批动作: approve/reject/return"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
def _do_approve(db: Session, reimb_id: int, action: str, comment: str, approver: str,
entity_id: int = None):
"""执行审批动作: approve/reject/return(账套隔离 2026-09-21:跨账套 → 404)"""
q = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id)
if entity_id is not None:
q = q.filter(ExpenseReimbursement.entity_id == entity_id)
r = q.first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("pending", "returned"):
raise HTTPException(400, f"当前状态({r.status})不可审批")
@@ -462,10 +476,11 @@ def approve_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""审批通过"""
"""审批通过(账套隔离 2026-09-21:跨账套 → 404)"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver)
r = _do_approve(db, reimb_id, "approve", (data or {}).get("comment", ""), approver, entity_id)
return {"message": "已审批通过", "data": _reimb_to_dict(r)}
@@ -475,13 +490,14 @@ def reject_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""审批拒绝"""
"""审批拒绝(账套隔离 2026-09-21:跨账套 → 404)"""
comment = (data or {}).get("comment", "")
if not comment:
raise HTTPException(400, "拒绝时必须填写审批意见")
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "reject", comment, approver)
r = _do_approve(db, reimb_id, "reject", comment, approver, entity_id)
return {"message": "已拒绝", "data": _reimb_to_dict(r)}
@@ -491,10 +507,11 @@ def return_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
current_user=Depends(require_role("ceo", "finance")),
entity_id: int = Depends(get_entity_id),
):
"""人工打回"""
"""人工打回(账套隔离 2026-09-21:跨账套 → 404)"""
approver = current_user.name if hasattr(current_user, "name") else current_user.username
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver)
r = _do_approve(db, reimb_id, "return", (data or {}).get("comment", "人工打回"), approver, entity_id)
return {"message": "已打回", "data": _reimb_to_dict(r)}
@@ -504,9 +521,13 @@ def resubmit_reimbursement(
data: dict = None,
db: Session = Depends(get_db),
_=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""被驳回/打回后重新提交 → 重新自动校验"""
r = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.id == reimb_id).first()
"""被驳回/打回后重新提交 → 重新自动校验(账套隔离 2026-09-21:跨账套 → 404)"""
r = db.query(ExpenseReimbursement).filter(
ExpenseReimbursement.id == reimb_id,
ExpenseReimbursement.entity_id == entity_id,
).first()
if not r:
raise HTTPException(404, "报销单不存在")
if r.status not in ("returned", "rejected"):
@@ -537,6 +558,7 @@ def resubmit_reimbursement(
def expense_stats(
period: str = Query(None, description="期间 YYYY-MM,默认当前月"),
db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id),
_=Depends(require_auth),
):
"""费用审核看板统计: 状态计数 / 费用类型统计 / 超限预警 / 本月总额"""
@@ -544,7 +566,9 @@ def expense_stats(
period = period or now.strftime("%Y-%m")
counts = {"pending": 0, "approved": 0, "rejected": 0, "returned": 0, "total": 0}
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).group_by(
for st, cnt in db.query(ExpenseReimbursement.status, func.count(ExpenseReimbursement.id)).filter(
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
).group_by(
ExpenseReimbursement.status
).all():
if st in counts:
@@ -560,6 +584,7 @@ def expense_stats(
)
.filter(
ExpenseReimbursement.status.in_(["pending", "approved"]),
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
func.date_format(ExpenseReimbursement.expense_date, "%Y-%m") == period,
)
.group_by(ExpenseReimbursement.expense_type)
@@ -583,6 +608,7 @@ def expense_stats(
.filter(
ExpenseReimbursement.status == "returned",
ExpenseReimbursement.check_result == "fail",
ExpenseReimbursement.entity_id == entity_id, # 账套隔离 2026-09-21
)
.order_by(ExpenseReimbursement.id.desc())
.limit(50)
@@ -604,7 +630,9 @@ def expense_stats(
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]
# 本月预算使用率(招待费15万/月 管理费91万/月)
+7 -2
View File
@@ -15,6 +15,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_auth, require_role
from app.models import KPIDefinition
@@ -166,9 +167,13 @@ def validate_kpi(
def governance_audit(
db: Session = Depends(get_db),
current_user=Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""全量校验所有活跃KPI,输出按4条规则分组的不合规清单。"""
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
"""校验本账套活跃KPI,输出按4条规则分组的不合规清单(账套隔离 2026-09-21)。"""
kpis = db.query(KPIDefinition).filter(
KPIDefinition.status == "active",
KPIDefinition.entity_id == entity_id,
).all()
non_compliant = []
by_rule: dict = {1: [], 2: [], 3: [], 4: []}
+32 -10
View File
@@ -437,9 +437,13 @@ def get_kpi_causality_chain(
kpi_id: int,
db: Session = Depends(get_db),
current_user = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""KPI因果链追踪 — 返回单个KPI的上下游因果链"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
"""KPI因果链追踪 — 返回单个KPI的上下游因果链(账套隔离 2026-09-21:跨账套 id → 404)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
@@ -884,14 +888,21 @@ def kpi_to_dict(k):
@router.put("/{kpi_id}/associate-map")
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES):
"""关联KPI到战略地图"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
def associate_kpi_map(kpi_id: int, data: dict, db: Session = Depends(get_db), user=WRITE_ROLES,
entity_id: int = Depends(get_entity_id)):
"""关联KPI到战略地图(账套隔离 2026-09-21:KPI 与地图均须属本账套,跨账套 → 404)"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id,
KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
map_id = data.get("map_id")
if map_id is not None:
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
m = db.query(StrategicMap).filter(
StrategicMap.id == map_id,
StrategicMap.entity_id == entity_id,
).first()
if not m:
raise HTTPException(404, "战略地图不存在")
kpi.map_id = map_id
@@ -907,9 +918,16 @@ def _log(db, user_id, action, target_type, target_id, detail):
@router.get("/{kpi_id}/objectives")
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
"""查看KPI所属的目标和战略地图"""
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first()
def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db),
entity_id: int = Depends(get_entity_id)):
"""查看KPI所属的目标和战略地图(账套隔离 2026-09-21:跨账套 id → 404)
鉴权口径:本 router 级 `dependencies=[Depends(require_role(...))]` 已覆盖本端点
(无 token → 401),此处不再重复挂 require_auth,只补缺失的账套收口。
"""
kpi = db.query(KPIDefinition).filter(
KPIDefinition.id == kpi_id, KPIDefinition.entity_id == entity_id,
).first()
if not kpi:
raise HTTPException(404, "KPI不存在")
@@ -922,15 +940,19 @@ def get_kpi_objectives(kpi_id: int, db: Session = Depends(get_db)):
}
if kpi.map_id:
m = db.query(StrategicMap).filter(StrategicMap.id == kpi.map_id).first()
m = db.query(StrategicMap).filter(
StrategicMap.id == kpi.map_id, StrategicMap.entity_id == entity_id,
).first()
if m:
result["map"] = {"id": m.id, "title": m.title, "status": m.status}
if kpi.objective:
objs = db.query(MapObjective).filter(
MapObjective.map_id == kpi.map_id,
MapObjective.entity_id == entity_id,
MapObjective.name == kpi.objective,
).all()
result["objectives"] = [{"id": o.id, "name": o.name, "dimension_key": o.dimension_key} for o in objs]
return result
+39 -5
View File
@@ -2,10 +2,21 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import get_entity_id
from app.models import NotificationChannel, NotificationLog
from app.auth_middleware import require_role, require_auth_401
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)
# 缺口:本 router 6 个端点全部无鉴权——无 token 可读写通知渠道(config 内含 webhook
# 地址/令牌等凭据)、可触发渠道测试推送、可读推送日志。收口为 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()
if not ch:
raise HTTPException(404, "渠道不存在")
# 字段白名单(2026-09-21 mass-assignment 收口):仅业务字段,其余键忽略
_updatable = ("name", "config", "enabled")
for k, v in data.items():
if hasattr(ch, k) and k not in ("id", "created_at"):
if k in _updatable:
setattr(ch, k, v)
db.commit()
db.refresh(ch)
@@ -95,14 +108,35 @@ def test_channel(ch_id: int, db: Session = Depends(get_db)):
@router.get("/logs")
def list_logs(page: int = 1, db: Session = Depends(get_db)):
"""通知历史"""
total = db.query(NotificationLog).count()
logs = db.query(NotificationLog).order_by(
def list_logs(page: int = 1,
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db)):
"""通知历史(**按会话账套收口**,B2 零迁移)
2026-09-22 dama-workbench-entity-isolation-fullstack-20260922(§四 B2):
`notification_logs` 无 entity_id 列 → 经父键 join 归属:
`notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id`。
口径与 batch5 对 `KpiDataQualityLog` 的处置一致(宁少勿错):
`alert_id IS NULL` 的行无可归属父键 → **不返回**(不暴露给任一账套)。
"""
from app.models import KPIAlert
scoped = (db.query(NotificationLog)
.join(KPIAlert, NotificationLog.alert_id == KPIAlert.id)
.filter(KPIAlert.entity_id == entity_id))
total = scoped.count()
logs = scoped.order_by(
NotificationLog.created_at.desc()
).offset((page - 1) * 20).limit(20).all()
return {
"total": total,
"entity_id": entity_id,
# 口径字段(2026-09-22 DAMA 回查 §5 收口):上线后本页在无预警推送的账套会显示 0 条,
# 属口径而非故障——现有 8 行历史记录全是 `alert_id IS NULL` 的 OKR 复盘提醒(无账套归属),
# 按「宁少勿错」不返回。前端据此文案提示,避免被当成页面坏了。
"policy": "alert-linked-only",
"note": ("仅显示与预警推送关联的记录(按父键 kpi_alerts 归属会话账套)。"
"无父键的历史通知(如 OKR 复盘提醒,写入方 backend/scripts/okr_review_reminder.py)"
"不属于任何账套,不在本列表返回。口径登记:docs/audit-and-notification-caliber-20260922.md"),
"data": [{
"id": l.id,
"alert_id": l.alert_id,
+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)
action_plans = db.query(ActionPlan).filter(
ActionPlan.objective_id == okr_id,
# 账套隔离(2026-09-18 batch3 派生单):objective_id 已由
# _get_objective_or_404 校验归属,但计划侧此前无账套条件 →
# 对端账套计划(含脏行)会出现在本账套分解视图(实测 OBS2)。
# 前置条件:create_plan 必须已写会话账套 entity_id(顺序铁律)。
ActionPlan.entity_id == entity_id,
ActionPlan.due_date.between(week_start, week_end)
).all()
+17 -1
View File
@@ -1,10 +1,24 @@
"""
OKR模板库 API — 按维度筛选 + 用户自定义模板
⚠️ 集团级共享口径(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,§四 B2)
------------------------------------------------------------------------------------
`okr_templates` 表**无 entity_id 列**,且属**模板/配置类**(无父键可 join 桥接)。
任总 2026-09-22「都批」已按 **方案 1(零 DDL)** 拍板:
**保持全局 = 集团级共享配置** —— 模板库是集团知识资产,一套模板(维度/行业标签/
目标+KR 骨架)供全账套复用;账套差异体现在**使用模板生成的具体目标/KR 行**
(`objectives` / `map_objectives` 均带 entity_id,已隔离),而不是模板本身。
因此本模块的列表/详情/新增/改/删**有意不按账套过滤**——这是口径,不是漏隔离。
若后续确需「账套私有模板」,须单独立单加 `entity_id` 列(DDL + 回填 + 唯一键
`name+dimension` 改造),本单不预设。
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.database import get_db
from app.deps import get_entity_id
from app.auth_middleware import require_role
from app.models import OKRTemplate, StrategicMap
from app.utils.kr_name import sanitize_kr_name
@@ -253,7 +267,8 @@ def increment_use_count(template_id: int, db: Session = Depends(get_db)):
@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"""
t = db.query(OKRTemplate).filter(OKRTemplate.id == template_id).first()
if not t:
@@ -293,6 +308,7 @@ def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_d
})
m = StrategicMap(
entity_id=entity_id, # 账套隔离 2026-09-21:原漏写→落模型默认 1(对齐 maps.py)
title=map_title,
version=data.get("version", "v1.0"),
status="draft",
+155 -68
View File
@@ -10,7 +10,7 @@ from app.utils.cash_forecast_engine import (
calculate_accuracy, generate_scenario_suggestion,
)
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 sqlalchemy.orm import Session
@@ -724,95 +724,182 @@ def _get_dim_detail_indicators(dimension: str, entity: dict) -> list:
return indicators
# ── 增长质量诊断取数(账套隔离 2026-09-21)────────────────────────
# 输入 ← 库内 KPI 实际值(kpi_values),编码口径沿用 kpi_definitions 既有编码
# (同 app/api/growth_quality.py 的 KPI_CODES),不新增计算口径。
_GQ_KPI_CODE_MAP = {
"C_REBATE_RATE": "rebateRate", # 渠补率(%)
"F_GROSS_MARGIN": "trueGrossMargin", # 毛利率(%)
"F_REVENUE_GROWTH": "revenueGrowthRate", # 收入增长率(%)
}
_GQ_DIM_SCORERS = {
"revenueStructure": _score_revenue_structure,
"profitStructure": _score_profit_structure,
"cashAssets": _score_cash_assets,
"growthDriver": _score_growth_driver,
"orgEfficiency": _score_org_efficiency,
}
# 各维度所需输入键。缺任一输入 → 该维度不评分(避免用缺省 0 编造低分)
_GQ_DIM_INPUTS = {
"revenueStructure": ("rebateRate",),
"profitStructure": ("trueGrossMargin",),
"cashAssets": ("cashRatio",),
"growthDriver": ("expenseGrowthRate", "revenueGrowthRate"),
"orgEfficiency": ("mgmtRatio",),
}
def _growth_quality_entity_from_db(db: Session, entity_id: int, period=None):
"""按会话账套从库内 KPI 实际值取增长质量输入。
返回 (entity_dict|None, collected):
- entity_dict 只含库内真实取到的指标(缺的键不填,绝不用请求体/硬编码兜底)
- collected = 本账套是否存在增长质量相关 KPI 实际值
"""
from app.models import Entity, KPIDefinition, KPIValue
ent = db.query(Entity).filter(Entity.id == entity_id).first()
if not ent:
return None, False
q = (
db.query(KPIDefinition.kpi_code, KPIValue.actual_value, KPIValue.period)
.join(KPIValue, KPIValue.kpi_id == KPIDefinition.id)
.filter(KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code.in_(list(_GQ_KPI_CODE_MAP.keys())),
KPIValue.actual_value.isnot(None))
)
if period:
q = q.filter(KPIValue.period == period)
rows = q.order_by(KPIValue.period.desc()).all()
entity = {"entity": ent.name}
seen = set()
for code, value, _period in rows:
if code in seen:
continue
seen.add(code)
entity[_GQ_KPI_CODE_MAP[code]] = float(value)
return entity, bool(seen)
def _growth_quality_score(entity: dict):
"""计算「有输入的维度」评分 + 明细 + 缺失维度清单(缺输入的维度不评分)。"""
scores, detail, missing = {}, {}, []
for dim, inputs in _GQ_DIM_INPUTS.items():
if any(k not in entity for k in inputs):
missing.append(dim)
detail[dim] = {"score": None, "data_missing": True,
"indicators": [], "suggestions": []}
continue
score = _GQ_DIM_SCORERS[dim](entity)
scores[dim] = score
detail[dim] = {
"score": score,
"indicators": _get_dim_detail_indicators(dim, entity),
"suggestions": _generate_improvement_suggestions(dim, score, entity),
}
return scores, detail, missing
def _gq_level_of(overall: float):
if overall is None:
return None, None
if overall >= 4:
return "🟢 越增长越轻", "excellent"
if overall >= 3:
return "🟡 增长质量中等", "medium"
if overall >= 2:
return "🟠 需关注", "warning"
return "🔴 越增长越重", "danger"
@router.post("/growth-quality")
def api_growth_quality(request: Request, data: dict):
"""增长质量诊断 — 五维度评分+综合评分+诊断结论"""
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:
entity_id = resolve_entity_for_request(request, data.get("entity_id"))
ENTITY_DATA = {
1: {"entity":"陕西酣客文化传媒","rebateRate":82.8,"trueGrossMargin":18.6,"cashRatio":0.6,"expenseGrowthRate":2.2,"revenueGrowthRate":1.0,"mgmtRatio":447},
2: {"entity":"陕西博海网络科技","rebateRate":0,"trueGrossMargin":13.1,"cashRatio":6.7,"expenseGrowthRate":0.8,"revenueGrowthRate":1.0,"mgmtRatio":1.4},
}
entity = ENTITY_DATA.get(entity_id, data.get("entity", data))
entity_name = entity.get("entity", entity.get("name", "该企业"))
period = entity.get("period", data.get("period", "当前"))
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 "当前"
# 五维度评分
dim_scores = {
"revenueStructure": _score_revenue_structure(entity),
"profitStructure": _score_profit_structure(entity),
"cashAssets": _score_cash_assets(entity),
"growthDriver": _score_growth_driver(entity),
"orgEfficiency": _score_org_efficiency(entity),
}
overall = round(sum(dim_scores.values()) / 5, 1)
# 综合等级
if overall >= 4:
level = "🟢 越增长越轻"
level_type = "excellent"
elif overall >= 3:
level = "🟡 增长质量中等"
level_type = "medium"
elif overall >= 2:
level = "🟠 需关注"
level_type = "warning"
else:
level = "🔴 越增长越重"
level_type = "danger"
# 诊断结论
dimensions_payload = {}
detail_payload = {}
for dim, score in dim_scores.items():
dimensions_payload[dim] = {"score": score, "weight": 20}
detail_payload[dim] = {
"score": score,
"indicators": _get_dim_detail_indicators(dim, entity),
"suggestions": _generate_improvement_suggestions(dim, score, entity),
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,
}
diagnosis = _diagnosis_text(overall, dimensions_payload, entity_name)
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 = data.get("compare", None)
# 对比:只接受账套内 compare_entity_id 并从库内取数(原请求体 compare 数据不再采信)
compare_result = None
if compare:
compare_entity = compare
compare_name = compare_entity.get("entity", compare_entity.get("name", "对比企业"))
cdims = {
"revenueStructure": _score_revenue_structure(compare_entity),
"profitStructure": _score_profit_structure(compare_entity),
"cashAssets": _score_cash_assets(compare_entity),
"growthDriver": _score_growth_driver(compare_entity),
"orgEfficiency": _score_org_efficiency(compare_entity),
}
compare_overall = round(sum(cdims.values()) / 5, 1)
compare_result = {
"entity_name": compare_name,
"overall": compare_overall,
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
"level": ("🟢 越增长越轻" if compare_overall >= 4 else
"🟡 增长质量中等" if compare_overall >= 3 else
"🟠 需关注" if compare_overall >= 2 else "🔴 越增长越重"),
}
compare_entity_id = data.get("compare_entity_id")
if compare_entity_id:
assert_entity_scope(request, int(compare_entity_id), db)
c_entity, c_collected = _growth_quality_entity_from_db(db, int(compare_entity_id), period)
if c_entity is not None and c_collected:
cdims, _c_detail, _c_missing = _growth_quality_score(c_entity)
if cdims:
c_overall = round(sum(cdims.values()) / len(cdims), 1)
compare_result = {
"entity_id": int(compare_entity_id),
"entity_name": c_entity.get("entity", "对比企业"),
"overall": c_overall,
"dimensions": {k: {"score": v, "weight": 20} for k, v in cdims.items()},
"level": _gq_level_of(c_overall)[0],
}
return {
"entity_id": entity_id,
"entity_name": entity_name,
"period": period,
"period": period_label,
"collected": True,
"data_source": "kpi_values",
"overall": overall,
"level": level,
"level_type": level_type,
"dimensions": dimensions_payload,
"detail": detail_payload,
"dimensions_missing": dims_missing,
"diagnosis": diagnosis,
"compare": compare_result,
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, f"增长质量诊断失败: {str(e)}")
# ── KPI趋势预测(预测性成本智能 MVP) ────────────────────────────
from app.utils.kpi_forecast_engine import ( # noqa: E402
MODELS, forecast_kpi, forecast_finance_kpis,
+6 -2
View File
@@ -831,7 +831,7 @@ def seed_demo_data(
exists = db.query(ExpenseReimbursement).filter(ExpenseReimbursement.reimb_no == d["reimb_no"]).first()
if exists:
continue
db.add(ExpenseReimbursement(**d))
db.add(ExpenseReimbursement(entity_id=eid, **d))
# 发票: 正常2张(金额匹配) + 格式错误1张 + 金额不符1张 + 供应商与合同不符1张
invoice_demo = [
@@ -854,7 +854,11 @@ def seed_demo_data(
)
if exists:
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)
db.add(inv)
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 pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth_401
from app.models import SystemConfig, Entity
from app.auth_middleware import require_auth_401, require_role
from app.models import SystemConfig, Entity, OperationLog
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
@@ -25,8 +51,15 @@ def _tenant_name(entity_id: int) -> str:
@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()
if cfg and cfg.config_value:
import json
@@ -38,13 +71,25 @@ def get_current_tenant(db: Session = Depends(get_db)):
@router.post("/switch")
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
"""切换当前租户(CMA前端企业切换器调用)"""
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
user=Depends(require_auth_401)):
"""切换当前租户状态记录(项目Bot 分发 A2A 任务用;CMA前端企业切换器已下线)
2026-09-22:写操作**不动**(保留 Bot 通道可用性),补 `operation_logs` 审计留痕。
"""
ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
if not ent:
raise HTTPException(404, "企业不存在")
import json
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 = {
"tenant_id": _tenant_name(data.entity_id),
"entity_id": data.entity_id,
@@ -54,15 +99,32 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db)):
"switched_at": __import__("datetime").datetime.now().isoformat(),
}
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
if cfg:
cfg.config_value = json.dumps(state, ensure_ascii=False)
else:
db.add(SystemConfig(
config_key=TENANT_KEY,
config_value=json.dumps(state, ensure_ascii=False),
description="当前租户状态(CMA企业切换联动)",
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()
return {"success": True, "current": state}
+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 # 趋势百分比
# ── 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]:
"""获取当前现金余额(万元)— 优先级: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
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:
latest_cash = db.query(KPIValue).filter(
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:
return float(latest_cash.actual_value)
cfg = db.query(SystemConfig).filter(
SystemConfig.config_key == "cash.current_balance"
SystemConfig.config_key == cash_balance_key(entity_id)
).first()
if cfg and cfg.config_value:
try:
@@ -243,19 +271,27 @@ def get_current_cash_balance(db: Session, entity_id: int) -> Optional[float]:
return None
def set_current_cash_balance(db: Session, value: float) -> float:
"""设置当前现金余额(写入SystemConfig,供预测引擎使用)"""
def set_current_cash_balance(db: Session, value: float, entity_id: int) -> float:
"""设置当前现金余额(写入本账套键 `cash.current_balance.<entity_id>`,供预测引擎使用)
entity_id 为**必传**(2026-09-22 B1):签名中不设默认值,强制调用方带上会话账套,
防「漏传 → 落默认账套」重演本次污染。全部调用点见 cash.py(POST /balance、
网银流水导入联动)。
"""
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(
SystemConfig.config_key == "cash.current_balance"
SystemConfig.config_key == key
).first()
if cfg:
cfg.config_value = str(value)
else:
cfg = SystemConfig(
config_key="cash.current_balance",
config_key=key,
config_value=str(value),
description="当前现金余额(万元),资金预测基线",
description=f"当前现金余额(万元),资金预测基线 — 账套 {int(entity_id)}",
)
db.add(cfg)
db.commit()
@@ -803,9 +839,17 @@ def check_cash_alerts(db: Session, entity_id: int = 1) -> dict:
(此前博海走无排序兜底 → 命中 uk_entity_kpi 索引必中 BH_ADM_EXPENSE(437 管理费用))
F3 去重命中时刷新文本/等级/逾期天数/归属(此前直接 continue → 金额与天数永久冻结在首版)
F4 余额快照批次行按开关处理(默认 skip,不再逐户生成伪"到期未收款")
R1(裁定 2026-09-19):demo 演示账套不产生业务预警 → 直接返回 skipped。
"""
import json as _json
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)
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,
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()()
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 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:
return {"error": "分配率未设置"}
allocated = round(driver_consumed * act.driver_rate, 2)
alloc = AbcAllocation(
alloc_kwargs = dict(
period=period,
activity_id=activity_id,
product_code=product_code,
@@ -179,6 +189,9 @@ def allocate_cost(activity_id: int, period: str, product_code: str,
driver_consumed=driver_consumed,
allocated_cost=allocated,
)
if entity_id is not None:
alloc_kwargs["entity_id"] = entity_id
alloc = AbcAllocation(**alloc_kwargs)
db.add(alloc)
db.commit()
return {
+125 -41
View File
@@ -14,6 +14,11 @@ from typing import Optional
from app.database import get_session_local
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")
@@ -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
def get_actual_for_kpi(db, kpi_id: int, period: str) -> Optional[float]:
"""获取指定KPI在指定期间的实际值"""
val = db.query(KPIValue).filter(
def get_kpi_value_row(db, kpi_id: int, period: str):
"""取 (kpi_id, period) 的 KPIValue 行(取行口径唯一来源)。
与 get_actual_for_kpi 同源:预警行关联的 kpi_values 行 = 数值来源那一行。
"""
return db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_id,
KPIValue.period == period,
).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:
@@ -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:
"""运行差异预警检查,返回新增预警数(统一走 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:
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(
KPIDefinition.status == "active"
).all()
new_count = 0
skipped_demo = 0
skipped_period_linkage = 0
skipped_linkage_kpis = []
for kpi in kpis:
# R1(裁定 2026-09-19):demo 演示账套不参与预警遍历
if kpi_excluded(db_session, kpi, excluded):
skipped_demo += 1
continue
# 1. 差异预警:实际 vs 预算(统一逻辑)
result = build_deviation_alert(db_session, kpi, period)
if result["triggered"]:
@@ -330,51 +389,76 @@ def run_deviation_check(db_session, period: str = None) -> int:
f"vs 预算{deviation['budget_value']},"
f"差异率{deviation['deviation_rate']}%"
)
alert_type = result["alert_type"] or "actual"
# 检查是否已有同KPI同期间的差异预警
existing = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[差异预警]"),
KPIAlert.alert_message.contains(period),
KPIAlert.status.in_(["pending", "processing"]),
).first()
# 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(
KPIAlert.entity_id == (kpi.entity_id or 1),
KPIAlert.kpi_id == kpi.id,
KPIAlert.kpi_value_id == linked_kv.id,
KPIAlert.alert_type == alert_type,
KPIAlert.status.in_(["pending", "processing"]),
).first()
if not existing:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
kpi_id=kpi.id,
alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}",
alert_type=result["alert_type"] or "actual",
suggestion=result["suggestion"],
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
if not existing:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属(原缺省=1 串账套)
kpi_id=kpi.id,
kpi_value_id=linked_kv.id, # R5-补1: 期间经该行可派生(可归期)
alert_level=result["kpi_alert_level"],
alert_message=f"[差异预警] {alert_msg}",
alert_type=alert_type,
suggestion=result["suggestion"],
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增差异预警 [{result['kpi_alert_level']}] {kpi.kpi_name}: 差异率{deviation['deviation_rate']}%")
# 2. 趋势异常检测(每期检查连续3期)
trend = check_trend_anomaly(db_session, kpi.id, period, consecutive=3)
if trend.get("anomaly") and trend.get("level") in ("yellow", "red"):
trend_alert_msg = trend["message"]
existing_trend = db_session.query(KPIAlert).filter(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[趋势预警]"),
KPIAlert.status.in_(["pending", "processing"]),
).first()
# 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(
KPIAlert.kpi_id == kpi.id,
KPIAlert.alert_message.contains("[趋势预警]"),
KPIAlert.status.in_(["pending", "processing"]),
).first()
if not existing_trend:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
alert_level=trend["level"],
alert_message=f"[趋势预警] {trend_alert_msg}",
status="pending",
)
db_session.add(alert)
new_count += 1
logger.info(f" 新增趋势预警 [{trend['level']}] {trend_alert_msg}")
if not existing_trend:
alert = KPIAlert(
entity_id=kpi.entity_id or 1, # 2026-09-13 F1: 显式写归属
kpi_id=kpi.id,
kpi_value_id=linked_kv.id, # R5-补1: 趋势末端当期关联
alert_level=trend["level"],
alert_message=f"[趋势预警] {trend_alert_msg}",
status="pending",
)
db_session.add(alert)
new_count += 1
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()
return new_count
+192 -29
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__))))
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
# 裁定2a(2026-09-17 财务口径):比率类指标统一「百分比(%)」口径(取数×100),
# 未核对/疑似单位错配的项降级待核(不落预警、不推送)
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")
@@ -85,8 +90,55 @@ def _check_threshold(actual: float, threshold_expr: str, level: str) -> tuple:
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,
stats: Optional[dict] = None) -> int:
stats: Optional[dict] = None, now: Optional[datetime] = None) -> int:
"""检查所有KPI的实际值是否触发预警,返回(将)新生成的预警数
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,
与阈值 150/80 同量纲);**未核对/疑似单位错配**的项降级待核 → 不落预警、不推送;
· 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 {}
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),
("periods_used", {}), ("normalized", 0), ("normalized_kpis", []),
("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)
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
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
thr = {
"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 实测值")
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该期间的最新值
latest = db_session.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
@@ -214,19 +310,25 @@ def run_alert_check(db_session, period: Optional[str] = None, dry_run: bool = Fa
plan_title = f"渠补谈判: {msg} —— 下游渠道返利谈判"
elif kpi.kpi_code == "F_COST_RATIO":
plan_title = f"费用压缩: {msg} —— 管理费削减计划"
plan = ActionPlan(
alert_id=alert.id,
kpi_id=kpi.id,
title=plan_title,
assignee="财务部",
priority="high" if level == "red" else "medium",
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
created_by="alert_generator",
)
db_session.add(plan)
db_session.flush()
alert.action_plan_linked_id = plan.id
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
# 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(
entity_id=kpi.entity_id, # 归属唯一事实源:来源 KPI 的账套
alert_id=alert.id,
kpi_id=kpi.id,
title=plan_title,
assignee="财务部",
priority="high" if level == "red" else "medium",
due_date=datetime.now().date() + timedelta(days=3 if level == "red" else 7),
created_by="alert_generator",
)
db_session.add(plan)
db_session.flush()
alert.action_plan_linked_id = plan.id
logger.info(f" ↳ 自动创建行动计划 #{plan.id}: {plan_title}")
break # 只取最高级别
elif level == "red" and not triggered:
# 红没触发(判定顺序中 red 最先)→ 若该期间该KPI已有 pending/processing 红灯,
@@ -261,7 +363,8 @@ def run_rule_engine_checks(db_session) -> dict:
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:
rows = db_session.query(AlertRule.entity_id).filter(
AlertRule.enabled == 1,
@@ -272,6 +375,11 @@ def run_rule_engine_checks(db_session) -> dict:
return stats
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),
("customer_concentration", run_customer_concentration_check)):
try:
@@ -285,24 +393,42 @@ def run_rule_engine_checks(db_session) -> dict:
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:
· **period=None** → 判定期间 = 各 KPI「最近可用期间」(裁定2b,不再硬编码当月);
显式给出 period → 固定该期间(抽查复核用);
· **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 = {}
logger.info("开始预警检查(当月=%s;判定期间=%s)..." % (
cur_month, ("固定 %s" % period) if period else "各 KPI 最近可用期间(裁定2b)"))
logger.info("开始预警检查(当月=%s;判定期间=%s;窗口下限=%s;排除账套=%s)..." % (
cur_month, ("固定 %s" % period) if period else "各 KPI 最近可用期间(裁定2b)",
window_lower_period(now), sorted(excluded_entity_ids(db_session))))
# 0. Bug1修复: 先清理历史green预警(dry-run 不写)
cleaned = 0 if dry_run else cleanup_green_alerts(db_session)
# 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} 条")
# 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)
# 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
if new_count > 0 and not dry_run:
try:
from app.utils.notifier import push_pending_alerts
pushed = push_pending_alerts(db_session)
logger.info(f"推送完成: {pushed} 条")
except Exception as e:
logger.error(f"推送失败: {e}")
if push_skipped_no_channel:
logger.info("推送出口缺失(notification_channels 启用数=0)→ 仅入库、不推送、不阻断(裁定 R4)")
else:
try:
from app.utils.notifier import push_pending_alerts
pushed = push_pending_alerts(db_session)
logger.info(f"推送完成: {pushed} 条")
except Exception as e:
logger.error(f"推送失败(不影响已入库预警,生成/推送解耦 R4): {e}")
return {"period": cur_month, "dry_run": dry_run,
"period_source": st.get("period_source"), "period_fixed": st.get("period_fixed"),
"periods_used": st.get("periods_used", {}),
"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,
"coverage": coverage,
# 裁定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", []),
# 期间/取值覆盖明细
"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:
@@ -380,6 +526,23 @@ def coverage_note(result: dict) -> str:
% (result["dimension_suspect"], (result.get("suspect_kpis") or [])[:3]))
if result.get("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)
+7 -1
View File
@@ -38,6 +38,8 @@ import re
from sqlalchemy import func
from app.database import get_session_local
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")
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)。
# 幂等规则:同一 KPIValue 已有同级别 pending → 保持不动(值未变不重建);
# 其它/旧级别 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_value = value_rows.get(kpi.id)
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())
+226 -18
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 --grant-plan # 零 DB:打印授权收敛语句计划(授权面幂等自证证据)
python3 treasury_migrate.py --check # 只读预检:缺表/缺列/缺索引/种子行数(零写入,默认动作)
python3 treasury_migrate.py --apply # 建表 + 加列 + 索引 + 种子(幂等)
python3 treasury_migrate.py --apply # 建表 + 加列 + 索引 + 种子 + 授权收敛(幂等)
python3 treasury_migrate.py --down # 回滚(先 dump 后删;有数据时要求 --yes)
幂等契约(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 / 种子行数不变
指纹行:import_logs(batch=treasury-ddl-<sha1[:10]>),按 (import_type,batch) 先查后写,
不依赖表上是否有唯一键(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 hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
@@ -60,6 +77,21 @@ CASH_PLAN_ALTER = """ALTER TABLE cash_plans
DEFAULT_SOCKET = "/var/run/mysqld/mysqld.sock"
FORBIDDEN_RUN_ACCOUNTS = {"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():
@@ -87,19 +119,161 @@ def get_engine():
pool_pre_ping=True)
def grant_app_account(conn):
"""授权应用运行账号 cma_api 对 6 张 treasury 新表的 DML(幂等 GRANT,双 host)。
必须由特权连接执行;否则应用端点读不到/写不进新表(1142/1044)。"""
from app import database as db
dbn = (os.getenv("CMA_MIGRATE_DB_NAME") or getattr(db, "DB_NAME", None) or "cma")
granted = []
for host in ("localhost", "127.0.0.1"):
for t in NEW_TABLES:
conn.execute(text(
f"GRANT SELECT, INSERT, UPDATE, DELETE ON `{dbn}`.`{t}` "
f"TO '{GRANT_APP_ACCOUNT}'@{host}"))
granted.append(f"{t}@{host}")
return granted
def _resolve_dbn():
"""库名:env CMA_MIGRATE_DB_NAME 优先,否则应用配置(app.database.DB_NAME),最后 'cma'。"""
name = (os.getenv("CMA_MIGRATE_DB_NAME") or "").strip()
if name:
return name
try:
from app import database as db
return getattr(db, "DB_NAME", None) or "cma"
except Exception:
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:
raw = ops.get(t)
if raw is None:
raise SystemExit(f"[FAIL] 白名单 table_ops 缺 {t} 裁定({WHITELIST_PATH})")
privs = tuple(p for p in GRANT_PRIV_DOMAIN if p in _parse_priv_text(raw))
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:
conn.execute(text(p["index"]))
# ③b 授权应用账号 cma_api 对本 6 表 DML(幂等 GRANT,须特权连接)
granted = grant_app_account(conn)
# ③b 授权收敛(按白名单最小面 + 幂等,须特权连接):REVOKE 白名单外 + GRANT 白名单
# 旧形态为无条件 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,数据级幂等)
for stmt in p["seeds"]:
@@ -315,10 +494,14 @@ def up(conn):
"limits_rows": counts["limits_rows"],
"import_logs_row": action,
"batch": batch,
"granted": granted,
"granted": grant_sync["granted"],
"grant_sync": grant_sync,
"missing_tables": post["missing_tables"],
"cash_plans_rows": post["cash_plans_rows"],
}, ensure_ascii=False))
if not grant_sync["converged"]:
print(f"❌ 授权收敛未达成 {grant_sync['converged_mismatch']}(非零退出)", file=sys.stderr)
return 6
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():
"""零 DB 的 DDL 解析复算(P0-1 证据):命中 6 条 CREATE + 表名清单 + 种子语句。"""
p = parse_ddl()
@@ -407,6 +612,7 @@ def main():
ap = argparse.ArgumentParser()
g = ap.add_mutually_exclusive_group()
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("--apply", action="store_true")
g.add_argument("--down", action="store_true")
@@ -415,6 +621,8 @@ def main():
if args.parse_only:
return parse_only()
if args.grant_plan:
return grant_plan_only()
engine = get_engine()
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_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
from typing import Generator
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._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)
from app.models import (
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)
@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
def db() -> Generator[Session, None, None]:
"""提供数据库 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}"
+28 -11
View File
@@ -2,7 +2,7 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from tests.conftest import create_test_user, get_token_for_user, auth_header
from tests.conftest import create_test_user, create_test_kpi, get_token_for_user, auth_header
class TestNotificationChannels:
@@ -149,16 +149,32 @@ class TestNotificationLogs:
assert resp.json()["total"] == 0
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
# 直接插入日志
log = NotificationLog(
alert_id=1, channel="wecom", recipient="测试群",
title="测试通知", content="测试内容", status="sent", sent_at=datetime.now(),
)
db.add(log)
kpi = create_test_kpi(db, kpi_code="NOTIF_LOG_KPI", entity_id=1)
alert = KPIAlert(entity_id=1, kpi_id=kpi.id, alert_level="yellow",
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(),
),
# 孤儿行:无父键可归属 → 一律不返回
NotificationLog(alert_id=None, channel="wecom", title="无归属通知", status="sent"),
])
db.commit()
create_test_user(db)
@@ -166,8 +182,9 @@ class TestNotificationLogs:
resp = client.get("/api/cma/notifications/logs", headers=auth_header(token))
assert resp.status_code == 200
assert resp.json()["total"] >= 1
assert len(resp.json()["data"]) >= 1
assert resp.json()["total"] == 1, resp.json()
assert len(resp.json()["data"]) == 1
assert resp.json()["data"][0]["title"] == "测试通知"
class TestAlertPush:
+19 -9
View File
@@ -252,16 +252,27 @@ class TestGrowthQuality:
assert "overall" in data or "level" in data or "score" in data
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)
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))
assert resp.status_code == 200
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):
"""自定义企业数据(低分场景)"""
"""自定义企业数据(低分场景)→ 契约变更:请求体指标不再被采信
原用例靠请求体 rebateRate/mgmtRatio 等打分得 danger;该回退已按 batch5 派单
要求移除 → 改为断言请求体指标被忽略、无库内数据即未采集(不编造评分)。
"""
token99 = _token_for_entity(client, db, 99)
resp = client.post(f"{BASE}/growth-quality", json={
"entity_id": 99,
@@ -271,8 +282,7 @@ class TestGrowthQuality:
}, headers=auth_header(token99))
assert resp.status_code == 200
data = resp.json()
assert data["entity_name"] == "测试企业"
assert data["overall"] < 2
assert data["level_type"] == "danger"
# 低分诊断
assert "越增长越重" in data["diagnosis"]
assert data["entity_name"] == "企业99", "企业名取库内,不取请求体 name"
assert data["collected"] is False
assert data["overall"] is None and data["level_type"] is None
assert data["dimensions"] == {} and data["diagnosis"] is None
@@ -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()
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):
"""生成演示数据(MySQL-only:demo日期为字符串,SQLite DateTime严格模式不兼容)"""
"""生成演示数据(batch5 起 invoice_date 经 _parse_date,SQLite 亦可跑通)"""
create_test_user(db)
token = get_token_for_user(client)
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
# 管理会计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
# 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)"
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 " 入口工作区(开发区): $REPO_DIR"
echo " 部署树(已提交树) : $DEPLOY_SRC"
echo " 模式 : $MODE"
echo ""
# 0. 提交纪律检查:本地有未提交修改则中止,防止 git pull 覆盖丢失
echo "[0/5] 提交纪律检查..."
cd "$SCRIPT_DIR"
DIRTY=$(git status --porcelain 2>/dev/null | grep -vE "__pycache__|^\?\?" | head -20)
if [ -n "$DIRTY" ]; then
echo "❌ 检测到本地未提交的修改,中止部署(防止 git pull 覆盖丢失代码):"
echo "$DIRTY"
# ── 演练模式安全闸(必须最先判定:drill 绝不允许碰到生产面)──
if [ "$MODE" = "drill" ]; then
case "$WWW_DIR" in
/var/www|/var/www/*) echo "❌ 演练模式禁止写生产 www 目录: $WWW_DIR(请用 CMA_WWW_DIR 指向临时目录)"; exit 1;;
esac
for c in systemctl nginx; do
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 "请先提交代码: 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
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. 拉取最新代码
echo "[1/5] 拉取最新代码..."
git pull origin main
echo " ✓ 代码已更新"
# 1.2 运行时资产接线(单源、幂等):.env / venv 指向开发区主副本,避免密钥与依赖双份漂移
if [ ! -e "$ENV_SRC" ]; then
echo "❌ 运行时密钥文件缺失: $ENV_SRC(部署树需要它)"; exit 1
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 类缺口(用户端点无鉴权)存在即中止部署
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
echo "❌ 鉴权面审计发现 B 类缺口(用户端点缺鉴权),中止部署。详见 /tmp/cma-auth-audit.log"
tail -20 /tmp/cma-auth-audit.log
exit 1
fi
cd "$SCRIPT_DIR"
echo " ✓ 鉴权面审计通过(无 B 类缺口)"
# 2. 构建前端
# 2. 构建前端(在部署树内构建:pnpm/corepack 的生成物落在这里,污染不到开发区)
echo "[2/5] 构建前端..."
cd "$SCRIPT_DIR/frontend"
cd "$DEPLOY_SRC/frontend"
pnpm build
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
echo "[3/5] 部署前端..."
rm -rf /var/www/cma/assets/
cp -r dist/* /var/www/cma/
rm -rf "$WWW_DIR/assets/"
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 " ✓ 部署完成"
# 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中断部署)
echo "[4/5] 更新后端依赖..."
cd "$SCRIPT_DIR/backend"
if [ -f venv/bin/activate ]; then
cd "$DEPLOY_SRC/backend"
if [ "$MODE" = "drill" ]; then
echo " ℹ️ 演练模式:跳过依赖安装(共享 venv 只读)"
elif [ -f venv/bin/activate ]; then
source venv/bin/activate
pip install -r requirements.txt --quiet --no-cache-dir || echo " ⚠️ 依赖更新失败(不阻断)"
deactivate
@@ -162,18 +214,58 @@ echo " ✓ 依赖检查完成"
# 新模型必须由**有 DDL 权限的账号**在重启前建表;此处失败即中止部署(后端保持旧码运行),
# 避免 2026-09-16 那种「启动 create_all 被 cma_api 拒 → 崩溃循环」。
echo "[4b/5] 数据库结构迁移(特权账号,重启前)..."
cd "$SCRIPT_DIR/backend"
if ! ./venv/bin/python3 scripts/db_migrate.py --apply; then
cd "$DEPLOY_SRC/backend"
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 " 排查: cd $SCRIPT_DIR/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 --check"
echo " 回滚: cd $DEPLOY_SRC/backend && ./venv/bin/python3 scripts/db_migrate.py --rollback latest"
exit 1
fi
cd "$SCRIPT_DIR"
echo " ✓ 结构迁移完成(幂等;journal: backend/scripts/backups/db-migrate-journal/)"
cd "$DEPLOY_SRC"
echo " ✓ 结构迁移完成(幂等;journal: $DEPLOY_SRC/backend/scripts/backups/db-migrate-journal/)"
# 5. 重启后端服务
# 5. 重启后端服务(运行树 = 部署树:幂等改写 unit,改前备份,附一行回滚)
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 restart cma-backend
systemctl reload nginx 2>/dev/null || nginx -s reload
@@ -227,10 +319,10 @@ else
echo " ❌ BOT桥接API失败"; SMOKE_FAIL=1
fi
# 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一致性通过"
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
fi
# 凭据同源(2026-09-14 cma-defaultcred-code-fix):登录冒烟不再硬编码默认账号/口令 —— 轮换后必须自动跟随。
@@ -267,12 +359,15 @@ fi
if [ "$SMOKE_FAIL" -eq 1 ]; then
echo ""
echo "🚨 冒烟测试未全部通过!请检查后端日志: journalctl -u cma-backend -n 50"
echo " 一行回滚: cp ${UNIT_FILE}.bak-* ${UNIT_FILE} && systemctl daemon-reload && systemctl restart cma-backend"
exit 1
fi
echo " ✅ 全部冒烟测试通过"
echo ""
echo "===== 部署完成 ====="
echo " 部署源 : origin/main = ${DEPLOY_SHA:0:8} ${DEPLOY_SUBJECT}"
echo " 部署树 : $DEPLOY_SRC"
echo " 前端: https://cma.sxbh.ltd"
echo " 后端: http://127.0.0.1:8010/docs"
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),
delete: (id: number) => api.delete(`/action-plans/${id}`),
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'),
}
@@ -61,21 +61,24 @@
<div v-if="prevRevenue" class="pb-compare">
<div class="pb-compare-item">
<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) }}%
</span>
<span v-else style="color:#909399">缺上期数据</span>
</div>
<div class="pb-compare-item">
<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) }}%
</span>
<span v-else style="color:#909399">缺上期数据</span>
</div>
<div class="pb-compare-item">
<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) }}%
</span>
<span v-else style="color:#909399">缺上期数据</span>
</div>
</div>
@@ -129,19 +132,22 @@ const costRate = computed(() => {
return (props.cost / props.revenue * 100).toFixed(1) + '%'
})
const revChg = computed(() => {
if (!props.prevRevenue) return 0
// 缺失值 NULL 语义(财务裁定 2026-09-21 §2):上期缺失/为 0 时返回 null,
// 不得回落 0(0 会被显示成「持平」,把「无数据」当真实 0 参与同环比)。
const revChg = computed<number | null>(() => {
if (props.prevRevenue == null || props.prevRevenue === 0) return null
return (props.revenue - props.prevRevenue) / props.prevRevenue
})
const profitChg = computed(() => {
if (!props.prevProfit) return 0
const profitChg = computed<number | null>(() => {
if (props.prevProfit == null || props.prevProfit === 0) return null
return (props.profit - props.prevProfit) / Math.abs(props.prevProfit)
})
const rateChg = computed(() => {
if (!props.prevRevenue || !props.prevProfit) return 0
const rateChg = computed<number | null>(() => {
if (props.prevRevenue == null || props.prevProfit == null || props.prevRevenue === 0) return null
const cur = props.profit / props.revenue
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(() => {
+34 -1
View File
@@ -294,7 +294,8 @@
<!-- 企业只读标识(不可点):账套由登录窗口选定,页内无切换入口 -->
<el-tag size="small" type="primary" effect="plain">{{ entityLabel }}</el-tag>
<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 v-if="cosoData">
@@ -667,6 +668,38 @@ async function loadCosoChecklist() {
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 () => {
loadStats()
+2 -2
View File
@@ -200,7 +200,7 @@
</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 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>
@@ -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[]>([])
async function loadExecutionReport() {
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
}
+6 -5
View File
@@ -342,19 +342,20 @@ async function loadTrend() {
const keyB = b.period || ''
return keyA.localeCompare(keyB)
})
// 缺失值 NULL 语义(财务裁定 2026-09-21 §2):actual/budget 缺失保留 null(不回落 0)
trendData.value = monthly.map((i: any) => ({
period: i.period,
actual: i.actual_value || 0,
budget: i.budget_value || 0,
actual: i.actual_value ?? null,
budget: i.budget_value ?? null,
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 */ }
trendLoading.value = false
}
function barHeight(val: number, max: number) {
if (!max) return 0
function barHeight(val: number | null, max: number) {
if (!max || val == null) return 0 // 缺失值 NULL 语义:不画柱,不以 0 参与
return Math.max(4, (val / max) * 150)
}
+11 -1
View File
@@ -15,6 +15,8 @@
<!-- 模式选择器 -->
<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 type="info" :closable="false" show-icon title="系统级设置,影响全部账套"
description="对齐模式保存在系统级配置中,切换后将同时作用于全部账套。" style="margin-bottom:12px;" />
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<el-card v-for="mode in alignModes" :key="mode.key"
:class="{ 'mode-card': true, 'selected-mode': selectedMode === mode.key }"
@@ -136,7 +138,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
// 统一走 api/index.ts 的 axios 实例(拦截器自动带 Bearer / 401 跳登录)
import { alignmentApi, mapApi } from '../api/index'
@@ -286,6 +288,14 @@ async function loadConfig() {
async function saveAlignConfig() {
if (!selectedMode.value) return
// 系统级设置二次确认:对齐模式存于全局配置(role_permissions 无 entity_id),切换影响全部账套
try {
await ElMessageBox.confirm(
`对齐模式为系统级设置,切换后将立即作用于全部账套。确认切换为「${selectedModeName.value}」?`,
'系统级设置二次确认',
{ type: 'warning', confirmButtonText: '确认切换', cancelButtonText: '取消' }
)
} catch { return }
saving.value = true
try {
// 修复:原 `'Bearer ' + token` 模板字面量 bug(header 值被写死成字面量 → 必 401)
+34 -3
View File
@@ -56,6 +56,7 @@
<div class="section-header">
<span class="section-icon">📊</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>
</div>
<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>
</div>
<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) }}
</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>
<!-- 缺失值 NULL 语义(财务裁定 2026-09-21 §2):actual 缺失时不得显示 0% 进度 -->
<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"
:stroke-width="6"
:show-text="false"
/>
<div v-else style="height:6px;line-height:6px;color:#c0c4cc;font-size:11px">—</div>
</div>
</div>
</div>
@@ -174,6 +180,23 @@ function targetLabel(k: any) {
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) {
if (p.status === 'completed') return '✅'
if (p.overdue) return '🔴'
@@ -262,6 +285,8 @@ onMounted(async () => {
}
.section-action { font-size: 13px; color: #409eff; cursor: pointer; }
.section-action:hover { text-decoration: underline; }
/* 数据截至 X 月(2026-09-18):说明本页各卡的最新可得期间,不改渲染结构 */
.section-asof { font-size: 12px; color: #999; margin-right: 10px; }
.loading-wrap { padding: 40px; }
/* ── 空状态 ── */
@@ -310,6 +335,12 @@ onMounted(async () => {
.kpi-value-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
.kpi-actual { font-size: 24px; font-weight: 700; }
.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; }
/* ── 改善行动 ── */
+2 -1
View File
@@ -42,7 +42,8 @@
<!-- 发送记录 -->
<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="title" label="标题" min-width="200" />
<el-table-column prop="channel" label="渠道" width="80" />