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
+3
View File
@@ -20,3 +20,6 @@ __pycache__/
.idea/
.claude/
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
docs/samples/
+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()
+17
View File
@@ -30,6 +30,7 @@ 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")
@@ -251,6 +252,22 @@ async def batch_create(
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 = {}
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) and not (p.get(field) or "").strip():
p[field] = rec[field]
batch = st.create_batch(
platform=platform,
dispatch_date=dispatch_date,
@@ -54,13 +54,13 @@
// 플랫폼별 안내/파일 라벨. 데이터 엑셀이 필수, 라벨 PDF 는 선택(보관용).
var CFG = {
"TikTok": {
intro: "TikTok: 03_TikTok_Order_Export.xlsx 만 있으면 출고 리스트가 자동 생성됩니다. 라벨 PDF 는 보관용(선택)입니다.",
label: "📄 Shipping label + Packing slip.pdf <span class='erp-muted'>(선택 · 실제 포장/라벨 원본)</span>",
intro: "TikTok: Order Export.xlsx 출고 리스트가 생성됩니다. 받는 사람 이름/주소는 라벨 PDF 에서 가져오니 PDF 도 함께 올리세요.",
label: "📄 Shipping label + Packing slip.pdf <span class='erp-muted'>(받는 사람 이름/주소 추출 · 권장)</span>",
data: "📊 TikTok Order Export.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(자동 출고 리스트 기준)</span>"
},
"Shopee": {
intro: "Shopee: Packing List.Doorstep Delivery.xlsx 로 출고 리스트가 자동 생성됩니다. Shopee Seller Centre.pdf(택배 송장)는 보관용(선택)입니다.",
label: "📄 Shopee Seller Centre.pdf <span class='erp-muted'>(선택 · 택배 송장 원본)</span>",
intro: "Shopee: Packing List.Doorstep Delivery.xlsx 로 출고 리스트가 생성됩니다. 받는 사람 이름/주소는 Shopee Seller Centre.pdf 에서 가져오니 PDF 도 함께 올리세요.",
label: "📄 Shopee Seller Centre.pdf <span class='erp-muted'>(받는 사람 이름/주소 추출 · 권장)</span>",
data: "📊 Packing List.Doorstep Delivery.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(자동 출고 리스트 기준)</span>"
}
};
+10 -2
View File
@@ -154,8 +154,16 @@ python -m app.modules.dispatch.tests.test_grouping # 앱 의존성 설치 환
- 스택: FastAPI · PostgreSQL(psycopg3) · Jinja2 · 기존 ERP CSS(Bootstrap 미사용,
레포 공통 `erp.css` 재사용) · **openpyxl**(스펙의 pandas 대신, 레포 의존성 경량 유지).
- 코드: `app/modules/dispatch/` (`store.py` 순수 로직 / `parser.py` 엑셀 파싱 /
`export.py` 취합 출고 엑셀 생성 / `db.py` DB I/O / `router.py` 라우팅 /
`templates/dispatch/` 화면).
`pdf_parser.py` 라벨 PDF 받는사람 추출 / `export.py` 취합 출고 엑셀 생성 /
`db.py` DB I/O / `router.py` 라우팅 / `templates/dispatch/` 화면).
- 데이터 출처(하이브리드):
- **xlsx** = 박스/SKU/수량/주문/송장. TikTok 은 표준 컬럼, Shopee 는 SKU·수량이
`product_info` 한 셀에 묶여 있어 `store.parse_shopee_product_info` 로 분해.
- **라벨 PDF** = 받는 사람 이름/전화/주소. xlsx 엔 없거나 가려져 있어 PDF 가 출처.
업로드 시 Order ID 로 박스와 조인(`pdf_parser.extract_recipients`, pdfplumber).
TikTok 라벨은 배송사마다 양식이 달라 `To <이름> (+60)…`(Ninja)·`Receiver <이름>`
(NDD) 등을 모두 처리. Shopee SPX 라벨은 2단 중 왼쪽 열만 읽는다(전화 미표시).
PDF 가 없거나 파싱 실패해도 업로드는 진행(해당 PII 만 빈 값).
- 개인정보 보관: `dispatch_parcels.recipient_name/recipient_phone/recipient_address`.
기존 DB 에는 마이그레이션 `scripts/sql/dispatch_add_recipient_columns.sql` 로 컬럼을
추가한다(멱등). 접근/보관 기간 정책을 별도로 검토할 것.
+1
View File
@@ -8,4 +8,5 @@ python-dotenv>=1.0
psycopg[binary,pool]>=3.2
python-multipart>=0.0.20
openpyxl>=3.1
pdfplumber>=0.11
pillow>=10.0