From ad953ac759385fb023322cce1fcd9604e583bce0 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 18 Sep 2026 18:15:22 +0800 Subject: [PATCH] fix(cma): entity isolation batch2 (roll-forward/check-timeout/by-id/cost-abc) Scope: backend/app/api/{alerts,action_plans,budget,cost}.py + tests Task: 0-P1-project-20260918-entity-isolation-batch2 --- backend/app/api/action_plans.py | 13 +- backend/app/api/alerts.py | 29 +- backend/app/api/budget.py | 10 +- backend/app/api/cost.py | 15 +- ..._cross_entity_isolation_batch2_20260918.py | 424 ++++++++++++++++++ 5 files changed, 467 insertions(+), 24 deletions(-) create mode 100644 backend/tests/test_cross_entity_isolation_batch2_20260918.py diff --git a/backend/app/api/action_plans.py b/backend/app/api/action_plans.py index dc2e4898..8123edf0 100644 --- a/backend/app/api/action_plans.py +++ b/backend/app/api/action_plans.py @@ -241,9 +241,11 @@ def update_plan( plan_id: int, data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), ): - """更新行动计划""" - plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first() + """更新行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)""" + plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter( + ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first() if not plan: raise HTTPException(404, "计划不存在") @@ -285,9 +287,10 @@ def update_plan( @router.delete("/{plan_id}") -def delete_plan(plan_id: int, db: Session = Depends(get_db)): - """删除行动计划""" - plan = db.query(ActionPlan).filter(ActionPlan.id == plan_id).first() +def delete_plan(plan_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + """删除行动计划(账套隔离: join KPI按企业过滤, 2026-09-18)""" + plan = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter( + ActionPlan.id == plan_id, KPIDefinition.entity_id == entity_id).first() if not plan: raise HTTPException(404, "计划不存在") db.delete(plan) diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py index aa460abb..1c88e63e 100644 --- a/backend/app/api/alerts.py +++ b/backend/app/api/alerts.py @@ -45,9 +45,10 @@ def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db), enti @router.post("/{alert_id}/process") -def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): - """标记预警为处理中""" - alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first() +def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + """标记预警为处理中(账套隔离: join kpi_definitions 按企业过滤, 2026-09-18)""" + alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter( + KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first() if not alert: raise HTTPException(404, "预警不存在") if alert.status == "resolved": @@ -63,9 +64,10 @@ def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): @router.post("/{alert_id}/escalate") -def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): - """升级预警级别""" - alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first() +def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + """升级预警级别(账套隔离: join kpi_definitions 按企业过滤, 2026-09-18)""" + alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter( + KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first() if not alert: raise HTTPException(404, "预警不存在") assignee = data.get("assignee") @@ -82,11 +84,13 @@ def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): @router.get("/check-timeout") -def check_alert_timeout(db: Session = Depends(get_db)): - """超时预警检测 — 超过24小时未处理的pending预警自动升级为红色""" +def check_alert_timeout(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + """超时预警检测 — 超过24小时未处理的pending预警自动升级为红色 + (账套隔离: join kpi_definitions 按企业过滤, 2026-09-18 — 原为全账套批处理)""" from datetime import datetime, timedelta cutoff = datetime.now() - timedelta(hours=24) - timeout_alerts = db.query(KPIAlert).filter( + timeout_alerts = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter( + KPIDefinition.entity_id == entity_id, KPIAlert.status == "pending", KPIAlert.created_at < cutoff, KPIAlert.alert_level != "red", @@ -102,9 +106,10 @@ def check_alert_timeout(db: Session = Depends(get_db)): @router.post("/{alert_id}/create-action-plan") -def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db)): - """从预警创建改善行动计划""" - alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first() +def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + """从预警创建改善行动计划(账套隔离: join kpi_definitions 按企业过滤, 2026-09-18)""" + alert = db.query(KPIAlert).join(KPIDefinition, KPIDefinition.id == KPIAlert.kpi_id).filter( + KPIAlert.id == alert_id, KPIDefinition.entity_id == entity_id).first() if not alert: raise HTTPException(404, "预警不存在") if alert.action_plan_linked_id: diff --git a/backend/app/api/budget.py b/backend/app/api/budget.py index 35f5fa47..5123ed62 100644 --- a/backend/app/api/budget.py +++ b/backend/app/api/budget.py @@ -534,12 +534,14 @@ def set_budget_config( def budget_roll_forward( db: Session = Depends(get_db), current_user=Depends(require_auth), + entity_id: int = Depends(get_entity_id), ): """ 滚动预算自动延展: - 删除最早一个月的预测数据 - 新增未来一个月的预测数据(取最近三个月均值) - 返回延展结果 + 账套隔离:只处理会话账套(join kpi_definitions 按企业过滤),2026-09-18 """ from app.models import SystemConfig cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first() @@ -557,8 +559,11 @@ def budget_roll_forward( now = datetime.now() current_year, current_month = now.year, now.month - # 获取所有active的预算记录 - plans = db.query(BudgetPlan).filter(BudgetPlan.status == "active").all() + # 获取本账套 active 的预算记录(账套隔离: join KPI 按企业过滤, 2026-09-18 — 原为全账套) + plans = (db.query(BudgetPlan) + .join(KPIDefinition, BudgetPlan.kpi_id == KPIDefinition.id) + .filter(BudgetPlan.status == "active", KPIDefinition.entity_id == entity_id) + .all()) # 按KPI分组 from collections import defaultdict @@ -598,6 +603,7 @@ def budget_roll_forward( if not existing: bp = BudgetPlan( + entity_id=entity_id, kpi_id=kpi_id, period=new_period, budget_value=avg_value, diff --git a/backend/app/api/cost.py b/backend/app/api/cost.py index 104dc739..4acbc63d 100644 --- a/backend/app/api/cost.py +++ b/backend/app/api/cost.py @@ -176,14 +176,19 @@ def create_actual_cost(data: dict, db: Session = Depends(get_db), # ============================================================ # ABC 作业成本 -# 注:ABC 作业中心/分配为单实体演示数据(ab_activities/ab_allocations 全为 entity 1, -# 历史演示用途),OpenCode 多租户审查 2026-09-07 判定暂不强制按账套隔离;若启用多企业 ABC 需补 entity 过滤。 +# 账套隔离(2026-09-18,0-P1-project-20260918-entity-isolation-batch2): +# GET /abc/activities 原为全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心; +# 现按会话账套过滤(AbcActivity.entity_id)。 +# OpenCode 多租户审查 2026-09-07 的「单实体演示数据暂不强制隔离」豁免就此撤销。 # ============================================================ @router.get("/abc/activities") -def list_abc_activities(db: Session = Depends(get_db)): - """查询ABC作业中心列表""" - items = db.query(AbcActivity).order_by(AbcActivity.activity_code).all() +def list_abc_activities(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + """查询ABC作业中心列表(账套隔离:只返回本账套)""" + items = (db.query(AbcActivity) + .filter(AbcActivity.entity_id == entity_id) + .order_by(AbcActivity.activity_code) + .all()) return {"data": items} diff --git a/backend/tests/test_cross_entity_isolation_batch2_20260918.py b/backend/tests/test_cross_entity_isolation_batch2_20260918.py new file mode 100644 index 00000000..4c1f6486 --- /dev/null +++ b/backend/tests/test_cross_entity_isolation_batch2_20260918.py @@ -0,0 +1,424 @@ +"""CMA 跨实体隔离收口(第二批)账套隔离回归测试 — 2026-09-18 + +任务:0-P1-project-20260918-entity-isolation-batch2(项目Bot 派单,任总 2026-09-18 17:59 放行) +缺陷(本单范围): +1. 写型·列表批处理 + - POST /api/cma/budget/roll-forward 全账套 active 预算一起删最早月/加新月 + - GET /api/cma/alerts/check-timeout 全账套 pending 超时预警一起升级为 red +2. 写型/读型·按 id 直取(无归属校验 → 跨账套可达) + - POST /api/cma/alerts/{alert_id}/process | /escalate | /create-action-plan + - PUT /api/cma/action-plans/{plan_id} | DELETE /api/cma/action-plans/{plan_id} +3. 读型·已在发生 + - GET /api/cma/cost/abc/activities 全表取(生产 10 行全属 entity2)→ 酣客会话可见博海 ABC 作业中心 + +本用例覆盖(每条均双向 + 同账套正对照): +- 写型批处理:会话账套触发只影响本账套行,对端账套行前后逐字段不变; +- 按 id 直取:跨账套 id → 404(对齐同文件既有语义),本账套 id → 正常;且跨账套请求后对端行未被修改/删除; +- 读型:会话账套只返回本账套行,不含对端行。 + +修复前(旧码)本文件在下列断言上必失败 —— 即「改前/改后对照」中的改前证据(见回报 §三)。 +""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +import json + +from app.models import ( + AbcActivity, + ActionPlan, + BudgetPlan, + Entity, + KPIAlert, + KPIDefinition, + SystemConfig, +) +from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user + +ALERTS = "/api/cma/alerts" +BUDGET = "/api/cma/budget" +COST = "/api/cma/cost" +PLANS = "/api/cma/action-plans" + +# 账套 1/2 各自的资源编码前缀(kpi_code / activity_code 库内唯一) +E1_KPI = "ZZ_B2_E1_KPI_001" +E2_KPI = "ZZ_B2_E2_KPI_001" +E1_ACT = "ZZ_B2_E1_ACT_001" +E2_ACT = "ZZ_B2_E2_ACT_001" +E2_ACT2 = "ZZ_B2_E2_ACT_002" + + +# ────────────────────────────────────────────── +# 夹具 +# ────────────────────────────────────────────── + +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 _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 _set_rolling(db: Session) -> None: + """滚动预算模式(全局 SystemConfig,配置值为 JSON 文本,与 /budget/config 写入口径一致)""" + cfg = db.query(SystemConfig).filter(SystemConfig.config_key == "budget_mode").first() + val = json.dumps({"mode": "rolling", "rolling_months": 12}, ensure_ascii=False) + if cfg: + cfg.config_value = val + else: + db.add(SystemConfig(config_key="budget_mode", config_value=val, + description="预算模式: fixed=固定预算, rolling=滚动预算")) + db.commit() + + +def _seed_plans(db: Session, kpi_id: int, eid: int, months: int, base: float) -> None: + for m in range(1, months + 1): + db.add(BudgetPlan( + entity_id=eid, kpi_id=kpi_id, period=f"2026-{m:02d}", + budget_value=base + m, budget_year=2026, budget_month=m, + version="v1.0", status="active", + )) + db.commit() + + +def _plan_snapshot(db: Session, eid: int) -> list: + """某账套全部预算行(按 period 排序,逐字段快照)""" + rows = (db.query(BudgetPlan) + .filter(BudgetPlan.entity_id == eid) + .order_by(BudgetPlan.period) + .all()) + return [ + (r.id, r.kpi_id, r.period, round(r.budget_value, 4), r.budget_year, + r.budget_month, r.status, r.version) + for r in rows + ] + + +def _mk_alert(db: Session, kpi_id: int, eid: int, **kw) -> KPIAlert: + alert = KPIAlert( + entity_id=eid, + kpi_id=kpi_id, + alert_level=kw.get("alert_level", "yellow"), + alert_message=kw.get("alert_message", f"e{eid} 测试预警"), + status=kw.get("status", "pending"), + created_at=kw.get("created_at", datetime.now()), + ) + db.add(alert) + db.commit() + db.refresh(alert) + return alert + + +def _mk_plan(db: Session, kpi_id: int, eid: int, title: str) -> ActionPlan: + plan = ActionPlan(entity_id=eid, kpi_id=kpi_id, title=title, + assignee="张三", priority="medium", status="pending", progress=0) + db.add(plan) + db.commit() + db.refresh(plan) + return plan + + +def _mk_activity(db: Session, code: str, eid: int) -> AbcActivity: + act = AbcActivity( + entity_id=eid, activity_code=code, activity_name=f"{code}-作业", + cost_driver="动因", driver_unit="次", total_cost=1000.0, driver_volume=10, + driver_rate=100.0, status="active", + ) + db.add(act) + db.commit() + db.refresh(act) + return act + + +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 + + +# ────────────────────────────────────────────── +# 1. 写型·列表批处理:budget/roll-forward 按会话账套 +# ────────────────────────────────────────────── + +def test_roll_forward_only_touches_session_entity(client: TestClient, db: Session): + """酣客会话滚动延展 → 只动 e1 预算,e2 预算前后逐字段不变(含行数/最早月/新月份)""" + k1, k2 = _seed_two_entities(db) + _set_rolling(db) + _seed_plans(db, k1.id, 1, months=12, base=1000.0) # e1: 12 个月 + _seed_plans(db, k2.id, 2, months=3, base=2000.0) # e2: 3 个月 + + before_e1 = _plan_snapshot(db, 1) + before_e2 = _plan_snapshot(db, 2) + assert len(before_e1) == 12 and len(before_e2) == 3 + + token1 = _login(client, 1) + r = client.post(f"{BUDGET}/roll-forward", headers=auth_header(token1)) + assert r.status_code == 200, r.text + data = r.json() + + after_e1 = _plan_snapshot(db, 1) + after_e2 = _plan_snapshot(db, 2) + + # 响应只含本账套 KPI + assert len(data["rolled_kpis"]) == 1, f"应只延展 1 个 KPI,实际 {data['rolled_kpis']}" + assert data["rolled_kpis"][0]["kpi_id"] == k1.id + + # e1:删 1 行加 1 行(行数不变,最早月被删) + assert len(after_e1) == 12 + e1_periods = [p for _, _, p, *_ in after_e1] + assert "2026-01" not in e1_periods + assert data["rolled_kpis"][0]["added_period"] in e1_periods + + # e2:逐字段完全未变(这是缺陷核心:跨账套批量写) + assert after_e2 == before_e2, ( + f"entity2 预算被跨账套修改\nbefore={before_e2}\nafter={after_e2}" + ) + + +def test_roll_forward_reverse_direction(client: TestClient, db: Session): + """博海会话滚动延展 → 只动 e2 预算,e1 预算前后不变""" + k1, k2 = _seed_two_entities(db) + _set_rolling(db) + _seed_plans(db, k1.id, 1, months=12, base=1000.0) + _seed_plans(db, k2.id, 2, months=3, base=2000.0) + + before_e1 = _plan_snapshot(db, 1) + + token2 = _login(client, 2) + r = client.post(f"{BUDGET}/roll-forward", headers=auth_header(token2)) + assert r.status_code == 200, r.text + data = r.json() + + assert len(data["rolled_kpis"]) == 1 + assert data["rolled_kpis"][0]["kpi_id"] == k2.id + assert _plan_snapshot(db, 1) == before_e1, "entity1 预算被 e2 会话跨账套修改" + + +# ────────────────────────────────────────────── +# 2. 写型·列表批处理:alerts/check-timeout 按会话账套 +# ────────────────────────────────────────────── + +def _alert_state(db: Session, eid: int): + rows = db.query(KPIAlert).filter(KPIAlert.entity_id == eid).order_by(KPIAlert.id).all() + return [(a.id, a.status, a.alert_level) for a in rows] + + +def test_check_timeout_only_upgrades_session_entity(client: TestClient, db: Session): + """酣客会话超时检测 → 只升级 e1 的 pending 预警,e2 保持 pending/yellow""" + k1, k2 = _seed_two_entities(db) + old = datetime.now() - timedelta(hours=25) + _mk_alert(db, k1.id, 1, created_at=old) + _mk_alert(db, k1.id, 1, created_at=old) + _mk_alert(db, k2.id, 2, created_at=old) + _mk_alert(db, k2.id, 2, created_at=old) + + token1 = _login(client, 1) + r = client.get(f"{ALERTS}/check-timeout", headers=auth_header(token1)) + assert r.status_code == 200, r.text + body = r.json() + assert body["total_timeout"] == 2, f"应只命中本账套 2 条,实际 {body}" + assert body["upgraded_count"] == 2 + + # e1 升级为 red/processing + for _id, status, level in _alert_state(db, 1): + assert (status, level) == ("processing", "red"), f"e1 预警 {_id} 未升级: {status}/{level}" + # e2 完全未动(缺陷核心:一次调用把所有账套升级) + for _id, status, level in _alert_state(db, 2): + assert (status, level) == ("pending", "yellow"), f"e2 预警 {_id} 被跨账套升级: {status}/{level}" + + +def test_check_timeout_reverse_direction(client: TestClient, db: Session): + """博海会话超时检测 → 只升级 e2,e1 保持 pending/yellow""" + k1, k2 = _seed_two_entities(db) + old = datetime.now() - timedelta(hours=25) + _mk_alert(db, k1.id, 1, created_at=old) + _mk_alert(db, k2.id, 2, created_at=old) + + token2 = _login(client, 2) + r = client.get(f"{ALERTS}/check-timeout", headers=auth_header(token2)) + assert r.status_code == 200, r.text + assert r.json()["upgraded_count"] == 1 + + for _id, status, level in _alert_state(db, 1): + assert (status, level) == ("pending", "yellow"), f"e1 预警 {_id} 被 e2 会话跨账套升级" + for _id, status, level in _alert_state(db, 2): + assert (status, level) == ("processing", "red") + + +# ────────────────────────────────────────────── +# 3. 按 id 直取:alerts 三条(跨账套 → 404,同账套 → 正常) +# ────────────────────────────────────────────── + +def test_alert_process_entity_scoped(client: TestClient, db: Session): + k1, k2 = _seed_two_entities(db) + mine = _mk_alert(db, k1.id, 1) + other = _mk_alert(db, k2.id, 2) + token1 = _login(client, 1) + + # 跨账套 → 404(对齐 /resolve 既有语义:查无此记录) + r = client.post(f"{ALERTS}/{other.id}/process", headers=auth_header(token1), + json={"assignee": "李四"}) + assert r.status_code == 404, f"跨账套 process 应 404,实际 {r.status_code} {r.text[:200]}" + db.refresh(other) + assert other.status == "pending", "跨账套 process 竟改动了对端账套记录" + + # 同账套 → 正常 + r = client.post(f"{ALERTS}/{mine.id}/process", headers=auth_header(token1), + json={"assignee": "李四"}) + assert r.status_code == 200, r.text + db.refresh(mine) + assert mine.status == "processing" and mine.assignee == "李四" + + +def test_alert_escalate_entity_scoped(client: TestClient, db: Session): + k1, k2 = _seed_two_entities(db) + mine = _mk_alert(db, k1.id, 1) + other = _mk_alert(db, k2.id, 2) + token1 = _login(client, 1) + + r = client.post(f"{ALERTS}/{other.id}/escalate", headers=auth_header(token1), + json={"assignee": "李四"}) + assert r.status_code == 404, f"跨账套 escalate 应 404,实际 {r.status_code} {r.text[:200]}" + db.refresh(other) + assert other.alert_level == "yellow", "跨账套 escalate 竟升级了对端账套预警" + + r = client.post(f"{ALERTS}/{mine.id}/escalate", headers=auth_header(token1), + json={"assignee": "李四"}) + assert r.status_code == 200, r.text + db.refresh(mine) + assert mine.alert_level == "red" + + +def test_alert_create_action_plan_entity_scoped(client: TestClient, db: Session): + k1, k2 = _seed_two_entities(db) + mine = _mk_alert(db, k1.id, 1) + other = _mk_alert(db, k2.id, 2) + token1 = _login(client, 1) + + r = client.post(f"{ALERTS}/{other.id}/create-action-plan", headers=auth_header(token1), + json={"assignee": "李四"}) + assert r.status_code == 404, f"跨账套 create-action-plan 应 404,实际 {r.status_code} {r.text[:200]}" + assert db.query(ActionPlan).filter(ActionPlan.alert_id == other.id).count() == 0, \ + "跨账套 create-action-plan 竟为对端账套预警建了计划" + db.refresh(other) + assert other.action_plan_linked_id is None + + r = client.post(f"{ALERTS}/{mine.id}/create-action-plan", headers=auth_header(token1), + json={"assignee": "李四"}) + assert r.status_code == 200, r.text + db.refresh(mine) + assert mine.action_plan_linked_id is not None + + +# ────────────────────────────────────────────── +# 4. 按 id 直取:action-plans 改/删(跨账套 → 404,同账套 → 正常) +# ────────────────────────────────────────────── + +def test_update_plan_entity_scoped(client: TestClient, db: Session): + k1, k2 = _seed_two_entities(db) + mine = _mk_plan(db, k1.id, 1, "e1 计划") + other = _mk_plan(db, k2.id, 2, "e2 计划") + token1 = _login(client, 1) + + r = client.put(f"{PLANS}/{other.id}", headers=auth_header(token1), + json={"title": "被跨账套改名", "status": "completed"}) + assert r.status_code == 404, f"跨账套 update 应 404,实际 {r.status_code} {r.text[:200]}" + db.refresh(other) + assert other.title == "e2 计划" and other.status == "pending", "跨账套 update 竟改动了对端计划" + + r = client.put(f"{PLANS}/{mine.id}", headers=auth_header(token1), + json={"title": "本账套改名", "status": "in_progress"}) + assert r.status_code == 200, r.text + db.refresh(mine) + assert mine.title == "本账套改名" and mine.status == "in_progress" + + +def test_delete_plan_entity_scoped(client: TestClient, db: Session): + k1, k2 = _seed_two_entities(db) + mine = _mk_plan(db, k1.id, 1, "e1 计划") + other = _mk_plan(db, k2.id, 2, "e2 计划") + token1 = _login(client, 1) + + r = client.delete(f"{PLANS}/{other.id}", headers=auth_header(token1)) + assert r.status_code == 404, f"跨账套 delete 应 404,实际 {r.status_code} {r.text[:200]}" + assert db.query(ActionPlan).filter(ActionPlan.id == other.id).count() == 1, \ + "跨账套 delete 竟删除了对端账套计划" + + r = client.delete(f"{PLANS}/{mine.id}", headers=auth_header(token1)) + assert r.status_code == 200, r.text + assert db.query(ActionPlan).filter(ActionPlan.id == mine.id).count() == 0 + + +# ────────────────────────────────────────────── +# 5. 读型:cost/abc/activities 按会话账套 +# ────────────────────────────────────────────── + +def _abc_codes(client: TestClient, token: str) -> set: + r = client.get(f"{COST}/abc/activities", headers=auth_header(token)) + assert r.status_code == 200, r.text + return {row["activity_code"] for row in r.json()["data"]} + + +def test_abc_activities_isolated_by_entity(client: TestClient, db: Session): + """酣客会话不应看到博海 ABC 作业中心(生产:10 行全属 entity2)""" + _seed_two_entities(db) + _mk_activity(db, E1_ACT, 1) + _mk_activity(db, E2_ACT, 2) + _mk_activity(db, E2_ACT2, 2) + + token1 = _login(client, 1) + token2 = _login(client, 2) + + codes1 = _abc_codes(client, token1) + codes2 = _abc_codes(client, token2) + + assert codes1 == {E1_ACT}, f"entity1 会话应只见本账套作业中心,实际 {sorted(codes1)}" + assert not any(c.startswith("ZZ_B2_E2_") for c in codes1), "酣客会话混入博海 ABC 作业中心" + assert codes2 == {E2_ACT, E2_ACT2}, f"entity2 会话应只见本账套,实际 {sorted(codes2)}" + + +def test_abc_activities_without_token_401(client: TestClient, db: Session): + """鉴权底线不变:无 Bearer token → 401""" + _seed_two_entities(db) + _mk_activity(db, E1_ACT, 1) + r = client.get(f"{COST}/abc/activities") + assert r.status_code == 401, f"无 token 应 401,实际 {r.status_code}"