Files
cma-management/backend/app/api/cash_classify.py
T
Hermes CI Fix cfd6a13ca4
ci/woodpecker/push/main Pipeline failed
fix(budget): 年度行(budget_month=0)不回落 1 月 + 同族 R1/R2/R3
- R-A2 sync-cash-plans: 年度行形态判定(_is_annual_budget_row)跳过 + skipped_annual 披露
- R2 POST /plans 期间形态校验(YYYY-MM / YYYY-00 显式接受)
- R3 budget_roll_forward 排除年度行
- R1 cash_classify 归类路径年度行判据 + 披露
- 新增 2 个回归件(14+10 例)

仅含本族 hunk(禁整文件提交): 开发区其余 14 hunk 属他会话在途件,未纳入。
2026-10-13 03:41:39 +08:00

267 lines
9.6 KiB
Python

"""现金流分类规则 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(有期间和预算值时;年度行跳过,不落月度计划)
from app.api.budget import _is_annual_budget_row # 函数内局部导入,规避 import 顺序/循环风险
plan_created = False
skipped_annual = False
if item.period and item.budget_value is not None:
# 先按形态解析月份(不裸索引 split,避免 'YYYY' / 'YYYY-00' 抛异常被静默吞掉)
parts = item.period.split("-") if isinstance(item.period, str) else []
month = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
if _is_annual_budget_row(item.period, month):
# 年度行(YYYY / YYYY-00 / budget_month=0)不得落成月度现金流计划
skipped_annual = True
else:
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()
message = f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else "")
if skipped_annual:
message += "(年度行,跳过月度现金流计划)"
return {
"message": message,
"plan_type": plan_type,
"rule_created": not existing_rule,
"plan_created": plan_created,
"skipped_annual": skipped_annual,
}
@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": "已忽略"}