6dc14a7350
배치 목록 상단에 검색 박스 추가. 주문번호·패키지번호·송장번호 일부만 입력하면 전체 출고 배치에서 해당 박스의 받는 사람(이름·전화·주소)과 소속 배치를 찾아준다. - db.search_parcels: 3개 식별자 ILIKE 부분일치 + 배치 조인(파라미터 바인딩) - GET /dispatch/api/search: 2글자 이상, dispatch 권한 가드 - batches.html: 디바운스 검색 UI, 매칭 강조, 한/영 토글 연동 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
735 lines
30 KiB
Python
735 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": []})
|
|
return JSONResponse({"query": query, "results": st.search_parcels(query=query)})
|
|
|
|
|
|
@router.get("/health")
|
|
async def health() -> dict[str, str]:
|
|
return {"status": "ok", "module": "dispatch"}
|