diff --git a/CLAUDE.md b/CLAUDE.md index b9aa151..415e628 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아 - 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용 - 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` - 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia` -- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok 출고관리. 03_TikTok_Order_Export.xlsx 업로드 → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 5단계 토글(`dispatch_logs` 기록). 고객 이름/주소/전화는 미저장(파싱 시 폐기). 엑셀은 openpyxl 파싱. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md` +- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md` 상세는 `docs/PROJECT_OVERVIEW.md`. diff --git a/app/modules/dispatch/db.py b/app/modules/dispatch/db.py index eba5935..87d2056 100644 --- a/app/modules/dispatch/db.py +++ b/app/modules/dispatch/db.py @@ -89,8 +89,9 @@ class DispatchStore: """ INSERT INTO dispatch_parcels (batch_id, seq, order_id, package_id, - tracking_id, shipping_provider) - VALUES (%s,%s,%s,%s,%s,%s) + tracking_id, shipping_provider, + recipient_name, recipient_phone, recipient_address) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id """, ( @@ -100,6 +101,9 @@ class DispatchStore: (p.get("package_id") or "").strip(), (p.get("tracking_id") or "").strip(), (p.get("shipping_provider") or "").strip(), + (p.get("recipient_name") or "").strip(), + (p.get("recipient_phone") or "").strip(), + (p.get("recipient_address") or "").strip(), ), ).fetchone() parcel_id = parcel_row["id"] diff --git a/app/modules/dispatch/export.py b/app/modules/dispatch/export.py new file mode 100644 index 0000000..38edd78 --- /dev/null +++ b/app/modules/dispatch/export.py @@ -0,0 +1,125 @@ +"""출고 배치 → 취합 엑셀(openpyxl) 생성. + +다운로드 zip 에 업로드 원본과 함께 넣는 '출고 엑셀'을 만든다. 1박스 안에 여러 +SKU 가 있으면 SKU 당 1행으로 펼치고 박스 단위(받는 사람/주문/택배) 정보는 반복한다. + +파일명 형식: 2026.06.22(Mon)_tictoc.xlsx (Shopee 는 끝이 _shopee). + - TikTok 은 운영 요청 철자에 맞춰 'tictoc' 을 쓴다. +""" + +from __future__ import annotations + +import io +from datetime import date, datetime +from typing import Any + +# 엑셀 컬럼(요청 순서). (헤더, 박스/배치/상품 키) +_COLUMNS: tuple[tuple[str, str], ...] = ( + ("발송 날짜", "dispatch_date"), + ("고객 이름", "recipient_name"), + ("전화번호", "recipient_phone"), + ("주소", "recipient_address"), + ("상품이름", "product_name"), + ("아이템 코드", "seller_sku"), + ("주문 수량", "quantity"), + ("오더번호", "order_id"), + ("택배사", "shipping_provider"), + ("택배송장번호", "tracking_id"), + ("주문처", "platform"), +) + +# 플랫폼 → 파일명 접미사. +_PLATFORM_SUFFIX: dict[str, str] = { + "TikTok": "tictoc", + "Shopee": "shopee", +} + + +def export_filename(*, dispatch_date: str, platform: str) -> str: + """2026.06.22(Mon)_tictoc.xlsx 형식 파일명. 날짜 파싱 실패 시 원문 사용.""" + d = _parse_date(dispatch_date) + if d is not None: + stamp = d.strftime("%Y.%m.%d(%a)") + else: + stamp = (dispatch_date or "").strip() or "dispatch" + suffix = _PLATFORM_SUFFIX.get((platform or "").strip(), (platform or "dispatch").strip().lower()) + return f"{stamp}_{suffix}.xlsx" + + +def build_workbook_bytes(*, batch: dict[str, Any], parcels: list[dict[str, Any]]) -> bytes: + """배치 + 박스(상품 포함) → xlsx 바이트. + + parcels 각 항목은 db.list_parcels 결과(items 포함, recipient_* 포함). + """ + from openpyxl import Workbook # noqa: WPS433 — 지연 import + from openpyxl.styles import Font + + wb = Workbook() + ws = wb.active + ws.title = "출고" + + headers = [h for h, _ in _COLUMNS] + ws.append(headers) + for cell in ws[1]: + cell.font = Font(bold=True) + + dispatch_date = batch.get("dispatch_date", "") or "" + platform = batch.get("platform", "") or "" + + for p in parcels: + box = { + "dispatch_date": dispatch_date, + "platform": platform, + "recipient_name": p.get("recipient_name", "") or "", + "recipient_phone": p.get("recipient_phone", "") or "", + "recipient_address": p.get("recipient_address", "") or "", + "order_id": p.get("order_id", "") or "", + "shipping_provider": p.get("shipping_provider", "") or "", + "tracking_id": p.get("tracking_id", "") or "", + } + items = p.get("items") or [{}] + for it in items: + row_data = dict(box) + row_data["product_name"] = it.get("product_name", "") or "" + row_data["seller_sku"] = it.get("seller_sku", "") or "" + row_data["quantity"] = it.get("quantity", "") if it.get("quantity") is not None else "" + ws.append([_cell(row_data.get(key, "")) for _, key in _COLUMNS]) + + _autosize(ws, headers) + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() + + +def _cell(value: Any) -> Any: + """송장/전화번호 등이 숫자로 보이면 엑셀이 과학표기/반올림 하므로 문자열 보존.""" + if isinstance(value, int): + return value + return "" if value is None else str(value) + + +def _autosize(ws: Any, headers: list[str]) -> None: + """대략적인 컬럼 폭. 주소는 길어 상한을 둔다.""" + from openpyxl.utils import get_column_letter + + widths: list[int] = [len(h) for h in headers] + for row in ws.iter_rows(min_row=2, values_only=True): + for i, val in enumerate(row): + ln = len(str(val)) if val is not None else 0 + if ln > widths[i]: + widths[i] = ln + for i, w in enumerate(widths, start=1): + ws.column_dimensions[get_column_letter(i)].width = min(max(w + 2, 8), 50) + + +def _parse_date(value: str) -> date | None: + s = (value or "").strip() + if not s: + return None + for fmt in ("%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d"): + try: + return datetime.strptime(s, fmt).date() + except ValueError: + continue + return None diff --git a/app/modules/dispatch/parser.py b/app/modules/dispatch/parser.py index ad08a4f..2cc40b7 100644 --- a/app/modules/dispatch/parser.py +++ b/app/modules/dispatch/parser.py @@ -1,11 +1,13 @@ -"""TikTok Order Export XLSX 파서 (openpyxl). +"""출고 XLSX 파서 (openpyxl) — TikTok / Shopee 공용. -요구사항 2/11 구현. pandas 대신 레포에 이미 있는 openpyxl 로 읽어 의존성을 -가볍게 유지한다(동작/결과는 동일: 헤더 정규화 → 표준키 매핑 → 박스 묶기). +pandas 대신 레포에 이미 있는 openpyxl 로 읽어 의존성을 가볍게 유지한다 +(동작: 헤더 정규화 → 표준키 매핑 → 박스 묶기). -- 첫 시트의 첫 행을 헤더로 본다. 헤더 앞뒤 공백 제거 후 표준키로 매핑한다. -- 매핑되지 않는 열(고객 이름/주소/전화 등)은 읽기 단계에서 버린다 → 개인정보 - 미보관(요구사항 11). +- TikTok: 03_TikTok_Order_Export.xlsx, Shopee: Packing List.Doorstep Delivery.xlsx. + 두 포맷 모두 같은 표준키로 매핑된다(store.COLUMN_ALIASES). 헤더가 첫 행이 아닐 + 수 있어(Shopee 는 제목 행이 위에 붙는 경우가 있다) 상단 몇 행을 훑어 필수 컬럼이 + 잡히는 첫 행을 헤더로 본다. +- 받는 사람(이름/전화/주소) 컬럼은 매핑되면 보존한다(작업 카드 표시 + 출고 엑셀). - Tracking ID 는 숫자로 읽혀도 문자열로 보존(store.clean_text). - Quantity 가 비면 1, NaN/None 은 빈 문자열로 처리. - 필수 컬럼이 없으면 어떤 컬럼이 없는지 ParseError 로 알린다. @@ -22,10 +24,15 @@ class ParseError(Exception): """엑셀 파싱 실패. message 는 사용자에게 그대로 보여줄 한국어 메시지.""" -def parse_order_export(path: str) -> dict[str, Any]: - """XLSX 경로 → {"parcels": [...], "row_count": int}. +# 헤더가 첫 행이 아닐 수 있어 상단에서 헤더 행을 찾을 때 훑는 최대 행 수. +_HEADER_SCAN_ROWS = 15 + + +def parse_export(path: str) -> dict[str, Any]: + """출고 XLSX 경로 → {"parcels": [...], "row_count": int}. parcels 는 store.group_parcels 결과(1박스=1항목). 실패 시 ParseError. + TikTok·Shopee 공용(표준키 매핑이 흡수한다). """ try: from openpyxl import load_workbook # noqa: WPS433 — 지연 import @@ -42,22 +49,20 @@ def parse_order_export(path: str) -> dict[str, Any]: try: ws = wb.worksheets[0] - rows_iter = ws.iter_rows(values_only=True) - try: - header = next(rows_iter) - except StopIteration: + all_rows = list(ws.iter_rows(values_only=True)) + if not all_rows: raise ParseError("엑셀에 데이터가 없습니다(빈 파일).") - mapping = store.resolve_columns(header) - missing = store.missing_required(mapping) - if missing: + header_idx, mapping = _find_header(all_rows) + if header_idx is None: + missing = store.missing_required({}) raise ParseError("엑셀에서 필요한 컬럼을 찾지 못했습니다: " + ", ".join(missing)) norm_rows: list[dict[str, str]] = [] - for raw in rows_iter: + for raw in all_rows[header_idx + 1:]: if raw is None: continue - # 표준키만 추출(매핑 안 된 열 = 개인정보 등은 버린다). + # 표준키만 추출(매핑 안 된 열은 버린다). record = { key: store.clean_text(raw[idx]) if idx < len(raw) else "" for key, idx in mapping.items() @@ -78,6 +83,24 @@ def parse_order_export(path: str) -> dict[str, Any]: return {"parcels": parcels, "row_count": len(norm_rows)} +# 하위 호환 별칭(기존 호출부/테스트가 쓰던 이름). +parse_order_export = parse_export + + +def _find_header(rows: list) -> tuple[int | None, dict[str, int]]: + """상단 행들을 훑어 필수 컬럼이 잡히는 첫 행을 헤더로 본다. + + 반환: (헤더 행 인덱스, {표준키: 열 인덱스}). 못 찾으면 (None, {}). + """ + for idx, raw in enumerate(rows[:_HEADER_SCAN_ROWS]): + if raw is None: + continue + mapping = store.resolve_columns(raw) + if not store.missing_required(mapping): + return idx, mapping + return None, {} + + def _is_description_row(record: dict[str, str]) -> bool: """헤더 아래 '컬럼 설명' 행 판별. diff --git a/app/modules/dispatch/router.py b/app/modules/dispatch/router.py index 6aa174a..1e97771 100644 --- a/app/modules/dispatch/router.py +++ b/app/modules/dispatch/router.py @@ -6,13 +6,15 @@ DISPATCH_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내. - 업로드 원본은 DATA_DIR/dispatch/<날짜>/<배치slug>/ 에 저장, DB 엔 경로만 기록. -초보 물류 직원용 화면이라 고객정보는 일절 다루지 않는다. 작업 기준은 -Order ID / Package ID / Tracking ID / Seller SKU / Quantity 뿐이다. +초보 물류 직원용 화면. 작업 기준 키는 Order ID / Package ID / Tracking ID / +Seller SKU / Quantity 이고, 받는 사람(이름/전화/주소)은 작업 카드 표시 + 취합 +출고 엑셀 생성을 위해 박스 단위로 보관한다(개인정보). """ from __future__ import annotations import io +import logging import re import shutil import unicodedata @@ -26,15 +28,25 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Resp from app.timezone import now_kst, today_kst -from . import store -from .parser import ParseError, parse_order_export +from . import export, store +from .parser import ParseError, parse_export + +logger = logging.getLogger("dispatch.router") 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",)) +# 업로드 슬롯 (저장 파일명, 허용 확장자). 플랫폼마다 올리는 파일이 다르다. +# - 데이터 슬롯(필수): 자동 출고 리스트의 기준 엑셀. +# - 라벨 슬롯(선택): 실제 포장/라벨 원본 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",)), +} # ──────────────────────────────────────────────────────────── @@ -176,8 +188,9 @@ async def batch_new(request: Request) -> HTMLResponse: ctx.update( { "page_title": "말레이시아 배송 — 업로드", - "page_subtitle": "TikTok 파일 업로드 → 출고 배치 자동 생성", + "page_subtitle": "플랫폼 파일 업로드 → 출고 배치 자동 생성", "today": today_kst().isoformat(), + "platforms": store.PLATFORMS, "active_tab": "new", } ) @@ -190,23 +203,31 @@ async def batch_create( 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), + data_xlsx: UploadFile | None = File(None), user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: """파일 업로드 + 배치 생성 + 엑셀 파싱 + 박스 저장. - 03_TikTok_Order_Export.xlsx 는 필수(자동 출고 리스트의 기준). PDF 2개는 선택. - 같은 날짜/플랫폼이라도 새 배치로 생성한다(덮어쓰지 않음, 요구사항 12). + 플랫폼별 슬롯: + - 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 미설정") - if order_export is None or not (order_export.filename or "").strip(): + + 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="03_TikTok_Order_Export.xlsx 는 필수입니다(자동 출고 리스트 기준 데이터).", + detail=f"{data_slot[0]} 는 필수입니다(자동 출고 리스트 기준 데이터).", ) # 저장 폴더: DATA_DIR/dispatch/<날짜>/<배치slug>_/ @@ -214,13 +235,12 @@ async def batch_create( 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) + 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_order_export(order_path) + parsed = parse_export(order_path) except ParseError as exc: raise HTTPException(status_code=400, detail=str(exc)) @@ -235,7 +255,7 @@ async def batch_create( platform=platform, dispatch_date=dispatch_date, batch_name=name, - picking_pdf_path=picking_path, + picking_pdf_path="", label_pdf_path=label_path, order_export_path=order_path, ) @@ -266,7 +286,7 @@ async def batch_delete( async def batch_download( request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user) ) -> Response: - """배치에 업로드된 원본 파일(피킹/라벨 PDF + 주문 엑셀)을 zip 으로 묶어 다운로드.""" + """배치 다운로드: 업로드 원본(라벨 PDF + 데이터 엑셀) + 취합 출고 엑셀을 zip 으로 묶음.""" st = _store(request) if st is None: raise HTTPException(status_code=503, detail="dispatch_db 미설정") @@ -297,8 +317,21 @@ async def batch_download( 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="다운로드할 업로드 파일이 없습니다.") + raise HTTPException(status_code=404, detail="다운로드할 파일이 없습니다.") slug = _slugify(batch.get("batch_name") or "dispatch") fname = f"{batch.get('dispatch_date', '')}_{slug}.zip".lstrip("_") diff --git a/app/modules/dispatch/store.py b/app/modules/dispatch/store.py index 6168299..f303315 100644 --- a/app/modules/dispatch/store.py +++ b/app/modules/dispatch/store.py @@ -37,16 +37,33 @@ STATUS_LABELS: dict[str, dict[str, str]] = { "courier_scanned": {"ko": "택배스캔 확인", "en": "Courier Scanned"}, } +# ── 플랫폼 ── +# 주문처(엑셀 '주문처' 컬럼)이자 업로드 슬롯/파서 선택의 기준값. +PLATFORM_TIKTOK = "TikTok" +PLATFORM_SHOPEE = "Shopee" +PLATFORMS: tuple[str, ...] = (PLATFORM_TIKTOK, PLATFORM_SHOPEE) + + # ── 엑셀 컬럼명(앞뒤 공백 제거 후, 소문자 비교). 표준키 → 가능한 헤더들 ── -# 개인정보 컬럼은 의도적으로 매핑하지 않는다(이름/주소/전화는 버린다). +# TikTok(03_TikTok_Order_Export.xlsx)·Shopee(Packing List.Doorstep Delivery.xlsx) +# 두 포맷의 헤더를 한 테이블에 모은다(매칭되는 것만 사용). 헤더가 다르면 +# 해당 표준키 줄에 별칭 1개만 추가하면 된다. +# 받는 사람(recipient_*) 정보는 작업 카드 표시 + 출고 엑셀 생성을 위해 보관한다. COLUMN_ALIASES: dict[str, tuple[str, ...]] = { - "order_id": ("order id", "order_id", "주문번호"), - "package_id": ("package id", "package_id", "패키지번호"), - "tracking_id": ("tracking id", "tracking_id", "tracking number", "송장번호"), - "shipping_provider": ("shipping provider name", "shipping provider", "shipping_provider_name", "배송사", "택배사"), - "seller_sku": ("seller sku", "seller_sku", "sku", "판매자 sku"), - "product_name": ("product name", "product_name", "상품명"), - "quantity": ("quantity", "qty", "수량"), + "order_id": ("order id", "order_id", "order sn", "ordersn", "order no.", "order number", "주문번호"), + "package_id": ("package id", "package_id", "package number", "패키지번호"), + "tracking_id": ("tracking id", "tracking_id", "tracking number", "tracking no", "tracking no.", "awb", "awb no.", "송장번호"), + "shipping_provider": ("shipping provider name", "shipping provider", "shipping_provider_name", + "shipping option", "courier", "carrier", "logistics", "배송사", "택배사"), + "seller_sku": ("seller sku", "seller_sku", "sku", "sku reference no.", "sku reference no", + "parent sku reference no.", "판매자 sku", "상품코드"), + "product_name": ("product name", "product_name", "item name", "product", "상품명"), + "quantity": ("quantity", "qty", "수량", "수량(개)"), + "recipient_name": ("recipient", "recipient name", "buyer name", "consignee", "받는 사람", "받는사람", "수취인", "수령인"), + "recipient_phone": ("phone #", "phone#", "phone", "phone number", "contact number", "contact", + "전화", "전화번호", "연락처"), + "recipient_address": ("detail address", "detailed address", "address", "delivery address", + "shipping address", "full address", "주소", "배송지", "배송주소", "수령지 주소"), } # 사용자에게 보여줄 컬럼 이름(누락 안내 메시지용). 비교키는 소문자라 별도 표기. @@ -58,8 +75,14 @@ COLUMN_DISPLAY: dict[str, str] = { "seller_sku": "Seller SKU", "product_name": "Product Name", "quantity": "Quantity", + "recipient_name": "Recipient", + "recipient_phone": "Phone", + "recipient_address": "Address", } +# 박스 단위로 보존하는 받는 사람 필드(같은 박스의 첫 비어있지 않은 값 사용). +RECIPIENT_FIELDS: tuple[str, ...] = ("recipient_name", "recipient_phone", "recipient_address") + # 박스 묶음 기준 우선순위. 앞에서부터 비어있지 않은 첫 값으로 묶는다. BOX_KEY_PRIORITY: tuple[str, ...] = ("package_id", "tracking_id", "order_id") @@ -153,12 +176,16 @@ def group_parcels(rows: list[dict[str, str]]) -> list[dict[str, Any]]: "package_id": (row.get("package_id") or "").strip(), "tracking_id": (row.get("tracking_id") or "").strip(), "shipping_provider": (row.get("shipping_provider") or "").strip(), + "recipient_name": (row.get("recipient_name") or "").strip(), + "recipient_phone": (row.get("recipient_phone") or "").strip(), + "recipient_address": (row.get("recipient_address") or "").strip(), "_items": {}, # sku -> {product_name, quantity} } boxes[key] = box else: # 같은 박스인데 식별값이 빈 경우 뒤늦게 채운다(누락 보강). - for field in ("order_id", "package_id", "tracking_id", "shipping_provider"): + for field in ("order_id", "package_id", "tracking_id", "shipping_provider", + "recipient_name", "recipient_phone", "recipient_address"): if not box[field] and (row.get(field) or "").strip(): box[field] = (row.get(field) or "").strip() @@ -188,6 +215,9 @@ def group_parcels(rows: list[dict[str, str]]) -> list[dict[str, Any]]: "package_id": box["package_id"], "tracking_id": box["tracking_id"], "shipping_provider": box["shipping_provider"], + "recipient_name": box["recipient_name"], + "recipient_phone": box["recipient_phone"], + "recipient_address": box["recipient_address"], "items": items, } ) diff --git a/app/modules/dispatch/templates/dispatch/detail.html b/app/modules/dispatch/templates/dispatch/detail.html index f5feb6f..bb9d3ce 100644 --- a/app/modules/dispatch/templates/dispatch/detail.html +++ b/app/modules/dispatch/templates/dispatch/detail.html @@ -16,6 +16,10 @@ .dsp-order { font-size:15px;line-height:1.3;color:#334155;word-break:break-all; } .dsp-order b { display:block;font-size:14px;font-weight:700;color:#475569;letter-spacing:.5px; } .dsp-order span { font-size:27px;font-weight:800;color:#0f172a;letter-spacing:.2px; } + .dsp-recipient { border-top:1px dashed #e2e8f0;padding-top:8px; } + .dsp-rname { display:block;font-size:22px;font-weight:800;color:#0f172a;line-height:1.2;word-break:break-word; } + .dsp-raddr { font-size:14px;line-height:1.45;color:#334155;white-space:normal;word-break:break-word;margin-top:3px; } + .dsp-rphone { font-size:13px;color:#475569;margin-top:2px; } .dsp-track-link { color:#2563eb !important;font-size:18px;font-weight:800; text-decoration:underline !important;text-decoration-line:underline !important; text-decoration-thickness:2px;text-underline-offset:3px;cursor:pointer; } @@ -54,7 +58,7 @@ + placeholder="검색: Order ID / Tracking ID / 받는 사람 / SKU" />
@@ -64,7 +68,7 @@ data-parcel="{{ p.id }}" data-label_attached="{{ p.label_attached|lower }}" data-handed_to_kagayaku="{{ p.handed_to_kagayaku|lower }}" - data-search="{{ (p.order_id ~ ' ' ~ p.package_id ~ ' ' ~ p.tracking_id ~ ' ' ~ (p['items']|map(attribute='seller_sku')|join(' ')))|lower }}"> + data-search="{{ (p.order_id ~ ' ' ~ p.tracking_id ~ ' ' ~ p.recipient_name ~ ' ' ~ (p['items']|map(attribute='seller_sku')|join(' ')))|lower }}"> {% set logo = p.shipping_provider | courier_logo %} {% if logo %} {{ p.shipping_provider }} @@ -74,13 +78,17 @@
No. {{ p.seq }}
Order ID{{ p.order_id or '—' }}
-
Package {{ p.package_id or '—' }}
Tracking {% set turl = p.tracking_id | courier_track(p.shipping_provider) %} {% if turl %}{{ p.tracking_id }} {% else %}{{ p.tracking_id or '—' }}{% endif %}
+
+ {{ p.recipient_name or '—' }} + {% if p.recipient_address %}
{{ p.recipient_address }}
{% endif %} + {% if p.recipient_phone %}
{{ p.recipient_phone }}
{% endif %} +