"""现金流分类规则 API — 管理会计OS (P2-⑥ 2026-08-28) 分类规则管理(cash_plan_classify_rules) + 待分类队列(cash_plan_unclassified) + 一键归类。 sync-cash-plans 未命中的KPI进入待分类队列,人工一键归类 → 自动补建规则+生成CashPlan。 """ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from typing import Optional from datetime import datetime from app.database import get_db from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import ( CashPlanClassifyRule, CashPlanUnclassified, CashPlan, KPIDefinition, BudgetPlan, ) router = APIRouter( prefix="/api/cma/budget", tags=["现金流分类"], dependencies=[Depends(require_role("ceo", "finance", "it"))], ) # ── 分类规则 CRUD ────────────────────────────── @router.get("/cash-classify-rules") def list_classify_rules( db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """分类规则列表(按 entity_id 隔离)""" rows = db.query(CashPlanClassifyRule).filter( CashPlanClassifyRule.entity_id == entity_id ).order_by(CashPlanClassifyRule.priority.asc(), CashPlanClassifyRule.id.desc()).all() kpi_ids = {r.kpi_id for r in rows if r.kpi_id} kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {} result = [] for r in rows: kpi = kpis.get(r.kpi_id) if r.kpi_id else None result.append({ "id": r.id, "kpi_id": r.kpi_id, "kpi_code": kpi.kpi_code if kpi else "", "kpi_name": kpi.kpi_name if kpi else "", "kpi_code_pattern": r.kpi_code_pattern, "plan_type": r.plan_type, "priority": r.priority, "status": r.status, "created_at": r.created_at.isoformat() if r.created_at else None, }) return {"data": result, "total": len(result)} @router.post("/cash-classify-rules") def create_classify_rule( data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """新建分类规则(kpi_id 精确 或 kpi_code_pattern 关键词 二选一)""" plan_type = data.get("plan_type") if plan_type not in ("receive", "pay"): raise HTTPException(400, "plan_type 必须是 receive/pay") kpi_id = data.get("kpi_id") pattern = data.get("kpi_code_pattern") if not kpi_id and not pattern: raise HTTPException(400, "需要 kpi_id 或 kpi_code_pattern 至少一个") row = CashPlanClassifyRule( entity_id=entity_id, kpi_id=kpi_id, kpi_code_pattern=pattern, plan_type=plan_type, priority=data.get("priority", 10), status=data.get("status", "active"), ) db.add(row) db.commit() db.refresh(row) return {"message": "分类规则已创建", "id": row.id} @router.put("/cash-classify-rules/{rule_id}") def update_classify_rule( rule_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """更新分类规则""" row = db.query(CashPlanClassifyRule).filter( CashPlanClassifyRule.id == rule_id, CashPlanClassifyRule.entity_id == entity_id, ).first() if not row: raise HTTPException(404, "规则不存在") for field in ("kpi_id", "kpi_code_pattern", "plan_type", "priority", "status"): if field in data: setattr(row, field, data[field]) db.commit() return {"message": "分类规则已更新", "id": row.id} @router.delete("/cash-classify-rules/{rule_id}") def delete_classify_rule( rule_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """删除分类规则""" row = db.query(CashPlanClassifyRule).filter( CashPlanClassifyRule.id == rule_id, CashPlanClassifyRule.entity_id == entity_id, ).first() if not row: raise HTTPException(404, "规则不存在") db.delete(row) db.commit() return {"message": "分类规则已删除"} # ── 待分类队列 ────────────────────────────── @router.get("/cash-unclassified") def list_unclassified( status: Optional[str] = Query(None, description="pending/classified/ignored"), db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), ): """待分类KPI队列""" query = db.query(CashPlanUnclassified).filter(CashPlanUnclassified.entity_id == entity_id) if status: query = query.filter(CashPlanUnclassified.status == status) rows = query.order_by(CashPlanUnclassified.created_at.desc()).all() result = [] for r in rows: result.append({ "id": r.id, "kpi_id": r.kpi_id, "kpi_name": r.kpi_name, "period": r.period, "budget_value": r.budget_value, "reason": r.reason, "status": r.status, "created_at": r.created_at.isoformat() if r.created_at else None, "resolved_at": r.resolved_at.isoformat() if r.resolved_at else None, }) return {"data": result, "total": len(result)} @router.post("/cash-unclassified/{item_id}/classify") def classify_unclassified( item_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """一键归类: body {plan_type: receive/pay} ① 自动补建分类规则 ② 标记队列 classified ③ 联动生成对应 CashPlan """ plan_type = data.get("plan_type") if plan_type not in ("receive", "pay"): raise HTTPException(400, "plan_type 必须是 receive/pay") item = db.query(CashPlanUnclassified).filter( CashPlanUnclassified.id == item_id, CashPlanUnclassified.entity_id == entity_id, CashPlanUnclassified.status == "pending", ).first() if not item: raise HTTPException(404, "待分类记录不存在或已处理") # ① 自动补建规则(无精确KPI规则时) existing_rule = db.query(CashPlanClassifyRule).filter( CashPlanClassifyRule.entity_id == entity_id, CashPlanClassifyRule.kpi_id == item.kpi_id, ).first() if not existing_rule: db.add(CashPlanClassifyRule( entity_id=entity_id, kpi_id=item.kpi_id, kpi_code_pattern=None, plan_type=plan_type, priority=10, status="active", )) # ② 标记队列 item.status = "classified" item.resolved_at = datetime.now() # ③ 联动生成 CashPlan(有期间和预算值时) plan_created = False if item.period and item.budget_value is not None: try: year, month = int(item.period.split("-")[0]), int(item.period.split("-")[1]) plan_date = datetime(year, month, 1) except Exception: plan_date = None if plan_date: existing_plan = db.query(CashPlan).filter( CashPlan.entity_id == entity_id, CashPlan.related_kpi_id == item.kpi_id, CashPlan.plan_type == plan_type, CashPlan.plan_date == plan_date, ).first() if not existing_plan: db.add(CashPlan( entity_id=entity_id, plan_type=plan_type, related_kpi_id=item.kpi_id, amount=item.budget_value, plan_date=plan_date, description=f"待分类队列归类: {item.kpi_name or ''}", status="pending", source="budget_sync", )) plan_created = True db.commit() return { "message": f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else ""), "plan_type": plan_type, "rule_created": not existing_rule, "plan_created": plan_created, } @router.post("/cash-unclassified/{item_id}/ignore") def ignore_unclassified( item_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """忽略该KPI(不生成规则)""" item = db.query(CashPlanUnclassified).filter( CashPlanUnclassified.id == item_id, CashPlanUnclassified.entity_id == entity_id, ).first() if not item: raise HTTPException(404, "待分类记录不存在") item.status = "ignored" item.resolved_at = datetime.now() db.commit() return {"message": "已忽略"}