| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334 |
- 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_label', 'scope_kind', 'collection'),
- [
- (
- '市场专员',
- 'manager',
- {
- 'regionId': 'region-a',
- 'hospitalId': 'hospital-a',
- 'salesUserId': 'u2',
- 'regionManagerUserId': 'u1',
- },
- ),
- (
- '地区经理',
- 'specialist',
- {
- 'regionId': 'region-a',
- 'hospitalId': 'hospital-a',
- 'salesUserId': 'u1',
- 'regionManagerUserId': 'u2',
- },
- ),
- ],
- )
- def test_protocol_create_allows_assignment_without_target_region_staff_binding(
- source, field_label, 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})
- created = create_protocol(
- db,
- body=_body(source, **overrides),
- ctx=_ctx(scope_kind),
- user_info=_user(),
- )
- saved = created['collection']
- if field_label == '市场专员':
- assert saved['salesUserId'] == 'u2'
- else:
- assert saved['regionManagerUserId'] == 'u2'
- @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
- @pytest.mark.parametrize(
- ('field_name', 'scope_kind'),
- [('salesUserId', 'manager'), ('regionManagerUserId', 'specialist')],
- )
- def test_protocol_update_allows_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()
- updated = 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(),
- )
- assert updated['collection'][field_name] == 'u2'
- @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
|