market_sample.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198
  1. """市场移动端统一样本查询与轻量办理。"""
  2. from __future__ import annotations
  3. import uuid
  4. from datetime import datetime
  5. from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
  6. from fastapi import HTTPException
  7. from sqlalchemy import and_, literal, or_
  8. from sqlalchemy.orm import Session
  9. from app.models.models import (
  10. BizCellSampleType,
  11. BizCollectionProtocol,
  12. BizProtocolCollectionInfo,
  13. BizCollectionRegionStaff,
  14. BizProtocolFullmoonTodo,
  15. BizProtocolPartyCustomer,
  16. BizProtocolSample,
  17. BizProtocolStorageCert,
  18. BizQcWorkOrder,
  19. BizSampleReception,
  20. BizStorageReleaseReview,
  21. SysUser,
  22. )
  23. from app.models.research_donation import (
  24. BizResearchDonation,
  25. BizResearchDonationCollection,
  26. BizResearchDonationParty,
  27. BizResearchDonationSample,
  28. )
  29. from app.services.market_field_scope import (
  30. MarketFieldContext,
  31. apply_cell_protocol_scope,
  32. apply_donation_scope,
  33. leader_region_ids_query,
  34. )
  35. from app.services.market_support import validate_attachment_refs
  36. from app.services.sample_info_progress import (
  37. apply_handle_payload,
  38. insurance_handled,
  39. storage_cert_handled,
  40. )
  41. CELL_SOURCE = 'cell_storage'
  42. RESEARCH_SOURCE = 'research_donation'
  43. VALID_SOURCES = (CELL_SOURCE, RESEARCH_SOURCE)
  44. def _fmt_dt(value: Optional[datetime]) -> str:
  45. return value.strftime('%Y-%m-%d %H:%M:%S') if value else ''
  46. def _fmt_date(value: Any) -> str:
  47. if not value:
  48. return ''
  49. if hasattr(value, 'strftime'):
  50. return value.strftime('%Y-%m-%d')
  51. text = str(value)
  52. return f'{text[:4]}-{text[4:6]}-{text[6:8]}' if len(text) == 8 and text.isdigit() else text
  53. def _parse_dt(value: Any) -> Optional[datetime]:
  54. if value in (None, ''):
  55. return None
  56. if isinstance(value, datetime):
  57. return value
  58. text = str(value).strip()
  59. for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%dT%H:%M:%S'):
  60. try:
  61. return datetime.strptime(text, fmt)
  62. except ValueError:
  63. continue
  64. raise ValueError('办理时间格式无效')
  65. def _status_meta(code: str, label: str) -> Dict[str, str]:
  66. normalized = (code or '').strip()
  67. if normalized in (
  68. 'ycj',
  69. 'collected',
  70. 'js',
  71. 'complete',
  72. 'tg',
  73. 'yrk',
  74. 'handled',
  75. ):
  76. tag_type = 'success'
  77. elif normalized in ('jcz', 'incomplete', 'processing'):
  78. tag_type = 'warning'
  79. elif normalized in ('yzf', 'voided', 'bj', 'btg', 'rejected'):
  80. tag_type = 'danger'
  81. else:
  82. tag_type = 'info'
  83. return {
  84. 'code': normalized,
  85. 'label': label,
  86. 'statusTone': tag_type,
  87. 'tagType': tag_type,
  88. }
  89. def _sample_status(status: str) -> Dict[str, str]:
  90. status = (status or '').strip()
  91. label = {
  92. 'ycj': '已采集',
  93. 'jcz': '检测中',
  94. 'yrk': '已入库',
  95. 'yzf': '已作废',
  96. }.get(status, '未采集' if not status else status)
  97. return _status_meta(status or 'none', label)
  98. def _collection_progress(status: str) -> Dict[str, str]:
  99. status = (status or '').strip()
  100. if status == 'yzf':
  101. return _status_meta('voided', '已作废')
  102. if status in ('ycj', 'jcz', 'yrk'):
  103. return _status_meta('collected', '已采集')
  104. return _status_meta('uncollected', '未采集')
  105. def _apply_joined_collection_scope(query, model, db: Session, ctx: MarketFieldContext):
  106. if ctx.scope_kind == 'specialist':
  107. return query.filter(model.sales_user_id == ctx.user_id)
  108. return query.filter(
  109. or_(
  110. model.region_id.in_(leader_region_ids_query(db, ctx)),
  111. model.region_manager_user_id == ctx.user_id,
  112. )
  113. )
  114. def _stable_id(source: str, sample_code: str, row_id: str) -> str:
  115. code = (sample_code or '').strip()
  116. return f'{source}:{code}' if code else f'{source}:@id:{row_id}'
  117. def _parse_stable_id(sample_id: str) -> Tuple[str, str, bool]:
  118. source, separator, token = (sample_id or '').partition(':')
  119. token = token.strip()
  120. if not separator or source not in VALID_SOURCES or not token:
  121. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  122. if token.startswith('@id:'):
  123. row_id = token[4:].strip()
  124. if not row_id:
  125. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  126. return source, row_id, True
  127. return source, token, False
  128. def _materialization_order(query, source: str):
  129. if source == CELL_SOURCE:
  130. return query.order_by(
  131. BizProtocolCollectionInfo.create_time.desc(),
  132. BizProtocolCollectionInfo.id.desc(),
  133. BizProtocolPartyCustomer.create_time.desc(),
  134. BizProtocolPartyCustomer.id.desc(),
  135. )
  136. return query.order_by(
  137. BizResearchDonationCollection.create_time.desc(),
  138. BizResearchDonationCollection.id.desc(),
  139. BizResearchDonationParty.create_time.desc(),
  140. BizResearchDonationParty.id.desc(),
  141. )
  142. def _cell_query(db: Session, ctx: MarketFieldContext, user_info: dict):
  143. query = (
  144. db.query(
  145. BizProtocolSample,
  146. BizCollectionProtocol,
  147. BizProtocolCollectionInfo,
  148. BizProtocolPartyCustomer,
  149. )
  150. .join(
  151. BizCollectionProtocol,
  152. and_(
  153. BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id,
  154. BizCollectionProtocol.tenant_no == ctx.tenant_no,
  155. BizCollectionProtocol.del_flag == 0,
  156. ),
  157. )
  158. .join(
  159. BizProtocolCollectionInfo,
  160. and_(
  161. BizProtocolCollectionInfo.collection_protocol_id == BizCollectionProtocol.id,
  162. BizProtocolCollectionInfo.tenant_no == ctx.tenant_no,
  163. BizProtocolCollectionInfo.del_flag == 0,
  164. ),
  165. )
  166. .outerjoin(
  167. BizProtocolPartyCustomer,
  168. and_(
  169. BizProtocolPartyCustomer.collection_protocol_id == BizCollectionProtocol.id,
  170. BizProtocolPartyCustomer.tenant_no == ctx.tenant_no,
  171. BizProtocolPartyCustomer.del_flag == 0,
  172. ),
  173. )
  174. .filter(
  175. BizProtocolSample.tenant_no == ctx.tenant_no,
  176. BizProtocolSample.del_flag == 0,
  177. )
  178. )
  179. query = apply_cell_protocol_scope(query, db, ctx, user_info=user_info)
  180. return _apply_joined_collection_scope(
  181. query, BizProtocolCollectionInfo, db, ctx
  182. )
  183. def _research_query(db: Session, ctx: MarketFieldContext, user_info: dict):
  184. query = (
  185. db.query(
  186. BizResearchDonationSample,
  187. BizResearchDonation,
  188. BizResearchDonationCollection,
  189. BizResearchDonationParty,
  190. )
  191. .join(
  192. BizResearchDonation,
  193. and_(
  194. BizResearchDonation.id == BizResearchDonationSample.research_donation_id,
  195. BizResearchDonation.tenant_no == ctx.tenant_no,
  196. BizResearchDonation.del_flag == 0,
  197. ),
  198. )
  199. .join(
  200. BizResearchDonationCollection,
  201. and_(
  202. BizResearchDonationCollection.research_donation_id == BizResearchDonation.id,
  203. BizResearchDonationCollection.tenant_no == ctx.tenant_no,
  204. BizResearchDonationCollection.del_flag == 0,
  205. ),
  206. )
  207. .outerjoin(
  208. BizResearchDonationParty,
  209. and_(
  210. BizResearchDonationParty.research_donation_id == BizResearchDonation.id,
  211. BizResearchDonationParty.tenant_no == ctx.tenant_no,
  212. BizResearchDonationParty.del_flag == 0,
  213. ),
  214. )
  215. .filter(
  216. BizResearchDonationSample.tenant_no == ctx.tenant_no,
  217. BizResearchDonationSample.del_flag == 0,
  218. )
  219. )
  220. query = apply_donation_scope(query, db, ctx, user_info=user_info)
  221. return _apply_joined_collection_scope(
  222. query, BizResearchDonationCollection, db, ctx
  223. )
  224. def _apply_filters(query, source: str, keyword: Optional[str], status: Optional[str]):
  225. if source == CELL_SOURCE:
  226. sample = BizProtocolSample
  227. parent = BizCollectionProtocol
  228. party = BizProtocolPartyCustomer
  229. else:
  230. sample = BizResearchDonationSample
  231. parent = BizResearchDonation
  232. party = BizResearchDonationParty
  233. text = (keyword or '').strip()
  234. if text:
  235. like = f'%{text}%'
  236. number_col = (
  237. BizCollectionProtocol.protocol_no
  238. if source == CELL_SOURCE
  239. else BizResearchDonation.donation_no
  240. )
  241. query = query.filter(
  242. or_(sample.sample_code.like(like), number_col.like(like), party.mother_name.like(like))
  243. )
  244. normalized_status = (status or '').strip()
  245. if normalized_status and normalized_status not in ('all', 'pending', 'processing', 'completed'):
  246. query = query.filter(sample.sample_status == normalized_status)
  247. elif normalized_status == 'pending':
  248. query = query.filter(or_(sample.sample_status.is_(None), sample.sample_status == ''))
  249. elif normalized_status == 'processing':
  250. query = query.filter(sample.sample_status.in_(('ycj', 'jcz')))
  251. elif normalized_status == 'completed':
  252. query = query.filter(sample.sample_status == 'yrk')
  253. return query
  254. def _identity_query(query, source: str):
  255. sample = BizProtocolSample if source == CELL_SOURCE else BizResearchDonationSample
  256. return query.with_entities(
  257. literal(source).label('source'),
  258. sample.id.label('row_id'),
  259. sample.create_time.label('create_time'),
  260. ).distinct()
  261. def _load_rows(
  262. db: Session,
  263. identities: Iterable[Any],
  264. ctx: MarketFieldContext,
  265. user_info: dict,
  266. ) -> List[Dict[str, Any]]:
  267. ordered = [(row.source, row.row_id) for row in identities]
  268. cell_ids = [row_id for source, row_id in ordered if source == CELL_SOURCE]
  269. research_ids = [row_id for source, row_id in ordered if source == RESEARCH_SOURCE]
  270. rows_by_key: Dict[Tuple[str, str], Dict[str, Any]] = {}
  271. if cell_ids:
  272. query = _cell_query(db, ctx, user_info).filter(
  273. BizProtocolSample.id.in_(cell_ids)
  274. )
  275. for sample, parent, collection, party in _materialization_order(
  276. query, CELL_SOURCE
  277. ).all():
  278. rows_by_key.setdefault((CELL_SOURCE, sample.id), _raw_item(
  279. CELL_SOURCE, sample, parent, collection, party
  280. ))
  281. if research_ids:
  282. query = _research_query(db, ctx, user_info).filter(
  283. BizResearchDonationSample.id.in_(research_ids)
  284. )
  285. for sample, parent, collection, party in _materialization_order(
  286. query, RESEARCH_SOURCE
  287. ).all():
  288. rows_by_key.setdefault((RESEARCH_SOURCE, sample.id), _raw_item(
  289. RESEARCH_SOURCE, sample, parent, collection, party
  290. ))
  291. return [rows_by_key[key] for key in ordered if key in rows_by_key]
  292. def _raw_item(source: str, sample, parent, collection, party) -> Dict[str, Any]:
  293. number = parent.protocol_no if source == CELL_SOURCE else parent.donation_no
  294. return {
  295. 'id': _stable_id(source, sample.sample_code or '', sample.id),
  296. 'source': source,
  297. 'sourceLabel': '细胞存储' if source == CELL_SOURCE else '科研捐赠',
  298. 'sampleRowId': sample.id,
  299. 'parentId': parent.id,
  300. 'protocolNo': number or '',
  301. 'protocolStatus': (
  302. parent.protocol_status
  303. if source == CELL_SOURCE
  304. else parent.donation_status
  305. ) or '',
  306. 'sampleCode': sample.sample_code or '',
  307. 'sampleTypeId': sample.sample_type_id or '',
  308. 'sampleTypeName': '',
  309. 'sampleStatus': sample.sample_status or '',
  310. 'customerName': party.mother_name if party else '',
  311. 'motherName': party.mother_name if party else '',
  312. 'newbornName': party.newborn_name if party else '',
  313. 'expectedDeliveryDate': _fmt_date(
  314. party.expected_delivery_date if party else None
  315. ),
  316. 'regionId': collection.region_id or '',
  317. 'hospitalId': collection.hospital_id or '',
  318. 'salesUserId': collection.sales_user_id or '',
  319. 'regionManagerUserId': collection.region_manager_user_id or '',
  320. 'sampleCollectTime': _fmt_dt(collection.sample_collect_time),
  321. 'createTime': _fmt_dt(sample.create_time),
  322. }
  323. def _latest_maps(
  324. db: Session, tenant_no: str, items: List[Dict[str, Any]]
  325. ) -> Tuple[dict, dict, dict]:
  326. keys = {
  327. (item['source'], (item.get('sampleCode') or '').strip())
  328. for item in items
  329. if (item.get('sampleCode') or '').strip()
  330. }
  331. if not keys:
  332. return {}, {}, {}
  333. clauses = [
  334. and_(
  335. BizSampleReception.reception_source == source,
  336. BizSampleReception.sample_code.in_(
  337. [code for key_source, code in keys if key_source == source]
  338. ),
  339. )
  340. for source in VALID_SOURCES
  341. if any(key_source == source for key_source, _ in keys)
  342. ]
  343. receptions = (
  344. db.query(BizSampleReception)
  345. .filter(
  346. BizSampleReception.tenant_no == tenant_no,
  347. BizSampleReception.del_flag == 0,
  348. or_(*clauses),
  349. )
  350. .order_by(
  351. BizSampleReception.reception_time.desc(),
  352. BizSampleReception.create_time.desc(),
  353. BizSampleReception.id.desc(),
  354. )
  355. .all()
  356. )
  357. qc_clauses = [
  358. and_(
  359. BizQcWorkOrder.reception_source == source,
  360. BizQcWorkOrder.sample_code.in_(
  361. [code for key_source, code in keys if key_source == source]
  362. ),
  363. )
  364. for source in VALID_SOURCES
  365. if any(key_source == source for key_source, _ in keys)
  366. ]
  367. qc_rows = (
  368. db.query(BizQcWorkOrder)
  369. .filter(
  370. BizQcWorkOrder.tenant_no == tenant_no,
  371. BizQcWorkOrder.del_flag == 0,
  372. BizQcWorkOrder.trigger_source == 'reception',
  373. or_(*qc_clauses),
  374. )
  375. .order_by(
  376. BizQcWorkOrder.update_time.desc(),
  377. BizQcWorkOrder.create_time.desc(),
  378. BizQcWorkOrder.id.desc(),
  379. )
  380. .all()
  381. )
  382. storage_clauses = [
  383. and_(
  384. BizStorageReleaseReview.reception_source == source,
  385. BizStorageReleaseReview.sample_code.in_(
  386. [code for key_source, code in keys if key_source == source]
  387. ),
  388. )
  389. for source in VALID_SOURCES
  390. if any(key_source == source for key_source, _ in keys)
  391. ]
  392. storage_rows = (
  393. db.query(BizStorageReleaseReview)
  394. .filter(
  395. BizStorageReleaseReview.tenant_no == tenant_no,
  396. BizStorageReleaseReview.del_flag == 0,
  397. or_(*storage_clauses),
  398. )
  399. .order_by(
  400. BizStorageReleaseReview.storage_time.desc(),
  401. BizStorageReleaseReview.update_time.desc(),
  402. BizStorageReleaseReview.create_time.desc(),
  403. BizStorageReleaseReview.id.desc(),
  404. )
  405. .all()
  406. )
  407. reception_map = {}
  408. qc_map = {}
  409. storage_map = {}
  410. for row in receptions:
  411. reception_map.setdefault((row.reception_source, (row.sample_code or '').strip()), row)
  412. for row in qc_rows:
  413. qc_map.setdefault((row.reception_source, (row.sample_code or '').strip()), row)
  414. for row in storage_rows:
  415. storage_map.setdefault((row.reception_source, (row.sample_code or '').strip()), row)
  416. return reception_map, qc_map, storage_map
  417. def _handle_maps(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> Tuple[dict, dict]:
  418. parent_ids = {
  419. item['parentId'] for item in items if item['source'] == CELL_SOURCE
  420. }
  421. if not parent_ids:
  422. return {}, {}
  423. cert_rows = (
  424. db.query(BizProtocolStorageCert)
  425. .filter(
  426. BizProtocolStorageCert.tenant_no == tenant_no,
  427. BizProtocolStorageCert.del_flag == 0,
  428. BizProtocolStorageCert.collection_protocol_id.in_(parent_ids),
  429. )
  430. .order_by(
  431. BizProtocolStorageCert.update_time.desc(),
  432. BizProtocolStorageCert.create_time.desc(),
  433. BizProtocolStorageCert.id.desc(),
  434. )
  435. .all()
  436. )
  437. todo_rows = (
  438. db.query(BizProtocolFullmoonTodo)
  439. .filter(
  440. BizProtocolFullmoonTodo.tenant_no == tenant_no,
  441. BizProtocolFullmoonTodo.del_flag == 0,
  442. BizProtocolFullmoonTodo.collection_protocol_id.in_(parent_ids),
  443. )
  444. .order_by(
  445. BizProtocolFullmoonTodo.update_time.desc(),
  446. BizProtocolFullmoonTodo.create_time.desc(),
  447. BizProtocolFullmoonTodo.id.desc(),
  448. )
  449. .all()
  450. )
  451. cert_map = {}
  452. todo_map = {}
  453. for row in cert_rows:
  454. cert_map.setdefault(row.collection_protocol_id, row)
  455. for row in todo_rows:
  456. todo_map.setdefault(row.collection_protocol_id, row)
  457. return cert_map, todo_map
  458. def _handle_summary(
  459. row, *, insurance: bool = False, handler_user_name: str = ''
  460. ) -> Dict[str, Any]:
  461. if row is None:
  462. return {
  463. **_status_meta('pending', '未办理'),
  464. 'handleTime': '',
  465. 'handlerUserId': '',
  466. 'handlerUserName': '',
  467. 'attachments': '',
  468. 'remark': '',
  469. }
  470. attachments = row.insurance_attachments if insurance else row.cert_image_urls
  471. remark = row.handle_remark if insurance else row.remark
  472. handled = insurance_handled(row) if insurance else storage_cert_handled(row)
  473. return {
  474. **_status_meta('handled' if handled else 'pending', '已办理' if handled else '未办理'),
  475. 'handleTime': _fmt_dt(row.handle_time),
  476. 'handlerUserId': row.handler_user_id or '',
  477. 'handlerUserName': handler_user_name,
  478. 'attachments': attachments or '',
  479. 'remark': remark or '',
  480. }
  481. def _referenced_user_name_map(
  482. db: Session, tenant_no: str, user_ids: List[str]
  483. ) -> Dict[str, str]:
  484. wanted = {str(user_id or '').strip() for user_id in user_ids if user_id}
  485. if not wanted:
  486. return {}
  487. return {
  488. str(user_id): str(nick_name or '').strip()
  489. for user_id, nick_name in db.query(SysUser.id, SysUser.nick_name)
  490. .filter(
  491. SysUser.id.in_(wanted),
  492. SysUser.tenant_no == tenant_no,
  493. )
  494. .all()
  495. }
  496. def _enrich(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> None:
  497. type_ids = {item['sampleTypeId'] for item in items if item['sampleTypeId']}
  498. type_map = {}
  499. if type_ids:
  500. type_map = {
  501. row.id: row.type_name or ''
  502. for row in db.query(BizCellSampleType).filter(
  503. BizCellSampleType.id.in_(type_ids),
  504. BizCellSampleType.del_flag == 0,
  505. or_(BizCellSampleType.tenant_no == tenant_no, BizCellSampleType.tenant_no == ''),
  506. )
  507. }
  508. reception_map, qc_map, storage_map = _latest_maps(db, tenant_no, items)
  509. cert_map, todo_map = _handle_maps(db, tenant_no, items)
  510. handler_names = _referenced_user_name_map(
  511. db,
  512. tenant_no,
  513. [
  514. row.handler_user_id
  515. for row in (*cert_map.values(), *todo_map.values())
  516. if row.handler_user_id
  517. ],
  518. )
  519. for item in items:
  520. item['sampleTypeName'] = type_map.get(item['sampleTypeId'], '')
  521. sample_status = _sample_status(item['sampleStatus'])
  522. collection_progress = _collection_progress(item['sampleStatus'])
  523. item.update(
  524. statusLabel=sample_status['label'],
  525. statusTone=sample_status['statusTone'],
  526. tagType=sample_status['tagType'],
  527. )
  528. code = (item['sampleCode'] or '').strip()
  529. key = (item['source'], code)
  530. reception = reception_map.get(key) if code else None
  531. qc = qc_map.get(key) if code else None
  532. storage = storage_map.get(key) if code else None
  533. reception_code = (reception.accept_conclusion or '').strip() if reception else 'none'
  534. reception_label = {'js': '已接收', 'bj': '拒收'}.get(reception_code, '未接收')
  535. qc_code = (qc.conclusion or '').strip() if qc else 'incomplete'
  536. if qc and qc.work_order_status == 'yzf':
  537. qc_code = 'yzf'
  538. qc_label = {'tg': '通过', 'btg': '不通过', 'yzf': '已作废'}.get(qc_code, '未完成')
  539. if storage and storage.storage_status == 'yrk':
  540. storage_code, storage_label = 'yrk', '已入库'
  541. elif storage and storage.review_conclusion == 'btg':
  542. storage_code, storage_label = 'rejected', '拒绝入库'
  543. else:
  544. storage_code, storage_label = 'none', '未入库'
  545. item['progress'] = {
  546. 'collection': collection_progress,
  547. 'reception': {
  548. **_status_meta(reception_code, reception_label),
  549. 'recordId': reception.id if reception else '',
  550. 'time': _fmt_dt(reception.reception_time) if reception else '',
  551. },
  552. 'qc': {
  553. **_status_meta(qc_code, qc_label),
  554. 'recordId': qc.id if qc else '',
  555. 'workOrderNo': qc.work_order_no if qc else '',
  556. },
  557. 'storage': {
  558. **_status_meta(storage_code, storage_label),
  559. 'recordId': storage.id if storage else '',
  560. 'time': _fmt_dt(storage.storage_time) if storage else '',
  561. },
  562. }
  563. item['labStorageCode'] = (
  564. getattr(reception, 'lab_storage_code', None) or ''
  565. )
  566. if item['source'] == CELL_SOURCE:
  567. cert = cert_map.get(item['parentId'])
  568. todo = todo_map.get(item['parentId'])
  569. item['storageCert'] = _handle_summary(
  570. cert,
  571. handler_user_name=handler_names.get(
  572. getattr(cert, 'handler_user_id', '') or '', ''
  573. ),
  574. )
  575. item['insurance'] = _handle_summary(
  576. todo,
  577. insurance=True,
  578. handler_user_name=handler_names.get(
  579. getattr(todo, 'handler_user_id', '') or '', ''
  580. ),
  581. )
  582. def list_samples(
  583. db: Session,
  584. *,
  585. ctx: MarketFieldContext,
  586. user_info: dict,
  587. page: int = 1,
  588. page_size: int = 10,
  589. keyword: Optional[str] = None,
  590. status: Optional[str] = None,
  591. source: Optional[str] = None,
  592. ) -> Tuple[List[Dict[str, Any]], int]:
  593. page = max(1, int(page or 1))
  594. page_size = min(max(1, int(page_size or 10)), 100)
  595. identities = []
  596. if source in (None, '', CELL_SOURCE):
  597. cell = _apply_filters(_cell_query(db, ctx, user_info), CELL_SOURCE, keyword, status)
  598. identities.append(_identity_query(cell, CELL_SOURCE))
  599. if source in (None, '', RESEARCH_SOURCE):
  600. research = _apply_filters(
  601. _research_query(db, ctx, user_info), RESEARCH_SOURCE, keyword, status
  602. )
  603. identities.append(_identity_query(research, RESEARCH_SOURCE))
  604. if not identities:
  605. return [], 0
  606. combined = identities[0].union_all(*identities[1:]).subquery()
  607. total = db.query(combined).count()
  608. page_rows = (
  609. db.query(combined)
  610. .order_by(combined.c.create_time.desc(), combined.c.row_id.desc(), combined.c.source.desc())
  611. .offset((page - 1) * page_size)
  612. .limit(page_size)
  613. .all()
  614. )
  615. items = _load_rows(db, page_rows, ctx, user_info)
  616. _enrich(db, ctx.tenant_no, items)
  617. return items, total
  618. def _cell_sample_lookup_query(
  619. db: Session,
  620. *,
  621. token: str,
  622. by_row_id: bool,
  623. ctx: MarketFieldContext,
  624. user_info: dict,
  625. for_update: bool = False,
  626. ):
  627. query = _cell_query(db, ctx, user_info)
  628. if by_row_id:
  629. query = query.filter(
  630. BizProtocolSample.id == token,
  631. or_(
  632. BizProtocolSample.sample_code.is_(None),
  633. BizProtocolSample.sample_code == '',
  634. ),
  635. )
  636. else:
  637. query = query.filter(BizProtocolSample.sample_code == token)
  638. query = query.order_by(
  639. BizProtocolSample.create_time.desc(),
  640. BizProtocolSample.id.desc(),
  641. BizProtocolCollectionInfo.create_time.desc(),
  642. BizProtocolCollectionInfo.id.desc(),
  643. BizProtocolPartyCustomer.create_time.desc(),
  644. BizProtocolPartyCustomer.id.desc(),
  645. )
  646. if for_update:
  647. query = query.populate_existing().with_for_update()
  648. return query
  649. def _find_sample(
  650. db: Session,
  651. sample_id: str,
  652. ctx: MarketFieldContext,
  653. user_info: dict,
  654. *,
  655. for_update: bool = False,
  656. ) -> Dict[str, Any]:
  657. source, token, by_row_id = _parse_stable_id(sample_id)
  658. if source == CELL_SOURCE:
  659. row = _cell_sample_lookup_query(
  660. db,
  661. token=token,
  662. by_row_id=by_row_id,
  663. ctx=ctx,
  664. user_info=user_info,
  665. for_update=for_update,
  666. ).first()
  667. else:
  668. query = _research_query(db, ctx, user_info)
  669. if by_row_id:
  670. query = query.filter(
  671. BizResearchDonationSample.id == token,
  672. or_(
  673. BizResearchDonationSample.sample_code.is_(None),
  674. BizResearchDonationSample.sample_code == '',
  675. ),
  676. )
  677. else:
  678. query = query.filter(BizResearchDonationSample.sample_code == token)
  679. query = query.order_by(
  680. BizResearchDonationSample.create_time.desc(),
  681. BizResearchDonationSample.id.desc(),
  682. BizResearchDonationCollection.create_time.desc(),
  683. BizResearchDonationCollection.id.desc(),
  684. BizResearchDonationParty.create_time.desc(),
  685. BizResearchDonationParty.id.desc(),
  686. )
  687. if for_update:
  688. query = query.populate_existing().with_for_update()
  689. row = query.first()
  690. if row is None:
  691. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  692. return _raw_item(source, *row)
  693. def get_sample_detail(
  694. db: Session,
  695. *,
  696. sample_id: str,
  697. ctx: MarketFieldContext,
  698. user_info: dict,
  699. ) -> Dict[str, Any]:
  700. item = _find_sample(db, sample_id, ctx, user_info)
  701. _enrich(db, ctx.tenant_no, [item])
  702. progress = item.pop('progress')
  703. storage_cert = item.pop('storageCert', None)
  704. insurance = item.pop('insurance', None)
  705. sections = [
  706. {
  707. 'key': 'basic',
  708. 'title': '基本信息',
  709. 'fields': [
  710. {'label': '业务来源', 'value': item['sourceLabel']},
  711. {'label': '协议编号', 'value': item['protocolNo']},
  712. {'label': '样本编码', 'value': item['sampleCode']},
  713. {'label': '样本类型', 'value': item['sampleTypeName']},
  714. {'label': '实验室入库编码', 'value': item['labStorageCode']},
  715. ],
  716. },
  717. {
  718. 'key': 'progress',
  719. 'title': '办理进度',
  720. 'fields': [
  721. {'label': '采集', 'value': progress['collection']['label']},
  722. {'label': '接收', 'value': progress['reception']['label']},
  723. {'label': '质检', 'value': progress['qc']['label']},
  724. {'label': '入库', 'value': progress['storage']['label']},
  725. ],
  726. },
  727. ]
  728. if item['source'] == CELL_SOURCE:
  729. sections.append(
  730. {
  731. 'key': 'market-handles',
  732. 'title': '市场办理',
  733. 'storageCert': storage_cert,
  734. 'insurance': insurance,
  735. }
  736. )
  737. actions = []
  738. if item['source'] == CELL_SOURCE and item['sampleCode']:
  739. actions = [
  740. {'key': 'storageCert', 'label': '办理储存证'},
  741. {'key': 'insurance', 'label': '办理保险'},
  742. ]
  743. return {'id': item['id'], 'card': item, 'progress': progress, 'sections': sections, 'actions': actions}
  744. def _eligible_region_staff_query(
  745. db: Session, tenant_no: str, region_id: str
  746. ):
  747. return (
  748. db.query(SysUser)
  749. .join(
  750. BizCollectionRegionStaff,
  751. BizCollectionRegionStaff.user_id == SysUser.id,
  752. )
  753. .filter(
  754. BizCollectionRegionStaff.region_id == region_id,
  755. BizCollectionRegionStaff.tenant_no == tenant_no,
  756. BizCollectionRegionStaff.del_flag == 0,
  757. SysUser.tenant_no == tenant_no,
  758. SysUser.status == '1',
  759. SysUser.del_flag == 0,
  760. )
  761. )
  762. def get_sample_form_options(
  763. db: Session,
  764. *,
  765. sample_id: str,
  766. action: Optional[Literal['storage-cert', 'insurance']] = None,
  767. ctx: MarketFieldContext,
  768. user_info: dict,
  769. ) -> Dict[str, List[Dict[str, str]]]:
  770. item = _find_sample(db, sample_id, ctx, user_info)
  771. if item['source'] != CELL_SOURCE or not item['sampleCode']:
  772. raise ValueError('该样本不支持办理人员选择')
  773. users = (
  774. _eligible_region_staff_query(db, ctx.tenant_no, item['regionId'])
  775. .distinct()
  776. .order_by(SysUser.nick_name.asc(), SysUser.id.asc())
  777. .all()
  778. )
  779. referenced_ids = []
  780. if action:
  781. model = (
  782. BizProtocolStorageCert
  783. if action == 'storage-cert'
  784. else BizProtocolFullmoonTodo
  785. )
  786. historical = (
  787. db.query(model)
  788. .filter(
  789. model.collection_protocol_id == item['parentId'],
  790. model.tenant_no == ctx.tenant_no,
  791. model.del_flag == 0,
  792. )
  793. .order_by(
  794. model.update_time.desc(),
  795. model.create_time.desc(),
  796. model.id.desc(),
  797. )
  798. .first()
  799. )
  800. if historical is not None and historical.handler_user_id:
  801. referenced_ids.append(historical.handler_user_id)
  802. referenced_names = _referenced_user_name_map(
  803. db, ctx.tenant_no, referenced_ids
  804. )
  805. staff = [
  806. {'value': str(user.id), 'label': str(user.nick_name or '')}
  807. for user in users
  808. ]
  809. included = {option['value'] for option in staff}
  810. for user_id in referenced_ids:
  811. value = str(user_id)
  812. if value not in included and value in referenced_names:
  813. staff.append({'value': value, 'label': referenced_names[value]})
  814. included.add(value)
  815. return {
  816. 'staff': staff
  817. }
  818. def _validate_handler(
  819. db: Session,
  820. tenant_no: str,
  821. region_id: str,
  822. handler_user_id: str,
  823. ) -> SysUser:
  824. user = (
  825. _eligible_region_staff_query(db, tenant_no, region_id)
  826. .filter(SysUser.id == handler_user_id)
  827. .first()
  828. )
  829. if user is None:
  830. raise ValueError('办理人员无效')
  831. return user
  832. def _handle_identity_changed(row, normalized: Dict[str, Any]) -> bool:
  833. """全量表单会回传原办理人/时间;只有语义变化时重新校验资格。"""
  834. return (
  835. str(normalized['handler_user_id'] or '').strip()
  836. != str(row.handler_user_id or '').strip()
  837. or normalized['handle_time'] != row.handle_time
  838. )
  839. def _cell_parent_lock_query(
  840. db: Session, parent_id: str, ctx: MarketFieldContext
  841. ):
  842. return (
  843. db.query(BizCollectionProtocol)
  844. .populate_existing()
  845. .filter(
  846. BizCollectionProtocol.id == parent_id,
  847. BizCollectionProtocol.tenant_no == ctx.tenant_no,
  848. BizCollectionProtocol.del_flag == 0,
  849. )
  850. .with_for_update()
  851. )
  852. def _lock_cell_parent(
  853. db: Session, parent_id: str, ctx: MarketFieldContext
  854. ) -> BizCollectionProtocol:
  855. parent = _cell_parent_lock_query(db, parent_id, ctx).first()
  856. if parent is None:
  857. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  858. return parent
  859. def _handle_child_current_query(
  860. db: Session,
  861. model,
  862. parent_id: str,
  863. ctx: MarketFieldContext,
  864. ):
  865. return (
  866. db.query(model)
  867. .populate_existing()
  868. .filter(
  869. model.collection_protocol_id == parent_id,
  870. model.tenant_no == ctx.tenant_no,
  871. model.del_flag == 0,
  872. )
  873. .order_by(
  874. model.update_time.desc(),
  875. model.create_time.desc(),
  876. model.id.desc(),
  877. )
  878. .with_for_update()
  879. )
  880. def _normalize_partial_handle(
  881. row,
  882. fields: Dict[str, Any],
  883. *,
  884. insurance: bool,
  885. handle_time: Optional[datetime],
  886. handler_user_id: str,
  887. attachments: str,
  888. remark: str,
  889. ) -> Dict[str, Any]:
  890. if 'handleTime' in fields:
  891. return apply_handle_payload(
  892. handle_time=handle_time,
  893. handler_user_id=handler_user_id,
  894. attachments=attachments,
  895. remark=remark,
  896. )
  897. if 'handlerUserId' in fields:
  898. if not handler_user_id:
  899. raise ValueError('办理人员不能为空')
  900. handled = (
  901. insurance_handled(row) if insurance else storage_cert_handled(row)
  902. )
  903. if not handled:
  904. raise ValueError('记录尚未办理,不能单独设置办理人员')
  905. return {
  906. 'handle_time': row.handle_time,
  907. 'handler_user_id': handler_user_id,
  908. 'attachments': attachments,
  909. 'remark': remark,
  910. 'make_or_todo_status': '1',
  911. 'legacy_date': '',
  912. }
  913. return {
  914. 'handle_time': row.handle_time,
  915. 'handler_user_id': row.handler_user_id,
  916. 'attachments': attachments,
  917. 'remark': remark,
  918. 'make_or_todo_status': '0',
  919. 'legacy_date': '',
  920. }
  921. def _write_handle(
  922. db: Session,
  923. *,
  924. sample_id: str,
  925. fields: Dict[str, Any],
  926. ctx: MarketFieldContext,
  927. user_info: dict,
  928. kind: str,
  929. ) -> Dict[str, Any]:
  930. source, _, by_row_id = _parse_stable_id(sample_id)
  931. if source != CELL_SOURCE or by_row_id:
  932. raise ValueError('该样本不支持此办理操作')
  933. item = _find_sample(db, sample_id, ctx, user_info)
  934. if not item['sampleCode']:
  935. raise ValueError('该样本不支持此办理操作')
  936. initial_parent_id = item['parentId']
  937. try:
  938. _lock_cell_parent(db, initial_parent_id, ctx)
  939. item = _find_sample(
  940. db,
  941. sample_id,
  942. ctx,
  943. user_info,
  944. for_update=True,
  945. )
  946. if item['parentId'] != initial_parent_id:
  947. raise HTTPException(
  948. status_code=404, detail='数据不存在或无权访问'
  949. )
  950. except Exception:
  951. db.rollback()
  952. raise
  953. now = datetime.now()
  954. create_dept_id = str(
  955. user_info.get('dept_id') or user_info.get('deptId') or user_info.get('region_id') or ''
  956. )
  957. try:
  958. if kind == 'storage_cert':
  959. row = _handle_child_current_query(
  960. db, BizProtocolStorageCert, item['parentId'], ctx
  961. ).first()
  962. creating_handle = row is None
  963. if row is None:
  964. row = BizProtocolStorageCert(
  965. id=str(uuid.uuid4()),
  966. collection_protocol_id=item['parentId'],
  967. tenant_no=ctx.tenant_no,
  968. create_by=ctx.user_id,
  969. create_dept_id=create_dept_id,
  970. create_time=now,
  971. del_flag=0,
  972. )
  973. db.add(row)
  974. handle_time = (
  975. _parse_dt(fields.get('handleTime'))
  976. if 'handleTime' in fields
  977. else row.handle_time
  978. )
  979. handler_user_id = (
  980. str(fields.get('handlerUserId') or '').strip()
  981. if 'handlerUserId' in fields
  982. else str(row.handler_user_id or '').strip()
  983. )
  984. attachments = (
  985. str(fields.get('attachments') or '').strip()
  986. if 'attachments' in fields
  987. else str(row.cert_image_urls or '').strip()
  988. )
  989. if 'attachments' in fields:
  990. attachments = validate_attachment_refs(
  991. db,
  992. attachments,
  993. module='sample',
  994. ctx=ctx,
  995. creating=creating_handle,
  996. existing_refs=row.cert_image_urls,
  997. business_id=row.id,
  998. )
  999. remark = (
  1000. str(fields.get('remark') or '').strip()
  1001. if 'remark' in fields
  1002. else str(row.remark or '').strip()
  1003. )
  1004. normalized = _normalize_partial_handle(
  1005. row,
  1006. fields,
  1007. insurance=False,
  1008. handle_time=handle_time,
  1009. handler_user_id=handler_user_id,
  1010. attachments=attachments,
  1011. remark=remark,
  1012. )
  1013. should_validate_handler = creating_handle or _handle_identity_changed(
  1014. row, normalized
  1015. )
  1016. handler = (
  1017. _validate_handler(
  1018. db,
  1019. ctx.tenant_no,
  1020. item['regionId'],
  1021. normalized['handler_user_id'],
  1022. )
  1023. if normalized['handler_user_id'] and should_validate_handler
  1024. else None
  1025. )
  1026. row.handle_time = normalized['handle_time']
  1027. row.handler_user_id = normalized['handler_user_id']
  1028. row.cert_image_urls = normalized['attachments']
  1029. row.remark = normalized['remark']
  1030. if 'handleTime' in fields:
  1031. row.make_status = normalized['make_or_todo_status']
  1032. row.make_date = normalized['legacy_date'] or None
  1033. else:
  1034. row = _handle_child_current_query(
  1035. db, BizProtocolFullmoonTodo, item['parentId'], ctx
  1036. ).first()
  1037. creating_handle = row is None
  1038. if row is None:
  1039. row = BizProtocolFullmoonTodo(
  1040. id=str(uuid.uuid4()),
  1041. collection_protocol_id=item['parentId'],
  1042. tenant_no=ctx.tenant_no,
  1043. create_by=ctx.user_id,
  1044. create_dept_id=create_dept_id,
  1045. create_time=now,
  1046. del_flag=0,
  1047. )
  1048. db.add(row)
  1049. handle_time = (
  1050. _parse_dt(fields.get('handleTime'))
  1051. if 'handleTime' in fields
  1052. else row.handle_time
  1053. )
  1054. handler_user_id = (
  1055. str(fields.get('handlerUserId') or '').strip()
  1056. if 'handlerUserId' in fields
  1057. else str(row.handler_user_id or '').strip()
  1058. )
  1059. attachments = (
  1060. str(fields.get('attachments') or '').strip()
  1061. if 'attachments' in fields
  1062. else str(row.insurance_attachments or '').strip()
  1063. )
  1064. if 'attachments' in fields:
  1065. attachments = validate_attachment_refs(
  1066. db,
  1067. attachments,
  1068. module='sample',
  1069. ctx=ctx,
  1070. creating=creating_handle,
  1071. existing_refs=row.insurance_attachments,
  1072. business_id=row.id,
  1073. )
  1074. remark = (
  1075. str(fields.get('remark') or '').strip()
  1076. if 'remark' in fields
  1077. else str(row.handle_remark or '').strip()
  1078. )
  1079. normalized = _normalize_partial_handle(
  1080. row,
  1081. fields,
  1082. insurance=True,
  1083. handle_time=handle_time,
  1084. handler_user_id=handler_user_id,
  1085. attachments=attachments,
  1086. remark=remark,
  1087. )
  1088. should_validate_handler = creating_handle or _handle_identity_changed(
  1089. row, normalized
  1090. )
  1091. handler = (
  1092. _validate_handler(
  1093. db,
  1094. ctx.tenant_no,
  1095. item['regionId'],
  1096. normalized['handler_user_id'],
  1097. )
  1098. if normalized['handler_user_id'] and should_validate_handler
  1099. else None
  1100. )
  1101. row.handle_time = normalized['handle_time']
  1102. row.handler_user_id = normalized['handler_user_id']
  1103. row.insurance_attachments = normalized['attachments']
  1104. row.handle_remark = normalized['remark']
  1105. if 'handleTime' in fields:
  1106. row.todo_status = normalized['make_or_todo_status']
  1107. row.handle_date = normalized['legacy_date'] or None
  1108. row.update_by = ctx.user_id
  1109. row.update_time = now
  1110. db.flush()
  1111. db.commit()
  1112. db.refresh(row)
  1113. except Exception:
  1114. db.rollback()
  1115. raise
  1116. data = _handle_summary(row, insurance=kind == 'insurance')
  1117. data['handlerName'] = handler.nick_name if handler else ''
  1118. return data
  1119. def update_storage_cert(db: Session, **kwargs) -> Dict[str, Any]:
  1120. return _write_handle(db, kind='storage_cert', **kwargs)
  1121. def update_insurance(db: Session, **kwargs) -> Dict[str, Any]:
  1122. return _write_handle(db, kind='insurance', **kwargs)