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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user