diff --git a/backend/app/models/bx_models.py b/backend/app/models/bx_models.py index 1009d5d71..141f7b430 100644 --- a/backend/app/models/bx_models.py +++ b/backend/app/models/bx_models.py @@ -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" diff --git a/backend/app/services/bx/reimburse.py b/backend/app/services/bx/reimburse.py index df1759143..3c9b97a2e 100644 --- a/backend/app/services/bx/reimburse.py +++ b/backend/app/services/bx/reimburse.py @@ -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 diff --git a/backend/tests/test_bx_reimburse_batch_pay.py b/backend/tests/test_bx_reimburse_batch_pay.py index 321873f95..c65e60544 100644 --- a/backend/tests/test_bx_reimburse_batch_pay.py +++ b/backend/tests/test_bx_reimburse_batch_pay.py @@ -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)() diff --git a/backend/tests/test_bx_reimburse_partial_settlement.py b/backend/tests/test_bx_reimburse_partial_settlement.py new file mode 100644 index 000000000..3b3483350 --- /dev/null +++ b/backend/tests/test_bx_reimburse_partial_settlement.py @@ -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 diff --git a/backend/tests/test_wechat_completion_notices.py b/backend/tests/test_wechat_completion_notices.py index be03858ea..55f413d60 100644 --- a/backend/tests/test_wechat_completion_notices.py +++ b/backend/tests/test_wechat_completion_notices.py @@ -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 diff --git a/docs/sql/migrations/2026-11-05-reimburse-payment.sql b/docs/sql/migrations/2026-11-05-reimburse-payment.sql new file mode 100644 index 000000000..1391f5a58 --- /dev/null +++ b/docs/sql/migrations/2026-11-05-reimburse-payment.sql @@ -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='报销-发放明细'; diff --git a/docs/sql/shanxia2.0.sql b/docs/sql/shanxia2.0.sql index e49136544..9443cb1f5 100644 --- a/docs/sql/shanxia2.0.sql +++ b/docs/sql/shanxia2.0.sql @@ -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 -- ---------------------------- diff --git a/web-admin/src/views/bx/payment/index.vue b/web-admin/src/views/bx/payment/index.vue index b16abf14e..5e459e95c 100644 --- a/web-admin/src/views/bx/payment/index.vue +++ b/web-admin/src/views/bx/payment/index.vue @@ -252,6 +252,17 @@ }} + + +
@@ -564,6 +575,17 @@ {{ row.payTime || '-' }} + + +
@@ -639,51 +661,6 @@
- - - -
- - - - - - - - - -
-
- -
-

发放附件

- -
- - -
- - + - - + + - - + + + + + -

已发放备用金列表

-
- - - 搜索 - - - 重置 - -
- - - - - - - - - - - - - - - - -
- +
+ + + +
+

已发放备用金列表

+
+ + + 搜索 + + + 重置 + +
+ + + + + + + + + + + + + + + + +
+ +
+ + + + + +

冲抵附件(必填)

+ -
+ - - - - -

冲抵附件(必填)

- + - @@ -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>({}) const dialogs = reactive>({ viewDetail: false, - issue: false, batchIssue: false, offset: false, processedDetail: false @@ -1172,15 +1199,10 @@ const viewBasicKvFields = computed(() => { const viewPayKvFields = computed(() => { 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([]) +const payFiles = ref([]) const issueAttachmentRef = ref | null>(null) const offsetAttachmentRef = ref | null>(null) const viewAttachments = ref([]) @@ -1215,18 +1238,7 @@ const offsetAdvancePageSize = ref(5) const offsetAdvanceLoading = ref(false) const offsetSelectedAdvances = ref([]) 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(() => { const processedPayKvFields = computed(() => { 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 {