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:
@@ -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}"
|
||||
Reference in New Issue
Block a user