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>
This commit is contained in:
2026-06-22 14:16:11 +09:00
parent c0a661db82
commit f1097b85dc
13 changed files with 388 additions and 86 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용 - 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` - 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia` - 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok 출고관리. 03_TikTok_Order_Export.xlsx 업로드 → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 5단계 토글(`dispatch_logs` 기록). 고객 이름/주소/전화는 미저장(파싱 시 폐기). 엑셀은 openpyxl 파싱. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md` - 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1박스=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1박스 묶음 기준 Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 박스 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
상세는 `docs/PROJECT_OVERVIEW.md`. 상세는 `docs/PROJECT_OVERVIEW.md`.
+6 -2
View File
@@ -89,8 +89,9 @@ class DispatchStore:
""" """
INSERT INTO dispatch_parcels INSERT INTO dispatch_parcels
(batch_id, seq, order_id, package_id, (batch_id, seq, order_id, package_id,
tracking_id, shipping_provider) tracking_id, shipping_provider,
VALUES (%s,%s,%s,%s,%s,%s) recipient_name, recipient_phone, recipient_address)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id RETURNING id
""", """,
( (
@@ -100,6 +101,9 @@ class DispatchStore:
(p.get("package_id") or "").strip(), (p.get("package_id") or "").strip(),
(p.get("tracking_id") or "").strip(), (p.get("tracking_id") or "").strip(),
(p.get("shipping_provider") or "").strip(), (p.get("shipping_provider") or "").strip(),
(p.get("recipient_name") or "").strip(),
(p.get("recipient_phone") or "").strip(),
(p.get("recipient_address") or "").strip(),
), ),
).fetchone() ).fetchone()
parcel_id = parcel_row["id"] parcel_id = parcel_row["id"]
+125
View File
@@ -0,0 +1,125 @@
"""출고 배치 → 취합 엑셀(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
+40 -17
View File
@@ -1,11 +1,13 @@
"""TikTok Order Export XLSX 파서 (openpyxl). """출고 XLSX 파서 (openpyxl) — TikTok / Shopee 공용.
요구사항 2/11 구현. pandas 대신 레포에 이미 있는 openpyxl 로 읽어 의존성을 pandas 대신 레포에 이미 있는 openpyxl 로 읽어 의존성을 가볍게 유지한다
가볍게 유지한다(동작/결과는 동일: 헤더 정규화 → 표준키 매핑 → 박스 묶기). (동작: 헤더 정규화 → 표준키 매핑 → 박스 묶기).
- 첫 시트의 첫 행을 헤더로 본다. 헤더 앞뒤 공백 제거 후 표준키로 매핑한다. - TikTok: 03_TikTok_Order_Export.xlsx, Shopee: Packing List.Doorstep Delivery.xlsx.
- 매핑되지 않는 열(고객 이름/주소/전화 등)은 읽기 단계에서 버린다 → 개인정보 두 포맷 모두 같은 표준키로 매핑된다(store.COLUMN_ALIASES). 헤더가 첫 행이 아닐
미보관(요구사항 11). 수 있어(Shopee 는 제목 행이 위에 붙는 경우가 있다) 상단 몇 행을 훑어 필수 컬럼이
잡히는 첫 행을 헤더로 본다.
- 받는 사람(이름/전화/주소) 컬럼은 매핑되면 보존한다(작업 카드 표시 + 출고 엑셀).
- Tracking ID 는 숫자로 읽혀도 문자열로 보존(store.clean_text). - Tracking ID 는 숫자로 읽혀도 문자열로 보존(store.clean_text).
- Quantity 가 비면 1, NaN/None 은 빈 문자열로 처리. - Quantity 가 비면 1, NaN/None 은 빈 문자열로 처리.
- 필수 컬럼이 없으면 어떤 컬럼이 없는지 ParseError 로 알린다. - 필수 컬럼이 없으면 어떤 컬럼이 없는지 ParseError 로 알린다.
@@ -22,10 +24,15 @@ class ParseError(Exception):
"""엑셀 파싱 실패. message 는 사용자에게 그대로 보여줄 한국어 메시지.""" """엑셀 파싱 실패. message 는 사용자에게 그대로 보여줄 한국어 메시지."""
def parse_order_export(path: str) -> dict[str, Any]: # 헤더가 첫 행이 아닐 수 있어 상단에서 헤더 행을 찾을 때 훑는 최대 행 수.
"""XLSX 경로 → {"parcels": [...], "row_count": int}. _HEADER_SCAN_ROWS = 15
def parse_export(path: str) -> dict[str, Any]:
"""출고 XLSX 경로 → {"parcels": [...], "row_count": int}.
parcels 는 store.group_parcels 결과(1박스=1항목). 실패 시 ParseError. parcels 는 store.group_parcels 결과(1박스=1항목). 실패 시 ParseError.
TikTok·Shopee 공용(표준키 매핑이 흡수한다).
""" """
try: try:
from openpyxl import load_workbook # noqa: WPS433 — 지연 import from openpyxl import load_workbook # noqa: WPS433 — 지연 import
@@ -42,22 +49,20 @@ def parse_order_export(path: str) -> dict[str, Any]:
try: try:
ws = wb.worksheets[0] ws = wb.worksheets[0]
rows_iter = ws.iter_rows(values_only=True) all_rows = list(ws.iter_rows(values_only=True))
try: if not all_rows:
header = next(rows_iter)
except StopIteration:
raise ParseError("엑셀에 데이터가 없습니다(빈 파일).") raise ParseError("엑셀에 데이터가 없습니다(빈 파일).")
mapping = store.resolve_columns(header) header_idx, mapping = _find_header(all_rows)
missing = store.missing_required(mapping) if header_idx is None:
if missing: missing = store.missing_required({})
raise ParseError("엑셀에서 필요한 컬럼을 찾지 못했습니다: " + ", ".join(missing)) raise ParseError("엑셀에서 필요한 컬럼을 찾지 못했습니다: " + ", ".join(missing))
norm_rows: list[dict[str, str]] = [] norm_rows: list[dict[str, str]] = []
for raw in rows_iter: for raw in all_rows[header_idx + 1:]:
if raw is None: if raw is None:
continue continue
# 표준키만 추출(매핑 안 된 열 = 개인정보 등은 버린다). # 표준키만 추출(매핑 안 된 열은 버린다).
record = { record = {
key: store.clean_text(raw[idx]) if idx < len(raw) else "" key: store.clean_text(raw[idx]) if idx < len(raw) else ""
for key, idx in mapping.items() for key, idx in mapping.items()
@@ -78,6 +83,24 @@ def parse_order_export(path: str) -> dict[str, Any]:
return {"parcels": parcels, "row_count": len(norm_rows)} return {"parcels": parcels, "row_count": len(norm_rows)}
# 하위 호환 별칭(기존 호출부/테스트가 쓰던 이름).
parse_order_export = parse_export
def _find_header(rows: list) -> tuple[int | None, dict[str, int]]:
"""상단 행들을 훑어 필수 컬럼이 잡히는 첫 행을 헤더로 본다.
반환: (헤더 행 인덱스, {표준키: 열 인덱스}). 못 찾으면 (None, {}).
"""
for idx, raw in enumerate(rows[:_HEADER_SCAN_ROWS]):
if raw is None:
continue
mapping = store.resolve_columns(raw)
if not store.missing_required(mapping):
return idx, mapping
return None, {}
def _is_description_row(record: dict[str, str]) -> bool: def _is_description_row(record: dict[str, str]) -> bool:
"""헤더 아래 '컬럼 설명' 행 판별. """헤더 아래 '컬럼 설명' 행 판별.
+55 -22
View File
@@ -6,13 +6,15 @@
DISPATCH_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내. DISPATCH_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
- 업로드 원본은 DATA_DIR/dispatch/<날짜>/<배치slug>/ 에 저장, DB 엔 경로만 기록. - 업로드 원본은 DATA_DIR/dispatch/<날짜>/<배치slug>/ 에 저장, DB 엔 경로만 기록.
초보 물류 직원용 화면이라 고객정보는 일절 다루지 않는다. 작업 기준은 초보 물류 직원용 화면. 작업 기준 키는 Order ID / Package ID / Tracking ID /
Order ID / Package ID / Tracking ID / Seller SKU / Quantity 뿐이다. Seller SKU / Quantity 이고, 받는 사람(이름/전화/주소)은 작업 카드 표시 + 취합
출고 엑셀 생성을 위해 박스 단위로 보관한다(개인정보).
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import logging
import re import re
import shutil import shutil
import unicodedata import unicodedata
@@ -26,15 +28,25 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Resp
from app.timezone import now_kst, today_kst from app.timezone import now_kst, today_kst
from . import store from . import export, store
from .parser import ParseError, parse_order_export from .parser import ParseError, parse_export
logger = logging.getLogger("dispatch.router")
router = APIRouter(prefix="/dispatch", tags=["dispatch"]) router = APIRouter(prefix="/dispatch", tags=["dispatch"])
# 업로드 허용 파일 슬롯 (필드명 → (저장 파일명, 허용 확장자)) # 업로드 슬롯 (저장 파일명, 허용 확장자). 플랫폼마다 올리는 파일이 다르다.
_PICKING = ("01_Picking_List.pdf", (".pdf",)) # - 데이터 슬롯(필수): 자동 출고 리스트의 기준 엑셀.
_LABEL = ("02_Shipping_Label_Packing_Slip.pdf", (".pdf",)) # - 라벨 슬롯(선택): 실제 포장/라벨 원본 PDF(보관용).
_ORDER = ("03_TikTok_Order_Export.xlsx", (".xlsx",)) # TikTok 의 01_Picking_List.pdf 는 쓰지 않으므로 업로드 받지 않는다.
_DATA_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
store.PLATFORM_TIKTOK: ("03_TikTok_Order_Export.xlsx", (".xlsx",)),
store.PLATFORM_SHOPEE: ("Packing_List.Doorstep_Delivery.xlsx", (".xlsx",)),
}
_LABEL_SLOTS: dict[str, tuple[str, tuple[str, ...]]] = {
store.PLATFORM_TIKTOK: ("02_Shipping_Label_Packing_Slip.pdf", (".pdf",)),
store.PLATFORM_SHOPEE: ("Shopee_Seller_Centre.pdf", (".pdf",)),
}
# ──────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────
@@ -176,8 +188,9 @@ async def batch_new(request: Request) -> HTMLResponse:
ctx.update( ctx.update(
{ {
"page_title": "말레이시아 배송 — 업로드", "page_title": "말레이시아 배송 — 업로드",
"page_subtitle": "TikTok 파일 업로드 → 출고 배치 자동 생성", "page_subtitle": "플랫폼 파일 업로드 → 출고 배치 자동 생성",
"today": today_kst().isoformat(), "today": today_kst().isoformat(),
"platforms": store.PLATFORMS,
"active_tab": "new", "active_tab": "new",
} }
) )
@@ -190,23 +203,31 @@ async def batch_create(
dispatch_date: str = Form(...), dispatch_date: str = Form(...),
platform: str = Form("TikTok"), platform: str = Form("TikTok"),
batch_name: str = Form(""), batch_name: str = Form(""),
picking_pdf: UploadFile | None = File(None),
label_pdf: UploadFile | None = File(None), label_pdf: UploadFile | None = File(None),
order_export: UploadFile | None = File(None), data_xlsx: UploadFile | None = File(None),
user: dict[str, Any] = Depends(_require_user), user: dict[str, Any] = Depends(_require_user),
) -> RedirectResponse: ) -> RedirectResponse:
"""파일 업로드 + 배치 생성 + 엑셀 파싱 + 박스 저장. """파일 업로드 + 배치 생성 + 엑셀 파싱 + 박스 저장.
03_TikTok_Order_Export.xlsx 는 필수(자동 출고 리스트의 기준). PDF 2개는 선택. 플랫폼별 슬롯:
같은 날짜/플랫폼이라도 새 배치로 생성한다(덮어쓰지 않음, 요구사항 12). - TikTok: 데이터=03_TikTok_Order_Export.xlsx(필수), 라벨=Shipping Label PDF(선택)
- Shopee: 데이터=Packing List.Doorstep Delivery.xlsx(필수), 라벨=Shopee Seller Centre.pdf(선택)
같은 날짜/플랫폼이라도 새 배치로 생성한다(덮어쓰지 않음).
""" """
st = _store(request) st = _store(request)
if st is None: if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정") raise HTTPException(status_code=503, detail="dispatch_db 미설정")
if order_export is None or not (order_export.filename or "").strip():
platform = (platform or store.PLATFORM_TIKTOK).strip()
if platform not in _DATA_SLOTS:
raise HTTPException(status_code=400, detail=f"지원하지 않는 플랫폼입니다: {platform}")
data_slot = _DATA_SLOTS[platform]
label_slot = _LABEL_SLOTS[platform]
if data_xlsx is None or not (data_xlsx.filename or "").strip():
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="03_TikTok_Order_Export.xlsx 는 필수입니다(자동 출고 리스트 기준 데이터).", detail=f"{data_slot[0]} 는 필수입니다(자동 출고 리스트 기준 데이터).",
) )
# 저장 폴더: DATA_DIR/dispatch/<날짜>/<배치slug>_<HHMMSS>/ # 저장 폴더: DATA_DIR/dispatch/<날짜>/<배치slug>_<HHMMSS>/
@@ -214,13 +235,12 @@ async def batch_create(
stamp = now_kst().strftime("%H%M%S") stamp = now_kst().strftime("%H%M%S")
dest_dir = _data_dir(request) / "dispatch" / dispatch_date.strip() / f"{_slugify(name)}_{stamp}" dest_dir = _data_dir(request) / "dispatch" / dispatch_date.strip() / f"{_slugify(name)}_{stamp}"
picking_path = _save_upload(picking_pdf, dest_dir=dest_dir, slot=_PICKING) label_path = _save_upload(label_pdf, dest_dir=dest_dir, slot=label_slot)
label_path = _save_upload(label_pdf, dest_dir=dest_dir, slot=_LABEL) order_path = _save_upload(data_xlsx, dest_dir=dest_dir, slot=data_slot)
order_path = _save_upload(order_export, dest_dir=dest_dir, slot=_ORDER)
# 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다. # 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다.
try: try:
parsed = parse_order_export(order_path) parsed = parse_export(order_path)
except ParseError as exc: except ParseError as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
@@ -235,7 +255,7 @@ async def batch_create(
platform=platform, platform=platform,
dispatch_date=dispatch_date, dispatch_date=dispatch_date,
batch_name=name, batch_name=name,
picking_pdf_path=picking_path, picking_pdf_path="",
label_pdf_path=label_path, label_pdf_path=label_path,
order_export_path=order_path, order_export_path=order_path,
) )
@@ -266,7 +286,7 @@ async def batch_delete(
async def batch_download( async def batch_download(
request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user) request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user)
) -> Response: ) -> Response:
"""배치 업로드 원본 파일(피킹/라벨 PDF + 주문 엑셀)을 zip 으로 묶어 다운로드.""" """배치 다운로드: 업로드 원본(라벨 PDF + 데이터 엑셀) + 취합 출고 엑셀을 zip 으로 묶."""
st = _store(request) st = _store(request)
if st is None: if st is None:
raise HTTPException(status_code=503, detail="dispatch_db 미설정") raise HTTPException(status_code=503, detail="dispatch_db 미설정")
@@ -297,8 +317,21 @@ async def batch_download(
zf.write(p, arcname=arcname) zf.write(p, arcname=arcname)
count += 1 count += 1
# 취합 출고 엑셀(발송날짜/고객/주소/상품/택배 등)을 생성해 함께 넣는다.
parcels = st.list_parcels(batch_id=batch_id)
xlsx_name = export.export_filename(
dispatch_date=batch.get("dispatch_date", ""),
platform=batch.get("platform", ""),
)
try:
xlsx_bytes = export.build_workbook_bytes(batch=batch, parcels=parcels)
zf.writestr(xlsx_name, xlsx_bytes)
count += 1
except Exception: # noqa: BLE001 — 엑셀 생성 실패해도 원본 다운로드는 살린다.
logger.exception("출고 엑셀 생성 실패 batch_id=%s", batch_id)
if count == 0: if count == 0:
raise HTTPException(status_code=404, detail="다운로드할 업로드 파일이 없습니다.") raise HTTPException(status_code=404, detail="다운로드할 파일이 없습니다.")
slug = _slugify(batch.get("batch_name") or "dispatch") slug = _slugify(batch.get("batch_name") or "dispatch")
fname = f"{batch.get('dispatch_date', '')}_{slug}.zip".lstrip("_") fname = f"{batch.get('dispatch_date', '')}_{slug}.zip".lstrip("_")
+39 -9
View File
@@ -37,16 +37,33 @@ STATUS_LABELS: dict[str, dict[str, str]] = {
"courier_scanned": {"ko": "택배스캔 확인", "en": "Courier Scanned"}, "courier_scanned": {"ko": "택배스캔 확인", "en": "Courier Scanned"},
} }
# ── 플랫폼 ──
# 주문처(엑셀 '주문처' 컬럼)이자 업로드 슬롯/파서 선택의 기준값.
PLATFORM_TIKTOK = "TikTok"
PLATFORM_SHOPEE = "Shopee"
PLATFORMS: tuple[str, ...] = (PLATFORM_TIKTOK, PLATFORM_SHOPEE)
# ── 엑셀 컬럼명(앞뒤 공백 제거 후, 소문자 비교). 표준키 → 가능한 헤더들 ── # ── 엑셀 컬럼명(앞뒤 공백 제거 후, 소문자 비교). 표준키 → 가능한 헤더들 ──
# 개인정보 컬럼은 의도적으로 매핑하지 않는다(이름/주소/전화는 버린다). # TikTok(03_TikTok_Order_Export.xlsx)·Shopee(Packing List.Doorstep Delivery.xlsx)
# 두 포맷의 헤더를 한 테이블에 모은다(매칭되는 것만 사용). 헤더가 다르면
# 해당 표준키 줄에 별칭 1개만 추가하면 된다.
# 받는 사람(recipient_*) 정보는 작업 카드 표시 + 출고 엑셀 생성을 위해 보관한다.
COLUMN_ALIASES: dict[str, tuple[str, ...]] = { COLUMN_ALIASES: dict[str, tuple[str, ...]] = {
"order_id": ("order id", "order_id", "주문번호"), "order_id": ("order id", "order_id", "order sn", "ordersn", "order no.", "order number", "주문번호"),
"package_id": ("package id", "package_id", "패키지번호"), "package_id": ("package id", "package_id", "package number", "패키지번호"),
"tracking_id": ("tracking id", "tracking_id", "tracking number", "송장번호"), "tracking_id": ("tracking id", "tracking_id", "tracking number", "tracking no", "tracking no.", "awb", "awb no.", "송장번호"),
"shipping_provider": ("shipping provider name", "shipping provider", "shipping_provider_name", "배송사", "택배사"), "shipping_provider": ("shipping provider name", "shipping provider", "shipping_provider_name",
"seller_sku": ("seller sku", "seller_sku", "sku", "판매자 sku"), "shipping option", "courier", "carrier", "logistics", "배송사", "택배사"),
"product_name": ("product name", "product_name", "상품명"), "seller_sku": ("seller sku", "seller_sku", "sku", "sku reference no.", "sku reference no",
"quantity": ("quantity", "qty", "수량"), "parent sku reference no.", "판매자 sku", "상품코드"),
"product_name": ("product name", "product_name", "item name", "product", "상품명"),
"quantity": ("quantity", "qty", "수량", "수량(개)"),
"recipient_name": ("recipient", "recipient name", "buyer name", "consignee", "받는 사람", "받는사람", "수취인", "수령인"),
"recipient_phone": ("phone #", "phone#", "phone", "phone number", "contact number", "contact",
"전화", "전화번호", "연락처"),
"recipient_address": ("detail address", "detailed address", "address", "delivery address",
"shipping address", "full address", "주소", "배송지", "배송주소", "수령지 주소"),
} }
# 사용자에게 보여줄 컬럼 이름(누락 안내 메시지용). 비교키는 소문자라 별도 표기. # 사용자에게 보여줄 컬럼 이름(누락 안내 메시지용). 비교키는 소문자라 별도 표기.
@@ -58,8 +75,14 @@ COLUMN_DISPLAY: dict[str, str] = {
"seller_sku": "Seller SKU", "seller_sku": "Seller SKU",
"product_name": "Product Name", "product_name": "Product Name",
"quantity": "Quantity", "quantity": "Quantity",
"recipient_name": "Recipient",
"recipient_phone": "Phone",
"recipient_address": "Address",
} }
# 박스 단위로 보존하는 받는 사람 필드(같은 박스의 첫 비어있지 않은 값 사용).
RECIPIENT_FIELDS: tuple[str, ...] = ("recipient_name", "recipient_phone", "recipient_address")
# 박스 묶음 기준 우선순위. 앞에서부터 비어있지 않은 첫 값으로 묶는다. # 박스 묶음 기준 우선순위. 앞에서부터 비어있지 않은 첫 값으로 묶는다.
BOX_KEY_PRIORITY: tuple[str, ...] = ("package_id", "tracking_id", "order_id") BOX_KEY_PRIORITY: tuple[str, ...] = ("package_id", "tracking_id", "order_id")
@@ -153,12 +176,16 @@ def group_parcels(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
"package_id": (row.get("package_id") or "").strip(), "package_id": (row.get("package_id") or "").strip(),
"tracking_id": (row.get("tracking_id") or "").strip(), "tracking_id": (row.get("tracking_id") or "").strip(),
"shipping_provider": (row.get("shipping_provider") or "").strip(), "shipping_provider": (row.get("shipping_provider") or "").strip(),
"recipient_name": (row.get("recipient_name") or "").strip(),
"recipient_phone": (row.get("recipient_phone") or "").strip(),
"recipient_address": (row.get("recipient_address") or "").strip(),
"_items": {}, # sku -> {product_name, quantity} "_items": {}, # sku -> {product_name, quantity}
} }
boxes[key] = box boxes[key] = box
else: else:
# 같은 박스인데 식별값이 빈 경우 뒤늦게 채운다(누락 보강). # 같은 박스인데 식별값이 빈 경우 뒤늦게 채운다(누락 보강).
for field in ("order_id", "package_id", "tracking_id", "shipping_provider"): for field in ("order_id", "package_id", "tracking_id", "shipping_provider",
"recipient_name", "recipient_phone", "recipient_address"):
if not box[field] and (row.get(field) or "").strip(): if not box[field] and (row.get(field) or "").strip():
box[field] = (row.get(field) or "").strip() box[field] = (row.get(field) or "").strip()
@@ -188,6 +215,9 @@ def group_parcels(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
"package_id": box["package_id"], "package_id": box["package_id"],
"tracking_id": box["tracking_id"], "tracking_id": box["tracking_id"],
"shipping_provider": box["shipping_provider"], "shipping_provider": box["shipping_provider"],
"recipient_name": box["recipient_name"],
"recipient_phone": box["recipient_phone"],
"recipient_address": box["recipient_address"],
"items": items, "items": items,
} }
) )
@@ -16,6 +16,10 @@
.dsp-order { font-size:15px;line-height:1.3;color:#334155;word-break:break-all; } .dsp-order { font-size:15px;line-height:1.3;color:#334155;word-break:break-all; }
.dsp-order b { display:block;font-size:14px;font-weight:700;color:#475569;letter-spacing:.5px; } .dsp-order b { display:block;font-size:14px;font-weight:700;color:#475569;letter-spacing:.5px; }
.dsp-order span { font-size:27px;font-weight:800;color:#0f172a;letter-spacing:.2px; } .dsp-order span { font-size:27px;font-weight:800;color:#0f172a;letter-spacing:.2px; }
.dsp-recipient { border-top:1px dashed #e2e8f0;padding-top:8px; }
.dsp-rname { display:block;font-size:22px;font-weight:800;color:#0f172a;line-height:1.2;word-break:break-word; }
.dsp-raddr { font-size:14px;line-height:1.45;color:#334155;white-space:normal;word-break:break-word;margin-top:3px; }
.dsp-rphone { font-size:13px;color:#475569;margin-top:2px; }
.dsp-track-link { color:#2563eb !important;font-size:18px;font-weight:800; .dsp-track-link { color:#2563eb !important;font-size:18px;font-weight:800;
text-decoration:underline !important;text-decoration-line:underline !important; text-decoration:underline !important;text-decoration-line:underline !important;
text-decoration-thickness:2px;text-underline-offset:3px;cursor:pointer; } text-decoration-thickness:2px;text-underline-offset:3px;cursor:pointer; }
@@ -54,7 +58,7 @@
<button class="erp-btn erp-btn-outline" data-filter="handed_to_kagayaku" type="button">Kagayaku 전달</button> <button class="erp-btn erp-btn-outline" data-filter="handed_to_kagayaku" type="button">Kagayaku 전달</button>
</div> </div>
<input class="erp-input dsp-search" id="dsp-search" type="search" <input class="erp-input dsp-search" id="dsp-search" type="search"
placeholder="검색: Order ID / Tracking ID / Package ID / SKU" /> placeholder="검색: Order ID / Tracking ID / 받는 사람 / SKU" />
</div> </div>
<div class="dsp-cards" id="dsp-cards"> <div class="dsp-cards" id="dsp-cards">
@@ -64,7 +68,7 @@
data-parcel="{{ p.id }}" data-parcel="{{ p.id }}"
data-label_attached="{{ p.label_attached|lower }}" data-label_attached="{{ p.label_attached|lower }}"
data-handed_to_kagayaku="{{ p.handed_to_kagayaku|lower }}" data-handed_to_kagayaku="{{ p.handed_to_kagayaku|lower }}"
data-search="{{ (p.order_id ~ ' ' ~ p.package_id ~ ' ' ~ p.tracking_id ~ ' ' ~ (p['items']|map(attribute='seller_sku')|join(' ')))|lower }}"> data-search="{{ (p.order_id ~ ' ' ~ p.tracking_id ~ ' ' ~ p.recipient_name ~ ' ' ~ (p['items']|map(attribute='seller_sku')|join(' ')))|lower }}">
{% set logo = p.shipping_provider | courier_logo %} {% set logo = p.shipping_provider | courier_logo %}
{% if logo %} {% if logo %}
<img class="dsp-courier" src="{{ logo }}" alt="{{ p.shipping_provider }}" title="{{ p.shipping_provider }}" /> <img class="dsp-courier" src="{{ logo }}" alt="{{ p.shipping_provider }}" title="{{ p.shipping_provider }}" />
@@ -74,13 +78,17 @@
<div class="dsp-no">No. {{ p.seq }}</div> <div class="dsp-no">No. {{ p.seq }}</div>
<div class="dsp-order"><b>Order ID</b><span>{{ p.order_id or '—' }}</span></div> <div class="dsp-order"><b>Order ID</b><span>{{ p.order_id or '—' }}</span></div>
<div class="dsp-meta"> <div class="dsp-meta">
<div><b>Package</b> {{ p.package_id or '—' }}</div>
<div><b>Tracking</b> <div><b>Tracking</b>
{% set turl = p.tracking_id | courier_track(p.shipping_provider) %} {% set turl = p.tracking_id | courier_track(p.shipping_provider) %}
{% if turl %}<a class="dsp-track-link" href="{{ turl }}" target="_blank" rel="noopener noreferrer">{{ p.tracking_id }}</a> {% if turl %}<a class="dsp-track-link" href="{{ turl }}" target="_blank" rel="noopener noreferrer">{{ p.tracking_id }}</a>
{% else %}{{ p.tracking_id or '—' }}{% endif %} {% else %}{{ p.tracking_id or '—' }}{% endif %}
</div> </div>
</div> </div>
<div class="dsp-recipient">
<span class="dsp-rname">{{ p.recipient_name or '—' }}</span>
{% if p.recipient_address %}<div class="dsp-raddr">{{ p.recipient_address }}</div>{% endif %}
{% if p.recipient_phone %}<div class="dsp-rphone">{{ p.recipient_phone }}</div>{% endif %}
</div>
<ul class="dsp-items"> <ul class="dsp-items">
{% for it in p['items'] %} {% for it in p['items'] %}
<li>{{ it.seller_sku }} <span class="qty">× {{ it.quantity }}</span></li> <li>{{ it.seller_sku }} <span class="qty">× {{ it.quantity }}</span></li>
@@ -5,11 +5,8 @@
{% include "dispatch/_nav.html" %} {% include "dispatch/_nav.html" %}
<div class="erp-card" style="max-width:640px;"> <div class="erp-card" style="max-width:640px;">
<div class="cpg-card-head"><h2>TikTok 파일 업로드</h2></div> <div class="cpg-card-head"><h2>출고 파일 업로드</h2></div>
<p class="erp-muted" style="margin:4px 0 16px;"> <p class="erp-muted" id="dsp-intro" style="margin:4px 0 16px;"></p>
03_TikTok_Order_Export.xlsx 만 있으면 출고 리스트가 자동 생성됩니다.
PDF 2개는 보관용(선택)입니다. 고객 이름/주소/전화는 저장하지 않습니다.
</p>
<form method="post" action="/dispatch/batches" enctype="multipart/form-data" style="display:flex;flex-direction:column;gap:14px;"> <form method="post" action="/dispatch/batches" enctype="multipart/form-data" style="display:flex;flex-direction:column;gap:14px;">
<label style="display:flex;flex-direction:column;gap:4px;"> <label style="display:flex;flex-direction:column;gap:4px;">
@@ -19,29 +16,26 @@
<label style="display:flex;flex-direction:column;gap:4px;"> <label style="display:flex;flex-direction:column;gap:4px;">
<span>플랫폼</span> <span>플랫폼</span>
<select class="erp-select" name="platform"> <select class="erp-select" name="platform" id="dsp-platform">
<option value="TikTok" selected>TikTok</option> {% for pf in platforms %}
<option value="{{ pf }}">{{ pf }}</option>
{% endfor %}
</select> </select>
</label> </label>
<label style="display:flex;flex-direction:column;gap:4px;"> <label style="display:flex;flex-direction:column;gap:4px;">
<span>배치명 <span class="erp-muted">(예: 2026-06-19 TikTok 오전 출고)</span></span> <span>배치명 <span class="erp-muted">(예: 2026-06-19 오전 출고)</span></span>
<input class="erp-input" type="text" name="batch_name" placeholder="오전 출고 / 오후 출고 등으로 구분" /> <input class="erp-input" type="text" name="batch_name" placeholder="오전 출고 / 오후 출고 등으로 구분" />
</label> </label>
<label style="display:flex;flex-direction:column;gap:4px;"> <label style="display:flex;flex-direction:column;gap:4px;">
<span>1️⃣ Picking List.pdf <span class="erp-muted">(선택 · 전체 피킹 참고용)</span></span> <span id="dsp-label-lbl"></span>
<input class="erp-input" type="file" name="picking_pdf" accept="application/pdf" /> <input class="erp-input" type="file" name="label_pdf" id="dsp-label" accept="application/pdf" />
</label> </label>
<label style="display:flex;flex-direction:column;gap:4px;"> <label style="display:flex;flex-direction:column;gap:4px;">
<span>2️⃣ Shipping label+Packing slip.pdf <span class="erp-muted">(선택 · 실제 포장/라벨 원본)</span></span> <span id="dsp-data-lbl"></span>
<input class="erp-input" type="file" name="label_pdf" accept="application/pdf" /> <input class="erp-input" type="file" name="data_xlsx" id="dsp-data"
</label>
<label style="display:flex;flex-direction:column;gap:4px;">
<span>3️⃣ TikTok Order Export.xlsx <span style="color:#dc2626;">*</span> <span class="erp-muted">(자동 출고 리스트 기준)</span></span>
<input class="erp-input" type="file" name="order_export"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required /> accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required />
</label> </label>
@@ -53,3 +47,32 @@
</div> </div>
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
(function () {
// 플랫폼별 안내/파일 라벨. 데이터 엑셀이 필수, 라벨 PDF 는 선택(보관용).
var CFG = {
"TikTok": {
intro: "TikTok: 03_TikTok_Order_Export.xlsx 만 있으면 출고 리스트가 자동 생성됩니다. 라벨 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>",
data: "📊 Packing List.Doorstep Delivery.xlsx <span style='color:#dc2626;'>*</span> <span class='erp-muted'>(자동 출고 리스트 기준)</span>"
}
};
var sel = document.getElementById('dsp-platform');
function apply() {
var c = CFG[sel.value] || CFG["TikTok"];
document.getElementById('dsp-intro').textContent = c.intro;
document.getElementById('dsp-label-lbl').innerHTML = c.label;
document.getElementById('dsp-data-lbl').innerHTML = c.data;
}
sel.addEventListener('change', apply);
apply();
})();
</script>
{% endblock %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+41 -13
View File
@@ -1,4 +1,4 @@
# 말레이시아 배송 모듈 (dispatch) — TikTok 출고관리 # 말레이시아 배송 모듈 (dispatch) — TikTok·Shopee 출고관리
> 초보 물류 직원이 엑셀을 열지 않고 웹 화면만 보고 출고 작업을 하도록 만든 모듈. > 초보 물류 직원이 엑셀을 열지 않고 웹 화면만 보고 출고 작업을 하도록 만든 모듈.
> main-app ERP 에 편입된 **모듈**이다(별도 앱/포트 아님). 인증은 기존 Google > main-app ERP 에 편입된 **모듈**이다(별도 앱/포트 아님). 인증은 기존 Google
@@ -8,17 +8,29 @@
## 1. 무엇을 하나 ## 1. 무엇을 하나
TikTok Seller Center 에서 매일 받는 3개 파일을 업로드하면, 직원용 **출고 작업 플랫폼(TikTok·Shopee)에서 매일 받는 파일을 업로드하면, 직원용 **출고 작업
리스트(04_Daily_Dispatch_List)** 를 웹에서 자동 생성한다(엑셀 파일로 만들 필요 없음). 리스트** 를 웹에서 자동 생성한다(엑셀 파일로 만들 필요 없음). 플랫폼마다 올리는
파일이 다르며, 업로드 화면이 플랫폼 선택에 따라 바뀐다.
**TikTok**
| 파일 | 용도 | 업로드 | | 파일 | 용도 | 업로드 |
| --- | --- | --- | | --- | --- | --- |
| 01_Picking_List.pdf | 전체 상품 피킹 참고용 | 선택(보관) |
| 02_Shipping_Label_Packing_Slip.pdf | 실제 포장/라벨 부착 원본 | 선택(보관) | | 02_Shipping_Label_Packing_Slip.pdf | 실제 포장/라벨 부착 원본 | 선택(보관) |
| 03_TikTok_Order_Export.xlsx | **자동 출고 리스트 기준 데이터** | **필수** | | 03_TikTok_Order_Export.xlsx | **자동 출고 리스트 기준 데이터** | **필수** |
작업 기준은 고객정보가 아니라 **Order ID / Package ID / Tracking ID / Seller SKU / > 01_Picking_List.pdf 는 더 이상 받지 않는다(피킹 요약은 데이터 엑셀로 생성).
Quantity** 뿐이다. 고객 이름·주소·전화는 파싱 단계에서 버려 **DB 에 저장하지 않는다.**
**Shopee**
| 파일 | 용도 | 업로드 |
| --- | --- | --- |
| Shopee Seller Centre.pdf | 택배 송장(라벨) 원본 | 선택(보관) |
| Packing List.Doorstep Delivery.xlsx | **자동 출고 리스트 기준 데이터** | **필수** |
작업 기준 키는 **Order ID / Package ID / Tracking ID / Seller SKU / Quantity** 이고,
**받는 사람 이름·전화·주소**는 출고 작업 카드 표시 + 취합 출고 엑셀 생성을 위해
박스 단위로 **dispatch_db 에 저장한다**(개인정보 — 접근/보관 정책 검토 대상).
### 1박스 묶음 규칙 ### 1박스 묶음 규칙
@@ -90,12 +102,21 @@ cd /opt/www/main && docker compose up -d --build web
업로드한 PDF/XLSX 원본은 아래에 저장되고 DB 에는 경로만 기록한다. 업로드한 PDF/XLSX 원본은 아래에 저장되고 DB 에는 경로만 기록한다.
``` ```
# TikTok
$DATA_DIR/dispatch/2026-06-19/<배치slug>_<HHMMSS>/ $DATA_DIR/dispatch/2026-06-19/<배치slug>_<HHMMSS>/
01_Picking_List.pdf
02_Shipping_Label_Packing_Slip.pdf 02_Shipping_Label_Packing_Slip.pdf
03_TikTok_Order_Export.xlsx 03_TikTok_Order_Export.xlsx
# Shopee
$DATA_DIR/dispatch/2026-06-19/<배치slug>_<HHMMSS>/
Shopee_Seller_Centre.pdf
Packing_List.Doorstep_Delivery.xlsx
``` ```
배치 목록의 **다운로드** 버튼은 위 업로드 원본에 더해, 발송 날짜·고객 이름·전화·
주소·상품명·아이템 코드·주문 수량·오더번호·택배사·송장번호·주문처를 취합한
**출고 엑셀**을 같은 zip 에 함께 넣는다. 엑셀 파일명: `YYYY.MM.DD(Ddd)_tictoc.xlsx`
(Shopee 는 끝이 `_shopee`).
같은 날짜/플랫폼이라도 batch_name 으로 구분해 **새 배치**로 생성된다(덮어쓰지 않음). 같은 날짜/플랫폼이라도 batch_name 으로 구분해 **새 배치**로 생성된다(덮어쓰지 않음).
예: "2026-06-19 TikTok 오전 출고", "2026-06-19 TikTok 오후 출고". 예: "2026-06-19 TikTok 오전 출고", "2026-06-19 TikTok 오후 출고".
@@ -107,10 +128,14 @@ $DATA_DIR/dispatch/2026-06-19/<배치slug>_<HHMMSS>/
- Quantity 가 비면 1 로 처리한다. - Quantity 가 비면 1 로 처리한다.
- Tracking ID 가 숫자로 읽혀도 문자열로 보존한다(과학표기/`.0` 제거). - Tracking ID 가 숫자로 읽혀도 문자열로 보존한다(과학표기/`.0` 제거).
- NaN/None 은 빈 문자열로 처리한다. - NaN/None 은 빈 문자열로 처리한다.
- 고객 이름/주소/전화 컬럼은 매핑하지 않아 **읽는 즉시 버린다**(미저장). - 받는 사람 이름/전화/주소 컬럼은 매핑되면 **박스 단위로 보존**한다(작업 카드·엑셀).
- 헤더가 첫 행이 아닐 수 있어(Shopee 는 제목 행이 위에 붙을 수 있음) 상단 몇 행을
훑어 필수 컬럼이 잡히는 첫 행을 헤더로 본다.
가능한 헤더(앞뒤 공백·대소문자 무시): Order ID, Package ID, Tracking ID, 가능한 헤더(앞뒤 공백·대소문자 무시, TikTok·Shopee 별칭은 `store.COLUMN_ALIASES`):
Shipping Provider Name, Seller SKU, Product Name, Quantity. Order ID(또는 Order SN), Package ID, Tracking ID(또는 Tracking Number),
Shipping Provider Name(또는 Shipping Option/Courier), Seller SKU(또는 SKU Reference No.),
Product Name, Quantity, Recipient(받는 사람), Phone(전화), Address(주소).
--- ---
@@ -128,6 +153,9 @@ python -m app.modules.dispatch.tests.test_grouping # 앱 의존성 설치 환
- 스택: FastAPI · PostgreSQL(psycopg3) · Jinja2 · 기존 ERP CSS(Bootstrap 미사용, - 스택: FastAPI · PostgreSQL(psycopg3) · Jinja2 · 기존 ERP CSS(Bootstrap 미사용,
레포 공통 `erp.css` 재사용) · **openpyxl**(스펙의 pandas 대신, 레포 의존성 경량 유지). 레포 공통 `erp.css` 재사용) · **openpyxl**(스펙의 pandas 대신, 레포 의존성 경량 유지).
- 코드: `app/modules/dispatch/` (`store.py` 순수 로직 / `parser.py` 엑셀 / `db.py` - 코드: `app/modules/dispatch/` (`store.py` 순수 로직 / `parser.py` 엑셀 파싱 /
DB I/O / `router.py` 라우팅 / `templates/dispatch/` 화면). `export.py` 취합 출고 엑셀 생성 / `db.py` DB I/O / `router.py` 라우팅 /
- 개인정보 미보관: 스키마에 이름/주소/전화 컬럼 자체가 없다. `templates/dispatch/` 화면).
- 개인정보 보관: `dispatch_parcels.recipient_name/recipient_phone/recipient_address`.
기존 DB 에는 마이그레이션 `scripts/sql/dispatch_add_recipient_columns.sql` 로 컬럼을
추가한다(멱등). 접근/보관 기간 정책을 별도로 검토할 것.
@@ -0,0 +1,24 @@
-- =====================================================================
-- dispatch_db 마이그레이션 — 박스에 받는 사람(PII) 컬럼 추가
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전(ADD COLUMN IF NOT EXISTS).
--
-- 배경: 기존 dispatch 모듈은 개인정보(이름/주소/전화)를 저장하지 않는 구조였다.
-- 운영 요청으로 출고 작업 카드 표시 + 출고 엑셀(취합본) 생성을 위해
-- 받는 사람 정보를 박스 단위로 보관하도록 정책을 변경한다.
--
-- 실행 (운영 PostgreSQL 컨테이너명: postgres-db):
-- docker exec -i postgres-db psql -U postgres -d dispatch_db \
-- < scripts/sql/dispatch_add_recipient_columns.sql
--
-- ⚠️ 개인정보 컬럼이다. 접근 권한/보관 기간 정책을 함께 검토할 것.
-- =====================================================================
\set ON_ERROR_STOP on
\connect dispatch_db
ALTER TABLE dispatch_parcels ADD COLUMN IF NOT EXISTS recipient_name TEXT NOT NULL DEFAULT '';
ALTER TABLE dispatch_parcels ADD COLUMN IF NOT EXISTS recipient_phone TEXT NOT NULL DEFAULT '';
ALTER TABLE dispatch_parcels ADD COLUMN IF NOT EXISTS recipient_address TEXT NOT NULL DEFAULT '';
SELECT 'dispatch_parcels recipient columns ready' AS status;
+6 -2
View File
@@ -22,7 +22,8 @@
-- 주의: -- 주의:
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만). -- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달. -- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
-- - 개인정보(고객 이름/주소/전화)는 저장하지 않는 구조다(컬럼 자체가 없음). -- - 받는 사람 정보(이름/전화/주소)는 출고 작업 카드 표시 + 출고 엑셀 생성을
-- 위해 박스 단위로 보관한다(개인정보 — 접근/보관 정책 검토 대상).
-- - 작업 기준 키: order_id / package_id / tracking_id / seller_sku / quantity. -- - 작업 기준 키: order_id / package_id / tracking_id / seller_sku / quantity.
-- ===================================================================== -- =====================================================================
@@ -80,7 +81,7 @@ CREATE TRIGGER trg_dispatch_batches_updated
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
-- 2) 출고 박스 (1박스 = 1행 = 1작업카드) -- 2) 출고 박스 (1박스 = 1행 = 1작업카드)
-- 묶음 기준: package_id > tracking_id > order_id (파서가 결정). -- 묶음 기준: package_id > tracking_id > order_id (파서가 결정).
-- 개인정보 컬럼 없음(이름/주소/전화 저장 안 함). -- 받는 사람(recipient_*) 정보 보관 — 작업 카드 표시 + 출고 엑셀 생성용.
-- seq 는 배치 내 박스 표시 순번(No). -- seq 는 배치 내 박스 표시 순번(No).
-- ════════════════════════════════════════════════════════════ -- ════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS dispatch_parcels ( CREATE TABLE IF NOT EXISTS dispatch_parcels (
@@ -91,6 +92,9 @@ CREATE TABLE IF NOT EXISTS dispatch_parcels (
package_id TEXT NOT NULL DEFAULT '', package_id TEXT NOT NULL DEFAULT '',
tracking_id TEXT NOT NULL DEFAULT '', tracking_id TEXT NOT NULL DEFAULT '',
shipping_provider TEXT NOT NULL DEFAULT '', shipping_provider TEXT NOT NULL DEFAULT '',
recipient_name TEXT NOT NULL DEFAULT '',
recipient_phone TEXT NOT NULL DEFAULT '',
recipient_address TEXT NOT NULL DEFAULT '',
product_ready BOOLEAN NOT NULL DEFAULT FALSE, product_ready BOOLEAN NOT NULL DEFAULT FALSE,
packed BOOLEAN NOT NULL DEFAULT FALSE, packed BOOLEAN NOT NULL DEFAULT FALSE,
label_attached BOOLEAN NOT NULL DEFAULT FALSE, label_attached BOOLEAN NOT NULL DEFAULT FALSE,