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:
@@ -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("_")
|
||||
|
||||
Reference in New Issue
Block a user