Files
cma-management/backend/app/api/cash.py
T

713 lines
28 KiB
Python
Raw 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 json
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import or_
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,
}