feat: 网银流水导入模板+现金流联动(财务数据通道P1)

This commit is contained in:
Hermes CI Fix
2026-08-28 09:59:07 +08:00
parent b9c624fd7e
commit 3bc68fa1c6
9 changed files with 1050 additions and 6 deletions
+404 -2
View File
@@ -1,10 +1,15 @@
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 (资金管理智能体)"""
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 + 网银流水导入 (资金管理智能体)"""
import io
import json
import logging
import os
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends, Query, Request
from pathlib import Path
from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import or_
import pandas as pd
from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_role
@@ -710,3 +715,400 @@ def api_import_bohai_ar(data: dict = None, entity_id: int = Depends(get_entity_i
"total_amount_wan": round(total_wan, 2),
"entity_id": entity_id,
}
# ══════════════════════════════════════════════════════════
# 6. 网银流水标准导入 — 三校验规则 + 现金流余额联动 (P1方案② 2026-08-28)
# 模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要
# ══════════════════════════════════════════════════════════
_TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "templates" / "网银流水导入模板.xlsx"
_TEMPLATE_PATH = str(_TEMPLATE_PATH) # FileResponse/os.path.exists 兼容 str
# 模板列别名映射(兼容中英文列名)
_VOUCHER_COL_ALIASES = {
"voucher_date": ["凭证日期", "日期", "voucher_date", "date"],
"voucher_no": ["凭证号", "凭证编号", "凭证字号", "voucher_no"],
"subject_code": ["科目编码", "科目代码", "subject_code", "code"],
"subject_name": ["科目名称", "subject_name", "name"],
"debit_amount": ["借方金额", "借方", "debit_amount", "debit"],
"credit_amount": ["贷方金额", "贷方", "credit_amount", "credit"],
"summary": ["摘要", "备注", "summary", "remark"],
}
def _resolve_voucher_cols(cols: list) -> dict:
"""列名归一化:返回 {字段: 实际列名}(不区分大小写/空格)"""
col_map = {}
for c in cols:
key = str(c).strip().lower()
if key and key not in col_map:
col_map[key] = str(c)
resolved = {}
for field, aliases in _VOUCHER_COL_ALIASES.items():
for alias in aliases:
if alias.lower() in col_map:
resolved[field] = col_map[alias.lower()]
break
return resolved
def _norm_str(raw) -> str:
"""单元格→干净字符串:NaN/None→空;整数float→去.0(如1002.0→1002"""
if raw is None:
return ""
if isinstance(raw, float) and pd.isna(raw):
return ""
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
f = float(raw)
return str(int(f)) if f.is_integer() else str(f)
s = str(raw).strip()
return "" if s.lower() in ("nan", "none") else s
def _is_carry_forward(summary: str, subject_name: str) -> bool:
"""结转行识别:摘要含'结转' 或 科目名称含'本年利润'/'结转'"""
return ("结转" in summary) or ("本年利润" in subject_name) or ("结转" in subject_name)
def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float:
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)"""
from app.models import VoucherDetail
from app.utils.cash_forecast_engine import set_current_cash_balance
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
balance_yuan = round(sum(
(r.debit_amount or 0) - (r.credit_amount or 0)
for r in rows
if r.carry_forward == 0
and r.subject_code
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
), 2)
cash_wan = round(balance_yuan / 10000, 4)
set_current_cash_balance(db, cash_wan)
logger.info(f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 → 现金余额联动")
return cash_wan
def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> list:
"""现金流KPI联动:
① EXT_现金类KPI → 货币资金科目期末余额(元,与存量口径一致,source_type=ledger
② F_CASH_SAFETY 现金安全垫(万元 = 货币资金余额 - 短期借款EXT_139,entity_id隔离,不存在则创建)
"""
from app.models import KPIDefinition, KPIValue, VoucherDetail
updates = []
period = periods[-1] if periods else datetime.now().strftime("%Y-%m")
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
monetary = [
r for r in rows
if r.carry_forward == 0 and r.subject_code
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
]
balance_yuan = round(sum((r.debit_amount or 0) - (r.credit_amount or 0) for r in monetary), 2)
cash_1001 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0)
for r in monetary if r.subject_code.startswith("1001")), 2)
cash_1002 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0)
for r in monetary if r.subject_code.startswith("1002")), 2)
cash_wan = round(balance_yuan / 10000, 2)
# ── ① EXT_现金类KPI(名称含'现金'/'货币资金'active;排除F_CASH_SAFETY,由②专用逻辑按万元口径处理)──
cash_kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
or_(KPIDefinition.kpi_name.like("%现金%"), KPIDefinition.kpi_name.like("%货币资金%")),
KPIDefinition.status == "active",
KPIDefinition.kpi_code != "F_CASH_SAFETY",
).all()
for k in cash_kpis:
if "库存现金" in k.kpi_name:
val = cash_1001
elif "银行" in k.kpi_name:
val = cash_1002
else:
val = balance_yuan
remark = f"网银流水导入[{batch}]联动: 货币资金科目期末余额{balance_yuan}元(库存现金{cash_1001}/银行存款{cash_1002})"
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period,
KPIValue.source_type == "ledger",
).order_by(KPIValue.id.desc()).first()
if existing:
existing.actual_value = val
existing.source_batch = batch # type: ignore[assignment] # SQLAlchemy Column类型推断噪音
existing.remark = remark
existing.calculated_at = datetime.now()
else:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=k.id,
period=period,
actual_value=val,
source_type="ledger",
source_batch=batch,
data_status="verified",
remark=remark,
))
updates.append({"kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "period": period, "value": val})
# ── ② F_CASH_SAFETY 现金安全垫(万元)──
safety_kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
or_(KPIDefinition.kpi_code == "F_CASH_SAFETY", KPIDefinition.kpi_name.like("%安全垫%")),
).first()
if not safety_kpi:
safety_kpi = KPIDefinition(
entity_id=entity_id,
kpi_code="F_CASH_SAFETY",
kpi_name="现金安全垫",
dimension="finance",
category="cash_risk",
formula="货币资金余额-短期借款",
formula_desc="货币资金科目(1001/1002)期末余额 - 短期借款(EXT_139),单位万元",
unit="万元",
target_value=0, # kpi_definitions.target_value NOT NULL DEFAULT 0.00ORM显式传None会绕过默认值导致IntegrityError
data_source_type="ledger",
data_source="网银流水导入联动",
data_owner="财务Bot",
frequency="monthly",
status="active",
kpi_level="operational",
important_flag=1,
data_level="core",
epic="Epic2",
)
db.add(safety_kpi)
db.flush()
logger.info(f"新增KPI F_CASH_SAFETY 现金安全垫 (entity_id={entity_id})")
short_debt_wan = None
debt_kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == "EXT_139",
).first()
if debt_kpi:
dv = db.query(KPIValue).filter(
KPIValue.kpi_id == debt_kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
if dv and dv.actual_value is not None:
short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元
if short_debt_wan is not None:
safety_value = round(cash_wan - short_debt_wan, 2)
remark = f"网银流水导入[{batch}]联动: 货币资金{cash_wan}万 - 短期借款{short_debt_wan}万 = 安全垫{safety_value}"
else:
safety_value = cash_wan
remark = f"网银流水导入[{batch}]联动: 无短期借款(EXT_139)数据,现金安全垫=货币资金余额{cash_wan}"
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == safety_kpi.id,
KPIValue.period == period,
KPIValue.source_type == "ledger",
).order_by(KPIValue.id.desc()).first()
if existing:
existing.actual_value = safety_value
existing.remark = remark
existing.calculated_at = datetime.now()
else:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=safety_kpi.id,
period=period,
actual_value=safety_value,
source_type="ledger",
source_batch=batch,
data_status="verified",
remark=remark,
))
updates.append({"kpi_code": "F_CASH_SAFETY", "kpi_name": "现金安全垫", "period": period, "value": safety_value})
return updates
@router.get("/import/template")
def api_get_voucher_template():
"""下载网银流水导入模板xlsx"""
if not os.path.exists(_TEMPLATE_PATH):
raise HTTPException(404, "模板文件不存在,请联系管理员生成")
return FileResponse(
_TEMPLATE_PATH,
filename="网银流水导入模板.xlsx",
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
@router.post("/import/vouchers")
async def api_import_vouchers(
file: UploadFile = File(...),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""网银流水标准导入 — 三校验(借贷平衡/期间合计/结转行) → voucher_details/import_logs → 现金流余额联动"""
from app.models import VoucherDetail, ImportLog
content = await file.read()
fname = (file.filename or "网银流水.xlsx").strip()
try:
df = pd.read_excel(io.BytesIO(content))
except Exception as e:
raise HTTPException(400, f"无法读取Excel文件: {e}")
if df is None or len(df) == 0:
raise HTTPException(400, "Excel文件为空(无数据行)")
colmap = _resolve_voucher_cols(list(df.columns))
missing = [f for f in ("voucher_date", "voucher_no", "subject_code") if f not in colmap]
if missing:
raise HTTPException(400, f"缺少必要列: {', '.join(missing)}(模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要)")
# ── 逐行校验 ──
errors = []
valid_rows = []
for idx, row in df.iterrows():
excel_row = idx + 2 # 表头占第1行
raw_date = row.get(colmap["voucher_date"])
if raw_date is None or (isinstance(raw_date, float) and pd.isna(raw_date)):
errors.append({"row": excel_row, "field": "voucher_date", "reason": "凭证日期为空"})
continue
try:
voucher_date = pd.to_datetime(raw_date).to_pydatetime()
except Exception:
errors.append({"row": excel_row, "field": "voucher_date", "reason": f"日期无法解析: {raw_date}"})
continue
voucher_no = _norm_str(row.get(colmap["voucher_no"]))
if not voucher_no:
errors.append({"row": excel_row, "field": "voucher_no", "reason": "凭证号为空"})
continue
subject_code = _norm_str(row.get(colmap["subject_code"]))
if not subject_code:
errors.append({"row": excel_row, "field": "subject_code", "reason": "科目编码为空"})
continue
subject_name = _norm_str(row.get(colmap["subject_name"]) if "subject_name" in colmap else "")
if not subject_name:
errors.append({"row": excel_row, "field": "subject_name", "reason": "科目名称为空"})
continue
def _parse_amount(raw) -> float:
"""金额解析:空→0;数字→float;字符串去逗号→float;失败→None"""
if raw is None or (isinstance(raw, float) and pd.isna(raw)):
return 0.0
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
return float(raw)
s = str(raw).strip().replace(",", "")
try:
return float(s)
except Exception:
return None
debit = _parse_amount(row.get(colmap["debit_amount"]) if "debit_amount" in colmap else None)
credit = _parse_amount(row.get(colmap["credit_amount"]) if "credit_amount" in colmap else None)
if debit is None:
errors.append({"row": excel_row, "field": "debit_amount", "reason": f"借方金额不是有效数字: {row.get(colmap['debit_amount'])}"})
continue
if credit is None:
errors.append({"row": excel_row, "field": "credit_amount", "reason": f"贷方金额不是有效数字: {row.get(colmap['credit_amount'])}"})
continue
if debit < 0 or credit < 0:
errors.append({"row": excel_row, "field": "amount", "reason": "金额不能为负"})
continue
if debit == 0 and credit == 0:
errors.append({"row": excel_row, "field": "amount", "reason": "借贷金额不能同时为0"})
continue
summary = _norm_str(row.get(colmap["summary"]) if "summary" in colmap else "")
carry_forward = 1 if _is_carry_forward(summary, subject_name) else 0
valid_rows.append({
"voucher_no": voucher_no,
"voucher_date": voucher_date,
"period": voucher_date.strftime("%Y-%m"),
"subject_code": subject_code,
"subject_name": subject_name,
"debit_amount": round(debit, 2),
"credit_amount": round(credit, 2),
"summary": summary,
"carry_forward": carry_forward,
})
total = len(df)
success = len(valid_rows)
failed = len(errors)
# ── 校验规则① 借贷平衡(Σ借 vs Σ贷,容差0.01)──
debit_total = round(sum(r["debit_amount"] for r in valid_rows), 2)
credit_total = round(sum(r["credit_amount"] for r in valid_rows), 2)
diff = round(debit_total - credit_total, 2)
balance_ok = abs(diff) <= 0.01
balance_check = {
"passed": balance_ok,
"debit_total": debit_total,
"credit_total": credit_total,
"diff": diff,
"tolerance": 0.01,
}
if not balance_ok:
errors.append({"row": 0, "field": "balance", "reason": f"借贷不平衡: 借方合计{debit_total} ≠ 贷方合计{credit_total},差额{diff}"})
# ── 校验规则② 期间合计(按period汇总,供对账)──
period_totals = {}
for r in valid_rows:
pt = period_totals.setdefault(r["period"], {"debit_total": 0.0, "credit_total": 0.0})
pt["debit_total"] = round(pt["debit_total"] + r["debit_amount"], 2)
pt["credit_total"] = round(pt["credit_total"] + r["credit_amount"], 2)
# ── 校验规则③ 结转行 ──
carry_forward_count = sum(1 for r in valid_rows if r["carry_forward"])
# ── 入库(部分成功模式:失败行不阻断整体)──
batch = f"{os.path.splitext(fname)[0]}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
for r in valid_rows:
db.add(VoucherDetail(
entity_id=entity_id,
voucher_no=r["voucher_no"],
voucher_date=r["voucher_date"],
subject_code=r["subject_code"],
subject_name=r["subject_name"],
debit_amount=r["debit_amount"],
credit_amount=r["credit_amount"],
summary=r["summary"],
carry_forward=r["carry_forward"],
period=r["period"],
batch=batch,
))
periods_in = sorted(set(r["period"] for r in valid_rows))
db.add(ImportLog(
entity_id=entity_id,
filename=fname,
batch=batch,
total_rows=total,
success_rows=success,
failed_rows=failed,
errors=errors or None,
period=periods_in[0] if periods_in else None,
import_type="vouchers",
created_by="finance-bot",
))
db.commit()
logger.info(f"网银流水导入[{batch}] entity={entity_id}: 总{total}/成功{success}/失败{failed}, 借贷平衡={'通过' if balance_ok else '失败'}")
# ── 现金流联动 ──
cash_balance = None
kpi_updates = []
try:
cash_balance = _sync_cash_balance_from_ledger(db, entity_id, batch)
kpi_updates = _sync_cash_kpis(db, entity_id, batch, periods_in)
db.commit()
except Exception as e:
db.rollback()
logger.error(f"网银流水导入[{batch}] 现金流联动失败: {e}", exc_info=True)
return {
"success": True,
"total": total,
"success_rows": success,
"failed_rows": failed,
"errors": errors,
"balance_check": balance_check,
"period_totals": period_totals,
"carry_forward_count": carry_forward_count,
"cash_balance": cash_balance,
"kpi_updates": kpi_updates,
"batch": batch,
"entity_id": entity_id,
}