Files
cma-management/backend/app/api/action_plans.py
T
Hermes CI Fix 9fb9d14a58 fix(cma): entity isolation batch3 (cost abc write endpoints + allocations/stats reads + plan kr_id scope)
- cost.py: POST /abc/activities 按会话账套写入 entity_id;POST /abc/allocate 校验 activity_id 归属本账套(跨账套 404)并透传 entity_id;GET /abc/allocations 加账套过滤
- cost_engine.py: allocate_cost(entity_id=None) 按账套过滤作业中心 + 显式写入 AbcAllocation.entity_id(不传维持原语义)
- action_plans.py: update_plan 的 kr_id 加账套校验(跨账套 404,对齐 create_plan 既有写法);plan_stats join KPIDefinition 按账套过滤(口径与 list_plans 一致)
- tests: 新增 tests/test_cross_entity_isolation_batch3_20260918.py(10 例;改前 7 failed/3 passed → 改后 10 passed)
- 回归: 1184 passed,1 xfailed → 1194 passed,1 xfailed(无新增失败)
2026-09-18 18:42:38 +08:00

463 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""改善行动计划 API — 管理会计OS"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from typing import Optional
import re
import logging
from calendar import monthrange
from app.database import get_db
from app.deps import get_entity_id, assert_entity_slug_scope, resolve_session_slug
from app.auth_middleware import require_role, require_auth
from app.models import ActionPlan, KPIAlert, KPIDefinition, KPIValue, User, Objective, KR
from app.api.verify import build_auto_verify_rule
logger = logging.getLogger("cma.action_plans")
router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
# ──────────────────────────────────────────────
# 工具函数
# ──────────────────────────────────────────────
def _quarter_to_date_range(quarter: str) -> tuple:
"""解析季度字符串 '2026Q3' → (start_date, end_date)"""
m = re.match(r"^(\d{4})[Qq]([1-4])$", quarter.strip())
if not m:
return None, None
year = int(m.group(1))
q = int(m.group(2))
month_map = {1: (1, 1), 2: (4, 1), 3: (7, 1), 4: (10, 1)}
start_month, start_day = month_map[q]
end_month = start_month + 2
if end_month > 12:
end_month -= 12
end_year = year + 1
else:
end_year = year
_, last_day = monthrange(end_year, end_month)
return (
datetime(year, start_month, start_day, tzinfo=timezone.utc),
datetime(end_year, end_month, last_day, 23, 59, 59, tzinfo=timezone.utc),
)
def _validate_due_date_against_quarter(due_date: datetime, quarter: str):
"""校验截止日期是否在季度范围内,不匹配则抛422"""
q_start, q_end = _quarter_to_date_range(quarter)
if q_start is None:
return # 无法解析季度,跳过校验
due = due_date if due_date.tzinfo else due_date.replace(tzinfo=timezone.utc)
if due < q_start:
raise HTTPException(422,
f"KR截止日期({due.date()})早于本季度开始({q_start.date()}),请检查")
if due > q_end:
raise HTTPException(422,
f"KR截止日期({due.date()})超出本季度范围({q_end.date()}),最大截止为{q_end.date()}")
def plan_to_dict(p: ActionPlan) -> dict:
return {
"id": p.id,
"alert_id": p.alert_id,
"kpi_id": p.kpi_id,
"objective_id": p.objective_id,
"kr_id": p.kr_id,
"title": p.title,
"description": p.description,
"assignee": p.assignee,
"priority": p.priority,
"due_date": p.due_date.isoformat() if p.due_date else None,
"status": p.status,
"progress": p.progress or 0,
"result": p.result,
"created_by": p.created_by,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
# ──────────────────────────────────────────────
# API 端点
# ──────────────────────────────────────────────
@router.get("")
def list_plans(
status: Optional[str] = None,
kpi_id: Optional[int] = None,
alert_id: Optional[int] = None,
keyword: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""获取行动计划列表(账套隔离: join KPI按企业过滤, 2026-08-23 P1b)"""
query = db.query(ActionPlan).join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id).filter(KPIDefinition.entity_id == entity_id).order_by(ActionPlan.created_at.desc())
if status:
query = query.filter(ActionPlan.status == status)
if kpi_id:
query = query.filter(ActionPlan.kpi_id == kpi_id)
if alert_id:
query = query.filter(ActionPlan.alert_id == alert_id)
if keyword:
query = query.filter(ActionPlan.title.like(f"%{keyword}%"))
# business角色只看自己的
if current_user.role == "business":
query = query.filter(
(ActionPlan.assignee == current_user.username) |
(ActionPlan.assignee == current_user.name)
)
plans = query.all()
result = []
for p in plans:
item = plan_to_dict(p)
# 附带KPI名称
kpi = db.query(KPIDefinition).filter(KPIDefinition.id == p.kpi_id).first()
item["kpi_name"] = kpi.kpi_name if kpi else "未知KPI"
item["kpi_code"] = kpi.kpi_code if kpi else None
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()
item["kr_title"] = kr.title if kr else None
item["kr_metric_kpi_id"] = kr.metric_kpi_id if kr else None
else:
item["kr_title"] = None
item["kr_metric_kpi_id"] = None
result.append(item)
return {"data": result}
@router.post("")
def create_plan(
data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id),
):
"""创建改善行动计划(也是OKR的KR)"""
required = ["title", "kpi_id"]
for field in required:
if field not in data:
raise HTTPException(400, f"缺少必填字段: {field}")
# 账套隔离: 关联KPI必须属于当前企业 (2026-08-23 P1b)
kpi_ent = db.query(KPIDefinition).filter(KPIDefinition.id == data["kpi_id"]).first()
if not kpi_ent or kpi_ent.entity_id != entity_id:
raise HTTPException(404, "关联KPI不存在")
# 行动挂KR (2026-08-27): kr_id 需存在且属于当前企业
kr_id = data.get("kr_id")
if kr_id:
kr_ent = db.query(KR).filter(KR.id == kr_id, KR.entity_id == entity_id).first()
if not kr_ent:
raise HTTPException(404, "关联KR不存在")
# 未显式传objective_id时从KR继承目标
if not data.get("objective_id"):
data["objective_id"] = kr_ent.objective_id
due_date = datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None
# 校验截止日期与关联Objective的季度匹配
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:
_validate_due_date_against_quarter(due_date, obj.quarter)
plan = ActionPlan(
alert_id=data.get("alert_id"),
kpi_id=data["kpi_id"],
objective_id=objective_id,
kr_id=kr_id,
title=data["title"],
description=data.get("description"),
assignee=data.get("assignee"),
priority=data.get("priority", "medium"),
due_date=due_date,
status="pending",
progress=0,
created_by=current_user.name or current_user.username,
auto_verify_rule=data.get("auto_verify_rule"), # 显式规则原样保存;None 时下面自动生成
)
# 第三阶段接入(2026-08-31):创建 ActionPlan 自动生成验证规则
# 请求体未传 auto_verify_rule 且关联 KPI 存在 → 复用 verify.build_auto_verify_rule 生成默认规则(不改变现有创建行为;auto_close 默认 false)
if not data.get("auto_verify_rule"):
baseline_value = data.get("baseline_value")
if baseline_value is None:
# 基线缺省时取 KPI 当前最新值(period <= 当前月),供 kpi_current_before 回填
latest_kpi = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi_ent.id,
KPIValue.actual_value.isnot(None),
KPIValue.period <= datetime.now().strftime("%Y-%m"),
).order_by(KPIValue.period.desc(), KPIValue.calculated_at.desc(), KPIValue.id.desc()).first()
if latest_kpi:
baseline_value = latest_kpi.actual_value
plan.auto_verify_rule = build_auto_verify_rule(kpi_ent, baseline_value=baseline_value)
db.add(plan)
db.commit()
db.refresh(plan)
# OKR时间分解:KR创建时自动生成3个月度里程碑(按截止日期向前均分)
if due_date and not plan.monthly_milestones:
try:
plan.monthly_milestones = _auto_build_milestones(due_date)
db.commit()
except Exception:
db.rollback() # 里程碑生成失败不影响KR创建
db.refresh(plan)
return plan_to_dict(plan)
def _auto_build_milestones(due_date: datetime) -> list:
"""按截止日期向前均分3个月度里程碑"""
base = due_date.replace(day=1)
month_keys = []
for i in range(3, 0, -1):
y, m = base.year, base.month - i
while m <= 0:
m += 12
y -= 1
month_keys.append(f"{y:04d}-{m:02d}")
return [
{
"month": mk,
"label": f"里程碑{i + 1}",
"status": "pending",
"target": None,
}
for i, mk in enumerate(month_keys)
]
@router.put("/{plan_id}")
def update_plan(
plan_id: int,
data: dict,
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, "计划不存在")
if "title" in data:
plan.title = data["title"]
if "description" in data:
plan.description = data["description"]
if "assignee" in data:
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
if "objective_id" in data:
plan.objective_id = data["objective_id"]
if "due_date" in data:
plan.due_date = datetime.fromisoformat(data["due_date"]) if data["due_date"] else None
if "status" in data:
plan.status = data["status"]
if "progress" in data:
plan.progress = max(0, min(100, data["progress"]))
if "result" in data:
plan.result = data["result"]
db.commit()
db.refresh(plan)
return plan_to_dict(plan)
@router.delete("/{plan_id}")
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)
db.commit()
return {"message": "已删除"}
@router.get("/stats")
def plan_stats(db: Session = Depends(get_db), current_user: User = Depends(require_auth),
entity_id: int = Depends(get_entity_id)):
"""行动计划统计(账套隔离: join KPI 按企业过滤, 2026-09-18 batch3)
口径说明(为何改):本端点原为全局统计(仅 business 角色按 assignee 过滤)→ 非 e1 账套
用户看到的是含其它账套计划的合计,与同文件列表端点 list_plans(join KPIDefinition 按账套)
的可见集不一致。现按同文件既有先例(update_plan/delete_plan 的 join(KPIDefinition))过滤,
保证「统计数 = 列表可见行数」。数据侧存在 action_plans 行 entity_id 与 KPI 归属不一致的
存量行(batch2 报告 §六.2),统一以 KPI 归属为准与列表口径一致。
"""
query = (db.query(ActionPlan)
.join(KPIDefinition, KPIDefinition.id == ActionPlan.kpi_id)
.filter(KPIDefinition.entity_id == entity_id))
if current_user.role == "business":
query = query.filter(
(ActionPlan.assignee == current_user.username) |
(ActionPlan.assignee == current_user.name)
)
total = query.count()
pending = query.filter(ActionPlan.status == "pending").count()
in_progress = query.filter(ActionPlan.status == "in_progress").count()
completed = query.filter(ActionPlan.status == "completed").count()
from datetime import datetime
overdue = query.filter(ActionPlan.status.in_(["pending", "in_progress"]), ActionPlan.due_date < datetime.now()).count()
return {
"total": total,
"pending": pending,
"in_progress": in_progress,
"completed": completed,
"overdue": overdue,
}
# ──────────────────────────────────────────────
# COSO内控自检表 (CMA P1 - COSO五要素)
# ──────────────────────────────────────────────
COSO_CHECKLIST_DATA = {
"hanke": {
"entity_name": "陕西酣客(白酒经销)",
"total_score": 46,
"max_score": 100,
"risk_level": "high",
"risk_label": "高风险",
"elements": [
{
"id": "control_environment", "name": "控制环境", "name_en": "Control Environment",
"score": 60, "max_score": 100, "status": "medium",
"items": [
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 任总亲自跟"},
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务"},
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 渠补无标准审批流程"},
{"id": "ce_04", "text": "人事政策", "passed": False, "detail": "❌ 无定期轮岗"},
],
},
{
"id": "risk_assessment", "name": "风险评估", "name_en": "Risk Assessment",
"score": 40, "max_score": 100, "status": "low",
"items": [
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 没有系统风险清单"},
{"id": "ra_02", "text": "风险应对预案", "passed": False, "detail": "❌ 现金断流无预案"},
],
},
{
"id": "control_activities", "name": "控制活动", "name_en": "Control Activities",
"score": 30, "max_score": 100, "status": "low",
"items": [
{"id": "ca_01", "text": "渠补审批流程", "passed": False, "detail": "❌ 口头谈,无记录"},
{"id": "ca_02", "text": "费用审批流程", "passed": False, "detail": "❌ 超预算无拦截"},
{"id": "ca_03", "text": "实物返利入账流程", "passed": False, "detail": "❌ 纯P&L不进系统"},
],
},
{
"id": "information_communication", "name": "信息与沟通", "name_en": "Information & Communication",
"score": 70, "max_score": 100, "status": "medium",
"items": [
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
{"id": "ic_02", "text": "系统数据互通", "passed": False, "detail": "❌ 进销存≠财务账"},
],
},
{
"id": "monitoring", "name": "监控", "name_en": "Monitoring",
"score": 30, "max_score": 100, "status": "low",
"items": [
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
{"id": "mo_02", "text": "异常追踪机制", "passed": False, "detail": "❌ 发现异常无跟踪"},
],
},
],
},
"bohai": {
"entity_name": "陕西博海科技(IT服务)",
"total_score": 55,
"max_score": 100,
"risk_level": "medium",
"risk_label": "中风险",
"elements": [
{
"id": "control_environment", "name": "控制环境", "name_en": "Control Environment",
"score": 70, "max_score": 100, "status": "medium",
"items": [
{"id": "ce_01", "text": "管理层重视内控", "passed": True, "detail": "✅ 老板直接管"},
{"id": "ce_02", "text": "职责分离", "passed": True, "detail": "✅ 业务≠财务≠技术"},
{"id": "ce_03", "text": "授权审批制度", "passed": False, "detail": "❌ 部分项目无预算审批"},
],
},
{
"id": "risk_assessment", "name": "风险评估", "name_en": "Risk Assessment",
"score": 50, "max_score": 100, "status": "low",
"items": [
{"id": "ra_01", "text": "风险识别机制", "passed": False, "detail": "❌ 无正式风险清单"},
{"id": "ra_02", "text": "风险应对预案", "passed": True, "detail": "✅ 重点项目有预案"},
],
},
{
"id": "control_activities", "name": "控制活动", "name_en": "Control Activities",
"score": 50, "max_score": 100, "status": "low",
"items": [
{"id": "ca_01", "text": "采购审批流程", "passed": True, "detail": "✅ 有标准流程"},
{"id": "ca_02", "text": "项目交付流程", "passed": False, "detail": "❌ 验收流程不完善"},
],
},
{
"id": "information_communication", "name": "信息与沟通", "name_en": "Information & Communication",
"score": 60, "max_score": 100, "status": "medium",
"items": [
{"id": "ic_01", "text": "财务报告及时性", "passed": True, "detail": "✅ 月度出表"},
{"id": "ic_02", "text": "项目沟通机制", "passed": False, "detail": "❌ 跨部门信息滞后"},
],
},
{
"id": "monitoring", "name": "监控", "name_en": "Monitoring",
"score": 40, "max_score": 100, "status": "low",
"items": [
{"id": "mo_01", "text": "定期内审", "passed": False, "detail": "❌ 无"},
{"id": "mo_02", "text": "异常追踪机制", "passed": True, "detail": "✅ 项目延期有跟踪"},
],
},
],
},
}
@router.get("/coso-checklist")
def get_coso_checklist(
request: Request,
entity: Optional[str] = None,
):
"""COSO内控自检表 - CMA P1 COSO五要素
多租户(cma-cross-entity-endpoints-auth-20260914):
非法实体值 → 400 显式报错(不再静默回落酣客数据);跨实体 → 403。
"""
slug = assert_entity_slug_scope(request, entity)
if slug is None:
slug = resolve_session_slug(request, "hanke")
data = COSO_CHECKLIST_DATA.get(slug)
if not data:
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
return data