From 106cf70922e84ef4836903f6557610d8c5350ca5 Mon Sep 17 00:00:00 2001 From: king Date: Mon, 22 Jun 2026 14:45:35 +0900 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20=EB=9D=BC=EB=B2=A8=20PDF?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B0=9B=EB=8A=94=20=EC=82=AC=EB=9E=8C(?= =?UTF-8?q?=EC=9D=B4=EB=A6=84/=EC=A0=84=ED=99=94/=EC=A3=BC=EC=86=8C)=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 + app/modules/dispatch/pdf_parser.py | 209 ++++++++++++++++++ app/modules/dispatch/router.py | 17 ++ .../dispatch/templates/dispatch/new.html | 8 +- docs/DISPATCH_MODULE.md | 12 +- requirements.txt | 1 + 6 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 app/modules/dispatch/pdf_parser.py 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