diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index ac4749b6..43237e36 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -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, diff --git a/backend/app/api/okr.py b/backend/app/api/okr.py index cd7cd207..04edc4e7 100644 --- a/backend/app/api/okr.py +++ b/backend/app/api/okr.py @@ -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() diff --git a/backend/tests/test_actionplan_entity_write_and_okr_read_20260918.py b/backend/tests/test_actionplan_entity_write_and_okr_read_20260918.py new file mode 100644 index 00000000..9a82a7e5 --- /dev/null +++ b/backend/tests/test_actionplan_entity_write_and_okr_read_20260918.py @@ -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}"