"""改善行动计划 API — 管理会计OS""" from fastapi import APIRouter, Depends, HTTPException, Request 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, 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()}") 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: 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() 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( 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