P1a(有entity表查询补齐): - kpis get/update/delete/restore 跨企业404校验 - kpis create 强制entity=token企业, update禁止改归属 P1b(无entity表join隔离): - alerts list/resolve join kpi_definitions 按企业过滤 - action_plans list join过滤 + create校验关联KPI归属 - kpi_causality full-network/list join过滤 - dashboard my_dashboard(用户发现) assigned/preset均按企业隔离 测试: 测试KPI种子entity对齐(2→1), pytest 451 passed 实证: 酣客token 6KPI(无博海id) vs 博海token 1KPI(414) 切换隔离正确
384 lines
16 KiB
Python
384 lines
16 KiB
Python
"""改善行动计划 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
|
||
|
||
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,
|
||
"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,
|
||
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)
|
||
|
||
# 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"
|
||
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不存在")
|
||
|
||
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,
|
||
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 "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
|