test_market_protocol_api.py 155 KB

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