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:
@@ -34,7 +34,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
|
||||
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
|
||||
- 휴가 관리 (`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/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`.
|
||||
|
||||
|
||||
@@ -89,8 +89,9 @@ class DispatchStore:
|
||||
"""
|
||||
INSERT INTO dispatch_parcels
|
||||
(batch_id, seq, order_id, package_id,
|
||||
tracking_id, shipping_provider)
|
||||
VALUES (%s,%s,%s,%s,%s,%s)
|
||||
tracking_id, shipping_provider,
|
||||
recipient_name, recipient_phone, recipient_address)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
@@ -100,6 +101,9 @@ class DispatchStore:
|
||||
(p.get("package_id") or "").strip(),
|
||||
(p.get("tracking_id") 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()
|
||||
parcel_id = parcel_row["id"]
|
||||
|
||||
@@ -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
|
||||
@@ -1,11 +1,13 @@
|
||||
"""TikTok Order Export XLSX 파서 (openpyxl).
|
||||
"""출고 XLSX 파서 (openpyxl) — TikTok / Shopee 공용.
|
||||
|
||||
요구사항 2/11 구현. pandas 대신 레포에 이미 있는 openpyxl 로 읽어 의존성을
|
||||
가볍게 유지한다(동작/결과는 동일: 헤더 정규화 → 표준키 매핑 → 박스 묶기).
|
||||
pandas 대신 레포에 이미 있는 openpyxl 로 읽어 의존성을 가볍게 유지한다
|
||||
(동작: 헤더 정규화 → 표준키 매핑 → 박스 묶기).
|
||||
|
||||
- 첫 시트의 첫 행을 헤더로 본다. 헤더 앞뒤 공백 제거 후 표준키로 매핑한다.
|
||||
- 매핑되지 않는 열(고객 이름/주소/전화 등)은 읽기 단계에서 버린다 → 개인정보
|
||||
미보관(요구사항 11).
|
||||
- TikTok: 03_TikTok_Order_Export.xlsx, Shopee: Packing List.Doorstep Delivery.xlsx.
|
||||
두 포맷 모두 같은 표준키로 매핑된다(store.COLUMN_ALIASES). 헤더가 첫 행이 아닐
|
||||
수 있어(Shopee 는 제목 행이 위에 붙는 경우가 있다) 상단 몇 행을 훑어 필수 컬럼이
|
||||
잡히는 첫 행을 헤더로 본다.
|
||||
- 받는 사람(이름/전화/주소) 컬럼은 매핑되면 보존한다(작업 카드 표시 + 출고 엑셀).
|
||||
- Tracking ID 는 숫자로 읽혀도 문자열로 보존(store.clean_text).
|
||||
- Quantity 가 비면 1, NaN/None 은 빈 문자열로 처리.
|
||||
- 필수 컬럼이 없으면 어떤 컬럼이 없는지 ParseError 로 알린다.
|
||||
@@ -22,10 +24,15 @@ class ParseError(Exception):
|
||||
"""엑셀 파싱 실패. 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.
|
||||
TikTok·Shopee 공용(표준키 매핑이 흡수한다).
|
||||
"""
|
||||
try:
|
||||
from openpyxl import load_workbook # noqa: WPS433 — 지연 import
|
||||
@@ -42,22 +49,20 @@ def parse_order_export(path: str) -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
ws = wb.worksheets[0]
|
||||
rows_iter = ws.iter_rows(values_only=True)
|
||||
try:
|
||||
header = next(rows_iter)
|
||||
except StopIteration:
|
||||
all_rows = list(ws.iter_rows(values_only=True))
|
||||
if not all_rows:
|
||||
raise ParseError("엑셀에 데이터가 없습니다(빈 파일).")
|
||||
|
||||
mapping = store.resolve_columns(header)
|
||||
missing = store.missing_required(mapping)
|
||||
if missing:
|
||||
header_idx, mapping = _find_header(all_rows)
|
||||
if header_idx is None:
|
||||
missing = store.missing_required({})
|
||||
raise ParseError("엑셀에서 필요한 컬럼을 찾지 못했습니다: " + ", ".join(missing))
|
||||
|
||||
norm_rows: list[dict[str, str]] = []
|
||||
for raw in rows_iter:
|
||||
for raw in all_rows[header_idx + 1:]:
|
||||
if raw is None:
|
||||
continue
|
||||
# 표준키만 추출(매핑 안 된 열 = 개인정보 등은 버린다).
|
||||
# 표준키만 추출(매핑 안 된 열은 버린다).
|
||||
record = {
|
||||
key: store.clean_text(raw[idx]) if idx < len(raw) else ""
|
||||
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)}
|
||||
|
||||
|
||||
# 하위 호환 별칭(기존 호출부/테스트가 쓰던 이름).
|
||||
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:
|
||||
"""헤더 아래 '컬럼 설명' 행 판별.
|
||||
|
||||
|
||||
@@ -6,13 +6,15 @@
|
||||
DISPATCH_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
|
||||
- 업로드 원본은 DATA_DIR/dispatch/<날짜>/<배치slug>/ 에 저장, DB 엔 경로만 기록.
|
||||
|
||||
초보 물류 직원용 화면이라 고객정보는 일절 다루지 않는다. 작업 기준은
|
||||
Order ID / Package ID / Tracking ID / Seller SKU / Quantity 뿐이다.
|
||||
초보 물류 직원용 화면. 작업 기준 키는 Order ID / Package ID / Tracking ID /
|
||||
Seller SKU / Quantity 이고, 받는 사람(이름/전화/주소)은 작업 카드 표시 + 취합
|
||||
출고 엑셀 생성을 위해 박스 단위로 보관한다(개인정보).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import unicodedata
|
||||
@@ -26,15 +28,25 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Resp
|
||||
|
||||
from app.timezone import now_kst, today_kst
|
||||
|
||||
from . import store
|
||||
from .parser import ParseError, parse_order_export
|
||||
from . import export, store
|
||||
from .parser import ParseError, parse_export
|
||||
|
||||
logger = logging.getLogger("dispatch.router")
|
||||
|
||||
router = APIRouter(prefix="/dispatch", tags=["dispatch"])
|
||||
|
||||
# 업로드 허용 파일 슬롯 (필드명 → (저장 파일명, 허용 확장자))
|
||||
_PICKING = ("01_Picking_List.pdf", (".pdf",))
|
||||
_LABEL = ("02_Shipping_Label_Packing_Slip.pdf", (".pdf",))
|
||||
_ORDER = ("03_TikTok_Order_Export.xlsx", (".xlsx",))
|
||||
# 업로드 슬롯 (저장 파일명, 허용 확장자). 플랫폼마다 올리는 파일이 다르다.
|
||||
# - 데이터 슬롯(필수): 자동 출고 리스트의 기준 엑셀.
|
||||
# - 라벨 슬롯(선택): 실제 포장/라벨 원본 PDF(보관용).
|
||||
# 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(
|
||||
{
|
||||
"page_title": "말레이시아 배송 — 업로드",
|
||||
"page_subtitle": "TikTok 파일 업로드 → 출고 배치 자동 생성",
|
||||
"page_subtitle": "플랫폼 파일 업로드 → 출고 배치 자동 생성",
|
||||
"today": today_kst().isoformat(),
|
||||
"platforms": store.PLATFORMS,
|
||||
"active_tab": "new",
|
||||
}
|
||||
)
|
||||
@@ -190,23 +203,31 @@ async def batch_create(
|
||||
dispatch_date: str = Form(...),
|
||||
platform: str = Form("TikTok"),
|
||||
batch_name: str = Form(""),
|
||||
picking_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),
|
||||
) -> 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)
|
||||
if st is None:
|
||||
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(
|
||||
status_code=400,
|
||||
detail="03_TikTok_Order_Export.xlsx 는 필수입니다(자동 출고 리스트 기준 데이터).",
|
||||
detail=f"{data_slot[0]} 는 필수입니다(자동 출고 리스트 기준 데이터).",
|
||||
)
|
||||
|
||||
# 저장 폴더: DATA_DIR/dispatch/<날짜>/<배치slug>_<HHMMSS>/
|
||||
@@ -214,13 +235,12 @@ async def batch_create(
|
||||
stamp = now_kst().strftime("%H%M%S")
|
||||
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)
|
||||
order_path = _save_upload(order_export, dest_dir=dest_dir, slot=_ORDER)
|
||||
label_path = _save_upload(label_pdf, dest_dir=dest_dir, slot=label_slot)
|
||||
order_path = _save_upload(data_xlsx, dest_dir=dest_dir, slot=data_slot)
|
||||
|
||||
# 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다.
|
||||
try:
|
||||
parsed = parse_order_export(order_path)
|
||||
parsed = parse_export(order_path)
|
||||
except ParseError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
@@ -235,7 +255,7 @@ async def batch_create(
|
||||
platform=platform,
|
||||
dispatch_date=dispatch_date,
|
||||
batch_name=name,
|
||||
picking_pdf_path=picking_path,
|
||||
picking_pdf_path="",
|
||||
label_pdf_path=label_path,
|
||||
order_export_path=order_path,
|
||||
)
|
||||
@@ -266,7 +286,7 @@ async def batch_delete(
|
||||
async def batch_download(
|
||||
request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user)
|
||||
) -> Response:
|
||||
"""배치에 업로드된 원본 파일(피킹/라벨 PDF + 주문 엑셀)을 zip 으로 묶어 다운로드."""
|
||||
"""배치 다운로드: 업로드 원본(라벨 PDF + 데이터 엑셀) + 취합 출고 엑셀을 zip 으로 묶음."""
|
||||
st = _store(request)
|
||||
if st is None:
|
||||
raise HTTPException(status_code=503, detail="dispatch_db 미설정")
|
||||
@@ -297,8 +317,21 @@ async def batch_download(
|
||||
zf.write(p, arcname=arcname)
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="다운로드할 업로드 파일이 없습니다.")
|
||||
raise HTTPException(status_code=404, detail="다운로드할 파일이 없습니다.")
|
||||
|
||||
slug = _slugify(batch.get("batch_name") or "dispatch")
|
||||
fname = f"{batch.get('dispatch_date', '')}_{slug}.zip".lstrip("_")
|
||||
|
||||
@@ -37,16 +37,33 @@ STATUS_LABELS: dict[str, dict[str, str]] = {
|
||||
"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, ...]] = {
|
||||
"order_id": ("order id", "order_id", "주문번호"),
|
||||
"package_id": ("package id", "package_id", "패키지번호"),
|
||||
"tracking_id": ("tracking id", "tracking_id", "tracking number", "송장번호"),
|
||||
"shipping_provider": ("shipping provider name", "shipping provider", "shipping_provider_name", "배송사", "택배사"),
|
||||
"seller_sku": ("seller sku", "seller_sku", "sku", "판매자 sku"),
|
||||
"product_name": ("product name", "product_name", "상품명"),
|
||||
"quantity": ("quantity", "qty", "수량"),
|
||||
"order_id": ("order id", "order_id", "order sn", "ordersn", "order no.", "order number", "주문번호"),
|
||||
"package_id": ("package id", "package_id", "package 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 option", "courier", "carrier", "logistics", "배송사", "택배사"),
|
||||
"seller_sku": ("seller sku", "seller_sku", "sku", "sku reference no.", "sku reference no",
|
||||
"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",
|
||||
"product_name": "Product Name",
|
||||
"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")
|
||||
|
||||
@@ -153,12 +176,16 @@ def group_parcels(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
|
||||
"package_id": (row.get("package_id") or "").strip(),
|
||||
"tracking_id": (row.get("tracking_id") 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}
|
||||
}
|
||||
boxes[key] = box
|
||||
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():
|
||||
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"],
|
||||
"tracking_id": box["tracking_id"],
|
||||
"shipping_provider": box["shipping_provider"],
|
||||
"recipient_name": box["recipient_name"],
|
||||
"recipient_phone": box["recipient_phone"],
|
||||
"recipient_address": box["recipient_address"],
|
||||
"items": items,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
.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 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;
|
||||
text-decoration:underline !important;text-decoration-line:underline !important;
|
||||
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>
|
||||
</div>
|
||||
<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 class="dsp-cards" id="dsp-cards">
|
||||
@@ -64,7 +68,7 @@
|
||||
data-parcel="{{ p.id }}"
|
||||
data-label_attached="{{ p.label_attached|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 %}
|
||||
{% if logo %}
|
||||
<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-order"><b>Order ID</b><span>{{ p.order_id or '—' }}</span></div>
|
||||
<div class="dsp-meta">
|
||||
<div><b>Package</b> {{ p.package_id or '—' }}</div>
|
||||
<div><b>Tracking</b>
|
||||
{% 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>
|
||||
{% else %}{{ p.tracking_id or '—' }}{% endif %}
|
||||
</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">
|
||||
{% for it in p['items'] %}
|
||||
<li>{{ it.seller_sku }} <span class="qty">× {{ it.quantity }}</span></li>
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
{% include "dispatch/_nav.html" %}
|
||||
|
||||
<div class="erp-card" style="max-width:640px;">
|
||||
<div class="cpg-card-head"><h2>TikTok 파일 업로드</h2></div>
|
||||
<p class="erp-muted" style="margin:4px 0 16px;">
|
||||
03_TikTok_Order_Export.xlsx 만 있으면 출고 리스트가 자동 생성됩니다.
|
||||
PDF 2개는 보관용(선택)입니다. 고객 이름/주소/전화는 저장하지 않습니다.
|
||||
</p>
|
||||
<div class="cpg-card-head"><h2>출고 파일 업로드</h2></div>
|
||||
<p class="erp-muted" id="dsp-intro" style="margin:4px 0 16px;"></p>
|
||||
|
||||
<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;">
|
||||
@@ -19,29 +16,26 @@
|
||||
|
||||
<label style="display:flex;flex-direction:column;gap:4px;">
|
||||
<span>플랫폼</span>
|
||||
<select class="erp-select" name="platform">
|
||||
<option value="TikTok" selected>TikTok</option>
|
||||
<select class="erp-select" name="platform" id="dsp-platform">
|
||||
{% for pf in platforms %}
|
||||
<option value="{{ pf }}">{{ pf }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<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="오전 출고 / 오후 출고 등으로 구분" />
|
||||
</label>
|
||||
|
||||
<label style="display:flex;flex-direction:column;gap:4px;">
|
||||
<span>1️⃣ Picking List.pdf <span class="erp-muted">(선택 · 전체 피킹 참고용)</span></span>
|
||||
<input class="erp-input" type="file" name="picking_pdf" accept="application/pdf" />
|
||||
<span id="dsp-label-lbl"></span>
|
||||
<input class="erp-input" type="file" name="label_pdf" id="dsp-label" accept="application/pdf" />
|
||||
</label>
|
||||
|
||||
<label style="display:flex;flex-direction:column;gap:4px;">
|
||||
<span>2️⃣ Shipping label+Packing slip.pdf <span class="erp-muted">(선택 · 실제 포장/라벨 원본)</span></span>
|
||||
<input class="erp-input" type="file" name="label_pdf" accept="application/pdf" />
|
||||
</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"
|
||||
<span id="dsp-data-lbl"></span>
|
||||
<input class="erp-input" type="file" name="data_xlsx" id="dsp-data"
|
||||
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required />
|
||||
</label>
|
||||
|
||||
@@ -53,3 +47,32 @@
|
||||
</div>
|
||||
</section>
|
||||
{% 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
@@ -1,4 +1,4 @@
|
||||
# 말레이시아 배송 모듈 (dispatch) — TikTok 출고관리
|
||||
# 말레이시아 배송 모듈 (dispatch) — TikTok·Shopee 출고관리
|
||||
|
||||
> 초보 물류 직원이 엑셀을 열지 않고 웹 화면만 보고 출고 작업을 하도록 만든 모듈.
|
||||
> main-app ERP 에 편입된 **모듈**이다(별도 앱/포트 아님). 인증은 기존 Google
|
||||
@@ -8,17 +8,29 @@
|
||||
|
||||
## 1. 무엇을 하나
|
||||
|
||||
TikTok Seller Center 에서 매일 받는 3개 파일을 업로드하면, 직원용 **출고 작업
|
||||
리스트(04_Daily_Dispatch_List)** 를 웹에서 자동 생성한다(엑셀 파일로 만들 필요 없음).
|
||||
플랫폼(TikTok·Shopee)에서 매일 받는 파일을 업로드하면, 직원용 **출고 작업
|
||||
리스트** 를 웹에서 자동 생성한다(엑셀 파일로 만들 필요 없음). 플랫폼마다 올리는
|
||||
파일이 다르며, 업로드 화면이 플랫폼 선택에 따라 바뀐다.
|
||||
|
||||
**TikTok**
|
||||
|
||||
| 파일 | 용도 | 업로드 |
|
||||
| --- | --- | --- |
|
||||
| 01_Picking_List.pdf | 전체 상품 피킹 참고용 | 선택(보관) |
|
||||
| 02_Shipping_Label_Packing_Slip.pdf | 실제 포장/라벨 부착 원본 | 선택(보관) |
|
||||
| 03_TikTok_Order_Export.xlsx | **자동 출고 리스트 기준 데이터** | **필수** |
|
||||
|
||||
작업 기준은 고객정보가 아니라 **Order ID / Package ID / Tracking ID / Seller SKU /
|
||||
Quantity** 뿐이다. 고객 이름·주소·전화는 파싱 단계에서 버려 **DB 에 저장하지 않는다.**
|
||||
> 01_Picking_List.pdf 는 더 이상 받지 않는다(피킹 요약은 데이터 엑셀로 생성).
|
||||
|
||||
**Shopee**
|
||||
|
||||
| 파일 | 용도 | 업로드 |
|
||||
| --- | --- | --- |
|
||||
| Shopee Seller Centre.pdf | 택배 송장(라벨) 원본 | 선택(보관) |
|
||||
| Packing List.Doorstep Delivery.xlsx | **자동 출고 리스트 기준 데이터** | **필수** |
|
||||
|
||||
작업 기준 키는 **Order ID / Package ID / Tracking ID / Seller SKU / Quantity** 이고,
|
||||
**받는 사람 이름·전화·주소**는 출고 작업 카드 표시 + 취합 출고 엑셀 생성을 위해
|
||||
박스 단위로 **dispatch_db 에 저장한다**(개인정보 — 접근/보관 정책 검토 대상).
|
||||
|
||||
### 1박스 묶음 규칙
|
||||
|
||||
@@ -90,12 +102,21 @@ cd /opt/www/main && docker compose up -d --build web
|
||||
업로드한 PDF/XLSX 원본은 아래에 저장되고 DB 에는 경로만 기록한다.
|
||||
|
||||
```
|
||||
# TikTok
|
||||
$DATA_DIR/dispatch/2026-06-19/<배치slug>_<HHMMSS>/
|
||||
01_Picking_List.pdf
|
||||
02_Shipping_Label_Packing_Slip.pdf
|
||||
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 으로 구분해 **새 배치**로 생성된다(덮어쓰지 않음).
|
||||
예: "2026-06-19 TikTok 오전 출고", "2026-06-19 TikTok 오후 출고".
|
||||
|
||||
@@ -107,10 +128,14 @@ $DATA_DIR/dispatch/2026-06-19/<배치slug>_<HHMMSS>/
|
||||
- Quantity 가 비면 1 로 처리한다.
|
||||
- Tracking ID 가 숫자로 읽혀도 문자열로 보존한다(과학표기/`.0` 제거).
|
||||
- NaN/None 은 빈 문자열로 처리한다.
|
||||
- 고객 이름/주소/전화 컬럼은 매핑하지 않아 **읽는 즉시 버린다**(미저장).
|
||||
- 받는 사람 이름/전화/주소 컬럼은 매핑되면 **박스 단위로 보존**한다(작업 카드·엑셀).
|
||||
- 헤더가 첫 행이 아닐 수 있어(Shopee 는 제목 행이 위에 붙을 수 있음) 상단 몇 행을
|
||||
훑어 필수 컬럼이 잡히는 첫 행을 헤더로 본다.
|
||||
|
||||
가능한 헤더(앞뒤 공백·대소문자 무시): Order ID, Package ID, Tracking ID,
|
||||
Shipping Provider Name, Seller SKU, Product Name, Quantity.
|
||||
가능한 헤더(앞뒤 공백·대소문자 무시, TikTok·Shopee 별칭은 `store.COLUMN_ALIASES`):
|
||||
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 미사용,
|
||||
레포 공통 `erp.css` 재사용) · **openpyxl**(스펙의 pandas 대신, 레포 의존성 경량 유지).
|
||||
- 코드: `app/modules/dispatch/` (`store.py` 순수 로직 / `parser.py` 엑셀 / `db.py`
|
||||
DB I/O / `router.py` 라우팅 / `templates/dispatch/` 화면).
|
||||
- 개인정보 미보관: 스키마에 이름/주소/전화 컬럼 자체가 없다.
|
||||
- 코드: `app/modules/dispatch/` (`store.py` 순수 로직 / `parser.py` 엑셀 파싱 /
|
||||
`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;
|
||||
@@ -22,7 +22,8 @@
|
||||
-- 주의:
|
||||
-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만).
|
||||
-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달.
|
||||
-- - 개인정보(고객 이름/주소/전화)는 저장하지 않는 구조다(컬럼 자체가 없음).
|
||||
-- - 받는 사람 정보(이름/전화/주소)는 출고 작업 카드 표시 + 출고 엑셀 생성을
|
||||
-- 위해 박스 단위로 보관한다(개인정보 — 접근/보관 정책 검토 대상).
|
||||
-- - 작업 기준 키: order_id / package_id / tracking_id / seller_sku / quantity.
|
||||
-- =====================================================================
|
||||
|
||||
@@ -80,7 +81,7 @@ CREATE TRIGGER trg_dispatch_batches_updated
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- 2) 출고 박스 (1박스 = 1행 = 1작업카드)
|
||||
-- 묶음 기준: package_id > tracking_id > order_id (파서가 결정).
|
||||
-- 개인정보 컬럼 없음(이름/주소/전화 저장 안 함).
|
||||
-- 받는 사람(recipient_*) 정보 보관 — 작업 카드 표시 + 출고 엑셀 생성용.
|
||||
-- seq 는 배치 내 박스 표시 순번(No).
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE TABLE IF NOT EXISTS dispatch_parcels (
|
||||
@@ -91,6 +92,9 @@ CREATE TABLE IF NOT EXISTS dispatch_parcels (
|
||||
package_id TEXT NOT NULL DEFAULT '',
|
||||
tracking_id 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,
|
||||
packed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
label_attached BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
Reference in New Issue
Block a user