Files
cma-management/backend/app/api/action_plans.py
T
Hermes CI Fix 6b479bfe7d feat(KR): 行动挂KR完成 — action_plans.kr_id字段+API+前端目标/KR选择+KR中文名自动填+地图krs端点
- action_plans表加kr_id字段(关联krs.id),create/update/list支持kr_id读写
- 创建行动选KR时自动继承objective_id;列表附kr_title
- 前端行动方案库加'所属目标/KR'两级联动选择,选中KR自动带出KPI
- OKR详情页KR卡片下显示达成行动列表(O→KR→行动三层)
- MapCanvasDialogs onKpiChange选中KPI自动填kr.name=kpi.kpi_name(中文)
- 编辑回填英文code KR名转中文显示兜底
- maps.py 新增GET /{map_id}/krs端点(MapReview战略回顾会KR进度)
- 数据修复: krs表9条英文code title→中文名(按kpi_definitions映射)
- 数据迁移: 高置信行动挂KR(费用率→kr10,应收→kr14); 地图32空KR清理
- okr list的kr_summary补metric_kpi_id(前端联动用)
- 新增6个kr_id测试(test_action_plans),全量547通过
2026-08-27 12:50:30 +08:00

424 lines
17 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
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
from app.auth_middleware import require_role, require_auth
from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective, KR
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,
)
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),
):
"""更新行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_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_ent = db.query(KR).filter(KR.id == data["kr_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)):
"""删除行动计划"""
plan = db.query(ActionPlan).filter(ActionPlan.id == plan_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)):
"""行动计划统计"""
query = db.query(ActionPlan)
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(entity: str = "hanke"):
"""COSO内控自检表 - CMA P1 COSO五要素"""
data = COSO_CHECKLIST_DATA.get(entity)
if not data:
data = COSO_CHECKLIST_DATA["hanke"]
data["entity_name"] = f"未知实体({entity}),默认返回酣客数据"
return data