"""말레이시아 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>_/ 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) 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("/health") async def health() -> dict[str, str]: return {"status": "ok", "module": "dispatch"}