fix(dispatch): Shopee Packing List xlsx 파싱 추가

- 헤더 별칭에 underscore형(order_sn, tracking_number) 추가
- Shopee 는 SKU/수량이 product_info 한 셀에 묶여 있어 전용 파서 추가
  · store.parse_shopee_product_info: [n] 단위 상품 분해, SKU/상품명/수량 추출
  · parser._parse_shopee: 1행=1주문=1박스, 상품 펼쳐 박스로 묶음, 배송사 SPX 추론
- parse_export(platform) 로 Shopee/일반 경로 분기, 라우터가 platform 전달
- 이름/주소/전화는 xlsx 에 없음 → 라벨 PDF 단계에서 보강 예정
- 테스트 3개 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:29:54 +09:00
parent f1097b85dc
commit 691eecee52
4 changed files with 187 additions and 34 deletions
+95 -31
View File
@@ -27,13 +27,23 @@ class ParseError(Exception):
# 헤더가 첫 행이 아닐 수 있어 상단에서 헤더 행을 찾을 때 훑는 최대 행 수.
_HEADER_SCAN_ROWS = 15
# Shopee SPX 송장번호 접두사 → 배송사. xlsx 에 배송사 컬럼이 없어 송장으로 추론.
_SHOPEE_DEFAULT_PROVIDER = "SPX"
def parse_export(path: str) -> dict[str, Any]:
def parse_export(path: str, platform: str = "") -> dict[str, Any]:
"""출고 XLSX 경로 → {"parcels": [...], "row_count": int}.
parcels 는 store.group_parcels 결과(1박스=1항목). 실패 시 ParseError.
TikTok·Shopee 공용(표준키 매핑이 흡수한다).
Shopee 는 SKU/수량이 product_info 한 셀에 묶여 있어 별도 경로로 파싱한다.
"""
if (platform or "").strip() == store.PLATFORM_SHOPEE:
return _parse_shopee(path)
return _parse_generic(path)
def _load_rows(path: str) -> list:
"""첫 시트 전체 행(values_only). 열기/빈 파일 예외는 ParseError 로."""
try:
from openpyxl import load_workbook # noqa: WPS433 — 지연 import
except ImportError as exc: # pragma: no cover
@@ -46,38 +56,92 @@ def parse_export(path: str) -> dict[str, Any]:
wb = load_workbook(path, data_only=True)
except Exception as exc: # noqa: BLE001
raise ParseError(f"엑셀 파일을 열 수 없습니다: {type(exc).__name__}") from exc
try:
ws = wb.worksheets[0]
all_rows = list(ws.iter_rows(values_only=True))
if not all_rows:
raise ParseError("엑셀에 데이터가 없습니다(빈 파일).")
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 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()
}
# 완전 빈 행 스킵
if not any(record.get(k) for k in ("order_id", "package_id", "tracking_id", "seller_sku")):
continue
# 헤더 바로 아래 '컬럼 설명' 행 스킵(TikTok export 2번째 행).
# 주문/송장/패키지 ID 는 공백을 포함하지 않는다. 설명 문장은 공백을
# 포함하므로, 채워진 ID 값에 공백이 있으면 데이터가 아니라 설명 행이다.
if _is_description_row(record):
continue
norm_rows.append(record)
rows = list(wb.worksheets[0].iter_rows(values_only=True))
finally:
wb.close()
if not rows:
raise ParseError("엑셀에 데이터가 없습니다(빈 파일).")
return rows
def _parse_generic(path: str) -> dict[str, Any]:
"""TikTok 등 표준 컬럼형 엑셀: 헤더 행 자동탐지 → 표준키 매핑 → 박스 묶기."""
all_rows = _load_rows(path)
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 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()
}
# 완전 빈 행 스킵
if not any(record.get(k) for k in ("order_id", "package_id", "tracking_id", "seller_sku")):
continue
# 헤더 바로 아래 '컬럼 설명' 행 스킵(TikTok export 2번째 행).
# 주문/송장/패키지 ID 는 공백을 포함하지 않는다. 설명 문장은 공백을
# 포함하므로, 채워진 ID 값에 공백이 있으면 데이터가 아니라 설명 행이다.
if _is_description_row(record):
continue
norm_rows.append(record)
parcels = store.group_parcels(norm_rows)
return {"parcels": parcels, "row_count": len(norm_rows)}
def _parse_shopee(path: str) -> dict[str, Any]:
"""Shopee Packing List.Doorstep Delivery.xlsx 파싱.
컬럼: order_sn, tracking_number, product_info(상품 N개가 한 셀에 묶임).
1행 = 1주문 = 1박스. product_info 를 펼쳐 상품별 행을 만든 뒤 박스로 묶는다.
이름/주소/전화는 xlsx 에 없으므로 라벨 PDF 단계에서 채운다(여기선 빈 값).
"""
all_rows = _load_rows(path)
header_idx = order_idx = track_idx = pinfo_idx = None
for idx, raw in enumerate(all_rows[:_HEADER_SCAN_ROWS]):
if raw is None:
continue
oi = store.find_column(raw, store.COLUMN_ALIASES["order_id"])
ti = store.find_column(raw, store.COLUMN_ALIASES["tracking_id"])
pi = store.find_column(raw, store.PRODUCT_INFO_ALIASES)
if pi is not None and (oi is not None or ti is not None):
header_idx, order_idx, track_idx, pinfo_idx = idx, oi, ti, pi
break
if header_idx is None:
raise ParseError(
"Shopee 엑셀에서 필요한 컬럼을 찾지 못했습니다: "
"product_info + (order_sn 또는 tracking_number)"
)
norm_rows: list[dict[str, str]] = []
for raw in all_rows[header_idx + 1:]:
if raw is None:
continue
order = store.clean_text(raw[order_idx]) if order_idx is not None and order_idx < len(raw) else ""
tracking = store.clean_text(raw[track_idx]) if track_idx is not None and track_idx < len(raw) else ""
blob = raw[pinfo_idx] if pinfo_idx < len(raw) else ""
if not (order or tracking):
continue
for it in store.parse_shopee_product_info(blob):
norm_rows.append(
{
"order_id": order,
"tracking_id": tracking,
"shipping_provider": _SHOPEE_DEFAULT_PROVIDER,
"seller_sku": it["seller_sku"],
"product_name": it["product_name"],
"quantity": it["quantity"],
}
)
parcels = store.group_parcels(norm_rows)
return {"parcels": parcels, "row_count": len(norm_rows)}