diff --git a/app/modules/dispatch/router.py b/app/modules/dispatch/router.py index b150e78..6aa174a 100644 --- a/app/modules/dispatch/router.py +++ b/app/modules/dispatch/router.py @@ -12,14 +12,17 @@ Order ID / Package ID / Tracking ID / Seller SKU / Quantity 뿐이다. from __future__ import annotations +import io import re import shutil import unicodedata +import zipfile from pathlib import Path from typing import Any +from urllib.parse import quote from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from app.timezone import now_kst, today_kst @@ -259,6 +262,57 @@ async def batch_delete( return RedirectResponse(url="/dispatch/", status_code=303) +@router.get("/batches/{batch_id:int}/download") +async def batch_download( + request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user) +) -> Response: + """배치에 업로드된 원본 파일(피킹/라벨 PDF + 주문 엑셀)을 zip 으로 묶어 다운로드.""" + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="dispatch_db 미설정") + batch = st.get_batch(batch_id=batch_id) + if batch is None: + raise HTTPException(status_code=404, detail="배치를 찾을 수 없습니다.") + + # 저장된 경로 중 실제로 존재하는 파일만 묶는다. 같은 파일명 충돌 시 번호 부여. + paths = [ + batch.get("picking_pdf_path"), + batch.get("label_pdf_path"), + batch.get("order_export_path"), + ] + buf = io.BytesIO() + used: set[str] = set() + count = 0 + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for raw in paths: + p = Path(raw) if raw else None + if not p or not p.is_file(): + continue + arcname = p.name + n = 1 + while arcname in used: + arcname = f"{p.stem}_{n}{p.suffix}" + n += 1 + used.add(arcname) + zf.write(p, arcname=arcname) + count += 1 + + if count == 0: + raise HTTPException(status_code=404, detail="다운로드할 업로드 파일이 없습니다.") + + slug = _slugify(batch.get("batch_name") or "dispatch") + fname = f"{batch.get('dispatch_date', '')}_{slug}.zip".lstrip("_") + headers = { + "Content-Disposition": ( + f"attachment; filename=\"dispatch_{batch_id}.zip\"; " + f"filename*=UTF-8''{quote(fname)}" + ) + } + return Response( + content=buf.getvalue(), media_type="application/zip", headers=headers + ) + + # ════════════════════════════════════════════════════════════ # 출고 작업 리스트 (1박스 = 1카드) # ════════════════════════════════════════════════════════════ diff --git a/app/modules/dispatch/templates/dispatch/batches.html b/app/modules/dispatch/templates/dispatch/batches.html index 6df0ace..aac62c9 100644 --- a/app/modules/dispatch/templates/dispatch/batches.html +++ b/app/modules/dispatch/templates/dispatch/batches.html @@ -36,6 +36,7 @@ 작업 피킹 전달 + 다운로드 {% endfor %}