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:
@@ -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 에서 검색해 등록한다(읽기만).
|
||||
# 미설정 시 검색 비활성 → 수동 등록만 가능.
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -29,6 +29,7 @@ MODULE_KEYS: tuple[str, ...] = (
|
||||
"vacation",
|
||||
"cupang",
|
||||
"malaysia",
|
||||
"dispatch",
|
||||
"expense_approver",
|
||||
"vacation_approver",
|
||||
)
|
||||
|
||||
@@ -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/` 화면).
|
||||
- 개인정보 미보관: 스키마에 이름/주소/전화 컬럼 자체가 없다.
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user