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 %}