feat(dispatch): 말레이시아 TikTok 출고관리 모듈 추가

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 15:11:43 +09:00
parent 8453c425f8
commit 99eea01fc1
19 changed files with 1868 additions and 0 deletions
+8
View File
@@ -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 에서 검색해 등록한다(읽기만).
# 미설정 시 검색 비활성 → 수동 등록만 가능.
+1
View File
@@ -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`.
+19
View File
@@ -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": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
"dispatch": '<rect x="1" y="3" width="15" height="13"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
}
body = paths.get(name, paths["modules"])
+47
View File
@@ -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)
+313
View File
@@ -0,0 +1,313 @@
"""dispatch_db PostgreSQL 저장소.
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — 다른 모듈과 동일 패턴.
- 연결 정보: 환경변수 `DISPATCH_DB_URL`
(예: postgresql://dispatch_app:<pwd>@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
+70
View File
@@ -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)}
+395
View File
@@ -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>_<HHMMSS>/
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"}
+223
View File
@@ -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"])
@@ -0,0 +1,12 @@
{# 말레이시아 배송 공용 상단 바: 목록/업로드 + (배치 진입 시) 작업/피킹/전달 서브탭 #}
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<a class="erp-btn {% if active_tab=='batches' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/">출고 배치</a>
<a class="erp-btn {% if active_tab=='new' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/batches/new"> 새 업로드</a>
{% if batch %}
<span style="width:1px;height:24px;background:#e2e8f0;margin:0 4px;"></span>
<a class="erp-btn {% if active_tab=='work' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/batches/{{ batch.id }}">출고 작업</a>
<a class="erp-btn {% if active_tab=='picking' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/batches/{{ batch.id }}/picking">피킹 요약</a>
<a class="erp-btn {% if active_tab=='handover' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/dispatch/batches/{{ batch.id }}/handover">Kagayaku 전달</a>
{% endif %}
</div>
@@ -0,0 +1,50 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="erp-card">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
<h2>출고 배치 ({{ batches|length }})</h2>
<a class="erp-btn erp-btn-primary" href="/dispatch/batches/new"> 새 업로드</a>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th>날짜</th><th>플랫폼</th><th>배치명</th>
<th style="text-align:right">박스</th>
<th style="text-align:right">스캔완료</th>
<th>생성시각</th><th></th>
</tr>
</thead>
<tbody>
{% for b in batches %}
<tr>
<td style="white-space:nowrap;">{{ b.dispatch_date }}</td>
<td>{{ b.platform }}</td>
<td><a href="/dispatch/batches/{{ b.id }}" style="font-weight:600;">{{ b.batch_name or '(이름없음)' }}</a></td>
<td style="text-align:right;">{{ b.parcel_count }}</td>
<td style="text-align:right;">
{% if b.parcel_count and b.scanned_count == b.parcel_count %}
<span style="color:#16a34a;font-weight:600;">{{ b.scanned_count }} ✓</span>
{% else %}{{ b.scanned_count }}{% endif %}
</td>
<td class="erp-muted" style="white-space:nowrap;">{{ b.created_at }}</td>
<td style="text-align:right;white-space:nowrap;">
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}">작업</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/picking">피킹</a>
<a class="erp-btn erp-btn-outline" href="/dispatch/batches/{{ b.id }}/handover">전달</a>
</td>
</tr>
{% endfor %}
{% if not batches %}
<tr><td colspan="7" class="erp-muted">아직 출고 배치가 없습니다. <a href="/dispatch/batches/new">새 업로드</a>로 시작하세요.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,177 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
.dsp-toolbar { display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0; }
.dsp-filter { display:flex;gap:6px;flex-wrap:wrap; }
.dsp-search { flex:1 1 220px;min-width:180px; }
.dsp-cards { display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px; }
.dsp-card { border:1px solid #e2e8f0;border-radius:12px;padding:14px;background:#fff;display:flex;flex-direction:column;gap:10px; }
.dsp-card.is-done { border-color:#86efac;background:#f0fdf4; }
.dsp-no { font-size:20px;font-weight:700;color:#0f172a; }
.dsp-meta { font-size:13px;line-height:1.5;color:#334155;word-break:break-all; }
.dsp-meta b { color:#0f172a; }
.dsp-items { list-style:none;margin:0;padding:8px 0;border-top:1px dashed #e2e8f0;border-bottom:1px dashed #e2e8f0; }
.dsp-items li { font-size:16px;font-weight:600;color:#0f172a;padding:2px 0; }
.dsp-items .qty { color:#2563eb; }
.dsp-status { display:grid;grid-template-columns:1fr 1fr;gap:6px; }
.dsp-status .full { grid-column:1 / -1; }
.dsp-btn-status { border:1px solid #cbd5e1;border-radius:8px;padding:8px 6px;background:#f1f5f9;color:#475569;
font-weight:600;cursor:pointer;text-align:center;line-height:1.25;transition:background .12s,border-color .12s,color .12s; }
.dsp-btn-status small { display:block;font-size:10px;font-weight:500;opacity:.8; }
.dsp-btn-status.on { background:#16a34a;border-color:#16a34a;color:#fff; }
.dsp-btn-status:disabled { opacity:.6;cursor:wait; }
.dsp-hidden { display:none !important; }
@media (max-width:480px){ .dsp-cards{grid-template-columns:1fr;} }
</style>
{% endblock %}
{% block content %}
<section class="dsp" data-batch="{{ batch.id }}">
{% include "dispatch/_nav.html" %}
<div class="erp-card">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<h2>출고 작업 — 박스 {{ parcels|length }}개</h2>
<span class="erp-muted" id="dsp-done-label">완료 {{ done_count }} / {{ parcels|length }}</span>
</div>
<div class="dsp-toolbar">
<div class="dsp-filter" id="dsp-filter">
<button class="erp-btn erp-btn-primary" data-filter="all" type="button">전체</button>
<button class="erp-btn erp-btn-outline" data-filter="unpacked" type="button">미포장</button>
<button class="erp-btn erp-btn-outline" data-filter="packed" type="button">포장완료</button>
<button class="erp-btn erp-btn-outline" data-filter="label_attached" type="button">라벨부착</button>
<button class="erp-btn erp-btn-outline" data-filter="handed_to_kagayaku" type="button">Kagayaku 전달</button>
<button class="erp-btn erp-btn-outline" data-filter="courier_scanned" type="button">택배스캔 확인</button>
</div>
<input class="erp-input dsp-search" id="dsp-search" type="search"
placeholder="검색: Order ID / Tracking ID / Package ID / SKU" />
</div>
<div class="dsp-cards" id="dsp-cards">
{% 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 %}
<article class="dsp-card {% if is_done %}is-done{% endif %}"
data-parcel="{{ p.id }}"
data-product_ready="{{ p.product_ready|lower }}"
data-packed="{{ p.packed|lower }}"
data-label_attached="{{ p.label_attached|lower }}"
data-handed_to_kagayaku="{{ p.handed_to_kagayaku|lower }}"
data-courier_scanned="{{ p.courier_scanned|lower }}"
data-search="{{ (p.order_id ~ ' ' ~ p.package_id ~ ' ' ~ p.tracking_id ~ ' ' ~ (p['items']|map(attribute='seller_sku')|join(' ')))|lower }}">
<div style="display:flex;justify-content:space-between;align-items:baseline;">
<span class="dsp-no">No. {{ p.seq }}</span>
{% if p.shipping_provider %}<span class="erp-badge">{{ p.shipping_provider }}</span>{% endif %}
</div>
<div class="dsp-meta">
<div><b>Order</b> {{ p.order_id or '—' }}</div>
<div><b>Package</b> {{ p.package_id or '—' }}</div>
<div><b>Tracking</b> {{ p.tracking_id or '—' }}</div>
</div>
<ul class="dsp-items">
{% for it in p['items'] %}
<li>{{ it.seller_sku }} <span class="qty">× {{ it.quantity }}</span></li>
{% else %}
<li class="erp-muted" style="font-weight:400;">상품 없음</li>
{% endfor %}
</ul>
<div class="dsp-status">
{% for field in status_fields %}
<button type="button"
class="dsp-btn-status {% if (field == status_fields[-1]) and (status_fields|length % 2 == 1) %}full{% endif %} {% if p[field] %}on{% endif %}"
data-field="{{ field }}"
onclick="dspToggle(this)">
{{ status_labels[field].ko }}
<small>{{ status_labels[field].en }}</small>
</button>
{% endfor %}
</div>
</article>
{% endfor %}
{% if not parcels %}
<p class="erp-muted">이 배치에는 박스가 없습니다.</p>
{% endif %}
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
var STATUS_FIELDS = {{ status_fields|list|tojson }};
// ── 상태 토글 (즉시 DB 저장) ──
window.dspToggle = function (btn) {
var card = btn.closest('.dsp-card');
var parcelId = card.getAttribute('data-parcel');
var field = btn.getAttribute('data-field');
btn.disabled = true;
var body = new URLSearchParams();
body.set('field', field);
fetch('/dispatch/parcels/' + parcelId + '/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (data) {
btn.classList.toggle('on', data.value);
card.setAttribute('data-' + field, data.value ? 'true' : 'false');
refreshDone(card);
applyFilter();
})
.catch(function () { alert('저장에 실패했습니다. 다시 시도하세요.'); })
.finally(function () { btn.disabled = false; });
};
function refreshDone(card) {
var done = STATUS_FIELDS.every(function (f) {
return card.getAttribute('data-' + f) === 'true';
});
card.classList.toggle('is-done', done);
updateDoneLabel();
}
function updateDoneLabel() {
var cards = document.querySelectorAll('.dsp-card');
var done = document.querySelectorAll('.dsp-card.is-done').length;
var label = document.getElementById('dsp-done-label');
if (label) label.textContent = '완료 ' + done + ' / ' + cards.length;
}
// ── 필터 ──
var currentFilter = 'all';
function matchesFilter(card) {
switch (currentFilter) {
case 'all': return true;
case 'unpacked': return card.getAttribute('data-packed') !== 'true';
default: return card.getAttribute('data-' + currentFilter) === 'true';
}
}
function applyFilter() {
var term = (document.getElementById('dsp-search').value || '').trim().toLowerCase();
document.querySelectorAll('.dsp-card').forEach(function (card) {
var ok = matchesFilter(card) &&
(term === '' || (card.getAttribute('data-search') || '').indexOf(term) !== -1);
card.classList.toggle('dsp-hidden', !ok);
});
}
document.getElementById('dsp-filter').addEventListener('click', function (e) {
var btn = e.target.closest('[data-filter]');
if (!btn) return;
currentFilter = btn.getAttribute('data-filter');
this.querySelectorAll('[data-filter]').forEach(function (b) {
b.classList.toggle('erp-btn-primary', b === btn);
b.classList.toggle('erp-btn-outline', b !== btn);
});
applyFilter();
});
document.getElementById('dsp-search').addEventListener('input', applyFilter);
})();
</script>
{% endblock %}
@@ -0,0 +1,77 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
.dsp-ho-grid { display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin:12px 0; }
.dsp-ho-stat { border:1px solid #e2e8f0;border-radius:10px;padding:12px;text-align:center;background:#fff; }
.dsp-ho-stat .n { font-size:26px;font-weight:700;color:#0f172a; }
.dsp-ho-stat .l { font-size:13px;color:#64748b; }
.dsp-track td { font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap; }
@media print {
.erp-sidebar, .erp-topbar, .erp-page-actions, .dsp-noprint { display:none !important; }
.erp-content, .erp-page, .erp-app { margin:0 !important;padding:0 !important;display:block !important; }
.erp-card { border:none !important;box-shadow:none !important;padding:0 !important; }
body { background:#fff !important; }
.dsp-print-head { display:block !important; }
@page { size:A4;margin:14mm; }
}
.dsp-print-head { display:none; }
</style>
{% endblock %}
{% block content %}
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="dsp-noprint" style="margin:8px 0;">
<button class="erp-btn erp-btn-primary" type="button" onclick="window.print()">🖨 인쇄하기</button>
</div>
<div class="erp-card">
<div class="dsp-print-head" style="margin-bottom:10px;">
<h2 style="margin:0;">Kagayaku 전달 리스트</h2>
</div>
<div class="cpg-card-head">
<h2>{{ batch.batch_name or 'TikTok 출고' }}</h2>
</div>
<p class="erp-muted" style="margin:4px 0 4px;">
날짜 <b>{{ batch.dispatch_date }}</b> · 플랫폼 <b>{{ batch.platform }}</b>
</p>
<div class="dsp-ho-grid">
<div class="dsp-ho-stat">
<div class="n">{{ handover.total_boxes }}</div>
<div class="l">총 박스 수</div>
</div>
{% for pv in handover.by_provider %}
<div class="dsp-ho-stat">
<div class="n">{{ pv.box_count }}</div>
<div class="l">{{ pv.provider }}</div>
</div>
{% endfor %}
</div>
<h3 style="margin:16px 0 8px;">Tracking ID 목록 ({{ handover.tracking_list|length }})</h3>
<div class="erp-table-wrap">
<table class="erp-table dsp-track">
<thead>
<tr><th style="text-align:right">No</th><th>Tracking ID</th><th>배송사</th><th>Order ID</th></tr>
</thead>
<tbody>
{% for t in handover.tracking_list %}
<tr>
<td style="text-align:right">{{ t.seq }}</td>
<td>{{ t.tracking_id or '—' }}</td>
<td>{{ t.shipping_provider or '—' }}</td>
<td>{{ t.order_id or '—' }}</td>
</tr>
{% endfor %}
{% if not handover.tracking_list %}
<tr><td colspan="4" class="erp-muted">전달할 박스가 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,55 @@
{% extends "erp_base.html" %}
{% block content %}
<section class="dsp">
{% 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>
<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;">
<span>출고 날짜 <span style="color:#dc2626;">*</span></span>
<input class="erp-input" type="date" name="dispatch_date" value="{{ today }}" required />
</label>
<label style="display:flex;flex-direction:column;gap:4px;">
<span>플랫폼</span>
<select class="erp-select" name="platform">
<option value="TikTok" selected>TikTok</option>
</select>
</label>
<label style="display:flex;flex-direction:column;gap:4px;">
<span>배치명 <span class="erp-muted">(예: 2026-06-19 TikTok 오전 출고)</span></span>
<input class="erp-input" type="text" name="batch_name" placeholder="오전 출고 / 오후 출고 등으로 구분" />
</label>
<label style="display:flex;flex-direction:column;gap:4px;">
<span>01_Picking_List.pdf <span class="erp-muted">(선택 · 전체 피킹 참고용)</span></span>
<input class="erp-input" type="file" name="picking_pdf" accept="application/pdf" />
</label>
<label style="display:flex;flex-direction:column;gap:4px;">
<span>02_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>03_TikTok_Order_Export.xlsx <span style="color:#dc2626;">*</span> <span class="erp-muted">(자동 출고 리스트 기준)</span></span>
<input class="erp-input" type="file" name="order_export"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" required />
</label>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="erp-btn erp-btn-primary" type="submit">업로드 후 배치 생성</button>
<a class="erp-btn erp-btn-outline" href="/dispatch/">취소</a>
</div>
</form>
</div>
</section>
{% endblock %}
@@ -0,0 +1,41 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<style>
.dsp-pick td.sku { font-size:18px;font-weight:700;color:#0f172a;white-space:nowrap; }
.dsp-pick td.qty { font-size:18px;font-weight:700;text-align:right;color:#2563eb;white-space:nowrap; }
</style>
{% endblock %}
{% block content %}
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="erp-card" style="max-width:560px;">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
<h2>피킹 요약 (SKU별 총 수량)</h2>
<span class="erp-muted">총 {{ total_qty }}개 · {{ summary|length }} SKU</span>
</div>
<p class="erp-muted" style="margin:4px 0 12px;">이 화면을 보고 먼저 전체 상품을 꺼내세요.</p>
<div class="erp-table-wrap">
<table class="erp-table dsp-pick">
<thead>
<tr><th>Seller SKU</th><th>상품명</th><th style="text-align:right">수량</th></tr>
</thead>
<tbody>
{% for r in summary %}
<tr>
<td class="sku">{{ r.seller_sku }}</td>
<td class="erp-muted">{{ r.product_name or '—' }}</td>
<td class="qty">{{ r.total_qty }}개</td>
</tr>
{% endfor %}
{% if not summary %}
<tr><td colspan="3" class="erp-muted">피킹할 상품이 없습니다.</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
@@ -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()
+1
View File
@@ -29,6 +29,7 @@ MODULE_KEYS: tuple[str, ...] = (
"vacation",
"cupang",
"malaysia",
"dispatch",
"expense_approver",
"vacation_approver",
)
+133
View File
@@ -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:<APP_PWD>@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>_<HHMMSS>/
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/` 화면).
- 개인정보 미보관: 스키마에 이름/주소/전화 컬럼 자체가 없다.
+153
View File
@@ -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:<APP_PWD>@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;