feat(dispatch): 말레이시아 TikTok 출고관리 모듈 추가

03_TikTok_Order_Export.xlsx 업로드 → 1박스=1카드 출고 작업 리스트,
SKU 피킹 요약, Kagayaku 전달표(A4 인쇄)를 자동 생성.

- 1박스 묶음 기준: Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산
- 작업 상태 5단계 토글(AJAX 즉시 저장) + dispatch_logs 기록
- 고객 이름/주소/전화 미저장(파싱 시 폐기, 스키마에 컬럼 없음)
- 엑셀은 openpyxl 파싱(pandas 미사용), DB는 dispatch_db 전용
- 인증은 기존 Google OAuth + 신규 권한키 dispatch 재사용
- scripts/sql/dispatch_db_init.sql, docs/DISPATCH_MODULE.md 동봉

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 15:11:43 +09:00
parent 8453c425f8
commit 99eea01fc1
19 changed files with 1868 additions and 0 deletions
+395
View File
@@ -0,0 +1,395 @@
"""말레이시아 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 re
import shutil
import unicodedata
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.timezone import now_kst, today_kst
from . import store
from .parser import ParseError, parse_order_export
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",))
# ────────────────────────────────────────────────────────────
# 공용 헬퍼
# ────────────────────────────────────────────────────────────
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": "TikTok 파일 업로드 → 출고 배치 자동 생성",
"today": today_kst().isoformat(),
"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(""),
picking_pdf: UploadFile | None = File(None),
label_pdf: UploadFile | None = File(None),
order_export: UploadFile | None = File(None),
user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse:
"""파일 업로드 + 배치 생성 + 엑셀 파싱 + 박스 저장.
03_TikTok_Order_Export.xlsx 는 필수(자동 출고 리스트의 기준). PDF 2개는 선택.
같은 날짜/플랫폼이라도 새 배치로 생성한다(덮어쓰지 않음, 요구사항 12).
"""
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():
raise HTTPException(
status_code=400,
detail="03_TikTok_Order_Export.xlsx 는 필수입니다(자동 출고 리스트 기준 데이터).",
)
# 저장 폴더: 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}"
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)
# 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다.
try:
parsed = parse_order_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=picking_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)
# ════════════════════════════════════════════════════════════
# 출고 작업 리스트 (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"}