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
@@ -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()