diff --git a/backend/app/api/cash.py b/backend/app/api/cash.py index d7cdcf30..7db19e03 100644 --- a/backend/app/api/cash.py +++ b/backend/app/api/cash.py @@ -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, + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a72c4099..a7bacbf3 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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()) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index bd9b7da4..1d7dfb0a 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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 }), // 页面看板(日历+预测+提醒) diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts index df7996a5..babafcb9 100644 --- a/frontend/src/permission.ts +++ b/frontend/src/permission.ts @@ -9,8 +9,8 @@ interface MenuItem { // ── 角色路由映射 ── export const ROLE_ROUTES: Record = { - 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 执行与控制' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 39173136..4893c275 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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'] } }, ] diff --git a/frontend/src/views/Receivables.vue b/frontend/src/views/Receivables.vue new file mode 100644 index 00000000..cbd77f9d --- /dev/null +++ b/frontend/src/views/Receivables.vue @@ -0,0 +1,191 @@ + + + + +