feat: 应收款催收闭环 — 催收视图+博海230万录入+回款登记联动

This commit is contained in:
Hermes CI Fix
2026-08-14 22:07:02 +08:00
parent a268c4e74d
commit 266277d121
6 changed files with 588 additions and 5 deletions
+384 -1
View File
@@ -1,8 +1,10 @@
"""资金管理API — 资金缺口预测 + 收付款计划 + 预警 (资金管理智能体)""" """资金管理API — 资金缺口预测 + 收付款计划 + 预警 + 应收催收闭环 (资金管理智能体)"""
import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Depends, Query, Request from fastapi import APIRouter, HTTPException, Depends, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_
from app.database import get_db from app.database import get_db
from app.deps import get_entity_id, resolve_entity_for_request from app.deps import get_entity_id, resolve_entity_for_request
from app.auth_middleware import require_role from app.auth_middleware import require_role
@@ -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: 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 { return {
"id": p.id, "id": p.id,
"entity_id": p.entity_id, "entity_id": p.entity_id,
"plan_type": p.plan_type, "plan_type": p.plan_type,
"plan_type_label": "收款" if p.plan_type == "receive" else "付款", "plan_type_label": "收款" if p.plan_type == "receive" else "付款",
"amount": round(p.amount or 0, 2), "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 "", "plan_date": p.plan_date.strftime("%Y-%m-%d") if p.plan_date else "",
"counterparty": p.counterparty or "", "counterparty": p.counterparty or "",
"description": p.description or "", "description": p.description or "",
"status": p.status, "status": p.status,
"status_label": {"pending": "待执行", "completed": "已完成", "cancelled": "已取消"}.get(p.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, "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, "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(), counterparty=(data.get("counterparty") or "").strip(),
description=(data.get("description") or "").strip(), description=(data.get("description") or "").strip(),
status=data.get("status", "pending"), 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.add(plan)
db.commit() 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() plan.counterparty = (data["counterparty"] or "").strip()
if "description" in data: if "description" in data:
plan.description = (data["description"] or "").strip() 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: if "status" in data:
plan.status = data["status"] plan.status = data["status"]
if data["status"] == "completed" and not plan.completed_at: 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 plan.completed_at = None
db.commit() db.commit()
db.refresh(plan) 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)} 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, "计划不存在") raise HTTPException(404, "计划不存在")
plan.status = "completed" plan.status = "completed"
plan.completed_at = datetime.now() plan.completed_at = datetime.now()
if plan.plan_type == "receive" and not plan.paid_amount:
plan.paid_amount = plan.amount
db.commit() db.commit()
db.refresh(plan) 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)} 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"], "min_cash_date": result["min_cash_date"],
"summary": result["summary"], "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,
}
+5 -2
View File
@@ -427,16 +427,19 @@ class CashForecast(Base):
class CashPlan(Base): class CashPlan(Base):
"""收付款计划 — 资金管理智能体""" """收付款计划 — 资金管理智能体(唯一应收载体:含回款登记、负责人、数据来源)"""
__tablename__ = "cash_plans" __tablename__ = "cash_plans"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID") entity_id = Column(Integer, ForeignKey("entities.id"), default=1, comment="企业ID")
plan_type = Column(String(10), nullable=False, comment="receive收/pay付") plan_type = Column(String(10), nullable=False, comment="receive收/pay付")
amount = Column(Float, nullable=False, comment="金额(万元)") 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="关联客户/供应商") counterparty = Column(String(200), nullable=True, comment="关联客户/供应商")
description = Column(String(500), nullable=True, comment="说明") description = Column(String(500), nullable=True, comment="说明")
status = Column(String(20), default="pending", comment="pending/completed/cancelled") 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="完成时间") completed_at = Column(DateTime, nullable=True, comment="完成时间")
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+4
View File
@@ -345,6 +345,10 @@ export const cashApi = {
updatePlan: (id: number, data: any) => api.put(`/cash/plans/${id}`, data), updatePlan: (id: number, data: any) => api.put(`/cash/plans/${id}`, data),
deletePlan: (id: number) => api.delete(`/cash/plans/${id}`), deletePlan: (id: number) => api.delete(`/cash/plans/${id}`),
completePlan: (id: number) => api.post(`/cash/plans/${id}/complete`), 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 }), upcoming: (params?: any) => api.get('/cash/upcoming', { params }),
// 页面看板(日历+预测+提醒) // 页面看板(日历+预测+提醒)
+3 -2
View File
@@ -9,8 +9,8 @@ interface MenuItem {
// ── 角色路由映射 ── // ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = { 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'], 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', '/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'], 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'], 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: '/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: '/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: '/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: '/growth-quality', label: '增长质量诊断', icon: 'TrendCharts', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' }, { path: '/deviations', label: '差异分析', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business', 'it'], group: '🟢 D 执行与控制' },
+1
View File
@@ -43,6 +43,7 @@ const routes = [
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } }, { 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: '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: '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: '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'] } }, { path: 'tax-compliance', name: 'TaxCompliance', component: () => import('@/views/TaxCompliance.vue'), meta: { title: '税务合规', roles: ['ceo', 'finance', 'business', 'it'] } },
] ]
+191
View File
@@ -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>