research_donation.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. """科研捐赠管理 — 捐赠单 CRUD / 审核 / 状态变更 / 转换 API"""
  2. from typing import Any, Dict, List, Optional
  3. from fastapi import APIRouter, Depends, Query
  4. from pydantic import BaseModel, ConfigDict, Field
  5. from sqlalchemy.orm import Session
  6. from app.core.database import get_db
  7. from app.core.menu_guard import (
  8. assert_strict_menu_code,
  9. require_admin_or_super_admin,
  10. require_strict_menu_code,
  11. )
  12. from app.core.response import error_response, success_response
  13. from app.crud import research_donation as rd_svc
  14. from app.models.models import SysUser
  15. from app.models.research_donation import BizResearchDonation
  16. router = APIRouter(prefix="/web/admin", tags=["科研捐赠管理"])
  17. PROTOCOL_LIST_MENU = "biz:cellStorageProtocol:list"
  18. PROTOCOL_AUDIT_MENU = "biz:cellStorageProtocolAudit:list"
  19. def _get_tenant_no(db: Session, user: dict) -> str:
  20. user_id = user.get("user_id")
  21. if not user_id:
  22. raise PermissionError("用户身份信息缺失,无法确定租户")
  23. sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
  24. if not sys_user or not sys_user.tenant_no:
  25. raise PermissionError("用户租户信息缺失")
  26. return sys_user.tenant_no
  27. def _get_current_user_id(user: dict) -> str:
  28. user_id = user.get("user_id")
  29. if not user_id:
  30. raise PermissionError("用户身份信息缺失")
  31. return str(user_id)
  32. # ---------------------------------------------------------------------------
  33. # Pydantic bodies
  34. # ---------------------------------------------------------------------------
  35. class DonationCreateBody(BaseModel):
  36. model_config = ConfigDict(populate_by_name=True)
  37. donation_no: Optional[str] = Field(None, alias="donationNo")
  38. donation_status: Optional[str] = Field("dsx", alias="donationStatus")
  39. donation_date: Optional[str] = Field(None, alias="donationDate")
  40. source_type: Optional[str] = Field("direct", alias="sourceType")
  41. source_protocol_id: Optional[str] = Field(None, alias="sourceProtocolId")
  42. consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls")
  43. consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
  44. attachment_urls: Optional[str] = Field(None, alias="attachmentUrls")
  45. other_attachments: Optional[list] = Field(None, alias="otherAttachments")
  46. infectious_disease_report_urls: Optional[str] = Field(
  47. None, alias="infectiousDiseaseReportUrls"
  48. )
  49. infectious_disease_report_attachments: Optional[list] = Field(
  50. None, alias="infectiousDiseaseReportAttachments"
  51. )
  52. remark: Optional[str] = None
  53. party: Optional[dict] = None
  54. party_customer: Optional[dict] = Field(None, alias="partyCustomer")
  55. collection: Optional[dict] = None
  56. collection_info: Optional[dict] = Field(None, alias="collectionInfo")
  57. samples: Optional[list] = None
  58. collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
  59. class DonationUpdateBody(BaseModel):
  60. model_config = ConfigDict(populate_by_name=True)
  61. donation_no: Optional[str] = Field(None, alias="donationNo")
  62. donation_status: Optional[str] = Field(None, alias="donationStatus")
  63. donation_date: Optional[str] = Field(None, alias="donationDate")
  64. source_type: Optional[str] = Field(None, alias="sourceType")
  65. source_protocol_id: Optional[str] = Field(None, alias="sourceProtocolId")
  66. consent_form_urls: Optional[str] = Field(None, alias="consentFormUrls")
  67. consent_attachments: Optional[list] = Field(None, alias="consentAttachments")
  68. attachment_urls: Optional[str] = Field(None, alias="attachmentUrls")
  69. other_attachments: Optional[list] = Field(None, alias="otherAttachments")
  70. infectious_disease_report_urls: Optional[str] = Field(
  71. None, alias="infectiousDiseaseReportUrls"
  72. )
  73. infectious_disease_report_attachments: Optional[list] = Field(
  74. None, alias="infectiousDiseaseReportAttachments"
  75. )
  76. remark: Optional[str] = None
  77. party: Optional[dict] = None
  78. party_customer: Optional[dict] = Field(None, alias="partyCustomer")
  79. collection: Optional[dict] = None
  80. collection_info: Optional[dict] = Field(None, alias="collectionInfo")
  81. samples: Optional[list] = None
  82. collector_user_id: Optional[str] = Field(None, alias="collectorUserId")
  83. class BatchDeleteBody(BaseModel):
  84. model_config = ConfigDict(populate_by_name=True)
  85. ids: List[str] = Field(..., alias="ids", min_length=1)
  86. class AuditSubmitBody(BaseModel):
  87. model_config = ConfigDict(populate_by_name=True)
  88. audit_status: str = Field(..., alias="auditStatus")
  89. audit_remark: Optional[str] = Field(None, alias="auditRemark")
  90. class StatusChangeBody(BaseModel):
  91. model_config = ConfigDict(populate_by_name=True)
  92. new_status: str = Field(..., alias="newStatus")
  93. desc: Optional[str] = Field(None, alias="desc")
  94. time_val: Optional[str] = Field(None, alias="timeVal")
  95. void_type: Optional[str] = Field(None, alias="voidType")
  96. class ConvertFromStorageBody(BaseModel):
  97. model_config = ConfigDict(populate_by_name=True)
  98. protocol_id: str = Field(..., alias="protocolId")
  99. def assert_donation_sensitive_fields(
  100. db: Session,
  101. user: dict,
  102. body: BaseModel,
  103. *,
  104. creating: bool,
  105. ) -> None:
  106. """防止通过普通保存接口注入状态或协议互转来源。"""
  107. submitted = set(body.model_fields_set)
  108. if creating:
  109. status_value = str(getattr(body, "donation_status", None) or "dsx").strip()
  110. source_value = str(getattr(body, "source_type", None) or "direct").strip()
  111. requires_audit = (
  112. status_value != "dsx"
  113. or source_value != "direct"
  114. or bool(getattr(body, "source_protocol_id", None))
  115. )
  116. else:
  117. requires_audit = bool(
  118. submitted.intersection(
  119. {"donation_status", "source_type", "source_protocol_id"}
  120. )
  121. )
  122. if requires_audit:
  123. assert_strict_menu_code(db, user, PROTOCOL_AUDIT_MENU)
  124. # ---------------------------------------------------------------------------
  125. # 1. 列表
  126. # ---------------------------------------------------------------------------
  127. @router.get("/research-donations")
  128. async def list_research_donations(
  129. page: int = Query(1, ge=1),
  130. pageSize: int = Query(20, ge=1, le=200, alias="pageSize"),
  131. keyword: Optional[str] = Query(None, alias="keyword"),
  132. donationStatus: Optional[str] = Query(None, alias="donationStatus"),
  133. createTimeStart: Optional[str] = Query(None, alias="createTimeStart"),
  134. createTimeEnd: Optional[str] = Query(None, alias="createTimeEnd"),
  135. sampleCollectTimeStart: Optional[str] = Query(None, alias="sampleCollectTimeStart"),
  136. sampleCollectTimeEnd: Optional[str] = Query(None, alias="sampleCollectTimeEnd"),
  137. user: dict = Depends(require_admin_or_super_admin),
  138. db: Session = Depends(get_db),
  139. ):
  140. tenant_no = _get_tenant_no(db, user)
  141. result = rd_svc.list_donations(
  142. db,
  143. tenant_no=tenant_no,
  144. page=page,
  145. page_size=pageSize,
  146. keyword=keyword,
  147. donation_status=donationStatus,
  148. create_time_start=createTimeStart,
  149. create_time_end=createTimeEnd,
  150. sample_collect_time_start=sampleCollectTimeStart,
  151. sample_collect_time_end=sampleCollectTimeEnd,
  152. user_info=user,
  153. )
  154. return success_response(result)
  155. # ---------------------------------------------------------------------------
  156. # 2. 审核列表(须在 {donation_id} 详情路由之前注册,避免 audits 被当作 id)
  157. # ---------------------------------------------------------------------------
  158. @router.get("/research-donations/audits")
  159. async def list_research_donation_audits(
  160. page: int = Query(1, ge=1),
  161. pageSize: int = Query(20, ge=1, le=200, alias="pageSize"),
  162. keyword: Optional[str] = Query(None, alias="keyword"),
  163. auditStatus: Optional[str] = Query(None, alias="auditStatus"),
  164. researchDonationId: Optional[str] = Query(None, alias="researchDonationId"),
  165. user: dict = Depends(require_admin_or_super_admin),
  166. db: Session = Depends(get_db),
  167. ):
  168. tenant_no = _get_tenant_no(db, user)
  169. result = rd_svc.list_audits(
  170. db,
  171. tenant_no=tenant_no,
  172. page=page,
  173. page_size=pageSize,
  174. keyword=keyword,
  175. audit_status=auditStatus,
  176. research_donation_id=researchDonationId,
  177. user_info=user,
  178. )
  179. return success_response(result)
  180. # ---------------------------------------------------------------------------
  181. # 3. 下一捐赠样本编码(须在 {donation_id} 详情路由之前注册)
  182. # ---------------------------------------------------------------------------
  183. @router.get("/research-donations/next-sample-code")
  184. async def next_research_donation_sample_code(
  185. pendingCodes: Optional[str] = Query(None, alias="pendingCodes"),
  186. user: dict = Depends(require_admin_or_super_admin),
  187. db: Session = Depends(get_db),
  188. ):
  189. tenant_no = _get_tenant_no(db, user)
  190. extra = [c.strip() for c in (pendingCodes or "").split(",") if c.strip()]
  191. code = rd_svc.next_donation_sample_code(db, tenant_no=tenant_no, extra_codes=extra)
  192. return success_response({"sampleCode": code})
  193. # ---------------------------------------------------------------------------
  194. # 4. 详情
  195. # ---------------------------------------------------------------------------
  196. @router.get("/research-donations/{donation_id}")
  197. async def get_research_donation_detail(
  198. donation_id: str,
  199. user: dict = Depends(require_admin_or_super_admin),
  200. db: Session = Depends(get_db),
  201. ):
  202. tenant_no = _get_tenant_no(db, user)
  203. result = rd_svc.get_donation_detail(db, donation_id, tenant_no)
  204. if not result:
  205. return error_response(404, "捐赠单不存在或无权查看")
  206. return success_response(result)
  207. # ---------------------------------------------------------------------------
  208. # 4. 创建
  209. # ---------------------------------------------------------------------------
  210. @router.post("/research-donations")
  211. async def create_research_donation(
  212. body: DonationCreateBody,
  213. user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
  214. db: Session = Depends(get_db),
  215. ):
  216. assert_donation_sensitive_fields(db, user, body, creating=True)
  217. tenant_no = _get_tenant_no(db, user)
  218. user_id = _get_current_user_id(user)
  219. try:
  220. data = body.model_dump(exclude_none=False, by_alias=True)
  221. collector = (data.get("collectorUserId") or data.get("collector_user_id") or "").strip()
  222. if not collector:
  223. data["collectorUserId"] = user_id
  224. obj = rd_svc.create_donation(
  225. db,
  226. data=data,
  227. tenant_no=tenant_no,
  228. user_id=user_id,
  229. )
  230. return success_response(
  231. rd_svc._donation_to_dict(obj, db, tenant_no),
  232. message="创建成功",
  233. )
  234. except ValueError as e:
  235. return error_response(400, str(e))
  236. # ---------------------------------------------------------------------------
  237. # 5. 更新
  238. # ---------------------------------------------------------------------------
  239. @router.put("/research-donations/{donation_id}")
  240. async def update_research_donation(
  241. donation_id: str,
  242. body: DonationUpdateBody,
  243. user: dict = Depends(require_strict_menu_code(PROTOCOL_LIST_MENU)),
  244. db: Session = Depends(get_db),
  245. ):
  246. assert_donation_sensitive_fields(db, user, body, creating=False)
  247. tenant_no = _get_tenant_no(db, user)
  248. user_id = _get_current_user_id(user)
  249. try:
  250. data = body.model_dump(exclude_unset=True, by_alias=True)
  251. obj = rd_svc.update_donation(
  252. db,
  253. donation_id,
  254. data=data,
  255. tenant_no=tenant_no,
  256. user_id=user_id,
  257. )
  258. return success_response(
  259. rd_svc._donation_to_dict(obj, db, tenant_no),
  260. message="更新成功",
  261. )
  262. except ValueError as e:
  263. return error_response(400, str(e))
  264. # ---------------------------------------------------------------------------
  265. # 6. 删除
  266. # ---------------------------------------------------------------------------
  267. @router.delete("/research-donations/{donation_id}")
  268. async def delete_research_donation(
  269. donation_id: str,
  270. user: dict = Depends(require_admin_or_super_admin),
  271. db: Session = Depends(get_db),
  272. ):
  273. tenant_no = _get_tenant_no(db, user)
  274. user_id = _get_current_user_id(user)
  275. try:
  276. rd_svc.delete_donation(
  277. db,
  278. donation_id,
  279. tenant_no=tenant_no,
  280. user_id=user_id,
  281. )
  282. return success_response(None, message="删除成功")
  283. except ValueError as e:
  284. return error_response(400, str(e))
  285. # ---------------------------------------------------------------------------
  286. # 7. 批量删除
  287. # ---------------------------------------------------------------------------
  288. @router.post("/research-donations/batch-delete")
  289. async def batch_delete_research_donations(
  290. body: BatchDeleteBody,
  291. user: dict = Depends(require_admin_or_super_admin),
  292. db: Session = Depends(get_db),
  293. ):
  294. tenant_no = _get_tenant_no(db, user)
  295. user_id = _get_current_user_id(user)
  296. try:
  297. count = rd_svc.batch_delete_donations(
  298. db,
  299. ids=body.ids,
  300. tenant_no=tenant_no,
  301. user_id=user_id,
  302. )
  303. return success_response({"count": count}, message=f"已成功删除 {count} 条")
  304. except ValueError as e:
  305. return error_response(400, str(e))
  306. # ---------------------------------------------------------------------------
  307. # 8. 提交审核
  308. # ---------------------------------------------------------------------------
  309. @router.post("/research-donations/{donation_id}/audit")
  310. async def submit_donation_audit(
  311. donation_id: str,
  312. body: AuditSubmitBody,
  313. user: dict = Depends(require_admin_or_super_admin),
  314. db: Session = Depends(get_db),
  315. ):
  316. tenant_no = _get_tenant_no(db, user)
  317. try:
  318. rd_svc.submit_audit(
  319. db,
  320. donation_id,
  321. audit_status=body.audit_status,
  322. audit_remark=body.audit_remark,
  323. user_id=user.get("user_id"),
  324. tenant_no=tenant_no,
  325. )
  326. d = (
  327. db.query(BizResearchDonation)
  328. .filter(BizResearchDonation.id == donation_id)
  329. .first()
  330. )
  331. dn = (d.donation_no if d else "") or ""
  332. return success_response({"donationNo": dn}, message="审核已提交")
  333. except ValueError as e:
  334. return error_response(400, str(e))
  335. # ---------------------------------------------------------------------------
  336. # 9. 状态变更
  337. # ---------------------------------------------------------------------------
  338. @router.post("/research-donations/{donation_id}/status-change")
  339. async def change_donation_status(
  340. donation_id: str,
  341. body: StatusChangeBody,
  342. user: dict = Depends(require_admin_or_super_admin),
  343. db: Session = Depends(get_db),
  344. ):
  345. tenant_no = _get_tenant_no(db, user)
  346. user_id = _get_current_user_id(user)
  347. try:
  348. obj = rd_svc.change_status(
  349. db,
  350. donation_id,
  351. new_status=body.new_status,
  352. desc=body.desc,
  353. time_val=body.time_val,
  354. void_type=body.void_type,
  355. user_id=user_id,
  356. tenant_no=tenant_no,
  357. )
  358. return success_response(
  359. rd_svc._donation_to_dict(obj, db, tenant_no),
  360. message="状态变更成功",
  361. )
  362. except ValueError as e:
  363. return error_response(400, str(e))
  364. @router.get("/research-donations/{donation_id}/status-changes")
  365. async def list_research_donation_status_changes(
  366. donation_id: str,
  367. user: dict = Depends(require_admin_or_super_admin),
  368. db: Session = Depends(get_db),
  369. ):
  370. tenant_no = _get_tenant_no(db, user)
  371. obj = (
  372. db.query(BizResearchDonation)
  373. .filter(
  374. BizResearchDonation.id == donation_id,
  375. BizResearchDonation.tenant_no == tenant_no,
  376. BizResearchDonation.del_flag == 0,
  377. )
  378. .first()
  379. )
  380. if not obj:
  381. return error_response(404, "捐赠单不存在")
  382. items = rd_svc.list_donation_status_changes(
  383. db,
  384. research_donation_id=donation_id,
  385. tenant_no=tenant_no,
  386. )
  387. return success_response({"list": items})
  388. # ---------------------------------------------------------------------------
  389. # 10. 细胞存储 → 科研捐赠
  390. # ---------------------------------------------------------------------------
  391. @router.post("/research-donations/convert-from-storage")
  392. async def convert_from_storage(
  393. body: ConvertFromStorageBody,
  394. user: dict = Depends(require_admin_or_super_admin),
  395. db: Session = Depends(get_db),
  396. ):
  397. tenant_no = _get_tenant_no(db, user)
  398. user_id = _get_current_user_id(user)
  399. try:
  400. obj = rd_svc.convert_from_storage(
  401. db,
  402. body.protocol_id,
  403. tenant_no=tenant_no,
  404. user_id=user_id,
  405. )
  406. return success_response(
  407. rd_svc._donation_to_dict(obj, db, tenant_no),
  408. message="转换成功",
  409. )
  410. except ValueError as e:
  411. return error_response(400, str(e))
  412. # ---------------------------------------------------------------------------
  413. # 11. 科研捐赠 → 细胞存储
  414. # ---------------------------------------------------------------------------
  415. @router.post("/research-donations/{donation_id}/convert-to-storage")
  416. async def convert_to_storage(
  417. donation_id: str,
  418. user: dict = Depends(require_admin_or_super_admin),
  419. db: Session = Depends(get_db),
  420. ):
  421. tenant_no = _get_tenant_no(db, user)
  422. user_id = _get_current_user_id(user)
  423. try:
  424. obj = rd_svc.convert_to_storage(
  425. db,
  426. donation_id,
  427. tenant_no=tenant_no,
  428. user_id=user_id,
  429. )
  430. return success_response(
  431. rd_svc._donation_to_dict(obj, db, tenant_no),
  432. message="转换成功",
  433. )
  434. except ValueError as e:
  435. return error_response(400, str(e))