fix(cma): scope action-plan objective_id writes to session entity
create_plan/update_plan now validate Objective.entity_id ownership (404 on cross-entity, no partial row) and update_plan gets an explicit objective_id-vs-KR-derived priority rule. Adds 10 regression cases (batch3 bias: bidirectional + same-entity control + OKR decomposition read-side consequence).
This commit is contained in:
@@ -14,6 +14,9 @@ from app.api.verify import build_auto_verify_rule
|
||||
|
||||
logger = logging.getLogger("cma.action_plans")
|
||||
|
||||
# 区分「未传该字段」与「显式传 null」的哨兵(update_plan 的 objective_id 口径,2026-09-18)
|
||||
_UNSET = object()
|
||||
|
||||
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
@@ -167,11 +170,17 @@ def create_plan(
|
||||
|
||||
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
|
||||
|
||||
# 校验截止日期与关联Objective的季度匹配
|
||||
# 账套隔离: 关联Objective必须属于当前企业(2026-09-18 batch3 派生单)
|
||||
# 旧实现只按 Objective.id 查询(仅用于季度校验),不校验 entity_id,
|
||||
# 随后 ActionPlan(objective_id=...) 直接落库 → 本账套计划可挂到对端账套目标
|
||||
objective_id = data.get("objective_id")
|
||||
if objective_id and due_date:
|
||||
obj = db.query(Objective).filter(Objective.id == objective_id).first()
|
||||
if obj and obj.quarter:
|
||||
if objective_id:
|
||||
obj = db.query(Objective).filter(
|
||||
Objective.id == objective_id, Objective.entity_id == entity_id).first()
|
||||
if not obj:
|
||||
raise HTTPException(404, "关联目标不存在")
|
||||
# 校验截止日期与关联Objective的季度匹配
|
||||
if due_date and obj.quarter:
|
||||
_validate_due_date_against_quarter(due_date, obj.quarter)
|
||||
|
||||
plan = ActionPlan(
|
||||
@@ -260,22 +269,35 @@ def update_plan(
|
||||
plan.assignee = data["assignee"]
|
||||
if "priority" in data:
|
||||
plan.priority = data["priority"]
|
||||
if "kr_id" in data:
|
||||
# 行动挂KR: 支持置空(null)或改挂
|
||||
if data["kr_id"] is None:
|
||||
plan.kr_id = None
|
||||
else:
|
||||
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan:158 既有写法;2026-09-18 batch3)
|
||||
kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
plan.kr_id = kr_ent.id
|
||||
if data.get("objective_id") is not None:
|
||||
plan.objective_id = data["objective_id"]
|
||||
elif plan.objective_id is None:
|
||||
plan.objective_id = kr_ent.objective_id
|
||||
# 目标/KR 归属口径(2026-09-18 batch3 派生单):
|
||||
# ① 显式传 objective_id(含 null=显式置空)→ 以入参为准(优先于 kr_id 推导值)
|
||||
# ② 未传 objective_id、且改挂合法 KR、且计划当前无目标 → 继承该 KR 的目标
|
||||
# ③ 未传 objective_id 且计划已有目标 → 保持不动
|
||||
# 两者都必须是当前账套的数据,否则 404;校验全部放在改动 plan 之前,
|
||||
# 保证 404 请求对计划零改动(旧实现无归属校验,且末尾无条件覆盖推导值)
|
||||
kr_ent = _UNSET
|
||||
if "kr_id" in data and data["kr_id"] is not None:
|
||||
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan 既有写法;2026-09-18 batch3)
|
||||
kr_ent = db.query(KR).filter(KR.id == data["kr_id"], KR.entity_id == entity_id).first()
|
||||
if not kr_ent:
|
||||
raise HTTPException(404, "关联KR不存在")
|
||||
|
||||
objective_change = _UNSET
|
||||
if "objective_id" in data:
|
||||
plan.objective_id = data["objective_id"]
|
||||
objective_change = data["objective_id"] # 口径①
|
||||
elif kr_ent is not _UNSET and plan.objective_id is None:
|
||||
objective_change = kr_ent.objective_id # 口径②
|
||||
if objective_change is not _UNSET and objective_change is not None:
|
||||
obj_ent = db.query(Objective).filter(
|
||||
Objective.id == objective_change, Objective.entity_id == entity_id).first()
|
||||
if not obj_ent:
|
||||
raise HTTPException(404, "关联目标不存在")
|
||||
|
||||
# 校验全部通过后落值(口径③:objective_change 仍为 _UNSET 时目标保持不动)
|
||||
if "kr_id" in data:
|
||||
plan.kr_id = kr_ent.id if kr_ent is not _UNSET else None
|
||||
if objective_change is not _UNSET:
|
||||
plan.objective_id = objective_change
|
||||
if "due_date" in data:
|
||||
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
|
||||
if "status" in data:
|
||||
|
||||
Reference in New Issue
Block a user