- _apply_overdue():`ActionPlan.due_date < datetime.now()` → `... < 今天0点` (唯一实现,plan_stats 与 list_plans 共用,故一处改即两端点同改) - 口径同步注释(口径 A→B 的沿革与分界式);行为改动仅此一处 - 测试夹具改到日粒度(整天偏移),新增「今日到期不算逾期」鉴别行与 `test_overdue_caliber_is_date_granularity` 口径锁定用例(防回退 A) - 依据:任总 2026-09-22 10:10 拍板口径 B;前端角标 ActionPlanLibrary.vue::isOverdue 与 cash.py::_plan_dict 本就同粒度,本单未动 - 只改码:未 push / 未部署 / 未重启(发布须放行) (cherry picked from commit 2da5b6da018013f860430913b5b0e0436f966685)
686 lines
31 KiB
Python
686 lines
31 KiB
Python
"""改善行动计划 API — 管理会计OS"""
|
||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||
from sqlalchemy import or_
|
||
from sqlalchemy.orm import Session
|
||
from datetime import datetime, timezone, timedelta
|
||
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")
|
||
|
||
# 区分「未传该字段」与「显式传 null」的哨兵(update_plan 的 objective_id 口径,2026-09-18)
|
||
_UNSET = object()
|
||
|
||
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()}")
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 「已逾期」派生口径(单一来源,2026-09-21 project-20260921-actionplan-overdue-filter)
|
||
# ──────────────────────────────────────────────
|
||
# 口径:overdue = status in (pending, in_progress) AND due_date < 今天0点
|
||
# (**日期粒度 · 口径 B**,任总 2026-09-22 10:10 拍板;当天到期不算逾期。
|
||
# 2026-09-21 首版为 datetime 粒度 `due_date < now`,本次仅此一处改为日粒度。)
|
||
#
|
||
# 为什么要有这个函数(缺陷根因):status 的取值域只有
|
||
# pending / in_progress / completed / cancelled —— **没有** overdue 这个值。
|
||
# 前端把统计卡片的 key('overdue')直接当 status 传给列表端点
|
||
# (frontend/src/views/ActionPlanLibrary.vue:414 卡片 key、:26 filterStatus、:422 params.status),
|
||
# 而 list_plans 原实现按 `ActionPlan.status == 'overdue'` 直查 → 恒 0 条,
|
||
# 于是出现「卡片 7 条 ≠ 列表 0 条」。
|
||
# 统计端点 plan_stats 早就用的是派生口径 —— 两处口径分家正是本次不一致的来源。
|
||
# 故把派生口径抽成本函数,plan_stats 与 list_plans **共用同一实现**(同源),
|
||
# 今后口径只此一处,不再出现「卡片 ≠ 列表」。
|
||
OVERDUE_KEY = "overdue"
|
||
OVERDUE_STATUSES = ("pending", "in_progress")
|
||
|
||
|
||
def _apply_overdue(query):
|
||
"""把「已逾期」派生口径施加到 ActionPlan 查询上(stats / list 共用)。
|
||
|
||
口径 B(日期粒度,任总 2026-09-22 拍板):due_date < **今天 0 点** —— 当天到期不算逾期。
|
||
与 cash.py::_plan_dict(应收催收 overdue)及前端行内角标
|
||
ActionPlanLibrary.vue::isOverdue 同粒度(二者本就是日期粒度,本单未动)。
|
||
"""
|
||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||
return query.filter(
|
||
ActionPlan.status.in_(list(OVERDUE_STATUSES)),
|
||
ActionPlan.due_date < today_start,
|
||
)
|
||
|
||
|
||
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 == OVERDUE_KEY:
|
||
# 「已逾期」是**派生条件**而非 status 值(口径与 /stats 同源,2026-09-21)
|
||
# 前端统计卡片直接把 key='overdue' 当 status 传来,故此处按派生口径过滤,
|
||
# 与 plan_stats 共用 _apply_overdue,保证「卡片条数 = 列表条数」。
|
||
query = _apply_overdue(query)
|
||
elif 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:
|
||
# 账套隔离(2026-09-18 batch3 读侧同族残余):kr_id 可能指向对端账套 KR(存量脏行,
|
||
# 写入侧 create_plan/update_plan 已校验归属但旧行仍存在)→ 富化查询必须带账套,
|
||
# 否则列表跨账套回显对端 KR 标题
|
||
kr = db.query(KR).filter(KR.id == p.kr_id, KR.entity_id == entity_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必须属于当前企业(2026-09-18 batch3 派生单)
|
||
# 旧实现只按 Objective.id 查询(仅用于季度校验),不校验 entity_id,
|
||
# 随后 ActionPlan(objective_id=...) 直接落库 → 本账套计划可挂到对端账套目标
|
||
objective_id = data.get("objective_id")
|
||
if objective_id:
|
||
obj = db.query(Objective).filter(
|
||
Objective.id == objective_id, Objective.entity_id == entity_id).first()
|
||
if not obj:
|
||
raise HTTPException(404, "关联目标不存在")
|
||
# 校验截止日期与关联Objective的季度匹配
|
||
if due_date and obj.quarter:
|
||
_validate_due_date_against_quarter(due_date, obj.quarter)
|
||
|
||
plan = ActionPlan(
|
||
# 账套隔离(2026-09-18 batch3 派生单):必须显式写会话账套 ——
|
||
# 旧实现不写 entity_id,落模型默认值 1(e2 会话创建的计划库内 entity_id=1,
|
||
# 实测 OBS1)。list/stats/update/delete 靠 join KPIDefinition 判账套,
|
||
# 但任何按 ActionPlan.entity_id 过滤的读路径(如 OKR 分解视图)会失效。
|
||
entity_id=entity_id,
|
||
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"]
|
||
# 目标/KR 归属口径(2026-09-18 batch3 派生单):
|
||
# ① 显式传 objective_id(含 null=显式置空)→ 以入参为准(优先于 kr_id 推导值)
|
||
# ② 未传 objective_id、且改挂合法 KR、且计划当前无目标 → 继承该 KR 的目标
|
||
# ③ 未传 objective_id 且计划已有目标 → 保持不动
|
||
# 两者都必须是当前账套的数据,否则 404;校验全部放在改动 plan 之前,
|
||
# 保证 404 请求对计划零改动(旧实现无归属校验,且末尾无条件覆盖推导值)
|
||
kr_ent = _UNSET
|
||
if "kr_id" in data and data["kr_id"] is not None:
|
||
# 账套隔离: KR 必须属于当前企业(对齐同文件 create_plan 既有写法;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不存在")
|
||
|
||
objective_change = _UNSET
|
||
if "objective_id" in data:
|
||
objective_change = data["objective_id"] # 口径①
|
||
elif kr_ent is not _UNSET and plan.objective_id is None:
|
||
objective_change = kr_ent.objective_id # 口径②
|
||
if objective_change is not _UNSET and objective_change is not None:
|
||
obj_ent = db.query(Objective).filter(
|
||
Objective.id == objective_change, Objective.entity_id == entity_id).first()
|
||
if not obj_ent:
|
||
raise HTTPException(404, "关联目标不存在")
|
||
|
||
# 校验全部通过后落值(口径③:objective_change 仍为 _UNSET 时目标保持不动)
|
||
if "kr_id" in data:
|
||
plan.kr_id = kr_ent.id if kr_ent is not _UNSET else None
|
||
if objective_change is not _UNSET:
|
||
plan.objective_id = objective_change
|
||
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()
|
||
overdue = _apply_overdue(query).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
|
||
|
||
|
||
@router.post("/coso-generate-actions")
|
||
def generate_coso_actions(
|
||
request: Request,
|
||
data: Optional[dict] = Body(default=None),
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(require_auth),
|
||
entity_id: int = Depends(get_entity_id),
|
||
):
|
||
"""COSO内控自检 → 生成改善行动(把自检「未通过」条目一键转为行动计划)
|
||
|
||
口径说明:
|
||
- 账套(多租户铁律):写入 entity_id 只取 token 绑定账套(get_entity_id),
|
||
body 里若含 entity_id 字段一律忽略;
|
||
- 数据源:与 get_coso_checklist 同口径(resolve_session_slug 按会话账套取同一份
|
||
自检)——屏幕上看到哪份自检,就用哪份生成,不做跨账套内容搬运;
|
||
- 关联 KPI:action_plans.kpi_id 是 NOT NULL 且列表按 KPI 归属做账套 join,
|
||
故必须挂本账套 KPI;
|
||
- 幂等:判重键 (entity_id, title),已存在的计入 skipped 不再 INSERT;
|
||
- auto_verify_rule 显式不生成:COSO 条目没有可复算的 KPI 判据。
|
||
"""
|
||
data = data or {}
|
||
|
||
# ① 取自检数据(与只读端点同口径解析 slug)
|
||
slug = resolve_session_slug(request, "hanke")
|
||
coso = COSO_CHECKLIST_DATA.get(slug)
|
||
if not coso:
|
||
raise HTTPException(404, f"实体 entity={slug} 无 COSO 自检数据")
|
||
|
||
# ② 待生成条目:passed 为真的一律不生成
|
||
failed_items = []
|
||
for el in coso["elements"]:
|
||
for item in el.get("items") or []:
|
||
if not item.get("passed"):
|
||
failed_items.append((el, item))
|
||
|
||
# ③ 无未通过条目 → 返回空结果结构(不算错误)
|
||
if not failed_items:
|
||
return {
|
||
"entity_id": entity_id,
|
||
"entity_slug": slug,
|
||
"kpi_id": None,
|
||
"kpi_name": None,
|
||
"failed_total": 0,
|
||
"created": 0,
|
||
"skipped": 0,
|
||
"created_ids": [],
|
||
"source": "coso",
|
||
}
|
||
|
||
# ④ 关联 KPI(必须,kpi_id NOT NULL 且列表按 KPI 归属做账套 join)
|
||
if data.get("kpi_id"):
|
||
# 显式指定:必须属于当前账套(跨账套 KPI 一律 404,不得跨账套写)
|
||
kpi = db.query(KPIDefinition).filter(
|
||
KPIDefinition.id == data["kpi_id"],
|
||
KPIDefinition.entity_id == entity_id,
|
||
).first()
|
||
if not kpi:
|
||
raise HTTPException(404, "关联KPI不存在")
|
||
else:
|
||
# 自动选取(判据显式、可解释):
|
||
# a. 名称/编码含关键词(按序:内控/合规/COSO/control/compliance),id 最小者优先
|
||
# b. 都没有 → 本账套 id 最小的 KPI(确定性兜底)
|
||
# c. 本账套一个 KPI 都没有 → 400(无法满足 kpi_id NOT NULL)
|
||
kpi = None
|
||
for kw in ("内控", "合规", "COSO", "control", "compliance"):
|
||
kpi = (db.query(KPIDefinition)
|
||
.filter(KPIDefinition.entity_id == entity_id)
|
||
.filter(or_(
|
||
KPIDefinition.kpi_name.like(f"%{kw}%"),
|
||
KPIDefinition.kpi_code.like(f"%{kw}%"),
|
||
))
|
||
.order_by(KPIDefinition.id)
|
||
.first())
|
||
if kpi:
|
||
break
|
||
if not kpi:
|
||
kpi = (db.query(KPIDefinition)
|
||
.filter(KPIDefinition.entity_id == entity_id)
|
||
.order_by(KPIDefinition.id)
|
||
.first())
|
||
if not kpi:
|
||
raise HTTPException(400, "当前账套没有可关联的KPI,无法生成改善行动")
|
||
|
||
# ⑤ 截止日期:body 显式传则校验格式且不得早于今天;否则默认 +30 天
|
||
due_date = None
|
||
if data.get("due_date"):
|
||
try:
|
||
due_date = datetime.strptime(str(data["due_date"]), "%Y-%m-%d")
|
||
except (ValueError, TypeError):
|
||
raise HTTPException(400, f"due_date 格式非法: {data['due_date']}(应为 YYYY-MM-DD)")
|
||
if due_date.date() < datetime.now().date():
|
||
raise HTTPException(400, "due_date 不能早于今天")
|
||
else:
|
||
due_date = datetime.now() + timedelta(days=30)
|
||
|
||
# ⑥ 幂等判重键 (entity_id, title):带账套过滤 —— 对端账套同标题行不抑制本账套创建
|
||
existing_titles = {
|
||
p.title for p in db.query(ActionPlan).filter(ActionPlan.entity_id == entity_id).all()
|
||
}
|
||
|
||
created_plans = []
|
||
skipped_count = 0
|
||
for el, item in failed_items:
|
||
title = f"[COSO] {el['name']}·{item['text']}"[:200] # title 列宽 String(200)
|
||
if title in existing_titles:
|
||
skipped_count += 1
|
||
continue
|
||
existing_titles.add(title) # 本批次内自去重
|
||
score = el.get("score") or 0
|
||
if score < 40:
|
||
priority = "high"
|
||
elif score < 60:
|
||
priority = "medium"
|
||
else:
|
||
priority = "low"
|
||
plan = ActionPlan(
|
||
entity_id=entity_id,
|
||
kpi_id=kpi.id,
|
||
title=title,
|
||
# 来源标注便于回溯(自检哪条条目生成了这条行动)
|
||
description=(
|
||
f"来源: COSO内控自检 / {el['name']}({el['id']})/ 条目 {item['id']}: {item['text']}\n"
|
||
f"自检结论: {item.get('detail') or '未通过'}"
|
||
),
|
||
status="pending",
|
||
progress=0,
|
||
priority=priority,
|
||
due_date=due_date,
|
||
created_by=current_user.name or current_user.username,
|
||
# 不传 auto_verify_rule:该列是 MySQL json 列,显式传 None 会被落成 JSON null
|
||
# (JSON_TYPE()='NULL'、IS NOT NULL 为真)而不是 SQL NULL,会让 bot_iron_law 的
|
||
# auto_verify_rule.isnot(None) 把「没有规则」的行算成「有规则」。不赋值则 INSERT
|
||
# 省略该列 → 保持 SQL NULL。COSO 条目没有可复算判据,引擎对空规则是跳过(安全)。
|
||
)
|
||
db.add(plan)
|
||
created_plans.append(plan)
|
||
|
||
# ⑦ 单次 add 循环 + 一次 commit:任一异常向上抛,不留半批
|
||
db.commit()
|
||
created_ids = [p.id for p in created_plans]
|
||
|
||
return {
|
||
"entity_id": entity_id,
|
||
"entity_slug": slug,
|
||
"kpi_id": kpi.id,
|
||
"kpi_name": kpi.kpi_name,
|
||
"failed_total": len(failed_items), # 自检未通过条目总数
|
||
"created": len(created_ids), # 本次新建条数
|
||
"skipped": skipped_count, # 已存在(幂等命中)条数
|
||
"created_ids": created_ids, # 新建行 id 列表
|
||
"source": "coso",
|
||
}
|