Files
dbx-main/app/modules/dispatch/router.py
T
king 77ce86f5ed feat(dispatch): 검색 결과 가독성 — 송장 조회 링크·택배사 배지·줄분리
- api_search: 결과에 courier_tracking_url 주입(송장 클릭 시 조회 페이지)
- batches.html: Order/Pkg/송장 한 줄씩, 글자 확대, 송장번호·날짜·쇼핑몰
  굵게, 송장 오른쪽 배송사 배지, 송장번호 클릭 → 배송사 조회 새 탭

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:17:04 +09:00

741 lines
30 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",)),
store.PLATFORM_MANUAL: ("Manual_Order.xlsx", (".xlsx",)),
}
# 라벨(PDF) 슬롯이 없는 플랫폼(Manual)은 받는 사람 정보가 데이터 엑셀에 직접 있다.
_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 _itemcode_name_map(request: Request) -> dict[str, str]:
"""itemcode_db {코드(대문자): 상품명}. 미설정/실패 시 빈 dict.
malaysia 모듈의 읽기 전용 리더(single_items + set_items)를 재사용한다.
"""
reader = getattr(request.app.state, "malaysia_itemcode", None)
if reader is None or not getattr(reader, "enabled", False):
return {}
try:
return reader.name_map()
except Exception: # noqa: BLE001 — 다운로드를 막지 않는다.
logger.exception("itemcode name_map 조회 실패")
return {}
def _itemcode_bom_and_sabangnet(request: Request) -> tuple[dict[str, Any], dict[str, str]]:
"""(세트 BOM map, item_code→사방넷코드 map). 미설정/실패 시 빈 dict."""
reader = getattr(request.app.state, "malaysia_itemcode", None)
if reader is None or not getattr(reader, "enabled", False):
return {}, {}
bom: dict[str, Any] = {}
sab: dict[str, str] = {}
try:
bom = reader.set_bom_map()
except Exception: # noqa: BLE001
logger.exception("itemcode set_bom_map 조회 실패")
try:
sab = reader.sabangnet_code_map()
except Exception: # noqa: BLE001
logger.exception("itemcode sabangnet_code_map 조회 실패")
return bom, sab
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
batches = st.list_batches()
ctx = _base_ctx(request, user)
ctx.update(
{
"page_title": "말레이시아 배송 — 출고 배치",
"page_subtitle": "TikTok 일일 출고 배치 목록",
"batches": batches,
"batch_dates": sorted({b["dispatch_date"] for b in batches if b.get("dispatch_date")}),
"deducted_dates": st.list_deducted_dates(),
"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.get(platform) # Manual 은 라벨 없음(None)
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) if label_slot else ""
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 = {}
# PDF 가 받는 사람 정보의 정본(authoritative). xlsx 의 가려진 이름/주소는
# 신뢰하지 않으므로 PDF 값이 있으면 덮어쓴다.
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):
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)."""
if not bool(user.get("is_super_admin")):
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
# 취합 출고 엑셀(발송날짜/고객/주소/상품/택배 등)을 생성해 함께 넣는다.
# 상품이름은 아이템코드로 itemcode_db 에서 조회해 채운다.
parcels = st.list_parcels(batch_id=batch_id)
name_map = _itemcode_name_map(request)
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, name_map=name_map
)
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
)
@router.get("/stock-export")
async def stock_export(
request: Request, date: str, user: dict[str, Any] = Depends(_require_user)
) -> Response:
"""선택한 날짜의 전 배치 출고 수량을 낱개로 분해해 사방넷 재고 업로드 xlsx 다운로드.
콤보(세트)는 BOM 으로 낱개 분해, _N 배수 반영, MD-(뚜껑) 제외.
A=사방넷코드 / B=수량 / C=불용수량(0) / D=아이템코드.
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
d = (date or "").strip()
if not d:
raise HTTPException(status_code=400, detail="날짜가 필요합니다.")
rows = st.stock_aggregate_for_date(dispatch_date=d)
if not rows:
raise HTTPException(status_code=404, detail="해당 날짜의 출고 데이터가 없습니다.")
set_bom, sabangnet_map = _itemcode_bom_and_sabangnet(request)
# 세트(MY-)인데 BOM 을 못 찾으면 낱개 분해가 안 된 채 파일이 만들어진다.
# 조용히 잘못된 파일을 주지 말고, 어떤 세트가 분해 안 되는지 명확히 알린다.
missing_sets = sorted({
b for b in (
export.split_sku(r.get("seller_sku", ""))[0].strip().upper() for r in rows
)
if b.startswith(export._SET_PREFIX) and b not in set_bom
})
if missing_sets:
reader = getattr(request.app.state, "malaysia_itemcode", None)
hint = getattr(reader, "last_error", "") or getattr(reader, "reason", "")
detail = (
"세트 BOM 을 찾지 못해 낱개로 분해할 수 없습니다: "
+ ", ".join(missing_sets)
+ ". itemcode_db 의 set_components 에 해당 세트 BOM 이 등록돼 있는지, "
"읽기 전용 역할(itemcode_ro)에 set_components SELECT 권한이 있는지 확인하세요."
)
if hint:
detail += f" (itemcode 리더 오류: {hint})"
raise HTTPException(status_code=422, detail=detail)
# 사방넷 코드 맵이 통째로 비어 있으면 A열(상품코드[필수])이 전부 공란이 된다.
# 보통 itemcode_ro 의 single_items/set_items SELECT 권한 누락이 원인.
reader = getattr(request.app.state, "malaysia_itemcode", None)
if getattr(reader, "enabled", False) and not sabangnet_map:
hint = getattr(reader, "last_error", "") or getattr(reader, "reason", "")
detail = (
"사방넷 코드 맵이 비어 있어 상품코드[필수] 열을 채울 수 없습니다. "
"itemcode_db 의 single_items/set_items 에 sabangnet_code 가 등록돼 있는지, "
"읽기 전용 역할(itemcode_ro)에 두 테이블 SELECT 권한이 있는지 확인하세요."
)
if hint:
detail += f" (itemcode 리더 오류: {hint})"
raise HTTPException(status_code=422, detail=detail)
xlsx_bytes = export.build_stock_xlsx(rows=rows, set_bom=set_bom, sabangnet_map=sabangnet_map)
fname = export.stock_filename(d)
headers = {
"Content-Disposition": (
f"attachment; filename=\"stock_{d}.xlsx\"; "
f"filename*=UTF-8''{quote(fname)}"
)
}
return Response(
content=xlsx_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers=headers,
)
@router.post("/stock-deduct")
async def stock_deduct(
request: Request,
date: str = Form(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""선택 날짜의 출고를 말레이시아 재고관리에 OUT 으로 반영(1회성).
- 낱개(MT/MX/MZ)는 낱개 OUT, 콤보(MY-)는 세트 OUT(BOM 으로 낱개 분해).
- _N 배수 반영, MD-(뚜껑) 제외. movement_date = 해당 날짜.
- dispatch_stock_deductions 로 날짜당 1회만 — 재실행 시 재고 이중 차감 방지.
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
d = (date or "").strip()
if not d:
raise HTTPException(status_code=400, detail="날짜가 필요합니다.")
if st.deduction_exists(dispatch_date=d):
raise HTTPException(status_code=409, detail=f"{d} 는 이미 재고 차감되었습니다.")
ms = getattr(request.app.state, "malaysia_store", None)
if ms is None:
raise HTTPException(status_code=503, detail="말레이시아 재고관리(MALAYSIA_STOCK_DB_URL) 미설정")
# 낱개/콤보 분리(_N 배수, MD- 제외)
rows = st.stock_aggregate_for_date(dispatch_date=d)
singles, combos = export.split_singles_combos(rows)
if not singles and not combos:
raise HTTPException(status_code=404, detail="해당 날짜의 출고 데이터가 없습니다.")
# 창고 결정(첫 활성 창고)
warehouses = ms.list_warehouses()
if not warehouses:
raise HTTPException(status_code=400, detail="등록된 창고가 없습니다.")
wh = warehouses[0]["warehouse_code"]
# 콤보 BOM 사전검증(누락 시 아무 것도 쓰지 않고 중단)
bom_map, _sab = _itemcode_bom_and_sabangnet(request)
missing = [c for c in combos if c not in bom_map]
if missing:
raise HTTPException(
status_code=400,
detail="콤보 BOM 이 없어 차감할 수 없습니다(세트구성 먼저 등록): " + ", ".join(sorted(missing)),
)
# 마커 선점(레이스/재실행 차단). 충돌 시 이미 차감됨.
try:
st.record_deduction(
dispatch_date=d, warehouse_code=wh,
single_lines=len(singles), combo_lines=len(combos),
worker_name=user.get("email", ""),
)
except Exception: # noqa: BLE001 — unique 위반 등 = 이미 차감
raise HTTPException(status_code=409, detail=f"{d} 는 이미 재고 차감되었습니다.")
# 실제 OUT 반영. 실패해도 마커는 남겨 재고 이중 차감을 막는다(원인은 로그).
memo = f"배송 출고 {d}"
try:
for code, qty in combos.items():
ms.create_set_out(
movement_date=d, warehouse_code=wh, set_code=code, set_qty=qty,
bom_map=bom_map, ref_type="dispatch", ref_no=d, memo=memo,
created_by=user.get("email", ""),
)
for code, qty in singles.items():
ms.create_movement(
movement_date=d, warehouse_code=wh, item_code=code,
movement_type="OUT", qty=qty, ref_type="dispatch", ref_no=d,
memo=memo, created_by=user.get("email", ""),
)
except Exception as exc: # noqa: BLE001
logger.exception("재고 차감 중 오류 date=%s", d)
raise HTTPException(
status_code=500,
detail=f"일부 반영 중 오류가 발생했습니다(재차감 방지를 위해 차감완료로 표시됨). 재고관리 이동이력을 확인하세요: {type(exc).__name__}",
)
return JSONResponse({
"ok": True, "date": d, "warehouse": wh,
"singles": len(singles), "combos": len(combos),
})
# ════════════════════════════════════════════════════════════
# 출고 작업 리스트 (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.post("/batches/{batch_id:int}/bulk")
async def parcels_bulk(
request: Request,
batch_id: int,
field: str = Form(...),
value: str = Form("true"),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""배치 내 모든 박스의 작업 상태 1개를 value(완료/해제)로 일괄 설정. JSON 반환."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
target = str(value).strip().lower() in ("true", "1", "on", "yes")
try:
count = st.bulk_set_status(
batch_id=batch_id, field=field, value=target, worker_name=user.get("email", "")
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return JSONResponse({"ok": True, "field": field, "value": target, "count": count})
@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("/api/search")
async def api_search(
request: Request, q: str = "", _: dict[str, Any] = Depends(_require_user)
) -> JSONResponse:
"""주문번호/패키지번호/송장번호(부분 일치)로 박스 검색 → 받는 사람/배치 반환."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
query = (q or "").strip()
if len(query) < 2:
return JSONResponse({"query": query, "results": []})
results = st.search_parcels(query=query)
# 송장 조회 딥링크는 배송사 맵(store)에서 만든다 — 프런트엔 맵이 없다.
for r in results:
r["tracking_url"] = store.courier_tracking_url(
r.get("tracking_id", ""), r.get("shipping_provider", "")
)
return JSONResponse({"query": query, "results": results})
@router.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "module": "dispatch"}