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).
This commit is contained in:
Hermes CI Fix
2026-09-18 20:12:46 +08:00
parent b072602191
commit d03e322a81
4 changed files with 389 additions and 4 deletions
+6 -1
View File
@@ -238,6 +238,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}",
@@ -340,6 +341,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 +351,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":
+1
View File
@@ -115,6 +115,7 @@ def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depen
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}",
+7 -3
View File
@@ -6,6 +6,7 @@ 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
import json
import logging
@@ -22,6 +23,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 +49,9 @@ def push_deviation_to_map(
if new_value is None:
raise HTTPException(400, "缺少 newValue")
# 1. 查找战略地图
# 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,7 +91,7 @@ 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 表
@@ -147,11 +149,13 @@ def push_deviation_to_map(
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'}",
@@ -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}"