| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575 |
- import asyncio
- from datetime import date, datetime, timedelta
- from decimal import Decimal
- from types import SimpleNamespace
- import pytest
- from fastapi import HTTPException
- from fastapi.encoders import jsonable_encoder
- from pydantic import ValidationError
- from sqlalchemy import create_engine, event
- from sqlalchemy.orm import sessionmaker
- from starlette.responses import JSONResponse
- from app.models.base import Base
- from app.models.models import (
- BizAttachment,
- BizCellSampleType,
- BizCollectionProtocol,
- BizCollectionRegionStaff,
- BizCollectionHospital,
- BizCollectionRegion,
- BizItemUnit,
- BizProtocolAudit,
- BizProtocolCollectionInfo,
- BizProtocolFullmoonTodo,
- BizProtocolPayment,
- BizProtocolPaymentDetail,
- BizProtocolPartyCustomer,
- BizProtocolPaymentSchedule,
- BizProtocolPaymentTransfer,
- BizProtocolSample,
- BizProtocolSamplePayment,
- BizProtocolSampleTransport,
- BizProtocolStorageCert,
- BizPresignOrder,
- BizPresignTransfer,
- BizSampleFreight,
- BizSampleFreightItem,
- BizSampleReception,
- SysDictData,
- SysPost,
- SysPostRole,
- SysRole,
- SysRoleDataScopeDetail,
- SysUser,
- SysUserPost,
- SysUserRole,
- )
- def _session():
- engine = create_engine('sqlite:///:memory:')
- Base.metadata.create_all(
- engine,
- tables=[
- BizCollectionProtocol.__table__,
- BizProtocolCollectionInfo.__table__,
- BizProtocolPartyCustomer.__table__,
- BizProtocolSamplePayment.__table__,
- BizProtocolPaymentSchedule.__table__,
- BizProtocolPaymentTransfer.__table__,
- BizProtocolPayment.__table__,
- BizProtocolPaymentDetail.__table__,
- BizProtocolSample.__table__,
- BizProtocolSampleTransport.__table__,
- BizProtocolAudit.__table__,
- BizProtocolStorageCert.__table__,
- BizProtocolFullmoonTodo.__table__,
- BizPresignOrder.__table__,
- BizPresignTransfer.__table__,
- BizSampleFreight.__table__,
- BizSampleFreightItem.__table__,
- BizSampleReception.__table__,
- BizCellSampleType.__table__,
- BizAttachment.__table__,
- BizCollectionRegionStaff.__table__,
- BizCollectionRegion.__table__,
- BizCollectionHospital.__table__,
- BizItemUnit.__table__,
- SysDictData.__table__,
- SysUser.__table__,
- SysRole.__table__,
- SysRoleDataScopeDetail.__table__,
- SysUserRole.__table__,
- SysPost.__table__,
- SysPostRole.__table__,
- SysUserPost.__table__,
- ],
- )
- return sessionmaker(bind=engine)()
- def _ctx(scope_kind='specialist', user_id='u1'):
- from app.services.market_field_scope import MarketFieldContext
- return MarketFieldContext('tenant-a', user_id, scope_kind)
- def _user(user_id='u1'):
- return {
- 'user_id': user_id,
- 'tenant_no': 'tenant-a',
- 'role': 'super_admin',
- 'region_id': 'dept-from-auth',
- }
- def _payment_tree(
- db,
- key,
- *,
- sales='u1',
- manager='manager-a',
- region='region-a',
- tenant='tenant-a',
- protocol_status='sxz',
- payment_model_version=2,
- due_date=None,
- due_amount=100,
- schedule_status='open',
- deleted=0,
- transfer_statuses=(),
- transfer_dept='',
- register_date='20261001',
- storage_years=20,
- sample_price=80,
- sample_original_price=100,
- ):
- protocol = BizCollectionProtocol(
- id=f'p-{key}',
- protocol_no=f'P-{key}',
- protocol_status=protocol_status,
- payment_model_version=payment_model_version,
- register_date=register_date,
- tenant_no=tenant,
- create_dept_id='dept-a',
- create_time=datetime.now(),
- del_flag=deleted,
- )
- collection = BizProtocolCollectionInfo(
- id=f'ci-{key}',
- collection_protocol_id=protocol.id,
- region_id=region,
- sales_user_id=sales,
- region_manager_user_id=manager,
- tenant_no=tenant,
- create_dept_id='dept-a',
- del_flag=deleted,
- )
- party = BizProtocolPartyCustomer(
- id=f'party-{key}',
- collection_protocol_id=protocol.id,
- mother_name=f'客户-{key}',
- tenant_no=tenant,
- create_dept_id='dept-a',
- del_flag=deleted,
- )
- payment = BizProtocolSamplePayment(
- id=f'sp-{key}',
- collection_protocol_id=protocol.id,
- sample_code=f'S-{key}',
- pay_mode='fq',
- contract_amount=due_amount,
- tenant_no=tenant,
- create_dept_id='dept-a',
- create_time=datetime.now(),
- del_flag=deleted,
- )
- schedule = BizProtocolPaymentSchedule(
- id=f'sch-{key}',
- sample_payment_id=payment.id,
- collection_protocol_id=protocol.id,
- sample_code=payment.sample_code,
- schedule_no=1,
- due_amount=due_amount,
- due_date=due_date or date.today().strftime('%Y%m%d'),
- schedule_status=schedule_status,
- tenant_no=tenant,
- create_dept_id='dept-a',
- del_flag=deleted,
- )
- sample_type = BizCellSampleType(
- id=f'type-{key}',
- type_code=f'TYPE-{key}',
- type_name=f'样本类型-{key}',
- tenant_no=tenant,
- create_dept_id='dept-a',
- del_flag=deleted,
- )
- sample = BizProtocolSample(
- id=f'sample-{key}',
- collection_protocol_id=protocol.id,
- sample_type_id=sample_type.id,
- sample_code=payment.sample_code,
- storage_years=storage_years,
- price=sample_price,
- original_price=sample_original_price,
- tenant_no=tenant,
- create_dept_id='dept-a',
- del_flag=deleted,
- )
- db.add_all([protocol, collection, party, payment, schedule, sample_type, sample])
- for index, status in enumerate(transfer_statuses):
- db.add(
- BizProtocolPaymentTransfer(
- id=f't-{key}-{index}',
- schedule_id=schedule.id,
- sample_payment_id=payment.id,
- collection_protocol_id=protocol.id,
- sample_code=payment.sample_code,
- transfer_amount=20,
- transfer_time=datetime.now(),
- transfer_type='bank',
- pay_status=status,
- tenant_no=tenant,
- create_dept_id=transfer_dept,
- del_flag=deleted,
- )
- )
- db.commit()
- return protocol, payment, schedule
- def test_chase_is_overdue_union_next_30_days_with_parent_scope():
- from app.services.market_payment import list_payment_chase
- db = _session()
- today = date(2026, 10, 11)
- _payment_tree(db, 'overdue', due_date=(today - timedelta(days=1)).strftime('%Y%m%d'))
- _payment_tree(db, 'today', due_date=today.strftime('%Y%m%d'))
- _payment_tree(db, 'day30', due_date=(today + timedelta(days=30)).strftime('%Y%m%d'))
- _payment_tree(db, 'day31', due_date=(today + timedelta(days=31)).strftime('%Y%m%d'))
- _payment_tree(db, 'other', sales='u2', due_date=today.strftime('%Y%m%d'))
- _payment_tree(db, 'void', protocol_status='yzf', due_date=today.strftime('%Y%m%d'))
- _payment_tree(db, 'bad-date', due_date='not-a-date')
- items, total = list_payment_chase(
- db, ctx=_ctx(), user_info=_user(), today=today
- )
- assert total == 4
- assert {item['scheduleId'] for item in items} == {
- 'sch-overdue',
- 'sch-today',
- 'sch-day30',
- 'sch-void',
- }
- def test_payment_chase_uses_historical_nickname_without_user_no_fallback():
- from app.services.market_payment import list_payment_chase
- db = _session()
- db.add_all(
- [
- SysUser(id='u1', user_no='deleted-sales-no', nick_name='历史删除专员', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1),
- SysUser(id='blank-sales', user_no='raw-sales-no', nick_name='', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=1),
- ]
- )
- _payment_tree(db, 'historical-sales', sales='u1')
- _payment_tree(db, 'blank-sales', sales='blank-sales')
- historical_items, historical_total = list_payment_chase(
- db, ctx=_ctx(user_id='u1'), user_info=_user('u1')
- )
- blank_items, blank_total = list_payment_chase(
- db,
- ctx=_ctx(user_id='blank-sales'),
- user_info=_user('blank-sales'),
- )
- assert historical_total == 1
- assert historical_items[0]['salesUserName'] == '历史删除专员'
- assert blank_total == 1
- assert blank_items[0]['salesUserName'] == ''
- def test_payment_list_filters_progress_from_schedule_status_and_due_date_range():
- from app.services.market_payment import list_payments
- db = _session()
- _payment_tree(db, 'unpaid', due_date='20261010', due_amount=100)
- _payment_tree(
- db,
- 'partial',
- due_date='20261011',
- due_amount=100,
- transfer_statuses=('yjf',),
- )
- _payment_tree(
- db,
- 'paid',
- due_date='20261012',
- due_amount=20,
- schedule_status='cleared',
- transfer_statuses=('yjf',),
- )
- _payment_tree(db, 'outside', due_date='20261101', due_amount=100)
- partial, partial_total = list_payments(
- db,
- ctx=_ctx(),
- user_info=_user(),
- progress_status='bfyj',
- installment_due_date_start='2026-10-01',
- installment_due_date_end='2026-10-31',
- )
- paid, paid_total = list_payments(
- db,
- ctx=_ctx(),
- user_info=_user(),
- progress_status='yjq',
- installment_due_date_start='2026-10-01',
- installment_due_date_end='2026-10-31',
- )
- assert partial_total == 1
- assert [item['id'] for item in partial] == ['sp-partial']
- assert paid_total == 1
- assert [item['id'] for item in paid] == ['sp-paid']
- def test_payment_progress_infers_cleared_when_confirmed_paid_covers_due():
- from app.services.market_payment import list_payments
- db = _session()
- _payment_tree(
- db,
- 'amount-covered-but-open',
- due_amount=20,
- schedule_status='open',
- transfer_statuses=('yjf',),
- )
- _payment_tree(
- db,
- 'status-cleared-without-transfer',
- due_amount=100,
- schedule_status='cleared',
- )
- _payment_tree(
- db,
- 'partial-open',
- due_amount=100,
- schedule_status='open',
- transfer_statuses=('yjf',),
- )
- partial, partial_total = list_payments(
- db, ctx=_ctx(), user_info=_user(), progress_status='bfyj'
- )
- cleared, cleared_total = list_payments(
- db, ctx=_ctx(), user_info=_user(), progress_status='yjq'
- )
- assert partial_total == 1
- assert partial[0]['id'] == 'sp-partial-open'
- assert partial[0]['paymentProgress'] == '部分已缴'
- assert partial[0]['clearedScheduleCount'] == 0
- assert cleared_total == 2
- assert {item['id'] for item in cleared} == {
- 'sp-amount-covered-but-open',
- 'sp-status-cleared-without-transfer',
- }
- covered = next(item for item in cleared if item['id'] == 'sp-amount-covered-but-open')
- assert covered['paymentProgress'] == '已缴清'
- assert covered['clearedScheduleCount'] == 1
- assert covered['schedules'][0]['scheduleStatus'] == 'cleared'
- def test_progress_filter_pages_in_sql_before_loading_children(monkeypatch):
- from app.services import market_payment
- db = _session()
- for key in ('partial-a', 'partial-b', 'partial-c'):
- _payment_tree(
- db,
- key,
- due_amount=100,
- transfer_statuses=('yjf',),
- )
- calls = []
- original = market_payment._load_children
- def tracked_load_children(db_arg, payment_ids, tenant_no):
- calls.append(list(payment_ids))
- return original(db_arg, payment_ids, tenant_no)
- monkeypatch.setattr(market_payment, '_load_children', tracked_load_children)
- items, total = market_payment.list_payments(
- db,
- ctx=_ctx(),
- user_info=_user(),
- progress_status='bfyj',
- page=2,
- page_size=1,
- )
- assert total == 3
- assert len(items) == 1
- assert calls == [[items[0]['id']]]
- def test_progress_counts_confirmed_transfers_from_void_schedules_like_web():
- from app.services.market_payment import list_payments
- db = _session()
- _payment_tree(db, 'void-unpaid', schedule_status='void', due_amount=100)
- _payment_tree(
- db,
- 'void-confirmed',
- schedule_status='void',
- due_amount=20,
- transfer_statuses=('yjf',),
- )
- unpaid, unpaid_total = list_payments(
- db,
- ctx=_ctx(),
- user_info=_user(),
- progress_status='dfj',
- )
- partial, partial_total = list_payments(
- db,
- ctx=_ctx(),
- user_info=_user(),
- progress_status='bfyj',
- )
- cleared, cleared_total = list_payments(
- db,
- ctx=_ctx(),
- user_info=_user(),
- progress_status='yjq',
- )
- assert unpaid_total == 1
- assert [item['id'] for item in unpaid] == ['sp-void-unpaid']
- assert unpaid[0]['progressStatus'] == 'dfj'
- assert unpaid[0]['paymentProgress'] == '待缴费'
- assert unpaid[0]['paidAmount'] == '0.00'
- assert unpaid[0]['activeScheduleCount'] == 0
- assert partial_total == 1
- assert [item['id'] for item in partial] == ['sp-void-confirmed']
- assert partial[0]['progressStatus'] == 'bfyj'
- assert partial[0]['paymentProgress'] == '部分已缴'
- assert partial[0]['paidAmount'] == '20.00'
- assert partial[0]['activeScheduleCount'] == 0
- assert cleared_total == 0
- assert cleared == []
- def test_chase_counts_only_yjf_and_excludes_fully_confirmed():
- from app.services.market_payment import list_payment_chase
- db = _session()
- _payment_tree(
- db,
- 'part-paid',
- due_amount=100,
- transfer_statuses=('dfj', 'jfqrz', 'yjf'),
- )
- _payment_tree(
- db,
- 'fully-paid',
- due_amount=60,
- transfer_statuses=('yjf', 'yjf', 'yjf'),
- )
- items, total = list_payment_chase(db, ctx=_ctx(), user_info=_user())
- assert total == 1
- assert items[0]['scheduleId'] == 'sch-part-paid'
- assert items[0]['paidAmount'] == '20.00'
- assert items[0]['arrearsAmount'] == '80.00'
- def test_chase_bound_payment_detail_shows_only_requested_schedule():
- from app.services.market_payment import get_payment_chase_detail
- db = _session()
- _, payment, first_schedule = _payment_tree(
- db,
- 'chase-detail',
- due_amount=100,
- transfer_statuses=('yjf',),
- )
- second_schedule = BizProtocolPaymentSchedule(
- id='sch-chase-detail-2',
- sample_payment_id=payment.id,
- collection_protocol_id='p-chase-detail',
- sample_code=payment.sample_code,
- schedule_no=2,
- due_amount=50,
- due_date=date.today().strftime('%Y%m%d'),
- schedule_status='cleared',
- tenant_no='tenant-a',
- create_dept_id='dept-a',
- del_flag=0,
- )
- second_transfer = BizProtocolPaymentTransfer(
- id='t-chase-detail-2',
- schedule_id=second_schedule.id,
- sample_payment_id=payment.id,
- collection_protocol_id='p-chase-detail',
- sample_code=payment.sample_code,
- transfer_amount=50,
- transfer_time=datetime.now(),
- transfer_type='bank',
- pay_status='yjf',
- tenant_no='tenant-a',
- create_dept_id='dept-a',
- del_flag=0,
- )
- db.add_all([second_schedule, second_transfer])
- db.commit()
- detail = get_payment_chase_detail(
- db,
- schedule_id=first_schedule.id,
- view='payment',
- ctx=_ctx(),
- user_info=_user(),
- )
- assert detail['chaseView'] == 'payment'
- assert detail['targetScheduleId'] == first_schedule.id
- assert [row['id'] for row in detail['schedules']] == [first_schedule.id]
- assert detail['receivableAmount'] == '100.00'
- assert detail['paidAmount'] == '20.00'
- assert detail['dueRemainAmount'] == '80.00'
- assert detail['remainAmount'] == '80.00'
- assert detail['clearedScheduleCount'] == 0
- assert detail['activeScheduleCount'] == 1
- assert detail['progressStatus'] == 'bfyj'
- assert detail['paymentProgress'] == '部分已缴'
- assert [
- period['scheduleId']
- for period in detail['periods']
- if period is not None
- ] == [first_schedule.id]
- assert detail['allowedActions'] == []
- def test_chase_bound_protocol_detail_is_read_only(monkeypatch):
- from app.services.market_payment import get_payment_chase_detail
- from app.services import market_protocol
- db = _session()
- _, _, schedule = _payment_tree(db, 'chase-protocol')
- resolved_parents = []
- def fake_cell_detail(db_arg, protocol, tenant_no):
- resolved_parents.append((db_arg, protocol.id, tenant_no))
- return {
- 'source': 'cell_storage',
- 'basic': {'protocolNo': protocol.protocol_no},
- 'collection': None,
- 'customer': None,
- 'samples': [],
- 'payment': None,
- 'paymentModelVersion': protocol.payment_model_version,
- 'samplePayments': [],
- 'paymentDetails': [],
- 'depositTransfers': [],
- 'presignDepositHint': None,
- 'storageCert': None,
- 'fullmoonTodo': None,
- 'audits': [],
- 'attachments': {},
- }
- monkeypatch.setattr(market_protocol, '_cell_detail', fake_cell_detail)
- detail = get_payment_chase_detail(
- db,
- schedule_id=schedule.id,
- view='protocol',
- ctx=_ctx(),
- user_info=_user(),
- )
- assert detail['chaseView'] == 'protocol'
- assert detail['targetScheduleId'] == schedule.id
- assert detail['source'] == 'cell_storage'
- assert detail['basic']['protocolNo'] == 'P-chase-protocol'
- assert detail['allowedActions'] == []
- assert resolved_parents == [(db, 'p-chase-protocol', 'tenant-a')]
- @pytest.mark.parametrize(
- ('key', 'tree_kwargs', 'ctx', 'user'),
- [
- ('outside-scope', {'sales': 'u2'}, _ctx(), _user()),
- ('cross-tenant', {'tenant': 'tenant-b'}, _ctx(), _user()),
- ('void-schedule', {'schedule_status': 'void'}, _ctx(), _user()),
- ('invalid-due-date', {'due_date': '20260230'}, _ctx(), _user()),
- (
- 'outside-chase-window',
- {'due_date': (date.today() + timedelta(days=31)).strftime('%Y%m%d')},
- _ctx(),
- _user(),
- ),
- ('no-arrears', {'transfer_statuses': ('yjf',) * 5}, _ctx(), _user()),
- ],
- )
- def test_chase_bound_detail_rejects_guessed_non_candidate_schedule(
- key, tree_kwargs, ctx, user
- ):
- from app.services.market_payment import get_payment_chase_detail
- db = _session()
- _, _, schedule = _payment_tree(db, key, **tree_kwargs)
- with pytest.raises(HTTPException) as exc:
- get_payment_chase_detail(
- db,
- schedule_id=schedule.id,
- view='payment',
- ctx=ctx,
- user_info=user,
- )
- assert exc.value.status_code == 404
- def test_chase_filters_counts_orders_and_pages_in_sql_before_materialization():
- from app.services.market_payment import list_payment_chase
- db = _session()
- today = date(2026, 10, 11)
- for index in range(35):
- _payment_tree(
- db,
- f'history-{index:02d}',
- due_date=(today - timedelta(days=index + 1)).strftime('%Y%m%d'),
- transfer_statuses=('dfj',),
- )
- _payment_tree(db, 'invalid-day', due_date='20260230')
- statements = []
- def capture(_conn, _cursor, statement, _parameters, _context, _many):
- statements.append(statement)
- event.listen(db.get_bind(), 'before_cursor_execute', capture)
- try:
- items, total = list_payment_chase(
- db,
- ctx=_ctx(),
- user_info=_user(),
- today=today,
- page=3,
- page_size=5,
- )
- finally:
- event.remove(db.get_bind(), 'before_cursor_execute', capture)
- assert total == 35
- assert len(items) == 5
- assert [item['scheduleId'] for item in items] == [
- 'sch-history-24',
- 'sch-history-23',
- 'sch-history-22',
- 'sch-history-21',
- 'sch-history-20',
- ]
- chase_selects = [sql for sql in statements if 'biz_protocol_payment_schedule' in sql]
- assert len(chase_selects) == 2
- assert ' LIMIT ' in chase_selects[-1].upper()
- assert 'SUM(CASE' in chase_selects[-1].upper()
- assert not any(
- 'FROM biz_protocol_payment_transfer' in sql and 'SUM(CASE' not in sql.upper()
- for sql in statements
- )
- def test_payment_list_includes_legacy_models_and_uses_parent_scope_not_transfer_department():
- from app.services.market_payment import list_payments
- db = _session()
- _payment_tree(db, 'v2', transfer_statuses=('dfj',), transfer_dept='')
- _payment_tree(db, 'v1', payment_model_version=1)
- _payment_tree(db, 'other', sales='u2')
- _payment_tree(db, 'cross', tenant='tenant-b')
- _payment_tree(db, 'deleted', deleted=1)
- items, total = list_payments(db, ctx=_ctx(), user_info=_user())
- assert total == 2
- assert {item['id'] for item in items} == {'sp-v2', 'sp-v1'}
- assert {item['paymentModelVersion'] for item in items} == {1, 2}
- def test_manager_scope_is_fixed_by_route_context_and_client_filter_cannot_expand():
- from app.services.market_payment import list_payment_chase
- db = _session()
- _payment_tree(db, 'manager', manager='manager-a', sales='u2')
- _payment_tree(db, 'outside', manager='manager-b', sales='u1')
- items, total = list_payment_chase(
- db,
- ctx=_ctx('manager', 'manager-a'),
- user_info=_user('manager-a'),
- sales_user_id='u1',
- )
- assert total == 0
- assert items == []
- def test_payment_detail_rejects_out_of_scope_sample():
- from app.services.market_payment import get_payment_detail
- db = _session()
- _payment_tree(db, 'other', sales='u2')
- with pytest.raises(HTTPException) as exc:
- get_payment_detail(
- db, payment_id='sp-other', ctx=_ctx(), user_info=_user()
- )
- assert exc.value.status_code == 404
- def test_payment_detail_reads_legacy_model_like_web():
- from app.services.market_payment import get_payment_detail
- db = _session()
- _payment_tree(db, 'legacy', payment_model_version=1)
- detail = get_payment_detail(
- db, payment_id='sp-legacy', ctx=_ctx(), user_info=_user()
- )
- assert detail['id'] == 'sp-legacy'
- assert detail['paymentModelVersion'] == 1
- def test_market_can_save_submit_and_withdraw_transfer_atomically():
- from app.services.market_payment import (
- create_transfer,
- submit_transfer,
- update_transfer,
- withdraw_transfer,
- )
- db = _session()
- _, _, schedule = _payment_tree(db, 'own')
- db.add(
- BizAttachment(
- id='att-pay',
- business_type='market:specialist:payment:staging',
- original_name='a.jpg',
- file_ext='jpg',
- file_url='a.jpg',
- file_size=10,
- file_type='image/jpeg',
- tenant_no='tenant-a',
- create_by='u1',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- created = create_transfer(
- db,
- schedule_id=schedule.id,
- fields={
- 'transferAmount': 50,
- 'transferTime': '2026-10-11 10:00:00',
- 'transferType': 'bank',
- 'transferAttachments': 'a.jpg',
- },
- ctx=_ctx(),
- user_info=_user(),
- )
- assert created['payStatus'] == 'dfj'
- row = db.get(BizProtocolPaymentTransfer, created['id'])
- assert row.create_dept_id == 'dept-from-auth'
- attachment = db.get(BizAttachment, 'att-pay')
- assert (attachment.business_type, attachment.business_id) == (
- 'market:specialist:payment:bound',
- created['id'],
- )
- updated = update_transfer(
- db,
- transfer_id=created['id'],
- fields={'remark': '补充说明'},
- ctx=_ctx(),
- user_info=_user(),
- )
- assert updated['remark'] == '补充说明'
- assert submit_transfer(
- db, transfer_id=created['id'], ctx=_ctx(), user_info=_user()
- )['payStatus'] == 'jfqrz'
- assert withdraw_transfer(
- db, transfer_id=created['id'], ctx=_ctx(), user_info=_user()
- )['payStatus'] == 'dfj'
- @pytest.mark.parametrize('operation', ['confirm', 'reject', 'withdraw'])
- def test_web_finance_transitions_lock_schedule_before_transfer(
- monkeypatch, operation
- ):
- from sqlalchemy.orm import Query
- from app.crud import protocol_sample_payment as payment_crud
- db = _session()
- _payment_tree(db, f'finance-lock-{operation}', transfer_statuses=('jfqrz',))
- transfer_id = f't-finance-lock-{operation}-0'
- locked_entities = []
- original = Query.with_for_update
- def record_lock(query, *args, **kwargs):
- entity = query.column_descriptions[0].get('entity')
- if entity in {BizProtocolPaymentSchedule, BizProtocolPaymentTransfer}:
- locked_entities.append(entity)
- return original(query, *args, **kwargs)
- monkeypatch.setattr(Query, 'with_for_update', record_lock)
- function = {
- 'confirm': payment_crud.confirm_receipt,
- 'reject': payment_crud.reject_receipt,
- 'withdraw': payment_crud.withdraw_receipt,
- }[operation]
- function(
- db,
- transfer_id=transfer_id,
- tenant_no='tenant-a',
- user_id='finance-user',
- )
- assert locked_entities == [
- BizProtocolPaymentSchedule,
- BizProtocolPaymentTransfer,
- ]
- @pytest.mark.parametrize(
- ('operation', 'initial_status'),
- [
- ('update', 'dfj'),
- ('submit', 'dfj'),
- ('withdraw', 'jfqrz'),
- ('confirm', 'jfqrz'),
- ('reject', 'jfqrz'),
- ('cross-tenant', 'dfj'),
- ],
- )
- def test_web_payment_transfer_writes_reject_out_of_scope_before_commit(
- operation, initial_status
- ):
- from app.api import protocol_sample_payment as payment_api
- db = _session()
- engine = db.get_bind()
- protocol, _, _ = _payment_tree(
- db,
- f'web-scope-{operation}',
- tenant='tenant-b' if operation == 'cross-tenant' else 'tenant-a',
- transfer_statuses=(initial_status,),
- )
- protocol.create_by = 'owner-user'
- db.add_all(
- [
- protocol,
- SysUser(
- id='denied-user',
- dept_id='denied-dept',
- user_no='denied-user',
- nick_name='越权用户',
- email='',
- phone_no='',
- password='',
- status='1',
- tenant_no='tenant-a',
- create_dept_id='denied-dept',
- del_flag=0,
- ),
- SysRole(
- id='self-only-role',
- role_name='仅本人',
- data_scope='4',
- status='1',
- tenant_no='tenant-a',
- create_dept_id='denied-dept',
- del_flag=0,
- ),
- SysUserRole(
- id='denied-user-role',
- user_id='denied-user',
- role_id='self-only-role',
- tenant_no='tenant-a',
- create_dept_id='denied-dept',
- del_flag=0,
- ),
- ]
- )
- db.commit()
- transfer_id = f't-web-scope-{operation}-0'
- user = {
- 'user_id': 'denied-user',
- 'role': 'admin',
- 'dept_id': 'denied-dept',
- }
- if operation in {'update', 'cross-tenant'}:
- response = asyncio.run(
- payment_api.update_payment_transfer(
- transfer_id,
- payment_api.TransferDraftBody(transferAmount=99),
- user=user,
- db=db,
- )
- )
- elif operation == 'submit':
- response = asyncio.run(
- payment_api.submit_payment_transfer_finance(
- transfer_id,
- payment_api.TransferDraftBody(transferAmount=99),
- user=user,
- db=db,
- )
- )
- elif operation == 'withdraw':
- response = asyncio.run(
- payment_api.withdraw_payment_transfer(
- transfer_id,
- user=user,
- db=db,
- )
- )
- elif operation == 'confirm':
- response = asyncio.run(
- payment_api.confirm_payment_transfer(
- transfer_id,
- payment_api.FinanceConfirmBody(financeRemark='must-not-write'),
- user=user,
- db=db,
- )
- )
- else:
- response = asyncio.run(
- payment_api.reject_payment_transfer(
- transfer_id,
- user=user,
- db=db,
- )
- )
- assert response['code'] == 403
- db.close()
- verify_db = sessionmaker(bind=engine)()
- persisted = verify_db.get(BizProtocolPaymentTransfer, transfer_id)
- assert persisted.pay_status == initial_status
- assert persisted.transfer_amount == Decimal('20')
- assert persisted.finance_remark is None
- verify_db.close()
- def test_market_withdraw_locks_schedule_before_transfer(monkeypatch):
- from sqlalchemy.orm import Query
- from app.services.market_payment import withdraw_transfer
- db = _session()
- _payment_tree(db, 'market-withdraw-lock', transfer_statuses=('jfqrz',))
- locked_entities = []
- original = Query.with_for_update
- def record_lock(query, *args, **kwargs):
- entity = query.column_descriptions[0].get('entity')
- if entity in {BizProtocolPaymentSchedule, BizProtocolPaymentTransfer}:
- locked_entities.append(entity)
- return original(query, *args, **kwargs)
- monkeypatch.setattr(Query, 'with_for_update', record_lock)
- withdraw_transfer(
- db,
- transfer_id='t-market-withdraw-lock-0',
- ctx=_ctx(),
- user_info=_user(),
- )
- assert locked_entities == [
- BizProtocolPaymentSchedule,
- BizProtocolPaymentTransfer,
- ]
- def test_market_can_soft_delete_only_scoped_draft_transfer():
- from app.services.market_payment import delete_transfer
- db = _session()
- _payment_tree(db, 'draft-delete', transfer_statuses=('dfj',))
- _payment_tree(db, 'pending-delete', transfer_statuses=('jfqrz',))
- _payment_tree(db, 'outside-delete', sales='u2', transfer_statuses=('dfj',))
- result = delete_transfer(
- db,
- transfer_id='t-draft-delete-0',
- ctx=_ctx(),
- user_info=_user(),
- )
- assert result == {'id': 't-draft-delete-0'}
- assert db.get(BizProtocolPaymentTransfer, 't-draft-delete-0').del_flag == 1
- with pytest.raises(ValueError, match='状态'):
- delete_transfer(
- db,
- transfer_id='t-pending-delete-0',
- ctx=_ctx(),
- user_info=_user(),
- )
- with pytest.raises(HTTPException):
- delete_transfer(
- db,
- transfer_id='t-outside-delete-0',
- ctx=_ctx(),
- user_info=_user(),
- )
- def test_transfer_create_rejects_external_attachment_reference():
- from app.services.market_payment import create_transfer
- db = _session()
- _, _, schedule = _payment_tree(db, 'attachment-reject')
- with pytest.raises(ValueError, match='附件'):
- create_transfer(
- db,
- schedule_id=schedule.id,
- fields={
- 'transferAmount': 10,
- 'transferType': 'bank',
- 'transferAttachments': 'https://evil.test/a.jpg',
- },
- ctx=_ctx(),
- user_info=_user(),
- )
- def test_transfer_create_rollback_restores_staging_attachment_on_business_failure(
- monkeypatch,
- ):
- from app.services.market_payment import create_transfer
- db = _session()
- _, _, schedule = _payment_tree(db, 'attachment-rollback')
- attachment = BizAttachment(
- id='att-rollback',
- business_type='market:specialist:payment:staging',
- original_name='rollback.jpg',
- file_ext='jpg',
- file_url='rollback.jpg',
- file_size=10,
- file_type='image/jpeg',
- tenant_no='tenant-a',
- create_by='u1',
- create_dept_id='',
- del_flag=0,
- )
- db.add(attachment)
- db.commit()
- monkeypatch.setattr(
- db, 'commit', lambda: (_ for _ in ()).throw(RuntimeError('db down'))
- )
- with pytest.raises(RuntimeError, match='db down'):
- create_transfer(
- db,
- schedule_id=schedule.id,
- fields={
- 'transferAmount': 10,
- 'transferType': 'bank',
- 'transferAttachments': 'att-rollback',
- },
- ctx=_ctx(),
- user_info=_user(),
- )
- db.expire_all()
- restored = db.get(BizAttachment, 'att-rollback')
- assert (restored.business_type, restored.business_id) == (
- 'market:specialist:payment:staging',
- None,
- )
- assert db.query(BizProtocolPaymentTransfer).count() == 0
- def test_out_of_scope_write_is_checked_before_any_mutation():
- from app.services.market_payment import update_transfer
- db = _session()
- _payment_tree(db, 'other', sales='u2', transfer_statuses=('dfj',))
- before = db.get(BizProtocolPaymentTransfer, 't-other-0').remark
- with pytest.raises(HTTPException):
- update_transfer(
- db,
- transfer_id='t-other-0',
- fields={'remark': 'must-not-write'},
- ctx=_ctx(),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizProtocolPaymentTransfer, 't-other-0').remark == before
- def test_transfer_create_rejects_schedule_with_mismatched_payment_parent():
- from app.services.market_payment import create_transfer
- db = _session()
- _, _, schedule = _payment_tree(db, 'create-parent-a')
- _, other_payment, _ = _payment_tree(db, 'create-parent-b')
- schedule.sample_payment_id = other_payment.id
- db.add(schedule)
- db.commit()
- before = db.query(BizProtocolPaymentTransfer).count()
- with pytest.raises(HTTPException) as exc:
- create_transfer(
- db,
- schedule_id=schedule.id,
- fields={'transferAmount': 10, 'transferType': 'bank'},
- ctx=_ctx(),
- user_info=_user(),
- )
- assert exc.value.status_code == 404
- assert db.query(BizProtocolPaymentTransfer).count() == before
- @pytest.mark.parametrize(
- ('parent_field', 'other_parent_id'),
- [
- ('schedule_id', 'sch-transfer-parent-b'),
- ('sample_payment_id', 'sp-transfer-parent-b'),
- ],
- )
- def test_transfer_mutation_rejects_mismatched_parent_links(
- parent_field, other_parent_id
- ):
- from app.services.market_payment import update_transfer
- db = _session()
- _payment_tree(db, 'transfer-parent-a', transfer_statuses=('dfj',))
- _payment_tree(db, 'transfer-parent-b')
- transfer = db.get(BizProtocolPaymentTransfer, 't-transfer-parent-a-0')
- setattr(transfer, parent_field, other_parent_id)
- db.add(transfer)
- db.commit()
- with pytest.raises(HTTPException) as exc:
- update_transfer(
- db,
- transfer_id=transfer.id,
- fields={'remark': 'must-not-write'},
- ctx=_ctx(),
- user_info=_user(),
- )
- assert exc.value.status_code == 404
- db.expire_all()
- assert db.get(BizProtocolPaymentTransfer, transfer.id).remark is None
- @pytest.mark.parametrize('protocol_status', ['yzf', 'sdfq', 'zky'])
- def test_terminal_protocol_does_not_block_transfer_create_when_schedule_has_arrears(protocol_status):
- from app.services.market_payment import create_transfer
- db = _session()
- _, _, schedule = _payment_tree(
- db, f'terminal-create-{protocol_status}', protocol_status=protocol_status
- )
- created = create_transfer(
- db,
- schedule_id=schedule.id,
- fields={'transferAmount': 10, 'transferType': 'bank'},
- ctx=_ctx(),
- user_info=_user(),
- )
- assert created['scheduleId'] == schedule.id
- def test_cleared_schedule_with_arrears_can_create_transfer_like_web():
- from app.services.market_payment import create_transfer
- db = _session()
- _, _, schedule = _payment_tree(db, 'cleared-create', schedule_status='cleared')
- created = create_transfer(
- db,
- schedule_id=schedule.id,
- fields={'transferAmount': 10, 'transferType': 'bank'},
- ctx=_ctx(),
- user_info=_user(),
- )
- assert created['scheduleId'] == schedule.id
- @pytest.mark.parametrize(
- ('schedule_status', 'confirmed_count'),
- [('void', 0), ('open', 5)],
- )
- def test_transfer_create_rejects_void_or_no_arrears_after_locked_recheck(
- schedule_status, confirmed_count
- ):
- from app.services.market_payment import create_transfer
- db = _session()
- _, _, schedule = _payment_tree(
- db,
- f'create-reject-{schedule_status}-{confirmed_count}',
- schedule_status=schedule_status,
- transfer_statuses=('yjf',) * confirmed_count,
- )
- before = db.query(BizProtocolPaymentTransfer).count()
- with pytest.raises((HTTPException, ValueError), match='应缴|欠缴'):
- create_transfer(
- db,
- schedule_id=schedule.id,
- fields={'transferAmount': 10, 'transferType': 'bank'},
- ctx=_ctx(),
- user_info=_user(),
- )
- assert db.query(BizProtocolPaymentTransfer).count() == before
- @pytest.mark.parametrize(
- ('protocol_status', 'schedule_status', 'pay_status', 'operation'),
- [
- ('yzf', 'open', 'dfj', 'update'),
- ('sdfq', 'open', 'dfj', 'submit'),
- ('zky', 'open', 'jfqrz', 'withdraw'),
- ('sxz', 'cleared', 'dfj', 'update'),
- ('sxz', 'void', 'dfj', 'submit'),
- ],
- )
- def test_existing_transfer_mutations_depend_on_transfer_status_not_parent_state(
- protocol_status, schedule_status, pay_status, operation
- ):
- from app.services.market_payment import submit_transfer, update_transfer, withdraw_transfer
- db = _session()
- key = f'ineligible-{protocol_status}-{schedule_status}-{operation}'
- _payment_tree(
- db,
- key,
- protocol_status=protocol_status,
- schedule_status=schedule_status,
- transfer_statuses=(pay_status,),
- )
- transfer_id = f't-{key}-0'
- if operation == 'update':
- result = update_transfer(
- db,
- transfer_id=transfer_id,
- fields={'remark': 'allowed'},
- ctx=_ctx(),
- user_info=_user(),
- )
- assert result['remark'] == 'allowed'
- elif operation == 'submit':
- result = submit_transfer(
- db, transfer_id=transfer_id, ctx=_ctx(), user_info=_user()
- )
- assert result['payStatus'] == 'jfqrz'
- else:
- result = withdraw_transfer(
- db, transfer_id=transfer_id, ctx=_ctx(), user_info=_user()
- )
- assert result['payStatus'] == 'dfj'
- def test_chase_includes_terminal_and_cleared_non_void_schedules_like_web():
- from app.services.market_payment import list_payment_chase
- db = _session()
- _payment_tree(db, 'eligible-chase')
- for status in ('yzf', 'sdfq', 'zky'):
- _payment_tree(db, f'terminal-chase-{status}', protocol_status=status)
- _payment_tree(db, 'cleared-chase', schedule_status='cleared')
- _payment_tree(db, 'void-chase', schedule_status='void')
- items, total = list_payment_chase(db, ctx=_ctx(), user_info=_user())
- assert total == 5
- assert {item['scheduleId'] for item in items} == {
- 'sch-eligible-chase',
- 'sch-terminal-chase-yzf',
- 'sch-terminal-chase-sdfq',
- 'sch-terminal-chase-zky',
- 'sch-cleared-chase',
- }
- def test_yjf_is_immutable_and_double_submit_fails_closed():
- from app.services.market_payment import submit_transfer, update_transfer, withdraw_transfer
- db = _session()
- _payment_tree(db, 'draft', transfer_statuses=('dfj',))
- submit_transfer(db, transfer_id='t-draft-0', ctx=_ctx(), user_info=_user())
- with pytest.raises(ValueError, match='状态'):
- submit_transfer(db, transfer_id='t-draft-0', ctx=_ctx(), user_info=_user())
- _payment_tree(db, 'confirmed', transfer_statuses=('yjf',))
- with pytest.raises(ValueError):
- update_transfer(
- db,
- transfer_id='t-confirmed-0',
- fields={'remark': 'no'},
- ctx=_ctx(),
- user_info=_user(),
- )
- with pytest.raises(ValueError):
- withdraw_transfer(
- db,
- transfer_id='t-confirmed-0',
- ctx=_ctx(),
- user_info=_user(),
- )
- def test_transfer_dto_forbids_authorization_status_and_finance_fields():
- from app.schemas.market_mobile import MarketPaymentTransferBody
- forbidden = (
- {'tenantNo': 'tenant-b'},
- {'scope': 'manager'},
- {'payStatus': 'yjf'},
- {'financeRemark': 'confirmed'},
- {'scheduleStatus': 'void'},
- )
- for extra in forbidden:
- with pytest.raises(ValidationError):
- MarketPaymentTransferBody.model_validate(
- {'transferAmount': 10, 'transferType': 'bank', **extra}
- )
- @pytest.mark.parametrize(
- 'amount',
- [float('inf'), float('-inf'), float('nan'), '0.001', '10000000000000000.00'],
- )
- def test_transfer_amount_rejects_non_finite_over_precision_and_overflow(amount):
- from app.schemas.market_mobile import MarketPaymentTransferBody
- with pytest.raises(ValidationError):
- MarketPaymentTransferBody.model_validate({'transferAmount': amount})
- @pytest.mark.parametrize('amount', ['0.01', '9999999999999999.99'])
- def test_transfer_amount_accepts_decimal_18_2_boundaries_exactly(amount):
- from app.schemas.market_mobile import MarketPaymentTransferBody
- body = MarketPaymentTransferBody.model_validate({'transferAmount': amount})
- assert body.transfer_amount == Decimal(amount)
- def test_task5_money_is_fixed_two_decimal_string_in_actual_json_response():
- from app.core.response import success_response
- from app.schemas.market_mobile import MarketPaymentTransferCreateBody
- from app.services.market_payment import _payment_dict, create_transfer
- db = _session()
- _, _, schedule = _payment_tree(
- db, 'money-json', due_amount=Decimal('9999999999999999.99')
- )
- body = MarketPaymentTransferCreateBody.model_validate(
- {
- 'scheduleId': schedule.id,
- 'transferAmount': '9999999999999999.99',
- 'transferType': 'bank',
- }
- )
- payload = body.model_dump(by_alias=True, exclude_unset=True)
- schedule_id = payload.pop('scheduleId')
- transfer = create_transfer(
- db,
- schedule_id=schedule_id,
- fields=payload,
- ctx=_ctx(),
- user_info=_user(),
- )
- max_money = Decimal('9999999999999999.99')
- payment = _payment_dict(
- SimpleNamespace(
- id='sp-json',
- sample_code='S-json',
- pay_mode='fq',
- contract_amount=max_money,
- remark=None,
- ),
- SimpleNamespace(id='p-json', protocol_no='P-json', protocol_status='sxz'),
- None,
- [
- SimpleNamespace(
- id='sch-json',
- schedule_no=1,
- due_amount=max_money,
- due_date='20261011',
- schedule_status='open',
- )
- ],
- {},
- )
- encoded = jsonable_encoder(
- success_response({'transfer': transfer, 'payment': payment})
- )
- response = JSONResponse(encoded)
- decoded = response.body.decode('utf-8')
- assert transfer['transferAmount'] == '9999999999999999.99'
- assert payment['contractAmount'] == '9999999999999999.99'
- assert payment['schedules'][0]['dueAmount'] == '9999999999999999.99'
- assert '"9999999999999999.99"' in decoded
- def test_task5_normal_money_is_always_serialized_with_two_decimals():
- from app.services.market_payment import list_payment_chase
- db = _session()
- _payment_tree(db, 'normal-money', due_amount=Decimal('100.00'))
- items, _ = list_payment_chase(db, ctx=_ctx(), user_info=_user())
- assert items[0]['dueAmount'] == '100.00'
- assert items[0]['paidAmount'] == '0.00'
- assert items[0]['arrearsAmount'] == '100.00'
- def test_market_routes_expose_only_allowed_payment_mutations():
- from app.api.market_mobile import router
- paths = {route.path for route in router.routes}
- for scope in ('specialist', 'manager'):
- prefix = f'/mini/market/{scope}'
- assert f'{prefix}/payment-chase' in paths
- assert f'{prefix}/payment-chase/{{schedule_id}}' in paths
- assert f'{prefix}/payment-chases' not in paths
- assert f'{prefix}/payments' in paths
- assert f'{prefix}/payment-transfers' in paths
- assert f'{prefix}/payment-transfers/{{transfer_id}}/submit' in paths
- assert f'{prefix}/payment-transfers/{{transfer_id}}/withdraw' in paths
- assert f'{prefix}/payment-transfers/{{transfer_id}}/confirm' not in paths
- assert f'{prefix}/payment-transfers/{{transfer_id}}/reject' not in paths
- assert f'{prefix}/payment-transfers/{{transfer_id}}/refund' not in paths
- assert f'{prefix}/payment-transfers/{{transfer_id}}/invoice' not in paths
- def test_chase_detail_route_accepts_chase_only_menu_and_denies_missing_menu(monkeypatch):
- from app.api.market_mobile import router
- from app.core import mobile_menu_guard
- route = next(
- item
- for item in router.routes
- if item.path == '/mini/market/specialist/payment-chase/{schedule_id}'
- )
- guard = next(
- dependency.call
- for dependency in route.dependant.dependencies
- if dependency.name == 'user'
- )
- requested = []
- monkeypatch.setattr(
- mobile_menu_guard,
- 'resolve_web_role_for_user',
- lambda *_args, **_kwargs: 'market_specialist',
- )
- monkeypatch.setattr(
- mobile_menu_guard,
- 'user_has_tenant_menu_perm',
- lambda _db, **kwargs: requested.append(kwargs['perm_code'])
- or kwargs['perm_code'] == 'mini:market:specialist:payment-chase',
- )
- assert asyncio.run(guard(user=_user(), db=object())) == _user()
- assert requested == ['mini:market:specialist:payment-chase']
- monkeypatch.setattr(
- mobile_menu_guard,
- 'user_has_tenant_menu_perm',
- lambda *_args, **_kwargs: False,
- )
- with pytest.raises(HTTPException) as exc:
- asyncio.run(guard(user=_user(), db=object()))
- assert exc.value.status_code == 403
- def test_list_queries_batch_children_without_n_plus_one():
- from app.services.market_payment import list_payments
- db = _session()
- for index in range(8):
- _payment_tree(db, f'row-{index}', transfer_statuses=('dfj', 'yjf'))
- statements = []
- def capture(_conn, _cursor, statement, _parameters, _context, _many):
- statements.append(statement)
- event.listen(db.get_bind(), 'before_cursor_execute', capture)
- try:
- items, total = list_payments(
- db, ctx=_ctx(), user_info=_user(), page=1, page_size=5
- )
- finally:
- event.remove(db.get_bind(), 'before_cursor_execute', capture)
- assert total == 8
- assert len(items) == 5
- assert len(statements) <= 5
- def test_payment_list_contains_card_summary_totals():
- from app.services.market_payment import list_payments
- db = _session()
- _payment_tree(db, 'summary', due_amount=Decimal('100.00'), transfer_statuses=('yjf',))
- transfer = db.get(BizProtocolPaymentTransfer, 't-summary-0')
- transfer.finance_remark = '财务已确认'
- db.commit()
- items, total = list_payments(db, ctx=_ctx(), user_info=_user())
- assert total == 1
- assert items[0]['paidAmount'] == '20.00'
- assert items[0]['remainAmount'] == '80.00'
- assert items[0]['activeScheduleCount'] == 1
- assert items[0]['clearedScheduleCount'] == 0
- assert items[0]['paymentProgress'] == '部分已缴'
- assert items[0]['signDate'] == '2026-10-01'
- assert items[0]['storageYears'] == 20
- assert items[0]['sampleTypeName'] == '样本类型-summary'
- assert items[0]['discountAmount'] == '20.00'
- assert items[0]['receivableAmount'] == '100.00'
- assert items[0]['dueRemainAmount'] == '80.00'
- assert len(items[0]['periods']) == 6
- assert items[0]['periods'][0]['scheduleNo'] == 1
- assert items[0]['periods'][0]['displayPaidAmount'] == 20.0
- assert items[0]['periods'][0]['payDate']
- assert items[0]['periods'][0]['transfers'][0]['financeRemark'] == '财务已确认'
- assert items[0]['schedules'][0]['displayPaidAmount'] == '20.00'
- assert items[0]['schedules'][0]['payDate']
- def test_payment_sample_meta_uses_tenant_and_sample_code_after_protocol_change():
- from app.services.market_payment import list_payments
- db = _session()
- _, payment, _ = _payment_tree(db, 'protocol-changed')
- sample = (
- db.query(BizProtocolSample)
- .filter(BizProtocolSample.sample_code == payment.sample_code)
- .one()
- )
- sample.collection_protocol_id = 'p-previous-protocol'
- db.commit()
- items, total = list_payments(db, ctx=_ctx(), user_info=_user())
- assert total == 1
- assert items[0]['storageYears'] == 20
- assert items[0]['sampleTypeName'] == '样本类型-protocol-changed'
- assert items[0]['discountAmount'] == '20.00'
|