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 json
import logging import logging
import os
from datetime import datetime, timedelta 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.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
import pandas as pd
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_role 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), "total_amount_wan": round(total_wan, 2),
"entity_id": entity_id, "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,
}
+21 -1
View File
@@ -612,9 +612,10 @@ class Subject(Base):
class VoucherDetail(Base): class VoucherDetail(Base):
"""凭证明细 — 新30号准则分类""" """凭证明细 — 新30号准则分类 (网银流水导入 2026-08-28)"""
__tablename__ = "voucher_details" __tablename__ = "voucher_details"
id = Column(Integer, primary_key=True, index=True) 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_no = Column(String(50), nullable=False, comment="凭证编号")
voucher_date = Column(DateTime, nullable=False, comment="凭证日期") voucher_date = Column(DateTime, nullable=False, comment="凭证日期")
subject_code = Column(String(20), 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="借方金额") debit_amount = Column(Float, default=0, comment="借方金额")
credit_amount = Column(Float, default=0, comment="贷方金额") credit_amount = Column(Float, default=0, comment="贷方金额")
summary = Column(String(500), nullable=True, 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号准则分类") new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类")
period = Column(String(20), nullable=True, comment="期间 YYYY-MM") 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()) 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())
+99 -2
View File
@@ -1,11 +1,13 @@
"""现金流模块测试 — 收付款计划 + 资金缺口预测 + 看板 """现金流模块测试 — 收付款计划 + 资金缺口预测 + 看板 + 网银流水导入
覆盖 cash.py 核心端点: 覆盖 cash.py 核心端点:
gap-forecast / balance GET+POST / plans CRUD / plans{id}/complete / 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 from datetime import datetime, timedelta
import pandas as pd
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -218,3 +220,98 @@ class TestAlerts:
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.json() data = resp.json()
assert "entity_id" in data and "critical_line" in data 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
+109
View File
@@ -0,0 +1,109 @@
# 财务数据通道方案:网银流水标准导入模板(P1)
- 提出:研学调度中枢(反向上报触发,2026-08-28)
- 执行:项目Bot(方案/验收/协调)→ 全栈Bot(开发)
- 背景:酣客现金流2.2万 vs 短债350万 = 全年最大风险,现金流监控空窗(不直连,靠手工)
## 一、ERP连通可行性结论(已完成摸底,2026-08-28)
| 检查项 | 结果 |
|--------|------|
| ERP网关容器 | 活着(erp-gateway Up 4 days127.0.0.1:8300 |
| ERP真实数据库 | **不可达**SQL Server 211.149.143.215 连接超时(pymssql OperationalError 20009 |
| ERP API 端点 | /stats/monthly、/cashflow 等全部 000/500 |
| erp_sync cron | 2026-08-19 已 PAUSED#PAUSED-20260819 |
| kpi_values erp源数据 | 0 条(从未同步成功) |
| data_source_config | 12条ERP源配置存在但全部空转 |
**结论:ERP连通短期无望(真实ERP库在外部网络不可达),走方案②网银流水标准导入模板。**
## 二、现状盘点(基础设施大部分已就绪)
| 已有资产 | 状态 |
|----------|------|
| voucher_details 表(凭证明细) | ✅ 已建,0行。字段:voucher_no/voucher_date/subject_code/subject_name/debit_amount/credit_amount/summary/new_standard_category/period |
| import_logs 表(导入日志) | ✅ 已建,0行。字段:filename/batch/total_rows/success_rows/failed_rows/errors/period/import_type/created_by |
| VoucherDetail 模型 | ✅ 已注册(app/models/__init__.py:614 |
| /api/cma/cash/balance | ✅ 现金余额(手工基线) |
| /api/cma/cash/dashboard | ✅ 现金流看板 |
| /api/cma/cash/gap-forecast | ✅ 缺口预测 |
| /api/cma/cash/plans | ✅ 收付款计划 |
| DataManage.vue Excel导入tab | ✅ 已有(import-excel-smartKPI导入) |
| 现金流KPIEXT_202-208各店现金等) | ✅ 存在,2026-06有数据(手工Excel导入) |
**缺口(本次开发内容)**:无凭证/网银流水导入API、无校验规则(借贷平衡/期间合计/结转行识别)、无前端流水导入界面、现金流余额不自动更新。
## 三、开发内容(全栈Bot执行)
### 3.1 导入模板定义(xlsx
模板列(与 voucher_details 字段对齐):
| 列名 | 字段 | 必填 | 说明 |
|------|------|------|------|
| 凭证日期 | voucher_date | ✅ | YYYY-MM-DD 或 日期格式 |
| 凭证号 | voucher_no | ✅ | 字符串 |
| 科目编码 | subject_code | ✅ | 如 1002(银行存款) |
| 科目名称 | subject_name | ✅ | 如 银行存款-工行 |
| 借方金额 | debit_amount | 二选一 | 无则0 |
| 贷方金额 | credit_amount | 二选一 | 无则0 |
| 摘要 | summary | 可选 | 结转行识别依据 |
生成模板文件:`backend/scripts/templates/网银流水导入模板.xlsx`(含表头+1行示例)。
### 3.2 新入库接口 POST /api/cma/cash/import/vouchers
入参:multipart file + entity_idDepends get_entity_id+ period(可选,默认从文件名/日期提取)
处理流程:
1. 解析xlsxopenpyxl/pandas
2. 逐行校验:日期可解析、科目编码/名称非空、金额为数字且≥0、借贷不全为0
3. 校验规则(核心):
- **借贷平衡**:Σ借方 = Σ贷方(容差 0.01),不平衡返回错误+差额
- **期间合计**:按 period 汇总借方/贷方合计(用于对账展示)
- **结转行识别**:摘要含"结转"或科目名称含"本年利润/结转" → 标记 carry_forward=True,不参与现金流计算
4. 写入 voucher_detailsbatch = 文件名_时间戳),period 从日期列提取
5. 写 import_logstotal/success/failed/errors 明细)
6. **现金流联动**:计算货币资金类科目(科目编码 1001/1002 开头)期末余额 → 调用 set_current_cash_balance → 同步更新 EXT_现金类KPI 实际值(写入 kpi_valuessource_type=ledger)→ 看板可见
返回:{success, total, success_rows, failed_rows, errors[], 借贷平衡校验, 期间合计, 结转行数, 现金余额}
### 3.3 校验规则实现细节
- 借贷平衡容差:|Σ借-Σ贷| <= 0.01 通过
- 期间合计:返回 {period: {debit_total, credit_total}} 供对账
- 结转行识别:summary LIKE '%结转%' OR subject_name LIKE '%本年利润%' OR subject_name LIKE '%结转%'
- 失败行收集:{行号, 原因} 数组,不中断整体导入(部分成功模式)
### 3.4 前端:CashPlan.vue 增加"网银流水导入"tab
- el-tab-pane "流水导入":上传xlsx → 调 import/vouchers → 显示校验结果(借贷平衡✅/❌、期间合计、成功/失败行、错误明细)→ 成功提示
- 注意:项目已知 el-dialog 坑,弹窗用 MyDialog;交互组件用原生 button
- 导入成功后刷新 cash dashboard(现金余额更新可见)
### 3.5 财务Bot自助入库流程(文档)
文档:`docs/财务Bot网银流水自助入库流程.md`
- 每月出纳导出网银流水 → 按模板整理xlsx
- 财务Bot调 POST /api/cma/cash/import/voucherscurl 或脚本)
- 校验通过 → 入库 → 现金余额自动更新 → 看板可见
- 校验失败 → 按错误明细修正后重导
## 四、验收标准(铁律七:不验证=没做)
1. ERP可行性结论 ✅(已有:ERP库不可达,走②)
2. 模板文件存在:ls backend/scripts/templates/网银流水导入模板.xlsx
3. 校验规则跑通:构造测试xlsx(含借贷不平衡、结转行、正常行)实测三种规则
4. 入库接口可用:curl 导入 → SELECT voucher_details 有数据 → import_logs 有记录
5. 现金流联动:导入后 GET /api/cma/cash/balance 现金余额=货币资金科目余额,dashboard可见
6. 前端:CashPlan.vue 有"流水导入"tab,上传可导入
7. 文档:财务Bot自助入库流程文档存在
8. 无回归:/health 正常,已有cash端点正常
## 五、开发约束
- 代码库:/root/cma-management(后端 FastAPI + 前端 Vue3
- 后端重启:systemctl restart cma-backend(禁止手动起 uvicorn
- 前端部署:npm run build → cp -rf dist/* /var/www/cma/
- 完成后 git add 关键目录(backend/app/ frontend/src/+ commit + push
- 数据库:MySQL cma 库,多租户 entity_id 隔离
@@ -0,0 +1,160 @@
# 财务Bot网银流水自助入库流程
> 适用:每月出纳导出银行/现金流水 → 按模板整理 xlsx → 财务Bot调用导入 API → 三校验 → 入库 → 现金流KPI联动 → 看板可见。
> 方案文档:`docs/网银流水导入方案-20260828.md` 后端代码:`backend/app/api/cash.py`(网银流水标准导入区)
---
## 一、流程总览
```
出纳导出网银流水(Excel)
按模板整理 xlsx(列:凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要)
① 下载模板 GET /api/cma/cash/import/template (模板缺失时参考)
② 导入 POST /api/cma/cash/import/vouchers multipart file + entity_id
三校验:① 借贷平衡(容差0.01) ② 期间合计 ③ 结转行识别
入库:voucher_details(凭证明细)+ import_logs(导入日志)
现金流联动:现金余额自动更新 + EXT_现金类KPI + F_CASH_SAFETY 现金安全垫KPI
看板可见:GET /api/cma/cash/balance、/api/cma/cash/dashboard
```
---
## 二、模板字段说明
模板下载:`GET /api/cma/cash/import/template`(需登录态,返回 xlsx 附件)。
| 列名 | 字段 | 必填 | 说明 |
|------|------|------|------|
| 凭证日期 | voucher_date | ✅ | 日期格式(Excel日期/文本均可,如 2026-08-01 |
| 凭证号 | voucher_no | ✅ | 如 `记-001` |
| 科目编码 | subject_code | ✅ | 如 `1002``1001``1122`**1001/1002 开头视为货币资金科目**(现金流联动依据) |
| 科目名称 | subject_name | ✅ | 如 `银行存款-工行` |
| 借方金额 | debit_amount | 条件 | 数字,可含千分位逗号;与贷方二选一 |
| 贷方金额 | credit_amount | 条件 | 数字,可含千分位逗号;与借方二选一 |
| 摘要 | summary | 条件 | 含"结转"或科目名含"本年利润"/"结转" → 识别为结转行 |
> 列名兼容中英文别名(如 `date`/`voucher_date`、`借方`/`debit_amount` 等,不区分大小写),但建议严格使用模板列名。
> 模板为单示例行,导入前删除示例行或直接覆盖为真实流水。
---
## 三、curl 命令示例
### 1. 登录拿 tokenadmin/admin123,账套 entity_id=1 酣客)
```bash
TOKEN=$(curl -s -X POST http://127.0.0.1:8010/api/cma/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123","entity_id":1}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
echo "$TOKEN" # 响应字段含 token/entity_id/entity_name/user
```
### 2. 下载导入模板
```bash
curl -s -o 网银流水导入模板.xlsx \
-H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:8010/api/cma/cash/import/template
ls -la 网银流水导入模板.xlsx # 应 >1000 字节
```
### 3. 导入网银流水(multipart 上传)
```bash
curl -s -X POST http://127.0.0.1:8010/api/cma/cash/import/vouchers \
-H "Authorization: Bearer $TOKEN" \
-F "entity_id=1" \
-F "file=@/path/to/2026年08月网银流水.xlsx"
```
### 4. 看板核对
```bash
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8010/api/cma/cash/balance?entity_id=1"
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8010/api/cma/cash/dashboard?entity_id=1"
```
---
## 四、三校验规则(导入时自动执行)
| # | 规则 | 逻辑 | 不通过时 |
|---|------|------|----------|
| ① | **借贷平衡** | Σ借方 = Σ贷方,容差 **0.01** | 返回 `balance_check.passed=false`errors 追加 `借贷不平衡: 借方合计X ≠ 贷方合计Y,差额Z`(仍入库其余行,部分成功模式) |
| ② | **期间合计** | 按 `period`(YYYY-MM)汇总借方/贷方合计,返回 `period_totals`,供对账 | 不阻断,仅展示 |
| ③ | **结转行识别** | 摘要含"结转" 或 科目名含"本年利润"/"结转" → `carry_forward=1`,**不参与现金流余额计算** | 不阻断,返回 `carry_forward_count` |
> 逐行校验(失败行记录 errors,不阻断整体):凭证日期为空/无法解析、凭证号为空、科目编码为空、科目名称为空、金额非数字、金额为负、借贷同时为0 → 该行跳过,其余行照常入库(**部分成功模式**)。
---
## 五、入库内容
- **voucher_details 表**(凭证明细):voucher_no / voucher_date / subject_code / subject_name / debit_amount / credit_amount / summary / carry_forward / period / batchbatch = 文件名_时间戳)
- **import_logs 表**(导入日志):filename / batch / total_rows / success_rows / failed_rows / errors(明细JSON/ period / import_type="vouchers" / created_by="finance-bot"
返回体示例:
```json
{
"success": true,
"total": 120, "success_rows": 118, "failed_rows": 2,
"errors": [{"row": 5, "field": "voucher_date", "reason": "日期无法解析: xxx"}],
"balance_check": {"passed": true, "debit_total": 123456.78, "credit_total": 123456.78, "diff": 0.0, "tolerance": 0.01},
"period_totals": {"2026-08": {"debit_total": 123456.78, "credit_total": 123456.78}},
"carry_forward_count": 2,
"cash_balance": 88.88,
"kpi_updates": [{"kpi_code": "EXT_069", "kpi_name": "库存现金", "period": "2026-08", "value": ...}, ...],
"batch": "2026年08月网银流水_20260828153000",
"entity_id": 1
}
```
---
## 六、现金流联动(导入成功自动触发)
1. **现金余额自动更新**:汇总 1001/1002 开头科目(排除结转行)`Σ借 - Σ贷` 得货币资金期末余额(元)→ `set_current_cash_balance`(万元)→ `GET /api/cma/cash/balance``current_cash` 实时反映
2. **EXT_现金类KPI**:名称含"现金"/"货币资金"的 active KPI(排除 F_CASH_SAFETY)写入/更新 `kpi_values`source_type="ledger"、data_status="verified"、remark 记录批次与明细口径)——库存现金类→1001余额,银行类→1002余额,其余→货币资金总额
3. **F_CASH_SAFETY 现金安全垫KPI**(万元):不存在则自动创建(formula=货币资金余额-短期借款);短期借款取 EXT_139 最新实际值(单位元÷10000),无数据时安全垫=货币资金余额
---
## 七、常见错误处理
| 现象 | 原因 | 处理 |
|------|------|------|
| 返回 `借贷不平衡: 借方合计X ≠ 贷方合计Y`balance_check.passed=false | 流水有遗漏/金额录错 | 核对 Excel 借贷金额,修正后重新导入(余额按最新批次重算,重复导入不叠加) |
| HTTP 400 `缺少必要列: ...` | 列名与模板不一致(如"日期"未被别名命中、列名带空格/全半角差异) | 对照模板列名重命名表头,或使用 `_VOUCHER_COL_ALIASES` 支持的别名 |
| HTTP 400 `无法读取Excel文件` | 文件损坏/非xlsx/加密 | 用 WPS/Excel 另存为 .xlsx 后再传 |
| HTTP 400 `Excel文件为空(无数据行)` | 工作表无数据 | 删除空sheet或填入数据 |
| errors 含 `日期无法解析` | 凭证日期为文本/格式异常 | 改成标准日期格式(如 2026-08-01 |
| errors 含 `金额不能为负` / `借贷金额不能同时为0` | 数据录入问题 | 修正对应行(失败行不会入库) |
| errors 含 `凭证号为空` / `科目编码为空` / `科目名称为空` | 缺单元格 | 补全后重导 |
| HTTP 401 | token 失效/未登录 | 重新登录拿 token;注意登录必须带 entity_id |
| HTTP 403 | 账号未授权该 entity_id | 联系管理员在 user_entities 授权 |
| 模板下载 404 | 模板文件缺失 | 检查 `backend/scripts/templates/网银流水导入模板.xlsx` 是否存在,或运行 `backend/scripts/gen_voucher_import_template.py` 重新生成 |
---
## 八、注意事项
- 导入是**增量写入**:同一文件重复导入会生成多条记录(batch 不同),余额按最新 batch 重算;对账以 `batch` 为粒度
- 结转行不参与现金流余额计算,但会计入借贷平衡校验
- 现金流联动失败不阻断入库(已入库数据保留,KPI 联动失败记 error 日志),可联系全栈Bot排查 `journalctl -u cma-backend`
+7
View File
@@ -383,6 +383,13 @@ export const cashApi = {
receivables: (params?: any) => api.get('/cash/receivables', { params }), receivables: (params?: any) => api.get('/cash/receivables', { params }),
registerPayment: (id: number, data: any) => api.post(`/cash/receivables/${id}/payment`, data), registerPayment: (id: number, data: any) => api.post(`/cash/receivables/${id}/payment`, data),
importBohaiAR: () => api.post('/cash/import/bohai-ar', {}), importBohaiAR: () => api.post('/cash/import/bohai-ar', {}),
// 网银流水导入(P1方案② 2026-08-28
importVouchers: (file: File) => {
const form = new FormData()
form.append('file', file)
return api.post('/cash/import/vouchers', form, { timeout: 60000 })
},
getVoucherTemplate: () => api.get('/cash/import/template', { responseType: 'blob' }),
// 到期提醒 // 到期提醒
upcoming: (params?: any) => api.get('/cash/upcoming', { params }), upcoming: (params?: any) => api.get('/cash/upcoming', { params }),
// 页面看板(日历+预测+提醒) // 页面看板(日历+预测+提醒)
+174 -1
View File
@@ -2,6 +2,8 @@
<div> <div>
<h3 style="margin-bottom: 12px;">收付款计划 · 资金缺口预测</h3> <h3 style="margin-bottom: 12px;">收付款计划 · 资金缺口预测</h3>
<el-tabs v-model="activeTab" class="cash-plan-tabs">
<el-tab-pane label="资金看板" name="dashboard">
<!-- 顶部统计卡片 --> <!-- 顶部统计卡片 -->
<el-row :gutter="12" style="margin-bottom: 12px;"> <el-row :gutter="12" style="margin-bottom: 12px;">
<el-col :span="4"> <el-col :span="4">
@@ -197,7 +199,80 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </el-card>
</el-tab-pane>
<!-- 流水导入 tab网银流水标准导入 校验 入库 现金流联动 -->
<el-tab-pane label="流水导入" name="import">
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<span>📥 网银流水导入Excel 三校验 入库 现金流联动</span>
<button type="button" class="cash-btn cash-btn-primary" @click="downloadTemplate"> 下载导入模板</button>
</div>
</template>
<div class="import-toolbar">
<input ref="fileInput" type="file" accept=".xlsx,.xls" style="display:none;" @change="onFileChange" />
<button type="button" class="cash-btn" @click="fileInput?.click()">📂 选择Excel文件</button>
<span v-if="selectedFile" class="import-filename">{{ selectedFile.name }}</span>
<span v-else class="import-filename muted">未选择文件请先下载模板按模板列整理银行流水后导入</span>
<button type="button" class="cash-btn cash-btn-primary" :disabled="!selectedFile || importing" @click="doImportVouchers">
{{ importing ? '导入中…' : '🚀 开始导入' }}
</button>
</div>
<div v-if="importError" class="import-result error">
<b> 导入失败</b>{{ importError }}
</div>
<div v-if="importResult && !importError" class="import-result">
<!-- 借贷平衡 -->
<div class="ir-row" :class="importResult.balance_check?.passed ? 'ok' : 'fail'">
<span class="ir-label">借贷平衡</span>
<b>{{ importResult.balance_check?.passed ? '✅ 通过' : '❌ 不平衡' }}</b>
<span class="ir-sub">借方合计 {{ fmtMoney(importResult.balance_check?.debit_total) }} / 贷方合计 {{ fmtMoney(importResult.balance_check?.credit_total) }} / 差额 {{ fmtMoney(importResult.balance_check?.diff) }}容差 {{ importResult.balance_check?.tolerance }}</span>
</div>
<!-- 期间合计 -->
<div class="ir-row">
<span class="ir-label">期间合计</span>
<span v-for="(pt, period) in importResult.period_totals || {}" :key="period" class="ir-tag">
{{ period }} {{ fmtMoney(pt.debit_total) }} / {{ fmtMoney(pt.credit_total) }}
</span>
<span v-if="!Object.keys(importResult.period_totals || {}).length" class="ir-sub muted">无有效行</span>
</div>
<!-- 行统计 -->
<div class="ir-row">
<span class="ir-label">导入统计</span>
<span class="ir-tag">总行数 {{ importResult.total }}</span>
<span class="ir-tag ok">成功 {{ importResult.success_rows }}</span>
<span class="ir-tag" :class="importResult.failed_rows > 0 ? 'fail' : 'ok'">失败 {{ importResult.failed_rows }}</span>
<span class="ir-tag">结转行 {{ importResult.carry_forward_count }}</span>
<span class="ir-tag">批次 {{ importResult.batch }}</span>
</div>
<!-- 现金流联动 -->
<div class="ir-row" v-if="importResult.cash_balance !== null && importResult.cash_balance !== undefined">
<span class="ir-label">现金余额联动</span>
<b class="ok">更新后货币资金余额 = {{ importResult.cash_balance }} 万元</b>
<span class="ir-sub">1001/1002 科目期末余额 ÷ 10000</span>
</div>
<div class="ir-row" v-if="(importResult.kpi_updates || []).length">
<span class="ir-label">KPI联动</span>
<span v-for="k in importResult.kpi_updates" :key="k.kpi_code + k.period" class="ir-tag">
{{ k.kpi_name }}{{ k.kpi_code }}{{ k.period }} = {{ k.value }}
</span>
</div>
<!-- 错误明细 -->
<div v-if="(importResult.errors || []).length" class="ir-errors">
<div class="ir-label"> 错误明细{{ importResult.errors.length }} </div>
<div v-for="(e, i) in importResult.errors" :key="i" class="ir-error-line">
{{ e.row === 0 ? '—' : e.row }} [{{ e.field }}]{{ e.reason }}
</div>
</div>
</div>
</el-card>
</el-tab-pane>
</el-tabs>
<!-- 新建/编辑弹窗 --> <!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑收付款计划' : '新建收付款计划'" width="480px"> <el-dialog v-model="dialogVisible" :title="form.id ? '编辑收付款计划' : '新建收付款计划'" width="480px">
@@ -255,6 +330,14 @@ const alertChecking = ref(false)
const alertCheckMsg = ref('触发预警检查') const alertCheckMsg = ref('触发预警检查')
const saving = ref(false) const saving = ref(false)
//
const activeTab = ref('dashboard')
const fileInput = ref<HTMLInputElement>()
const selectedFile = ref<File | null>(null)
const importing = ref(false)
const importResult = ref<any>(null)
const importError = ref('')
// //
const now = new Date() const now = new Date()
const curY = now.getFullYear() const curY = now.getFullYear()
@@ -517,6 +600,60 @@ async function runAlertCheck() {
} }
} }
//
function fmtMoney(v: any): string {
if (v === null || v === undefined || isNaN(Number(v))) return '—'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function onFileChange(e: any) {
const f = e.target?.files?.[0]
selectedFile.value = f || null
importResult.value = null
importError.value = ''
}
async function downloadTemplate() {
try {
const blob: any = await cashApi.getVoucherTemplate()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '网银流水导入模板.xlsx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('模板已下载,请按模板列整理流水后导入')
} catch (e: any) {
ElMessage.error('模板下载失败: ' + (e?.response?.data?.detail || e.message))
}
}
async function doImportVouchers() {
if (!selectedFile.value) { ElMessage.warning('请先选择Excel文件'); return }
importing.value = true
importResult.value = null
importError.value = ''
try {
const r: any = await cashApi.importVouchers(selectedFile.value)
importResult.value = r
// //+
await loadAll()
if (r.balance_check?.passed && r.success_rows > 0) {
ElMessage.success(`导入完成:成功 ${r.success_rows} 行 / 失败 ${r.failed_rows} 行,现金余额已联动为 ${r.cash_balance} 万元`)
} else {
ElMessage.warning(`导入完成但有异常:成功 ${r.success_rows} 行 / 失败 ${r.failed_rows} 行,详见下方结果`)
}
} catch (e: any) {
const msg = e?.response?.data?.detail || e.message
importError.value = msg
ElMessage.error('导入失败: ' + msg)
} finally {
importing.value = false
}
}
// //
onMounted(() => { loadAll() }) onMounted(() => { loadAll() })
onBeforeUnmount(() => { if (chart) { chart.dispose(); chart = null } }) onBeforeUnmount(() => { if (chart) { chart.dispose(); chart = null } })
@@ -554,4 +691,40 @@ onBeforeUnmount(() => { if (chart) { chart.dispose(); chart = null } })
.remind-amt { font-weight: 700; color: #303133; } .remind-amt { font-weight: 700; color: #303133; }
.remind-ct { color: #606266; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .remind-ct { color: #606266; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.remind-date { color: #909399; font-size: 12px; } .remind-date { color: #909399; font-size: 12px; }
/* ── 流水导入 tab ── */
.cash-plan-tabs { margin-top: 4px; }
.import-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.cash-btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 7px 14px; font-size: 13px; line-height: 1.4;
border: 1px solid #dcdfe6; border-radius: 4px; background: #fff; color: #606266;
cursor: pointer; transition: all .2s;
}
.cash-btn:hover { border-color: #409eff; color: #409eff; }
.cash-btn-primary { background: #409eff; border-color: #409eff; color: #fff; }
.cash-btn-primary:hover { background: #66b1ff; border-color: #66b1ff; color: #fff; }
.cash-btn:disabled { opacity: .5; cursor: not-allowed; }
.import-filename { font-size: 13px; color: #303133; word-break: break-all; }
.import-filename.muted { color: #909399; }
.import-result {
border: 1px solid #e4e7ed; border-radius: 6px; padding: 12px 14px;
background: #fafafa; font-size: 13px;
}
.import-result.error { border-color: #f56c6c; background: #fef0f0; color: #f56c6c; }
.ir-row { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; padding: 6px 0; border-bottom: 1px dashed #ebeef5; }
.ir-row:last-child { border-bottom: none; }
.ir-row.ok { color: #67c23a; }
.ir-row.fail { color: #f56c6c; }
.ir-label { font-weight: 600; color: #606266; min-width: 90px; }
.ir-sub { color: #909399; font-size: 12px; }
.ir-tag {
display: inline-block; padding: 1px 8px; border-radius: 3px;
background: #ecf5ff; color: #409eff; font-size: 12px; margin-right: 4px;
}
.ir-tag.ok { background: #f0f9eb; color: #67c23a; }
.ir-tag.fail { background: #fef0f0; color: #f56c6c; }
.muted { color: #909399; }
.ir-errors { margin-top: 8px; }
.ir-error-line { padding: 3px 0 3px 10px; border-left: 3px solid #f56c6c; margin: 4px 0; background: #fef0f0; color: #f56c6c; font-size: 12px; }
</style> </style>