feat: 网银流水导入模板+现金流联动(财务数据通道P1)
This commit is contained in:
+404
-2
@@ -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.00,ORM显式传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,
|
||||
}
|
||||
|
||||
@@ -612,9 +612,10 @@ class Subject(Base):
|
||||
|
||||
|
||||
class VoucherDetail(Base):
|
||||
"""凭证明细 — 新30号准则分类"""
|
||||
"""凭证明细 — 新30号准则分类 (网银流水导入 2026-08-28)"""
|
||||
__tablename__ = "voucher_details"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)")
|
||||
voucher_no = Column(String(50), nullable=False, comment="凭证编号")
|
||||
voucher_date = Column(DateTime, nullable=False, comment="凭证日期")
|
||||
subject_code = Column(String(20), nullable=False, comment="科目编码")
|
||||
@@ -622,8 +623,27 @@ class VoucherDetail(Base):
|
||||
debit_amount = Column(Float, default=0, comment="借方金额")
|
||||
credit_amount = Column(Float, default=0, comment="贷方金额")
|
||||
summary = Column(String(500), nullable=True, comment="摘要")
|
||||
carry_forward = Column(Integer, default=0, nullable=False, comment="结转行标记(1=结转行不参与现金流)")
|
||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类")
|
||||
period = Column(String(20), nullable=True, comment="期间 YYYY-MM")
|
||||
batch = Column(String(100), nullable=True, comment="导入批次号")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class ImportLog(Base):
|
||||
"""数据导入日志 — 网银流水/Excel导入批次记录 (2026-08-28)"""
|
||||
__tablename__ = "import_logs"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)")
|
||||
filename = Column(String(500), nullable=False, comment="文件名")
|
||||
batch = Column(String(100), nullable=False, comment="批次号")
|
||||
total_rows = Column(Integer, nullable=True, comment="总行数")
|
||||
success_rows = Column(Integer, nullable=True, comment="成功行数")
|
||||
failed_rows = Column(Integer, nullable=True, comment="失败行数")
|
||||
errors = Column(JSON, nullable=True, comment="失败详情 [{row, field, reason}]")
|
||||
period = Column(String(20), nullable=True, comment="导入期间")
|
||||
import_type = Column(String(20), nullable=True, comment="导入类型: vouchers/kpi")
|
||||
created_by = Column(String(100), nullable=True, comment="导入人")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""生成网银流水标准导入模板 xlsx — 列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要
|
||||
|
||||
用法: python scripts/gen_voucher_import_template.py
|
||||
输出: backend/scripts/templates/网银流水导入模板.xlsx(表头 + 1行示例)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
OUT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates", "网银流水导入模板.xlsx")
|
||||
|
||||
HEADERS = ["凭证日期", "凭证号", "科目编码", "科目名称", "借方金额", "贷方金额", "摘要"]
|
||||
# 1行示例(借贷平衡)
|
||||
EXAMPLE = ["2026-08-01", "记-001", "1002", "银行存款-工行", 50000, 0, "收到客户回款"]
|
||||
# 附赠一行结转示例行(注释说明用,不写入数据行)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "网银流水"
|
||||
|
||||
# 表头样式
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill("solid", fgColor="409EFF")
|
||||
for col, h in enumerate(HEADERS, start=1):
|
||||
cell = ws.cell(row=1, column=col, value=h)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
|
||||
for col, val in enumerate(EXAMPLE, start=1):
|
||||
cell = ws.cell(row=2, column=col, value=val)
|
||||
cell.alignment = Alignment(horizontal="center" if col in (1, 3, 5, 6) else "left")
|
||||
|
||||
# 列宽
|
||||
widths = [14, 12, 12, 22, 12, 12, 30]
|
||||
for i, w in enumerate(widths, start=1):
|
||||
ws.column_dimensions[get_column_letter(i)].width = w
|
||||
|
||||
# 说明sheet
|
||||
note = wb.create_sheet("填写说明")
|
||||
notes = [
|
||||
["网银流水标准导入模板 — 填写说明"],
|
||||
[""],
|
||||
["1. 列说明(与凭证明细表 voucher_details 对齐):"],
|
||||
[" 凭证日期: YYYY-MM-DD(必填,用于提取期间period)"],
|
||||
[" 凭证号: 字符串(必填,如 记-001 / 银收-20260801-001)"],
|
||||
[" 科目编码: 必填,如 1001库存现金 / 1002银行存款"],
|
||||
[" 科目名称: 必填,如 银行存款-工行 / 库存现金"],
|
||||
[" 借方金额: 数字,无则留空或0(与贷方二选一)"],
|
||||
[" 贷方金额: 数字,无则留空或0(与借方二选一)"],
|
||||
[" 摘要: 可选,含'结转'或科目名含'本年利润'的行将标记为结转行,不参与现金流计算"],
|
||||
[""],
|
||||
["2. 三校验规则(导入时自动执行):"],
|
||||
[" ① 借贷平衡: 全文件Σ借方 = Σ贷方(容差0.01),不平衡将提示差额"],
|
||||
[" ② 期间合计: 按期间(YYYY-MM)汇总借贷合计,供对账"],
|
||||
[" ③ 结转行识别: 摘要含'结转' 或 科目名含'本年利润'/'结转' → carry_forward标记"],
|
||||
[""],
|
||||
["3. 现金流联动:货币资金科目(1001/1002开头)期末余额自动更新现金余额与EXT_现金类KPI、F_CASH_SAFETY现金安全垫"],
|
||||
["4. 示例行(第2行)请删除后填入真实流水;不要修改表头列名"],
|
||||
]
|
||||
for row in notes:
|
||||
note.append(row)
|
||||
note.column_dimensions["A"].width = 90
|
||||
|
||||
wb.save(OUT_PATH)
|
||||
print(f"模板已生成: {OUT_PATH}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
@@ -1,11 +1,13 @@
|
||||
"""现金流模块测试 — 收付款计划 + 资金缺口预测 + 看板
|
||||
"""现金流模块测试 — 收付款计划 + 资金缺口预测 + 看板 + 网银流水导入
|
||||
|
||||
覆盖 cash.py 核心端点:
|
||||
gap-forecast / balance GET+POST / plans CRUD / plans{id}/complete /
|
||||
upcoming / dashboard / check-alerts / alerts/status
|
||||
upcoming / dashboard / check-alerts / alerts/status / import/vouchers
|
||||
"""
|
||||
import io
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -218,3 +220,98 @@ class TestAlerts:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "entity_id" in data and "critical_line" in data
|
||||
|
||||
|
||||
class TestVoucherImport:
|
||||
"""网银流水导入 — 三校验规则(借贷平衡/期间合计/结转行)+ 入库 + 现金流联动"""
|
||||
|
||||
COLS = ["凭证日期", "凭证号", "科目编码", "科目名称", "借方金额", "贷方金额", "摘要"]
|
||||
|
||||
def _xlsx(self, rows: list, cols: list = None) -> io.BytesIO:
|
||||
df = pd.DataFrame(rows, columns=cols or self.COLS)
|
||||
buf = io.BytesIO()
|
||||
df.to_excel(buf, index=False)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
def _upload(self, client, token, buf, fname="test_vouchers.xlsx"):
|
||||
return client.post(
|
||||
f"{BASE}/import/vouchers", headers=auth_header(token),
|
||||
files={"file": (fname, buf,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")},
|
||||
)
|
||||
|
||||
def test_import_ok_with_rules(self, client: TestClient, db: Session):
|
||||
"""正常导入:借贷平衡+结转行识别+期间合计,全部成功"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
buf = self._xlsx([
|
||||
["2026-08-01", "记-001", "1002", "银行存款-工行", 50000, 0, "收到客户回款"],
|
||||
["2026-08-02", "记-001", "1001", "库存现金", 0, 50000, "提现备用"],
|
||||
["2026-08-31", "记-099", "4103", "本年利润", 2000, 0, "结转利润"],
|
||||
["2026-08-31", "记-099", "6001", "主营业务收入", 0, 2000, "结转收入"],
|
||||
])
|
||||
resp = self._upload(client, token, buf)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["total"] == 4
|
||||
assert data["success_rows"] == 4
|
||||
assert data["failed_rows"] == 0
|
||||
assert data["balance_check"]["passed"] is True
|
||||
assert data["balance_check"]["debit_total"] == 52000
|
||||
assert data["carry_forward_count"] == 2 # 结转行识别(本年利润+结转摘要)
|
||||
assert "2026-08" in data["period_totals"]
|
||||
assert data["cash_balance"] == 0.0 # 货币资金联动(50000-50000=0万元)
|
||||
# 入库验证
|
||||
from app.models import VoucherDetail, ImportLog
|
||||
details = db.query(VoucherDetail).all()
|
||||
assert len(details) == 4
|
||||
assert all(d.entity_id == 1 for d in details)
|
||||
cf = [d for d in details if d.carry_forward == 1]
|
||||
assert len(cf) == 2 and all("结转" in (d.summary or "") for d in cf)
|
||||
log = db.query(ImportLog).order_by(ImportLog.id.desc()).first()
|
||||
assert log is not None and log.import_type == "vouchers"
|
||||
assert log.success_rows == 4 and log.total_rows == 4
|
||||
|
||||
def test_import_unbalanced(self, client: TestClient, db: Session):
|
||||
"""借贷不平衡:balance_check.passed=False + 差额报告,行仍入库"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
buf = self._xlsx([
|
||||
["2026-08-01", "记-001", "1002", "银行存款-工行", 10000, 0, "回款"],
|
||||
["2026-08-01", "记-002", "1002", "银行存款-工行", 0, 3000, "付款"],
|
||||
])
|
||||
resp = self._upload(client, token, buf)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["balance_check"]["passed"] is False
|
||||
assert data["balance_check"]["diff"] == 7000
|
||||
|
||||
def test_import_partial_fail(self, client: TestClient, db: Session):
|
||||
"""部分失败模式:坏行进errors,好行入库"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
buf = self._xlsx([
|
||||
["2026-08-01", "记-001", "1002", "银行存款-工行", 8000, 0, "回款"],
|
||||
["bad-date", "记-002", "1001", "库存现金", 0, 8000, "提现"],
|
||||
["2026-08-01", "", "1001", "库存现金", 100, 0, "缺凭证号"],
|
||||
])
|
||||
resp = self._upload(client, token, buf)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success_rows"] == 1
|
||||
assert data["failed_rows"] == 2
|
||||
# 行级错误2条 + 借贷平衡全局错误1条(仅8000借无贷)
|
||||
assert len(data["errors"]) == 3
|
||||
assert any(e["field"] == "balance" for e in data["errors"])
|
||||
from app.models import VoucherDetail
|
||||
assert db.query(VoucherDetail).count() == 1
|
||||
|
||||
def test_import_missing_cols(self, client: TestClient, db: Session):
|
||||
"""缺必要列 → 400"""
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
buf = self._xlsx([["2026-08-01", "记-001", 100, 0]], cols=["日期", "凭证号", "借方金额", "贷方金额"])
|
||||
resp = self._upload(client, token, buf)
|
||||
assert resp.status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user