diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index 694995f6..dcbd9b83 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -124,7 +124,10 @@ def list_plans( item["kpi_dimension"] = kpi.dimension if kpi else None # 附带KR信息(行动挂KR 2026-08-27) if p.kr_id is not None: - kr = db.query(KR).filter(KR.id == p.kr_id).first() + # 账套隔离(2026-09-18 batch3 读侧同族残余):kr_id 可能指向对端账套 KR(存量脏行, + # 写入侧 create_plan/update_plan 已校验归属但旧行仍存在)→ 富化查询必须带账套, + # 否则列表跨账套回显对端 KR 标题 + kr = db.query(KR).filter(KR.id == p.kr_id, KR.entity_id == entity_id).first() item["kr_title"] = kr.title if kr else None item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None else: diff --git a/backend/tests/test_cross_entity_isolation_batch3_20260918.py b/backend/tests/test_cross_entity_isolation_batch3_20260918.py index f2390f52..0ebf7120 100644 --- a/backend/tests/test_cross_entity_isolation_batch3_20260918.py +++ b/backend/tests/test_cross_entity_isolation_batch3_20260918.py @@ -127,7 +127,7 @@ def _mk_allocation(db: Session, activity: AbcActivity, eid: int, product_code: s def _mk_plan(db: Session, kpi_id: int, eid: int, title: str, **kw) -> ActionPlan: - plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, title=title, + plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, kr_id=kw.get("kr_id"), title=title, assignee=kw.get("assignee", "张三"), priority="medium", status=kw.get("status", "pending"), progress=0) db.add(plan) @@ -394,6 +394,59 @@ def test_plan_stats_reverse_direction(client: TestClient, db: Session): assert s2["pending"] == 1 +# ────────────────────────────────────────────── +# D6. GET /action-plans 的 KR 富化按会话账套(batch3 读侧同族残余) +# ────────────────────────────────────────────── +# 缺陷:list_plans 富化 kr_title 的查询 db.query(KR).filter(KR.id == p.kr_id) 无账套校验 +# → 存量脏行(action_plans.kr_id 指向对端账套 KR)会让列表跨账套回显对端 KR 标题。 +# 写入侧已在 batch3 修(update_plan/create_plan 校验 kr_id 归属),本用例覆盖读侧。 + +def _list_plans(client: TestClient, token: str) -> dict: + r = client.get(PLANS, headers=auth_header(token)) + assert r.status_code == 200, r.text + return {row["id"]: row for row in r.json()["data"]} + + +def test_list_plans_kr_enrichment_isolated_by_entity(client: TestClient, db: Session): + """双向:本账套计划挂对端 KR(存量脏行)→ 列表不回显对端 KR 标题;挂本账套 KR → 正常回显""" + k1, k2 = _seed_two_entities(db) + kr1 = _mk_objective_kr(db, 1, "e1 KR") + kr2 = _mk_objective_kr(db, 2, "e2 KR") + + # e1 账套:一条挂本账套 KR(正对照)+ 一条脏行挂 e2 的 KR + good1 = _mk_plan(db, k1.id, 1, "zz e1 挂本账套 KR", kr_id=kr1.id) + dirty1 = _mk_plan(db, k1.id, 1, "zz e1 脏行挂对端 KR", kr_id=kr2.id) + # e2 账套:反向同理 + good2 = _mk_plan(db, k2.id, 2, "zz e2 挂本账套 KR", kr_id=kr2.id) + dirty2 = _mk_plan(db, k2.id, 2, "zz e2 脏行挂对端 KR", kr_id=kr1.id) + + rows1 = _list_plans(client, _login(client, 1)) + assert good1.id in rows1 and dirty1.id in rows1, f"e1 会话看不到本账套计划: {sorted(rows1)}" + assert rows1[good1.id]["kr_title"] == "e1 KR", ( + f"e1 本账套 KR 标题未回显: {rows1[good1.id].get('kr_title')!r}" + ) + assert rows1[dirty1.id]["kr_title"] is None, ( + f"e1 会话跨账套回显了 e2 的 KR 标题 {rows1[dirty1.id]['kr_title']!r}" + "(缺陷:list_plans 富化 kr 查询缺 KR.entity_id 校验)" + ) + assert rows1[dirty1.id]["kr_metric_kpi_id"] is None, ( + f"e1 会话跨账套回显了 e2 KR 的 metric_kpi_id = {rows1[dirty1.id]['kr_metric_kpi_id']!r}" + ) + + rows2 = _list_plans(client, _login(client, 2)) + assert good2.id in rows2 and dirty2.id in rows2, f"e2 会话看不到本账套计划: {sorted(rows2)}" + assert rows2[good2.id]["kr_title"] == "e2 KR", ( + f"e2 本账套 KR 标题未回显: {rows2[good2.id].get('kr_title')!r}" + ) + assert rows2[dirty2.id]["kr_title"] is None, ( + f"e2 会话跨账套回显了 e1 的 KR 标题 {rows2[dirty2.id]['kr_title']!r}" + ) + assert rows2[dirty2.id]["kr_metric_kpi_id"] is None + + # 计划本体归属未被本改动影响:e1 列表不含 e2 的两条计划 + assert good2.id not in rows1 and dirty2.id not in rows1, "e1 列表混入了 e2 账套计划" + + # ────────────────────────────────────────────── # 鉴权底线(改前改后都应通过) # ──────────────────────────────────────────────