feat(bx): 报销支付按未结清余额分次冲抵和发放。

冲抵后的余额不再自动记成发放,发放和冲抵分开提交,未结清单据继续留在待处理。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-11-05 21:14:09 +08:00
1 parent b49c99f1bd
commit e7f942b399
8 files changed
+707 -261

No files matched your search

+3 -1
View File
@@ -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