test_market_protocol_api.py 148 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328
  1. import json
  2. from datetime import datetime, timedelta
  3. from decimal import Decimal
  4. from unittest.mock import patch
  5. import pytest
  6. from fastapi import HTTPException
  7. from pydantic import ValidationError
  8. from sqlalchemy import create_engine, event
  9. from sqlalchemy.orm import Query, sessionmaker
  10. from app.models.base import Base
  11. from app.models.models import (
  12. BizAttachment,
  13. BizCellSampleType,
  14. BizCollectionHospital,
  15. BizCollectionProtocol,
  16. BizCollectionRegion,
  17. BizCollectionRegionStaff,
  18. BizCustomer,
  19. BizItemUnit,
  20. BizProtocolCollectionInfo,
  21. BizProtocolPartyCustomer,
  22. BizProtocolPayment,
  23. BizProtocolPaymentDetail,
  24. BizProtocolPaymentSchedule,
  25. BizProtocolPaymentTransfer,
  26. BizProtocolSamplePayment,
  27. BizProtocolSample,
  28. BizProtocolSampleTransport,
  29. BizPresignOrder,
  30. BizPresignTransfer,
  31. BizProtocolAudit,
  32. BizProtocolStorageCert,
  33. BizProtocolFullmoonTodo,
  34. BizSampleCode,
  35. BizSampleFreight,
  36. BizSampleFreightItem,
  37. BizSampleReception,
  38. SysPost,
  39. SysPostRole,
  40. SysRole,
  41. SysRoleDataScopeDetail,
  42. SysDictData,
  43. SysUser,
  44. SysUserPost,
  45. SysUserRole,
  46. )
  47. from app.models.research_donation import (
  48. BizProtocolDonationConvert,
  49. BizResearchDonation,
  50. BizResearchDonationCollection,
  51. BizResearchDonationParty,
  52. BizResearchDonationSample,
  53. BizResearchDonationSampleTransport,
  54. )
  55. def _session():
  56. engine = create_engine('sqlite:///:memory:')
  57. Base.metadata.create_all(engine)
  58. return sessionmaker(bind=engine)()
  59. def _ctx(scope_kind, user_id='u1'):
  60. from app.services.market_field_scope import MarketFieldContext
  61. return MarketFieldContext('tenant-a', user_id, scope_kind)
  62. def _user(user_id='u1'):
  63. return {
  64. 'user_id': user_id,
  65. 'tenant_no': 'tenant-a',
  66. 'role': 'super_admin',
  67. 'region_id': 'dept-from-auth',
  68. }
  69. def _cell(db, row_id, *, sales='u1', manager='manager-a', region='region-a', created=None):
  70. row = BizCollectionProtocol(
  71. id=row_id,
  72. protocol_no=f'CELL-{row_id}',
  73. protocol_status='dsx',
  74. tenant_no='tenant-a',
  75. create_dept_id='dept-a',
  76. create_time=created or datetime.now(),
  77. del_flag=0,
  78. )
  79. collection = BizProtocolCollectionInfo(
  80. id=f'ci-{row_id}',
  81. collection_protocol_id=row_id,
  82. region_id=region,
  83. sales_user_id=sales,
  84. region_manager_user_id=manager,
  85. tenant_no='tenant-a',
  86. create_dept_id='dept-a',
  87. del_flag=0,
  88. )
  89. db.add_all([row, collection])
  90. return row
  91. def _donation(db, row_id, *, sales='u1', manager='manager-a', region='region-a', created=None):
  92. row = BizResearchDonation(
  93. id=row_id,
  94. donation_no=f'DON-{row_id}',
  95. donation_status='dsx',
  96. tenant_no='tenant-a',
  97. create_dept_id='dept-a',
  98. create_time=created or datetime.now(),
  99. del_flag=0,
  100. )
  101. collection = BizResearchDonationCollection(
  102. id=f'dci-{row_id}',
  103. research_donation_id=row_id,
  104. region_id=region,
  105. sales_user_id=sales,
  106. region_manager_user_id=manager,
  107. tenant_no='tenant-a',
  108. create_dept_id='dept-a',
  109. del_flag=0,
  110. )
  111. db.add_all([row, collection])
  112. return row
  113. def _masters(db):
  114. db.add_all(
  115. [
  116. BizCollectionRegion(
  117. id='region-a', region_name='A区', tenant_no='tenant-a', create_dept_id='', del_flag=0
  118. ),
  119. BizCollectionRegion(
  120. id='region-b', region_name='B区', tenant_no='tenant-a', create_dept_id='', del_flag=0
  121. ),
  122. BizCollectionHospital(
  123. id='hospital-a', region_id='region-a', hospital_name='A院', tenant_no='tenant-a', create_dept_id='', del_flag=0
  124. ),
  125. BizCellSampleType(
  126. id='type-a', type_code='A', type_name='样本A', tenant_no='tenant-a', create_dept_id='', del_flag=0
  127. ),
  128. BizSampleCode(
  129. id='code-a', sample_code='S-001', tenant_no='tenant-a', create_dept_id='', del_flag=0
  130. ),
  131. BizSampleCode(
  132. id='code-b', sample_code='S-002', tenant_no='tenant-a', create_dept_id='', del_flag=0
  133. ),
  134. BizSampleCode(
  135. id='code-c', sample_code='S-003', tenant_no='tenant-a', create_dept_id='', del_flag=0
  136. ),
  137. BizSampleCode(
  138. id='code-cross', sample_code='S-CROSS', tenant_no='tenant-b', create_dept_id='', del_flag=0
  139. ),
  140. BizSampleCode(
  141. id='code-deleted', sample_code='S-DELETED', tenant_no='tenant-a', create_dept_id='', del_flag=1
  142. ),
  143. BizItemUnit(
  144. id='unit-a', unit_code='ml', unit_name='毫升', tenant_no='tenant-a', create_dept_id='', del_flag=0
  145. ),
  146. SysUser(
  147. id='u1', user_no='u1', nick_name='当前用户', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
  148. ),
  149. SysUser(
  150. id='manager-a', user_no='manager-a', nick_name='经理', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
  151. ),
  152. SysUser(
  153. id='u2', user_no='u2', nick_name='其他专员', status='1', tenant_no='tenant-a', create_dept_id='', del_flag=0
  154. ),
  155. BizCollectionRegionStaff(
  156. id='staff-u1-region-a', region_id='region-a', user_id='u1', is_leader=1,
  157. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  158. ),
  159. BizCollectionRegionStaff(
  160. id='staff-manager-a-region-a', region_id='region-a', user_id='manager-a', is_leader=0,
  161. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  162. ),
  163. BizAttachment(
  164. id='att-protocol-a',
  165. business_type='market:specialist:protocol:staging',
  166. original_name='a.pdf',
  167. file_ext='pdf',
  168. file_url='protocol/a.pdf',
  169. file_size=10,
  170. file_type='application/pdf',
  171. tenant_no='tenant-a',
  172. create_by='u1',
  173. create_dept_id='',
  174. del_flag=0,
  175. ),
  176. ]
  177. )
  178. db.commit()
  179. def _body(source='cell_storage', **overrides):
  180. from app.schemas.market_mobile import MarketProtocolBody
  181. payload = {
  182. 'source': source,
  183. 'protocolNo': 'P-001',
  184. 'registerDate': '20261010',
  185. 'attachments': {'protocolAttachments': 'protocol/a.pdf'},
  186. 'customer': {'motherName': '张三', 'motherPhone': '13800000000'},
  187. 'collection': {
  188. 'regionId': 'region-a',
  189. 'hospitalId': 'hospital-a',
  190. 'salesUserId': 'someone-else',
  191. 'regionManagerUserId': 'manager-a',
  192. },
  193. 'samples': [
  194. {
  195. 'sampleTypeId': 'type-a',
  196. 'sampleCodeId': 'code-a',
  197. 'sampleCode': 'S-001',
  198. 'unitId': 'unit-a',
  199. 'collectAmount': 10,
  200. }
  201. ],
  202. 'remark': '移动端登记',
  203. }
  204. payload.update(overrides)
  205. return MarketProtocolBody.model_validate(payload)
  206. def test_protocol_create_rejects_unknown_attachment_reference():
  207. from app.services.market_protocol import create_protocol
  208. db = _session()
  209. _masters(db)
  210. body = _body(
  211. attachments={'protocolAttachments': 'https://evil.test/a.pdf'}
  212. )
  213. with pytest.raises(ValueError, match='附件'):
  214. create_protocol(
  215. db,
  216. body=body,
  217. ctx=_ctx('specialist'),
  218. user_info=_user(),
  219. )
  220. def test_terminal_protocol_allows_web_parity_edit_without_status_change():
  221. from app.services.market_protocol import update_protocol
  222. db = _session()
  223. _masters(db)
  224. row = _cell(db, 'terminal')
  225. row.protocol_status = 'yzf'
  226. db.commit()
  227. update_protocol(
  228. db,
  229. source='cell_storage',
  230. protocol_id=row.id,
  231. body=_body(protocolNo='CELL-terminal'),
  232. ctx=_ctx('specialist'),
  233. user_info=_user(),
  234. )
  235. db.expire_all()
  236. saved = db.get(BizCollectionProtocol, row.id)
  237. assert saved.protocol_status == 'yzf'
  238. assert saved.protocol_no == 'CELL-terminal'
  239. def test_protocol_list_combines_cell_and_donation_without_scope_leak():
  240. from app.services.market_protocol import list_protocols
  241. db = _session()
  242. now = datetime.now()
  243. _cell(db, 'cell-own', created=now - timedelta(minutes=2))
  244. _cell(db, 'cell-other', sales='u2', created=now)
  245. _donation(db, 'don-own', created=now - timedelta(minutes=1))
  246. _donation(db, 'don-other', sales='u2', created=now + timedelta(minutes=1))
  247. db.commit()
  248. items, total = list_protocols(db, ctx=_ctx('specialist'), user_info=_user())
  249. assert total == 2
  250. assert [(item['source'], item['id']) for item in items] == [
  251. ('research_donation', 'don-own'),
  252. ('cell_storage', 'cell-own'),
  253. ]
  254. def test_protocol_list_large_page_uses_bounded_source_queries_and_global_order():
  255. from app.services.market_protocol import list_protocols
  256. db = _session()
  257. base = datetime(2026, 10, 10, 12, 0, 0)
  258. expected = []
  259. for index in range(24):
  260. created = base + timedelta(seconds=index)
  261. cell_id = f'cell-{index:02d}'
  262. donation_id = f'don-{index:02d}'
  263. _cell(db, cell_id, created=created)
  264. _donation(db, donation_id, created=created + timedelta(microseconds=1))
  265. expected.extend([(created, cell_id), (created + timedelta(microseconds=1), donation_id)])
  266. db.commit()
  267. statements = []
  268. def capture(_conn, _cursor, statement, _parameters, _context, _many):
  269. if 'FROM biz_collection_protocol ' in statement or 'FROM biz_research_donation ' in statement:
  270. statements.append(statement)
  271. event.listen(db.get_bind(), 'before_cursor_execute', capture)
  272. try:
  273. items, total = list_protocols(
  274. db,
  275. ctx=_ctx('specialist'),
  276. user_info=_user(),
  277. page=4,
  278. page_size=7,
  279. )
  280. finally:
  281. event.remove(db.get_bind(), 'before_cursor_execute', capture)
  282. ordered_ids = [item[1] for item in sorted(expected, key=lambda item: (item[0], item[1]), reverse=True)]
  283. assert total == 48
  284. assert [item['id'] for item in items] == ordered_ids[21:28]
  285. page_selects = [sql for sql in statements if 'count(' not in sql.lower()]
  286. assert len(page_selects) == 2
  287. assert all(' LIMIT ' in sql.upper() for sql in page_selects)
  288. def test_protocol_list_stable_tie_breaker_prevents_page_overlap():
  289. from app.services.market_protocol import list_protocols
  290. db = _session()
  291. same_time = datetime(2026, 10, 10, 12, 0, 0)
  292. for row_id in ('a1', 'a3', 'a5', 'a7'):
  293. _cell(db, row_id, created=same_time)
  294. for row_id in ('a2', 'a4', 'a6', 'a8'):
  295. _donation(db, row_id, created=same_time)
  296. db.commit()
  297. page_one, total = list_protocols(
  298. db, ctx=_ctx('specialist'), user_info=_user(), page=1, page_size=3
  299. )
  300. page_two, _ = list_protocols(
  301. db, ctx=_ctx('specialist'), user_info=_user(), page=2, page_size=3
  302. )
  303. assert total == 8
  304. assert [item['id'] for item in page_one] == ['a8', 'a7', 'a6']
  305. assert [item['id'] for item in page_two] == ['a5', 'a4', 'a3']
  306. assert {item['id'] for item in page_one}.isdisjoint(
  307. {item['id'] for item in page_two}
  308. )
  309. def test_protocol_detail_rechecks_source_specific_idor():
  310. from app.services.market_protocol import get_protocol
  311. db = _session()
  312. _cell(db, 'cell-other', sales='u2')
  313. _donation(db, 'don-other', sales='u2')
  314. db.commit()
  315. with pytest.raises(HTTPException) as cell_error:
  316. get_protocol(
  317. db,
  318. source='cell_storage',
  319. protocol_id='cell-other',
  320. ctx=_ctx('specialist'),
  321. user_info=_user(),
  322. )
  323. with pytest.raises(HTTPException) as donation_error:
  324. get_protocol(
  325. db,
  326. source='research_donation',
  327. protocol_id='don-other',
  328. ctx=_ctx('specialist'),
  329. user_info=_user(),
  330. )
  331. assert cell_error.value.status_code == 404
  332. assert donation_error.value.status_code == 404
  333. def test_protocol_detail_can_resolve_source_from_scoped_record():
  334. from app.services.market_protocol import get_protocol
  335. db = _session()
  336. _donation(db, 'don-own')
  337. db.commit()
  338. result = get_protocol(
  339. db,
  340. source=None,
  341. protocol_id='don-own',
  342. ctx=_ctx('specialist'),
  343. user_info=_user(),
  344. )
  345. assert result['source'] == 'research_donation'
  346. assert result['basic']['id'] == 'don-own'
  347. def test_protocol_detail_resolves_web_display_names_for_mobile_fields():
  348. from app.services.market_protocol import get_protocol
  349. db = _session()
  350. _masters(db)
  351. row = _cell(db, 'named')
  352. row.collector_user_id = 'u1'
  353. collection = db.query(BizProtocolCollectionInfo).filter_by(
  354. collection_protocol_id=row.id
  355. ).one()
  356. collection.hospital_id = 'hospital-a'
  357. db.add(
  358. BizProtocolSample(
  359. id='sample-named',
  360. collection_protocol_id=row.id,
  361. sample_type_id='type-a',
  362. sample_code='S-NAMED',
  363. unit_id='unit-a',
  364. tenant_no='tenant-a',
  365. create_dept_id='',
  366. del_flag=0,
  367. )
  368. )
  369. db.commit()
  370. result = get_protocol(
  371. db,
  372. source='cell_storage',
  373. protocol_id=row.id,
  374. ctx=_ctx('specialist'),
  375. user_info=_user(),
  376. )
  377. assert result['basic']['collectorUserName'] == '当前用户'
  378. assert result['collection']['regionName'] == 'A区'
  379. assert result['collection']['hospitalName'] == 'A院'
  380. assert result['collection']['salesUserName'] == '当前用户'
  381. assert result['collection']['regionManagerUserName'] == '经理'
  382. assert result['samples'][0]['sampleTypeName'] == '样本A'
  383. assert result['samples'][0]['unitName'] == '毫升'
  384. def test_protocol_detail_resolves_soft_deleted_master_names_with_tenant_scope():
  385. from app.services.market_protocol import get_protocol
  386. db = _session()
  387. _masters(db)
  388. row = _cell(db, 'historical-master')
  389. collection = db.query(BizProtocolCollectionInfo).filter_by(
  390. collection_protocol_id=row.id
  391. ).one()
  392. collection.hospital_id = 'hospital-a'
  393. db.get(BizCollectionRegion, 'region-a').del_flag = 1
  394. db.get(BizCollectionHospital, 'hospital-a').del_flag = 1
  395. db.get(BizCellSampleType, 'type-a').del_flag = 1
  396. db.get(BizItemUnit, 'unit-a').del_flag = 1
  397. db.add_all(
  398. [
  399. BizCellSampleType(id='type-global-history', type_code='GLOBAL', type_name='历史全局样本', tenant_no='', create_dept_id='', del_flag=1),
  400. BizCellSampleType(id='type-cross-history', type_code='CROSS', type_name='跨租户样本名称', tenant_no='tenant-b', create_dept_id='', del_flag=1),
  401. BizItemUnit(id='unit-cross-history', unit_code='cross', unit_name='跨租户单位名称', tenant_no='tenant-b', create_dept_id='', del_flag=1),
  402. 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),
  403. 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),
  404. 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),
  405. ]
  406. )
  407. db.commit()
  408. result = get_protocol(
  409. db,
  410. source='cell_storage',
  411. protocol_id=row.id,
  412. ctx=_ctx('specialist'),
  413. user_info=_user(),
  414. )
  415. samples = {item['sampleCode']: item for item in result['samples']}
  416. assert result['collection']['regionName'] == 'A区'
  417. assert result['collection']['hospitalName'] == 'A院'
  418. assert samples['S-HISTORY-LOCAL']['sampleTypeName'] == '样本A'
  419. assert samples['S-HISTORY-LOCAL']['unitName'] == '毫升'
  420. assert samples['S-HISTORY-GLOBAL']['sampleTypeName'] == '历史全局样本'
  421. assert samples['S-HISTORY-CROSS']['sampleTypeName'] == ''
  422. assert samples['S-HISTORY-CROSS']['unitName'] == ''
  423. def test_protocol_names_use_exact_historical_nicknames_without_raw_fallbacks():
  424. from app.services.market_protocol import get_protocol, list_protocols
  425. db = _session()
  426. _masters(db)
  427. row = _cell(db, 'historical-names')
  428. row.collector_user_id = 'blank-name-user'
  429. collection = db.query(BizProtocolCollectionInfo).filter_by(
  430. collection_protocol_id=row.id
  431. ).one()
  432. collection.region_manager_user_id = 'manager-a'
  433. current_user = db.get(SysUser, 'u1')
  434. current_user.nick_name = '历史删除专员'
  435. current_user.del_flag = 1
  436. manager = db.get(SysUser, 'manager-a')
  437. manager.nick_name = '历史停用经理'
  438. manager.status = '0'
  439. db.add(
  440. SysUser(
  441. id='blank-name-user',
  442. user_no='raw-collector-no',
  443. nick_name='',
  444. status='0',
  445. tenant_no='tenant-a',
  446. create_dept_id='',
  447. del_flag=1,
  448. )
  449. )
  450. sample_type = db.get(BizCellSampleType, 'type-a')
  451. sample_type.type_name = ''
  452. unit = db.get(BizItemUnit, 'unit-a')
  453. unit.unit_name = ''
  454. db.add(
  455. BizProtocolSample(
  456. id='sample-historical-names',
  457. collection_protocol_id=row.id,
  458. sample_type_id='type-a',
  459. sample_code='S-HISTORY',
  460. unit_id='unit-a',
  461. tenant_no='tenant-a',
  462. create_dept_id='',
  463. del_flag=0,
  464. )
  465. )
  466. db.commit()
  467. items, total = list_protocols(
  468. db, ctx=_ctx('specialist'), user_info=_user()
  469. )
  470. detail = get_protocol(
  471. db,
  472. source='cell_storage',
  473. protocol_id=row.id,
  474. ctx=_ctx('specialist'),
  475. user_info=_user(),
  476. )
  477. assert total == 1
  478. assert items[0]['salesUserName'] == '历史删除专员'
  479. assert items[0]['collectorUserName'] == ''
  480. assert items[0]['managerName'] == '历史停用经理'
  481. assert items[0]['sampleTypeName'] == ''
  482. assert detail['basic']['collectorUserName'] == ''
  483. assert detail['collection']['salesUserName'] == '历史删除专员'
  484. assert detail['collection']['regionManagerUserName'] == '历史停用经理'
  485. assert detail['samples'][0]['sampleTypeName'] == ''
  486. assert detail['samples'][0]['unitName'] == ''
  487. def test_specialist_cell_create_forces_current_collection_and_collector_users():
  488. from app.services.market_protocol import create_protocol
  489. db = _session()
  490. _masters(db)
  491. result = create_protocol(
  492. db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
  493. )
  494. main = db.get(BizCollectionProtocol, result['basic']['id'])
  495. collection = db.query(BizProtocolCollectionInfo).filter_by(collection_protocol_id=main.id, del_flag=0).one()
  496. party = db.query(BizProtocolPartyCustomer).filter_by(collection_protocol_id=main.id, del_flag=0).one()
  497. sample = db.query(BizProtocolSample).filter_by(collection_protocol_id=main.id, del_flag=0).one()
  498. assert main.collector_user_id == 'u1'
  499. assert main.create_dept_id == 'dept-from-auth'
  500. assert collection.sales_user_id == 'u1'
  501. assert collection.create_dept_id == 'dept-from-auth'
  502. assert party.collector_user_id == 'u1'
  503. assert party.customer_id is None
  504. assert db.query(BizCustomer).count() == 0
  505. assert sample.sample_code == 'S-001'
  506. def test_specialist_donation_create_forces_current_users_and_has_no_payment_section():
  507. from app.services.market_protocol import create_protocol
  508. db = _session()
  509. _masters(db)
  510. result = create_protocol(
  511. db,
  512. body=_body('research_donation', donationNo='D-001', protocolNo=None),
  513. ctx=_ctx('specialist'),
  514. user_info=_user(),
  515. )
  516. donation = db.get(BizResearchDonation, result['basic']['id'])
  517. collection = db.query(BizResearchDonationCollection).filter_by(research_donation_id=donation.id, del_flag=0).one()
  518. party = db.query(BizResearchDonationParty).filter_by(research_donation_id=donation.id, del_flag=0).one()
  519. sample = db.query(BizResearchDonationSample).filter_by(research_donation_id=donation.id, del_flag=0).one()
  520. assert donation.collector_user_id == 'u1'
  521. assert donation.create_dept_id == 'dept-from-auth'
  522. assert collection.sales_user_id == 'u1'
  523. assert collection.create_dept_id == 'dept-from-auth'
  524. assert party.collector_user_id == 'u1'
  525. assert party.create_dept_id == 'dept-from-auth'
  526. assert sample.create_dept_id == 'dept-from-auth'
  527. assert party.customer_id is None
  528. assert db.query(BizCustomer).count() == 0
  529. assert 'payment' not in result
  530. def test_manager_update_cannot_move_document_out_of_scope_and_rolls_back():
  531. from app.services.market_protocol import update_protocol
  532. db = _session()
  533. _masters(db)
  534. _cell(db, 'cell-manager', manager='u1')
  535. db.commit()
  536. with pytest.raises(ValueError, match='范围'):
  537. update_protocol(
  538. db,
  539. source='cell_storage',
  540. protocol_id='cell-manager',
  541. body=_body(collection={'regionId': 'region-b', 'regionManagerUserId': 'u1'}),
  542. ctx=_ctx('manager'),
  543. user_info=_user(),
  544. )
  545. db.expire_all()
  546. collection = db.query(BizProtocolCollectionInfo).filter_by(collection_protocol_id='cell-manager', del_flag=0).one()
  547. assert collection.region_id == 'region-a'
  548. assert collection.region_manager_user_id == 'u1'
  549. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  550. def test_manager_create_cannot_self_assign_an_unmanaged_region(source):
  551. from app.services.market_protocol import create_protocol
  552. db = _session()
  553. _masters(db)
  554. body = _body(
  555. source,
  556. **(
  557. {'donationNo': 'D-UNMANAGED', 'protocolNo': None}
  558. if source == 'research_donation'
  559. else {}
  560. ),
  561. collection={
  562. 'regionId': 'region-b',
  563. 'regionManagerUserId': 'u1',
  564. 'salesUserId': 'u2',
  565. },
  566. attachments=None,
  567. )
  568. with pytest.raises(ValueError, match='范围'):
  569. create_protocol(
  570. db,
  571. body=body,
  572. ctx=_ctx('manager'),
  573. user_info=_user(),
  574. )
  575. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  576. @pytest.mark.parametrize('operation', ['update', 'delete'])
  577. def test_protocol_mutations_lock_the_scoped_parent_row(
  578. monkeypatch, source, operation
  579. ):
  580. from app.schemas.market_mobile import MarketProtocolBody
  581. from app.services.market_protocol import delete_protocol, update_protocol
  582. db = _session()
  583. _masters(db)
  584. row_id = f'{source}-{operation}-lock'
  585. if source == 'cell_storage':
  586. _cell(db, row_id)
  587. else:
  588. _donation(db, row_id)
  589. db.commit()
  590. lock_calls = []
  591. original = Query.with_for_update
  592. def record_lock(query, *args, **kwargs):
  593. lock_calls.append(True)
  594. return original(query, *args, **kwargs)
  595. monkeypatch.setattr(Query, 'with_for_update', record_lock)
  596. if operation == 'update':
  597. update_protocol(
  598. db,
  599. source=source,
  600. protocol_id=row_id,
  601. body=MarketProtocolBody.model_validate(
  602. {'source': source, 'remark': '锁后更新'}
  603. ),
  604. ctx=_ctx('specialist'),
  605. user_info=_user(),
  606. )
  607. else:
  608. delete_protocol(
  609. db,
  610. source=source,
  611. protocol_id=row_id,
  612. ctx=_ctx('specialist'),
  613. user_info=_user(),
  614. )
  615. assert lock_calls
  616. def test_v1_cell_protocol_mobile_update_keeps_version_and_saves_legacy_payment():
  617. from app.schemas.market_mobile import MarketProtocolBody
  618. from app.services.market_protocol import update_protocol
  619. db = _session()
  620. _masters(db)
  621. row = _cell(db, 'legacy-v1')
  622. row.payment_model_version = 1
  623. db.commit()
  624. result = update_protocol(
  625. db,
  626. source='cell_storage',
  627. protocol_id=row.id,
  628. body=MarketProtocolBody.model_validate(
  629. {
  630. 'source': 'cell_storage',
  631. 'remark': '移动端可编辑历史协议',
  632. 'payment': {
  633. 'payMode': 'dj',
  634. 'contractAmount': '999999999999.99',
  635. 'lumpSumDeadline': '20261231',
  636. },
  637. 'paymentDetails': [
  638. {
  639. 'installmentNo': 1,
  640. 'installmentDueDate': '20261231',
  641. 'dueAmount': '999999999999.99',
  642. 'transferType': 'wx',
  643. 'transferAmount': '1.01',
  644. 'transferTime': '2026-10-14 10:00:00',
  645. 'transferAttachments': 'protocol/a.pdf',
  646. 'remark': '移动草稿',
  647. }
  648. ],
  649. }
  650. ),
  651. ctx=_ctx('specialist'),
  652. user_info=_user(),
  653. )
  654. db.expire_all()
  655. saved = db.get(BizCollectionProtocol, row.id)
  656. assert saved.payment_model_version == 1
  657. assert saved.remark == '移动端可编辑历史协议'
  658. assert result['paymentModelVersion'] == 1
  659. assert result['payment']['contractAmount'] == '999999999999.99'
  660. assert result['paymentDetails'][0]['transferAmount'] == '1.01'
  661. def test_v1_market_update_accepts_complete_generated_installment_amounts():
  662. from app.schemas.market_mobile import MarketProtocolBody
  663. from app.services.market_protocol import update_protocol
  664. db = _session()
  665. _masters(db)
  666. row = _cell(db, 'legacy-v1-generated-installments')
  667. row.payment_model_version = 1
  668. db.commit()
  669. update_protocol(
  670. db,
  671. source='cell_storage',
  672. protocol_id=row.id,
  673. body=MarketProtocolBody.model_validate(
  674. {
  675. 'source': 'cell_storage',
  676. 'payment': {
  677. 'payMode': 'fq',
  678. 'contractAmount': '100.01',
  679. 'installmentCount': 2,
  680. 'installmentIntervalMonths': '1',
  681. 'installmentStartDate': '20260131',
  682. 'installmentAdvanceDays': 0,
  683. 'installmentRatios': ['50.00', '50.01'],
  684. },
  685. 'paymentDetails': [
  686. {
  687. 'installmentNo': 1,
  688. 'installmentDueDate': '20260131',
  689. 'dueAmount': '50.00',
  690. },
  691. {
  692. 'installmentNo': 2,
  693. 'installmentDueDate': '20260228',
  694. 'dueAmount': '50.01',
  695. },
  696. ],
  697. }
  698. ),
  699. ctx=_ctx('specialist'),
  700. user_info=_user(),
  701. )
  702. db.expire_all()
  703. payment = db.query(BizProtocolPayment).filter_by(
  704. collection_protocol_id=row.id, del_flag=0
  705. ).one()
  706. assert payment.installment_ratios == '["50.00", "50.01"]'
  707. details = db.query(BizProtocolPaymentDetail).filter_by(
  708. payment_id=payment.id, del_flag=0
  709. ).order_by(BizProtocolPaymentDetail.installment_no).all()
  710. assert [detail.due_amount for detail in details] == [
  711. Decimal('50.00'), Decimal('50.01')
  712. ]
  713. assert [detail.installment_due_date for detail in details] == [
  714. '20260131', '20260228'
  715. ]
  716. def test_protocol_body_legacy_payment_is_typed_and_rejects_sensitive_detail_fields():
  717. from app.schemas.market_mobile import MarketProtocolBody
  718. from fastapi.encoders import jsonable_encoder
  719. body = MarketProtocolBody.model_validate(
  720. {
  721. 'source': 'cell_storage',
  722. 'payment': {
  723. 'payMode': 'fq',
  724. 'contractAmount': '1000.01',
  725. 'installmentCount': 2,
  726. 'installmentIntervalMonths': '12.50',
  727. 'installmentStartDate': '20261014',
  728. 'installmentAdvanceDays': 30,
  729. 'installmentRatios': ['400.00', '600.01'],
  730. },
  731. 'paymentDetails': [
  732. {
  733. 'id': 'detail-a',
  734. 'installmentNo': 1,
  735. 'dueAmount': '400.00',
  736. 'transferType': 'bank',
  737. 'transferAmount': '1.01',
  738. 'transferTime': '2026-10-14 10:00:00',
  739. 'transferAttachments': 'proof.pdf',
  740. 'remark': '市场备注',
  741. }
  742. ],
  743. }
  744. )
  745. assert body.payment.contract_amount == Decimal('1000.01')
  746. assert body.payment.installment_ratios == [Decimal('400.00'), Decimal('600.01')]
  747. assert body.payment_details[0].transfer_amount == Decimal('1.01')
  748. attachment_alias = MarketProtocolBody.model_validate(
  749. {
  750. 'source': 'cell_storage',
  751. 'paymentDetails': [{'attachments': 'protocol/proof.pdf'}],
  752. }
  753. )
  754. assert attachment_alias.payment_details[0].transfer_attachments == 'protocol/proof.pdf'
  755. max_amount = MarketProtocolBody.model_validate(
  756. {
  757. 'source': 'cell_storage',
  758. 'payment': {
  759. 'payMode': 'dj',
  760. 'contractAmount': '9999999999999999.99',
  761. },
  762. }
  763. )
  764. assert max_amount.payment.contract_amount == Decimal('9999999999999999.99')
  765. with pytest.raises(ValidationError):
  766. MarketProtocolBody.model_validate(
  767. {
  768. 'source': 'cell_storage',
  769. 'payment': {'payMode': 'evil', 'contractAmount': '1.00'},
  770. }
  771. )
  772. from app.crud import collection_protocol as cell_crud
  773. serialized_payment = cell_crud._payment_to_dict(
  774. BizProtocolPayment(
  775. id='max-payment', collection_protocol_id='max-protocol',
  776. contract_amount=Decimal('9999999999999999.99'),
  777. installment_ratios='["4999999999999999.99", "5000000000000000.00"]',
  778. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  779. )
  780. )
  781. serialized_detail = cell_crud._detail_to_dict(
  782. BizProtocolPaymentDetail(
  783. id='max-detail', payment_id='max-payment',
  784. due_amount=Decimal('4999999999999999.99'),
  785. transfer_amount=Decimal('0.01'), tenant_no='tenant-a',
  786. create_dept_id='', del_flag=0,
  787. )
  788. )
  789. assert serialized_payment['contractAmount'] == '9999999999999999.99'
  790. assert jsonable_encoder(serialized_payment)['installmentRatios'] == [
  791. '4999999999999999.99', '5000000000000000.00'
  792. ]
  793. assert serialized_detail['dueAmount'] == '4999999999999999.99'
  794. assert serialized_detail['transferAmount'] == '0.01'
  795. for forbidden in (
  796. {'payStatus': 'dfj'},
  797. {'financeRemark': '越权'},
  798. {'refundAmount': '1.00'},
  799. {'financeConfirmAttachments': 'finance.pdf'},
  800. ):
  801. with pytest.raises(ValidationError):
  802. MarketProtocolBody.model_validate(
  803. {
  804. 'source': 'cell_storage',
  805. 'paymentDetails': [{'dueAmount': '1.00', **forbidden}],
  806. }
  807. )
  808. @pytest.mark.parametrize(
  809. ('section', 'field', 'value'),
  810. [
  811. ('payment', 'lumpSumDeadline', '20261399'),
  812. ('payment', 'installmentStartDate', '20230229'),
  813. ('paymentDetails', 'installmentDueDate', '20260230'),
  814. ],
  815. )
  816. def test_protocol_body_legacy_payment_rejects_invalid_calendar_dates(
  817. section, field, value
  818. ):
  819. from app.schemas.market_mobile import MarketProtocolBody
  820. payload = {'source': 'cell_storage'}
  821. if section == 'paymentDetails':
  822. payload[section] = [{field: value}]
  823. else:
  824. payload[section] = {'payMode': 'dj', field: value}
  825. with pytest.raises(ValidationError, match='YYYYMMDD'):
  826. MarketProtocolBody.model_validate(payload)
  827. def test_protocol_body_legacy_payment_accepts_month_end_and_leap_dates():
  828. from app.schemas.market_mobile import MarketProtocolBody
  829. body = MarketProtocolBody.model_validate(
  830. {
  831. 'source': 'cell_storage',
  832. 'payment': {
  833. 'payMode': 'fq',
  834. 'lumpSumDeadline': '20260228',
  835. 'installmentStartDate': '20240229',
  836. },
  837. 'paymentDetails': [{'installmentDueDate': '20260228'}],
  838. }
  839. )
  840. assert body.payment.installment_start_date == '20240229'
  841. assert body.payment_details[0].installment_due_date == '20260228'
  842. def test_protocol_payment_models_are_mutually_exclusive_by_stored_version():
  843. from app.schemas.market_mobile import MarketProtocolBody
  844. from app.services.market_protocol import update_protocol
  845. db = _session()
  846. _masters(db)
  847. v1 = _cell(db, 'legacy-mixed')
  848. v1.payment_model_version = 1
  849. v2 = _cell(db, 'sample-mixed')
  850. v2.payment_model_version = 2
  851. db.commit()
  852. with pytest.raises(ValueError, match='v1.*samplePayments|旧版.*样本缴费'):
  853. update_protocol(
  854. db,
  855. source='cell_storage',
  856. protocol_id=v1.id,
  857. body=MarketProtocolBody.model_validate(
  858. {
  859. 'source': 'cell_storage',
  860. 'samplePayments': [
  861. {'sampleCode': 'S-001', 'payMode': 'dj'}
  862. ],
  863. }
  864. ),
  865. ctx=_ctx('specialist'),
  866. user_info=_user(),
  867. )
  868. with pytest.raises(ValueError, match='v2.*payment|新版.*整单缴费'):
  869. update_protocol(
  870. db,
  871. source='cell_storage',
  872. protocol_id=v2.id,
  873. body=MarketProtocolBody.model_validate(
  874. {
  875. 'source': 'cell_storage',
  876. 'payment': {'payMode': 'dj', 'contractAmount': '1.00'},
  877. 'paymentDetails': [],
  878. }
  879. ),
  880. ctx=_ctx('specialist'),
  881. user_info=_user(),
  882. )
  883. donation = _donation(db, 'donation-payment-mixed')
  884. db.commit()
  885. with pytest.raises(ValueError, match='科研捐赠.*缴费'):
  886. update_protocol(
  887. db,
  888. source='research_donation',
  889. protocol_id=donation.id,
  890. body=MarketProtocolBody.model_validate(
  891. {
  892. 'source': 'research_donation',
  893. 'payment': {'payMode': 'dj', 'contractAmount': '1.00'},
  894. }
  895. ),
  896. ctx=_ctx('specialist'),
  897. user_info=_user(),
  898. )
  899. def test_v1_market_update_preserves_locked_rows_and_mutates_only_drafts():
  900. from app.schemas.market_mobile import MarketProtocolBody
  901. from app.services.market_protocol import update_protocol
  902. db = _session()
  903. _masters(db)
  904. row = _cell(db, 'legacy-status-guard')
  905. row.payment_model_version = 1
  906. payment = BizProtocolPayment(
  907. id='legacy-payment', collection_protocol_id=row.id, pay_mode='dj',
  908. contract_amount=Decimal('1000.00'), tenant_no='tenant-a',
  909. create_dept_id='', del_flag=0,
  910. )
  911. details = [
  912. BizProtocolPaymentDetail(
  913. id=f'detail-{status_name}', payment_id=payment.id,
  914. pay_status=status, due_amount=Decimal('100.00'),
  915. transfer_amount=Decimal('10.00'), remark='原值',
  916. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  917. )
  918. for status_name, status in (
  919. ('paid', 'yjf'), ('confirming', 'jfqrz'),
  920. ('empty', ''), ('spaces', ' '), ('draft', 'dfj'),
  921. ('padded-draft', ' dfj '), ('delete', 'dfj'),
  922. )
  923. ]
  924. db.add_all([payment, *details])
  925. db.commit()
  926. update_protocol(
  927. db,
  928. source='cell_storage',
  929. protocol_id=row.id,
  930. body=MarketProtocolBody.model_validate(
  931. {
  932. 'source': 'cell_storage',
  933. 'payment': {'payMode': 'dj', 'contractAmount': '1000.00'},
  934. 'paymentDetails': [
  935. {'id': 'detail-paid', 'dueAmount': '1.00', 'remark': '篡改'},
  936. {'id': 'detail-confirming', 'dueAmount': '1.00', 'remark': '篡改'},
  937. {'id': 'detail-empty', 'dueAmount': '1.00', 'remark': '篡改'},
  938. {'id': 'detail-spaces', 'dueAmount': '1.00', 'remark': '篡改'},
  939. {'id': 'detail-draft', 'dueAmount': '250.00', 'remark': '已更新'},
  940. {'dueAmount': '350.00', 'remark': '新增草稿'},
  941. ],
  942. }
  943. ),
  944. ctx=_ctx('specialist'),
  945. user_info=_user(),
  946. )
  947. db.expire_all()
  948. for locked_id in (
  949. 'detail-paid', 'detail-confirming', 'detail-empty',
  950. 'detail-spaces', 'detail-padded-draft',
  951. ):
  952. locked = db.get(BizProtocolPaymentDetail, locked_id)
  953. assert locked.due_amount == Decimal('100.00')
  954. assert locked.remark == '原值'
  955. assert locked.del_flag == 0
  956. assert db.get(BizProtocolPaymentDetail, 'detail-draft').due_amount == Decimal('250.00')
  957. assert db.get(BizProtocolPaymentDetail, 'detail-delete').del_flag == 1
  958. created = db.query(BizProtocolPaymentDetail).filter_by(
  959. payment_id=payment.id, remark='新增草稿', del_flag=0
  960. ).one()
  961. assert created.pay_status == 'dfj'
  962. def test_v1_market_update_rolls_back_protocol_and_payment_on_detail_failure():
  963. from app.schemas.market_mobile import MarketProtocolBody
  964. from app.services.market_protocol import update_protocol
  965. db = _session()
  966. _masters(db)
  967. row = _cell(db, 'legacy-atomic')
  968. row.payment_model_version = 1
  969. payment = BizProtocolPayment(
  970. id='legacy-atomic-payment', collection_protocol_id=row.id,
  971. pay_mode='fq', contract_amount=Decimal('2000.00'),
  972. installment_count=2, installment_ratios='[1000, 1000]',
  973. remark='原方案', tenant_no='tenant-a', create_dept_id='', del_flag=0,
  974. )
  975. locked = BizProtocolPaymentDetail(
  976. id='legacy-atomic-locked', payment_id=payment.id, pay_status='yjf',
  977. installment_no=2, due_amount=Decimal('1000.00'),
  978. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  979. )
  980. db.add_all([payment, locked])
  981. db.commit()
  982. with pytest.raises(ValueError, match='明细条数'):
  983. update_protocol(
  984. db,
  985. source='cell_storage',
  986. protocol_id=row.id,
  987. body=MarketProtocolBody.model_validate(
  988. {
  989. 'source': 'cell_storage',
  990. 'remark': '不应提交',
  991. 'payment': {
  992. 'payMode': 'fq', 'contractAmount': '700.00',
  993. 'installmentCount': 1, 'installmentRatios': ['700.00'],
  994. },
  995. 'paymentDetails': [],
  996. }
  997. ),
  998. ctx=_ctx('specialist'),
  999. user_info=_user(),
  1000. )
  1001. db.expire_all()
  1002. assert db.get(BizCollectionProtocol, row.id).remark is None
  1003. saved_payment = db.get(BizProtocolPayment, payment.id)
  1004. assert saved_payment.contract_amount == Decimal('2000.00')
  1005. assert saved_payment.installment_count == 2
  1006. assert saved_payment.remark == '原方案'
  1007. assert db.get(BizProtocolPaymentDetail, locked.id).del_flag == 0
  1008. def test_v1_market_update_rejects_locked_plan_inconsistency_and_rolls_back_everything():
  1009. from app.schemas.market_mobile import MarketProtocolBody
  1010. from app.services.market_protocol import update_protocol
  1011. db = _session()
  1012. _masters(db)
  1013. row = _cell(db, 'legacy-locked-plan')
  1014. row.payment_model_version = 1
  1015. payment = BizProtocolPayment(
  1016. id='legacy-locked-plan-payment', collection_protocol_id=row.id,
  1017. pay_mode='fq', contract_amount=Decimal('2000.00'),
  1018. installment_count=2, installment_interval_months=Decimal('12'),
  1019. installment_start_date='20261001', installment_advance_days=30,
  1020. installment_ratios='[1000, 1000]', tenant_no='tenant-a',
  1021. create_dept_id='', del_flag=0,
  1022. )
  1023. locked = BizProtocolPaymentDetail(
  1024. id='legacy-locked-plan-paid', payment_id=payment.id, pay_status='yjf',
  1025. installment_no=1, due_amount=Decimal('1000.00'),
  1026. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1027. )
  1028. db.add_all([payment, locked])
  1029. db.commit()
  1030. with pytest.raises(ValueError, match='已缴|缴清|锁定|合同金额|缴费计划'):
  1031. update_protocol(
  1032. db,
  1033. source='cell_storage',
  1034. protocol_id=row.id,
  1035. body=MarketProtocolBody.model_validate(
  1036. {
  1037. 'source': 'cell_storage',
  1038. 'remark': '不应提交',
  1039. 'payment': {'payMode': 'dj', 'contractAmount': '1.00'},
  1040. 'paymentDetails': [
  1041. {
  1042. 'id': locked.id,
  1043. 'installmentNo': 1,
  1044. 'dueAmount': '1000.00',
  1045. }
  1046. ],
  1047. }
  1048. ),
  1049. ctx=_ctx('specialist'),
  1050. user_info=_user(),
  1051. )
  1052. db.expire_all()
  1053. assert db.get(BizCollectionProtocol, row.id).remark is None
  1054. saved_payment = db.get(BizProtocolPayment, payment.id)
  1055. assert saved_payment.pay_mode == 'fq'
  1056. assert saved_payment.contract_amount == Decimal('2000.00')
  1057. assert saved_payment.installment_count == 2
  1058. assert db.get(BizProtocolPaymentDetail, locked.id).del_flag == 0
  1059. def test_v1_market_update_blocks_plan_changes_during_finance_confirmation():
  1060. from app.schemas.market_mobile import MarketProtocolBody
  1061. from app.services.market_protocol import update_protocol
  1062. db = _session()
  1063. _masters(db)
  1064. row = _cell(db, 'legacy-confirming-plan')
  1065. row.payment_model_version = 1
  1066. payment = BizProtocolPayment(
  1067. id='legacy-confirming-plan-payment', collection_protocol_id=row.id,
  1068. pay_mode='fq', contract_amount=Decimal('2000.00'),
  1069. installment_count=2, installment_interval_months=Decimal('12'),
  1070. installment_start_date='20261001', installment_advance_days=30,
  1071. installment_ratios='[1000, 1000]', tenant_no='tenant-a',
  1072. create_dept_id='', del_flag=0,
  1073. )
  1074. confirming = BizProtocolPaymentDetail(
  1075. id='legacy-confirming-plan-locked', payment_id=payment.id,
  1076. pay_status='jfqrz', installment_no=1, due_amount=Decimal('1000.00'),
  1077. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1078. )
  1079. draft = BizProtocolPaymentDetail(
  1080. id='legacy-confirming-plan-draft', payment_id=payment.id,
  1081. pay_status='dfj', installment_no=2, due_amount=Decimal('1000.00'),
  1082. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1083. )
  1084. db.add_all([payment, confirming, draft])
  1085. db.commit()
  1086. with pytest.raises(ValueError, match='确认中|锁定|缴费计划'):
  1087. update_protocol(
  1088. db,
  1089. source='cell_storage',
  1090. protocol_id=row.id,
  1091. body=MarketProtocolBody.model_validate(
  1092. {
  1093. 'source': 'cell_storage',
  1094. 'remark': '不应提交',
  1095. 'payment': {
  1096. 'payMode': 'fq',
  1097. 'contractAmount': '2000.00',
  1098. 'installmentCount': 2,
  1099. 'installmentIntervalMonths': '24',
  1100. 'installmentStartDate': '20261001',
  1101. 'installmentAdvanceDays': 30,
  1102. 'installmentRatios': ['1000.00', '1000.00'],
  1103. },
  1104. 'paymentDetails': [
  1105. {
  1106. 'id': confirming.id,
  1107. 'installmentNo': 1,
  1108. 'dueAmount': '1000.00',
  1109. },
  1110. {
  1111. 'id': draft.id,
  1112. 'installmentNo': 2,
  1113. 'dueAmount': '1000.00',
  1114. },
  1115. ],
  1116. }
  1117. ),
  1118. ctx=_ctx('specialist'),
  1119. user_info=_user(),
  1120. )
  1121. db.expire_all()
  1122. assert db.get(BizCollectionProtocol, row.id).remark is None
  1123. assert db.get(BizProtocolPayment, payment.id).installment_interval_months == Decimal('12')
  1124. def test_v1_market_update_rejects_stale_draft_schedule_after_paid_plan_change():
  1125. from app.schemas.market_mobile import MarketProtocolBody
  1126. from app.services.market_protocol import update_protocol
  1127. db = _session()
  1128. _masters(db)
  1129. row = _cell(db, 'legacy-stale-plan')
  1130. row.payment_model_version = 1
  1131. payment = BizProtocolPayment(
  1132. id='legacy-stale-plan-payment', collection_protocol_id=row.id,
  1133. pay_mode='fq', contract_amount=Decimal('2000.00'),
  1134. installment_count=2, installment_interval_months=Decimal('12'),
  1135. installment_start_date='20261001', installment_advance_days=30,
  1136. installment_ratios='[1000, 1000]', tenant_no='tenant-a',
  1137. create_dept_id='', del_flag=0,
  1138. )
  1139. paid = BizProtocolPaymentDetail(
  1140. id='legacy-stale-plan-paid', payment_id=payment.id, pay_status='yjf',
  1141. installment_no=1, installment_due_date='20261001',
  1142. due_amount=Decimal('1000.00'), tenant_no='tenant-a',
  1143. create_dept_id='', del_flag=0,
  1144. )
  1145. draft = BizProtocolPaymentDetail(
  1146. id='legacy-stale-plan-draft', payment_id=payment.id, pay_status='dfj',
  1147. installment_no=2, installment_due_date='20270901',
  1148. due_amount=Decimal('1000.00'), tenant_no='tenant-a',
  1149. create_dept_id='', del_flag=0,
  1150. )
  1151. db.add_all([payment, paid, draft])
  1152. db.commit()
  1153. with pytest.raises(ValueError, match='缴费计划|应缴时间'):
  1154. update_protocol(
  1155. db,
  1156. source='cell_storage',
  1157. protocol_id=row.id,
  1158. body=MarketProtocolBody.model_validate(
  1159. {
  1160. 'source': 'cell_storage',
  1161. 'remark': '不应提交',
  1162. 'payment': {
  1163. 'payMode': 'fq', 'contractAmount': '2000.00',
  1164. 'installmentCount': 2, 'installmentIntervalMonths': '24',
  1165. 'installmentStartDate': '20261001',
  1166. 'installmentAdvanceDays': 30,
  1167. 'installmentRatios': ['1000.00', '1000.00'],
  1168. },
  1169. 'paymentDetails': [
  1170. {
  1171. 'id': paid.id, 'installmentNo': 1,
  1172. 'installmentDueDate': '20261001',
  1173. 'dueAmount': '1000.00',
  1174. },
  1175. {
  1176. 'id': draft.id, 'installmentNo': 2,
  1177. 'installmentDueDate': '20270901',
  1178. 'dueAmount': '1000.00',
  1179. },
  1180. ],
  1181. }
  1182. ),
  1183. ctx=_ctx('specialist'),
  1184. user_info=_user(),
  1185. )
  1186. db.expire_all()
  1187. assert db.get(BizCollectionProtocol, row.id).remark is None
  1188. assert db.get(BizProtocolPayment, payment.id).installment_interval_months == Decimal('12')
  1189. assert db.get(BizProtocolPaymentDetail, draft.id).installment_due_date == '20270901'
  1190. result = update_protocol(
  1191. db,
  1192. source='cell_storage',
  1193. protocol_id=row.id,
  1194. body=MarketProtocolBody.model_validate(
  1195. {
  1196. 'source': 'cell_storage',
  1197. 'remark': '已对齐新计划',
  1198. 'payment': {
  1199. 'payMode': 'fq', 'contractAmount': '2000.00',
  1200. 'installmentCount': 2, 'installmentIntervalMonths': '24',
  1201. 'installmentStartDate': '20261001',
  1202. 'installmentAdvanceDays': 30,
  1203. 'installmentRatios': ['1000.00', '1000.00'],
  1204. },
  1205. 'paymentDetails': [
  1206. {
  1207. 'id': paid.id, 'installmentNo': 1,
  1208. 'installmentDueDate': '20261001',
  1209. 'dueAmount': '1000.00',
  1210. },
  1211. {
  1212. 'id': draft.id, 'installmentNo': 2,
  1213. 'installmentDueDate': '20280901',
  1214. 'dueAmount': '1000.00',
  1215. },
  1216. ],
  1217. }
  1218. ),
  1219. ctx=_ctx('specialist'),
  1220. user_info=_user(),
  1221. )
  1222. assert result['payment']['installmentIntervalMonths'] == 24.0
  1223. db.expire_all()
  1224. assert db.get(BizCollectionProtocol, row.id).remark == '已对齐新计划'
  1225. assert db.get(BizProtocolPaymentDetail, draft.id).installment_due_date == '20280901'
  1226. def test_v1_market_update_requires_rebuilt_lump_sum_after_paid_installment_switch():
  1227. from app.schemas.market_mobile import MarketProtocolBody
  1228. from app.services.market_protocol import update_protocol
  1229. db = _session()
  1230. _masters(db)
  1231. row = _cell(db, 'legacy-switch-to-lump-sum')
  1232. row.payment_model_version = 1
  1233. payment = BizProtocolPayment(
  1234. id='legacy-switch-to-lump-sum-payment', collection_protocol_id=row.id,
  1235. pay_mode='fq', contract_amount=Decimal('2000.00'),
  1236. installment_count=2, installment_interval_months=Decimal('12'),
  1237. installment_start_date='20261001', installment_advance_days=30,
  1238. installment_ratios='[1000, 1000]', tenant_no='tenant-a',
  1239. create_dept_id='', del_flag=0,
  1240. )
  1241. paid = BizProtocolPaymentDetail(
  1242. id='legacy-switch-to-lump-sum-paid', payment_id=payment.id,
  1243. pay_status='yjf', installment_no=1, installment_due_date='20261001',
  1244. due_amount=Decimal('1000.00'), tenant_no='tenant-a',
  1245. create_dept_id='', del_flag=0,
  1246. )
  1247. old_draft = BizProtocolPaymentDetail(
  1248. id='legacy-switch-to-lump-sum-draft', payment_id=payment.id,
  1249. pay_status='dfj', installment_no=2, installment_due_date='20270901',
  1250. due_amount=Decimal('1000.00'), tenant_no='tenant-a',
  1251. create_dept_id='', del_flag=0,
  1252. )
  1253. db.add_all([payment, paid, old_draft])
  1254. db.commit()
  1255. payment_payload = {
  1256. 'payMode': 'dj', 'contractAmount': '2000.00',
  1257. 'lumpSumDeadline': '20261231',
  1258. }
  1259. with pytest.raises(ValueError, match='趸交|待缴|期号|应缴时间'):
  1260. update_protocol(
  1261. db,
  1262. source='cell_storage',
  1263. protocol_id=row.id,
  1264. body=MarketProtocolBody.model_validate(
  1265. {
  1266. 'source': 'cell_storage',
  1267. 'remark': '不应提交',
  1268. 'payment': payment_payload,
  1269. 'paymentDetails': [
  1270. {
  1271. 'id': paid.id, 'installmentNo': 1,
  1272. 'installmentDueDate': '20261001',
  1273. 'dueAmount': '1000.00',
  1274. },
  1275. {
  1276. 'id': old_draft.id, 'installmentNo': 2,
  1277. 'installmentDueDate': '20270901',
  1278. 'dueAmount': '1000.00',
  1279. },
  1280. ],
  1281. }
  1282. ),
  1283. ctx=_ctx('specialist'),
  1284. user_info=_user(),
  1285. )
  1286. db.expire_all()
  1287. assert db.get(BizCollectionProtocol, row.id).remark is None
  1288. assert db.get(BizProtocolPayment, payment.id).pay_mode == 'fq'
  1289. assert db.get(BizProtocolPaymentDetail, old_draft.id).del_flag == 0
  1290. result = update_protocol(
  1291. db,
  1292. source='cell_storage',
  1293. protocol_id=row.id,
  1294. body=MarketProtocolBody.model_validate(
  1295. {
  1296. 'source': 'cell_storage',
  1297. 'remark': '转趸交',
  1298. 'payment': payment_payload,
  1299. 'paymentDetails': [
  1300. {
  1301. 'id': paid.id, 'installmentNo': 1,
  1302. 'installmentDueDate': '20261001',
  1303. 'dueAmount': '1000.00',
  1304. },
  1305. {
  1306. 'installmentNo': None,
  1307. 'installmentDueDate': '20261231',
  1308. 'dueAmount': '1000.00',
  1309. },
  1310. ],
  1311. }
  1312. ),
  1313. ctx=_ctx('specialist'),
  1314. user_info=_user(),
  1315. )
  1316. editable = [item for item in result['paymentDetails'] if item['payStatus'] == 'dfj']
  1317. assert len(editable) == 1
  1318. assert editable[0]['installmentNo'] is None
  1319. assert editable[0]['installmentDueDate'] == '20261231'
  1320. assert editable[0]['dueAmount'] == '1000.00'
  1321. db.expire_all()
  1322. assert db.get(BizProtocolPaymentDetail, old_draft.id).del_flag == 1
  1323. def test_v1_market_update_rejects_details_without_payment_and_keeps_attachment_staging():
  1324. from app.schemas.market_mobile import MarketProtocolBody
  1325. from app.services.market_protocol import update_protocol
  1326. db = _session()
  1327. _masters(db)
  1328. row = _cell(db, 'legacy-details-without-parent')
  1329. row.payment_model_version = 1
  1330. db.commit()
  1331. with pytest.raises(ValueError, match='缴费主表|缴费方案'):
  1332. update_protocol(
  1333. db,
  1334. source='cell_storage',
  1335. protocol_id=row.id,
  1336. body=MarketProtocolBody.model_validate(
  1337. {
  1338. 'source': 'cell_storage',
  1339. 'remark': '不应提交',
  1340. 'paymentDetails': [
  1341. {
  1342. 'dueAmount': '100.00',
  1343. 'attachments': 'protocol/a.pdf',
  1344. }
  1345. ],
  1346. }
  1347. ),
  1348. ctx=_ctx('specialist'),
  1349. user_info=_user(),
  1350. )
  1351. db.expire_all()
  1352. assert db.get(BizCollectionProtocol, row.id).remark is None
  1353. assert db.query(BizProtocolPayment).count() == 0
  1354. assert db.query(BizProtocolPaymentDetail).count() == 0
  1355. attachment = db.get(BizAttachment, 'att-protocol-a')
  1356. assert attachment.business_type == 'market:specialist:protocol:staging'
  1357. assert attachment.business_id in (None, '')
  1358. @pytest.mark.parametrize(
  1359. 'attachment',
  1360. [
  1361. {
  1362. 'id': 'unknown-ref', 'file_url': 'protocol/unknown.pdf',
  1363. 'business_type': 'market:specialist:protocol:staging',
  1364. 'tenant_no': 'tenant-a', 'create_by': 'u1',
  1365. },
  1366. {
  1367. 'id': 'cross-tenant-ref', 'file_url': 'protocol/cross.pdf',
  1368. 'business_type': 'market:specialist:protocol:staging',
  1369. 'tenant_no': 'tenant-b', 'create_by': 'u1',
  1370. },
  1371. {
  1372. 'id': 'wrong-module-ref', 'file_url': 'payment/wrong-module.pdf',
  1373. 'business_type': 'market:specialist:payment:staging',
  1374. 'tenant_no': 'tenant-a', 'create_by': 'u1',
  1375. },
  1376. {
  1377. 'id': 'other-user-ref', 'file_url': 'protocol/other-user.pdf',
  1378. 'business_type': 'market:specialist:protocol:staging',
  1379. 'tenant_no': 'tenant-a', 'create_by': 'u2',
  1380. },
  1381. ],
  1382. )
  1383. def test_v1_market_update_rejects_untrusted_payment_detail_attachment_and_rolls_back(attachment):
  1384. from app.schemas.market_mobile import MarketProtocolBody
  1385. from app.services.market_protocol import update_protocol
  1386. db = _session()
  1387. _masters(db)
  1388. row = _cell(db, f"legacy-{attachment['id']}")
  1389. row.payment_model_version = 1
  1390. payment = BizProtocolPayment(
  1391. id=f"payment-{attachment['id']}", collection_protocol_id=row.id,
  1392. pay_mode='dj', contract_amount=Decimal('100.00'),
  1393. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1394. )
  1395. detail = BizProtocolPaymentDetail(
  1396. id=f"detail-{attachment['id']}", payment_id=payment.id,
  1397. pay_status='dfj', due_amount=Decimal('100.00'),
  1398. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1399. )
  1400. if attachment['id'] != 'unknown-ref':
  1401. db.add(
  1402. BizAttachment(
  1403. id=attachment['id'], business_type=attachment['business_type'],
  1404. original_name='proof.pdf', file_ext='pdf',
  1405. file_url=attachment['file_url'], file_size=10,
  1406. file_type='application/pdf', tenant_no=attachment['tenant_no'],
  1407. create_by=attachment['create_by'], create_dept_id='', del_flag=0,
  1408. )
  1409. )
  1410. db.add_all([payment, detail])
  1411. db.commit()
  1412. with pytest.raises(ValueError, match='附件'):
  1413. update_protocol(
  1414. db,
  1415. source='cell_storage',
  1416. protocol_id=row.id,
  1417. body=MarketProtocolBody.model_validate(
  1418. {
  1419. 'source': 'cell_storage',
  1420. 'remark': '不应提交',
  1421. 'paymentDetails': [
  1422. {
  1423. 'id': detail.id,
  1424. 'dueAmount': '100.00',
  1425. 'transferAttachments': attachment['file_url'],
  1426. }
  1427. ],
  1428. }
  1429. ),
  1430. ctx=_ctx('specialist'),
  1431. user_info=_user(),
  1432. )
  1433. db.expire_all()
  1434. assert db.get(BizCollectionProtocol, row.id).remark is None
  1435. assert db.get(BizProtocolPaymentDetail, detail.id).transfer_attachments is None
  1436. def test_v1_market_update_binds_payment_detail_attachment_to_protocol_business():
  1437. from app.schemas.market_mobile import MarketProtocolBody
  1438. from app.services.market_protocol import update_protocol
  1439. db = _session()
  1440. _masters(db)
  1441. row = _cell(db, 'legacy-payment-attachment')
  1442. row.payment_model_version = 1
  1443. payment = BizProtocolPayment(
  1444. id='legacy-payment-attachment-parent', collection_protocol_id=row.id,
  1445. pay_mode='dj', contract_amount=Decimal('100.00'),
  1446. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1447. )
  1448. detail = BizProtocolPaymentDetail(
  1449. id='legacy-payment-attachment-detail', payment_id=payment.id,
  1450. pay_status='dfj', due_amount=Decimal('100.00'),
  1451. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1452. )
  1453. db.add_all([payment, detail])
  1454. db.commit()
  1455. update_protocol(
  1456. db,
  1457. source='cell_storage',
  1458. protocol_id=row.id,
  1459. body=MarketProtocolBody.model_validate(
  1460. {
  1461. 'source': 'cell_storage',
  1462. 'paymentDetails': [
  1463. {
  1464. 'id': detail.id,
  1465. 'dueAmount': '100.00',
  1466. 'attachments': 'protocol/a.pdf',
  1467. }
  1468. ],
  1469. }
  1470. ),
  1471. ctx=_ctx('specialist'),
  1472. user_info=_user(),
  1473. )
  1474. db.expire_all()
  1475. saved_detail = db.get(BizProtocolPaymentDetail, detail.id)
  1476. saved_attachment = db.get(BizAttachment, 'att-protocol-a')
  1477. assert saved_detail.transfer_attachments == 'protocol/a.pdf'
  1478. assert saved_attachment.business_type == 'market:specialist:protocol:bound'
  1479. assert saved_attachment.business_id == row.id
  1480. def test_protocol_attachment_download_authorizes_exact_payment_detail_relation_only():
  1481. from app.services.market_support import get_market_attachment_download_url
  1482. class FakeStorage:
  1483. @staticmethod
  1484. def presigned_download_url(file_url, file_name):
  1485. return f'https://download.test/{file_url}?name={file_name}'
  1486. db = _session()
  1487. own = _cell(db, 'payment-attachment-own')
  1488. hidden = _cell(db, 'payment-attachment-hidden', sales='u2')
  1489. own_payment = BizProtocolPayment(
  1490. id='payment-attachment-own-parent', collection_protocol_id=own.id,
  1491. pay_mode='dj', tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1492. )
  1493. hidden_payment = BizProtocolPayment(
  1494. id='payment-attachment-hidden-parent', collection_protocol_id=hidden.id,
  1495. pay_mode='dj', tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1496. )
  1497. db.add_all(
  1498. [
  1499. own_payment,
  1500. hidden_payment,
  1501. BizProtocolPaymentDetail(
  1502. id='payment-attachment-own-detail', payment_id=own_payment.id,
  1503. transfer_attachments='protocol/exact.pdf', pay_status='dfj',
  1504. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1505. ),
  1506. BizProtocolPaymentDetail(
  1507. id='payment-attachment-hidden-detail', payment_id=hidden_payment.id,
  1508. transfer_attachments='protocol/hidden.pdf', pay_status='dfj',
  1509. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1510. ),
  1511. BizProtocolPaymentDetail(
  1512. id='payment-attachment-substring-detail', payment_id=own_payment.id,
  1513. transfer_attachments='protocol/exact.pdf.evil', pay_status='dfj',
  1514. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  1515. ),
  1516. BizAttachment(
  1517. id='payment-attachment-exact',
  1518. business_type='market:specialist:protocol:bound', business_id=own.id,
  1519. original_name='exact.pdf', file_ext='pdf', file_url='protocol/exact.pdf',
  1520. file_size=10, file_type='application/pdf', tenant_no='tenant-a',
  1521. create_by='u1', create_dept_id='', del_flag=0,
  1522. ),
  1523. BizAttachment(
  1524. id='payment-attachment-hidden',
  1525. business_type='market:specialist:protocol:bound', business_id=hidden.id,
  1526. original_name='hidden.pdf', file_ext='pdf', file_url='protocol/hidden.pdf',
  1527. file_size=10, file_type='application/pdf', tenant_no='tenant-a',
  1528. create_by='u2', create_dept_id='', del_flag=0,
  1529. ),
  1530. BizAttachment(
  1531. id='payment-attachment-substring',
  1532. business_type='market:specialist:protocol:bound', business_id=own.id,
  1533. original_name='exact-short.pdf', file_ext='pdf', file_url='protocol/exact.pdf.evil',
  1534. file_size=10, file_type='application/pdf', tenant_no='tenant-a',
  1535. create_by='u1', create_dept_id='', del_flag=0,
  1536. ),
  1537. ]
  1538. )
  1539. db.commit()
  1540. result = get_market_attachment_download_url(
  1541. db,
  1542. file_url='protocol/exact.pdf',
  1543. file_name=None,
  1544. module='protocol',
  1545. ctx=_ctx('specialist'),
  1546. user_info=_user(),
  1547. storage_backend=FakeStorage(),
  1548. )
  1549. assert result['downloadUrl'].startswith('https://download.test/protocol/exact.pdf?')
  1550. with pytest.raises(PermissionError):
  1551. get_market_attachment_download_url(
  1552. db,
  1553. file_url='protocol/hidden.pdf',
  1554. file_name=None,
  1555. module='protocol',
  1556. ctx=_ctx('specialist'),
  1557. user_info=_user(),
  1558. storage_backend=FakeStorage(),
  1559. )
  1560. db.query(BizProtocolPaymentDetail).filter_by(
  1561. id='payment-attachment-own-detail'
  1562. ).delete()
  1563. db.commit()
  1564. with pytest.raises(PermissionError):
  1565. get_market_attachment_download_url(
  1566. db,
  1567. file_url='protocol/exact.pdf',
  1568. file_name=None,
  1569. module='protocol',
  1570. ctx=_ctx('specialist'),
  1571. user_info=_user(),
  1572. storage_backend=FakeStorage(),
  1573. )
  1574. @pytest.mark.parametrize('boundary', ['tenant', 'scope'])
  1575. def test_v1_market_update_cannot_cross_tenant_or_manager_scope(boundary):
  1576. from app.schemas.market_mobile import MarketProtocolBody
  1577. from app.services.market_protocol import update_protocol
  1578. db = _session()
  1579. _masters(db)
  1580. row = _cell(
  1581. db,
  1582. f'legacy-{boundary}-hidden',
  1583. sales='u2',
  1584. manager='manager-a',
  1585. region='region-b' if boundary == 'scope' else 'region-a',
  1586. )
  1587. row.payment_model_version = 1
  1588. payment = BizProtocolPayment(
  1589. id=f'legacy-{boundary}-payment',
  1590. collection_protocol_id=row.id,
  1591. pay_mode='dj',
  1592. contract_amount=Decimal('100.00'),
  1593. tenant_no='tenant-a',
  1594. create_dept_id='',
  1595. del_flag=0,
  1596. )
  1597. if boundary == 'tenant':
  1598. row.tenant_no = 'tenant-b'
  1599. db.query(BizProtocolCollectionInfo).filter_by(
  1600. collection_protocol_id=row.id
  1601. ).update({'tenant_no': 'tenant-b'})
  1602. payment.tenant_no = 'tenant-b'
  1603. db.add(payment)
  1604. db.commit()
  1605. with pytest.raises(HTTPException):
  1606. update_protocol(
  1607. db,
  1608. source='cell_storage',
  1609. protocol_id=row.id,
  1610. body=MarketProtocolBody.model_validate(
  1611. {
  1612. 'source': 'cell_storage',
  1613. 'payment': {'payMode': 'dj', 'contractAmount': '999.00'},
  1614. 'paymentDetails': [],
  1615. }
  1616. ),
  1617. ctx=_ctx('specialist' if boundary == 'tenant' else 'manager'),
  1618. user_info=_user(),
  1619. )
  1620. db.expire_all()
  1621. assert db.get(BizProtocolPayment, payment.id).contract_amount == Decimal('100.00')
  1622. def test_cell_sample_partial_edit_preserves_hidden_lifecycle_fields():
  1623. from app.schemas.market_mobile import MarketProtocolBody
  1624. from app.services.market_protocol import update_protocol
  1625. db = _session()
  1626. _masters(db)
  1627. row = _cell(db, 'sample-merge')
  1628. db.add(
  1629. BizProtocolSample(
  1630. id='sample-merge-old',
  1631. collection_protocol_id=row.id,
  1632. sample_type_id='type-a',
  1633. sample_code_id='code-a',
  1634. sample_code='S-001',
  1635. collect_amount=12.5,
  1636. unit_id='unit-a',
  1637. price=88,
  1638. original_price=99,
  1639. storage_years=20,
  1640. sample_status='yrk',
  1641. storage_in_date='20261001',
  1642. detect_start_date='20260929',
  1643. void_date='20261002',
  1644. storage_expire_date='20461001',
  1645. is_special=1,
  1646. special_remark='历史特殊说明',
  1647. remark='历史样本备注',
  1648. tenant_no='tenant-a',
  1649. create_dept_id='',
  1650. del_flag=0,
  1651. )
  1652. )
  1653. db.commit()
  1654. with patch(
  1655. 'app.crud.collection_protocol._migrate_sample_child_refs_on_replace'
  1656. ):
  1657. update_protocol(
  1658. db,
  1659. source='cell_storage',
  1660. protocol_id=row.id,
  1661. body=MarketProtocolBody.model_validate(
  1662. {
  1663. 'source': 'cell_storage',
  1664. 'samples': [
  1665. {
  1666. 'sampleTypeId': 'type-a',
  1667. 'sampleCodeId': 'code-a',
  1668. 'sampleCode': 'S-001',
  1669. 'price': 77,
  1670. }
  1671. ],
  1672. }
  1673. ),
  1674. ctx=_ctx('specialist'),
  1675. user_info=_user(),
  1676. )
  1677. saved = db.query(BizProtocolSample).filter_by(
  1678. collection_protocol_id=row.id, del_flag=0
  1679. ).one()
  1680. assert float(saved.collect_amount) == 12.5
  1681. assert saved.unit_id == 'unit-a'
  1682. assert saved.remark == '历史样本备注'
  1683. assert saved.storage_in_date == '20261001'
  1684. assert saved.detect_start_date == '20260929'
  1685. assert saved.void_date == '20261002'
  1686. assert saved.storage_expire_date == '20461001'
  1687. assert float(saved.price) == 77
  1688. def test_donation_sample_partial_edit_preserves_hidden_lifecycle_fields():
  1689. from app.schemas.market_mobile import MarketProtocolBody
  1690. from app.services.market_protocol import update_protocol
  1691. db = _session()
  1692. _masters(db)
  1693. row = _donation(db, 'donation-sample-merge')
  1694. db.add(
  1695. BizResearchDonationSample(
  1696. id='donation-sample-merge-old',
  1697. research_donation_id=row.id,
  1698. sample_type_id='type-a',
  1699. sample_code_id='code-a',
  1700. sample_code='S-001',
  1701. collect_amount=9.5,
  1702. unit_id='unit-a',
  1703. storage_years=10,
  1704. sample_status='jcz',
  1705. storage_in_date='20261001',
  1706. detect_start_date='20260929',
  1707. void_date='20261002',
  1708. storage_expire_date='20361001',
  1709. is_special=1,
  1710. special_remark='历史捐赠说明',
  1711. remark='历史捐赠备注',
  1712. tenant_no='tenant-a',
  1713. create_dept_id='',
  1714. del_flag=0,
  1715. )
  1716. )
  1717. db.commit()
  1718. with patch(
  1719. 'app.crud.research_donation._migrate_donation_sample_child_refs_on_replace'
  1720. ):
  1721. update_protocol(
  1722. db,
  1723. source='research_donation',
  1724. protocol_id=row.id,
  1725. body=MarketProtocolBody.model_validate(
  1726. {
  1727. 'source': 'research_donation',
  1728. 'samples': [
  1729. {
  1730. 'sampleTypeId': 'type-a',
  1731. 'sampleCodeId': 'code-a',
  1732. 'sampleCode': 'S-001',
  1733. 'storageYears': 11,
  1734. }
  1735. ],
  1736. }
  1737. ),
  1738. ctx=_ctx('specialist'),
  1739. user_info=_user(),
  1740. )
  1741. saved = db.query(BizResearchDonationSample).filter_by(
  1742. research_donation_id=row.id, del_flag=0
  1743. ).one()
  1744. assert float(saved.collect_amount) == 9.5
  1745. assert saved.unit_id == 'unit-a'
  1746. assert saved.remark == '历史捐赠备注'
  1747. assert saved.storage_in_date == '20261001'
  1748. assert saved.detect_start_date == '20260929'
  1749. assert saved.void_date == '20261002'
  1750. assert saved.storage_expire_date == '20361001'
  1751. assert saved.storage_years == 11
  1752. def _protocol_sample_row(source, *, row_id, protocol_id, code_id, code, amount, remark):
  1753. common = {
  1754. 'id': row_id,
  1755. 'sample_type_id': 'type-a',
  1756. 'sample_code_id': code_id,
  1757. 'sample_code': code,
  1758. 'collect_amount': amount,
  1759. 'unit_id': 'unit-a',
  1760. 'sample_status': 'yrk',
  1761. 'remark': remark,
  1762. 'tenant_no': 'tenant-a',
  1763. 'create_dept_id': '',
  1764. 'del_flag': 0,
  1765. }
  1766. if source == 'cell_storage':
  1767. return BizProtocolSample(collection_protocol_id=protocol_id, **common)
  1768. return BizResearchDonationSample(research_donation_id=protocol_id, **common)
  1769. def _active_protocol_samples(db, source, protocol_id):
  1770. if source == 'cell_storage':
  1771. return (
  1772. db.query(BizProtocolSample)
  1773. .filter_by(collection_protocol_id=protocol_id, del_flag=0)
  1774. .order_by(BizProtocolSample.order_num.asc())
  1775. .all()
  1776. )
  1777. return (
  1778. db.query(BizResearchDonationSample)
  1779. .filter_by(research_donation_id=protocol_id, del_flag=0)
  1780. .order_by(BizResearchDonationSample.order_num.asc())
  1781. .all()
  1782. )
  1783. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  1784. def test_protocol_sample_update_uses_id_for_insert_and_reorder(source):
  1785. from app.schemas.market_mobile import MarketProtocolBody
  1786. from app.services.market_protocol import update_protocol
  1787. db = _session()
  1788. _masters(db)
  1789. row_id = f'{source}-sample-reorder'
  1790. (_cell if source == 'cell_storage' else _donation)(db, row_id)
  1791. db.add_all(
  1792. [
  1793. _protocol_sample_row(
  1794. source,
  1795. row_id='sample-a',
  1796. protocol_id=row_id,
  1797. code_id='code-a',
  1798. code='S-001',
  1799. amount=11,
  1800. remark='隐藏-A',
  1801. ),
  1802. _protocol_sample_row(
  1803. source,
  1804. row_id='sample-b',
  1805. protocol_id=row_id,
  1806. code_id='code-b',
  1807. code='S-002',
  1808. amount=22,
  1809. remark='隐藏-B',
  1810. ),
  1811. ]
  1812. )
  1813. db.commit()
  1814. migrate_target = (
  1815. 'app.crud.collection_protocol._migrate_sample_child_refs_on_replace'
  1816. if source == 'cell_storage'
  1817. else 'app.crud.research_donation._migrate_donation_sample_child_refs_on_replace'
  1818. )
  1819. with patch(migrate_target):
  1820. update_protocol(
  1821. db,
  1822. source=source,
  1823. protocol_id=row_id,
  1824. body=MarketProtocolBody.model_validate(
  1825. {
  1826. 'source': source,
  1827. 'samples': [
  1828. {
  1829. 'sampleTypeId': 'type-a',
  1830. 'sampleCodeId': 'code-c',
  1831. 'sampleCode': 'S-003',
  1832. },
  1833. {
  1834. 'id': 'sample-b',
  1835. 'sampleTypeId': 'type-a',
  1836. 'sampleCodeId': 'code-b',
  1837. 'sampleCode': 'S-002',
  1838. },
  1839. {
  1840. 'id': 'sample-a',
  1841. 'sampleTypeId': 'type-a',
  1842. 'sampleCodeId': 'code-a',
  1843. 'sampleCode': 'S-001',
  1844. },
  1845. ],
  1846. }
  1847. ),
  1848. ctx=_ctx('specialist'),
  1849. user_info=_user(),
  1850. )
  1851. saved = _active_protocol_samples(db, source, row_id)
  1852. assert [row.sample_code for row in saved] == ['S-003', 'S-002', 'S-001']
  1853. assert [row.remark for row in saved] == [None, '隐藏-B', '隐藏-A']
  1854. assert [float(row.collect_amount) if row.collect_amount is not None else None for row in saved] == [
  1855. None,
  1856. 22.0,
  1857. 11.0,
  1858. ]
  1859. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  1860. def test_protocol_sample_update_uses_id_when_replacing_code(source):
  1861. from app.schemas.market_mobile import MarketProtocolBody
  1862. from app.services.market_protocol import update_protocol
  1863. db = _session()
  1864. _masters(db)
  1865. row_id = f'{source}-sample-recode'
  1866. (_cell if source == 'cell_storage' else _donation)(db, row_id)
  1867. db.add(
  1868. _protocol_sample_row(
  1869. source,
  1870. row_id='sample-recode',
  1871. protocol_id=row_id,
  1872. code_id='code-a',
  1873. code='S-001',
  1874. amount=15,
  1875. remark='必须保留',
  1876. )
  1877. )
  1878. db.commit()
  1879. migrate_target = (
  1880. 'app.crud.collection_protocol._migrate_sample_child_refs_on_replace'
  1881. if source == 'cell_storage'
  1882. else 'app.crud.research_donation._migrate_donation_sample_child_refs_on_replace'
  1883. )
  1884. with patch(migrate_target):
  1885. update_protocol(
  1886. db,
  1887. source=source,
  1888. protocol_id=row_id,
  1889. body=MarketProtocolBody.model_validate(
  1890. {
  1891. 'source': source,
  1892. 'samples': [
  1893. {
  1894. 'sampleId': 'sample-recode',
  1895. 'sampleTypeId': 'type-a',
  1896. 'sampleCodeId': 'code-c',
  1897. 'sampleCode': 'S-003',
  1898. }
  1899. ],
  1900. }
  1901. ),
  1902. ctx=_ctx('specialist'),
  1903. user_info=_user(),
  1904. )
  1905. saved = _active_protocol_samples(db, source, row_id)[0]
  1906. assert saved.sample_code_id == 'code-c'
  1907. assert saved.sample_code == 'S-003'
  1908. assert float(saved.collect_amount) == 15
  1909. assert saved.remark == '必须保留'
  1910. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  1911. @pytest.mark.parametrize('duplicate_field', ['sampleCode', 'sampleCodeId'])
  1912. def test_protocol_sample_write_rejects_duplicate_identity(source, duplicate_field):
  1913. from app.services.market_protocol import create_protocol
  1914. db = _session()
  1915. _masters(db)
  1916. if duplicate_field == 'sampleCode':
  1917. samples = [
  1918. {'sampleTypeId': 'type-a', 'sampleCode': 'DUP-001'},
  1919. {'sampleTypeId': 'type-a', 'sampleCode': 'DUP-001'},
  1920. ]
  1921. else:
  1922. samples = [
  1923. {'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'},
  1924. {'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-ALT'},
  1925. ]
  1926. overrides = {'samples': samples}
  1927. if source == 'research_donation':
  1928. overrides.update({'donationNo': 'D-DUP', 'protocolNo': None})
  1929. with pytest.raises(ValueError, match=f'{duplicate_field}.*重复'):
  1930. create_protocol(
  1931. db,
  1932. body=_body(source, **overrides),
  1933. ctx=_ctx('specialist'),
  1934. user_info=_user(),
  1935. )
  1936. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  1937. def test_protocol_sample_update_rejects_duplicate_sample_id(source):
  1938. from app.schemas.market_mobile import MarketProtocolBody
  1939. from app.services.market_protocol import update_protocol
  1940. db = _session()
  1941. _masters(db)
  1942. row_id = f'{source}-duplicate-sample-id'
  1943. (_cell if source == 'cell_storage' else _donation)(db, row_id)
  1944. db.add(
  1945. _protocol_sample_row(
  1946. source,
  1947. row_id='sample-duplicate-id',
  1948. protocol_id=row_id,
  1949. code_id='code-a',
  1950. code='S-001',
  1951. amount=10,
  1952. remark='不能双重继承',
  1953. )
  1954. )
  1955. db.commit()
  1956. migrate_target = (
  1957. 'app.crud.collection_protocol._migrate_sample_child_refs_on_replace'
  1958. if source == 'cell_storage'
  1959. else 'app.crud.research_donation._migrate_donation_sample_child_refs_on_replace'
  1960. )
  1961. with patch(migrate_target), pytest.raises(ValueError, match='sampleId.*重复'):
  1962. update_protocol(
  1963. db,
  1964. source=source,
  1965. protocol_id=row_id,
  1966. body=MarketProtocolBody.model_validate(
  1967. {
  1968. 'source': source,
  1969. 'samples': [
  1970. {
  1971. 'id': 'sample-duplicate-id',
  1972. 'sampleTypeId': 'type-a',
  1973. 'sampleCodeId': 'code-b',
  1974. 'sampleCode': 'S-002',
  1975. },
  1976. {
  1977. 'sampleId': 'sample-duplicate-id',
  1978. 'sampleTypeId': 'type-a',
  1979. 'sampleCodeId': 'code-c',
  1980. 'sampleCode': 'S-003',
  1981. },
  1982. ],
  1983. }
  1984. ),
  1985. ctx=_ctx('specialist'),
  1986. user_info=_user(),
  1987. )
  1988. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  1989. def test_protocol_sample_update_rejects_sample_id_from_another_protocol(source):
  1990. from app.schemas.market_mobile import MarketProtocolBody
  1991. from app.services.market_protocol import update_protocol
  1992. db = _session()
  1993. _masters(db)
  1994. owner_id = f'{source}-owner'
  1995. other_id = f'{source}-other'
  1996. creator = _cell if source == 'cell_storage' else _donation
  1997. creator(db, owner_id)
  1998. creator(db, other_id)
  1999. db.add_all(
  2000. [
  2001. _protocol_sample_row(
  2002. source,
  2003. row_id='sample-owner',
  2004. protocol_id=owner_id,
  2005. code_id='code-a',
  2006. code='S-001',
  2007. amount=10,
  2008. remark='本协议',
  2009. ),
  2010. _protocol_sample_row(
  2011. source,
  2012. row_id='sample-foreign',
  2013. protocol_id=other_id,
  2014. code_id='code-b',
  2015. code='S-002',
  2016. amount=20,
  2017. remark='其他协议',
  2018. ),
  2019. ]
  2020. )
  2021. db.commit()
  2022. with pytest.raises(ValueError, match='样本.*不属于当前协议'):
  2023. update_protocol(
  2024. db,
  2025. source=source,
  2026. protocol_id=owner_id,
  2027. body=MarketProtocolBody.model_validate(
  2028. {
  2029. 'source': source,
  2030. 'samples': [
  2031. {
  2032. 'id': 'sample-foreign',
  2033. 'sampleTypeId': 'type-a',
  2034. 'sampleCodeId': 'code-a',
  2035. 'sampleCode': 'S-001',
  2036. }
  2037. ],
  2038. }
  2039. ),
  2040. ctx=_ctx('specialist'),
  2041. user_info=_user(),
  2042. )
  2043. def test_partial_collection_update_validates_against_resulting_region():
  2044. from app.schemas.market_mobile import MarketProtocolBody
  2045. from app.services.market_protocol import update_protocol
  2046. db = _session()
  2047. _masters(db)
  2048. _cell(db, 'cell-partial', sales='u1')
  2049. db.commit()
  2050. update_protocol(
  2051. db,
  2052. source='cell_storage',
  2053. protocol_id='cell-partial',
  2054. body=MarketProtocolBody.model_validate(
  2055. {
  2056. 'source': 'cell_storage',
  2057. 'collection': {'hospitalId': 'hospital-a'},
  2058. }
  2059. ),
  2060. ctx=_ctx('specialist'),
  2061. user_info=_user(),
  2062. )
  2063. collection = db.query(BizProtocolCollectionInfo).filter_by(
  2064. collection_protocol_id='cell-partial', del_flag=0
  2065. ).one()
  2066. assert collection.region_id == 'region-a'
  2067. assert collection.hospital_id == 'hospital-a'
  2068. def test_cell_partial_customer_update_preserves_ids_and_unspecified_fields():
  2069. from app.schemas.market_mobile import MarketProtocolBody
  2070. from app.services.market_protocol import update_protocol
  2071. db = _session()
  2072. _masters(db)
  2073. _cell(db, 'cell-party')
  2074. db.add_all(
  2075. [
  2076. BizCustomer(id='mother-c', customer_name='母客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  2077. BizCustomer(id='father-c', customer_name='父客户', sex='0', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  2078. BizCustomer(id='newborn-c', customer_name='子客户', sex='2', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  2079. BizProtocolPartyCustomer(
  2080. id='party-cell', collection_protocol_id='cell-party',
  2081. customer_id='mother-c', father_customer_id='father-c', newborn_customer_id='newborn-c',
  2082. mother_name='母亲', mother_phone='旧电话', mother_id_card='ID-M',
  2083. father_name='父亲', father_phone='父电话', newborn_name='宝宝',
  2084. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  2085. ),
  2086. ]
  2087. )
  2088. db.commit()
  2089. update_protocol(
  2090. db,
  2091. source='cell_storage',
  2092. protocol_id='cell-party',
  2093. body=MarketProtocolBody.model_validate(
  2094. {
  2095. 'source': 'cell_storage',
  2096. 'customer': {'motherPhone': '新电话', 'remark': '只改电话'},
  2097. }
  2098. ),
  2099. ctx=_ctx('specialist'),
  2100. user_info=_user(),
  2101. )
  2102. party = db.get(BizProtocolPartyCustomer, 'party-cell')
  2103. assert (party.customer_id, party.father_customer_id, party.newborn_customer_id) == (
  2104. 'mother-c', 'father-c', 'newborn-c'
  2105. )
  2106. assert party.mother_name == '母亲'
  2107. assert party.mother_id_card == 'ID-M'
  2108. assert party.father_name == '父亲'
  2109. assert party.father_phone == '父电话'
  2110. assert party.newborn_name == '宝宝'
  2111. assert party.mother_phone == '新电话'
  2112. assert party.remark == '只改电话'
  2113. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  2114. def test_protocol_update_can_rebind_same_tenant_customer_id(source):
  2115. from app.schemas.market_mobile import MarketProtocolBody
  2116. from app.services.market_protocol import update_protocol
  2117. db = _session()
  2118. _masters(db)
  2119. db.add_all(
  2120. [
  2121. BizCustomer(id='linked-c', customer_name='已关联', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  2122. BizCustomer(id='unrelated-c', customer_name='无关客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0),
  2123. ]
  2124. )
  2125. if source == 'cell_storage':
  2126. _cell(db, 'customer-idor')
  2127. 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))
  2128. else:
  2129. _donation(db, 'customer-idor')
  2130. 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))
  2131. db.commit()
  2132. update_protocol(
  2133. db,
  2134. source=source,
  2135. protocol_id='customer-idor',
  2136. body=MarketProtocolBody.model_validate(
  2137. {
  2138. 'source': source,
  2139. 'customer': {'customerId': 'unrelated-c', 'motherName': '无关客户'},
  2140. }
  2141. ),
  2142. ctx=_ctx('specialist'),
  2143. user_info=_user(),
  2144. )
  2145. db.expire_all()
  2146. if source == 'cell_storage':
  2147. party = (
  2148. db.query(BizProtocolPartyCustomer)
  2149. .filter_by(collection_protocol_id='customer-idor', del_flag=0)
  2150. .one()
  2151. )
  2152. else:
  2153. party = (
  2154. db.query(BizResearchDonationParty)
  2155. .filter_by(research_donation_id='customer-idor', del_flag=0)
  2156. .one()
  2157. )
  2158. assert party.customer_id == 'unrelated-c'
  2159. assert party.mother_name == '无关客户'
  2160. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  2161. def test_protocol_update_rejects_missing_or_cross_tenant_customer_id(source):
  2162. from app.schemas.market_mobile import MarketProtocolBody
  2163. from app.services.market_protocol import update_protocol
  2164. db = _session()
  2165. _masters(db)
  2166. db.add(
  2167. BizCustomer(
  2168. id='other-tenant-c',
  2169. customer_name='外租户',
  2170. sex='1',
  2171. tenant_no='tenant-b',
  2172. create_dept_id='',
  2173. del_flag=0,
  2174. )
  2175. )
  2176. if source == 'cell_storage':
  2177. _cell(db, 'customer-cross')
  2178. db.add(
  2179. BizProtocolPartyCustomer(
  2180. id='cross-party',
  2181. collection_protocol_id='customer-cross',
  2182. mother_name='原姓名',
  2183. tenant_no='tenant-a',
  2184. create_dept_id='',
  2185. del_flag=0,
  2186. )
  2187. )
  2188. else:
  2189. _donation(db, 'customer-cross')
  2190. db.add(
  2191. BizResearchDonationParty(
  2192. id='cross-party',
  2193. research_donation_id='customer-cross',
  2194. mother_name='原姓名',
  2195. tenant_no='tenant-a',
  2196. create_dept_id='',
  2197. del_flag=0,
  2198. )
  2199. )
  2200. db.commit()
  2201. with pytest.raises(ValueError, match='客户'):
  2202. update_protocol(
  2203. db,
  2204. source=source,
  2205. protocol_id='customer-cross',
  2206. body=MarketProtocolBody.model_validate(
  2207. {
  2208. 'source': source,
  2209. 'customer': {'customerId': 'other-tenant-c', 'motherName': '外租户'},
  2210. }
  2211. ),
  2212. ctx=_ctx('specialist'),
  2213. user_info=_user(),
  2214. )
  2215. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  2216. def test_protocol_update_does_not_mutate_unrelated_customer_by_id_card(source):
  2217. from app.schemas.market_mobile import MarketProtocolBody
  2218. from app.services.market_protocol import update_protocol
  2219. db = _session()
  2220. _masters(db)
  2221. 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))
  2222. if source == 'cell_storage':
  2223. _cell(db, 'card-idor')
  2224. db.add(BizProtocolPartyCustomer(id='card-party', collection_protocol_id='card-idor', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  2225. else:
  2226. _donation(db, 'card-idor')
  2227. db.add(BizResearchDonationParty(id='card-party', research_donation_id='card-idor', mother_name='原姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  2228. db.commit()
  2229. update_protocol(
  2230. db,
  2231. source=source,
  2232. protocol_id='card-idor',
  2233. body=MarketProtocolBody.model_validate(
  2234. {
  2235. 'source': source,
  2236. 'customer': {'motherName': '协议姓名', 'motherIdCard': 'CARD-1'},
  2237. }
  2238. ),
  2239. ctx=_ctx('specialist'),
  2240. user_info=_user(),
  2241. )
  2242. db.expire_all()
  2243. assert db.get(BizCustomer, 'unrelated-card').customer_name == '身份证客户'
  2244. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  2245. def test_protocol_create_allows_existing_customer_ids(source):
  2246. from app.services.market_protocol import create_protocol
  2247. db = _session()
  2248. _masters(db)
  2249. db.add(BizCustomer(id='existing-c', customer_name='已有客户', sex='1', tenant_no='tenant-a', create_dept_id='', del_flag=0))
  2250. db.commit()
  2251. created = create_protocol(
  2252. db,
  2253. body=_body(
  2254. source,
  2255. **({'donationNo': 'D-CUSTOMER', 'protocolNo': None} if source == 'research_donation' else {}),
  2256. customer={'customerId': 'existing-c', 'motherName': '已有客户'},
  2257. ),
  2258. ctx=_ctx('specialist'),
  2259. user_info=_user(),
  2260. )
  2261. assert (created.get('customer') or {}).get('customerId') == 'existing-c'
  2262. def test_market_protocol_body_accepts_sample_payments():
  2263. from app.schemas.market_mobile import MarketProtocolBody
  2264. body = MarketProtocolBody.model_validate(
  2265. {
  2266. 'source': 'cell_storage',
  2267. 'protocolNo': 'P-PAY-1',
  2268. 'customer': {'motherName': '李四'},
  2269. 'samplePayments': [
  2270. {
  2271. 'sampleCode': 'S-001',
  2272. 'payMode': 'dj',
  2273. 'contractAmount': 20000,
  2274. 'lumpSumDeadline': '20261231',
  2275. 'schedules': [
  2276. {
  2277. 'scheduleNo': 1,
  2278. 'dueAmount': 20000,
  2279. 'dueDate': '20261231',
  2280. }
  2281. ],
  2282. }
  2283. ],
  2284. }
  2285. )
  2286. assert body.sample_payments is not None
  2287. assert len(body.sample_payments) == 1
  2288. assert body.sample_payments[0].sample_code == 'S-001'
  2289. assert body.sample_payments[0].pay_mode == 'dj'
  2290. def test_market_protocol_money_json_keeps_decimal_18_2_through_service_payload():
  2291. from app.schemas.market_mobile import MarketProtocolBody
  2292. from app.services.market_protocol import (
  2293. _market_sample_payment_payload,
  2294. _persist_sample_payments_if_any,
  2295. )
  2296. amount = '9999999999999999.99'
  2297. body = MarketProtocolBody.model_validate_json(json.dumps({
  2298. 'source': 'cell_storage',
  2299. 'protocolTotalAmount': amount,
  2300. 'samplePayments': [{
  2301. 'sampleCode': 'S-001',
  2302. 'payMode': 'fq',
  2303. 'contractAmount': amount,
  2304. 'installmentRatios': [amount],
  2305. 'schedules': [{
  2306. 'scheduleNo': 1,
  2307. 'dueAmount': amount,
  2308. 'transfers': [{'transferAmount': amount, 'transferType': 'bank'}],
  2309. }],
  2310. }],
  2311. }))
  2312. assert body.protocol_total_amount == Decimal(amount)
  2313. assert body.sample_payments[0].contract_amount == Decimal(amount)
  2314. assert body.sample_payments[0].schedules[0].due_amount == Decimal(amount)
  2315. assert body.sample_payments[0].schedules[0].transfers[0].transfer_amount == Decimal(amount)
  2316. db = _session()
  2317. row = _cell(db, 'decimal-boundary')
  2318. row.payment_model_version = 2
  2319. sample = BizProtocolSample(
  2320. id='sample-decimal', collection_protocol_id=row.id,
  2321. sample_code='S-001', tenant_no='tenant-a', del_flag=0,
  2322. )
  2323. db.add(sample)
  2324. db.commit()
  2325. payload = _market_sample_payment_payload(
  2326. db,
  2327. row=row,
  2328. body=body,
  2329. ctx=_ctx('specialist'),
  2330. saved_samples=[sample],
  2331. )
  2332. assert payload[0]['contractAmount'] == Decimal(amount)
  2333. assert payload[0]['schedules'][0]['dueAmount'] == Decimal(amount)
  2334. with patch(
  2335. 'app.services.market_protocol.psp_crud.save_sample_payments_for_protocol'
  2336. ) as save:
  2337. _persist_sample_payments_if_any(
  2338. db,
  2339. row=row,
  2340. body=body,
  2341. ctx=_ctx('specialist'),
  2342. saved_samples=[sample],
  2343. )
  2344. crud_payload = save.call_args.kwargs['sample_payments']
  2345. assert crud_payload[0]['contractAmount'] == Decimal(amount)
  2346. assert crud_payload[0]['schedules'][0]['dueAmount'] == Decimal(amount)
  2347. def test_mobile_payment_response_serializes_decimal_as_fixed_string():
  2348. from app.crud.protocol_sample_payment import _transfer_to_dict
  2349. transfer = BizProtocolPaymentTransfer(
  2350. id='t-boundary',
  2351. transfer_amount=Decimal('9999999999999999.99'),
  2352. tenant_no='tenant-a',
  2353. del_flag=0,
  2354. )
  2355. response = _transfer_to_dict(_session(), transfer, 'tenant-a', money_as_string=True)
  2356. encoded = json.dumps(response)
  2357. assert response['transferAmount'] == '9999999999999999.99'
  2358. assert '1e+16' not in encoded.lower()
  2359. def test_protocol_create_persists_sample_payments():
  2360. from app.services.market_protocol import create_protocol, get_protocol
  2361. db = _session()
  2362. _masters(db)
  2363. body = _body(
  2364. samplePayments=[
  2365. {
  2366. 'sampleCode': 'S-001',
  2367. 'payMode': 'dj',
  2368. 'contractAmount': 20000,
  2369. 'lumpSumDeadline': '20261231',
  2370. 'schedules': [
  2371. {
  2372. 'scheduleNo': 1,
  2373. 'dueAmount': 20000,
  2374. 'dueDate': '20261231',
  2375. 'scheduleStatus': 'open',
  2376. 'transfers': [],
  2377. }
  2378. ],
  2379. }
  2380. ]
  2381. )
  2382. created = create_protocol(
  2383. db, body=body, ctx=_ctx('specialist'), user_info=_user()
  2384. )
  2385. protocol_id = created['basic']['id']
  2386. pays = created.get('samplePayments') or []
  2387. assert len(pays) == 1
  2388. assert pays[0]['sampleCode'] == 'S-001'
  2389. assert pays[0]['payMode'] == 'dj'
  2390. assert pays[0]['contractAmount'] == '20000.00'
  2391. assert pays[0]['schedules'][0]['dueAmount'] == '20000.00'
  2392. assert str(pays[0]['schedules'][0].get('dueDate') or '').replace('-', '')[:8] == '20261231'
  2393. detail = get_protocol(
  2394. db,
  2395. source='cell_storage',
  2396. protocol_id=protocol_id,
  2397. ctx=_ctx('specialist'),
  2398. user_info=_user(),
  2399. )
  2400. assert len(detail.get('samplePayments') or []) == 1
  2401. assert detail['samplePayments'][0]['sampleCode'] == 'S-001'
  2402. assert detail['samplePayments'][0]['contractAmount'] == '20000.00'
  2403. @pytest.mark.parametrize('scope_kind', ['specialist', 'manager'])
  2404. def test_protocol_update_rejects_foreign_sample_payment_id(scope_kind):
  2405. from app.services.market_protocol import _market_sample_payment_payload
  2406. db = _session()
  2407. _masters(db)
  2408. owner = _cell(db, 'pay-owner')
  2409. _cell(db, 'pay-foreign')
  2410. owner_sample = BizProtocolSample(
  2411. id='sample-owner', collection_protocol_id='pay-owner',
  2412. sample_type_id='type-a', sample_code='S-001', tenant_no='tenant-a',
  2413. create_dept_id='', del_flag=0,
  2414. )
  2415. db.add_all(
  2416. [
  2417. owner_sample,
  2418. BizProtocolSamplePayment(
  2419. id='payment-foreign', collection_protocol_id='pay-foreign',
  2420. protocol_sample_id='sample-foreign', sample_code='S-FOREIGN',
  2421. pay_mode='dj', contract_amount=999, tenant_no='tenant-a',
  2422. create_dept_id='', del_flag=0,
  2423. ),
  2424. ]
  2425. )
  2426. db.commit()
  2427. body = _body(
  2428. samples=None,
  2429. attachments=None,
  2430. samplePayments=[
  2431. {
  2432. 'id': 'payment-foreign',
  2433. 'sampleCode': 'S-001',
  2434. 'protocolSampleId': 'sample-foreign',
  2435. 'payMode': 'dj',
  2436. 'contractAmount': 100,
  2437. 'schedules': [
  2438. {'scheduleNo': 1, 'dueAmount': 100, 'dueDate': '20261031'}
  2439. ],
  2440. }
  2441. ]
  2442. )
  2443. with pytest.raises(ValueError, match='缴费方案'):
  2444. _market_sample_payment_payload(
  2445. db,
  2446. row=owner,
  2447. body=body,
  2448. ctx=_ctx(scope_kind, 'u1' if scope_kind == 'specialist' else 'manager-a'),
  2449. saved_samples=[owner_sample],
  2450. )
  2451. db.expire_all()
  2452. foreign = db.get(BizProtocolSamplePayment, 'payment-foreign')
  2453. assert foreign.collection_protocol_id == 'pay-foreign'
  2454. assert foreign.sample_code == 'S-FOREIGN'
  2455. assert float(foreign.contract_amount) == 999
  2456. def test_protocol_payment_payload_uses_saved_sample_and_preserves_submitted_transfer():
  2457. from app.crud.protocol_sample_payment import save_sample_payments_for_protocol
  2458. from app.services.market_protocol import _market_sample_payment_payload
  2459. db = _session()
  2460. _masters(db)
  2461. owner = _cell(db, 'pay-safe')
  2462. sample = BizProtocolSample(
  2463. id='sample-safe', collection_protocol_id='pay-safe',
  2464. sample_type_id='type-a', sample_code='S-SAFE', tenant_no='tenant-a',
  2465. create_dept_id='', del_flag=0,
  2466. )
  2467. payment = BizProtocolSamplePayment(
  2468. id='payment-safe', collection_protocol_id='pay-safe',
  2469. protocol_sample_id='sample-safe-old', sample_code='S-SAFE', pay_mode='dj',
  2470. contract_amount=100, tenant_no='tenant-a', create_dept_id='', del_flag=0,
  2471. )
  2472. schedule = BizProtocolPaymentSchedule(
  2473. id='schedule-safe', sample_payment_id='payment-safe',
  2474. collection_protocol_id='pay-safe', sample_code='S-SAFE', schedule_no=1,
  2475. due_amount=100, due_date='20261031', schedule_status='open',
  2476. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  2477. )
  2478. transfer = BizProtocolPaymentTransfer(
  2479. id='transfer-safe', schedule_id='schedule-safe',
  2480. sample_payment_id='payment-safe', collection_protocol_id='pay-safe',
  2481. sample_code='S-SAFE', transfer_amount=20, transfer_type='bank',
  2482. transfer_attachments='payment/existing.jpg', pay_status='jfqrz',
  2483. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  2484. )
  2485. db.add_all([sample, payment, schedule, transfer])
  2486. db.commit()
  2487. body = _body(
  2488. samples=None,
  2489. attachments=None,
  2490. samplePayments=[
  2491. {
  2492. 'id': 'payment-safe', 'sampleCode': 'S-SAFE',
  2493. 'protocolSampleId': 'foreign-sample', 'payMode': 'dj',
  2494. 'contractAmount': 100,
  2495. 'schedules': [
  2496. {
  2497. 'id': 'foreign-schedule', 'scheduleNo': 1,
  2498. 'dueAmount': 100, 'dueDate': '20261031',
  2499. 'scheduleStatus': 'void',
  2500. 'transfers': [
  2501. {
  2502. 'id': 'transfer-safe', 'transferAmount': 999,
  2503. 'transferAttachments': 'evil.jpg',
  2504. }
  2505. ],
  2506. }
  2507. ],
  2508. }
  2509. ],
  2510. )
  2511. payload = _market_sample_payment_payload(
  2512. db,
  2513. row=owner,
  2514. body=body,
  2515. ctx=_ctx('specialist'),
  2516. saved_samples=[sample],
  2517. )
  2518. assert payload[0]['protocolSampleId'] == 'sample-safe'
  2519. assert payload[0]['schedules'][0]['scheduleStatus'] == 'open'
  2520. assert payload[0]['schedules'][0]['transfers'] == [{'id': 'transfer-safe'}]
  2521. save_sample_payments_for_protocol(
  2522. db,
  2523. collection_protocol_id='pay-safe',
  2524. tenant_no='tenant-a',
  2525. user_id='u1',
  2526. sample_payments=payload,
  2527. )
  2528. db.expire_all()
  2529. saved_transfer = db.get(BizProtocolPaymentTransfer, 'transfer-safe')
  2530. assert float(saved_transfer.transfer_amount) == 20
  2531. assert saved_transfer.transfer_attachments == 'payment/existing.jpg'
  2532. assert saved_transfer.pay_status == 'jfqrz'
  2533. assert db.get(BizProtocolSamplePayment, 'payment-safe').protocol_sample_id == 'sample-safe'
  2534. def test_save_sample_payments_serializes_decimal_installment_ratios_as_fixed_strings():
  2535. from app.crud.protocol_sample_payment import save_sample_payments_for_protocol
  2536. db = _session()
  2537. _cell(db, 'decimal-ratios')
  2538. save_sample_payments_for_protocol(
  2539. db,
  2540. collection_protocol_id='decimal-ratios',
  2541. tenant_no='tenant-a',
  2542. user_id='u1',
  2543. sample_payments=[
  2544. {
  2545. 'sampleCode': 'S-DECIMAL',
  2546. 'payMode': 'fq',
  2547. 'contractAmount': Decimal('100.00'),
  2548. 'installmentCount': 2,
  2549. 'installmentRatios': [
  2550. Decimal('20.00'),
  2551. Decimal('80.00'),
  2552. Decimal('9999999999999999.99'),
  2553. ],
  2554. 'schedules': [
  2555. {'scheduleNo': 1, 'dueAmount': Decimal('20.00'), 'dueDate': '20261101'},
  2556. {'scheduleNo': 2, 'dueAmount': Decimal('80.00'), 'dueDate': '20261201'},
  2557. ],
  2558. },
  2559. {
  2560. 'sampleCode': 'S-STRING',
  2561. 'payMode': 'fq',
  2562. 'contractAmount': Decimal('100.00'),
  2563. 'installmentCount': 1,
  2564. 'installmentRatios': 'legacy-string',
  2565. 'schedules': [
  2566. {'scheduleNo': 1, 'dueAmount': Decimal('100.00'), 'dueDate': '20261101'},
  2567. ],
  2568. },
  2569. ],
  2570. )
  2571. saved = (
  2572. db.query(BizProtocolSamplePayment)
  2573. .filter(
  2574. BizProtocolSamplePayment.collection_protocol_id == 'decimal-ratios',
  2575. BizProtocolSamplePayment.sample_code == 'S-DECIMAL',
  2576. )
  2577. .one()
  2578. )
  2579. assert json.loads(saved.installment_ratios) == [
  2580. '20.00',
  2581. '80.00',
  2582. '9999999999999999.99',
  2583. ]
  2584. legacy = (
  2585. db.query(BizProtocolSamplePayment)
  2586. .filter(
  2587. BizProtocolSamplePayment.collection_protocol_id == 'decimal-ratios',
  2588. BizProtocolSamplePayment.sample_code == 'S-STRING',
  2589. )
  2590. .one()
  2591. )
  2592. assert legacy.installment_ratios == 'legacy-string'
  2593. def test_protocol_update_rejects_unscoped_freight_target():
  2594. from app.services.market_protocol import update_protocol
  2595. db = _session()
  2596. _masters(db)
  2597. _cell(db, 'freight-owner')
  2598. _cell(db, 'freight-hidden', sales='u2', manager='manager-other', region='region-b')
  2599. db.add_all(
  2600. [
  2601. BizProtocolSample(
  2602. id='sample-owner', collection_protocol_id='freight-owner',
  2603. sample_type_id='type-a', sample_code='S-001', tenant_no='tenant-a',
  2604. create_dept_id='', del_flag=0,
  2605. ),
  2606. BizProtocolSample(
  2607. id='sample-hidden', collection_protocol_id='freight-hidden',
  2608. sample_type_id='type-a', sample_code='S-HIDDEN', tenant_no='tenant-a',
  2609. create_dept_id='', del_flag=0,
  2610. ),
  2611. BizSampleFreight(
  2612. id='freight-hidden', freight_no='F-HIDDEN', transport_mode='zxc',
  2613. freight_amount=80, tenant_no='tenant-a', create_dept_id='', del_flag=0,
  2614. ),
  2615. BizSampleFreightItem(
  2616. id='freight-item-hidden', freight_id='freight-hidden',
  2617. sample_source='cell_storage', sample_id='sample-hidden',
  2618. protocol_id='freight-hidden', sample_code='S-HIDDEN',
  2619. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  2620. ),
  2621. ]
  2622. )
  2623. db.commit()
  2624. body = _body(
  2625. protocolNo='CELL-freight-owner',
  2626. samples=[
  2627. {
  2628. 'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a',
  2629. 'sampleCode': 'S-001', 'unitId': 'unit-a',
  2630. 'collectAmount': 10, 'freightId': 'freight-hidden',
  2631. }
  2632. ]
  2633. )
  2634. with pytest.raises(HTTPException):
  2635. update_protocol(
  2636. db,
  2637. source='cell_storage',
  2638. protocol_id='freight-owner',
  2639. body=body,
  2640. ctx=_ctx('specialist'),
  2641. user_info=_user(),
  2642. )
  2643. db.expire_all()
  2644. assert db.get(BizProtocolSample, 'sample-owner').del_flag == 0
  2645. hidden_item = db.get(BizSampleFreightItem, 'freight-item-hidden')
  2646. assert hidden_item.sample_id == 'sample-hidden'
  2647. def test_protocol_body_rejects_status_finance_and_conversion_fields():
  2648. from app.schemas.market_mobile import MarketProtocolBody
  2649. forbidden = [
  2650. {'auditStatus': 'shtg'},
  2651. {'protocolStatus': 'yzf'},
  2652. {'delFlag': 1},
  2653. {'refundAmount': 100},
  2654. {'financeRemark': '通过'},
  2655. {'toResearchTime': '2026-10-10'},
  2656. ]
  2657. for field in forbidden:
  2658. with pytest.raises(ValidationError):
  2659. MarketProtocolBody.model_validate({'source': 'cell_storage', **field})
  2660. def test_cross_tenant_or_disabled_referenced_user_is_rejected():
  2661. from app.services.market_protocol import create_protocol
  2662. db = _session()
  2663. _masters(db)
  2664. db.add(
  2665. SysUser(
  2666. id='disabled', user_no='disabled', nick_name='停用', status='0', tenant_no='tenant-a', create_dept_id='', del_flag=0
  2667. )
  2668. )
  2669. db.commit()
  2670. with pytest.raises(ValueError, match='用户'):
  2671. create_protocol(
  2672. db,
  2673. body=_body(collection={'regionId': 'region-a', 'hospitalId': 'hospital-a', 'regionManagerUserId': 'disabled'}),
  2674. ctx=_ctx('specialist'),
  2675. user_info=_user(),
  2676. )
  2677. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  2678. @pytest.mark.parametrize(
  2679. ('field_name', 'scope_kind', 'collection'),
  2680. [
  2681. (
  2682. 'salesUserId',
  2683. 'manager',
  2684. {
  2685. 'regionId': 'region-a',
  2686. 'hospitalId': 'hospital-a',
  2687. 'salesUserId': 'u2',
  2688. 'regionManagerUserId': 'u1',
  2689. },
  2690. ),
  2691. (
  2692. 'regionManagerUserId',
  2693. 'specialist',
  2694. {
  2695. 'regionId': 'region-a',
  2696. 'hospitalId': 'hospital-a',
  2697. 'salesUserId': 'u1',
  2698. 'regionManagerUserId': 'u2',
  2699. },
  2700. ),
  2701. ],
  2702. )
  2703. def test_protocol_create_rejects_assignment_without_target_region_staff_binding(
  2704. source, field_name, scope_kind, collection
  2705. ):
  2706. from app.services.market_protocol import create_protocol
  2707. db = _session()
  2708. _masters(db)
  2709. overrides = {'collection': collection, 'attachments': {}}
  2710. if source == 'research_donation':
  2711. overrides.update({'donationNo': 'D-OUTSIDER', 'protocolNo': None})
  2712. with pytest.raises(ValueError, match=f'{field_name}.*采集地区人员'):
  2713. create_protocol(
  2714. db,
  2715. body=_body(source, **overrides),
  2716. ctx=_ctx(scope_kind),
  2717. user_info=_user(),
  2718. )
  2719. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  2720. @pytest.mark.parametrize(
  2721. ('field_name', 'scope_kind'),
  2722. [('salesUserId', 'manager'), ('regionManagerUserId', 'specialist')],
  2723. )
  2724. def test_protocol_update_rejects_changed_assignment_without_target_region_staff_binding(
  2725. source, field_name, scope_kind
  2726. ):
  2727. from app.schemas.market_mobile import MarketProtocolBody
  2728. from app.services.market_protocol import update_protocol
  2729. db = _session()
  2730. _masters(db)
  2731. row_id = f'{source}-{field_name}-outsider'
  2732. if source == 'cell_storage':
  2733. _cell(
  2734. db,
  2735. row_id,
  2736. sales='u1',
  2737. manager='u1' if scope_kind == 'manager' else 'manager-a',
  2738. )
  2739. else:
  2740. _donation(
  2741. db,
  2742. row_id,
  2743. sales='u1',
  2744. manager='u1' if scope_kind == 'manager' else 'manager-a',
  2745. )
  2746. db.commit()
  2747. with pytest.raises(ValueError, match=f'{field_name}.*采集地区人员'):
  2748. update_protocol(
  2749. db,
  2750. source=source,
  2751. protocol_id=row_id,
  2752. body=MarketProtocolBody.model_validate(
  2753. {'source': source, 'collection': {field_name: 'u2'}}
  2754. ),
  2755. ctx=_ctx(scope_kind),
  2756. user_info=_user(),
  2757. )
  2758. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  2759. @pytest.mark.parametrize(
  2760. ('historical_field', 'scope_kind'),
  2761. [('sales_user_id', 'manager'), ('region_manager_user_id', 'specialist')],
  2762. )
  2763. def test_protocol_update_keeps_unchanged_historical_assignment_without_binding(
  2764. source, historical_field, scope_kind
  2765. ):
  2766. from app.schemas.market_mobile import MarketProtocolBody
  2767. from app.services.market_protocol import update_protocol
  2768. db = _session()
  2769. _masters(db)
  2770. row_id = f'{source}-{historical_field}-legacy'
  2771. kwargs = {'sales': 'u1', 'manager': 'manager-a'}
  2772. if historical_field == 'sales_user_id':
  2773. kwargs.update({'sales': 'u2', 'manager': 'u1'})
  2774. else:
  2775. kwargs['manager'] = 'u2'
  2776. if source == 'cell_storage':
  2777. _cell(db, row_id, **kwargs)
  2778. else:
  2779. _donation(db, row_id, **kwargs)
  2780. db.commit()
  2781. result = update_protocol(
  2782. db,
  2783. source=source,
  2784. protocol_id=row_id,
  2785. body=MarketProtocolBody.model_validate(
  2786. {'source': source, 'remark': '仅修改非归属字段'}
  2787. ),
  2788. ctx=_ctx(scope_kind),
  2789. user_info=_user(),
  2790. )
  2791. assert result['basic']['remark'] == '仅修改非归属字段'
  2792. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  2793. @pytest.mark.parametrize(
  2794. ('sample_code_id', 'sample_code'),
  2795. [
  2796. ('code-cross', 'S-CROSS'),
  2797. ('code-deleted', 'S-DELETED'),
  2798. ('code-a', 'S-WRONG'),
  2799. ],
  2800. )
  2801. def test_protocol_rejects_invalid_or_inconsistent_sample_code_reference(
  2802. source, sample_code_id, sample_code
  2803. ):
  2804. from app.services.market_protocol import create_protocol
  2805. db = _session()
  2806. _masters(db)
  2807. payload = {
  2808. 'samples': [
  2809. {
  2810. 'sampleTypeId': 'type-a',
  2811. 'sampleCodeId': sample_code_id,
  2812. 'sampleCode': sample_code,
  2813. 'unitId': 'unit-a',
  2814. }
  2815. ]
  2816. }
  2817. if source == 'research_donation':
  2818. payload.update({'donationNo': 'D-CODE', 'protocolNo': None})
  2819. with pytest.raises(ValueError, match='样本编码'):
  2820. create_protocol(
  2821. db,
  2822. body=_body(source, **payload),
  2823. ctx=_ctx('specialist'),
  2824. user_info=_user(),
  2825. )
  2826. def test_cell_update_rolls_back_main_and_existing_children_after_late_failure():
  2827. from app.schemas.market_mobile import MarketProtocolBody
  2828. from app.services.market_protocol import update_protocol
  2829. db = _session()
  2830. _masters(db)
  2831. _cell(db, 'cell-update')
  2832. db.add_all(
  2833. [
  2834. BizProtocolPartyCustomer(
  2835. id='party-old', collection_protocol_id='cell-update', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
  2836. ),
  2837. BizProtocolSample(
  2838. id='sample-old', collection_protocol_id='cell-update', sample_code='OLD-CODE', tenant_no='tenant-a', create_dept_id='', del_flag=0
  2839. ),
  2840. ]
  2841. )
  2842. db.commit()
  2843. body = MarketProtocolBody.model_validate(
  2844. {
  2845. 'source': 'cell_storage',
  2846. 'remark': '新备注',
  2847. 'customer': {'motherName': '新姓名'},
  2848. 'collection': {'medicalStaff': '新医护'},
  2849. 'samples': [{'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'}],
  2850. }
  2851. )
  2852. with patch(
  2853. 'app.services.market_protocol.cell_crud.save_samples',
  2854. side_effect=RuntimeError('late failure'),
  2855. ):
  2856. with pytest.raises(RuntimeError, match='late'):
  2857. update_protocol(
  2858. db,
  2859. source='cell_storage',
  2860. protocol_id='cell-update',
  2861. body=body,
  2862. ctx=_ctx('specialist'),
  2863. user_info=_user(),
  2864. )
  2865. db.expire_all()
  2866. assert db.get(BizCollectionProtocol, 'cell-update').remark is None
  2867. assert db.get(BizProtocolPartyCustomer, 'party-old').mother_name == '旧姓名'
  2868. assert db.get(BizProtocolPartyCustomer, 'party-old').del_flag == 0
  2869. assert db.get(BizProtocolSample, 'sample-old').sample_code == 'OLD-CODE'
  2870. assert db.get(BizProtocolCollectionInfo, 'ci-cell-update').medical_staff is None
  2871. def test_donation_update_rolls_back_main_and_old_children_after_soft_delete():
  2872. from app.schemas.market_mobile import MarketProtocolBody
  2873. from app.services.market_protocol import update_protocol
  2874. db = _session()
  2875. _masters(db)
  2876. _donation(db, 'don-update')
  2877. db.add_all(
  2878. [
  2879. BizResearchDonationParty(
  2880. id='dparty-old', research_donation_id='don-update', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
  2881. ),
  2882. BizResearchDonationSample(
  2883. id='dsample-old', research_donation_id='don-update', sample_code='OLD-CODE', tenant_no='tenant-a', create_dept_id='', del_flag=0
  2884. ),
  2885. ]
  2886. )
  2887. db.commit()
  2888. body = MarketProtocolBody.model_validate(
  2889. {
  2890. 'source': 'research_donation',
  2891. 'donationNo': 'DON-don-update',
  2892. 'remark': '新备注',
  2893. 'customer': {'motherName': '新姓名'},
  2894. 'collection': {'medicalStaff': '新医护'},
  2895. 'samples': [{'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a', 'sampleCode': 'S-001'}],
  2896. }
  2897. )
  2898. with patch(
  2899. 'app.crud.research_donation._create_samples',
  2900. side_effect=RuntimeError('late failure'),
  2901. ):
  2902. with pytest.raises(RuntimeError, match='late'):
  2903. update_protocol(
  2904. db,
  2905. source='research_donation',
  2906. protocol_id='don-update',
  2907. body=body,
  2908. ctx=_ctx('specialist'),
  2909. user_info=_user(),
  2910. )
  2911. db.expire_all()
  2912. assert db.get(BizResearchDonation, 'don-update').remark is None
  2913. assert db.get(BizResearchDonationParty, 'dparty-old').mother_name == '旧姓名'
  2914. assert db.get(BizResearchDonationParty, 'dparty-old').del_flag == 0
  2915. assert db.get(BizResearchDonationSample, 'dsample-old').del_flag == 0
  2916. assert db.get(BizResearchDonationCollection, 'dci-don-update').medical_staff is None
  2917. def test_manager_can_update_donation_in_scope_but_cannot_move_it_out():
  2918. from app.schemas.market_mobile import MarketProtocolBody
  2919. from app.services.market_protocol import update_protocol
  2920. db = _session()
  2921. _masters(db)
  2922. _donation(db, 'don-manager', sales='u2', manager='u1')
  2923. db.add(
  2924. BizResearchDonationParty(
  2925. id='dparty-manager', research_donation_id='don-manager', mother_name='旧姓名', tenant_no='tenant-a', create_dept_id='', del_flag=0
  2926. )
  2927. )
  2928. db.commit()
  2929. result = update_protocol(
  2930. db,
  2931. source='research_donation',
  2932. protocol_id='don-manager',
  2933. body=MarketProtocolBody.model_validate(
  2934. {
  2935. 'source': 'research_donation',
  2936. 'donationNo': 'DON-don-manager',
  2937. 'remark': '经理已修改',
  2938. 'customer': {'motherName': '新姓名'},
  2939. 'collection': {'medicalStaff': '新医护'},
  2940. }
  2941. ),
  2942. ctx=_ctx('manager'),
  2943. user_info=_user(),
  2944. )
  2945. assert result['basic']['remark'] == '经理已修改'
  2946. assert result['customer']['motherName'] == '新姓名'
  2947. assert result['collection']['medicalStaff'] == '新医护'
  2948. with pytest.raises(ValueError, match='范围'):
  2949. update_protocol(
  2950. db,
  2951. source='research_donation',
  2952. protocol_id='don-manager',
  2953. body=MarketProtocolBody.model_validate(
  2954. {
  2955. 'source': 'research_donation',
  2956. 'collection': {
  2957. 'regionId': 'region-b',
  2958. 'regionManagerUserId': 'u1',
  2959. },
  2960. }
  2961. ),
  2962. ctx=_ctx('manager'),
  2963. user_info=_user(),
  2964. )
  2965. db.expire_all()
  2966. collection = db.query(BizResearchDonationCollection).filter_by(
  2967. research_donation_id='don-manager', del_flag=0
  2968. ).one()
  2969. assert collection.region_id == 'region-a'
  2970. assert collection.region_manager_user_id == 'u1'
  2971. def test_protocol_routes_expose_delete_but_no_web_only_admin_mutations():
  2972. from app.api.market_mobile import router
  2973. for scope in ('specialist', 'manager'):
  2974. prefix = f'/mini/market/{scope}/protocols/{{protocol_id}}'
  2975. routes = {
  2976. route.path: route.methods
  2977. for route in router.routes
  2978. if route.path.startswith(f'/mini/market/{scope}/protocols')
  2979. }
  2980. assert 'DELETE' in routes[prefix]
  2981. assert f'{prefix}/audit' not in routes
  2982. assert all(not any(word in path for word in ('void', 'refund', 'convert', 'to-research', 'to-storage')) for path in routes)
  2983. assert not any(
  2984. route.path.startswith(f'/mini/market/{scope}/samples')
  2985. or route.path.startswith(f'/mini/market/{scope}/sample/attachments')
  2986. for route in router.routes
  2987. )
  2988. def test_protocol_delete_rechecks_scoped_access_before_crud(monkeypatch):
  2989. from app.services.market_protocol import delete_protocol
  2990. db = _session()
  2991. _masters(db)
  2992. own = _cell(db, 'own', sales='u1')
  2993. other = _cell(db, 'other', sales='u2')
  2994. db.commit()
  2995. delete_calls = []
  2996. monkeypatch.setattr(
  2997. 'app.services.market_protocol.cell_crud.soft_delete_collection_protocol',
  2998. lambda *args, **kwargs: delete_calls.append((args, kwargs)),
  2999. )
  3000. delete_protocol(
  3001. db,
  3002. source='cell_storage',
  3003. protocol_id=own.id,
  3004. ctx=_ctx('specialist'),
  3005. user_info=_user(),
  3006. )
  3007. assert delete_calls[0][0][1] == own.id
  3008. with pytest.raises(HTTPException, match='无权'):
  3009. delete_protocol(
  3010. db,
  3011. source='cell_storage',
  3012. protocol_id=other.id,
  3013. ctx=_ctx('specialist'),
  3014. user_info=_user(),
  3015. )
  3016. def test_protocol_list_contains_mobile_card_fields():
  3017. from app.services.market_protocol import list_protocols
  3018. db = _session()
  3019. _masters(db)
  3020. row = _cell(db, 'card')
  3021. row.protocol_total_amount = 20000
  3022. db.add_all([
  3023. BizProtocolPartyCustomer(
  3024. id='party-card', collection_protocol_id=row.id, mother_name='李安然',
  3025. mother_phone='13800138000', mother_id_card='340100', father_name='王先生',
  3026. newborn_sex='1', delivery_mode='sc', newborn_weight_gram=3200,
  3027. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3028. ),
  3029. BizProtocolSample(
  3030. id='sample-card', collection_protocol_id=row.id, sample_type_id='type-a',
  3031. sample_code='S-CARD', tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3032. ),
  3033. ])
  3034. db.commit()
  3035. items, total = list_protocols(db, ctx=_ctx('specialist'), user_info=_user())
  3036. assert total == 1
  3037. assert items[0]['motherName'] == '李安然'
  3038. assert items[0]['sampleCode'] == 'S-CARD'
  3039. assert items[0]['sampleTypeName'] == '样本A'
  3040. assert items[0]['regionName'] == 'A区'
  3041. assert items[0]['motherPhone'] == '13800138000'
  3042. assert items[0]['motherIdCard'] == '340100'
  3043. assert items[0]['fatherName'] == '王先生'
  3044. assert items[0]['newbornSex'] == '1'
  3045. assert items[0]['deliveryMode'] == 'sc'
  3046. assert items[0]['newbornWeightGram'] == 3200
  3047. assert items[0]['salesUserName'] == '当前用户'
  3048. by_customer, customer_total = list_protocols(
  3049. db, ctx=_ctx('specialist'), user_info=_user(), keyword='李安然'
  3050. )
  3051. by_sample, sample_total = list_protocols(
  3052. db, ctx=_ctx('specialist'), user_info=_user(), keyword='S-CARD'
  3053. )
  3054. by_phone, phone_total = list_protocols(
  3055. db, ctx=_ctx('specialist'), user_info=_user(), keyword='13800138000'
  3056. )
  3057. by_id_card, id_card_total = list_protocols(
  3058. db, ctx=_ctx('specialist'), user_info=_user(), keyword='340100'
  3059. )
  3060. assert customer_total == sample_total == phone_total == id_card_total == 1
  3061. assert {
  3062. by_customer[0]['id'],
  3063. by_sample[0]['id'],
  3064. by_phone[0]['id'],
  3065. by_id_card[0]['id'],
  3066. } == {row.id}
  3067. @pytest.mark.parametrize('source', ['cell_storage', 'research_donation'])
  3068. def test_protocol_list_and_detail_resolve_tenant_void_type_label(source):
  3069. from app.services.market_protocol import get_protocol, list_protocols
  3070. db = _session()
  3071. _masters(db)
  3072. db.add(
  3073. SysDictData(
  3074. id=f'void-dict-{source}',
  3075. dict_type='xyzflx',
  3076. dict_value='tenant-custom',
  3077. dict_label='租户自定义作废',
  3078. tenant_no='tenant-a',
  3079. create_dept_id='',
  3080. del_flag=0,
  3081. )
  3082. )
  3083. if source == 'cell_storage':
  3084. row = _cell(db, 'void-label-cell')
  3085. row.protocol_status = 'yzf'
  3086. row.void_type = 'tenant-custom'
  3087. else:
  3088. row = _donation(db, 'void-label-donation')
  3089. row.donation_status = 'yzf'
  3090. row.void_type = 'tenant-custom'
  3091. db.commit()
  3092. items, total = list_protocols(
  3093. db,
  3094. ctx=_ctx('specialist'),
  3095. user_info=_user(),
  3096. source=source,
  3097. )
  3098. detail = get_protocol(
  3099. db,
  3100. source=source,
  3101. protocol_id=row.id,
  3102. ctx=_ctx('specialist'),
  3103. user_info=_user(),
  3104. )
  3105. assert total == 1
  3106. assert items[0]['voidType'] == 'tenant-custom'
  3107. assert items[0]['voidTypeLabel'] == '租户自定义作废'
  3108. assert detail['basic']['voidType'] == 'tenant-custom'
  3109. assert detail['basic']['voidTypeLabel'] == '租户自定义作废'
  3110. def test_unknown_void_type_label_preserves_original_code():
  3111. from app.services.market_protocol import list_protocols
  3112. db = _session()
  3113. _masters(db)
  3114. row = _cell(db, 'void-label-unknown')
  3115. row.protocol_status = 'yzf'
  3116. row.void_type = 'legacy-unknown'
  3117. db.commit()
  3118. items, _ = list_protocols(
  3119. db,
  3120. ctx=_ctx('specialist'),
  3121. user_info=_user(),
  3122. source='cell_storage',
  3123. )
  3124. assert items[0]['voidType'] == 'legacy-unknown'
  3125. assert items[0]['voidTypeLabel'] == 'legacy-unknown'
  3126. def test_protocol_list_filters_status_create_time_and_sample_collect_time():
  3127. from app.services.market_protocol import list_protocols
  3128. db = _session()
  3129. _masters(db)
  3130. hit = _cell(db, 'hit', created=datetime(2026, 10, 8, 9, 0))
  3131. hit.protocol_status = 'sxz'
  3132. db.query(BizProtocolCollectionInfo).filter_by(
  3133. collection_protocol_id=hit.id
  3134. ).update({'sample_collect_time': datetime(2026, 10, 9, 10, 0)})
  3135. miss_status = _cell(db, 'miss-status', created=datetime(2026, 10, 8, 9, 0))
  3136. miss_status.protocol_status = 'dsx'
  3137. db.query(BizProtocolCollectionInfo).filter_by(
  3138. collection_protocol_id=miss_status.id
  3139. ).update({'sample_collect_time': datetime(2026, 10, 9, 10, 0)})
  3140. miss_date = _cell(db, 'miss-date', created=datetime(2026, 9, 1, 9, 0))
  3141. miss_date.protocol_status = 'sxz'
  3142. db.query(BizProtocolCollectionInfo).filter_by(
  3143. collection_protocol_id=miss_date.id
  3144. ).update({'sample_collect_time': datetime(2026, 9, 2, 10, 0)})
  3145. db.commit()
  3146. items, total = list_protocols(
  3147. db,
  3148. ctx=_ctx('specialist'),
  3149. user_info=_user(),
  3150. source='cell_storage',
  3151. protocol_status='sxz',
  3152. create_time_start='2026-10-01',
  3153. create_time_end='2026-10-13',
  3154. sample_collect_time_start='2026-10-01',
  3155. sample_collect_time_end='2026-10-13',
  3156. )
  3157. assert total == 1
  3158. assert [item['id'] for item in items] == ['hit']
  3159. def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
  3160. from app.services.market_protocol import create_protocol
  3161. db = _session()
  3162. _masters(db)
  3163. with patch(
  3164. 'app.services.market_protocol.cell_crud.upsert_collection_info',
  3165. side_effect=RuntimeError('injected child failure'),
  3166. ):
  3167. with pytest.raises(RuntimeError, match='injected'):
  3168. create_protocol(
  3169. db, body=_body(), ctx=_ctx('specialist'), user_info=_user()
  3170. )
  3171. assert db.query(BizCollectionProtocol).count() == 0
  3172. assert db.query(BizProtocolPartyCustomer).count() == 0
  3173. assert db.query(BizCustomer).count() == 0
  3174. def test_donation_create_rolls_back_customer_and_children_on_child_failure():
  3175. from app.services.market_protocol import create_protocol
  3176. db = _session()
  3177. _masters(db)
  3178. with patch(
  3179. 'app.crud.research_donation._create_collection',
  3180. side_effect=RuntimeError('injected child failure'),
  3181. ):
  3182. with pytest.raises(RuntimeError, match='injected'):
  3183. create_protocol(
  3184. db,
  3185. body=_body(
  3186. 'research_donation', donationNo='D-ROLLBACK', protocolNo=None
  3187. ),
  3188. ctx=_ctx('specialist'),
  3189. user_info=_user(),
  3190. )
  3191. assert db.query(BizResearchDonation).count() == 0
  3192. assert db.query(BizResearchDonationParty).count() == 0
  3193. assert db.query(BizCustomer).count() == 0
  3194. def test_cell_protocol_detail_includes_storage_insurance_and_audits():
  3195. from app.services.market_protocol import get_protocol
  3196. db = _session()
  3197. _masters(db)
  3198. _cell(db, 'cell-enrich')
  3199. db.add(
  3200. BizProtocolStorageCert(
  3201. id='sc-1',
  3202. collection_protocol_id='cell-enrich',
  3203. make_status='1',
  3204. make_date='20261001',
  3205. tenant_no='tenant-a',
  3206. create_dept_id='',
  3207. del_flag=0,
  3208. )
  3209. )
  3210. db.add(
  3211. BizProtocolFullmoonTodo(
  3212. id='fm-1',
  3213. collection_protocol_id='cell-enrich',
  3214. insurance_company_name='测试保险',
  3215. insurance_policy_no='POL-1',
  3216. tenant_no='tenant-a',
  3217. create_dept_id='',
  3218. del_flag=0,
  3219. )
  3220. )
  3221. db.add(
  3222. BizProtocolAudit(
  3223. id='au-1',
  3224. collection_protocol_id='cell-enrich',
  3225. audit_status='shtg',
  3226. audit_remark='通过',
  3227. auditor_user_id='u1',
  3228. tenant_no='tenant-a',
  3229. create_dept_id='',
  3230. del_flag=0,
  3231. )
  3232. )
  3233. db.commit()
  3234. detail = get_protocol(
  3235. db,
  3236. source='cell_storage',
  3237. protocol_id='cell-enrich',
  3238. ctx=_ctx('specialist'),
  3239. user_info=_user(),
  3240. )
  3241. assert detail['storageCert']['makeStatus'] == '1'
  3242. assert detail['fullmoonTodo']['insurancePolicyNo'] == 'POL-1'
  3243. assert len(detail['audits']) == 1
  3244. assert detail['audits'][0]['auditStatus'] == 'shtg'
  3245. def test_cell_protocol_detail_hides_cross_tenant_and_deleted_handler_names():
  3246. from app.services.market_protocol import get_protocol
  3247. db = _session()
  3248. _masters(db)
  3249. _cell(db, 'cell-handler-isolation')
  3250. db.add_all(
  3251. [
  3252. SysUser(
  3253. id='cross-tenant-handler',
  3254. user_no='cross-handler',
  3255. nick_name='异租户经办人',
  3256. status='1',
  3257. tenant_no='tenant-b',
  3258. create_dept_id='',
  3259. del_flag=0,
  3260. ),
  3261. SysUser(
  3262. id='deleted-handler',
  3263. user_no='deleted-handler',
  3264. nick_name='已删除经办人',
  3265. status='1',
  3266. tenant_no='tenant-a',
  3267. create_dept_id='',
  3268. del_flag=1,
  3269. ),
  3270. BizProtocolStorageCert(
  3271. id='sc-handler-isolation',
  3272. collection_protocol_id='cell-handler-isolation',
  3273. handler_user_id='cross-tenant-handler',
  3274. tenant_no='tenant-a',
  3275. create_dept_id='',
  3276. del_flag=0,
  3277. ),
  3278. BizProtocolFullmoonTodo(
  3279. id='fm-handler-isolation',
  3280. collection_protocol_id='cell-handler-isolation',
  3281. handler_user_id='deleted-handler',
  3282. tenant_no='tenant-a',
  3283. create_dept_id='',
  3284. del_flag=0,
  3285. ),
  3286. ]
  3287. )
  3288. db.commit()
  3289. detail = get_protocol(
  3290. db,
  3291. source='cell_storage',
  3292. protocol_id='cell-handler-isolation',
  3293. ctx=_ctx('specialist'),
  3294. user_info=_user(),
  3295. )
  3296. assert detail['storageCert']['handlerUserName'] == ''
  3297. assert detail['fullmoonTodo']['handlerUserName'] == ''
  3298. def test_cell_protocol_detail_includes_presign_deposit_hint_and_transfers():
  3299. from app.services.market_protocol import get_protocol
  3300. db = _session()
  3301. _masters(db)
  3302. row = _cell(db, 'cell-deposit')
  3303. row.source_presign_order_id = 'presign-d1'
  3304. db.add(
  3305. BizPresignOrder(
  3306. id='presign-d1',
  3307. protocol_no='PS-D1',
  3308. mother_name='张敏',
  3309. order_status='yzh',
  3310. tenant_no='tenant-a',
  3311. create_dept_id='',
  3312. del_flag=0,
  3313. )
  3314. )
  3315. db.add(
  3316. BizPresignTransfer(
  3317. id='tr-d1',
  3318. presign_order_id='presign-d1',
  3319. transfer_amount=1000,
  3320. transfer_type='wx',
  3321. pay_status='yjf',
  3322. tenant_no='tenant-a',
  3323. create_dept_id='',
  3324. del_flag=0,
  3325. )
  3326. )
  3327. db.add(
  3328. SysDictData(
  3329. id='dict-deposit-wx',
  3330. dict_label='微信支付',
  3331. dict_value='wx',
  3332. dict_type='zzfs',
  3333. tenant_no='tenant-a',
  3334. create_dept_id='',
  3335. del_flag=0,
  3336. )
  3337. )
  3338. db.commit()
  3339. detail = get_protocol(
  3340. db,
  3341. source='cell_storage',
  3342. protocol_id='cell-deposit',
  3343. ctx=_ctx('specialist'),
  3344. user_info=_user(),
  3345. )
  3346. assert detail['presignDepositHint']['confirmedDepositAmount'] == '1000.00'
  3347. assert detail['presignDepositHint']['confirmedTransferCount'] == 1
  3348. assert len(detail['depositTransfers']) == 1
  3349. assert detail['depositTransfers'][0]['id'] == 'tr-d1'
  3350. assert detail['depositTransfers'][0]['transferAmount'] == '1000.00'
  3351. assert detail['depositTransfers'][0]['transferTypeLabel'] == '微信支付'
  3352. def test_protocol_customer_search_empty_keyword_returns_recent_list():
  3353. """空关键字仍返回近期客户列表,供移动端「选档案」弹窗默认展示。"""
  3354. from app.services.market_support import search_customers_for_protocol
  3355. db = _session()
  3356. db.add(
  3357. BizCustomer(
  3358. id='c-empty-kw',
  3359. customer_name='可见客户',
  3360. sex='1',
  3361. tenant_no='tenant-a',
  3362. create_dept_id='',
  3363. del_flag=0,
  3364. )
  3365. )
  3366. db.commit()
  3367. data = search_customers_for_protocol(
  3368. db, ctx=_ctx('specialist'), user_info=_user(), keyword=' '
  3369. )
  3370. assert [item['id'] for item in data['list']] == ['c-empty-kw']
  3371. assert data['total'] == 1
  3372. def test_protocol_customer_search_matches_name_phone_and_isolates_tenant():
  3373. from app.services.market_support import search_customers_for_protocol
  3374. db = _session()
  3375. db.add_all(
  3376. [
  3377. BizCustomer(
  3378. id='c-hit',
  3379. customer_name='张敏',
  3380. phone_no='13800001111',
  3381. id_card_no='ID-HIT',
  3382. sex='1',
  3383. tenant_no='tenant-a',
  3384. create_dept_id='',
  3385. del_flag=0,
  3386. ),
  3387. BizCustomer(
  3388. id='c-other-tenant',
  3389. customer_name='张敏',
  3390. phone_no='13800001111',
  3391. sex='1',
  3392. tenant_no='tenant-b',
  3393. create_dept_id='',
  3394. del_flag=0,
  3395. ),
  3396. BizCustomer(
  3397. id='c-miss',
  3398. customer_name='李四',
  3399. phone_no='13900002222',
  3400. sex='1',
  3401. tenant_no='tenant-a',
  3402. create_dept_id='',
  3403. del_flag=0,
  3404. ),
  3405. ]
  3406. )
  3407. db.commit()
  3408. by_name = search_customers_for_protocol(
  3409. db, ctx=_ctx('specialist'), user_info=_user(), keyword='张敏'
  3410. )
  3411. assert [item['id'] for item in by_name['list']] == ['c-hit']
  3412. assert by_name['list'][0]['phoneNo'] == '13800001111'
  3413. assert by_name['list'][0]['idCardNo'] == 'ID-HIT'
  3414. by_phone = search_customers_for_protocol(
  3415. db, ctx=_ctx('manager'), user_info=_user(), keyword='1380000'
  3416. )
  3417. assert [item['id'] for item in by_phone['list']] == ['c-hit']
  3418. def test_protocol_routes_expose_customers_search():
  3419. from app.api.market_mobile import router
  3420. for scope in ('specialist', 'manager'):
  3421. paths = {
  3422. route.path
  3423. for route in router.routes
  3424. if route.path.startswith(f'/mini/market/{scope}/protocols')
  3425. }
  3426. assert f'/mini/market/{scope}/protocols/customers' in paths
  3427. def test_protocol_sample_body_accepts_status_and_freight_fields():
  3428. from app.schemas.market_mobile import MarketProtocolBody
  3429. body = MarketProtocolBody.model_validate(
  3430. {
  3431. 'source': 'cell_storage',
  3432. 'samples': [
  3433. {
  3434. 'sampleTypeId': 'type-a',
  3435. 'sampleCode': 'S-001',
  3436. 'sampleStatus': 'ycj',
  3437. 'etaTime': '2026-10-13 12:00:00',
  3438. 'freightId': 'fr-1',
  3439. 'freightDraft': {
  3440. 'clientKey': 'fd_1',
  3441. 'transportMode': 'zxc',
  3442. 'freightAmount': 120,
  3443. },
  3444. }
  3445. ],
  3446. }
  3447. )
  3448. sample = body.samples[0]
  3449. assert sample.sample_status == 'ycj'
  3450. assert sample.freight_id == 'fr-1'
  3451. assert sample.freight_draft.client_key == 'fd_1'
  3452. assert sample.freight_draft.transport_mode == 'zxc'
  3453. assert sample.freight_draft.freight_amount == 120
  3454. def test_cell_protocol_detail_reuses_web_sample_lifecycle_bundle(monkeypatch):
  3455. from app.services.market_protocol import get_protocol
  3456. db = _session()
  3457. _masters(db)
  3458. _cell(db, 'cell-lifecycle')
  3459. db.add(
  3460. BizProtocolSample(
  3461. id='ps-lifecycle',
  3462. collection_protocol_id='cell-lifecycle',
  3463. sample_type_id='type-a',
  3464. sample_code='S-LIFE-1',
  3465. sample_status='yrk',
  3466. tenant_no='tenant-a',
  3467. create_dept_id='',
  3468. del_flag=0,
  3469. )
  3470. )
  3471. db.commit()
  3472. lifecycle_sample = {
  3473. 'id': 'ps-lifecycle',
  3474. 'sampleCode': 'S-LIFE-1',
  3475. 'sampleStatus': 'yrk',
  3476. 'sampleReception': {'receptionTime': '2026-10-01', 'receiveAttachmentFiles': [{'fileUrl': 'receive.pdf'}]},
  3477. 'productionReception': {'processTime': '2026-10-02'},
  3478. 'qcInfo': {'workOrderNo': 'QC-1'},
  3479. 'processQcInfoList': [{'workOrderNo': 'PQC-1'}],
  3480. 'productionInfo': {'workOrderNo': 'PR-1'},
  3481. 'storageReleaseInfo': {'storageStatus': 'yrk'},
  3482. }
  3483. monkeypatch.setattr(
  3484. 'app.services.market_protocol.cell_crud.get_protocol_detail_bundle',
  3485. lambda *args, **kwargs: {'samples': [lifecycle_sample]},
  3486. )
  3487. detail = get_protocol(
  3488. db,
  3489. source='cell_storage',
  3490. protocol_id='cell-lifecycle',
  3491. ctx=_ctx('specialist'),
  3492. user_info=_user(),
  3493. )
  3494. sample = detail['samples'][0]
  3495. for key in [
  3496. 'sampleReception', 'productionReception', 'qcInfo',
  3497. 'processQcInfoList', 'productionInfo', 'storageReleaseInfo',
  3498. ]:
  3499. assert sample[key] == lifecycle_sample[key]
  3500. def test_cell_v1_detail_reuses_web_payment_details_and_transfer_type_dict(monkeypatch):
  3501. from app.services.market_protocol import get_protocol
  3502. db = _session()
  3503. _masters(db)
  3504. row = _cell(db, 'cell-v1-payment')
  3505. row.payment_model_version = 1
  3506. db.add(
  3507. SysDictData(
  3508. id='dict-wx',
  3509. dict_label='微信支付',
  3510. dict_value='wx',
  3511. dict_type='zzfs',
  3512. tenant_no='tenant-a',
  3513. create_dept_id='',
  3514. del_flag=0,
  3515. )
  3516. )
  3517. db.commit()
  3518. monkeypatch.setattr(
  3519. 'app.services.market_protocol.cell_crud.get_protocol_detail_bundle',
  3520. lambda *args, **kwargs: {
  3521. 'samples': [],
  3522. 'paymentDetails': [{
  3523. 'id': 'pd-1',
  3524. 'installmentNo': 1,
  3525. 'dueAmount': 1000,
  3526. 'transferAmount': 800,
  3527. 'installmentDueDate': '20261031',
  3528. 'transferTime': '2026-10-13 12:00:00',
  3529. 'transferType': 'wx',
  3530. 'transferAttachments': 'proof.pdf',
  3531. 'payStatus': 'yjf',
  3532. 'financeRemark': '已确认',
  3533. }],
  3534. },
  3535. )
  3536. detail = get_protocol(
  3537. db,
  3538. source='cell_storage',
  3539. protocol_id='cell-v1-payment',
  3540. ctx=_ctx('specialist'),
  3541. user_info=_user(),
  3542. )
  3543. assert detail['paymentModelVersion'] == 1
  3544. assert detail['samplePayments'] == []
  3545. assert detail['paymentDetails'][0]['installmentNo'] == 1
  3546. assert detail['paymentDetails'][0]['transferTypeLabel'] == '微信支付'
  3547. def test_cell_v2_detail_labels_all_transfer_types_with_unknown_fallback(monkeypatch):
  3548. from app.services.market_protocol import get_protocol
  3549. db = _session()
  3550. _masters(db)
  3551. row = _cell(db, 'cell-v2-labels')
  3552. row.payment_model_version = 2
  3553. db.add(
  3554. SysDictData(
  3555. id='dict-v2-zfb',
  3556. dict_label='支付宝',
  3557. dict_value='zfb',
  3558. dict_type='zzfs',
  3559. tenant_no='tenant-a',
  3560. create_dept_id='',
  3561. del_flag=0,
  3562. )
  3563. )
  3564. db.commit()
  3565. monkeypatch.setattr(
  3566. 'app.services.market_protocol.cell_crud.get_protocol_detail_bundle',
  3567. lambda *args, **kwargs: {'samples': [], 'paymentDetails': []},
  3568. )
  3569. monkeypatch.setattr(
  3570. 'app.services.market_protocol.psp_crud.get_sample_payments_bundle',
  3571. lambda *args, **kwargs: [{
  3572. 'id': 'sp-1',
  3573. 'schedules': [{
  3574. 'id': 'sch-1',
  3575. 'transfers': [
  3576. {'id': 'tr-1', 'transferType': 'zfb'},
  3577. {'id': 'tr-2', 'transferType': 'future-code'},
  3578. ],
  3579. }],
  3580. }],
  3581. )
  3582. detail = get_protocol(
  3583. db,
  3584. source='cell_storage',
  3585. protocol_id='cell-v2-labels',
  3586. ctx=_ctx('specialist'),
  3587. user_info=_user(),
  3588. )
  3589. transfers = detail['samplePayments'][0]['schedules'][0]['transfers']
  3590. assert transfers[0]['transferTypeLabel'] == '支付宝'
  3591. assert transfers[1]['transferTypeLabel'] == 'future-code'
  3592. def test_donation_detail_reuses_web_bundle_lifecycle_audits_and_convert_aliases(monkeypatch):
  3593. from app.services.market_protocol import get_protocol
  3594. db = _session()
  3595. _masters(db)
  3596. _donation(db, 'don-web-detail')
  3597. db.commit()
  3598. lifecycle_sample = {
  3599. 'id': 'ds-1',
  3600. 'sampleCode': 'D-S-1',
  3601. 'sampleReception': {'receptionTime': '2026-10-01'},
  3602. 'productionReception': {'processTime': '2026-10-02'},
  3603. 'qcInfo': {'workOrderNo': 'D-QC-1'},
  3604. 'processQcInfoList': [{'workOrderNo': 'D-PQC-1'}],
  3605. 'productionInfo': {'workOrderNo': 'D-PR-1'},
  3606. 'storageReleaseInfo': {'storageStatus': 'yrk'},
  3607. }
  3608. monkeypatch.setattr(
  3609. 'app.services.market_protocol.donation_crud.get_donation_detail',
  3610. lambda *args, **kwargs: {
  3611. 'donation': {
  3612. 'id': 'don-web-detail',
  3613. 'donationNo': 'DON-WEB',
  3614. 'donationStatus': 'zckc',
  3615. 'toStorageTime': '2026-10-10 10:00:00',
  3616. 'toStorageDesc': '已转细胞存储',
  3617. 'consentFormUrls': 'consent.pdf',
  3618. },
  3619. 'party': {'motherName': '张敏'},
  3620. 'collection': {'regionName': 'A区'},
  3621. 'samples': [lifecycle_sample],
  3622. },
  3623. )
  3624. monkeypatch.setattr(
  3625. 'app.services.market_protocol.donation_crud.list_audits',
  3626. lambda *args, **kwargs: {
  3627. 'list': [{'id': 'da-1', 'auditStatus': 'shtg', 'auditRemark': '通过'}]
  3628. },
  3629. )
  3630. detail = get_protocol(
  3631. db,
  3632. source='research_donation',
  3633. protocol_id='don-web-detail',
  3634. ctx=_ctx('specialist'),
  3635. user_info=_user(),
  3636. )
  3637. assert detail['samples'][0] == lifecycle_sample
  3638. assert detail['audits'][0]['id'] == 'da-1'
  3639. assert detail['basic']['convertTime'] == '2026-10-10 10:00:00'
  3640. assert detail['basic']['convertDesc'] == '已转细胞存储'
  3641. def test_cell_protocol_detail_attaches_sample_freight():
  3642. from app.services.market_protocol import get_protocol
  3643. db = _session()
  3644. _masters(db)
  3645. _cell(db, 'cell-fr')
  3646. db.add(
  3647. BizProtocolSample(
  3648. id='ps-fr',
  3649. collection_protocol_id='cell-fr',
  3650. sample_type_id='type-a',
  3651. sample_code='S-FR-1',
  3652. sample_status='ycj',
  3653. tenant_no='tenant-a',
  3654. create_dept_id='',
  3655. del_flag=0,
  3656. )
  3657. )
  3658. db.add(
  3659. SysDictData(
  3660. id='dict-zxc',
  3661. dict_label='专线车',
  3662. dict_value='zxc',
  3663. dict_type='sample_transport_mode',
  3664. tenant_no='tenant-a',
  3665. create_dept_id='',
  3666. del_flag=0,
  3667. )
  3668. )
  3669. db.add(
  3670. BizSampleFreight(
  3671. id='fr-1',
  3672. freight_no='F-001',
  3673. transport_mode='zxc',
  3674. freight_amount=88,
  3675. tenant_no='tenant-a',
  3676. create_dept_id='',
  3677. del_flag=0,
  3678. )
  3679. )
  3680. db.add(
  3681. BizSampleFreightItem(
  3682. id='fri-1',
  3683. freight_id='fr-1',
  3684. sample_source='cell_storage',
  3685. sample_id='ps-fr',
  3686. protocol_id='cell-fr',
  3687. sample_code='S-FR-1',
  3688. tenant_no='tenant-a',
  3689. create_dept_id='',
  3690. del_flag=0,
  3691. )
  3692. )
  3693. db.commit()
  3694. detail = get_protocol(
  3695. db,
  3696. source='cell_storage',
  3697. protocol_id='cell-fr',
  3698. ctx=_ctx('specialist'),
  3699. user_info=_user(),
  3700. )
  3701. sample = detail['samples'][0]
  3702. assert sample['freightId'] == 'fr-1'
  3703. assert sample['freight']['transportMode'] == 'zxc'
  3704. assert sample['freight']['freightAmount'] == '88.00'
  3705. def test_protocol_update_replaces_freight_item_without_duplicate_active_links():
  3706. from app.services.market_protocol import update_protocol
  3707. db = _session()
  3708. _masters(db)
  3709. _cell(db, 'cell-edit')
  3710. _cell(db, 'cell-sibling')
  3711. db.add_all(
  3712. [
  3713. BizProtocolSample(
  3714. id='sample-old', collection_protocol_id='cell-edit',
  3715. sample_type_id='type-a', sample_code_id='code-a', sample_code='S-001',
  3716. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3717. ),
  3718. BizProtocolSample(
  3719. id='sample-sibling', collection_protocol_id='cell-sibling',
  3720. sample_type_id='type-a', sample_code_id='code-b', sample_code='S-002',
  3721. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3722. ),
  3723. BizSampleFreight(
  3724. id='freight-shared', freight_no='F-SHARED', transport_mode='zxc',
  3725. freight_amount=88, tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3726. ),
  3727. BizSampleFreightItem(
  3728. id='item-old', freight_id='freight-shared', sample_source='cell_storage',
  3729. sample_id='sample-old', protocol_id='cell-edit', sample_code='S-001',
  3730. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3731. ),
  3732. BizSampleFreightItem(
  3733. id='item-sibling', freight_id='freight-shared', sample_source='cell_storage',
  3734. sample_id='sample-sibling', protocol_id='cell-sibling', sample_code='S-002',
  3735. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3736. ),
  3737. ]
  3738. )
  3739. db.commit()
  3740. body = _body(
  3741. protocolNo='CELL-cell-edit',
  3742. samples=[{
  3743. 'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a',
  3744. 'sampleCode': 'S-001', 'unitId': 'unit-a',
  3745. 'collectAmount': 10, 'freightId': 'freight-shared',
  3746. }],
  3747. )
  3748. first = update_protocol(
  3749. db, source='cell_storage', protocol_id='cell-edit', body=body,
  3750. ctx=_ctx('specialist'), user_info=_user(),
  3751. )
  3752. second = update_protocol(
  3753. db, source='cell_storage', protocol_id='cell-edit', body=body,
  3754. ctx=_ctx('specialist'), user_info=_user(),
  3755. )
  3756. active_sample = db.query(BizProtocolSample).filter_by(
  3757. collection_protocol_id='cell-edit', tenant_no='tenant-a', del_flag=0,
  3758. ).one()
  3759. active_items = db.query(BizSampleFreightItem).filter_by(
  3760. freight_id='freight-shared', tenant_no='tenant-a', del_flag=0,
  3761. ).all()
  3762. assert {item.sample_id for item in active_items} == {
  3763. active_sample.id, 'sample-sibling'
  3764. }
  3765. assert db.get(BizSampleFreightItem, 'item-old').del_flag == 1
  3766. assert first['samples'][0]['id'] != second['samples'][0]['id']
  3767. assert second['samples'][0]['freight']['linkedSampleCount'] == 2
  3768. def test_protocol_update_cancels_then_relinks_freight_without_touching_other_samples():
  3769. from app.services.market_protocol import update_protocol
  3770. db = _session()
  3771. _masters(db)
  3772. _cell(db, 'cell-move')
  3773. _cell(db, 'cell-other-a')
  3774. _cell(db, 'cell-other-b')
  3775. samples = [
  3776. BizProtocolSample(
  3777. id='sample-move', collection_protocol_id='cell-move',
  3778. sample_type_id='type-a', sample_code_id='code-a', sample_code='S-001',
  3779. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3780. ),
  3781. BizProtocolSample(
  3782. id='sample-other-a', collection_protocol_id='cell-other-a',
  3783. sample_type_id='type-a', sample_code_id='code-b', sample_code='S-002',
  3784. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3785. ),
  3786. BizProtocolSample(
  3787. id='sample-other-b', collection_protocol_id='cell-other-b',
  3788. sample_type_id='type-a', sample_code_id='code-c', sample_code='S-003',
  3789. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3790. ),
  3791. ]
  3792. freights = [
  3793. BizSampleFreight(
  3794. id='freight-a', freight_no='F-A', transport_mode='zxc', freight_amount=10,
  3795. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3796. ),
  3797. BizSampleFreight(
  3798. id='freight-b', freight_no='F-B', transport_mode='zxc', freight_amount=20,
  3799. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3800. ),
  3801. ]
  3802. items = [
  3803. BizSampleFreightItem(
  3804. id='item-move', freight_id='freight-a', sample_source='cell_storage',
  3805. sample_id='sample-move', protocol_id='cell-move', sample_code='S-001',
  3806. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3807. ),
  3808. BizSampleFreightItem(
  3809. id='item-other-a', freight_id='freight-a', sample_source='cell_storage',
  3810. sample_id='sample-other-a', protocol_id='cell-other-a', sample_code='S-002',
  3811. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3812. ),
  3813. BizSampleFreightItem(
  3814. id='item-other-b', freight_id='freight-b', sample_source='cell_storage',
  3815. sample_id='sample-other-b', protocol_id='cell-other-b', sample_code='S-003',
  3816. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3817. ),
  3818. ]
  3819. role = SysRole(
  3820. id='role-all', role_name='市场人员', data_scope='1', status='1',
  3821. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3822. )
  3823. user_role = SysUserRole(
  3824. id='user-role-all', user_id='u1', role_id='role-all',
  3825. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3826. )
  3827. db.add_all([*samples, *freights, *items, role, user_role])
  3828. db.commit()
  3829. base_sample = {
  3830. 'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a',
  3831. 'sampleCode': 'S-001', 'unitId': 'unit-a', 'collectAmount': 10,
  3832. }
  3833. cancelled = update_protocol(
  3834. db, source='cell_storage', protocol_id='cell-move',
  3835. body=_body(protocolNo='CELL-cell-move', samples=[base_sample]),
  3836. ctx=_ctx('specialist'), user_info=_user(),
  3837. )
  3838. assert cancelled['samples'][0]['freightId'] == ''
  3839. assert db.get(BizSampleFreightItem, 'item-move').del_flag == 1
  3840. assert db.get(BizSampleFreightItem, 'item-other-a').del_flag == 0
  3841. relinked = update_protocol(
  3842. db, source='cell_storage', protocol_id='cell-move',
  3843. body=_body(
  3844. protocolNo='CELL-cell-move',
  3845. samples=[{**base_sample, 'freightId': 'freight-b'}],
  3846. ),
  3847. ctx=_ctx('specialist'), user_info=_user(),
  3848. )
  3849. current_id = relinked['samples'][0]['id']
  3850. assert relinked['samples'][0]['freight']['linkedSampleCount'] == 2
  3851. assert {
  3852. item.sample_id
  3853. for item in db.query(BizSampleFreightItem).filter_by(
  3854. freight_id='freight-b', tenant_no='tenant-a', del_flag=0,
  3855. ).all()
  3856. } == {'sample-other-b', current_id}
  3857. assert db.get(BizSampleFreightItem, 'item-other-a').del_flag == 0
  3858. def test_protocol_update_freight_reconciliation_rolls_back_on_later_failure(monkeypatch):
  3859. from app.services.market_protocol import update_protocol
  3860. db = _session()
  3861. _masters(db)
  3862. _cell(db, 'cell-rollback')
  3863. db.add_all(
  3864. [
  3865. BizProtocolSample(
  3866. id='sample-rollback', collection_protocol_id='cell-rollback',
  3867. sample_type_id='type-a', sample_code_id='code-a', sample_code='S-001',
  3868. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3869. ),
  3870. BizSampleFreight(
  3871. id='freight-rollback', freight_no='F-ROLLBACK', transport_mode='zxc',
  3872. freight_amount=88, tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3873. ),
  3874. BizSampleFreightItem(
  3875. id='item-rollback', freight_id='freight-rollback',
  3876. sample_source='cell_storage', sample_id='sample-rollback',
  3877. protocol_id='cell-rollback', sample_code='S-001',
  3878. tenant_no='tenant-a', create_dept_id='', del_flag=0,
  3879. ),
  3880. ]
  3881. )
  3882. db.commit()
  3883. monkeypatch.setattr(
  3884. 'app.services.market_protocol._persist_sample_payments_if_any',
  3885. lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError('later failure')),
  3886. )
  3887. with pytest.raises(RuntimeError, match='later failure'):
  3888. update_protocol(
  3889. db, source='cell_storage', protocol_id='cell-rollback',
  3890. body=_body(
  3891. protocolNo='CELL-cell-rollback',
  3892. samples=[{
  3893. 'sampleTypeId': 'type-a', 'sampleCodeId': 'code-a',
  3894. 'sampleCode': 'S-001', 'unitId': 'unit-a',
  3895. 'collectAmount': 10, 'freightId': 'freight-rollback',
  3896. }],
  3897. ),
  3898. ctx=_ctx('specialist'), user_info=_user(),
  3899. )
  3900. db.expire_all()
  3901. assert db.get(BizProtocolSample, 'sample-rollback').del_flag == 0
  3902. assert db.get(BizSampleFreightItem, 'item-rollback').del_flag == 0
  3903. assert db.query(BizProtocolSample).filter_by(
  3904. collection_protocol_id='cell-rollback', tenant_no='tenant-a', del_flag=0,
  3905. ).count() == 1
  3906. assert db.query(BizSampleFreightItem).filter_by(
  3907. protocol_id='cell-rollback', tenant_no='tenant-a', del_flag=0,
  3908. ).count() == 1