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 @@
}}
+