feat(dispatch): 라벨 PDF에서 받는 사람(이름/전화/주소) 추출

xlsx 엔 받는 사람이 없거나(Shopee) 가려져(TikTok) 라벨 PDF 를 출처로 사용.
업로드 시 Order ID 로 박스와 조인해 채운다(하이브리드).

- pdf_parser.py 추가 (pdfplumber)
  · Shopee SPX 라벨: 2단 중 왼쪽 열만 읽어 Recipient 블록 추출(전화 미표시)
  · TikTok 라벨: 배송사별 양식 대응 — To <이름> (+60)…(Ninja), Receiver <이름>(NDD)
  · 주소 영역에 섞인 10자리+ 바코드 숫자 제거(우편번호 5자리 보존)
  · PDF 없거나 파싱 실패해도 업로드 진행(PII 만 빈 값)
- router: 라벨 PDF 파싱 결과를 parcels 에 머지 후 저장
- requirements: pdfplumber>=0.11 (Docker 재빌드 필요)
- new.html: PDF 가 받는 사람 출처임을 안내(권장)
- .gitignore: docs/samples/ (실제 고객 PII PDF — 커밋 금지)
- 문서 갱신

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 14:45:35 +09:00
parent 691eecee52
commit 106cf70922
6 changed files with 244 additions and 6 deletions
+209
View File
@@ -0,0 +1,209 @@
"""라벨 PDF → 주문별 받는 사람(이름/전화/주소) 추출 (pdfplumber).
xlsx 는 박스/SKU/수량/주문/송장을 담당하고, 받는 사람 개인정보는 가려져 있거나
컬럼이 없어 라벨 PDF 에서 가져온다. 추출 결과는 Order ID 로 박스와 조인한다.
- Shopee(SPX 라벨): 1주문 = 라벨 1p + 패킹리스트 1p. 라벨 페이지에
'Recipient Details (Penerima)' 블록이 있다. 2단 레이아웃이라 왼쪽 열만 본다.
전화는 표시되지 않는다(빈 값).
- TikTok: 1주문 = 1p. 'To <이름> (+60)<전화>' 줄 + 그 아래 주소. 전화는 일부
마스킹될 수 있다(보이는 그대로 보존).
좌표 의존 파서라 라벨 양식이 바뀌면 조정이 필요하다(상단 상수만 손보면 됨).
"""
from __future__ import annotations
import logging
import re
from typing import Any
logger = logging.getLogger("dispatch.pdf")
# Shopee 라벨 2단 중 왼쪽 열 경계(x0 < 값). 오른쪽 열의 바코드/코드(SPXMY…,
# Seller Details, KV6-…)를 배제한다.
_SHOPEE_LEFT_MAX = 185
# 같은 줄로 묶을 top 좌표 허용 오차(px).
_LINE_TOL = 3
# 전화번호 토큰: (+60)193819581 / (+60)11******36 등.
_PHONE_RE = re.compile(r"\(\+?\d[\d()*\- ]*")
class PdfParseError(Exception):
"""PDF 파싱 실패. message 는 사용자에게 보여줄 한국어 메시지."""
def extract_recipients(path: str, platform: str) -> dict[str, dict[str, str]]:
"""라벨 PDF → {order_id: {recipient_name, recipient_phone, recipient_address}}.
실패해도 빈 dict 를 반환할 수 있다(호출부는 PII 없이 진행). 열기 자체 실패는
PdfParseError.
"""
try:
import pdfplumber # noqa: WPS433 — 지연 import
except ImportError as exc: # pragma: no cover
raise PdfParseError("pdfplumber 가 설치되어 있지 않습니다.") from exc
try:
pdf = pdfplumber.open(path)
except Exception as exc: # noqa: BLE001
raise PdfParseError(f"PDF 를 열 수 없습니다: {type(exc).__name__}") from exc
out: dict[str, dict[str, str]] = {}
try:
for page in pdf.pages:
try:
words = page.extract_words()
except Exception: # noqa: BLE001
continue
if not words:
continue
if platform == "Shopee":
rec = _shopee_page(words)
else:
rec = _tiktok_page(words)
if rec and rec.get("order_id"):
oid = rec.pop("order_id")
# 이미 있으면 빈 값만 보강(중복 페이지 방어).
cur = out.setdefault(oid, {"recipient_name": "", "recipient_phone": "", "recipient_address": ""})
for k, v in rec.items():
if v and not cur.get(k):
cur[k] = v
finally:
pdf.close()
return out
# ────────────────────────────────────────────────────────────
# 줄 단위 헬퍼
# ────────────────────────────────────────────────────────────
def _lines(words: list[dict[str, Any]], *, x_max: float | None = None) -> list[tuple[float, list[dict[str, Any]]]]:
"""단어들을 top 좌표로 묶어 줄 리스트로. 각 줄: (top, [word,...]) x0 오름차순."""
ws = [w for w in words if x_max is None or w["x0"] < x_max]
ws.sort(key=lambda w: (round(w["top"]), w["x0"]))
lines: list[tuple[float, list[dict[str, Any]]]] = []
for w in ws:
if lines and abs(w["top"] - lines[-1][0]) <= _LINE_TOL:
lines[-1][1].append(w)
else:
lines.append((w["top"], [w]))
return lines
def _text(line_words: list[dict[str, Any]]) -> str:
return " ".join(w["text"] for w in sorted(line_words, key=lambda w: w["x0"])).strip()
# ────────────────────────────────────────────────────────────
# Shopee 라벨 페이지
# ────────────────────────────────────────────────────────────
def _shopee_page(words: list[dict[str, Any]]) -> dict[str, str] | None:
lines = _lines(words, x_max=_SHOPEE_LEFT_MAX)
texts = [_text(lw) for _, lw in lines]
joined = " ".join(texts)
if "Recipient Details" not in joined and "Penerima" not in joined:
return None # 라벨 페이지 아님(패킹리스트 등)
order_id = ""
rec_idx = None
for i, t in enumerate(texts):
if not order_id:
m = re.search(r"Order\s*ID\s*:\s*([A-Za-z0-9]+)", t)
if m:
order_id = m.group(1)
if rec_idx is None and ("Recipient Details" in t or "Penerima" in t):
rec_idx = i
if rec_idx is None:
return None
name = ""
addr_parts: list[str] = []
postcode = ""
collecting = False
for t in texts[rec_idx + 1:]:
if t.startswith("Name:"):
name = t[len("Name:"):].strip()
continue
if t.startswith("Address:"):
collecting = True
addr_parts.append(t[len("Address:"):].strip())
continue
if t.startswith("Postcode:"):
postcode = t[len("Postcode:"):].strip()
break # 받는 사람 우편번호에서 주소 끝
if collecting:
# 프로모/푸터 시작 전까지 주소 줄로 본다.
if t.startswith(("Select", "Scan", "Seller Details")):
break
addr_parts.append(t)
address = ", ".join(p for p in addr_parts if p)
if postcode and postcode not in address:
address = f"{address}, {postcode}" if address else postcode
address = _tidy(address)
return {"order_id": order_id, "recipient_name": name, "recipient_phone": "", "recipient_address": address}
# ────────────────────────────────────────────────────────────
# TikTok 라벨 페이지
# ────────────────────────────────────────────────────────────
# 주소 수집을 멈추는 키워드(섹션 경계). TikTok 라벨은 배송사마다 양식이 달라
# 넉넉히 둔다.
_TIKTOK_STOP = ("CASHLESS", "CASH ON", "COD", "Order ID", "Shipping Date",
"Product Name", "Estimated", "In transit", "DROP-OFF", "PICK-UP",
"Self Collect", "Order Created", "Return for", "Scan me", "Sender")
# 받는 사람 앵커: 'To <이름> (+60)…'(Ninja) 또는 'Receiver <이름>'(NDD 등).
_TIKTOK_ANCHOR = re.compile(r"^(To|Receiver)\b\s*(.*)$")
def _tiktok_page(words: list[dict[str, Any]]) -> dict[str, str] | None:
lines = _lines(words)
texts = [_text(lw) for _, lw in lines]
order_id = ""
for t in texts:
m = re.search(r"Order\s*ID\s*:\s*(\d+)", t)
if m:
order_id = m.group(1)
break
if not order_id:
return None
anchor_idx = None
for i, t in enumerate(texts):
m = _TIKTOK_ANCHOR.match(t)
if m and (m.group(1) == "Receiver" or _PHONE_RE.search(t)):
anchor_idx = i
break
if anchor_idx is None:
return {"order_id": order_id, "recipient_name": "", "recipient_phone": "", "recipient_address": ""}
rest = _TIKTOK_ANCHOR.match(texts[anchor_idx]).group(2).strip()
phone = ""
name = rest
pm = _PHONE_RE.search(rest)
if pm: # 'To <이름> (+60)<전화>' 형식
phone = rest[pm.start():].strip()
name = rest[:pm.start()].strip()
addr_parts: list[str] = []
for t in texts[anchor_idx + 1:]:
if any(k in t for k in _TIKTOK_STOP):
break
if t.strip():
addr_parts.append(t.strip())
# 일부 배송사 라벨은 바코드/송장 숫자가 주소 영역에 섞인다. 10자리 이상
# 숫자열은 주소가 아니라 바코드이므로 제거(우편번호 5자리는 보존).
address = re.sub(r"\b\d{10,}\b", "", ", ".join(addr_parts))
address = _tidy(address)
return {"order_id": order_id, "recipient_name": name, "recipient_phone": phone, "recipient_address": address}
def _tidy(s: str) -> str:
"""중복 공백/콤마 정리."""
s = re.sub(r"\s+", " ", s)
s = re.sub(r"\s*,\s*", ", ", s)
s = re.sub(r"(,\s*){2,}", ", ", s)
return s.strip().strip(",").strip()