test_market_protocol_api.py 148 KB

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