|
|
@@ -1,10 +1,17 @@
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import hashlib
|
|
|
+import io
|
|
|
import json
|
|
|
import re
|
|
|
-from typing import Any, Mapping, Optional, Sequence
|
|
|
-from urllib.parse import unquote, urlparse
|
|
|
+import zipfile
|
|
|
+from typing import Any, Callable, Mapping, Optional, Sequence
|
|
|
+from urllib.parse import quote, unquote, urlparse
|
|
|
+
|
|
|
+from fastapi.responses import StreamingResponse
|
|
|
+from sqlalchemy.orm import Session
|
|
|
+
|
|
|
+from app.utils.excel_export import DictLabelResolver, ExportColumn, ExportValidationError
|
|
|
|
|
|
ARCHIVE_ZIP_ATTACH_COL_MAX = 20
|
|
|
ARCHIVE_ZIP_MAX_FILES = 2000
|
|
|
@@ -123,3 +130,153 @@ def load_plan_products_by_plan_ids(db, *, tenant_no: str, plan_ids: list[str]):
|
|
|
for row in rows:
|
|
|
grouped.setdefault(row.plan_id, []).append(row)
|
|
|
return grouped
|
|
|
+
|
|
|
+
|
|
|
+def _default_download(url: str) -> bytes:
|
|
|
+ from app.services.bx.storage import download_file_bytes
|
|
|
+
|
|
|
+ return download_file_bytes(url)
|
|
|
+
|
|
|
+
|
|
|
+def _attach_field(index: int) -> str:
|
|
|
+ return f"attach{index}"
|
|
|
+
|
|
|
+
|
|
|
+def build_archive_zip_bytes(
|
|
|
+ *,
|
|
|
+ db: Optional[Session],
|
|
|
+ tenant_no: str,
|
|
|
+ columns: Sequence[ExportColumn],
|
|
|
+ rows: Sequence[Mapping[str, Any]],
|
|
|
+ attachments_by_plan: Mapping[str, Sequence[Mapping[str, str]]],
|
|
|
+ stamp: str,
|
|
|
+ download_fn: Optional[Callable[[str], bytes]] = None,
|
|
|
+) -> tuple[bytes, list[str]]:
|
|
|
+ """打包归档 ZIP:xlsx + 附件/;返回 (zip_bytes, failure_messages)。"""
|
|
|
+ download = download_fn or _default_download
|
|
|
+ row_no_by_plan = first_row_no_by_plan_id(rows)
|
|
|
+ root = f"计划归档({stamp})"
|
|
|
+ attach_dir = f"{root}/附件"
|
|
|
+
|
|
|
+ # plan_id -> list of (rel_path_in_zip_under_附件, label, formula_rel)
|
|
|
+ plan_file_meta: dict[str, list[tuple[str, str, str]]] = {}
|
|
|
+ zip_file_entries: list[tuple[str, bytes]] = []
|
|
|
+ failures: list[str] = []
|
|
|
+ total_bytes = 0
|
|
|
+ file_count = 0
|
|
|
+
|
|
|
+ for plan_id, items in (attachments_by_plan or {}).items():
|
|
|
+ seq = row_no_by_plan.get(plan_id) or 0
|
|
|
+ if seq <= 0:
|
|
|
+ continue
|
|
|
+ meta: list[tuple[str, str, str]] = []
|
|
|
+ for item in items or []:
|
|
|
+ url = str(item.get("url") or "").strip()
|
|
|
+ if not url:
|
|
|
+ continue
|
|
|
+ label = str(item.get("label") or "附件")
|
|
|
+ filename = build_attachment_filename(seq, label, url)
|
|
|
+ zip_path = f"{attach_dir}/{filename}"
|
|
|
+ try:
|
|
|
+ content = download(url)
|
|
|
+ except Exception as exc: # noqa: BLE001 — 单文件失败不阻断整包
|
|
|
+ failures.append(f"{label}: {url} ({exc})")
|
|
|
+ continue
|
|
|
+ file_count += 1
|
|
|
+ total_bytes += len(content)
|
|
|
+ if file_count > ARCHIVE_ZIP_MAX_FILES:
|
|
|
+ raise ExportValidationError(
|
|
|
+ f"附件文件数超过 {ARCHIVE_ZIP_MAX_FILES},请缩小筛选范围"
|
|
|
+ )
|
|
|
+ if total_bytes > ARCHIVE_ZIP_MAX_BYTES:
|
|
|
+ raise ExportValidationError(
|
|
|
+ "附件总体积过大,请缩小筛选范围后重试"
|
|
|
+ )
|
|
|
+ zip_file_entries.append((zip_path, content))
|
|
|
+ formula_rel = f"附件/{filename}"
|
|
|
+ meta.append((zip_path, label, formula_rel))
|
|
|
+ if meta:
|
|
|
+ plan_file_meta[plan_id] = meta
|
|
|
+
|
|
|
+ max_att = 0
|
|
|
+ for meta in plan_file_meta.values():
|
|
|
+ max_att = max(max_att, len(meta))
|
|
|
+ k = min(ARCHIVE_ZIP_ATTACH_COL_MAX, max_att)
|
|
|
+
|
|
|
+ attach_columns = [
|
|
|
+ ExportColumn(title=f"附件_{i}", field=_attach_field(i)) for i in range(1, k + 1)
|
|
|
+ ]
|
|
|
+ all_columns = list(columns) + attach_columns
|
|
|
+
|
|
|
+ enriched_rows: list[dict[str, Any]] = []
|
|
|
+ seen_first: set[str] = set()
|
|
|
+ for row in rows:
|
|
|
+ data = dict(row)
|
|
|
+ plan_id = str(data.get("planId") or "").strip()
|
|
|
+ for i in range(1, k + 1):
|
|
|
+ data[_attach_field(i)] = ""
|
|
|
+ if plan_id and plan_id not in seen_first:
|
|
|
+ seen_first.add(plan_id)
|
|
|
+ meta = plan_file_meta.get(plan_id) or []
|
|
|
+ for i, (_zip_path, label, formula_rel) in enumerate(meta[:k], start=1):
|
|
|
+ data[_attach_field(i)] = build_hyperlink_formula(formula_rel, label)
|
|
|
+ enriched_rows.append(data)
|
|
|
+
|
|
|
+ if not enriched_rows:
|
|
|
+ raise ExportValidationError("没有可导出的数据")
|
|
|
+ if not all_columns:
|
|
|
+ raise ExportValidationError("没有可导出的列")
|
|
|
+
|
|
|
+ resolver = DictLabelResolver(db, all_columns, tenant_no=tenant_no)
|
|
|
+ from openpyxl import Workbook
|
|
|
+
|
|
|
+ wb = Workbook()
|
|
|
+ ws = wb.active
|
|
|
+ ws.title = "计划归档"
|
|
|
+ ws.append([c.title for c in all_columns])
|
|
|
+ for row in enriched_rows:
|
|
|
+ cells = resolver.row_to_cells(row, all_columns)
|
|
|
+ ws.append(cells)
|
|
|
+
|
|
|
+ xlsx_buf = io.BytesIO()
|
|
|
+ wb.save(xlsx_buf)
|
|
|
+ xlsx_bytes = xlsx_buf.getvalue()
|
|
|
+
|
|
|
+ out = io.BytesIO()
|
|
|
+ with zipfile.ZipFile(out, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
|
+ zf.writestr(f"{root}/(总表)计划归档.xlsx", xlsx_bytes)
|
|
|
+ for path, content in zip_file_entries:
|
|
|
+ zf.writestr(path, content)
|
|
|
+ if failures:
|
|
|
+ note = "以下附件下载失败,已跳过:\n" + "\n".join(failures)
|
|
|
+ zf.writestr(f"{root}/导出说明.txt", note.encode("utf-8"))
|
|
|
+ return out.getvalue(), failures
|
|
|
+
|
|
|
+
|
|
|
+def build_archive_zip_streaming_response(
|
|
|
+ *,
|
|
|
+ db: Session,
|
|
|
+ tenant_no: str,
|
|
|
+ columns: Sequence[ExportColumn],
|
|
|
+ rows: Sequence[Mapping[str, Any]],
|
|
|
+ attachments_by_plan: Mapping[str, Sequence[Mapping[str, str]]],
|
|
|
+ stamp: str,
|
|
|
+ download_fn: Optional[Callable[[str], bytes]] = None,
|
|
|
+) -> StreamingResponse:
|
|
|
+ data, _failures = build_archive_zip_bytes(
|
|
|
+ db=db,
|
|
|
+ tenant_no=tenant_no,
|
|
|
+ columns=columns,
|
|
|
+ rows=rows,
|
|
|
+ attachments_by_plan=attachments_by_plan,
|
|
|
+ stamp=stamp,
|
|
|
+ download_fn=download_fn,
|
|
|
+ )
|
|
|
+ filename = f"计划归档({stamp}).zip"
|
|
|
+ encoded = quote(filename)
|
|
|
+ disposition = f"attachment; filename={encoded}; filename*=UTF-8''{encoded}"
|
|
|
+ return StreamingResponse(
|
|
|
+ io.BytesIO(data),
|
|
|
+ media_type="application/zip",
|
|
|
+ headers={"Content-Disposition": disposition},
|
|
|
+ )
|