market_support.py 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465
  1. """移动市场表单选项、附件暂存与动作白名单。
  2. 该模块是移动市场写接口的安全边界:客户端不能选择任意字典、目录或附件 URL。
  3. """
  4. from __future__ import annotations
  5. import os
  6. import re
  7. import uuid
  8. import zipfile
  9. from decimal import Decimal
  10. from io import BytesIO
  11. from typing import Any, Dict, Iterable, List, Optional
  12. from fastapi import HTTPException
  13. from sqlalchemy import or_
  14. from sqlalchemy.orm import Session
  15. from app.core.config import settings
  16. from app.core.storage import storage
  17. from app.models.models import (
  18. BizAttachment,
  19. BizCellSampleType,
  20. BizCollectionProtocol,
  21. BizCollectionHospital,
  22. BizCustomer,
  23. BizProtocolCollectionInfo,
  24. BizCollectionRegion,
  25. BizCollectionRegionStaff,
  26. BizItemUnit,
  27. BizPresignOrder,
  28. BizPresignTransfer,
  29. BizProtocolFullmoonTodo,
  30. BizProtocolPayment,
  31. BizProtocolPaymentDetail,
  32. BizProtocolPaymentTransfer,
  33. BizProtocolStorageCert,
  34. BizSampleCode,
  35. SysDictData,
  36. SysEthnic,
  37. SysUser,
  38. )
  39. from app.crud import collection_region as collection_region_crud
  40. from app.crud import customer as customer_crud
  41. from app.core.data_scope import apply_data_scope
  42. from app.models.research_donation import (
  43. BizResearchDonation,
  44. BizResearchDonationCollection,
  45. )
  46. from app.services.market_field_scope import (
  47. MarketFieldContext,
  48. apply_cell_protocol_scope,
  49. apply_donation_scope,
  50. apply_presign_scope,
  51. assert_cell_protocol_access,
  52. assert_donation_access,
  53. assert_presign_access,
  54. )
  55. FORM_MODULES = frozenset({'presign', 'protocol', 'payment', 'freight'})
  56. UPLOAD_MODULES = frozenset({'presign', 'protocol', 'payment', 'sample'})
  57. DICT_BY_MODULE = {
  58. 'payment': ('zzfs', 'transferTypes'),
  59. 'freight': ('sample_transport_mode', 'transportModes'),
  60. }
  61. MAX_UPLOAD_BYTES = 10 * 1024 * 1024
  62. PRESIGN_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
  63. PROTOCOL_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
  64. MAX_ATTACHMENT_REFS = 20
  65. MAX_ATTACHMENT_REFS_LENGTH = 4000
  66. MAX_ATTACHMENT_REF_LENGTH = 1000
  67. PAYMENT_TERMINAL_PROTOCOL_STATUSES = frozenset({'yzf', 'sdfq', 'zky'})
  68. PAYMENT_WRITABLE_SCHEDULE_STATUS = 'open'
  69. HEIC_BRANDS = frozenset(
  70. {b'heic', b'heix', b'hevc', b'hevx', b'heim', b'heis'}
  71. )
  72. HEIF_BRANDS = HEIC_BRANDS | frozenset({b'mif1', b'msf1'})
  73. ALLOWED_MIME_BY_EXT = {
  74. 'jpg': {'image/jpeg'},
  75. 'jpeg': {'image/jpeg'},
  76. 'png': {'image/png'},
  77. 'webp': {'image/webp'},
  78. 'heic': {'image/heic', 'image/heif', 'application/octet-stream'},
  79. 'heif': {'image/heic', 'image/heif', 'application/octet-stream'},
  80. 'pdf': {'application/pdf'},
  81. 'doc': {'application/msword', 'application/octet-stream'},
  82. 'docx': {
  83. 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  84. 'application/zip',
  85. 'application/octet-stream',
  86. },
  87. 'xls': {'application/vnd.ms-excel', 'application/octet-stream'},
  88. 'xlsx': {
  89. 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  90. 'application/zip',
  91. 'application/octet-stream',
  92. },
  93. }
  94. PROTOCOL_ALLOWED_MIME_BY_EXT = {
  95. **ALLOWED_MIME_BY_EXT,
  96. 'gif': {'image/gif'},
  97. 'bmp': {'image/bmp', 'image/x-ms-bmp'},
  98. 'ppt': {'application/vnd.ms-powerpoint', 'application/octet-stream'},
  99. 'pptx': {
  100. 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  101. 'application/zip',
  102. 'application/octet-stream',
  103. },
  104. 'txt': {'text/plain', 'application/octet-stream'},
  105. 'mp4': {'video/mp4', 'application/octet-stream'},
  106. 'webm': {'video/webm', 'application/octet-stream'},
  107. 'ogg': {'video/ogg', 'application/ogg', 'application/octet-stream'},
  108. 'mov': {'video/quicktime', 'application/octet-stream'},
  109. 'avi': {'video/x-msvideo', 'application/octet-stream'},
  110. 'zip': {'application/zip', 'application/x-zip-compressed', 'application/octet-stream'},
  111. 'rar': {'application/vnd.rar', 'application/x-rar-compressed', 'application/octet-stream'},
  112. }
  113. def _option(value: Any, label: Any) -> Dict[str, str]:
  114. return {'value': str(value or ''), 'label': str(label or '')}
  115. def _protocol_sample_type_option(row: BizCellSampleType) -> Dict[str, Any]:
  116. storage_price = (
  117. format(Decimal(str(row.storage_price)).quantize(Decimal('0.01')), 'f')
  118. if row.storage_price is not None
  119. else None
  120. )
  121. return {
  122. **_option(row.id, row.type_name),
  123. 'storagePrice': storage_price,
  124. 'storageYears': (
  125. int(row.storage_years) if row.storage_years is not None else None
  126. ),
  127. 'sampleCodeFilter': (row.sample_code_filter or '').strip(),
  128. }
  129. def _verify_identity(ctx: MarketFieldContext, user_info: dict) -> None:
  130. if not isinstance(user_info, dict):
  131. raise PermissionError('用户身份与现场范围上下文不一致')
  132. if (
  133. str(user_info.get('user_id') or '').strip() != ctx.user_id
  134. or str(user_info.get('tenant_no') or '').strip() != ctx.tenant_no
  135. ):
  136. raise PermissionError('用户身份与现场范围上下文不一致')
  137. def authorized_region_ids(db: Session, ctx: MarketFieldContext) -> List[str]:
  138. query = db.query(BizCollectionRegionStaff.region_id).filter(
  139. BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
  140. BizCollectionRegionStaff.del_flag == 0,
  141. BizCollectionRegionStaff.user_id == ctx.user_id,
  142. )
  143. if ctx.scope_kind == 'manager':
  144. query = query.filter(BizCollectionRegionStaff.is_leader == 1)
  145. return [str(row[0]) for row in query.distinct().all() if row and row[0]]
  146. def _tenant_region_ids(db: Session, tenant_no: str) -> List[str]:
  147. rows = (
  148. db.query(BizCollectionRegion.id)
  149. .filter(
  150. BizCollectionRegion.tenant_no == tenant_no,
  151. BizCollectionRegion.del_flag == 0,
  152. )
  153. .all()
  154. )
  155. return [str(row[0]) for row in rows if row and row[0]]
  156. def form_option_region_ids(
  157. db: Session,
  158. *,
  159. module: str,
  160. ctx: MarketFieldContext,
  161. user_info: dict,
  162. business_id: Optional[str] = None,
  163. ) -> List[str]:
  164. """表单采集地区候选:与 Web 一致,返回本租户全部采集地区。
  165. 不依赖 ``biz_collection_region_staff`` 绑定;现场写入范围仍由各模块
  166. create/update(如地区经理归属校验)与列表 scope 约束。
  167. """
  168. _ = (module, user_info, business_id)
  169. return _tenant_region_ids(db, ctx.tenant_no)
  170. def _dict_options(db: Session, tenant_no: str, dict_type: str) -> List[dict]:
  171. rows = (
  172. db.query(SysDictData)
  173. .filter(
  174. SysDictData.tenant_no == tenant_no,
  175. SysDictData.del_flag == 0,
  176. SysDictData.dict_type == dict_type,
  177. )
  178. .order_by(SysDictData.order_num.asc(), SysDictData.dict_label.asc())
  179. .all()
  180. )
  181. return [_option(row.dict_value, row.dict_label) for row in rows]
  182. def _business_region_id(
  183. db: Session,
  184. *,
  185. module: str,
  186. business_id: str,
  187. ctx: MarketFieldContext,
  188. user_info: dict,
  189. ) -> Optional[str]:
  190. """仅从当前已在 scope 内的单据补充地区,不把 businessId 当越权入口。"""
  191. record_id = str(business_id or '').strip()
  192. if not record_id:
  193. return None
  194. if module == 'presign':
  195. return str(
  196. assert_presign_access(
  197. db, record_id, ctx, user_info=user_info
  198. ).region_id
  199. or ''
  200. ).strip() or None
  201. if module != 'protocol':
  202. return None
  203. try:
  204. assert_cell_protocol_access(db, record_id, ctx, user_info=user_info)
  205. collection = (
  206. db.query(BizProtocolCollectionInfo)
  207. .filter(
  208. BizProtocolCollectionInfo.collection_protocol_id == record_id,
  209. BizProtocolCollectionInfo.tenant_no == ctx.tenant_no,
  210. BizProtocolCollectionInfo.del_flag == 0,
  211. )
  212. .order_by(
  213. BizProtocolCollectionInfo.create_time.desc(),
  214. BizProtocolCollectionInfo.id.desc(),
  215. )
  216. .first()
  217. )
  218. except HTTPException:
  219. assert_donation_access(db, record_id, ctx, user_info=user_info)
  220. collection = (
  221. db.query(BizResearchDonationCollection)
  222. .filter(
  223. BizResearchDonationCollection.research_donation_id == record_id,
  224. BizResearchDonationCollection.tenant_no == ctx.tenant_no,
  225. BizResearchDonationCollection.del_flag == 0,
  226. )
  227. .order_by(
  228. BizResearchDonationCollection.create_time.desc(),
  229. BizResearchDonationCollection.id.desc(),
  230. )
  231. .first()
  232. )
  233. return str(collection.region_id or '').strip() if collection else None
  234. def search_customers_for_protocol(
  235. db: Session,
  236. *,
  237. ctx: MarketFieldContext,
  238. user_info: dict,
  239. keyword: Optional[str] = None,
  240. page: int = 1,
  241. page_size: int = 20,
  242. ) -> Dict[str, Any]:
  243. """协议单姓名联想:只读检索客户档案(含回填所需联系方式/证件)。"""
  244. _verify_identity(ctx, user_info)
  245. kw = str(keyword or '').strip()
  246. page = max(1, int(page or 1))
  247. page_size = min(max(1, int(page_size or 20)), 100)
  248. query = db.query(BizCustomer).filter(
  249. BizCustomer.del_flag == 0,
  250. BizCustomer.tenant_no == ctx.tenant_no,
  251. )
  252. if kw:
  253. like = f'%{kw}%'
  254. query = query.filter(
  255. or_(
  256. BizCustomer.customer_name.like(like),
  257. BizCustomer.phone_no.like(like),
  258. BizCustomer.id_card_no.like(like),
  259. BizCustomer.customer_tags.like(like),
  260. ),
  261. )
  262. query = apply_data_scope(query, BizCustomer, user_info, db, ctx.tenant_no)
  263. total = query.count()
  264. rows = (
  265. query.order_by(BizCustomer.create_time.desc())
  266. .offset((page - 1) * page_size)
  267. .limit(page_size)
  268. .all()
  269. )
  270. return {
  271. 'list': [customer_crud._row_to_dict(row) for row in rows],
  272. 'total': int(total),
  273. 'page': page,
  274. 'pageSize': page_size,
  275. }
  276. def get_form_options(
  277. db: Session,
  278. *,
  279. module: str,
  280. ctx: MarketFieldContext,
  281. user_info: dict,
  282. region_id: Optional[str] = None,
  283. business_id: Optional[str] = None,
  284. user_keyword: Optional[str] = None,
  285. code_filter: Optional[str] = None,
  286. sample_code_keyword: Optional[str] = None,
  287. ) -> Dict[str, Any]:
  288. """返回移动表单最小选项集;不会返回联系方式或证件等 PII。"""
  289. _verify_identity(ctx, user_info)
  290. normalized_module = str(module or '').strip()
  291. if normalized_module not in FORM_MODULES:
  292. raise ValueError('不支持的表单选项模块')
  293. if normalized_module in DICT_BY_MODULE:
  294. dict_type, key = DICT_BY_MODULE[normalized_module]
  295. return {key: _dict_options(db, ctx.tenant_no, dict_type)}
  296. region_ids = form_option_region_ids(
  297. db,
  298. module=normalized_module,
  299. ctx=ctx,
  300. user_info=user_info,
  301. business_id=business_id,
  302. )
  303. selected_region = str(region_id or '').strip()
  304. if selected_region and selected_region not in region_ids:
  305. raise PermissionError('所选地区不在当前用户权限范围内')
  306. regions = []
  307. if region_ids:
  308. regions = (
  309. db.query(BizCollectionRegion)
  310. .filter(
  311. BizCollectionRegion.id.in_(region_ids),
  312. BizCollectionRegion.tenant_no == ctx.tenant_no,
  313. BizCollectionRegion.del_flag == 0,
  314. )
  315. .order_by(BizCollectionRegion.order_num.asc(), BizCollectionRegion.region_name.asc())
  316. .all()
  317. )
  318. active_region_ids = [str(row.id) for row in regions]
  319. if selected_region and selected_region not in active_region_ids:
  320. raise PermissionError('所选地区不存在或已停用')
  321. hospital_regions = (
  322. [selected_region] if selected_region else active_region_ids
  323. )
  324. hospitals = []
  325. staff_rows = []
  326. if hospital_regions:
  327. hospitals = (
  328. db.query(BizCollectionHospital)
  329. .filter(
  330. BizCollectionHospital.region_id.in_(hospital_regions),
  331. BizCollectionHospital.tenant_no == ctx.tenant_no,
  332. BizCollectionHospital.del_flag == 0,
  333. )
  334. .order_by(BizCollectionHospital.order_num.asc(), BizCollectionHospital.hospital_name.asc())
  335. .all()
  336. )
  337. staff_rows = (
  338. db.query(SysUser, BizCollectionRegionStaff)
  339. .join(
  340. BizCollectionRegionStaff,
  341. BizCollectionRegionStaff.user_id == SysUser.id,
  342. )
  343. .filter(
  344. BizCollectionRegionStaff.region_id.in_(hospital_regions),
  345. BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
  346. BizCollectionRegionStaff.del_flag == 0,
  347. SysUser.tenant_no == ctx.tenant_no,
  348. SysUser.del_flag == 0,
  349. SysUser.status == '1',
  350. )
  351. .order_by(
  352. BizCollectionRegionStaff.is_leader.desc(),
  353. SysUser.nick_name.asc(),
  354. SysUser.id.asc(),
  355. )
  356. .all()
  357. )
  358. sample_types = (
  359. db.query(BizCellSampleType)
  360. .filter(
  361. BizCellSampleType.tenant_no == ctx.tenant_no,
  362. BizCellSampleType.del_flag == 0,
  363. )
  364. .order_by(BizCellSampleType.order_num.asc(), BizCellSampleType.type_name.asc())
  365. .all()
  366. )
  367. units = (
  368. db.query(BizItemUnit)
  369. .filter(BizItemUnit.tenant_no == ctx.tenant_no, BizItemUnit.del_flag == 0)
  370. .order_by(BizItemUnit.order_num.asc(), BizItemUnit.unit_name.asc())
  371. .all()
  372. )
  373. staff = []
  374. seen_staff_ids = set()
  375. for user, binding in staff_rows:
  376. if user.id in seen_staff_ids:
  377. continue
  378. seen_staff_ids.add(user.id)
  379. staff.append(
  380. {
  381. **_option(user.id, user.nick_name),
  382. 'isLeader': bool(binding.is_leader),
  383. }
  384. )
  385. current_user = (
  386. db.query(SysUser)
  387. .filter(
  388. SysUser.id == ctx.user_id,
  389. SysUser.tenant_no == ctx.tenant_no,
  390. SysUser.del_flag == 0,
  391. SysUser.status == '1',
  392. )
  393. .first()
  394. )
  395. searched_users = []
  396. if normalized_module == 'presign' and user_keyword is not None:
  397. keyword = str(user_keyword or '').strip()
  398. user_query = db.query(SysUser).filter(
  399. SysUser.tenant_no == ctx.tenant_no,
  400. SysUser.del_flag == 0,
  401. SysUser.status == '1',
  402. )
  403. if keyword:
  404. like = f'%{keyword}%'
  405. user_query = user_query.filter(
  406. or_(SysUser.nick_name.like(like), SysUser.user_no.like(like))
  407. )
  408. searched_users = (
  409. user_query.order_by(SysUser.nick_name.asc(), SysUser.id.asc())
  410. .limit(50)
  411. .all()
  412. )
  413. current_user_regions = []
  414. if current_user:
  415. current_user_regions = [
  416. _option(item.get('id'), item.get('regionName'))
  417. for item in collection_region_crud.list_regions_by_staff_user(
  418. db,
  419. user_id=str(current_user.id),
  420. tenant_no=ctx.tenant_no,
  421. )
  422. if item.get('id')
  423. ]
  424. result = {
  425. 'regions': [_option(row.id, row.region_name) for row in regions],
  426. 'hospitals': [_option(row.id, row.hospital_name) for row in hospitals],
  427. 'staff': staff,
  428. 'sampleTypes': [
  429. (
  430. _protocol_sample_type_option(row)
  431. if normalized_module == 'protocol'
  432. else _option(row.id, row.type_name)
  433. )
  434. for row in sample_types
  435. ],
  436. 'units': [_option(row.id, row.unit_name) for row in units],
  437. 'currentUser': (
  438. _option(current_user.id, current_user.nick_name)
  439. if current_user
  440. else None
  441. ),
  442. # 当前登录人在 biz_collection_region_staff 的关联地区(顺序即默认取第一个)
  443. 'currentUserRegions': current_user_regions,
  444. }
  445. if normalized_module == 'protocol':
  446. ethnicities = (
  447. db.query(SysEthnic)
  448. .filter(
  449. SysEthnic.del_flag == 0,
  450. or_(SysEthnic.tenant_no == '', SysEthnic.tenant_no == ctx.tenant_no),
  451. )
  452. .order_by(SysEthnic.order_num.asc(), SysEthnic.ethnic_code.asc())
  453. .all()
  454. )
  455. # 多取候选再过滤已占用,尽量凑满可选列表
  456. sample_code_query = db.query(BizSampleCode).filter(
  457. BizSampleCode.tenant_no == ctx.tenant_no,
  458. BizSampleCode.del_flag == 0,
  459. )
  460. cf = (code_filter or '').strip()
  461. if cf:
  462. sample_code_query = sample_code_query.filter(
  463. BizSampleCode.sample_code.like(f'%{cf}%')
  464. )
  465. code_keyword = (sample_code_keyword or '').strip()
  466. if code_keyword:
  467. sample_code_query = sample_code_query.filter(
  468. BizSampleCode.sample_code.like(f'%{code_keyword}%')
  469. )
  470. sample_codes = (
  471. sample_code_query.order_by(
  472. BizSampleCode.create_time.desc(), BizSampleCode.sample_code.desc()
  473. )
  474. .limit(200)
  475. .all()
  476. )
  477. from app.crud import collection_protocol as cell_crud
  478. used_codes = cell_crud.get_sample_codes_used_in_protocol_samples(
  479. db,
  480. tenant_no=ctx.tenant_no,
  481. sample_codes=[(row.sample_code or '').strip() for row in sample_codes],
  482. )
  483. available_codes = [
  484. row
  485. for row in sample_codes
  486. if (row.sample_code or '').strip()
  487. and (row.sample_code or '').strip() not in used_codes
  488. ][:100]
  489. result.update(
  490. {
  491. 'ethnicities': [
  492. _option(row.ethnic_name, row.ethnic_name)
  493. for row in ethnicities
  494. ],
  495. 'sampleCodes': [
  496. {
  497. **_option(row.id, row.sample_code),
  498. 'sampleCode': row.sample_code or '',
  499. }
  500. for row in available_codes
  501. ],
  502. # 协议单内嵌转账与缴费模块共用转账方式字典
  503. 'transferTypes': _dict_options(db, ctx.tenant_no, 'zzfs'),
  504. # 协议单内嵌运费与运费模块共用运输方式字典
  505. 'transportModes': _dict_options(
  506. db, ctx.tenant_no, 'sample_transport_mode'
  507. ),
  508. }
  509. )
  510. if normalized_module == 'presign':
  511. result.update(
  512. {
  513. 'payModes': [
  514. {'label': '趸交', 'value': 'dj'},
  515. {'label': '分期', 'value': 'fq'},
  516. ],
  517. 'orderStatuses': [
  518. {'label': '草稿', 'value': 'draft'},
  519. {'label': '进行中', 'value': 'active'},
  520. {'label': '已转正式', 'value': 'converted'},
  521. {'label': '已作废', 'value': 'void'},
  522. ],
  523. 'transferTypes': _dict_options(db, ctx.tenant_no, 'zzfs'),
  524. 'users': [_option(row.id, row.nick_name) for row in searched_users],
  525. }
  526. )
  527. return result
  528. def _validate_upload(
  529. content: bytes,
  530. filename: str,
  531. content_type: str,
  532. *,
  533. max_bytes: int = MAX_UPLOAD_BYTES,
  534. allowed_mime_by_ext: Optional[Dict[str, set]] = None,
  535. ) -> tuple[str, str]:
  536. name = str(filename or '').strip()
  537. if not name or len(name) > 200 or re.search(r'[\x00-\x1f\x7f]', name):
  538. raise ValueError('文件名无效')
  539. if '/' in name or '\\' in name:
  540. raise ValueError('文件名无效')
  541. if not content:
  542. raise ValueError('上传文件不能为空')
  543. if len(content) > max_bytes:
  544. raise ValueError(f'上传文件不能超过 {max_bytes // (1024 * 1024)}MiB')
  545. ext = os.path.splitext(name)[1].lower().lstrip('.')
  546. mime = str(content_type or '').lower().split(';', 1)[0].strip()
  547. allowed_types = allowed_mime_by_ext or ALLOWED_MIME_BY_EXT
  548. if ext not in allowed_types or mime not in allowed_types[ext]:
  549. raise ValueError('不支持的文件类型')
  550. signatures = {
  551. 'jpg': (b'\xff\xd8\xff',),
  552. 'jpeg': (b'\xff\xd8\xff',),
  553. 'png': (b'\x89PNG\r\n\x1a\n',),
  554. 'webp': (b'RIFF',),
  555. 'pdf': (b'%PDF-',),
  556. 'doc': (b'\xd0\xcf\x11\xe0',),
  557. 'xls': (b'\xd0\xcf\x11\xe0',),
  558. 'docx': (b'PK\x03\x04',),
  559. 'xlsx': (b'PK\x03\x04',),
  560. 'gif': (b'GIF87a', b'GIF89a'),
  561. 'bmp': (b'BM',),
  562. 'ppt': (b'\xd0\xcf\x11\xe0',),
  563. 'pptx': (b'PK\x03\x04',),
  564. 'mp4': (b'\x00\x00\x00',),
  565. 'webm': (b'\x1a\x45\xdf\xa3',),
  566. 'ogg': (b'OggS',),
  567. 'mov': (b'\x00\x00\x00',),
  568. 'avi': (b'RIFF',),
  569. 'zip': (b'PK\x03\x04',),
  570. 'rar': (b'Rar!\x1a\x07',),
  571. }
  572. if ext not in {'heic', 'heif', 'txt'} and not any(
  573. content.startswith(prefix) for prefix in signatures.get(ext, ())
  574. ):
  575. raise ValueError('文件内容与扩展名不匹配')
  576. if ext == 'webp' and (len(content) < 12 or content[8:12] != b'WEBP'):
  577. raise ValueError('文件内容与扩展名不匹配')
  578. if ext in {'heic', 'heif'}:
  579. if len(content) < 16 or content[4:8] != b'ftyp':
  580. raise ValueError('文件内容与扩展名不匹配')
  581. box_size = int.from_bytes(content[:4], byteorder='big', signed=False)
  582. if (
  583. box_size < 16
  584. or box_size > len(content)
  585. or (box_size - 16) % 4 != 0
  586. ):
  587. raise ValueError('文件内容与扩展名不匹配')
  588. brands = {content[8:12]}
  589. brands.update(
  590. content[offset : offset + 4]
  591. for offset in range(16, box_size, 4)
  592. )
  593. allowed_brands = HEIC_BRANDS if ext == 'heic' else HEIF_BRANDS
  594. if not brands.intersection(allowed_brands):
  595. raise ValueError('文件内容与扩展名不匹配')
  596. if ext in {'docx', 'xlsx', 'pptx', 'zip'}:
  597. try:
  598. with zipfile.ZipFile(BytesIO(content)) as archive:
  599. names = set(archive.namelist())
  600. except (OSError, zipfile.BadZipFile):
  601. raise ValueError('文件内容与扩展名不匹配')
  602. if ext != 'zip':
  603. required_prefix = {
  604. 'docx': 'word/',
  605. 'xlsx': 'xl/',
  606. 'pptx': 'ppt/',
  607. }[ext]
  608. if '[Content_Types].xml' not in names or not any(
  609. name.startswith(required_prefix) for name in names
  610. ):
  611. raise ValueError('文件内容与扩展名不匹配')
  612. if ext in {'mp4', 'mov'} and (len(content) < 12 or content[4:8] != b'ftyp'):
  613. raise ValueError('文件内容与扩展名不匹配')
  614. if ext == 'avi' and (len(content) < 12 or content[8:12] != b'AVI '):
  615. raise ValueError('文件内容与扩展名不匹配')
  616. if ext == 'txt':
  617. try:
  618. content.decode('utf-8-sig')
  619. except UnicodeDecodeError:
  620. raise ValueError('文件内容与扩展名不匹配')
  621. return ext, mime
  622. def _relative_file_url(full_url: str) -> str:
  623. base_url = (settings.MINIO_BASE_URL or '').rstrip('/')
  624. if base_url and full_url.startswith(base_url + '/'):
  625. return full_url[len(base_url) + 1 :]
  626. return full_url
  627. def _file_size_display(size: int) -> str:
  628. if size < 1024:
  629. return f'{size} B'
  630. if size < 1024 * 1024:
  631. return f'{size / 1024:.1f} KB'
  632. return f'{size / (1024 * 1024):.1f} MB'
  633. def save_market_attachment(
  634. db: Session,
  635. *,
  636. content: bytes,
  637. filename: str,
  638. content_type: str,
  639. module: str,
  640. ctx: MarketFieldContext,
  641. user_info: dict,
  642. storage_backend=storage,
  643. ) -> Dict[str, Any]:
  644. _verify_identity(ctx, user_info)
  645. normalized_module = str(module or '').strip()
  646. if normalized_module not in UPLOAD_MODULES:
  647. raise ValueError('不支持的附件模块')
  648. ext, mime = _validate_upload(
  649. content,
  650. filename,
  651. content_type,
  652. max_bytes=(
  653. PRESIGN_MAX_UPLOAD_BYTES
  654. if normalized_module == 'presign'
  655. else PROTOCOL_MAX_UPLOAD_BYTES
  656. if normalized_module == 'protocol'
  657. else MAX_UPLOAD_BYTES
  658. ),
  659. allowed_mime_by_ext=(
  660. PROTOCOL_ALLOWED_MIME_BY_EXT
  661. if normalized_module == 'protocol'
  662. else ALLOWED_MIME_BY_EXT
  663. ),
  664. )
  665. folder = f'market/{ctx.scope_kind}/{normalized_module}'
  666. full_url = storage_backend.upload_file(content, filename, folder)
  667. file_url = _relative_file_url(full_url)
  668. row = BizAttachment(
  669. id=uuid.uuid4().hex,
  670. business_type=f'market:{ctx.scope_kind}:{normalized_module}:staging',
  671. business_id=None,
  672. original_name=filename,
  673. file_ext=ext,
  674. file_url=file_url,
  675. file_size=len(content),
  676. file_type=mime,
  677. tenant_no=ctx.tenant_no,
  678. create_by=ctx.user_id,
  679. create_dept_id=str(
  680. user_info.get('dept_id')
  681. or user_info.get('deptId')
  682. or user_info.get('region_id')
  683. or ''
  684. ).strip(),
  685. del_flag=0,
  686. )
  687. committed = False
  688. try:
  689. db.add(row)
  690. db.flush()
  691. db.refresh(row)
  692. result = {
  693. 'id': row.id,
  694. 'fileName': row.original_name,
  695. 'fileUrl': row.file_url,
  696. 'fileSize': row.file_size,
  697. 'fileSizeDisplay': _file_size_display(row.file_size or 0),
  698. 'fileType': row.file_type,
  699. }
  700. db.commit()
  701. committed = True
  702. except Exception:
  703. if not committed:
  704. db.rollback()
  705. try:
  706. storage_backend.delete_file(full_url)
  707. except Exception:
  708. pass
  709. raise
  710. return result
  711. def _split_refs(refs: Any) -> List[str]:
  712. if refs is None:
  713. return []
  714. if isinstance(refs, str):
  715. if len(refs) > MAX_ATTACHMENT_REFS_LENGTH:
  716. raise ValueError('附件引用总长度超出限制')
  717. raw: Iterable[Any] = refs.split(',')
  718. elif isinstance(refs, (list, tuple, set)):
  719. raw = refs
  720. else:
  721. raise ValueError('附件引用格式无效')
  722. raw = list(raw)
  723. if len(raw) > MAX_ATTACHMENT_REFS:
  724. raise ValueError('附件数量不能超过 20 个')
  725. values = []
  726. total_length = 0
  727. for item in raw:
  728. if isinstance(item, dict):
  729. item = item.get('id') or item.get('fileUrl') or item.get('url')
  730. value = str(item or '').strip()
  731. if len(value) > MAX_ATTACHMENT_REF_LENGTH:
  732. raise ValueError('附件引用长度无效')
  733. total_length += len(value)
  734. if total_length + max(0, len(raw) - 1) > MAX_ATTACHMENT_REFS_LENGTH:
  735. raise ValueError('附件引用总长度超出限制')
  736. if value and value not in values:
  737. values.append(value)
  738. return values
  739. def _attachment_candidates(value: str) -> List[str]:
  740. candidates = [value]
  741. base = (settings.MINIO_BASE_URL or '').rstrip('/')
  742. if base and value.startswith(base + '/'):
  743. candidates.append(value[len(base) + 1 :])
  744. return list(dict.fromkeys(candidates))
  745. def _attachment_value_matches(value: Any, candidates: Iterable[str]) -> bool:
  746. expected = set(candidates)
  747. for item in str(value or '').split(','):
  748. normalized = str(item or '').strip()
  749. if normalized and expected.intersection(_attachment_candidates(normalized)):
  750. return True
  751. return False
  752. def _attachment_columns_filter(columns, candidates: Iterable[str]):
  753. return or_(
  754. *(
  755. column.contains(candidate)
  756. for column in columns
  757. for candidate in candidates
  758. )
  759. )
  760. def _rows_reference_attachment(rows, field_names, candidates) -> bool:
  761. return any(
  762. _attachment_value_matches(getattr(row, field_name, None), candidates)
  763. for row in rows
  764. for field_name in field_names
  765. )
  766. def _has_accessible_protocol_cert_or_insurance_reference(
  767. db: Session,
  768. *,
  769. candidates: List[str],
  770. ctx: MarketFieldContext,
  771. user_info: dict,
  772. ) -> bool:
  773. reference_models = (
  774. (BizProtocolStorageCert, 'cert_image_urls'),
  775. (BizProtocolFullmoonTodo, 'insurance_attachments'),
  776. )
  777. for model, field_name in reference_models:
  778. rows = (
  779. apply_cell_protocol_scope(
  780. db.query(model).join(
  781. BizCollectionProtocol,
  782. BizCollectionProtocol.id == model.collection_protocol_id,
  783. ),
  784. db,
  785. ctx,
  786. user_info=user_info,
  787. )
  788. .filter(
  789. model.tenant_no == ctx.tenant_no,
  790. model.del_flag == 0,
  791. _attachment_columns_filter(
  792. (getattr(model, field_name),), candidates
  793. ),
  794. )
  795. .all()
  796. )
  797. if _rows_reference_attachment(rows, (field_name,), candidates):
  798. return True
  799. return False
  800. def _has_accessible_protocol_deposit_attachment_reference(
  801. db: Session,
  802. *,
  803. candidates: List[str],
  804. ctx: MarketFieldContext,
  805. user_info: dict,
  806. ) -> bool:
  807. """正式协议意向金凭证挂在预签转账上,经 source_presign_order_id 关联授权。"""
  808. transfer_rows = (
  809. db.query(BizPresignTransfer)
  810. .filter(
  811. BizPresignTransfer.tenant_no == ctx.tenant_no,
  812. BizPresignTransfer.del_flag == 0,
  813. _attachment_columns_filter(
  814. (BizPresignTransfer.transfer_attachments,), candidates
  815. ),
  816. )
  817. .all()
  818. )
  819. if not _rows_reference_attachment(
  820. transfer_rows, ('transfer_attachments',), candidates
  821. ):
  822. return False
  823. presign_ids = {
  824. str(row.presign_order_id or '').strip()
  825. for row in transfer_rows
  826. if str(row.presign_order_id or '').strip()
  827. }
  828. if not presign_ids:
  829. return False
  830. protocol_rows = (
  831. apply_cell_protocol_scope(
  832. db.query(BizCollectionProtocol),
  833. db,
  834. ctx,
  835. user_info=user_info,
  836. )
  837. .filter(
  838. BizCollectionProtocol.tenant_no == ctx.tenant_no,
  839. BizCollectionProtocol.del_flag == 0,
  840. BizCollectionProtocol.source_presign_order_id.in_(presign_ids),
  841. )
  842. .limit(1)
  843. .all()
  844. )
  845. return bool(protocol_rows)
  846. def _has_accessible_market_attachment_reference(
  847. db: Session,
  848. *,
  849. module: str,
  850. candidates: List[str],
  851. ctx: MarketFieldContext,
  852. user_info: dict,
  853. ) -> bool:
  854. """附件必须被当前模块中调用人可访问的业务记录引用。"""
  855. if module == 'protocol':
  856. cell_fields = (
  857. 'protocol_attachments',
  858. 'consent_form_urls',
  859. 'health_survey_form_urls',
  860. )
  861. cell_columns = tuple(
  862. getattr(BizCollectionProtocol, field) for field in cell_fields
  863. )
  864. cell_rows = (
  865. apply_cell_protocol_scope(
  866. db.query(BizCollectionProtocol),
  867. db,
  868. ctx,
  869. user_info=user_info,
  870. )
  871. .filter(_attachment_columns_filter(cell_columns, candidates))
  872. .all()
  873. )
  874. if _rows_reference_attachment(cell_rows, cell_fields, candidates):
  875. return True
  876. payment_detail_rows = (
  877. apply_cell_protocol_scope(
  878. db.query(BizProtocolPaymentDetail)
  879. .join(
  880. BizProtocolPayment,
  881. BizProtocolPayment.id == BizProtocolPaymentDetail.payment_id,
  882. )
  883. .join(
  884. BizCollectionProtocol,
  885. BizCollectionProtocol.id
  886. == BizProtocolPayment.collection_protocol_id,
  887. ),
  888. db,
  889. ctx,
  890. user_info=user_info,
  891. )
  892. .filter(
  893. BizProtocolPaymentDetail.tenant_no == ctx.tenant_no,
  894. BizProtocolPaymentDetail.del_flag == 0,
  895. BizProtocolPayment.tenant_no == ctx.tenant_no,
  896. BizProtocolPayment.del_flag == 0,
  897. _attachment_columns_filter(
  898. (BizProtocolPaymentDetail.transfer_attachments,), candidates
  899. ),
  900. )
  901. .all()
  902. )
  903. if _rows_reference_attachment(
  904. payment_detail_rows, ('transfer_attachments',), candidates
  905. ):
  906. return True
  907. if _has_accessible_protocol_cert_or_insurance_reference(
  908. db,
  909. candidates=candidates,
  910. ctx=ctx,
  911. user_info=user_info,
  912. ):
  913. return True
  914. if _has_accessible_protocol_deposit_attachment_reference(
  915. db,
  916. candidates=candidates,
  917. ctx=ctx,
  918. user_info=user_info,
  919. ):
  920. return True
  921. donation_fields = (
  922. 'consent_form_urls',
  923. 'attachment_urls',
  924. 'infectious_disease_report_urls',
  925. )
  926. donation_columns = tuple(
  927. getattr(BizResearchDonation, field) for field in donation_fields
  928. )
  929. donation_rows = (
  930. apply_donation_scope(
  931. db.query(BizResearchDonation),
  932. db,
  933. ctx,
  934. user_info=user_info,
  935. )
  936. .filter(_attachment_columns_filter(donation_columns, candidates))
  937. .all()
  938. )
  939. return _rows_reference_attachment(
  940. donation_rows, donation_fields, candidates
  941. )
  942. if module == 'presign':
  943. rows = (
  944. apply_presign_scope(
  945. db.query(BizPresignTransfer).join(
  946. BizPresignOrder,
  947. BizPresignOrder.id == BizPresignTransfer.presign_order_id,
  948. ),
  949. db,
  950. ctx,
  951. user_info=user_info,
  952. )
  953. .filter(
  954. BizPresignTransfer.tenant_no == ctx.tenant_no,
  955. BizPresignTransfer.del_flag == 0,
  956. _attachment_columns_filter(
  957. (BizPresignTransfer.transfer_attachments,), candidates
  958. ),
  959. )
  960. .all()
  961. )
  962. return _rows_reference_attachment(
  963. rows, ('transfer_attachments',), candidates
  964. )
  965. if module == 'payment':
  966. rows = (
  967. apply_cell_protocol_scope(
  968. db.query(BizProtocolPaymentTransfer).join(
  969. BizCollectionProtocol,
  970. BizCollectionProtocol.id
  971. == BizProtocolPaymentTransfer.collection_protocol_id,
  972. ),
  973. db,
  974. ctx,
  975. user_info=user_info,
  976. )
  977. .filter(
  978. BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no,
  979. BizProtocolPaymentTransfer.del_flag == 0,
  980. _attachment_columns_filter(
  981. (BizProtocolPaymentTransfer.transfer_attachments,), candidates
  982. ),
  983. )
  984. .all()
  985. )
  986. return _rows_reference_attachment(
  987. rows, ('transfer_attachments',), candidates
  988. )
  989. if module == 'sample':
  990. return _has_accessible_protocol_cert_or_insurance_reference(
  991. db,
  992. candidates=candidates,
  993. ctx=ctx,
  994. user_info=user_info,
  995. )
  996. return False
  997. def get_market_attachment_download_url(
  998. db: Session,
  999. *,
  1000. file_url: str,
  1001. file_name: Optional[str],
  1002. module: str,
  1003. ctx: MarketFieldContext,
  1004. user_info: dict,
  1005. storage_backend=storage,
  1006. ) -> Dict[str, str]:
  1007. """为市场移动端返回附件下载地址。
  1008. 新附件按市场模块隔离;历史 Web 附件没有 business_type,
  1009. 但仍必须是当前租户已登记的 BizAttachment 记录。
  1010. """
  1011. _verify_identity(ctx, user_info)
  1012. normalized_module = str(module or '').strip()
  1013. if normalized_module not in UPLOAD_MODULES:
  1014. raise ValueError('不支持的附件模块')
  1015. normalized_url = str(file_url or '').strip()
  1016. if not normalized_url or len(normalized_url) > MAX_ATTACHMENT_REF_LENGTH:
  1017. raise ValueError('文件地址无效')
  1018. candidates = _attachment_candidates(normalized_url)
  1019. rows = (
  1020. db.query(BizAttachment)
  1021. .filter(
  1022. BizAttachment.tenant_no == ctx.tenant_no,
  1023. BizAttachment.del_flag == 0,
  1024. BizAttachment.file_url.in_(candidates),
  1025. )
  1026. .all()
  1027. )
  1028. expected_staging = f'market:{ctx.scope_kind}:{normalized_module}:staging'
  1029. allowed_bound = {
  1030. f'market:specialist:{normalized_module}:bound',
  1031. f'market:manager:{normalized_module}:bound',
  1032. }
  1033. legacy_sample_bound = {
  1034. 'market:specialist:sample:bound',
  1035. 'market:manager:sample:bound',
  1036. }
  1037. legacy_presign_bound = {
  1038. 'market:specialist:presign:bound',
  1039. 'market:manager:presign:bound',
  1040. }
  1041. row = next(
  1042. (
  1043. item
  1044. for item in rows
  1045. if (
  1046. str(item.business_type or '').strip() == expected_staging
  1047. and str(item.create_by or '') == ctx.user_id
  1048. and not item.business_id
  1049. )
  1050. ),
  1051. None,
  1052. )
  1053. if row is None:
  1054. referenced_rows = [
  1055. item
  1056. for item in rows
  1057. if str(item.business_type or '').strip() in ({''} | allowed_bound)
  1058. ]
  1059. if referenced_rows and _has_accessible_market_attachment_reference(
  1060. db,
  1061. module=normalized_module,
  1062. candidates=candidates,
  1063. ctx=ctx,
  1064. user_info=user_info,
  1065. ):
  1066. row = referenced_rows[0]
  1067. if row is None and normalized_module == 'protocol':
  1068. sample_bound_rows = [
  1069. item
  1070. for item in rows
  1071. if str(item.business_type or '').strip() in legacy_sample_bound
  1072. ]
  1073. if (
  1074. sample_bound_rows
  1075. and _has_accessible_protocol_cert_or_insurance_reference(
  1076. db,
  1077. candidates=candidates,
  1078. ctx=ctx,
  1079. user_info=user_info,
  1080. )
  1081. ):
  1082. row = sample_bound_rows[0]
  1083. if row is None and normalized_module == 'protocol':
  1084. deposit_bound_rows = [
  1085. item
  1086. for item in rows
  1087. if str(item.business_type or '').strip() in legacy_presign_bound
  1088. ]
  1089. if (
  1090. deposit_bound_rows
  1091. and _has_accessible_protocol_deposit_attachment_reference(
  1092. db,
  1093. candidates=candidates,
  1094. ctx=ctx,
  1095. user_info=user_info,
  1096. )
  1097. ):
  1098. row = deposit_bound_rows[0]
  1099. if row is None:
  1100. raise PermissionError('附件不存在或无权下载')
  1101. download_name = str(row.original_name or '').strip()
  1102. if not download_name:
  1103. download_name = str(file_name or '').strip()
  1104. if not download_name:
  1105. download_name = str(row.file_url or '').rsplit('/', 1)[-1] or 'download'
  1106. return {
  1107. 'downloadUrl': storage_backend.presigned_download_url(
  1108. str(row.file_url), download_name
  1109. )
  1110. }
  1111. def validate_attachment_refs(
  1112. db: Session,
  1113. refs: Any,
  1114. *,
  1115. module: str,
  1116. ctx: MarketFieldContext,
  1117. creating: bool,
  1118. existing_refs: Any = None,
  1119. business_id: Optional[str] = None,
  1120. ) -> str:
  1121. """批量锁定并解析附件;有 business_id 时原子消费当前角色暂存记录。"""
  1122. normalized_module = str(module or '').strip()
  1123. if normalized_module not in UPLOAD_MODULES:
  1124. raise ValueError('不支持的附件模块')
  1125. values = _split_refs(refs)
  1126. if not values:
  1127. return ''
  1128. existing = set(_split_refs(existing_refs)) if not creating else set()
  1129. new_values = [value for value in values if value not in existing]
  1130. if not new_values:
  1131. return ','.join(values)
  1132. normalized_business_id = str(business_id or '').strip()
  1133. candidates_by_value = {
  1134. value: _attachment_candidates(value) for value in new_values
  1135. }
  1136. all_candidates = {
  1137. candidate
  1138. for candidates in candidates_by_value.values()
  1139. for candidate in candidates
  1140. }
  1141. rows = (
  1142. db.query(BizAttachment)
  1143. .filter(
  1144. BizAttachment.tenant_no == ctx.tenant_no,
  1145. BizAttachment.del_flag == 0,
  1146. or_(
  1147. BizAttachment.id.in_(new_values),
  1148. BizAttachment.file_url.in_(all_candidates),
  1149. ),
  1150. )
  1151. .with_for_update()
  1152. .all()
  1153. )
  1154. by_id = {str(row.id): row for row in rows}
  1155. by_url = {str(row.file_url): row for row in rows}
  1156. expected_staging = f'market:{ctx.scope_kind}:{normalized_module}:staging'
  1157. expected_bound = f'market:{ctx.scope_kind}:{normalized_module}:bound'
  1158. resolved = []
  1159. seen_row_ids = set()
  1160. for value in values:
  1161. if value in existing:
  1162. if value not in resolved:
  1163. resolved.append(value)
  1164. continue
  1165. row = by_id.get(value)
  1166. if row is None:
  1167. row = next(
  1168. (
  1169. by_url.get(candidate)
  1170. for candidate in candidates_by_value[value]
  1171. if by_url.get(candidate)
  1172. ),
  1173. None,
  1174. )
  1175. if row is None:
  1176. raise ValueError('附件不存在或无权引用')
  1177. if row.id in seen_row_ids:
  1178. continue
  1179. seen_row_ids.add(row.id)
  1180. is_current_staging = (
  1181. row.business_type == expected_staging
  1182. and row.create_by == ctx.user_id
  1183. and not row.business_id
  1184. )
  1185. is_same_binding = (
  1186. bool(normalized_business_id)
  1187. and row.business_type == expected_bound
  1188. and str(row.business_id or '') == normalized_business_id
  1189. )
  1190. if not (is_current_staging or is_same_binding):
  1191. raise ValueError('附件不存在、已被绑定或无权引用')
  1192. if normalized_business_id and is_current_staging:
  1193. row.business_type = expected_bound
  1194. row.business_id = normalized_business_id
  1195. db.add(row)
  1196. resolved.append(str(row.file_url))
  1197. if normalized_business_id:
  1198. db.flush()
  1199. return ','.join(resolved)
  1200. def allowed_actions(
  1201. module: str,
  1202. *,
  1203. status: str,
  1204. scope_kind: str = 'specialist',
  1205. source: str = '',
  1206. has_sample_code: bool = False,
  1207. has_payment_permission: bool = False,
  1208. ) -> List[str]:
  1209. """纯函数动作白名单;路由仍必须执行对应权限与状态校验。"""
  1210. module, state = str(module or '').strip(), str(status or '').strip().lower()
  1211. if module in {'presign-list', 'protocol-list', 'freight-list'}:
  1212. return ['create']
  1213. if module == 'presign':
  1214. if state == 'converted':
  1215. return ['edit']
  1216. if state == 'void':
  1217. return ['edit', 'delete']
  1218. return ['edit', 'convert', 'delete']
  1219. if module == 'presign-transfer':
  1220. if state == 'dfj':
  1221. return ['edit', 'delete', 'submit']
  1222. if state == 'jfqrz':
  1223. return ['withdraw']
  1224. return []
  1225. if module == 'protocol':
  1226. return ['edit', 'delete']
  1227. if module == 'payment':
  1228. return ['register-transfer']
  1229. if module == 'payment-transfer':
  1230. if state == 'dfj':
  1231. return ['edit-transfer', 'delete-payment-transfer', 'submit-finance']
  1232. if state == 'jfqrz':
  1233. return ['withdraw-transfer']
  1234. return []
  1235. if module == 'payment-chase':
  1236. return []
  1237. if module == 'sample':
  1238. return []
  1239. if module == 'freight':
  1240. return ['edit'] if scope_kind == 'manager' else []
  1241. return []
  1242. def decorate_allowed_actions(
  1243. module: str,
  1244. item: Dict[str, Any],
  1245. *,
  1246. scope_kind: str,
  1247. has_payment_permission: bool = False,
  1248. detail: bool = False,
  1249. ) -> Dict[str, Any]:
  1250. """为移动列表/详情 DTO 增加动作白名单,不把动作判断交给客户端。"""
  1251. data = dict(item)
  1252. basic = data.get('basic') if isinstance(data.get('basic'), dict) else {}
  1253. status = str(
  1254. data.get('orderStatus')
  1255. or data.get('protocolStatus')
  1256. or data.get('donationStatus')
  1257. or data.get('payStatus')
  1258. or data.get('status')
  1259. or basic.get('protocolStatus')
  1260. or basic.get('donationStatus')
  1261. or basic.get('status')
  1262. or ''
  1263. )
  1264. if module == 'sample':
  1265. card = data.get('card') if isinstance(data.get('card'), dict) else data
  1266. source = str(card.get('source') or data.get('source') or '')
  1267. sample_code = str(card.get('sampleCode') or data.get('sampleCode') or '')
  1268. candidates = allowed_actions(
  1269. module,
  1270. status=status,
  1271. scope_kind=scope_kind,
  1272. source=source,
  1273. has_sample_code=bool(sample_code.strip()),
  1274. )
  1275. progress = data.get('progress') if isinstance(data.get('progress'), dict) else card.get('progress') or {}
  1276. storage_ready = str((progress.get('storage') or {}).get('code') or '') == 'yrk'
  1277. handles = next(
  1278. (
  1279. section
  1280. for section in data.get('sections') or []
  1281. if section.get('key') == 'market-handles'
  1282. ),
  1283. {},
  1284. )
  1285. storage_cert = card.get('storageCert') or handles.get('storageCert') or {}
  1286. insurance = card.get('insurance') or handles.get('insurance') or {}
  1287. data['allowedActions'] = [
  1288. action
  1289. for action in candidates
  1290. if storage_ready
  1291. and (
  1292. (action == 'storage-cert' and storage_cert.get('code') != 'handled')
  1293. or (action == 'insurance' and insurance.get('code') != 'handled')
  1294. )
  1295. ]
  1296. return data
  1297. if module == 'payment':
  1298. schedules = []
  1299. has_writable_schedule = False
  1300. for schedule in data.get('schedules') or []:
  1301. schedule_data = dict(schedule)
  1302. try:
  1303. arrears = Decimal(str(schedule_data.get('arrearsAmount') or 0))
  1304. except (ArithmeticError, ValueError):
  1305. arrears = Decimal('0')
  1306. schedule_writable = (
  1307. str(schedule_data.get('scheduleStatus') or '').lower() != 'void'
  1308. and arrears > 0
  1309. )
  1310. schedule_data['allowedActions'] = (
  1311. ['register-transfer'] if schedule_writable else []
  1312. )
  1313. has_writable_schedule = has_writable_schedule or schedule_writable
  1314. transfers = []
  1315. for transfer in schedule_data.get('transfers') or []:
  1316. transfer_data = dict(transfer)
  1317. transfer_data['allowedActions'] = (
  1318. allowed_actions(
  1319. 'payment-transfer',
  1320. status=str(transfer_data.get('payStatus') or ''),
  1321. )
  1322. )
  1323. transfers.append(transfer_data)
  1324. schedule_data['transfers'] = transfers
  1325. schedules.append(schedule_data)
  1326. data['schedules'] = schedules
  1327. data['allowedActions'] = (
  1328. ['register-transfer'] if has_writable_schedule else []
  1329. )
  1330. return data
  1331. if module == 'presign' and detail:
  1332. parent_writable = status.lower() in {'', 'draft', 'active'}
  1333. data['transferAllowedActions'] = (
  1334. ['create-transfer'] if parent_writable else []
  1335. )
  1336. transfers = []
  1337. for transfer in data.get('transfers') or []:
  1338. transfer_data = dict(transfer)
  1339. transfer_data['allowedActions'] = (
  1340. allowed_actions(
  1341. 'presign-transfer',
  1342. status=str(transfer_data.get('payStatus') or ''),
  1343. )
  1344. if parent_writable
  1345. else []
  1346. )
  1347. transfers.append(transfer_data)
  1348. data['transfers'] = transfers
  1349. if module == 'freight' and (detail or isinstance(data.get('allowedActions'), list)):
  1350. provided = data.get('allowedActions')
  1351. safe_actions = {'edit', 'delete', 'link', 'unlink'}
  1352. data['allowedActions'] = (
  1353. [action for action in provided if action in safe_actions]
  1354. if isinstance(provided, list)
  1355. else ['link', 'unlink']
  1356. )
  1357. return data
  1358. if module == 'protocol':
  1359. sample_payments = []
  1360. for payment in data.get('samplePayments') or []:
  1361. payment_data = dict(payment)
  1362. schedules = []
  1363. for schedule in payment_data.get('schedules') or []:
  1364. schedule_data = dict(schedule)
  1365. try:
  1366. arrears = Decimal(str(schedule_data.get('arrearsAmount') or 0))
  1367. except (ArithmeticError, ValueError):
  1368. arrears = Decimal('0')
  1369. schedule_writable = (
  1370. has_payment_permission
  1371. and str(schedule_data.get('scheduleStatus') or '').lower() != 'void'
  1372. and arrears > 0
  1373. )
  1374. schedule_data['allowedActions'] = (
  1375. ['register-transfer'] if schedule_writable else []
  1376. )
  1377. transfers = []
  1378. for transfer in schedule_data.get('transfers') or []:
  1379. transfer_data = dict(transfer)
  1380. transfer_data['allowedActions'] = (
  1381. allowed_actions(
  1382. 'payment-transfer',
  1383. status=str(transfer_data.get('payStatus') or ''),
  1384. )
  1385. if has_payment_permission
  1386. else []
  1387. )
  1388. transfers.append(transfer_data)
  1389. schedule_data['transfers'] = transfers
  1390. schedules.append(schedule_data)
  1391. payment_data['schedules'] = schedules
  1392. sample_payments.append(payment_data)
  1393. data['samplePayments'] = sample_payments
  1394. actions = allowed_actions(
  1395. module,
  1396. status=status,
  1397. scope_kind=scope_kind,
  1398. has_payment_permission=has_payment_permission,
  1399. )
  1400. data['allowedActions'] = actions
  1401. return data