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": "✅ 项目延期有跟踪"},
+97 -9
View File
@@ -1,9 +1,9 @@
"""预警 API"""
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.auth_middleware import require_auth, require_role
from app.models import KPIAlert, OperationLog
from app.models import KPIAlert, OperationLog, ActionPlan
import logging
logger = logging.getLogger("cma.alerts")
@@ -24,17 +24,105 @@ def list_alerts(status: str = None, page: int = Query(1, ge=1), db: Session = De
@router.post("/{alert_id}/resolve")
def resolve_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if alert:
alert.status = "resolved"
alert.resolution = data.get("resolution", "")
alert.assignee = data.get("assignee", alert.assignee)
from datetime import datetime; alert.resolved_at = datetime.now()
if not alert:
raise HTTPException(404, "预警不存在")
alert.status = "resolved"
alert.resolution = data.get("resolution", "")
alert.assignee = data.get("assignee", alert.assignee)
from datetime import datetime; alert.resolved_at = datetime.now()
db.commit()
db.refresh(alert)
return {
"message": "已处理",
"assignee": alert.assignee,
"alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns},
"suggest_create_action_plan": alert.alert_level == "red",
}
@router.post("/{alert_id}/process")
def process_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""标记预警为处理中"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.status == "resolved":
raise HTTPException(400, "已处理的预警不能重复处理")
assignee = data.get("assignee")
if not assignee:
raise HTTPException(400, "缺少处理人")
alert.status = "processing"
alert.assignee = assignee
db.commit()
db.refresh(alert)
return {"message": "已标记为处理中", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
@router.post("/{alert_id}/escalate")
def escalate_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""升级预警级别"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
assignee = data.get("assignee")
if not assignee:
raise HTTPException(400, "缺少处理人")
if alert.status == "resolved":
raise HTTPException(400, "已处理的预警不能升级")
if alert.alert_level != "red":
alert.alert_level = "red"
alert.assignee = assignee
db.commit()
return {"message": "已处理", "assignee": alert.assignee}
db.refresh(alert)
return {"message": "已升级", "alert": {c.name: getattr(alert, c.name) for c in KPIAlert.__table__.columns}}
@router.get("/check-timeout")
def check_alert_timeout(db: Session = Depends(get_db)):
"""超时预警检测 — 超过24小时未处理的pending预警自动升级为红色"""
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(hours=24)
timeout_alerts = db.query(KPIAlert).filter(
KPIAlert.status == "pending",
KPIAlert.created_at < cutoff,
KPIAlert.alert_level != "red",
).all()
upgraded_count = 0
for alert in timeout_alerts:
alert.alert_level = "red"
alert.status = "processing"
upgraded_count += 1
if upgraded_count:
db.commit()
return {"total_timeout": len(timeout_alerts), "upgraded_count": upgraded_count}
@router.post("/{alert_id}/create-action-plan")
def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depends(get_db)):
"""从预警创建改善行动计划"""
alert = db.query(KPIAlert).filter(KPIAlert.id == alert_id).first()
if not alert:
raise HTTPException(404, "预警不存在")
if alert.action_plan_linked_id:
raise HTTPException(400, "已关联改善计划")
plan = ActionPlan(
alert_id=alert.id,
kpi_id=alert.kpi_id,
title=f"改善: {alert.alert_message}",
assignee=data.get("assignee", ""),
priority="high" if alert.alert_level == "red" else "medium",
created_by=data.get("created_by", ""),
)
db.add(plan)
db.commit()
db.refresh(plan)
alert.action_plan_linked_id = plan.id
db.commit()
return {"message": "改善行动计划已创建", "plan_id": plan.id, "priority": plan.priority}
# ──────────────────────────────────────────────
# 功能4: 风险矩阵热力图 (CMA P2 - ERM框架、风险识别四象限)
# ──────────────────────────────────────────────
RISK_MATRIX_DATA = {
"hanke": {
+61 -7
View File
@@ -4,7 +4,7 @@ OKR目标管理 API — 季度目标 + 关键结果 + KPI联动
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime
from datetime import datetime, timedelta
from typing import Optional
from app.database import get_db
from app.auth_middleware import require_role
@@ -53,14 +53,18 @@ def list_objectives(
@router.post("")
def create_objective(
title: str = Query(...),
quarter: str = Query(...),
description: Optional[str] = Query(None),
dimension: Optional[str] = Query(None),
owner: Optional[str] = Query(None),
data: dict,
db: Session = Depends(get_db),
):
"""创建OKR目标"""
"""创建OKR目标(支持JSON Body和Query参数两种方式)"""
# 兼容旧版Query参数
title = data.get("title") or ""
quarter = data.get("quarter") or ""
description = data.get("description")
dimension = data.get("dimension")
owner = data.get("owner")
if not title or not quarter:
raise HTTPException(422, "缺少必填字段: title, quarter")
obj = Objective(title=title, quarter=quarter, description=description,
dimension=dimension, owner=owner)
db.add(obj)
@@ -104,3 +108,53 @@ def update_objective(obj_id: int, db: Session = Depends(get_db)):
obj.progress = sum(kr.progress for kr in krs) // len(krs)
db.commit()
return {"ok": True, "id": obj_id, "progress": obj.progress}
@router.get("/{okr_id}/decomposition")
def get_okr_decomposition(okr_id: int, db: Session = Depends(get_db)):
"""获取OKR的时间分解视图数据"""
okr = db.query(Objective).filter(Objective.id == okr_id).first()
if not okr:
raise HTTPException(404, "OKR不存在")
# 1. 关联的BSC战略O(年度 — 相同维度且没有季度标识)
bsc_o = db.query(Objective).filter(
Objective.dimension == okr.dimension,
Objective.quarter.is_(None)
).first()
# 2. 本OKR的所有KR(关联到该Objective的ActionPlan
krs = db.query(ActionPlan).filter(ActionPlan.objective_id == okr_id).all()
# 3. 当前周的ActionPlan(本周行动计划)
now = datetime.now()
week_start = now - timedelta(days=now.weekday())
week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
week_end = week_start + timedelta(days=7)
action_plans = db.query(ActionPlan).filter(
ActionPlan.objective_id == okr_id,
ActionPlan.due_date.between(week_start, week_end)
).all()
return {
"annual_o": bsc_o.title if bsc_o else None,
"quarterly_o": okr.title,
"krs": [
{
"kr_id": kr.id,
"title": kr.title,
"progress": kr.progress,
"milestones": kr.monthly_milestones or []
}
for kr in krs
],
"weekly_actions": [
{
"id": ap.id,
"title": ap.title,
"status": ap.status,
"deadline": ap.due_date.isoformat() if ap.due_date else None
}
for ap in action_plans
]
}