Prechádzať zdrojové kódy

feat(pl): build archive export zip with relative hyperlinks.

Pack archive xlsx plus attachment files under 附件/, with HYPERLINK columns on each plan's first row.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 mesiac pred
rodič
commit
81e52477de

+ 159 - 2
backend/app/services/pl_plan_archive_zip_export.py

@@ -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},
+    )

+ 50 - 0
backend/tests/test_pl_plan_archive_zip_export.py

@@ -1,3 +1,8 @@
+import io
+import zipfile
+
+from openpyxl import load_workbook
+
 from app.services.pl_plan_archive_zip_export import (
     sanitize_zip_segment,
     build_attachment_filename,
@@ -6,7 +11,9 @@ from app.services.pl_plan_archive_zip_export import (
     parse_url_list,
     collect_hazard_cert_items,
     merge_plan_attachment_items,
+    build_archive_zip_bytes,
 )
+from app.utils.excel_export import ExportColumn
 
 
 def test_sanitize_zip_segment():
@@ -60,3 +67,46 @@ def test_merge_dedupes_url():
     merged = merge_plan_attachment_items(doc, haz)
     assert len(merged) == 1
     assert merged[0]["label"] == "司机"
+
+
+def test_build_archive_zip_bytes_structure_and_hyperlink():
+    columns = [
+        ExportColumn(title="序号", field="rowNo"),
+        ExportColumn(title="计划编号", field="planNo"),
+    ]
+    rows = [
+        {"planId": "p1", "rowNo": 1, "planNo": "PL001"},
+        {"planId": "p1", "rowNo": 2, "planNo": "PL001"},
+    ]
+    attachments_by_plan = {
+        "p1": [{"url": "https://cdn.example/a.jpg", "label": "司机"}],
+    }
+
+    def fake_download(url: str) -> bytes:
+        return b"fake-image-bytes"
+
+    stamp = "202609231230"
+    data, failures = build_archive_zip_bytes(
+        db=None,
+        tenant_no="t1",
+        columns=columns,
+        rows=rows,
+        attachments_by_plan=attachments_by_plan,
+        stamp=stamp,
+        download_fn=fake_download,
+    )
+    assert failures == []
+    with zipfile.ZipFile(io.BytesIO(data)) as zf:
+        names = zf.namelist()
+        assert f"计划归档({stamp})/(总表)计划归档.xlsx" in names
+        att = [n for n in names if n.startswith(f"计划归档({stamp})/附件/")]
+        assert len(att) == 1
+        assert "01_司机_" in att[0]
+        xlsx_bytes = zf.read(f"计划归档({stamp})/(总表)计划归档.xlsx")
+    wb = load_workbook(io.BytesIO(xlsx_bytes))
+    ws = wb.active
+    headers = [c.value for c in ws[1]]
+    assert "附件_1" in headers
+    attach_col = headers.index("附件_1") + 1
+    assert str(ws.cell(2, attach_col).value).startswith("=HYPERLINK(")
+    assert ws.cell(3, attach_col).value in (None, "")