feat(dispatch): Shopee 플랫폼 추가 + 받는 사람 정보 저장/출고 엑셀

- 플랫폼 TikTok/Shopee 분리: 업로드 화면이 선택에 따라 파일 안내 변경
  (TikTok=Order Export xlsx, Shopee=Packing List xlsx / 라벨 PDF 선택)
- TikTok Picking List.pdf 업로드 제거(피킹 요약은 데이터 엑셀로 생성)
- 파서 공용화 + 헤더 행 자동탐지(Shopee 제목행 대응), 두 포맷 헤더 별칭 통합
- 받는 사람 이름/전화/주소를 박스 단위로 dispatch_db 에 저장(개인정보)
  · init SQL 갱신 + 기존 DB용 멱등 마이그레이션 dispatch_add_recipient_columns.sql
- 출고 작업 카드: Package 제거, 받는 사람 이름(크게)+주소(여러 줄)+전화 표시
- 배치 다운로드 zip 에 취합 출고 엑셀 포함
  파일명 YYYY.MM.DD(Ddd)_tictoc.xlsx / _shopee.xlsx (export.py)
- 문서(CLAUDE.md, DISPATCH_MODULE.md) PII 보관·Shopee 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:16:11 +09:00
parent c0a661db82
commit f1097b85dc
13 changed files with 388 additions and 86 deletions
+55 -22
View File
@@ -6,13 +6,15 @@
DISPATCH_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
- 업로드 원본은 DATA_DIR/dispatch/<날짜>/<배치slug>/ 에 저장, DB 엔 경로만 기록.
초보 물류 직원용 화면이라 고객정보는 일절 다루지 않는다. 작업 기준은
Order ID / Package ID / Tracking ID / Seller SKU / Quantity 뿐이다.
초보 물류 직원용 화면. 작업 기준 키는 Order ID / Package ID / Tracking ID /
Seller SKU / Quantity 이고, 받는 사람(이름/전화/주소)은 작업 카드 표시 + 취합
출고 엑셀 생성을 위해 박스 단위로 보관한다(개인정보).
"""
from __future__ import annotations
import io
import logging
import re
import shutil
import unicodedata
@@ -26,15 +28,25 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Resp
from app.timezone import now_kst, today_kst
from . import store
from .parser import ParseError, parse_order_export
from . import export, store
from .parser import ParseError, parse_export
logger = logging.getLogger("dispatch.router")
router = APIRouter(prefix="/dispatch", tags=["dispatch"])
# 업로드 허용 파일 슬롯 (필드명 → (저장 파일명, 허용 확장자))
_PICKING = ("01_Picking_List.pdf", (".pdf",))
_LABEL = ("02_Shipping_Label_Packing_Slip.pdf", (".pdf",))
_ORDER = ("03_TikTok_Order_Export.xlsx", (".xlsx",))
# 업로드 슬롯 (저장 파일명, 허용 확장자). 플랫폼마다 올리는 파일이 다르다.
# - 데이터 슬롯(필수): 자동 출고 리스트의 기준 엑셀.
# - 라벨 슬롯(선택): 실제 포장/라벨 원본 PDF(보관용).
# TikTok 의 01_Picking_List.pdf 는 쓰지 않으므로 업로드 받지 않는다.
_DATA_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
store.PLATFORM_TIKTOK: ("03_TikTok_Order_Export.xlsx", (".xlsx",)),
store.PLATFORM_SHOPEE: ("Packing_List.Doorstep_Delivery.xlsx", (".xlsx",)),
}
_LABEL_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
store.PLATFORM_TIKTOK: ("02_Shipping_Label_Packing_Slip.pdf", (".pdf",)),
store.PLATFORM_SHOPEE: ("Shopee_Seller_Centre.pdf", (".pdf",)),
}
# ────────────────────────────────────────────────────────────
@@ -176,8 +188,9 @@ async def batch_new(request: Request) -> HTMLResponse:
ctx.update(
{
"page_title": "말레이시아 배송 — 업로드",
"page_subtitle": "TikTok 파일 업로드 → 출고 배치 자동 생성",
"page_subtitle": "플랫폼 파일 업로드 → 출고 배치 자동 생성",
"today": today_kst().isoformat(),
"platforms": store.PLATFORMS,
"active_tab": "new",
}
)
@@ -190,23 +203,31 @@ async def batch_create(
dispatch_date: str = Form(...),
platform: str = Form("TikTok"),
batch_name: str = Form(""),
picking_pdf: UploadFile | None = File(None),
label_pdf: UploadFile | None = File(None),
order_export: UploadFile | None = File(None),
data_xlsx: UploadFile | None = File(None),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""파일 업로드 + 배치 생성 + 엑셀 파싱 + 박스 저장.
03_TikTok_Order_Export.xlsx 는 필수(자동 출고 리스트의 기준). PDF 2개는 선택.
같은 날짜/플랫폼이라도 새 배치로 생성한다(덮어쓰지 않음, 요구사항 12).
플랫폼별 슬롯:
- TikTok: 데이터=03_TikTok_Order_Export.xlsx(필수), 라벨=Shipping Label PDF(선택)
- Shopee: 데이터=Packing List.Doorstep Delivery.xlsx(필수), 라벨=Shopee Seller Centre.pdf(선택)
같은 날짜/플랫폼이라도 새 배치로 생성한다(덮어쓰지 않음).
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
if order_export is None or not (order_export.filename or "").strip():
platform = (platform or store.PLATFORM_TIKTOK).strip()
if platform not in _DATA_SLOTS:
raise HTTPException(status_code=400, detail=f"지원하지 않는 플랫폼입니다: {platform}")
data_slot = _DATA_SLOTS[platform]
label_slot = _LABEL_SLOTS[platform]
if data_xlsx is None or not (data_xlsx.filename or "").strip():
raise HTTPException(
status_code=400,
detail="03_TikTok_Order_Export.xlsx 는 필수입니다(자동 출고 리스트 기준 데이터).",
detail=f"{data_slot[0]} 는 필수입니다(자동 출고 리스트 기준 데이터).",
)
# 저장 폴더: DATA_DIR/dispatch/<날짜>/<배치slug>_<HHMMSS>/
@@ -214,13 +235,12 @@ async def batch_create(
stamp = now_kst().strftime("%H%M%S")
dest_dir = _data_dir(request) / "dispatch" / dispatch_date.strip() / f"{_slugify(name)}_{stamp}"
picking_path = _save_upload(picking_pdf, dest_dir=dest_dir, slot=_PICKING)
label_path = _save_upload(label_pdf, dest_dir=dest_dir, slot=_LABEL)
order_path = _save_upload(order_export, dest_dir=dest_dir, slot=_ORDER)
label_path = _save_upload(label_pdf, dest_dir=dest_dir, slot=label_slot)
order_path = _save_upload(data_xlsx, dest_dir=dest_dir, slot=data_slot)
# 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다.
try:
parsed = parse_order_export(order_path)
parsed = parse_export(order_path)
except ParseError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@@ -235,7 +255,7 @@ async def batch_create(
platform=platform,
dispatch_date=dispatch_date,
batch_name=name,
picking_pdf_path=picking_path,
picking_pdf_path="",
label_pdf_path=label_path,
order_export_path=order_path,
)
@@ -266,7 +286,7 @@ async def batch_delete(
async def batch_download(
request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user)
) -> Response:
"""배치 업로드 원본 파일(피킹/라벨 PDF + 주문 엑셀)을 zip 으로 묶어 다운로드."""
"""배치 다운로드: 업로드 원본(라벨 PDF + 데이터 엑셀) + 취합 출고 엑셀을 zip 으로 묶."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
@@ -297,8 +317,21 @@ async def batch_download(
zf.write(p, arcname=arcname)
count += 1
# 취합 출고 엑셀(발송날짜/고객/주소/상품/택배 등)을 생성해 함께 넣는다.
parcels = st.list_parcels(batch_id=batch_id)
xlsx_name = export.export_filename(
dispatch_date=batch.get("dispatch_date", ""),
platform=batch.get("platform", ""),
)
try:
xlsx_bytes = export.build_workbook_bytes(batch=batch, parcels=parcels)
zf.writestr(xlsx_name, xlsx_bytes)
count += 1
except Exception: # noqa: BLE001 — 엑셀 생성 실패해도 원본 다운로드는 살린다.
logger.exception("출고 엑셀 생성 실패 batch_id=%s", batch_id)
if count == 0:
raise HTTPException(status_code=404, detail="다운로드할 업로드 파일이 없습니다.")
raise HTTPException(status_code=404, detail="다운로드할 파일이 없습니다.")
slug = _slugify(batch.get("batch_name") or "dispatch")
fname = f"{batch.get('dispatch_date', '')}_{slug}.zip".lstrip("_")