| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454 |
- """BX/充值通知快照:只取当前节点和真实审批结果,不把发放当审批。"""
- from datetime import date, datetime, timedelta
- import importlib
- import importlib.util
- import pytest
- from sqlalchemy import create_engine
- from sqlalchemy.orm import sessionmaker
- from app.models.bx_models import (
- BxAdvanceApply, BxAdvanceApproval, BxReimburseApply, BxReimburseApproval,
- BxWorkflowDef, BxWorkflowNode,
- )
- from app.models.pl_models import PlVehicleRechargeApply, PlVehicleRechargeApproval
- @pytest.fixture
- def db():
- engine = create_engine('sqlite://')
- for model in (BxAdvanceApply, BxAdvanceApproval, BxReimburseApply,
- BxReimburseApproval, BxWorkflowDef, BxWorkflowNode,
- PlVehicleRechargeApply, PlVehicleRechargeApproval):
- model.__table__.create(engine)
- session = sessionmaker(bind=engine, autoflush=False)()
- yield session
- session.close()
- engine.dispose()
- def snapshot(db, kind):
- name = 'app.services.wechat_notice_bx_sources'
- assert importlib.util.find_spec(name) is not None, 'BX/充值审批通知快照适配器尚未实现'
- return importlib.import_module(name).get_snapshot(db, kind, 'apply', 't1')
- def seed(db, kind, status=None, disburse_first=False):
- recharge = kind == 'pl_recharge'
- db.add(BxWorkflowDef(id='wf', biz_type='vehicle_recharge' if recharge else kind,
- wf_name='审批流程', tenant_no='t1'))
- for i in (1, 2):
- db.add(BxWorkflowNode(id=f'n{i}', wf_id='wf', node_name=f'审批{i}', node_order=i,
- approver_id=f'u{i}', tenant_no='t1',
- action_type='disburse' if recharge and (i == 2 or disburse_first) else 'approve'))
- fields = dict(id='apply', applicant_id='applicant', create_by='operator', tenant_no='t1',
- wf_id='wf', status=status or ('in_approval' if recharge else 'pending'),
- current_node='n1' if recharge else '审批1')
- if recharge:
- row = PlVehicleRechargeApply(**fields, apply_no='PL001', license_plate='鄂A00001',
- recharge_type='fuel', apply_date=date.today(), approval_round=2)
- else:
- fields.update(apply_code='BX001', applicant_name='申请人', amount=100)
- row = (BxAdvanceApply(**fields, reason='备用金') if kind == 'bx_advance' else
- BxReimburseApply(**fields, title='报销', reimburse_date=date.today(),
- expense_id='expense', expense_name='差旅'))
- db.add(row)
- db.flush()
- return row
- def approval(db, kind, outcome='approved', uid='u2', when=None, round_no=2):
- model = {'bx_advance': BxAdvanceApproval, 'bx_reimburse': BxReimburseApproval,
- 'pl_recharge': PlVehicleRechargeApproval}[kind]
- fields = dict(id=f'{outcome}-{uid}-{round_no}', apply_id='apply', tenant_no='t1',
- approver_id=uid, approver_name=uid, node_name='审批2',
- approval_status=outcome, approval_time=when or datetime(2026, 10, 27, 12))
- fields.update(dict(approval_round=round_no, node_id='n2') if kind == 'pl_recharge' else dict(node_order=2))
- record = model(**fields)
- db.add(record)
- db.flush()
- return record
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
- def test_only_current_approver_and_real_applicant(db, kind):
- row = seed(db, kind)
- result = snapshot(db, kind)
- assert [(p.task_key, p.user_id) for p in result.pending] == [('n1', 'u1')]
- assert result.applicant_id == 'applicant'
- assert result.result is None
- assert result.approver_id == ''
- assert result.page == ''
- row.current_node = 'n2' if kind == 'pl_recharge' else '审批2'
- db.flush()
- result = snapshot(db, kind)
- assert [p.user_id for p in result.pending] == ([] if kind == 'pl_recharge' else ['u2'])
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
- @pytest.mark.parametrize('state', ['draft', 'cancelled'])
- def test_inactive_row_suppresses_stale_node_and_approval(db, kind, state):
- seed(db, kind, status=state)
- approval(db, kind)
- result = snapshot(db, kind)
- assert result.pending == []
- assert result.result is None
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
- def test_result_uses_latest_approval_time_not_node_order_or_operator(db, kind):
- row = seed(db, kind, status='rejected')
- approval(db, kind, when=datetime(2026, 10, 27, 11))
- last = approval(db, kind, outcome='rejected', uid='u1')
- result = snapshot(db, kind)
- assert result.result == 'rejected'
- assert result.pending == []
- assert result.approver_id == 'u1'
- assert result.occurred_at == last.approval_time
- assert result.page == {
- 'bx_advance': 'pages/bx/advance/detail?id=apply',
- 'bx_reimburse': 'pages/bx/reimburse/detail?id=apply',
- 'pl_recharge': 'pages/pl/vehicle-recharge-apply/form?id=apply',
- }[kind]
- row.status = 'in_approval' if kind == 'pl_recharge' else 'pending'
- db.flush()
- assert snapshot(db, kind).result is None
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
- def test_bx_approved_requires_real_record(db, kind):
- seed(db, kind, status='approved')
- assert snapshot(db, kind).result is None
- record = approval(db, kind)
- result = snapshot(db, kind)
- assert result.result == 'approved'
- assert result.occurred_at == record.approval_time
- def test_recharge_approval_completes_before_disbursement(db):
- row = seed(db, 'pl_recharge')
- row.current_node = 'n2'
- db.flush()
- approval(db, 'pl_recharge', round_no=1)
- assert snapshot(db, 'pl_recharge').result is None
- approved = approval(db, 'pl_recharge')
- result = snapshot(db, 'pl_recharge')
- assert result.result == 'approved'
- assert result.pending == []
- assert result.approver_id == 'u2'
- assert result.occurred_at == approved.approval_time
- row.status = 'disbursed'
- approval(db, 'pl_recharge', outcome='disbursed', uid='finance',
- when=approved.approval_time + timedelta(hours=1))
- result = snapshot(db, 'pl_recharge')
- assert result.result == 'approved'
- assert result.approver_id == 'u2'
- assert result.occurred_at == approved.approval_time
- def test_recharge_direct_disbursement_does_not_invent_approval(db):
- row = seed(db, 'pl_recharge', disburse_first=True)
- assert snapshot(db, 'pl_recharge').pending == []
- assert snapshot(db, 'pl_recharge').result is None
- row.status = 'disbursed'
- approval(db, 'pl_recharge', outcome='disbursed', uid='finance')
- assert snapshot(db, 'pl_recharge').result is None
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
- def test_tenant_and_soft_deleted_application_are_excluded(db, kind):
- row = seed(db, kind)
- row.tenant_no = 'other'
- db.flush()
- assert snapshot(db, kind) is None
- row.tenant_no = 't1'
- row.del_flag = 1
- db.flush()
- assert snapshot(db, kind) is None
- @pytest.fixture
- def live(db, monkeypatch):
- from app.core.bx_auth import BxUserContext
- from app.models.bx_models import BxInvoiceLedger, BxInvoiceWalletSummary
- from app.models.models import SysUser
- from app.models.wechat_notice import WechatApprovalCycle, WechatNotice
- from app.services import wechat_approval_notices as notices
- from app.services.bx import base
- from app.services import pl_vehicle_recharge_service as recharge
- from app.services import pl_vehicle_recharge_approval_service as recharge_approval
- for model in (SysUser, BxInvoiceLedger, BxInvoiceWalletSummary, WechatApprovalCycle, WechatNotice):
- model.__table__.create(db.get_bind())
- for uid in ('applicant', 'operator', 'u1', 'u2'):
- db.add(SysUser(id=uid, tenant_no='t1', user_no=uid, nick_name=uid))
- db.commit()
- monkeypatch.setattr(notices.settings, 'WECHAT_OA_ENABLED', True)
- monkeypatch.setattr(notices.settings, 'WECHAT_OA_TEMPLATE_PENDING', 'pending-template')
- monkeypatch.setattr(notices.settings, 'WECHAT_OA_TEMPLATE_RESULT', 'result-template')
- monkeypatch.setattr(base, 'assert_bx_row_in_scope', lambda *a, **kw: None)
- monkeypatch.setattr(recharge, 'assert_pl_row_in_scope', lambda *a, **kw: None)
- # Presentation is unrelated to transactional business mutations under test.
- monkeypatch.setattr(recharge, 'apply_to_read', lambda row, *a, **kw: row)
- monkeypatch.setattr(recharge_approval, 'apply_to_read', lambda row, *a, **kw: row)
- return (lambda uid: BxUserContext(uid, uid, 'd1', 'admin', 't1')), WechatNotice, WechatApprovalCycle
- def bx_service(kind):
- from app.services.bx.advance import AdvanceService
- from app.services.bx.reimburse import ReimburseService
- return AdvanceService if kind == 'bx_advance' else ReimburseService
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
- def test_bx_real_submit_sequential_approve_notifies_applicant_and_rolls_back(db, live, kind):
- import json
- user, Notice, Cycle = live
- row = seed(db, kind, status='draft')
- db.commit()
- service = bx_service(kind)
- service.submit(db, row.id, user('operator'))
- assert db.query(Notice).filter_by(status='pending').one().recipient_id == 'u1'
- service.approve(db, row.id, user('u1'), '同意')
- assert db.query(Notice).filter_by(status='pending').one().recipient_id == 'u2'
- service.approve(db, row.id, user('u2'), '同意')
- result = db.query(Notice).filter_by(status='pending').one()
- assert result.kind == 'result'
- assert result.recipient_id == 'applicant'
- assert json.loads(result.payload)['thing6']['value'] == 'u2'
- assert json.loads(result.payload)['const5']['value'] == '通过'
- db.rollback()
- assert db.query(Notice).count() == 0
- assert db.query(Cycle).count() == 0
- assert row.status == 'draft'
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
- def test_bx_real_withdraw_resubmit_and_reject_use_new_cycle(db, live, kind):
- user, Notice, Cycle = live
- row = seed(db, kind, status='draft')
- db.commit()
- service = bx_service(kind)
- service.submit(db, row.id, user('applicant'))
- first_cycle = db.query(Cycle).one().cycle_id
- service.withdraw(db, row.id, user('applicant'))
- assert db.query(Notice).filter_by(status='pending').count() == 0
- service.submit(db, row.id, user('operator'))
- assert db.query(Cycle).one().cycle_id != first_cycle
- service.reject(db, row.id, user('u1'), '请补充')
- result = db.query(Notice).filter_by(status='pending').one()
- assert result.kind == 'result'
- assert result.result == 'rejected'
- assert result.recipient_id == 'applicant'
- service.submit(db, row.id, user('applicant'))
- assert db.query(Notice).filter_by(status='pending').one().kind == 'pending'
- def test_recharge_real_approve_handoff_withdraw_resubmit_reject_cancel(db, live):
- from app.services import pl_vehicle_recharge_service as service
- from app.services import pl_vehicle_recharge_approval_service as approval_service
- user, Notice, Cycle = live
- row = seed(db, 'pl_recharge', status='draft')
- db.commit()
- service.submit_apply(db, 't1', user('operator'), row.id)
- assert db.query(Notice).filter_by(status='pending').one().recipient_id == 'u1'
- first_cycle = db.query(Cycle).one().cycle_id
- service.withdraw_apply(db, 't1', user('applicant'), row.id)
- assert db.query(Notice).filter_by(status='pending').count() == 0
- service.submit_apply(db, 't1', user('applicant'), row.id)
- assert db.query(Cycle).one().cycle_id != first_cycle
- approval_service.approve_apply(db, 't1', user('u1'), row.id)
- result = db.query(Notice).filter_by(status='pending').one()
- assert result.kind == 'result'
- assert result.result == 'approved'
- assert result.recipient_id == 'applicant'
- assert row.status == 'in_approval'
- service.withdraw_apply(db, 't1', user('applicant'), row.id)
- service.submit_apply(db, 't1', user('applicant'), row.id)
- approval_service.reject_apply(db, 't1', user('u1'), row.id, opinion='请补充')
- assert db.query(Notice).filter_by(status='pending').one().result == 'rejected'
- service.cancel_apply(db, 't1', user('operator'), row.id)
- assert db.query(Notice).filter_by(status='pending').count() == 0
- def test_recharge_real_direct_disburse_submission_has_no_pending_or_result(db, live):
- from app.services import pl_vehicle_recharge_service as service
- user, Notice, Cycle = live
- row = seed(db, 'pl_recharge', status='draft', disburse_first=True)
- db.commit()
- service.submit_apply(db, 't1', user('operator'), row.id)
- assert db.query(Cycle).count() == 1
- assert db.query(Notice).count() == 0
- def test_recharge_blank_action_is_approval_like_business_service(db):
- seed(db, 'pl_recharge')
- db.query(BxWorkflowNode).filter_by(id='n1').one().action_type = ' '
- db.flush()
- assert [p.user_id for p in snapshot(db, 'pl_recharge').pending] == ['u1']
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
- def test_deleted_and_other_tenant_nodes_never_produce_tasks(db, kind):
- seed(db, kind)
- node = db.query(BxWorkflowNode).filter_by(id='n1').one()
- node.tenant_no = 'other'
- db.flush()
- assert snapshot(db, kind).pending == []
- node.tenant_no = 't1'
- node.del_flag = 1
- db.flush()
- assert snapshot(db, kind).pending == []
- @pytest.mark.parametrize('direct', [False, True])
- def test_real_disbursement_preserves_approval_result_and_adds_completion(db, live, direct):
- import json
- from app.models.pl_models import PlVehicleRechargeRecord
- from app.schemas.pl.vehicle_recharge import VehicleRechargeRecordCreate
- from app.services import pl_vehicle_recharge_service as service
- from app.services import pl_vehicle_recharge_approval_service as approval_service
- from app.services.wechat_approval_notices import _valid_now
- user, Notice, _ = live
- PlVehicleRechargeRecord.__table__.create(db.get_bind())
- row = seed(db, 'pl_recharge', status='draft', disburse_first=direct)
- db.commit()
- service.submit_apply(db, 't1', user('operator'), row.id)
- if not direct:
- approval_service.approve_apply(db, 't1', user('u1'), row.id)
- approval_service.disburse_apply(
- db, 't1', user('u1' if direct else 'u2'), row.id,
- VehicleRechargeRecordCreate(rechargeDate=date.today(), rechargeType='fuel', amount=100),
- )
- assert row.status == 'disbursed'
- completion = db.query(Notice).filter_by(status='pending', kind='completion').one()
- assert json.loads(completion.payload)['thing3']['value'] == '充值发放'
- assert completion.recipient_id == 'applicant'
- assert _valid_now(db, completion) is True
- pending = db.query(Notice).filter_by(status='pending').filter(
- Notice.kind.in_(('pending', 'result'))
- ).all()
- if direct:
- assert pending == []
- else:
- assert len(pending) == 1
- assert pending[0].kind == 'result'
- assert json.loads(pending[0].payload)['thing6']['value'] == 'u1'
- assert _valid_now(db, pending[0]) is True
- def test_recharge_notification_failure_occurs_before_original_commit(db, live, monkeypatch):
- from app.services import pl_vehicle_recharge_service as service
- user, _, _ = live
- row = seed(db, 'pl_recharge', status='draft')
- db.commit()
- original_round = row.approval_round
- def failed_enqueue(*args, **kwargs):
- raise RuntimeError('transactional enqueue failed')
- monkeypatch.setattr(service, 'sync_approval_notifications', failed_enqueue)
- with pytest.raises(RuntimeError, match='transactional enqueue failed'):
- service.submit_apply(db, 't1', user('operator'), row.id)
- db.rollback()
- assert row.status == 'draft'
- assert row.approval_round == original_round
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
- @pytest.mark.parametrize('operation', ['approve', 'reject'])
- def test_bx_rejects_nonpending_state_even_with_leftover_node(db, live, kind, operation):
- user, Notice, _ = live
- row = seed(db, kind, status='draft')
- db.commit()
- assert getattr(bx_service(kind), operation)(db, row.id, user('u1'), '重复请求') is None
- assert row.status == 'draft'
- assert db.query(Notice).count() == 0
- approval_model = BxAdvanceApproval if kind == 'bx_advance' else BxReimburseApproval
- assert db.query(approval_model).count() == 0
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
- def test_bx_duplicate_submit_does_not_restart_active_cycle(db, live, kind):
- user, Notice, Cycle = live
- row = seed(db, kind, status='draft')
- db.commit()
- service = bx_service(kind)
- service.submit(db, row.id, user('applicant'))
- first_cycle = db.query(Cycle).one().cycle_id
- assert service.submit(db, row.id, user('applicant')) is None
- assert db.query(Cycle).one().cycle_id == first_cycle
- assert db.query(Notice).count() == 1
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse'])
- def test_bx_duplicate_terminal_approval_refreshes_cached_state(db, live, kind):
- from sqlalchemy import update
- user, Notice, _ = live
- row = seed(db, kind)
- row.current_node = '审批2'
- db.commit()
- assert row.current_node == '审批2'
- # 另一事务已完成审批;当前Session仍持有旧的identity-map对象。
- db.execute(update(type(row)).where(type(row).id == row.id).values(
- status='approved', current_node=None
- ).execution_options(synchronize_session=False))
- assert row.status == 'pending'
- assert bx_service(kind).approve(db, row.id, user('u2'), '重复终审') is None
- assert row.status == 'approved'
- approval_model = BxAdvanceApproval if kind == 'bx_advance' else BxReimburseApproval
- assert db.query(approval_model).count() == 0
- assert db.query(Notice).count() == 0
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
- def test_submit_locks_business_row_before_state_change(db, live, kind):
- from sqlalchemy import event
- from sqlalchemy.dialects import mysql
- from app.services import pl_vehicle_recharge_service as recharge
- user, _, _ = live
- row = seed(db, kind, status='draft')
- db.commit()
- row_id = row.id
- selects = []
- locked_before_pending_write = []
- def capture(orm_state):
- if orm_state.is_select:
- sql = str(orm_state.statement.compile(dialect=mysql.dialect()))
- if f'FROM {row.__tablename__} ' in sql or f'FROM {row.__tablename__}\n' in sql:
- selects.append(sql)
- def before_flush(session, flush_context, instances):
- if row in session.dirty and row.status in ('pending', 'in_approval'):
- locked_before_pending_write.append(any('FOR UPDATE' in sql for sql in selects))
- event.listen(db, 'do_orm_execute', capture)
- event.listen(db, 'before_flush', before_flush)
- if kind == 'pl_recharge':
- recharge.submit_apply(db, 't1', user('applicant'), row_id)
- else:
- bx_service(kind).submit(db, row_id, user('applicant'))
- # A read-only lookup may precede the definition lock. The authoritative
- # business-row read must still lock and refresh before the pending write.
- assert locked_before_pending_write and all(locked_before_pending_write)
- def test_recharge_duplicate_terminal_approval_refreshes_cached_state(db, live):
- from fastapi import HTTPException
- from sqlalchemy import update
- from app.services import pl_vehicle_recharge_approval_service as service
- user, Notice, _ = live
- row = seed(db, 'pl_recharge')
- db.commit()
- assert row.current_node == 'n1'
- db.execute(update(type(row)).where(type(row).id == row.id).values(
- status='rejected', current_node=None
- ).execution_options(synchronize_session=False))
- assert row.status == 'in_approval'
- with pytest.raises(HTTPException) as exc:
- service.approve_apply(db, 't1', user('u1'), row.id)
- assert exc.value.status_code == 400
- assert row.status == 'rejected'
- assert db.query(PlVehicleRechargeApproval).count() == 0
- assert db.query(Notice).count() == 0
- @pytest.mark.parametrize('kind', ['bx_advance', 'bx_reimburse', 'pl_recharge'])
- def test_pending_uses_web_approval_instead_of_nonactionable_mobile_detail(db, kind):
- seed(db, kind)
- assert snapshot(db, kind).page == ''
|