feat: 网银流水导入模板+现金流联动(财务数据通道P1)
This commit is contained in:
+404
-2
@@ -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.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):
|
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())
|
||||||
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 财务数据通道方案:网银流水标准导入模板(P1)
|
||||||
|
|
||||||
|
- 提出:研学调度中枢(反向上报触发,2026-08-28)
|
||||||
|
- 执行:项目Bot(方案/验收/协调)→ 全栈Bot(开发)
|
||||||
|
- 背景:酣客现金流2.2万 vs 短债350万 = 全年最大风险,现金流监控空窗(不直连,靠手工)
|
||||||
|
|
||||||
|
## 一、ERP连通可行性结论(已完成摸底,2026-08-28)
|
||||||
|
|
||||||
|
| 检查项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| ERP网关容器 | 活着(erp-gateway Up 4 days,127.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-smart,KPI导入) |
|
||||||
|
| 现金流KPI(EXT_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_id(Depends get_entity_id)+ period(可选,默认从文件名/日期提取)
|
||||||
|
|
||||||
|
处理流程:
|
||||||
|
1. 解析xlsx(openpyxl/pandas)
|
||||||
|
2. 逐行校验:日期可解析、科目编码/名称非空、金额为数字且≥0、借贷不全为0
|
||||||
|
3. 校验规则(核心):
|
||||||
|
- **借贷平衡**:Σ借方 = Σ贷方(容差 0.01),不平衡返回错误+差额
|
||||||
|
- **期间合计**:按 period 汇总借方/贷方合计(用于对账展示)
|
||||||
|
- **结转行识别**:摘要含"结转"或科目名称含"本年利润/结转" → 标记 carry_forward=True,不参与现金流计算
|
||||||
|
4. 写入 voucher_details(batch = 文件名_时间戳),period 从日期列提取
|
||||||
|
5. 写 import_logs(total/success/failed/errors 明细)
|
||||||
|
6. **现金流联动**:计算货币资金类科目(科目编码 1001/1002 开头)期末余额 → 调用 set_current_cash_balance → 同步更新 EXT_现金类KPI 实际值(写入 kpi_values,source_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/vouchers(curl 或脚本)
|
||||||
|
- 校验通过 → 入库 → 现金余额自动更新 → 看板可见
|
||||||
|
- 校验失败 → 按错误明细修正后重导
|
||||||
|
|
||||||
|
## 四、验收标准(铁律七:不验证=没做)
|
||||||
|
|
||||||
|
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. 登录拿 token(admin/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 / batch(batch = 文件名_时间戳)
|
||||||
|
- **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`
|
||||||
@@ -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 }),
|
||||||
// 页面看板(日历+预测+提醒)
|
// 页面看板(日历+预测+提醒)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user