From 99eea01fc1d1b1586654d113d78d85d0c79cc0f2 Mon Sep 17 00:00:00 2001 From: king Date: Fri, 19 Jun 2026 15:11:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20=EB=A7=90=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=8B=9C=EC=95=84=20TikTok=20=EC=B6=9C=EA=B3=A0=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=AA=A8=EB=93=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 03_TikTok_Order_Export.xlsx 업로드 → 1박스=1카드 출고 작업 리스트, SKU 피킹 요약, Kagayaku 전달표(A4 인쇄)를 자동 생성. - 1박스 묶음 기준: Package ID > Tracking ID > Order ID, 같은 박스 같은 SKU 합산 - 작업 상태 5단계 토글(AJAX 즉시 저장) + dispatch_logs 기록 - 고객 이름/주소/전화 미저장(파싱 시 폐기, 스키마에 컬럼 없음) - 엑셀은 openpyxl 파싱(pandas 미사용), DB는 dispatch_db 전용 - 인증은 기존 Google OAuth + 신규 권한키 dispatch 재사용 - scripts/sql/dispatch_db_init.sql, docs/DISPATCH_MODULE.md 동봉 Co-Authored-By: Claude Opus 4.8 --- .env.example | 8 + CLAUDE.md | 1 + app/main.py | 19 + app/modules/dispatch/__init__.py | 47 +++ app/modules/dispatch/db.py | 313 ++++++++++++++ app/modules/dispatch/parser.py | 70 ++++ app/modules/dispatch/router.py | 395 ++++++++++++++++++ app/modules/dispatch/store.py | 223 ++++++++++ .../dispatch/templates/dispatch/_nav.html | 12 + .../dispatch/templates/dispatch/batches.html | 50 +++ .../dispatch/templates/dispatch/detail.html | 177 ++++++++ .../dispatch/templates/dispatch/handover.html | 77 ++++ .../dispatch/templates/dispatch/new.html | 55 +++ .../dispatch/templates/dispatch/picking.html | 41 ++ app/modules/dispatch/tests/__init__.py | 0 app/modules/dispatch/tests/test_grouping.py | 93 +++++ app/store.py | 1 + docs/DISPATCH_MODULE.md | 133 ++++++ scripts/sql/dispatch_db_init.sql | 153 +++++++ 19 files changed, 1868 insertions(+) create mode 100644 app/modules/dispatch/__init__.py create mode 100644 app/modules/dispatch/db.py create mode 100644 app/modules/dispatch/parser.py create mode 100644 app/modules/dispatch/router.py create mode 100644 app/modules/dispatch/store.py create mode 100644 app/modules/dispatch/templates/dispatch/_nav.html create mode 100644 app/modules/dispatch/templates/dispatch/batches.html create mode 100644 app/modules/dispatch/templates/dispatch/detail.html create mode 100644 app/modules/dispatch/templates/dispatch/handover.html create mode 100644 app/modules/dispatch/templates/dispatch/new.html create mode 100644 app/modules/dispatch/templates/dispatch/picking.html create mode 100644 app/modules/dispatch/tests/__init__.py create mode 100644 app/modules/dispatch/tests/test_grouping.py create mode 100644 docs/DISPATCH_MODULE.md create mode 100644 scripts/sql/dispatch_db_init.sql diff --git a/.env.example b/.env.example index ea49a59..b7fe103 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,14 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/ # 권한키: malaysia(접근). admin 은 항상 통과. 상품명은 아래 ITEMCODE_DB_URL 재사용. # MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:replace-me@postgres-db:5432/malaysia_stock_db +# ─── 말레이시아 배송 모듈 (dispatch_db) — TikTok 출고관리 ─── +# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음). +# DB/역할/스키마 생성: scripts/sql/dispatch_db_init.sql 참고. +# 권한키: dispatch(접근). admin 은 항상 통과. +# 업로드 원본 파일은 DATA_DIR/dispatch/<날짜>/<배치>/ 아래에 저장된다. +# 개인정보(고객 이름/주소/전화)는 저장하지 않는다. +# DISPATCH_DB_URL=postgresql://dispatch_app:replace-me@postgres-db:5432/dispatch_db + # ─── 상품 검색 (itemcode_db 읽기 전용) ─── # cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만). # 미설정 시 검색 비활성 → 수동 등록만 가능. diff --git a/CLAUDE.md b/CLAUDE.md index 3b92b4c..b9aa151 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +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` 상세는 `docs/PROJECT_OVERVIEW.md`. diff --git a/app/main.py b/app/main.py index f46aaee..ed722de 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,8 @@ from starlette.middleware.sessions import SessionMiddleware from .modules.cupang import build_cupang_store, build_itemcode_reader from .modules.cupang import router as cupang_router +from .modules.dispatch import build_dispatch_store +from .modules.dispatch import router as dispatch_router from .modules.expense import CategoryStore, build_expense_store from .modules.expense import router as expense_router from .modules.malaysia import build_malaysia_itemcode, build_malaysia_store @@ -39,6 +41,7 @@ MODULE_LABELS: dict[str, str] = { "vacation": "휴가", "cupang": "쿠팡 밀크런", "malaysia": "말레이시아 재고관리", + "dispatch": "말레이시아 배송", "expense_approver": "개인경비", "vacation_approver": "휴가", } @@ -112,6 +115,7 @@ _MODULE_TEMPLATE_DIRS = [ BASE_DIR / "modules" / "cupang" / "templates", BASE_DIR / "modules" / "vacation" / "templates", BASE_DIR / "modules" / "malaysia" / "templates", + BASE_DIR / "modules" / "dispatch" / "templates", ] templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates.env.loader = ChoiceLoader( @@ -144,12 +148,16 @@ app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or No app.state.malaysia_store = build_malaysia_store(dsn=env("MALAYSIA_STOCK_DB_URL") or None) # 세트 BOM 은 itemcode_db(set_components)에서 읽는다(ITEMCODE_DB_URL 재사용). app.state.malaysia_itemcode = build_malaysia_itemcode() +# 말레이시아 배송(TikTok 출고): DISPATCH_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내). +# 업로드 원본은 DATA_DIR/dispatch/ 아래 저장(개인정보는 저장하지 않음). +app.state.dispatch_store = build_dispatch_store(dsn=env("DISPATCH_DB_URL") or None) # 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄. app.include_router(expense_router) app.include_router(cupang_router) app.include_router(vacation_router) app.include_router(malaysia_router) +app.include_router(dispatch_router) def public_url_for(request: Request, route_name: str) -> str: @@ -302,6 +310,16 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]: "status": "ready", "category": "운영", }, + { + "key": "dispatch", + "title": "말레이시아 배송", + "subtitle": "Malaysia Dispatch", + "description": "TikTok 출고 파일을 올리면 박스별 작업 리스트·피킹 요약·Kagayaku 전달표를 자동 생성합니다.", + "url": "/dispatch/", + "health_url": "/dispatch/health", + "status": "ready", + "category": "운영", + }, ] allowed = allowed_modules(user_rec) for item in items: @@ -319,6 +337,7 @@ def _icon_svg(name: str) -> str: "order": '', "cupang": '', "malaysia": '', + "dispatch": '', "modules": '', } body = paths.get(name, paths["modules"]) diff --git a/app/modules/dispatch/__init__.py b/app/modules/dispatch/__init__.py new file mode 100644 index 0000000..8060d6a --- /dev/null +++ b/app/modules/dispatch/__init__.py @@ -0,0 +1,47 @@ +"""말레이시아 TikTok 출고관리(dispatch) 모듈. + +라우터/저장소/파서/템플릿을 한 디렉토리에서 관리한다. +- 라우터: `router.py` (FastAPI APIRouter, prefix=/dispatch) +- 저장소: `db.py` (dispatch_db / PostgreSQL 전용) +- 파서: `parser.py` (TikTok Order Export XLSX → 박스 묶기, openpyxl) +- 순수 로직: `store.py` (컬럼 정규화/박스 묶기/SKU 합산/상태 상수) +- 템플릿: `templates/dispatch/` + +데이터 저장은 dispatch_db 전용이다. DISPATCH_DB_URL 미설정 시 +build_dispatch_store 는 None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 +보여준다(앱은 죽지 않음). + +업로드 원본 파일은 DATA_DIR/dispatch/<날짜>/<배치>/ 아래에 저장하고, DB 에는 +경로만 기록한다. 고객 이름/주소/전화 등 개인정보는 저장하지 않는다. +""" + +from typing import Any + +from . import store +from .parser import ParseError, parse_order_export +from .router import router +from .store import STATUS_FIELDS, STATUS_LABELS, group_parcels, sku_summary + +__all__ = [ + "router", + "store", + "STATUS_FIELDS", + "STATUS_LABELS", + "group_parcels", + "sku_summary", + "parse_order_export", + "ParseError", + "build_dispatch_store", +] + + +def build_dispatch_store(*, dsn: str | None) -> Any: + """DISPATCH_DB_URL 이 있으면 DispatchStore, 없으면 None. + + JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 표시. + """ + if not dsn: + return None + from .db import DispatchStore # 지연 import (개발 환경 deps 없을 수 있음) + + return DispatchStore(dsn) diff --git a/app/modules/dispatch/db.py b/app/modules/dispatch/db.py new file mode 100644 index 0000000..3f7082f --- /dev/null +++ b/app/modules/dispatch/db.py @@ -0,0 +1,313 @@ +"""dispatch_db PostgreSQL 저장소. + +- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴. +- 연결 정보: 환경변수 `DISPATCH_DB_URL` + (예: postgresql://dispatch_app:@postgres-db:5432/dispatch_db) +- 스키마는 앱이 만들지 않는다. `scripts/sql/dispatch_db_init.sql` 을 superuser 가 + 사전 적용한다. 앱 계정(dispatch_app)은 CRUD 권한만 받는다. +- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게. + +박스 묶기/SKU 합산 등 순수 로직은 `store.py` 에 있고, 여기서는 DB I/O 와 +조립만 담당한다. +""" + +from __future__ import annotations + +import logging +from datetime import date, datetime +from typing import Any + +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool + +from app.timezone import KST + +from . import store + +logger = logging.getLogger("dispatch.db") + + +class DispatchStore: + def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5): + self._pool = ConnectionPool( + conninfo=dsn, + min_size=min_size, + max_size=max_size, + kwargs={"row_factory": dict_row, "autocommit": True}, + open=False, + ) + self._pool.open(wait=False) + + def close(self) -> None: + self._pool.close() + + # ════════════════════════════════════════════════════════════ + # 배치 + # ════════════════════════════════════════════════════════════ + def create_batch( + self, + *, + platform: str, + dispatch_date: str, + batch_name: str, + picking_pdf_path: str = "", + label_pdf_path: str = "", + order_export_path: str = "", + ) -> dict[str, Any]: + if not (dispatch_date or "").strip(): + raise ValueError("출고 날짜는 필수입니다.") + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO dispatch_batches + (platform, dispatch_date, batch_name, + picking_pdf_path, label_pdf_path, order_export_path) + VALUES (%s,%s,%s,%s,%s,%s) + RETURNING * + """, + ( + (platform or "TikTok").strip(), + dispatch_date.strip(), + (batch_name or "").strip(), + (picking_pdf_path or "").strip(), + (label_pdf_path or "").strip(), + (order_export_path or "").strip(), + ), + ).fetchone() + return self._serialize(row) + + def add_parcels(self, *, batch_id: int, parcels: list[dict[str, Any]]) -> int: + """파서 결과(parcels)를 박스+상품으로 일괄 저장. 반환: 저장한 박스 수. + + 한 트랜잭션. 같은 박스 안 같은 SKU 는 파서가 이미 합산해서 들어온다. + """ + saved = 0 + with self._pool.connection() as conn: + with conn.transaction(): + for p in parcels: + parcel_row = conn.execute( + """ + INSERT INTO dispatch_parcels + (batch_id, seq, order_id, package_id, + tracking_id, shipping_provider) + VALUES (%s,%s,%s,%s,%s,%s) + RETURNING id + """, + ( + batch_id, + int(p.get("seq") or (saved + 1)), + (p.get("order_id") or "").strip(), + (p.get("package_id") or "").strip(), + (p.get("tracking_id") or "").strip(), + (p.get("shipping_provider") or "").strip(), + ), + ).fetchone() + parcel_id = parcel_row["id"] + for it in p.get("items", []): + conn.execute( + """ + INSERT INTO dispatch_items + (parcel_id, seller_sku, product_name, quantity) + VALUES (%s,%s,%s,%s) + """, + ( + parcel_id, + (it.get("seller_sku") or "").strip(), + (it.get("product_name") or "").strip(), + int(it.get("quantity") or 1), + ), + ) + saved += 1 + return saved + + def list_batches(self, *, limit: int = 200) -> list[dict[str, Any]]: + """배치 목록 + 박스 수/완료(택배스캔) 수 요약.""" + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT b.*, + COUNT(p.id) AS parcel_count, + COUNT(p.id) FILTER (WHERE p.courier_scanned) AS scanned_count + FROM dispatch_batches b + LEFT JOIN dispatch_parcels p ON p.batch_id = b.id + GROUP BY b.id + ORDER BY b.dispatch_date DESC, b.id DESC + LIMIT %s + """, + (int(limit),), + ).fetchall() + return [self._serialize(r) for r in rows] + + def get_batch(self, *, batch_id: int) -> dict[str, Any] | None: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT * FROM dispatch_batches WHERE id = %s", (batch_id,) + ).fetchone() + return self._serialize(row) if row else None + + def delete_batch(self, *, batch_id: int) -> None: + """배치 + 하위 박스/상품/로그 전체 삭제(CASCADE).""" + with self._pool.connection() as conn: + cur = conn.execute("DELETE FROM dispatch_batches WHERE id = %s", (batch_id,)) + if cur.rowcount == 0: + raise KeyError(batch_id) + + # ════════════════════════════════════════════════════════════ + # 박스(parcel) + # ════════════════════════════════════════════════════════════ + def list_parcels(self, *, batch_id: int) -> list[dict[str, Any]]: + """배치의 박스 전체 + 각 박스의 상품 목록(seq 순).""" + with self._pool.connection() as conn: + parcel_rows = conn.execute( + "SELECT * FROM dispatch_parcels WHERE batch_id = %s " + "ORDER BY seq ASC, id ASC", + (batch_id,), + ).fetchall() + item_rows = conn.execute( + """ + SELECT i.* FROM dispatch_items i + JOIN dispatch_parcels p ON p.id = i.parcel_id + WHERE p.batch_id = %s + ORDER BY i.seller_sku ASC, i.id ASC + """, + (batch_id,), + ).fetchall() + items_by_parcel: dict[int, list[dict[str, Any]]] = {} + for r in item_rows: + items_by_parcel.setdefault(r["parcel_id"], []).append(self._serialize(r)) + parcels: list[dict[str, Any]] = [] + for r in parcel_rows: + p = self._serialize(r) + p["items"] = items_by_parcel.get(r["id"], []) + parcels.append(p) + return parcels + + def sku_summary(self, *, batch_id: int) -> list[dict[str, Any]]: + """배치 전체 SKU별 총 수량(피킹 요약). seller_sku 오름차순.""" + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT i.seller_sku, + MAX(i.product_name) AS product_name, + SUM(i.quantity)::int AS total_qty + FROM dispatch_items i + JOIN dispatch_parcels p ON p.id = i.parcel_id + WHERE p.batch_id = %s + GROUP BY i.seller_sku + ORDER BY i.seller_sku ASC + """, + (batch_id,), + ).fetchall() + return [ + { + "seller_sku": r["seller_sku"], + "product_name": r["product_name"] or "", + "total_qty": int(r["total_qty"] or 0), + } + for r in rows + ] + + def toggle_status( + self, *, parcel_id: int, field: str, worker_name: str = "" + ) -> dict[str, Any]: + """박스의 작업 상태 boolean 1개를 토글하고 로그를 남긴다. + + 반환: {"parcel_id", "field", "value"(토글 후 값)}. + field 는 store.STATUS_FIELDS 화이트리스트 검증(SQL 식별자 안전). + """ + if field not in store.STATUS_FIELDS: + raise ValueError(f"허용되지 않는 작업 상태: {field}") + with self._pool.connection() as conn: + with conn.transaction(): + cur = conn.execute( + # field 는 화이트리스트라 인젝션 위험 없음. RETURNING 으로 새 값 확인. + f"UPDATE dispatch_parcels SET {field} = NOT {field} " + "WHERE id = %s RETURNING " + field, + (parcel_id,), + ).fetchone() + if cur is None: + raise KeyError(parcel_id) + new_value = bool(cur[field]) + conn.execute( + """ + INSERT INTO dispatch_logs + (parcel_id, action, old_value, new_value, worker_name) + VALUES (%s,%s,%s,%s,%s) + """, + ( + parcel_id, + field, + str(not new_value).lower(), + str(new_value).lower(), + (worker_name or "").lower().strip(), + ), + ) + return {"parcel_id": parcel_id, "field": field, "value": new_value} + + def parcel_batch_id(self, *, parcel_id: int) -> int | None: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT batch_id FROM dispatch_parcels WHERE id = %s", (parcel_id,) + ).fetchone() + return int(row["batch_id"]) if row else None + + # ════════════════════════════════════════════════════════════ + # Kagayaku 전달 요약 + # ════════════════════════════════════════════════════════════ + def handover_summary(self, *, batch_id: int) -> dict[str, Any]: + """배송사별 박스 수 + Tracking ID 목록(전달 리스트/인쇄용).""" + with self._pool.connection() as conn: + provider_rows = conn.execute( + """ + SELECT COALESCE(NULLIF(shipping_provider, ''), '(미지정)') AS provider, + COUNT(*)::int AS box_count + FROM dispatch_parcels + WHERE batch_id = %s + GROUP BY provider + ORDER BY box_count DESC, provider ASC + """, + (batch_id,), + ).fetchall() + tracking_rows = conn.execute( + """ + SELECT seq, order_id, tracking_id, shipping_provider + FROM dispatch_parcels + WHERE batch_id = %s + ORDER BY seq ASC, id ASC + """, + (batch_id,), + ).fetchall() + total = sum(int(r["box_count"]) for r in provider_rows) + return { + "total_boxes": total, + "by_provider": [ + {"provider": r["provider"], "box_count": int(r["box_count"])} + for r in provider_rows + ], + "tracking_list": [self._serialize(r) for r in tracking_rows], + } + + # ════════════════════════════════════════════════════════════ + # 직렬화 + # ════════════════════════════════════════════════════════════ + @staticmethod + def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None: + if row is None: + return None + out = dict(row) + for k, v in list(out.items()): + if isinstance(v, datetime): + out[k] = v.astimezone(KST).isoformat(timespec="seconds") + elif isinstance(v, date): + out[k] = v.isoformat() + for k in ("id", "batch_id", "parcel_id", "seq", "quantity", + "parcel_count", "scanned_count", "total_qty", "box_count"): + if k in out and out[k] is not None: + try: + out[k] = int(out[k]) + except (TypeError, ValueError): + pass + for k in store.STATUS_FIELDS: + if k in out: + out[k] = bool(out[k]) + return out diff --git a/app/modules/dispatch/parser.py b/app/modules/dispatch/parser.py new file mode 100644 index 0000000..e6f0802 --- /dev/null +++ b/app/modules/dispatch/parser.py @@ -0,0 +1,70 @@ +"""TikTok Order Export XLSX 파서 (openpyxl). + +요구사항 2/11 구현. pandas 대신 레포에 이미 있는 openpyxl 로 읽어 의존성을 +가볍게 유지한다(동작/결과는 동일: 헤더 정규화 → 표준키 매핑 → 박스 묶기). + +- 첫 시트의 첫 행을 헤더로 본다. 헤더 앞뒤 공백 제거 후 표준키로 매핑한다. +- 매핑되지 않는 열(고객 이름/주소/전화 등)은 읽기 단계에서 버린다 → 개인정보 + 미보관(요구사항 11). +- Tracking ID 는 숫자로 읽혀도 문자열로 보존(store.clean_text). +- Quantity 가 비면 1, NaN/None 은 빈 문자열로 처리. +- 필수 컬럼이 없으면 어떤 컬럼이 없는지 ParseError 로 알린다. +""" + +from __future__ import annotations + +from typing import Any + +from . import store + + +class ParseError(Exception): + """엑셀 파싱 실패. message 는 사용자에게 그대로 보여줄 한국어 메시지.""" + + +def parse_order_export(path: str) -> dict[str, Any]: + """XLSX 경로 → {"parcels": [...], "row_count": int}. + + parcels 는 store.group_parcels 결과(1박스=1항목). 실패 시 ParseError. + """ + try: + from openpyxl import load_workbook # noqa: WPS433 — 지연 import + except ImportError as exc: # pragma: no cover + raise ParseError("openpyxl 이 설치되어 있지 않습니다.") from exc + + try: + wb = load_workbook(path, read_only=True, data_only=True) + except Exception as exc: # noqa: BLE001 + raise ParseError(f"엑셀 파일을 열 수 없습니다: {type(exc).__name__}") from exc + + try: + ws = wb.worksheets[0] + rows_iter = ws.iter_rows(values_only=True) + try: + header = next(rows_iter) + except StopIteration: + raise ParseError("엑셀에 데이터가 없습니다(빈 파일).") + + mapping = store.resolve_columns(header) + missing = store.missing_required(mapping) + if missing: + raise ParseError("엑셀에서 필요한 컬럼을 찾지 못했습니다: " + ", ".join(missing)) + + norm_rows: list[dict[str, str]] = [] + for raw in rows_iter: + if raw is None: + continue + # 표준키만 추출(매핑 안 된 열 = 개인정보 등은 버린다). + record = { + key: store.clean_text(raw[idx]) if idx < len(raw) else "" + for key, idx in mapping.items() + } + # 완전 빈 행 스킵 + if not any(record.get(k) for k in ("order_id", "package_id", "tracking_id", "seller_sku")): + continue + norm_rows.append(record) + finally: + wb.close() + + parcels = store.group_parcels(norm_rows) + return {"parcels": parcels, "row_count": len(norm_rows)} diff --git a/app/modules/dispatch/router.py b/app/modules/dispatch/router.py new file mode 100644 index 0000000..b150e78 --- /dev/null +++ b/app/modules/dispatch/router.py @@ -0,0 +1,395 @@ +"""말레이시아 TikTok 출고관리 모듈 라우터. + +- 경로: /dispatch +- 권한: 로그인 + `dispatch` 모듈 권한 (관리자는 항상 통과). 서버 측 검사. +- 데이터: DispatchStore (dispatch_db / PostgreSQL) 전용. + DISPATCH_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내. +- 업로드 원본은 DATA_DIR/dispatch/<날짜>/<배치slug>/ 에 저장, DB 엔 경로만 기록. + +초보 물류 직원용 화면이라 고객정보는 일절 다루지 않는다. 작업 기준은 +Order ID / Package ID / Tracking ID / Seller SKU / Quantity 뿐이다. +""" + +from __future__ import annotations + +import re +import shutil +import unicodedata +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app.timezone import now_kst, today_kst + +from . import store +from .parser import ParseError, parse_order_export + +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",)) + + +# ──────────────────────────────────────────────────────────── +# 공용 헬퍼 +# ──────────────────────────────────────────────────────────── +def _store(request: Request) -> Any: + return getattr(request.app.state, "dispatch_store", None) + + +def _data_dir(request: Request) -> Path: + return Path(getattr(request.app.state, "data_dir", Path("app/data"))) + + +def _require_user(request: Request) -> dict[str, Any]: + from app.main import get_current_user_record # noqa: WPS433 + from app.store import has_module # noqa: WPS433 + + user = get_current_user_record(request) + if user is None: + raise HTTPException(status_code=401, detail="로그인이 필요합니다.") + if not has_module(user, "dispatch"): + raise HTTPException(status_code=403, detail="말레이시아 배송 모듈 권한이 없습니다.") + return user + + +def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse: + from app.main import build_erp_nav, render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + return render_template( + request, + "denied.html", + { + "reason": "말레이시아 배송 모듈이 아직 설정되지 않았습니다. " + "DISPATCH_DB_URL 환경변수를 설정하고 " + "scripts/sql/dispatch_db_init.sql 로 dispatch_db 를 초기화한 뒤 " + "컨테이너를 재기동하세요.", + "user": user, + "is_admin": is_admin(user), + "nav_items": build_erp_nav(user, active="dispatch"), + }, + status_code=503, + ) + + +def _guard(request: Request): + """로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용.""" + from app.main import get_current_user_record, render_template # noqa: WPS433 + from app.store import has_module, is_admin # noqa: WPS433 + + user = get_current_user_record(request) + if user is None: + return RedirectResponse(url="/login", status_code=303) + if not has_module(user, "dispatch"): + return render_template( + request, + "denied.html", + {"reason": "말레이시아 배송 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)}, + status_code=403, + ) + st = _store(request) + if st is None: + return _render_config_needed(request, user) + return st, user + + +def _base_ctx(request: Request, user: dict[str, Any]) -> dict[str, Any]: + from app.main import build_erp_nav # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + return { + "user": user, + "is_admin": is_admin(user), + "is_super": bool(user.get("is_super_admin")), + "nav_items": build_erp_nav(user, active="dispatch"), + "status_fields": store.STATUS_FIELDS, + "status_labels": store.STATUS_LABELS, + } + + +def _slugify(value: str) -> str: + """배치명 → 폴더/파일 안전한 슬러그. 비면 'batch'.""" + text = unicodedata.normalize("NFKC", value or "").strip() + text = re.sub(r"[^\w\-.가-힣]+", "_", text, flags=re.UNICODE) + text = text.strip("._") + return text or "batch" + + +def _save_upload(upload: UploadFile | None, *, dest_dir: Path, slot: tuple) -> str: + """업로드 파일 1개 저장. 반환: 저장 경로(없으면 ""). 확장자 검증.""" + filename, allowed_ext = slot + if upload is None or not (upload.filename or "").strip(): + return "" + ext = Path(upload.filename).suffix.lower() + if ext not in allowed_ext: + raise HTTPException( + status_code=400, + detail=f"{filename} 는 {', '.join(allowed_ext)} 형식이어야 합니다. (업로드: {upload.filename})", + ) + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / filename + with dest.open("wb") as f: + shutil.copyfileobj(upload.file, f) + return str(dest) + + +# ════════════════════════════════════════════════════════════ +# 배치 목록 (메인) +# ════════════════════════════════════════════════════════════ +@router.get("/", response_class=HTMLResponse) +async def batches_index(request: Request) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + st, user = guard + ctx = _base_ctx(request, user) + ctx.update( + { + "page_title": "말레이시아 배송 — 출고 배치", + "page_subtitle": "TikTok 일일 출고 배치 목록", + "batches": st.list_batches(), + "active_tab": "batches", + } + ) + return render_template(request, "dispatch/batches.html", ctx) + + +@router.get("/batches/new", response_class=HTMLResponse) +async def batch_new(request: Request) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + _st, user = guard + ctx = _base_ctx(request, user) + ctx.update( + { + "page_title": "말레이시아 배송 — 업로드", + "page_subtitle": "TikTok 파일 업로드 → 출고 배치 자동 생성", + "today": today_kst().isoformat(), + "active_tab": "new", + } + ) + return render_template(request, "dispatch/new.html", ctx) + + +@router.post("/batches") +async def batch_create( + request: Request, + 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), + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + """파일 업로드 + 배치 생성 + 엑셀 파싱 + 박스 저장. + + 03_TikTok_Order_Export.xlsx 는 필수(자동 출고 리스트의 기준). PDF 2개는 선택. + 같은 날짜/플랫폼이라도 새 배치로 생성한다(덮어쓰지 않음, 요구사항 12). + """ + 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(): + raise HTTPException( + status_code=400, + detail="03_TikTok_Order_Export.xlsx 는 필수입니다(자동 출고 리스트 기준 데이터).", + ) + + # 저장 폴더: DATA_DIR/dispatch/<날짜>/<배치slug>_/ + name = (batch_name or "").strip() or f"{platform} 출고" + 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) + + # 엑셀 파싱 — 실패 시 사용자에게 한국어 메시지 그대로 보여준다. + try: + parsed = parse_order_export(order_path) + except ParseError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + parcels = parsed["parcels"] + if not parcels: + raise HTTPException( + status_code=400, + detail="엑셀에서 출고할 박스를 찾지 못했습니다. 컬럼/데이터를 확인하세요.", + ) + + batch = st.create_batch( + platform=platform, + dispatch_date=dispatch_date, + batch_name=name, + picking_pdf_path=picking_path, + label_pdf_path=label_path, + order_export_path=order_path, + ) + st.add_parcels(batch_id=batch["id"], parcels=parcels) + return RedirectResponse(url=f"/dispatch/batches/{batch['id']}", status_code=303) + + +@router.post("/batches/{batch_id:int}/delete") +async def batch_delete( + request: Request, batch_id: int, user: dict[str, Any] = Depends(_require_user) +) -> RedirectResponse: + """배치 삭제 — 관리자 전용(박스/상품/로그 CASCADE).""" + from app.store import is_admin # noqa: WPS433 + + if not is_admin(user): + raise HTTPException(status_code=403, detail="배치 삭제는 관리자만 가능합니다.") + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="dispatch_db 미설정") + try: + st.delete_batch(batch_id=batch_id) + except KeyError: + raise HTTPException(status_code=404, detail="배치를 찾을 수 없습니다.") + return RedirectResponse(url="/dispatch/", status_code=303) + + +# ════════════════════════════════════════════════════════════ +# 출고 작업 리스트 (1박스 = 1카드) +# ════════════════════════════════════════════════════════════ +@router.get("/batches/{batch_id:int}", response_class=HTMLResponse) +async def batch_detail(request: Request, batch_id: int) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + st, user = guard + batch = st.get_batch(batch_id=batch_id) + if batch is None: + return render_template( + request, "denied.html", + {"reason": "출고 배치를 찾을 수 없습니다.", "is_admin": is_admin(user)}, + status_code=404, + ) + parcels = st.list_parcels(batch_id=batch_id) + done_count = sum(1 for p in parcels if all(p[f] for f in store.STATUS_FIELDS)) + ctx = _base_ctx(request, user) + ctx.update( + { + "page_title": f"출고 작업 — {batch['batch_name']}", + "page_subtitle": f"{batch['dispatch_date']} · {batch['platform']} · 박스 {len(parcels)}개", + "batch": batch, + "parcels": parcels, + "done_count": done_count, + "active_tab": "work", + } + ) + return render_template(request, "dispatch/detail.html", ctx) + + +@router.get("/batches/{batch_id:int}/picking", response_class=HTMLResponse) +async def batch_picking(request: Request, batch_id: int) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + st, user = guard + batch = st.get_batch(batch_id=batch_id) + if batch is None: + return render_template( + request, "denied.html", + {"reason": "출고 배치를 찾을 수 없습니다.", "is_admin": is_admin(user)}, + status_code=404, + ) + summary = st.sku_summary(batch_id=batch_id) + ctx = _base_ctx(request, user) + ctx.update( + { + "page_title": f"피킹 요약 — {batch['batch_name']}", + "page_subtitle": f"{batch['dispatch_date']} · SKU별 총 수량", + "batch": batch, + "summary": summary, + "total_qty": sum(r["total_qty"] for r in summary), + "active_tab": "picking", + } + ) + return render_template(request, "dispatch/picking.html", ctx) + + +@router.get("/batches/{batch_id:int}/handover", response_class=HTMLResponse) +async def batch_handover(request: Request, batch_id: int) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + st, user = guard + batch = st.get_batch(batch_id=batch_id) + if batch is None: + return render_template( + request, "denied.html", + {"reason": "출고 배치를 찾을 수 없습니다.", "is_admin": is_admin(user)}, + status_code=404, + ) + summary = st.handover_summary(batch_id=batch_id) + ctx = _base_ctx(request, user) + ctx.update( + { + "page_title": f"Kagayaku 전달 — {batch['batch_name']}", + "page_subtitle": f"{batch['dispatch_date']} · {batch['platform']}", + "batch": batch, + "handover": summary, + "active_tab": "handover", + } + ) + return render_template(request, "dispatch/handover.html", ctx) + + +# ════════════════════════════════════════════════════════════ +# 상태 토글 (AJAX) + JSON API +# ════════════════════════════════════════════════════════════ +@router.post("/parcels/{parcel_id:int}/toggle") +async def parcel_toggle( + request: Request, + parcel_id: int, + field: str = Form(...), + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + """박스 작업 상태 1개 토글 → 즉시 DB 저장 + 로그. JSON 반환(버튼 색 갱신용).""" + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="dispatch_db 미설정") + try: + result = st.toggle_status( + parcel_id=parcel_id, field=field, worker_name=user.get("email", "") + ) + except KeyError: + raise HTTPException(status_code=404, detail="박스를 찾을 수 없습니다.") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return JSONResponse({"ok": True, **result}) + + +@router.get("/api/batches/{batch_id:int}/parcels") +async def api_parcels( + request: Request, batch_id: int, _: dict[str, Any] = Depends(_require_user) +) -> JSONResponse: + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="dispatch_db 미설정") + return JSONResponse({"parcels": st.list_parcels(batch_id=batch_id)}) + + +@router.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok", "module": "dispatch"} diff --git a/app/modules/dispatch/store.py b/app/modules/dispatch/store.py new file mode 100644 index 0000000..9fc8537 --- /dev/null +++ b/app/modules/dispatch/store.py @@ -0,0 +1,223 @@ +"""말레이시아 TikTok 출고관리 모듈 — 상수 및 순수 계산/검증 헬퍼. + +- 데이터 저장은 dispatch_db(PostgreSQL) 전용이다(`db.py`). +- 엑셀 읽기는 `parser.py`(openpyxl)가 담당하고, 여기에는 DB/파일 의존이 없는 + 순수 함수(컬럼 정규화, 박스 묶기, SKU 합산)만 둔다(테스트 용이). + +핵심 업무 규칙: + - 03_TikTok_Order_Export.xlsx 가 자동 출고 리스트의 기준 데이터다. + - 엑셀 1줄 ≠ 1박스. 1박스 묶음 기준은 아래 우선순위로 정한다: + 1순위 package_id → 2순위 tracking_id → 3순위 order_id + - 같은 박스 안 같은 SKU 는 수량을 합산한다. + - 고객 이름/주소/전화 등 개인정보는 절대 보관하지 않는다(허용 컬럼만 사용). +""" + +from __future__ import annotations + +from typing import Any, Iterable + +# ── 작업 상태 필드(토글 대상) ── +# 순서 = 작업 진행 순서. label_ko/label_en 은 버튼 표기. +STATUS_FIELDS: tuple[str, ...] = ( + "product_ready", + "packed", + "label_attached", + "handed_to_kagayaku", + "courier_scanned", +) + +STATUS_LABELS: dict[str, dict[str, str]] = { + "product_ready": {"ko": "상품준비 완료", "en": "Product Ready"}, + "packed": {"ko": "포장완료", "en": "Packed"}, + "label_attached": {"ko": "라벨부착 완료", "en": "Label Attached"}, + "handed_to_kagayaku": {"ko": "Kagayaku 전달 완료", "en": "Handed to Kagayaku"}, + "courier_scanned": {"ko": "택배스캔 확인", "en": "Courier Scanned"}, +} + +# ── 엑셀 컬럼명(앞뒤 공백 제거 후, 소문자 비교). 표준키 → 가능한 헤더들 ── +# 개인정보 컬럼은 의도적으로 매핑하지 않는다(이름/주소/전화는 버린다). +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", "수량"), +} + +# 사용자에게 보여줄 컬럼 이름(누락 안내 메시지용). 비교키는 소문자라 별도 표기. +COLUMN_DISPLAY: dict[str, str] = { + "order_id": "Order ID", + "package_id": "Package ID", + "tracking_id": "Tracking ID", + "shipping_provider": "Shipping Provider Name", + "seller_sku": "Seller SKU", + "product_name": "Product Name", + "quantity": "Quantity", +} + +# 박스 묶음 기준 우선순위. 앞에서부터 비어있지 않은 첫 값으로 묶는다. +BOX_KEY_PRIORITY: tuple[str, ...] = ("package_id", "tracking_id", "order_id") + +# 파싱에 최소한 필요한 표준키. (개인정보는 불필요) +# - SKU/수량이 없으면 포장할 상품을 못 만든다 → seller_sku 필수. +# - 박스 묶음을 위해 묶음키 3개 중 최소 하나는 있어야 한다. +REQUIRED_COLUMNS: tuple[str, ...] = ("seller_sku",) + + +def clean_text(value: Any) -> str: + """셀 값 → 안전한 문자열. None/NaN 은 빈 문자열. 양끝 공백 제거. + + 숫자는 과학적 표기/불필요한 .0 없이 보존한다(예: 송장번호가 float 로 읽혀도 + '12345678901' 로 보존). Tracking ID 보존 요구사항을 만족한다. + """ + if value is None: + return "" + # pandas/openpyxl NaN(float('nan')) 방어 + if isinstance(value, float): + if value != value: # NaN + return "" + if value.is_integer(): + return str(int(value)) + return repr(value) + if isinstance(value, int): + return str(value) + s = str(value).strip() + if s.lower() in ("nan", "none"): + return "" + return s + + +def normalize_header(value: Any) -> str: + """헤더 셀 → 비교용 키(공백 제거 + 소문자).""" + return clean_text(value).lower() + + +def resolve_columns(headers: Iterable[Any]) -> dict[str, int]: + """헤더 행 → {표준키: 열 인덱스}. 매핑 안 되는 열은 무시(개인정보 포함).""" + norm = [normalize_header(h) for h in headers] + mapping: dict[str, int] = {} + for std_key, aliases in COLUMN_ALIASES.items(): + for idx, h in enumerate(norm): + if h in aliases: + mapping[std_key] = idx + break + return mapping + + +def missing_required(mapping: dict[str, int]) -> list[str]: + """필수 컬럼 누락 목록(원문 헤더 형태로). 묶음키 전무도 누락으로 본다.""" + missing: list[str] = [] + for key in REQUIRED_COLUMNS: + if key not in mapping: + missing.append(COLUMN_DISPLAY[key]) + if not any(k in mapping for k in BOX_KEY_PRIORITY): + missing.append("Package ID / Tracking ID / Order ID (최소 1개)") + return missing + + +def box_key(row: dict[str, str]) -> str: + """박스 묶음 키 — package_id > tracking_id > order_id 우선순위.""" + for key in BOX_KEY_PRIORITY: + v = (row.get(key) or "").strip() + if v: + return f"{key}:{v}" + return "" + + +def group_parcels(rows: list[dict[str, str]]) -> list[dict[str, Any]]: + """정규화된 행 리스트 → 박스(parcel) 리스트. + + rows 각 항목 키: order_id/package_id/tracking_id/shipping_provider/ + seller_sku/product_name/quantity(이미 문자열/int 정리됨). + + 반환(등장 순서 유지, seq 1..N 부여): + [{seq, order_id, package_id, tracking_id, shipping_provider, + items: [{seller_sku, product_name, quantity}, ...]}, ...] + 같은 박스 안 같은 SKU 는 수량 합산. 묶음키가 전혀 없는 행은 버린다. + """ + boxes: dict[str, dict[str, Any]] = {} + for row in rows: + key = box_key(row) + if not key: + continue + box = boxes.get(key) + if box is None: + box = { + "key": key, + "order_id": (row.get("order_id") or "").strip(), + "package_id": (row.get("package_id") or "").strip(), + "tracking_id": (row.get("tracking_id") or "").strip(), + "shipping_provider": (row.get("shipping_provider") or "").strip(), + "_items": {}, # sku -> {product_name, quantity} + } + boxes[key] = box + else: + # 같은 박스인데 식별값이 빈 경우 뒤늦게 채운다(누락 보강). + for field in ("order_id", "package_id", "tracking_id", "shipping_provider"): + if not box[field] and (row.get(field) or "").strip(): + box[field] = (row.get(field) or "").strip() + + sku = (row.get("seller_sku") or "").strip() + if not sku: + continue + qty = normalize_quantity(row.get("quantity")) + item = box["_items"].get(sku) + if item is None: + box["_items"][sku] = { + "seller_sku": sku, + "product_name": (row.get("product_name") or "").strip(), + "quantity": qty, + } + else: + item["quantity"] += qty + if not item["product_name"] and (row.get("product_name") or "").strip(): + item["product_name"] = (row.get("product_name") or "").strip() + + parcels: list[dict[str, Any]] = [] + for seq, box in enumerate(boxes.values(), start=1): + items = sorted(box["_items"].values(), key=lambda it: it["seller_sku"]) + parcels.append( + { + "seq": seq, + "order_id": box["order_id"], + "package_id": box["package_id"], + "tracking_id": box["tracking_id"], + "shipping_provider": box["shipping_provider"], + "items": items, + } + ) + return parcels + + +def normalize_quantity(value: Any) -> int: + """수량 정규화. 비어있으면 1. 음수/0/파싱불가도 1 로 보정(요구사항 11).""" + s = clean_text(value) + if s == "": + return 1 + try: + n = int(float(s)) + except (TypeError, ValueError): + return 1 + return n if n > 0 else 1 + + +def sku_summary(parcels: list[dict[str, Any]]) -> list[dict[str, Any]]: + """전체 박스 기준 SKU별 총 수량(피킹 요약). seller_sku 오름차순.""" + totals: dict[str, dict[str, Any]] = {} + for p in parcels: + for it in p.get("items", []): + sku = it["seller_sku"] + row = totals.get(sku) + if row is None: + totals[sku] = { + "seller_sku": sku, + "product_name": it.get("product_name", ""), + "total_qty": int(it["quantity"]), + } + else: + row["total_qty"] += int(it["quantity"]) + if not row["product_name"] and it.get("product_name"): + row["product_name"] = it["product_name"] + return sorted(totals.values(), key=lambda r: r["seller_sku"]) diff --git a/app/modules/dispatch/templates/dispatch/_nav.html b/app/modules/dispatch/templates/dispatch/_nav.html new file mode 100644 index 0000000..5f7d629 --- /dev/null +++ b/app/modules/dispatch/templates/dispatch/_nav.html @@ -0,0 +1,12 @@ +{# 말레이시아 배송 공용 상단 바: 목록/업로드 + (배치 진입 시) 작업/피킹/전달 서브탭 #} +
+ 출고 배치 + + 새 업로드 + + {% if batch %} + + 출고 작업 + 피킹 요약 + Kagayaku 전달 + {% endif %} +
diff --git a/app/modules/dispatch/templates/dispatch/batches.html b/app/modules/dispatch/templates/dispatch/batches.html new file mode 100644 index 0000000..3d8b67c --- /dev/null +++ b/app/modules/dispatch/templates/dispatch/batches.html @@ -0,0 +1,50 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ {% include "dispatch/_nav.html" %} + +
+
+

