pl_plan_archive_zip_export.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. from __future__ import annotations
  2. import hashlib
  3. import io
  4. import json
  5. import re
  6. import zipfile
  7. from typing import Any, Callable, Mapping, Optional, Sequence
  8. from urllib.parse import quote, unquote, urlparse
  9. from fastapi.responses import StreamingResponse
  10. from sqlalchemy.orm import Session
  11. from app.utils.excel_export import DictLabelResolver, ExportColumn, ExportValidationError
  12. ARCHIVE_ZIP_ATTACH_COL_MAX = 20
  13. ARCHIVE_ZIP_MAX_FILES = 2000
  14. ARCHIVE_ZIP_MAX_BYTES = 500 * 1024 * 1024
  15. def sanitize_zip_segment(raw: str) -> str:
  16. value = re.sub(r'[\\/:*?"<>|]+', "_", str(raw or "").strip())
  17. value = re.sub(r"\s+", " ", value).strip()
  18. return value or "未命名"
  19. def short_hash(text: str, *, n: int = 6) -> str:
  20. return hashlib.sha1(str(text or "").encode("utf-8")).hexdigest()[:n]
  21. def guess_ext_from_url(url: str) -> str:
  22. path = unquote(urlparse(str(url or "").split("?", 1)[0]).path)
  23. name = path.rsplit("/", 1)[-1]
  24. if "." not in name:
  25. return "bin"
  26. ext = name.rsplit(".", 1)[-1].lower()
  27. if not ext or len(ext) > 8 or "/" in ext:
  28. return "bin"
  29. return ext
  30. def build_attachment_filename(row_no: int, label: str, url: str) -> str:
  31. return (
  32. f"{int(row_no):02d}_{sanitize_zip_segment(label)}_"
  33. f"{short_hash(url)}.{guess_ext_from_url(url)}"
  34. )
  35. def first_row_no_by_plan_id(rows: Sequence[Mapping[str, Any]]) -> dict[str, int]:
  36. out: dict[str, int] = {}
  37. for row in rows:
  38. plan_id = str(row.get("planId") or "").strip()
  39. if not plan_id or plan_id in out:
  40. continue
  41. try:
  42. out[plan_id] = int(row.get("rowNo") or 0)
  43. except (TypeError, ValueError):
  44. out[plan_id] = 0
  45. return out
  46. def build_hyperlink_formula(rel_path: str, display: str) -> str:
  47. path = str(rel_path).replace('"', '""')
  48. text = sanitize_zip_segment(display).replace('"', '""')
  49. return f'=HYPERLINK("{path}","{text}")'
  50. def parse_url_list(raw: Optional[str]) -> list[str]:
  51. if not raw:
  52. return []
  53. text = str(raw).strip()
  54. if not text:
  55. return []
  56. try:
  57. parsed = json.loads(text)
  58. if isinstance(parsed, list):
  59. return [str(x).strip() for x in parsed if str(x).strip()]
  60. except json.JSONDecodeError:
  61. pass
  62. return [p.strip() for p in text.split(",") if p.strip()]
  63. def collect_hazard_cert_items(products: Sequence[Any]) -> list[dict[str, str]]:
  64. out: list[dict[str, str]] = []
  65. for product in products or []:
  66. pname = sanitize_zip_segment(getattr(product, "product_name", None) or "")
  67. specs = (
  68. ("购买证", getattr(product, "purchase_cert_url", None)),
  69. ("运输证", getattr(product, "transport_cert_url", None)),
  70. ("合法使用证明", getattr(product, "legal_use_cert_url", None)),
  71. )
  72. for base_label, raw in specs:
  73. for url in parse_url_list(raw):
  74. label = base_label if not pname or pname == "未命名" else f"{base_label}·{pname}"
  75. out.append({"url": url, "label": label})
  76. return out
  77. def merge_plan_attachment_items(
  78. document_items: Sequence[Mapping[str, str]],
  79. hazard_items: Sequence[Mapping[str, str]],
  80. ) -> list[dict[str, str]]:
  81. seen: set[str] = set()
  82. out: list[dict[str, str]] = []
  83. for item in list(document_items or []) + list(hazard_items or []):
  84. url = str(item.get("url") or "").strip()
  85. if not url or url in seen:
  86. continue
  87. seen.add(url)
  88. out.append({"url": url, "label": str(item.get("label") or "附件")})
  89. return out
  90. def load_plan_products_by_plan_ids(db, *, tenant_no: str, plan_ids: list[str]):
  91. from app.models.pl_models import PlPlanProduct
  92. if not plan_ids:
  93. return {}
  94. rows = (
  95. db.query(PlPlanProduct)
  96. .filter(
  97. PlPlanProduct.plan_id.in_(plan_ids),
  98. PlPlanProduct.tenant_no == tenant_no,
  99. PlPlanProduct.del_flag == 0,
  100. )
  101. .order_by(PlPlanProduct.order_num, PlPlanProduct.create_time)
  102. .all()
  103. )
  104. grouped: dict[str, list] = {pid: [] for pid in plan_ids}
  105. for row in rows:
  106. grouped.setdefault(row.plan_id, []).append(row)
  107. return grouped
  108. def _default_download(url: str) -> bytes:
  109. from app.services.bx.storage import download_file_bytes
  110. return download_file_bytes(url)
  111. def _attach_field(index: int) -> str:
  112. return f"attach{index}"
  113. def build_archive_zip_bytes(
  114. *,
  115. db: Optional[Session],
  116. tenant_no: str,
  117. columns: Sequence[ExportColumn],
  118. rows: Sequence[Mapping[str, Any]],
  119. attachments_by_plan: Mapping[str, Sequence[Mapping[str, str]]],
  120. stamp: str,
  121. download_fn: Optional[Callable[[str], bytes]] = None,
  122. ) -> tuple[bytes, list[str]]:
  123. """打包归档 ZIP:xlsx + 附件/;返回 (zip_bytes, failure_messages)。"""
  124. download = download_fn or _default_download
  125. row_no_by_plan = first_row_no_by_plan_id(rows)
  126. root = f"计划归档({stamp})"
  127. attach_dir = f"{root}/附件"
  128. # plan_id -> list of (rel_path_in_zip_under_附件, label, formula_rel)
  129. plan_file_meta: dict[str, list[tuple[str, str, str]]] = {}
  130. zip_file_entries: list[tuple[str, bytes]] = []
  131. failures: list[str] = []
  132. total_bytes = 0
  133. file_count = 0
  134. for plan_id, items in (attachments_by_plan or {}).items():
  135. seq = row_no_by_plan.get(plan_id) or 0
  136. if seq <= 0:
  137. continue
  138. meta: list[tuple[str, str, str]] = []
  139. for item in items or []:
  140. url = str(item.get("url") or "").strip()
  141. if not url:
  142. continue
  143. label = str(item.get("label") or "附件")
  144. filename = build_attachment_filename(seq, label, url)
  145. zip_path = f"{attach_dir}/{filename}"
  146. try:
  147. content = download(url)
  148. except Exception as exc: # noqa: BLE001 — 单文件失败不阻断整包
  149. failures.append(f"{label}: {url} ({exc})")
  150. continue
  151. file_count += 1
  152. total_bytes += len(content)
  153. if file_count > ARCHIVE_ZIP_MAX_FILES:
  154. raise ExportValidationError(
  155. f"附件文件数超过 {ARCHIVE_ZIP_MAX_FILES},请缩小筛选范围"
  156. )
  157. if total_bytes > ARCHIVE_ZIP_MAX_BYTES:
  158. raise ExportValidationError(
  159. "附件总体积过大,请缩小筛选范围后重试"
  160. )
  161. zip_file_entries.append((zip_path, content))
  162. formula_rel = f"附件/{filename}"
  163. meta.append((zip_path, label, formula_rel))
  164. if meta:
  165. plan_file_meta[plan_id] = meta
  166. max_att = 0
  167. for meta in plan_file_meta.values():
  168. max_att = max(max_att, len(meta))
  169. k = min(ARCHIVE_ZIP_ATTACH_COL_MAX, max_att)
  170. attach_columns = [
  171. ExportColumn(title=f"附件_{i}", field=_attach_field(i)) for i in range(1, k + 1)
  172. ]
  173. all_columns = list(columns) + attach_columns
  174. enriched_rows: list[dict[str, Any]] = []
  175. seen_first: set[str] = set()
  176. for row in rows:
  177. data = dict(row)
  178. plan_id = str(data.get("planId") or "").strip()
  179. for i in range(1, k + 1):
  180. data[_attach_field(i)] = ""
  181. if plan_id and plan_id not in seen_first:
  182. seen_first.add(plan_id)
  183. meta = plan_file_meta.get(plan_id) or []
  184. for i, (_zip_path, label, formula_rel) in enumerate(meta[:k], start=1):
  185. data[_attach_field(i)] = build_hyperlink_formula(formula_rel, label)
  186. enriched_rows.append(data)
  187. if not enriched_rows:
  188. raise ExportValidationError("没有可导出的数据")
  189. if not all_columns:
  190. raise ExportValidationError("没有可导出的列")
  191. resolver = DictLabelResolver(db, all_columns, tenant_no=tenant_no)
  192. from openpyxl import Workbook
  193. wb = Workbook()
  194. ws = wb.active
  195. ws.title = "计划归档"
  196. ws.append([c.title for c in all_columns])
  197. for row in enriched_rows:
  198. cells = resolver.row_to_cells(row, all_columns)
  199. ws.append(cells)
  200. xlsx_buf = io.BytesIO()
  201. wb.save(xlsx_buf)
  202. xlsx_bytes = xlsx_buf.getvalue()
  203. out = io.BytesIO()
  204. with zipfile.ZipFile(out, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
  205. zf.writestr(f"{root}/(总表)计划归档.xlsx", xlsx_bytes)
  206. for path, content in zip_file_entries:
  207. zf.writestr(path, content)
  208. if failures:
  209. note = "以下附件下载失败,已跳过:\n" + "\n".join(failures)
  210. zf.writestr(f"{root}/导出说明.txt", note.encode("utf-8"))
  211. return out.getvalue(), failures
  212. def build_archive_zip_streaming_response(
  213. *,
  214. db: Session,
  215. tenant_no: str,
  216. columns: Sequence[ExportColumn],
  217. rows: Sequence[Mapping[str, Any]],
  218. attachments_by_plan: Mapping[str, Sequence[Mapping[str, str]]],
  219. stamp: str,
  220. download_fn: Optional[Callable[[str], bytes]] = None,
  221. ) -> StreamingResponse:
  222. data, _failures = build_archive_zip_bytes(
  223. db=db,
  224. tenant_no=tenant_no,
  225. columns=columns,
  226. rows=rows,
  227. attachments_by_plan=attachments_by_plan,
  228. stamp=stamp,
  229. download_fn=download_fn,
  230. )
  231. filename = f"计划归档({stamp}).zip"
  232. encoded = quote(filename)
  233. disposition = f"attachment; filename={encoded}; filename*=UTF-8''{encoded}"
  234. return StreamingResponse(
  235. io.BytesIO(data),
  236. media_type="application/zip",
  237. headers={"Content-Disposition": disposition},
  238. )