Files

1115 lines
46 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 + 网银流水导入 (资金管理智能体)"""
import io
import json
import logging
import os
from datetime import datetime, timedelta
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
from app.models import CashPlan
from app.utils.cash_forecast_engine import (
forecast_cash_flow_with_plans,
check_cash_alerts,
DEFAULT_CASH_WARNING,
DEFAULT_CASH_CRITICAL,
)
logger = logging.getLogger("cma.cash")
router = APIRouter(
prefix="/api/cma/cash",
tags=["资金管理"],
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
)
def _aging_bucket(days_late: int) -> str:
"""账龄区间(按逾期天数):未到期 / 0-30天 / 30-60天 / 60-90天 / 90天以上"""
if days_late <= 0:
return "未到期"
if days_late <= 30:
return "0-30天"
if days_late <= 60:
return "30-60天"
if days_late <= 90:
return "60-90天"
return "90天以上"
def _plan_dict(p: CashPlan) -> dict:
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
due = p.plan_date.replace(hour=0, minute=0, second=0, microsecond=0) if p.plan_date else today
days_late = (today - due).days
paid = round(p.paid_amount or 0, 2)
balance = round((p.amount or 0) - paid, 2)
if p.plan_type == "receive":
overdue = p.status == "pending" and due < today
if p.status == "pending":
aging = _aging_bucket(days_late)
elif p.status == "completed":
aging = "已结清"
else:
aging = "已取消"
else:
overdue = False
aging = "—"
return {
"id": p.id,
"entity_id": p.entity_id,
"plan_type": p.plan_type,
"plan_type_label": "收款" if p.plan_type == "receive" else "付款",
"amount": round(p.amount or 0, 2),
"paid_amount": paid,
"receivable_balance": max(balance, 0),
"plan_date": p.plan_date.strftime("%Y-%m-%d") if p.plan_date else "",
"counterparty": p.counterparty or "",
"description": p.description or "",
"status": p.status,
"status_label": {"pending": "待执行", "completed": "已完成", "cancelled": "已取消"}.get(p.status, p.status),
"owner": p.owner or "",
"source": p.source or "manual",
"overdue": overdue,
"overdue_days": max(days_late, 0) if overdue else 0,
"aging_bucket": aging,
"completed_at": p.completed_at.strftime("%Y-%m-%d %H:%M") if p.completed_at else None,
"created_at": p.created_at.strftime("%Y-%m-%d %H:%M") if p.created_at else None,
}
# ══════════════════════════════════════════════════════════
# 1. 资金缺口预测
# ══════════════════════════════════════════════════════════
@router.get("/gap-forecast")
def api_gap_forecast(
entity_id: int = Depends(get_entity_id),
days: int = Query(30, ge=1, le=90, description="预测天数"),
current_cash: float = Query(None, description="当前现金余额(万元),不传则自动获取"),
db: Session = Depends(get_db),
):
"""资金缺口预测 — 趋势引擎 + 收付款计划,识别余额<警戒线的缺口日期"""
try:
return forecast_cash_flow_with_plans(entity_id, db, days=days, current_cash=current_cash)
except Exception as e:
logger.error(f"资金缺口预测失败: {e}", exc_info=True)
raise HTTPException(400, f"资金缺口预测失败: {str(e)}")
@router.get("/balance")
def api_get_balance(
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""获取当前现金余额(预测基线)"""
from app.utils.cash_forecast_engine import get_current_cash_balance
value = get_current_cash_balance(db, entity_id)
return {"entity_id": entity_id, "current_cash": value}
@router.post("/balance")
def api_set_balance(
data: dict,
db: Session = Depends(get_db),
):
"""设置当前现金余额(万元),作为预测基线"""
from app.utils.cash_forecast_engine import set_current_cash_balance
value = float(data.get("current_cash", 0))
if value < 0:
raise HTTPException(400, "现金余额不能为负")
set_current_cash_balance(db, value)
return {"message": "当前现金余额已更新", "current_cash": value}
# ══════════════════════════════════════════════════════════
# 2. 收付款计划 CRUD
# ══════════════════════════════════════════════════════════
@router.get("/plans")
def api_list_plans(
entity_id: int = Depends(get_entity_id),
plan_type: str = Query(None, description="receive/pay"),
status: str = Query(None, description="pending/completed/cancelled"),
month: str = Query(None, description="YYYY-MM 按计划月份过滤"),
page: int = Query(1, ge=1),
page_size: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
):
"""收付款计划列表"""
query = db.query(CashPlan).filter(CashPlan.entity_id == entity_id)
if plan_type:
query = query.filter(CashPlan.plan_type == plan_type)
if status:
query = query.filter(CashPlan.status == status)
if month:
try:
y, m = int(month[:4]), int(month[5:7])
start = datetime(y, m, 1)
end = (start + timedelta(days=32)).replace(day=1)
query = query.filter(CashPlan.plan_date >= start, CashPlan.plan_date < end)
except Exception:
raise HTTPException(400, "month格式应为YYYY-MM")
total = query.count()
plans = query.order_by(CashPlan.plan_date.asc(), CashPlan.id.desc()) \
.offset((page - 1) * page_size).limit(page_size).all()
return {"total": total, "data": [_plan_dict(p) for p in plans]}
@router.post("/plans")
def api_create_plan(request: Request, data: dict, db: Session = Depends(get_db)):
"""新建收付款计划"""
plan_type = data.get("plan_type")
if plan_type not in ("receive", "pay"):
raise HTTPException(400, "plan_type必须为receive(收)或pay(付)")
amount = float(data.get("amount", 0))
if amount <= 0:
raise HTTPException(400, "金额必须大于0")
date_str = data.get("plan_date")
if not date_str:
raise HTTPException(400, "缺少计划日期")
try:
plan_date = datetime.strptime(str(date_str)[:10], "%Y-%m-%d")
except Exception:
raise HTTPException(400, "plan_date格式应为YYYY-MM-DD")
plan = CashPlan(
entity_id=resolve_entity_for_request(request, int(data.get("entity_id", 1))),
plan_type=plan_type,
amount=amount,
plan_date=plan_date,
counterparty=(data.get("counterparty") or "").strip(),
description=(data.get("description") or "").strip(),
status=data.get("status", "pending"),
owner=(data.get("owner") or "").strip() or None,
source=(data.get("source") or "manual").strip(),
paid_amount=float(data.get("paid_amount") or 0),
)
db.add(plan)
db.commit()
db.refresh(plan)
logger.info(f"新建收付款计划 #{plan.id} [{plan_type}] {amount}{date_str}")
return {"message": "收付款计划已创建", "data": _plan_dict(plan)}
@router.put("/plans/{plan_id}")
def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
"""更新收付款计划"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
if "plan_type" in data:
if data["plan_type"] not in ("receive", "pay"):
raise HTTPException(400, "plan_type必须为receive或pay")
plan.plan_type = data["plan_type"]
if "amount" in data:
amount = float(data["amount"])
if amount <= 0:
raise HTTPException(400, "金额必须大于0")
plan.amount = amount
if "plan_date" in data and data["plan_date"]:
try:
plan.plan_date = datetime.strptime(str(data["plan_date"])[:10], "%Y-%m-%d")
except Exception:
raise HTTPException(400, "plan_date格式应为YYYY-MM-DD")
if "counterparty" in data:
plan.counterparty = (data["counterparty"] or "").strip()
if "description" in data:
plan.description = (data["description"] or "").strip()
if "owner" in data:
plan.owner = (data["owner"] or "").strip() or None
if "source" in data:
plan.source = (data["source"] or "manual").strip()
if "paid_amount" in data:
plan.paid_amount = float(data["paid_amount"] or 0)
if "status" in data:
plan.status = data["status"]
if data["status"] == "completed" and not plan.completed_at:
plan.completed_at = datetime.now()
elif data["status"] in ("pending", "cancelled"):
plan.completed_at = None
db.commit()
db.refresh(plan)
# 完成收款时联动:消除到期未收款预警 + 催收行动 + F_AR_DAYS
if plan.plan_type == "receive" and plan.status == "completed":
try:
_resolve_plan_alerts(db, plan.entity_id, plan.id, plan.paid_amount or plan.amount)
_sync_collection_action_plan(db, plan.entity_id)
_update_ar_days_kpi(db, plan.entity_id)
db.commit()
except Exception as e:
logger.warning(f"完成收款联动失败: {e}")
return {"message": "计划已更新", "data": _plan_dict(plan)}
@router.delete("/plans/{plan_id}")
def api_delete_plan(plan_id: int, db: Session = Depends(get_db)):
"""删除收付款计划"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
db.delete(plan)
db.commit()
return {"message": "计划已删除"}
@router.post("/plans/{plan_id}/complete")
def api_complete_plan(plan_id: int, db: Session = Depends(get_db)):
"""标记计划为已完成(收款到账/付款完成)"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "计划不存在")
plan.status = "completed"
plan.completed_at = datetime.now()
if plan.plan_type == "receive" and not plan.paid_amount:
plan.paid_amount = plan.amount
db.commit()
db.refresh(plan)
if plan.plan_type == "receive":
try:
_resolve_plan_alerts(db, plan.entity_id, plan.id, plan.paid_amount or plan.amount)
_sync_collection_action_plan(db, plan.entity_id)
_update_ar_days_kpi(db, plan.entity_id)
db.commit()
except Exception as e:
logger.warning(f"完成收款联动失败: {e}")
return {"message": "已标记完成", "data": _plan_dict(plan)}
# ══════════════════════════════════════════════════════════
# 3. 到期提醒 + 页面看板
# ══════════════════════════════════════════════════════════
@router.get("/upcoming")
def api_upcoming(
entity_id: int = Depends(get_entity_id),
days: int = Query(7, ge=1, le=30),
db: Session = Depends(get_db),
):
"""未来N天到期提醒 + 已逾期未收/未付"""
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
upcoming = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status == "pending",
CashPlan.plan_date >= today,
CashPlan.plan_date <= today + timedelta(days=days),
).order_by(CashPlan.plan_date.asc()).all()
overdue = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status == "pending",
CashPlan.plan_date < today,
).order_by(CashPlan.plan_date.asc()).all()
return {
"days": days,
"upcoming": [_plan_dict(p) for p in upcoming],
"overdue": [_plan_dict(p) for p in overdue],
"overdue_receive_amount": round(sum(p.amount for p in overdue if p.plan_type == "receive"), 2),
"overdue_pay_amount": round(sum(p.amount for p in overdue if p.plan_type == "pay"), 2),
}
@router.get("/dashboard")
def api_cash_dashboard(
entity_id: int = Depends(get_entity_id),
month: str = Query(None, description="YYYY-MM 默认本月"),
days: int = Query(30),
current_cash: float = Query(None, description="当前现金余额(万元)"),
db: Session = Depends(get_db),
):
"""收付款计划页面看板 — 日历汇总 + 预测 + 到期提醒"""
today = datetime.now()
if month:
try:
y, m = int(month[:4]), int(month[5:7])
except Exception:
raise HTTPException(400, "month格式应为YYYY-MM")
else:
y, m = today.year, today.month
start = datetime(y, m, 1)
end = (start + timedelta(days=32)).replace(day=1)
plans = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.status == "pending",
CashPlan.plan_date >= start,
CashPlan.plan_date < end,
).all()
# 日历:按天汇总 应收/应付
calendar = {}
for p in plans:
dkey = p.plan_date.strftime("%Y-%m-%d")
cell = calendar.setdefault(dkey, {"receive": 0.0, "pay": 0.0, "items": []})
if p.plan_type == "receive":
cell["receive"] += p.amount
else:
cell["pay"] += p.amount
cell["items"].append(_plan_dict(p))
# 本月合计
month_receive = round(sum(p.amount for p in plans if p.plan_type == "receive"), 2)
month_pay = round(sum(p.amount for p in plans if p.plan_type == "pay"), 2)
forecast = forecast_cash_flow_with_plans(entity_id, db, days=days, current_cash=current_cash)
upcoming = api_upcoming(entity_id, 7, db)
return {
"entity_id": entity_id,
"month": f"{y:04d}-{m:02d}",
"month_receive": month_receive,
"month_pay": month_pay,
"month_net": round(month_receive - month_pay, 2),
"calendar": calendar,
"forecast": forecast,
"upcoming": upcoming,
}
# ══════════════════════════════════════════════════════════
# 4. 资金预警 — 缺口前3天预警 + 到期未收款提醒
# ══════════════════════════════════════════════════════════
@router.post("/check-alerts")
def api_check_cash_alerts(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
"""手动触发资金预警检查(写入预警中心kpi_alerts"""
try:
return check_cash_alerts(db, entity_id=entity_id)
except Exception as e:
logger.error(f"资金预警检查失败: {e}", exc_info=True)
raise HTTPException(400, f"资金预警检查失败: {str(e)}")
@router.get("/alerts/status")
def api_cash_alert_status(entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
"""资金预警状态概览 — 当前缺口/逾期情况(不写库,只读)"""
result = forecast_cash_flow_with_plans(entity_id, db, days=30)
return {
"entity_id": entity_id,
"warning_line": result["warning_line"],
"critical_line": result["critical_line"],
"gap_dates": result["gap_dates"],
"pre_alerts": result["pre_alerts"],
"min_cash": result["min_cash"],
"min_cash_date": result["min_cash_date"],
"summary": result["summary"],
}
# ══════════════════════════════════════════════════════════
# 5. 应收催收闭环 — 催收视图 / 回款登记 / 博海数据录入
# 唯一应收载体:cash_plans(plan_type=receive)
# ══════════════════════════════════════════════════════════
# 博海应收款汇总表(2026H1期末余额,按业务员)— 录入源数据
# 来源: 应收款汇总表(业务员+客户).xlsx / bohai_comprehensive_report.md
BOHAI_AR_DATA = [
# (业务员, 期末余额元, 业务说明)
("董均国", 387676, "IT业务-重点催收"),
("蒋亚文", 529786, "IT业务-重点催收"),
("陈艳", 89949, "IT业务"),
("李亚玲", 92450, "IT业务"),
("李巧玲", 45006, "IT业务"),
("贾妮", 27820, "IT业务"),
("王平安", 31298, "IT业务"),
("任富海", 26978, "IT业务"),
("其他", 6559, "IT业务"),
("王婧", 1065000, "酣客酒类(独立核算)"),
]
def _resolve_plan_alerts(db: Session, entity_id: int, plan_id: int, amount: float) -> int:
"""回款登记后自动消除该计划的【到期未收款】预警(kpi_alerts.alert_type=cash_plan"""
from app.models import KPIAlert
alerts = db.query(KPIAlert).filter(
KPIAlert.alert_type == "cash_plan",
KPIAlert.status.in_(["pending", "processing"]),
).all()
resolved = 0
for a in alerts:
try:
sug = json.loads(a.suggestion or "{}")
except Exception:
continue
if sug.get("plan_id") == plan_id:
a.status = "resolved"
a.resolution = f"回款登记+{amount:.2f}万,系统自动消除"
a.resolved_at = datetime.now()
resolved += 1
if resolved:
db.commit()
return resolved
def _sync_collection_action_plan(db: Session, entity_id: int):
"""逾期应收 → 『应收账款催收行动』联动:有逾期→in_progress,全部结清→completed"""
from app.models import ActionPlan
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
plans = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
).all()
pending = [p for p in plans if p.status == "pending"]
overdue = [p for p in pending if p.plan_date < today]
total_amount = sum(p.amount or 0 for p in plans)
paid_amount = sum(p.paid_amount or 0 for p in plans)
progress = round(paid_amount / total_amount * 100) if total_amount > 0 else 0
if overdue:
new_status = "in_progress"
elif not pending:
new_status = "completed"
progress = 100
else:
new_status = "in_progress"
ap = db.query(ActionPlan).filter(
ActionPlan.title.like("%催收%"),
ActionPlan.status != "cancelled",
).order_by(ActionPlan.id.asc()).first()
if not ap:
return
changed = False
if ap.status != new_status:
ap.status = new_status
changed = True
if ap.progress is None or progress > (ap.progress or 0):
ap.progress = progress
changed = True
if changed:
db.commit()
logger.info(f"催收行动#{ap.id} 联动: status={new_status} progress={progress}%")
def _update_ar_days_kpi(db: Session, entity_id: int):
"""联动F_AR_DAYS(应收周转天数KPI)— 按当前应收余额重算当期值 = 应收余额/月营收×30"""
from app.models import KPIDefinition, KPIValue
kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == "F_AR_DAYS",
).first()
if not kpi:
return
plans = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.status == "pending",
).all()
ar_balance_wan = round(sum((p.amount or 0) - (p.paid_amount or 0) for p in plans), 2)
period = datetime.now().strftime("%Y-%m")
monthly_rev = 100.0 # 缺省月营收(万元)
rev_kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == "F_REVENUE",
).first()
if rev_kpi:
val = db.query(KPIValue).filter(
KPIValue.kpi_id == rev_kpi.id,
KPIValue.period == period,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.id.desc()).first()
if val and val.actual_value:
monthly_rev = float(val.actual_value)
ar_days = round(ar_balance_wan / monthly_rev * 30, 1) if monthly_rev > 0 else None
if ar_days is None:
return
remark = f"应收催收闭环联动: 应收余额{ar_balance_wan:.2f}万/月营收{monthly_rev:.1f}万×30天"
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == kpi.id,
KPIValue.period == period,
).order_by(KPIValue.id.desc()).first()
if existing:
existing.actual_value = ar_days
existing.source_type = "cash_plan"
existing.remark = remark
existing.calculated_at = datetime.now()
else:
db.add(KPIValue(
kpi_id=kpi.id,
period=period,
actual_value=ar_days,
source_type="cash_plan",
data_status="calculated",
remark=remark,
))
db.commit()
logger.info(f"F_AR_DAYS联动更新: {period} = {ar_days}天 (应收{ar_balance_wan}万)")
@router.get("/receivables")
def api_receivables(
entity_id: int = Depends(get_entity_id),
owner: str = Query(None, description="按负责人/业务员筛选"),
status: str = Query(None, description="pending/completed/cancelled/overdue"),
aging: str = Query(None, description="账龄: 未到期/0-30天/30-60天/60-90天/90天以上/已结清"),
keyword: str = Query(None, description="客户/说明关键字"),
db: Session = Depends(get_db),
):
"""应收催收视图 — 应收余额 + 账龄(30/60/90) + 逾期状态 + 负责人筛选"""
query = db.query(CashPlan).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
)
if owner:
query = query.filter(CashPlan.owner == owner)
if keyword:
kw = f"%{keyword}%"
query = query.filter(or_(CashPlan.counterparty.like(kw), CashPlan.description.like(kw)))
plans = query.order_by(CashPlan.plan_date.asc(), CashPlan.id.asc()).all()
items = []
total_amount = total_paid = 0.0
pending_balance = 0.0
overdue_count = 0
overdue_amount = 0.0
aging_amount = {}
completed_amount = 0.0
for p in plans:
d = _plan_dict(p)
if status:
if status == "overdue":
if not d["overdue"]:
continue
elif d["status"] != status:
continue
if aging and d["aging_bucket"] != aging:
continue
items.append(d)
total_amount += d["amount"]
total_paid += d["paid_amount"]
if d["status"] == "pending":
pending_balance += d["receivable_balance"]
if d["overdue"]:
overdue_count += 1
overdue_amount += d["receivable_balance"]
if d["status"] == "pending":
aging_amount[d["aging_bucket"]] = aging_amount.get(d["aging_bucket"], 0) + d["receivable_balance"]
elif d["status"] == "completed":
completed_amount += d["amount"]
owners = [r[0] for r in db.query(CashPlan.owner).filter(
CashPlan.entity_id == entity_id,
CashPlan.plan_type == "receive",
CashPlan.owner.isnot(None),
CashPlan.owner != "",
).distinct().order_by(CashPlan.owner.asc()).all()]
return {
"entity_id": entity_id,
"total": len(items),
"owners": owners,
"summary": {
"total_amount": round(total_amount, 2),
"total_paid": round(total_paid, 2),
"total_balance": round(total_amount - total_paid, 2),
"pending_balance": round(pending_balance, 2),
"overdue_count": overdue_count,
"overdue_amount": round(overdue_amount, 2),
"completed_amount": round(completed_amount, 2),
"aging": {k: round(v, 2) for k, v in sorted(aging_amount.items(), key=lambda x: x[0])},
},
"data": items,
}
@router.post("/receivables/{plan_id}/payment")
def api_register_payment(plan_id: int, data: dict, db: Session = Depends(get_db)):
"""回款登记 — 更新计划状态 + 自动消除到期未收款预警 + 联动催收行动/F_AR_DAYS"""
plan = db.query(CashPlan).filter(CashPlan.id == plan_id).first()
if not plan:
raise HTTPException(404, "应收计划不存在")
if plan.plan_type != "receive":
raise HTTPException(400, "仅应收(receive)计划支持回款登记")
if plan.status == "cancelled":
raise HTTPException(400, "已取消的计划不能登记回款")
amount = float(data.get("amount", 0))
if amount <= 0:
raise HTTPException(400, "回款金额必须大于0")
paid_date_str = str(data.get("paid_date") or "")[:10]
if not paid_date_str:
paid_date_str = datetime.now().strftime("%Y-%m-%d")
try:
paid_date = datetime.strptime(paid_date_str, "%Y-%m-%d")
except Exception:
raise HTTPException(400, "paid_date格式应为YYYY-MM-DD")
balance = round((plan.amount or 0) - (plan.paid_amount or 0), 2)
if amount > balance + 1e-9:
raise HTTPException(400, f"回款金额{amount}万超过应收余额{balance}万")
plan.paid_amount = round((plan.paid_amount or 0) + amount, 2)
if plan.paid_amount >= (plan.amount or 0) - 1e-9:
plan.paid_amount = plan.amount
plan.status = "completed"
plan.completed_at = paid_date
else:
plan.status = "pending"
plan.completed_at = None
db.commit()
entity_id = plan.entity_id
resolved = _resolve_plan_alerts(db, entity_id, plan.id, amount)
_sync_collection_action_plan(db, entity_id)
_update_ar_days_kpi(db, entity_id)
db.commit()
db.refresh(plan)
logger.info(f"回款登记 #{plan.id} {plan.counterparty or ''} +{amount}万 → {plan.status}, 消除预警{resolved}条")
return {
"message": "回款登记成功",
"resolved_alerts": resolved,
"data": _plan_dict(plan),
}
@router.post("/import/bohai-ar")
def api_import_bohai_ar(data: dict = None, entity_id: int = Depends(get_entity_id), db: Session = Depends(get_db)):
"""录入博海应收汇总表数据(¥2.3M, 按业务员)到cash_plans — 幂等,重复调用不重复导入"""
if data is None:
data = {}
existing = db.query(CashPlan).filter(
CashPlan.source == "bohai_ar",
CashPlan.entity_id == entity_id,
).count()
if existing and not data.get("force"):
return {"message": "博海应收已录入,未重复导入", "existing": existing, "imported": 0}
if existing:
db.query(CashPlan).filter(
CashPlan.source == "bohai_ar",
CashPlan.entity_id == entity_id,
).delete()
db.commit()
plan_date = datetime(2026, 6, 30) # 2026H1期末余额基准日
imported = 0
total_wan = 0.0
for sp, yuan, note in BOHAI_AR_DATA:
wan = round(yuan / 10000, 2)
total_wan += wan
db.add(CashPlan(
entity_id=entity_id,
plan_type="receive",
amount=wan,
plan_date=plan_date,
counterparty=sp,
description=f"博海应收款汇总表2026H1期末余额({note})",
status="pending",
owner=sp,
source="bohai_ar",
paid_amount=0,
))
imported += 1
db.commit()
# 触发预警检查生成到期未收款预警 + 联动催收行动/F_AR_DAYS
try:
check_cash_alerts(db, entity_id=entity_id)
except Exception as e:
logger.warning(f"导入后预警检查失败: {e}")
_sync_collection_action_plan(db, entity_id)
_update_ar_days_kpi(db, entity_id)
db.commit()
return {
"message": "博海应收已录入",
"imported": imported,
"total_amount_wan": round(total_wan, 2),
"entity_id": entity_id,
}
# ══════════════════════════════════════════════════════════
# 6. 网银流水标准导入 — 三校验规则 + 现金流余额联动 (P1方案② 2026-08-28)
# 模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要
# ══════════════════════════════════════════════════════════
_TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "templates" / "网银流水导入模板.xlsx"
_TEMPLATE_PATH = str(_TEMPLATE_PATH) # FileResponse/os.path.exists 兼容 str
# 模板列别名映射(兼容中英文列名)
_VOUCHER_COL_ALIASES = {
"voucher_date": ["凭证日期", "日期", "voucher_date", "date"],
"voucher_no": ["凭证号", "凭证编号", "凭证字号", "voucher_no"],
"subject_code": ["科目编码", "科目代码", "subject_code", "code"],
"subject_name": ["科目名称", "subject_name", "name"],
"debit_amount": ["借方金额", "借方", "debit_amount", "debit"],
"credit_amount": ["贷方金额", "贷方", "credit_amount", "credit"],
"summary": ["摘要", "备注", "summary", "remark"],
}
def _resolve_voucher_cols(cols: list) -> dict:
"""列名归一化:返回 {字段: 实际列名}(不区分大小写/空格)"""
col_map = {}
for c in cols:
key = str(c).strip().lower()
if key and key not in col_map:
col_map[key] = str(c)
resolved = {}
for field, aliases in _VOUCHER_COL_ALIASES.items():
for alias in aliases:
if alias.lower() in col_map:
resolved[field] = col_map[alias.lower()]
break
return resolved
def _norm_str(raw) -> str:
"""单元格→干净字符串:NaN/None→空;整数float→去.0(如1002.0→1002"""
if raw is None:
return ""
if isinstance(raw, float) and pd.isna(raw):
return ""
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
f = float(raw)
return str(int(f)) if f.is_integer() else str(f)
s = str(raw).strip()
return "" if s.lower() in ("nan", "none") else s
def _is_carry_forward(summary: str, subject_name: str) -> bool:
"""结转行识别:摘要含'结转' 或 科目名称含'本年利润'/'结转'"""
return ("结转" in summary) or ("本年利润" in subject_name) or ("结转" in subject_name)
def _sync_cash_balance_from_ledger(db: Session, entity_id: int, batch: str) -> float:
"""货币资金类科目(1001/1002开头)期末余额 → set_current_cash_balance(万元)"""
from app.models import VoucherDetail
from app.utils.cash_forecast_engine import set_current_cash_balance
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
balance_yuan = round(sum(
(r.debit_amount or 0) - (r.credit_amount or 0)
for r in rows
if r.carry_forward == 0
and r.subject_code
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
), 2)
cash_wan = round(balance_yuan / 10000, 4)
set_current_cash_balance(db, cash_wan)
logger.info(f"网银流水导入[{batch}] 货币资金期末余额{balance_yuan}元 = {cash_wan}万元 → 现金余额联动")
return cash_wan
def _sync_cash_kpis(db: Session, entity_id: int, batch: str, periods: list) -> list:
"""现金流KPI联动:
① EXT_现金类KPI → 货币资金科目期末余额(元,与存量口径一致,source_type=ledger
② F_CASH_SAFETY 现金安全垫(万元 = 货币资金余额 - 短期借款EXT_139,entity_id隔离,不存在则创建)
"""
from app.models import KPIDefinition, KPIValue, VoucherDetail
updates = []
period = periods[-1] if periods else datetime.now().strftime("%Y-%m")
rows = db.query(VoucherDetail).filter(VoucherDetail.entity_id == entity_id).all()
monetary = [
r for r in rows
if r.carry_forward == 0 and r.subject_code
and (r.subject_code.startswith("1001") or r.subject_code.startswith("1002"))
]
balance_yuan = round(sum((r.debit_amount or 0) - (r.credit_amount or 0) for r in monetary), 2)
cash_1001 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0)
for r in monetary if r.subject_code.startswith("1001")), 2)
cash_1002 = round(sum((r.debit_amount or 0) - (r.credit_amount or 0)
for r in monetary if r.subject_code.startswith("1002")), 2)
cash_wan = round(balance_yuan / 10000, 2)
# ── ① EXT_现金类KPI(名称含'现金'/'货币资金'active;排除F_CASH_SAFETY,由②专用逻辑按万元口径处理)──
cash_kpis = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
or_(KPIDefinition.kpi_name.like("%现金%"), KPIDefinition.kpi_name.like("%货币资金%")),
KPIDefinition.status == "active",
KPIDefinition.kpi_code != "F_CASH_SAFETY",
).all()
for k in cash_kpis:
if "库存现金" in k.kpi_name:
val = cash_1001
elif "银行" in k.kpi_name:
val = cash_1002
else:
val = balance_yuan
remark = f"网银流水导入[{batch}]联动: 货币资金科目期末余额{balance_yuan}元(库存现金{cash_1001}/银行存款{cash_1002})"
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == k.id,
KPIValue.period == period,
KPIValue.source_type == "ledger",
).order_by(KPIValue.id.desc()).first()
if existing:
existing.actual_value = val
existing.source_batch = batch # type: ignore[assignment] # SQLAlchemy Column类型推断噪音
existing.remark = remark
existing.calculated_at = datetime.now()
else:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=k.id,
period=period,
actual_value=val,
source_type="ledger",
source_batch=batch,
data_status="verified",
remark=remark,
))
updates.append({"kpi_code": k.kpi_code, "kpi_name": k.kpi_name, "period": period, "value": val})
# ── ② F_CASH_SAFETY 现金安全垫(万元)──
safety_kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
or_(KPIDefinition.kpi_code == "F_CASH_SAFETY", KPIDefinition.kpi_name.like("%安全垫%")),
).first()
if not safety_kpi:
safety_kpi = KPIDefinition(
entity_id=entity_id,
kpi_code="F_CASH_SAFETY",
kpi_name="现金安全垫",
dimension="finance",
category="cash_risk",
formula="货币资金余额-短期借款",
formula_desc="货币资金科目(1001/1002)期末余额 - 短期借款(EXT_139),单位万元",
unit="万元",
target_value=0, # kpi_definitions.target_value NOT NULL DEFAULT 0.00ORM显式传None会绕过默认值导致IntegrityError
data_source_type="ledger",
data_source="网银流水导入联动",
data_owner="财务Bot",
frequency="monthly",
status="active",
kpi_level="operational",
important_flag=1,
data_level="core",
epic="Epic2",
)
db.add(safety_kpi)
db.flush()
logger.info(f"新增KPI F_CASH_SAFETY 现金安全垫 (entity_id={entity_id})")
short_debt_wan = None
debt_kpi = db.query(KPIDefinition).filter(
KPIDefinition.entity_id == entity_id,
KPIDefinition.kpi_code == "EXT_139",
).first()
if debt_kpi:
dv = db.query(KPIValue).filter(
KPIValue.kpi_id == debt_kpi.id,
KPIValue.actual_value.isnot(None),
).order_by(KPIValue.period.desc()).first()
if dv and dv.actual_value is not None:
short_debt_wan = round(float(dv.actual_value) / 10000, 2) # EXT_139单位元
if short_debt_wan is not None:
safety_value = round(cash_wan - short_debt_wan, 2)
remark = f"网银流水导入[{batch}]联动: 货币资金{cash_wan}万 - 短期借款{short_debt_wan}万 = 安全垫{safety_value}万"
else:
safety_value = cash_wan
remark = f"网银流水导入[{batch}]联动: 无短期借款(EXT_139)数据,现金安全垫=货币资金余额{cash_wan}万"
existing = db.query(KPIValue).filter(
KPIValue.kpi_id == safety_kpi.id,
KPIValue.period == period,
KPIValue.source_type == "ledger",
).order_by(KPIValue.id.desc()).first()
if existing:
existing.actual_value = safety_value
existing.remark = remark
existing.calculated_at = datetime.now()
else:
db.add(KPIValue(
entity_id=entity_id,
kpi_id=safety_kpi.id,
period=period,
actual_value=safety_value,
source_type="ledger",
source_batch=batch,
data_status="verified",
remark=remark,
))
updates.append({"kpi_code": "F_CASH_SAFETY", "kpi_name": "现金安全垫", "period": period, "value": safety_value})
return updates
@router.get("/import/template")
def api_get_voucher_template():
"""下载网银流水导入模板xlsx"""
if not os.path.exists(_TEMPLATE_PATH):
raise HTTPException(404, "模板文件不存在,请联系管理员生成")
return FileResponse(
_TEMPLATE_PATH,
filename="网银流水导入模板.xlsx",
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
@router.post("/import/vouchers")
async def api_import_vouchers(
file: UploadFile = File(...),
entity_id: int = Depends(get_entity_id),
db: Session = Depends(get_db),
):
"""网银流水标准导入 — 三校验(借贷平衡/期间合计/结转行) → voucher_details/import_logs → 现金流余额联动"""
from app.models import VoucherDetail, ImportLog
content = await file.read()
fname = (file.filename or "网银流水.xlsx").strip()
try:
df = pd.read_excel(io.BytesIO(content))
except Exception as e:
raise HTTPException(400, f"无法读取Excel文件: {e}")
if df is None or len(df) == 0:
raise HTTPException(400, "Excel文件为空(无数据行)")
colmap = _resolve_voucher_cols(list(df.columns))
missing = [f for f in ("voucher_date", "voucher_no", "subject_code") if f not in colmap]
if missing:
raise HTTPException(400, f"缺少必要列: {', '.join(missing)}(模板列: 凭证日期/凭证号/科目编码/科目名称/借方金额/贷方金额/摘要)")
# ── 逐行校验 ──
errors = []
valid_rows = []
for idx, row in df.iterrows():
excel_row = idx + 2 # 表头占第1行
raw_date = row.get(colmap["voucher_date"])
if raw_date is None or (isinstance(raw_date, float) and pd.isna(raw_date)):
errors.append({"row": excel_row, "field": "voucher_date", "reason": "凭证日期为空"})
continue
try:
voucher_date = pd.to_datetime(raw_date).to_pydatetime()
except Exception:
errors.append({"row": excel_row, "field": "voucher_date", "reason": f"日期无法解析: {raw_date}"})
continue
voucher_no = _norm_str(row.get(colmap["voucher_no"]))
if not voucher_no:
errors.append({"row": excel_row, "field": "voucher_no", "reason": "凭证号为空"})
continue
subject_code = _norm_str(row.get(colmap["subject_code"]))
if not subject_code:
errors.append({"row": excel_row, "field": "subject_code", "reason": "科目编码为空"})
continue
subject_name = _norm_str(row.get(colmap["subject_name"]) if "subject_name" in colmap else "")
if not subject_name:
errors.append({"row": excel_row, "field": "subject_name", "reason": "科目名称为空"})
continue
def _parse_amount(raw) -> float:
"""金额解析:空→0;数字→float;字符串去逗号→float;失败→None"""
if raw is None or (isinstance(raw, float) and pd.isna(raw)):
return 0.0
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
return float(raw)
s = str(raw).strip().replace(",", "")
try:
return float(s)
except Exception:
return None
debit = _parse_amount(row.get(colmap["debit_amount"]) if "debit_amount" in colmap else None)
credit = _parse_amount(row.get(colmap["credit_amount"]) if "credit_amount" in colmap else None)
if debit is None:
errors.append({"row": excel_row, "field": "debit_amount", "reason": f"借方金额不是有效数字: {row.get(colmap['debit_amount'])}"})
continue
if credit is None:
errors.append({"row": excel_row, "field": "credit_amount", "reason": f"贷方金额不是有效数字: {row.get(colmap['credit_amount'])}"})
continue
if debit < 0 or credit < 0:
errors.append({"row": excel_row, "field": "amount", "reason": "金额不能为负"})
continue
if debit == 0 and credit == 0:
errors.append({"row": excel_row, "field": "amount", "reason": "借贷金额不能同时为0"})
continue
summary = _norm_str(row.get(colmap["summary"]) if "summary" in colmap else "")
carry_forward = 1 if _is_carry_forward(summary, subject_name) else 0
valid_rows.append({
"voucher_no": voucher_no,
"voucher_date": voucher_date,
"period": voucher_date.strftime("%Y-%m"),
"subject_code": subject_code,
"subject_name": subject_name,
"debit_amount": round(debit, 2),
"credit_amount": round(credit, 2),
"summary": summary,
"carry_forward": carry_forward,
})
total = len(df)
success = len(valid_rows)
failed = len(errors)
# ── 校验规则① 借贷平衡(Σ借 vs Σ贷,容差0.01)──
debit_total = round(sum(r["debit_amount"] for r in valid_rows), 2)
credit_total = round(sum(r["credit_amount"] for r in valid_rows), 2)
diff = round(debit_total - credit_total, 2)
balance_ok = abs(diff) <= 0.01
balance_check = {
"passed": balance_ok,
"debit_total": debit_total,
"credit_total": credit_total,
"diff": diff,
"tolerance": 0.01,
}
if not balance_ok:
errors.append({"row": 0, "field": "balance", "reason": f"借贷不平衡: 借方合计{debit_total} ≠ 贷方合计{credit_total},差额{diff}"})
# ── 校验规则② 期间合计(按period汇总,供对账)──
period_totals = {}
for r in valid_rows:
pt = period_totals.setdefault(r["period"], {"debit_total": 0.0, "credit_total": 0.0})
pt["debit_total"] = round(pt["debit_total"] + r["debit_amount"], 2)
pt["credit_total"] = round(pt["credit_total"] + r["credit_amount"], 2)
# ── 校验规则③ 结转行 ──
carry_forward_count = sum(1 for r in valid_rows if r["carry_forward"])
# ── 入库(部分成功模式:失败行不阻断整体)──
batch = f"{os.path.splitext(fname)[0]}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
for r in valid_rows:
db.add(VoucherDetail(
entity_id=entity_id,
voucher_no=r["voucher_no"],
voucher_date=r["voucher_date"],
subject_code=r["subject_code"],
subject_name=r["subject_name"],
debit_amount=r["debit_amount"],
credit_amount=r["credit_amount"],
summary=r["summary"],
carry_forward=r["carry_forward"],
period=r["period"],
batch=batch,
))
periods_in = sorted(set(r["period"] for r in valid_rows))
db.add(ImportLog(
entity_id=entity_id,
filename=fname,
batch=batch,
total_rows=total,
success_rows=success,
failed_rows=failed,
errors=errors or None,
period=periods_in[0] if periods_in else None,
import_type="vouchers",
created_by="finance-bot",
))
db.commit()
logger.info(f"网银流水导入[{batch}] entity={entity_id}: 总{total}/成功{success}/失败{failed}, 借贷平衡={'通过' if balance_ok else '失败'}")
# ── 现金流联动 ──
cash_balance = None
kpi_updates = []
try:
cash_balance = _sync_cash_balance_from_ledger(db, entity_id, batch)
kpi_updates = _sync_cash_kpis(db, entity_id, batch, periods_in)
db.commit()
except Exception as e:
db.rollback()
logger.error(f"网银流水导入[{batch}] 现金流联动失败: {e}", exc_info=True)
return {
"success": True,
"total": total,
"success_rows": success,
"failed_rows": failed,
"errors": errors,
"balance_check": balance_check,
"period_totals": period_totals,
"carry_forward_count": carry_forward_count,
"cash_balance": cash_balance,
"kpi_updates": kpi_updates,
"batch": batch,
"entity_id": entity_id,
}