출고 배치 ({{ batches|length }})

+ + 새 업로드 +
+
+ + + + + + + + + + + {% for b in batches %} + + + + + + + + + + {% endfor %} + {% if not batches %} + + {% endif %} + +
날짜플랫폼배치명박스스캔완료생성시각
{{ b.dispatch_date }}{{ b.platform }}{{ b.batch_name or '(이름없음)' }}{{ b.parcel_count }} + {% if b.parcel_count and b.scanned_count == b.parcel_count %} + {{ b.scanned_count }} ✓ + {% else %}{{ b.scanned_count }}{% endif %} + {{ b.created_at }} + 작업 + 피킹 + 전달 +
아직 출고 배치가 없습니다. 새 업로드로 시작하세요.
+
+
+
+{% endblock %} diff --git a/app/modules/dispatch/templates/dispatch/detail.html b/app/modules/dispatch/templates/dispatch/detail.html new file mode 100644 index 0000000..f6b0a90 --- /dev/null +++ b/app/modules/dispatch/templates/dispatch/detail.html @@ -0,0 +1,177 @@ +{% extends "erp_base.html" %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+ {% include "dispatch/_nav.html" %} + +
+
+

출고 작업 — 박스 {{ parcels|length }}개

+ 완료 {{ done_count }} / {{ parcels|length }} +
+ +
+
+ + + + + + +
+ +
+ +
+ {% for p in parcels %} + {% set is_done = p.product_ready and p.packed and p.label_attached and p.handed_to_kagayaku and p.courier_scanned %} +
+
+ No. {{ p.seq }} + {% if p.shipping_provider %}{{ p.shipping_provider }}{% endif %} +
+
+
Order {{ p.order_id or '—' }}
+
Package {{ p.package_id or '—' }}
+
Tracking {{ p.tracking_id or '—' }}
+
+
    + {% for it in p['items'] %} +
  • {{ it.seller_sku }} × {{ it.quantity }}
  • + {% else %} +
  • 상품 없음
  • + {% endfor %} +
+
+ {% for field in status_fields %} + + {% endfor %} +
+
+ {% endfor %} + {% if not parcels %} +

이 배치에는 박스가 없습니다.

+ {% endif %} +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/modules/dispatch/templates/dispatch/handover.html b/app/modules/dispatch/templates/dispatch/handover.html new file mode 100644 index 0000000..8b296b4 --- /dev/null +++ b/app/modules/dispatch/templates/dispatch/handover.html @@ -0,0 +1,77 @@ +{% extends "erp_base.html" %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+ {% include "dispatch/_nav.html" %} + +
+ +
+ +
+
+

Kagayaku 전달 리스트

+
+
+

{{ batch.batch_name or 'TikTok 출고' }}

+
+

+ 날짜 {{ batch.dispatch_date }} · 플랫폼 {{ batch.platform }} +

+ +
+
+
{{ handover.total_boxes }}
+
총 박스 수
+
+ {% for pv in handover.by_provider %} +
+
{{ pv.box_count }}
+
{{ pv.provider }}
+
+ {% endfor %} +
+ +

Tracking ID 목록 ({{ handover.tracking_list|length }})

+
+ + + + + + {% for t in handover.tracking_list %} + + + + + + + {% endfor %} + {% if not handover.tracking_list %} + + {% endif %} + +
NoTracking ID배송사Order ID
{{ t.seq }}{{ t.tracking_id or '—' }}{{ t.shipping_provider or '—' }}{{ t.order_id or '—' }}
전달할 박스가 없습니다.
+
+
+
+{% endblock %} diff --git a/app/modules/dispatch/templates/dispatch/new.html b/app/modules/dispatch/templates/dispatch/new.html new file mode 100644 index 0000000..b05947f --- /dev/null +++ b/app/modules/dispatch/templates/dispatch/new.html @@ -0,0 +1,55 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ {% include "dispatch/_nav.html" %} + +
+

TikTok 파일 업로드

+

+ 03_TikTok_Order_Export.xlsx 만 있으면 출고 리스트가 자동 생성됩니다. + PDF 2개는 보관용(선택)입니다. 고객 이름/주소/전화는 저장하지 않습니다. +

+ +
+ + + + + + + + + + + + +
+ + 취소 +
+
+
+
+{% endblock %} diff --git a/app/modules/dispatch/templates/dispatch/picking.html b/app/modules/dispatch/templates/dispatch/picking.html new file mode 100644 index 0000000..709acec --- /dev/null +++ b/app/modules/dispatch/templates/dispatch/picking.html @@ -0,0 +1,41 @@ +{% extends "erp_base.html" %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+ {% include "dispatch/_nav.html" %} + +
+
+

피킹 요약 (SKU별 총 수량)

+ 총 {{ total_qty }}개 · {{ summary|length }} SKU +
+

이 화면을 보고 먼저 전체 상품을 꺼내세요.

+
+ + + + + + {% for r in summary %} + + + + + + {% endfor %} + {% if not summary %} + + {% endif %} + +
Seller SKU상품명수량
{{ r.seller_sku }}{{ r.product_name or '—' }}{{ r.total_qty }}개
피킹할 상품이 없습니다.
+
+
+
+{% endblock %} diff --git a/app/modules/dispatch/tests/__init__.py b/app/modules/dispatch/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/dispatch/tests/test_grouping.py b/app/modules/dispatch/tests/test_grouping.py new file mode 100644 index 0000000..ec710e3 --- /dev/null +++ b/app/modules/dispatch/tests/test_grouping.py @@ -0,0 +1,93 @@ +"""dispatch 모듈 박스 묶기/SKU 합산 순수 로직 테스트. + +DB/엑셀 없이 store.py 의 규칙만 검증한다. + python -m app.modules.dispatch.tests.test_grouping +또는 pytest 로 실행 가능. +""" + +from __future__ import annotations + +from app.modules.dispatch import store + + +def test_package_id_priority(): + """묶음키 우선순위: package_id > tracking_id > order_id.""" + rows = [ + {"order_id": "O1", "package_id": "P1", "tracking_id": "T1", "seller_sku": "MT-0320", "quantity": "1"}, + {"order_id": "O1", "package_id": "P1", "tracking_id": "T1", "seller_sku": "MT-0320", "quantity": "1"}, + {"order_id": "O1", "package_id": "P2", "tracking_id": "T2", "seller_sku": "MY-0001", "quantity": "1"}, + ] + parcels = store.group_parcels(rows) + assert len(parcels) == 2, parcels # P1, P2 → 박스 2개 + p1 = next(p for p in parcels if p["package_id"] == "P1") + # 같은 박스 같은 SKU 수량 합산 → 2 + assert p1["items"][0]["quantity"] == 2, p1 + + +def test_tracking_then_order_fallback(): + """package_id 없으면 tracking_id, 그것도 없으면 order_id 로 묶는다.""" + rows = [ + {"order_id": "O9", "package_id": "", "tracking_id": "TRK", "seller_sku": "MT-0550", "quantity": ""}, + {"order_id": "O9", "package_id": "", "tracking_id": "TRK", "seller_sku": "MX-0001", "quantity": "3"}, + {"order_id": "O8", "package_id": "", "tracking_id": "", "seller_sku": "MT-0750", "quantity": "2"}, + ] + parcels = store.group_parcels(rows) + assert len(parcels) == 2, parcels + trk = next(p for p in parcels if p["tracking_id"] == "TRK") + assert len(trk["items"]) == 2 # 다른 SKU 2종 + # quantity 빈칸 → 1 로 보정 + qty_by_sku = {it["seller_sku"]: it["quantity"] for it in trk["items"]} + assert qty_by_sku["MT-0550"] == 1 + assert qty_by_sku["MX-0001"] == 3 + + +def test_quantity_and_tracking_preserved_as_string(): + """수량 비면 1, 송장번호 숫자로 들어와도 문자열 보존.""" + assert store.normalize_quantity("") == 1 + assert store.normalize_quantity(None) == 1 + assert store.normalize_quantity("0") == 1 + assert store.normalize_quantity("5") == 5 + # 큰 송장번호가 float 로 읽혀도 과학표기/.0 없이 보존 + assert store.clean_text(12345678901.0) == "12345678901" + assert store.clean_text(float("nan")) == "" + + +def test_sku_summary(): + rows = [ + {"order_id": "A", "package_id": "PA", "tracking_id": "", "seller_sku": "MT-0320_3", "quantity": "1"}, + {"order_id": "B", "package_id": "PB", "tracking_id": "", "seller_sku": "MT-0320_3", "quantity": "1"}, + {"order_id": "C", "package_id": "PC", "tracking_id": "", "seller_sku": "MY-0001", "quantity": "5"}, + ] + parcels = store.group_parcels(rows) + summary = store.sku_summary(parcels) + totals = {r["seller_sku"]: r["total_qty"] for r in summary} + assert totals == {"MT-0320_3": 2, "MY-0001": 5}, totals + + +def test_missing_required_columns(): + """필수 컬럼 누락 감지: seller_sku 없거나 묶음키 전무.""" + mapping = store.resolve_columns(["Order ID", "Product Name", "Quantity"]) + missing = store.missing_required(mapping) + assert any("Seller SKU" in m for m in missing), missing + # 묶음키(order id) 는 있으므로 그 누락 메시지는 없어야 함 + assert not any("최소 1개" in m for m in missing), missing + + +def test_column_alias_strip_and_case(): + """헤더 앞뒤 공백/대소문자 무시하고 매핑.""" + mapping = store.resolve_columns([" Order ID ", "TRACKING ID", "Seller SKU", "Quantity"]) + assert "order_id" in mapping + assert "tracking_id" in mapping + assert "seller_sku" in mapping + + +def _run_all(): + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + for fn in fns: + fn() + print("PASS", fn.__name__) + print(f"\n{len(fns)} tests passed.") + + +if __name__ == "__main__": + _run_all() diff --git a/app/store.py b/app/store.py index 4443371..1a9e125 100644 --- a/app/store.py +++ b/app/store.py @@ -29,6 +29,7 @@ MODULE_KEYS: tuple[str, ...] = ( "vacation", "cupang", "malaysia", + "dispatch", "expense_approver", "vacation_approver", ) diff --git a/docs/DISPATCH_MODULE.md b/docs/DISPATCH_MODULE.md new file mode 100644 index 0000000..0ee6923 --- /dev/null +++ b/docs/DISPATCH_MODULE.md @@ -0,0 +1,133 @@ +# 말레이시아 배송 모듈 (dispatch) — TikTok 출고관리 + +> 초보 물류 직원이 엑셀을 열지 않고 웹 화면만 보고 출고 작업을 하도록 만든 모듈. +> main-app ERP 에 편입된 **모듈**이다(별도 앱/포트 아님). 인증은 기존 Google +> OAuth + 권한키 `dispatch` 를 재사용한다. + +--- + +## 1. 무엇을 하나 + +TikTok Seller Center 에서 매일 받는 3개 파일을 업로드하면, 직원용 **출고 작업 +리스트(04_Daily_Dispatch_List)** 를 웹에서 자동 생성한다(엑셀 파일로 만들 필요 없음). + +| 파일 | 용도 | 업로드 | +| --- | --- | --- | +| 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 에 저장하지 않는다.** + +### 1박스 묶음 규칙 + +엑셀 1줄 ≠ 1박스. 아래 우선순위로 1박스를 묶는다. + +1. **Package ID** → 2. **Tracking ID** → 3. **Order ID** + +같은 박스 안 같은 SKU 는 수량을 합산한다. 결과는 "1박스 = 1카드". + +--- + +## 2. 화면 + +| 경로 | 화면 | +| --- | --- | +| `GET /dispatch/` | 출고 배치 목록 | +| `GET /dispatch/batches/new` | 업로드(날짜·플랫폼·파일 3개) | +| `POST /dispatch/batches` | 업로드 + 배치 생성 + 파싱 + 박스 저장 | +| `GET /dispatch/batches/{id}` | 출고 작업 리스트(1박스=1카드, 상태 토글·필터·검색) | +| `GET /dispatch/batches/{id}/picking` | SKU별 피킹 요약 | +| `GET /dispatch/batches/{id}/handover` | Kagayaku 전달 리스트(A4 인쇄용) | +| `POST /dispatch/parcels/{id}/toggle` | 작업 상태 1개 토글(AJAX, 즉시 저장) | + +작업 상태(카드 버튼, 누르면 초록색): 상품준비 완료 → 포장완료 → 라벨부착 완료 → +Kagayaku 전달 완료 → 택배스캔 확인. 모든 변경은 `dispatch_logs` 에 기록된다. + +--- + +## 3. 설치 / 실행 + +### 3-1. DB 초기화 (superuser 로 1회) + +```bash +read -s -p "dispatch_app password: " APP_PWD; echo +docker exec -i postgres-db psql -U postgres \ + -v app_password="$APP_PWD" \ + < scripts/sql/dispatch_db_init.sql +``` + +생성물: DB `dispatch_db`, 역할 `dispatch_app`(CRUD only), 테이블 +`dispatch_batches / dispatch_parcels / dispatch_items / dispatch_logs`. +멱등 스크립트라 여러 번 실행해도 기존 데이터를 지우지 않는다. + +### 3-2. .env 등록 + +``` +DISPATCH_DB_URL=postgresql://dispatch_app:@postgres-db:5432/dispatch_db +``` + +미설정 시 모듈은 "설정 필요" 안내만 보여주고 앱은 정상 기동한다(JSON 폴백 없음). + +### 3-3. 재배포 + +```bash +cd /opt/www/main && docker compose up -d --build web +``` + +> `git pull` 후 반드시 `--build`. `restart` 만으로는 코드가 안 바뀐다. + +### 3-4. 권한 부여 + +`/admin` 에서 대상 사용자에게 권한키 `dispatch`(라벨 "말레이시아 배송")를 켠다. +관리자(admin)는 항상 통과. + +--- + +## 4. 업로드 원본 보관 + +업로드한 PDF/XLSX 원본은 아래에 저장되고 DB 에는 경로만 기록한다. + +``` +$DATA_DIR/dispatch/2026-06-19/<배치slug>_/ + 01_Picking_List.pdf + 02_Shipping_Label_Packing_Slip.pdf + 03_TikTok_Order_Export.xlsx +``` + +같은 날짜/플랫폼이라도 batch_name 으로 구분해 **새 배치**로 생성된다(덮어쓰지 않음). +예: "2026-06-19 TikTok 오전 출고", "2026-06-19 TikTok 오후 출고". + +--- + +## 5. 파싱 예외 처리 + +- 필수 컬럼이 없으면 **어떤 컬럼이 없는지** 화면에 표시한다. +- Quantity 가 비면 1 로 처리한다. +- Tracking ID 가 숫자로 읽혀도 문자열로 보존한다(과학표기/`.0` 제거). +- NaN/None 은 빈 문자열로 처리한다. +- 고객 이름/주소/전화 컬럼은 매핑하지 않아 **읽는 즉시 버린다**(미저장). + +가능한 헤더(앞뒤 공백·대소문자 무시): Order ID, Package ID, Tracking ID, +Shipping Provider Name, Seller SKU, Product Name, Quantity. + +--- + +## 6. 테스트 + +박스 묶기/SKU 합산/컬럼 검증 순수 로직 테스트: + +```bash +python -m app.modules.dispatch.tests.test_grouping # 앱 의존성 설치 환경에서 +``` + +--- + +## 7. 기술 메모 + +- 스택: 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/` 화면). +- 개인정보 미보관: 스키마에 이름/주소/전화 컬럼 자체가 없다. diff --git a/scripts/sql/dispatch_db_init.sql b/scripts/sql/dispatch_db_init.sql new file mode 100644 index 0000000..cb8b524 --- /dev/null +++ b/scripts/sql/dispatch_db_init.sql @@ -0,0 +1,153 @@ +-- ===================================================================== +-- dispatch_db 초기화 스크립트 (PostgreSQL) — 말레이시아 TikTok 출고관리 +-- ===================================================================== +-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다. +-- +-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음. +-- +-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db): +-- +-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회) +-- read -s -p "dispatch_app password: " APP_PWD; echo +-- docker exec -i postgres-db psql -U postgres \ +-- -v app_password="$APP_PWD" \ +-- < scripts/sql/dispatch_db_init.sql +-- +-- 2) main-app .env 에 연결 정보 등록 +-- DISPATCH_DB_URL=postgresql://dispatch_app:@postgres-db:5432/dispatch_db +-- +-- 3) main-app 재기동 +-- cd /opt/www/main && docker compose up -d --build web +-- +-- 주의: +-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만). +-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달. +-- - 개인정보(고객 이름/주소/전화)는 저장하지 않는 구조다(컬럼 자체가 없음). +-- - 작업 기준 키: order_id / package_id / tracking_id / seller_sku / quantity. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- DB 가 없을 때만 생성 +SELECT 'CREATE DATABASE dispatch_db ENCODING ''UTF8'' TEMPLATE template0' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'dispatch_db') +\gexec + +-- 앱 전용 로그인 역할 +SELECT 'CREATE ROLE dispatch_app LOGIN PASSWORD ' || quote_literal(:'app_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'dispatch_app') +\gexec + +-- 항상 최신 비밀번호로 동기화 +SELECT 'ALTER ROLE dispatch_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password') +\gexec + +GRANT CONNECT ON DATABASE dispatch_db TO dispatch_app; + +-- dispatch_db 컨텍스트로 전환 +\connect dispatch_db + +-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ── +CREATE OR REPLACE FUNCTION dispatch_set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ════════════════════════════════════════════════════════════ +-- 1) 출고 배치 (하루 1회~여러 회 업로드 단위) +-- 같은 날짜/플랫폼이라도 batch_name 으로 구분해 여러 배치 생성 가능. +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS dispatch_batches ( + id BIGSERIAL PRIMARY KEY, + platform TEXT NOT NULL DEFAULT 'TikTok', + dispatch_date DATE NOT NULL, + batch_name TEXT NOT NULL DEFAULT '', + picking_pdf_path TEXT NOT NULL DEFAULT '', + label_pdf_path TEXT NOT NULL DEFAULT '', + order_export_path TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_dispatch_batches_date ON dispatch_batches (dispatch_date DESC); + +DROP TRIGGER IF EXISTS trg_dispatch_batches_updated ON dispatch_batches; +CREATE TRIGGER trg_dispatch_batches_updated + BEFORE UPDATE ON dispatch_batches + FOR EACH ROW EXECUTE FUNCTION dispatch_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 2) 출고 박스 (1박스 = 1행 = 1작업카드) +-- 묶음 기준: package_id > tracking_id > order_id (파서가 결정). +-- 개인정보 컬럼 없음(이름/주소/전화 저장 안 함). +-- seq 는 배치 내 박스 표시 순번(No). +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS dispatch_parcels ( + id BIGSERIAL PRIMARY KEY, + batch_id BIGINT NOT NULL REFERENCES dispatch_batches(id) ON DELETE CASCADE, + seq INTEGER NOT NULL DEFAULT 0, + order_id TEXT NOT NULL DEFAULT '', + package_id TEXT NOT NULL DEFAULT '', + tracking_id TEXT NOT NULL DEFAULT '', + shipping_provider TEXT NOT NULL DEFAULT '', + product_ready BOOLEAN NOT NULL DEFAULT FALSE, + packed BOOLEAN NOT NULL DEFAULT FALSE, + label_attached BOOLEAN NOT NULL DEFAULT FALSE, + handed_to_kagayaku BOOLEAN NOT NULL DEFAULT FALSE, + courier_scanned BOOLEAN NOT NULL DEFAULT FALSE, + memo TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_dispatch_parcels_batch ON dispatch_parcels (batch_id, seq); +CREATE INDEX IF NOT EXISTS idx_dispatch_parcels_order ON dispatch_parcels (order_id); +CREATE INDEX IF NOT EXISTS idx_dispatch_parcels_tracking ON dispatch_parcels (tracking_id); +CREATE INDEX IF NOT EXISTS idx_dispatch_parcels_package ON dispatch_parcels (package_id); + +DROP TRIGGER IF EXISTS trg_dispatch_parcels_updated ON dispatch_parcels; +CREATE TRIGGER trg_dispatch_parcels_updated + BEFORE UPDATE ON dispatch_parcels + FOR EACH ROW EXECUTE FUNCTION dispatch_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 3) 박스 안 상품 (1박스 N상품, 같은 SKU 는 수량 합산되어 1행) +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS dispatch_items ( + id BIGSERIAL PRIMARY KEY, + parcel_id BIGINT NOT NULL REFERENCES dispatch_parcels(id) ON DELETE CASCADE, + seller_sku TEXT NOT NULL DEFAULT '', + product_name TEXT NOT NULL DEFAULT '', + quantity INTEGER NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS idx_dispatch_items_parcel ON dispatch_items (parcel_id); +CREATE INDEX IF NOT EXISTS idx_dispatch_items_sku ON dispatch_items (seller_sku); + +-- ════════════════════════════════════════════════════════════ +-- 4) 작업 상태 변경 로그 (감사/추적용) +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS dispatch_logs ( + id BIGSERIAL PRIMARY KEY, + parcel_id BIGINT NOT NULL REFERENCES dispatch_parcels(id) ON DELETE CASCADE, + action TEXT NOT NULL DEFAULT '', + old_value TEXT NOT NULL DEFAULT '', + new_value TEXT NOT NULL DEFAULT '', + worker_name TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_dispatch_logs_parcel ON dispatch_logs (parcel_id); + +-- ════════════════════════════════════════════════════════════ +-- 5) 권한 (dispatch_app: CRUD only) +-- ════════════════════════════════════════════════════════════ +GRANT USAGE ON SCHEMA public TO dispatch_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON + dispatch_batches, dispatch_parcels, dispatch_items, dispatch_logs + TO dispatch_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO dispatch_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO dispatch_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO dispatch_app; + +SELECT 'dispatch_db ready' AS status;