"""말레이시아 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 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 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>_/ 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) @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 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"}