feat: 应收款催收闭环 — 催收视图+博海230万录入+回款登记联动
This commit is contained in:
+384
-1
@@ -1,8 +1,10 @@
|
||||
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 (资金管理智能体)"""
|
||||
"""资金管理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
|
||||
@@ -23,18 +25,54 @@ router = APIRouter(
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -143,6 +181,9 @@ def api_create_plan(request: Request, data: dict, db: Session = Depends(get_db))
|
||||
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()
|
||||
@@ -175,6 +216,12 @@ def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
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:
|
||||
@@ -183,6 +230,15 @@ def api_update_plan(plan_id: int, data: dict, db: Session = Depends(get_db)):
|
||||
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)}
|
||||
|
||||
|
||||
@@ -205,8 +261,18 @@ def api_complete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
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)}
|
||||
|
||||
|
||||
@@ -327,3 +393,320 @@ def api_cash_alert_status(entity_id: int = Depends(get_entity_id), db: Session =
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -427,16 +427,19 @@ class CashForecast(Base):
|
||||
|
||||
|
||||
class CashPlan(Base):
|
||||
"""收付款计划 — 资金管理智能体"""
|
||||
"""收付款计划 — 资金管理智能体(唯一应收载体:含回款登记、负责人、数据来源)"""
|
||||
__tablename__ = "cash_plans"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
|
||||
plan_type = Column(String(10), nullable=False, comment="receive收/pay付")
|
||||
amount = Column(Float, nullable=False, comment="金额(万元)")
|
||||
plan_date = Column(DateTime, nullable=False, comment="计划日期")
|
||||
plan_date = Column(DateTime, nullable=False, comment="计划日期(应收即到期日)")
|
||||
counterparty = Column(String(200), nullable=True, comment="关联客户/供应商")
|
||||
description = Column(String(500), nullable=True, comment="说明")
|
||||
status = Column(String(20), default="pending", comment="pending/completed/cancelled")
|
||||
owner = Column(String(100), nullable=True, comment="负责人/业务员(应收催收责任人)")
|
||||
source = Column(String(50), default="manual", comment="数据来源: manual/bohai_ar/receivables_migrate")
|
||||
paid_amount = Column(Float, default=0, comment="已回款金额(万元)")
|
||||
completed_at = Column(DateTime, nullable=True, comment="完成时间")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -345,6 +345,10 @@ export const cashApi = {
|
||||
updatePlan: (id: number, data: any) => api.put(`/cash/plans/${id}`, data),
|
||||
deletePlan: (id: number) => api.delete(`/cash/plans/${id}`),
|
||||
completePlan: (id: number) => api.post(`/cash/plans/${id}/complete`),
|
||||
// 应收款催收
|
||||
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', {}),
|
||||
// 到期提醒
|
||||
upcoming: (params?: any) => api.get('/cash/upcoming', { params }),
|
||||
// 页面看板(日历+预测+提醒)
|
||||
|
||||
@@ -9,8 +9,8 @@ interface MenuItem {
|
||||
|
||||
// ── 角色路由映射 ──
|
||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/growth-quality', '/tax-compliance'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/tax-compliance'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/tax-compliance'],
|
||||
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/tax-compliance'],
|
||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/tax-compliance'],
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
{ path: '/expenses', label: '费用审核', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/tax-compliance', label: '税务合规', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/cash-plan', label: '收付款计划', icon: 'Money', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/receivables', label: '应收款催收', icon: 'Money', roles: ['ceo', 'finance', 'business'], group: '🟢 D 执行与控制' },
|
||||
{ path: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
|
||||
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ const routes = [
|
||||
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'expenses', name: 'ExpenseManage', component: () => import('@/views/ExpenseManage.vue'), meta: { title: '费用审核', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'cash-plan', name: 'CashPlan', component: () => import('@/views/CashPlan.vue'), meta: { title: '收付款计划', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'receivables', name: 'Receivables', component: () => import('@/views/Receivables.vue'), meta: { title: '应收款催收', roles: ['ceo', 'finance', 'business'] } },
|
||||
{ path: 'growth-quality', name: 'GrowthQuality', component: () => import('@/views/GrowthQuality.vue'), meta: { title: '增长质量诊断', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<div class="receivables-page">
|
||||
<div class="page-header">
|
||||
<h2>💰 应收款催收</h2>
|
||||
<div class="header-right">
|
||||
<el-select v-model="owner" size="small" clearable placeholder="按负责人筛选" style="width:140px" @change="loadData">
|
||||
<el-option v-for="o in owners" :key="o" :value="o" :label="o" />
|
||||
</el-select>
|
||||
<el-select v-model="status" size="small" clearable placeholder="按状态筛选" style="width:130px" @change="loadData">
|
||||
<el-option value="pending" label="待收款" />
|
||||
<el-option value="overdue" label="已逾期" />
|
||||
<el-option value="completed" label="已回款" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="loadData">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6"><el-card shadow="never" class="stat-card red"><div class="stat-val">¥{{ (summary.total_balance || 0).toFixed(2) }}万</div><div class="stat-label">应收总额</div></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="never" class="stat-card orange"><div class="stat-val">¥{{ (summary.overdue_amount || 0).toFixed(2) }}万</div><div class="stat-label">逾期金额 ({{ summary.overdue_count || 0 }}笔)</div></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="never" class="stat-card green"><div class="stat-val">¥{{ (summary.total_paid || 0).toFixed(2) }}万</div><div class="stat-label">已回款</div></el-card></el-col>
|
||||
<el-col :span="6"><el-card shadow="never" class="stat-card blue"><div class="stat-val">{{ summary.completed_count || 0 }}</div><div class="stat-label">已完成笔数</div></el-card></el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 账龄分布 -->
|
||||
<el-row style="margin-top:16px">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="never">
|
||||
<template #header>账龄分布(万元)</template>
|
||||
<div ref="agingRef" style="height:220px"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 应收明细 -->
|
||||
<el-row style="margin-top:16px">
|
||||
<el-col :span="24">
|
||||
<el-card shadow="never">
|
||||
<template #header>应收明细</template>
|
||||
<el-table :data="items" size="small" v-loading="loading">
|
||||
<el-table-column prop="name" label="客户/单位" min-width="140" />
|
||||
<el-table-column prop="owner" label="负责人" width="90" />
|
||||
<el-table-column prop="amount" label="金额(万)" width="90">
|
||||
<template #default="{ row }"><span style="font-weight:600">¥{{ row.amount }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="paid_amount" label="已回款(万)" width="100" />
|
||||
<el-table-column prop="due_date" label="到期日" width="100" />
|
||||
<el-table-column prop="aging_label" label="账龄" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.overdue ? 'danger' : (row.aging_label === '未到期' ? 'success' : 'warning')" size="small">
|
||||
{{ row.aging_label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'completed' ? 'success' : (row.overdue ? 'danger' : 'info')" size="small">
|
||||
{{ row.status === 'completed' ? '已回款' : row.overdue ? '已逾期' : '待收款' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status !== 'completed'" type="primary" size="small" @click="openPayment(row)">登记回款</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 回款登记弹窗 -->
|
||||
<el-dialog v-model="paymentVisible" title="登记回款" width="420px" append-to-body>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="客户">{{ currentRow?.name }}</el-form-item>
|
||||
<el-form-item label="应收金额">¥{{ currentRow?.amount }}万</el-form-item>
|
||||
<el-form-item label="回款金额">
|
||||
<el-input-number v-model="paymentAmount" :min="0" :max="currentRow?.amount || 0" :precision="2" style="width:180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="paymentNote" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="paymentVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="paying" @click="submitPayment">确认回款</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import api from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const items = ref<any[]>([])
|
||||
const owners = ref<string[]>([])
|
||||
const summary = ref<any>({})
|
||||
const owner = ref('')
|
||||
const status = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const paymentVisible = ref(false)
|
||||
const currentRow = ref<any>(null)
|
||||
const paymentAmount = ref(0)
|
||||
const paymentNote = ref('')
|
||||
const paying = ref(false)
|
||||
|
||||
const agingRef = ref<HTMLElement>()
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (owner.value) params.owner = owner.value
|
||||
if (status.value) params.status = status.value
|
||||
const r: any = await api.get('/cash/receivables', { params })
|
||||
const d = r.data || r || {}
|
||||
summary.value = d.summary || {}
|
||||
owners.value = d.owners || []
|
||||
const rawItems = d.data || d.items || d.receivables || []
|
||||
items.value = rawItems
|
||||
await nextTick()
|
||||
renderAging()
|
||||
} catch (e) {
|
||||
ElMessage.error('加载应收数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function renderAging() {
|
||||
if (!agingRef.value) return
|
||||
const chart = echarts.init(agingRef.value)
|
||||
const aging = summary.value.aging || {}
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
xAxis: { type: 'category', data: Object.keys(aging) },
|
||||
yAxis: { type: 'value', name: '万元' },
|
||||
series: [{
|
||||
type: 'bar',
|
||||
data: Object.values(aging),
|
||||
itemStyle: { color: '#409EFF', borderRadius: [4, 4, 0, 0] },
|
||||
label: { show: true, position: 'top' },
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function openPayment(row: any) {
|
||||
currentRow.value = row
|
||||
paymentAmount.value = row.amount || 0
|
||||
paymentNote.value = ''
|
||||
paymentVisible.value = true
|
||||
}
|
||||
|
||||
async function submitPayment() {
|
||||
if (!currentRow.value || paymentAmount.value <= 0) return
|
||||
paying.value = true
|
||||
try {
|
||||
await api.post(`/cash/receivables/${currentRow.value.id}/payment`, {
|
||||
amount: paymentAmount.value,
|
||||
note: paymentNote.value,
|
||||
})
|
||||
ElMessage.success('回款登记成功')
|
||||
paymentVisible.value = false
|
||||
loadData()
|
||||
} catch (e) {
|
||||
ElMessage.error('回款登记失败')
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.receivables-page { max-width: 1400px; margin: 0 auto; padding: 16px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-val { font-size: 24px; font-weight: 700; }
|
||||
.stat-label { font-size: 12px; color: #888; margin-top: 4px; }
|
||||
.red .stat-val { color: #f56c6c; }
|
||||
.orange .stat-val { color: #e6a23c; }
|
||||
.green .stat-val { color: #67c23a; }
|
||||
.blue .stat-val { color: #409EFF; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user