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:
@@ -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
|
||||
Reference in New Issue
Block a user