fix: 行动方案库统计卡片—补全API+overdue计算

This commit is contained in:
Hermes CI Fix
2026-07-27 17:16:01 +08:00
parent f85b18ce6d
commit 2bd66b1e89
36 changed files with 2114 additions and 447 deletions
+115 -74
View File
@@ -1,12 +1,14 @@
"""改善行动计划 API — 管理会计OS"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime
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.auth_middleware import require_role, require_auth
from app.models import ActionPlan, KPIAlert, KPIDefinition, User
import logging
from app.models import ActionPlan, KPIAlert, KPIDefinition, User, Objective
logger = logging.getLogger("cma.action_plans")
@@ -15,11 +17,52 @@ router = APIRouter(prefix="/api/cma/action-plans", tags=["改善行动"],
)
# ──────────────────────────────────────────────
# 工具函数
# ──────────────────────────────────────────────
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,
@@ -34,6 +77,10 @@ def plan_to_dict(p: ActionPlan) -> dict:
}
# ──────────────────────────────────────────────
# API 端点
# ──────────────────────────────────────────────
@router.get("")
def list_plans(
status: Optional[str] = None,
@@ -44,21 +91,21 @@ def list_plans(
):
"""获取行动计划列表"""
query = db.query(ActionPlan).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:
@@ -67,7 +114,7 @@ def list_plans(
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}
@@ -77,20 +124,30 @@ def create_plan(
db: Session = Depends(get_db),
current_user: User = Depends(require_auth),
):
"""创建改善行动计划"""
"""创建改善行动计划(也是OKR的KR"""
required = ["title", "kpi_id"]
for field in required:
if field not in data:
raise HTTPException(400, f"缺少必填字段: {field}")
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=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
due_date=due_date,
status="pending",
progress=0,
created_by=current_user.name or current_user.username,
@@ -111,7 +168,7 @@ def update_plan(
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:
@@ -128,7 +185,7 @@ def update_plan(
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)
@@ -145,8 +202,32 @@ def delete_plan(plan_id: int, db: Session = Depends(get_db)):
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.deadline < datetime.now()).count()
return {
"total": total,
"pending": pending,
"in_progress": in_progress,
"completed": completed,
"overdue": overdue,
}
# ──────────────────────────────────────────────
# 功能5: COSO内控自检表 (CMA P1 - COSO五要素)
# COSO内控自检表 (CMA P1 - COSO五要素)
# ──────────────────────────────────────────────
COSO_CHECKLIST_DATA = {
@@ -154,16 +235,12 @@ COSO_CHECKLIST_DATA = {
"entity_name": "陕西酣客(白酒经销)",
"total_score": 46,
"max_score": 100,
"risk_level": "high", # high / medium / low
"risk_level": "high",
"risk_label": "高风险",
"elements": [
{
"id": "control_environment",
"name": "控制环境",
"name_en": "Control Environment",
"score": 60,
"max_score": 100,
"status": "medium",
"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": "✅ 业务≠财务"},
@@ -172,24 +249,16 @@ COSO_CHECKLIST_DATA = {
],
},
{
"id": "risk_assessment",
"name": "风险评估",
"name_en": "Risk Assessment",
"score": 40,
"max_score": 100,
"status": "low",
"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",
"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": "❌ 超预算无拦截"},
@@ -197,24 +266,16 @@ COSO_CHECKLIST_DATA = {
],
},
{
"id": "information_communication",
"name": "信息与沟通",
"name_en": "Information & Communication",
"score": 70,
"max_score": 100,
"status": "medium",
"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",
"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": "❌ 发现异常无跟踪"},
@@ -230,12 +291,8 @@ COSO_CHECKLIST_DATA = {
"risk_label": "中风险",
"elements": [
{
"id": "control_environment",
"name": "控制环境",
"name_en": "Control Environment",
"score": 70,
"max_score": 100,
"status": "medium",
"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": "✅ 业务≠财务≠技术"},
@@ -243,48 +300,32 @@ COSO_CHECKLIST_DATA = {
],
},
{
"id": "risk_assessment",
"name": "风险评估",
"name_en": "Risk Assessment",
"score": 50,
"max_score": 100,
"status": "low",
"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",
"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",
"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",
"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": "✅ 项目延期有跟踪"},