Files
dbx-main/app/modules/dispatch/router.py
T
king f1097b85dc 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>
2026-06-22 14:16:11 +09:00

483 lines
19 KiB
Python

"""말레이시아 TikTok 출고관리 모듈 라우터.
- 경로: /dispatch
- 권한: 로그인 + `dispatch` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
- 데이터: DispatchStore (dispatch_db / PostgreSQL) 전용.
DISPATCH_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
- 업로드 원본은 DATA_DIR/dispatch/<날짜>/<배치slug>/ 에 저장, DB 엔 경로만 기록.
초보 물류 직원용 화면. 작업 기준 키는 Order ID / Package ID / Tracking ID /
Seller SKU / Quantity 이고, 받는 사람(이름/전화/주소)은 작업 카드 표시 + 취합
출고 엑셀 생성을 위해 박스 단위로 보관한다(개인정보).
"""
from __future__ import annotations
import io
import logging
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, Response
from app.timezone import now_kst, today_kst
from . import export, store
from .parser import ParseError, parse_export
logger = logging.getLogger("dispatch.router")
router = APIRouter(prefix="/dispatch", tags=["dispatch"])
# 업로드 슬롯 (저장 파일명, 허용 확장자). 플랫폼마다 올리는 파일이 다르다.
# - 데이터 슬롯(필수): 자동 출고 리스트의 기준 엑셀.
# - 라벨 슬롯(선택): 실제 포장/라벨 원본 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",)),
}
# ────────────────────────────────────────────────────────────
# 공용 헬퍼
# ────────────────────────────────────────────────────────────
def _store(request: Request) -> Any:
return getattr(request.app.state, "dispatch_store", None)
def _data_dir(request: Request) -> Path:
return Path(getattr(request.app.state, "data_dir", Path("app/data")))
def _require_user(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433
user = get_current_user_record(request)
if user is None:
raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
if not has_module(user, "dispatch"):
raise HTTPException(status_code=403, detail="말레이시아 배송 모듈 권한이 없습니다.")
return user
def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
return render_template(
request,
"denied.html",
{
"reason": "말레이시아 배송 모듈이 아직 설정되지 않았습니다. "
"DISPATCH_DB_URL 환경변수를 설정하고 "
"scripts/sql/dispatch_db_init.sql 로 dispatch_db 를 초기화한 뒤 "
"컨테이너를 재기동하세요.",
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="dispatch"),
},
status_code=503,
)
def _guard(request: Request):
"""로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용."""
from app.main import get_current_user_record, render_template # noqa: WPS433
from app.store import has_module, is_admin # noqa: WPS433
user = get_current_user_record(request)
if user is None:
return RedirectResponse(url="/login", status_code=303)
if not has_module(user, "dispatch"):
return render_template(
request,
"denied.html",
{"reason": "말레이시아 배송 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)},
status_code=403,
)
st = _store(request)
if st is None:
return _render_config_needed(request, user)
return st, user
def _base_ctx(request: Request, user: dict[str, Any]) -> dict[str, Any]:
from app.main import build_erp_nav # noqa: WPS433
from app.store import is_admin # noqa: WPS433
return {
"user": user,
"is_admin": is_admin(user),
"is_super": bool(user.get("is_super_admin")),
"nav_items": build_erp_nav(user, active="dispatch"),
"status_fields": store.STATUS_FIELDS,
"status_labels": store.STATUS_LABELS,
}
def _slugify(value: str) -> str:
"""배치명 → 폴더/파일 안전한 슬러그. 비면 'batch'."""
text = unicodedata.normalize("NFKC", value or "").strip()
text = re.sub(r"[^\w\-.가-힣]+", "_", text, flags=re.UNICODE)
text = text.strip("._")
return text or "batch"
def _save_upload(upload: UploadFile | None, *, dest_dir: Path, slot: tuple) -> str:
"""업로드 파일 1개 저장. 반환: 저장 경로(없으면 ""). 확장자 검증."""
filename, allowed_ext = slot
if upload is None or not (upload.filename or "").strip():
return ""
ext = Path(upload.filename).suffix.lower()
if ext not in allowed_ext:
raise HTTPException(
status_code=400,
detail=f"{filename}{', '.join(allowed_ext)} 형식이어야 합니다. (업로드: {upload.filename})",
)
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / filename
with dest.open("wb") as f:
shutil.copyfileobj(upload.file, f)
return str(dest)
# ════════════════════════════════════════════════════════════
# 배치 목록 (메인)
# ════════════════════════════════════════════════════════════
@router.get("/", response_class=HTMLResponse)
async def batches_index(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
st, user = guard
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": "말레이시아 배송 — 출고 배치",
"page_subtitle": "TikTok 일일 출고 배치 목록",
"batches": st.list_batches(),
"active_tab": "batches",
}
)
return render_template(request, "dispatch/batches.html", ctx)
@router.get("/batches/new", response_class=HTMLResponse)
async def batch_new(request: Request) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
_st, user = guard
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": "말레이시아 배송 — 업로드",
"page_subtitle": "플랫폼 파일 업로드 → 출고 배치 자동 생성",
"today": today_kst().isoformat(),
"platforms": store.PLATFORMS,
"active_tab": "new",
}
)
return render_template(request, "dispatch/new.html", ctx)
@router.post("/batches")
async def batch_create(
request: Request,
dispatch_date: str = Form(...),
platform: str = Form("TikTok"),
batch_name: str = Form(""),
label_pdf: UploadFile | None = File(None),
data_xlsx: UploadFile | None = File(None),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""파일 업로드 + 배치 생성 + 엑셀 파싱 + 박스 저장.
플랫폼별 슬롯:
- 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 미설정")
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=f"{data_slot[0]} 는 필수입니다(자동 출고 리스트 기준 데이터).",
)
# 저장 폴더: DATA_DIR/dispatch/<날짜>/<배치slug>_<HHMMSS>/
name = (batch_name or "").strip() or f"{platform} 출고"
stamp = now_kst().strftime("%H%M%S")
dest_dir = _data_dir(request) / "dispatch" / dispatch_date.strip() / f"{_slugify(name)}_{stamp}"
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_export(order_path)
except ParseError as exc:
raise HTTPException(status_code=400, detail=str(exc))
parcels = parsed["parcels"]
if not parcels:
raise HTTPException(
status_code=400,
detail="엑셀에서 출고할 박스를 찾지 못했습니다. 컬럼/데이터를 확인하세요.",
)
batch = st.create_batch(
platform=platform,
dispatch_date=dispatch_date,
batch_name=name,
picking_pdf_path="",
label_pdf_path=label_path,
order_export_path=order_path,
)
st.add_parcels(batch_id=batch["id"], parcels=parcels)
return RedirectResponse(url=f"/dispatch/batches/{batch['id']}", status_code=303)
@router.post("/batches/{batch_id:int}/delete")
async def batch_delete(
request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""배치 삭제 — 관리자 전용(박스/상품/로그 CASCADE)."""
from app.store import is_admin # noqa: WPS433
if not is_admin(user):
raise HTTPException(status_code=403, detail="배치 삭제는 관리자만 가능합니다.")
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
try:
st.delete_batch(batch_id=batch_id)
except KeyError:
raise HTTPException(status_code=404, detail="배치를 찾을 수 없습니다.")
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
# 취합 출고 엑셀(발송날짜/고객/주소/상품/택배 등)을 생성해 함께 넣는다.
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="다운로드할 파일이 없습니다.")
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카드)
# ════════════════════════════════════════════════════════════
@router.get("/batches/{batch_id:int}", response_class=HTMLResponse)
async def batch_detail(request: Request, batch_id: int) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
st, user = guard
batch = st.get_batch(batch_id=batch_id)
if batch is None:
return render_template(
request, "denied.html",
{"reason": "출고 배치를 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
parcels = st.list_parcels(batch_id=batch_id)
done_count = sum(1 for p in parcels if all(p[f] for f in store.STATUS_FIELDS))
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": f"출고 작업 — {batch['batch_name']}",
"page_subtitle": f"{batch['dispatch_date']} · {batch['platform']} · 박스 {len(parcels)}",
"batch": batch,
"parcels": parcels,
"done_count": done_count,
"active_tab": "work",
}
)
return render_template(request, "dispatch/detail.html", ctx)
@router.get("/batches/{batch_id:int}/picking", response_class=HTMLResponse)
async def batch_picking(request: Request, batch_id: int) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
st, user = guard
batch = st.get_batch(batch_id=batch_id)
if batch is None:
return render_template(
request, "denied.html",
{"reason": "출고 배치를 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
summary = st.sku_summary(batch_id=batch_id)
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": f"피킹 요약 — {batch['batch_name']}",
"page_subtitle": f"{batch['dispatch_date']} · SKU별 총 수량",
"batch": batch,
"summary": summary,
"total_qty": sum(r["total_qty"] for r in summary),
"active_tab": "picking",
}
)
return render_template(request, "dispatch/picking.html", ctx)
@router.get("/batches/{batch_id:int}/handover", response_class=HTMLResponse)
async def batch_handover(request: Request, batch_id: int) -> HTMLResponse:
from app.main import render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
st, user = guard
batch = st.get_batch(batch_id=batch_id)
if batch is None:
return render_template(
request, "denied.html",
{"reason": "출고 배치를 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
summary = st.handover_summary(batch_id=batch_id)
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": f"Kagayaku 전달 — {batch['batch_name']}",
"page_subtitle": f"{batch['dispatch_date']} · {batch['platform']}",
"batch": batch,
"handover": summary,
"active_tab": "handover",
}
)
return render_template(request, "dispatch/handover.html", ctx)
# ════════════════════════════════════════════════════════════
# 상태 토글 (AJAX) + JSON API
# ════════════════════════════════════════════════════════════
@router.post("/parcels/{parcel_id:int}/toggle")
async def parcel_toggle(
request: Request,
parcel_id: int,
field: str = Form(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""박스 작업 상태 1개 토글 → 즉시 DB 저장 + 로그. JSON 반환(버튼 색 갱신용)."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
try:
result = st.toggle_status(
parcel_id=parcel_id, field=field, worker_name=user.get("email", "")
)
except KeyError:
raise HTTPException(status_code=404, detail="박스를 찾을 수 없습니다.")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, **result})
@router.get("/api/batches/{batch_id:int}/parcels")
async def api_parcels(
request: Request, batch_id: int, _: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
return JSONResponse({"parcels": st.list_parcels(batch_id=batch_id)})
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "module": "dispatch"}