"""改善行动计划 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", }