Files
dbx-main/app/modules/dispatch/export.py
T
king f1097b85dc feat(dispatch): Shopee 플랫폼 추가 + 받는 사람 정보 저장/출고 엑셀
- 플랫폼 TikTok/Shopee 분리: 업로드 화면이 선택에 따라 파일 안내 변경
  (TikTok=Order Export xlsx, Shopee=Packing List xlsx / 라벨 PDF 선택)
- TikTok Picking List.pdf 업로드 제거(피킹 요약은 데이터 엑셀로 생성)
- 파서 공용화 + 헤더 행 자동탐지(Shopee 제목행 대응), 두 포맷 헤더 별칭 통합
- 받는 사람 이름/전화/주소를 박스 단위로 dispatch_db 에 저장(개인정보)
  · init SQL 갱신 + 기존 DB용 멱등 마이그레이션 dispatch_add_recipient_columns.sql
- 출고 작업 카드: Package 제거, 받는 사람 이름(크게)+주소(여러 줄)+전화 표시
- 배치 다운로드 zip 에 취합 출고 엑셀 포함
  파일명 YYYY.MM.DD(Ddd)_tictoc.xlsx / _shopee.xlsx (export.py)
- 문서(CLAUDE.md, DISPATCH_MODULE.md) PII 보관·Shopee 반영

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:16:11 +09:00

126 lines
4.3 KiB
Python

"""출고 배치 → 취합 엑셀(openpyxl) 생성.
다운로드 zip 에 업로드 원본과 함께 넣는 '출고 엑셀'을 만든다. 1박스 안에 여러
SKU 가 있으면 SKU 당 1행으로 펼치고 박스 단위(받는 사람/주문/택배) 정보는 반복한다.
파일명 형식: 2026.06.22(Mon)_tictoc.xlsx (Shopee 는 끝이 _shopee).
- TikTok 은 운영 요청 철자에 맞춰 'tictoc' 을 쓴다.
"""
from __future__ import annotations
import io
from datetime import date, datetime
from typing import Any
# 엑셀 컬럼(요청 순서). (헤더, 박스/배치/상품 키)
_COLUMNS: tuple[tuple[str, str], ...] = (
("발송 날짜", "dispatch_date"),
("고객 이름", "recipient_name"),
("전화번호", "recipient_phone"),
("주소", "recipient_address"),
("상품이름", "product_name"),
("아이템 코드", "seller_sku"),
("주문 수량", "quantity"),
("오더번호", "order_id"),
("택배사", "shipping_provider"),
("택배송장번호", "tracking_id"),
("주문처", "platform"),
)
# 플랫폼 → 파일명 접미사.
_PLATFORM_SUFFIX: dict[str, str] = {
"TikTok": "tictoc",
"Shopee": "shopee",
}
def export_filename(*, dispatch_date: str, platform: str) -> str:
"""2026.06.22(Mon)_tictoc.xlsx 형식 파일명. 날짜 파싱 실패 시 원문 사용."""
d = _parse_date(dispatch_date)
if d is not None:
stamp = d.strftime("%Y.%m.%d(%a)")
else:
stamp = (dispatch_date or "").strip() or "dispatch"
suffix = _PLATFORM_SUFFIX.get((platform or "").strip(), (platform or "dispatch").strip().lower())
return f"{stamp}_{suffix}.xlsx"
def build_workbook_bytes(*, batch: dict[str, Any], parcels: list[dict[str, Any]]) -> bytes:
"""배치 + 박스(상품 포함) → xlsx 바이트.
parcels 각 항목은 db.list_parcels 결과(items 포함, recipient_* 포함).
"""
from openpyxl import Workbook # noqa: WPS433 — 지연 import
from openpyxl.styles import Font
wb = Workbook()
ws = wb.active
ws.title = "출고"
headers = [h for h, _ in _COLUMNS]
ws.append(headers)
for cell in ws[1]:
cell.font = Font(bold=True)
dispatch_date = batch.get("dispatch_date", "") or ""
platform = batch.get("platform", "") or ""
for p in parcels:
box = {
"dispatch_date": dispatch_date,
"platform": platform,
"recipient_name": p.get("recipient_name", "") or "",
"recipient_phone": p.get("recipient_phone", "") or "",
"recipient_address": p.get("recipient_address", "") or "",
"order_id": p.get("order_id", "") or "",
"shipping_provider": p.get("shipping_provider", "") or "",
"tracking_id": p.get("tracking_id", "") or "",
}
items = p.get("items") or [{}]
for it in items:
row_data = dict(box)
row_data["product_name"] = it.get("product_name", "") or ""
row_data["seller_sku"] = it.get("seller_sku", "") or ""
row_data["quantity"] = it.get("quantity", "") if it.get("quantity") is not None else ""
ws.append([_cell(row_data.get(key, "")) for _, key in _COLUMNS])
_autosize(ws, headers)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def _cell(value: Any) -> Any:
"""송장/전화번호 등이 숫자로 보이면 엑셀이 과학표기/반올림 하므로 문자열 보존."""
if isinstance(value, int):
return value
return "" if value is None else str(value)
def _autosize(ws: Any, headers: list[str]) -> None:
"""대략적인 컬럼 폭. 주소는 길어 상한을 둔다."""
from openpyxl.utils import get_column_letter
widths: list[int] = [len(h) for h in headers]
for row in ws.iter_rows(min_row=2, values_only=True):
for i, val in enumerate(row):
ln = len(str(val)) if val is not None else 0
if ln > widths[i]:
widths[i] = ln
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = min(max(w + 2, 8), 50)
def _parse_date(value: str) -> date | None:
s = (value or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None