| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330 |
- import json
- from datetime import datetime, timedelta
- from decimal import Decimal
- from unittest.mock import patch
- import pytest
- from fastapi import HTTPException
- from pydantic import ValidationError
- from sqlalchemy import create_engine, event
- from sqlalchemy.orm import Query, sessionmaker
- from app.models.base import Base
- from app.models.models import (
- BizAttachment,
- BizCellSampleType,
- BizCollectionHospital,
- BizCollectionProtocol,
- BizCollectionRegion,
- BizCollectionRegionStaff,
- BizCustomer,
- BizItemUnit,
- BizProtocolCollectionInfo,
- BizProtocolPartyCustomer,
- BizProtocolPayment,
- BizProtocolPaymentDetail,
- BizProtocolPaymentSchedule,
- BizProtocolPaymentTransfer,
- BizProtocolSamplePayment,
- BizProtocolSample,
- BizProtocolSampleTransport,
- BizPresignOrder,
- BizPresignTransfer,
- BizProtocolAudit,
- BizProtocolStorageCert,
- BizProtocolFullmoonTodo,
- BizSampleCode,
- BizSampleFreight,
- BizSampleFreightItem,
- BizSampleReception,
- SysPost,
- SysPostRole,
- SysRole,
- SysRoleDataScopeDetail,
- SysDictData,
- SysUser,
- SysUserPost,
- SysUserRole,
- )
- from app.models.research_donation import (
- BizProtocolDonationConvert,
- BizResearchDonation,
- BizResearchDonationCollection,
- BizResearchDonationParty,
- BizResearchDonationSample,
- BizResearchDonationSampleTransport,
- )
- def _session():
- engine = create_engine('sqlite:///:memory:')
- Base.metadata.create_all(engine)
- return sessionmaker(bind=engine)()
- def _ctx(scope_kind, 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 _cell(db, row_id, *, sales='u1', manager='manager-a', region='region-a', created=None):
- row = BizCollectionProtocol(
- id=row_id,
- protocol_no=f'CELL-{row_id}',
- protocol_status='dsx',
- tenant_no='tenant-a',
- create_dept_id='dept-a',
- create_time=created or datetime.now(),
- del_flag=0,
- )
- collection = BizProtocolCollectionInfo(
- id=f'ci-{row_id}',
- collection_protocol_id=row_id,
- region_id=region,
- sales_user_id=sales,
- region_manager_user_id=manager,
- tenant_no='tenant-a',
- create_dept_id='dept-a',
- del_flag=0,
- )
- db.add_all([row, collection])
- return row
- def _donation(db, row_id, *, sales='u1', manager='manager-a', region='region-a', created=None):
- row = BizResearchDonation(
- id=row_id,
- donation_no=f'DON-{row_id}',
- donation_status='dsx',
- tenant_no='tenant-a',
- create_dept_id='dept-a',
- create_time=created or datetime.now(),
- del_flag=0,
- )
- collection = BizResearchDonationCollection(
- id=f'dci-{row_id}',
- research_donation_id=row_id,
- region_id=region,
- sales_user_id=sales,
- region_manager_user_id=manager,
- tenant_no='tenant-a',
- create_dept_id='dept-a',
- del_flag=0,
- )
- db.add_all([row, collection])
- return row
- def _masters(db):
- db.add_all(
- [
- BizCollectionRegion(
- id='region-a', region_name='A区', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizCollectionRegion(
- id='region-b', region_name='B区', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizCollectionHospital(
- id='hospital-a', region_id='region-a', hospital_name='A院', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizCellSampleType(
- id='type-a', type_code='A', type_name='样本A', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizSampleCode(
- id='code-a', sample_code='S-001', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizSampleCode(
- id='code-b', sample_code='S-002', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizSampleCode(
- id='code-c', sample_code='S-003', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizSampleCode(
- id='code-cross', sample_code='S-CROSS', tenant_no='tenant-b', create_dept_id='', del_flag=0
- ),
- BizSampleCode(
- id='code-deleted', sample_code='S-DELETED', tenant_no='tenant-a', create_dept_id='', del_flag=1
- ),
- BizItemUnit(
- id='unit-a', unit_code='ml', unit_name='毫升', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- SysUser(
- id='u1', user_no='u1', nick_name='当前用户', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- SysUser(
- id='manager-a', user_no='manager-a', nick_name='经理', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- SysUser(
- id='u2', user_no='u2', nick_name='其他专员', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizCollectionRegionStaff(
- id='staff-u1-region-a', region_id='region-a', user_id='u1', is_leader=1,
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizCollectionRegionStaff(
- id='staff-manager-a-region-a', region_id='region-a', user_id='manager-a', is_leader=0,
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizAttachment(
- id='att-protocol-a',
- business_type='market:specialist:protocol:staging',
- original_name='a.pdf',
- file_ext='pdf',
- file_url='protocol/a.pdf',
- file_size=10,
- file_type='application/pdf',
- tenant_no='tenant-a',
- create_by='u1',
- create_dept_id='',
- del_flag=0,
- ),
- ]
- )
- db.commit()
- def _body(source='cell_storage', **overrides):
- from app.schemas.market_mobile import MarketProtocolBody
- payload = {
- 'source': source,
- 'protocolNo': 'P-001',
- 'registerDate': '20261010',
- 'attachments': {'protocolAttachments': 'protocol/a.pdf'},
- 'customer': {'motherName': '张三', 'motherPhone': '13800000000'},
- 'collection': {
- 'regionId': 'region-a',
- 'hospitalId': 'hospital-a',
- 'salesUserId': 'someone-else',
- 'regionManagerUserId': 'manager-a',
- },
- 'samples': [
- {
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-a',
- 'sampleCode': 'S-001',
- 'unitId': 'unit-a',
- 'collectAmount': 10,
- }
- ],
- 'remark': '移动端登记',
- }
- payload.update(overrides)
- return MarketProtocolBody.model_validate(payload)
- def test_protocol_create_rejects_unknown_attachment_reference():
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- body = _body(
- attachments={'protocolAttachments': 'https://evil.test/a.pdf'}
- )
- with pytest.raises(ValueError, match='附件'):
- create_protocol(
- db,
- body=body,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- def test_terminal_protocol_allows_web_parity_edit_without_status_change():
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'terminal')
- row.protocol_status = 'yzf'
- db.commit()
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=_body(protocolNo='CELL-terminal'),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- saved = db.get(BizCollectionProtocol, row.id)
- assert saved.protocol_status == 'yzf'
- assert saved.protocol_no == 'CELL-terminal'
- def test_protocol_list_combines_cell_and_donation_without_scope_leak():
- from app.services.market_protocol import list_protocols
- db = _session()
- now = datetime.now()
- _cell(db, 'cell-own', created=now - timedelta(minutes=2))
- _cell(db, 'cell-other', sales='u2', created=now)
- _donation(db, 'don-own', created=now - timedelta(minutes=1))
- _donation(db, 'don-other', sales='u2', created=now + timedelta(minutes=1))
- db.commit()
- items, total = list_protocols(db, ctx=_ctx('specialist'), user_info=_user())
- assert total == 2
- assert [(item['source'], item['id']) for item in items] == [
- ('research_donation', 'don-own'),
- ('cell_storage', 'cell-own'),
- ]
- def test_protocol_list_large_page_uses_bounded_source_queries_and_global_order():
- from app.services.market_protocol import list_protocols
- db = _session()
- base = datetime(2026, 10, 10, 12, 0, 0)
- expected = []
- for index in range(24):
- created = base + timedelta(seconds=index)
- cell_id = f'cell-{index:02d}'
- donation_id = f'don-{index:02d}'
- _cell(db, cell_id, created=created)
- _donation(db, donation_id, created=created + timedelta(microseconds=1))
- expected.extend([(created, cell_id), (created + timedelta(microseconds=1), donation_id)])
- db.commit()
- statements = []
- def capture(_conn, _cursor, statement, _parameters, _context, _many):
- if 'FROM biz_collection_protocol ' in statement or 'FROM biz_research_donation ' in statement:
- statements.append(statement)
- event.listen(db.get_bind(), 'before_cursor_execute', capture)
- try:
- items, total = list_protocols(
- db,
- ctx=_ctx('specialist'),
- user_info=_user(),
- page=4,
- page_size=7,
- )
- finally:
- event.remove(db.get_bind(), 'before_cursor_execute', capture)
- ordered_ids = [item[1] for item in sorted(expected, key=lambda item: (item[0], item[1]), reverse=True)]
- assert total == 48
- assert [item['id'] for item in items] == ordered_ids[21:28]
- page_selects = [sql for sql in statements if 'count(' not in sql.lower()]
- assert len(page_selects) == 2
- assert all(' LIMIT ' in sql.upper() for sql in page_selects)
- def test_protocol_list_stable_tie_breaker_prevents_page_overlap():
- from app.services.market_protocol import list_protocols
- db = _session()
- same_time = datetime(2026, 10, 10, 12, 0, 0)
- for row_id in ('a1', 'a3', 'a5', 'a7'):
- _cell(db, row_id, created=same_time)
- for row_id in ('a2', 'a4', 'a6', 'a8'):
- _donation(db, row_id, created=same_time)
- db.commit()
- page_one, total = list_protocols(
- db, ctx=_ctx('specialist'), user_info=_user(), page=1, page_size=3
- )
- page_two, _ = list_protocols(
- db, ctx=_ctx('specialist'), user_info=_user(), page=2, page_size=3
- )
- assert total == 8
- assert [item['id'] for item in page_one] == ['a8', 'a7', 'a6']
- assert [item['id'] for item in page_two] == ['a5', 'a4', 'a3']
- assert {item['id'] for item in page_one}.isdisjoint(
- {item['id'] for item in page_two}
- )
- def test_protocol_detail_rechecks_source_specific_idor():
- from app.services.market_protocol import get_protocol
- db = _session()
- _cell(db, 'cell-other', sales='u2')
- _donation(db, 'don-other', sales='u2')
- db.commit()
- with pytest.raises(HTTPException) as cell_error:
- get_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-other',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- with pytest.raises(HTTPException) as donation_error:
- get_protocol(
- db,
- source='research_donation',
- protocol_id='don-other',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert cell_error.value.status_code == 404
- assert donation_error.value.status_code == 404
- def test_protocol_detail_can_resolve_source_from_scoped_record():
- from app.services.market_protocol import get_protocol
- db = _session()
- _donation(db, 'don-own')
- db.commit()
- result = get_protocol(
- db,
- source=None,
- protocol_id='don-own',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert result['source'] == 'research_donation'
- assert result['basic']['id'] == 'don-own'
- def test_protocol_detail_resolves_web_display_names_for_mobile_fields():
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'named')
- row.collector_user_id = 'u1'
- collection = db.query(BizProtocolCollectionInfo).filter_by(
- collection_protocol_id=row.id
- ).one()
- collection.hospital_id = 'hospital-a'
- db.add(
- BizProtocolSample(
- id='sample-named',
- collection_protocol_id=row.id,
- sample_type_id='type-a',
- sample_code='S-NAMED',
- unit_id='unit-a',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- result = get_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert result['basic']['collectorUserName'] == '当前用户'
- assert result['collection']['regionName'] == 'A区'
- assert result['collection']['hospitalName'] == 'A院'
- assert result['collection']['salesUserName'] == '当前用户'
- assert result['collection']['regionManagerUserName'] == '经理'
- assert result['samples'][0]['sampleTypeName'] == '样本A'
- assert result['samples'][0]['unitName'] == '毫升'
- def test_protocol_detail_resolves_soft_deleted_master_names_with_tenant_scope():
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'historical-master')
- collection = db.query(BizProtocolCollectionInfo).filter_by(
- collection_protocol_id=row.id
- ).one()
- collection.hospital_id = 'hospital-a'
- db.get(BizCollectionRegion, 'region-a').del_flag = 1
- db.get(BizCollectionHospital, 'hospital-a').del_flag = 1
- db.get(BizCellSampleType, 'type-a').del_flag = 1
- db.get(BizItemUnit, 'unit-a').del_flag = 1
- db.add_all(
- [
- BizCellSampleType(id='type-global-history', type_code='GLOBAL', type_name='历史全局样本', tenant_no='', create_dept_id='', del_flag=1),
- BizCellSampleType(id='type-cross-history', type_code='CROSS', type_name='跨租户样本名称', tenant_no='tenant-b', create_dept_id='', del_flag=1),
- BizItemUnit(id='unit-cross-history', unit_code='cross', unit_name='跨租户单位名称', tenant_no='tenant-b', create_dept_id='', del_flag=1),
- BizProtocolSample(id='sample-history-local', collection_protocol_id=row.id, sample_type_id='type-a', sample_code='S-HISTORY-LOCAL', unit_id='unit-a', tenant_no='tenant-a', create_dept_id='', del_flag=0),
- BizProtocolSample(id='sample-history-global', collection_protocol_id=row.id, sample_type_id='type-global-history', sample_code='S-HISTORY-GLOBAL', unit_id='', tenant_no='tenant-a', create_dept_id='', del_flag=0),
- BizProtocolSample(id='sample-history-cross', collection_protocol_id=row.id, sample_type_id='type-cross-history', sample_code='S-HISTORY-CROSS', unit_id='unit-cross-history', tenant_no='tenant-a', create_dept_id='', del_flag=0),
- ]
- )
- db.commit()
- result = get_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- samples = {item['sampleCode']: item for item in result['samples']}
- assert result['collection']['regionName'] == 'A区'
- assert result['collection']['hospitalName'] == 'A院'
- assert samples['S-HISTORY-LOCAL']['sampleTypeName'] == '样本A'
- assert samples['S-HISTORY-LOCAL']['unitName'] == '毫升'
- assert samples['S-HISTORY-GLOBAL']['sampleTypeName'] == '历史全局样本'
- assert samples['S-HISTORY-CROSS']['sampleTypeName'] == ''
- assert samples['S-HISTORY-CROSS']['unitName'] == ''
- def test_protocol_names_use_exact_historical_nicknames_without_raw_fallbacks():
- from app.services.market_protocol import get_protocol, list_protocols
- db = _session()
- _masters(db)
- row = _cell(db, 'historical-names')
- row.collector_user_id = 'blank-name-user'
- collection = db.query(BizProtocolCollectionInfo).filter_by(
- collection_protocol_id=row.id
- ).one()
- collection.region_manager_user_id = 'manager-a'
- current_user = db.get(SysUser, 'u1')
- current_user.nick_name = '历史删除专员'
- current_user.del_flag = 1
- manager = db.get(SysUser, 'manager-a')
- manager.nick_name = '历史停用经理'
- manager.status = '0'
- db.add(
- SysUser(
- id='blank-name-user',
- user_no='raw-collector-no',
- nick_name='',
- status='0',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=1,
- )
- )
- sample_type = db.get(BizCellSampleType, 'type-a')
- sample_type.type_name = ''
- unit = db.get(BizItemUnit, 'unit-a')
- unit.unit_name = ''
- db.add(
- BizProtocolSample(
- id='sample-historical-names',
- collection_protocol_id=row.id,
- sample_type_id='type-a',
- sample_code='S-HISTORY',
- unit_id='unit-a',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- items, total = list_protocols(
- db, ctx=_ctx('specialist'), user_info=_user()
- )
- detail = get_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert total == 1
- assert items[0]['salesUserName'] == '历史删除专员'
- assert items[0]['collectorUserName'] == ''
- assert items[0]['managerName'] == '历史停用经理'
- assert items[0]['sampleTypeName'] == ''
- assert detail['basic']['collectorUserName'] == ''
- assert detail['collection']['salesUserName'] == '历史删除专员'
- assert detail['collection']['regionManagerUserName'] == '历史停用经理'
- assert detail['samples'][0]['sampleTypeName'] == ''
- assert detail['samples'][0]['unitName'] == ''
- def test_specialist_cell_create_forces_current_collection_and_collector_users():
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- result = create_protocol(
- db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
- )
- main = db.get(BizCollectionProtocol, result['basic']['id'])
- collection = db.query(BizProtocolCollectionInfo).filter_by(collection_protocol_id=main.id, del_flag=0).one()
- party = db.query(BizProtocolPartyCustomer).filter_by(collection_protocol_id=main.id, del_flag=0).one()
- sample = db.query(BizProtocolSample).filter_by(collection_protocol_id=main.id, del_flag=0).one()
- assert main.protocol_status == 'sxz'
- assert main.collector_user_id == 'u1'
- assert main.create_dept_id == 'dept-from-auth'
- assert collection.sales_user_id == 'u1'
- assert collection.create_dept_id == 'dept-from-auth'
- assert party.collector_user_id == 'u1'
- assert party.customer_id is None
- assert db.query(BizCustomer).count() == 0
- assert sample.sample_code == 'S-001'
- def test_specialist_donation_create_forces_current_users_and_has_no_payment_section():
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- result = create_protocol(
- db,
- body=_body('research_donation', donationNo='D-001', protocolNo=None),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- donation = db.get(BizResearchDonation, result['basic']['id'])
- collection = db.query(BizResearchDonationCollection).filter_by(research_donation_id=donation.id, del_flag=0).one()
- party = db.query(BizResearchDonationParty).filter_by(research_donation_id=donation.id, del_flag=0).one()
- sample = db.query(BizResearchDonationSample).filter_by(research_donation_id=donation.id, del_flag=0).one()
- assert donation.donation_status == 'sxz'
- assert donation.collector_user_id == 'u1'
- assert donation.create_dept_id == 'dept-from-auth'
- assert collection.sales_user_id == 'u1'
- assert collection.create_dept_id == 'dept-from-auth'
- assert party.collector_user_id == 'u1'
- assert party.create_dept_id == 'dept-from-auth'
- assert sample.create_dept_id == 'dept-from-auth'
- assert party.customer_id is None
- assert db.query(BizCustomer).count() == 0
- assert 'payment' not in result
- def test_manager_update_cannot_move_document_out_of_scope_and_rolls_back():
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-manager', manager='u1')
- db.commit()
- with pytest.raises(ValueError, match='范围'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-manager',
- body=_body(collection={'regionId': 'region-b', 'regionManagerUserId': 'u1'}),
- ctx=_ctx('manager'),
- user_info=_user(),
- )
- db.expire_all()
- collection = db.query(BizProtocolCollectionInfo).filter_by(collection_protocol_id='cell-manager', del_flag=0).one()
- assert collection.region_id == 'region-a'
- assert collection.region_manager_user_id == 'u1'
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_manager_create_cannot_self_assign_an_unmanaged_region(source):
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- body = _body(
- source,
- **(
- {'donationNo': 'D-UNMANAGED', 'protocolNo': None}
- if source == 'research_donation'
- else {}
- ),
- collection={
- 'regionId': 'region-b',
- 'regionManagerUserId': 'u1',
- 'salesUserId': 'u2',
- },
- attachments=None,
- )
- with pytest.raises(ValueError, match='范围'):
- create_protocol(
- db,
- body=body,
- ctx=_ctx('manager'),
- user_info=_user(),
- )
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- @pytest.mark.parametrize('operation', ['update', 'delete'])
- def test_protocol_mutations_lock_the_scoped_parent_row(
- monkeypatch, source, operation
- ):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import delete_protocol, update_protocol
- db = _session()
- _masters(db)
- row_id = f'{source}-{operation}-lock'
- if source == 'cell_storage':
- _cell(db, row_id)
- else:
- _donation(db, row_id)
- db.commit()
- lock_calls = []
- original = Query.with_for_update
- def record_lock(query, *args, **kwargs):
- lock_calls.append(True)
- return original(query, *args, **kwargs)
- monkeypatch.setattr(Query, 'with_for_update', record_lock)
- if operation == 'update':
- update_protocol(
- db,
- source=source,
- protocol_id=row_id,
- body=MarketProtocolBody.model_validate(
- {'source': source, 'remark': '锁后更新'}
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- else:
- delete_protocol(
- db,
- source=source,
- protocol_id=row_id,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert lock_calls
- def test_v1_cell_protocol_mobile_update_keeps_version_and_saves_legacy_payment():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-v1')
- row.payment_model_version = 1
- db.commit()
- result = update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '移动端可编辑历史协议',
- 'payment': {
- 'payMode': 'dj',
- 'contractAmount': '999999999999.99',
- 'lumpSumDeadline': '20261231',
- },
- 'paymentDetails': [
- {
- 'installmentNo': 1,
- 'installmentDueDate': '20261231',
- 'dueAmount': '999999999999.99',
- 'transferType': 'wx',
- 'transferAmount': '1.01',
- 'transferTime': '2026-10-14 10:00:00',
- 'transferAttachments': 'protocol/a.pdf',
- 'remark': '移动草稿',
- }
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- saved = db.get(BizCollectionProtocol, row.id)
- assert saved.payment_model_version == 1
- assert saved.remark == '移动端可编辑历史协议'
- assert result['paymentModelVersion'] == 1
- assert result['payment']['contractAmount'] == '999999999999.99'
- assert result['paymentDetails'][0]['transferAmount'] == '1.01'
- def test_v1_market_update_accepts_complete_generated_installment_amounts():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-v1-generated-installments')
- row.payment_model_version = 1
- db.commit()
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'payment': {
- 'payMode': 'fq',
- 'contractAmount': '100.01',
- 'installmentCount': 2,
- 'installmentIntervalMonths': '1',
- 'installmentStartDate': '20260131',
- 'installmentAdvanceDays': 0,
- 'installmentRatios': ['50.00', '50.01'],
- },
- 'paymentDetails': [
- {
- 'installmentNo': 1,
- 'installmentDueDate': '20260131',
- 'dueAmount': '50.00',
- },
- {
- 'installmentNo': 2,
- 'installmentDueDate': '20260228',
- 'dueAmount': '50.01',
- },
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- payment = db.query(BizProtocolPayment).filter_by(
- collection_protocol_id=row.id, del_flag=0
- ).one()
- assert payment.installment_ratios == '["50.00", "50.01"]'
- details = db.query(BizProtocolPaymentDetail).filter_by(
- payment_id=payment.id, del_flag=0
- ).order_by(BizProtocolPaymentDetail.installment_no).all()
- assert [detail.due_amount for detail in details] == [
- Decimal('50.00'), Decimal('50.01')
- ]
- assert [detail.installment_due_date for detail in details] == [
- '20260131', '20260228'
- ]
- def test_protocol_body_legacy_payment_is_typed_and_rejects_sensitive_detail_fields():
- from app.schemas.market_mobile import MarketProtocolBody
- from fastapi.encoders import jsonable_encoder
- body = MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'payment': {
- 'payMode': 'fq',
- 'contractAmount': '1000.01',
- 'installmentCount': 2,
- 'installmentIntervalMonths': '12.50',
- 'installmentStartDate': '20261014',
- 'installmentAdvanceDays': 30,
- 'installmentRatios': ['400.00', '600.01'],
- },
- 'paymentDetails': [
- {
- 'id': 'detail-a',
- 'installmentNo': 1,
- 'dueAmount': '400.00',
- 'transferType': 'bank',
- 'transferAmount': '1.01',
- 'transferTime': '2026-10-14 10:00:00',
- 'transferAttachments': 'proof.pdf',
- 'remark': '市场备注',
- }
- ],
- }
- )
- assert body.payment.contract_amount == Decimal('1000.01')
- assert body.payment.installment_ratios == [Decimal('400.00'), Decimal('600.01')]
- assert body.payment_details[0].transfer_amount == Decimal('1.01')
- attachment_alias = MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'paymentDetails': [{'attachments': 'protocol/proof.pdf'}],
- }
- )
- assert attachment_alias.payment_details[0].transfer_attachments == 'protocol/proof.pdf'
- max_amount = MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'payment': {
- 'payMode': 'dj',
- 'contractAmount': '9999999999999999.99',
- },
- }
- )
- assert max_amount.payment.contract_amount == Decimal('9999999999999999.99')
- with pytest.raises(ValidationError):
- MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'payment': {'payMode': 'evil', 'contractAmount': '1.00'},
- }
- )
- from app.crud import collection_protocol as cell_crud
- serialized_payment = cell_crud._payment_to_dict(
- BizProtocolPayment(
- id='max-payment', collection_protocol_id='max-protocol',
- contract_amount=Decimal('9999999999999999.99'),
- installment_ratios='["4999999999999999.99", "5000000000000000.00"]',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- )
- serialized_detail = cell_crud._detail_to_dict(
- BizProtocolPaymentDetail(
- id='max-detail', payment_id='max-payment',
- due_amount=Decimal('4999999999999999.99'),
- transfer_amount=Decimal('0.01'), tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- )
- assert serialized_payment['contractAmount'] == '9999999999999999.99'
- assert jsonable_encoder(serialized_payment)['installmentRatios'] == [
- '4999999999999999.99', '5000000000000000.00'
- ]
- assert serialized_detail['dueAmount'] == '4999999999999999.99'
- assert serialized_detail['transferAmount'] == '0.01'
- for forbidden in (
- {'payStatus': 'dfj'},
- {'financeRemark': '越权'},
- {'refundAmount': '1.00'},
- {'financeConfirmAttachments': 'finance.pdf'},
- ):
- with pytest.raises(ValidationError):
- MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'paymentDetails': [{'dueAmount': '1.00', **forbidden}],
- }
- )
- @pytest.mark.parametrize(
- ('section', 'field', 'value'),
- [
- ('payment', 'lumpSumDeadline', '20261399'),
- ('payment', 'installmentStartDate', '20230229'),
- ('paymentDetails', 'installmentDueDate', '20260230'),
- ],
- )
- def test_protocol_body_legacy_payment_rejects_invalid_calendar_dates(
- section, field, value
- ):
- from app.schemas.market_mobile import MarketProtocolBody
- payload = {'source': 'cell_storage'}
- if section == 'paymentDetails':
- payload[section] = [{field: value}]
- else:
- payload[section] = {'payMode': 'dj', field: value}
- with pytest.raises(ValidationError, match='YYYYMMDD'):
- MarketProtocolBody.model_validate(payload)
- def test_protocol_body_legacy_payment_accepts_month_end_and_leap_dates():
- from app.schemas.market_mobile import MarketProtocolBody
- body = MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'payment': {
- 'payMode': 'fq',
- 'lumpSumDeadline': '20260228',
- 'installmentStartDate': '20240229',
- },
- 'paymentDetails': [{'installmentDueDate': '20260228'}],
- }
- )
- assert body.payment.installment_start_date == '20240229'
- assert body.payment_details[0].installment_due_date == '20260228'
- def test_protocol_payment_models_are_mutually_exclusive_by_stored_version():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- v1 = _cell(db, 'legacy-mixed')
- v1.payment_model_version = 1
- v2 = _cell(db, 'sample-mixed')
- v2.payment_model_version = 2
- db.commit()
- with pytest.raises(ValueError, match='v1.*samplePayments|旧版.*样本缴费'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=v1.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'samplePayments': [
- {'sampleCode': 'S-001', 'payMode': 'dj'}
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- with pytest.raises(ValueError, match='v2.*payment|新版.*整单缴费'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=v2.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'payment': {'payMode': 'dj', 'contractAmount': '1.00'},
- 'paymentDetails': [],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- donation = _donation(db, 'donation-payment-mixed')
- db.commit()
- with pytest.raises(ValueError, match='科研捐赠.*缴费'):
- update_protocol(
- db,
- source='research_donation',
- protocol_id=donation.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'research_donation',
- 'payment': {'payMode': 'dj', 'contractAmount': '1.00'},
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- def test_v1_market_update_preserves_locked_rows_and_mutates_only_drafts():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-status-guard')
- row.payment_model_version = 1
- payment = BizProtocolPayment(
- id='legacy-payment', collection_protocol_id=row.id, pay_mode='dj',
- contract_amount=Decimal('1000.00'), tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- details = [
- BizProtocolPaymentDetail(
- id=f'detail-{status_name}', payment_id=payment.id,
- pay_status=status, due_amount=Decimal('100.00'),
- transfer_amount=Decimal('10.00'), remark='原值',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- for status_name, status in (
- ('paid', 'yjf'), ('confirming', 'jfqrz'),
- ('empty', ''), ('spaces', ' '), ('draft', 'dfj'),
- ('padded-draft', ' dfj '), ('delete', 'dfj'),
- )
- ]
- db.add_all([payment, *details])
- db.commit()
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'payment': {'payMode': 'dj', 'contractAmount': '1000.00'},
- 'paymentDetails': [
- {'id': 'detail-paid', 'dueAmount': '1.00', 'remark': '篡改'},
- {'id': 'detail-confirming', 'dueAmount': '1.00', 'remark': '篡改'},
- {'id': 'detail-empty', 'dueAmount': '1.00', 'remark': '篡改'},
- {'id': 'detail-spaces', 'dueAmount': '1.00', 'remark': '篡改'},
- {'id': 'detail-draft', 'dueAmount': '250.00', 'remark': '已更新'},
- {'dueAmount': '350.00', 'remark': '新增草稿'},
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- for locked_id in (
- 'detail-paid', 'detail-confirming', 'detail-empty',
- 'detail-spaces', 'detail-padded-draft',
- ):
- locked = db.get(BizProtocolPaymentDetail, locked_id)
- assert locked.due_amount == Decimal('100.00')
- assert locked.remark == '原值'
- assert locked.del_flag == 0
- assert db.get(BizProtocolPaymentDetail, 'detail-draft').due_amount == Decimal('250.00')
- assert db.get(BizProtocolPaymentDetail, 'detail-delete').del_flag == 1
- created = db.query(BizProtocolPaymentDetail).filter_by(
- payment_id=payment.id, remark='新增草稿', del_flag=0
- ).one()
- assert created.pay_status == 'dfj'
- def test_v1_market_update_rolls_back_protocol_and_payment_on_detail_failure():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-atomic')
- row.payment_model_version = 1
- payment = BizProtocolPayment(
- id='legacy-atomic-payment', collection_protocol_id=row.id,
- pay_mode='fq', contract_amount=Decimal('2000.00'),
- installment_count=2, installment_ratios='[1000, 1000]',
- remark='原方案', tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- locked = BizProtocolPaymentDetail(
- id='legacy-atomic-locked', payment_id=payment.id, pay_status='yjf',
- installment_no=2, due_amount=Decimal('1000.00'),
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- db.add_all([payment, locked])
- db.commit()
- with pytest.raises(ValueError, match='明细条数'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '不应提交',
- 'payment': {
- 'payMode': 'fq', 'contractAmount': '700.00',
- 'installmentCount': 1, 'installmentRatios': ['700.00'],
- },
- 'paymentDetails': [],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizCollectionProtocol, row.id).remark is None
- saved_payment = db.get(BizProtocolPayment, payment.id)
- assert saved_payment.contract_amount == Decimal('2000.00')
- assert saved_payment.installment_count == 2
- assert saved_payment.remark == '原方案'
- assert db.get(BizProtocolPaymentDetail, locked.id).del_flag == 0
- def test_v1_market_update_rejects_locked_plan_inconsistency_and_rolls_back_everything():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-locked-plan')
- row.payment_model_version = 1
- payment = BizProtocolPayment(
- id='legacy-locked-plan-payment', collection_protocol_id=row.id,
- pay_mode='fq', contract_amount=Decimal('2000.00'),
- installment_count=2, installment_interval_months=Decimal('12'),
- installment_start_date='20261001', installment_advance_days=30,
- installment_ratios='[1000, 1000]', tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- locked = BizProtocolPaymentDetail(
- id='legacy-locked-plan-paid', payment_id=payment.id, pay_status='yjf',
- installment_no=1, due_amount=Decimal('1000.00'),
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- db.add_all([payment, locked])
- db.commit()
- with pytest.raises(ValueError, match='已缴|缴清|锁定|合同金额|缴费计划'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '不应提交',
- 'payment': {'payMode': 'dj', 'contractAmount': '1.00'},
- 'paymentDetails': [
- {
- 'id': locked.id,
- 'installmentNo': 1,
- 'dueAmount': '1000.00',
- }
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizCollectionProtocol, row.id).remark is None
- saved_payment = db.get(BizProtocolPayment, payment.id)
- assert saved_payment.pay_mode == 'fq'
- assert saved_payment.contract_amount == Decimal('2000.00')
- assert saved_payment.installment_count == 2
- assert db.get(BizProtocolPaymentDetail, locked.id).del_flag == 0
- def test_v1_market_update_blocks_plan_changes_during_finance_confirmation():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-confirming-plan')
- row.payment_model_version = 1
- payment = BizProtocolPayment(
- id='legacy-confirming-plan-payment', collection_protocol_id=row.id,
- pay_mode='fq', contract_amount=Decimal('2000.00'),
- installment_count=2, installment_interval_months=Decimal('12'),
- installment_start_date='20261001', installment_advance_days=30,
- installment_ratios='[1000, 1000]', tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- confirming = BizProtocolPaymentDetail(
- id='legacy-confirming-plan-locked', payment_id=payment.id,
- pay_status='jfqrz', installment_no=1, due_amount=Decimal('1000.00'),
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- draft = BizProtocolPaymentDetail(
- id='legacy-confirming-plan-draft', payment_id=payment.id,
- pay_status='dfj', installment_no=2, due_amount=Decimal('1000.00'),
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- db.add_all([payment, confirming, draft])
- db.commit()
- with pytest.raises(ValueError, match='确认中|锁定|缴费计划'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '不应提交',
- 'payment': {
- 'payMode': 'fq',
- 'contractAmount': '2000.00',
- 'installmentCount': 2,
- 'installmentIntervalMonths': '24',
- 'installmentStartDate': '20261001',
- 'installmentAdvanceDays': 30,
- 'installmentRatios': ['1000.00', '1000.00'],
- },
- 'paymentDetails': [
- {
- 'id': confirming.id,
- 'installmentNo': 1,
- 'dueAmount': '1000.00',
- },
- {
- 'id': draft.id,
- 'installmentNo': 2,
- 'dueAmount': '1000.00',
- },
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizCollectionProtocol, row.id).remark is None
- assert db.get(BizProtocolPayment, payment.id).installment_interval_months == Decimal('12')
- def test_v1_market_update_rejects_stale_draft_schedule_after_paid_plan_change():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-stale-plan')
- row.payment_model_version = 1
- payment = BizProtocolPayment(
- id='legacy-stale-plan-payment', collection_protocol_id=row.id,
- pay_mode='fq', contract_amount=Decimal('2000.00'),
- installment_count=2, installment_interval_months=Decimal('12'),
- installment_start_date='20261001', installment_advance_days=30,
- installment_ratios='[1000, 1000]', tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- paid = BizProtocolPaymentDetail(
- id='legacy-stale-plan-paid', payment_id=payment.id, pay_status='yjf',
- installment_no=1, installment_due_date='20261001',
- due_amount=Decimal('1000.00'), tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- draft = BizProtocolPaymentDetail(
- id='legacy-stale-plan-draft', payment_id=payment.id, pay_status='dfj',
- installment_no=2, installment_due_date='20270901',
- due_amount=Decimal('1000.00'), tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- db.add_all([payment, paid, draft])
- db.commit()
- with pytest.raises(ValueError, match='缴费计划|应缴时间'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '不应提交',
- 'payment': {
- 'payMode': 'fq', 'contractAmount': '2000.00',
- 'installmentCount': 2, 'installmentIntervalMonths': '24',
- 'installmentStartDate': '20261001',
- 'installmentAdvanceDays': 30,
- 'installmentRatios': ['1000.00', '1000.00'],
- },
- 'paymentDetails': [
- {
- 'id': paid.id, 'installmentNo': 1,
- 'installmentDueDate': '20261001',
- 'dueAmount': '1000.00',
- },
- {
- 'id': draft.id, 'installmentNo': 2,
- 'installmentDueDate': '20270901',
- 'dueAmount': '1000.00',
- },
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizCollectionProtocol, row.id).remark is None
- assert db.get(BizProtocolPayment, payment.id).installment_interval_months == Decimal('12')
- assert db.get(BizProtocolPaymentDetail, draft.id).installment_due_date == '20270901'
- result = update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '已对齐新计划',
- 'payment': {
- 'payMode': 'fq', 'contractAmount': '2000.00',
- 'installmentCount': 2, 'installmentIntervalMonths': '24',
- 'installmentStartDate': '20261001',
- 'installmentAdvanceDays': 30,
- 'installmentRatios': ['1000.00', '1000.00'],
- },
- 'paymentDetails': [
- {
- 'id': paid.id, 'installmentNo': 1,
- 'installmentDueDate': '20261001',
- 'dueAmount': '1000.00',
- },
- {
- 'id': draft.id, 'installmentNo': 2,
- 'installmentDueDate': '20280901',
- 'dueAmount': '1000.00',
- },
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert result['payment']['installmentIntervalMonths'] == 24.0
- db.expire_all()
- assert db.get(BizCollectionProtocol, row.id).remark == '已对齐新计划'
- assert db.get(BizProtocolPaymentDetail, draft.id).installment_due_date == '20280901'
- def test_v1_market_update_requires_rebuilt_lump_sum_after_paid_installment_switch():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-switch-to-lump-sum')
- row.payment_model_version = 1
- payment = BizProtocolPayment(
- id='legacy-switch-to-lump-sum-payment', collection_protocol_id=row.id,
- pay_mode='fq', contract_amount=Decimal('2000.00'),
- installment_count=2, installment_interval_months=Decimal('12'),
- installment_start_date='20261001', installment_advance_days=30,
- installment_ratios='[1000, 1000]', tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- paid = BizProtocolPaymentDetail(
- id='legacy-switch-to-lump-sum-paid', payment_id=payment.id,
- pay_status='yjf', installment_no=1, installment_due_date='20261001',
- due_amount=Decimal('1000.00'), tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- old_draft = BizProtocolPaymentDetail(
- id='legacy-switch-to-lump-sum-draft', payment_id=payment.id,
- pay_status='dfj', installment_no=2, installment_due_date='20270901',
- due_amount=Decimal('1000.00'), tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- db.add_all([payment, paid, old_draft])
- db.commit()
- payment_payload = {
- 'payMode': 'dj', 'contractAmount': '2000.00',
- 'lumpSumDeadline': '20261231',
- }
- with pytest.raises(ValueError, match='趸交|待缴|期号|应缴时间'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '不应提交',
- 'payment': payment_payload,
- 'paymentDetails': [
- {
- 'id': paid.id, 'installmentNo': 1,
- 'installmentDueDate': '20261001',
- 'dueAmount': '1000.00',
- },
- {
- 'id': old_draft.id, 'installmentNo': 2,
- 'installmentDueDate': '20270901',
- 'dueAmount': '1000.00',
- },
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizCollectionProtocol, row.id).remark is None
- assert db.get(BizProtocolPayment, payment.id).pay_mode == 'fq'
- assert db.get(BizProtocolPaymentDetail, old_draft.id).del_flag == 0
- result = update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '转趸交',
- 'payment': payment_payload,
- 'paymentDetails': [
- {
- 'id': paid.id, 'installmentNo': 1,
- 'installmentDueDate': '20261001',
- 'dueAmount': '1000.00',
- },
- {
- 'installmentNo': None,
- 'installmentDueDate': '20261231',
- 'dueAmount': '1000.00',
- },
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- editable = [item for item in result['paymentDetails'] if item['payStatus'] == 'dfj']
- assert len(editable) == 1
- assert editable[0]['installmentNo'] is None
- assert editable[0]['installmentDueDate'] == '20261231'
- assert editable[0]['dueAmount'] == '1000.00'
- db.expire_all()
- assert db.get(BizProtocolPaymentDetail, old_draft.id).del_flag == 1
- def test_v1_market_update_rejects_details_without_payment_and_keeps_attachment_staging():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-details-without-parent')
- row.payment_model_version = 1
- db.commit()
- with pytest.raises(ValueError, match='缴费主表|缴费方案'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '不应提交',
- 'paymentDetails': [
- {
- 'dueAmount': '100.00',
- 'attachments': 'protocol/a.pdf',
- }
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizCollectionProtocol, row.id).remark is None
- assert db.query(BizProtocolPayment).count() == 0
- assert db.query(BizProtocolPaymentDetail).count() == 0
- attachment = db.get(BizAttachment, 'att-protocol-a')
- assert attachment.business_type == 'market:specialist:protocol:staging'
- assert attachment.business_id in (None, '')
- @pytest.mark.parametrize(
- 'attachment',
- [
- {
- 'id': 'unknown-ref', 'file_url': 'protocol/unknown.pdf',
- 'business_type': 'market:specialist:protocol:staging',
- 'tenant_no': 'tenant-a', 'create_by': 'u1',
- },
- {
- 'id': 'cross-tenant-ref', 'file_url': 'protocol/cross.pdf',
- 'business_type': 'market:specialist:protocol:staging',
- 'tenant_no': 'tenant-b', 'create_by': 'u1',
- },
- {
- 'id': 'wrong-module-ref', 'file_url': 'payment/wrong-module.pdf',
- 'business_type': 'market:specialist:payment:staging',
- 'tenant_no': 'tenant-a', 'create_by': 'u1',
- },
- {
- 'id': 'other-user-ref', 'file_url': 'protocol/other-user.pdf',
- 'business_type': 'market:specialist:protocol:staging',
- 'tenant_no': 'tenant-a', 'create_by': 'u2',
- },
- ],
- )
- def test_v1_market_update_rejects_untrusted_payment_detail_attachment_and_rolls_back(attachment):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, f"legacy-{attachment['id']}")
- row.payment_model_version = 1
- payment = BizProtocolPayment(
- id=f"payment-{attachment['id']}", collection_protocol_id=row.id,
- pay_mode='dj', contract_amount=Decimal('100.00'),
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- detail = BizProtocolPaymentDetail(
- id=f"detail-{attachment['id']}", payment_id=payment.id,
- pay_status='dfj', due_amount=Decimal('100.00'),
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- if attachment['id'] != 'unknown-ref':
- db.add(
- BizAttachment(
- id=attachment['id'], business_type=attachment['business_type'],
- original_name='proof.pdf', file_ext='pdf',
- file_url=attachment['file_url'], file_size=10,
- file_type='application/pdf', tenant_no=attachment['tenant_no'],
- create_by=attachment['create_by'], create_dept_id='', del_flag=0,
- )
- )
- db.add_all([payment, detail])
- db.commit()
- with pytest.raises(ValueError, match='附件'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '不应提交',
- 'paymentDetails': [
- {
- 'id': detail.id,
- 'dueAmount': '100.00',
- 'transferAttachments': attachment['file_url'],
- }
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizCollectionProtocol, row.id).remark is None
- assert db.get(BizProtocolPaymentDetail, detail.id).transfer_attachments is None
- def test_v1_market_update_binds_payment_detail_attachment_to_protocol_business():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'legacy-payment-attachment')
- row.payment_model_version = 1
- payment = BizProtocolPayment(
- id='legacy-payment-attachment-parent', collection_protocol_id=row.id,
- pay_mode='dj', contract_amount=Decimal('100.00'),
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- detail = BizProtocolPaymentDetail(
- id='legacy-payment-attachment-detail', payment_id=payment.id,
- pay_status='dfj', due_amount=Decimal('100.00'),
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- db.add_all([payment, detail])
- db.commit()
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'paymentDetails': [
- {
- 'id': detail.id,
- 'dueAmount': '100.00',
- 'attachments': 'protocol/a.pdf',
- }
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- saved_detail = db.get(BizProtocolPaymentDetail, detail.id)
- saved_attachment = db.get(BizAttachment, 'att-protocol-a')
- assert saved_detail.transfer_attachments == 'protocol/a.pdf'
- assert saved_attachment.business_type == 'market:specialist:protocol:bound'
- assert saved_attachment.business_id == row.id
- def test_protocol_attachment_download_authorizes_exact_payment_detail_relation_only():
- from app.services.market_support import get_market_attachment_download_url
- class FakeStorage:
- @staticmethod
- def presigned_download_url(file_url, file_name):
- return f'https://download.test/{file_url}?name={file_name}'
- db = _session()
- own = _cell(db, 'payment-attachment-own')
- hidden = _cell(db, 'payment-attachment-hidden', sales='u2')
- own_payment = BizProtocolPayment(
- id='payment-attachment-own-parent', collection_protocol_id=own.id,
- pay_mode='dj', tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- hidden_payment = BizProtocolPayment(
- id='payment-attachment-hidden-parent', collection_protocol_id=hidden.id,
- pay_mode='dj', tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- db.add_all(
- [
- own_payment,
- hidden_payment,
- BizProtocolPaymentDetail(
- id='payment-attachment-own-detail', payment_id=own_payment.id,
- transfer_attachments='protocol/exact.pdf', pay_status='dfj',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizProtocolPaymentDetail(
- id='payment-attachment-hidden-detail', payment_id=hidden_payment.id,
- transfer_attachments='protocol/hidden.pdf', pay_status='dfj',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizProtocolPaymentDetail(
- id='payment-attachment-substring-detail', payment_id=own_payment.id,
- transfer_attachments='protocol/exact.pdf.evil', pay_status='dfj',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizAttachment(
- id='payment-attachment-exact',
- business_type='market:specialist:protocol:bound', business_id=own.id,
- original_name='exact.pdf', file_ext='pdf', file_url='protocol/exact.pdf',
- file_size=10, file_type='application/pdf', tenant_no='tenant-a',
- create_by='u1', create_dept_id='', del_flag=0,
- ),
- BizAttachment(
- id='payment-attachment-hidden',
- business_type='market:specialist:protocol:bound', business_id=hidden.id,
- original_name='hidden.pdf', file_ext='pdf', file_url='protocol/hidden.pdf',
- file_size=10, file_type='application/pdf', tenant_no='tenant-a',
- create_by='u2', create_dept_id='', del_flag=0,
- ),
- BizAttachment(
- id='payment-attachment-substring',
- business_type='market:specialist:protocol:bound', business_id=own.id,
- original_name='exact-short.pdf', file_ext='pdf', file_url='protocol/exact.pdf.evil',
- file_size=10, file_type='application/pdf', tenant_no='tenant-a',
- create_by='u1', create_dept_id='', del_flag=0,
- ),
- ]
- )
- db.commit()
- result = get_market_attachment_download_url(
- db,
- file_url='protocol/exact.pdf',
- file_name=None,
- module='protocol',
- ctx=_ctx('specialist'),
- user_info=_user(),
- storage_backend=FakeStorage(),
- )
- assert result['downloadUrl'].startswith('https://download.test/protocol/exact.pdf?')
- with pytest.raises(PermissionError):
- get_market_attachment_download_url(
- db,
- file_url='protocol/hidden.pdf',
- file_name=None,
- module='protocol',
- ctx=_ctx('specialist'),
- user_info=_user(),
- storage_backend=FakeStorage(),
- )
- db.query(BizProtocolPaymentDetail).filter_by(
- id='payment-attachment-own-detail'
- ).delete()
- db.commit()
- with pytest.raises(PermissionError):
- get_market_attachment_download_url(
- db,
- file_url='protocol/exact.pdf',
- file_name=None,
- module='protocol',
- ctx=_ctx('specialist'),
- user_info=_user(),
- storage_backend=FakeStorage(),
- )
- @pytest.mark.parametrize('boundary', ['tenant', 'scope'])
- def test_v1_market_update_cannot_cross_tenant_or_manager_scope(boundary):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(
- db,
- f'legacy-{boundary}-hidden',
- sales='u2',
- manager='manager-a',
- region='region-b' if boundary == 'scope' else 'region-a',
- )
- row.payment_model_version = 1
- payment = BizProtocolPayment(
- id=f'legacy-{boundary}-payment',
- collection_protocol_id=row.id,
- pay_mode='dj',
- contract_amount=Decimal('100.00'),
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- if boundary == 'tenant':
- row.tenant_no = 'tenant-b'
- db.query(BizProtocolCollectionInfo).filter_by(
- collection_protocol_id=row.id
- ).update({'tenant_no': 'tenant-b'})
- payment.tenant_no = 'tenant-b'
- db.add(payment)
- db.commit()
- with pytest.raises(HTTPException):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'payment': {'payMode': 'dj', 'contractAmount': '999.00'},
- 'paymentDetails': [],
- }
- ),
- ctx=_ctx('specialist' if boundary == 'tenant' else 'manager'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizProtocolPayment, payment.id).contract_amount == Decimal('100.00')
- def test_cell_sample_partial_edit_preserves_hidden_lifecycle_fields():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'sample-merge')
- db.add(
- BizProtocolSample(
- id='sample-merge-old',
- collection_protocol_id=row.id,
- sample_type_id='type-a',
- sample_code_id='code-a',
- sample_code='S-001',
- collect_amount=12.5,
- unit_id='unit-a',
- price=88,
- original_price=99,
- storage_years=20,
- sample_status='yrk',
- storage_in_date='20261001',
- detect_start_date='20260929',
- void_date='20261002',
- storage_expire_date='20461001',
- is_special=1,
- special_remark='历史特殊说明',
- remark='历史样本备注',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- with patch(
- 'app.crud.collection_protocol._migrate_sample_child_refs_on_replace'
- ):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'samples': [
- {
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-a',
- 'sampleCode': 'S-001',
- 'price': 77,
- }
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- saved = db.query(BizProtocolSample).filter_by(
- collection_protocol_id=row.id, del_flag=0
- ).one()
- assert float(saved.collect_amount) == 12.5
- assert saved.unit_id == 'unit-a'
- assert saved.remark == '历史样本备注'
- assert saved.storage_in_date == '20261001'
- assert saved.detect_start_date == '20260929'
- assert saved.void_date == '20261002'
- assert saved.storage_expire_date == '20461001'
- assert float(saved.price) == 77
- def test_donation_sample_partial_edit_preserves_hidden_lifecycle_fields():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row = _donation(db, 'donation-sample-merge')
- db.add(
- BizResearchDonationSample(
- id='donation-sample-merge-old',
- research_donation_id=row.id,
- sample_type_id='type-a',
- sample_code_id='code-a',
- sample_code='S-001',
- collect_amount=9.5,
- unit_id='unit-a',
- storage_years=10,
- sample_status='jcz',
- storage_in_date='20261001',
- detect_start_date='20260929',
- void_date='20261002',
- storage_expire_date='20361001',
- is_special=1,
- special_remark='历史捐赠说明',
- remark='历史捐赠备注',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- with patch(
- 'app.crud.research_donation._migrate_donation_sample_child_refs_on_replace'
- ):
- update_protocol(
- db,
- source='research_donation',
- protocol_id=row.id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'research_donation',
- 'samples': [
- {
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-a',
- 'sampleCode': 'S-001',
- 'storageYears': 11,
- }
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- saved = db.query(BizResearchDonationSample).filter_by(
- research_donation_id=row.id, del_flag=0
- ).one()
- assert float(saved.collect_amount) == 9.5
- assert saved.unit_id == 'unit-a'
- assert saved.remark == '历史捐赠备注'
- assert saved.storage_in_date == '20261001'
- assert saved.detect_start_date == '20260929'
- assert saved.void_date == '20261002'
- assert saved.storage_expire_date == '20361001'
- assert saved.storage_years == 11
- def _protocol_sample_row(source, *, row_id, protocol_id, code_id, code, amount, remark):
- common = {
- 'id': row_id,
- 'sample_type_id': 'type-a',
- 'sample_code_id': code_id,
- 'sample_code': code,
- 'collect_amount': amount,
- 'unit_id': 'unit-a',
- 'sample_status': 'yrk',
- 'remark': remark,
- 'tenant_no': 'tenant-a',
- 'create_dept_id': '',
- 'del_flag': 0,
- }
- if source == 'cell_storage':
- return BizProtocolSample(collection_protocol_id=protocol_id, **common)
- return BizResearchDonationSample(research_donation_id=protocol_id, **common)
- def _active_protocol_samples(db, source, protocol_id):
- if source == 'cell_storage':
- return (
- db.query(BizProtocolSample)
- .filter_by(collection_protocol_id=protocol_id, del_flag=0)
- .order_by(BizProtocolSample.order_num.asc())
- .all()
- )
- return (
- db.query(BizResearchDonationSample)
- .filter_by(research_donation_id=protocol_id, del_flag=0)
- .order_by(BizResearchDonationSample.order_num.asc())
- .all()
- )
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_protocol_sample_update_uses_id_for_insert_and_reorder(source):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row_id = f'{source}-sample-reorder'
- (_cell if source == 'cell_storage' else _donation)(db, row_id)
- db.add_all(
- [
- _protocol_sample_row(
- source,
- row_id='sample-a',
- protocol_id=row_id,
- code_id='code-a',
- code='S-001',
- amount=11,
- remark='隐藏-A',
- ),
- _protocol_sample_row(
- source,
- row_id='sample-b',
- protocol_id=row_id,
- code_id='code-b',
- code='S-002',
- amount=22,
- remark='隐藏-B',
- ),
- ]
- )
- db.commit()
- migrate_target = (
- 'app.crud.collection_protocol._migrate_sample_child_refs_on_replace'
- if source == 'cell_storage'
- else 'app.crud.research_donation._migrate_donation_sample_child_refs_on_replace'
- )
- with patch(migrate_target):
- update_protocol(
- db,
- source=source,
- protocol_id=row_id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': source,
- 'samples': [
- {
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-c',
- 'sampleCode': 'S-003',
- },
- {
- 'id': 'sample-b',
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-b',
- 'sampleCode': 'S-002',
- },
- {
- 'id': 'sample-a',
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-a',
- 'sampleCode': 'S-001',
- },
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- saved = _active_protocol_samples(db, source, row_id)
- assert [row.sample_code for row in saved] == ['S-003', 'S-002', 'S-001']
- assert [row.remark for row in saved] == [None, '隐藏-B', '隐藏-A']
- assert [float(row.collect_amount) if row.collect_amount is not None else None for row in saved] == [
- None,
- 22.0,
- 11.0,
- ]
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_protocol_sample_update_uses_id_when_replacing_code(source):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row_id = f'{source}-sample-recode'
- (_cell if source == 'cell_storage' else _donation)(db, row_id)
- db.add(
- _protocol_sample_row(
- source,
- row_id='sample-recode',
- protocol_id=row_id,
- code_id='code-a',
- code='S-001',
- amount=15,
- remark='必须保留',
- )
- )
- db.commit()
- migrate_target = (
- 'app.crud.collection_protocol._migrate_sample_child_refs_on_replace'
- if source == 'cell_storage'
- else 'app.crud.research_donation._migrate_donation_sample_child_refs_on_replace'
- )
- with patch(migrate_target):
- update_protocol(
- db,
- source=source,
- protocol_id=row_id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': source,
- 'samples': [
- {
- 'sampleId': 'sample-recode',
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-c',
- 'sampleCode': 'S-003',
- }
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- saved = _active_protocol_samples(db, source, row_id)[0]
- assert saved.sample_code_id == 'code-c'
- assert saved.sample_code == 'S-003'
- assert float(saved.collect_amount) == 15
- assert saved.remark == '必须保留'
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- @pytest.mark.parametrize('duplicate_field', ['sampleCode', 'sampleCodeId'])
- def test_protocol_sample_write_rejects_duplicate_identity(source, duplicate_field):
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- if duplicate_field == 'sampleCode':
- samples = [
- {'sampleTypeId': 'type-a', 'sampleCode': 'DUP-001'},
- {'sampleTypeId': 'type-a', 'sampleCode': 'DUP-001'},
- ]
- else:
- samples = [
- {'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'},
- {'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-ALT'},
- ]
- overrides = {'samples': samples}
- if source == 'research_donation':
- overrides.update({'donationNo': 'D-DUP', 'protocolNo': None})
- with pytest.raises(ValueError, match=f'{duplicate_field}.*重复'):
- create_protocol(
- db,
- body=_body(source, **overrides),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_protocol_sample_update_rejects_duplicate_sample_id(source):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row_id = f'{source}-duplicate-sample-id'
- (_cell if source == 'cell_storage' else _donation)(db, row_id)
- db.add(
- _protocol_sample_row(
- source,
- row_id='sample-duplicate-id',
- protocol_id=row_id,
- code_id='code-a',
- code='S-001',
- amount=10,
- remark='不能双重继承',
- )
- )
- db.commit()
- migrate_target = (
- 'app.crud.collection_protocol._migrate_sample_child_refs_on_replace'
- if source == 'cell_storage'
- else 'app.crud.research_donation._migrate_donation_sample_child_refs_on_replace'
- )
- with patch(migrate_target), pytest.raises(ValueError, match='sampleId.*重复'):
- update_protocol(
- db,
- source=source,
- protocol_id=row_id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': source,
- 'samples': [
- {
- 'id': 'sample-duplicate-id',
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-b',
- 'sampleCode': 'S-002',
- },
- {
- 'sampleId': 'sample-duplicate-id',
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-c',
- 'sampleCode': 'S-003',
- },
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_protocol_sample_update_rejects_sample_id_from_another_protocol(source):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- owner_id = f'{source}-owner'
- other_id = f'{source}-other'
- creator = _cell if source == 'cell_storage' else _donation
- creator(db, owner_id)
- creator(db, other_id)
- db.add_all(
- [
- _protocol_sample_row(
- source,
- row_id='sample-owner',
- protocol_id=owner_id,
- code_id='code-a',
- code='S-001',
- amount=10,
- remark='本协议',
- ),
- _protocol_sample_row(
- source,
- row_id='sample-foreign',
- protocol_id=other_id,
- code_id='code-b',
- code='S-002',
- amount=20,
- remark='其他协议',
- ),
- ]
- )
- db.commit()
- with pytest.raises(ValueError, match='样本.*不属于当前协议'):
- update_protocol(
- db,
- source=source,
- protocol_id=owner_id,
- body=MarketProtocolBody.model_validate(
- {
- 'source': source,
- 'samples': [
- {
- 'id': 'sample-foreign',
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': 'code-a',
- 'sampleCode': 'S-001',
- }
- ],
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- def test_partial_collection_update_validates_against_resulting_region():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-partial', sales='u1')
- db.commit()
- update_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-partial',
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'collection': {'hospitalId': 'hospital-a'},
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- collection = db.query(BizProtocolCollectionInfo).filter_by(
- collection_protocol_id='cell-partial', del_flag=0
- ).one()
- assert collection.region_id == 'region-a'
- assert collection.hospital_id == 'hospital-a'
- def test_cell_partial_customer_update_preserves_ids_and_unspecified_fields():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-party')
- db.add_all(
- [
- BizCustomer(id='mother-c', customer_name='母客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
- BizCustomer(id='father-c', customer_name='父客户', sex='0', tenant_no='tenant-a', create_dept_id='', del_flag=0),
- BizCustomer(id='newborn-c', customer_name='子客户', sex='2', tenant_no='tenant-a', create_dept_id='', del_flag=0),
- BizProtocolPartyCustomer(
- id='party-cell', collection_protocol_id='cell-party',
- customer_id='mother-c', father_customer_id='father-c', newborn_customer_id='newborn-c',
- mother_name='母亲', mother_phone='旧电话', mother_id_card='ID-M',
- father_name='父亲', father_phone='父电话', newborn_name='宝宝',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- ]
- )
- db.commit()
- update_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-party',
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'customer': {'motherPhone': '新电话', 'remark': '只改电话'},
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- party = db.get(BizProtocolPartyCustomer, 'party-cell')
- assert (party.customer_id, party.father_customer_id, party.newborn_customer_id) == (
- 'mother-c', 'father-c', 'newborn-c'
- )
- assert party.mother_name == '母亲'
- assert party.mother_id_card == 'ID-M'
- assert party.father_name == '父亲'
- assert party.father_phone == '父电话'
- assert party.newborn_name == '宝宝'
- assert party.mother_phone == '新电话'
- assert party.remark == '只改电话'
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_protocol_update_can_rebind_same_tenant_customer_id(source):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- db.add_all(
- [
- BizCustomer(id='linked-c', customer_name='已关联', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
- BizCustomer(id='unrelated-c', customer_name='无关客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
- ]
- )
- if source == 'cell_storage':
- _cell(db, 'customer-idor')
- db.add(BizProtocolPartyCustomer(id='linked-party', collection_protocol_id='customer-idor', customer_id='linked-c', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
- else:
- _donation(db, 'customer-idor')
- db.add(BizResearchDonationParty(id='linked-party', research_donation_id='customer-idor', customer_id='linked-c', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
- db.commit()
- update_protocol(
- db,
- source=source,
- protocol_id='customer-idor',
- body=MarketProtocolBody.model_validate(
- {
- 'source': source,
- 'customer': {'customerId': 'unrelated-c', 'motherName': '无关客户'},
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- if source == 'cell_storage':
- party = (
- db.query(BizProtocolPartyCustomer)
- .filter_by(collection_protocol_id='customer-idor', del_flag=0)
- .one()
- )
- else:
- party = (
- db.query(BizResearchDonationParty)
- .filter_by(research_donation_id='customer-idor', del_flag=0)
- .one()
- )
- assert party.customer_id == 'unrelated-c'
- assert party.mother_name == '无关客户'
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_protocol_update_rejects_missing_or_cross_tenant_customer_id(source):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- db.add(
- BizCustomer(
- id='other-tenant-c',
- customer_name='外租户',
- sex='1',
- tenant_no='tenant-b',
- create_dept_id='',
- del_flag=0,
- )
- )
- if source == 'cell_storage':
- _cell(db, 'customer-cross')
- db.add(
- BizProtocolPartyCustomer(
- id='cross-party',
- collection_protocol_id='customer-cross',
- mother_name='原姓名',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- else:
- _donation(db, 'customer-cross')
- db.add(
- BizResearchDonationParty(
- id='cross-party',
- research_donation_id='customer-cross',
- mother_name='原姓名',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- with pytest.raises(ValueError, match='客户'):
- update_protocol(
- db,
- source=source,
- protocol_id='customer-cross',
- body=MarketProtocolBody.model_validate(
- {
- 'source': source,
- 'customer': {'customerId': 'other-tenant-c', 'motherName': '外租户'},
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_protocol_update_does_not_mutate_unrelated_customer_by_id_card(source):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- db.add(BizCustomer(id='unrelated-card', customer_name='身份证客户', sex='1', id_card_no='CARD-1', tenant_no='tenant-a', create_dept_id='', del_flag=0))
- if source == 'cell_storage':
- _cell(db, 'card-idor')
- db.add(BizProtocolPartyCustomer(id='card-party', collection_protocol_id='card-idor', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
- else:
- _donation(db, 'card-idor')
- db.add(BizResearchDonationParty(id='card-party', research_donation_id='card-idor', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
- db.commit()
- update_protocol(
- db,
- source=source,
- protocol_id='card-idor',
- body=MarketProtocolBody.model_validate(
- {
- 'source': source,
- 'customer': {'motherName': '协议姓名', 'motherIdCard': 'CARD-1'},
- }
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizCustomer, 'unrelated-card').customer_name == '身份证客户'
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_protocol_create_allows_existing_customer_ids(source):
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- db.add(BizCustomer(id='existing-c', customer_name='已有客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0))
- db.commit()
- created = create_protocol(
- db,
- body=_body(
- source,
- **({'donationNo': 'D-CUSTOMER', 'protocolNo': None} if source == 'research_donation' else {}),
- customer={'customerId': 'existing-c', 'motherName': '已有客户'},
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert (created.get('customer') or {}).get('customerId') == 'existing-c'
- def test_market_protocol_body_accepts_sample_payments():
- from app.schemas.market_mobile import MarketProtocolBody
- body = MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'protocolNo': 'P-PAY-1',
- 'customer': {'motherName': '李四'},
- 'samplePayments': [
- {
- 'sampleCode': 'S-001',
- 'payMode': 'dj',
- 'contractAmount': 20000,
- 'lumpSumDeadline': '20261231',
- 'schedules': [
- {
- 'scheduleNo': 1,
- 'dueAmount': 20000,
- 'dueDate': '20261231',
- }
- ],
- }
- ],
- }
- )
- assert body.sample_payments is not None
- assert len(body.sample_payments) == 1
- assert body.sample_payments[0].sample_code == 'S-001'
- assert body.sample_payments[0].pay_mode == 'dj'
- def test_market_protocol_money_json_keeps_decimal_18_2_through_service_payload():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import (
- _market_sample_payment_payload,
- _persist_sample_payments_if_any,
- )
- amount = '9999999999999999.99'
- body = MarketProtocolBody.model_validate_json(json.dumps({
- 'source': 'cell_storage',
- 'protocolTotalAmount': amount,
- 'samplePayments': [{
- 'sampleCode': 'S-001',
- 'payMode': 'fq',
- 'contractAmount': amount,
- 'installmentRatios': [amount],
- 'schedules': [{
- 'scheduleNo': 1,
- 'dueAmount': amount,
- 'transfers': [{'transferAmount': amount, 'transferType': 'bank'}],
- }],
- }],
- }))
- assert body.protocol_total_amount == Decimal(amount)
- assert body.sample_payments[0].contract_amount == Decimal(amount)
- assert body.sample_payments[0].schedules[0].due_amount == Decimal(amount)
- assert body.sample_payments[0].schedules[0].transfers[0].transfer_amount == Decimal(amount)
- db = _session()
- row = _cell(db, 'decimal-boundary')
- row.payment_model_version = 2
- sample = BizProtocolSample(
- id='sample-decimal', collection_protocol_id=row.id,
- sample_code='S-001', tenant_no='tenant-a', del_flag=0,
- )
- db.add(sample)
- db.commit()
- payload = _market_sample_payment_payload(
- db,
- row=row,
- body=body,
- ctx=_ctx('specialist'),
- saved_samples=[sample],
- )
- assert payload[0]['contractAmount'] == Decimal(amount)
- assert payload[0]['schedules'][0]['dueAmount'] == Decimal(amount)
- with patch(
- 'app.services.market_protocol.psp_crud.save_sample_payments_for_protocol'
- ) as save:
- _persist_sample_payments_if_any(
- db,
- row=row,
- body=body,
- ctx=_ctx('specialist'),
- saved_samples=[sample],
- )
- crud_payload = save.call_args.kwargs['sample_payments']
- assert crud_payload[0]['contractAmount'] == Decimal(amount)
- assert crud_payload[0]['schedules'][0]['dueAmount'] == Decimal(amount)
- def test_mobile_payment_response_serializes_decimal_as_fixed_string():
- from app.crud.protocol_sample_payment import _transfer_to_dict
- transfer = BizProtocolPaymentTransfer(
- id='t-boundary',
- transfer_amount=Decimal('9999999999999999.99'),
- tenant_no='tenant-a',
- del_flag=0,
- )
- response = _transfer_to_dict(_session(), transfer, 'tenant-a', money_as_string=True)
- encoded = json.dumps(response)
- assert response['transferAmount'] == '9999999999999999.99'
- assert '1e+16' not in encoded.lower()
- def test_protocol_create_persists_sample_payments():
- from app.services.market_protocol import create_protocol, get_protocol
- db = _session()
- _masters(db)
- body = _body(
- samplePayments=[
- {
- 'sampleCode': 'S-001',
- 'payMode': 'dj',
- 'contractAmount': 20000,
- 'lumpSumDeadline': '20261231',
- 'schedules': [
- {
- 'scheduleNo': 1,
- 'dueAmount': 20000,
- 'dueDate': '20261231',
- 'scheduleStatus': 'open',
- 'transfers': [],
- }
- ],
- }
- ]
- )
- created = create_protocol(
- db, body=body, ctx=_ctx('specialist'), user_info=_user()
- )
- protocol_id = created['basic']['id']
- pays = created.get('samplePayments') or []
- assert len(pays) == 1
- assert pays[0]['sampleCode'] == 'S-001'
- assert pays[0]['payMode'] == 'dj'
- assert pays[0]['contractAmount'] == '20000.00'
- assert pays[0]['schedules'][0]['dueAmount'] == '20000.00'
- assert str(pays[0]['schedules'][0].get('dueDate') or '').replace('-', '')[:8] == '20261231'
- detail = get_protocol(
- db,
- source='cell_storage',
- protocol_id=protocol_id,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert len(detail.get('samplePayments') or []) == 1
- assert detail['samplePayments'][0]['sampleCode'] == 'S-001'
- assert detail['samplePayments'][0]['contractAmount'] == '20000.00'
- @pytest.mark.parametrize('scope_kind', ['specialist', 'manager'])
- def test_protocol_update_rejects_foreign_sample_payment_id(scope_kind):
- from app.services.market_protocol import _market_sample_payment_payload
- db = _session()
- _masters(db)
- owner = _cell(db, 'pay-owner')
- _cell(db, 'pay-foreign')
- owner_sample = BizProtocolSample(
- id='sample-owner', collection_protocol_id='pay-owner',
- sample_type_id='type-a', sample_code='S-001', tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- db.add_all(
- [
- owner_sample,
- BizProtocolSamplePayment(
- id='payment-foreign', collection_protocol_id='pay-foreign',
- protocol_sample_id='sample-foreign', sample_code='S-FOREIGN',
- pay_mode='dj', contract_amount=999, tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- ),
- ]
- )
- db.commit()
- body = _body(
- samples=None,
- attachments=None,
- samplePayments=[
- {
- 'id': 'payment-foreign',
- 'sampleCode': 'S-001',
- 'protocolSampleId': 'sample-foreign',
- 'payMode': 'dj',
- 'contractAmount': 100,
- 'schedules': [
- {'scheduleNo': 1, 'dueAmount': 100, 'dueDate': '20261031'}
- ],
- }
- ]
- )
- with pytest.raises(ValueError, match='缴费方案'):
- _market_sample_payment_payload(
- db,
- row=owner,
- body=body,
- ctx=_ctx(scope_kind, 'u1' if scope_kind == 'specialist' else 'manager-a'),
- saved_samples=[owner_sample],
- )
- db.expire_all()
- foreign = db.get(BizProtocolSamplePayment, 'payment-foreign')
- assert foreign.collection_protocol_id == 'pay-foreign'
- assert foreign.sample_code == 'S-FOREIGN'
- assert float(foreign.contract_amount) == 999
- def test_protocol_payment_payload_uses_saved_sample_and_preserves_submitted_transfer():
- from app.crud.protocol_sample_payment import save_sample_payments_for_protocol
- from app.services.market_protocol import _market_sample_payment_payload
- db = _session()
- _masters(db)
- owner = _cell(db, 'pay-safe')
- sample = BizProtocolSample(
- id='sample-safe', collection_protocol_id='pay-safe',
- sample_type_id='type-a', sample_code='S-SAFE', tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- )
- payment = BizProtocolSamplePayment(
- id='payment-safe', collection_protocol_id='pay-safe',
- protocol_sample_id='sample-safe-old', sample_code='S-SAFE', pay_mode='dj',
- contract_amount=100, tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- schedule = BizProtocolPaymentSchedule(
- id='schedule-safe', sample_payment_id='payment-safe',
- collection_protocol_id='pay-safe', sample_code='S-SAFE', schedule_no=1,
- due_amount=100, due_date='20261031', schedule_status='open',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- transfer = BizProtocolPaymentTransfer(
- id='transfer-safe', schedule_id='schedule-safe',
- sample_payment_id='payment-safe', collection_protocol_id='pay-safe',
- sample_code='S-SAFE', transfer_amount=20, transfer_type='bank',
- transfer_attachments='payment/existing.jpg', pay_status='jfqrz',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- db.add_all([sample, payment, schedule, transfer])
- db.commit()
- body = _body(
- samples=None,
- attachments=None,
- samplePayments=[
- {
- 'id': 'payment-safe', 'sampleCode': 'S-SAFE',
- 'protocolSampleId': 'foreign-sample', 'payMode': 'dj',
- 'contractAmount': 100,
- 'schedules': [
- {
- 'id': 'foreign-schedule', 'scheduleNo': 1,
- 'dueAmount': 100, 'dueDate': '20261031',
- 'scheduleStatus': 'void',
- 'transfers': [
- {
- 'id': 'transfer-safe', 'transferAmount': 999,
- 'transferAttachments': 'evil.jpg',
- }
- ],
- }
- ],
- }
- ],
- )
- payload = _market_sample_payment_payload(
- db,
- row=owner,
- body=body,
- ctx=_ctx('specialist'),
- saved_samples=[sample],
- )
- assert payload[0]['protocolSampleId'] == 'sample-safe'
- assert payload[0]['schedules'][0]['scheduleStatus'] == 'open'
- assert payload[0]['schedules'][0]['transfers'] == [{'id': 'transfer-safe'}]
- save_sample_payments_for_protocol(
- db,
- collection_protocol_id='pay-safe',
- tenant_no='tenant-a',
- user_id='u1',
- sample_payments=payload,
- )
- db.expire_all()
- saved_transfer = db.get(BizProtocolPaymentTransfer, 'transfer-safe')
- assert float(saved_transfer.transfer_amount) == 20
- assert saved_transfer.transfer_attachments == 'payment/existing.jpg'
- assert saved_transfer.pay_status == 'jfqrz'
- assert db.get(BizProtocolSamplePayment, 'payment-safe').protocol_sample_id == 'sample-safe'
- def test_save_sample_payments_serializes_decimal_installment_ratios_as_fixed_strings():
- from app.crud.protocol_sample_payment import save_sample_payments_for_protocol
- db = _session()
- _cell(db, 'decimal-ratios')
- save_sample_payments_for_protocol(
- db,
- collection_protocol_id='decimal-ratios',
- tenant_no='tenant-a',
- user_id='u1',
- sample_payments=[
- {
- 'sampleCode': 'S-DECIMAL',
- 'payMode': 'fq',
- 'contractAmount': Decimal('100.00'),
- 'installmentCount': 2,
- 'installmentRatios': [
- Decimal('20.00'),
- Decimal('80.00'),
- Decimal('9999999999999999.99'),
- ],
- 'schedules': [
- {'scheduleNo': 1, 'dueAmount': Decimal('20.00'), 'dueDate': '20261101'},
- {'scheduleNo': 2, 'dueAmount': Decimal('80.00'), 'dueDate': '20261201'},
- ],
- },
- {
- 'sampleCode': 'S-STRING',
- 'payMode': 'fq',
- 'contractAmount': Decimal('100.00'),
- 'installmentCount': 1,
- 'installmentRatios': 'legacy-string',
- 'schedules': [
- {'scheduleNo': 1, 'dueAmount': Decimal('100.00'), 'dueDate': '20261101'},
- ],
- },
- ],
- )
- saved = (
- db.query(BizProtocolSamplePayment)
- .filter(
- BizProtocolSamplePayment.collection_protocol_id == 'decimal-ratios',
- BizProtocolSamplePayment.sample_code == 'S-DECIMAL',
- )
- .one()
- )
- assert json.loads(saved.installment_ratios) == [
- '20.00',
- '80.00',
- '9999999999999999.99',
- ]
- legacy = (
- db.query(BizProtocolSamplePayment)
- .filter(
- BizProtocolSamplePayment.collection_protocol_id == 'decimal-ratios',
- BizProtocolSamplePayment.sample_code == 'S-STRING',
- )
- .one()
- )
- assert legacy.installment_ratios == 'legacy-string'
- def test_protocol_update_rejects_unscoped_freight_target():
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _cell(db, 'freight-owner')
- _cell(db, 'freight-hidden', sales='u2', manager='manager-other', region='region-b')
- db.add_all(
- [
- BizProtocolSample(
- id='sample-owner', collection_protocol_id='freight-owner',
- sample_type_id='type-a', sample_code='S-001', tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- ),
- BizProtocolSample(
- id='sample-hidden', collection_protocol_id='freight-hidden',
- sample_type_id='type-a', sample_code='S-HIDDEN', tenant_no='tenant-a',
- create_dept_id='', del_flag=0,
- ),
- BizSampleFreight(
- id='freight-hidden', freight_no='F-HIDDEN', transport_mode='zxc',
- freight_amount=80, tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizSampleFreightItem(
- id='freight-item-hidden', freight_id='freight-hidden',
- sample_source='cell_storage', sample_id='sample-hidden',
- protocol_id='freight-hidden', sample_code='S-HIDDEN',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- ]
- )
- db.commit()
- body = _body(
- protocolNo='CELL-freight-owner',
- samples=[
- {
- 'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a',
- 'sampleCode': 'S-001', 'unitId': 'unit-a',
- 'collectAmount': 10, 'freightId': 'freight-hidden',
- }
- ]
- )
- with pytest.raises(HTTPException):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id='freight-owner',
- body=body,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizProtocolSample, 'sample-owner').del_flag == 0
- hidden_item = db.get(BizSampleFreightItem, 'freight-item-hidden')
- assert hidden_item.sample_id == 'sample-hidden'
- def test_protocol_body_rejects_status_finance_and_conversion_fields():
- from app.schemas.market_mobile import MarketProtocolBody
- forbidden = [
- {'auditStatus': 'shtg'},
- {'protocolStatus': 'yzf'},
- {'delFlag': 1},
- {'refundAmount': 100},
- {'financeRemark': '通过'},
- {'toResearchTime': '2026-10-10'},
- ]
- for field in forbidden:
- with pytest.raises(ValidationError):
- MarketProtocolBody.model_validate({'source': 'cell_storage', **field})
- def test_cross_tenant_or_disabled_referenced_user_is_rejected():
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- db.add(
- SysUser(
- id='disabled', user_no='disabled', nick_name='停用', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0
- )
- )
- db.commit()
- with pytest.raises(ValueError, match='用户'):
- create_protocol(
- db,
- body=_body(collection={'regionId': 'region-a', 'hospitalId': 'hospital-a', 'regionManagerUserId': 'disabled'}),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- @pytest.mark.parametrize(
- ('field_name', 'scope_kind', 'collection'),
- [
- (
- 'salesUserId',
- 'manager',
- {
- 'regionId': 'region-a',
- 'hospitalId': 'hospital-a',
- 'salesUserId': 'u2',
- 'regionManagerUserId': 'u1',
- },
- ),
- (
- 'regionManagerUserId',
- 'specialist',
- {
- 'regionId': 'region-a',
- 'hospitalId': 'hospital-a',
- 'salesUserId': 'u1',
- 'regionManagerUserId': 'u2',
- },
- ),
- ],
- )
- def test_protocol_create_rejects_assignment_without_target_region_staff_binding(
- source, field_name, scope_kind, collection
- ):
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- overrides = {'collection': collection, 'attachments': {}}
- if source == 'research_donation':
- overrides.update({'donationNo': 'D-OUTSIDER', 'protocolNo': None})
- with pytest.raises(ValueError, match=f'{field_name}.*采集地区人员'):
- create_protocol(
- db,
- body=_body(source, **overrides),
- ctx=_ctx(scope_kind),
- user_info=_user(),
- )
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- @pytest.mark.parametrize(
- ('field_name', 'scope_kind'),
- [('salesUserId', 'manager'), ('regionManagerUserId', 'specialist')],
- )
- def test_protocol_update_rejects_changed_assignment_without_target_region_staff_binding(
- source, field_name, scope_kind
- ):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row_id = f'{source}-{field_name}-outsider'
- if source == 'cell_storage':
- _cell(
- db,
- row_id,
- sales='u1',
- manager='u1' if scope_kind == 'manager' else 'manager-a',
- )
- else:
- _donation(
- db,
- row_id,
- sales='u1',
- manager='u1' if scope_kind == 'manager' else 'manager-a',
- )
- db.commit()
- with pytest.raises(ValueError, match=f'{field_name}.*采集地区人员'):
- update_protocol(
- db,
- source=source,
- protocol_id=row_id,
- body=MarketProtocolBody.model_validate(
- {'source': source, 'collection': {field_name: 'u2'}}
- ),
- ctx=_ctx(scope_kind),
- user_info=_user(),
- )
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- @pytest.mark.parametrize(
- ('historical_field', 'scope_kind'),
- [('sales_user_id', 'manager'), ('region_manager_user_id', 'specialist')],
- )
- def test_protocol_update_keeps_unchanged_historical_assignment_without_binding(
- source, historical_field, scope_kind
- ):
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- row_id = f'{source}-{historical_field}-legacy'
- kwargs = {'sales': 'u1', 'manager': 'manager-a'}
- if historical_field == 'sales_user_id':
- kwargs.update({'sales': 'u2', 'manager': 'u1'})
- else:
- kwargs['manager'] = 'u2'
- if source == 'cell_storage':
- _cell(db, row_id, **kwargs)
- else:
- _donation(db, row_id, **kwargs)
- db.commit()
- result = update_protocol(
- db,
- source=source,
- protocol_id=row_id,
- body=MarketProtocolBody.model_validate(
- {'source': source, 'remark': '仅修改非归属字段'}
- ),
- ctx=_ctx(scope_kind),
- user_info=_user(),
- )
- assert result['basic']['remark'] == '仅修改非归属字段'
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- @pytest.mark.parametrize(
- ('sample_code_id', 'sample_code'),
- [
- ('code-cross', 'S-CROSS'),
- ('code-deleted', 'S-DELETED'),
- ('code-a', 'S-WRONG'),
- ],
- )
- def test_protocol_rejects_invalid_or_inconsistent_sample_code_reference(
- source, sample_code_id, sample_code
- ):
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- payload = {
- 'samples': [
- {
- 'sampleTypeId': 'type-a',
- 'sampleCodeId': sample_code_id,
- 'sampleCode': sample_code,
- 'unitId': 'unit-a',
- }
- ]
- }
- if source == 'research_donation':
- payload.update({'donationNo': 'D-CODE', 'protocolNo': None})
- with pytest.raises(ValueError, match='样本编码'):
- create_protocol(
- db,
- body=_body(source, **payload),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- def test_cell_update_rolls_back_main_and_existing_children_after_late_failure():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-update')
- db.add_all(
- [
- BizProtocolPartyCustomer(
- id='party-old', collection_protocol_id='cell-update', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizProtocolSample(
- id='sample-old', collection_protocol_id='cell-update', sample_code='OLD-CODE', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- ]
- )
- db.commit()
- body = MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'remark': '新备注',
- 'customer': {'motherName': '新姓名'},
- 'collection': {'medicalStaff': '新医护'},
- 'samples': [{'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'}],
- }
- )
- with patch(
- 'app.services.market_protocol.cell_crud.save_samples',
- side_effect=RuntimeError('late failure'),
- ):
- with pytest.raises(RuntimeError, match='late'):
- update_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-update',
- body=body,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizCollectionProtocol, 'cell-update').remark is None
- assert db.get(BizProtocolPartyCustomer, 'party-old').mother_name == '旧姓名'
- assert db.get(BizProtocolPartyCustomer, 'party-old').del_flag == 0
- assert db.get(BizProtocolSample, 'sample-old').sample_code == 'OLD-CODE'
- assert db.get(BizProtocolCollectionInfo, 'ci-cell-update').medical_staff is None
- def test_donation_update_rolls_back_main_and_old_children_after_soft_delete():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _donation(db, 'don-update')
- db.add_all(
- [
- BizResearchDonationParty(
- id='dparty-old', research_donation_id='don-update', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- BizResearchDonationSample(
- id='dsample-old', research_donation_id='don-update', sample_code='OLD-CODE', tenant_no='tenant-a', create_dept_id='', del_flag=0
- ),
- ]
- )
- db.commit()
- body = MarketProtocolBody.model_validate(
- {
- 'source': 'research_donation',
- 'donationNo': 'DON-don-update',
- 'remark': '新备注',
- 'customer': {'motherName': '新姓名'},
- 'collection': {'medicalStaff': '新医护'},
- 'samples': [{'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'}],
- }
- )
- with patch(
- 'app.crud.research_donation._create_samples',
- side_effect=RuntimeError('late failure'),
- ):
- with pytest.raises(RuntimeError, match='late'):
- update_protocol(
- db,
- source='research_donation',
- protocol_id='don-update',
- body=body,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizResearchDonation, 'don-update').remark is None
- assert db.get(BizResearchDonationParty, 'dparty-old').mother_name == '旧姓名'
- assert db.get(BizResearchDonationParty, 'dparty-old').del_flag == 0
- assert db.get(BizResearchDonationSample, 'dsample-old').del_flag == 0
- assert db.get(BizResearchDonationCollection, 'dci-don-update').medical_staff is None
- def test_manager_can_update_donation_in_scope_but_cannot_move_it_out():
- from app.schemas.market_mobile import MarketProtocolBody
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _donation(db, 'don-manager', sales='u2', manager='u1')
- db.add(
- BizResearchDonationParty(
- id='dparty-manager', research_donation_id='don-manager', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
- )
- )
- db.commit()
- result = update_protocol(
- db,
- source='research_donation',
- protocol_id='don-manager',
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'research_donation',
- 'donationNo': 'DON-don-manager',
- 'remark': '经理已修改',
- 'customer': {'motherName': '新姓名'},
- 'collection': {'medicalStaff': '新医护'},
- }
- ),
- ctx=_ctx('manager'),
- user_info=_user(),
- )
- assert result['basic']['remark'] == '经理已修改'
- assert result['customer']['motherName'] == '新姓名'
- assert result['collection']['medicalStaff'] == '新医护'
- with pytest.raises(ValueError, match='范围'):
- update_protocol(
- db,
- source='research_donation',
- protocol_id='don-manager',
- body=MarketProtocolBody.model_validate(
- {
- 'source': 'research_donation',
- 'collection': {
- 'regionId': 'region-b',
- 'regionManagerUserId': 'u1',
- },
- }
- ),
- ctx=_ctx('manager'),
- user_info=_user(),
- )
- db.expire_all()
- collection = db.query(BizResearchDonationCollection).filter_by(
- research_donation_id='don-manager', del_flag=0
- ).one()
- assert collection.region_id == 'region-a'
- assert collection.region_manager_user_id == 'u1'
- def test_protocol_routes_expose_delete_but_no_web_only_admin_mutations():
- from app.api.market_mobile import router
- for scope in ('specialist', 'manager'):
- prefix = f'/mini/market/{scope}/protocols/{{protocol_id}}'
- routes = {
- route.path: route.methods
- for route in router.routes
- if route.path.startswith(f'/mini/market/{scope}/protocols')
- }
- assert 'DELETE' in routes[prefix]
- assert f'{prefix}/audit' not in routes
- assert all(not any(word in path for word in ('void', 'refund', 'convert', 'to-research', 'to-storage')) for path in routes)
- assert not any(
- route.path.startswith(f'/mini/market/{scope}/samples')
- or route.path.startswith(f'/mini/market/{scope}/sample/attachments')
- for route in router.routes
- )
- def test_protocol_delete_rechecks_scoped_access_before_crud(monkeypatch):
- from app.services.market_protocol import delete_protocol
- db = _session()
- _masters(db)
- own = _cell(db, 'own', sales='u1')
- other = _cell(db, 'other', sales='u2')
- db.commit()
- delete_calls = []
- monkeypatch.setattr(
- 'app.services.market_protocol.cell_crud.soft_delete_collection_protocol',
- lambda *args, **kwargs: delete_calls.append((args, kwargs)),
- )
- delete_protocol(
- db,
- source='cell_storage',
- protocol_id=own.id,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert delete_calls[0][0][1] == own.id
- with pytest.raises(HTTPException, match='无权'):
- delete_protocol(
- db,
- source='cell_storage',
- protocol_id=other.id,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- def test_protocol_list_contains_mobile_card_fields():
- from app.services.market_protocol import list_protocols
- db = _session()
- _masters(db)
- row = _cell(db, 'card')
- row.protocol_total_amount = 20000
- db.add_all([
- BizProtocolPartyCustomer(
- id='party-card', collection_protocol_id=row.id, mother_name='李安然',
- mother_phone='13800138000', mother_id_card='340100', father_name='王先生',
- newborn_sex='1', delivery_mode='sc', newborn_weight_gram=3200,
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizProtocolSample(
- id='sample-card', collection_protocol_id=row.id, sample_type_id='type-a',
- sample_code='S-CARD', tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- ])
- db.commit()
- items, total = list_protocols(db, ctx=_ctx('specialist'), user_info=_user())
- assert total == 1
- assert items[0]['motherName'] == '李安然'
- assert items[0]['sampleCode'] == 'S-CARD'
- assert items[0]['sampleTypeName'] == '样本A'
- assert items[0]['regionName'] == 'A区'
- assert items[0]['motherPhone'] == '13800138000'
- assert items[0]['motherIdCard'] == '340100'
- assert items[0]['fatherName'] == '王先生'
- assert items[0]['newbornSex'] == '1'
- assert items[0]['deliveryMode'] == 'sc'
- assert items[0]['newbornWeightGram'] == 3200
- assert items[0]['salesUserName'] == '当前用户'
- by_customer, customer_total = list_protocols(
- db, ctx=_ctx('specialist'), user_info=_user(), keyword='李安然'
- )
- by_sample, sample_total = list_protocols(
- db, ctx=_ctx('specialist'), user_info=_user(), keyword='S-CARD'
- )
- by_phone, phone_total = list_protocols(
- db, ctx=_ctx('specialist'), user_info=_user(), keyword='13800138000'
- )
- by_id_card, id_card_total = list_protocols(
- db, ctx=_ctx('specialist'), user_info=_user(), keyword='340100'
- )
- assert customer_total == sample_total == phone_total == id_card_total == 1
- assert {
- by_customer[0]['id'],
- by_sample[0]['id'],
- by_phone[0]['id'],
- by_id_card[0]['id'],
- } == {row.id}
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- def test_protocol_list_and_detail_resolve_tenant_void_type_label(source):
- from app.services.market_protocol import get_protocol, list_protocols
- db = _session()
- _masters(db)
- db.add(
- SysDictData(
- id=f'void-dict-{source}',
- dict_type='xyzflx',
- dict_value='tenant-custom',
- dict_label='租户自定义作废',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- if source == 'cell_storage':
- row = _cell(db, 'void-label-cell')
- row.protocol_status = 'yzf'
- row.void_type = 'tenant-custom'
- else:
- row = _donation(db, 'void-label-donation')
- row.donation_status = 'yzf'
- row.void_type = 'tenant-custom'
- db.commit()
- items, total = list_protocols(
- db,
- ctx=_ctx('specialist'),
- user_info=_user(),
- source=source,
- )
- detail = get_protocol(
- db,
- source=source,
- protocol_id=row.id,
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert total == 1
- assert items[0]['voidType'] == 'tenant-custom'
- assert items[0]['voidTypeLabel'] == '租户自定义作废'
- assert detail['basic']['voidType'] == 'tenant-custom'
- assert detail['basic']['voidTypeLabel'] == '租户自定义作废'
- def test_unknown_void_type_label_preserves_original_code():
- from app.services.market_protocol import list_protocols
- db = _session()
- _masters(db)
- row = _cell(db, 'void-label-unknown')
- row.protocol_status = 'yzf'
- row.void_type = 'legacy-unknown'
- db.commit()
- items, _ = list_protocols(
- db,
- ctx=_ctx('specialist'),
- user_info=_user(),
- source='cell_storage',
- )
- assert items[0]['voidType'] == 'legacy-unknown'
- assert items[0]['voidTypeLabel'] == 'legacy-unknown'
- def test_protocol_list_filters_status_create_time_and_sample_collect_time():
- from app.services.market_protocol import list_protocols
- db = _session()
- _masters(db)
- hit = _cell(db, 'hit', created=datetime(2026, 10, 8, 9, 0))
- hit.protocol_status = 'sxz'
- db.query(BizProtocolCollectionInfo).filter_by(
- collection_protocol_id=hit.id
- ).update({'sample_collect_time': datetime(2026, 10, 9, 10, 0)})
- miss_status = _cell(db, 'miss-status', created=datetime(2026, 10, 8, 9, 0))
- miss_status.protocol_status = 'dsx'
- db.query(BizProtocolCollectionInfo).filter_by(
- collection_protocol_id=miss_status.id
- ).update({'sample_collect_time': datetime(2026, 10, 9, 10, 0)})
- miss_date = _cell(db, 'miss-date', created=datetime(2026, 9, 1, 9, 0))
- miss_date.protocol_status = 'sxz'
- db.query(BizProtocolCollectionInfo).filter_by(
- collection_protocol_id=miss_date.id
- ).update({'sample_collect_time': datetime(2026, 9, 2, 10, 0)})
- db.commit()
- items, total = list_protocols(
- db,
- ctx=_ctx('specialist'),
- user_info=_user(),
- source='cell_storage',
- protocol_status='sxz',
- create_time_start='2026-10-01',
- create_time_end='2026-10-13',
- sample_collect_time_start='2026-10-01',
- sample_collect_time_end='2026-10-13',
- )
- assert total == 1
- assert [item['id'] for item in items] == ['hit']
- def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- with patch(
- 'app.services.market_protocol.cell_crud.upsert_collection_info',
- side_effect=RuntimeError('injected child failure'),
- ):
- with pytest.raises(RuntimeError, match='injected'):
- create_protocol(
- db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
- )
- assert db.query(BizCollectionProtocol).count() == 0
- assert db.query(BizProtocolPartyCustomer).count() == 0
- assert db.query(BizCustomer).count() == 0
- def test_donation_create_rolls_back_customer_and_children_on_child_failure():
- from app.services.market_protocol import create_protocol
- db = _session()
- _masters(db)
- with patch(
- 'app.crud.research_donation._create_collection',
- side_effect=RuntimeError('injected child failure'),
- ):
- with pytest.raises(RuntimeError, match='injected'):
- create_protocol(
- db,
- body=_body(
- 'research_donation', donationNo='D-ROLLBACK', protocolNo=None
- ),
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert db.query(BizResearchDonation).count() == 0
- assert db.query(BizResearchDonationParty).count() == 0
- assert db.query(BizCustomer).count() == 0
- def test_cell_protocol_detail_includes_storage_insurance_and_audits():
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-enrich')
- db.add(
- BizProtocolStorageCert(
- id='sc-1',
- collection_protocol_id='cell-enrich',
- make_status='1',
- make_date='20261001',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.add(
- BizProtocolFullmoonTodo(
- id='fm-1',
- collection_protocol_id='cell-enrich',
- insurance_company_name='测试保险',
- insurance_policy_no='POL-1',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.add(
- BizProtocolAudit(
- id='au-1',
- collection_protocol_id='cell-enrich',
- audit_status='shtg',
- audit_remark='通过',
- auditor_user_id='u1',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- detail = get_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-enrich',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert detail['storageCert']['makeStatus'] == '1'
- assert detail['fullmoonTodo']['insurancePolicyNo'] == 'POL-1'
- assert len(detail['audits']) == 1
- assert detail['audits'][0]['auditStatus'] == 'shtg'
- def test_cell_protocol_detail_hides_cross_tenant_and_deleted_handler_names():
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-handler-isolation')
- db.add_all(
- [
- SysUser(
- id='cross-tenant-handler',
- user_no='cross-handler',
- nick_name='异租户经办人',
- status='1',
- tenant_no='tenant-b',
- create_dept_id='',
- del_flag=0,
- ),
- SysUser(
- id='deleted-handler',
- user_no='deleted-handler',
- nick_name='已删除经办人',
- status='1',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=1,
- ),
- BizProtocolStorageCert(
- id='sc-handler-isolation',
- collection_protocol_id='cell-handler-isolation',
- handler_user_id='cross-tenant-handler',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- ),
- BizProtocolFullmoonTodo(
- id='fm-handler-isolation',
- collection_protocol_id='cell-handler-isolation',
- handler_user_id='deleted-handler',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- ),
- ]
- )
- db.commit()
- detail = get_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-handler-isolation',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert detail['storageCert']['handlerUserName'] == ''
- assert detail['fullmoonTodo']['handlerUserName'] == ''
- def test_cell_protocol_detail_includes_presign_deposit_hint_and_transfers():
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'cell-deposit')
- row.source_presign_order_id = 'presign-d1'
- db.add(
- BizPresignOrder(
- id='presign-d1',
- protocol_no='PS-D1',
- mother_name='张敏',
- order_status='yzh',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.add(
- BizPresignTransfer(
- id='tr-d1',
- presign_order_id='presign-d1',
- transfer_amount=1000,
- transfer_type='wx',
- pay_status='yjf',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.add(
- SysDictData(
- id='dict-deposit-wx',
- dict_label='微信支付',
- dict_value='wx',
- dict_type='zzfs',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- detail = get_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-deposit',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert detail['presignDepositHint']['confirmedDepositAmount'] == '1000.00'
- assert detail['presignDepositHint']['confirmedTransferCount'] == 1
- assert len(detail['depositTransfers']) == 1
- assert detail['depositTransfers'][0]['id'] == 'tr-d1'
- assert detail['depositTransfers'][0]['transferAmount'] == '1000.00'
- assert detail['depositTransfers'][0]['transferTypeLabel'] == '微信支付'
- def test_protocol_customer_search_empty_keyword_returns_recent_list():
- """空关键字仍返回近期客户列表,供移动端「选档案」弹窗默认展示。"""
- from app.services.market_support import search_customers_for_protocol
- db = _session()
- db.add(
- BizCustomer(
- id='c-empty-kw',
- customer_name='可见客户',
- sex='1',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- data = search_customers_for_protocol(
- db, ctx=_ctx('specialist'), user_info=_user(), keyword=' '
- )
- assert [item['id'] for item in data['list']] == ['c-empty-kw']
- assert data['total'] == 1
- def test_protocol_customer_search_matches_name_phone_and_isolates_tenant():
- from app.services.market_support import search_customers_for_protocol
- db = _session()
- db.add_all(
- [
- BizCustomer(
- id='c-hit',
- customer_name='张敏',
- phone_no='13800001111',
- id_card_no='ID-HIT',
- sex='1',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- ),
- BizCustomer(
- id='c-other-tenant',
- customer_name='张敏',
- phone_no='13800001111',
- sex='1',
- tenant_no='tenant-b',
- create_dept_id='',
- del_flag=0,
- ),
- BizCustomer(
- id='c-miss',
- customer_name='李四',
- phone_no='13900002222',
- sex='1',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- ),
- ]
- )
- db.commit()
- by_name = search_customers_for_protocol(
- db, ctx=_ctx('specialist'), user_info=_user(), keyword='张敏'
- )
- assert [item['id'] for item in by_name['list']] == ['c-hit']
- assert by_name['list'][0]['phoneNo'] == '13800001111'
- assert by_name['list'][0]['idCardNo'] == 'ID-HIT'
- by_phone = search_customers_for_protocol(
- db, ctx=_ctx('manager'), user_info=_user(), keyword='1380000'
- )
- assert [item['id'] for item in by_phone['list']] == ['c-hit']
- def test_protocol_routes_expose_customers_search():
- from app.api.market_mobile import router
- for scope in ('specialist', 'manager'):
- paths = {
- route.path
- for route in router.routes
- if route.path.startswith(f'/mini/market/{scope}/protocols')
- }
- assert f'/mini/market/{scope}/protocols/customers' in paths
- def test_protocol_sample_body_accepts_status_and_freight_fields():
- from app.schemas.market_mobile import MarketProtocolBody
- body = MarketProtocolBody.model_validate(
- {
- 'source': 'cell_storage',
- 'samples': [
- {
- 'sampleTypeId': 'type-a',
- 'sampleCode': 'S-001',
- 'sampleStatus': 'ycj',
- 'etaTime': '2026-10-13 12:00:00',
- 'freightId': 'fr-1',
- 'freightDraft': {
- 'clientKey': 'fd_1',
- 'transportMode': 'zxc',
- 'freightAmount': 120,
- },
- }
- ],
- }
- )
- sample = body.samples[0]
- assert sample.sample_status == 'ycj'
- assert sample.freight_id == 'fr-1'
- assert sample.freight_draft.client_key == 'fd_1'
- assert sample.freight_draft.transport_mode == 'zxc'
- assert sample.freight_draft.freight_amount == 120
- def test_cell_protocol_detail_reuses_web_sample_lifecycle_bundle(monkeypatch):
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-lifecycle')
- db.add(
- BizProtocolSample(
- id='ps-lifecycle',
- collection_protocol_id='cell-lifecycle',
- sample_type_id='type-a',
- sample_code='S-LIFE-1',
- sample_status='yrk',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- lifecycle_sample = {
- 'id': 'ps-lifecycle',
- 'sampleCode': 'S-LIFE-1',
- 'sampleStatus': 'yrk',
- 'sampleReception': {'receptionTime': '2026-10-01', 'receiveAttachmentFiles': [{'fileUrl': 'receive.pdf'}]},
- 'productionReception': {'processTime': '2026-10-02'},
- 'qcInfo': {'workOrderNo': 'QC-1'},
- 'processQcInfoList': [{'workOrderNo': 'PQC-1'}],
- 'productionInfo': {'workOrderNo': 'PR-1'},
- 'storageReleaseInfo': {'storageStatus': 'yrk'},
- }
- monkeypatch.setattr(
- 'app.services.market_protocol.cell_crud.get_protocol_detail_bundle',
- lambda *args, **kwargs: {'samples': [lifecycle_sample]},
- )
- detail = get_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-lifecycle',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- sample = detail['samples'][0]
- for key in [
- 'sampleReception', 'productionReception', 'qcInfo',
- 'processQcInfoList', 'productionInfo', 'storageReleaseInfo',
- ]:
- assert sample[key] == lifecycle_sample[key]
- def test_cell_v1_detail_reuses_web_payment_details_and_transfer_type_dict(monkeypatch):
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'cell-v1-payment')
- row.payment_model_version = 1
- db.add(
- SysDictData(
- id='dict-wx',
- dict_label='微信支付',
- dict_value='wx',
- dict_type='zzfs',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- monkeypatch.setattr(
- 'app.services.market_protocol.cell_crud.get_protocol_detail_bundle',
- lambda *args, **kwargs: {
- 'samples': [],
- 'paymentDetails': [{
- 'id': 'pd-1',
- 'installmentNo': 1,
- 'dueAmount': 1000,
- 'transferAmount': 800,
- 'installmentDueDate': '20261031',
- 'transferTime': '2026-10-13 12:00:00',
- 'transferType': 'wx',
- 'transferAttachments': 'proof.pdf',
- 'payStatus': 'yjf',
- 'financeRemark': '已确认',
- }],
- },
- )
- detail = get_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-v1-payment',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert detail['paymentModelVersion'] == 1
- assert detail['samplePayments'] == []
- assert detail['paymentDetails'][0]['installmentNo'] == 1
- assert detail['paymentDetails'][0]['transferTypeLabel'] == '微信支付'
- def test_cell_v2_detail_labels_all_transfer_types_with_unknown_fallback(monkeypatch):
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- row = _cell(db, 'cell-v2-labels')
- row.payment_model_version = 2
- db.add(
- SysDictData(
- id='dict-v2-zfb',
- dict_label='支付宝',
- dict_value='zfb',
- dict_type='zzfs',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- monkeypatch.setattr(
- 'app.services.market_protocol.cell_crud.get_protocol_detail_bundle',
- lambda *args, **kwargs: {'samples': [], 'paymentDetails': []},
- )
- monkeypatch.setattr(
- 'app.services.market_protocol.psp_crud.get_sample_payments_bundle',
- lambda *args, **kwargs: [{
- 'id': 'sp-1',
- 'schedules': [{
- 'id': 'sch-1',
- 'transfers': [
- {'id': 'tr-1', 'transferType': 'zfb'},
- {'id': 'tr-2', 'transferType': 'future-code'},
- ],
- }],
- }],
- )
- detail = get_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-v2-labels',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- transfers = detail['samplePayments'][0]['schedules'][0]['transfers']
- assert transfers[0]['transferTypeLabel'] == '支付宝'
- assert transfers[1]['transferTypeLabel'] == 'future-code'
- def test_donation_detail_reuses_web_bundle_lifecycle_audits_and_convert_aliases(monkeypatch):
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- _donation(db, 'don-web-detail')
- db.commit()
- lifecycle_sample = {
- 'id': 'ds-1',
- 'sampleCode': 'D-S-1',
- 'sampleReception': {'receptionTime': '2026-10-01'},
- 'productionReception': {'processTime': '2026-10-02'},
- 'qcInfo': {'workOrderNo': 'D-QC-1'},
- 'processQcInfoList': [{'workOrderNo': 'D-PQC-1'}],
- 'productionInfo': {'workOrderNo': 'D-PR-1'},
- 'storageReleaseInfo': {'storageStatus': 'yrk'},
- }
- monkeypatch.setattr(
- 'app.services.market_protocol.donation_crud.get_donation_detail',
- lambda *args, **kwargs: {
- 'donation': {
- 'id': 'don-web-detail',
- 'donationNo': 'DON-WEB',
- 'donationStatus': 'zckc',
- 'toStorageTime': '2026-10-10 10:00:00',
- 'toStorageDesc': '已转细胞存储',
- 'consentFormUrls': 'consent.pdf',
- },
- 'party': {'motherName': '张敏'},
- 'collection': {'regionName': 'A区'},
- 'samples': [lifecycle_sample],
- },
- )
- monkeypatch.setattr(
- 'app.services.market_protocol.donation_crud.list_audits',
- lambda *args, **kwargs: {
- 'list': [{'id': 'da-1', 'auditStatus': 'shtg', 'auditRemark': '通过'}]
- },
- )
- detail = get_protocol(
- db,
- source='research_donation',
- protocol_id='don-web-detail',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- assert detail['samples'][0] == lifecycle_sample
- assert detail['audits'][0]['id'] == 'da-1'
- assert detail['basic']['convertTime'] == '2026-10-10 10:00:00'
- assert detail['basic']['convertDesc'] == '已转细胞存储'
- def test_cell_protocol_detail_attaches_sample_freight():
- from app.services.market_protocol import get_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-fr')
- db.add(
- BizProtocolSample(
- id='ps-fr',
- collection_protocol_id='cell-fr',
- sample_type_id='type-a',
- sample_code='S-FR-1',
- sample_status='ycj',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.add(
- SysDictData(
- id='dict-zxc',
- dict_label='专线车',
- dict_value='zxc',
- dict_type='sample_transport_mode',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.add(
- BizSampleFreight(
- id='fr-1',
- freight_no='F-001',
- transport_mode='zxc',
- freight_amount=88,
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.add(
- BizSampleFreightItem(
- id='fri-1',
- freight_id='fr-1',
- sample_source='cell_storage',
- sample_id='ps-fr',
- protocol_id='cell-fr',
- sample_code='S-FR-1',
- tenant_no='tenant-a',
- create_dept_id='',
- del_flag=0,
- )
- )
- db.commit()
- detail = get_protocol(
- db,
- source='cell_storage',
- protocol_id='cell-fr',
- ctx=_ctx('specialist'),
- user_info=_user(),
- )
- sample = detail['samples'][0]
- assert sample['freightId'] == 'fr-1'
- assert sample['freight']['transportMode'] == 'zxc'
- assert sample['freight']['freightAmount'] == '88.00'
- def test_protocol_update_replaces_freight_item_without_duplicate_active_links():
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-edit')
- _cell(db, 'cell-sibling')
- db.add_all(
- [
- BizProtocolSample(
- id='sample-old', collection_protocol_id='cell-edit',
- sample_type_id='type-a', sample_code_id='code-a', sample_code='S-001',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizProtocolSample(
- id='sample-sibling', collection_protocol_id='cell-sibling',
- sample_type_id='type-a', sample_code_id='code-b', sample_code='S-002',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizSampleFreight(
- id='freight-shared', freight_no='F-SHARED', transport_mode='zxc',
- freight_amount=88, tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizSampleFreightItem(
- id='item-old', freight_id='freight-shared', sample_source='cell_storage',
- sample_id='sample-old', protocol_id='cell-edit', sample_code='S-001',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizSampleFreightItem(
- id='item-sibling', freight_id='freight-shared', sample_source='cell_storage',
- sample_id='sample-sibling', protocol_id='cell-sibling', sample_code='S-002',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- ]
- )
- db.commit()
- body = _body(
- protocolNo='CELL-cell-edit',
- samples=[{
- 'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a',
- 'sampleCode': 'S-001', 'unitId': 'unit-a',
- 'collectAmount': 10, 'freightId': 'freight-shared',
- }],
- )
- first = update_protocol(
- db, source='cell_storage', protocol_id='cell-edit', body=body,
- ctx=_ctx('specialist'), user_info=_user(),
- )
- second = update_protocol(
- db, source='cell_storage', protocol_id='cell-edit', body=body,
- ctx=_ctx('specialist'), user_info=_user(),
- )
- active_sample = db.query(BizProtocolSample).filter_by(
- collection_protocol_id='cell-edit', tenant_no='tenant-a', del_flag=0,
- ).one()
- active_items = db.query(BizSampleFreightItem).filter_by(
- freight_id='freight-shared', tenant_no='tenant-a', del_flag=0,
- ).all()
- assert {item.sample_id for item in active_items} == {
- active_sample.id, 'sample-sibling'
- }
- assert db.get(BizSampleFreightItem, 'item-old').del_flag == 1
- assert first['samples'][0]['id'] != second['samples'][0]['id']
- assert second['samples'][0]['freight']['linkedSampleCount'] == 2
- def test_protocol_update_cancels_then_relinks_freight_without_touching_other_samples():
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-move')
- _cell(db, 'cell-other-a')
- _cell(db, 'cell-other-b')
- samples = [
- BizProtocolSample(
- id='sample-move', collection_protocol_id='cell-move',
- sample_type_id='type-a', sample_code_id='code-a', sample_code='S-001',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizProtocolSample(
- id='sample-other-a', collection_protocol_id='cell-other-a',
- sample_type_id='type-a', sample_code_id='code-b', sample_code='S-002',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizProtocolSample(
- id='sample-other-b', collection_protocol_id='cell-other-b',
- sample_type_id='type-a', sample_code_id='code-c', sample_code='S-003',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- ]
- freights = [
- BizSampleFreight(
- id='freight-a', freight_no='F-A', transport_mode='zxc', freight_amount=10,
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizSampleFreight(
- id='freight-b', freight_no='F-B', transport_mode='zxc', freight_amount=20,
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- ]
- items = [
- BizSampleFreightItem(
- id='item-move', freight_id='freight-a', sample_source='cell_storage',
- sample_id='sample-move', protocol_id='cell-move', sample_code='S-001',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizSampleFreightItem(
- id='item-other-a', freight_id='freight-a', sample_source='cell_storage',
- sample_id='sample-other-a', protocol_id='cell-other-a', sample_code='S-002',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizSampleFreightItem(
- id='item-other-b', freight_id='freight-b', sample_source='cell_storage',
- sample_id='sample-other-b', protocol_id='cell-other-b', sample_code='S-003',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- ]
- role = SysRole(
- id='role-all', role_name='市场人员', data_scope='1', status='1',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- user_role = SysUserRole(
- id='user-role-all', user_id='u1', role_id='role-all',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- )
- db.add_all([*samples, *freights, *items, role, user_role])
- db.commit()
- base_sample = {
- 'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a',
- 'sampleCode': 'S-001', 'unitId': 'unit-a', 'collectAmount': 10,
- }
- cancelled = update_protocol(
- db, source='cell_storage', protocol_id='cell-move',
- body=_body(protocolNo='CELL-cell-move', samples=[base_sample]),
- ctx=_ctx('specialist'), user_info=_user(),
- )
- assert cancelled['samples'][0]['freightId'] == ''
- assert db.get(BizSampleFreightItem, 'item-move').del_flag == 1
- assert db.get(BizSampleFreightItem, 'item-other-a').del_flag == 0
- relinked = update_protocol(
- db, source='cell_storage', protocol_id='cell-move',
- body=_body(
- protocolNo='CELL-cell-move',
- samples=[{**base_sample, 'freightId': 'freight-b'}],
- ),
- ctx=_ctx('specialist'), user_info=_user(),
- )
- current_id = relinked['samples'][0]['id']
- assert relinked['samples'][0]['freight']['linkedSampleCount'] == 2
- assert {
- item.sample_id
- for item in db.query(BizSampleFreightItem).filter_by(
- freight_id='freight-b', tenant_no='tenant-a', del_flag=0,
- ).all()
- } == {'sample-other-b', current_id}
- assert db.get(BizSampleFreightItem, 'item-other-a').del_flag == 0
- def test_protocol_update_freight_reconciliation_rolls_back_on_later_failure(monkeypatch):
- from app.services.market_protocol import update_protocol
- db = _session()
- _masters(db)
- _cell(db, 'cell-rollback')
- db.add_all(
- [
- BizProtocolSample(
- id='sample-rollback', collection_protocol_id='cell-rollback',
- sample_type_id='type-a', sample_code_id='code-a', sample_code='S-001',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizSampleFreight(
- id='freight-rollback', freight_no='F-ROLLBACK', transport_mode='zxc',
- freight_amount=88, tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- BizSampleFreightItem(
- id='item-rollback', freight_id='freight-rollback',
- sample_source='cell_storage', sample_id='sample-rollback',
- protocol_id='cell-rollback', sample_code='S-001',
- tenant_no='tenant-a', create_dept_id='', del_flag=0,
- ),
- ]
- )
- db.commit()
- monkeypatch.setattr(
- 'app.services.market_protocol._persist_sample_payments_if_any',
- lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError('later failure')),
- )
- with pytest.raises(RuntimeError, match='later failure'):
- update_protocol(
- db, source='cell_storage', protocol_id='cell-rollback',
- body=_body(
- protocolNo='CELL-cell-rollback',
- samples=[{
- 'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a',
- 'sampleCode': 'S-001', 'unitId': 'unit-a',
- 'collectAmount': 10, 'freightId': 'freight-rollback',
- }],
- ),
- ctx=_ctx('specialist'), user_info=_user(),
- )
- db.expire_all()
- assert db.get(BizProtocolSample, 'sample-rollback').del_flag == 0
- assert db.get(BizSampleFreightItem, 'item-rollback').del_flag == 0
- assert db.query(BizProtocolSample).filter_by(
- collection_protocol_id='cell-rollback', tenant_no='tenant-a', del_flag=0,
- ).count() == 1
- assert db.query(BizSampleFreightItem).filter_by(
- protocol_id='cell-rollback', tenant_no='tenant-a', del_flag=0,
- ).count() == 1
|