106cf70922
xlsx 엔 받는 사람이 없거나(Shopee) 가려져(TikTok) 라벨 PDF 를 출처로 사용. 업로드 시 Order ID 로 박스와 조인해 채운다(하이브리드). - pdf_parser.py 추가 (pdfplumber) · Shopee SPX 라벨: 2단 중 왼쪽 열만 읽어 Recipient 블록 추출(전화 미표시) · TikTok 라벨: 배송사별 양식 대응 — To <이름> (+60)…(Ninja), Receiver <이름>(NDD) · 주소 영역에 섞인 10자리+ 바코드 숫자 제거(우편번호 5자리 보존) · PDF 없거나 파싱 실패해도 업로드 진행(PII 만 빈 값) - router: 라벨 PDF 파싱 결과를 parcels 에 머지 후 저장 - requirements: pdfplumber>=0.11 (Docker 재빌드 필요) - new.html: PDF 가 받는 사람 출처임을 안내(권장) - .gitignore: docs/samples/ (실제 고객 PII PDF — 커밋 금지) - 문서 갱신 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
500 lines
20 KiB
Python
500 lines
20 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
|
|
from .pdf_parser import PdfParseError, extract_recipients
|
|
|
|
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, platform=platform)
|
|
except ParseError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
|
|
parcels = parsed["parcels"]
|
|
if not parcels:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="엑셀에서 출고할 박스를 찾지 못했습니다. 컬럼/데이터를 확인하세요.",
|
|
)
|
|
|
|
# 라벨 PDF 가 있으면 받는 사람(이름/전화/주소)을 추출해 Order ID 로 박스에 채운다.
|
|
# 이름/주소는 xlsx 에 없거나 가려져 있어 PDF 가 출처. PDF 없거나 실패 시 빈 값.
|
|
if label_path:
|
|
try:
|
|
recipients = extract_recipients(label_path, platform)
|
|
except PdfParseError as exc:
|
|
logger.warning("라벨 PDF 파싱 실패 — PII 없이 진행: %s", exc)
|
|
recipients = {}
|
|
for p in parcels:
|
|
rec = recipients.get((p.get("order_id") or "").strip())
|
|
if not rec:
|
|
continue
|
|
for field in ("recipient_name", "recipient_phone", "recipient_address"):
|
|
if rec.get(field) and not (p.get(field) or "").strip():
|
|
p[field] = rec[field]
|
|
|
|
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"}
|