collection_protocol.py 167 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758
  1. """采集协议单 / 审核 / 缴费明细 CRUD(细胞存储 2.2.6~2.2.10)"""
  2. from __future__ import annotations
  3. import json
  4. import math
  5. import calendar
  6. from datetime import datetime, timedelta
  7. from decimal import Decimal
  8. from typing import Any, Dict, List, Optional, Tuple
  9. from sqlalchemy import and_, func, or_
  10. from sqlalchemy.orm import Session
  11. from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date
  12. from app.crud.base import camel_to_snake
  13. from app.crud.biz_no_generator import allocate_next_biz_no
  14. from app.crud.protocol_donation_convert import (
  15. assert_biz_no_available,
  16. get_partner_sample_codes,
  17. )
  18. from app.services.protocol_payment_installment import (
  19. calc_fq_installment_due_date,
  20. calc_installment_due_amounts_by_ratios,
  21. parse_installment_ratios,
  22. resolve_installment_due_amounts,
  23. resolve_locked_remaining_due_amounts,
  24. )
  25. from app.services.protocol_pay_mode_switch import validate_payment_details_for_save
  26. from app.utils.sample_special import (
  27. apply_sample_special_fields,
  28. sample_special_to_dict,
  29. validate_sample_special_raw,
  30. )
  31. from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
  32. from app.models.models import (
  33. BizAttachment,
  34. BizCollectionProtocol,
  35. BizProtocolAudit,
  36. BizProtocolStatusChange,
  37. BizProtocolCollectionInfo,
  38. BizProtocolFullmoonTodo,
  39. BizProtocolInvoiceRequest,
  40. BizProtocolInvoiceRequestItem,
  41. BizProtocolPartyCustomer,
  42. BizProtocolPayment,
  43. BizProtocolPaymentDetail,
  44. BizProtocolSample,
  45. BizProtocolSampleTransport,
  46. BizProtocolStorageCert,
  47. BizQcWorkOrder,
  48. BizSampleReception,
  49. BizStorageReleaseReview,
  50. BizCollectionRegion,
  51. BizCollectionHospital,
  52. BizCellSampleType,
  53. BizExpressCompany,
  54. BizItemUnit,
  55. SysSite,
  56. SysUser,
  57. )
  58. _NEWBORN_SEX_LABEL = {"1": "男", "0": "女"}
  59. _SAMPLE_STATUS_LABEL = {
  60. "ycj": "已采集",
  61. "jcz": "检测中",
  62. "yrk": "已入库",
  63. "yzf": "已作废",
  64. }
  65. def get_protocol_nos_used_in_protocols(
  66. db: Session,
  67. *,
  68. tenant_no: str,
  69. protocol_nos: List[str],
  70. ) -> set:
  71. """返回已在采集协议单或科研捐赠单中占用的协议编号集合(同租户、未删除)。"""
  72. nos = list({(n or "").strip() for n in protocol_nos if (n or "").strip()})
  73. if not nos:
  74. return set()
  75. used: set = set()
  76. proto_rows = (
  77. db.query(BizCollectionProtocol.protocol_no)
  78. .filter(
  79. BizCollectionProtocol.tenant_no == tenant_no,
  80. BizCollectionProtocol.del_flag == 0,
  81. BizCollectionProtocol.protocol_no.in_(nos),
  82. )
  83. .all()
  84. )
  85. used.update(r[0] for r in proto_rows if r[0])
  86. donation_rows = (
  87. db.query(BizResearchDonation.donation_no)
  88. .filter(
  89. BizResearchDonation.tenant_no == tenant_no,
  90. BizResearchDonation.del_flag == 0,
  91. BizResearchDonation.donation_no.in_(nos),
  92. )
  93. .all()
  94. )
  95. used.update(r[0] for r in donation_rows if r[0])
  96. return used
  97. def get_sample_codes_used_in_protocol_samples(
  98. db: Session,
  99. *,
  100. tenant_no: str,
  101. sample_codes: List[str],
  102. ) -> set:
  103. """返回已在采集协议单或科研捐赠单样本表中占用的样本编码集合(同租户、未删除)。"""
  104. codes = list({(c or "").strip() for c in sample_codes if (c or "").strip()})
  105. if not codes:
  106. return set()
  107. used: set = set()
  108. proto_rows = (
  109. db.query(BizProtocolSample.sample_code)
  110. .filter(
  111. BizProtocolSample.tenant_no == tenant_no,
  112. BizProtocolSample.del_flag == 0,
  113. BizProtocolSample.sample_code.in_(codes),
  114. )
  115. .all()
  116. )
  117. used.update(r[0] for r in proto_rows if r[0])
  118. donation_rows = (
  119. db.query(BizResearchDonationSample.sample_code)
  120. .filter(
  121. BizResearchDonationSample.tenant_no == tenant_no,
  122. BizResearchDonationSample.del_flag == 0,
  123. BizResearchDonationSample.sample_code.in_(codes),
  124. )
  125. .all()
  126. )
  127. used.update(r[0] for r in donation_rows if r[0])
  128. return used
  129. def validate_sample_codes_not_used(
  130. db: Session,
  131. *,
  132. tenant_no: str,
  133. samples: list,
  134. exclude_protocol_id: Optional[str] = None,
  135. exclude_donation_id: Optional[str] = None,
  136. ) -> None:
  137. """创建/更新样本行前校验:样本编码不得已被任一业务单占用(转移配对单豁免)。"""
  138. codes: List[str] = []
  139. for raw_item in samples or []:
  140. raw = _normalize_fields(raw_item)
  141. c = (raw.get("sample_code") or "").strip()
  142. if c:
  143. codes.append(c)
  144. if not codes:
  145. return
  146. used = get_sample_codes_used_in_protocol_samples(
  147. db, tenant_no=tenant_no, sample_codes=codes
  148. )
  149. exempt = get_partner_sample_codes(
  150. db,
  151. tenant_no,
  152. protocol_id=exclude_protocol_id,
  153. donation_id=exclude_donation_id,
  154. )
  155. used -= exempt
  156. conflicts = set(codes) & used
  157. if conflicts:
  158. first = sorted(conflicts)[0]
  159. raise ValueError(f"样本编码「{first}」已被占用")
  160. def total_pages(total: int, page_size: int) -> int:
  161. if total <= 0:
  162. return 0
  163. return int(math.ceil(total / float(page_size)))
  164. def _fmt_dt(t: Optional[datetime]) -> str:
  165. return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
  166. def _dec(v: Any) -> Optional[float]:
  167. if v is None:
  168. return None
  169. if isinstance(v, Decimal):
  170. return float(v)
  171. return float(v)
  172. def _money_string(v: Any) -> Optional[str]:
  173. if v is None:
  174. return None
  175. return format(Decimal(str(v)).quantize(Decimal("0.01")), "f")
  176. def _optional_decimal(v: Any) -> Optional[Decimal]:
  177. if v is None or v == "":
  178. return None
  179. return Decimal(str(v))
  180. def _today_yyyymmdd() -> str:
  181. return datetime.now().strftime("%Y%m%d")
  182. def _normalize_yyyymmdd_date(val: Any) -> Optional[str]:
  183. """将 YYYYMMDD / YYYY-MM-DD 等格式规范为 8 位日期字符串。"""
  184. if val is None:
  185. return None
  186. s = str(val).strip()
  187. if not s:
  188. return None
  189. compact = s.replace("-", "").replace("/", "")[:8]
  190. if len(compact) == 8 and compact.isdigit():
  191. return compact
  192. return None
  193. def _validate_yyyymmdd_date(val: Any, field_label: str) -> Optional[str]:
  194. """Validate supplied business dates instead of silently clearing bad input."""
  195. if val is None or str(val).strip() == "":
  196. return None
  197. compact = str(val).strip().replace("-", "").replace("/", "")
  198. if len(compact) != 8 or not compact.isdigit():
  199. raise ValueError(f"{field_label}日期格式无效")
  200. try:
  201. datetime.strptime(compact, "%Y%m%d")
  202. except ValueError as exc:
  203. raise ValueError(f"{field_label}日期无效") from exc
  204. return compact
  205. _SNAKE_ALIASES = {
  206. "apgar1min": "apgar_1min",
  207. "apgar5min": "apgar_5min",
  208. }
  209. def _normalize_fields(fields: Optional[dict]) -> dict:
  210. """将前端 camelCase 字段名转为数据库 snake_case。"""
  211. if not fields:
  212. return {}
  213. out: Dict[str, Any] = {}
  214. for k, v in fields.items():
  215. sk = _SNAKE_ALIASES.get(k) or camel_to_snake(k)
  216. out[sk] = v
  217. return out
  218. def _pop_attachment_urls(raw: Dict[str, Any], *keys: str) -> Optional[str]:
  219. """从保存载荷中取出附件字段(支持 snake/camel、URL 串或附件对象列表)。"""
  220. for key in keys:
  221. if key not in raw:
  222. continue
  223. urls = attachments_to_urls(raw.pop(key))
  224. if urls:
  225. return urls
  226. return None
  227. def attachments_to_urls(attachments: Any) -> Optional[str]:
  228. """附件列表或字符串 → 逗号分隔 URL(MinIO)。"""
  229. if attachments is None:
  230. return None
  231. if isinstance(attachments, str):
  232. return attachments.strip() or None
  233. if isinstance(attachments, list):
  234. urls: List[str] = []
  235. for item in attachments:
  236. if isinstance(item, dict):
  237. u = (item.get("fileUrl") or item.get("file_url") or "").strip()
  238. if u:
  239. urls.append(u)
  240. elif isinstance(item, str) and item.strip():
  241. urls.append(item.strip())
  242. return ",".join(urls) if urls else None
  243. return None
  244. def _normalize_attachment_url_key(url: str) -> str:
  245. u = (url or "").strip()
  246. if not u:
  247. return ""
  248. if "?" in u:
  249. u = u.split("?", 1)[0]
  250. return u.lstrip("/")
  251. def _format_attachment_file_size(size_bytes: int) -> str:
  252. if size_bytes < 1024:
  253. return f"{size_bytes} B"
  254. if size_bytes < 1024 * 1024:
  255. return f"{size_bytes / 1024:.1f} KB"
  256. return f"{size_bytes / (1024 * 1024):.1f} MB"
  257. def _lookup_attachment_by_url(
  258. db: Session,
  259. url: str,
  260. tenant_no: Optional[str],
  261. ) -> Optional[BizAttachment]:
  262. key = _normalize_attachment_url_key(url)
  263. if not key:
  264. return None
  265. q = db.query(BizAttachment).filter(BizAttachment.del_flag == 0)
  266. if tenant_no:
  267. q = q.filter(BizAttachment.tenant_no == tenant_no)
  268. row = q.filter(BizAttachment.file_url == key).first()
  269. if row:
  270. return row
  271. tail = key.rsplit("/", 1)[-1]
  272. if tail:
  273. row = (
  274. q.filter(BizAttachment.file_url.like(f"%/{tail}"))
  275. .order_by(BizAttachment.create_time.desc())
  276. .first()
  277. )
  278. return row
  279. def lookup_attachments_by_urls(
  280. db: Session,
  281. urls: List[str],
  282. tenant_no: Optional[str],
  283. ) -> Dict[str, BizAttachment]:
  284. """批量按 file_url 精确匹配附件,避免列表场景逐 URL 查库。
  285. 返回 normalize(url) -> BizAttachment。未命中的 URL 不回退 LIKE,
  286. 由调用方按无元数据展示;详情单条查询仍可用 `_lookup_attachment_by_url`。
  287. """
  288. keys: List[str] = []
  289. seen = set()
  290. for url in urls:
  291. key = _normalize_attachment_url_key(url)
  292. if not key or key in seen:
  293. continue
  294. seen.add(key)
  295. keys.append(key)
  296. if not keys:
  297. return {}
  298. q = db.query(BizAttachment).filter(
  299. BizAttachment.del_flag == 0,
  300. BizAttachment.file_url.in_(keys),
  301. )
  302. if tenant_no:
  303. q = q.filter(BizAttachment.tenant_no == tenant_no)
  304. out: Dict[str, BizAttachment] = {}
  305. for row in q.all():
  306. k = _normalize_attachment_url_key(row.file_url or "")
  307. if k and k not in out:
  308. out[k] = row
  309. return out
  310. def _attachment_item_from_row(url: str, att: Optional[BizAttachment]) -> Dict[str, Any]:
  311. if att:
  312. size = att.file_size or 0
  313. return {
  314. "id": att.id,
  315. "fileUrl": url,
  316. "fileName": att.original_name or url.rsplit("/", 1)[-1],
  317. "fileSize": size,
  318. "fileSizeDisplay": _format_attachment_file_size(size),
  319. "fileType": att.file_type or "",
  320. }
  321. name = url.rsplit("/", 1)[-1] if "/" in url else url
  322. return {"fileUrl": url, "fileName": name, "fileSize": 0}
  323. def urls_to_attachment_list(
  324. urls_str: Optional[str],
  325. db: Optional[Session] = None,
  326. tenant_no: Optional[str] = None,
  327. *,
  328. attachment_by_url: Optional[Dict[str, BizAttachment]] = None,
  329. ) -> List[Dict[str, Any]]:
  330. """逗号分隔 URL → 前端附件卡片结构;有 db 时从 biz_attachment 取原始文件名。
  331. 传入 `attachment_by_url`(normalize key → row)时只做内存匹配,不再逐条查库。
  332. """
  333. if not urls_str:
  334. return []
  335. result: List[Dict[str, Any]] = []
  336. for url in urls_str.split(","):
  337. u = url.strip()
  338. if not u:
  339. continue
  340. att: Optional[BizAttachment] = None
  341. if attachment_by_url is not None:
  342. att = attachment_by_url.get(_normalize_attachment_url_key(u))
  343. elif db:
  344. att = _lookup_attachment_by_url(db, u, tenant_no)
  345. result.append(_attachment_item_from_row(u, att))
  346. return result
  347. def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
  348. ids = list({x for x in user_ids if x})
  349. if not ids:
  350. return {}
  351. rows = db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(ids)).all()
  352. return {u.id: u.nick_name or "" for u in rows}
  353. def _protocol_to_dict(p: BizCollectionProtocol) -> Dict[str, Any]:
  354. return {
  355. "id": p.id,
  356. "protocolNoId": p.protocol_no_id or "",
  357. "protocolNo": p.protocol_no or "",
  358. "protocolStatus": p.protocol_status or "",
  359. "protocolAttachments": p.protocol_attachments or "",
  360. "consentFormUrls": p.consent_form_urls or "",
  361. "healthSurveyFormUrls": p.health_survey_form_urls or "",
  362. "registerDate": (p.register_date or "").strip() if p.register_date else "",
  363. "effectiveTime": _fmt_dt(p.effective_time),
  364. "effectiveDesc": p.effective_desc or "",
  365. "expireTime": _fmt_dt(p.expire_time),
  366. "expireDesc": p.expire_desc or "",
  367. "voidTime": _fmt_dt(p.void_time),
  368. "voidDesc": p.void_desc or "",
  369. "voidType": p.void_type or "",
  370. "toResearchTime": _fmt_dt(p.to_research_time),
  371. "toResearchDesc": p.to_research_desc or "",
  372. "collectorUserId": p.collector_user_id or "",
  373. "protocolTotalAmount": _dec(p.protocol_total_amount)
  374. if getattr(p, "protocol_total_amount", None) is not None
  375. else None,
  376. "sourcePresignOrderId": getattr(p, "source_presign_order_id", None) or "",
  377. "paymentModelVersion": int(
  378. getattr(p, "payment_model_version", None) or 1
  379. ),
  380. "tenantNo": p.tenant_no,
  381. "orderNum": p.order_num if p.order_num is not None else 0,
  382. "remark": p.remark or "",
  383. "createTime": _fmt_dt(p.create_time),
  384. "updateTime": _fmt_dt(p.update_time),
  385. }
  386. def _payment_to_dict(p: BizProtocolPayment) -> Dict[str, Any]:
  387. ratios_list = [
  388. format(value, "f")
  389. for value in parse_installment_ratios(p.installment_ratios)
  390. ]
  391. return {
  392. "id": p.id,
  393. "collectionProtocolId": p.collection_protocol_id,
  394. "payMode": p.pay_mode or "",
  395. "contractAmount": _money_string(p.contract_amount),
  396. "installmentCount": p.installment_count,
  397. "installmentIntervalMonths": _dec(p.installment_interval_months),
  398. "lumpSumDeadline": (p.lump_sum_deadline or "").strip() if p.lump_sum_deadline else "",
  399. "installmentStartDate": (p.installment_start_date or "").strip() if p.installment_start_date else "",
  400. "installmentAdvanceDays": p.installment_advance_days if p.installment_advance_days is not None else 30,
  401. "installmentRatios": ratios_list,
  402. "tenantNo": p.tenant_no,
  403. "orderNum": p.order_num if p.order_num is not None else 0,
  404. "remark": p.remark or "",
  405. "createTime": _fmt_dt(p.create_time),
  406. "updateTime": _fmt_dt(p.update_time),
  407. }
  408. def _detail_to_dict(
  409. d: BizProtocolPaymentDetail,
  410. *,
  411. protocol_no: str = "",
  412. collection_protocol_id: str = "",
  413. protocol_status: str = "",
  414. pay_mode: str = "",
  415. mother_name: str = "",
  416. mother_phone: str = "",
  417. mother_id_card: str = "",
  418. newborn_name: str = "",
  419. newborn_id_card: str = "",
  420. ) -> Dict[str, Any]:
  421. return {
  422. "id": d.id,
  423. "paymentId": d.payment_id,
  424. "collectionProtocolId": collection_protocol_id,
  425. "protocolNo": protocol_no,
  426. "protocolStatus": protocol_status,
  427. "payMode": pay_mode,
  428. "motherName": mother_name,
  429. "motherPhone": mother_phone,
  430. "motherIdCard": mother_id_card,
  431. "newbornName": newborn_name,
  432. "newbornIdCard": newborn_id_card,
  433. "transferType": d.transfer_type or "",
  434. "dueAmount": _money_string(d.due_amount),
  435. "transferAmount": _money_string(d.transfer_amount),
  436. "transferTime": _fmt_dt(d.transfer_time),
  437. "transferAttachments": d.transfer_attachments or "",
  438. "financeConfirmAttachments": d.finance_confirm_attachments or "",
  439. "financeRemark": d.finance_remark or "",
  440. "refundFinanceConfirmAttachments": d.refund_finance_confirm_attachments or "",
  441. "refundFinanceRemark": d.refund_finance_remark or "",
  442. "payStatus": d.pay_status or "",
  443. "installmentNo": d.installment_no,
  444. "installmentDueDate": (d.installment_due_date or "").strip() if d.installment_due_date else "",
  445. "refundAmount": _money_string(d.refund_amount),
  446. "refundRemark": d.refund_remark or "",
  447. "refundAttachments": d.refund_attachments or "",
  448. "tenantNo": d.tenant_no,
  449. "orderNum": d.order_num if d.order_num is not None else 0,
  450. "remark": d.remark or "",
  451. "createTime": _fmt_dt(d.create_time),
  452. "updateTime": _fmt_dt(d.update_time),
  453. }
  454. def _audit_to_dict(
  455. a: BizProtocolAudit,
  456. *,
  457. protocol_no: str = "",
  458. auditor_user_name: str = "",
  459. ) -> Dict[str, Any]:
  460. return {
  461. "id": a.id,
  462. "collectionProtocolId": a.collection_protocol_id,
  463. "protocolNo": protocol_no,
  464. "auditorUserId": a.auditor_user_id or "",
  465. "auditorUserName": auditor_user_name or "",
  466. "auditRemark": a.audit_remark or "",
  467. "auditStatus": a.audit_status or "",
  468. "tenantNo": a.tenant_no,
  469. "orderNum": a.order_num if a.order_num is not None else 0,
  470. "remark": a.remark or "",
  471. "createTime": _fmt_dt(a.create_time),
  472. "updateTime": _fmt_dt(a.update_time),
  473. }
  474. _PROTOCOL_STATUS_CHANGE_TYPE = {
  475. "sxz": "activate",
  476. "ygq": "expire",
  477. "yzf": "void",
  478. "zky": "to_research",
  479. }
  480. def _status_change_to_dict(
  481. row: BizProtocolStatusChange,
  482. *,
  483. operator_user_name: str = "",
  484. ) -> Dict[str, Any]:
  485. return {
  486. "id": row.id,
  487. "collectionProtocolId": row.collection_protocol_id,
  488. "changeType": row.change_type or "",
  489. "fromStatus": row.from_status or "",
  490. "toStatus": row.to_status or "",
  491. "changeTime": _fmt_dt(row.change_time),
  492. "changeDesc": row.change_desc or "",
  493. "voidType": row.void_type or "",
  494. "operatorUserId": row.operator_user_id or "",
  495. "operatorUserName": operator_user_name or "",
  496. "tenantNo": row.tenant_no,
  497. "createTime": _fmt_dt(row.create_time),
  498. "updateTime": _fmt_dt(row.update_time),
  499. }
  500. def append_protocol_status_change(
  501. db: Session,
  502. *,
  503. collection_protocol_id: str,
  504. change_type: str,
  505. from_status: Optional[str],
  506. to_status: str,
  507. change_time: datetime,
  508. change_desc: Optional[str] = None,
  509. void_type: Optional[str] = None,
  510. tenant_no: str,
  511. user_id: Optional[str] = None,
  512. ) -> BizProtocolStatusChange:
  513. """追加一条状态变更历史(不 commit)。"""
  514. now = datetime.now()
  515. obj = BizProtocolStatusChange(
  516. id=generate_uuid(),
  517. collection_protocol_id=collection_protocol_id,
  518. change_type=(change_type or "").strip(),
  519. from_status=(from_status or "").strip() or None,
  520. to_status=(to_status or "").strip(),
  521. change_time=change_time,
  522. change_desc=(change_desc or "").strip() or None,
  523. void_type=(void_type or "").strip() or None,
  524. operator_user_id=user_id,
  525. tenant_no=tenant_no,
  526. order_num=0,
  527. remark=None,
  528. create_by=user_id,
  529. create_dept_id="",
  530. create_time=now,
  531. update_by=user_id,
  532. update_time=now,
  533. del_flag=0,
  534. )
  535. db.add(obj)
  536. return obj
  537. def list_protocol_status_changes(
  538. db: Session,
  539. *,
  540. collection_protocol_id: str,
  541. tenant_no: str,
  542. ) -> List[Dict[str, Any]]:
  543. pid = (collection_protocol_id or "").strip()
  544. if not pid:
  545. return []
  546. rows = (
  547. db.query(BizProtocolStatusChange)
  548. .filter(
  549. BizProtocolStatusChange.collection_protocol_id == pid,
  550. BizProtocolStatusChange.tenant_no == tenant_no,
  551. BizProtocolStatusChange.del_flag == 0,
  552. )
  553. .order_by(
  554. BizProtocolStatusChange.change_time.asc(),
  555. BizProtocolStatusChange.create_time.asc(),
  556. )
  557. .all()
  558. )
  559. op_ids = list({r.operator_user_id for r in rows if r.operator_user_id})
  560. name_map: Dict[str, str] = {}
  561. if op_ids:
  562. for u in db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(op_ids)).all():
  563. name_map[u.id] = u.nick_name or ""
  564. return [
  565. _status_change_to_dict(r, operator_user_name=name_map.get(r.operator_user_id or "", ""))
  566. for r in rows
  567. ]
  568. def _party_customer_to_dict(p: BizProtocolPartyCustomer) -> Dict[str, Any]:
  569. return {
  570. "id": p.id,
  571. "collectionProtocolId": p.collection_protocol_id,
  572. "customerId": p.customer_id or "",
  573. "fatherCustomerId": p.father_customer_id or "",
  574. "newbornCustomerId": p.newborn_customer_id or "",
  575. "motherName": p.mother_name or "",
  576. "motherPhone": p.mother_phone or "",
  577. "motherIdCard": p.mother_id_card or "",
  578. "motherBirthYm": (p.mother_birth_ym or "").strip() if p.mother_birth_ym else "",
  579. "motherEthnic": p.mother_ethnic or "",
  580. "motherEmail": p.mother_email or "",
  581. "motherAddress": p.mother_address or "",
  582. "fatherName": p.father_name or "",
  583. "fatherPhone": p.father_phone or "",
  584. "fatherIdCard": p.father_id_card or "",
  585. "newbornName": p.newborn_name or "",
  586. "newbornIdCard": p.newborn_id_card or "",
  587. "expectedDeliveryDate": format_date(p.expected_delivery_date),
  588. "newbornDeliveryTime": _fmt_dt(p.newborn_delivery_time),
  589. "newbornSex": p.newborn_sex or "",
  590. "deliveryMode": p.delivery_mode or "",
  591. "birthOrder": p.birth_order,
  592. "newbornWeightGram": p.newborn_weight_gram,
  593. "apgar1min": _dec(p.apgar_1min),
  594. "apgar5min": _dec(p.apgar_5min),
  595. "collectorUserId": p.collector_user_id or "",
  596. "tenantNo": p.tenant_no,
  597. "orderNum": p.order_num if p.order_num is not None else 0,
  598. "remark": p.remark or "",
  599. "createTime": _fmt_dt(p.create_time),
  600. "updateTime": _fmt_dt(p.update_time),
  601. }
  602. def _collection_info_to_dict(c: BizProtocolCollectionInfo) -> Dict[str, Any]:
  603. return {
  604. "id": c.id,
  605. "collectionProtocolId": c.collection_protocol_id,
  606. "regionId": c.region_id or "",
  607. "hospitalId": c.hospital_id or "",
  608. "medicalStaff": c.medical_staff or "",
  609. "salesUserId": c.sales_user_id or "",
  610. "regionManagerUserId": c.region_manager_user_id or "",
  611. "sampleCollectTime": _fmt_dt(c.sample_collect_time),
  612. "maternalBloodCollectTime": _fmt_dt(c.maternal_blood_collect_time),
  613. "tenantNo": c.tenant_no,
  614. "orderNum": c.order_num if c.order_num is not None else 0,
  615. "remark": c.remark or "",
  616. "createTime": _fmt_dt(c.create_time),
  617. "updateTime": _fmt_dt(c.update_time),
  618. }
  619. def _sample_to_dict(s: BizProtocolSample) -> Dict[str, Any]:
  620. return {
  621. "id": s.id,
  622. "collectionProtocolId": s.collection_protocol_id,
  623. "sampleTypeId": s.sample_type_id or "",
  624. "sampleCodeId": s.sample_code_id or "",
  625. "sampleCode": s.sample_code or "",
  626. "collectAmount": _dec(s.collect_amount),
  627. "unitId": s.unit_id or "",
  628. "price": _dec(s.price),
  629. "originalPrice": _dec(s.original_price),
  630. "storageYears": s.storage_years,
  631. "sampleStatus": (s.sample_status or "").strip() if (s.sample_status or "").strip() else "",
  632. "storageInDate": (s.storage_in_date or "").strip() if s.storage_in_date else "",
  633. "detectStartDate": (s.detect_start_date or "").strip() if s.detect_start_date else "",
  634. "voidDate": (s.void_date or "").strip() if s.void_date else "",
  635. "storageExpireDate": (s.storage_expire_date or "").strip() if s.storage_expire_date else "",
  636. **sample_special_to_dict(s),
  637. "tenantNo": s.tenant_no,
  638. "orderNum": s.order_num if s.order_num is not None else 0,
  639. "remark": s.remark or "",
  640. "createTime": _fmt_dt(s.create_time),
  641. "updateTime": _fmt_dt(s.update_time),
  642. }
  643. def _transport_to_dict(t: BizProtocolSampleTransport) -> Dict[str, Any]:
  644. return {
  645. "id": t.id,
  646. "collectionProtocolId": t.collection_protocol_id,
  647. "sampleId": t.sample_id,
  648. "transportType": t.transport_type or "",
  649. "expressCompanyId": t.express_company_id or "",
  650. "waybillNo": t.waybill_no or "",
  651. "destSiteId": t.dest_site_id or "",
  652. "expressFee": _dec(t.express_fee),
  653. "etaTime": _fmt_dt(t.eta_time),
  654. "carrierName": t.carrier_name or "",
  655. "carrierPhone": t.carrier_phone or "",
  656. "remark": t.remark or "",
  657. "orderNum": t.order_num if t.order_num is not None else 0,
  658. "createTime": _fmt_dt(t.create_time),
  659. "updateTime": _fmt_dt(t.update_time),
  660. }
  661. def _storage_cert_to_dict(s: BizProtocolStorageCert) -> Dict[str, Any]:
  662. from app.services.sample_info_progress import storage_cert_handled
  663. return {
  664. "id": s.id,
  665. "collectionProtocolId": s.collection_protocol_id,
  666. "makeStatus": s.make_status or "0",
  667. "certImageUrls": s.cert_image_urls or "",
  668. "makeDate": (s.make_date or "").strip() if s.make_date else "",
  669. "handleTime": _fmt_dt(s.handle_time) if getattr(s, "handle_time", None) else "",
  670. "handlerUserId": getattr(s, "handler_user_id", None) or "",
  671. "handled": storage_cert_handled(s),
  672. "tenantNo": s.tenant_no,
  673. "orderNum": s.order_num if s.order_num is not None else 0,
  674. "remark": s.remark or "",
  675. "createTime": _fmt_dt(s.create_time),
  676. "updateTime": _fmt_dt(s.update_time),
  677. }
  678. def _format_newborn_info(pc: Optional[BizProtocolPartyCustomer]) -> str:
  679. if not pc:
  680. return ""
  681. parts: List[str] = []
  682. name = (pc.newborn_name or "").strip()
  683. if name:
  684. parts.append(name)
  685. sex = _NEWBORN_SEX_LABEL.get((pc.newborn_sex or "").strip(), "")
  686. if sex:
  687. parts.append(sex)
  688. if pc.newborn_weight_gram is not None:
  689. parts.append(f"{pc.newborn_weight_gram}g")
  690. if pc.newborn_delivery_time:
  691. parts.append(_fmt_dt(pc.newborn_delivery_time))
  692. return " / ".join(parts)
  693. def _format_sample_collect_summary(samples: List[BizProtocolSample]) -> str:
  694. """汇总协议单下各样本采集情况,如「1个已采集,1个未采集」。"""
  695. collected = 0
  696. not_collected = 0
  697. for s in samples:
  698. status = (s.sample_status or "").strip()
  699. if status:
  700. collected += 1
  701. else:
  702. not_collected += 1
  703. parts: List[str] = []
  704. if collected:
  705. parts.append(f"{collected}个已采集")
  706. if not_collected:
  707. parts.append(f"{not_collected}个未采集")
  708. return ",".join(parts)
  709. def _format_storage_cert_sample_info(
  710. samples: List[Dict[str, Any]], type_map: Dict[str, str]
  711. ) -> str:
  712. parts: List[str] = []
  713. for s in samples:
  714. type_name = type_map.get(s.get("sampleTypeId") or "", "") or "样本"
  715. code = (s.get("sampleCode") or "").strip()
  716. status = (s.get("sampleStatus") or "").strip()
  717. seg = type_name
  718. if code:
  719. seg += f"({code})"
  720. if status:
  721. seg += f"[{_SAMPLE_STATUS_LABEL.get(status, status)}]"
  722. parts.append(seg)
  723. return ";".join(parts)
  724. def _pick_storage_in_date(samples: List[Dict[str, Any]]) -> str:
  725. for s in samples:
  726. d = (s.get("storageInDate") or "").strip()
  727. if d:
  728. return d
  729. return ""
  730. def _load_protocol_storage_in_date_map(
  731. db: Session,
  732. protocol_ids: List[str],
  733. tenant_no: str,
  734. ) -> Dict[str, str]:
  735. """从入库放行审核记录补全入库日期(历史数据可能未写入样本 storage_in_date)。"""
  736. if not protocol_ids:
  737. return {}
  738. rows = (
  739. db.query(BizStorageReleaseReview)
  740. .filter(
  741. BizStorageReleaseReview.protocol_id.in_(protocol_ids),
  742. BizStorageReleaseReview.tenant_no == tenant_no,
  743. BizStorageReleaseReview.del_flag == 0,
  744. BizStorageReleaseReview.storage_status == "yrk",
  745. BizStorageReleaseReview.storage_time.isnot(None),
  746. )
  747. .all()
  748. )
  749. out: Dict[str, str] = {}
  750. for rev in rows:
  751. pid = (rev.protocol_id or "").strip()
  752. if not pid or not rev.storage_time:
  753. continue
  754. d = rev.storage_time.strftime("%Y%m%d")
  755. prev = out.get(pid)
  756. if not prev or d < prev:
  757. out[pid] = d
  758. return out
  759. def _load_sample_type_name_map(db: Session, samples: List[BizProtocolSample]) -> Dict[str, str]:
  760. type_ids = list({s.sample_type_id for s in samples if s.sample_type_id})
  761. if not type_ids:
  762. return {}
  763. type_map: Dict[str, str] = {}
  764. for t in (
  765. db.query(BizCellSampleType)
  766. .filter(BizCellSampleType.id.in_(type_ids), BizCellSampleType.del_flag == 0)
  767. .all()
  768. ):
  769. type_map[t.id] = t.type_name or ""
  770. return type_map
  771. def _load_unit_name_map(db: Session, samples: List[BizProtocolSample]) -> Dict[str, str]:
  772. unit_ids = list({s.unit_id for s in samples if s.unit_id})
  773. if not unit_ids:
  774. return {}
  775. unit_map: Dict[str, str] = {}
  776. for u in (
  777. db.query(BizItemUnit)
  778. .filter(BizItemUnit.id.in_(unit_ids), BizItemUnit.del_flag == 0)
  779. .all()
  780. ):
  781. unit_map[u.id] = u.unit_name or ""
  782. return unit_map
  783. def _load_site_name_map(db: Session, site_ids: List[str]) -> Dict[str, str]:
  784. ids = list({sid for sid in site_ids if sid})
  785. if not ids:
  786. return {}
  787. site_map: Dict[str, str] = {}
  788. for site in (
  789. db.query(SysSite)
  790. .filter(SysSite.id.in_(ids), SysSite.del_flag == 0)
  791. .all()
  792. ):
  793. site_map[site.id] = site.site_name or ""
  794. return site_map
  795. def _load_express_name_map(db: Session, express_ids: List[str]) -> Dict[str, str]:
  796. ids = list({eid for eid in express_ids if eid})
  797. if not ids:
  798. return {}
  799. express_map: Dict[str, str] = {}
  800. for row in (
  801. db.query(BizExpressCompany)
  802. .filter(BizExpressCompany.id.in_(ids), BizExpressCompany.del_flag == 0)
  803. .all()
  804. ):
  805. express_map[row.id] = row.express_name or ""
  806. return express_map
  807. def list_storage_certs(
  808. db: Session,
  809. *,
  810. tenant_no: str,
  811. keyword: Optional[str] = None,
  812. make_status: Optional[str] = None,
  813. storage_in_date_start: Optional[str] = None,
  814. storage_in_date_end: Optional[str] = None,
  815. page: int = 1,
  816. page_size: int = 10,
  817. user_info=None,
  818. ) -> Tuple[List[Dict[str, Any]], int]:
  819. q = db.query(BizCollectionProtocol).filter(
  820. BizCollectionProtocol.del_flag == 0,
  821. BizCollectionProtocol.tenant_no == tenant_no,
  822. BizCollectionProtocol.protocol_status == "sxz",
  823. )
  824. kw = (keyword or "").strip()
  825. if kw:
  826. like = f"%{kw}%"
  827. party_ids = (
  828. db.query(BizProtocolPartyCustomer.collection_protocol_id)
  829. .filter(
  830. BizProtocolPartyCustomer.del_flag == 0,
  831. BizProtocolPartyCustomer.tenant_no == tenant_no,
  832. BizProtocolPartyCustomer.mother_name.like(like),
  833. )
  834. )
  835. q = q.filter(BizCollectionProtocol.id.in_(party_ids))
  836. if user_info:
  837. from app.core.data_scope import apply_data_scope
  838. q = apply_data_scope(q, BizCollectionProtocol, user_info, db, tenant_no)
  839. rows_all = q.order_by(BizCollectionProtocol.create_time.desc()).all()
  840. protocol_ids = [r.id for r in rows_all]
  841. pc_map: Dict[str, BizProtocolPartyCustomer] = {}
  842. sc_map: Dict[str, BizProtocolStorageCert] = {}
  843. sample_map: Dict[str, List[Dict[str, Any]]] = {}
  844. type_map: Dict[str, str] = {}
  845. storage_in_date_map: Dict[str, str] = {}
  846. if protocol_ids:
  847. for pc in (
  848. db.query(BizProtocolPartyCustomer)
  849. .filter(
  850. BizProtocolPartyCustomer.collection_protocol_id.in_(protocol_ids),
  851. BizProtocolPartyCustomer.del_flag == 0,
  852. )
  853. .all()
  854. ):
  855. pc_map[pc.collection_protocol_id] = pc
  856. for sc in (
  857. db.query(BizProtocolStorageCert)
  858. .filter(
  859. BizProtocolStorageCert.collection_protocol_id.in_(protocol_ids),
  860. BizProtocolStorageCert.del_flag == 0,
  861. )
  862. .all()
  863. ):
  864. sc_map[sc.collection_protocol_id] = sc
  865. s_rows = (
  866. db.query(BizProtocolSample)
  867. .filter(
  868. BizProtocolSample.collection_protocol_id.in_(protocol_ids),
  869. BizProtocolSample.del_flag == 0,
  870. )
  871. .all()
  872. )
  873. type_map = _load_sample_type_name_map(db, s_rows)
  874. for s in s_rows:
  875. sample_map.setdefault(s.collection_protocol_id, []).append(_sample_to_dict(s))
  876. storage_in_date_map = _load_protocol_storage_in_date_map(db, protocol_ids, tenant_no)
  877. items: List[Dict[str, Any]] = []
  878. ms_filter = (make_status or "").strip()
  879. start_in = _yyyymmdd_from_date_param(storage_in_date_start) if storage_in_date_start else ""
  880. end_in = _yyyymmdd_from_date_param(storage_in_date_end) if storage_in_date_end else ""
  881. for r in rows_all:
  882. sc = sc_map.get(r.id)
  883. cert_ms = (sc.make_status or "0") if sc else "0"
  884. if ms_filter and cert_ms != ms_filter:
  885. continue
  886. pc = pc_map.get(r.id)
  887. samples = sample_map.get(r.id, [])
  888. storage_in = _pick_storage_in_date(samples) or storage_in_date_map.get(r.id, "")
  889. if start_in or end_in:
  890. sid = _storage_in_date_to_yyyymmdd(storage_in)
  891. if not sid:
  892. continue
  893. if start_in and sid < start_in:
  894. continue
  895. if end_in and sid > end_in:
  896. continue
  897. cert_dict = _storage_cert_to_dict(sc) if sc else {"makeStatus": "0", "certImageUrls": "", "makeDate": "", "remark": ""}
  898. items.append(
  899. {
  900. "id": r.id,
  901. "protocolId": r.id,
  902. "protocolNo": r.protocol_no or "",
  903. "protocolStatus": r.protocol_status or "",
  904. "motherName": (pc.mother_name or "") if pc else "",
  905. "motherPhone": (pc.mother_phone or "") if pc else "",
  906. "motherIdCard": (pc.mother_id_card or "") if pc else "",
  907. "newbornName": (pc.newborn_name or "") if pc else "",
  908. "newbornIdCard": (pc.newborn_id_card or "") if pc else "",
  909. "newbornDeliveryTime": _fmt_dt(pc.newborn_delivery_time) if pc and pc.newborn_delivery_time else "",
  910. "newbornSex": (pc.newborn_sex or "") if pc else "",
  911. "newbornWeightGram": pc.newborn_weight_gram if pc else None,
  912. "newbornInfo": _format_newborn_info(pc),
  913. "sampleInfo": _format_storage_cert_sample_info(samples, type_map),
  914. "storageInDate": storage_in,
  915. "samples": samples,
  916. "makeStatus": cert_dict.get("makeStatus", "0"),
  917. "certImageUrls": cert_dict.get("certImageUrls", ""),
  918. "makeDate": cert_dict.get("makeDate", ""),
  919. "remark": cert_dict.get("remark", ""),
  920. }
  921. )
  922. total = len(items)
  923. page_safe = max(1, page)
  924. page_size_safe = min(max(1, page_size), 100)
  925. start = (page_safe - 1) * page_size_safe
  926. return items[start : start + page_size_safe], total
  927. def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str:
  928. val = (value or "").strip()
  929. if len(val) == 10:
  930. return f"{val} 23:59:59" if end_of_day else f"{val} 00:00:00"
  931. return val
  932. def _yyyymmdd_from_date_param(value: str) -> str:
  933. val = (value or "").strip()
  934. if len(val) == 10 and val[4] == "-" and val[7] == "-":
  935. return val.replace("-", "")
  936. digits = "".join(c for c in val if c.isdigit())
  937. return digits[:8] if len(digits) >= 8 else digits
  938. def _storage_in_date_to_yyyymmdd(value: str) -> str:
  939. digits = "".join(c for c in (value or "").strip() if c.isdigit())
  940. return digits[:8] if len(digits) >= 8 else ""
  941. def list_collection_protocols(
  942. db: Session,
  943. *,
  944. page: int = 1,
  945. page_size: int = 10,
  946. keyword: Optional[str] = None,
  947. protocol_status: Optional[str] = None,
  948. create_time_start: Optional[str] = None,
  949. create_time_end: Optional[str] = None,
  950. sample_collect_time_start: Optional[str] = None,
  951. sample_collect_time_end: Optional[str] = None,
  952. tenant_no: Optional[str] = None,
  953. user_info=None,
  954. ) -> Tuple[List[Dict[str, Any]], int]:
  955. def _filtered():
  956. q0 = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.del_flag == 0)
  957. if tenant_no:
  958. q0 = q0.filter(BizCollectionProtocol.tenant_no == tenant_no)
  959. if protocol_status:
  960. q0 = q0.filter(BizCollectionProtocol.protocol_status == protocol_status.strip())
  961. if create_time_start:
  962. q0 = q0.filter(
  963. BizCollectionProtocol.create_time >= _normalize_date_bound(create_time_start)
  964. )
  965. if create_time_end:
  966. q0 = q0.filter(
  967. BizCollectionProtocol.create_time
  968. <= _normalize_date_bound(create_time_end, end_of_day=True)
  969. )
  970. if sample_collect_time_start or sample_collect_time_end:
  971. ci_q = db.query(BizProtocolCollectionInfo.collection_protocol_id).filter(
  972. BizProtocolCollectionInfo.del_flag == 0,
  973. )
  974. if tenant_no:
  975. ci_q = ci_q.filter(BizProtocolCollectionInfo.tenant_no == tenant_no)
  976. if sample_collect_time_start:
  977. ci_q = ci_q.filter(
  978. BizProtocolCollectionInfo.sample_collect_time
  979. >= _normalize_date_bound(sample_collect_time_start)
  980. )
  981. if sample_collect_time_end:
  982. ci_q = ci_q.filter(
  983. BizProtocolCollectionInfo.sample_collect_time
  984. <= _normalize_date_bound(sample_collect_time_end, end_of_day=True)
  985. )
  986. q0 = q0.filter(BizCollectionProtocol.id.in_(ci_q))
  987. kw = (keyword or "").strip()
  988. if kw:
  989. like = f"%{kw}%"
  990. party_q = db.query(BizProtocolPartyCustomer.collection_protocol_id).filter(
  991. BizProtocolPartyCustomer.del_flag == 0,
  992. or_(
  993. BizProtocolPartyCustomer.mother_name.like(like),
  994. BizProtocolPartyCustomer.mother_id_card.like(like),
  995. BizProtocolPartyCustomer.mother_phone.like(like),
  996. ),
  997. )
  998. if tenant_no:
  999. party_q = party_q.filter(BizProtocolPartyCustomer.tenant_no == tenant_no)
  1000. kw_conds = [
  1001. BizCollectionProtocol.id.in_(party_q),
  1002. ]
  1003. if tenant_no:
  1004. from app.crud import sample_reception as reception_svc
  1005. proto_ids = reception_svc.protocol_ids_matching_lab_storage_keyword(
  1006. db, tenant_no, kw
  1007. )
  1008. if proto_ids:
  1009. kw_conds.append(BizCollectionProtocol.id.in_(proto_ids))
  1010. q0 = q0.filter(or_(*kw_conds))
  1011. if user_info:
  1012. from app.core.data_scope import apply_data_scope
  1013. q0 = apply_data_scope(q0, BizCollectionProtocol, user_info, db, tenant_no)
  1014. return q0
  1015. total = _filtered().order_by(None).with_entities(func.count(BizCollectionProtocol.id)).scalar() or 0
  1016. page = max(1, page)
  1017. page_size = min(max(1, page_size), 100)
  1018. rows = (
  1019. _filtered()
  1020. .order_by(BizCollectionProtocol.create_time.desc())
  1021. .offset((page - 1) * page_size)
  1022. .limit(page_size)
  1023. .all()
  1024. )
  1025. # 批量加载子表数据,避免 N+1 查询
  1026. protocol_ids = [r.id for r in rows]
  1027. pc_map: Dict[str, Dict[str, Any]] = {}
  1028. ci_map: Dict[str, Dict[str, Any]] = {}
  1029. if protocol_ids:
  1030. pc_rows = (
  1031. db.query(BizProtocolPartyCustomer)
  1032. .filter(
  1033. BizProtocolPartyCustomer.collection_protocol_id.in_(protocol_ids),
  1034. BizProtocolPartyCustomer.del_flag == 0,
  1035. )
  1036. .all()
  1037. )
  1038. for pc in pc_rows:
  1039. pc_map[pc.collection_protocol_id] = _party_customer_to_dict(pc)
  1040. ci_rows = (
  1041. db.query(BizProtocolCollectionInfo)
  1042. .filter(
  1043. BizProtocolCollectionInfo.collection_protocol_id.in_(protocol_ids),
  1044. BizProtocolCollectionInfo.del_flag == 0,
  1045. )
  1046. .all()
  1047. )
  1048. for ci in ci_rows:
  1049. ci_map[ci.collection_protocol_id] = _collection_info_to_dict(ci)
  1050. # 批量解析地区/医院名称
  1051. region_ids = list({ci_map.get(pid, {}).get("regionId", "") for pid in protocol_ids} - {""})
  1052. hospital_ids = list({ci_map.get(pid, {}).get("hospitalId", "") for pid in protocol_ids} - {""})
  1053. region_name_map: Dict[str, str] = {}
  1054. hospital_name_map: Dict[str, str] = {}
  1055. if region_ids:
  1056. for r in db.query(BizCollectionRegion.id, BizCollectionRegion.region_name).filter(BizCollectionRegion.id.in_(region_ids)).all():
  1057. region_name_map[r.id] = r.region_name or ""
  1058. if hospital_ids:
  1059. for h in db.query(BizCollectionHospital.id, BizCollectionHospital.hospital_name).filter(BizCollectionHospital.id.in_(hospital_ids)).all():
  1060. hospital_name_map[h.id] = h.hospital_name or ""
  1061. # 批量解析市场专员 / 地区经理名称
  1062. sales_user_ids = list({ci_map.get(pid, {}).get("salesUserId", "") for pid in protocol_ids} - {""})
  1063. region_manager_user_ids = list(
  1064. {ci_map.get(pid, {}).get("regionManagerUserId", "") for pid in protocol_ids} - {""}
  1065. )
  1066. collector_user_ids = list({_protocol_to_dict(r).get("collectorUserId", "") for r in rows} - {""})
  1067. all_user_ids = list(set(sales_user_ids + region_manager_user_ids + collector_user_ids))
  1068. user_name_map: Dict[str, str] = {}
  1069. if all_user_ids:
  1070. for u in db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(all_user_ids)).all():
  1071. user_name_map[u.id] = u.nick_name or ""
  1072. # 批量加载协议样本(列表展示样本类型、样本编码)
  1073. sample_map: Dict[str, List[BizProtocolSample]] = {}
  1074. sample_type_name_map: Dict[str, str] = {}
  1075. if protocol_ids:
  1076. s_rows = (
  1077. db.query(BizProtocolSample)
  1078. .filter(
  1079. BizProtocolSample.collection_protocol_id.in_(protocol_ids),
  1080. BizProtocolSample.del_flag == 0,
  1081. )
  1082. .order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc())
  1083. .all()
  1084. )
  1085. sample_type_name_map = _load_sample_type_name_map(db, s_rows)
  1086. for s in s_rows:
  1087. sample_map.setdefault(s.collection_protocol_id, []).append(s)
  1088. all_sample_codes: List[str] = []
  1089. for samples in sample_map.values():
  1090. for s in samples:
  1091. c = (s.sample_code or "").strip()
  1092. if c:
  1093. all_sample_codes.append(c)
  1094. lab_code_map: Dict[str, str] = {}
  1095. reception_summary_map: Dict[str, Dict[str, str]] = {}
  1096. qc_status_map: Dict[str, Dict[str, str]] = {}
  1097. from app.services.protocol_sample_list_summary import (
  1098. build_sample_item_reception_qc_fields,
  1099. get_list_qc_status_map_by_sample_codes,
  1100. get_list_reception_summary_map_by_sample_codes,
  1101. )
  1102. if tenant_no and all_sample_codes:
  1103. from app.crud import sample_reception as reception_svc
  1104. lab_code_map = reception_svc.get_lab_storage_code_map_by_sample_codes(
  1105. db, tenant_no, all_sample_codes, reception_source="cell_storage"
  1106. )
  1107. reception_summary_map = get_list_reception_summary_map_by_sample_codes(
  1108. db, tenant_no, all_sample_codes, reception_source="cell_storage"
  1109. )
  1110. qc_status_map = get_list_qc_status_map_by_sample_codes(
  1111. db, tenant_no, all_sample_codes, reception_source="cell_storage"
  1112. )
  1113. transport_by_sample_id: Dict[str, BizProtocolSampleTransport] = {}
  1114. if protocol_ids:
  1115. for t in (
  1116. db.query(BizProtocolSampleTransport)
  1117. .filter(
  1118. BizProtocolSampleTransport.collection_protocol_id.in_(protocol_ids),
  1119. BizProtocolSampleTransport.tenant_no == tenant_no,
  1120. BizProtocolSampleTransport.del_flag == 0,
  1121. )
  1122. .all()
  1123. ):
  1124. transport_by_sample_id[t.sample_id] = t
  1125. site_name_map = _load_site_name_map(
  1126. db, [t.dest_site_id for t in transport_by_sample_id.values() if t.dest_site_id]
  1127. )
  1128. express_name_map = _load_express_name_map(
  1129. db,
  1130. [t.express_company_id for t in transport_by_sample_id.values() if t.express_company_id],
  1131. )
  1132. from app.services.qc_work_order_fee_summary import build_protocol_payment_summaries
  1133. payment_summary_map = build_protocol_payment_summaries(
  1134. db, protocol_ids, tenant_no, include_sample_payment_modes=True
  1135. )
  1136. # 合并主表 + 子表数据
  1137. result = []
  1138. for r in rows:
  1139. d = _protocol_to_dict(r)
  1140. pc = pc_map.get(r.id)
  1141. ci = ci_map.get(r.id)
  1142. d["collectorUserName"] = user_name_map.get(d.get("collectorUserId", ""), "")
  1143. if pc:
  1144. d["motherName"] = pc.get("motherName", "")
  1145. d["motherPhone"] = pc.get("motherPhone", "")
  1146. d["motherIdCard"] = pc.get("motherIdCard", "")
  1147. d["motherBirthYm"] = pc.get("motherBirthYm", "")
  1148. d["motherEthnic"] = pc.get("motherEthnic", "")
  1149. d["fatherName"] = pc.get("fatherName", "")
  1150. d["fatherPhone"] = pc.get("fatherPhone", "")
  1151. d["newbornName"] = pc.get("newbornName", "")
  1152. d["expectedDeliveryDate"] = pc.get("expectedDeliveryDate", "")
  1153. d["newbornDeliveryTime"] = pc.get("newbornDeliveryTime", "")
  1154. d["newbornSex"] = pc.get("newbornSex", "")
  1155. d["deliveryMode"] = pc.get("deliveryMode", "")
  1156. d["birthOrder"] = pc.get("birthOrder")
  1157. d["newbornWeightGram"] = pc.get("newbornWeightGram")
  1158. else:
  1159. d["motherName"] = ""
  1160. d["motherPhone"] = ""
  1161. d["motherIdCard"] = ""
  1162. d["motherBirthYm"] = ""
  1163. d["motherEthnic"] = ""
  1164. d["fatherName"] = ""
  1165. d["fatherPhone"] = ""
  1166. d["newbornName"] = ""
  1167. d["expectedDeliveryDate"] = ""
  1168. d["newbornDeliveryTime"] = ""
  1169. d["newbornSex"] = ""
  1170. d["deliveryMode"] = ""
  1171. d["birthOrder"] = None
  1172. d["newbornWeightGram"] = None
  1173. if ci:
  1174. d["regionName"] = region_name_map.get(ci.get("regionId", ""), "")
  1175. d["hospitalName"] = hospital_name_map.get(ci.get("hospitalId", ""), "")
  1176. d["salesUserName"] = user_name_map.get(ci.get("salesUserId", ""), "")
  1177. d["regionManagerUserName"] = user_name_map.get(ci.get("regionManagerUserId", ""), "")
  1178. d["sampleCollectTime"] = ci.get("sampleCollectTime", "")
  1179. else:
  1180. d["regionName"] = ""
  1181. d["hospitalName"] = ""
  1182. d["salesUserName"] = ""
  1183. d["regionManagerUserName"] = ""
  1184. d["sampleCollectTime"] = ""
  1185. protocol_samples = sample_map.get(r.id, [])
  1186. type_names: List[str] = []
  1187. codes: List[str] = []
  1188. sample_items: List[Dict[str, Any]] = []
  1189. for s in protocol_samples:
  1190. type_name = sample_type_name_map.get(s.sample_type_id or "", "")
  1191. code = (s.sample_code or "").strip()
  1192. if type_name:
  1193. type_names.append(type_name)
  1194. if code:
  1195. codes.append(code)
  1196. transport_obj = transport_by_sample_id.get(s.id)
  1197. transport_dict: Optional[Dict[str, Any]] = None
  1198. if transport_obj and (
  1199. (transport_obj.transport_type or "").strip() or transport_obj.eta_time
  1200. ):
  1201. dest_site_id = transport_obj.dest_site_id or ""
  1202. express_company_id = transport_obj.express_company_id or ""
  1203. transport_dict = {
  1204. "transportType": transport_obj.transport_type or "",
  1205. "destSiteId": dest_site_id,
  1206. "destSiteName": site_name_map.get(dest_site_id, ""),
  1207. "expressCompanyId": express_company_id,
  1208. "expressCompanyName": express_name_map.get(express_company_id, ""),
  1209. "waybillNo": transport_obj.waybill_no or "",
  1210. "carrierName": transport_obj.carrier_name or "",
  1211. "carrierPhone": transport_obj.carrier_phone or "",
  1212. "etaTime": _fmt_dt(transport_obj.eta_time),
  1213. }
  1214. sample_items.append(
  1215. {
  1216. "sampleTypeName": type_name,
  1217. "sampleCode": code,
  1218. "sampleStatus": (s.sample_status or "").strip(),
  1219. "labStorageCode": lab_code_map.get(code, "") if code else "",
  1220. "transport": transport_dict,
  1221. **sample_special_to_dict(s),
  1222. **build_sample_item_reception_qc_fields(
  1223. reception_summary_map.get(code) if code else None,
  1224. qc_status_map.get(code) if code else None,
  1225. ),
  1226. }
  1227. )
  1228. d["sampleItems"] = sample_items
  1229. d["sampleTypeNames"] = ",".join(type_names)
  1230. d["sampleCodes"] = ",".join(codes)
  1231. d["labStorageCodes"] = ",".join((lab_code_map.get(c) or "") for c in codes)
  1232. d["sampleCollectSummary"] = _format_sample_collect_summary(protocol_samples)
  1233. d["paymentSummary"] = payment_summary_map.get(r.id)
  1234. result.append(d)
  1235. return result, int(total)
  1236. def get_collection_protocol(
  1237. db: Session,
  1238. protocol_id: str,
  1239. tenant_no: str,
  1240. *,
  1241. scope_collector_user_id: Optional[str] = None,
  1242. ) -> Optional[BizCollectionProtocol]:
  1243. filters = [
  1244. BizCollectionProtocol.id == protocol_id,
  1245. BizCollectionProtocol.del_flag == 0,
  1246. BizCollectionProtocol.tenant_no == tenant_no,
  1247. ]
  1248. if scope_collector_user_id:
  1249. filters.append(
  1250. BizCollectionProtocol.collector_user_id == scope_collector_user_id
  1251. )
  1252. return db.query(BizCollectionProtocol).filter(and_(*filters)).first()
  1253. def get_payment_by_protocol_id(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolPayment]:
  1254. return (
  1255. db.query(BizProtocolPayment)
  1256. .filter(
  1257. and_(
  1258. BizProtocolPayment.collection_protocol_id == collection_protocol_id,
  1259. BizProtocolPayment.tenant_no == tenant_no,
  1260. BizProtocolPayment.del_flag == 0,
  1261. )
  1262. )
  1263. .first()
  1264. )
  1265. def get_protocol_detail_bundle(
  1266. db: Session,
  1267. protocol_id: str,
  1268. tenant_no: str,
  1269. *,
  1270. scope_collector_user_id: Optional[str] = None,
  1271. ) -> Optional[Dict[str, Any]]:
  1272. p = get_collection_protocol(
  1273. db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id
  1274. )
  1275. if not p:
  1276. return None
  1277. pay = get_payment_by_protocol_id(db, protocol_id, tenant_no)
  1278. pc = get_party_customer(db, protocol_id, tenant_no)
  1279. ci = get_collection_info(db, protocol_id, tenant_no)
  1280. s_rows = list_samples(db, protocol_id, tenant_no)
  1281. sc = get_storage_cert(db, protocol_id, tenant_no)
  1282. protocol_dict = _protocol_to_dict(p)
  1283. ci_dict = _collection_info_to_dict(ci) if ci else None
  1284. user_ids: List[str] = []
  1285. if protocol_dict.get("collectorUserId"):
  1286. user_ids.append(str(protocol_dict["collectorUserId"]))
  1287. if ci_dict and ci_dict.get("salesUserId"):
  1288. user_ids.append(str(ci_dict["salesUserId"]))
  1289. if ci_dict and ci_dict.get("regionManagerUserId"):
  1290. user_ids.append(str(ci_dict["regionManagerUserId"]))
  1291. user_name_map = _user_nick_name_map(db, user_ids)
  1292. protocol_dict["collectorUserName"] = user_name_map.get(
  1293. str(protocol_dict.get("collectorUserId") or ""), ""
  1294. )
  1295. if ci_dict:
  1296. ci_dict["salesUserName"] = user_name_map.get(str(ci_dict.get("salesUserId") or ""), "")
  1297. ci_dict["regionManagerUserName"] = user_name_map.get(
  1298. str(ci_dict.get("regionManagerUserId") or ""), ""
  1299. )
  1300. region_id = str(ci_dict.get("regionId") or "")
  1301. if region_id:
  1302. r = (
  1303. db.query(BizCollectionRegion.region_name)
  1304. .filter(BizCollectionRegion.id == region_id)
  1305. .first()
  1306. )
  1307. ci_dict["regionName"] = r.region_name if r else ""
  1308. hospital_id = str(ci_dict.get("hospitalId") or "")
  1309. if hospital_id:
  1310. h = (
  1311. db.query(BizCollectionHospital.hospital_name)
  1312. .filter(BizCollectionHospital.id == hospital_id)
  1313. .first()
  1314. )
  1315. ci_dict["hospitalName"] = h.hospital_name if h else ""
  1316. out: Dict[str, Any] = {"protocol": protocol_dict}
  1317. out["payment"] = _payment_to_dict(pay) if pay else None
  1318. out["partyCustomer"] = _party_customer_to_dict(pc) if pc else None
  1319. out["collectionInfo"] = ci_dict
  1320. transport_rows = list_sample_transports(db, protocol_id, tenant_no)
  1321. site_name_map = _load_site_name_map(
  1322. db, [t.dest_site_id for t in transport_rows if t.dest_site_id]
  1323. )
  1324. transport_by_sample: Dict[str, Dict[str, Any]] = {}
  1325. for t in transport_rows:
  1326. td = _transport_to_dict(t)
  1327. td["destSiteName"] = site_name_map.get(t.dest_site_id or "", "")
  1328. transport_by_sample[t.sample_id] = td
  1329. from app.crud import sample_reception as sample_reception_svc
  1330. sample_code_by_id = {s.id: s.sample_code or "" for s in s_rows}
  1331. reception_by_sample = sample_reception_svc.get_receptions_map_for_samples(
  1332. db,
  1333. tenant_no,
  1334. sample_code_by_id=sample_code_by_id,
  1335. )
  1336. from app.crud import qc_work_order as qc_work_order_svc
  1337. from app.crud import production_work_order as production_work_order_svc
  1338. from app.crud import storage_release_review as storage_release_review_svc
  1339. qc_by_sample = qc_work_order_svc.get_qc_info_map_for_samples(
  1340. db,
  1341. [s.id for s in s_rows],
  1342. tenant_no,
  1343. sample_code_by_id=sample_code_by_id,
  1344. )
  1345. process_qc_by_sample = qc_work_order_svc.get_process_qc_info_map_for_samples(
  1346. db,
  1347. [s.id for s in s_rows],
  1348. tenant_no,
  1349. sample_code_by_id=sample_code_by_id,
  1350. )
  1351. production_by_sample = production_work_order_svc.get_production_info_map_for_samples(
  1352. db,
  1353. [s.id for s in s_rows],
  1354. tenant_no,
  1355. sample_code_by_id=sample_code_by_id,
  1356. )
  1357. storage_release_by_sample = storage_release_review_svc.get_storage_release_map_for_samples(
  1358. db,
  1359. [s.id for s in s_rows],
  1360. tenant_no,
  1361. sample_code_by_id=sample_code_by_id,
  1362. )
  1363. unit_name_map = _load_unit_name_map(db, s_rows)
  1364. type_name_map = _load_sample_type_name_map(db, s_rows)
  1365. from app.crud import sample_freight as sample_freight_svc
  1366. freight_by_sample = sample_freight_svc.freight_map_for_samples(
  1367. db,
  1368. tenant_no=tenant_no,
  1369. sample_source=sample_freight_svc.SAMPLE_SOURCE_CELL,
  1370. sample_ids=[s.id for s in s_rows],
  1371. )
  1372. samples_out: List[Dict[str, Any]] = []
  1373. for s in s_rows:
  1374. item = _sample_to_dict(s)
  1375. item["sampleTypeName"] = type_name_map.get(s.sample_type_id or "", "")
  1376. item["unitName"] = unit_name_map.get(s.unit_id or "", "")
  1377. item["transport"] = transport_by_sample.get(s.id)
  1378. item["freight"] = freight_by_sample.get(s.id)
  1379. item["sampleReception"] = reception_by_sample.get(s.id)
  1380. item["productionReception"] = (reception_by_sample.get(s.id) or {}).get(
  1381. "productionReception"
  1382. )
  1383. item["qcInfo"] = qc_by_sample.get(s.id)
  1384. item["processQcInfoList"] = process_qc_by_sample.get(s.id, [])
  1385. item["productionInfo"] = production_by_sample.get(s.id)
  1386. item["storageReleaseInfo"] = storage_release_by_sample.get(s.id)
  1387. samples_out.append(item)
  1388. out["samples"] = samples_out
  1389. out["storageCert"] = _storage_cert_to_dict(sc) if sc else None
  1390. if out["storageCert"]:
  1391. hid = (out["storageCert"].get("handlerUserId") or "").strip()
  1392. if hid:
  1393. hu = db.query(SysUser).filter(SysUser.id == hid).first()
  1394. out["storageCert"]["handlerUserName"] = (hu.nick_name or "") if hu else ""
  1395. else:
  1396. out["storageCert"]["handlerUserName"] = ""
  1397. ft = get_fullmoon_todo(db, protocol_id, tenant_no)
  1398. delivery_dt = pc.newborn_delivery_time if pc else None
  1399. out["fullmoonTodo"] = _fullmoon_todo_detail_dict(ft, newborn_delivery_time=delivery_dt)
  1400. hid2 = (out["fullmoonTodo"].get("handlerUserId") or "").strip()
  1401. if hid2:
  1402. hu2 = db.query(SysUser).filter(SysUser.id == hid2).first()
  1403. out["fullmoonTodo"]["handlerUserName"] = (hu2.nick_name or "") if hu2 else ""
  1404. else:
  1405. out["fullmoonTodo"]["handlerUserName"] = ""
  1406. out["attachments"] = urls_to_attachment_list(p.protocol_attachments, db, tenant_no)
  1407. out["consentAttachmentFiles"] = urls_to_attachment_list(p.consent_form_urls, db, tenant_no)
  1408. out["healthSurveyAttachmentFiles"] = urls_to_attachment_list(
  1409. p.health_survey_form_urls, db, tenant_no
  1410. )
  1411. if pay:
  1412. detail_rows = list_payment_details(db, pay.id, tenant_no)
  1413. details_out: List[Dict[str, Any]] = []
  1414. for d in detail_rows:
  1415. item = _detail_to_dict(
  1416. d,
  1417. protocol_no=p.protocol_no or "",
  1418. collection_protocol_id=protocol_id,
  1419. )
  1420. item["attachments"] = urls_to_attachment_list(d.transfer_attachments, db, tenant_no)
  1421. item["financeConfirmAttachmentFiles"] = urls_to_attachment_list(
  1422. d.finance_confirm_attachments, db, tenant_no
  1423. )
  1424. item["refundFinanceConfirmAttachmentFiles"] = urls_to_attachment_list(
  1425. d.refund_finance_confirm_attachments, db, tenant_no
  1426. )
  1427. item["refundAttachmentFiles"] = urls_to_attachment_list(
  1428. d.refund_attachments, db, tenant_no
  1429. )
  1430. details_out.append(item)
  1431. out["paymentDetails"] = details_out
  1432. else:
  1433. out["paymentDetails"] = []
  1434. from app.crud import protocol_sample_payment as sp_pay_svc
  1435. from app.crud import protocol_refund_record as rr_svc
  1436. version = int(getattr(p, "payment_model_version", None) or 1)
  1437. if version >= 2:
  1438. out["samplePayments"] = sp_pay_svc.get_sample_payments_bundle(
  1439. db, protocol_id, tenant_no
  1440. )
  1441. else:
  1442. out["samplePayments"] = []
  1443. out["refundRecords"] = rr_svc.list_refund_records(
  1444. db, collection_protocol_id=protocol_id, tenant_no=tenant_no
  1445. )
  1446. source_presign_id = getattr(p, "source_presign_order_id", None)
  1447. # 意向金与预签共用 biz_presign_transfer,仅通过 source_presign_order_id 关联
  1448. out["depositTransfers"] = sp_pay_svc.list_deposit_transfers_for_protocol(
  1449. db, source_presign_order_id=source_presign_id, tenant_no=tenant_no
  1450. )
  1451. out["presignDepositHint"] = sp_pay_svc.get_presign_deposit_hint(
  1452. db,
  1453. source_presign_order_id=source_presign_id,
  1454. tenant_no=tenant_no,
  1455. )
  1456. return out
  1457. def list_audits(
  1458. db: Session,
  1459. *,
  1460. page: int = 1,
  1461. page_size: int = 10,
  1462. keyword: Optional[str] = None,
  1463. audit_status: Optional[str] = None,
  1464. collection_protocol_id: Optional[str] = None,
  1465. tenant_no: Optional[str] = None,
  1466. user_info=None,
  1467. ) -> Tuple[List[Dict[str, Any]], int]:
  1468. q_base = (
  1469. db.query(BizProtocolAudit, BizCollectionProtocol.protocol_no)
  1470. .join(
  1471. BizCollectionProtocol,
  1472. and_(
  1473. BizProtocolAudit.collection_protocol_id == BizCollectionProtocol.id,
  1474. BizCollectionProtocol.del_flag == 0,
  1475. ),
  1476. )
  1477. .filter(BizProtocolAudit.del_flag == 0)
  1478. )
  1479. if tenant_no:
  1480. q_base = q_base.filter(BizProtocolAudit.tenant_no == tenant_no)
  1481. if audit_status:
  1482. q_base = q_base.filter(BizProtocolAudit.audit_status == audit_status.strip())
  1483. pid = (collection_protocol_id or "").strip()
  1484. if pid:
  1485. q_base = q_base.filter(BizProtocolAudit.collection_protocol_id == pid)
  1486. kw = (keyword or "").strip()
  1487. if kw:
  1488. like = f"%{kw}%"
  1489. q_base = q_base.filter(
  1490. or_(
  1491. BizCollectionProtocol.protocol_no.like(like),
  1492. BizProtocolAudit.audit_remark.like(like),
  1493. )
  1494. )
  1495. if user_info:
  1496. from app.core.data_scope import apply_data_scope
  1497. q_base = apply_data_scope(q_base, BizProtocolAudit, user_info, db, tenant_no)
  1498. total = q_base.order_by(None).with_entities(func.count(BizProtocolAudit.id)).scalar() or 0
  1499. page = max(1, page)
  1500. page_size = min(max(1, page_size), 100)
  1501. rows = (
  1502. q_base.order_by(
  1503. BizProtocolAudit.order_num.asc(),
  1504. BizProtocolAudit.create_time.desc(),
  1505. )
  1506. .offset((page - 1) * page_size)
  1507. .limit(page_size)
  1508. .all()
  1509. )
  1510. auditor_ids = list({a.auditor_user_id for a, _ in rows if a.auditor_user_id})
  1511. auditor_name_map: Dict[str, str] = {}
  1512. if auditor_ids:
  1513. for u in db.query(SysUser.id, SysUser.nick_name).filter(SysUser.id.in_(auditor_ids)).all():
  1514. auditor_name_map[u.id] = u.nick_name or ""
  1515. out = [
  1516. _audit_to_dict(
  1517. a,
  1518. protocol_no=pn or "",
  1519. auditor_user_name=auditor_name_map.get(a.auditor_user_id or "", ""),
  1520. )
  1521. for a, pn in rows
  1522. ]
  1523. return out, int(total)
  1524. def create_audit(
  1525. db: Session,
  1526. *,
  1527. collection_protocol_id: str,
  1528. audit_status: str,
  1529. audit_remark: Optional[str],
  1530. tenant_no: str,
  1531. user_id: Optional[str],
  1532. ) -> BizProtocolAudit:
  1533. p = get_collection_protocol(db, collection_protocol_id, tenant_no)
  1534. if not p:
  1535. raise ValueError("协议单不存在")
  1536. st = (audit_status or "").strip()
  1537. if st not in ("shtg", "shth"):
  1538. raise ValueError("审核状态无效")
  1539. obj = BizProtocolAudit(
  1540. id=generate_uuid(),
  1541. collection_protocol_id=collection_protocol_id,
  1542. auditor_user_id=user_id,
  1543. audit_remark=(audit_remark or "").strip() or None,
  1544. audit_status=st,
  1545. tenant_no=tenant_no,
  1546. order_num=0,
  1547. remark=None,
  1548. create_by=user_id,
  1549. create_dept_id="",
  1550. create_time=datetime.now(),
  1551. update_by=user_id,
  1552. update_time=datetime.now(),
  1553. del_flag=0,
  1554. )
  1555. db.add(obj)
  1556. db.commit()
  1557. db.refresh(obj)
  1558. return obj
  1559. def _detail_query_with_protocol(
  1560. db: Session,
  1561. tenant_no: str,
  1562. pay_status_filter: str,
  1563. ):
  1564. return (
  1565. db.query(BizProtocolPaymentDetail, BizProtocolPayment, BizCollectionProtocol)
  1566. .join(BizProtocolPayment, BizProtocolPaymentDetail.payment_id == BizProtocolPayment.id)
  1567. .join(BizCollectionProtocol, BizProtocolPayment.collection_protocol_id == BizCollectionProtocol.id)
  1568. .filter(
  1569. BizProtocolPaymentDetail.del_flag == 0,
  1570. BizProtocolPayment.del_flag == 0,
  1571. BizCollectionProtocol.del_flag == 0,
  1572. BizProtocolPaymentDetail.tenant_no == tenant_no,
  1573. BizProtocolPaymentDetail.pay_status == pay_status_filter,
  1574. )
  1575. )
  1576. def _apply_payment_detail_list_filters(
  1577. q,
  1578. db: Session,
  1579. *,
  1580. tenant_no: str,
  1581. keyword: Optional[str] = None,
  1582. installment_due_date_start: Optional[str] = None,
  1583. installment_due_date_end: Optional[str] = None,
  1584. ):
  1585. kw = (keyword or "").strip()
  1586. if kw:
  1587. like = f"%{kw}%"
  1588. party_q = db.query(BizProtocolPartyCustomer.collection_protocol_id).filter(
  1589. BizProtocolPartyCustomer.del_flag == 0,
  1590. or_(
  1591. BizProtocolPartyCustomer.mother_name.like(like),
  1592. BizProtocolPartyCustomer.mother_id_card.like(like),
  1593. BizProtocolPartyCustomer.newborn_name.like(like),
  1594. BizProtocolPartyCustomer.newborn_id_card.like(like),
  1595. ),
  1596. )
  1597. if tenant_no:
  1598. party_q = party_q.filter(BizProtocolPartyCustomer.tenant_no == tenant_no)
  1599. q = q.filter(BizCollectionProtocol.id.in_(party_q))
  1600. if installment_due_date_start:
  1601. start_due = _yyyymmdd_from_date_param(installment_due_date_start)
  1602. if start_due:
  1603. q = q.filter(BizProtocolPaymentDetail.installment_due_date >= start_due)
  1604. if installment_due_date_end:
  1605. end_due = _yyyymmdd_from_date_param(installment_due_date_end)
  1606. if end_due:
  1607. q = q.filter(BizProtocolPaymentDetail.installment_due_date <= end_due)
  1608. return q
  1609. def _rows_to_payment_detail_dicts(
  1610. db: Session,
  1611. rows: List[Any],
  1612. tenant_no: str,
  1613. *,
  1614. post_process: Optional[Any] = None,
  1615. ) -> List[Dict[str, Any]]:
  1616. """缴费明细行 → 前端字典(含协议、客户、转账附件)。"""
  1617. protocol_ids = [
  1618. pay.collection_protocol_id for _, pay, _ in rows if pay and pay.collection_protocol_id
  1619. ]
  1620. pc_map = _party_customer_map_by_protocol_ids(db, protocol_ids)
  1621. out: List[Dict[str, Any]] = []
  1622. for d, pay, prot in rows:
  1623. cpid = pay.collection_protocol_id if pay else ""
  1624. pc = pc_map.get(cpid)
  1625. item = _detail_to_dict(
  1626. d,
  1627. protocol_no=(prot.protocol_no if prot else "") or "",
  1628. collection_protocol_id=cpid,
  1629. protocol_status=(prot.protocol_status if prot else "") or "",
  1630. pay_mode=(pay.pay_mode if pay else "") or "",
  1631. mother_name=(pc.mother_name if pc else "") or "",
  1632. mother_id_card=(pc.mother_id_card if pc else "") or "",
  1633. newborn_name=(pc.newborn_name if pc else "") or "",
  1634. newborn_id_card=(pc.newborn_id_card if pc else "") or "",
  1635. )
  1636. item["attachments"] = urls_to_attachment_list(d.transfer_attachments, db, tenant_no)
  1637. if post_process:
  1638. post_process(item, d)
  1639. out.append(item)
  1640. return out
  1641. def list_receipt_confirm_details(
  1642. db: Session,
  1643. *,
  1644. page: int = 1,
  1645. page_size: int = 10,
  1646. keyword: Optional[str] = None,
  1647. installment_due_date_start: Optional[str] = None,
  1648. installment_due_date_end: Optional[str] = None,
  1649. tenant_no: Optional[str] = None,
  1650. user_info=None,
  1651. ) -> Tuple[List[Dict[str, Any]], int]:
  1652. if not tenant_no:
  1653. return [], 0
  1654. q_base = _detail_query_with_protocol(db, tenant_no, "jfqrz")
  1655. q_base = _apply_payment_detail_list_filters(
  1656. q_base,
  1657. db,
  1658. tenant_no=tenant_no,
  1659. keyword=keyword,
  1660. installment_due_date_start=installment_due_date_start,
  1661. installment_due_date_end=installment_due_date_end,
  1662. )
  1663. if user_info:
  1664. from app.core.data_scope import apply_data_scope
  1665. q_base = apply_data_scope(q_base, BizProtocolPaymentDetail, user_info, db, tenant_no)
  1666. total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0
  1667. page = max(1, page)
  1668. page_size = min(max(1, page_size), 100)
  1669. rows = (
  1670. q_base.order_by(
  1671. BizProtocolPaymentDetail.order_num.asc(),
  1672. BizProtocolPaymentDetail.create_time.desc(),
  1673. )
  1674. .offset((page - 1) * page_size)
  1675. .limit(page_size)
  1676. .all()
  1677. )
  1678. return _rows_to_payment_detail_dicts(db, rows, tenant_no), int(total)
  1679. def list_receipt_confirmed_details(
  1680. db: Session,
  1681. *,
  1682. page: int = 1,
  1683. page_size: int = 10,
  1684. keyword: Optional[str] = None,
  1685. installment_due_date_start: Optional[str] = None,
  1686. installment_due_date_end: Optional[str] = None,
  1687. tenant_no: Optional[str] = None,
  1688. user_info=None,
  1689. ) -> Tuple[List[Dict[str, Any]], int]:
  1690. if not tenant_no:
  1691. return [], 0
  1692. q_base = _detail_query_with_protocol(db, tenant_no, "yjf")
  1693. q_base = _apply_payment_detail_list_filters(
  1694. q_base,
  1695. db,
  1696. tenant_no=tenant_no,
  1697. keyword=keyword,
  1698. installment_due_date_start=installment_due_date_start,
  1699. installment_due_date_end=installment_due_date_end,
  1700. )
  1701. if user_info:
  1702. from app.core.data_scope import apply_data_scope
  1703. q_base = apply_data_scope(q_base, BizProtocolPaymentDetail, user_info, db, tenant_no)
  1704. total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0
  1705. page = max(1, page)
  1706. page_size = min(max(1, page_size), 100)
  1707. rows = (
  1708. q_base.order_by(BizProtocolPaymentDetail.update_time.desc())
  1709. .offset((page - 1) * page_size)
  1710. .limit(page_size)
  1711. .all()
  1712. )
  1713. def _add_finance_files(item: Dict[str, Any], d: BizProtocolPaymentDetail) -> None:
  1714. item["financeConfirmAttachmentFiles"] = urls_to_attachment_list(
  1715. d.finance_confirm_attachments, db, tenant_no
  1716. )
  1717. return (
  1718. _rows_to_payment_detail_dicts(db, rows, tenant_no, post_process=_add_finance_files),
  1719. int(total),
  1720. )
  1721. def confirm_receipt(
  1722. db: Session,
  1723. *,
  1724. detail_id: str,
  1725. tenant_no: str,
  1726. user_id: Optional[str],
  1727. finance_confirm_attachments: Optional[str] = None,
  1728. finance_remark: Optional[str] = None,
  1729. ) -> BizProtocolPaymentDetail:
  1730. d = (
  1731. db.query(BizProtocolPaymentDetail)
  1732. .filter(
  1733. and_(
  1734. BizProtocolPaymentDetail.id == detail_id,
  1735. BizProtocolPaymentDetail.tenant_no == tenant_no,
  1736. BizProtocolPaymentDetail.del_flag == 0,
  1737. )
  1738. )
  1739. .first()
  1740. )
  1741. if not d:
  1742. raise ValueError("缴费明细不存在")
  1743. if d.pay_status != "jfqrz":
  1744. if d.pay_status == "yjf":
  1745. raise ValueError("该明细已确认收款")
  1746. raise ValueError("当前状态不可确认收款")
  1747. d.pay_status = "yjf"
  1748. if finance_confirm_attachments is not None:
  1749. d.finance_confirm_attachments = finance_confirm_attachments.strip() or None
  1750. if finance_remark is not None:
  1751. d.finance_remark = finance_remark.strip() or None
  1752. d.update_by = user_id
  1753. d.update_time = datetime.now()
  1754. db.add(d)
  1755. db.commit()
  1756. db.refresh(d)
  1757. return d
  1758. def reject_receipt(
  1759. db: Session,
  1760. *,
  1761. detail_id: str,
  1762. tenant_no: str,
  1763. user_id: Optional[str],
  1764. ) -> BizProtocolPaymentDetail:
  1765. d = (
  1766. db.query(BizProtocolPaymentDetail)
  1767. .filter(
  1768. and_(
  1769. BizProtocolPaymentDetail.id == detail_id,
  1770. BizProtocolPaymentDetail.tenant_no == tenant_no,
  1771. BizProtocolPaymentDetail.del_flag == 0,
  1772. )
  1773. )
  1774. .first()
  1775. )
  1776. if not d:
  1777. raise ValueError("缴费明细不存在")
  1778. if d.pay_status != "jfqrz":
  1779. raise ValueError("当前状态不可退回")
  1780. d.pay_status = "dfj"
  1781. d.update_by = user_id
  1782. d.update_time = datetime.now()
  1783. db.add(d)
  1784. db.commit()
  1785. db.refresh(d)
  1786. return d
  1787. def list_refund_confirm_details(
  1788. db: Session,
  1789. *,
  1790. page: int = 1,
  1791. page_size: int = 10,
  1792. keyword: Optional[str] = None,
  1793. installment_due_date_start: Optional[str] = None,
  1794. installment_due_date_end: Optional[str] = None,
  1795. tenant_no: Optional[str] = None,
  1796. user_info=None,
  1797. ) -> Tuple[List[Dict[str, Any]], int]:
  1798. if not tenant_no:
  1799. return [], 0
  1800. q_base = _detail_query_with_protocol(db, tenant_no, "tfqrz")
  1801. q_base = _apply_payment_detail_list_filters(
  1802. q_base,
  1803. db,
  1804. tenant_no=tenant_no,
  1805. keyword=keyword,
  1806. installment_due_date_start=installment_due_date_start,
  1807. installment_due_date_end=installment_due_date_end,
  1808. )
  1809. if user_info:
  1810. from app.core.data_scope import apply_data_scope
  1811. q_base = apply_data_scope(q_base, BizProtocolPaymentDetail, user_info, db, tenant_no)
  1812. total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0
  1813. page = max(1, page)
  1814. page_size = min(max(1, page_size), 100)
  1815. rows = (
  1816. q_base.order_by(
  1817. BizProtocolPaymentDetail.order_num.asc(),
  1818. BizProtocolPaymentDetail.create_time.desc(),
  1819. )
  1820. .offset((page - 1) * page_size)
  1821. .limit(page_size)
  1822. .all()
  1823. )
  1824. def _add_refund_apply_files(item: Dict[str, Any], d: BizProtocolPaymentDetail) -> None:
  1825. item["refundAttachmentFiles"] = urls_to_attachment_list(
  1826. d.refund_attachments, db, tenant_no
  1827. )
  1828. return (
  1829. _rows_to_payment_detail_dicts(db, rows, tenant_no, post_process=_add_refund_apply_files),
  1830. int(total),
  1831. )
  1832. def list_refund_confirmed_details(
  1833. db: Session,
  1834. *,
  1835. page: int = 1,
  1836. page_size: int = 10,
  1837. keyword: Optional[str] = None,
  1838. installment_due_date_start: Optional[str] = None,
  1839. installment_due_date_end: Optional[str] = None,
  1840. tenant_no: Optional[str] = None,
  1841. user_info=None,
  1842. ) -> Tuple[List[Dict[str, Any]], int]:
  1843. if not tenant_no:
  1844. return [], 0
  1845. q_base = _detail_query_with_protocol(db, tenant_no, "ytf")
  1846. q_base = _apply_payment_detail_list_filters(
  1847. q_base,
  1848. db,
  1849. tenant_no=tenant_no,
  1850. keyword=keyword,
  1851. installment_due_date_start=installment_due_date_start,
  1852. installment_due_date_end=installment_due_date_end,
  1853. )
  1854. if user_info:
  1855. from app.core.data_scope import apply_data_scope
  1856. q_base = apply_data_scope(q_base, BizProtocolPaymentDetail, user_info, db, tenant_no)
  1857. total = q_base.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0
  1858. page = max(1, page)
  1859. page_size = min(max(1, page_size), 100)
  1860. rows = (
  1861. q_base.order_by(BizProtocolPaymentDetail.update_time.desc())
  1862. .offset((page - 1) * page_size)
  1863. .limit(page_size)
  1864. .all()
  1865. )
  1866. def _add_refund_confirmed_files(item: Dict[str, Any], d: BizProtocolPaymentDetail) -> None:
  1867. item["refundAttachmentFiles"] = urls_to_attachment_list(
  1868. d.refund_attachments, db, tenant_no
  1869. )
  1870. item["refundFinanceConfirmAttachmentFiles"] = urls_to_attachment_list(
  1871. d.refund_finance_confirm_attachments, db, tenant_no
  1872. )
  1873. return (
  1874. _rows_to_payment_detail_dicts(db, rows, tenant_no, post_process=_add_refund_confirmed_files),
  1875. int(total),
  1876. )
  1877. def confirm_refund(
  1878. db: Session,
  1879. *,
  1880. detail_id: str,
  1881. tenant_no: str,
  1882. user_id: Optional[str],
  1883. refund_finance_confirm_attachments: Optional[str] = None,
  1884. refund_finance_remark: Optional[str] = None,
  1885. ) -> BizProtocolPaymentDetail:
  1886. d = (
  1887. db.query(BizProtocolPaymentDetail)
  1888. .filter(
  1889. and_(
  1890. BizProtocolPaymentDetail.id == detail_id,
  1891. BizProtocolPaymentDetail.tenant_no == tenant_no,
  1892. BizProtocolPaymentDetail.del_flag == 0,
  1893. )
  1894. )
  1895. .first()
  1896. )
  1897. if not d:
  1898. raise ValueError("缴费明细不存在")
  1899. if d.pay_status != "tfqrz":
  1900. if d.pay_status == "ytf":
  1901. raise ValueError("该明细已确认退费")
  1902. raise ValueError("当前状态不可确认退费")
  1903. d.pay_status = "ytf"
  1904. if refund_finance_confirm_attachments is not None:
  1905. d.refund_finance_confirm_attachments = refund_finance_confirm_attachments.strip() or None
  1906. if refund_finance_remark is not None:
  1907. d.refund_finance_remark = refund_finance_remark.strip() or None
  1908. d.update_by = user_id
  1909. d.update_time = datetime.now()
  1910. db.add(d)
  1911. db.commit()
  1912. db.refresh(d)
  1913. return d
  1914. def reject_refund(
  1915. db: Session,
  1916. *,
  1917. detail_id: str,
  1918. tenant_no: str,
  1919. user_id: Optional[str],
  1920. ) -> BizProtocolPaymentDetail:
  1921. d = (
  1922. db.query(BizProtocolPaymentDetail)
  1923. .filter(
  1924. and_(
  1925. BizProtocolPaymentDetail.id == detail_id,
  1926. BizProtocolPaymentDetail.tenant_no == tenant_no,
  1927. BizProtocolPaymentDetail.del_flag == 0,
  1928. )
  1929. )
  1930. .first()
  1931. )
  1932. if not d:
  1933. raise ValueError("缴费明细不存在")
  1934. if d.pay_status != "tfqrz":
  1935. raise ValueError("当前状态不可退回")
  1936. d.pay_status = "yjf"
  1937. d.update_by = user_id
  1938. d.update_time = datetime.now()
  1939. db.add(d)
  1940. db.commit()
  1941. db.refresh(d)
  1942. return d
  1943. def _get_payment_detail_or_raise(
  1944. db: Session,
  1945. *,
  1946. detail_id: str,
  1947. tenant_no: str,
  1948. ) -> BizProtocolPaymentDetail:
  1949. d = (
  1950. db.query(BizProtocolPaymentDetail)
  1951. .filter(
  1952. and_(
  1953. BizProtocolPaymentDetail.id == detail_id,
  1954. BizProtocolPaymentDetail.tenant_no == tenant_no,
  1955. BizProtocolPaymentDetail.del_flag == 0,
  1956. )
  1957. )
  1958. .first()
  1959. )
  1960. if not d:
  1961. raise ValueError("缴费明细不存在")
  1962. return d
  1963. def _parse_transfer_time_value(value: Any) -> Optional[datetime]:
  1964. if value is None or value == "":
  1965. return None
  1966. if isinstance(value, datetime):
  1967. return value
  1968. if isinstance(value, str):
  1969. return parse_datetime(value)
  1970. return None
  1971. def _apply_payment_detail_draft_fields(
  1972. db: Session,
  1973. *,
  1974. tenant_no: str,
  1975. d: BizProtocolPaymentDetail,
  1976. user_id: Optional[str],
  1977. fields: Dict[str, Any],
  1978. ) -> None:
  1979. from app.crud.dict_data import normalize_transfer_type
  1980. if d.pay_status != "dfj":
  1981. raise ValueError("仅已付款状态可编辑缴费明细")
  1982. if "transfer_type" in fields:
  1983. d.transfer_type = normalize_transfer_type(
  1984. db, value=str(fields.get("transfer_type") or ""), tenant_no=tenant_no
  1985. )
  1986. if "transfer_amount" in fields:
  1987. amount = fields.get("transfer_amount")
  1988. if amount is None or amount == "":
  1989. d.transfer_amount = None
  1990. else:
  1991. dec_amount = Decimal(str(amount))
  1992. if dec_amount < 0:
  1993. raise ValueError("转账金额无效")
  1994. d.transfer_amount = dec_amount
  1995. if "transfer_time" in fields:
  1996. d.transfer_time = _parse_transfer_time_value(fields.get("transfer_time"))
  1997. if "transfer_attachments" in fields:
  1998. d.transfer_attachments = (fields.get("transfer_attachments") or "").strip() or None
  1999. if "remark" in fields:
  2000. d.remark = (fields.get("remark") or "").strip() or None
  2001. d.update_by = user_id
  2002. d.update_time = datetime.now()
  2003. def _validate_receipt_finance_submit_ready(d: BizProtocolPaymentDetail) -> None:
  2004. if not (d.transfer_type or "").strip():
  2005. raise ValueError("请选择转账方式")
  2006. if d.transfer_amount is None or Decimal(str(d.transfer_amount)) <= 0:
  2007. raise ValueError("请填写转账金额")
  2008. def update_payment_detail_draft(
  2009. db: Session,
  2010. *,
  2011. detail_id: str,
  2012. tenant_no: str,
  2013. user_id: Optional[str],
  2014. draft_fields: Dict[str, Any],
  2015. ) -> BizProtocolPaymentDetail:
  2016. d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no)
  2017. _apply_payment_detail_draft_fields(
  2018. db, tenant_no=tenant_no, d=d, user_id=user_id, fields=draft_fields
  2019. )
  2020. db.add(d)
  2021. db.commit()
  2022. db.refresh(d)
  2023. return d
  2024. def _apply_submit_receipt_finance(
  2025. d: BizProtocolPaymentDetail,
  2026. *,
  2027. user_id: Optional[str],
  2028. ) -> None:
  2029. if d.pay_status != "dfj":
  2030. raise ValueError("仅已付款状态可提交财务确认")
  2031. d.pay_status = "jfqrz"
  2032. d.update_by = user_id
  2033. d.update_time = datetime.now()
  2034. def submit_receipt_finance(
  2035. db: Session,
  2036. *,
  2037. detail_id: str,
  2038. tenant_no: str,
  2039. user_id: Optional[str],
  2040. draft_fields: Optional[Dict[str, Any]] = None,
  2041. ) -> BizProtocolPaymentDetail:
  2042. d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no)
  2043. if draft_fields:
  2044. _apply_payment_detail_draft_fields(
  2045. db, tenant_no=tenant_no, d=d, user_id=user_id, fields=draft_fields
  2046. )
  2047. _validate_receipt_finance_submit_ready(d)
  2048. _apply_submit_receipt_finance(d, user_id=user_id)
  2049. db.add(d)
  2050. db.commit()
  2051. db.refresh(d)
  2052. return d
  2053. def batch_submit_receipt_finance(
  2054. db: Session,
  2055. *,
  2056. detail_ids: List[str],
  2057. tenant_no: str,
  2058. user_id: Optional[str],
  2059. ) -> int:
  2060. ids = [str(i).strip() for i in (detail_ids or []) if str(i).strip()]
  2061. if not ids:
  2062. raise ValueError("请选择缴费明细")
  2063. for detail_id in ids:
  2064. d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no)
  2065. _apply_submit_receipt_finance(d, user_id=user_id)
  2066. db.add(d)
  2067. db.commit()
  2068. return len(ids)
  2069. def _apply_submit_refund_finance(
  2070. d: BizProtocolPaymentDetail,
  2071. *,
  2072. user_id: Optional[str],
  2073. refund_amount: Any,
  2074. refund_remark: Optional[str] = None,
  2075. refund_attachments: Optional[str] = None,
  2076. ) -> None:
  2077. if d.pay_status != "yjf":
  2078. raise ValueError("仅已付款状态可提交退费财务确认")
  2079. if refund_amount is None or Decimal(str(refund_amount)) <= 0:
  2080. raise ValueError("请填写有效的退费金额")
  2081. d.pay_status = "tfqrz"
  2082. d.refund_amount = Decimal(str(refund_amount))
  2083. d.refund_remark = (refund_remark or "").strip() or None
  2084. d.refund_attachments = (refund_attachments or "").strip() or None
  2085. d.update_by = user_id
  2086. d.update_time = datetime.now()
  2087. def submit_refund_finance(
  2088. db: Session,
  2089. *,
  2090. detail_id: str,
  2091. tenant_no: str,
  2092. user_id: Optional[str],
  2093. refund_amount: Any,
  2094. refund_remark: Optional[str] = None,
  2095. refund_attachments: Optional[str] = None,
  2096. ) -> BizProtocolPaymentDetail:
  2097. d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no)
  2098. _apply_submit_refund_finance(
  2099. d,
  2100. user_id=user_id,
  2101. refund_amount=refund_amount,
  2102. refund_remark=refund_remark,
  2103. refund_attachments=refund_attachments,
  2104. )
  2105. db.add(d)
  2106. db.commit()
  2107. db.refresh(d)
  2108. return d
  2109. def batch_submit_refund_finance(
  2110. db: Session,
  2111. *,
  2112. detail_ids: List[str],
  2113. tenant_no: str,
  2114. user_id: Optional[str],
  2115. refund_amount: Any,
  2116. refund_remark: Optional[str] = None,
  2117. refund_attachments: Optional[str] = None,
  2118. ) -> int:
  2119. ids = [str(i).strip() for i in (detail_ids or []) if str(i).strip()]
  2120. if not ids:
  2121. raise ValueError("请选择缴费明细")
  2122. for detail_id in ids:
  2123. d = _get_payment_detail_or_raise(db, detail_id=detail_id, tenant_no=tenant_no)
  2124. _apply_submit_refund_finance(
  2125. d,
  2126. user_id=user_id,
  2127. refund_amount=refund_amount,
  2128. refund_remark=refund_remark,
  2129. refund_attachments=refund_attachments,
  2130. )
  2131. db.add(d)
  2132. db.commit()
  2133. return len(ids)
  2134. def _party_customer_map_by_protocol_ids(
  2135. db: Session,
  2136. protocol_ids: List[str],
  2137. ) -> Dict[str, BizProtocolPartyCustomer]:
  2138. if not protocol_ids:
  2139. return {}
  2140. rows = (
  2141. db.query(BizProtocolPartyCustomer)
  2142. .filter(
  2143. BizProtocolPartyCustomer.collection_protocol_id.in_(protocol_ids),
  2144. BizProtocolPartyCustomer.del_flag == 0,
  2145. )
  2146. .all()
  2147. )
  2148. return {r.collection_protocol_id: r for r in rows}
  2149. def _yyyymmdd_from_date_param(value: str) -> str:
  2150. val = (value or "").strip()
  2151. if len(val) == 10 and val[4] == "-" and val[7] == "-":
  2152. return val.replace("-", "")
  2153. return val
  2154. def list_payment_details_page(
  2155. db: Session,
  2156. *,
  2157. page: int = 1,
  2158. page_size: int = 10,
  2159. pay_status: Optional[str] = None,
  2160. keyword: Optional[str] = None,
  2161. transfer_type: Optional[str] = None,
  2162. installment_due_date_start: Optional[str] = None,
  2163. installment_due_date_end: Optional[str] = None,
  2164. tenant_no: Optional[str] = None,
  2165. user_info=None,
  2166. ) -> Tuple[List[Dict[str, Any]], int]:
  2167. if not tenant_no:
  2168. return [], 0
  2169. q = (
  2170. db.query(BizProtocolPaymentDetail, BizProtocolPayment, BizCollectionProtocol)
  2171. .join(BizProtocolPayment, BizProtocolPaymentDetail.payment_id == BizProtocolPayment.id)
  2172. .join(BizCollectionProtocol, BizProtocolPayment.collection_protocol_id == BizCollectionProtocol.id)
  2173. .filter(
  2174. BizProtocolPaymentDetail.del_flag == 0,
  2175. BizProtocolPayment.del_flag == 0,
  2176. BizCollectionProtocol.del_flag == 0,
  2177. BizProtocolPaymentDetail.tenant_no == tenant_no,
  2178. )
  2179. )
  2180. if pay_status:
  2181. q = q.filter(BizProtocolPaymentDetail.pay_status == pay_status)
  2182. if keyword and keyword.strip():
  2183. like = f"%{keyword.strip()}%"
  2184. party_q = db.query(BizProtocolPartyCustomer.collection_protocol_id).filter(
  2185. BizProtocolPartyCustomer.del_flag == 0,
  2186. or_(
  2187. BizProtocolPartyCustomer.mother_name.like(like),
  2188. BizProtocolPartyCustomer.mother_id_card.like(like),
  2189. BizProtocolPartyCustomer.newborn_name.like(like),
  2190. BizProtocolPartyCustomer.newborn_id_card.like(like),
  2191. ),
  2192. )
  2193. if tenant_no:
  2194. party_q = party_q.filter(BizProtocolPartyCustomer.tenant_no == tenant_no)
  2195. q = q.filter(BizCollectionProtocol.id.in_(party_q))
  2196. if transfer_type and transfer_type.strip():
  2197. q = q.filter(BizProtocolPaymentDetail.transfer_type == transfer_type.strip())
  2198. if installment_due_date_start:
  2199. start_due = _yyyymmdd_from_date_param(installment_due_date_start)
  2200. if start_due:
  2201. q = q.filter(BizProtocolPaymentDetail.installment_due_date >= start_due)
  2202. if installment_due_date_end:
  2203. end_due = _yyyymmdd_from_date_param(installment_due_date_end)
  2204. if end_due:
  2205. q = q.filter(BizProtocolPaymentDetail.installment_due_date <= end_due)
  2206. if user_info:
  2207. from app.core.data_scope import apply_data_scope
  2208. q = apply_data_scope(q, BizProtocolPaymentDetail, user_info, db, tenant_no)
  2209. total = q.order_by(None).with_entities(func.count(BizProtocolPaymentDetail.id)).scalar() or 0
  2210. page = max(1, page)
  2211. page_size = min(max(1, page_size), 100)
  2212. due_col = BizProtocolPaymentDetail.installment_due_date
  2213. # 缴费信息 Tab:已付款/付款确认中/退费确认中 → 应缴日期正序;付款完成/已退费 → 倒序
  2214. due_order = due_col.desc() if pay_status in ("yjf", "ytf") else due_col.asc()
  2215. rows = (
  2216. q.order_by(due_order)
  2217. .offset((page - 1) * page_size)
  2218. .limit(page_size)
  2219. .all()
  2220. )
  2221. protocol_ids = [
  2222. pay.collection_protocol_id for _, pay, _ in rows if pay and pay.collection_protocol_id
  2223. ]
  2224. pc_map = _party_customer_map_by_protocol_ids(db, protocol_ids)
  2225. out = []
  2226. for d, pay, prot in rows:
  2227. cpid = pay.collection_protocol_id if pay else ""
  2228. pc = pc_map.get(cpid)
  2229. item = _detail_to_dict(
  2230. d,
  2231. protocol_no=(prot.protocol_no if prot else "") or "",
  2232. collection_protocol_id=cpid,
  2233. protocol_status=(prot.protocol_status if prot else "") or "",
  2234. pay_mode=(pay.pay_mode if pay else "") or "",
  2235. mother_name=(pc.mother_name if pc else "") or "",
  2236. mother_phone=(pc.mother_phone if pc else "") or "",
  2237. mother_id_card=(pc.mother_id_card if pc else "") or "",
  2238. newborn_name=(pc.newborn_name if pc else "") or "",
  2239. newborn_id_card=(pc.newborn_id_card if pc else "") or "",
  2240. )
  2241. item["attachments"] = urls_to_attachment_list(d.transfer_attachments, db, tenant_no)
  2242. out.append(item)
  2243. return out, int(total)
  2244. def _classify_installment_warning(due: str, today: str) -> str:
  2245. """overdue | warn | upcoming — due 为 YYYYMMDD"""
  2246. if not due or len(due) != 8 or not due.isdigit():
  2247. return "unknown"
  2248. if due < today:
  2249. return "overdue"
  2250. last_warn = (datetime.strptime(today, "%Y%m%d") + timedelta(days=15)).strftime("%Y%m%d")
  2251. if today <= due <= last_warn:
  2252. return "warn"
  2253. return "upcoming"
  2254. def list_installment_warning(
  2255. db: Session,
  2256. *,
  2257. page: int = 1,
  2258. page_size: int = 10,
  2259. warning_type: str = "all",
  2260. tenant_no: Optional[str] = None,
  2261. user_info=None,
  2262. ) -> Tuple[List[Dict[str, Any]], int]:
  2263. if not tenant_no:
  2264. return [], 0
  2265. today = _today_yyyymmdd()
  2266. q0 = _detail_query_with_protocol(db, tenant_no, "dfj").filter(
  2267. BizProtocolPaymentDetail.installment_no.isnot(None),
  2268. )
  2269. if user_info:
  2270. from app.core.data_scope import apply_data_scope
  2271. q0 = apply_data_scope(q0, BizProtocolPaymentDetail, user_info, db, tenant_no)
  2272. # 拉取当前租户下候选(分期号非空);日期过滤在内存中完成以保证与 classify 一致
  2273. rows_all = q0.order_by(BizProtocolPaymentDetail.installment_due_date.asc()).all()
  2274. flattened: List[Dict[str, Any]] = []
  2275. for d, pay, prot in rows_all:
  2276. due_raw = (d.installment_due_date or "").strip() if d.installment_due_date else ""
  2277. wt = _classify_installment_warning(due_raw, today)
  2278. cpid = pay.collection_protocol_id if pay else ""
  2279. item = _detail_to_dict(
  2280. d,
  2281. protocol_no=(prot.protocol_no if prot else "") or "",
  2282. collection_protocol_id=cpid,
  2283. protocol_status=(prot.protocol_status if prot else "") or "",
  2284. pay_mode=(pay.pay_mode if pay else "") or "",
  2285. )
  2286. item["warningType"] = wt
  2287. wt_filter = (warning_type or "all").strip().lower()
  2288. if wt_filter == "overdue" and wt != "overdue":
  2289. continue
  2290. if wt_filter == "warn" and wt != "warn":
  2291. continue
  2292. if wt_filter == "all":
  2293. pass
  2294. elif wt_filter not in ("all", "warn", "overdue"):
  2295. pass
  2296. flattened.append(item)
  2297. total = len(flattened)
  2298. page = max(1, page)
  2299. page_size = min(max(1, page_size), 100)
  2300. start = (page - 1) * page_size
  2301. page_rows = flattened[start : start + page_size]
  2302. return page_rows, total
  2303. def create_collection_protocol(
  2304. db: Session,
  2305. *,
  2306. protocol_no_id: Optional[str] = None,
  2307. protocol_no: str = "",
  2308. protocol_status: str = "dsx",
  2309. protocol_attachments: Optional[str] = None,
  2310. consent_form_urls: Optional[str] = None,
  2311. health_survey_form_urls: Optional[str] = None,
  2312. register_date: Optional[str] = None,
  2313. collector_user_id: Optional[str] = None,
  2314. protocol_total_amount: Any = None,
  2315. source_presign_order_id: Optional[str] = None,
  2316. payment_model_version: Optional[int] = None,
  2317. remark: Optional[str] = None,
  2318. tenant_no: str = "zkxw",
  2319. user_id: Optional[str] = None,
  2320. create_dept_id: str = "",
  2321. commit: bool = True,
  2322. exclude_presign_order_id: Optional[str] = None,
  2323. ) -> BizCollectionProtocol:
  2324. pn = (protocol_no or "").strip()
  2325. if not pn:
  2326. pn = allocate_next_biz_no(
  2327. db, tenant_no=tenant_no, kind="collection", user_id=user_id
  2328. )
  2329. # 预签转正式时排除来源预签订单自身占用的编号
  2330. assert_biz_no_available(
  2331. db,
  2332. tenant_no=tenant_no,
  2333. biz_no=pn,
  2334. exclude_presign_order_id=exclude_presign_order_id or source_presign_order_id,
  2335. )
  2336. version = int(payment_model_version) if payment_model_version is not None else 2
  2337. if version not in (1, 2):
  2338. version = 2
  2339. total_amt = None
  2340. if protocol_total_amount not in (None, ""):
  2341. total_amt = Decimal(str(protocol_total_amount))
  2342. obj = BizCollectionProtocol(
  2343. id=generate_uuid(),
  2344. protocol_no_id=(protocol_no_id or "").strip() or None,
  2345. protocol_no=pn,
  2346. protocol_status=protocol_status.strip() or "dsx",
  2347. protocol_attachments=(protocol_attachments or "").strip() or None,
  2348. consent_form_urls=(consent_form_urls or "").strip() or None,
  2349. health_survey_form_urls=(health_survey_form_urls or "").strip() or None,
  2350. register_date=(register_date or "").strip() or None,
  2351. collector_user_id=(collector_user_id or "").strip() or None,
  2352. protocol_total_amount=total_amt,
  2353. source_presign_order_id=(source_presign_order_id or "").strip() or None,
  2354. payment_model_version=version,
  2355. tenant_no=tenant_no,
  2356. order_num=0,
  2357. remark=(remark or "").strip() or None,
  2358. create_by=user_id,
  2359. create_dept_id=create_dept_id,
  2360. create_time=datetime.now(),
  2361. update_by=user_id,
  2362. update_time=datetime.now(),
  2363. del_flag=0,
  2364. )
  2365. db.add(obj)
  2366. if commit:
  2367. db.commit()
  2368. db.refresh(obj)
  2369. else:
  2370. db.flush()
  2371. return obj
  2372. def update_collection_protocol(
  2373. db: Session,
  2374. protocol_id: str,
  2375. *,
  2376. tenant_no: str,
  2377. user_id: Optional[str] = None,
  2378. scope_collector_user_id: Optional[str] = None,
  2379. protocol_no: Optional[str] = None,
  2380. protocol_status: Optional[str] = None,
  2381. protocol_attachments: Optional[str] = None,
  2382. consent_form_urls: Optional[str] = None,
  2383. health_survey_form_urls: Optional[str] = None,
  2384. register_date: Optional[str] = None,
  2385. effective_time: Optional[datetime] = None,
  2386. effective_desc: Optional[str] = None,
  2387. expire_time: Optional[datetime] = None,
  2388. expire_desc: Optional[str] = None,
  2389. void_time: Optional[datetime] = None,
  2390. void_desc: Optional[str] = None,
  2391. void_type: Optional[str] = None,
  2392. to_research_time: Optional[datetime] = None,
  2393. to_research_desc: Optional[str] = None,
  2394. collector_user_id: Optional[str] = None,
  2395. protocol_total_amount: Any = None,
  2396. source_presign_order_id: Optional[str] = None,
  2397. remark: Optional[str] = None,
  2398. commit: bool = True,
  2399. ) -> BizCollectionProtocol:
  2400. obj = get_collection_protocol(
  2401. db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id
  2402. )
  2403. if not obj:
  2404. raise ValueError("协议单不存在或无权操作")
  2405. from_status = (obj.protocol_status or "").strip()
  2406. status_log_payload: Optional[Dict[str, Any]] = None
  2407. if protocol_no is not None:
  2408. pn = protocol_no.strip()
  2409. if pn != (obj.protocol_no or ""):
  2410. raise ValueError("协议号不可修改")
  2411. if protocol_status is not None:
  2412. from app.core.protocol_status_display import assert_protocol_status_transition
  2413. next_status = protocol_status.strip()
  2414. assert_protocol_status_transition(obj.protocol_status or "", next_status, kind="protocol")
  2415. obj.protocol_status = next_status
  2416. if next_status == "yzf" and void_time is not None:
  2417. if void_type is None or not str(void_type).strip():
  2418. raise ValueError("请选择作废类型")
  2419. change_type = _PROTOCOL_STATUS_CHANGE_TYPE.get(next_status)
  2420. change_time_val: Optional[datetime] = None
  2421. change_desc_val: Optional[str] = None
  2422. void_type_val: Optional[str] = None
  2423. if change_type == "activate" and effective_time is not None:
  2424. change_time_val = effective_time
  2425. change_desc_val = effective_desc
  2426. elif change_type == "expire" and expire_time is not None:
  2427. change_time_val = expire_time
  2428. change_desc_val = expire_desc
  2429. elif change_type == "void" and void_time is not None:
  2430. change_time_val = void_time
  2431. change_desc_val = void_desc
  2432. void_type_val = void_type
  2433. elif change_type == "to_research" and to_research_time is not None:
  2434. change_time_val = to_research_time
  2435. change_desc_val = to_research_desc
  2436. if change_type and change_time_val is not None:
  2437. status_log_payload = {
  2438. "change_type": change_type,
  2439. "to_status": next_status,
  2440. "change_time": change_time_val,
  2441. "change_desc": change_desc_val,
  2442. "void_type": void_type_val,
  2443. }
  2444. if protocol_attachments is not None:
  2445. obj.protocol_attachments = protocol_attachments.strip() or None
  2446. if consent_form_urls is not None:
  2447. obj.consent_form_urls = consent_form_urls.strip() or None
  2448. if health_survey_form_urls is not None:
  2449. obj.health_survey_form_urls = health_survey_form_urls.strip() or None
  2450. if register_date is not None:
  2451. obj.register_date = register_date.strip() or None
  2452. if effective_time is not None:
  2453. obj.effective_time = effective_time
  2454. if effective_desc is not None:
  2455. obj.effective_desc = effective_desc.strip() or None
  2456. if expire_time is not None:
  2457. obj.expire_time = expire_time
  2458. if expire_desc is not None:
  2459. obj.expire_desc = expire_desc.strip() or None
  2460. if void_time is not None:
  2461. obj.void_time = void_time
  2462. if void_desc is not None:
  2463. obj.void_desc = void_desc.strip() or None
  2464. if void_type is not None:
  2465. from app.crud.dict_data import normalize_void_type
  2466. obj.void_type = (
  2467. normalize_void_type(db, value=void_type, tenant_no=tenant_no)
  2468. if (void_type or "").strip()
  2469. else None
  2470. )
  2471. if status_log_payload and status_log_payload.get("change_type") == "void":
  2472. status_log_payload["void_type"] = obj.void_type
  2473. if to_research_time is not None:
  2474. obj.to_research_time = to_research_time
  2475. if to_research_desc is not None:
  2476. obj.to_research_desc = to_research_desc.strip() or None
  2477. if collector_user_id is not None:
  2478. obj.collector_user_id = collector_user_id.strip() or None
  2479. if protocol_total_amount is not None:
  2480. if protocol_total_amount == "":
  2481. obj.protocol_total_amount = None
  2482. else:
  2483. obj.protocol_total_amount = Decimal(str(protocol_total_amount))
  2484. if source_presign_order_id is not None:
  2485. obj.source_presign_order_id = (source_presign_order_id or "").strip() or None
  2486. if remark is not None:
  2487. obj.remark = remark.strip() or None
  2488. obj.update_by = user_id
  2489. obj.update_time = datetime.now()
  2490. db.add(obj)
  2491. if status_log_payload:
  2492. append_protocol_status_change(
  2493. db,
  2494. collection_protocol_id=obj.id,
  2495. change_type=status_log_payload["change_type"],
  2496. from_status=from_status,
  2497. to_status=status_log_payload["to_status"],
  2498. change_time=status_log_payload["change_time"],
  2499. change_desc=status_log_payload.get("change_desc"),
  2500. void_type=status_log_payload.get("void_type"),
  2501. tenant_no=tenant_no,
  2502. user_id=user_id,
  2503. )
  2504. if commit:
  2505. db.commit()
  2506. db.refresh(obj)
  2507. else:
  2508. db.flush()
  2509. return obj
  2510. def soft_delete_collection_protocol(
  2511. db: Session,
  2512. protocol_id: str,
  2513. tenant_no: str,
  2514. user_id: Optional[str] = None,
  2515. scope_collector_user_id: Optional[str] = None,
  2516. ) -> None:
  2517. obj = get_collection_protocol(
  2518. db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id
  2519. )
  2520. if not obj:
  2521. raise ValueError("协议单不存在或无权操作")
  2522. now = datetime.now()
  2523. # 同步软删样本与运输信息,释放样本编码占用(占用校验只看样本 del_flag)
  2524. for s in list_samples(db, protocol_id, tenant_no):
  2525. s.del_flag = 1
  2526. s.update_by = user_id
  2527. s.update_time = now
  2528. db.add(s)
  2529. _soft_delete_transports_for_protocol(db, protocol_id, tenant_no, user_id)
  2530. obj.del_flag = 1
  2531. obj.update_by = user_id
  2532. obj.update_time = now
  2533. db.add(obj)
  2534. db.commit()
  2535. # ---------------------------------------------------------------------------
  2536. # 子表:当事人客户 / 采集信息 / 样本 / 储存证书
  2537. # ---------------------------------------------------------------------------
  2538. def get_party_customer(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolPartyCustomer]:
  2539. return (
  2540. db.query(BizProtocolPartyCustomer)
  2541. .filter(
  2542. and_(
  2543. BizProtocolPartyCustomer.collection_protocol_id == collection_protocol_id,
  2544. BizProtocolPartyCustomer.tenant_no == tenant_no,
  2545. BizProtocolPartyCustomer.del_flag == 0,
  2546. )
  2547. )
  2548. .first()
  2549. )
  2550. def upsert_party_customer(
  2551. db: Session,
  2552. collection_protocol_id: str,
  2553. tenant_no: str,
  2554. user_id: Optional[str],
  2555. commit: bool = True,
  2556. create_dept_id: str = "",
  2557. **fields,
  2558. ) -> BizProtocolPartyCustomer:
  2559. fields = _normalize_fields(fields)
  2560. obj = get_party_customer(db, collection_protocol_id, tenant_no)
  2561. _STR_FIELDS = [
  2562. "customer_id", "father_customer_id", "newborn_customer_id",
  2563. "mother_name", "mother_phone", "mother_id_card",
  2564. "mother_birth_ym", "mother_ethnic", "mother_email", "mother_address",
  2565. "father_name", "father_phone", "father_id_card",
  2566. "newborn_name", "newborn_id_card",
  2567. "newborn_sex", "delivery_mode", "collector_user_id",
  2568. ]
  2569. _INT_FIELDS = ["birth_order", "newborn_weight_gram"]
  2570. _DEC_FIELDS = ["apgar_1min", "apgar_5min"]
  2571. _DATE_FIELDS = ["expected_delivery_date"]
  2572. _DT_FIELDS = ["newborn_delivery_time"]
  2573. if obj:
  2574. for k in _STR_FIELDS:
  2575. if k in fields:
  2576. setattr(obj, k, (fields[k] or "").strip() or None)
  2577. for k in _INT_FIELDS:
  2578. if k in fields:
  2579. setattr(obj, k, fields[k])
  2580. for k in _DEC_FIELDS:
  2581. if k in fields:
  2582. setattr(obj, k, _optional_decimal(fields[k]))
  2583. for k in _DATE_FIELDS:
  2584. if k in fields:
  2585. setattr(obj, k, optional_date(fields[k]))
  2586. for k in _DT_FIELDS:
  2587. if k in fields:
  2588. setattr(obj, k, fields[k])
  2589. if "remark" in fields:
  2590. obj.remark = (fields["remark"] or "").strip() or None
  2591. obj.update_by = user_id
  2592. obj.update_time = datetime.now()
  2593. db.add(obj)
  2594. if commit:
  2595. db.commit()
  2596. db.refresh(obj)
  2597. else:
  2598. db.flush()
  2599. return obj
  2600. obj = BizProtocolPartyCustomer(
  2601. id=generate_uuid(),
  2602. collection_protocol_id=collection_protocol_id,
  2603. tenant_no=tenant_no,
  2604. order_num=0,
  2605. remark=(fields.get("remark") or "").strip() or None,
  2606. create_by=user_id,
  2607. create_dept_id=create_dept_id,
  2608. create_time=datetime.now(),
  2609. update_by=user_id,
  2610. update_time=datetime.now(),
  2611. del_flag=0,
  2612. )
  2613. for k in _STR_FIELDS:
  2614. if k in fields:
  2615. setattr(obj, k, (fields[k] or "").strip() or None)
  2616. for k in _INT_FIELDS:
  2617. if k in fields:
  2618. setattr(obj, k, fields[k])
  2619. for k in _DEC_FIELDS:
  2620. if k in fields:
  2621. setattr(obj, k, _optional_decimal(fields[k]))
  2622. for k in _DATE_FIELDS:
  2623. if k in fields:
  2624. setattr(obj, k, optional_date(fields[k]))
  2625. for k in _DT_FIELDS:
  2626. if k in fields:
  2627. setattr(obj, k, fields[k])
  2628. db.add(obj)
  2629. if commit:
  2630. db.commit()
  2631. db.refresh(obj)
  2632. else:
  2633. db.flush()
  2634. return obj
  2635. def get_collection_info(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolCollectionInfo]:
  2636. return (
  2637. db.query(BizProtocolCollectionInfo)
  2638. .filter(
  2639. and_(
  2640. BizProtocolCollectionInfo.collection_protocol_id == collection_protocol_id,
  2641. BizProtocolCollectionInfo.tenant_no == tenant_no,
  2642. BizProtocolCollectionInfo.del_flag == 0,
  2643. )
  2644. )
  2645. .first()
  2646. )
  2647. def upsert_collection_info(
  2648. db: Session,
  2649. collection_protocol_id: str,
  2650. tenant_no: str,
  2651. user_id: Optional[str],
  2652. commit: bool = True,
  2653. create_dept_id: str = "",
  2654. **fields,
  2655. ) -> BizProtocolCollectionInfo:
  2656. fields = _normalize_fields(fields)
  2657. obj = get_collection_info(db, collection_protocol_id, tenant_no)
  2658. _STR_FIELDS = [
  2659. "region_id",
  2660. "hospital_id",
  2661. "medical_staff",
  2662. "sales_user_id",
  2663. "region_manager_user_id",
  2664. ]
  2665. _DT_FIELDS = ["sample_collect_time", "maternal_blood_collect_time"]
  2666. if obj:
  2667. for k in _STR_FIELDS:
  2668. if k in fields:
  2669. setattr(obj, k, (fields[k] or "").strip() or None)
  2670. for k in _DT_FIELDS:
  2671. if k in fields:
  2672. setattr(obj, k, fields[k])
  2673. if "remark" in fields:
  2674. obj.remark = (fields["remark"] or "").strip() or None
  2675. obj.update_by = user_id
  2676. obj.update_time = datetime.now()
  2677. db.add(obj)
  2678. if commit:
  2679. db.commit()
  2680. db.refresh(obj)
  2681. else:
  2682. db.flush()
  2683. return obj
  2684. obj = BizProtocolCollectionInfo(
  2685. id=generate_uuid(),
  2686. collection_protocol_id=collection_protocol_id,
  2687. tenant_no=tenant_no,
  2688. order_num=0,
  2689. remark=(fields.get("remark") or "").strip() or None,
  2690. create_by=user_id,
  2691. create_dept_id=create_dept_id,
  2692. create_time=datetime.now(),
  2693. update_by=user_id,
  2694. update_time=datetime.now(),
  2695. del_flag=0,
  2696. )
  2697. for k in _STR_FIELDS:
  2698. if k in fields:
  2699. setattr(obj, k, (fields[k] or "").strip() or None)
  2700. for k in _DT_FIELDS:
  2701. if k in fields:
  2702. setattr(obj, k, fields[k])
  2703. db.add(obj)
  2704. if commit:
  2705. db.commit()
  2706. db.refresh(obj)
  2707. else:
  2708. db.flush()
  2709. return obj
  2710. def list_samples(db: Session, collection_protocol_id: str, tenant_no: str) -> List[BizProtocolSample]:
  2711. return (
  2712. db.query(BizProtocolSample)
  2713. .filter(
  2714. and_(
  2715. BizProtocolSample.collection_protocol_id == collection_protocol_id,
  2716. BizProtocolSample.tenant_no == tenant_no,
  2717. BizProtocolSample.del_flag == 0,
  2718. )
  2719. )
  2720. .order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc())
  2721. .all()
  2722. )
  2723. def _migrate_sample_child_refs_on_replace(
  2724. db: Session,
  2725. *,
  2726. tenant_no: str,
  2727. old_id_to_code: Dict[str, str],
  2728. new_code_to_id: Dict[str, str],
  2729. user_id: Optional[str],
  2730. ) -> None:
  2731. """协议样本行重建后,将接收/质检工单的 protocol_sample_id 迁移到新样本 ID。"""
  2732. if not old_id_to_code or not new_code_to_id:
  2733. return
  2734. old_ids = list(old_id_to_code.keys())
  2735. now = datetime.now()
  2736. for model in (BizSampleReception, BizQcWorkOrder):
  2737. rows = (
  2738. db.query(model)
  2739. .filter(
  2740. model.protocol_sample_id.in_(old_ids),
  2741. model.tenant_no == tenant_no,
  2742. model.del_flag == 0,
  2743. )
  2744. .all()
  2745. )
  2746. for row in rows:
  2747. old_sid = (row.protocol_sample_id or "").strip()
  2748. code = old_id_to_code.get(old_sid) or (row.sample_code or "").strip()
  2749. new_sid = new_code_to_id.get(code) if code else None
  2750. if not new_sid:
  2751. continue
  2752. row.protocol_sample_id = new_sid
  2753. row.update_by = user_id
  2754. row.update_time = now
  2755. db.add(row)
  2756. def save_samples(
  2757. db: Session,
  2758. collection_protocol_id: str,
  2759. tenant_no: str,
  2760. user_id: Optional[str],
  2761. samples: list,
  2762. create_dept_id: str = "",
  2763. commit: bool = True,
  2764. ) -> List[BizProtocolSample]:
  2765. existing = list_samples(db, collection_protocol_id, tenant_no)
  2766. old_id_to_code = {
  2767. s.id: (s.sample_code or "").strip()
  2768. for s in existing
  2769. if (s.sample_code or "").strip()
  2770. }
  2771. for s in existing:
  2772. s.del_flag = 1
  2773. s.update_by = user_id
  2774. s.update_time = datetime.now()
  2775. db.add(s)
  2776. if existing:
  2777. db.flush()
  2778. validate_sample_codes_not_used(
  2779. db,
  2780. tenant_no=tenant_no,
  2781. samples=samples,
  2782. exclude_protocol_id=collection_protocol_id,
  2783. )
  2784. _STR_FIELDS = [
  2785. "sample_type_id", "sample_code_id", "sample_code", "unit_id",
  2786. "sample_status", "storage_in_date", "detect_start_date",
  2787. "void_date", "storage_expire_date",
  2788. ]
  2789. _DEC_FIELDS = ["collect_amount", "price", "original_price"]
  2790. _INT_FIELDS = ["storage_years"]
  2791. result = []
  2792. for idx, raw in enumerate(samples):
  2793. raw = _normalize_fields(raw)
  2794. validate_sample_special_raw(raw, index=idx)
  2795. obj = BizProtocolSample(
  2796. id=generate_uuid(),
  2797. collection_protocol_id=collection_protocol_id,
  2798. tenant_no=tenant_no,
  2799. order_num=idx,
  2800. remark=(raw.get("remark") or "").strip() or None,
  2801. create_by=user_id,
  2802. create_dept_id=create_dept_id,
  2803. create_time=datetime.now(),
  2804. update_by=user_id,
  2805. update_time=datetime.now(),
  2806. del_flag=0,
  2807. )
  2808. for k in _STR_FIELDS:
  2809. if k == "sample_status":
  2810. continue
  2811. if k in raw:
  2812. setattr(obj, k, (raw[k] or "").strip() or None)
  2813. # 样本状态:显式支持 null/空字符串,避免 ORM/库默认值 ycj 回填
  2814. if "sample_status" in raw:
  2815. status_val = raw.get("sample_status")
  2816. obj.sample_status = (
  2817. str(status_val).strip() if status_val is not None and str(status_val).strip() else None
  2818. )
  2819. for k in _DEC_FIELDS:
  2820. if k in raw and raw[k] is not None:
  2821. setattr(obj, k, Decimal(str(raw[k])))
  2822. for k in _INT_FIELDS:
  2823. if k in raw:
  2824. setattr(obj, k, raw[k])
  2825. apply_sample_special_fields(obj, raw)
  2826. db.add(obj)
  2827. result.append(obj)
  2828. new_code_to_id = {
  2829. (o.sample_code or "").strip(): o.id
  2830. for o in result
  2831. if (o.sample_code or "").strip()
  2832. }
  2833. _migrate_sample_child_refs_on_replace(
  2834. db,
  2835. tenant_no=tenant_no,
  2836. old_id_to_code=old_id_to_code,
  2837. new_code_to_id=new_code_to_id,
  2838. user_id=user_id,
  2839. )
  2840. db.flush()
  2841. for o in result:
  2842. db.refresh(o)
  2843. # 运费批次 + 预计到达(旧运输明细仅保留 eta 等仍在用的字段)
  2844. from app.crud import sample_freight as sample_freight_svc
  2845. sample_freight_svc.apply_freight_on_protocol_save(
  2846. db,
  2847. tenant_no=tenant_no,
  2848. user_id=user_id,
  2849. sample_source=sample_freight_svc.SAMPLE_SOURCE_CELL,
  2850. protocol_id=collection_protocol_id,
  2851. saved_samples=result,
  2852. input_samples=samples,
  2853. replaced_sample_ids=[sample.id for sample in existing],
  2854. )
  2855. save_sample_transports(
  2856. db,
  2857. collection_protocol_id=collection_protocol_id,
  2858. tenant_no=tenant_no,
  2859. user_id=user_id,
  2860. saved_samples=result,
  2861. input_samples=samples,
  2862. commit=False,
  2863. )
  2864. if commit:
  2865. db.commit()
  2866. for o in result:
  2867. db.refresh(o)
  2868. else:
  2869. db.flush()
  2870. return result
  2871. def list_sample_transports(
  2872. db: Session,
  2873. collection_protocol_id: str,
  2874. tenant_no: str,
  2875. ) -> List[BizProtocolSampleTransport]:
  2876. return (
  2877. db.query(BizProtocolSampleTransport)
  2878. .filter(
  2879. and_(
  2880. BizProtocolSampleTransport.collection_protocol_id == collection_protocol_id,
  2881. BizProtocolSampleTransport.tenant_no == tenant_no,
  2882. BizProtocolSampleTransport.del_flag == 0,
  2883. )
  2884. )
  2885. .order_by(BizProtocolSampleTransport.order_num.asc(), BizProtocolSampleTransport.create_time.asc())
  2886. .all()
  2887. )
  2888. def _soft_delete_transports_for_protocol(
  2889. db: Session,
  2890. collection_protocol_id: str,
  2891. tenant_no: str,
  2892. user_id: Optional[str],
  2893. ) -> None:
  2894. rows = list_sample_transports(db, collection_protocol_id, tenant_no)
  2895. for r in rows:
  2896. r.del_flag = 1
  2897. r.update_by = user_id
  2898. r.update_time = datetime.now()
  2899. db.add(r)
  2900. if rows:
  2901. db.flush()
  2902. def save_sample_transports(
  2903. db: Session,
  2904. collection_protocol_id: str,
  2905. tenant_no: str,
  2906. user_id: Optional[str],
  2907. saved_samples: List[BizProtocolSample],
  2908. input_samples: list,
  2909. commit: bool = True,
  2910. ) -> None:
  2911. """写回样本运输明细。运费批次改造后仍保留预计到达等字段(可仅有 eta_time)。"""
  2912. _soft_delete_transports_for_protocol(db, collection_protocol_id, tenant_no, user_id)
  2913. for idx, sample_obj in enumerate(saved_samples):
  2914. if idx >= len(input_samples):
  2915. break
  2916. raw = _normalize_fields(input_samples[idx] if isinstance(input_samples[idx], dict) else {})
  2917. transport_raw = raw.get("transport")
  2918. if not isinstance(transport_raw, dict):
  2919. transport_raw = {}
  2920. tnorm = _normalize_fields(transport_raw)
  2921. # 移动端可能把 etaTime 放在样本顶层
  2922. if not tnorm.get("eta_time") and raw.get("eta_time"):
  2923. tnorm["eta_time"] = raw.get("eta_time")
  2924. transport_type = (tnorm.get("transport_type") or "").strip()
  2925. eta_time = tnorm.get("eta_time")
  2926. if isinstance(eta_time, str):
  2927. eta_time = parse_datetime(eta_time)
  2928. if not transport_type and not eta_time:
  2929. continue
  2930. express_fee = tnorm.get("express_fee")
  2931. if express_fee is not None and express_fee != "":
  2932. express_fee = Decimal(str(express_fee))
  2933. else:
  2934. express_fee = None
  2935. obj = BizProtocolSampleTransport(
  2936. id=generate_uuid(),
  2937. collection_protocol_id=collection_protocol_id,
  2938. sample_id=sample_obj.id,
  2939. sample_code=sample_obj.sample_code or None,
  2940. transport_type=transport_type,
  2941. express_company_id=(tnorm.get("express_company_id") or "").strip() or None,
  2942. waybill_no=(tnorm.get("waybill_no") or "").strip() or None,
  2943. dest_site_id=(tnorm.get("dest_site_id") or "").strip() or None,
  2944. express_fee=express_fee,
  2945. eta_time=eta_time,
  2946. carrier_name=(tnorm.get("carrier_name") or "").strip() or None,
  2947. carrier_phone=(tnorm.get("carrier_phone") or "").strip() or None,
  2948. tenant_no=tenant_no,
  2949. order_num=idx,
  2950. remark=(tnorm.get("remark") or "").strip() or None,
  2951. create_by=user_id,
  2952. create_dept_id="",
  2953. create_time=datetime.now(),
  2954. update_by=user_id,
  2955. update_time=datetime.now(),
  2956. del_flag=0,
  2957. )
  2958. db.add(obj)
  2959. if commit:
  2960. db.commit()
  2961. else:
  2962. db.flush()
  2963. def get_storage_cert(db: Session, collection_protocol_id: str, tenant_no: str) -> Optional[BizProtocolStorageCert]:
  2964. return (
  2965. db.query(BizProtocolStorageCert)
  2966. .filter(
  2967. and_(
  2968. BizProtocolStorageCert.collection_protocol_id == collection_protocol_id,
  2969. BizProtocolStorageCert.tenant_no == tenant_no,
  2970. BizProtocolStorageCert.del_flag == 0,
  2971. )
  2972. )
  2973. .first()
  2974. )
  2975. def _add_months_yyyymmdd(date_str: str, months: int) -> str:
  2976. raw = (date_str or "").strip().replace("-", "")
  2977. if len(raw) != 8:
  2978. return ""
  2979. y = int(raw[0:4])
  2980. m = int(raw[4:6])
  2981. d = int(raw[6:8])
  2982. month_index = m - 1 + months
  2983. ny = y + month_index // 12
  2984. nm = month_index % 12 + 1
  2985. last_day = calendar.monthrange(ny, nm)[1]
  2986. nd = min(d, last_day)
  2987. return f"{ny:04d}{nm:02d}{nd:02d}"
  2988. def _calc_installment_due_amounts(
  2989. contract_amount: Optional[Decimal],
  2990. count: int,
  2991. ) -> List[Decimal]:
  2992. if contract_amount is None or count < 1:
  2993. return []
  2994. total_cents = int((Decimal(str(contract_amount)) * Decimal("100")).to_integral_value())
  2995. base_cents = total_cents // count
  2996. amounts: List[Decimal] = []
  2997. sum_cents = 0
  2998. for i in range(count):
  2999. if i == count - 1:
  3000. cents = total_cents - sum_cents
  3001. else:
  3002. cents = base_cents
  3003. sum_cents += base_cents
  3004. amounts.append(Decimal(cents) / Decimal(100))
  3005. return amounts
  3006. def align_fq_payment_details_payload(
  3007. pay: BizProtocolPayment,
  3008. details: Optional[list],
  3009. ) -> list:
  3010. if pay.pay_mode not in ("fq", "djfq") or not pay.installment_count or int(pay.installment_count) < 1:
  3011. return list(details or [])
  3012. count = int(pay.installment_count)
  3013. rows = [_normalize_fields(dict(d)) for d in (details or [])]
  3014. explicit_nos = [
  3015. int(row["installment_no"])
  3016. for row in rows
  3017. if row.get("installment_no") not in (None, "")
  3018. ]
  3019. if (
  3020. any(number < 1 or number > count for number in explicit_nos)
  3021. or len(explicit_nos) != len(set(explicit_nos))
  3022. ):
  3023. return rows
  3024. if len(rows) >= count:
  3025. needed = set(range(1, count + 1))
  3026. have = {
  3027. int(r["installment_no"])
  3028. for r in rows
  3029. if r.get("installment_no") is not None
  3030. }
  3031. if needed - have == set():
  3032. return rows[:count]
  3033. by_no: Dict[int, Dict[str, Any]] = {}
  3034. unnumbered: List[Dict[str, Any]] = []
  3035. for r in rows:
  3036. no = r.get("installment_no")
  3037. if no is not None:
  3038. by_no[int(no)] = r
  3039. else:
  3040. unnumbered.append(r)
  3041. ratios = parse_installment_ratios(pay.installment_ratios)
  3042. amounts = resolve_installment_due_amounts(pay.contract_amount, ratios, count)
  3043. start = (pay.installment_start_date or "").strip()
  3044. interval = pay.installment_interval_months
  3045. interval_val = Decimal(str(interval)) if interval is not None else Decimal("0")
  3046. advance_days = (
  3047. 30
  3048. if pay.installment_advance_days is None
  3049. else int(pay.installment_advance_days)
  3050. )
  3051. result: List[Dict[str, Any]] = []
  3052. for i in range(count):
  3053. no = i + 1
  3054. if no in by_no:
  3055. result.append(by_no[no])
  3056. continue
  3057. if i < len(unnumbered):
  3058. raw = dict(unnumbered[i])
  3059. raw["installment_no"] = no
  3060. if not raw.get("installment_due_date") and start and interval_val:
  3061. raw["installment_due_date"] = calc_fq_installment_due_date(
  3062. start, i, interval_val, advance_days
  3063. )
  3064. result.append(raw)
  3065. continue
  3066. due_date = (
  3067. calc_fq_installment_due_date(start, i, interval_val, advance_days)
  3068. if start and interval_val
  3069. else ""
  3070. )
  3071. due = amounts[i] if i < len(amounts) else None
  3072. result.append(
  3073. {
  3074. "installment_no": no,
  3075. "installment_due_date": due_date or None,
  3076. "due_amount": format(due, "f") if due is not None else None,
  3077. "pay_status": "dfj",
  3078. "transfer_type": "",
  3079. "transfer_amount": None,
  3080. "attachments": [],
  3081. }
  3082. )
  3083. return result
  3084. def list_payment_details(
  3085. db: Session,
  3086. payment_id: str,
  3087. tenant_no: str,
  3088. ) -> List[BizProtocolPaymentDetail]:
  3089. return (
  3090. db.query(BizProtocolPaymentDetail)
  3091. .filter(
  3092. and_(
  3093. BizProtocolPaymentDetail.payment_id == payment_id,
  3094. BizProtocolPaymentDetail.tenant_no == tenant_no,
  3095. BizProtocolPaymentDetail.del_flag == 0,
  3096. )
  3097. )
  3098. .order_by(
  3099. BizProtocolPaymentDetail.installment_no.asc(),
  3100. BizProtocolPaymentDetail.order_num.asc(),
  3101. BizProtocolPaymentDetail.create_time.asc(),
  3102. )
  3103. .all()
  3104. )
  3105. def upsert_payment(
  3106. db: Session,
  3107. collection_protocol_id: str,
  3108. tenant_no: str,
  3109. user_id: Optional[str],
  3110. commit: bool = True,
  3111. **fields,
  3112. ) -> BizProtocolPayment:
  3113. fields = _normalize_fields(fields)
  3114. if "pay_mode" in fields and fields["pay_mode"] not in {
  3115. "dj", "fq", "djfq", "fgd"
  3116. }:
  3117. raise ValueError("不支持的缴费模式")
  3118. if commit:
  3119. obj = get_payment_by_protocol_id(db, collection_protocol_id, tenant_no)
  3120. else:
  3121. obj = (
  3122. db.query(BizProtocolPayment)
  3123. .filter(
  3124. and_(
  3125. BizProtocolPayment.collection_protocol_id
  3126. == collection_protocol_id,
  3127. BizProtocolPayment.tenant_no == tenant_no,
  3128. BizProtocolPayment.del_flag == 0,
  3129. )
  3130. )
  3131. .populate_existing()
  3132. .with_for_update()
  3133. .first()
  3134. )
  3135. _STR_FIELDS = ["pay_mode"]
  3136. _STR_DATE_FIELDS = ["lump_sum_deadline", "installment_start_date"]
  3137. _DEC_FIELDS = ["contract_amount", "installment_interval_months"]
  3138. _INT_FIELDS = ["installment_count", "installment_advance_days"]
  3139. def _apply(target: BizProtocolPayment) -> None:
  3140. for k in _STR_FIELDS:
  3141. if k in fields:
  3142. setattr(target, k, (fields[k] or "").strip() or None)
  3143. for k in _STR_DATE_FIELDS:
  3144. if k in fields:
  3145. setattr(target, k, _validate_yyyymmdd_date(fields[k], "缴费方案"))
  3146. for k in _DEC_FIELDS:
  3147. if k in fields and fields[k] is not None:
  3148. setattr(target, k, Decimal(str(fields[k])))
  3149. for k in _INT_FIELDS:
  3150. if k in fields:
  3151. setattr(target, k, fields[k])
  3152. if "installment_ratios" in fields:
  3153. val = fields["installment_ratios"]
  3154. if val is None:
  3155. target.installment_ratios = None
  3156. elif isinstance(val, str):
  3157. target.installment_ratios = val.strip() or None
  3158. else:
  3159. target.installment_ratios = json.dumps(list(val), ensure_ascii=False)
  3160. if "remark" in fields:
  3161. target.remark = (fields["remark"] or "").strip() or None
  3162. if obj:
  3163. _apply(obj)
  3164. obj.update_by = user_id
  3165. obj.update_time = datetime.now()
  3166. db.add(obj)
  3167. if commit:
  3168. db.commit()
  3169. db.refresh(obj)
  3170. else:
  3171. db.flush()
  3172. return obj
  3173. pay_mode = (fields.get("pay_mode") or "").strip() or "dj"
  3174. obj = BizProtocolPayment(
  3175. id=generate_uuid(),
  3176. collection_protocol_id=collection_protocol_id,
  3177. pay_mode=pay_mode,
  3178. tenant_no=tenant_no,
  3179. order_num=0,
  3180. remark=(fields.get("remark") or "").strip() or None,
  3181. create_by=user_id,
  3182. create_dept_id="",
  3183. create_time=datetime.now(),
  3184. update_by=user_id,
  3185. update_time=datetime.now(),
  3186. del_flag=0,
  3187. )
  3188. _apply(obj)
  3189. db.add(obj)
  3190. if commit:
  3191. db.commit()
  3192. db.refresh(obj)
  3193. else:
  3194. db.flush()
  3195. return obj
  3196. _PAYMENT_PLAN_FIELDS = frozenset(
  3197. {
  3198. "pay_mode",
  3199. "contract_amount",
  3200. "lump_sum_deadline",
  3201. "installment_count",
  3202. "installment_interval_months",
  3203. "installment_start_date",
  3204. "installment_advance_days",
  3205. "installment_ratios",
  3206. }
  3207. )
  3208. def _payment_plan_changed_fields(
  3209. current: BizProtocolPayment,
  3210. incoming: Dict[str, Any],
  3211. ) -> set:
  3212. fields = _normalize_fields(dict(incoming))
  3213. changed = set()
  3214. for key in _PAYMENT_PLAN_FIELDS.intersection(fields):
  3215. value = fields[key]
  3216. existing = getattr(current, key, None)
  3217. if key in {"contract_amount", "installment_interval_months"}:
  3218. normalized = Decimal(str(value)) if value is not None else None
  3219. if existing != normalized:
  3220. changed.add(key)
  3221. continue
  3222. if key == "installment_ratios":
  3223. normalized = (
  3224. [Decimal(str(item)) for item in value]
  3225. if value is not None and not isinstance(value, str)
  3226. else parse_installment_ratios(value)
  3227. )
  3228. if parse_installment_ratios(existing) != normalized:
  3229. changed.add(key)
  3230. continue
  3231. if key in {"pay_mode", "lump_sum_deadline", "installment_start_date"}:
  3232. if str(existing or "").strip() != str(value or "").strip():
  3233. changed.add(key)
  3234. continue
  3235. if existing != value:
  3236. changed.add(key)
  3237. return changed
  3238. def _assert_locked_payment_plan_transition_allowed(
  3239. current: BizProtocolPayment,
  3240. incoming: Dict[str, Any],
  3241. locked_details: List[BizProtocolPaymentDetail],
  3242. draft_details: List[BizProtocolPaymentDetail],
  3243. changed_fields: set,
  3244. ) -> None:
  3245. if not changed_fields or not locked_details:
  3246. return
  3247. statuses = {str(item.pay_status or "") for item in locked_details}
  3248. if statuses != {"yjf"}:
  3249. raise ValueError("存在确认中或异常锁定明细,无法变更缴费计划")
  3250. fields = _normalize_fields(dict(incoming))
  3251. from_mode = str(current.pay_mode or "").strip()
  3252. to_mode = str(fields.get("pay_mode", from_mode) or "").strip()
  3253. installment_modes = {"fq", "djfq"}
  3254. if "pay_mode" in changed_fields:
  3255. if from_mode == "dj" and to_mode in installment_modes:
  3256. raise ValueError("趸交已有缴费记录,无法改为分期")
  3257. if from_mode in installment_modes and to_mode in installment_modes:
  3258. raise ValueError("分期已有缴费记录,无法切换分期类型")
  3259. if from_mode in installment_modes and to_mode == "dj":
  3260. if not draft_details:
  3261. raise ValueError("分期已全部缴清,无需改为趸交")
  3262. contract_amount = Decimal(
  3263. str(fields.get("contract_amount", current.contract_amount) or 0)
  3264. )
  3265. paid_due = sum(
  3266. (item.due_amount or Decimal("0")) for item in locked_details
  3267. )
  3268. if contract_amount <= paid_due:
  3269. raise ValueError("已缴清或合同金额不足,无法改为趸交")
  3270. def _compare_money(val: Any) -> Optional[str]:
  3271. if val is None or val == "":
  3272. return None
  3273. try:
  3274. return f"{Decimal(str(val)).quantize(Decimal('0.01'))}"
  3275. except Exception:
  3276. return str(val).strip() or None
  3277. def _compare_datetime(val: Any) -> Optional[str]:
  3278. if val is None or val == "":
  3279. return None
  3280. if isinstance(val, datetime):
  3281. return _fmt_dt(val)
  3282. if isinstance(val, str):
  3283. dt = parse_datetime(val)
  3284. return _fmt_dt(dt) if dt else val.strip()
  3285. return str(val)
  3286. def _payment_detail_compare_row(raw: Dict[str, Any]) -> Dict[str, Any]:
  3287. """Normalize one payment detail row for equality compare."""
  3288. raw = _normalize_fields(dict(raw))
  3289. transfer_att = _pop_attachment_urls(
  3290. raw, "attachments", "transfer_attachments", "transferAttachments"
  3291. )
  3292. finance_att = _pop_attachment_urls(
  3293. raw,
  3294. "finance_confirm_attachments",
  3295. "financeConfirmAttachments",
  3296. "finance_confirm_attachment_files",
  3297. "financeConfirmAttachmentFiles",
  3298. )
  3299. refund_att = _pop_attachment_urls(
  3300. raw,
  3301. "refund_attachments",
  3302. "refundAttachments",
  3303. "refund_attachment_files",
  3304. "refundAttachmentFiles",
  3305. )
  3306. refund_finance_att = _pop_attachment_urls(
  3307. raw,
  3308. "refund_finance_confirm_attachments",
  3309. "refundFinanceConfirmAttachments",
  3310. "refund_finance_confirm_attachment_files",
  3311. "refundFinanceConfirmAttachmentFiles",
  3312. )
  3313. transfer_time = raw.get("transfer_time")
  3314. if isinstance(transfer_time, str):
  3315. transfer_time = parse_datetime(transfer_time)
  3316. pay_status = (raw.get("pay_status") or "").strip() or "dfj"
  3317. return {
  3318. "installment_no": raw.get("installment_no"),
  3319. "installment_due_date": _normalize_yyyymmdd_date(raw.get("installment_due_date")),
  3320. "due_amount": _compare_money(raw.get("due_amount")),
  3321. "transfer_type": (raw.get("transfer_type") or "").strip() or None,
  3322. "transfer_amount": _compare_money(raw.get("transfer_amount")),
  3323. "transfer_time": _compare_datetime(transfer_time),
  3324. "pay_status": pay_status,
  3325. "remark": (raw.get("remark") or "").strip() or None,
  3326. "finance_remark": (raw.get("finance_remark") or "").strip() or None,
  3327. "refund_remark": (raw.get("refund_remark") or "").strip() or None,
  3328. "refund_finance_remark": (raw.get("refund_finance_remark") or "").strip() or None,
  3329. "refund_amount": _compare_money(raw.get("refund_amount")),
  3330. "transfer_attachments": transfer_att or None,
  3331. "finance_confirm_attachments": finance_att or None,
  3332. "refund_attachments": refund_att or None,
  3333. "refund_finance_confirm_attachments": refund_finance_att or None,
  3334. }
  3335. def _payment_detail_compare_from_model(d: BizProtocolPaymentDetail) -> Dict[str, Any]:
  3336. pay_status = (d.pay_status or "").strip() or "dfj"
  3337. return {
  3338. "installment_no": d.installment_no,
  3339. "installment_due_date": _normalize_yyyymmdd_date(d.installment_due_date),
  3340. "due_amount": _compare_money(d.due_amount),
  3341. "transfer_type": (d.transfer_type or "").strip() or None,
  3342. "transfer_amount": _compare_money(d.transfer_amount),
  3343. "transfer_time": _compare_datetime(d.transfer_time),
  3344. "pay_status": pay_status,
  3345. "remark": (d.remark or "").strip() or None,
  3346. "finance_remark": (d.finance_remark or "").strip() or None,
  3347. "refund_remark": (d.refund_remark or "").strip() or None,
  3348. "refund_finance_remark": (d.refund_finance_remark or "").strip() or None,
  3349. "refund_amount": _compare_money(d.refund_amount),
  3350. "transfer_attachments": (d.transfer_attachments or "").strip() or None,
  3351. "finance_confirm_attachments": (d.finance_confirm_attachments or "").strip() or None,
  3352. "refund_attachments": (d.refund_attachments or "").strip() or None,
  3353. "refund_finance_confirm_attachments": (
  3354. (d.refund_finance_confirm_attachments or "").strip() or None
  3355. ),
  3356. }
  3357. def _payment_detail_sort_key(row: Dict[str, Any]) -> tuple:
  3358. no = row.get("installment_no")
  3359. if no is not None:
  3360. return (0, int(no))
  3361. return (1, 0)
  3362. def _payment_details_payload_unchanged(
  3363. existing: List[BizProtocolPaymentDetail],
  3364. incoming: list,
  3365. ) -> bool:
  3366. incoming_rows = [_payment_detail_compare_row(dict(d)) for d in (incoming or [])]
  3367. existing_rows = [_payment_detail_compare_from_model(d) for d in existing]
  3368. if len(existing_rows) != len(incoming_rows):
  3369. return False
  3370. existing_sorted = sorted(existing_rows, key=_payment_detail_sort_key)
  3371. incoming_sorted = sorted(incoming_rows, key=_payment_detail_sort_key)
  3372. return existing_sorted == incoming_sorted
  3373. def _is_draft_payment_detail(detail: BizProtocolPaymentDetail) -> bool:
  3374. return detail.pay_status == "dfj"
  3375. def save_payment_details(
  3376. db: Session,
  3377. payment_id: str,
  3378. tenant_no: str,
  3379. user_id: Optional[str],
  3380. details: list,
  3381. commit: bool = True,
  3382. enforce_locked_plan_consistency: bool = False,
  3383. ) -> List[BizProtocolPaymentDetail]:
  3384. pay = (
  3385. db.query(BizProtocolPayment)
  3386. .filter(
  3387. and_(
  3388. BizProtocolPayment.id == payment_id,
  3389. BizProtocolPayment.tenant_no == tenant_no,
  3390. BizProtocolPayment.del_flag == 0,
  3391. )
  3392. )
  3393. .populate_existing()
  3394. .with_for_update()
  3395. .first()
  3396. )
  3397. existing = (
  3398. db.query(BizProtocolPaymentDetail)
  3399. .filter(
  3400. and_(
  3401. BizProtocolPaymentDetail.payment_id == payment_id,
  3402. BizProtocolPaymentDetail.tenant_no == tenant_no,
  3403. BizProtocolPaymentDetail.del_flag == 0,
  3404. )
  3405. )
  3406. .order_by(
  3407. BizProtocolPaymentDetail.installment_no.asc(),
  3408. BizProtocolPaymentDetail.order_num.asc(),
  3409. BizProtocolPaymentDetail.create_time.asc(),
  3410. )
  3411. .populate_existing()
  3412. .with_for_update()
  3413. .all()
  3414. )
  3415. existing_by_id = {row.id: row for row in existing}
  3416. normalized_details = [_normalize_fields(dict(detail)) for detail in (details or [])]
  3417. for raw in normalized_details:
  3418. if "installment_due_date" in raw:
  3419. raw["installment_due_date"] = _validate_yyyymmdd_date(
  3420. raw["installment_due_date"], "缴费明细"
  3421. )
  3422. submitted_ids: set = set()
  3423. for raw in normalized_details:
  3424. detail_id = str(raw.get("id") or "").strip()
  3425. if not detail_id:
  3426. continue
  3427. if detail_id not in existing_by_id:
  3428. raise ValueError("缴费明细不存在或不属于当前缴费方案")
  3429. if detail_id in submitted_ids:
  3430. raise ValueError("缴费明细ID重复")
  3431. submitted_ids.add(detail_id)
  3432. effective_details: List[Dict[str, Any]] = []
  3433. for raw in normalized_details:
  3434. detail_id = str(raw.get("id") or "").strip()
  3435. current = existing_by_id.get(detail_id)
  3436. if current is not None and not _is_draft_payment_detail(current):
  3437. authoritative = _payment_detail_compare_from_model(current)
  3438. authoritative["id"] = current.id
  3439. authoritative["pay_status"] = current.pay_status
  3440. effective_details.append(authoritative)
  3441. else:
  3442. effective_details.append(dict(raw))
  3443. for current in existing:
  3444. if current.id in submitted_ids or _is_draft_payment_detail(current):
  3445. continue
  3446. authoritative = _payment_detail_compare_from_model(current)
  3447. authoritative["id"] = current.id
  3448. authoritative["pay_status"] = current.pay_status
  3449. effective_details.append(authoritative)
  3450. if pay and (pay.pay_mode or "") in ("fq", "djfq", "fgd"):
  3451. installment_nos = [
  3452. int(raw["installment_no"])
  3453. for raw in effective_details
  3454. if raw.get("installment_no") not in (None, "")
  3455. ]
  3456. if len(installment_nos) != len(set(installment_nos)):
  3457. raise ValueError("分期期号不能重复")
  3458. if pay:
  3459. installment_ratios = parse_installment_ratios(pay.installment_ratios)
  3460. validate_payment_details_for_save(
  3461. pay.pay_mode or "",
  3462. pay.contract_amount,
  3463. pay.installment_count,
  3464. effective_details,
  3465. installment_ratios=installment_ratios or None,
  3466. installment_advance_days=pay.installment_advance_days,
  3467. locked_rows_are_read_only=True,
  3468. )
  3469. installment_plan = (pay.pay_mode or "") in ("fq", "djfq")
  3470. if enforce_locked_plan_consistency or installment_plan:
  3471. count = 0
  3472. ratios: List[Decimal] = []
  3473. if installment_plan:
  3474. count = int(pay.installment_count or 0)
  3475. ratios = parse_installment_ratios(pay.installment_ratios)
  3476. effective_nos = sorted(
  3477. int(item.get("installment_no") or 0)
  3478. for item in effective_details
  3479. )
  3480. if effective_nos != list(range(1, count + 1)):
  3481. raise ValueError("分期明细期号必须完整且为 1 至分期期数")
  3482. locked_due = sum(
  3483. (item.due_amount or Decimal("0"))
  3484. for item in existing
  3485. if not _is_draft_payment_detail(item)
  3486. )
  3487. contract_amount = pay.contract_amount
  3488. if contract_amount is None or contract_amount < locked_due:
  3489. raise ValueError("合同金额不能小于已缴或锁定明细金额")
  3490. incoming_due = []
  3491. for item in effective_details:
  3492. value = item.get("due_amount", item.get("dueAmount"))
  3493. if value in (None, ""):
  3494. raise ValueError("变更缴费计划时明细应缴金额不能为空")
  3495. incoming_due.append(Decimal(str(value)))
  3496. if sum(incoming_due, Decimal("0")) != contract_amount:
  3497. raise ValueError("变更后的缴费计划明细金额须与合同金额一致")
  3498. if (pay.pay_mode or "") == "dj":
  3499. locked_ids = {
  3500. item.id for item in existing
  3501. if not _is_draft_payment_detail(item)
  3502. }
  3503. editable_plan = [
  3504. item for item in effective_details
  3505. if str(item.get("id") or "") not in locked_ids
  3506. ]
  3507. if len(editable_plan) != 1:
  3508. raise ValueError("切换趸交后须仅保留一条待缴计划")
  3509. draft = editable_plan[0]
  3510. if draft.get("installment_no") not in (None, ""):
  3511. raise ValueError("切换趸交后待缴计划不能保留分期期号")
  3512. expected_amount = contract_amount - locked_due
  3513. if Decimal(str(draft.get("due_amount"))) != expected_amount:
  3514. raise ValueError("切换趸交后待缴金额应为剩余合同金额")
  3515. expected_date = _normalize_yyyymmdd_date(pay.lump_sum_deadline)
  3516. actual_date = _normalize_yyyymmdd_date(
  3517. draft.get("installment_due_date")
  3518. )
  3519. if actual_date != expected_date:
  3520. raise ValueError("切换趸交后待缴应缴时间须与趸交截止时间一致")
  3521. if installment_plan:
  3522. locked_ids = {
  3523. item.id for item in existing
  3524. if not _is_draft_payment_detail(item)
  3525. }
  3526. editable_installments = []
  3527. for item in effective_details:
  3528. if str(item.get("id") or "") in locked_ids:
  3529. continue
  3530. installment_no = int(item.get("installment_no") or 0)
  3531. if installment_no < 1 or installment_no > count:
  3532. raise ValueError("变更缴费计划时待缴明细期号无效")
  3533. editable_installments.append((item, installment_no))
  3534. editable_installments.sort(key=lambda entry: entry[1])
  3535. planned_amounts = resolve_locked_remaining_due_amounts(
  3536. contract_amount,
  3537. locked_due,
  3538. ratios,
  3539. [number - 1 for _, number in editable_installments],
  3540. )
  3541. for (item, installment_no), expected_amount in zip(
  3542. editable_installments, planned_amounts
  3543. ):
  3544. actual_amount = Decimal(str(item.get("due_amount")))
  3545. if actual_amount != expected_amount:
  3546. raise ValueError("变更缴费计划后待缴明细应缴金额与新方案不一致")
  3547. expected_date = calc_fq_installment_due_date(
  3548. pay.installment_start_date or "",
  3549. installment_no - 1,
  3550. pay.installment_interval_months or Decimal("0"),
  3551. (
  3552. 30
  3553. if pay.installment_advance_days is None
  3554. else int(pay.installment_advance_days)
  3555. ),
  3556. )
  3557. actual_date = _normalize_yyyymmdd_date(
  3558. item.get("installment_due_date")
  3559. )
  3560. if expected_date and actual_date != expected_date:
  3561. raise ValueError("变更缴费计划后待缴明细应缴时间与新方案不一致")
  3562. if _payment_details_payload_unchanged(existing, effective_details):
  3563. return existing
  3564. retained_locked: List[BizProtocolPaymentDetail] = []
  3565. for d in existing:
  3566. if d.id not in submitted_ids:
  3567. if _is_draft_payment_detail(d):
  3568. d.del_flag = 1
  3569. d.update_by = user_id
  3570. d.update_time = datetime.now()
  3571. db.add(d)
  3572. else:
  3573. retained_locked.append(d)
  3574. _STR_FIELDS = ["transfer_type"]
  3575. _STR_DATE_FIELDS = ["installment_due_date"]
  3576. _DEC_FIELDS = ["due_amount", "transfer_amount"]
  3577. _INT_FIELDS = ["installment_no"]
  3578. result: List[BizProtocolPaymentDetail] = []
  3579. for idx, raw in enumerate(normalized_details):
  3580. detail_id = str(raw.get("id") or "").strip()
  3581. obj = existing_by_id.get(detail_id)
  3582. if obj is not None and not _is_draft_payment_detail(obj):
  3583. result.append(obj)
  3584. continue
  3585. has_transfer_attachments = any(
  3586. key in raw for key in ("attachments", "transfer_attachments")
  3587. )
  3588. transfer_att = _pop_attachment_urls(
  3589. raw, "attachments", "transfer_attachments", "transferAttachments"
  3590. )
  3591. transfer_time = raw.get("transfer_time")
  3592. if isinstance(transfer_time, str):
  3593. transfer_time = parse_datetime(transfer_time)
  3594. installment_no = raw.get("installment_no")
  3595. if (
  3596. installment_no is None
  3597. and raw.get("installment_due_date")
  3598. and pay is not None
  3599. and (pay.pay_mode or "") in ("fq", "djfq", "fgd")
  3600. ):
  3601. installment_no = idx + 1
  3602. if obj is None:
  3603. obj = BizProtocolPaymentDetail(
  3604. id=generate_uuid(),
  3605. payment_id=payment_id,
  3606. tenant_no=tenant_no,
  3607. create_by=user_id,
  3608. create_dept_id="",
  3609. create_time=datetime.now(),
  3610. del_flag=0,
  3611. pay_status="dfj",
  3612. )
  3613. obj.order_num = idx
  3614. obj.remark = (raw.get("remark") or "").strip() or None
  3615. obj.update_by = user_id
  3616. obj.update_time = datetime.now()
  3617. obj.transfer_time = transfer_time
  3618. obj.installment_no = installment_no
  3619. if has_transfer_attachments:
  3620. obj.transfer_attachments = transfer_att
  3621. for k in _STR_FIELDS:
  3622. if k in raw:
  3623. setattr(obj, k, (raw[k] or "").strip() or None)
  3624. for k in _STR_DATE_FIELDS:
  3625. if k in raw:
  3626. setattr(obj, k, _validate_yyyymmdd_date(raw[k], "缴费明细"))
  3627. for k in _DEC_FIELDS:
  3628. if k in raw and raw[k] is not None:
  3629. setattr(obj, k, Decimal(str(raw[k])))
  3630. for k in _INT_FIELDS:
  3631. if k in raw and raw[k] is not None:
  3632. setattr(obj, k, raw[k])
  3633. db.add(obj)
  3634. result.append(obj)
  3635. result.extend(retained_locked)
  3636. if commit:
  3637. db.commit()
  3638. for o in result:
  3639. db.refresh(o)
  3640. else:
  3641. db.flush()
  3642. return result
  3643. def save_payment_with_details(
  3644. db: Session,
  3645. collection_protocol_id: str,
  3646. tenant_no: str,
  3647. user_id: Optional[str],
  3648. *,
  3649. payment: Optional[dict],
  3650. payment_details: Optional[list],
  3651. commit: bool = True,
  3652. ) -> Optional[BizProtocolPayment]:
  3653. """原子保存 v1 父缴费方案与明细,供 Web/移动端聚合表单复用。"""
  3654. try:
  3655. current_pay = (
  3656. db.query(BizProtocolPayment)
  3657. .filter(
  3658. BizProtocolPayment.collection_protocol_id
  3659. == collection_protocol_id,
  3660. BizProtocolPayment.tenant_no == tenant_no,
  3661. BizProtocolPayment.del_flag == 0,
  3662. )
  3663. .populate_existing()
  3664. .with_for_update()
  3665. .first()
  3666. )
  3667. current_details = (
  3668. db.query(BizProtocolPaymentDetail)
  3669. .filter(
  3670. BizProtocolPaymentDetail.payment_id == current_pay.id,
  3671. BizProtocolPaymentDetail.tenant_no == tenant_no,
  3672. BizProtocolPaymentDetail.del_flag == 0,
  3673. )
  3674. .populate_existing()
  3675. .with_for_update()
  3676. .all()
  3677. if current_pay is not None
  3678. else []
  3679. )
  3680. if payment_details is not None and current_pay is None and not payment:
  3681. raise ValueError("提交缴费明细前必须先提交缴费主表方案")
  3682. changed_plan_fields = (
  3683. _payment_plan_changed_fields(current_pay, payment)
  3684. if current_pay is not None and payment
  3685. else set()
  3686. )
  3687. plan_changed = bool(changed_plan_fields)
  3688. has_locked_details = any(
  3689. not _is_draft_payment_detail(item) for item in current_details
  3690. )
  3691. if current_pay is not None and payment and has_locked_details:
  3692. _assert_locked_payment_plan_transition_allowed(
  3693. current_pay,
  3694. payment,
  3695. [item for item in current_details if not _is_draft_payment_detail(item)],
  3696. [item for item in current_details if _is_draft_payment_detail(item)],
  3697. changed_plan_fields,
  3698. )
  3699. if plan_changed and has_locked_details and payment_details is None:
  3700. raise ValueError("存在已缴或锁定明细,变更缴费计划须同步提交明细")
  3701. pay: Optional[BizProtocolPayment] = None
  3702. if payment:
  3703. pay = upsert_payment(
  3704. db,
  3705. collection_protocol_id,
  3706. tenant_no,
  3707. user_id,
  3708. commit=False,
  3709. **payment,
  3710. )
  3711. if payment_details is not None:
  3712. if not pay:
  3713. pay = get_payment_by_protocol_id(
  3714. db, collection_protocol_id, tenant_no
  3715. )
  3716. if pay:
  3717. aligned_details = align_fq_payment_details_payload(
  3718. pay, payment_details
  3719. )
  3720. save_payment_details(
  3721. db,
  3722. pay.id,
  3723. tenant_no,
  3724. user_id,
  3725. aligned_details,
  3726. commit=False,
  3727. enforce_locked_plan_consistency=(
  3728. plan_changed and has_locked_details
  3729. ),
  3730. )
  3731. if commit:
  3732. db.commit()
  3733. if pay:
  3734. db.refresh(pay)
  3735. else:
  3736. db.flush()
  3737. return pay
  3738. except Exception:
  3739. if commit:
  3740. db.rollback()
  3741. raise
  3742. def upsert_storage_cert(
  3743. db: Session,
  3744. collection_protocol_id: str,
  3745. tenant_no: str,
  3746. user_id: Optional[str],
  3747. **fields,
  3748. ) -> BizProtocolStorageCert:
  3749. fields = _normalize_fields(fields)
  3750. obj = get_storage_cert(db, collection_protocol_id, tenant_no)
  3751. _STR_FIELDS = ["make_status", "cert_image_urls", "make_date", "handler_user_id"]
  3752. if obj:
  3753. for k in _STR_FIELDS:
  3754. if k in fields:
  3755. setattr(obj, k, (fields[k] or "").strip() or None if isinstance(fields[k], str) or fields[k] is None else fields[k])
  3756. if "handle_time" in fields:
  3757. obj.handle_time = fields.get("handle_time")
  3758. if "remark" in fields:
  3759. obj.remark = (fields["remark"] or "").strip() or None
  3760. obj.update_by = user_id
  3761. obj.update_time = datetime.now()
  3762. db.add(obj)
  3763. db.commit()
  3764. db.refresh(obj)
  3765. return obj
  3766. obj = BizProtocolStorageCert(
  3767. id=generate_uuid(),
  3768. collection_protocol_id=collection_protocol_id,
  3769. tenant_no=tenant_no,
  3770. order_num=0,
  3771. remark=(fields.get("remark") or "").strip() or None,
  3772. create_by=user_id,
  3773. create_dept_id="",
  3774. create_time=datetime.now(),
  3775. update_by=user_id,
  3776. update_time=datetime.now(),
  3777. del_flag=0,
  3778. )
  3779. for k in _STR_FIELDS:
  3780. if k in fields:
  3781. val = fields[k]
  3782. setattr(obj, k, (val or "").strip() or None if isinstance(val, str) or val is None else val)
  3783. if "handle_time" in fields:
  3784. obj.handle_time = fields.get("handle_time")
  3785. db.add(obj)
  3786. db.commit()
  3787. db.refresh(obj)
  3788. return obj
  3789. def upsert_storage_cert_handle(
  3790. db: Session,
  3791. collection_protocol_id: str,
  3792. tenant_no: str,
  3793. user_id: Optional[str],
  3794. *,
  3795. handle_time: Optional[datetime] = None,
  3796. handler_user_id: Optional[str] = None,
  3797. cert_image_urls: Optional[str] = None,
  3798. remark: Optional[str] = None,
  3799. ) -> BizProtocolStorageCert:
  3800. from app.services.sample_info_progress import apply_handle_payload
  3801. payload = apply_handle_payload(
  3802. handle_time=handle_time,
  3803. handler_user_id=handler_user_id,
  3804. attachments=cert_image_urls or "",
  3805. remark=remark or "",
  3806. )
  3807. return upsert_storage_cert(
  3808. db,
  3809. collection_protocol_id,
  3810. tenant_no,
  3811. user_id,
  3812. handle_time=payload["handle_time"],
  3813. handler_user_id=payload["handler_user_id"],
  3814. make_status=payload["make_or_todo_status"],
  3815. make_date=payload["legacy_date"] or None,
  3816. cert_image_urls=payload["attachments"] or None,
  3817. remark=payload["remark"] or None,
  3818. )
  3819. # ---------------------------------------------------------------------------
  3820. # 子表:满月待办
  3821. # ---------------------------------------------------------------------------
  3822. def _fullmoon_todo_empty_dict() -> Dict[str, Any]:
  3823. return {
  3824. "todoStatus": "0",
  3825. "handleDate": "",
  3826. "handleTime": "",
  3827. "handlerUserId": "",
  3828. "handled": False,
  3829. "handleRemark": "",
  3830. "insuranceAttachments": "",
  3831. "insuranceCompanyId": "",
  3832. "insuranceCompanyName": "",
  3833. "insuranceType": "",
  3834. "insurancePolicyNo": "",
  3835. "insuranceEffectiveDate": "",
  3836. "insuranceExpiryDate": "",
  3837. "insuranceSubscribed": "0",
  3838. "insuranceFee": None,
  3839. "fullmoonDate": "",
  3840. "isFullmoonPassed": False,
  3841. }
  3842. def _fullmoon_todo_detail_dict(
  3843. ft: Optional[BizProtocolFullmoonTodo],
  3844. *,
  3845. newborn_delivery_time: Optional[datetime] = None,
  3846. ) -> Dict[str, Any]:
  3847. """协议详情 bundle 用:满月待办字段 + 由分娩日推算的满月日期。"""
  3848. base = _fullmoon_todo_to_dict(ft) if ft else dict(_fullmoon_todo_empty_dict())
  3849. fullmoon_date_str = ""
  3850. is_fullmoon_passed = False
  3851. if newborn_delivery_time:
  3852. fullmoon_date = newborn_delivery_time + timedelta(days=30)
  3853. fullmoon_date_str = fullmoon_date.strftime("%Y-%m-%d")
  3854. is_fullmoon_passed = fullmoon_date <= datetime.now()
  3855. base["fullmoonDate"] = fullmoon_date_str
  3856. base["isFullmoonPassed"] = is_fullmoon_passed
  3857. return base
  3858. def _fullmoon_todo_to_dict(ft: BizProtocolFullmoonTodo) -> Dict[str, Any]:
  3859. from app.services.sample_info_progress import insurance_handled
  3860. return {
  3861. "id": ft.id,
  3862. "collectionProtocolId": ft.collection_protocol_id,
  3863. "todoStatus": ft.todo_status or "0",
  3864. "handleDate": (ft.handle_date or "").strip() if ft.handle_date else "",
  3865. "handleTime": _fmt_dt(ft.handle_time) if getattr(ft, "handle_time", None) else "",
  3866. "handlerUserId": getattr(ft, "handler_user_id", None) or "",
  3867. "handled": insurance_handled(ft),
  3868. "handleRemark": ft.handle_remark or "",
  3869. "insuranceAttachments": ft.insurance_attachments or "",
  3870. "insuranceCompanyId": ft.insurance_company_id or "",
  3871. "insuranceCompanyName": ft.insurance_company_name or "",
  3872. "insuranceType": ft.insurance_type or "",
  3873. "insurancePolicyNo": ft.insurance_policy_no or "",
  3874. "insuranceEffectiveDate": (ft.insurance_effective_date or "").strip() if ft.insurance_effective_date else "",
  3875. "insuranceExpiryDate": (ft.insurance_expiry_date or "").strip() if ft.insurance_expiry_date else "",
  3876. "insuranceSubscribed": ft.insurance_subscribed or "0",
  3877. "insuranceFee": _dec(ft.insurance_fee),
  3878. "tenantNo": ft.tenant_no,
  3879. "orderNum": ft.order_num if ft.order_num is not None else 0,
  3880. "remark": ft.remark or "",
  3881. "createTime": _fmt_dt(ft.create_time),
  3882. "updateTime": _fmt_dt(ft.update_time),
  3883. }
  3884. def get_fullmoon_todo(
  3885. db: Session,
  3886. collection_protocol_id: str,
  3887. tenant_no: str,
  3888. ) -> Optional[BizProtocolFullmoonTodo]:
  3889. return (
  3890. db.query(BizProtocolFullmoonTodo)
  3891. .filter(
  3892. and_(
  3893. BizProtocolFullmoonTodo.collection_protocol_id == collection_protocol_id,
  3894. BizProtocolFullmoonTodo.tenant_no == tenant_no,
  3895. BizProtocolFullmoonTodo.del_flag == 0,
  3896. )
  3897. )
  3898. .first()
  3899. )
  3900. def upsert_fullmoon_todo(
  3901. db: Session,
  3902. collection_protocol_id: str,
  3903. tenant_no: str,
  3904. user_id: Optional[str],
  3905. **fields,
  3906. ) -> BizProtocolFullmoonTodo:
  3907. fields = _normalize_fields(fields)
  3908. obj = get_fullmoon_todo(db, collection_protocol_id, tenant_no)
  3909. _STR_FIELDS = [
  3910. "todo_status", "handle_date", "handle_remark", "handler_user_id",
  3911. "insurance_attachments", "insurance_company_id",
  3912. "insurance_company_name", "insurance_type",
  3913. "insurance_policy_no", "insurance_effective_date",
  3914. "insurance_expiry_date", "insurance_subscribed",
  3915. ]
  3916. _DEC_FIELDS = ["insurance_fee"]
  3917. if obj:
  3918. for k in _STR_FIELDS:
  3919. if k in fields:
  3920. setattr(obj, k, (fields[k] or "").strip() or None)
  3921. if "handle_time" in fields:
  3922. obj.handle_time = fields.get("handle_time")
  3923. for k in _DEC_FIELDS:
  3924. if k in fields and fields[k] is not None:
  3925. setattr(obj, k, Decimal(str(fields[k])))
  3926. if "remark" in fields:
  3927. obj.remark = (fields["remark"] or "").strip() or None
  3928. obj.update_by = user_id
  3929. obj.update_time = datetime.now()
  3930. db.add(obj)
  3931. db.commit()
  3932. db.refresh(obj)
  3933. return obj
  3934. obj = BizProtocolFullmoonTodo(
  3935. id=generate_uuid(),
  3936. collection_protocol_id=collection_protocol_id,
  3937. tenant_no=tenant_no,
  3938. order_num=0,
  3939. remark=(fields.get("remark") or "").strip() or None,
  3940. create_by=user_id,
  3941. create_dept_id="",
  3942. create_time=datetime.now(),
  3943. update_by=user_id,
  3944. update_time=datetime.now(),
  3945. del_flag=0,
  3946. )
  3947. for k in _STR_FIELDS:
  3948. if k in fields:
  3949. setattr(obj, k, (fields[k] or "").strip() or None)
  3950. if "handle_time" in fields:
  3951. obj.handle_time = fields.get("handle_time")
  3952. for k in _DEC_FIELDS:
  3953. if k in fields and fields[k] is not None:
  3954. setattr(obj, k, Decimal(str(fields[k])))
  3955. db.add(obj)
  3956. db.commit()
  3957. db.refresh(obj)
  3958. return obj
  3959. def upsert_fullmoon_todo_handle(
  3960. db: Session,
  3961. collection_protocol_id: str,
  3962. tenant_no: str,
  3963. user_id: Optional[str],
  3964. *,
  3965. handle_time: Optional[datetime] = None,
  3966. handler_user_id: Optional[str] = None,
  3967. insurance_attachments: Optional[str] = None,
  3968. handle_remark: Optional[str] = None,
  3969. ) -> BizProtocolFullmoonTodo:
  3970. from app.services.sample_info_progress import apply_handle_payload
  3971. payload = apply_handle_payload(
  3972. handle_time=handle_time,
  3973. handler_user_id=handler_user_id,
  3974. attachments=insurance_attachments or "",
  3975. remark=handle_remark or "",
  3976. )
  3977. return upsert_fullmoon_todo(
  3978. db,
  3979. collection_protocol_id,
  3980. tenant_no,
  3981. user_id,
  3982. handle_time=payload["handle_time"],
  3983. handler_user_id=payload["handler_user_id"],
  3984. todo_status=payload["make_or_todo_status"],
  3985. handle_date=payload["legacy_date"] or None,
  3986. insurance_attachments=payload["attachments"] or None,
  3987. handle_remark=payload["remark"] or None,
  3988. )
  3989. # ---------------------------------------------------------------------------
  3990. # 开票申请(主表 + 明细子表)
  3991. # ---------------------------------------------------------------------------
  3992. def build_installment_summary(installment_nos: List[Optional[int]]) -> str:
  3993. nums = sorted({n for n in installment_nos if n is not None})
  3994. if not nums:
  3995. return "趸交"
  3996. if len(nums) == 1:
  3997. return f"第{nums[0]}期"
  3998. return "第" + "、".join(str(n) for n in nums) + "期"
  3999. def _invoice_request_item_to_dict(
  4000. item: BizProtocolInvoiceRequestItem,
  4001. *,
  4002. payment_detail: Optional[BizProtocolPaymentDetail] = None,
  4003. ) -> Dict[str, Any]:
  4004. out: Dict[str, Any] = {
  4005. "id": item.id,
  4006. "invoiceRequestId": item.invoice_request_id,
  4007. "paymentId": item.payment_id,
  4008. "paymentDetailId": item.payment_detail_id,
  4009. "installmentNo": item.installment_no,
  4010. "lineAmount": float(item.line_amount) if item.line_amount is not None else None,
  4011. "orderNum": item.order_num,
  4012. "remark": item.remark or "",
  4013. "createTime": item.create_time.strftime("%Y-%m-%d %H:%M:%S") if item.create_time else "",
  4014. }
  4015. if payment_detail is not None:
  4016. out["transferTime"] = (
  4017. payment_detail.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
  4018. if payment_detail.transfer_time
  4019. else ""
  4020. )
  4021. out["transferType"] = payment_detail.transfer_type or ""
  4022. out["installmentDueDate"] = payment_detail.installment_due_date or ""
  4023. return out
  4024. def _invoice_request_to_dict(
  4025. req: BizProtocolInvoiceRequest,
  4026. *,
  4027. items: Optional[List[Dict[str, Any]]] = None,
  4028. installment_summary: Optional[str] = None,
  4029. protocol_ctx: Optional[Dict[str, Any]] = None,
  4030. ) -> Dict[str, Any]:
  4031. out: Dict[str, Any] = {
  4032. "id": req.id,
  4033. "collectionProtocolId": req.collection_protocol_id,
  4034. "invoiceAmount": float(req.invoice_amount) if req.invoice_amount is not None else None,
  4035. "invoiceTitle": req.invoice_title or "",
  4036. "taxNo": req.tax_no or "",
  4037. "companyAddress": req.company_address or "",
  4038. "companyPhone": req.company_phone or "",
  4039. "bankName": req.bank_name or "",
  4040. "bankAccount": req.bank_account or "",
  4041. "applyRemark": req.apply_remark or "",
  4042. "invoiceAttachments": req.invoice_attachments or "",
  4043. "financeRemark": req.finance_remark or "",
  4044. "status": req.status,
  4045. "financeHandleTime": (
  4046. req.finance_handle_time.strftime("%Y-%m-%d %H:%M:%S") if req.finance_handle_time else ""
  4047. ),
  4048. "tenantNo": req.tenant_no,
  4049. "orderNum": req.order_num,
  4050. "remark": req.remark or "",
  4051. "createTime": req.create_time.strftime("%Y-%m-%d %H:%M:%S") if req.create_time else "",
  4052. "updateTime": req.update_time.strftime("%Y-%m-%d %H:%M:%S") if req.update_time else "",
  4053. }
  4054. if items is not None:
  4055. out["items"] = items
  4056. out["itemCount"] = len(items)
  4057. if installment_summary is not None:
  4058. out["installmentSummary"] = installment_summary
  4059. if protocol_ctx:
  4060. out.update(protocol_ctx)
  4061. return out
  4062. def _active_invoiced_payment_detail_ids(db: Session, tenant_no: str) -> set:
  4063. rows = (
  4064. db.query(BizProtocolInvoiceRequestItem.payment_detail_id)
  4065. .join(
  4066. BizProtocolInvoiceRequest,
  4067. BizProtocolInvoiceRequest.id == BizProtocolInvoiceRequestItem.invoice_request_id,
  4068. )
  4069. .filter(
  4070. BizProtocolInvoiceRequest.tenant_no == tenant_no,
  4071. BizProtocolInvoiceRequest.del_flag == 0,
  4072. BizProtocolInvoiceRequest.status.in_(("dkp", "ykp")),
  4073. BizProtocolInvoiceRequestItem.del_flag == 0,
  4074. )
  4075. .all()
  4076. )
  4077. return {r[0] for r in rows}
  4078. def _pack_invoice_request_with_items(
  4079. db: Session,
  4080. req: BizProtocolInvoiceRequest,
  4081. *,
  4082. tenant_no: str,
  4083. with_protocol_ctx: bool = False,
  4084. ) -> Dict[str, Any]:
  4085. items_orm = (
  4086. db.query(BizProtocolInvoiceRequestItem)
  4087. .filter(
  4088. BizProtocolInvoiceRequestItem.invoice_request_id == req.id,
  4089. BizProtocolInvoiceRequestItem.del_flag == 0,
  4090. )
  4091. .order_by(BizProtocolInvoiceRequestItem.order_num.asc())
  4092. .all()
  4093. )
  4094. detail_ids = [i.payment_detail_id for i in items_orm]
  4095. detail_map: Dict[str, BizProtocolPaymentDetail] = {}
  4096. if detail_ids:
  4097. details = (
  4098. db.query(BizProtocolPaymentDetail)
  4099. .filter(
  4100. BizProtocolPaymentDetail.id.in_(detail_ids),
  4101. BizProtocolPaymentDetail.del_flag == 0,
  4102. )
  4103. .all()
  4104. )
  4105. detail_map = {d.id: d for d in details}
  4106. item_dicts = [
  4107. _invoice_request_item_to_dict(i, payment_detail=detail_map.get(i.payment_detail_id))
  4108. for i in items_orm
  4109. ]
  4110. summary = build_installment_summary([i.installment_no for i in items_orm])
  4111. protocol_ctx: Optional[Dict[str, Any]] = None
  4112. if with_protocol_ctx:
  4113. prot = (
  4114. db.query(BizCollectionProtocol)
  4115. .filter(
  4116. BizCollectionProtocol.id == req.collection_protocol_id,
  4117. BizCollectionProtocol.del_flag == 0,
  4118. )
  4119. .first()
  4120. )
  4121. pc_map = _party_customer_map_by_protocol_ids(db, [req.collection_protocol_id])
  4122. pc = pc_map.get(req.collection_protocol_id)
  4123. protocol_ctx = {
  4124. "protocolNo": (prot.protocol_no if prot else "") or "",
  4125. "protocolStatus": (prot.protocol_status if prot else "") or "",
  4126. "motherName": (pc.mother_name if pc else "") or "",
  4127. "motherIdCard": (pc.mother_id_card if pc else "") or "",
  4128. "newbornName": (pc.newborn_name if pc else "") or "",
  4129. "newbornIdCard": (pc.newborn_id_card if pc else "") or "",
  4130. }
  4131. out = _invoice_request_to_dict(
  4132. req, items=item_dicts, installment_summary=summary, protocol_ctx=protocol_ctx
  4133. )
  4134. if req.status == "ykp" and req.invoice_attachments:
  4135. out["invoiceAttachmentFiles"] = urls_to_attachment_list(
  4136. req.invoice_attachments, db, tenant_no
  4137. )
  4138. else:
  4139. out["invoiceAttachmentFiles"] = []
  4140. return out
  4141. def list_invoice_eligible_payment_details(
  4142. db: Session,
  4143. *,
  4144. collection_protocol_id: str,
  4145. tenant_no: str,
  4146. ) -> List[Dict[str, Any]]:
  4147. if not tenant_no or not collection_protocol_id:
  4148. return []
  4149. invoiced_ids = _active_invoiced_payment_detail_ids(db, tenant_no)
  4150. payment = (
  4151. db.query(BizProtocolPayment)
  4152. .filter(
  4153. BizProtocolPayment.collection_protocol_id == collection_protocol_id,
  4154. BizProtocolPayment.tenant_no == tenant_no,
  4155. BizProtocolPayment.del_flag == 0,
  4156. )
  4157. .first()
  4158. )
  4159. if not payment:
  4160. return []
  4161. details = (
  4162. db.query(BizProtocolPaymentDetail)
  4163. .filter(
  4164. BizProtocolPaymentDetail.payment_id == payment.id,
  4165. BizProtocolPaymentDetail.tenant_no == tenant_no,
  4166. BizProtocolPaymentDetail.del_flag == 0,
  4167. BizProtocolPaymentDetail.pay_status == "yjf",
  4168. )
  4169. .order_by(
  4170. BizProtocolPaymentDetail.order_num.asc(),
  4171. BizProtocolPaymentDetail.create_time.desc(),
  4172. )
  4173. .all()
  4174. )
  4175. out: List[Dict[str, Any]] = []
  4176. for d in details:
  4177. if d.id in invoiced_ids:
  4178. continue
  4179. out.append(
  4180. {
  4181. "id": d.id,
  4182. "paymentId": payment.id,
  4183. "installmentNo": d.installment_no,
  4184. "dueAmount": float(d.due_amount) if d.due_amount is not None else None,
  4185. "transferAmount": float(d.transfer_amount) if d.transfer_amount is not None else None,
  4186. "transferTime": (
  4187. d.transfer_time.strftime("%Y-%m-%d %H:%M:%S") if d.transfer_time else ""
  4188. ),
  4189. "transferType": d.transfer_type or "",
  4190. "installmentDueDate": d.installment_due_date or "",
  4191. }
  4192. )
  4193. return out
  4194. def list_invoice_requests_by_protocol(
  4195. db: Session,
  4196. *,
  4197. collection_protocol_id: str,
  4198. tenant_no: str,
  4199. ) -> List[Dict[str, Any]]:
  4200. rows = (
  4201. db.query(BizProtocolInvoiceRequest)
  4202. .filter(
  4203. BizProtocolInvoiceRequest.collection_protocol_id == collection_protocol_id,
  4204. BizProtocolInvoiceRequest.tenant_no == tenant_no,
  4205. BizProtocolInvoiceRequest.del_flag == 0,
  4206. )
  4207. .order_by(BizProtocolInvoiceRequest.create_time.desc())
  4208. .all()
  4209. )
  4210. return [_pack_invoice_request_with_items(db, r, tenant_no=tenant_no) for r in rows]
  4211. def create_invoice_request(
  4212. db: Session,
  4213. *,
  4214. collection_protocol_id: str,
  4215. payment_detail_ids: List[str],
  4216. tenant_no: str,
  4217. user_info: Optional[dict] = None,
  4218. invoice_title: Optional[str] = None,
  4219. tax_no: Optional[str] = None,
  4220. company_address: Optional[str] = None,
  4221. company_phone: Optional[str] = None,
  4222. bank_name: Optional[str] = None,
  4223. bank_account: Optional[str] = None,
  4224. apply_remark: Optional[str] = None,
  4225. ) -> Dict[str, Any]:
  4226. ids = list(dict.fromkeys([str(i).strip() for i in (payment_detail_ids or []) if str(i).strip()]))
  4227. if not ids:
  4228. raise ValueError("请至少选择一条缴费明细")
  4229. invoiced_ids = _active_invoiced_payment_detail_ids(db, tenant_no)
  4230. overlap = set(ids) & invoiced_ids
  4231. if overlap:
  4232. raise ValueError("所选缴费明细中存在已申请开票的记录")
  4233. payment = (
  4234. db.query(BizProtocolPayment)
  4235. .filter(
  4236. BizProtocolPayment.collection_protocol_id == collection_protocol_id,
  4237. BizProtocolPayment.tenant_no == tenant_no,
  4238. BizProtocolPayment.del_flag == 0,
  4239. )
  4240. .first()
  4241. )
  4242. if not payment:
  4243. raise ValueError("未找到缴费信息")
  4244. details = (
  4245. db.query(BizProtocolPaymentDetail)
  4246. .filter(
  4247. BizProtocolPaymentDetail.payment_id == payment.id,
  4248. BizProtocolPaymentDetail.id.in_(ids),
  4249. BizProtocolPaymentDetail.tenant_no == tenant_no,
  4250. BizProtocolPaymentDetail.del_flag == 0,
  4251. BizProtocolPaymentDetail.pay_status == "yjf",
  4252. )
  4253. .all()
  4254. )
  4255. if len(details) != len(ids):
  4256. raise ValueError("部分缴费明细不可开票")
  4257. detail_map = {d.id: d for d in details}
  4258. ordered_details = [detail_map[i] for i in ids]
  4259. uid = (user_info or {}).get("user_id") or ""
  4260. dept_id = (user_info or {}).get("dept_id") or (user_info or {}).get("create_dept_id") or ""
  4261. req = BizProtocolInvoiceRequest(
  4262. id=generate_uuid(),
  4263. collection_protocol_id=collection_protocol_id,
  4264. invoice_amount=Decimal("0"),
  4265. invoice_title=(invoice_title or "").strip() or None,
  4266. tax_no=(tax_no or "").strip() or None,
  4267. company_address=(company_address or "").strip() or None,
  4268. company_phone=(company_phone or "").strip() or None,
  4269. bank_name=(bank_name or "").strip() or None,
  4270. bank_account=(bank_account or "").strip() or None,
  4271. apply_remark=(apply_remark or "").strip() or None,
  4272. status="dkp",
  4273. tenant_no=tenant_no,
  4274. create_by=uid,
  4275. create_dept_id=dept_id,
  4276. update_by=uid,
  4277. )
  4278. db.add(req)
  4279. db.flush()
  4280. total = Decimal("0")
  4281. for idx, d in enumerate(ordered_details, start=1):
  4282. line_amt = Decimal(str(d.transfer_amount or 0))
  4283. total += line_amt
  4284. db.add(
  4285. BizProtocolInvoiceRequestItem(
  4286. id=generate_uuid(),
  4287. invoice_request_id=req.id,
  4288. payment_id=payment.id,
  4289. payment_detail_id=d.id,
  4290. installment_no=d.installment_no,
  4291. line_amount=line_amt,
  4292. tenant_no=tenant_no,
  4293. order_num=idx,
  4294. create_by=uid,
  4295. create_dept_id=dept_id,
  4296. update_by=uid,
  4297. )
  4298. )
  4299. req.invoice_amount = total
  4300. db.commit()
  4301. db.refresh(req)
  4302. return _pack_invoice_request_with_items(db, req, tenant_no=tenant_no)
  4303. def _invoice_request_list_query(
  4304. db: Session,
  4305. *,
  4306. tenant_no: str,
  4307. status: str,
  4308. keyword: Optional[str] = None,
  4309. ):
  4310. q = db.query(BizProtocolInvoiceRequest).filter(
  4311. BizProtocolInvoiceRequest.tenant_no == tenant_no,
  4312. BizProtocolInvoiceRequest.del_flag == 0,
  4313. BizProtocolInvoiceRequest.status == status,
  4314. )
  4315. kw = (keyword or "").strip()
  4316. if kw:
  4317. like = f"%{kw}%"
  4318. party_q = db.query(BizProtocolPartyCustomer.collection_protocol_id).filter(
  4319. BizProtocolPartyCustomer.del_flag == 0,
  4320. BizProtocolPartyCustomer.tenant_no == tenant_no,
  4321. or_(
  4322. BizProtocolPartyCustomer.mother_name.like(like),
  4323. BizProtocolPartyCustomer.mother_id_card.like(like),
  4324. BizProtocolPartyCustomer.newborn_name.like(like),
  4325. BizProtocolPartyCustomer.newborn_id_card.like(like),
  4326. ),
  4327. )
  4328. q = q.filter(BizProtocolInvoiceRequest.collection_protocol_id.in_(party_q))
  4329. return q
  4330. def list_protocol_invoice_requests(
  4331. db: Session,
  4332. *,
  4333. status: str,
  4334. page: int = 1,
  4335. page_size: int = 10,
  4336. keyword: Optional[str] = None,
  4337. tenant_no: Optional[str] = None,
  4338. user_info=None,
  4339. ) -> Tuple[List[Dict[str, Any]], int]:
  4340. if not tenant_no:
  4341. return [], 0
  4342. q_base = _invoice_request_list_query(db, tenant_no=tenant_no, status=status, keyword=keyword)
  4343. if user_info:
  4344. from app.core.data_scope import apply_data_scope
  4345. q_base = apply_data_scope(q_base, BizProtocolInvoiceRequest, user_info, db, tenant_no)
  4346. total = q_base.order_by(None).with_entities(func.count(BizProtocolInvoiceRequest.id)).scalar() or 0
  4347. page = max(1, page)
  4348. page_size = min(max(1, page_size), 100)
  4349. order_col = (
  4350. BizProtocolInvoiceRequest.finance_handle_time.desc()
  4351. if status == "ykp"
  4352. else BizProtocolInvoiceRequest.create_time.desc()
  4353. )
  4354. rows = (
  4355. q_base.order_by(order_col)
  4356. .offset((page - 1) * page_size)
  4357. .limit(page_size)
  4358. .all()
  4359. )
  4360. items = [
  4361. _pack_invoice_request_with_items(db, r, tenant_no=tenant_no, with_protocol_ctx=True)
  4362. for r in rows
  4363. ]
  4364. return items, int(total)
  4365. def count_pending_protocol_invoice_requests(
  4366. db: Session,
  4367. *,
  4368. tenant_no: str,
  4369. user_info=None,
  4370. ) -> int:
  4371. q = _invoice_request_list_query(db, tenant_no=tenant_no, status="dkp")
  4372. if user_info:
  4373. from app.core.data_scope import apply_data_scope
  4374. q = apply_data_scope(q, BizProtocolInvoiceRequest, user_info, db, tenant_no)
  4375. return int(q.with_entities(func.count(BizProtocolInvoiceRequest.id)).scalar() or 0)
  4376. def complete_invoice_request(
  4377. db: Session,
  4378. *,
  4379. request_id: str,
  4380. tenant_no: str,
  4381. invoice_attachments: str,
  4382. finance_remark: Optional[str] = None,
  4383. user_info: Optional[dict] = None,
  4384. ) -> Dict[str, Any]:
  4385. if not (invoice_attachments or "").strip():
  4386. raise ValueError("请上传发票附件")
  4387. req = (
  4388. db.query(BizProtocolInvoiceRequest)
  4389. .filter(
  4390. BizProtocolInvoiceRequest.id == request_id,
  4391. BizProtocolInvoiceRequest.tenant_no == tenant_no,
  4392. BizProtocolInvoiceRequest.del_flag == 0,
  4393. )
  4394. .first()
  4395. )
  4396. if not req:
  4397. raise ValueError("开票申请不存在")
  4398. if req.status != "dkp":
  4399. raise ValueError("当前状态不可完成开票")
  4400. uid = (user_info or {}).get("user_id") or ""
  4401. req.status = "ykp"
  4402. req.invoice_attachments = invoice_attachments.strip()
  4403. req.finance_remark = (finance_remark or "").strip() or None
  4404. req.finance_handle_time = datetime.now()
  4405. req.update_by = uid
  4406. req.update_time = datetime.now()
  4407. db.commit()
  4408. db.refresh(req)
  4409. return _pack_invoice_request_with_items(
  4410. db, req, tenant_no=tenant_no, with_protocol_ctx=True
  4411. )