feat(dispatch): 출고 배치별 업로드 원본 zip 다운로드

배치 목록에 '다운로드' 버튼 추가. 피킹/라벨 PDF + 주문 엑셀 중
실제 존재하는 파일만 zip 으로 묶어 내려준다. 한글 파일명은
RFC5987 filename* 으로 인코딩.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:50:53 +09:00
parent 5426889f91
commit c0a661db82
2 changed files with 56 additions and 1 deletions
+55 -1
View File
@@ -12,14 +12,17 @@ Order ID / Package ID / Tracking ID / Seller SKU / Quantity 뿐이다.
from __future__ import annotations from __future__ import annotations
import io
import re import re
import shutil import shutil
import unicodedata import unicodedata
import zipfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile 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 from app.timezone import now_kst, today_kst
@@ -259,6 +262,57 @@ async def batch_delete(
return RedirectResponse(url="/dispatch/", status_code=303) 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카드) # 출고 작업 리스트 (1박스 = 1카드)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -36,6 +36,7 @@
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}">작업</a> <a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}">작업</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/picking">피킹</a> <a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/picking">피킹</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/handover">전달</a> <a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/handover">전달</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/download">다운로드</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}