ci/woodpecker/push/main Pipeline failed
- 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 属他会话在途件,未纳入。
267 lines
9.6 KiB
Python
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": "已忽略"}
|