market_freight.py 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290
  1. """移动市场运费批次:按现场范围过滤明细并执行原子写入。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timedelta
  4. from decimal import Decimal, InvalidOperation
  5. from typing import Any, Dict, List, Optional, Tuple
  6. from fastapi import HTTPException
  7. from sqlalchemy import and_, exists, func, literal, or_, select
  8. from sqlalchemy.exc import IntegrityError
  9. from sqlalchemy.orm import Session, aliased
  10. from app.core.data_scope import apply_data_scope
  11. from app.core.utils import generate_uuid
  12. from app.models.models import (
  13. BizCellSampleType,
  14. BizCollectionProtocol,
  15. BizCollectionRegionStaff,
  16. BizProtocolCollectionInfo,
  17. BizProtocolPartyCustomer,
  18. BizProtocolSample,
  19. BizSampleFreight,
  20. BizSampleFreightItem,
  21. SysDept,
  22. SysDictData,
  23. SysPost,
  24. SysPostRole,
  25. SysRole,
  26. SysRoleDataScopeDetail,
  27. SysUser,
  28. SysUserPost,
  29. SysUserRole,
  30. )
  31. from app.models.research_donation import (
  32. BizResearchDonation,
  33. BizResearchDonationCollection,
  34. BizResearchDonationParty,
  35. BizResearchDonationSample,
  36. )
  37. from app.services.market_field_scope import (
  38. MarketFieldContext,
  39. apply_cell_protocol_scope,
  40. apply_donation_scope,
  41. )
  42. from app.services.sample_transport_mode import DICT_TYPE, resolve_transport_mode_value
  43. from app.services.sf_express_settlement import (
  44. assert_freight_mutable,
  45. freight_amount_label,
  46. resolve_header_for_save,
  47. )
  48. from app.services.web_auth_roles import (
  49. _map_role_name_to_web,
  50. resolve_web_role_for_user,
  51. )
  52. CELL = 'cell_storage'
  53. DONATION = 'research_donation'
  54. SOURCES = (CELL, DONATION)
  55. def _current_tenant_user_info(
  56. db: Session, ctx: MarketFieldContext, user_info: dict
  57. ) -> dict:
  58. """用当前租户有效角色覆盖不可信的令牌 role 快照。"""
  59. if not isinstance(user_info, dict):
  60. raise PermissionError('用户身份与现场范围上下文不一致')
  61. user_id = str(user_info.get('user_id') or '').strip()
  62. tenant_no = str(user_info.get('tenant_no') or '').strip()
  63. if user_id != ctx.user_id or tenant_no != ctx.tenant_no:
  64. raise PermissionError('用户身份与现场范围上下文不一致')
  65. current = dict(user_info)
  66. current['role'] = resolve_web_role_for_user(
  67. db, ctx.user_id, tenant_no=ctx.tenant_no
  68. )
  69. return current
  70. def _latest_cell_party_id(ctx: MarketFieldContext):
  71. latest = aliased(BizProtocolPartyCustomer)
  72. return (
  73. select(latest.id)
  74. .where(
  75. latest.collection_protocol_id == BizCollectionProtocol.id,
  76. latest.tenant_no == ctx.tenant_no,
  77. latest.del_flag == 0,
  78. )
  79. .order_by(latest.create_time.desc(), latest.id.desc())
  80. .limit(1)
  81. .correlate(BizCollectionProtocol)
  82. .scalar_subquery()
  83. )
  84. def _latest_donation_party_id(ctx: MarketFieldContext):
  85. latest = aliased(BizResearchDonationParty)
  86. return (
  87. select(latest.id)
  88. .where(
  89. latest.research_donation_id == BizResearchDonation.id,
  90. latest.tenant_no == ctx.tenant_no,
  91. latest.del_flag == 0,
  92. )
  93. .order_by(latest.create_time.desc(), latest.id.desc())
  94. .limit(1)
  95. .correlate(BizResearchDonation)
  96. .scalar_subquery()
  97. )
  98. def _amount(value: Any) -> Decimal:
  99. try:
  100. result = Decimal(str(value))
  101. except (InvalidOperation, ValueError, TypeError) as exc:
  102. raise ValueError('运费格式无效') from exc
  103. if not result.is_finite() or result < 0:
  104. raise ValueError('运费格式无效')
  105. return result.quantize(Decimal('0.01'))
  106. def _fmt_dt(value: Optional[datetime]) -> str:
  107. return value.strftime('%Y-%m-%d %H:%M:%S') if value else ''
  108. def _normalize_items(items: List[Dict[str, Any]]) -> List[Tuple[str, str]]:
  109. if not items:
  110. raise ValueError('至少选择一个样本')
  111. result: List[Tuple[str, str]] = []
  112. seen = set()
  113. for item in items:
  114. source = str(item.get('sampleSource') or item.get('sample_source') or '').strip()
  115. sample_id = str(item.get('sampleId') or item.get('sample_id') or '').strip()
  116. if source not in SOURCES or not sample_id:
  117. raise ValueError('关联样本须包含有效的 sampleSource 与 sampleId')
  118. key = (source, sample_id)
  119. if key in seen:
  120. raise ValueError('关联样本不可重复')
  121. seen.add(key)
  122. result.append(key)
  123. return result
  124. def _cell_item_query(db: Session, ctx: MarketFieldContext, user_info: dict):
  125. query = (
  126. db.query(
  127. BizSampleFreightItem,
  128. BizProtocolSample,
  129. BizCollectionProtocol,
  130. BizProtocolPartyCustomer,
  131. BizCellSampleType,
  132. )
  133. .join(
  134. BizProtocolSample,
  135. and_(
  136. BizProtocolSample.id == BizSampleFreightItem.sample_id,
  137. BizProtocolSample.tenant_no == ctx.tenant_no,
  138. BizProtocolSample.del_flag == 0,
  139. ),
  140. )
  141. .join(
  142. BizCollectionProtocol,
  143. and_(
  144. BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id,
  145. BizCollectionProtocol.tenant_no == ctx.tenant_no,
  146. BizCollectionProtocol.del_flag == 0,
  147. ),
  148. )
  149. .outerjoin(
  150. BizProtocolPartyCustomer,
  151. and_(
  152. BizProtocolPartyCustomer.id == _latest_cell_party_id(ctx),
  153. BizProtocolPartyCustomer.tenant_no == ctx.tenant_no,
  154. BizProtocolPartyCustomer.del_flag == 0,
  155. ),
  156. )
  157. .outerjoin(
  158. BizCellSampleType,
  159. and_(
  160. BizCellSampleType.id == BizProtocolSample.sample_type_id,
  161. BizCellSampleType.tenant_no == ctx.tenant_no,
  162. BizCellSampleType.del_flag == 0,
  163. ),
  164. )
  165. .filter(
  166. BizSampleFreightItem.tenant_no == ctx.tenant_no,
  167. BizSampleFreightItem.del_flag == 0,
  168. BizSampleFreightItem.sample_source == CELL,
  169. )
  170. )
  171. return apply_cell_protocol_scope(query, db, ctx, user_info=user_info)
  172. def _donation_item_query(db: Session, ctx: MarketFieldContext, user_info: dict):
  173. query = (
  174. db.query(
  175. BizSampleFreightItem,
  176. BizResearchDonationSample,
  177. BizResearchDonation,
  178. BizResearchDonationParty,
  179. BizCellSampleType,
  180. )
  181. .join(
  182. BizResearchDonationSample,
  183. and_(
  184. BizResearchDonationSample.id == BizSampleFreightItem.sample_id,
  185. BizResearchDonationSample.tenant_no == ctx.tenant_no,
  186. BizResearchDonationSample.del_flag == 0,
  187. ),
  188. )
  189. .join(
  190. BizResearchDonation,
  191. and_(
  192. BizResearchDonation.id == BizResearchDonationSample.research_donation_id,
  193. BizResearchDonation.tenant_no == ctx.tenant_no,
  194. BizResearchDonation.del_flag == 0,
  195. ),
  196. )
  197. .outerjoin(
  198. BizResearchDonationParty,
  199. and_(
  200. BizResearchDonationParty.id == _latest_donation_party_id(ctx),
  201. BizResearchDonationParty.tenant_no == ctx.tenant_no,
  202. BizResearchDonationParty.del_flag == 0,
  203. ),
  204. )
  205. .outerjoin(
  206. BizCellSampleType,
  207. and_(
  208. BizCellSampleType.id == BizResearchDonationSample.sample_type_id,
  209. BizCellSampleType.tenant_no == ctx.tenant_no,
  210. BizCellSampleType.del_flag == 0,
  211. ),
  212. )
  213. .filter(
  214. BizSampleFreightItem.tenant_no == ctx.tenant_no,
  215. BizSampleFreightItem.del_flag == 0,
  216. BizSampleFreightItem.sample_source == DONATION,
  217. )
  218. )
  219. return apply_donation_scope(query, db, ctx, user_info=user_info)
  220. def _visible_item_query(db: Session, source: str, ctx: MarketFieldContext, user_info: dict):
  221. return _cell_item_query(db, ctx, user_info) if source == CELL else _donation_item_query(db, ctx, user_info)
  222. def _visible_rows(db: Session, freight_ids: List[str], ctx: MarketFieldContext, user_info: dict):
  223. rows = []
  224. if not freight_ids:
  225. return rows
  226. for source in SOURCES:
  227. rows.extend(
  228. _visible_item_query(db, source, ctx, user_info)
  229. .filter(BizSampleFreightItem.freight_id.in_(freight_ids))
  230. .order_by(BizSampleFreightItem.create_time.asc(), BizSampleFreightItem.id.asc())
  231. .all()
  232. )
  233. rows.sort(key=lambda row: (row[0].create_time or datetime.min, row[0].id))
  234. # 业务库历史上可能存在多条有效客户方信息;明细身份始终以关联行 ID 为准。
  235. unique = {}
  236. for row in rows:
  237. unique.setdefault(row[0].id, row)
  238. return list(unique.values())
  239. def _item_dict(row) -> Dict[str, Any]:
  240. item, sample, parent, party, sample_type = row
  241. source = item.sample_source
  242. return {
  243. 'id': item.id,
  244. 'freightId': item.freight_id,
  245. 'sampleSource': source,
  246. 'sampleId': sample.id,
  247. 'sampleCode': sample.sample_code or '',
  248. 'protocolId': parent.id,
  249. 'protocolNo': (parent.protocol_no if source == CELL else parent.donation_no) or '',
  250. 'sampleTypeName': sample_type.type_name if sample_type else '',
  251. 'motherName': party.mother_name if party else '',
  252. 'newbornName': party.newborn_name if party else '',
  253. }
  254. def _label_map(db: Session, tenant_no: str, values: List[str]) -> Dict[str, str]:
  255. wanted = list({value for value in values if value})
  256. if not wanted:
  257. return {}
  258. rows = db.query(SysDictData.dict_value, SysDictData.dict_label).filter(
  259. SysDictData.tenant_no == tenant_no,
  260. SysDictData.dict_type == DICT_TYPE,
  261. SysDictData.dict_value.in_(wanted),
  262. SysDictData.del_flag == 0,
  263. ).all()
  264. return {value: label or value for value, label in rows}
  265. def _creator_name_map(
  266. db: Session, tenant_no: str, user_ids: List[str]
  267. ) -> Dict[str, str]:
  268. wanted = list({str(user_id or '').strip() for user_id in user_ids if user_id})
  269. if not wanted:
  270. return {}
  271. rows = (
  272. db.query(SysUser.id, SysUser.nick_name)
  273. .filter(
  274. SysUser.id.in_(wanted),
  275. SysUser.tenant_no == tenant_no,
  276. )
  277. .all()
  278. )
  279. return {user_id: nick_name or '' for user_id, nick_name in rows}
  280. def _batch_dict(
  281. obj: BizSampleFreight, count: int, label: str, creator_name: str = ''
  282. ) -> Dict[str, Any]:
  283. amount = obj.freight_amount
  284. settle_time = getattr(obj, 'settle_time', None)
  285. return {
  286. 'id': obj.id,
  287. 'freightNo': obj.freight_no or '',
  288. 'transportMode': obj.transport_mode or '',
  289. 'transportModeLabel': label or obj.transport_mode or '',
  290. 'freightAmount': None
  291. if amount is None
  292. else str(Decimal(amount).quantize(Decimal('0.01'))),
  293. 'freightAmountLabel': freight_amount_label(obj.transport_mode, amount),
  294. 'waybillNo': getattr(obj, 'waybill_no', None) or '',
  295. 'settleStatus': getattr(obj, 'settle_status', None) or '',
  296. 'settleTime': _fmt_dt(settle_time) if settle_time else '',
  297. 'visibleItemCount': int(count),
  298. 'remark': obj.remark or '',
  299. 'createBy': obj.create_by or '',
  300. 'createByName': creator_name,
  301. 'createTime': _fmt_dt(obj.create_time),
  302. 'updateTime': _fmt_dt(obj.update_time),
  303. }
  304. def _freight_accessible(
  305. db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict
  306. ) -> Optional[BizSampleFreight]:
  307. """按 Web 运费单数据权限解析批次;不再要求现场可见明细。"""
  308. q = db.query(BizSampleFreight).filter(
  309. BizSampleFreight.id == freight_id,
  310. BizSampleFreight.tenant_no == ctx.tenant_no,
  311. BizSampleFreight.del_flag == 0,
  312. )
  313. return apply_data_scope(q, BizSampleFreight, user_info, db, ctx.tenant_no).first()
  314. def list_freights(
  315. db: Session, *, ctx: MarketFieldContext, user_info: dict, page: int = 1,
  316. page_size: int = 10, keyword: Optional[str] = None,
  317. transport_mode: Optional[str] = None, sample_source: Optional[str] = None,
  318. include_items: bool = False,
  319. create_time_start: Optional[str] = None,
  320. create_time_end: Optional[str] = None,
  321. ) -> Tuple[List[Dict[str, Any]], int]:
  322. """列表与 Web 一致:租户 + 通用数据权限;明细仍按现场范围脱敏。"""
  323. user_info = _current_tenant_user_info(db, ctx, user_info)
  324. page, page_size = max(1, int(page)), max(1, min(100, int(page_size)))
  325. source = (sample_source or '').strip()
  326. if source and source not in SOURCES:
  327. raise ValueError('sampleSource 无效')
  328. q = db.query(BizSampleFreight).filter(
  329. BizSampleFreight.tenant_no == ctx.tenant_no,
  330. BizSampleFreight.del_flag == 0,
  331. )
  332. q = apply_data_scope(q, BizSampleFreight, user_info, db, ctx.tenant_no)
  333. def date_value(raw: Optional[str], *, end: bool = False) -> Optional[datetime]:
  334. text = str(raw or '').strip()
  335. if not text:
  336. return None
  337. try:
  338. parsed = datetime.strptime(text, '%Y-%m-%d')
  339. except ValueError as exc:
  340. raise ValueError('日期格式无效,应为 YYYY-MM-DD') from exc
  341. return parsed + timedelta(days=1) if end else parsed
  342. created_from = date_value(create_time_start)
  343. created_until = date_value(create_time_end, end=True)
  344. if created_from:
  345. q = q.filter(BizSampleFreight.create_time >= created_from)
  346. if created_until:
  347. q = q.filter(BizSampleFreight.create_time < created_until)
  348. if source:
  349. item_exists = exists().where(
  350. and_(
  351. BizSampleFreightItem.freight_id == BizSampleFreight.id,
  352. BizSampleFreightItem.tenant_no == ctx.tenant_no,
  353. BizSampleFreightItem.del_flag == 0,
  354. BizSampleFreightItem.sample_source == source,
  355. )
  356. )
  357. q = q.filter(item_exists)
  358. if transport_mode:
  359. q = q.filter(BizSampleFreight.transport_mode == transport_mode.strip())
  360. text = (keyword or '').strip()
  361. if text:
  362. like = f'%{text}%'
  363. label_values = [
  364. value
  365. for value, in db.query(SysDictData.dict_value)
  366. .filter(
  367. SysDictData.dict_type == DICT_TYPE,
  368. SysDictData.tenant_no == ctx.tenant_no,
  369. SysDictData.del_flag == 0,
  370. SysDictData.dict_label.like(like),
  371. )
  372. .all()
  373. ]
  374. sample_code_exists = exists().where(
  375. and_(
  376. BizSampleFreightItem.freight_id == BizSampleFreight.id,
  377. BizSampleFreightItem.tenant_no == ctx.tenant_no,
  378. BizSampleFreightItem.del_flag == 0,
  379. BizSampleFreightItem.sample_code.like(like),
  380. )
  381. )
  382. conds = [
  383. BizSampleFreight.freight_no.like(like),
  384. BizSampleFreight.transport_mode.like(like),
  385. sample_code_exists,
  386. ]
  387. if label_values:
  388. conds.append(BizSampleFreight.transport_mode.in_(label_values))
  389. q = q.filter(or_(*conds))
  390. total = q.count()
  391. batches = q.order_by(BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc()).offset((page - 1) * page_size).limit(page_size).all()
  392. ids = [row.id for row in batches]
  393. visible_rows = _visible_rows(db, ids, ctx, user_info)
  394. active_counts = {
  395. freight_id: int(count)
  396. for freight_id, count in db.query(
  397. BizSampleFreightItem.freight_id,
  398. func.count(BizSampleFreightItem.id),
  399. ).filter(
  400. BizSampleFreightItem.freight_id.in_(ids or ['']),
  401. BizSampleFreightItem.tenant_no == ctx.tenant_no,
  402. BizSampleFreightItem.del_flag == 0,
  403. ).group_by(BizSampleFreightItem.freight_id).all()
  404. }
  405. by_batch: Dict[str, list] = {}
  406. for row in visible_rows:
  407. by_batch.setdefault(row[0].freight_id, []).append(row)
  408. labels = _label_map(db, ctx.tenant_no, [row.transport_mode for row in batches])
  409. creator_names = _creator_name_map(
  410. db, ctx.tenant_no, [row.create_by for row in batches]
  411. )
  412. result = []
  413. for batch in batches:
  414. rows = by_batch.get(batch.id, [])
  415. data = _batch_dict(
  416. batch,
  417. len(rows),
  418. labels.get(batch.transport_mode or '', ''),
  419. creator_names.get(batch.create_by or '', ''),
  420. )
  421. has_outside_scope_item = active_counts.get(batch.id, 0) > len(rows)
  422. data['allowedActions'] = (
  423. ['link', 'unlink']
  424. if has_outside_scope_item
  425. else ['edit', 'delete', 'link', 'unlink']
  426. )
  427. if include_items:
  428. data['items'] = [_item_dict(row) for row in rows]
  429. result.append(data)
  430. return result, total
  431. def get_freight_detail(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict) -> Dict[str, Any]:
  432. user_info = _current_tenant_user_info(db, ctx, user_info)
  433. obj = _freight_accessible(db, freight_id, ctx=ctx, user_info=user_info)
  434. if not obj:
  435. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  436. rows = _visible_rows(db, [freight_id], ctx, user_info)
  437. labels = _label_map(db, ctx.tenant_no, [obj.transport_mode])
  438. creator_names = _creator_name_map(db, ctx.tenant_no, [obj.create_by])
  439. data = _batch_dict(
  440. obj,
  441. len(rows),
  442. labels.get(obj.transport_mode or '', ''),
  443. creator_names.get(obj.create_by or '', ''),
  444. )
  445. data['items'] = [_item_dict(row) for row in rows]
  446. visible_item_ids = {row[0].id for row in rows}
  447. has_outside_scope_item = (
  448. db.query(BizSampleFreightItem.id)
  449. .filter(
  450. BizSampleFreightItem.freight_id == freight_id,
  451. BizSampleFreightItem.tenant_no == ctx.tenant_no,
  452. BizSampleFreightItem.del_flag == 0,
  453. *([BizSampleFreightItem.id.notin_(visible_item_ids)] if visible_item_ids else []),
  454. )
  455. .first()
  456. is not None
  457. )
  458. data['allowedActions'] = (
  459. ['link', 'unlink']
  460. if has_outside_scope_item or not rows
  461. else ['edit', 'delete', 'link', 'unlink']
  462. )
  463. return data
  464. def _option_query(db: Session, source: str, ctx: MarketFieldContext, user_info: dict, keyword: str):
  465. if source == CELL:
  466. q = db.query(
  467. literal(CELL).label('source'), BizProtocolSample.id.label('sample_id'),
  468. BizProtocolSample.sample_code.label('sample_code'), BizCollectionProtocol.id.label('protocol_id'),
  469. BizCollectionProtocol.protocol_no.label('protocol_no'), BizProtocolPartyCustomer.mother_name.label('customer_name'),
  470. BizProtocolPartyCustomer.newborn_name.label('newborn_name'),
  471. BizCellSampleType.type_name.label('sample_type_name'),
  472. BizProtocolSample.create_time.label('create_time'),
  473. ).join(BizCollectionProtocol, and_(BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id, BizCollectionProtocol.tenant_no == ctx.tenant_no, BizCollectionProtocol.del_flag == 0)).outerjoin(BizProtocolPartyCustomer, and_(BizProtocolPartyCustomer.id == _latest_cell_party_id(ctx), BizProtocolPartyCustomer.tenant_no == ctx.tenant_no, BizProtocolPartyCustomer.del_flag == 0)).outerjoin(BizCellSampleType, and_(BizCellSampleType.id == BizProtocolSample.sample_type_id, BizCellSampleType.tenant_no == ctx.tenant_no, BizCellSampleType.del_flag == 0)).filter(BizProtocolSample.tenant_no == ctx.tenant_no, BizProtocolSample.del_flag == 0)
  474. q = apply_cell_protocol_scope(q, db, ctx, user_info=user_info)
  475. if keyword:
  476. like = f'%{keyword}%'
  477. q = q.filter(or_(BizProtocolSample.sample_code.like(like), BizCollectionProtocol.protocol_no.like(like), BizProtocolPartyCustomer.mother_name.like(like)))
  478. return q.distinct()
  479. q = db.query(
  480. literal(DONATION).label('source'), BizResearchDonationSample.id.label('sample_id'),
  481. BizResearchDonationSample.sample_code.label('sample_code'), BizResearchDonation.id.label('protocol_id'),
  482. BizResearchDonation.donation_no.label('protocol_no'), BizResearchDonationParty.mother_name.label('customer_name'),
  483. BizResearchDonationParty.newborn_name.label('newborn_name'),
  484. BizCellSampleType.type_name.label('sample_type_name'),
  485. BizResearchDonationSample.create_time.label('create_time'),
  486. ).join(BizResearchDonation, and_(BizResearchDonation.id == BizResearchDonationSample.research_donation_id, BizResearchDonation.tenant_no == ctx.tenant_no, BizResearchDonation.del_flag == 0)).outerjoin(BizResearchDonationParty, and_(BizResearchDonationParty.id == _latest_donation_party_id(ctx), BizResearchDonationParty.tenant_no == ctx.tenant_no, BizResearchDonationParty.del_flag == 0)).outerjoin(BizCellSampleType, and_(BizCellSampleType.id == BizResearchDonationSample.sample_type_id, BizCellSampleType.tenant_no == ctx.tenant_no, BizCellSampleType.del_flag == 0)).filter(BizResearchDonationSample.tenant_no == ctx.tenant_no, BizResearchDonationSample.del_flag == 0)
  487. q = apply_donation_scope(q, db, ctx, user_info=user_info)
  488. if keyword:
  489. like = f'%{keyword}%'
  490. q = q.filter(or_(BizResearchDonationSample.sample_code.like(like), BizResearchDonation.donation_no.like(like), BizResearchDonationParty.mother_name.like(like)))
  491. return q.distinct()
  492. def list_sample_options(db: Session, *, ctx: MarketFieldContext, user_info: dict, page: int = 1, page_size: int = 20, keyword: Optional[str] = None, sample_source: Optional[str] = None):
  493. user_info = _current_tenant_user_info(db, ctx, user_info)
  494. page, page_size = max(1, int(page)), max(1, min(100, int(page_size)))
  495. source = (sample_source or '').strip()
  496. if source and source not in SOURCES:
  497. raise ValueError('sampleSource 无效')
  498. sources = (source,) if source else SOURCES
  499. queries = [_option_query(db, src, ctx, user_info, (keyword or '').strip()) for src in sources]
  500. combined = queries[0] if len(queries) == 1 else queries[0].union_all(*queries[1:])
  501. sub = combined.subquery()
  502. total = db.query(func.count()).select_from(sub).scalar() or 0
  503. rows = db.query(sub).order_by(sub.c.create_time.desc(), sub.c.source.desc(), sub.c.sample_id.desc()).offset((page - 1) * page_size).limit(page_size).all()
  504. keys = [(row.source, row.sample_id) for row in rows]
  505. links = {}
  506. if keys:
  507. clauses = [and_(BizSampleFreightItem.sample_source == src, BizSampleFreightItem.sample_id == sid) for src, sid in keys]
  508. for item, freight_no in db.query(
  509. BizSampleFreightItem, BizSampleFreight.freight_no
  510. ).join(
  511. BizSampleFreight,
  512. and_(
  513. BizSampleFreight.id == BizSampleFreightItem.freight_id,
  514. BizSampleFreight.tenant_no == ctx.tenant_no,
  515. BizSampleFreight.del_flag == 0,
  516. ),
  517. ).filter(
  518. BizSampleFreightItem.tenant_no == ctx.tenant_no,
  519. BizSampleFreightItem.del_flag == 0,
  520. or_(*clauses),
  521. ).all():
  522. links[(item.sample_source, item.sample_id)] = (
  523. item.freight_id, freight_no or ''
  524. )
  525. return [{
  526. 'sampleSource': row.source, 'sampleId': row.sample_id,
  527. 'sampleCode': row.sample_code or '', 'protocolId': row.protocol_id,
  528. 'protocolNo': row.protocol_no or '', 'customerName': row.customer_name or '',
  529. 'newbornName': row.newborn_name or '',
  530. 'sampleTypeName': row.sample_type_name or '',
  531. 'createTime': _fmt_dt(row.create_time),
  532. 'freightId': (links.get((row.source, row.sample_id)) or ('', ''))[0],
  533. 'freightNo': (links.get((row.source, row.sample_id)) or ('', ''))[1],
  534. } for row in rows], int(total)
  535. def _sample_parent_lock_query(
  536. db: Session,
  537. source: str,
  538. sample_ids: List[str],
  539. ctx: MarketFieldContext,
  540. ):
  541. ids = sorted(set(sample_ids))
  542. if source == CELL:
  543. query = (
  544. db.query(BizProtocolSample, BizCollectionProtocol)
  545. .join(
  546. BizCollectionProtocol,
  547. and_(
  548. BizCollectionProtocol.id
  549. == BizProtocolSample.collection_protocol_id,
  550. BizCollectionProtocol.tenant_no == ctx.tenant_no,
  551. BizCollectionProtocol.del_flag == 0,
  552. ),
  553. )
  554. .filter(
  555. BizProtocolSample.id.in_(ids),
  556. BizProtocolSample.tenant_no == ctx.tenant_no,
  557. BizProtocolSample.del_flag == 0,
  558. )
  559. )
  560. return query.order_by(
  561. BizProtocolSample.id.asc(), BizCollectionProtocol.id.asc()
  562. ).populate_existing().with_for_update()
  563. query = (
  564. db.query(BizResearchDonationSample, BizResearchDonation)
  565. .join(
  566. BizResearchDonation,
  567. and_(
  568. BizResearchDonation.id
  569. == BizResearchDonationSample.research_donation_id,
  570. BizResearchDonation.tenant_no == ctx.tenant_no,
  571. BizResearchDonation.del_flag == 0,
  572. ),
  573. )
  574. .filter(
  575. BizResearchDonationSample.id.in_(ids),
  576. BizResearchDonationSample.tenant_no == ctx.tenant_no,
  577. BizResearchDonationSample.del_flag == 0,
  578. )
  579. )
  580. return query.order_by(
  581. BizResearchDonationSample.id.asc(), BizResearchDonation.id.asc()
  582. ).populate_existing().with_for_update()
  583. def _collection_lock_query(
  584. db: Session,
  585. source: str,
  586. parent_ids: List[str],
  587. ctx: MarketFieldContext,
  588. ):
  589. ids = sorted(set(parent_ids))
  590. if source == CELL:
  591. return (
  592. db.query(BizProtocolCollectionInfo)
  593. .filter(
  594. BizProtocolCollectionInfo.collection_protocol_id.in_(ids),
  595. BizProtocolCollectionInfo.tenant_no == ctx.tenant_no,
  596. BizProtocolCollectionInfo.del_flag == 0,
  597. )
  598. .order_by(
  599. BizProtocolCollectionInfo.collection_protocol_id.asc(),
  600. BizProtocolCollectionInfo.id.asc(),
  601. )
  602. .populate_existing()
  603. .with_for_update()
  604. )
  605. return (
  606. db.query(BizResearchDonationCollection)
  607. .filter(
  608. BizResearchDonationCollection.research_donation_id.in_(ids),
  609. BizResearchDonationCollection.tenant_no == ctx.tenant_no,
  610. BizResearchDonationCollection.del_flag == 0,
  611. )
  612. .order_by(
  613. BizResearchDonationCollection.research_donation_id.asc(),
  614. BizResearchDonationCollection.id.asc(),
  615. )
  616. .populate_existing()
  617. .with_for_update()
  618. )
  619. def _leader_lock_query(db: Session, ctx: MarketFieldContext):
  620. """锁定经理当前有效的地区负责人关系,供字段范围直接计算。"""
  621. return (
  622. db.query(BizCollectionRegionStaff)
  623. .filter(
  624. BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
  625. BizCollectionRegionStaff.del_flag == 0,
  626. BizCollectionRegionStaff.user_id == ctx.user_id,
  627. BizCollectionRegionStaff.is_leader == 1,
  628. )
  629. .order_by(
  630. BizCollectionRegionStaff.region_id.asc(),
  631. BizCollectionRegionStaff.id.asc(),
  632. )
  633. .populate_existing()
  634. .with_for_update()
  635. )
  636. def _current_field_visible_parent_ids(
  637. source: str,
  638. collection_rows: List[Any],
  639. leader_rows: List[BizCollectionRegionStaff],
  640. ctx: MarketFieldContext,
  641. ) -> set[str]:
  642. """只使用已加锁的采集分配/负责人当前值计算现场字段范围。"""
  643. parent_attr = (
  644. 'collection_protocol_id'
  645. if source == CELL
  646. else 'research_donation_id'
  647. )
  648. leader_regions = {row.region_id for row in leader_rows}
  649. visible: set[str] = set()
  650. for row in collection_rows:
  651. if ctx.scope_kind == 'specialist':
  652. allowed = row.sales_user_id == ctx.user_id
  653. else:
  654. allowed = (
  655. row.region_id in leader_regions
  656. or row.region_manager_user_id == ctx.user_id
  657. )
  658. if allowed:
  659. visible.add(getattr(row, parent_attr))
  660. return visible
  661. def _current_data_scope_parent_ids(
  662. db: Session,
  663. parents: List[Any],
  664. ctx: MarketFieldContext,
  665. user_info: dict,
  666. ) -> set[str]:
  667. """锁定数据权限依赖并按锁后当前值计算多角色并集范围。"""
  668. if not isinstance(user_info, dict) or str(
  669. user_info.get('user_id') or ''
  670. ).strip() != ctx.user_id:
  671. raise PermissionError('用户身份与现场范围上下文不一致')
  672. parent_ids = {parent.id for parent in parents}
  673. current_users = (
  674. db.query(SysUser)
  675. .filter(
  676. SysUser.id == ctx.user_id,
  677. SysUser.tenant_no == ctx.tenant_no,
  678. SysUser.del_flag == 0,
  679. )
  680. .order_by(SysUser.id.asc())
  681. .populate_existing()
  682. .with_for_update()
  683. .all()
  684. )
  685. if not current_users:
  686. return set()
  687. current_user = current_users[0]
  688. direct_links = (
  689. db.query(SysUserRole)
  690. .filter(
  691. SysUserRole.user_id == ctx.user_id,
  692. SysUserRole.tenant_no == ctx.tenant_no,
  693. SysUserRole.del_flag == 0,
  694. )
  695. .order_by(SysUserRole.role_id.asc(), SysUserRole.id.asc())
  696. .populate_existing()
  697. .with_for_update()
  698. .all()
  699. )
  700. user_posts = (
  701. db.query(SysUserPost)
  702. .filter(
  703. SysUserPost.user_id == ctx.user_id,
  704. SysUserPost.tenant_no == ctx.tenant_no,
  705. SysUserPost.del_flag == 0,
  706. )
  707. .order_by(SysUserPost.post_id.asc(), SysUserPost.id.asc())
  708. .populate_existing()
  709. .with_for_update()
  710. .all()
  711. )
  712. post_ids = sorted({row.post_id for row in user_posts})
  713. posts = []
  714. if post_ids:
  715. posts = (
  716. db.query(SysPost)
  717. .filter(
  718. SysPost.id.in_(post_ids),
  719. SysPost.tenant_no == ctx.tenant_no,
  720. SysPost.del_flag == 0,
  721. SysPost.status == '1',
  722. )
  723. .order_by(SysPost.id.asc())
  724. .populate_existing()
  725. .with_for_update()
  726. .all()
  727. )
  728. enabled_post_ids = sorted({row.id for row in posts})
  729. post_role_links = []
  730. if enabled_post_ids:
  731. post_role_links = (
  732. db.query(SysPostRole)
  733. .filter(
  734. SysPostRole.post_id.in_(enabled_post_ids),
  735. SysPostRole.tenant_no == ctx.tenant_no,
  736. SysPostRole.del_flag == 0,
  737. )
  738. .order_by(
  739. SysPostRole.post_id.asc(),
  740. SysPostRole.role_id.asc(),
  741. SysPostRole.id.asc(),
  742. )
  743. .populate_existing()
  744. .with_for_update()
  745. .all()
  746. )
  747. role_ids = sorted(
  748. {row.role_id for row in direct_links}
  749. | {row.role_id for row in post_role_links}
  750. )
  751. roles = []
  752. if role_ids:
  753. roles = (
  754. db.query(SysRole)
  755. .filter(
  756. SysRole.id.in_(role_ids),
  757. SysRole.tenant_no == ctx.tenant_no,
  758. SysRole.del_flag == 0,
  759. SysRole.status == '1',
  760. )
  761. .order_by(SysRole.id.asc())
  762. .populate_existing()
  763. .with_for_update()
  764. .all()
  765. )
  766. if not roles:
  767. return set()
  768. if any(
  769. _map_role_name_to_web(role.role_name) == 'super_admin'
  770. for role in roles
  771. ):
  772. return parent_ids
  773. if any((role.data_scope or '1') == '1' for role in roles):
  774. return parent_ids
  775. active_role_ids = sorted({role.id for role in roles})
  776. details = (
  777. db.query(SysRoleDataScopeDetail)
  778. .filter(
  779. SysRoleDataScopeDetail.role_id.in_(active_role_ids),
  780. SysRoleDataScopeDetail.tenant_no == ctx.tenant_no,
  781. SysRoleDataScopeDetail.del_flag == 0,
  782. )
  783. .order_by(
  784. SysRoleDataScopeDetail.role_id.asc(),
  785. SysRoleDataScopeDetail.scope_type.asc(),
  786. SysRoleDataScopeDetail.scope_value.asc(),
  787. SysRoleDataScopeDetail.id.asc(),
  788. )
  789. .populate_existing()
  790. .with_for_update()
  791. .all()
  792. )
  793. details_by_role: Dict[str, List[SysRoleDataScopeDetail]] = {}
  794. for detail in details:
  795. details_by_role.setdefault(detail.role_id, []).append(detail)
  796. dept_ids: set[str] = set()
  797. user_ids: set[str] = set()
  798. current_dept_id = (current_user.dept_id or '').strip()
  799. needs_descendants = any(
  800. (role.data_scope or '1') == '3' for role in roles
  801. )
  802. dept_rows = []
  803. if needs_descendants and current_dept_id:
  804. dept_rows = (
  805. db.query(SysDept)
  806. .filter(
  807. SysDept.tenant_no == ctx.tenant_no,
  808. SysDept.del_flag == 0,
  809. )
  810. .order_by(SysDept.id.asc())
  811. .populate_existing()
  812. .with_for_update()
  813. .all()
  814. )
  815. descendants = {current_dept_id} if current_dept_id else set()
  816. if dept_rows and current_dept_id:
  817. pending = [current_dept_id]
  818. while pending:
  819. parent_id = pending.pop(0)
  820. for dept in dept_rows:
  821. if dept.parent_id == parent_id and dept.id not in descendants:
  822. descendants.add(dept.id)
  823. pending.append(dept.id)
  824. for role in roles:
  825. scope = role.data_scope or '1'
  826. if scope == '2' and current_dept_id:
  827. dept_ids.add(current_dept_id)
  828. elif scope == '3':
  829. dept_ids.update(descendants)
  830. elif scope == '4':
  831. user_ids.add(ctx.user_id)
  832. elif scope == '5':
  833. for detail in details_by_role.get(role.id, []):
  834. value = (detail.scope_value or '').strip()
  835. if not value:
  836. continue
  837. if detail.scope_type == 'dept':
  838. dept_ids.add(value)
  839. elif detail.scope_type == 'user':
  840. user_ids.add(value)
  841. return {
  842. parent.id
  843. for parent in parents
  844. if (
  845. (parent.create_dept_id in dept_ids if dept_ids else False)
  846. or (parent.create_by in user_ids if user_ids else False)
  847. )
  848. }
  849. def _active_item_lock_query(
  850. db: Session,
  851. ctx: MarketFieldContext,
  852. *,
  853. freight_id: Optional[str] = None,
  854. keys: Optional[List[Tuple[str, str]]] = None,
  855. ):
  856. conditions = []
  857. if freight_id:
  858. conditions.append(BizSampleFreightItem.freight_id == freight_id)
  859. for source, sample_id in sorted(set(keys or [])):
  860. conditions.append(
  861. and_(
  862. BizSampleFreightItem.sample_source == source,
  863. BizSampleFreightItem.sample_id == sample_id,
  864. )
  865. )
  866. query = db.query(BizSampleFreightItem).filter(
  867. BizSampleFreightItem.tenant_no == ctx.tenant_no,
  868. BizSampleFreightItem.del_flag == 0,
  869. )
  870. if conditions:
  871. query = query.filter(or_(*conditions))
  872. else:
  873. query = query.filter(False)
  874. return query.order_by(
  875. BizSampleFreightItem.sample_source.asc(),
  876. BizSampleFreightItem.sample_id.asc(),
  877. BizSampleFreightItem.id.asc(),
  878. ).populate_existing().with_for_update()
  879. def _lock_batch_header(
  880. db: Session, freight_id: str, ctx: MarketFieldContext
  881. ) -> BizSampleFreight:
  882. obj = (
  883. db.query(BizSampleFreight)
  884. .filter(
  885. BizSampleFreight.id == freight_id,
  886. BizSampleFreight.tenant_no == ctx.tenant_no,
  887. BizSampleFreight.del_flag == 0,
  888. )
  889. .populate_existing()
  890. .with_for_update()
  891. .first()
  892. )
  893. if not obj:
  894. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  895. return obj
  896. def _discover_batch_keys(
  897. db: Session, freight_id: str, ctx: MarketFieldContext
  898. ) -> List[Tuple[str, str]]:
  899. """批次头已锁后发现参与者;最终关联锁会再次验证该快照。"""
  900. rows = (
  901. db.query(
  902. BizSampleFreightItem.sample_source,
  903. BizSampleFreightItem.sample_id,
  904. )
  905. .filter(
  906. BizSampleFreightItem.freight_id == freight_id,
  907. BizSampleFreightItem.tenant_no == ctx.tenant_no,
  908. BizSampleFreightItem.del_flag == 0,
  909. )
  910. .order_by(
  911. BizSampleFreightItem.sample_source.asc(),
  912. BizSampleFreightItem.sample_id.asc(),
  913. BizSampleFreightItem.id.asc(),
  914. )
  915. .all()
  916. )
  917. return [(source, sample_id) for source, sample_id in rows]
  918. def _lock_sample_graph(
  919. db: Session,
  920. keys: List[Tuple[str, str]],
  921. ctx: MarketFieldContext,
  922. user_info: dict,
  923. ) -> Dict[Tuple[str, str], Tuple[Any, Any]]:
  924. """按来源和 ID 锁样本/父单/采集分配,再用锁定当前读重算范围。"""
  925. normalized = sorted(set(keys))
  926. locked_rows: Dict[str, List[Tuple[Any, Any]]] = {}
  927. locked_collections: Dict[str, List[Any]] = {}
  928. for source in SOURCES:
  929. ids = [sample_id for item_source, sample_id in normalized if item_source == source]
  930. if not ids:
  931. continue
  932. rows = _sample_parent_lock_query(db, source, ids, ctx).all()
  933. locked_rows[source] = rows
  934. parent_ids = [parent.id for _, parent in rows]
  935. locked_collections[source] = _collection_lock_query(
  936. db, source, parent_ids, ctx
  937. ).all()
  938. leader_rows = (
  939. _leader_lock_query(db, ctx).all()
  940. if ctx.scope_kind == 'manager'
  941. else []
  942. )
  943. all_parents = [
  944. parent
  945. for rows in locked_rows.values()
  946. for _, parent in rows
  947. ]
  948. data_scope_ids = _current_data_scope_parent_ids(
  949. db, all_parents, ctx, user_info
  950. )
  951. visible: Dict[Tuple[str, str], Tuple[Any, Any]] = {}
  952. for source, rows in locked_rows.items():
  953. field_scope_ids = _current_field_visible_parent_ids(
  954. source, locked_collections[source], leader_rows, ctx
  955. )
  956. for sample, parent in rows:
  957. if parent.id in field_scope_ids and parent.id in data_scope_ids:
  958. visible[(source, sample.id)] = (sample, parent)
  959. return visible
  960. def _lock_active_items(
  961. db: Session,
  962. ctx: MarketFieldContext,
  963. *,
  964. freight_id: Optional[str] = None,
  965. keys: Optional[List[Tuple[str, str]]] = None,
  966. expected_batch_keys: Optional[List[Tuple[str, str]]] = None,
  967. ) -> List[BizSampleFreightItem]:
  968. rows = _active_item_lock_query(
  969. db, ctx, freight_id=freight_id, keys=keys
  970. ).all()
  971. if freight_id is not None and expected_batch_keys is not None:
  972. current = sorted(
  973. (row.sample_source, row.sample_id)
  974. for row in rows
  975. if row.freight_id == freight_id
  976. )
  977. if current != sorted(expected_batch_keys):
  978. raise PermissionError('批次明细已发生变化,请重试')
  979. return rows
  980. def _next_freight_no(db: Session, tenant_no: str, now: datetime, offset: int = 0) -> str:
  981. prefix = f"YF{now.strftime('%Y%m%d')}"
  982. latest = db.query(BizSampleFreight.freight_no).filter(BizSampleFreight.tenant_no == tenant_no, BizSampleFreight.freight_no.like(f'{prefix}%')).order_by(BizSampleFreight.freight_no.desc()).with_for_update().first()
  983. seq = int(str(latest[0])[-3:]) + 1 if latest and str(latest[0])[-3:].isdigit() else 1
  984. seq += offset
  985. if seq > 999:
  986. raise ValueError('当日运费批次号已达上限(999)')
  987. return f'{prefix}{seq:03d}'
  988. def create_freight(db: Session, *, ctx: MarketFieldContext, user_info: dict, transport_mode: str, freight_amount: Any, items: List[Dict[str, Any]], waybill_no: Any = None):
  989. keys = _normalize_items(items)
  990. try:
  991. samples = _lock_sample_graph(db, keys, ctx, user_info)
  992. if len(samples) != len(set(keys)):
  993. raise HTTPException(status_code=404, detail='样本不存在或无权操作')
  994. conflicts = _lock_active_items(db, ctx, keys=keys)
  995. if conflicts:
  996. raise ValueError('样本已关联其他运费批次')
  997. mode = resolve_transport_mode_value(db, tenant_no=ctx.tenant_no, raw=transport_mode, user_id=ctx.user_id)
  998. plan = resolve_header_for_save(
  999. transport_mode=mode,
  1000. existing_waybill=None,
  1001. existing_amount=None,
  1002. existing_settle_status=None,
  1003. waybill_supplied=True,
  1004. waybill_no=waybill_no,
  1005. amount_supplied=True,
  1006. freight_amount=freight_amount,
  1007. )
  1008. now = datetime.now()
  1009. obj = None
  1010. for attempt in range(3):
  1011. candidate = BizSampleFreight(id=generate_uuid(), freight_no=_next_freight_no(db, ctx.tenant_no, now, attempt), transport_mode=plan.transport_mode, freight_amount=plan.freight_amount, waybill_no=plan.waybill_no, settle_status=plan.settle_status, settle_time=None, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0)
  1012. try:
  1013. with db.begin_nested():
  1014. db.add(candidate)
  1015. db.flush()
  1016. obj = candidate
  1017. break
  1018. except IntegrityError:
  1019. if attempt == 2:
  1020. raise ValueError('运费批次号生成冲突,请重试')
  1021. assert obj is not None
  1022. for source, sample_id in keys:
  1023. sample, parent = samples[(source, sample_id)]
  1024. db.add(BizSampleFreightItem(id=generate_uuid(), freight_id=obj.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0))
  1025. db.flush()
  1026. db.commit()
  1027. return get_freight_detail(db, obj.id, ctx=ctx, user_info=user_info)
  1028. except IntegrityError as exc:
  1029. db.rollback()
  1030. raise ValueError('样本已关联运费批次,不允许移动') from exc
  1031. except Exception:
  1032. db.rollback()
  1033. raise
  1034. def update_freight(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, transport_mode: Optional[str] = None, freight_amount: Any = None, waybill_no: Any = None, waybill_supplied: bool = False, amount_supplied: bool = False):
  1035. try:
  1036. obj = _lock_batch_header(db, freight_id, ctx)
  1037. expected_keys = _discover_batch_keys(db, freight_id, ctx)
  1038. visible = _lock_sample_graph(db, expected_keys, ctx, user_info)
  1039. active = _lock_active_items(
  1040. db,
  1041. ctx,
  1042. freight_id=freight_id,
  1043. expected_batch_keys=expected_keys,
  1044. )
  1045. if not active or not visible:
  1046. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  1047. if any(
  1048. (row.sample_source, row.sample_id) not in visible for row in active
  1049. ):
  1050. raise PermissionError('仅当批次全部有效样本均在当前范围内时可修改批次信息')
  1051. mode = obj.transport_mode or ''
  1052. if transport_mode is not None and str(transport_mode).strip() != '':
  1053. mode = resolve_transport_mode_value(db, tenant_no=ctx.tenant_no, raw=str(transport_mode), user_id=ctx.user_id)
  1054. plan = resolve_header_for_save(
  1055. transport_mode=mode,
  1056. existing_waybill=obj.waybill_no,
  1057. existing_amount=obj.freight_amount,
  1058. existing_settle_status=obj.settle_status,
  1059. waybill_supplied=waybill_supplied,
  1060. waybill_no=waybill_no,
  1061. amount_supplied=amount_supplied,
  1062. freight_amount=freight_amount,
  1063. )
  1064. obj.transport_mode = plan.transport_mode
  1065. obj.waybill_no = plan.waybill_no
  1066. obj.freight_amount = plan.freight_amount
  1067. obj.settle_status = plan.settle_status
  1068. if plan.clear_settle_time:
  1069. obj.settle_time = None
  1070. obj.update_by, obj.update_time = ctx.user_id, datetime.now()
  1071. db.flush()
  1072. db.commit()
  1073. return get_freight_detail(db, freight_id, ctx=ctx, user_info=user_info)
  1074. except Exception:
  1075. db.rollback()
  1076. raise
  1077. def assert_link_target_access(
  1078. db: Session,
  1079. freight_id: str,
  1080. *,
  1081. ctx: MarketFieldContext,
  1082. user_info: dict,
  1083. ) -> None:
  1084. """确认目标批次在 Web 同款数据权限内可关联(不要求已有现场可见明细)。"""
  1085. user_info = _current_tenant_user_info(db, ctx, user_info)
  1086. if not _freight_accessible(db, freight_id, ctx=ctx, user_info=user_info):
  1087. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  1088. def link_samples(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, items: List[Dict[str, Any]]):
  1089. keys = _normalize_items(items)
  1090. try:
  1091. user_info = _current_tenant_user_info(db, ctx, user_info)
  1092. if not _freight_accessible(db, freight_id, ctx=ctx, user_info=user_info):
  1093. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  1094. obj = _lock_batch_header(db, freight_id, ctx)
  1095. assert_freight_mutable(obj.settle_status, 'items')
  1096. expected_keys = _discover_batch_keys(db, freight_id, ctx)
  1097. samples = _lock_sample_graph(
  1098. db, sorted(set(expected_keys + keys)), ctx, user_info
  1099. )
  1100. locked_items = _lock_active_items(
  1101. db,
  1102. ctx,
  1103. freight_id=freight_id,
  1104. keys=keys,
  1105. expected_batch_keys=expected_keys,
  1106. )
  1107. if any(key not in samples for key in keys):
  1108. raise HTTPException(status_code=404, detail='样本不存在或无权操作')
  1109. requested = set(keys)
  1110. conflicts = [
  1111. row
  1112. for row in locked_items
  1113. if (row.sample_source, row.sample_id) in requested
  1114. ]
  1115. if conflicts:
  1116. raise ValueError('样本已关联运费批次,不允许移动')
  1117. now = datetime.now()
  1118. for source, sample_id in keys:
  1119. sample, parent = samples[(source, sample_id)]
  1120. db.add(BizSampleFreightItem(id=generate_uuid(), freight_id=obj.id, sample_source=source, sample_id=sample.id, sample_code=sample.sample_code, protocol_id=parent.id, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0))
  1121. db.flush()
  1122. db.commit()
  1123. return get_freight_detail(db, freight_id, ctx=ctx, user_info=user_info)
  1124. except IntegrityError as exc:
  1125. db.rollback()
  1126. raise ValueError('样本已关联运费批次,不允许移动') from exc
  1127. except Exception:
  1128. db.rollback()
  1129. raise
  1130. def unlink_samples(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, items: List[Dict[str, Any]]):
  1131. keys = _normalize_items(items)
  1132. try:
  1133. obj = _lock_batch_header(db, freight_id, ctx)
  1134. assert_freight_mutable(obj.settle_status, 'items')
  1135. expected_keys = _discover_batch_keys(db, freight_id, ctx)
  1136. visible = _lock_sample_graph(db, expected_keys, ctx, user_info)
  1137. active = _lock_active_items(
  1138. db,
  1139. ctx,
  1140. freight_id=freight_id,
  1141. expected_batch_keys=expected_keys,
  1142. )
  1143. if not any(
  1144. (row.sample_source, row.sample_id) in visible for row in active
  1145. ):
  1146. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  1147. requested = set(keys)
  1148. matched = [
  1149. row
  1150. for row in active
  1151. if (row.sample_source, row.sample_id) in visible
  1152. and (row.sample_source, row.sample_id) in requested
  1153. ]
  1154. if len(matched) != len(keys):
  1155. raise ValueError('关联明细不存在或无权操作')
  1156. now = datetime.now()
  1157. for row in matched:
  1158. row.del_flag, row.update_by, row.update_time = 1, ctx.user_id, now
  1159. db.flush()
  1160. matched_ids = {row.id for row in matched}
  1161. remaining = [row for row in active if row.id not in matched_ids]
  1162. remaining_visible = [
  1163. row
  1164. for row in remaining
  1165. if (row.sample_source, row.sample_id) in visible
  1166. ]
  1167. if not remaining_visible:
  1168. if not remaining:
  1169. obj.del_flag, obj.update_by, obj.update_time = 1, ctx.user_id, now
  1170. db.commit()
  1171. return {'id': freight_id, 'visibleItemCount': 0, 'disappeared': True}
  1172. db.commit()
  1173. return get_freight_detail(db, freight_id, ctx=ctx, user_info=user_info)
  1174. except Exception:
  1175. db.rollback()
  1176. raise
  1177. def delete_freight(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict):
  1178. """级联软删运费单;仅当全部有效关联样本均在当前范围内时允许。"""
  1179. try:
  1180. obj = _lock_batch_header(db, freight_id, ctx)
  1181. assert_freight_mutable(obj.settle_status, 'delete')
  1182. expected_keys = _discover_batch_keys(db, freight_id, ctx)
  1183. visible = _lock_sample_graph(db, expected_keys, ctx, user_info)
  1184. active = _lock_active_items(
  1185. db,
  1186. ctx,
  1187. freight_id=freight_id,
  1188. expected_batch_keys=expected_keys,
  1189. )
  1190. if not active or not visible:
  1191. raise HTTPException(status_code=404, detail='数据不存在或无权访问')
  1192. if any(
  1193. (row.sample_source, row.sample_id) not in visible for row in active
  1194. ):
  1195. raise PermissionError('仅当批次全部有效样本均在当前范围内时可删除运费单')
  1196. now = datetime.now()
  1197. for row in active:
  1198. row.del_flag, row.update_by, row.update_time = 1, ctx.user_id, now
  1199. obj.del_flag, obj.update_by, obj.update_time = 1, ctx.user_id, now
  1200. db.flush()
  1201. db.commit()
  1202. return {'id': freight_id, 'visibleItemCount': 0, 'disappeared': True}
  1203. except Exception:
  1204. db.rollback()
  1205. raise