fix(budget): 年度行(budget_month=0)不回落 1 月 + 同族 R1/R2/R3
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 属他会话在途件,未纳入。
This commit is contained in:
Hermes CI Fix
2026-10-13 03:41:39 +08:00
parent d08a8490eb
commit cfd6a13ca4
4 changed files with 423 additions and 29 deletions
+43 -3
View File
@@ -1,6 +1,8 @@
"""预算管理 API — 管理会计OS
预算值的CRUD、自动分解、版本管理
"""
import re
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, or_
@@ -96,7 +98,19 @@ def create_budget_plan(
if not kpi:
raise HTTPException(404, "KPI不存在")
year, month = period.split("-")
# 期间形态校验(年度行同族残余 2026-10-13:YYYY-00 显式接受为年度行,YYYY 提示补全形态)
if not isinstance(period, str):
raise HTTPException(400, "预算期间格式不正确:请使用 YYYY-MM(月 01–12)或 YYYY-00(年度)")
if re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", period):
# 月度形态
year, month = period.split("-")[0], int(period.split("-")[1])
elif re.fullmatch(r"\d{4}-00", period):
# 年度形态(系统内部既有年度行,如 apply-method / 年度分解写入):显式接受
year, month = period.split("-")[0], 0
elif re.fullmatch(r"\d{4}", period):
raise HTTPException(400, f"预算期间缺少月份:年度预算请使用 {period}-00 形态(例:2027-00),月度预算使用 YYYY-MM")
else:
raise HTTPException(400, "预算期间格式不正确:请使用 YYYY-MM(月 01–12)或 YYYY-00(年度)")
version = data.get("version", "v1.0")
map_id = data.get("map_id") # 预算归属地图(2026-08-27: 按地图隔离)
@@ -590,10 +604,14 @@ def budget_roll_forward(
.filter(BudgetPlan.status == "active", KPIDefinition.entity_id == entity_id)
.all())
# 按KPI分组
# 按KPI分组(年度行排除 2026-10-13:budget_month=0 哨兵不参与月度滚动,仅年度行的 KPI 不删不产新行)
from collections import defaultdict
kpi_plans = defaultdict(list)
annual_rows_ignored = 0
for p in plans:
if _is_annual_budget_row(p.period, p.budget_month):
annual_rows_ignored += 1
continue
kpi_plans[p.kpi_id].append(p)
rolled_kpis = []
@@ -653,6 +671,7 @@ def budget_roll_forward(
"rolled_kpis": rolled_kpis,
"current_month": f"{current_year}-{current_month:02d}",
"rolling_months": val.get("rolling_months", 12),
"annual_rows_ignored": annual_rows_ignored,
}
@@ -1631,6 +1650,17 @@ def diff_budget_versions(
# 预算↔现金流联动(断点修复#1, 2026-08-27)
# ════════════════════════════════════════════════════════════
def _is_annual_budget_row(period: Optional[str], budget_month: Optional[int]) -> bool:
"""年度预算行判定(形态驱动):budget_month == 0(年度哨兵),
或 period 命中年度形态 YYYY / YYYY-00。"""
if budget_month == 0:
return True
if period is None:
return False
p = str(period).strip()
return re.fullmatch(r"\d{4}(?:-00)?", p) is not None
@router.post("/sync-cash-plans")
def sync_cash_plans(
entity_id: int = Depends(get_entity_id),
@@ -1683,7 +1713,13 @@ def sync_cash_plans(
kpis = {k.id: k for k in db.query(KPIDefinition).filter(KPIDefinition.id.in_(kpi_ids)).all()} if kpi_ids else {}
created, updated, unclassified_count = 0, 0, 0
skipped_annual = 0
for b in budgets:
# 年度汇总行(budget_month=0 哨兵 / period=YYYY 或 YYYY-00)不联动现金流:
# CashPlan 无年度维度, 跳过以免全年额回落成 YYYY-01 污染按月分桶
if _is_annual_budget_row(b.period, b.budget_month):
skipped_annual += 1
continue
kpi = kpis.get(b.kpi_id)
if not kpi:
continue
@@ -1736,8 +1772,12 @@ def sync_cash_plans(
))
created += 1
db.commit()
message = f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条"
if skipped_annual:
message += f", 跳过年度行{skipped_annual}条"
return {
"message": f"现金流联动完成: 新建{created}条, 更新{updated}条, 待分类{unclassified_count}条",
"message": message,
"created": created, "updated": updated,
"unclassified_count": unclassified_count,
"skipped_annual": skipped_annual,
}
+39 -26
View File
@@ -196,40 +196,53 @@ def classify_unclassified(
item.status = "classified"
item.resolved_at = datetime.now()
# ③ 联动生成 CashPlan(有期间和预算值时)
# ③ 联动生成 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:
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
# 先按形态解析月份(不裸索引 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": f"已归类为 {plan_type}" + (" 并生成现金流计划" if plan_created else ""),
"message": message,
"plan_type": plan_type,
"rule_created": not existing_rule,
"plan_created": plan_created,
"skipped_annual": skipped_annual,
}