From 3bc68fa1c67ff0ac1750b16e0805a6dc8749bbff Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 28 Aug 2026 09:59:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BD=91=E9=93=B6=E6=B5=81=E6=B0=B4?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=A8=A1=E6=9D=BF+=E7=8E=B0=E9=87=91?= =?UTF-8?q?=E6=B5=81=E8=81=94=E5=8A=A8=EF=BC=88=E8=B4=A2=E5=8A=A1=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=80=9A=E9=81=93P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/cash.py | 406 +++++++++++++++++- backend/app/models/__init__.py | 22 +- .../scripts/gen_voucher_import_template.py | 76 ++++ .../scripts/templates/网银流水导入模板.xlsx | Bin 0 -> 6837 bytes backend/tests/test_cash.py | 101 ++++- docs/网银流水导入方案-20260828.md | 109 +++++ docs/财务Bot网银流水自助入库流程.md | 160 +++++++ frontend/src/api/index.ts | 7 + frontend/src/views/CashPlan.vue | 175 +++++++- 9 files changed, 1050 insertions(+), 6 deletions(-) create mode 100644 backend/scripts/gen_voucher_import_template.py create mode 100644 backend/scripts/templates/网银流水导入模板.xlsx create mode 100644 docs/网银流水导入方案-20260828.md create mode 100644 docs/财务Bot网银流水自助入库流程.md diff --git a/backend/app/api/cash.py b/backend/app/api/cash.py index 7db19e03..7f2878e4 100644 --- a/backend/app/api/cash.py +++ b/backend/app/api/cash.py @@ -1,10 +1,15 @@ -"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 (资金管理智能体)""" +"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 + 网银流水导入 (资金管理智能体)""" +import io import json import logging +import os from datetime import datetime, timedelta -from fastapi import APIRouter, HTTPException, Depends, Query, Request +from pathlib import Path +from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File +from fastapi.responses import FileResponse from sqlalchemy.orm import Session from sqlalchemy import or_ +import pandas as pd from app.database import get_db from app.deps import get_entity_id, resolve_entity_for_request from app.auth_middleware import require_role @@ -710,3 +715,400 @@ def api_import_bohai_ar(data: dict = None, entity_id: int = Depends(get_entity_i "total_amount_wan": round(total_wan, 2), "entity_id": entity_id, } + + +# ══════════════════════════════════════════════════════════ +# 6. 网银流水标准导入 — 三校验规则 + 现金流余额联动 (P1方案② 2026-08-28) +# 模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要 +# ══════════════════════════════════════════════════════════ + +_TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "templates" / "网银流水导入模板.xlsx" +_TEMPLATE_PATH = str(_TEMPLATE_PATH) # FileResponse/os.path.exists 兼容 str + +# 模板列别名映射(兼容中英文列名) +_VOUCHER_COL_ALIASES = { + "voucher_date": ["凭证日期", "日期", "voucher_date", "date"], + "voucher_no": ["凭证号", "凭证编号", "凭证字号", "voucher_no"], + "subject_code": ["科目编码", "科目代码", "subject_code", "code"], + "subject_name": ["科目名称", "subject_name", "name"], + "debit_amount": ["借方金额", "借方", "debit_amount", "debit"], + "credit_amount": ["贷方金额", "贷方", "credit_amount", "credit"], + "summary": ["摘要", "备注", "summary", "remark"], +} + + +def _resolve_voucher_cols(cols: list) -> dict: + """列名归一化:返回 {字段: 实际列名}(不区分大小写/空格)""" + col_map = {} + for c in cols: + key = str(c).strip().lower() + if key and key not in col_map: + col_map[key] = str(c) + resolved = {} + for field, aliases in _VOUCHER_COL_ALIASES.items(): + for alias in aliases: + if alias.lower() in col_map: + resolved[field] = col_map[alias.lower()] + break + return resolved + + +def _norm_str(raw) -> str: + """单元格→干净字符串:NaN/None→空;整数float→去.0(如1002.0→1002)""" + if raw is None: + return "" + if isinstance(raw, float) and pd.isna(raw): + return "" + if isinstance(raw, (int, float)) and not isinstance(raw, bool): + f = float(raw) + return str(int(f)) if f.is_integer() else str(f) + s = str(raw).strip() + return "" if s.lower() in ("nan", "none") else s + + +def _is_carry_forward(summary: str, subject_name: str) -> bool: + """结转行识别:摘要含'结转' 或 科目名称含'本年利润'/'结转'""" + return ("结转" in summary) or ("本年利润" in subject_name) or ("结转" in subject_name) + + +def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float: + """货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)""" + from app.models import VoucherDetail + from app.utils.cash_forecast_engine import set_current_cash_balance + rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all() + balance_yuan = round(sum( + (r.debit_amount or 0) - (r.credit_amount or 0) + for r in rows + if r.carry_forward == 0 + and r.subject_code + and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002")) + ), 2) + cash_wan = round(balance_yuan / 10000, 4) + set_current_cash_balance(db, cash_wan) + logger.info(f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 → 现金余额联动") + return cash_wan + + +def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> list: + """现金流KPI联动: + ① EXT_现金类KPI → 货币资金科目期末余额(元,与存量口径一致,source_type=ledger) + ② F_CASH_SAFETY 现金安全垫(万元 = 货币资金余额 - 短期借款EXT_139,entity_id隔离,不存在则创建) + """ + from app.models import KPIDefinition, KPIValue, VoucherDetail + updates = [] + period = periods[-1] if periods else datetime.now().strftime("%Y-%m") + + rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all() + monetary = [ + r for r in rows + if r.carry_forward == 0 and r.subject_code + and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002")) + ] + balance_yuan = round(sum((r.debit_amount or 0) - (r.credit_amount or 0) for r in monetary), 2) + cash_1001 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0) + for r in monetary if r.subject_code.startswith("1001")), 2) + cash_1002 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0) + for r in monetary if r.subject_code.startswith("1002")), 2) + cash_wan = round(balance_yuan / 10000, 2) + + # ── ① EXT_现金类KPI(名称含'现金'/'货币资金',active;排除F_CASH_SAFETY,由②专用逻辑按万元口径处理)── + cash_kpis = db.query(KPIDefinition).filter( + KPIDefinition.entity_id == entity_id, + or_(KPIDefinition.kpi_name.like("%现金%"), KPIDefinition.kpi_name.like("%货币资金%")), + KPIDefinition.status == "active", + KPIDefinition.kpi_code != "F_CASH_SAFETY", + ).all() + for k in cash_kpis: + if "库存现金" in k.kpi_name: + val = cash_1001 + elif "银行" in k.kpi_name: + val = cash_1002 + else: + val = balance_yuan + remark = f"网银流水导入[{batch}]联动: 货币资金科目期末余额{balance_yuan}元(库存现金{cash_1001}/银行存款{cash_1002})" + existing = db.query(KPIValue).filter( + KPIValue.kpi_id == k.id, + KPIValue.period == period, + KPIValue.source_type == "ledger", + ).order_by(KPIValue.id.desc()).first() + if existing: + existing.actual_value = val + existing.source_batch = batch # type: ignore[assignment] # SQLAlchemy Column类型推断噪音 + existing.remark = remark + existing.calculated_at = datetime.now() + else: + db.add(KPIValue( + entity_id=entity_id, + kpi_id=k.id, + period=period, + actual_value=val, + source_type="ledger", + source_batch=batch, + data_status="verified", + remark=remark, + )) + updates.append({"kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "period": period, "value": val}) + + # ── ② F_CASH_SAFETY 现金安全垫(万元)── + safety_kpi = db.query(KPIDefinition).filter( + KPIDefinition.entity_id == entity_id, + or_(KPIDefinition.kpi_code == "F_CASH_SAFETY", KPIDefinition.kpi_name.like("%安全垫%")), + ).first() + if not safety_kpi: + safety_kpi = KPIDefinition( + entity_id=entity_id, + kpi_code="F_CASH_SAFETY", + kpi_name="现金安全垫", + dimension="finance", + category="cash_risk", + formula="货币资金余额-短期借款", + formula_desc="货币资金科目(1001/1002)期末余额 - 短期借款(EXT_139),单位万元", + unit="万元", + target_value=0, # kpi_definitions.target_value NOT NULL DEFAULT 0.00,ORM显式传None会绕过默认值导致IntegrityError + data_source_type="ledger", + data_source="网银流水导入联动", + data_owner="财务Bot", + frequency="monthly", + status="active", + kpi_level="operational", + important_flag=1, + data_level="core", + epic="Epic2", + ) + db.add(safety_kpi) + db.flush() + logger.info(f"新增KPI F_CASH_SAFETY 现金安全垫 (entity_id={entity_id})") + + short_debt_wan = None + debt_kpi = db.query(KPIDefinition).filter( + KPIDefinition.entity_id == entity_id, + KPIDefinition.kpi_code == "EXT_139", + ).first() + if debt_kpi: + dv = db.query(KPIValue).filter( + KPIValue.kpi_id == debt_kpi.id, + KPIValue.actual_value.isnot(None), + ).order_by(KPIValue.period.desc()).first() + if dv and dv.actual_value is not None: + short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元 + + if short_debt_wan is not None: + safety_value = round(cash_wan - short_debt_wan, 2) + remark = f"网银流水导入[{batch}]联动: 货币资金{cash_wan}万 - 短期借款{short_debt_wan}万 = 安全垫{safety_value}万" + else: + safety_value = cash_wan + remark = f"网银流水导入[{batch}]联动: 无短期借款(EXT_139)数据,现金安全垫=货币资金余额{cash_wan}万" + existing = db.query(KPIValue).filter( + KPIValue.kpi_id == safety_kpi.id, + KPIValue.period == period, + KPIValue.source_type == "ledger", + ).order_by(KPIValue.id.desc()).first() + if existing: + existing.actual_value = safety_value + existing.remark = remark + existing.calculated_at = datetime.now() + else: + db.add(KPIValue( + entity_id=entity_id, + kpi_id=safety_kpi.id, + period=period, + actual_value=safety_value, + source_type="ledger", + source_batch=batch, + data_status="verified", + remark=remark, + )) + updates.append({"kpi_code": "F_CASH_SAFETY", "kpi_name": "现金安全垫", "period": period, "value": safety_value}) + return updates + + +@router.get("/import/template") +def api_get_voucher_template(): + """下载网银流水导入模板xlsx""" + if not os.path.exists(_TEMPLATE_PATH): + raise HTTPException(404, "模板文件不存在,请联系管理员生成") + return FileResponse( + _TEMPLATE_PATH, + filename="网银流水导入模板.xlsx", + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + +@router.post("/import/vouchers") +async def api_import_vouchers( + file: UploadFile = File(...), + entity_id: int = Depends(get_entity_id), + db: Session = Depends(get_db), +): + """网银流水标准导入 — 三校验(借贷平衡/期间合计/结转行) → voucher_details/import_logs → 现金流余额联动""" + from app.models import VoucherDetail, ImportLog + + content = await file.read() + fname = (file.filename or "网银流水.xlsx").strip() + try: + df = pd.read_excel(io.BytesIO(content)) + except Exception as e: + raise HTTPException(400, f"无法读取Excel文件: {e}") + if df is None or len(df) == 0: + raise HTTPException(400, "Excel文件为空(无数据行)") + + colmap = _resolve_voucher_cols(list(df.columns)) + missing = [f for f in ("voucher_date", "voucher_no", "subject_code") if f not in colmap] + if missing: + raise HTTPException(400, f"缺少必要列: {', '.join(missing)}(模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要)") + + # ── 逐行校验 ── + errors = [] + valid_rows = [] + for idx, row in df.iterrows(): + excel_row = idx + 2 # 表头占第1行 + raw_date = row.get(colmap["voucher_date"]) + if raw_date is None or (isinstance(raw_date, float) and pd.isna(raw_date)): + errors.append({"row": excel_row, "field": "voucher_date", "reason": "凭证日期为空"}) + continue + try: + voucher_date = pd.to_datetime(raw_date).to_pydatetime() + except Exception: + errors.append({"row": excel_row, "field": "voucher_date", "reason": f"日期无法解析: {raw_date}"}) + continue + + voucher_no = _norm_str(row.get(colmap["voucher_no"])) + if not voucher_no: + errors.append({"row": excel_row, "field": "voucher_no", "reason": "凭证号为空"}) + continue + subject_code = _norm_str(row.get(colmap["subject_code"])) + if not subject_code: + errors.append({"row": excel_row, "field": "subject_code", "reason": "科目编码为空"}) + continue + subject_name = _norm_str(row.get(colmap["subject_name"]) if "subject_name" in colmap else "") + if not subject_name: + errors.append({"row": excel_row, "field": "subject_name", "reason": "科目名称为空"}) + continue + + def _parse_amount(raw) -> float: + """金额解析:空→0;数字→float;字符串去逗号→float;失败→None""" + if raw is None or (isinstance(raw, float) and pd.isna(raw)): + return 0.0 + if isinstance(raw, (int, float)) and not isinstance(raw, bool): + return float(raw) + s = str(raw).strip().replace(",", "") + try: + return float(s) + except Exception: + return None + + debit = _parse_amount(row.get(colmap["debit_amount"]) if "debit_amount" in colmap else None) + credit = _parse_amount(row.get(colmap["credit_amount"]) if "credit_amount" in colmap else None) + if debit is None: + errors.append({"row": excel_row, "field": "debit_amount", "reason": f"借方金额不是有效数字: {row.get(colmap['debit_amount'])}"}) + continue + if credit is None: + errors.append({"row": excel_row, "field": "credit_amount", "reason": f"贷方金额不是有效数字: {row.get(colmap['credit_amount'])}"}) + continue + if debit < 0 or credit < 0: + errors.append({"row": excel_row, "field": "amount", "reason": "金额不能为负"}) + continue + if debit == 0 and credit == 0: + errors.append({"row": excel_row, "field": "amount", "reason": "借贷金额不能同时为0"}) + continue + + summary = _norm_str(row.get(colmap["summary"]) if "summary" in colmap else "") + carry_forward = 1 if _is_carry_forward(summary, subject_name) else 0 + valid_rows.append({ + "voucher_no": voucher_no, + "voucher_date": voucher_date, + "period": voucher_date.strftime("%Y-%m"), + "subject_code": subject_code, + "subject_name": subject_name, + "debit_amount": round(debit, 2), + "credit_amount": round(credit, 2), + "summary": summary, + "carry_forward": carry_forward, + }) + + total = len(df) + success = len(valid_rows) + failed = len(errors) + + # ── 校验规则① 借贷平衡(Σ借 vs Σ贷,容差0.01)── + debit_total = round(sum(r["debit_amount"] for r in valid_rows), 2) + credit_total = round(sum(r["credit_amount"] for r in valid_rows), 2) + diff = round(debit_total - credit_total, 2) + balance_ok = abs(diff) <= 0.01 + balance_check = { + "passed": balance_ok, + "debit_total": debit_total, + "credit_total": credit_total, + "diff": diff, + "tolerance": 0.01, + } + if not balance_ok: + errors.append({"row": 0, "field": "balance", "reason": f"借贷不平衡: 借方合计{debit_total} ≠ 贷方合计{credit_total},差额{diff}"}) + + # ── 校验规则② 期间合计(按period汇总,供对账)── + period_totals = {} + for r in valid_rows: + pt = period_totals.setdefault(r["period"], {"debit_total": 0.0, "credit_total": 0.0}) + pt["debit_total"] = round(pt["debit_total"] + r["debit_amount"], 2) + pt["credit_total"] = round(pt["credit_total"] + r["credit_amount"], 2) + + # ── 校验规则③ 结转行 ── + carry_forward_count = sum(1 for r in valid_rows if r["carry_forward"]) + + # ── 入库(部分成功模式:失败行不阻断整体)── + batch = f"{os.path.splitext(fname)[0]}_{datetime.now().strftime('%Y%m%d%H%M%S')}" + for r in valid_rows: + db.add(VoucherDetail( + entity_id=entity_id, + voucher_no=r["voucher_no"], + voucher_date=r["voucher_date"], + subject_code=r["subject_code"], + subject_name=r["subject_name"], + debit_amount=r["debit_amount"], + credit_amount=r["credit_amount"], + summary=r["summary"], + carry_forward=r["carry_forward"], + period=r["period"], + batch=batch, + )) + periods_in = sorted(set(r["period"] for r in valid_rows)) + db.add(ImportLog( + entity_id=entity_id, + filename=fname, + batch=batch, + total_rows=total, + success_rows=success, + failed_rows=failed, + errors=errors or None, + period=periods_in[0] if periods_in else None, + import_type="vouchers", + created_by="finance-bot", + )) + db.commit() + logger.info(f"网银流水导入[{batch}] entity={entity_id}: 总{total}/成功{success}/失败{failed}, 借贷平衡={'通过' if balance_ok else '失败'}") + + # ── 现金流联动 ── + cash_balance = None + kpi_updates = [] + try: + cash_balance = _sync_cash_balance_from_ledger(db, entity_id, batch) + kpi_updates = _sync_cash_kpis(db, entity_id, batch, periods_in) + db.commit() + except Exception as e: + db.rollback() + logger.error(f"网银流水导入[{batch}] 现金流联动失败: {e}", exc_info=True) + + return { + "success": True, + "total": total, + "success_rows": success, + "failed_rows": failed, + "errors": errors, + "balance_check": balance_check, + "period_totals": period_totals, + "carry_forward_count": carry_forward_count, + "cash_balance": cash_balance, + "kpi_updates": kpi_updates, + "batch": batch, + "entity_id": entity_id, + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c8575263..edf19ad4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -612,9 +612,10 @@ class Subject(Base): class VoucherDetail(Base): - """凭证明细 — 新30号准则分类""" + """凭证明细 — 新30号准则分类 (网银流水导入 2026-08-28)""" __tablename__ = "voucher_details" id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)") voucher_no = Column(String(50), nullable=False, comment="凭证编号") voucher_date = Column(DateTime, nullable=False, comment="凭证日期") subject_code = Column(String(20), nullable=False, comment="科目编码") @@ -622,8 +623,27 @@ class VoucherDetail(Base): debit_amount = Column(Float, default=0, comment="借方金额") credit_amount = Column(Float, default=0, comment="贷方金额") summary = Column(String(500), nullable=True, comment="摘要") + carry_forward = Column(Integer, default=0, nullable=False, comment="结转行标记(1=结转行不参与现金流)") new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类") period = Column(String(20), nullable=True, comment="期间 YYYY-MM") + batch = Column(String(100), nullable=True, comment="导入批次号") + created_at = Column(DateTime, server_default=func.now()) + + +class ImportLog(Base): + """数据导入日志 — 网银流水/Excel导入批次记录 (2026-08-28)""" + __tablename__ = "import_logs" + id = Column(Integer, primary_key=True, index=True) + entity_id = Column(Integer, default=1, nullable=False, comment="企业ID (多租户隔离)") + filename = Column(String(500), nullable=False, comment="文件名") + batch = Column(String(100), nullable=False, comment="批次号") + total_rows = Column(Integer, nullable=True, comment="总行数") + success_rows = Column(Integer, nullable=True, comment="成功行数") + failed_rows = Column(Integer, nullable=True, comment="失败行数") + errors = Column(JSON, nullable=True, comment="失败详情 [{row, field, reason}]") + period = Column(String(20), nullable=True, comment="导入期间") + import_type = Column(String(20), nullable=True, comment="导入类型: vouchers/kpi") + created_by = Column(String(100), nullable=True, comment="导入人") created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/scripts/gen_voucher_import_template.py b/backend/scripts/gen_voucher_import_template.py new file mode 100644 index 00000000..1a5f2fa1 --- /dev/null +++ b/backend/scripts/gen_voucher_import_template.py @@ -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()) diff --git a/backend/scripts/templates/网银流水导入模板.xlsx b/backend/scripts/templates/网银流水导入模板.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..dfa93169d3d59c15ce77d807c35bd0cf670a957b GIT binary patch literal 6837 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 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 diff --git a/docs/网银流水导入方案-20260828.md b/docs/网银流水导入方案-20260828.md new file mode 100644 index 00000000..42171d4b --- /dev/null +++ b/docs/网银流水导入方案-20260828.md @@ -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 隔离 diff --git a/docs/财务Bot网银流水自助入库流程.md b/docs/财务Bot网银流水自助入库流程.md new file mode 100644 index 00000000..9d0fb5a8 --- /dev/null +++ b/docs/财务Bot网银流水自助入库流程.md @@ -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` diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 366d6a76..4f9c5cdb 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -383,6 +383,13 @@ export const cashApi = { receivables: (params?: any) => api.get('/cash/receivables', { params }), registerPayment: (id: number, data: any) => api.post(`/cash/receivables/${id}/payment`, data), 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 }), // 页面看板(日历+预测+提醒) diff --git a/frontend/src/views/CashPlan.vue b/frontend/src/views/CashPlan.vue index 04eb4a4d..ce1832f5 100644 --- a/frontend/src/views/CashPlan.vue +++ b/frontend/src/views/CashPlan.vue @@ -2,6 +2,8 @@

收付款计划 · 资金缺口预测

+ + @@ -197,7 +199,80 @@ - + + + + + + + + +
+ + + {{ selectedFile.name }} + 未选择文件(请先下载模板,按模板列整理银行流水后导入) + +
+ +
+ ❌ 导入失败:{{ importError }} +
+ +
+ +
+ 借贷平衡: + {{ importResult.balance_check?.passed ? '✅ 通过' : '❌ 不平衡' }} + 借方合计 {{ fmtMoney(importResult.balance_check?.debit_total) }} / 贷方合计 {{ fmtMoney(importResult.balance_check?.credit_total) }} / 差额 {{ fmtMoney(importResult.balance_check?.diff) }}(容差 {{ importResult.balance_check?.tolerance }}) +
+ +
+ 期间合计: + + {{ period }}:借 {{ fmtMoney(pt.debit_total) }} / 贷 {{ fmtMoney(pt.credit_total) }} + + 无有效行 +
+ +
+ 导入统计: + 总行数 {{ importResult.total }} + 成功 {{ importResult.success_rows }} + 失败 {{ importResult.failed_rows }} + 结转行 {{ importResult.carry_forward_count }} + 批次 {{ importResult.batch }} +
+ +
+ 现金余额联动: + 更新后货币资金余额 = {{ importResult.cash_balance }} 万元 + (1001/1002 科目期末余额 ÷ 10000) +
+
+ KPI联动: + + {{ k.kpi_name }}({{ k.kpi_code }}){{ k.period }} = {{ k.value }} + +
+ +
+
⚠️ 错误明细({{ importResult.errors.length }} 条):
+
+ 第{{ e.row === 0 ? '—' : e.row }}行 [{{ e.field }}]:{{ e.reason }} +
+
+
+
+
+
@@ -255,6 +330,14 @@ const alertChecking = ref(false) const alertCheckMsg = ref('触发预警检查') const saving = ref(false) +// ── 流水导入 ── +const activeTab = ref('dashboard') +const fileInput = ref() +const selectedFile = ref(null) +const importing = ref(false) +const importResult = ref(null) +const importError = ref('') + // ── 日历 ── const now = new Date() 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() }) 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-ct { color: #606266; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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; }