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