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)
|
_validate_due_date_against_quarter(due_date, obj.quarter)
|
||||||
|
|
||||||
plan = ActionPlan(
|
plan = ActionPlan(
|
||||||
|
# 账套隔离(2026-09-18 batch3 派生单):必须显式写会话账套 ——
|
||||||
|
# 旧实现不写 entity_id,落模型默认值 1(e2 会话创建的计划库内 entity_id=1,
|
||||||
|
# 实测 OBS1)。list/stats/update/delete 靠 join KPIDefinition 判账套,
|
||||||
|
# 但任何按 ActionPlan.entity_id 过滤的读路径(如 OKR 分解视图)会失效。
|
||||||
|
entity_id=entity_id,
|
||||||
alert_id=data.get("alert_id"),
|
alert_id=data.get("alert_id"),
|
||||||
kpi_id=data["kpi_id"],
|
kpi_id=data["kpi_id"],
|
||||||
objective_id=objective_id,
|
objective_id=objective_id,
|
||||||
|
|||||||
@@ -547,6 +547,11 @@ def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db),
|
|||||||
week_end = week_start + timedelta(days=7)
|
week_end = week_start + timedelta(days=7)
|
||||||
action_plans = db.query(ActionPlan).filter(
|
action_plans = db.query(ActionPlan).filter(
|
||||||
ActionPlan.objective_id == okr_id,
|
ActionPlan.objective_id == okr_id,
|
||||||
|
# 账套隔离(2026-09-18 batch3 派生单):objective_id 已由
|
||||||
|
# _get_objective_or_404 校验归属,但计划侧此前无账套条件 →
|
||||||
|
# 对端账套计划(含脏行)会出现在本账套分解视图(实测 OBS2)。
|
||||||
|
# 前置条件:create_plan 必须已写会话账套 entity_id(顺序铁律)。
|
||||||
|
ActionPlan.entity_id == entity_id,
|
||||||
ActionPlan.due_date.between(week_start, week_end)
|
ActionPlan.due_date.between(week_start, week_end)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|||||||
@@ -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