From c0a661db8279a4046fad56e3289b8151c22b51c3 Mon Sep 17 00:00:00 2001 From: king Date: Sun, 21 Jun 2026 21:50:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20=EC=B6=9C=EA=B3=A0=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=EB=B3=84=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20zip=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배치 목록에 '다운로드' 버튼 추가. 피킹/라벨 PDF + 주문 엑셀 중 실제 존재하는 파일만 zip 으로 묶어 내려준다. 한글 파일명은 RFC5987 filename* 으로 인코딩. Co-Authored-By: Claude Opus 4.8 --- app/modules/dispatch/router.py | 56 ++++++++++++++++++- .../dispatch/templates/dispatch/batches.html | 1 + 2 files changed, 56 insertions(+), 1 deletion(-) 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 %}