pl_site_plan_service.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. """运输计划 · 现场按段页(history / page / feedback / upload)共享逻辑"""
  2. from __future__ import annotations
  3. import os
  4. from datetime import date, datetime
  5. from decimal import Decimal
  6. from typing import Any, Optional, Tuple
  7. from fastapi import UploadFile
  8. from sqlalchemy import and_, func, or_
  9. from sqlalchemy.orm import Session
  10. from app.core.attachment_biz import resolve_upload_folder
  11. from app.core.config import settings
  12. from app.core.storage import storage
  13. from app.crud import oss as oss_svc
  14. from app.models.pl_models import (
  15. PlPlan,
  16. PlPlanDelivery,
  17. PlPlanLoad,
  18. PlPlanProduct,
  19. PlPlanTransport,
  20. PlTransport,
  21. )
  22. from app.schemas.pl.transport import (
  23. PublicTransportHistoryPageRead,
  24. PublicTransportHistoryProductRead,
  25. PublicTransportHistoryRecordRead,
  26. PublicTransportHistoryStopProductRead,
  27. PublicTransportHistoryStopRead,
  28. PublicTransportMultiStopSitePageRead,
  29. )
  30. from app.services.pl_product_display import (
  31. load_crm_product_alias_maps,
  32. resolve_product_display_names,
  33. )
  34. from app.services.pl_public_transport_helpers import (
  35. CRM_PRODUCT_UNIT,
  36. _accumulate_calendar_day_counts,
  37. _history_status_label,
  38. _normalize_main_plate,
  39. _parse_calendar_month,
  40. _parse_history_date,
  41. _resolve_driver_link_flags,
  42. _resolve_unit_label,
  43. )
  44. from app.services.pl_transport_stop_feedback_service import (
  45. build_public_stop_reads_for_segments,
  46. build_transport_site_url,
  47. load_site_stop_segments,
  48. save_site_stop_feedback,
  49. )
  50. from app.utils.excel_export import load_dict_label_maps
  51. def plan_matches_history_day(plan: PlPlan, day: date) -> bool:
  52. """Mirrors SQL day filters in build_site_history_page for unit tests."""
  53. if plan.plan_date == day:
  54. return True
  55. if plan.pickup_time is not None:
  56. pt = plan.pickup_time
  57. if (pt.date() if isinstance(pt, datetime) else pt) == day:
  58. return True
  59. if plan.delivery_time is not None:
  60. dt = plan.delivery_time
  61. if (dt.date() if isinstance(dt, datetime) else dt) == day:
  62. return True
  63. return False
  64. def resolve_transport_for_plan(
  65. db: Session, *, tenant_no: str, plan_id: str
  66. ) -> Tuple[Optional[PlPlan], Optional[PlTransport]]:
  67. """本租户计划 + 关联运输(须有有效 site_access_token)。找不到返回 (None, None)。"""
  68. plan = (
  69. db.query(PlPlan)
  70. .filter(
  71. PlPlan.id == plan_id,
  72. PlPlan.tenant_no == tenant_no,
  73. PlPlan.del_flag == 0,
  74. )
  75. .first()
  76. )
  77. if not plan:
  78. return None, None
  79. link = (
  80. db.query(PlPlanTransport)
  81. .filter(
  82. PlPlanTransport.plan_id == plan.id,
  83. PlPlanTransport.del_flag == 0,
  84. )
  85. .order_by(PlPlanTransport.create_time.desc())
  86. .first()
  87. )
  88. if not link or not link.transport_id:
  89. return plan, None
  90. transport = (
  91. db.query(PlTransport)
  92. .filter(
  93. PlTransport.id == link.transport_id,
  94. PlTransport.tenant_no == tenant_no,
  95. PlTransport.del_flag == 0,
  96. PlTransport.site_access_token.isnot(None),
  97. PlTransport.site_access_token != "",
  98. )
  99. .first()
  100. )
  101. return plan, transport
  102. def _format_plan_date(value) -> Optional[str]:
  103. if value is None:
  104. return None
  105. if isinstance(value, datetime):
  106. return value.strftime("%Y-%m-%d")
  107. if isinstance(value, date):
  108. return value.isoformat()
  109. return str(value)
  110. def _format_file_size(size_bytes: int) -> str:
  111. if size_bytes < 1024:
  112. return f"{size_bytes} B"
  113. if size_bytes < 1024 * 1024:
  114. return f"{size_bytes / 1024:.1f} KB"
  115. return f"{size_bytes / (1024 * 1024):.1f} MB"
  116. def build_site_page_for_transport(
  117. db: Session, transport: PlTransport
  118. ) -> PublicTransportMultiStopSitePageRead:
  119. link = (
  120. db.query(PlPlanTransport)
  121. .filter(
  122. PlPlanTransport.transport_id == transport.id,
  123. PlPlanTransport.del_flag == 0,
  124. )
  125. .order_by(PlPlanTransport.create_time.desc())
  126. .first()
  127. )
  128. plan = None
  129. if link:
  130. plan = (
  131. db.query(PlPlan)
  132. .filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0)
  133. .first()
  134. )
  135. plan_voided, plan_archived = _resolve_driver_link_flags(plan, transport)
  136. stop_reads: list[dict] = []
  137. if plan:
  138. segments = load_site_stop_segments(
  139. db,
  140. tenant_no=transport.tenant_no,
  141. plan_id=plan.id,
  142. transport_id=transport.id,
  143. )
  144. stop_reads = build_public_stop_reads_for_segments(
  145. db,
  146. tenant_no=transport.tenant_no,
  147. plan_id=plan.id,
  148. segments=segments,
  149. )
  150. return PublicTransportMultiStopSitePageRead(
  151. plan_no=plan.plan_no if plan else transport.transport_no,
  152. plan_date=_format_plan_date(plan.plan_date if plan else None),
  153. license_plate=transport.license_plate,
  154. vehicle_no=transport.license_plate,
  155. plan_voided=plan_voided,
  156. plan_archived=plan_archived,
  157. stops=stop_reads,
  158. )
  159. def build_site_page_for_plan(
  160. db: Session, *, tenant_no: str, plan_id: str
  161. ) -> tuple[Optional[PublicTransportMultiStopSitePageRead], Optional[str]]:
  162. plan, transport = resolve_transport_for_plan(db, tenant_no=tenant_no, plan_id=plan_id)
  163. if not plan:
  164. return None, "计划不存在"
  165. if not transport:
  166. return None, "未找到关联运输或现场链接无效"
  167. return build_site_page_for_transport(db, transport), None
  168. def _build_site_history_stop_reads(
  169. db: Session,
  170. *,
  171. tenant_no: str,
  172. plan: PlPlan,
  173. transport: PlTransport,
  174. ) -> list[PublicTransportHistoryStopRead]:
  175. plan_products = (
  176. db.query(PlPlanProduct)
  177. .filter(
  178. PlPlanProduct.plan_id == plan.id,
  179. PlPlanProduct.tenant_no == tenant_no,
  180. PlPlanProduct.del_flag == 0,
  181. )
  182. .all()
  183. )
  184. plan_products_by_id = {row.id: row for row in plan_products}
  185. crm_product_ids = {
  186. (row.product_id or "").strip() for row in plan_products if (row.product_id or "").strip()
  187. }
  188. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  189. db, tenant_no=tenant_no, product_ids=crm_product_ids
  190. )
  191. unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get(
  192. CRM_PRODUCT_UNIT, {}
  193. )
  194. load_row = (
  195. db.query(PlPlanLoad)
  196. .filter(
  197. PlPlanLoad.plan_id == plan.id,
  198. PlPlanLoad.tenant_no == tenant_no,
  199. PlPlanLoad.del_flag == 0,
  200. )
  201. .first()
  202. )
  203. delivery_rows = (
  204. db.query(PlPlanDelivery)
  205. .filter(
  206. PlPlanDelivery.plan_id == plan.id,
  207. PlPlanDelivery.tenant_no == tenant_no,
  208. PlPlanDelivery.del_flag == 0,
  209. )
  210. .all()
  211. )
  212. delivery_time_by_id = {row.id: row.delivery_time for row in delivery_rows}
  213. load_time = load_row.load_time if load_row else None
  214. segments = load_site_stop_segments(
  215. db,
  216. tenant_no=tenant_no,
  217. plan_id=plan.id,
  218. transport_id=transport.id,
  219. )
  220. if not segments:
  221. return []
  222. reads: list[PublicTransportHistoryStopRead] = []
  223. for segment in segments:
  224. stop_time = load_time if segment.stop_type == "load" else delivery_time_by_id.get(segment.stop_id)
  225. stop_products: list[PublicTransportHistoryStopProductRead] = []
  226. for row in segment.products:
  227. plan_prod = plan_products_by_id.get(row.plan_product_id)
  228. product_id = (row.product_id or (plan_prod.product_id if plan_prod else "") or "").strip()
  229. display_name, _alias_name = resolve_product_display_names(
  230. saved_product_name=row.product_name,
  231. product_id=product_id,
  232. standard_by_id=standard_by_id,
  233. alias_options_by_id=alias_options_by_id,
  234. )
  235. stop_products.append(
  236. PublicTransportHistoryStopProductRead(
  237. product_name=display_name or row.product_name or "—",
  238. model=row.model,
  239. package_spec=row.package_spec,
  240. quantity=row.plan_qty,
  241. unit=_resolve_unit_label(unit_map, row.unit),
  242. )
  243. )
  244. reads.append(
  245. PublicTransportHistoryStopRead(
  246. stop_type=segment.stop_type,
  247. seq=segment.seq,
  248. label=segment.label,
  249. customer_name=segment.customer_name,
  250. address=segment.address,
  251. stop_time=stop_time,
  252. products=stop_products,
  253. )
  254. )
  255. return reads
  256. def build_site_history_page(
  257. db: Session,
  258. *,
  259. tenant_no: str,
  260. date_str: str,
  261. keyword: Optional[str] = None,
  262. license_plate_hint: Optional[str] = None,
  263. ) -> tuple[Optional[PublicTransportHistoryPageRead], Optional[str]]:
  264. day = _parse_history_date(date_str)
  265. if day is None:
  266. return None, "日期格式无效,请使用 YYYY-MM-DD"
  267. kw = (keyword or "").strip()
  268. query = (
  269. db.query(PlPlan, PlTransport)
  270. .join(
  271. PlPlanTransport,
  272. and_(
  273. PlPlanTransport.plan_id == PlPlan.id,
  274. PlPlanTransport.del_flag == 0,
  275. ),
  276. )
  277. .join(
  278. PlTransport,
  279. and_(
  280. PlTransport.id == PlPlanTransport.transport_id,
  281. PlTransport.del_flag == 0,
  282. ),
  283. )
  284. .filter(
  285. PlPlan.tenant_no == tenant_no,
  286. PlPlan.del_flag == 0,
  287. PlPlan.transport_status == "arranged",
  288. PlTransport.tenant_no == tenant_no,
  289. PlTransport.site_access_token.isnot(None),
  290. PlTransport.site_access_token != "",
  291. or_(
  292. PlPlan.plan_date == day,
  293. and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day),
  294. and_(PlPlan.delivery_time.isnot(None), func.date(PlPlan.delivery_time) == day),
  295. ),
  296. )
  297. )
  298. if kw:
  299. like = f"%{kw}%"
  300. product_match = (
  301. db.query(PlPlanProduct.id)
  302. .filter(
  303. PlPlanProduct.plan_id == PlPlan.id,
  304. PlPlanProduct.del_flag == 0,
  305. PlPlanProduct.product_name.like(like),
  306. )
  307. .exists()
  308. )
  309. query = query.filter(or_(PlTransport.license_plate.like(like), product_match))
  310. rows = query.order_by(
  311. PlPlan.pickup_time.desc(),
  312. PlPlan.plan_date.desc(),
  313. PlPlan.create_time.desc(),
  314. ).all()
  315. seen_plan: set[str] = set()
  316. plan_rows: list[tuple[PlPlan, PlTransport, list[PublicTransportHistoryStopRead]]] = []
  317. for plan, tr in rows:
  318. if plan.id in seen_plan:
  319. continue
  320. site_tok = (tr.site_access_token or "").strip()
  321. if not site_tok:
  322. continue
  323. site_stops = _build_site_history_stop_reads(
  324. db, tenant_no=tenant_no, plan=plan, transport=tr
  325. )
  326. if not site_stops:
  327. continue
  328. seen_plan.add(plan.id)
  329. plan_rows.append((plan, tr, site_stops))
  330. products_by_plan: dict[str, list[PublicTransportHistoryProductRead]] = {}
  331. if plan_rows:
  332. plan_ids = [plan.id for plan, _, _ in plan_rows]
  333. product_rows = (
  334. db.query(PlPlanProduct)
  335. .filter(
  336. PlPlanProduct.plan_id.in_(plan_ids),
  337. PlPlanProduct.del_flag == 0,
  338. )
  339. .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc())
  340. .all()
  341. )
  342. crm_product_ids = {
  343. (p.product_id or "").strip() for p in product_rows if (p.product_id or "").strip()
  344. }
  345. standard_by_id, alias_options_by_id = load_crm_product_alias_maps(
  346. db, tenant_no=tenant_no, product_ids=crm_product_ids
  347. )
  348. unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get(
  349. CRM_PRODUCT_UNIT, {}
  350. )
  351. for prod in product_rows:
  352. product_id = (prod.product_id or "").strip()
  353. display_name, _alias_name = resolve_product_display_names(
  354. saved_product_name=prod.product_name,
  355. product_id=product_id,
  356. standard_by_id=standard_by_id,
  357. alias_options_by_id=alias_options_by_id,
  358. )
  359. products_by_plan.setdefault(prod.plan_id, []).append(
  360. PublicTransportHistoryProductRead(
  361. product_name=display_name or prod.product_name or "—",
  362. model=prod.model,
  363. package_spec=prod.package_spec,
  364. quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty,
  365. unit=_resolve_unit_label(unit_map, prod.unit),
  366. )
  367. )
  368. records: list[PublicTransportHistoryRecordRead] = []
  369. for plan, tr, site_stops in plan_rows:
  370. voided, archived = _resolve_driver_link_flags(plan, tr)
  371. site_tok = (tr.site_access_token or "").strip()
  372. records.append(
  373. PublicTransportHistoryRecordRead(
  374. plan_id=plan.id,
  375. plan_no=plan.plan_no or "",
  376. transport_id=tr.id,
  377. site_access_token=site_tok or None,
  378. site_url=build_transport_site_url(site_tok) if site_tok else None,
  379. license_plate=tr.license_plate,
  380. pickup_time=plan.pickup_time,
  381. delivery_time=plan.delivery_time,
  382. pickup_address=plan.pickup_address,
  383. destination_address=plan.destination_address or plan.destination_name,
  384. products=products_by_plan.get(plan.id) or [],
  385. stops=site_stops,
  386. plan_voided=voided,
  387. plan_archived=archived,
  388. status_label=_history_status_label(voided, archived),
  389. )
  390. )
  391. page = PublicTransportHistoryPageRead(
  392. license_plate=_normalize_main_plate(license_plate_hint) or None,
  393. date=day.isoformat(),
  394. keyword=kw or None,
  395. records=records,
  396. )
  397. return page, None
  398. def build_site_calendar_counts(
  399. db: Session,
  400. *,
  401. tenant_no: str,
  402. month_str: str,
  403. ) -> tuple[Optional[dict[str, int]], Optional[str]]:
  404. """按月统计现场可见计划条数,键为 YYYY-MM-DD(不受列表关键字影响)。"""
  405. bounds = _parse_calendar_month(month_str)
  406. if bounds is None:
  407. return None, "月份格式无效,请使用 YYYY-MM"
  408. month_start, month_end = bounds
  409. rows = (
  410. db.query(PlPlan, PlTransport)
  411. .join(
  412. PlPlanTransport,
  413. and_(
  414. PlPlanTransport.plan_id == PlPlan.id,
  415. PlPlanTransport.del_flag == 0,
  416. ),
  417. )
  418. .join(
  419. PlTransport,
  420. and_(
  421. PlTransport.id == PlPlanTransport.transport_id,
  422. PlTransport.del_flag == 0,
  423. ),
  424. )
  425. .filter(
  426. PlPlan.tenant_no == tenant_no,
  427. PlPlan.del_flag == 0,
  428. PlPlan.transport_status == "arranged",
  429. PlTransport.tenant_no == tenant_no,
  430. PlTransport.site_access_token.isnot(None),
  431. PlTransport.site_access_token != "",
  432. or_(
  433. and_(
  434. PlPlan.plan_date.isnot(None),
  435. PlPlan.plan_date >= month_start,
  436. PlPlan.plan_date <= month_end,
  437. ),
  438. and_(
  439. PlPlan.pickup_time.isnot(None),
  440. func.date(PlPlan.pickup_time) >= month_start,
  441. func.date(PlPlan.pickup_time) <= month_end,
  442. ),
  443. and_(
  444. PlPlan.delivery_time.isnot(None),
  445. func.date(PlPlan.delivery_time) >= month_start,
  446. func.date(PlPlan.delivery_time) <= month_end,
  447. ),
  448. ),
  449. )
  450. .all()
  451. )
  452. seen: set[str] = set()
  453. plans: list[PlPlan] = []
  454. for plan, tr in rows:
  455. if plan.id in seen:
  456. continue
  457. site_tok = (tr.site_access_token or "").strip()
  458. if not site_tok:
  459. continue
  460. # 与 history 一致:无现场段则不计入
  461. if not load_site_stop_segments(
  462. db, tenant_no=tenant_no, plan_id=plan.id, transport_id=tr.id
  463. ):
  464. continue
  465. seen.add(plan.id)
  466. plans.append(plan)
  467. return (
  468. _accumulate_calendar_day_counts(
  469. plans,
  470. month_start=month_start,
  471. month_end=month_end,
  472. include_plan_date=True,
  473. ),
  474. None,
  475. )
  476. def save_site_feedback_for_transport(
  477. db: Session,
  478. transport: PlTransport,
  479. *,
  480. stop_type: str,
  481. stop_id: str,
  482. quantity: Optional[Decimal] = None,
  483. attachment_urls: Optional[list[str]] = None,
  484. remark: Optional[str] = None,
  485. ) -> tuple[bool, Optional[str]]:
  486. """保存段回传。成功返回 (True, None);失败返回 (False, msg)。不 commit。"""
  487. page = build_site_page_for_transport(db, transport)
  488. if page.plan_voided:
  489. return False, "运输计划已作废,无法提交回执"
  490. if page.plan_archived:
  491. return False, "运输计划已归档,无法提交回执"
  492. link = (
  493. db.query(PlPlanTransport)
  494. .filter(
  495. PlPlanTransport.transport_id == transport.id,
  496. PlPlanTransport.del_flag == 0,
  497. )
  498. .order_by(PlPlanTransport.create_time.desc())
  499. .first()
  500. )
  501. if not link or not link.plan_id:
  502. return False, "未找到关联计划,无法保存段回传"
  503. stop_type_n = (stop_type or "").strip()
  504. stop_id_n = (stop_id or "").strip()
  505. if stop_type_n not in ("load", "delivery") or not stop_id_n:
  506. return False, "停靠段参数无效"
  507. segments = load_site_stop_segments(
  508. db,
  509. tenant_no=transport.tenant_no,
  510. plan_id=link.plan_id,
  511. transport_id=transport.id,
  512. )
  513. segment_map = {(item.stop_type, item.stop_id): item for item in segments}
  514. segment = segment_map.get((stop_type_n, stop_id_n))
  515. if not segment:
  516. return False, "该段不在现场回传范围内"
  517. try:
  518. save_site_stop_feedback(
  519. db,
  520. tenant_no=transport.tenant_no,
  521. transport_id=transport.id,
  522. plan_id=link.plan_id,
  523. stop_type=stop_type_n,
  524. stop_id=stop_id_n,
  525. business_type=segment.business_type or "入库",
  526. quantity=quantity,
  527. attachment_urls=attachment_urls,
  528. remark=remark,
  529. )
  530. except ValueError as exc:
  531. return False, str(exc)
  532. return True, None
  533. def save_site_feedback_for_plan(
  534. db: Session,
  535. *,
  536. tenant_no: str,
  537. plan_id: str,
  538. stop_type: str,
  539. stop_id: str,
  540. quantity: Optional[Decimal] = None,
  541. attachment_urls: Optional[list[str]] = None,
  542. remark: Optional[str] = None,
  543. ) -> tuple[bool, Optional[str]]:
  544. plan, transport = resolve_transport_for_plan(db, tenant_no=tenant_no, plan_id=plan_id)
  545. if not plan:
  546. return False, "计划不存在"
  547. if not transport:
  548. return False, "未找到关联运输或现场链接无效"
  549. return save_site_feedback_for_transport(
  550. db,
  551. transport,
  552. stop_type=stop_type,
  553. stop_id=stop_id,
  554. quantity=quantity,
  555. attachment_urls=attachment_urls,
  556. remark=remark,
  557. )
  558. def upload_site_attachment_for_transport(
  559. db: Session,
  560. transport: PlTransport,
  561. *,
  562. file: UploadFile,
  563. folder: Optional[str] = "",
  564. ) -> tuple[Optional[dict[str, Any]], Optional[tuple[int, str]]]:
  565. """上传附件。成功返回 (data, None) 并 commit;失败返回 (None, (code, msg))。"""
  566. page = build_site_page_for_transport(db, transport)
  567. if page.plan_voided:
  568. return None, (400, "运输计划已作废,无法上传附件")
  569. if page.plan_archived:
  570. return None, (400, "运输计划已归档,无法上传附件")
  571. if not file or not file.filename:
  572. return None, (400, "请选择文件")
  573. try:
  574. effective_folder = resolve_upload_folder(
  575. folder=folder or "", biz_type="pl_transport_site_stop_feedback"
  576. )
  577. file_content = file.file.read()
  578. full_url = storage.upload_file(file_content, file.filename, effective_folder)
  579. base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else ""
  580. if base_url and full_url.startswith(base_url + "/"):
  581. file_url = full_url[len(base_url) + 1 :]
  582. else:
  583. file_url = full_url
  584. record = oss_svc.create_oss(
  585. db,
  586. tenant_no=transport.tenant_no,
  587. url=file_url,
  588. original_name=file.filename,
  589. file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "",
  590. file_size=len(file_content),
  591. file_type=file.content_type or "",
  592. biz_type="pl_transport_site_stop_feedback",
  593. user_id=None,
  594. ext1=effective_folder or None,
  595. )
  596. db.commit()
  597. return (
  598. {
  599. "id": record.id,
  600. "fileName": record.original_name,
  601. "fileUrl": record.url,
  602. "fileSize": record.file_size,
  603. "fileSizeDisplay": _format_file_size(record.file_size or 0),
  604. "fileType": record.file_type,
  605. },
  606. None,
  607. )
  608. except Exception as exc:
  609. db.rollback()
  610. return None, (500, f"上传失败: {str(exc)}")
  611. def upload_site_attachment_for_plan(
  612. db: Session,
  613. *,
  614. tenant_no: str,
  615. plan_id: str,
  616. file: UploadFile,
  617. folder: Optional[str] = "",
  618. ) -> tuple[Optional[dict[str, Any]], Optional[tuple[int, str]]]:
  619. plan, transport = resolve_transport_for_plan(db, tenant_no=tenant_no, plan_id=plan_id)
  620. if not plan:
  621. return None, (400, "计划不存在")
  622. if not transport:
  623. return None, (400, "未找到关联运输或现场链接无效")
  624. return upload_site_attachment_for_transport(
  625. db, transport, file=file, folder=folder
  626. )