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).
This commit is contained in:
@@ -184,6 +184,11 @@ def create_plan(
|
||||
_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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user