fix(cma): action plan entity_id at the three residual construction sites
alert-to-plan, deviation-push review topic and AI-suggestion-to-plan built ActionPlan(...) without entity_id, so non-e1 sessions wrote rows with the model default 1 and entity_id stayed untrustworthy on those paths. Write the session entity at all three sites, scope the deviation-push topic dedupe by entity (a foreign same-title topic no longer suppresses the session entity's own topic), and require the applied suggestion to belong to the session entity. Adds 7 regression tests (both directions + cross-entity negative controls + auth floor).
This commit is contained in:
@@ -238,6 +238,7 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use
|
|||||||
due_date = None
|
due_date = None
|
||||||
|
|
||||||
plan = ActionPlan(
|
plan = ActionPlan(
|
||||||
|
entity_id=sug.entity_id, # 2026-09-18 派生单: 写建议归属账套(=会话账套,端点已校验)
|
||||||
kpi_id=kpi_id,
|
kpi_id=kpi_id,
|
||||||
title=title,
|
title=title,
|
||||||
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
|
description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}",
|
||||||
@@ -340,6 +341,7 @@ def apply_suggestion(
|
|||||||
suggestion_id: int,
|
suggestion_id: int,
|
||||||
data: dict,
|
data: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
|
"""应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕)
|
||||||
@@ -349,7 +351,10 @@ def apply_suggestion(
|
|||||||
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
|
{"action": "budget_adjust", "period": "2026-09", "budget_value": 100000}
|
||||||
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
|
{"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"}
|
||||||
"""
|
"""
|
||||||
sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first()
|
sug = db.query(AISuggestion).filter(
|
||||||
|
AISuggestion.id == suggestion_id,
|
||||||
|
AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套
|
||||||
|
).first()
|
||||||
if not sug:
|
if not sug:
|
||||||
raise HTTPException(404, "建议不存在")
|
raise HTTPException(404, "建议不存在")
|
||||||
if sug.status == "applied":
|
if sug.status == "applied":
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depen
|
|||||||
if alert.action_plan_linked_id:
|
if alert.action_plan_linked_id:
|
||||||
raise HTTPException(400, "已关联改善计划")
|
raise HTTPException(400, "已关联改善计划")
|
||||||
plan = ActionPlan(
|
plan = ActionPlan(
|
||||||
|
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
|
||||||
alert_id=alert.id,
|
alert_id=alert.id,
|
||||||
kpi_id=alert.kpi_id,
|
kpi_id=alert.kpi_id,
|
||||||
title=f"改善: {alert.alert_message}",
|
title=f"改善: {alert.alert_message}",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
|
from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -22,6 +23,7 @@ router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"],
|
|||||||
def push_deviation_to_map(
|
def push_deviation_to_map(
|
||||||
data: dict,
|
data: dict,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""从差异分析回写实际值到战略地图节点
|
"""从差异分析回写实际值到战略地图节点
|
||||||
@@ -47,9 +49,9 @@ def push_deviation_to_map(
|
|||||||
if new_value is None:
|
if new_value is None:
|
||||||
raise HTTPException(400, "缺少 newValue")
|
raise HTTPException(400, "缺少 newValue")
|
||||||
|
|
||||||
# 1. 查找战略地图
|
# 1. 查找战略地图(2026-09-18 派生单: 地图须归属会话账套,NULL 归属按 e1 兼容)
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||||
if not m:
|
if not m or (m.entity_id or 1) != entity_id:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
# 2. 解析 node_id 格式: "finance-0"
|
# 2. 解析 node_id 格式: "finance-0"
|
||||||
@@ -89,7 +91,7 @@ def push_deviation_to_map(
|
|||||||
|
|
||||||
kpi_code = kpi_codes[0]
|
kpi_code = kpi_codes[0]
|
||||||
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first()
|
||||||
if not kpi:
|
if not kpi or (kpi.entity_id or 1) != entity_id:
|
||||||
raise HTTPException(404, f"KPI {kpi_code} 不存在")
|
raise HTTPException(404, f"KPI {kpi_code} 不存在")
|
||||||
|
|
||||||
# 3. 更新实际值到 KPIValue 表
|
# 3. 更新实际值到 KPIValue 表
|
||||||
@@ -147,11 +149,13 @@ def push_deviation_to_map(
|
|||||||
if create_review_topic:
|
if create_review_topic:
|
||||||
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
|
topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}"
|
||||||
existing_topic = db.query(ActionPlan).filter(
|
existing_topic = db.query(ActionPlan).filter(
|
||||||
|
ActionPlan.entity_id == entity_id, # 2026-09-18 派生单: 去重按账套隔离
|
||||||
ActionPlan.title == topic_title,
|
ActionPlan.title == topic_title,
|
||||||
ActionPlan.status.in_(["pending", "in_progress"]),
|
ActionPlan.status.in_(["pending", "in_progress"]),
|
||||||
).first()
|
).first()
|
||||||
if not existing_topic:
|
if not existing_topic:
|
||||||
topic = ActionPlan(
|
topic = ActionPlan(
|
||||||
|
entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1)
|
||||||
kpi_id=kpi.id,
|
kpi_id=kpi.id,
|
||||||
title=topic_title,
|
title=topic_title,
|
||||||
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
|
description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}",
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""CMA 跨实体隔离 — ActionPlan 归属写侧「残余三处构造点」(2026-09-18 派生单)
|
||||||
|
|
||||||
|
任务:fullstack-20260918-2015-actionplan-entity-write-residual-sites
|
||||||
|
父单:fullstack-20260918-1905b-actionplan-entity-write-and-okr-read
|
||||||
|
(其 §五 范围外只读侦查登记的三处同族缺口)
|
||||||
|
|
||||||
|
缺陷(`ActionPlan.entity_id` 模型默认 1,以下三处构造均不写会话账套):
|
||||||
|
1. app/api/alerts.py::create_action_plan_from_alert — 预警关联生成改善计划
|
||||||
|
2. app/api/deviation_push.py::push_deviation_to_map — 差异反打自动生成回顾会议题
|
||||||
|
3. app/api/ai_suggestions.py::_apply_action_plan — AI 建议转行动计划
|
||||||
|
|
||||||
|
后果:非 e1 账套经这三个入口创建的计划落 `entity_id=1`(不可信字段)。一旦读路径按
|
||||||
|
`ActionPlan.entity_id` 过滤(1905b 已在 OKR 分解视图加该条件),即出现跨账套泄漏或
|
||||||
|
「自己建的计划看不见」的假阴性。
|
||||||
|
|
||||||
|
本用例(改前失败条目见 _reports/evidence-fullstack-20260918-2015-red.txt):
|
||||||
|
- T1 预警关联生成计划:e2 会话 → 库内 entity_id=2;e1 会话 → 1(双向 + 正对照);
|
||||||
|
并附跨账套负对照(e2 token 打 e1 预警 → 404 且不落行)
|
||||||
|
- T2 差异反打生成议题:e2 会话 → 库内 entity_id=2(kpi_id 亦须落在本账套 KPI 上);
|
||||||
|
e1 会话 → 1(双向)
|
||||||
|
- T3 差异反打去重按账套隔离:对端账套存在同名 pending 议题,不得抑制本账套创建
|
||||||
|
- T4 AI 建议转计划:e2 会话 → 库内 entity_id=2;e1 会话 → 1(双向)
|
||||||
|
- T5 AI 建议越权负对照:对端账套的建议不得被本账套会话应用(404 + 零落行)
|
||||||
|
- T6 鉴权底线:三个入口无 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, AISuggestion, Entity, KPIAlert, KPIDefinition, StrategicMap
|
||||||
|
from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user
|
||||||
|
|
||||||
|
ALERTS = "/api/cma/alerts"
|
||||||
|
DPUSH = "/api/cma/deviation-push/push-to-map"
|
||||||
|
SUGG = "/api/cma/ai/suggestions"
|
||||||
|
|
||||||
|
# kpi_code 全局唯一(models.KPIDefinition.kpi_code unique=True)→ 每个账套用独立 code
|
||||||
|
E1_ALERT_KPI = "ZZ_APRES_E1_ALERT_KPI"
|
||||||
|
E2_ALERT_KPI = "ZZ_APRES_E2_ALERT_KPI"
|
||||||
|
E1_DPUSH_KPI = "ZZ_APRES_E1_DPUSH_KPI"
|
||||||
|
E2_DPUSH_KPI = "ZZ_APRES_E2_DPUSH_KPI"
|
||||||
|
E1_SUG_KPI = "ZZ_APRES_E1_SUG_KPI"
|
||||||
|
E2_SUG_KPI = "ZZ_APRES_E2_SUG_KPI"
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 夹具(沿用 1905b 同族风格:双向登录 + 同账套正对照)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
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, target: float = 1000.0) -> 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=target,
|
||||||
|
kpi_level="operational",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
db.add(kpi)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(kpi)
|
||||||
|
return kpi
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_alert(db: Session, eid: int, kpi_id: int, message: str) -> KPIAlert:
|
||||||
|
alert = KPIAlert(
|
||||||
|
entity_id=eid,
|
||||||
|
kpi_id=kpi_id,
|
||||||
|
alert_level="red",
|
||||||
|
alert_message=message,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db.add(alert)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(alert)
|
||||||
|
return alert
|
||||||
|
|
||||||
|
|
||||||
|
def _mk_map(db: Session, eid: int, title: str, kpi_code: str, obj_name: str) -> StrategicMap:
|
||||||
|
m = StrategicMap(
|
||||||
|
entity_id=eid,
|
||||||
|
title=title,
|
||||||
|
version="v1.0",
|
||||||
|
status="draft",
|
||||||
|
dimensions=[{
|
||||||
|
"key": "finance", "name": "财务维度", "icon": "F", "color": "#409eff",
|
||||||
|
"objectives": [{"name": obj_name, "kpis": [kpi_code]}],
|
||||||
|
}],
|
||||||
|
canvas_data={"connections": []},
|
||||||
|
)
|
||||||
|
db.add(m)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(m)
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_two_entities(db: Session):
|
||||||
|
_ensure_entity(db, 1, "酣客")
|
||||||
|
_ensure_entity(db, 2, "博海")
|
||||||
|
create_test_user(db)
|
||||||
|
|
||||||
|
|
||||||
|
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 _alert_plan_title(message: str) -> str:
|
||||||
|
return f"改善: {message}"
|
||||||
|
|
||||||
|
|
||||||
|
def _dpush_topic_title(kpi: KPIDefinition, obj_name: str) -> str:
|
||||||
|
return f"【差异反打】{kpi.kpi_name}偏差回写 — {obj_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _period() -> str:
|
||||||
|
return _dt.date.today().strftime("%Y-%m")
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# T1. 预警关联生成计划 → 会话账套(双向 + 跨账套负对照)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_alert_create_action_plan_writes_session_entity(client: TestClient, db: Session):
|
||||||
|
"""e1 会话 → 库内 entity_id=1;e2 会话 → 库内 entity_id=2(不得落模型默认值 1)"""
|
||||||
|
_seed_two_entities(db)
|
||||||
|
k1 = _mk_kpi(db, E1_ALERT_KPI, 1)
|
||||||
|
k2 = _mk_kpi(db, E2_ALERT_KPI, 2)
|
||||||
|
a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 营收严重下滑")
|
||||||
|
a2 = _mk_alert(db, 2, k2.id, "ZZ_APRES e2 营收严重下滑")
|
||||||
|
|
||||||
|
t1 = _alert_plan_title(a1.alert_message)
|
||||||
|
r1 = client.post(f"{ALERTS}/{a1.id}/create-action-plan",
|
||||||
|
headers=auth_header(_login(client, 1)), json={"assignee": "甲"})
|
||||||
|
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 = _alert_plan_title(a2.alert_message)
|
||||||
|
r2 = client.post(f"{ALERTS}/{a2.id}/create-action-plan",
|
||||||
|
headers=auth_header(_login(client, 2)), json={"assignee": "乙"})
|
||||||
|
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}"
|
||||||
|
" —— alerts.py 构造 ActionPlan 未写 entity_id,落模型默认值 1"
|
||||||
|
)
|
||||||
|
assert rows2[0].kpi_id == k2.id, (
|
||||||
|
f"e2 计划应挂 e2 自己的 KPI#{k2.id},实际 #{rows2[0].kpi_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_create_action_plan_cross_entity_404(client: TestClient, db: Session):
|
||||||
|
"""e2 token 打 e1 预警 → 404(读侧既有护栏,不得因写侧修复而退化)+ 零落行"""
|
||||||
|
_seed_two_entities(db)
|
||||||
|
k1 = _mk_kpi(db, E1_ALERT_KPI, 1)
|
||||||
|
a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 跨账套负对照")
|
||||||
|
|
||||||
|
r = client.post(f"{ALERTS}/{a1.id}/create-action-plan",
|
||||||
|
headers=auth_header(_login(client, 2)), json={"assignee": "乙"})
|
||||||
|
assert r.status_code == 404, f"跨账套应 404,实际 {r.status_code} {r.text[:200]}"
|
||||||
|
assert _plans_by_title(db, _alert_plan_title(a1.alert_message)) == [], (
|
||||||
|
"跨账套 create-action-plan 竟为对端账套预警建了计划"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# T2. 差异反打生成议题 → 会话账套(双向)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_deviation_push_writes_session_entity(client: TestClient, db: Session):
|
||||||
|
"""e1 会话 → 议题 entity_id=1;e2 会话 → 议题 entity_id=2(不得落默认值 1)"""
|
||||||
|
_seed_two_entities(db)
|
||||||
|
k1 = _mk_kpi(db, E1_DPUSH_KPI, 1)
|
||||||
|
k2 = _mk_kpi(db, E2_DPUSH_KPI, 2)
|
||||||
|
m1 = _mk_map(db, 1, "ZZ_APRES e1 地图", E1_DPUSH_KPI, "e1 目标")
|
||||||
|
m2 = _mk_map(db, 2, "ZZ_APRES e2 地图", E2_DPUSH_KPI, "e2 目标")
|
||||||
|
|
||||||
|
t1 = _dpush_topic_title(k1, "e1 目标")
|
||||||
|
r1 = client.post(DPUSH, headers=auth_header(_login(client, 1)), json={
|
||||||
|
"mapId": m1.id, "nodeId": "finance-0", "newValue": 900.0,
|
||||||
|
"period": _period(), "createReviewTopic": True,
|
||||||
|
})
|
||||||
|
assert r1.status_code == 200, f"e1 反打应 200,实际 {r1.status_code} {r1.text[:300]}"
|
||||||
|
assert r1.json()["review_topic_created"] is True, 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 = _dpush_topic_title(k2, "e2 目标")
|
||||||
|
r2 = client.post(DPUSH, headers=auth_header(_login(client, 2)), json={
|
||||||
|
"mapId": m2.id, "nodeId": "finance-0", "newValue": 900.0,
|
||||||
|
"period": _period(), "createReviewTopic": True,
|
||||||
|
})
|
||||||
|
assert r2.status_code == 200, f"e2 反打应 200,实际 {r2.status_code} {r2.text[:300]}"
|
||||||
|
assert r2.json()["review_topic_created"] is True, 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}"
|
||||||
|
" —— deviation_push.py 构造 ActionPlan 未写 entity_id,落模型默认值 1"
|
||||||
|
)
|
||||||
|
assert rows2[0].kpi_id == k2.id, (
|
||||||
|
f"e2 议题应挂 e2 自己的 KPI#{k2.id},实际 #{rows2[0].kpi_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# T3. 差异反打去重按账套隔离(对端同名议题不得抑制本账套创建)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_deviation_push_dedupe_scoped_to_entity(client: TestClient, db: Session):
|
||||||
|
"""e1 已有同名 pending 议题(含历史遗留 entity_id=1 行)不得抑制 e2 创建自己的议题"""
|
||||||
|
_seed_two_entities(db)
|
||||||
|
_mk_kpi(db, E1_DPUSH_KPI, 1)
|
||||||
|
k2 = _mk_kpi(db, E2_DPUSH_KPI, 2)
|
||||||
|
m2 = _mk_map(db, 2, "ZZ_APRES e2 地图去重", E2_DPUSH_KPI, "e2 去重目标")
|
||||||
|
|
||||||
|
t2 = _dpush_topic_title(k2, "e2 去重目标")
|
||||||
|
# 对端账套(e1)先落一条同标题的 pending 议题(模拟历史遗留/另一账套真实办理中)
|
||||||
|
foreign = ActionPlan(
|
||||||
|
entity_id=1, kpi_id=db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.kpi_code == E1_DPUSH_KPI).first().id,
|
||||||
|
title=t2, assignee="甲", priority="medium", status="pending", progress=0,
|
||||||
|
)
|
||||||
|
db.add(foreign)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
r2 = client.post(DPUSH, headers=auth_header(_login(client, 2)), json={
|
||||||
|
"mapId": m2.id, "nodeId": "finance-0", "newValue": 900.0,
|
||||||
|
"period": _period(), "createReviewTopic": True,
|
||||||
|
})
|
||||||
|
assert r2.status_code == 200, f"e2 反打应 200,实际 {r2.status_code} {r2.text[:300]}"
|
||||||
|
assert r2.json()["review_topic_created"] is True, (
|
||||||
|
"对端账套同名议题抑制了本账套创建(去重条件未按账套隔离)"
|
||||||
|
)
|
||||||
|
rows = _plans_by_title(db, t2)
|
||||||
|
assert len(rows) == 2, f"应有 e1 遗留 + e2 新建共 2 条,实际 {len(rows)}"
|
||||||
|
own = [x for x in rows if x.entity_id == 2]
|
||||||
|
assert len(own) == 1, (
|
||||||
|
f"e2 本账套议题未落库或落错账套: {[(x.id, x.entity_id) for x in rows]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# T4. AI 建议转行动计划 → 会话账套(双向)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _mk_suggestion(client: TestClient, token: str, kpi_id: int, title: str):
|
||||||
|
body = {
|
||||||
|
"suggestion_type": "action_plan",
|
||||||
|
"target_type": "kpi",
|
||||||
|
"target_id": kpi_id,
|
||||||
|
"title": title,
|
||||||
|
"content": "达成率低于阈值",
|
||||||
|
"suggestion_data": {"kpi_id": kpi_id},
|
||||||
|
}
|
||||||
|
r = client.post(SUGG, json=body, headers=auth_header(token))
|
||||||
|
assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}"
|
||||||
|
return r.json()["data"]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_suggestion_action_plan_writes_session_entity(client: TestClient, db: Session):
|
||||||
|
"""e1 会话 → 计划 entity_id=1;e2 会话 → 计划 entity_id=2(不得落默认值 1)"""
|
||||||
|
_seed_two_entities(db)
|
||||||
|
k1 = _mk_kpi(db, E1_SUG_KPI, 1)
|
||||||
|
k2 = _mk_kpi(db, E2_SUG_KPI, 2)
|
||||||
|
|
||||||
|
tok1 = _login(client, 1)
|
||||||
|
s1 = _mk_suggestion(client, tok1, k1.id, "ZZ_APRES e1 建议转计划")
|
||||||
|
r1 = client.post(f"{SUGG}/{s1}/apply", headers=auth_header(tok1),
|
||||||
|
json={"action": "action_plan", "title": "ZZ_APRES e1 转出的计划"})
|
||||||
|
assert r1.status_code == 200, f"e1 应用应 200,实际 {r1.status_code} {r1.text[:300]}"
|
||||||
|
rows1 = _plans_by_title(db, "ZZ_APRES e1 转出的计划")
|
||||||
|
assert len(rows1) == 1, f"e1 应用未生成计划: {rows1}"
|
||||||
|
assert rows1[0].entity_id == 1, (
|
||||||
|
f"e1 会话经 AI 建议生成的计划 entity_id 应为 1,实际 {rows1[0].entity_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
tok2 = _login(client, 2)
|
||||||
|
s2 = _mk_suggestion(client, tok2, k2.id, "ZZ_APRES e2 建议转计划")
|
||||||
|
r2 = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok2),
|
||||||
|
json={"action": "action_plan", "title": "ZZ_APRES e2 转出的计划"})
|
||||||
|
assert r2.status_code == 200, f"e2 应用应 200,实际 {r2.status_code} {r2.text[:300]}"
|
||||||
|
rows2 = _plans_by_title(db, "ZZ_APRES e2 转出的计划")
|
||||||
|
assert len(rows2) == 1, f"e2 应用未生成计划: {rows2}"
|
||||||
|
assert rows2[0].entity_id == 2, (
|
||||||
|
f"e2 会话经 AI 建议生成的计划 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}"
|
||||||
|
" —— ai_suggestions.py 构造 ActionPlan 未写 entity_id,落模型默认值 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_suggestion_apply_cross_entity_404(client: TestClient, db: Session):
|
||||||
|
"""e1 会话应用 e2 的建议 → 404(建议须归属会话账套)+ 零落行;本账套应用正常(正对照)"""
|
||||||
|
_seed_two_entities(db)
|
||||||
|
_mk_kpi(db, E1_SUG_KPI, 1)
|
||||||
|
k2 = _mk_kpi(db, E2_SUG_KPI, 2)
|
||||||
|
|
||||||
|
tok2 = _login(client, 2)
|
||||||
|
s2 = _mk_suggestion(client, tok2, k2.id, "ZZ_APRES e2 私有建议")
|
||||||
|
tok1 = _login(client, 1)
|
||||||
|
r = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok1),
|
||||||
|
json={"action": "action_plan", "title": "ZZ_APRES 越权转出的计划"})
|
||||||
|
assert r.status_code == 404, f"跨账套应用应 404,实际 {r.status_code} {r.text[:300]}"
|
||||||
|
assert _plans_by_title(db, "ZZ_APRES 越权转出的计划") == [], (
|
||||||
|
"跨账套应用竟为对端账套建议建了计划"
|
||||||
|
)
|
||||||
|
db.expire_all()
|
||||||
|
sug = db.query(AISuggestion).filter(AISuggestion.id == s2).first()
|
||||||
|
assert sug.status == "unapplied", f"越权应用改动了建议状态: {sug.status}"
|
||||||
|
|
||||||
|
# 正对照:本账套会话应用自己账套的建议仍然正常
|
||||||
|
r_ok = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok2),
|
||||||
|
json={"action": "action_plan", "title": "ZZ_APRES e2 自救计划"})
|
||||||
|
assert r_ok.status_code == 200, f"本账套应用应 200,实际 {r_ok.status_code} {r_ok.text[:300]}"
|
||||||
|
assert len(_plans_by_title(db, "ZZ_APRES e2 自救计划")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# T5. 鉴权底线(三个入口无 token → 401)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_residual_sites_without_token_401(client: TestClient, db: Session):
|
||||||
|
"""三个入口无 token 一律 401(改前改后都应通过)"""
|
||||||
|
_seed_two_entities(db)
|
||||||
|
k1 = _mk_kpi(db, E1_ALERT_KPI, 1)
|
||||||
|
a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 鉴权底线")
|
||||||
|
m1 = _mk_map(db, 1, "ZZ_APRES e1 鉴权地图", E1_ALERT_KPI, "e1 鉴权目标")
|
||||||
|
|
||||||
|
r1 = client.post(f"{ALERTS}/{a1.id}/create-action-plan", json={})
|
||||||
|
assert r1.status_code == 401, f"预警入口无 token 应 401,实际 {r1.status_code}"
|
||||||
|
|
||||||
|
r2 = client.post(DPUSH, json={"mapId": m1.id, "nodeId": "finance-0",
|
||||||
|
"newValue": 800.0, "createReviewTopic": False})
|
||||||
|
assert r2.status_code == 401, f"差异反打无 token 应 401,实际 {r2.status_code}"
|
||||||
|
|
||||||
|
r3 = client.post(SUGG, json={"suggestion_type": "action_plan", "title": "无 token"})
|
||||||
|
assert r3.status_code == 401, f"AI 建议无 token 应 401,实际 {r3.status_code}"
|
||||||
Reference in New Issue
Block a user