From 8d7fe01c2dd45ccab298a40cb62923c28f3c0382 Mon Sep 17 00:00:00 2001 From: king Date: Tue, 23 Jun 2026 12:16:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20=EB=A9=94=EB=89=B4=EC=96=BC?= =?UTF-8?q?=20=EC=98=A4=EB=8D=94=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(=EC=97=91=EC=85=80=20=EB=8B=A8=EB=8F=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 플랫폼 Manual 추가. 엑셀 첫 시트(Receiver Name/Phone/Address/Product Name/ Item Code/Quantity)를 전용 파서로 읽어 1행=1박스 생성 · 받는 사람 정보가 엑셀에 직접 있어 PDF 불필요(라벨 슬롯 없음) · 묶음키 없음 → 행마다 별도 박스 - 업로드 화면: Manual 선택 시 라벨 PDF 칸 숨김 + 안내문 - 출고 카드: Order ID/Tracking 은 값 있을 때만 표시(Manual 은 숨김), Manual 은 상품명 표기(받는사람/전화/주소/상품명/수량) - 재고 차감/사방넷 출고는 기존 집계 로직 그대로(콤보 _N 분해 포함) Co-Authored-By: Claude Opus 4.8 --- app/modules/dispatch/parser.py | 90 ++++++++++++++++++- app/modules/dispatch/router.py | 6 +- app/modules/dispatch/store.py | 3 +- .../dispatch/templates/dispatch/detail.html | 10 ++- .../dispatch/templates/dispatch/new.html | 14 ++- 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/app/modules/dispatch/parser.py b/app/modules/dispatch/parser.py index 7c7b16d..ff66571 100644 --- a/app/modules/dispatch/parser.py +++ b/app/modules/dispatch/parser.py @@ -35,13 +35,99 @@ def parse_export(path: str, platform: str = "") -> dict[str, Any]: """출고 XLSX 경로 → {"parcels": [...], "row_count": int}. parcels 는 store.group_parcels 결과(1박스=1항목). 실패 시 ParseError. - Shopee 는 SKU/수량이 product_info 한 셀에 묶여 있어 별도 경로로 파싱한다. + - Shopee: SKU/수량이 product_info 한 셀에 묶여 있어 별도 경로. + - Manual: 받는 사람/상품/수량이 첫 시트 한 행에 있는 수동 오더(1행=1박스). """ - if (platform or "").strip() == store.PLATFORM_SHOPEE: + p = (platform or "").strip() + if p == store.PLATFORM_SHOPEE: return _parse_shopee(path) + if p == store.PLATFORM_MANUAL: + return _parse_manual(path) return _parse_generic(path) +# 메뉴얼 오더 엑셀 컬럼 별칭(헤더 정규화 후 비교). 받는 사람 정보가 엑셀에 직접 있다. +_MANUAL_ALIASES: dict[str, tuple[str, ...]] = { + "recipient_name": ("receiver name", "recipient name", "recipient", "받는 사람", "받는사람", "수취인", "이름", "name"), + "recipient_phone": ("phone", "phone number", "phone #", "contact", "전화", "전화번호", "연락처"), + "recipient_address": ("address", "delivery address", "주소", "배송지", "배송주소"), + "product_name": ("product name", "item name", "product", "상품명", "상품이름"), + "seller_sku": ("item code", "seller sku", "sku", "아이템코드", "아이템 코드", "상품코드", "코드"), + "quantity": ("quantity", "qty", "수량", "주문수량"), +} + + +def _parse_manual(path: str) -> dict[str, Any]: + """메뉴얼 오더 엑셀(첫 시트). 1행 = 1박스. + + 컬럼: Receiver Name / Phone / Address / Product Name / Item Code / Quantity. + 받는 사람 정보가 엑셀에 직접 있어 PDF 없이 카드/엑셀에 표시된다. 묶음키 + (Order/Tracking/Package)가 없으므로 행마다 별도 박스(seq)로 만든다. + """ + all_rows = _load_rows(path) + + header_idx = None + mapping: dict[str, int] = {} + for idx, raw in enumerate(all_rows[:_HEADER_SCAN_ROWS]): + if raw is None: + continue + m = { + key: store.find_column(raw, aliases) + for key, aliases in _MANUAL_ALIASES.items() + } + # 최소: 아이템코드 컬럼이 잡혀야 한다. + if m.get("seller_sku") is not None: + header_idx = idx + mapping = {k: v for k, v in m.items() if v is not None} + break + + if header_idx is None: + raise ParseError( + "메뉴얼 오더 엑셀에서 컬럼을 찾지 못했습니다. " + "필요 컬럼: Item Code(필수), Receiver Name, Phone, Address, Product Name, Quantity" + ) + + def cell(raw: Any, key: str) -> str: + idx = mapping.get(key) + if idx is None or idx >= len(raw): + return "" + return store.clean_text(raw[idx]) + + parcels: list[dict[str, Any]] = [] + seq = 0 + for raw in all_rows[header_idx + 1:]: + if raw is None: + continue + sku = cell(raw, "seller_sku") + name = cell(raw, "recipient_name") + if not sku and not name: + continue # 빈 행 + if not sku: + continue # 아이템코드 없으면 출고 대상 아님 + seq += 1 + parcels.append( + { + "seq": seq, + "order_id": "", + "package_id": "", + "tracking_id": "", + "shipping_provider": "", + "recipient_name": name, + "recipient_phone": cell(raw, "recipient_phone"), + "recipient_address": cell(raw, "recipient_address"), + "items": [ + { + "seller_sku": sku, + "product_name": cell(raw, "product_name"), + "quantity": store.normalize_quantity(cell(raw, "quantity")), + } + ], + } + ) + + return {"parcels": parcels, "row_count": len(parcels)} + + def _load_rows(path: str) -> list: """첫 시트 전체 행(values_only). 열기/빈 파일 예외는 ParseError 로.""" try: diff --git a/app/modules/dispatch/router.py b/app/modules/dispatch/router.py index f644425..809a8bd 100644 --- a/app/modules/dispatch/router.py +++ b/app/modules/dispatch/router.py @@ -43,7 +43,9 @@ router = APIRouter(prefix="/dispatch", tags=["dispatch"]) _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",)), @@ -259,7 +261,7 @@ async def batch_create( if platform not in _DATA_SLOTS: raise HTTPException(status_code=400, detail=f"지원하지 않는 플랫폼입니다: {platform}") data_slot = _DATA_SLOTS[platform] - label_slot = _LABEL_SLOTS[platform] + label_slot = _LABEL_SLOTS.get(platform) # Manual 은 라벨 없음(None) if data_xlsx is None or not (data_xlsx.filename or "").strip(): raise HTTPException( @@ -272,7 +274,7 @@ async def batch_create( 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) + 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) # 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다. diff --git a/app/modules/dispatch/store.py b/app/modules/dispatch/store.py index 99637e7..918fafc 100644 --- a/app/modules/dispatch/store.py +++ b/app/modules/dispatch/store.py @@ -42,7 +42,8 @@ STATUS_LABELS: dict[str, dict[str, str]] = { # 주문처(엑셀 '주문처' 컬럼)이자 업로드 슬롯/파서 선택의 기준값. PLATFORM_TIKTOK = "TikTok" PLATFORM_SHOPEE = "Shopee" -PLATFORMS: tuple[str, ...] = (PLATFORM_TIKTOK, PLATFORM_SHOPEE) +PLATFORM_MANUAL = "Manual" +PLATFORMS: tuple[str, ...] = (PLATFORM_TIKTOK, PLATFORM_SHOPEE, PLATFORM_MANUAL) # ── 엑셀 컬럼명(앞뒤 공백 제거 후, 소문자 비교). 표준키 → 가능한 헤더들 ── diff --git a/app/modules/dispatch/templates/dispatch/detail.html b/app/modules/dispatch/templates/dispatch/detail.html index 1968728..d442aec 100644 --- a/app/modules/dispatch/templates/dispatch/detail.html +++ b/app/modules/dispatch/templates/dispatch/detail.html @@ -89,14 +89,18 @@ {{ p.shipping_provider }} {% endif %}
No. {{ p.seq }}
-
Order ID{{ p.order_id or '—' }}
+ {% if p.order_id %} +
Order ID{{ p.order_id }}
+ {% endif %} + {% if p.tracking_id %}
Tracking {% set turl = p.tracking_id | courier_track(p.shipping_provider) %} {% if turl %}{{ p.tracking_id }} - {% else %}{{ p.tracking_id or '—' }}{% endif %} + {% else %}{{ p.tracking_id }}{% endif %}
+ {% endif %}
{{ p.recipient_name or '—' }} {% if p.recipient_address %}
{{ p.recipient_address }}
{% endif %} @@ -104,7 +108,7 @@