378 lines
18 KiB
Python
378 lines
18 KiB
Python
"""真实业务事务中的完成通知;不连接生产、不调用微信。"""
|
|
from datetime import date, datetime
|
|
import importlib
|
|
import importlib.util
|
|
import json
|
|
|
|
import pytest
|
|
from sqlalchemy import create_engine, event
|
|
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.models import SysUser
|
|
from app.models.pl_models import (
|
|
PlFreightSettlement, PlFreightSettlementApproval, PlRemittanceVoucher,
|
|
PlVehicleRechargeApply, PlVehicleRechargeApproval, PlVehicleRechargeRecord,
|
|
)
|
|
from app.models.wechat_notice import WechatNotice
|
|
|
|
NOW = datetime(2026, 10, 27, 12, 30, 45)
|
|
|
|
|
|
def service():
|
|
name = 'app.services.wechat_completion_notices'
|
|
assert importlib.util.find_spec(name), '完成通知尚未实现'
|
|
return importlib.import_module(name)
|
|
|
|
|
|
@pytest.fixture
|
|
def db(monkeypatch):
|
|
engine = create_engine('sqlite://')
|
|
@event.listens_for(engine, 'connect')
|
|
def connect(connection, _):
|
|
connection.isolation_level = None
|
|
@event.listens_for(engine, 'begin')
|
|
def begin(connection):
|
|
connection.exec_driver_sql('BEGIN')
|
|
for model in (SysUser, WechatNotice, BxAdvanceApply, BxReimburseApply,
|
|
BxReimburseOffset, BxWorkflowNode, PlFreightSettlement,
|
|
PlFreightSettlementApproval, PlRemittanceVoucher,
|
|
PlVehicleRechargeApply, PlVehicleRechargeApproval, PlVehicleRechargeRecord):
|
|
model.__table__.create(engine)
|
|
session = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
|
|
for uid in ('applicant', 'operator', 'submitter'):
|
|
session.add(SysUser(id=uid, user_no=uid, nick_name=uid, tenant_no='t1'))
|
|
session.commit()
|
|
monkeypatch.setattr(settings, 'WECHAT_OA_ENABLED', True)
|
|
monkeypatch.setattr(settings, 'WECHAT_OA_TEMPLATE_COMPLETION', 'completion-template')
|
|
yield session
|
|
session.close()
|
|
engine.dispose()
|
|
|
|
|
|
def seed(db, kind, *, completed=True, biz_id='doc'):
|
|
common = dict(id=biz_id, tenant_no='t1', create_by='operator')
|
|
if kind in ('bx_advance', 'bx_reimburse'):
|
|
common.update(apply_code='BYJ20261027001' if kind == 'bx_advance' else 'BX20261027001',
|
|
applicant_id='applicant', applicant_name='申请人', amount=100)
|
|
if kind == 'bx_advance':
|
|
row = BxAdvanceApply(**common, reason='备用金', status='issued' if completed else 'approved',
|
|
issue_status='issued' if completed else None, issue_time=NOW if completed else None)
|
|
else:
|
|
row = BxReimburseApply(**common, title='报销', reimburse_date=date.today(), expense_id='expense',
|
|
expense_name='费用', status='processed' if completed else 'approved',
|
|
pay_status='paid' if completed else None, pay_time=NOW if completed else None)
|
|
elif kind == 'pl_freight':
|
|
row = PlFreightSettlement(**common, settlement_no='YF2026102701', total_freight=100,
|
|
settlement_status='confirmed' if completed else 'approved')
|
|
db.add(PlFreightSettlementApproval(id='approval', tenant_no='t1', settlement_id=biz_id,
|
|
step=1, approver_id='operator', create_by='submitter', approval_result='approved'))
|
|
if completed:
|
|
db.add(PlRemittanceVoucher(id='record', tenant_no='t1', settlement_id=biz_id,
|
|
remittance_date=date(2020, 1, 1), create_time=NOW))
|
|
else:
|
|
row = PlVehicleRechargeApply(**common, apply_no='ETC2026102701' + biz_id, applicant_id='applicant',
|
|
license_plate='鄂A00001', recharge_type='etc', apply_date=date.today(),
|
|
status='disbursed' if completed else 'pending')
|
|
if completed:
|
|
db.add(PlVehicleRechargeRecord(id='record', tenant_no='t1', apply_id=biz_id,
|
|
recharge_order_no='SXCL20261027000001', recharge_date=date(2020, 1, 1),
|
|
recharge_type='etc', license_plate='鄂A00001', amount=100, create_time=NOW))
|
|
db.add(row)
|
|
db.commit()
|
|
return row
|
|
|
|
|
|
def sync(db, kind, **kwargs):
|
|
return service().sync_completion_notification(db, kind, 'doc', 't1', actor_id='operator', **kwargs)
|
|
|
|
|
|
@pytest.mark.parametrize('kind,name,state', [
|
|
('bx_advance', '备用金发放', '发放完成'), ('bx_reimburse', '报销支付', '支付完成'),
|
|
('pl_freight', '运费发放', '发放完成'), ('pl_recharge', '充值发放', '发放完成'),
|
|
])
|
|
def test_exact_payload_real_time_recipient_without_approval_cycle_and_rollback(db, kind, name, state):
|
|
seed(db, kind)
|
|
kwargs = {'event_id': 'record', 'occurred_at': NOW} if kind.startswith('pl_') else {}
|
|
sync(db, kind, **kwargs)
|
|
notice = db.query(WechatNotice).one()
|
|
assert notice.recipient_id == ('submitter' if kind == 'pl_freight' else 'applicant')
|
|
assert set(json.loads(notice.payload)) == {'thing3', 'thing4', 'thing5', 'time12'}
|
|
assert json.loads(notice.payload)['thing3']['value'] == name
|
|
assert json.loads(notice.payload)['thing5']['value'] == state
|
|
assert json.loads(notice.payload)['time12']['value'] == '2026-10-27 12:30:45'
|
|
assert service().valid_completion_notice(db, notice)
|
|
sync(db, kind, **kwargs)
|
|
assert db.query(WechatNotice).count() == 1
|
|
db.rollback()
|
|
assert db.query(WechatNotice).count() == 0
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_freight', 'pl_recharge'])
|
|
def test_status_only_approval_never_generates_completion(db, kind):
|
|
seed(db, kind, completed=False)
|
|
sync(db, kind, event_id='record', occurred_at=NOW)
|
|
assert db.query(WechatNotice).count() == 0
|
|
|
|
|
|
def test_full_number_is_not_truncated_and_invalid_records_are_auditable(db):
|
|
row = seed(db, 'bx_advance')
|
|
row.apply_code = 'X' * 21
|
|
db.flush()
|
|
sync(db, 'bx_advance')
|
|
notice = db.query(WechatNotice).one()
|
|
assert notice.status == 'failed'
|
|
assert json.loads(notice.payload)['thing4']['value'] == 'X' * 21
|
|
assert notice.last_error
|
|
|
|
|
|
def test_recipient_and_tenant_or_deleted_document_revalidated(db):
|
|
row = seed(db, 'bx_advance')
|
|
sync(db, 'bx_advance')
|
|
notice = db.query(WechatNotice).one()
|
|
row.tenant_no = 'other'
|
|
db.flush()
|
|
assert not service().valid_completion_notice(db, notice)
|
|
row.tenant_no = 't1'
|
|
row.del_flag = 1
|
|
db.flush()
|
|
assert not service().valid_completion_notice(db, notice)
|
|
row.del_flag = 0
|
|
row.applicant_id = 'operator'
|
|
db.flush()
|
|
assert not service().valid_completion_notice(db, notice)
|
|
|
|
|
|
@pytest.fixture
|
|
def business(db, monkeypatch):
|
|
from app.services.bx import base
|
|
from app.services.bx.advance import AdvanceService
|
|
from app.services.bx.reimburse import ReimburseService
|
|
from app.services import pl_vehicle_recharge_service as recharge
|
|
from app.services import pl_vehicle_recharge_approval_service as approval
|
|
monkeypatch.setattr(base, 'assert_bx_row_in_scope', lambda *a, **kw: None)
|
|
for cls in (AdvanceService, ReimburseService):
|
|
monkeypatch.setattr(cls, '_tenant_no', lambda *a: 't1')
|
|
monkeypatch.setattr(recharge, 'assert_pl_row_in_scope', lambda *a, **kw: None)
|
|
monkeypatch.setattr(recharge, 'record_to_read', lambda row, *a, **kw: row)
|
|
monkeypatch.setattr(recharge, 'apply_to_read', lambda row, *a, **kw: row)
|
|
monkeypatch.setattr(approval, 'apply_to_read', lambda row, *a, **kw: row)
|
|
return BxUserContext('operator', '经办人', 'dept', 'admin', 't1')
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
|
|
def test_real_bx_complete_hook_retry_preserves_original_time_and_rolls_back(db, business, kind):
|
|
from app.services.bx.advance import AdvanceService
|
|
from app.services.bx.reimburse import ReimburseService
|
|
row = seed(db, kind, completed=False)
|
|
fn = AdvanceService.issue if kind == 'bx_advance' else ReimburseService.pay
|
|
fn(db, row.id, business)
|
|
assert db.query(WechatNotice).count() == 1
|
|
payload = db.query(WechatNotice).one().payload
|
|
stamp = row.issue_time if kind == 'bx_advance' else row.pay_time
|
|
fn(db, row.id, business)
|
|
assert (row.issue_time if kind == 'bx_advance' else row.pay_time) == stamp
|
|
assert db.query(WechatNotice).count() == 1
|
|
assert db.query(WechatNotice).one().payload == payload
|
|
db.rollback()
|
|
assert db.query(WechatNotice).count() == 0
|
|
assert (row.issue_time if kind == 'bx_advance' else row.pay_time) is None
|
|
|
|
|
|
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)
|
|
items = [{'advance_id': 'advance', 'offset_amount': 30}]
|
|
ReimburseService.batch_offset(db, row.id, items, 70, business)
|
|
assert db.query(WechatNotice).count() == 1
|
|
ReimburseService.batch_offset(db, row.id, items, 70, business)
|
|
assert db.query(BxReimburseOffset).count() == 1
|
|
db.rollback()
|
|
ReimburseService.batch_offset(db, row.id, items, 0, business)
|
|
assert db.query(WechatNotice).count() == 0
|
|
assert row.pay_time is None
|
|
|
|
|
|
def test_freight_real_disbursement_notifies_submitter_with_voucher_time(db, business):
|
|
from app.services.pl_freight_settlement_service import disburse_settlement
|
|
from app.schemas.pl.freight import FreightSettlementDisburseBody
|
|
row = seed(db, 'pl_freight', completed=False)
|
|
disburse_settlement(db, tenant_no='t1', settlement_id=row.id,
|
|
body=FreightSettlementDisburseBody(remittance_date=date(2020, 1, 1), remittance_amount=100, voucher_urls=['proof']),
|
|
user_id='operator', dept_id='dept')
|
|
notice = db.query(WechatNotice).one()
|
|
voucher = db.query(PlRemittanceVoucher).one()
|
|
assert row.settlement_status == 'confirmed'
|
|
assert notice.recipient_id == 'submitter'
|
|
assert notice.cycle_id == voucher.id
|
|
assert json.loads(notice.payload)['time12']['value'] == voucher.create_time.strftime('%Y-%m-%d %H:%M:%S')
|
|
assert service().valid_completion_notice(db, notice)
|
|
voucher.del_flag = 1
|
|
db.flush()
|
|
assert not service().valid_completion_notice(db, notice)
|
|
|
|
|
|
def payload(apply_id='doc'):
|
|
from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
|
|
return VehicleRechargeRecordCreate(apply_id=apply_id, license_plate='鄂A00001', recharge_type='etc',
|
|
recharge_date=date(2020, 1, 1), amount=100)
|
|
|
|
|
|
def test_recharge_create_delete_reissue_and_no_applicant(db, business, monkeypatch):
|
|
from app.services import pl_vehicle_recharge_service as recharge
|
|
row = seed(db, 'pl_recharge', completed=False)
|
|
record = recharge.create_record(db, 't1', business, payload())
|
|
notice = db.query(WechatNotice).one()
|
|
assert service().valid_completion_notice(db, notice)
|
|
recharge.delete_records(db, 't1', business, [record.id])
|
|
assert notice.status == 'cancelled'
|
|
assert not service().valid_completion_notice(db, notice)
|
|
recharge.create_record(db, 't1', business, payload())
|
|
assert db.query(WechatNotice).filter_by(status='pending').count() == 1
|
|
assert db.query(WechatNotice).count() == 2
|
|
monkeypatch.setattr(recharge, '_assert_vehicle_exists', lambda *a: None)
|
|
recharge.create_record(db, 't1', business, payload(None))
|
|
assert db.query(WechatNotice).count() == 2
|
|
|
|
|
|
@pytest.mark.parametrize('old_status', ['pending', 'sending'])
|
|
def test_recharge_same_second_reassociation_has_independent_event_and_cancels_old(db, business, monkeypatch, old_status):
|
|
from app.services import pl_vehicle_recharge_service as recharge
|
|
class FrozenDateTime(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None):
|
|
return NOW
|
|
monkeypatch.setattr(recharge, 'datetime', FrozenDateTime)
|
|
first = seed(db, 'pl_recharge', completed=False)
|
|
second = seed(db, 'pl_recharge', completed=False, biz_id='other')
|
|
record = recharge.create_record(db, 't1', business, payload())
|
|
original = db.query(WechatNotice).one()
|
|
original.status = old_status
|
|
db.commit()
|
|
recharge.update_record(db, 't1', business, record.id, payload(second.id))
|
|
recharge.update_record(db, 't1', business, record.id, payload(first.id))
|
|
assert db.query(WechatNotice).count() == 3
|
|
assert db.query(WechatNotice).filter_by(status='pending').count() == 1
|
|
assert not service().valid_completion_notice(db, original)
|
|
assert service().valid_completion_notice(db, db.query(WechatNotice).filter_by(status='pending').one())
|
|
|
|
|
|
def test_recharge_approval_disbursement_hook(db, business):
|
|
from app.services.pl_vehicle_recharge_approval_service import disburse_apply
|
|
row = seed(db, 'pl_recharge', completed=False)
|
|
row.status, row.current_node, row.wf_id = 'in_approval', 'node', 'wf'
|
|
db.add(BxWorkflowNode(id='node', wf_id='wf', node_name='发放', node_order=1,
|
|
approver_id='operator', action_type='disburse', tenant_no='t1'))
|
|
db.commit()
|
|
disburse_apply(db, 't1', business, row.id, payload())
|
|
notice = db.query(WechatNotice).one()
|
|
assert service().valid_completion_notice(db, notice)
|
|
assert notice.recipient_id == 'applicant'
|
|
|
|
|
|
def test_recharge_disabled_still_cancels_old_pending_but_does_not_enqueue(db, business, monkeypatch):
|
|
from app.services import pl_vehicle_recharge_service as recharge
|
|
seed(db, 'pl_recharge', completed=False)
|
|
record = recharge.create_record(db, 't1', business, payload())
|
|
notice = db.query(WechatNotice).one()
|
|
monkeypatch.setattr(settings, 'WECHAT_OA_ENABLED', False)
|
|
recharge.delete_records(db, 't1', business, [record.id])
|
|
recharge.create_record(db, 't1', business, payload())
|
|
assert notice.status == 'cancelled'
|
|
assert db.query(WechatNotice).count() == 1
|
|
|
|
|
|
def test_disabled_before_notification_schema_install_does_not_block_business(db, business, monkeypatch):
|
|
from app.services.bx.advance import AdvanceService
|
|
row = seed(db, 'bx_advance', completed=False)
|
|
WechatNotice.__table__.drop(db.get_bind())
|
|
monkeypatch.setattr(settings, 'WECHAT_OA_ENABLED', False)
|
|
AdvanceService.issue(db, row.id, business)
|
|
assert row.status == 'issued'
|
|
|
|
|
|
def test_completed_before_launch_is_not_retroactively_notified(db, business):
|
|
from app.services.bx.advance import AdvanceService
|
|
row = seed(db, 'bx_advance')
|
|
AdvanceService.issue(db, row.id, business)
|
|
assert db.query(WechatNotice).count() == 0
|
|
|
|
|
|
def test_invalid_recipient_never_enqueues(db):
|
|
row = seed(db, 'bx_advance')
|
|
row.applicant_id = 'outsider'
|
|
db.add(SysUser(id='outsider', user_no='outsider', nick_name='外租户', tenant_no='other'))
|
|
db.flush()
|
|
sync(db, 'bx_advance')
|
|
assert db.query(WechatNotice).count() == 0
|
|
row.applicant_id = 'applicant'
|
|
db.query(SysUser).filter_by(id='applicant').one().status = '0'
|
|
db.flush()
|
|
sync(db, 'bx_advance')
|
|
assert db.query(WechatNotice).count() == 0
|
|
|
|
|
|
def test_stale_sender_cannot_reactivate_revoked_recharge_association(db, business):
|
|
from app.services import pl_vehicle_recharge_service as recharge
|
|
first = seed(db, 'pl_recharge', completed=False)
|
|
second = seed(db, 'pl_recharge', completed=False, biz_id='other')
|
|
record = recharge.create_record(db, 't1', business, payload())
|
|
original = db.query(WechatNotice).one()
|
|
original.status = 'sending'
|
|
db.commit()
|
|
recharge.update_record(db, 't1', business, record.id, payload(second.id))
|
|
recharge.update_record(db, 't1', business, record.id, payload(first.id))
|
|
# 网络在撤销前已开始时,旧worker可能把瞬时失败重新写为pending。
|
|
original.status = 'pending'
|
|
db.flush()
|
|
assert not service().valid_completion_notice(db, original)
|
|
|
|
|
|
def test_freight_disbursement_consumes_one_locked_current_read(db, business, monkeypatch):
|
|
from app.services import pl_freight_settlement_service as freight
|
|
from app.schemas.pl.freight import FreightSettlementDisburseBody
|
|
row = seed(db, 'pl_freight', completed=False)
|
|
reads = []
|
|
@event.listens_for(db, 'do_orm_execute')
|
|
def track_reads(execute_state):
|
|
statement = execute_state.statement
|
|
if execute_state.is_select and any(
|
|
entry.get('entity') is PlFreightSettlement
|
|
for entry in getattr(statement, 'column_descriptions', [])
|
|
):
|
|
reads.append(statement._for_update_arg is not None)
|
|
# 只隔离通知快照及展示刷新读取;真实发放状态、凭证与提交仍执行。
|
|
monkeypatch.setattr(freight, 'sync_completion_notification', lambda *a, **kw: None)
|
|
monkeypatch.setattr(db, 'refresh', lambda *a, **kw: None)
|
|
freight.disburse_settlement(db, tenant_no='t1', settlement_id=row.id,
|
|
body=FreightSettlementDisburseBody(remittance_date=date.today(), remittance_amount=100, voucher_urls=['proof']),
|
|
user_id='operator', dept_id='dept')
|
|
assert reads == [True], '状态校验必须直接使用唯一锁读,不能随后退回普通快照读'
|
|
|
|
|
|
def test_freight_locked_get_refreshes_stale_identity_map(db):
|
|
from app.services.pl_freight_settlement_service import get_settlement_row
|
|
row = seed(db, 'pl_freight', completed=False)
|
|
db.execute(PlFreightSettlement.__table__.update().where(
|
|
PlFreightSettlement.id == row.id,
|
|
).values(settlement_status='confirmed'))
|
|
assert row.settlement_status == 'approved'
|
|
current = get_settlement_row(db, 't1', row.id, for_update=True)
|
|
assert current is row
|
|
assert current.settlement_status == 'confirmed'
|
|
|
|
|
|
def test_cancelled_freight_completion_is_not_revived(db):
|
|
seed(db, 'pl_freight')
|
|
notice = sync(db, 'pl_freight', event_id='record')
|
|
assert notice is not None
|
|
notice.status = 'cancelled'
|
|
notice.result = 'revoked'
|
|
db.flush()
|
|
sync(db, 'pl_freight', event_id='record')
|
|
assert db.query(WechatNotice).count() == 1
|
|
assert notice.status == 'cancelled'
|
|
assert not service().valid_completion_notice(db, notice)
|