diff --git a/.gitignore b/.gitignore
index 85649c9..08c0512 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,6 @@ __pycache__/
.idea/
.claude/
+
+# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
+docs/samples/
diff --git a/app/modules/dispatch/pdf_parser.py b/app/modules/dispatch/pdf_parser.py
new file mode 100644
index 0000000..6cc8665
--- /dev/null
+++ b/app/modules/dispatch/pdf_parser.py
@@ -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()
diff --git a/app/modules/dispatch/router.py b/app/modules/dispatch/router.py
index d68e697..6d5184f 100644
--- a/app/modules/dispatch/router.py
+++ b/app/modules/dispatch/router.py
@@ -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,
diff --git a/app/modules/dispatch/templates/dispatch/new.html b/app/modules/dispatch/templates/dispatch/new.html
index 0d4867e..707fbcf 100644
--- a/app/modules/dispatch/templates/dispatch/new.html
+++ b/app/modules/dispatch/templates/dispatch/new.html
@@ -54,13 +54,13 @@
// 플랫폼별 안내/파일 라벨. 데이터 엑셀이 필수, 라벨 PDF 는 선택(보관용).
var CFG = {
"TikTok": {
- intro: "TikTok: 03_TikTok_Order_Export.xlsx 만 있으면 출고 리스트가 자동 생성됩니다. 라벨 PDF 는 보관용(선택)입니다.",
- label: "📄 Shipping label + Packing slip.pdf (선택 · 실제 포장/라벨 원본)",
+ intro: "TikTok: Order Export.xlsx 로 출고 리스트가 생성됩니다. 받는 사람 이름/주소는 라벨 PDF 에서 가져오니 PDF 도 함께 올리세요.",
+ label: "📄 Shipping label + Packing slip.pdf (받는 사람 이름/주소 추출 · 권장)",
data: "📊 TikTok Order Export.xlsx * (자동 출고 리스트 기준)"
},
"Shopee": {
- intro: "Shopee: Packing List.Doorstep Delivery.xlsx 로 출고 리스트가 자동 생성됩니다. Shopee Seller Centre.pdf(택배 송장)는 보관용(선택)입니다.",
- label: "📄 Shopee Seller Centre.pdf (선택 · 택배 송장 원본)",
+ intro: "Shopee: Packing List.Doorstep Delivery.xlsx 로 출고 리스트가 생성됩니다. 받는 사람 이름/주소는 Shopee Seller Centre.pdf 에서 가져오니 PDF 도 함께 올리세요.",
+ label: "📄 Shopee Seller Centre.pdf (받는 사람 이름/주소 추출 · 권장)",
data: "📊 Packing List.Doorstep Delivery.xlsx * (자동 출고 리스트 기준)"
}
};
diff --git a/docs/DISPATCH_MODULE.md b/docs/DISPATCH_MODULE.md
index f70eb50..c1fbf38 100644
--- a/docs/DISPATCH_MODULE.md
+++ b/docs/DISPATCH_MODULE.md
@@ -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` 로 컬럼을
추가한다(멱등). 접근/보관 기간 정책을 별도로 검토할 것.
diff --git a/requirements.txt b/requirements.txt
index 58a4a26..2da3ac2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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