feat(bx): 报销支付按未结清余额分次冲抵和发放。
冲抵后的余额不再自动记成发放,发放和冲抵分开提交,未结清单据继续留在待处理。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
b49c99f1bd
commit
e7f942b399
8 files changed
+707
-261
No files matched your search
@@ -256,6 +256,27 @@ class BxReimburseOffset(Base):
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BxReimbursePayment(Base):
|
||||
__tablename__ = "bx_reimburse_payment"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID(UUID)")
|
||||
reimburse_id = Column(String(64), nullable=False, comment="报销申请单ID")
|
||||
pay_amount = Column(DECIMAL(14, 2), nullable=False, comment="本次发放金额")
|
||||
pay_time = Column(DateTime, nullable=False, comment="发放时间")
|
||||
pay_user_id = Column(String(64), nullable=False, comment="发放操作人ID")
|
||||
pay_user_name = Column(String(64), nullable=True, comment="发放操作人姓名")
|
||||
pay_remark = Column(String(512), nullable=True, comment="发放备注")
|
||||
tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号(公共)")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建部门")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人")
|
||||
update_time = Column(DateTime, nullable=True, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BxAttachment(Base):
|
||||
__tablename__ = "bx_attachment"
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ from app.models.bx_models import (
|
||||
BxReimburseApproval,
|
||||
BxReimburseDetail,
|
||||
BxReimburseInvoice,
|
||||
BxAdvanceApply,
|
||||
BxReimburseOffset,
|
||||
BxReimbursePayment,
|
||||
BxWorkflowDef,
|
||||
BxWorkflowNode,
|
||||
)
|
||||
@@ -35,6 +37,7 @@ from app.schemas.bx.reimburse import (
|
||||
from app.services.wechat_approval_notices import sync_approval_notifications
|
||||
from app.services.wechat_completion_notices import sync_completion_notification
|
||||
from app.services.bx.approval_cycle import filter_current_cycle_approvals
|
||||
from app.services.bx.advance import AdvanceService
|
||||
from app.services.bx.attachment import AttachmentService
|
||||
from app.services.bx.base import BxCrudService
|
||||
from app.services.bx.invoice_ledger import ENTRY_DEBIT_REIMBURSE, InvoiceLedgerService
|
||||
@@ -1395,6 +1398,159 @@ class ReimburseService:
|
||||
)
|
||||
return {"pending_total": int(total), "month_pending_amount": float(month_sum)}
|
||||
|
||||
_MONEY = Decimal("0.01")
|
||||
_CLOSED_STATUSES = ("processed", "paid", "offset")
|
||||
|
||||
@staticmethod
|
||||
def _money(value) -> Decimal:
|
||||
if value is None or value == "":
|
||||
return Decimal("0.00")
|
||||
try:
|
||||
amount = Decimal(str(value))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="金额格式不正确")
|
||||
quantized = amount.quantize(ReimburseService._MONEY)
|
||||
if amount != quantized:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="金额最多保留两位小数")
|
||||
return quantized
|
||||
|
||||
@staticmethod
|
||||
def _recorded_total(db: Session, model, reimburse_id: str, tenant_no: str, column) -> Optional[Decimal]:
|
||||
count, total = (
|
||||
db.query(func.count(model.id), func.coalesce(func.sum(column), 0))
|
||||
.filter(
|
||||
model.reimburse_id == reimburse_id,
|
||||
model.tenant_no == tenant_no,
|
||||
model.del_flag == 0,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
if not count:
|
||||
return None
|
||||
return Decimal(str(total)).quantize(ReimburseService._MONEY)
|
||||
|
||||
@staticmethod
|
||||
def _cash_paid(db: Session, apply_obj: BxReimburseApply) -> Decimal:
|
||||
recorded = ReimburseService._recorded_total(
|
||||
db, BxReimbursePayment, apply_obj.id, apply_obj.tenant_no, BxReimbursePayment.pay_amount
|
||||
)
|
||||
if recorded is not None:
|
||||
return recorded
|
||||
return ReimburseService._money(apply_obj.actual_amount)
|
||||
|
||||
@staticmethod
|
||||
def _offset_paid(db: Session, apply_obj: BxReimburseApply) -> Decimal:
|
||||
recorded = ReimburseService._recorded_total(
|
||||
db, BxReimburseOffset, apply_obj.id, apply_obj.tenant_no, BxReimburseOffset.offset_amount
|
||||
)
|
||||
if recorded is not None:
|
||||
return recorded
|
||||
return ReimburseService._money(apply_obj.offset_amount)
|
||||
|
||||
@staticmethod
|
||||
def _remaining(db: Session, apply_obj: BxReimburseApply) -> Decimal:
|
||||
balance = (
|
||||
ReimburseService._money(apply_obj.amount)
|
||||
- ReimburseService._offset_paid(db, apply_obj)
|
||||
- ReimburseService._cash_paid(db, apply_obj)
|
||||
)
|
||||
return balance if balance > 0 else Decimal("0.00")
|
||||
|
||||
@staticmethod
|
||||
def _closed(apply_obj: BxReimburseApply) -> bool:
|
||||
return apply_obj.status in ReimburseService._CLOSED_STATUSES or apply_obj.pay_status == "paid"
|
||||
|
||||
@staticmethod
|
||||
def _require_open(apply_obj: BxReimburseApply) -> bool:
|
||||
if apply_obj.status != "approved":
|
||||
if ReimburseService._closed(apply_obj):
|
||||
return False
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不是待处理报销单")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _backfill_legacy_payment(db: Session, apply_obj: BxReimburseApply, user: BxUserContext) -> None:
|
||||
recorded = ReimburseService._recorded_total(
|
||||
db, BxReimbursePayment, apply_obj.id, apply_obj.tenant_no, BxReimbursePayment.pay_amount
|
||||
)
|
||||
legacy = ReimburseService._money(apply_obj.actual_amount)
|
||||
if recorded is not None or legacy <= 0:
|
||||
return
|
||||
now = apply_obj.pay_time or datetime.now()
|
||||
db.add(
|
||||
BxReimbursePayment(
|
||||
id=generate_uuid(),
|
||||
reimburse_id=apply_obj.id,
|
||||
pay_amount=legacy,
|
||||
pay_time=now,
|
||||
pay_user_id=apply_obj.pay_user_id or user.id,
|
||||
pay_user_name=apply_obj.pay_user_name or user.name,
|
||||
pay_remark=apply_obj.pay_remark,
|
||||
tenant_no=apply_obj.tenant_no,
|
||||
create_by=user.id,
|
||||
create_dept_id=user.dept_id or "",
|
||||
create_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
@staticmethod
|
||||
def _append_payment(
|
||||
db: Session,
|
||||
apply_obj: BxReimburseApply,
|
||||
user: BxUserContext,
|
||||
amount: Decimal,
|
||||
remark: str,
|
||||
) -> None:
|
||||
ReimburseService._backfill_legacy_payment(db, apply_obj, user)
|
||||
now = datetime.now()
|
||||
db.add(
|
||||
BxReimbursePayment(
|
||||
id=generate_uuid(),
|
||||
reimburse_id=apply_obj.id,
|
||||
pay_amount=amount,
|
||||
pay_time=now,
|
||||
pay_user_id=user.id,
|
||||
pay_user_name=user.name,
|
||||
pay_remark=remark or None,
|
||||
tenant_no=apply_obj.tenant_no,
|
||||
create_by=user.id,
|
||||
create_dept_id=user.dept_id or "",
|
||||
create_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
apply_obj.actual_amount = ReimburseService._cash_paid(db, apply_obj)
|
||||
apply_obj.pay_time = now
|
||||
apply_obj.pay_user_id = user.id
|
||||
apply_obj.pay_user_name = user.name
|
||||
if remark:
|
||||
apply_obj.pay_remark = remark
|
||||
apply_obj.update_by = user.id
|
||||
|
||||
@staticmethod
|
||||
def _finish_if_settled(db: Session, apply_obj: BxReimburseApply, user: BxUserContext) -> None:
|
||||
if ReimburseService._remaining(db, apply_obj) > 0:
|
||||
apply_obj.status = "approved"
|
||||
apply_obj.pay_status = None
|
||||
apply_obj.update_by = user.id
|
||||
db.flush()
|
||||
return
|
||||
apply_obj.status = "processed"
|
||||
apply_obj.update_by = user.id
|
||||
if ReimburseService._cash_paid(db, apply_obj) > 0:
|
||||
apply_obj.pay_status = "paid"
|
||||
db.flush()
|
||||
sync_completion_notification(
|
||||
db, "bx_reimburse", apply_obj.id, apply_obj.tenant_no, actor_id=user.id
|
||||
)
|
||||
return
|
||||
apply_obj.actual_amount = Decimal("0.00")
|
||||
apply_obj.pay_status = None
|
||||
db.flush()
|
||||
|
||||
@staticmethod
|
||||
def pay(
|
||||
db: Session,
|
||||
@@ -1407,22 +1563,21 @@ class ReimburseService:
|
||||
apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user, for_update=True)
|
||||
if not apply_obj:
|
||||
return None
|
||||
if apply_obj.pay_status == "paid":
|
||||
return apply_obj # 保留首次支付时间,重试不重复写冲抵记录。
|
||||
apply_obj.status = "processed"
|
||||
apply_obj.update_by = user.id
|
||||
apply_obj.pay_status = "paid"
|
||||
apply_obj.pay_time = datetime.now()
|
||||
apply_obj.pay_user_id = user.id
|
||||
apply_obj.pay_user_name = user.name
|
||||
if not ReimburseService._require_open(apply_obj):
|
||||
return apply_obj
|
||||
remaining = ReimburseService._remaining(db, apply_obj)
|
||||
if remaining <= 0:
|
||||
return apply_obj
|
||||
remark = (pay_remark or "").strip()
|
||||
if len(remark) > 512:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发放备注不能超过512个字")
|
||||
apply_obj.pay_remark = remark or None
|
||||
if actual_amount is not None:
|
||||
apply_obj.actual_amount = actual_amount
|
||||
db.flush()
|
||||
sync_completion_notification(db, "bx_reimburse", apply_id, tenant_no, actor_id=user.id)
|
||||
amount = remaining if actual_amount is None else ReimburseService._money(actual_amount)
|
||||
if amount <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发放金额必须大于 0")
|
||||
if amount > remaining:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发放金额超过未结清余额")
|
||||
ReimburseService._append_payment(db, apply_obj, user, amount, remark)
|
||||
ReimburseService._finish_if_settled(db, apply_obj, user)
|
||||
return apply_obj
|
||||
|
||||
@staticmethod
|
||||
@@ -1462,11 +1617,22 @@ class ReimburseService:
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{row.apply_code} 不是待发放状态",
|
||||
)
|
||||
amount = float(row.amount or 0) if actual_amount is None else actual_amount
|
||||
if amount < 0:
|
||||
amount = ReimburseService._remaining(db, row) if actual_amount is None else ReimburseService._money(actual_amount)
|
||||
remaining = ReimburseService._remaining(db, row)
|
||||
if remaining <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{row.apply_code} 实际发放金额不能为负",
|
||||
detail=f"{row.apply_code} 不是待发放状态",
|
||||
)
|
||||
if amount <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{row.apply_code} 实际发放金额必须大于 0",
|
||||
)
|
||||
if amount > remaining:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"{row.apply_code} 发放金额超过未结清余额",
|
||||
)
|
||||
locked.append((row, amount))
|
||||
|
||||
@@ -1567,27 +1733,14 @@ class ReimburseService:
|
||||
user: BxUserContext,
|
||||
remark: str = "",
|
||||
) -> Optional[BxReimburseApply]:
|
||||
tenant_no = ReimburseService._tenant_no(db, user)
|
||||
apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
|
||||
if not apply_obj:
|
||||
return None
|
||||
apply_obj.status = "offset"
|
||||
apply_obj.offset_advance_id = advance_id
|
||||
apply_obj.offset_amount = amount
|
||||
apply_obj.update_by = user.id
|
||||
offset_record = BxReimburseOffset(
|
||||
id=generate_uuid(),
|
||||
reimburse_id=apply_id,
|
||||
advance_id=advance_id,
|
||||
offset_amount=amount,
|
||||
remark=remark or None,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user.name,
|
||||
create_dept_id=user.dept_id or "",
|
||||
return ReimburseService.batch_offset(
|
||||
db,
|
||||
apply_id,
|
||||
[{"advance_id": advance_id, "offset_amount": amount}],
|
||||
0,
|
||||
user,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(offset_record)
|
||||
db.flush()
|
||||
return apply_obj
|
||||
|
||||
@staticmethod
|
||||
def batch_offset(
|
||||
@@ -1602,50 +1755,78 @@ class ReimburseService:
|
||||
apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user, for_update=True)
|
||||
if not apply_obj:
|
||||
return None
|
||||
if apply_obj.pay_status == "paid":
|
||||
return apply_obj # 保留首次支付时间,重试不重复写冲抵记录。
|
||||
if not ReimburseService._require_open(apply_obj):
|
||||
return apply_obj
|
||||
remaining = ReimburseService._remaining(db, apply_obj)
|
||||
if remaining <= 0:
|
||||
return apply_obj
|
||||
|
||||
has_offsets = bool(items)
|
||||
total_offset = 0.0
|
||||
first_advance_id = None
|
||||
remark = (remark or "").strip()
|
||||
if len(remark) > 512:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="冲抵备注不能超过512个字")
|
||||
pay = ReimburseService._money(pay_amount)
|
||||
if pay < 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发放金额不能为负")
|
||||
|
||||
planned: List[Tuple[str, Decimal]] = []
|
||||
reserved: Dict[str, Decimal] = {}
|
||||
offset_map = AdvanceService.offset_amount_map(
|
||||
db, tenant_no, [str(item.get("advance_id") or "") for item in items]
|
||||
)
|
||||
for item in items:
|
||||
advance_id = str(item["advance_id"])
|
||||
offset_amount = float(item["offset_amount"])
|
||||
if first_advance_id is None:
|
||||
first_advance_id = advance_id
|
||||
total_offset += offset_amount
|
||||
offset_record = BxReimburseOffset(
|
||||
id=generate_uuid(),
|
||||
reimburse_id=apply_id,
|
||||
advance_id=advance_id,
|
||||
offset_amount=offset_amount,
|
||||
remark=remark or None,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user.name,
|
||||
create_dept_id=user.dept_id or "",
|
||||
advance_id = str(item.get("advance_id") or "").strip()
|
||||
if not advance_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="备用金单不能为空")
|
||||
offset_amount = ReimburseService._money(item.get("offset_amount"))
|
||||
if offset_amount <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="冲抵金额必须大于 0")
|
||||
advance = (
|
||||
db.query(BxAdvanceApply)
|
||||
.filter(
|
||||
BxAdvanceApply.id == advance_id,
|
||||
BxAdvanceApply.tenant_no == tenant_no,
|
||||
BxAdvanceApply.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
db.add(offset_record)
|
||||
if advance is None or (advance.issue_status != "issued" and advance.status != "issued"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="备用金不存在或未发放")
|
||||
issued = ReimburseService._money(
|
||||
advance.actual_amount if advance.actual_amount is not None else advance.amount
|
||||
)
|
||||
available = issued - ReimburseService._money(offset_map.get(advance_id, 0)) - reserved.get(advance_id, Decimal("0.00"))
|
||||
if offset_amount > available:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="冲抵金额超过备用金剩余可冲抵")
|
||||
reserved[advance_id] = reserved.get(advance_id, Decimal("0.00")) + offset_amount
|
||||
planned.append((advance_id, offset_amount))
|
||||
|
||||
if first_advance_id is not None:
|
||||
apply_obj.offset_advance_id = first_advance_id
|
||||
apply_obj.offset_amount = total_offset
|
||||
total_offset = sum((amount for _, amount in planned), Decimal("0.00"))
|
||||
if total_offset + pay > remaining:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="冲抵和发放金额超过未结清余额")
|
||||
if total_offset <= 0 and pay <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请填写冲抵金额或发放金额")
|
||||
|
||||
if pay_amount > 0:
|
||||
apply_obj.pay_status = "paid"
|
||||
apply_obj.pay_time = datetime.now()
|
||||
apply_obj.pay_user_id = user.id
|
||||
apply_obj.pay_user_name = user.name
|
||||
apply_obj.actual_amount = pay_amount
|
||||
apply_obj.status = "processed"
|
||||
elif has_offsets:
|
||||
apply_obj.actual_amount = 0
|
||||
apply_obj.status = "processed"
|
||||
|
||||
apply_obj.update_by = user.id
|
||||
db.flush()
|
||||
if pay_amount > 0:
|
||||
sync_completion_notification(db, "bx_reimburse", apply_id, tenant_no, actor_id=user.id)
|
||||
for advance_id, offset_amount in planned:
|
||||
db.add(
|
||||
BxReimburseOffset(
|
||||
id=generate_uuid(),
|
||||
reimburse_id=apply_id,
|
||||
advance_id=advance_id,
|
||||
offset_amount=offset_amount,
|
||||
remark=remark or None,
|
||||
tenant_no=tenant_no,
|
||||
create_by=user.name,
|
||||
create_dept_id=user.dept_id or "",
|
||||
)
|
||||
)
|
||||
if planned:
|
||||
db.flush()
|
||||
apply_obj.offset_advance_id = planned[0][0]
|
||||
apply_obj.offset_amount = ReimburseService._offset_paid(db, apply_obj)
|
||||
apply_obj.update_by = user.id
|
||||
if pay > 0:
|
||||
ReimburseService._append_payment(db, apply_obj, user, pay, remark)
|
||||
ReimburseService._finish_if_settled(db, apply_obj, user)
|
||||
return apply_obj
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.bx_auth import BxUserContext
|
||||
from app.models.bx_models import BxAttachment, BxReimburseApply
|
||||
from app.models.bx_models import BxAttachment, BxReimburseApply, BxReimburseOffset, BxReimbursePayment
|
||||
from app.services.bx.reimburse import ReimburseService
|
||||
|
||||
TENANT = "shanxia2.0"
|
||||
@@ -16,6 +16,8 @@ NOW = datetime(2026, 11, 4, 15, 0, 0)
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
BxReimburseApply.__table__.create(engine)
|
||||
BxReimburseOffset.__table__.create(engine)
|
||||
BxReimbursePayment.__table__.create(engine)
|
||||
BxAttachment.__table__.create(engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""报销支付按未结清余额分次冲抵、分次发放,两次操作互不影响。"""
|
||||
from datetime import date, datetime
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.bx_auth import BxUserContext
|
||||
from app.models.bx_models import BxAdvanceApply, BxReimburseApply, BxReimburseOffset, BxReimbursePayment
|
||||
from app.services.bx.reimburse import ReimburseService
|
||||
|
||||
TENANT = "shanxia2.0"
|
||||
NOW = datetime(2026, 11, 5, 10, 0, 0)
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
for model in (BxReimburseApply, BxReimburseOffset, BxReimbursePayment, BxAdvanceApply):
|
||||
model.__table__.create(engine)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _user() -> BxUserContext:
|
||||
return BxUserContext(id="finance-1", name="财务", dept_id="dept", role="super_admin", tenant_no=TENANT)
|
||||
|
||||
|
||||
def _apply(amount: float = 50000) -> BxReimburseApply:
|
||||
return BxReimburseApply(
|
||||
id="BX",
|
||||
apply_code="BX",
|
||||
title="报销BX",
|
||||
reimburse_date=date(2026, 11, 5),
|
||||
applicant_id="applicant",
|
||||
applicant_name="汪璇",
|
||||
expense_id="58",
|
||||
expense_name="车辆用品费",
|
||||
amount=amount,
|
||||
status="approved",
|
||||
tenant_no=TENANT,
|
||||
create_dept_id="dept",
|
||||
create_time=NOW,
|
||||
update_time=NOW,
|
||||
del_flag=0,
|
||||
)
|
||||
|
||||
|
||||
def _advance(advance_id: str, amount: float) -> BxAdvanceApply:
|
||||
return BxAdvanceApply(
|
||||
id=advance_id,
|
||||
apply_code=advance_id,
|
||||
applicant_id="applicant",
|
||||
applicant_name="汪璇",
|
||||
reason="备用金",
|
||||
amount=amount,
|
||||
status="issued",
|
||||
issue_status="issued",
|
||||
actual_amount=amount,
|
||||
tenant_no=TENANT,
|
||||
create_dept_id="dept",
|
||||
create_time=NOW,
|
||||
update_time=NOW,
|
||||
del_flag=0,
|
||||
)
|
||||
|
||||
|
||||
def test_offset_and_pay_are_separate_and_leave_balance_open():
|
||||
db = _session()
|
||||
db.add(_apply())
|
||||
db.add_all([_advance("ADV-A", 20000), _advance("ADV-B", 15000)])
|
||||
db.commit()
|
||||
|
||||
ReimburseService.batch_offset(
|
||||
db, "BX", [{"advance_id": "ADV-A", "offset_amount": 20000}], 0, _user()
|
||||
)
|
||||
row = db.query(BxReimburseApply).one()
|
||||
assert row.status == "approved"
|
||||
assert row.pay_status is None
|
||||
assert row.pay_time is None
|
||||
assert float(row.offset_amount) == 20000
|
||||
assert row.actual_amount is None
|
||||
|
||||
ReimburseService.pay(db, "BX", _user(), actual_amount=10000)
|
||||
db.refresh(row)
|
||||
assert row.status == "approved"
|
||||
assert row.pay_status is None
|
||||
assert float(row.actual_amount) == 10000
|
||||
assert float(row.offset_amount) == 20000
|
||||
assert db.query(BxReimbursePayment).count() == 1
|
||||
|
||||
ReimburseService.batch_offset(
|
||||
db, "BX", [{"advance_id": "ADV-B", "offset_amount": 10000}], 0, _user()
|
||||
)
|
||||
db.refresh(row)
|
||||
assert row.status == "approved"
|
||||
assert float(row.offset_amount) == 30000
|
||||
assert db.query(BxReimburseOffset).count() == 2
|
||||
|
||||
ReimburseService.pay(db, "BX", _user(), actual_amount=10000)
|
||||
db.refresh(row)
|
||||
assert row.status == "processed"
|
||||
assert row.pay_status == "paid"
|
||||
assert float(row.actual_amount) == 20000
|
||||
assert db.query(BxReimbursePayment).count() == 2
|
||||
|
||||
ReimburseService.pay(db, "BX", _user(), actual_amount=1)
|
||||
assert db.query(BxReimbursePayment).count() == 2
|
||||
|
||||
|
||||
def test_same_request_records_offset_and_pay_without_taking_the_remainder():
|
||||
db = _session()
|
||||
db.add(_apply())
|
||||
db.add(_advance("ADV-A", 20000))
|
||||
db.commit()
|
||||
|
||||
ReimburseService.batch_offset(
|
||||
db, "BX", [{"advance_id": "ADV-A", "offset_amount": 20000}], 10000, _user()
|
||||
)
|
||||
row = db.query(BxReimburseApply).one()
|
||||
assert row.status == "approved"
|
||||
assert float(row.offset_amount) == 20000
|
||||
assert float(row.actual_amount) == 10000
|
||||
assert db.query(BxReimburseOffset).count() == 1
|
||||
assert db.query(BxReimbursePayment).count() == 1
|
||||
|
||||
|
||||
def test_rejects_amount_above_balance_or_advance_remaining():
|
||||
db = _session()
|
||||
db.add(_apply())
|
||||
db.add(_advance("ADV-A", 20000))
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(HTTPException) as pay_exc:
|
||||
ReimburseService.pay(db, "BX", _user(), actual_amount=50001)
|
||||
assert pay_exc.value.status_code == 400
|
||||
|
||||
with pytest.raises(HTTPException) as offset_exc:
|
||||
ReimburseService.batch_offset(
|
||||
db, "BX", [{"advance_id": "ADV-A", "offset_amount": 20001}], 0, _user()
|
||||
)
|
||||
assert offset_exc.value.status_code == 400
|
||||
row = db.query(BxReimburseApply).one()
|
||||
assert row.status == "approved"
|
||||
assert row.offset_amount is None
|
||||
assert db.query(BxReimbursePayment).count() == 0
|
||||
@@ -10,7 +10,9 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.bx_auth import BxUserContext
|
||||
from app.core.config import settings
|
||||
from app.models.bx_models import BxAdvanceApply, BxReimburseApply, BxReimburseOffset, BxWorkflowNode
|
||||
from app.models.bx_models import (
|
||||
BxAdvanceApply, BxReimburseApply, BxReimburseOffset, BxReimbursePayment, BxWorkflowNode,
|
||||
)
|
||||
from app.models.models import SysUser
|
||||
from app.models.pl_models import (
|
||||
PlFreightSettlement, PlFreightSettlementApproval, PlRemittanceVoucher,
|
||||
@@ -37,7 +39,7 @@ def db(monkeypatch):
|
||||
def begin(connection):
|
||||
connection.exec_driver_sql('BEGIN')
|
||||
for model in (SysUser, WechatNotice, BxAdvanceApply, BxReimburseApply,
|
||||
BxReimburseOffset, BxWorkflowNode, PlFreightSettlement,
|
||||
BxReimburseOffset, BxReimbursePayment, BxWorkflowNode, PlFreightSettlement,
|
||||
PlFreightSettlementApproval, PlRemittanceVoucher,
|
||||
PlVehicleRechargeApply, PlVehicleRechargeApproval, PlVehicleRechargeRecord):
|
||||
model.__table__.create(engine)
|
||||
@@ -184,6 +186,12 @@ def test_real_bx_complete_hook_retry_preserves_original_time_and_rolls_back(db,
|
||||
def test_batch_offset_only_notifies_real_payment_and_deduplicates_offsets(db, business):
|
||||
from app.services.bx.reimburse import ReimburseService
|
||||
row = seed(db, 'bx_reimburse', completed=False)
|
||||
db.add(BxAdvanceApply(
|
||||
id='advance', apply_code='ADV', applicant_id='applicant', applicant_name='申请人',
|
||||
reason='备用金', amount=100, status='issued', issue_status='issued', actual_amount=100,
|
||||
tenant_no='t1', create_dept_id='', create_time=NOW, update_time=NOW, del_flag=0,
|
||||
))
|
||||
db.commit()
|
||||
items = [{'advance_id': 'advance', 'offset_amount': 30}]
|
||||
ReimburseService.batch_offset(db, row.id, items, 70, business)
|
||||
assert db.query(WechatNotice).count() == 1
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 报销发放明细:同一张报销单可以分多次发放,每次单独记账。
|
||||
-- MySQL 5.7。可重复执行。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `bx_reimburse_payment` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
||||
`reimburse_id` varchar(64) NOT NULL COMMENT '报销申请单ID',
|
||||
`pay_amount` decimal(14,2) NOT NULL COMMENT '本次发放金额',
|
||||
`pay_time` datetime NOT NULL COMMENT '发放时间',
|
||||
`pay_user_id` varchar(64) NOT NULL COMMENT '发放操作人ID',
|
||||
`pay_user_name` varchar(64) DEFAULT NULL COMMENT '发放操作人姓名',
|
||||
`pay_remark` varchar(512) DEFAULT NULL COMMENT '发放备注',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号(公共)',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
|
||||
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
KEY `idx_reimburse_payment_reimburse_id` (`reimburse_id`) USING BTREE,
|
||||
KEY `idx_reimburse_payment_tenant_no` (`tenant_no`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报销-发放明细';
|
||||
@@ -1277,6 +1277,32 @@ CREATE TABLE `bx_reimburse_offset` (
|
||||
KEY `idx_tenant_no` (`tenant_no`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报销-报销单备用金冲抵关联表';
|
||||
|
||||
-- ----------------------------
|
||||
-- Table structure for bx_reimburse_payment
|
||||
-- ----------------------------
|
||||
DROP TABLE IF EXISTS `bx_reimburse_payment`;
|
||||
CREATE TABLE `bx_reimburse_payment` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID(UUID)',
|
||||
`reimburse_id` varchar(64) NOT NULL COMMENT '报销申请单ID',
|
||||
`pay_amount` decimal(14,2) NOT NULL COMMENT '本次发放金额',
|
||||
`pay_time` datetime NOT NULL COMMENT '发放时间',
|
||||
`pay_user_id` varchar(64) NOT NULL COMMENT '发放操作人ID',
|
||||
`pay_user_name` varchar(64) DEFAULT NULL COMMENT '发放操作人姓名',
|
||||
`pay_remark` varchar(512) DEFAULT NULL COMMENT '发放备注',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
|
||||
`order_num` int(11) DEFAULT NULL COMMENT '排序号(公共)',
|
||||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
|
||||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建部门',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人',
|
||||
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` smallint(6) NOT NULL DEFAULT '0' COMMENT '删除标记:0-正常 1-已删除',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
KEY `idx_reimburse_payment_reimburse_id` (`reimburse_id`) USING BTREE,
|
||||
KEY `idx_reimburse_payment_tenant_no` (`tenant_no`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报销-发放明细';
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of bx_reimburse_offset
|
||||
-- ----------------------------
|
||||
|
||||
@@ -252,6 +252,17 @@
|
||||
}}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="未结清余额(元)"
|
||||
min-width="130"
|
||||
align="right"
|
||||
header-align="center"
|
||||
class-name="col-money"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatSumMoney(unpaidBalance(row)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
</div>
|
||||
<div ref="pendingPaymentBelowRef">
|
||||
@@ -564,6 +575,17 @@
|
||||
{{ row.payTime || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="未结清余额(元)"
|
||||
min-width="130"
|
||||
align="right"
|
||||
header-align="center"
|
||||
class-name="col-money"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ formatSumMoney(unpaidBalance(row)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
</div>
|
||||
<div ref="processedPaymentBelowRef">
|
||||
@@ -639,51 +661,6 @@
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 标记已发放 -->
|
||||
<el-dialog
|
||||
v-model="dialogs.issue"
|
||||
title="标记已发放"
|
||||
width="640px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top" class="bx-dialog-form">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="发放金额(元)" class="field-amount">
|
||||
<el-input :model-value="String(currentRow.amount || '')" disabled placeholder="发放金额" />
|
||||
</el-form-item>
|
||||
<el-form-item label="实际发放金额(元)" class="field-amount">
|
||||
<el-input v-model="actualAmount" placeholder="请输入实际发放金额" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" style="grid-column: 1 / -1">
|
||||
<el-input v-model="issueRemark" type="textarea" :rows="4" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="form-section form-section-last">
|
||||
<h4>发放附件</h4>
|
||||
<BxAttachmentUpload
|
||||
ref="issueAttachmentRef"
|
||||
v-model="uploadedFiles"
|
||||
biz-type="reimburse_pay"
|
||||
:biz-id="currentRow.id"
|
||||
hint="支持上传发放截图等凭证。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogs.issue = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="loadingMap.issueSubmit" @click="submitIssue">
|
||||
<el-icon><Check /></el-icon>确认发放
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量发放 -->
|
||||
<el-dialog
|
||||
v-model="dialogs.batchIssue"
|
||||
@@ -741,10 +718,10 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 冲抵备用金 -->
|
||||
<!-- 发放 / 冲抵,按入口只展示对应表单 -->
|
||||
<el-dialog
|
||||
v-model="dialogs.offset"
|
||||
title="冲抵备用金"
|
||||
:title="paymentActionTab === 'pay' ? '发放' : '冲抵备用金'"
|
||||
width="960px"
|
||||
class="dialog-form-layout"
|
||||
align-center
|
||||
@@ -760,84 +737,127 @@
|
||||
<el-form-item label="报销金额(元)" class="field-amount">
|
||||
<el-input :model-value="String(currentRow.amount || '')" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="冲抵金额(元)" class="field-amount">
|
||||
<el-input v-model="offsetAmount" placeholder="请输入冲抵金额" />
|
||||
<el-form-item label="已冲抵金额(元)" class="field-amount">
|
||||
<el-input :model-value="formatMoneyYuan(currentRow.offsetAmount || 0)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="发放金额(元)" class="field-amount">
|
||||
<el-input v-model="offsetPayAmount" placeholder="冲抵后剩余直接发放" />
|
||||
<el-form-item label="已发放金额(元)" class="field-amount">
|
||||
<el-input :model-value="formatMoneyYuan(currentRow.actualAmount || 0)" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="未结清余额(元)" class="field-amount">
|
||||
<el-input :model-value="formatMoneyYuan(unpaidBalance(currentRow))" disabled />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<h4 class="dialog-section-title">已发放备用金列表</h4>
|
||||
<div class="inline-row">
|
||||
<el-input v-model="offsetAdvanceFilter.keyword" placeholder="搜索单号/申请人" class="inline-grow" clearable />
|
||||
<el-button class="btn-main" type="primary" :loading="offsetAdvanceLoading" @click="handleOffsetAdvanceSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="handleOffsetAdvanceReset">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
</div>
|
||||
<ListTable table-key="bx-payment-3"
|
||||
class="list-table"
|
||||
:data="offsetAdvances"
|
||||
v-loading="offsetAdvanceLoading"
|
||||
max-height="300"
|
||||
stripe
|
||||
@selection-change="handleOffsetSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="48" align="center" header-align="center" />
|
||||
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
|
||||
<el-table-column prop="applyCode" label="申请单号" min-width="130" align="center" header-align="center" />
|
||||
<el-table-column prop="applicantName" label="申请人" min-width="80" align="center" header-align="center" />
|
||||
<el-table-column prop="reason" label="申请原因" min-width="120" align="left" header-align="center" show-overflow-tooltip />
|
||||
<el-table-column label="已发放金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney(row.actualAmount || row.amount || 0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已冲抵金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney(row.offsetAmount || 0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余可冲抵(元)" min-width="100" class-name="col-money" align="right" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney(Number(row.actualAmount || row.amount || 0) - Number(row.offsetAmount || 0)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
<div class="offset-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="offsetAdvancePage"
|
||||
v-model:page-size="offsetAdvancePageSize"
|
||||
:total="offsetAdvanceTotal"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:page-sizes="[10, 20, 100, 500]"
|
||||
@current-change="fetchOffsetAdvances"
|
||||
@size-change="handleOffsetAdvanceSizeChange"
|
||||
<template v-if="paymentActionTab === 'offset'">
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="本次冲抵金额(元)" class="field-amount">
|
||||
<el-input v-model="offsetAmount" placeholder="请输入本次冲抵金额" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<h4 class="dialog-section-title">已发放备用金列表</h4>
|
||||
<div class="inline-row">
|
||||
<el-input v-model="offsetAdvanceFilter.keyword" placeholder="搜索单号/申请人" class="inline-grow" clearable />
|
||||
<el-button class="btn-main" type="primary" :loading="offsetAdvanceLoading" @click="handleOffsetAdvanceSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" @click="handleOffsetAdvanceReset">
|
||||
<el-icon><RefreshLeft /></el-icon>重置
|
||||
</el-button>
|
||||
</div>
|
||||
<ListTable table-key="bx-payment-3"
|
||||
class="list-table"
|
||||
:data="offsetAdvances"
|
||||
v-loading="offsetAdvanceLoading"
|
||||
max-height="300"
|
||||
stripe
|
||||
@selection-change="handleOffsetSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="48" align="center" header-align="center" />
|
||||
<el-table-column type="index" label="序号" width="68" align="center" header-align="center" />
|
||||
<el-table-column prop="applyCode" label="申请单号" min-width="130" align="center" header-align="center" />
|
||||
<el-table-column prop="applicantName" label="申请人" min-width="80" align="center" header-align="center" />
|
||||
<el-table-column prop="reason" label="申请原因" min-width="120" align="left" header-align="center" show-overflow-tooltip />
|
||||
<el-table-column label="已发放金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney(row.actualAmount || row.amount || 0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已冲抵金额(元)" min-width="100" class-name="col-money" align="right" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney(row.offsetAmount || 0) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余可冲抵(元)" min-width="100" class-name="col-money" align="right" header-align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney(Number(row.actualAmount || row.amount || 0) - Number(row.offsetAmount || 0)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ListTable>
|
||||
<div class="offset-pager">
|
||||
<el-pagination
|
||||
v-model:current-page="offsetAdvancePage"
|
||||
v-model:page-size="offsetAdvancePageSize"
|
||||
:total="offsetAdvanceTotal"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:page-sizes="[10, 20, 100, 500]"
|
||||
@current-change="fetchOffsetAdvances"
|
||||
@size-change="handleOffsetAdvanceSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="offsetRemark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
|
||||
<h4 class="dialog-section-title">冲抵附件(必填)</h4>
|
||||
<BxAttachmentUpload
|
||||
ref="offsetAttachmentRef"
|
||||
v-model="uploadedFiles"
|
||||
biz-type="reimburse_offset"
|
||||
:biz-id="currentRow.id"
|
||||
hint="请至少上传 1 个冲抵附件。"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="offsetRemark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
|
||||
<h4 class="dialog-section-title">冲抵附件(必填)</h4>
|
||||
<BxAttachmentUpload
|
||||
ref="offsetAttachmentRef"
|
||||
v-model="uploadedFiles"
|
||||
biz-type="reimburse_offset"
|
||||
:biz-id="currentRow.id"
|
||||
hint="请至少上传 1 个冲抵附件。"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="form-grid-2">
|
||||
<el-form-item label="本次发放金额(元)" class="field-amount">
|
||||
<el-input v-model="actualAmount" placeholder="请输入本次发放金额" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发放备注" style="grid-column: 1 / -1">
|
||||
<el-input v-model="issueRemark" type="textarea" :rows="3" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<h4 class="dialog-section-title">发放附件</h4>
|
||||
<BxAttachmentUpload
|
||||
ref="issueAttachmentRef"
|
||||
v-model="payFiles"
|
||||
biz-type="reimburse_pay"
|
||||
:biz-id="currentRow.id"
|
||||
hint="支持上传本次发放截图等凭证。"
|
||||
/>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button class="btn-ghost" @click="dialogs.offset = false">
|
||||
<el-icon><Close /></el-icon>取消
|
||||
</el-button>
|
||||
<el-button class="btn-main" type="primary" :loading="loadingMap.offsetSubmit" @click="submitOffset">
|
||||
<el-button
|
||||
v-if="paymentActionTab === 'pay'"
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:loading="loadingMap.issueSubmit"
|
||||
@click="submitIssue"
|
||||
>
|
||||
<el-icon><Check /></el-icon>确认发放
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
class="btn-main"
|
||||
type="primary"
|
||||
:loading="loadingMap.offsetSubmit"
|
||||
@click="submitOffset"
|
||||
>
|
||||
<el-icon><Check /></el-icon>确认冲抵
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -961,7 +981,7 @@ import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
|
||||
import type { StatsRowColumn } from '@/utils/list-column-stats'
|
||||
import { paymentColumnFilterDefs } from './columnFilterDefs'
|
||||
|
||||
type DialogKey = 'viewDetail' | 'issue' | 'batchIssue' | 'offset' | 'processedDetail'
|
||||
type DialogKey = 'viewDetail' | 'batchIssue' | 'offset' | 'processedDetail'
|
||||
|
||||
const listColumnDefs = [
|
||||
{ prop: 'applyTime', label: '申请时间' },
|
||||
@@ -1030,9 +1050,16 @@ function buildPaymentStatsColumns(opts: {
|
||||
money: PAYMENT_MONEY_PROPS.has(def.prop)
|
||||
})
|
||||
}
|
||||
cols.push({ key: 'unpaidBalance', interactive: false, prop: 'unpaidBalance', money: true })
|
||||
return cols
|
||||
}
|
||||
|
||||
function unpaidBalance(row: any) {
|
||||
const value =
|
||||
Number(row?.amount || 0) - Number(row?.offsetAmount || 0) - Number(row?.actualAmount || 0)
|
||||
return Math.max(0, Math.round(value * 100) / 100)
|
||||
}
|
||||
|
||||
const pendingPaymentStatsRowColumns = computed(() =>
|
||||
buildPaymentStatsColumns({ withSelection: true, mode: 'pending' })
|
||||
)
|
||||
@@ -1042,6 +1069,7 @@ const processedPaymentStatsRowColumns = computed(() =>
|
||||
|
||||
function getPaymentStatCellValue(row: any, prop: string): unknown {
|
||||
if (prop === 'payAmount') return row?.actualAmount
|
||||
if (prop === 'unpaidBalance') return unpaidBalance(row)
|
||||
if (prop === 'processStatus') return processedStatusLabel(row?.status, row?.payStatus)
|
||||
return row?.[prop]
|
||||
}
|
||||
@@ -1049,7 +1077,6 @@ function getPaymentStatCellValue(row: any, prop: string): unknown {
|
||||
const loadingMap = reactive<Record<string, boolean>>({})
|
||||
const dialogs = reactive<Record<DialogKey, boolean>>({
|
||||
viewDetail: false,
|
||||
issue: false,
|
||||
batchIssue: false,
|
||||
offset: false,
|
||||
processedDetail: false
|
||||
@@ -1172,15 +1199,10 @@ const viewBasicKvFields = computed<ViewField[]>(() => {
|
||||
|
||||
const viewPayKvFields = computed<ViewField[]>(() => {
|
||||
const d = viewData.value
|
||||
const payAmount = Number(d.amount || 0) - Number(d.offsetAmount || 0)
|
||||
return [
|
||||
bxViewKvMoney('冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
|
||||
bxViewKvMoney('发放金额(元)', formatMoneyYuan(Math.max(0, payAmount)), { long: false }),
|
||||
bxViewKvMoney(
|
||||
'实际发放金额(元)',
|
||||
d.actualAmount != null && d.actualAmount !== '' ? formatMoneyYuan(d.actualAmount) : '—',
|
||||
{ long: false }
|
||||
),
|
||||
bxViewKvMoney('已冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
|
||||
bxViewKvMoney('已发放金额(元)', formatMoneyYuan(d.actualAmount || 0), { long: false }),
|
||||
bxViewKvMoney('未结清余额(元)', formatMoneyYuan(unpaidBalance(d)), { long: false }),
|
||||
bxViewKv('发放时间', d.payTime, { long: false }),
|
||||
bxViewKv('发放操作人', d.payUserName, { long: false }),
|
||||
bxViewKv('发放备注', d.payRemark, { long: false })
|
||||
@@ -1203,6 +1225,7 @@ const batchLines = ref<
|
||||
>([])
|
||||
const offsetRemark = ref('')
|
||||
const uploadedFiles = ref<BxAttachmentItem[]>([])
|
||||
const payFiles = ref<BxAttachmentItem[]>([])
|
||||
const issueAttachmentRef = ref<InstanceType<typeof BxAttachmentUpload> | null>(null)
|
||||
const offsetAttachmentRef = ref<InstanceType<typeof BxAttachmentUpload> | null>(null)
|
||||
const viewAttachments = ref<BxAttachmentItem[]>([])
|
||||
@@ -1215,18 +1238,7 @@ const offsetAdvancePageSize = ref(5)
|
||||
const offsetAdvanceLoading = ref(false)
|
||||
const offsetSelectedAdvances = ref<any[]>([])
|
||||
const offsetAmount = ref('')
|
||||
const offsetPayAmount = computed({
|
||||
get: () => {
|
||||
const amt = Number(currentRow.value.amount || 0)
|
||||
const offset = Number(offsetAmount.value) || 0
|
||||
return String(Math.max(0, amt - offset))
|
||||
},
|
||||
set: (val: string) => {
|
||||
const amt = Number(currentRow.value.amount || 0)
|
||||
const pay = Number(val) || 0
|
||||
offsetAmount.value = String(Math.max(0, amt - pay))
|
||||
}
|
||||
})
|
||||
const paymentActionTab = ref<'offset' | 'pay'>('offset')
|
||||
|
||||
|
||||
function getPaymentPendingExportFilterParams() {
|
||||
@@ -1284,15 +1296,10 @@ const processedBasicKvFields = computed<ViewField[]>(() => {
|
||||
|
||||
const processedPayKvFields = computed<ViewField[]>(() => {
|
||||
const d = processedViewData.value
|
||||
const payAmount = Number(d.amount || 0) - Number(d.offsetAmount || 0)
|
||||
return [
|
||||
bxViewKvMoney('冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
|
||||
bxViewKvMoney('发放金额(元)', formatMoneyYuan(Math.max(0, payAmount)), { long: false }),
|
||||
bxViewKvMoney(
|
||||
'实际发放金额(元)',
|
||||
formatMoneyYuan(d.actualAmount ?? Math.max(0, payAmount)),
|
||||
{ long: false }
|
||||
),
|
||||
bxViewKvMoney('已冲抵金额(元)', formatMoneyYuan(d.offsetAmount || 0), { long: false }),
|
||||
bxViewKvMoney('已发放金额(元)', formatMoneyYuan(d.actualAmount || 0), { long: false }),
|
||||
bxViewKvMoney('未结清余额(元)', formatMoneyYuan(unpaidBalance(d)), { long: false }),
|
||||
bxViewKv('发放时间', d.payTime, { long: false }),
|
||||
bxViewKv('发放操作人', d.payUserName, { long: false }),
|
||||
bxViewKv('发放备注', d.payRemark, { long: false })
|
||||
@@ -1322,20 +1329,28 @@ function onSelectionChange(rows: any[]) {
|
||||
selectedRows.value = rows
|
||||
}
|
||||
|
||||
function handleToolbarIssue() {
|
||||
function requireSinglePendingRow() {
|
||||
if (selectedRows.value.length === 0) {
|
||||
ElMessage.warning('请先勾选一条待处理记录')
|
||||
return
|
||||
return null
|
||||
}
|
||||
openIssueDialog(selectedRows.value[0])
|
||||
if (selectedRows.value.length > 1) {
|
||||
ElMessage.warning('只能选择一条待处理记录')
|
||||
return null
|
||||
}
|
||||
return selectedRows.value[0]
|
||||
}
|
||||
|
||||
function handleToolbarIssue() {
|
||||
const row = requireSinglePendingRow()
|
||||
if (!row) return
|
||||
openIssueDialog(row)
|
||||
}
|
||||
|
||||
function handleToolbarOffset() {
|
||||
if (selectedRows.value.length === 0) {
|
||||
ElMessage.warning('请先勾选一条待处理记录')
|
||||
return
|
||||
}
|
||||
openOffsetDialog(selectedRows.value[0])
|
||||
const row = requireSinglePendingRow()
|
||||
if (!row) return
|
||||
openOffsetDialog(row)
|
||||
}
|
||||
|
||||
function buildPendingQuery() {
|
||||
@@ -1411,11 +1426,7 @@ async function openView(row: any) {
|
||||
}
|
||||
|
||||
function openIssueDialog(row: any) {
|
||||
currentRow.value = row
|
||||
issueRemark.value = ''
|
||||
actualAmount.value = String(row.amount ?? '')
|
||||
uploadedFiles.value = []
|
||||
dialogs.issue = true
|
||||
openOffsetDialog(row, 'pay')
|
||||
}
|
||||
|
||||
function openBatchIssueDialog() {
|
||||
@@ -1424,14 +1435,13 @@ function openBatchIssueDialog() {
|
||||
return
|
||||
}
|
||||
batchLines.value = selectedRows.value.map((row) => {
|
||||
const payAmount = String(row.amount ?? '')
|
||||
return {
|
||||
id: String(row.id),
|
||||
applyCode: row.applyCode || '',
|
||||
applicantName: row.applicantName || '',
|
||||
title: row.title || '',
|
||||
payAmount,
|
||||
actualAmount: payAmount
|
||||
payAmount: String(row.amount ?? ''),
|
||||
actualAmount: String(unpaidBalance(row))
|
||||
}
|
||||
})
|
||||
batchIssueRemark.value = ''
|
||||
@@ -1469,20 +1479,41 @@ async function submitBatchIssue() {
|
||||
}
|
||||
}
|
||||
|
||||
async function keepOrCloseBill() {
|
||||
await refresh()
|
||||
const latest = rows.value.find((item) => item.id === currentRow.value.id)
|
||||
if (!latest || unpaidBalance(latest) <= 0) {
|
||||
dialogs.offset = false
|
||||
return
|
||||
}
|
||||
currentRow.value = { ...latest }
|
||||
}
|
||||
|
||||
async function submitIssue() {
|
||||
if (issueAttachmentRef.value?.hasUploading()) {
|
||||
ElMessage.warning('附件上传中,请稍候')
|
||||
return
|
||||
}
|
||||
const amount = Number(actualAmount.value)
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
ElMessage.warning('请填写本次发放金额')
|
||||
return
|
||||
}
|
||||
if (amount > unpaidBalance(currentRow.value) + 0.001) {
|
||||
ElMessage.warning('发放金额超过未结清余额')
|
||||
return
|
||||
}
|
||||
loadingMap.issueSubmit = true
|
||||
try {
|
||||
await paymentApi.pay(currentRow.value.id, {
|
||||
payRemark: issueRemark.value,
|
||||
actualAmount: Number(actualAmount.value) || undefined
|
||||
actualAmount: amount
|
||||
})
|
||||
ElMessage.success('发放成功')
|
||||
dialogs.issue = false
|
||||
await refresh()
|
||||
actualAmount.value = ''
|
||||
issueRemark.value = ''
|
||||
payFiles.value = []
|
||||
await keepOrCloseBill()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '发放失败')
|
||||
} finally {
|
||||
@@ -1532,44 +1563,42 @@ function getAdvanceAvailable(adv: any): number {
|
||||
return Math.max(0, Number(adv.actualAmount || adv.amount || 0) - Number(adv.offsetAmount || 0))
|
||||
}
|
||||
|
||||
function calcAutoOffsetAmount(selected: any[], reimburseAmount: number): number {
|
||||
const totalAvailable = selected.reduce((sum, adv) => sum + getAdvanceAvailable(adv), 0)
|
||||
return Math.min(totalAvailable, reimburseAmount)
|
||||
}
|
||||
|
||||
function handleOffsetSelectionChange(rows: any[]) {
|
||||
offsetSelectedAdvances.value = rows
|
||||
const reimburseAmount = Number(currentRow.value.amount || 0)
|
||||
offsetAmount.value = String(calcAutoOffsetAmount(rows, reimburseAmount))
|
||||
}
|
||||
|
||||
function openOffsetDialog(row: any) {
|
||||
function openOffsetDialog(row: any, tab: 'offset' | 'pay' = 'offset') {
|
||||
currentRow.value = row
|
||||
paymentActionTab.value = tab
|
||||
offsetAdvanceFilter.keyword = ''
|
||||
offsetAdvancePage.value = 1
|
||||
offsetSelectedAdvances.value = []
|
||||
offsetAmount.value = '0'
|
||||
offsetAmount.value = ''
|
||||
actualAmount.value = ''
|
||||
issueRemark.value = ''
|
||||
offsetRemark.value = ''
|
||||
uploadedFiles.value = []
|
||||
payFiles.value = []
|
||||
dialogs.offset = true
|
||||
fetchOffsetAdvances()
|
||||
if (tab === 'offset') fetchOffsetAdvances()
|
||||
}
|
||||
|
||||
function buildOffsetItems(totalOffset: number) {
|
||||
const items: Array<{ advanceId: number; offsetAmount: number; applyCode: string }> = []
|
||||
const items: Array<{ advanceId: string; offsetAmount: number; applyCode: string }> = []
|
||||
let remaining = totalOffset
|
||||
for (const adv of offsetSelectedAdvances.value) {
|
||||
if (remaining <= 0) break
|
||||
if (remaining <= 0.001) break
|
||||
const available = getAdvanceAvailable(adv)
|
||||
if (available <= 0) continue
|
||||
const toOffset = Math.min(remaining, available)
|
||||
items.push({
|
||||
advanceId: adv.id,
|
||||
offsetAmount: toOffset,
|
||||
offsetAmount: Math.round(toOffset * 100) / 100,
|
||||
applyCode: adv.applyCode || String(adv.id)
|
||||
})
|
||||
remaining -= toOffset
|
||||
}
|
||||
return items
|
||||
return { items, leftover: remaining }
|
||||
}
|
||||
|
||||
async function submitOffset() {
|
||||
@@ -1582,6 +1611,10 @@ async function submitOffset() {
|
||||
ElMessage.warning('请填写冲抵金额')
|
||||
return
|
||||
}
|
||||
if (totalOffset > unpaidBalance(currentRow.value) + 0.001) {
|
||||
ElMessage.warning('冲抵金额超过未结清余额')
|
||||
return
|
||||
}
|
||||
if (uploadedFiles.value.length === 0) {
|
||||
ElMessage.warning('请至少上传 1 个冲抵附件')
|
||||
return
|
||||
@@ -1591,16 +1624,18 @@ async function submitOffset() {
|
||||
return
|
||||
}
|
||||
|
||||
const items = buildOffsetItems(totalOffset)
|
||||
const payAmount = Number(offsetPayAmount.value) || 0
|
||||
const { items, leftover } = buildOffsetItems(totalOffset)
|
||||
if (!items.length || leftover > 0.001) {
|
||||
ElMessage.warning('冲抵金额超过所选备用金剩余可冲抵')
|
||||
return
|
||||
}
|
||||
const advanceLines = items.map((it) => `${it.applyCode}(冲抵 ${formatMoneyYuan(it.offsetAmount)})`).join('\n')
|
||||
const confirmMsg = [
|
||||
'请确认冲抵信息:',
|
||||
'',
|
||||
`冲抵金额(元):${formatMoneyYuan(totalOffset)}`,
|
||||
`本次冲抵金额(元):${formatMoneyYuan(totalOffset)}`,
|
||||
'冲抵备用金:',
|
||||
advanceLines,
|
||||
`发放金额(元):${formatMoneyYuan(payAmount)}`,
|
||||
'',
|
||||
'确定提交冲抵吗?'
|
||||
].join('\n')
|
||||
@@ -1619,12 +1654,15 @@ async function submitOffset() {
|
||||
try {
|
||||
await paymentApi.batchOffset(currentRow.value.id, {
|
||||
items: items.map(({ advanceId, offsetAmount: amt }) => ({ advanceId, offsetAmount: amt })),
|
||||
payAmount,
|
||||
payAmount: 0,
|
||||
remark: offsetRemark.value
|
||||
})
|
||||
ElMessage.success('冲抵成功')
|
||||
dialogs.offset = false
|
||||
await refresh()
|
||||
offsetAmount.value = ''
|
||||
offsetRemark.value = ''
|
||||
offsetSelectedAdvances.value = []
|
||||
uploadedFiles.value = []
|
||||
await keepOrCloseBill()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || '冲抵失败')
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user