market_protocol.py 78 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220
  1. """移动市场协议服务:双来源统一展示,写入保持来源内原子性。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timedelta
  4. from decimal import Decimal
  5. from typing import List, Optional, Tuple
  6. from fastapi import HTTPException
  7. from sqlalchemy import func, or_
  8. from sqlalchemy.orm import Session
  9. from app.core.utils import parse_datetime
  10. from app.core.protocol_status_display import (
  11. build_void_type_label_map,
  12. resolve_void_type_label,
  13. )
  14. from app.crud import collection_protocol as cell_crud
  15. from app.crud import protocol_sample_payment as psp_crud
  16. from app.crud import research_donation as donation_crud
  17. from app.crud import sample_freight as sample_freight_crud
  18. from app.models.models import (
  19. BizCellSampleType,
  20. BizCollectionHospital,
  21. BizCollectionProtocol,
  22. BizCollectionRegion,
  23. BizCollectionRegionStaff,
  24. BizCustomer,
  25. BizItemUnit,
  26. BizProtocolCollectionInfo,
  27. BizProtocolPartyCustomer,
  28. BizProtocolPayment,
  29. BizProtocolPaymentDetail,
  30. BizProtocolPaymentSchedule,
  31. BizProtocolPaymentTransfer,
  32. BizProtocolSample,
  33. BizProtocolSamplePayment,
  34. BizProtocolSampleTransport,
  35. BizSampleCode,
  36. SysUser,
  37. )
  38. from app.models.research_donation import (
  39. BizResearchDonation,
  40. BizResearchDonationCollection,
  41. BizResearchDonationParty,
  42. BizResearchDonationSample,
  43. BizResearchDonationSampleTransport,
  44. )
  45. from app.schemas.market_mobile import MarketProtocolBody
  46. from app.services.market_field_scope import (
  47. MarketFieldContext,
  48. apply_cell_protocol_scope,
  49. apply_donation_scope,
  50. assert_cell_protocol_access,
  51. assert_donation_access,
  52. )
  53. from app.services import market_freight
  54. from app.services.market_support import (
  55. _dict_options,
  56. allowed_actions,
  57. validate_attachment_refs,
  58. )
  59. SOURCE_CELL = 'cell_storage'
  60. SOURCE_DONATION = 'research_donation'
  61. def _fixed_money(value):
  62. if value is None:
  63. return None
  64. return format(Decimal(str(value)).quantize(Decimal('0.01')), 'f')
  65. SOURCES = (SOURCE_CELL, SOURCE_DONATION)
  66. _CUSTOMER_LINK_FIELDS = (
  67. 'customer_id',
  68. 'father_customer_id',
  69. 'newborn_customer_id',
  70. )
  71. def _current_dept_id(user_info: dict) -> str:
  72. if not isinstance(user_info, dict):
  73. return ''
  74. return str(
  75. user_info.get('dept_id')
  76. or user_info.get('deptId')
  77. or user_info.get('region_id')
  78. or ''
  79. ).strip()
  80. def _dump(value) -> dict:
  81. if value is None:
  82. return {}
  83. if hasattr(value, 'model_dump'):
  84. return value.model_dump(by_alias=False, exclude_unset=True)
  85. return dict(value)
  86. def _assert_customer_link_policy(
  87. db: Session,
  88. customer: dict,
  89. *,
  90. ctx: MarketFieldContext,
  91. user_info: dict,
  92. ) -> None:
  93. """允许绑定/改绑本租户数据权限内的客户主档;空值表示清空;禁止无效 id。"""
  94. from app.core.data_scope import apply_data_scope
  95. for key in _CUSTOMER_LINK_FIELDS:
  96. if key not in customer:
  97. continue
  98. supplied = str(customer.get(key) or '').strip()
  99. if not supplied:
  100. continue
  101. query = db.query(BizCustomer.id).filter(
  102. BizCustomer.id == supplied,
  103. BizCustomer.tenant_no == ctx.tenant_no,
  104. BizCustomer.del_flag == 0,
  105. )
  106. query = apply_data_scope(query, BizCustomer, user_info, db, ctx.tenant_no)
  107. if query.first() is None:
  108. raise ValueError('客户不存在或不在权限范围内')
  109. def _fmt_dt(value) -> str:
  110. if value is None:
  111. return ''
  112. if isinstance(value, str):
  113. return value
  114. return value.strftime('%Y-%m-%d %H:%M:%S')
  115. def _manager_assignment_allowed(
  116. db: Session,
  117. ctx: MarketFieldContext,
  118. *,
  119. region_id: Optional[str],
  120. region_manager_user_id: Optional[str],
  121. ) -> bool:
  122. region = str(region_id or '').strip()
  123. if not region:
  124. return False
  125. return (
  126. db.query(BizCollectionRegionStaff.id)
  127. .filter(
  128. BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
  129. BizCollectionRegionStaff.del_flag == 0,
  130. BizCollectionRegionStaff.user_id == ctx.user_id,
  131. BizCollectionRegionStaff.is_leader == 1,
  132. BizCollectionRegionStaff.region_id == region,
  133. )
  134. .first()
  135. is not None
  136. )
  137. def _ensure_manager_assignment(
  138. db: Session,
  139. ctx: MarketFieldContext,
  140. *,
  141. region_id: Optional[str],
  142. region_manager_user_id: Optional[str],
  143. ) -> None:
  144. if not _manager_assignment_allowed(
  145. db,
  146. ctx,
  147. region_id=region_id,
  148. region_manager_user_id=region_manager_user_id,
  149. ):
  150. raise ValueError('协议归属不在当前地区经理范围内')
  151. def _active_user_ids(db: Session, ctx: MarketFieldContext, ids: set) -> set:
  152. if not ids:
  153. return set()
  154. return {
  155. str(row[0])
  156. for row in db.query(SysUser.id)
  157. .filter(
  158. SysUser.id.in_(ids),
  159. SysUser.tenant_no == ctx.tenant_no,
  160. SysUser.del_flag == 0,
  161. SysUser.status == '1',
  162. )
  163. .all()
  164. }
  165. def _validate_references(
  166. db: Session,
  167. ctx: MarketFieldContext,
  168. *,
  169. customer: dict,
  170. collection: dict,
  171. samples: List[dict],
  172. extra_user_ids: Optional[set] = None,
  173. ) -> None:
  174. region_id = str(collection.get('region_id') or '').strip()
  175. hospital_id = str(collection.get('hospital_id') or '').strip()
  176. if region_id:
  177. exists = (
  178. db.query(BizCollectionRegion.id)
  179. .filter(
  180. BizCollectionRegion.id == region_id,
  181. BizCollectionRegion.tenant_no == ctx.tenant_no,
  182. BizCollectionRegion.del_flag == 0,
  183. )
  184. .first()
  185. )
  186. if not exists:
  187. raise ValueError('采集地区不存在或不属于当前租户')
  188. if hospital_id:
  189. exists = (
  190. db.query(BizCollectionHospital.id)
  191. .filter(
  192. BizCollectionHospital.id == hospital_id,
  193. BizCollectionHospital.region_id == region_id,
  194. BizCollectionHospital.tenant_no == ctx.tenant_no,
  195. BizCollectionHospital.del_flag == 0,
  196. )
  197. .first()
  198. )
  199. if not exists:
  200. raise ValueError('医院不存在、不属于当前租户或与采集地区不匹配')
  201. user_ids = {
  202. str(value).strip()
  203. for value in (
  204. customer.get('collector_user_id'),
  205. collection.get('sales_user_id'),
  206. collection.get('region_manager_user_id'),
  207. )
  208. if str(value or '').strip()
  209. }
  210. user_ids.update(
  211. str(value).strip()
  212. for value in (extra_user_ids or set())
  213. if str(value or '').strip()
  214. )
  215. if _active_user_ids(db, ctx, user_ids) != user_ids:
  216. raise ValueError('引用用户不存在、已停用或不属于当前租户')
  217. customer_ids = {
  218. str(customer.get(key) or '').strip()
  219. for key in ('customer_id', 'father_customer_id', 'newborn_customer_id')
  220. if str(customer.get(key) or '').strip()
  221. }
  222. if customer_ids:
  223. existing = {
  224. str(row[0])
  225. for row in db.query(BizCustomer.id)
  226. .filter(
  227. BizCustomer.id.in_(customer_ids),
  228. BizCustomer.tenant_no == ctx.tenant_no,
  229. BizCustomer.del_flag == 0,
  230. )
  231. .all()
  232. }
  233. if existing != customer_ids:
  234. raise ValueError('客户不存在或不属于当前租户')
  235. type_ids = {
  236. str(item.get('sample_type_id') or '').strip()
  237. for item in samples
  238. if str(item.get('sample_type_id') or '').strip()
  239. }
  240. if type_ids:
  241. existing = {
  242. str(row[0])
  243. for row in db.query(BizCellSampleType.id)
  244. .filter(
  245. BizCellSampleType.id.in_(type_ids),
  246. BizCellSampleType.tenant_no == ctx.tenant_no,
  247. BizCellSampleType.del_flag == 0,
  248. )
  249. .all()
  250. }
  251. if existing != type_ids:
  252. raise ValueError('样本类型不存在或不属于当前租户')
  253. unit_ids = {
  254. str(item.get('unit_id') or '').strip()
  255. for item in samples
  256. if str(item.get('unit_id') or '').strip()
  257. }
  258. if unit_ids:
  259. existing = {
  260. str(row[0])
  261. for row in db.query(BizItemUnit.id)
  262. .filter(
  263. BizItemUnit.id.in_(unit_ids),
  264. BizItemUnit.tenant_no == ctx.tenant_no,
  265. BizItemUnit.del_flag == 0,
  266. )
  267. .all()
  268. }
  269. if existing != unit_ids:
  270. raise ValueError('计量单位不存在或不属于当前租户')
  271. sample_code_ids = {
  272. str(item.get('sample_code_id') or '').strip()
  273. for item in samples
  274. if str(item.get('sample_code_id') or '').strip()
  275. }
  276. sample_code_map = {}
  277. if sample_code_ids:
  278. sample_code_map = {
  279. str(row.id): str(row.sample_code or '').strip()
  280. for row in db.query(BizSampleCode)
  281. .filter(
  282. BizSampleCode.id.in_(sample_code_ids),
  283. BizSampleCode.tenant_no == ctx.tenant_no,
  284. BizSampleCode.del_flag == 0,
  285. )
  286. .all()
  287. }
  288. if set(sample_code_map) != sample_code_ids:
  289. raise ValueError('样本编码不存在、已删除或不属于当前租户')
  290. for item in samples:
  291. sample_code_id = str(item.get('sample_code_id') or '').strip()
  292. if not sample_code_id:
  293. continue
  294. expected_code = sample_code_map[sample_code_id]
  295. supplied_code = str(item.get('sample_code') or '').strip()
  296. if supplied_code and supplied_code != expected_code:
  297. raise ValueError('样本编码ID与样本编码不一致')
  298. item['sample_code'] = expected_code
  299. def _ensure_donation_sample_codes(
  300. db: Session, samples: List[dict], *, tenant_no: str
  301. ) -> None:
  302. """科研捐赠:空样本编码时按 Web 规则自动生成。"""
  303. reserved: List[str] = []
  304. for item in samples:
  305. existing = str(item.get('sample_code') or '').strip()
  306. if existing:
  307. reserved.append(existing)
  308. continue
  309. code = donation_crud.next_donation_sample_code(
  310. db, tenant_no=tenant_no, extra_codes=reserved
  311. )
  312. item['sample_code'] = code
  313. reserved.append(code)
  314. def _normalize_write(
  315. db: Session,
  316. *,
  317. body: MarketProtocolBody,
  318. ctx: MarketFieldContext,
  319. current_collection=None,
  320. current_samples: Optional[List] = None,
  321. ) -> Tuple[dict, dict, List[dict], dict]:
  322. customer = _dump(body.customer)
  323. collection = _dump(body.collection)
  324. samples = [_dump(item) for item in (body.samples or [])]
  325. _validate_unique_sample_identity(samples)
  326. if body.samples is not None and current_samples is not None:
  327. samples = _merge_existing_sample_fields(current_samples, samples)
  328. elif any(str(item.get('id') or '').strip() for item in samples):
  329. raise ValueError('样本不存在或不属于当前协议')
  330. if body.source == SOURCE_DONATION:
  331. _ensure_donation_sample_codes(db, samples, tenant_no=ctx.tenant_no)
  332. # freight_draft 可能仍是嵌套 model;统一成 dict 供运费挂钩
  333. for item in samples:
  334. draft = item.get('freight_draft')
  335. if draft is not None and hasattr(draft, 'model_dump'):
  336. item['freight_draft'] = draft.model_dump(
  337. by_alias=False, exclude_unset=True
  338. )
  339. attachments = _dump(body.attachments)
  340. effective_collection = {
  341. key: getattr(current_collection, key, None)
  342. for key in (
  343. 'region_id',
  344. 'hospital_id',
  345. 'sales_user_id',
  346. 'region_manager_user_id',
  347. )
  348. }
  349. effective_collection.update(collection)
  350. if ctx.scope_kind == 'specialist':
  351. customer['collector_user_id'] = ctx.user_id
  352. collection['sales_user_id'] = ctx.user_id
  353. effective_collection['sales_user_id'] = ctx.user_id
  354. else:
  355. unchanged_direct_assignment = bool(
  356. current_collection is not None
  357. and str(getattr(current_collection, 'region_id', None) or '').strip()
  358. == str(effective_collection.get('region_id') or '').strip()
  359. and str(
  360. getattr(current_collection, 'region_manager_user_id', None) or ''
  361. ).strip()
  362. == ctx.user_id
  363. and str(effective_collection.get('region_manager_user_id') or '').strip()
  364. == ctx.user_id
  365. )
  366. if not unchanged_direct_assignment:
  367. _ensure_manager_assignment(
  368. db,
  369. ctx,
  370. region_id=effective_collection.get('region_id'),
  371. region_manager_user_id=effective_collection.get(
  372. 'region_manager_user_id'
  373. ),
  374. )
  375. for key in ('sample_collect_time', 'maternal_blood_collect_time'):
  376. if key in collection and isinstance(collection[key], str):
  377. collection[key] = parse_datetime(collection[key])
  378. if 'newborn_delivery_time' in customer and isinstance(
  379. customer['newborn_delivery_time'], str
  380. ):
  381. customer['newborn_delivery_time'] = parse_datetime(
  382. customer['newborn_delivery_time']
  383. )
  384. _validate_references(
  385. db,
  386. ctx,
  387. customer=customer,
  388. collection=effective_collection,
  389. samples=samples,
  390. extra_user_ids={body.collector_user_id}
  391. if ctx.scope_kind == 'manager' and body.collector_user_id
  392. else set(),
  393. )
  394. return customer, collection, samples, attachments
  395. _SAMPLE_PRESERVED_FIELDS = (
  396. 'sample_type_id',
  397. 'sample_code_id',
  398. 'sample_code',
  399. 'collect_amount',
  400. 'unit_id',
  401. 'price',
  402. 'original_price',
  403. 'storage_years',
  404. 'sample_status',
  405. 'storage_in_date',
  406. 'detect_start_date',
  407. 'void_date',
  408. 'storage_expire_date',
  409. 'is_special',
  410. 'special_remark',
  411. 'remark',
  412. )
  413. def _validate_unique_sample_identity(samples: List[dict]) -> None:
  414. for field_name, api_name in (
  415. ('id', 'sampleId'),
  416. ('sample_code', 'sampleCode'),
  417. ('sample_code_id', 'sampleCodeId'),
  418. ):
  419. values = [
  420. str(item.get(field_name) or '').strip()
  421. for item in samples
  422. if str(item.get(field_name) or '').strip()
  423. ]
  424. if len(values) != len(set(values)):
  425. raise ValueError(f'{api_name} 不能重复')
  426. def _unique_sample_map(current_samples: List, field_name: str) -> dict:
  427. grouped = {}
  428. for item in current_samples:
  429. value = str(getattr(item, field_name, None) or '').strip()
  430. if value:
  431. grouped.setdefault(value, []).append(item)
  432. return {
  433. value: rows[0]
  434. for value, rows in grouped.items()
  435. if len(rows) == 1
  436. }
  437. def _merge_existing_sample_fields(current_samples: List, samples: List[dict]) -> List[dict]:
  438. """按稳定身份沿用未展示字段;未匹配项一律按新样本处理。"""
  439. current_by_id = {
  440. str(getattr(item, 'id', None) or '').strip(): item
  441. for item in current_samples
  442. if str(getattr(item, 'id', None) or '').strip()
  443. }
  444. current_by_code_id = _unique_sample_map(current_samples, 'sample_code_id')
  445. current_by_code = _unique_sample_map(current_samples, 'sample_code')
  446. merged = []
  447. for incoming in samples:
  448. sample_id = str(incoming.get('id') or '').strip()
  449. code = str(incoming.get('sample_code') or '').strip()
  450. code_id = str(incoming.get('sample_code_id') or '').strip()
  451. if sample_id:
  452. current = current_by_id.get(sample_id)
  453. if current is None:
  454. raise ValueError('样本不存在或不属于当前协议')
  455. else:
  456. current = current_by_code_id.get(code_id) or current_by_code.get(code)
  457. base = (
  458. {
  459. key: getattr(current, key, None)
  460. for key in _SAMPLE_PRESERVED_FIELDS
  461. }
  462. if current is not None
  463. else {}
  464. )
  465. base.update(incoming)
  466. base.pop('id', None)
  467. merged.append(base)
  468. return merged
  469. def _validate_mobile_attachments(
  470. db: Session,
  471. attachments: dict,
  472. *,
  473. ctx: MarketFieldContext,
  474. creating: bool,
  475. existing: Optional[dict] = None,
  476. business_id: Optional[str] = None,
  477. ) -> dict:
  478. current = existing or {}
  479. normalized = dict(attachments)
  480. for key, refs in attachments.items():
  481. if refs is None:
  482. continue
  483. normalized[key] = validate_attachment_refs(
  484. db,
  485. refs,
  486. module='protocol',
  487. ctx=ctx,
  488. creating=creating,
  489. existing_refs=current.get(key),
  490. business_id=business_id,
  491. )
  492. return normalized
  493. def _validate_v1_payment_detail_attachments(
  494. db: Session,
  495. details: Optional[List],
  496. *,
  497. row: BizCollectionProtocol,
  498. ctx: MarketFieldContext,
  499. ) -> Optional[List[dict]]:
  500. """Validate and bind legacy detail receipts through the protocol boundary."""
  501. if details is None:
  502. return None
  503. payment = (
  504. db.query(BizProtocolPayment)
  505. .filter(
  506. BizProtocolPayment.collection_protocol_id == row.id,
  507. BizProtocolPayment.tenant_no == ctx.tenant_no,
  508. BizProtocolPayment.del_flag == 0,
  509. )
  510. .populate_existing()
  511. .with_for_update()
  512. .first()
  513. )
  514. existing_rows = (
  515. db.query(BizProtocolPaymentDetail)
  516. .filter(
  517. BizProtocolPaymentDetail.payment_id == payment.id,
  518. BizProtocolPaymentDetail.tenant_no == ctx.tenant_no,
  519. BizProtocolPaymentDetail.del_flag == 0,
  520. )
  521. .populate_existing()
  522. .with_for_update()
  523. .all()
  524. if payment is not None
  525. else []
  526. )
  527. existing_by_id = {item.id: item for item in existing_rows}
  528. normalized: List[dict] = []
  529. for item in details:
  530. raw = item.model_dump(by_alias=False, exclude_unset=True, mode='json')
  531. if 'transfer_attachments' not in raw:
  532. normalized.append(raw)
  533. continue
  534. current = existing_by_id.get(str(raw.get('id') or '').strip())
  535. current_refs = current.transfer_attachments if current is not None else None
  536. if current is not None and current.pay_status != 'dfj':
  537. if str(raw.get('transfer_attachments') or '').strip() != str(
  538. current_refs or ''
  539. ).strip():
  540. raise ValueError('锁定缴费明细的附件不可修改')
  541. raw['transfer_attachments'] = current_refs or ''
  542. else:
  543. raw['transfer_attachments'] = validate_attachment_refs(
  544. db,
  545. raw.get('transfer_attachments'),
  546. module='protocol',
  547. ctx=ctx,
  548. creating=current is None,
  549. existing_refs=current_refs,
  550. business_id=row.id,
  551. )
  552. normalized.append(raw)
  553. return normalized
  554. def _party_name_cell(db: Session, ids: List[str], tenant_no: str) -> dict:
  555. if not ids:
  556. return {}
  557. rows = (
  558. db.query(BizProtocolPartyCustomer)
  559. .filter(
  560. BizProtocolPartyCustomer.collection_protocol_id.in_(ids),
  561. BizProtocolPartyCustomer.tenant_no == tenant_no,
  562. BizProtocolPartyCustomer.del_flag == 0,
  563. )
  564. .all()
  565. )
  566. return {row.collection_protocol_id: row.mother_name or '' for row in rows}
  567. def _party_name_donation(db: Session, ids: List[str], tenant_no: str) -> dict:
  568. if not ids:
  569. return {}
  570. rows = (
  571. db.query(BizResearchDonationParty)
  572. .filter(
  573. BizResearchDonationParty.research_donation_id.in_(ids),
  574. BizResearchDonationParty.tenant_no == tenant_no,
  575. BizResearchDonationParty.del_flag == 0,
  576. )
  577. .all()
  578. )
  579. return {row.research_donation_id: row.mother_name or '' for row in rows}
  580. def _enrich_protocol_cards(
  581. db: Session, items: List[dict], tenant_no: str
  582. ) -> None:
  583. """批量补齐与 Web 列表一致的移动卡片字段,避免逐行查询。"""
  584. cell_ids = [item['id'] for item in items if item['source'] == SOURCE_CELL]
  585. donation_ids = [
  586. item['id'] for item in items if item['source'] == SOURCE_DONATION
  587. ]
  588. collection_map = {}
  589. sample_map = {}
  590. party_map = {}
  591. if cell_ids:
  592. for row in db.query(BizProtocolCollectionInfo).filter(
  593. BizProtocolCollectionInfo.collection_protocol_id.in_(cell_ids),
  594. BizProtocolCollectionInfo.tenant_no == tenant_no,
  595. BizProtocolCollectionInfo.del_flag == 0,
  596. ).order_by(BizProtocolCollectionInfo.create_time.desc()).all():
  597. collection_map.setdefault((SOURCE_CELL, row.collection_protocol_id), row)
  598. for row in db.query(BizProtocolSample).filter(
  599. BizProtocolSample.collection_protocol_id.in_(cell_ids),
  600. BizProtocolSample.tenant_no == tenant_no,
  601. BizProtocolSample.del_flag == 0,
  602. ).order_by(BizProtocolSample.create_time.asc()).all():
  603. sample_map.setdefault((SOURCE_CELL, row.collection_protocol_id), row)
  604. for row in db.query(BizProtocolPartyCustomer).filter(
  605. BizProtocolPartyCustomer.collection_protocol_id.in_(cell_ids),
  606. BizProtocolPartyCustomer.tenant_no == tenant_no,
  607. BizProtocolPartyCustomer.del_flag == 0,
  608. ).order_by(BizProtocolPartyCustomer.create_time.desc()).all():
  609. party_map.setdefault((SOURCE_CELL, row.collection_protocol_id), row)
  610. if donation_ids:
  611. for row in db.query(BizResearchDonationCollection).filter(
  612. BizResearchDonationCollection.research_donation_id.in_(donation_ids),
  613. BizResearchDonationCollection.tenant_no == tenant_no,
  614. BizResearchDonationCollection.del_flag == 0,
  615. ).order_by(BizResearchDonationCollection.create_time.desc()).all():
  616. collection_map.setdefault((SOURCE_DONATION, row.research_donation_id), row)
  617. for row in db.query(BizResearchDonationSample).filter(
  618. BizResearchDonationSample.research_donation_id.in_(donation_ids),
  619. BizResearchDonationSample.tenant_no == tenant_no,
  620. BizResearchDonationSample.del_flag == 0,
  621. ).order_by(BizResearchDonationSample.create_time.asc()).all():
  622. sample_map.setdefault((SOURCE_DONATION, row.research_donation_id), row)
  623. for row in db.query(BizResearchDonationParty).filter(
  624. BizResearchDonationParty.research_donation_id.in_(donation_ids),
  625. BizResearchDonationParty.tenant_no == tenant_no,
  626. BizResearchDonationParty.del_flag == 0,
  627. ).order_by(BizResearchDonationParty.create_time.desc()).all():
  628. party_map.setdefault((SOURCE_DONATION, row.research_donation_id), row)
  629. region_ids = {
  630. row.region_id for row in collection_map.values() if row.region_id
  631. }
  632. hospital_ids = {
  633. row.hospital_id for row in collection_map.values() if row.hospital_id
  634. }
  635. user_ids = {
  636. value
  637. for row in collection_map.values()
  638. for value in (row.sales_user_id, row.region_manager_user_id)
  639. if value
  640. }
  641. user_ids.update(
  642. item.get('collectorUserId')
  643. for item in items
  644. if item.get('collectorUserId')
  645. )
  646. type_ids = {
  647. row.sample_type_id for row in sample_map.values() if row.sample_type_id
  648. }
  649. region_names = {
  650. row.id: row.region_name or ''
  651. for row in db.query(BizCollectionRegion).filter(
  652. BizCollectionRegion.id.in_(region_ids or ['']),
  653. BizCollectionRegion.tenant_no == tenant_no,
  654. BizCollectionRegion.del_flag == 0,
  655. )
  656. }
  657. hospital_names = {
  658. row.id: row.hospital_name or ''
  659. for row in db.query(BizCollectionHospital).filter(
  660. BizCollectionHospital.id.in_(hospital_ids or ['']),
  661. BizCollectionHospital.tenant_no == tenant_no,
  662. BizCollectionHospital.del_flag == 0,
  663. )
  664. }
  665. user_names = {
  666. row.id: row.nick_name or ''
  667. for row in db.query(SysUser).filter(
  668. SysUser.id.in_(user_ids or ['']),
  669. SysUser.tenant_no == tenant_no,
  670. )
  671. }
  672. type_names = {
  673. row.id: row.type_name or ''
  674. for row in db.query(BizCellSampleType).filter(
  675. BizCellSampleType.id.in_(type_ids or ['']),
  676. BizCellSampleType.del_flag == 0,
  677. or_(
  678. BizCellSampleType.tenant_no == tenant_no,
  679. BizCellSampleType.tenant_no == '',
  680. ),
  681. )
  682. }
  683. sample_ids = [row.id for row in sample_map.values()]
  684. transport_by_sample = {}
  685. if sample_ids:
  686. for row in db.query(BizProtocolSampleTransport).filter(
  687. BizProtocolSampleTransport.sample_id.in_(sample_ids),
  688. BizProtocolSampleTransport.tenant_no == tenant_no,
  689. BizProtocolSampleTransport.del_flag == 0,
  690. ).all():
  691. transport_by_sample.setdefault(row.sample_id, row)
  692. for row in db.query(BizResearchDonationSampleTransport).filter(
  693. BizResearchDonationSampleTransport.sample_id.in_(sample_ids),
  694. BizResearchDonationSampleTransport.tenant_no == tenant_no,
  695. BizResearchDonationSampleTransport.del_flag == 0,
  696. ).all():
  697. transport_by_sample.setdefault(row.sample_id, row)
  698. lab_by_key = {}
  699. from app.crud import sample_reception as reception_crud
  700. for source in SOURCES:
  701. codes = [
  702. row.sample_code
  703. for (item_source, _), row in sample_map.items()
  704. if item_source == source and row.sample_code
  705. ]
  706. for code, lab_code in reception_crud.get_lab_storage_code_map_by_sample_codes(
  707. db, tenant_no, codes, reception_source=source
  708. ).items():
  709. lab_by_key[(source, code)] = lab_code
  710. payment_summaries = {}
  711. if cell_ids:
  712. from app.services.qc_work_order_fee_summary import (
  713. build_protocol_payment_summaries,
  714. )
  715. payment_summaries = build_protocol_payment_summaries(
  716. db, cell_ids, tenant_no
  717. )
  718. transport_labels = {'kdys': '快递运输', 'ryxd': '人员携带'}
  719. void_type_labels = build_void_type_label_map(
  720. db,
  721. tenant_no,
  722. [str(item.get('voidType') or '') for item in items],
  723. )
  724. for item in items:
  725. key = (item['source'], item['id'])
  726. collection = collection_map.get(key)
  727. sample = sample_map.get(key)
  728. party = party_map.get(key)
  729. payment_summary = payment_summaries.get(item['id']) or {}
  730. transport = transport_by_sample.get(getattr(sample, 'id', ''))
  731. sample_code = getattr(sample, 'sample_code', '') or ''
  732. region_manager_name = user_names.get(
  733. getattr(collection, 'region_manager_user_id', ''), ''
  734. )
  735. item.update(
  736. motherName=item.get('customerName', ''),
  737. motherPhone=getattr(party, 'mother_phone', '') or '',
  738. motherIdCard=getattr(party, 'mother_id_card', '') or '',
  739. fatherName=getattr(party, 'father_name', '') or '',
  740. newbornSex=getattr(party, 'newborn_sex', '') or '',
  741. deliveryMode=getattr(party, 'delivery_mode', '') or '',
  742. newbornWeightGram=getattr(party, 'newborn_weight_gram', None),
  743. birthOrder=getattr(party, 'birth_order', None),
  744. newbornDeliveryTime=_fmt_dt(
  745. getattr(party, 'newborn_delivery_time', None)
  746. ),
  747. protocolStatus=item.get('status', ''),
  748. donationStatus=item.get('status', ''),
  749. regionName=region_names.get(getattr(collection, 'region_id', ''), ''),
  750. hospitalName=hospital_names.get(
  751. getattr(collection, 'hospital_id', ''), ''
  752. ),
  753. sampleCollectTime=_fmt_dt(
  754. getattr(collection, 'sample_collect_time', None)
  755. ),
  756. maternalBloodCollectTime=_fmt_dt(
  757. getattr(collection, 'maternal_blood_collect_time', None)
  758. ),
  759. salesUserName=user_names.get(
  760. getattr(collection, 'sales_user_id', ''), ''
  761. ),
  762. collectorUserName=user_names.get(item.get('collectorUserId', ''), ''),
  763. managerName=region_manager_name,
  764. regionManagerUserName=region_manager_name,
  765. sampleCode=sample_code,
  766. sampleTypeName=type_names.get(
  767. getattr(sample, 'sample_type_id', ''), ''
  768. ),
  769. coStored=bool(getattr(sample, 'is_special', 0)),
  770. labStorageCode=lab_by_key.get((item['source'], sample_code), ''),
  771. payMode=payment_summary.get('payMode', ''),
  772. firstPayTime=payment_summary.get('firstPayTransferTime', ''),
  773. transportMode=getattr(transport, 'transport_type', '') or '',
  774. transportModeLabel=transport_labels.get(
  775. getattr(transport, 'transport_type', '') or '', ''
  776. ),
  777. voidTypeLabel=void_type_labels.get(
  778. str(item.get('voidType') or ''), ''
  779. ),
  780. )
  781. def list_protocols(
  782. db: Session,
  783. *,
  784. ctx: MarketFieldContext,
  785. user_info: dict,
  786. page: int = 1,
  787. page_size: int = 10,
  788. keyword: Optional[str] = None,
  789. source: Optional[str] = None,
  790. protocol_status: Optional[str] = None,
  791. create_time_start: Optional[str] = None,
  792. create_time_end: Optional[str] = None,
  793. sample_collect_time_start: Optional[str] = None,
  794. sample_collect_time_end: Optional[str] = None,
  795. ) -> Tuple[List[dict], int]:
  796. if source and source not in SOURCES:
  797. raise ValueError('不支持的协议来源')
  798. search = str(keyword or '').strip()
  799. def date_value(raw: Optional[str], *, end: bool = False) -> Optional[datetime]:
  800. text = str(raw or '').strip()
  801. if not text:
  802. return None
  803. try:
  804. parsed = datetime.strptime(text, '%Y-%m-%d')
  805. except ValueError as exc:
  806. raise ValueError('日期格式无效,应为 YYYY-MM-DD') from exc
  807. return parsed + timedelta(days=1) if end else parsed
  808. created_from = date_value(create_time_start)
  809. created_until = date_value(create_time_end, end=True)
  810. collected_from = date_value(sample_collect_time_start)
  811. collected_until = date_value(sample_collect_time_end, end=True)
  812. status = str(protocol_status or '').strip()
  813. rows = []
  814. fetch_limit = max(1, page) * min(max(1, page_size), 100)
  815. total = 0
  816. if source in (None, SOURCE_CELL):
  817. query = apply_cell_protocol_scope(
  818. db.query(BizCollectionProtocol), db, ctx, user_info=user_info
  819. )
  820. if status:
  821. query = query.filter(BizCollectionProtocol.protocol_status == status)
  822. if created_from:
  823. query = query.filter(BizCollectionProtocol.create_time >= created_from)
  824. if created_until:
  825. query = query.filter(BizCollectionProtocol.create_time < created_until)
  826. if collected_from or collected_until:
  827. collection_conditions = [
  828. BizProtocolCollectionInfo.collection_protocol_id
  829. == BizCollectionProtocol.id,
  830. BizProtocolCollectionInfo.tenant_no == ctx.tenant_no,
  831. BizProtocolCollectionInfo.del_flag == 0,
  832. ]
  833. if collected_from:
  834. collection_conditions.append(
  835. BizProtocolCollectionInfo.sample_collect_time >= collected_from
  836. )
  837. if collected_until:
  838. collection_conditions.append(
  839. BizProtocolCollectionInfo.sample_collect_time < collected_until
  840. )
  841. query = query.filter(
  842. db.query(BizProtocolCollectionInfo.id)
  843. .filter(*collection_conditions)
  844. .exists()
  845. .correlate(BizCollectionProtocol)
  846. )
  847. if search:
  848. like = f'%{search}%'
  849. party_ids = db.query(
  850. BizProtocolPartyCustomer.collection_protocol_id
  851. ).filter(
  852. BizProtocolPartyCustomer.tenant_no == ctx.tenant_no,
  853. BizProtocolPartyCustomer.del_flag == 0,
  854. or_(
  855. BizProtocolPartyCustomer.mother_name.like(like),
  856. BizProtocolPartyCustomer.mother_id_card.like(like),
  857. BizProtocolPartyCustomer.mother_phone.like(like),
  858. ),
  859. )
  860. sample_ids = db.query(BizProtocolSample.collection_protocol_id).filter(
  861. BizProtocolSample.tenant_no == ctx.tenant_no,
  862. BizProtocolSample.del_flag == 0,
  863. BizProtocolSample.sample_code.like(like),
  864. )
  865. query = query.filter(
  866. or_(
  867. BizCollectionProtocol.protocol_no.like(like),
  868. BizCollectionProtocol.id.in_(party_ids),
  869. BizCollectionProtocol.id.in_(sample_ids),
  870. )
  871. )
  872. total += int(
  873. query.order_by(None)
  874. .with_entities(func.count(BizCollectionProtocol.id))
  875. .scalar()
  876. or 0
  877. )
  878. cell_rows = (
  879. query.order_by(
  880. BizCollectionProtocol.create_time.desc(),
  881. BizCollectionProtocol.id.desc(),
  882. )
  883. .limit(fetch_limit)
  884. .all()
  885. )
  886. names = _party_name_cell(
  887. db, [row.id for row in cell_rows], ctx.tenant_no
  888. )
  889. rows.extend(
  890. {
  891. 'id': row.id,
  892. 'source': SOURCE_CELL,
  893. 'code': row.protocol_no or '',
  894. 'protocolNo': row.protocol_no or '',
  895. 'status': row.protocol_status or '',
  896. 'collectDate': row.register_date or '',
  897. 'registerDate': row.register_date or '',
  898. 'protocolTotalAmount': str(row.protocol_total_amount or ''),
  899. 'paymentModelVersion': int(
  900. getattr(row, 'payment_model_version', None) or 1
  901. ),
  902. 'voidType': row.void_type or '',
  903. 'collectorUserId': row.collector_user_id or '',
  904. 'customerName': names.get(row.id, ''),
  905. 'createTime': _fmt_dt(row.create_time),
  906. '_created': row.create_time or datetime.min,
  907. }
  908. for row in cell_rows
  909. )
  910. if source in (None, SOURCE_DONATION):
  911. if status or collected_from or collected_until:
  912. donation_enabled = False
  913. else:
  914. donation_enabled = True
  915. if not donation_enabled:
  916. query = None
  917. else:
  918. query = apply_donation_scope(
  919. db.query(BizResearchDonation), db, ctx, user_info=user_info
  920. )
  921. if query is None:
  922. donation_rows = []
  923. else:
  924. if created_from:
  925. query = query.filter(BizResearchDonation.create_time >= created_from)
  926. if created_until:
  927. query = query.filter(BizResearchDonation.create_time < created_until)
  928. if query is not None and search:
  929. like = f'%{search}%'
  930. party_ids = db.query(
  931. BizResearchDonationParty.research_donation_id
  932. ).filter(
  933. BizResearchDonationParty.tenant_no == ctx.tenant_no,
  934. BizResearchDonationParty.del_flag == 0,
  935. or_(
  936. BizResearchDonationParty.mother_name.like(like),
  937. BizResearchDonationParty.mother_id_card.like(like),
  938. BizResearchDonationParty.mother_phone.like(like),
  939. ),
  940. )
  941. sample_ids = db.query(
  942. BizResearchDonationSample.research_donation_id
  943. ).filter(
  944. BizResearchDonationSample.tenant_no == ctx.tenant_no,
  945. BizResearchDonationSample.del_flag == 0,
  946. BizResearchDonationSample.sample_code.like(like),
  947. )
  948. query = query.filter(
  949. or_(
  950. BizResearchDonation.donation_no.like(like),
  951. BizResearchDonation.id.in_(party_ids),
  952. BizResearchDonation.id.in_(sample_ids),
  953. )
  954. )
  955. if query is not None:
  956. total += int(
  957. query.order_by(None)
  958. .with_entities(func.count(BizResearchDonation.id))
  959. .scalar()
  960. or 0
  961. )
  962. donation_rows = (
  963. query.order_by(
  964. BizResearchDonation.create_time.desc(),
  965. BizResearchDonation.id.desc(),
  966. )
  967. .limit(fetch_limit)
  968. .all()
  969. )
  970. names = _party_name_donation(
  971. db, [row.id for row in donation_rows], ctx.tenant_no
  972. )
  973. rows.extend(
  974. {
  975. 'id': row.id,
  976. 'source': SOURCE_DONATION,
  977. 'code': row.donation_no or '',
  978. 'protocolNo': row.donation_no or '',
  979. 'donationNo': row.donation_no or '',
  980. 'status': row.donation_status or '',
  981. 'collectDate': row.donation_date or '',
  982. 'donationDate': row.donation_date or '',
  983. 'customerName': names.get(row.id, ''),
  984. 'collectorUserId': row.collector_user_id or '',
  985. 'createTime': _fmt_dt(row.create_time),
  986. 'monthlyVerifyNo': row.monthly_verify_no or '',
  987. 'voidType': row.void_type or '',
  988. 'voidDesc': row.void_desc or '',
  989. 'consentFormUrls': row.consent_form_urls or '',
  990. 'infectiousDiseaseReportUrls': row.infectious_disease_report_urls or '',
  991. 'attachmentUrls': row.attachment_urls or '',
  992. 'remark': row.remark or '',
  993. '_created': row.create_time or datetime.min,
  994. }
  995. for row in donation_rows
  996. )
  997. rows.sort(
  998. key=lambda item: (item['_created'], item['id']), reverse=True
  999. )
  1000. safe_page = max(1, page)
  1001. safe_size = min(max(1, page_size), 100)
  1002. page_rows = rows[(safe_page - 1) * safe_size : safe_page * safe_size]
  1003. for item in page_rows:
  1004. item.pop('_created', None)
  1005. _enrich_protocol_cards(db, page_rows, ctx.tenant_no)
  1006. return page_rows, total
  1007. def _attach_sample_eta(
  1008. sample_dicts: List[dict],
  1009. transport_by_sample_id: dict,
  1010. ) -> List[dict]:
  1011. for item in sample_dicts:
  1012. transport = transport_by_sample_id.get(str(item.get('id') or ''))
  1013. eta = ''
  1014. if transport is not None:
  1015. eta = cell_crud._fmt_dt(getattr(transport, 'eta_time', None))
  1016. item['etaTime'] = eta
  1017. item['transport'] = {'etaTime': eta} if eta else None
  1018. return sample_dicts
  1019. def _attach_sample_freight(
  1020. db: Session,
  1021. sample_dicts: List[dict],
  1022. *,
  1023. tenant_no: str,
  1024. sample_source: str,
  1025. ) -> List[dict]:
  1026. from app.crud import sample_freight as sample_freight_svc
  1027. ids = [str(item.get('id') or '').strip() for item in sample_dicts if item.get('id')]
  1028. freight_map = sample_freight_svc.freight_map_for_samples(
  1029. db,
  1030. tenant_no=tenant_no,
  1031. sample_source=sample_source,
  1032. sample_ids=ids,
  1033. money_as_string=True,
  1034. )
  1035. for item in sample_dicts:
  1036. sid = str(item.get('id') or '').strip()
  1037. freight = freight_map.get(sid)
  1038. if freight:
  1039. item['freight'] = freight
  1040. item['freightId'] = freight.get('id') or ''
  1041. else:
  1042. item['freight'] = None
  1043. item['freightId'] = ''
  1044. return sample_dicts
  1045. def _assert_sample_freight_targets(
  1046. db: Session,
  1047. *,
  1048. protocol_id: Optional[str],
  1049. samples: List[dict],
  1050. ctx: MarketFieldContext,
  1051. user_info: dict,
  1052. ) -> None:
  1053. """协议保存只允许保留原批次,或关联当前市场范围可访问的批次。"""
  1054. existing_samples = (
  1055. cell_crud.list_samples(db, protocol_id, ctx.tenant_no)
  1056. if protocol_id
  1057. else []
  1058. )
  1059. freight_by_sample = sample_freight_crud.freight_map_for_samples(
  1060. db,
  1061. tenant_no=ctx.tenant_no,
  1062. sample_source=SOURCE_CELL,
  1063. sample_ids=[row.id for row in existing_samples],
  1064. )
  1065. current_by_code = {
  1066. str(row.sample_code or '').strip(): str(
  1067. (freight_by_sample.get(row.id) or {}).get('id') or ''
  1068. )
  1069. for row in existing_samples
  1070. if str(row.sample_code or '').strip()
  1071. }
  1072. checked_targets = set()
  1073. for sample in samples:
  1074. code = str(sample.get('sample_code') or sample.get('sampleCode') or '').strip()
  1075. target_id = str(
  1076. sample.get('freight_id') or sample.get('freightId') or ''
  1077. ).strip()
  1078. draft = sample.get('freight_draft') or sample.get('freightDraft')
  1079. current_id = current_by_code.get(code, '')
  1080. if current_id and (draft or (target_id and target_id != current_id)):
  1081. raise ValueError('样本已关联运费批次,请先通过运费功能取消关联')
  1082. if not target_id or target_id == current_id or target_id in checked_targets:
  1083. continue
  1084. market_freight.assert_link_target_access(
  1085. db,
  1086. target_id,
  1087. ctx=ctx,
  1088. user_info=user_info,
  1089. )
  1090. checked_targets.add(target_id)
  1091. def _market_sample_payment_payload(
  1092. db: Session,
  1093. *,
  1094. row: BizCollectionProtocol,
  1095. body: MarketProtocolBody,
  1096. ctx: MarketFieldContext,
  1097. saved_samples: List[BizProtocolSample],
  1098. ) -> List[dict]:
  1099. """忽略客户端嵌套转账和样本 ID,只同步当前协议的缴费方案与期次。"""
  1100. raw_payments = [
  1101. item.model_dump(by_alias=True, exclude_none=False)
  1102. for item in (body.sample_payments or [])
  1103. ]
  1104. sample_by_code = {
  1105. str(sample.sample_code or '').strip(): sample
  1106. for sample in saved_samples
  1107. if str(sample.sample_code or '').strip()
  1108. }
  1109. current_payments = (
  1110. db.query(BizProtocolSamplePayment)
  1111. .filter(
  1112. BizProtocolSamplePayment.collection_protocol_id == row.id,
  1113. BizProtocolSamplePayment.tenant_no == ctx.tenant_no,
  1114. BizProtocolSamplePayment.del_flag == 0,
  1115. )
  1116. .all()
  1117. )
  1118. payment_by_id = {payment.id: payment for payment in current_payments}
  1119. payment_by_code = {
  1120. str(payment.sample_code or '').strip(): payment
  1121. for payment in current_payments
  1122. }
  1123. payment_ids = [payment.id for payment in current_payments]
  1124. schedules = (
  1125. db.query(BizProtocolPaymentSchedule)
  1126. .filter(
  1127. BizProtocolPaymentSchedule.sample_payment_id.in_(payment_ids or ['']),
  1128. BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no,
  1129. BizProtocolPaymentSchedule.del_flag == 0,
  1130. )
  1131. .all()
  1132. )
  1133. schedule_by_payment_no = {
  1134. (schedule.sample_payment_id, int(schedule.schedule_no or 0)): schedule
  1135. for schedule in schedules
  1136. }
  1137. schedule_ids = [schedule.id for schedule in schedules]
  1138. transfers = (
  1139. db.query(BizProtocolPaymentTransfer)
  1140. .filter(
  1141. BizProtocolPaymentTransfer.schedule_id.in_(schedule_ids or ['']),
  1142. BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
  1143. BizProtocolPaymentTransfer.del_flag == 0,
  1144. )
  1145. .all()
  1146. )
  1147. transfers_by_schedule = {}
  1148. for transfer in transfers:
  1149. transfers_by_schedule.setdefault(transfer.schedule_id, []).append(transfer)
  1150. seen_codes = set()
  1151. result = []
  1152. for raw in raw_payments:
  1153. code = str(raw.get('sampleCode') or '').strip()
  1154. if not code or code not in sample_by_code:
  1155. raise ValueError('样本缴费方案不属于当前协议样本')
  1156. if code in seen_codes:
  1157. raise ValueError('同一样本不能重复配置缴费方案')
  1158. seen_codes.add(code)
  1159. supplied_id = str(raw.get('id') or '').strip()
  1160. current = payment_by_id.get(supplied_id) if supplied_id else payment_by_code.get(code)
  1161. if supplied_id and (
  1162. current is None or str(current.sample_code or '').strip() != code
  1163. ):
  1164. raise ValueError('样本缴费方案不存在或不属于当前协议样本')
  1165. if current and payment_by_code.get(code) not in (None, current):
  1166. raise ValueError('样本缴费方案与当前协议样本不一致')
  1167. if current:
  1168. raw['id'] = current.id
  1169. else:
  1170. raw.pop('id', None)
  1171. raw['protocolSampleId'] = sample_by_code[code].id
  1172. incoming_schedules = raw.get('schedules') or []
  1173. incoming_nos = {
  1174. int(schedule.get('scheduleNo') or index + 1)
  1175. for index, schedule in enumerate(incoming_schedules)
  1176. }
  1177. current_nos = {
  1178. number
  1179. for payment_id, number in schedule_by_payment_no
  1180. if current and payment_id == current.id
  1181. }
  1182. current_transfers = [
  1183. transfer
  1184. for schedule in schedules
  1185. if current and schedule.sample_payment_id == current.id
  1186. for transfer in transfers_by_schedule.get(schedule.id, [])
  1187. ]
  1188. if current and incoming_nos != current_nos and current_transfers:
  1189. raise ValueError('存在转账记录时不可调整缴费期次结构')
  1190. sanitized_schedules = []
  1191. for index, schedule_raw in enumerate(incoming_schedules):
  1192. schedule = dict(schedule_raw)
  1193. number = int(schedule.get('scheduleNo') or index + 1)
  1194. existing_schedule = (
  1195. schedule_by_payment_no.get((current.id, number))
  1196. if current
  1197. else None
  1198. )
  1199. schedule.pop('id', None)
  1200. schedule['scheduleStatus'] = (
  1201. existing_schedule.schedule_status
  1202. if existing_schedule
  1203. else 'open'
  1204. )
  1205. schedule['transfers'] = (
  1206. [
  1207. {'id': transfer.id}
  1208. for transfer in transfers_by_schedule.get(existing_schedule.id, [])
  1209. ]
  1210. if existing_schedule
  1211. else []
  1212. )
  1213. sanitized_schedules.append(schedule)
  1214. raw['schedules'] = sanitized_schedules
  1215. result.append(raw)
  1216. return result
  1217. def _persist_sample_payments_if_any(
  1218. db: Session,
  1219. *,
  1220. row: BizCollectionProtocol,
  1221. body: MarketProtocolBody,
  1222. ctx: MarketFieldContext,
  1223. saved_samples: List[BizProtocolSample],
  1224. ) -> None:
  1225. """按数据库版本互斥保存 v1 整单缴费或 v2 按样本缴费方案。"""
  1226. if body.source != SOURCE_CELL:
  1227. if (
  1228. body.sample_payments is not None
  1229. or body.payment is not None
  1230. or body.payment_details is not None
  1231. ):
  1232. raise ValueError('科研捐赠不支持缴费方案')
  1233. return
  1234. version = int(getattr(row, 'payment_model_version', None) or 1)
  1235. if version < 2:
  1236. if body.sample_payments is not None:
  1237. raise ValueError('旧版 v1 协议不支持 samplePayments 样本缴费方案')
  1238. if body.payment is None and body.payment_details is None:
  1239. return
  1240. if body.payment is None and body.payment_details is not None:
  1241. existing_payment = cell_crud.get_payment_by_protocol_id(
  1242. db, row.id, ctx.tenant_no
  1243. )
  1244. if existing_payment is None:
  1245. raise ValueError('提交缴费明细前必须先提交缴费主表方案')
  1246. payment_details = _validate_v1_payment_detail_attachments(
  1247. db,
  1248. body.payment_details,
  1249. row=row,
  1250. ctx=ctx,
  1251. )
  1252. cell_crud.save_payment_with_details(
  1253. db,
  1254. row.id,
  1255. ctx.tenant_no,
  1256. ctx.user_id,
  1257. payment=body.payment.model_dump(
  1258. by_alias=False, exclude_unset=True, mode='json'
  1259. ) if body.payment is not None else None,
  1260. payment_details=payment_details,
  1261. commit=False,
  1262. )
  1263. return
  1264. if body.payment is not None or body.payment_details is not None:
  1265. raise ValueError('新版 v2 协议不支持 payment/paymentDetails 整单缴费方案')
  1266. if body.sample_payments is None:
  1267. return
  1268. raw = _market_sample_payment_payload(
  1269. db,
  1270. row=row,
  1271. body=body,
  1272. ctx=ctx,
  1273. saved_samples=saved_samples,
  1274. )
  1275. psp_crud.save_sample_payments_for_protocol(
  1276. db,
  1277. collection_protocol_id=row.id,
  1278. tenant_no=ctx.tenant_no,
  1279. user_id=ctx.user_id,
  1280. sample_payments=raw,
  1281. )
  1282. def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dict:
  1283. party = cell_crud.get_party_customer(db, row.id, tenant_no)
  1284. collection = cell_crud.get_collection_info(db, row.id, tenant_no)
  1285. samples = cell_crud.list_samples(db, row.id, tenant_no)
  1286. payment = cell_crud.get_payment_by_protocol_id(db, row.id, tenant_no)
  1287. transports = cell_crud.list_sample_transports(db, row.id, tenant_no)
  1288. transport_by_sample_id = {str(t.sample_id): t for t in transports}
  1289. # qc_task 先于 qc_work_order 初始化,避免两者历史互相导入时拿到半初始化模块。
  1290. from app.crud import qc_task as _qc_task_svc # noqa: F401
  1291. web_bundle = cell_crud.get_protocol_detail_bundle(db, row.id, tenant_no) or {}
  1292. web_samples = web_bundle.get('samples') or []
  1293. web_sample_by_id = {
  1294. str(item.get('id') or ''): item for item in web_samples if item.get('id')
  1295. }
  1296. web_sample_by_code = {
  1297. str(item.get('sampleCode') or ''): item
  1298. for item in web_samples
  1299. if item.get('sampleCode')
  1300. }
  1301. sample_dicts = _attach_sample_freight(
  1302. db,
  1303. _attach_sample_eta(
  1304. [cell_crud._sample_to_dict(item) for item in samples],
  1305. transport_by_sample_id,
  1306. ),
  1307. tenant_no=tenant_no,
  1308. sample_source=SOURCE_CELL,
  1309. )
  1310. lifecycle_keys = (
  1311. 'sampleReception',
  1312. 'productionReception',
  1313. 'qcInfo',
  1314. 'processQcInfoList',
  1315. 'productionInfo',
  1316. 'storageReleaseInfo',
  1317. )
  1318. for item in sample_dicts:
  1319. web_sample = web_sample_by_id.get(str(item.get('id') or '')) or web_sample_by_code.get(
  1320. str(item.get('sampleCode') or '')
  1321. )
  1322. if not web_sample:
  1323. continue
  1324. for key in lifecycle_keys:
  1325. item[key] = web_sample.get(key)
  1326. version = int(getattr(row, 'payment_model_version', None) or 1)
  1327. sample_payments = []
  1328. if version >= 2:
  1329. sample_payments = psp_crud.get_sample_payments_bundle(
  1330. db,
  1331. collection_protocol_id=row.id,
  1332. tenant_no=tenant_no,
  1333. money_as_string=True,
  1334. )
  1335. source_presign_id = getattr(row, 'source_presign_order_id', None)
  1336. storage_cert = cell_crud.get_storage_cert(db, row.id, tenant_no)
  1337. storage_cert_dict = (
  1338. cell_crud._storage_cert_to_dict(storage_cert) if storage_cert else None
  1339. )
  1340. if storage_cert_dict:
  1341. hid = str(storage_cert_dict.get('handlerUserId') or '').strip()
  1342. if hid:
  1343. hu = (
  1344. db.query(SysUser)
  1345. .filter(
  1346. SysUser.id == hid,
  1347. SysUser.tenant_no == tenant_no,
  1348. SysUser.del_flag == 0,
  1349. )
  1350. .first()
  1351. )
  1352. storage_cert_dict['handlerUserName'] = (
  1353. (hu.nick_name or '') if hu else ''
  1354. )
  1355. else:
  1356. storage_cert_dict['handlerUserName'] = ''
  1357. delivery_dt = getattr(party, 'newborn_delivery_time', None) if party else None
  1358. fullmoon = cell_crud.get_fullmoon_todo(db, row.id, tenant_no)
  1359. fullmoon_dict = cell_crud._fullmoon_todo_detail_dict(
  1360. fullmoon, newborn_delivery_time=delivery_dt
  1361. )
  1362. hid2 = str(fullmoon_dict.get('handlerUserId') or '').strip()
  1363. if hid2:
  1364. hu2 = (
  1365. db.query(SysUser)
  1366. .filter(
  1367. SysUser.id == hid2,
  1368. SysUser.tenant_no == tenant_no,
  1369. SysUser.del_flag == 0,
  1370. )
  1371. .first()
  1372. )
  1373. fullmoon_dict['handlerUserName'] = (hu2.nick_name or '') if hu2 else ''
  1374. else:
  1375. fullmoon_dict['handlerUserName'] = ''
  1376. audits, _ = cell_crud.list_audits(
  1377. db,
  1378. page=1,
  1379. page_size=100,
  1380. collection_protocol_id=row.id,
  1381. tenant_no=tenant_no,
  1382. )
  1383. basic_dict = cell_crud._protocol_to_dict(row)
  1384. basic_dict['protocolTotalAmount'] = _fixed_money(row.protocol_total_amount)
  1385. payment_dict = cell_crud._payment_to_dict(payment) if payment else None
  1386. if payment_dict is not None:
  1387. payment_dict['contractAmount'] = _fixed_money(payment.contract_amount)
  1388. payment_details = list(web_bundle.get('paymentDetails') or [])
  1389. if payment:
  1390. detail_models = cell_crud.list_payment_details(db, payment.id, tenant_no)
  1391. model_by_id = {str(item.id): item for item in detail_models}
  1392. for item in payment_details:
  1393. model = model_by_id.get(str(item.get('id') or ''))
  1394. if model:
  1395. for key, attr in (
  1396. ('dueAmount', 'due_amount'),
  1397. ('transferAmount', 'transfer_amount'),
  1398. ('refundAmount', 'refund_amount'),
  1399. ):
  1400. item[key] = _fixed_money(getattr(model, attr, None))
  1401. sample_model_by_id = {str(item.id): item for item in samples}
  1402. for item in sample_dicts:
  1403. model = sample_model_by_id.get(str(item.get('id') or ''))
  1404. if model:
  1405. item['price'] = _fixed_money(model.price)
  1406. item['originalPrice'] = _fixed_money(model.original_price)
  1407. detail = {
  1408. 'source': SOURCE_CELL,
  1409. 'basic': basic_dict,
  1410. 'collection': cell_crud._collection_info_to_dict(collection)
  1411. if collection
  1412. else None,
  1413. 'customer': cell_crud._party_customer_to_dict(party) if party else None,
  1414. 'samples': sample_dicts,
  1415. 'payment': payment_dict,
  1416. 'paymentModelVersion': version,
  1417. 'samplePayments': sample_payments,
  1418. 'paymentDetails': payment_details,
  1419. 'depositTransfers': psp_crud.list_deposit_transfers_for_protocol(
  1420. db,
  1421. source_presign_order_id=source_presign_id,
  1422. tenant_no=tenant_no,
  1423. money_as_string=True,
  1424. ),
  1425. 'presignDepositHint': psp_crud.get_presign_deposit_hint(
  1426. db,
  1427. source_presign_order_id=source_presign_id,
  1428. tenant_no=tenant_no,
  1429. money_as_string=True,
  1430. ),
  1431. 'storageCert': storage_cert_dict,
  1432. 'fullmoonTodo': fullmoon_dict,
  1433. 'audits': audits,
  1434. 'statusChanges': cell_crud.list_protocol_status_changes(
  1435. db,
  1436. collection_protocol_id=row.id,
  1437. tenant_no=tenant_no,
  1438. ),
  1439. 'attachments': {
  1440. 'protocolAttachments': row.protocol_attachments or '',
  1441. 'consentFormUrls': row.consent_form_urls or '',
  1442. 'healthSurveyFormUrls': row.health_survey_form_urls or '',
  1443. },
  1444. }
  1445. _enrich_transfer_type_labels(db, detail, tenant_no)
  1446. _enrich_protocol_detail_labels(db, detail, tenant_no)
  1447. return detail
  1448. def _donation_detail(
  1449. db: Session, row: BizResearchDonation, tenant_no: str
  1450. ) -> dict:
  1451. # 与 Web 查看弹窗共用同一份详情 DTO,避免移动端再次拼装时遗漏生命周期字段。
  1452. from app.crud import qc_task as _qc_task_svc # noqa: F401
  1453. web_bundle = donation_crud.get_donation_detail(db, row.id, tenant_no) or {}
  1454. basic = dict(web_bundle.get('donation') or donation_crud._donation_to_dict(row))
  1455. basic['convertTime'] = basic.get('convertTime') or basic.get('toStorageTime') or ''
  1456. basic['convertDesc'] = basic.get('convertDesc') or basic.get('toStorageDesc') or ''
  1457. audits = donation_crud.list_audits(
  1458. db,
  1459. tenant_no=tenant_no,
  1460. page=1,
  1461. page_size=100,
  1462. research_donation_id=row.id,
  1463. )
  1464. detail = {
  1465. 'source': SOURCE_DONATION,
  1466. 'basic': basic,
  1467. 'collection': web_bundle.get('collection'),
  1468. 'customer': web_bundle.get('party'),
  1469. 'samples': list(web_bundle.get('samples') or []),
  1470. 'audits': list(audits.get('list') or []),
  1471. 'statusChanges': donation_crud.list_donation_status_changes(
  1472. db,
  1473. research_donation_id=row.id,
  1474. tenant_no=tenant_no,
  1475. ),
  1476. 'attachments': {
  1477. 'consentFormUrls': basic.get('consentFormUrls') or '',
  1478. 'attachmentUrls': basic.get('attachmentUrls') or '',
  1479. 'infectiousDiseaseReportUrls': basic.get('infectiousDiseaseReportUrls') or '',
  1480. },
  1481. }
  1482. _enrich_protocol_detail_labels(db, detail, tenant_no)
  1483. return detail
  1484. def _enrich_transfer_type_labels(
  1485. db: Session, detail: dict, tenant_no: str
  1486. ) -> None:
  1487. """批量解析协议内所有转账方式;未知字典值保留原编码。"""
  1488. label_map = {
  1489. str(item.get('value') or ''): str(item.get('label') or '')
  1490. for item in _dict_options(db, tenant_no, 'zzfs')
  1491. }
  1492. def apply(row: dict) -> None:
  1493. code = str(row.get('transferType') or '').strip()
  1494. if code:
  1495. row['transferTypeLabel'] = (
  1496. label_map.get(code)
  1497. or str(row.get('transferTypeLabel') or '').strip()
  1498. or code
  1499. )
  1500. for row in detail.get('depositTransfers') or []:
  1501. apply(row)
  1502. for row in detail.get('paymentDetails') or []:
  1503. apply(row)
  1504. for payment in detail.get('samplePayments') or []:
  1505. for schedule in payment.get('schedules') or []:
  1506. for row in schedule.get('transfers') or []:
  1507. apply(row)
  1508. def _enrich_protocol_detail_labels(
  1509. db: Session, detail: dict, tenant_no: str
  1510. ) -> None:
  1511. """补齐 Web 详情使用的主数据名称,移动端不直接展示内部 ID。"""
  1512. basic = detail.get('basic') or {}
  1513. collection = detail.get('collection') or {}
  1514. samples = detail.get('samples') or []
  1515. basic['voidTypeLabel'] = resolve_void_type_label(
  1516. db,
  1517. tenant_no,
  1518. str(basic.get('voidType') or ''),
  1519. )
  1520. region_id = str(collection.get('regionId') or '').strip()
  1521. hospital_id = str(collection.get('hospitalId') or '').strip()
  1522. if region_id:
  1523. region = (
  1524. db.query(BizCollectionRegion)
  1525. .filter(
  1526. BizCollectionRegion.id == region_id,
  1527. BizCollectionRegion.tenant_no == tenant_no,
  1528. )
  1529. .first()
  1530. )
  1531. collection['regionName'] = region.region_name if region else ''
  1532. if hospital_id:
  1533. hospital = (
  1534. db.query(BizCollectionHospital)
  1535. .filter(
  1536. BizCollectionHospital.id == hospital_id,
  1537. BizCollectionHospital.tenant_no == tenant_no,
  1538. )
  1539. .first()
  1540. )
  1541. collection['hospitalName'] = hospital.hospital_name if hospital else ''
  1542. user_ids = {
  1543. str(value or '').strip()
  1544. for value in (
  1545. basic.get('collectorUserId'),
  1546. collection.get('salesUserId'),
  1547. collection.get('regionManagerUserId'),
  1548. )
  1549. if str(value or '').strip()
  1550. }
  1551. user_names = {
  1552. row.id: row.nick_name or ''
  1553. for row in db.query(SysUser).filter(
  1554. SysUser.id.in_(user_ids or ['']),
  1555. SysUser.tenant_no == tenant_no,
  1556. )
  1557. }
  1558. basic['collectorUserName'] = user_names.get(
  1559. str(basic.get('collectorUserId') or ''), ''
  1560. )
  1561. collection['salesUserName'] = user_names.get(
  1562. str(collection.get('salesUserId') or ''), ''
  1563. )
  1564. collection['regionManagerUserName'] = user_names.get(
  1565. str(collection.get('regionManagerUserId') or ''), ''
  1566. )
  1567. type_ids = {
  1568. str(item.get('sampleTypeId') or '').strip()
  1569. for item in samples
  1570. if str(item.get('sampleTypeId') or '').strip()
  1571. }
  1572. type_names = {
  1573. row.id: row.type_name or ''
  1574. for row in db.query(BizCellSampleType).filter(
  1575. BizCellSampleType.id.in_(type_ids or ['']),
  1576. or_(
  1577. BizCellSampleType.tenant_no == tenant_no,
  1578. BizCellSampleType.tenant_no == '',
  1579. ),
  1580. )
  1581. }
  1582. unit_ids = {
  1583. str(item.get('unitId') or '').strip()
  1584. for item in samples
  1585. if str(item.get('unitId') or '').strip()
  1586. }
  1587. unit_names = {
  1588. row.id: row.unit_name or ''
  1589. for row in db.query(BizItemUnit).filter(
  1590. BizItemUnit.id.in_(unit_ids or ['']),
  1591. BizItemUnit.tenant_no == tenant_no,
  1592. )
  1593. }
  1594. for item in samples:
  1595. item['sampleTypeName'] = type_names.get(
  1596. str(item.get('sampleTypeId') or ''),
  1597. str(item.get('sampleTypeName') or ''),
  1598. )
  1599. item['unitName'] = unit_names.get(
  1600. str(item.get('unitId') or ''),
  1601. str(item.get('unitName') or ''),
  1602. )
  1603. def get_protocol(
  1604. db: Session,
  1605. *,
  1606. source: Optional[str],
  1607. protocol_id: str,
  1608. ctx: MarketFieldContext,
  1609. user_info: dict,
  1610. ) -> dict:
  1611. if source == SOURCE_CELL:
  1612. row = assert_cell_protocol_access(
  1613. db, protocol_id, ctx, user_info=user_info
  1614. )
  1615. return _cell_detail(db, row, ctx.tenant_no)
  1616. if source == SOURCE_DONATION:
  1617. row = assert_donation_access(db, protocol_id, ctx, user_info=user_info)
  1618. return _donation_detail(db, row, ctx.tenant_no)
  1619. if source is None:
  1620. cell = (
  1621. apply_cell_protocol_scope(
  1622. db.query(BizCollectionProtocol), db, ctx, user_info=user_info
  1623. )
  1624. .filter(BizCollectionProtocol.id == protocol_id)
  1625. .first()
  1626. )
  1627. if cell is not None:
  1628. return _cell_detail(db, cell, ctx.tenant_no)
  1629. donation = (
  1630. apply_donation_scope(
  1631. db.query(BizResearchDonation), db, ctx, user_info=user_info
  1632. )
  1633. .filter(BizResearchDonation.id == protocol_id)
  1634. .first()
  1635. )
  1636. if donation is not None:
  1637. return _donation_detail(db, donation, ctx.tenant_no)
  1638. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  1639. raise ValueError('不支持的协议来源')
  1640. def _lock_cell_protocol_access(
  1641. db: Session,
  1642. protocol_id: str,
  1643. ctx: MarketFieldContext,
  1644. *,
  1645. user_info: dict,
  1646. ) -> BizCollectionProtocol:
  1647. row = (
  1648. apply_cell_protocol_scope(
  1649. db.query(BizCollectionProtocol), db, ctx, user_info=user_info
  1650. )
  1651. .filter(BizCollectionProtocol.id == protocol_id)
  1652. .populate_existing()
  1653. .with_for_update()
  1654. .first()
  1655. )
  1656. if row is None:
  1657. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  1658. return row
  1659. def _lock_donation_access(
  1660. db: Session,
  1661. protocol_id: str,
  1662. ctx: MarketFieldContext,
  1663. *,
  1664. user_info: dict,
  1665. ) -> BizResearchDonation:
  1666. row = (
  1667. apply_donation_scope(
  1668. db.query(BizResearchDonation), db, ctx, user_info=user_info
  1669. )
  1670. .filter(BizResearchDonation.id == protocol_id)
  1671. .populate_existing()
  1672. .with_for_update()
  1673. .first()
  1674. )
  1675. if row is None:
  1676. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  1677. return row
  1678. def audit_protocol(
  1679. db: Session,
  1680. *,
  1681. source: str,
  1682. protocol_id: str,
  1683. audit_status: str,
  1684. audit_remark: Optional[str],
  1685. ctx: MarketFieldContext,
  1686. user_info: dict,
  1687. ) -> dict:
  1688. if source == SOURCE_CELL:
  1689. row = assert_cell_protocol_access(db, protocol_id, ctx, user_info=user_info)
  1690. if 'audit' not in allowed_actions('protocol', status=row.protocol_status or ''):
  1691. raise ValueError('当前状态不可审核')
  1692. cell_crud.create_audit(
  1693. db,
  1694. collection_protocol_id=protocol_id,
  1695. audit_status=audit_status,
  1696. audit_remark=audit_remark,
  1697. tenant_no=ctx.tenant_no,
  1698. user_id=ctx.user_id,
  1699. )
  1700. return {'id': protocol_id, 'source': source, 'protocolNo': row.protocol_no or ''}
  1701. if source == SOURCE_DONATION:
  1702. row = assert_donation_access(db, protocol_id, ctx, user_info=user_info)
  1703. if 'audit' not in allowed_actions('protocol', status=row.donation_status or ''):
  1704. raise ValueError('当前状态不可审核')
  1705. donation_crud.submit_audit(
  1706. db,
  1707. protocol_id,
  1708. audit_status=audit_status,
  1709. audit_remark=audit_remark,
  1710. tenant_no=ctx.tenant_no,
  1711. user_id=ctx.user_id,
  1712. )
  1713. return {'id': protocol_id, 'source': source, 'donationNo': row.donation_no or ''}
  1714. raise ValueError('不支持的协议来源')
  1715. def delete_protocol(
  1716. db: Session,
  1717. *,
  1718. source: str,
  1719. protocol_id: str,
  1720. ctx: MarketFieldContext,
  1721. user_info: dict,
  1722. ) -> None:
  1723. if source == SOURCE_CELL:
  1724. _lock_cell_protocol_access(
  1725. db, protocol_id, ctx, user_info=user_info
  1726. )
  1727. cell_crud.soft_delete_collection_protocol(
  1728. db, protocol_id, ctx.tenant_no, user_id=ctx.user_id
  1729. )
  1730. return
  1731. if source == SOURCE_DONATION:
  1732. _lock_donation_access(
  1733. db, protocol_id, ctx, user_info=user_info
  1734. )
  1735. donation_crud.delete_donation(
  1736. db, protocol_id, ctx.tenant_no, user_id=ctx.user_id
  1737. )
  1738. return
  1739. raise ValueError('不支持的协议来源')
  1740. def create_protocol(
  1741. db: Session,
  1742. *,
  1743. body: MarketProtocolBody,
  1744. ctx: MarketFieldContext,
  1745. user_info: dict,
  1746. ) -> dict:
  1747. if body.source == SOURCE_DONATION and (
  1748. body.sample_payments is not None
  1749. or body.payment is not None
  1750. or body.payment_details is not None
  1751. ):
  1752. raise ValueError('科研捐赠不支持缴费方案')
  1753. customer, collection, samples, attachments = _normalize_write(
  1754. db, body=body, ctx=ctx
  1755. )
  1756. _assert_customer_link_policy(db, customer, ctx=ctx, user_info=user_info)
  1757. dept_id = _current_dept_id(user_info)
  1758. try:
  1759. if body.source == SOURCE_CELL:
  1760. _assert_sample_freight_targets(
  1761. db,
  1762. protocol_id=None,
  1763. samples=samples,
  1764. ctx=ctx,
  1765. user_info=user_info,
  1766. )
  1767. row = cell_crud.create_collection_protocol(
  1768. db,
  1769. protocol_no=body.protocol_no or '',
  1770. # 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态
  1771. protocol_status='sxz',
  1772. protocol_attachments=attachments.get('protocol_attachments'),
  1773. consent_form_urls=attachments.get('consent_form_urls'),
  1774. health_survey_form_urls=attachments.get(
  1775. 'health_survey_form_urls'
  1776. ),
  1777. register_date=body.register_date,
  1778. collector_user_id=ctx.user_id
  1779. if ctx.scope_kind == 'specialist'
  1780. else body.collector_user_id,
  1781. protocol_total_amount=body.protocol_total_amount,
  1782. remark=body.remark,
  1783. tenant_no=ctx.tenant_no,
  1784. user_id=ctx.user_id,
  1785. create_dept_id=dept_id,
  1786. commit=False,
  1787. )
  1788. attachments = _validate_mobile_attachments(
  1789. db,
  1790. attachments,
  1791. ctx=ctx,
  1792. creating=True,
  1793. business_id=row.id,
  1794. )
  1795. row.protocol_attachments = attachments.get('protocol_attachments')
  1796. row.consent_form_urls = attachments.get('consent_form_urls')
  1797. row.health_survey_form_urls = attachments.get(
  1798. 'health_survey_form_urls'
  1799. )
  1800. db.add(row)
  1801. cell_crud.upsert_party_customer(
  1802. db,
  1803. row.id,
  1804. ctx.tenant_no,
  1805. ctx.user_id,
  1806. commit=False,
  1807. create_dept_id=dept_id,
  1808. **customer,
  1809. )
  1810. cell_crud.upsert_collection_info(
  1811. db,
  1812. row.id,
  1813. ctx.tenant_no,
  1814. ctx.user_id,
  1815. commit=False,
  1816. create_dept_id=dept_id,
  1817. **collection,
  1818. )
  1819. saved_samples = cell_crud.save_samples(
  1820. db,
  1821. row.id,
  1822. ctx.tenant_no,
  1823. ctx.user_id,
  1824. samples,
  1825. create_dept_id=dept_id,
  1826. commit=False,
  1827. )
  1828. _persist_sample_payments_if_any(
  1829. db,
  1830. row=row,
  1831. body=body,
  1832. ctx=ctx,
  1833. saved_samples=saved_samples,
  1834. )
  1835. assert_cell_protocol_access(db, row.id, ctx, user_info=user_info)
  1836. db.commit()
  1837. db.refresh(row)
  1838. return _cell_detail(db, row, ctx.tenant_no)
  1839. if body.sample_payments:
  1840. raise ValueError('科研捐赠不支持样本缴费方案')
  1841. data = {
  1842. 'donation_no': body.donation_no or '',
  1843. # 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态
  1844. 'donation_status': 'sxz',
  1845. 'donation_date': body.donation_date,
  1846. 'collector_user_id': ctx.user_id
  1847. if ctx.scope_kind == 'specialist'
  1848. else body.collector_user_id,
  1849. 'remark': body.remark,
  1850. 'party': customer,
  1851. 'collection': collection,
  1852. 'samples': samples,
  1853. 'consent_form_urls': attachments.get('consent_form_urls'),
  1854. 'attachment_urls': attachments.get('attachment_urls'),
  1855. 'infectious_disease_report_urls': attachments.get(
  1856. 'infectious_disease_report_urls'
  1857. ),
  1858. }
  1859. row = donation_crud.create_donation(
  1860. db,
  1861. data=data,
  1862. tenant_no=ctx.tenant_no,
  1863. user_id=ctx.user_id,
  1864. create_dept_id=dept_id,
  1865. commit=False,
  1866. sync_customers=False,
  1867. )
  1868. attachments = _validate_mobile_attachments(
  1869. db,
  1870. attachments,
  1871. ctx=ctx,
  1872. creating=True,
  1873. business_id=row.id,
  1874. )
  1875. row.consent_form_urls = attachments.get('consent_form_urls')
  1876. row.attachment_urls = attachments.get('attachment_urls')
  1877. row.infectious_disease_report_urls = attachments.get(
  1878. 'infectious_disease_report_urls'
  1879. )
  1880. db.add(row)
  1881. assert_donation_access(db, row.id, ctx, user_info=user_info)
  1882. db.commit()
  1883. db.refresh(row)
  1884. return _donation_detail(db, row, ctx.tenant_no)
  1885. except Exception:
  1886. db.rollback()
  1887. raise
  1888. def update_protocol(
  1889. db: Session,
  1890. *,
  1891. source: str,
  1892. protocol_id: str,
  1893. body: MarketProtocolBody,
  1894. ctx: MarketFieldContext,
  1895. user_info: dict,
  1896. ) -> dict:
  1897. if body.source != source:
  1898. raise ValueError('请求来源与路径来源不一致')
  1899. if source == SOURCE_DONATION and (
  1900. body.sample_payments is not None
  1901. or body.payment is not None
  1902. or body.payment_details is not None
  1903. ):
  1904. raise ValueError('科研捐赠不支持缴费方案')
  1905. dept_id = _current_dept_id(user_info)
  1906. try:
  1907. if source == SOURCE_CELL:
  1908. row = _lock_cell_protocol_access(
  1909. db, protocol_id, ctx, user_info=user_info
  1910. )
  1911. current_collection = cell_crud.get_collection_info(
  1912. db, row.id, ctx.tenant_no
  1913. )
  1914. current_party = cell_crud.get_party_customer(
  1915. db, row.id, ctx.tenant_no
  1916. )
  1917. current_samples = cell_crud.list_samples(
  1918. db, row.id, ctx.tenant_no
  1919. )
  1920. customer, collection, samples, attachments = _normalize_write(
  1921. db,
  1922. body=body,
  1923. ctx=ctx,
  1924. current_collection=current_collection,
  1925. current_samples=current_samples,
  1926. )
  1927. if body.samples is not None:
  1928. _assert_sample_freight_targets(
  1929. db,
  1930. protocol_id=row.id,
  1931. samples=samples,
  1932. ctx=ctx,
  1933. user_info=user_info,
  1934. )
  1935. attachments = _validate_mobile_attachments(
  1936. db,
  1937. attachments,
  1938. ctx=ctx,
  1939. creating=False,
  1940. existing={
  1941. 'protocol_attachments': row.protocol_attachments,
  1942. 'consent_form_urls': row.consent_form_urls,
  1943. 'health_survey_form_urls': row.health_survey_form_urls,
  1944. },
  1945. business_id=row.id,
  1946. )
  1947. _assert_customer_link_policy(
  1948. db, customer, ctx=ctx, user_info=user_info
  1949. )
  1950. cell_crud.update_collection_protocol(
  1951. db,
  1952. row.id,
  1953. tenant_no=ctx.tenant_no,
  1954. user_id=ctx.user_id,
  1955. protocol_no=body.protocol_no,
  1956. protocol_attachments=attachments.get('protocol_attachments'),
  1957. consent_form_urls=attachments.get('consent_form_urls'),
  1958. health_survey_form_urls=attachments.get(
  1959. 'health_survey_form_urls'
  1960. ),
  1961. register_date=body.register_date,
  1962. collector_user_id=ctx.user_id
  1963. if ctx.scope_kind == 'specialist'
  1964. else body.collector_user_id,
  1965. protocol_total_amount=body.protocol_total_amount,
  1966. remark=body.remark,
  1967. commit=False,
  1968. )
  1969. if body.customer is not None:
  1970. if current_party is not None:
  1971. customer = {
  1972. **cell_crud._normalize_fields(
  1973. cell_crud._party_customer_to_dict(current_party)
  1974. ),
  1975. **customer,
  1976. }
  1977. for metadata_key in (
  1978. 'id',
  1979. 'collection_protocol_id',
  1980. 'tenant_no',
  1981. 'order_num',
  1982. 'create_time',
  1983. 'update_time',
  1984. ):
  1985. customer.pop(metadata_key, None)
  1986. newborn_delivery_time = customer.get(
  1987. 'newborn_delivery_time'
  1988. )
  1989. if isinstance(newborn_delivery_time, str):
  1990. if newborn_delivery_time.strip():
  1991. customer['newborn_delivery_time'] = parse_datetime(
  1992. newborn_delivery_time
  1993. )
  1994. else:
  1995. customer.pop('newborn_delivery_time', None)
  1996. cell_crud.upsert_party_customer(
  1997. db,
  1998. row.id,
  1999. ctx.tenant_no,
  2000. ctx.user_id,
  2001. commit=False,
  2002. create_dept_id=dept_id,
  2003. **customer,
  2004. )
  2005. if body.collection is not None:
  2006. cell_crud.upsert_collection_info(
  2007. db,
  2008. row.id,
  2009. ctx.tenant_no,
  2010. ctx.user_id,
  2011. commit=False,
  2012. create_dept_id=dept_id,
  2013. **collection,
  2014. )
  2015. saved_samples = current_samples
  2016. if body.samples is not None:
  2017. saved_samples = cell_crud.save_samples(
  2018. db,
  2019. row.id,
  2020. ctx.tenant_no,
  2021. ctx.user_id,
  2022. samples,
  2023. create_dept_id=dept_id,
  2024. commit=False,
  2025. )
  2026. _persist_sample_payments_if_any(
  2027. db,
  2028. row=row,
  2029. body=body,
  2030. ctx=ctx,
  2031. saved_samples=saved_samples,
  2032. )
  2033. row = assert_cell_protocol_access(
  2034. db, row.id, ctx, user_info=user_info
  2035. )
  2036. db.commit()
  2037. db.refresh(row)
  2038. return _cell_detail(db, row, ctx.tenant_no)
  2039. row = _lock_donation_access(
  2040. db, protocol_id, ctx, user_info=user_info
  2041. )
  2042. current_collection = donation_crud.get_collection(
  2043. db, row.id, ctx.tenant_no
  2044. )
  2045. current_party = donation_crud.get_party(db, row.id, ctx.tenant_no)
  2046. current_samples = donation_crud.list_samples(
  2047. db, row.id, ctx.tenant_no
  2048. )
  2049. customer, collection, samples, attachments = _normalize_write(
  2050. db,
  2051. body=body,
  2052. ctx=ctx,
  2053. current_collection=current_collection,
  2054. current_samples=current_samples,
  2055. )
  2056. attachments = _validate_mobile_attachments(
  2057. db,
  2058. attachments,
  2059. ctx=ctx,
  2060. creating=False,
  2061. existing={
  2062. 'consent_form_urls': row.consent_form_urls,
  2063. 'attachment_urls': row.attachment_urls,
  2064. 'infectious_disease_report_urls': row.infectious_disease_report_urls,
  2065. },
  2066. business_id=row.id,
  2067. )
  2068. _assert_customer_link_policy(
  2069. db, customer, ctx=ctx, user_info=user_info
  2070. )
  2071. if body.customer is not None and current_party is not None:
  2072. customer = {
  2073. **donation_crud._normalize_fields(
  2074. donation_crud._party_to_dict(current_party)
  2075. ),
  2076. **customer,
  2077. }
  2078. if body.collection is not None and current_collection is not None:
  2079. collection = {
  2080. **donation_crud._normalize_fields(
  2081. donation_crud._collection_to_dict(current_collection)
  2082. ),
  2083. **collection,
  2084. }
  2085. data = {
  2086. 'donation_no': body.donation_no,
  2087. 'donation_date': body.donation_date,
  2088. 'collector_user_id': ctx.user_id
  2089. if ctx.scope_kind == 'specialist'
  2090. else body.collector_user_id,
  2091. 'remark': body.remark,
  2092. 'consent_form_urls': attachments.get('consent_form_urls'),
  2093. 'attachment_urls': attachments.get('attachment_urls'),
  2094. 'infectious_disease_report_urls': attachments.get(
  2095. 'infectious_disease_report_urls'
  2096. ),
  2097. }
  2098. if body.customer is not None:
  2099. data['party'] = customer
  2100. if body.collection is not None:
  2101. data['collection'] = collection
  2102. if body.samples is not None:
  2103. data['samples'] = samples
  2104. data = {key: value for key, value in data.items() if value is not None}
  2105. row = donation_crud.update_donation(
  2106. db,
  2107. row.id,
  2108. data=data,
  2109. tenant_no=ctx.tenant_no,
  2110. user_id=ctx.user_id,
  2111. create_dept_id=dept_id,
  2112. commit=False,
  2113. sync_customers=False,
  2114. )
  2115. row = assert_donation_access(db, row.id, ctx, user_info=user_info)
  2116. db.commit()
  2117. db.refresh(row)
  2118. return _donation_detail(db, row, ctx.tenant_no)
  2119. except Exception:
  2120. db.rollback()
  2121. raise