Files
dbx-main/tests/test_malaysia_stock.py
king 032fb78867 feat(malaysia): 재고조사 랙(rack) 박스 단위 입력
낱개/콤보 평면 수량 그리드를 창고 랙 그림(A/B 36칸) 입력으로 대체.
각 칸에 아이템·박스당 입수량·박스 수를 넣고 + 로 한 칸 여러 아이템 추가.
저장 시 SKU별 qty=SUM(입수량×박스수) 집계로 daily_stocktake_line 재생성.
이후 분해/확정 로직은 라인 기준 그대로 동작.

- DB: daily_stocktake_rack 테이블 + grant (멱등)
- store: rack_cell_codes/rack_layout/aggregate_rack 순수함수
- db: list_rack_entries/replace_rack(1 트랜잭션, draft만)
- router: rack 컨텍스트 + POST /stocktakes/{id}/rack/bulk
- UI: 랙 보드 템플릿 + malaysia_rack.js(행 추가/삭제·실시간 소계)
- 테스트 4건 추가(18 전부 통과)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:25:40 +09:00

243 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""말레이시아 재고관리 — 순수 로직 테스트 (DB 불필요).
실행:
cd /opt/www/main (또는 개발 PC 프로젝트 루트)
python -m pytest tests/test_malaysia_stock.py -v
pytest 미설치 시:
python tests/test_malaysia_stock.py # __main__ 폴백으로 assert 실행
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
import pytest
except ModuleNotFoundError: # pytest 미설치 → 최소 shim 으로 standalone 실행 지원
class _PytestShim:
class mark:
@staticmethod
def parametrize(argnames, argvalues):
def deco(fn):
fn._params = (argnames, list(argvalues))
return fn
return deco
class raises:
def __init__(self, exc):
self.exc = exc
def __enter__(self):
return self
def __exit__(self, etype, evalue, tb):
if etype is None:
raise AssertionError(f"{self.exc.__name__} 가 발생하지 않음")
return issubclass(etype, self.exc)
pytest = _PytestShim() # type: ignore
from app.modules.malaysia import store
# ────────────────────────────────────────────────────────────
# 코드 분류
# ────────────────────────────────────────────────────────────
def test_classify_code():
assert store.classify_code("MT-0320") == "individual"
assert store.classify_code("MX-0001") == "individual"
assert store.classify_code("MZ-0002") == "individual"
assert store.classify_code("MY-0002") == "set"
assert store.classify_code("MD-0000") == "blocked"
assert store.classify_code("ZZ-9999") == "unknown"
# 소문자/공백 정규화
assert store.classify_code(" mt-0320 ") == "individual"
# ────────────────────────────────────────────────────────────
# movement 코드 검증 — MT/MX/MZ 허용, MY/MD 차단
# ────────────────────────────────────────────────────────────
def test_validate_movement_code_ok():
assert store.validate_movement_code("mt-0320") == "MT-0320"
@pytest.mark.parametrize("bad", ["MY-0002", "MD-0000", "ZZ-1", ""])
def test_validate_movement_code_blocks(bad):
with pytest.raises(ValueError):
store.validate_movement_code(bad)
# ────────────────────────────────────────────────────────────
# 재고조사 코드 검증 — MT/MX/MZ/MY 허용, MD 차단
# ────────────────────────────────────────────────────────────
def test_validate_stocktake_code():
assert store.validate_stocktake_code("MY-0002") == "MY-0002"
assert store.validate_stocktake_code("MT-0320") == "MT-0320"
with pytest.raises(ValueError):
store.validate_stocktake_code("MD-0001")
# ────────────────────────────────────────────────────────────
# set_bom 검증 — set 은 MY-, component 는 MT/MX/MZ (중첩/MD 금지)
# ────────────────────────────────────────────────────────────
def test_validate_bom():
assert store.validate_bom_set_code("MY-0001") == "MY-0001"
assert store.validate_bom_component_code("MT-0320") == "MT-0320"
with pytest.raises(ValueError):
store.validate_bom_set_code("MT-0320") # set 자리에 낱개
with pytest.raises(ValueError):
store.validate_bom_component_code("MY-0002") # 세트 중첩 금지
with pytest.raises(ValueError):
store.validate_bom_component_code("MD-0000") # 뚜껑 금지
def test_validate_qty():
assert store.validate_qty(5) == 5
assert store.validate_qty(0) == 0
assert store.validate_qty(-3, allow_negative=True) == -3
with pytest.raises(ValueError):
store.validate_qty(-1)
with pytest.raises(ValueError):
store.validate_qty(0, allow_zero=False)
# ────────────────────────────────────────────────────────────
# 세트 분해
# ────────────────────────────────────────────────────────────
def _bom_map():
# MY-0002 Starter Set = MT-0320 x1 + MT-0550 x2
return {
"MY-0002": [
{"component_code": "MT-0320", "component_qty": 1},
{"component_code": "MT-0550", "component_qty": 2},
],
"MY-0003": [
{"component_code": "MT-0320", "component_qty": 1},
],
}
def test_explode_set():
out = store.explode_set("MY-0002", 20, _bom_map())
assert out == {"MT-0320": 20, "MT-0550": 40}
def test_explode_set_missing_returns_empty():
assert store.explode_set("MY-9999", 5, _bom_map()) == {}
# ────────────────────────────────────────────────────────────
# 재고조사 집계 — 요구사항 예시 검증
# MT-0320 낱개 100 + MY-0002 20세트(MT-0320 1개 포함) → 120
# ────────────────────────────────────────────────────────────
def test_explode_stocktake_example():
lines = [
{"sku_code": "MT-0320", "qty": 100},
{"sku_code": "MY-0002", "qty": 20},
]
res = store.explode_stocktake(lines, _bom_map())
assert res["MT-0320"]["direct_qty"] == 100
assert res["MT-0320"]["from_set_qty"] == 20 # 20세트 x 1
assert res["MT-0320"]["total_qty"] == 120
# MT-0550 은 낱개 직접 입력 없음, 세트에서만 40
assert res["MT-0550"]["direct_qty"] == 0
assert res["MT-0550"]["from_set_qty"] == 40
assert res["MT-0550"]["total_qty"] == 40
def test_explode_stocktake_multiple_sets_same_component():
# MY-0002(MT-0320 x1) 10세트 + MY-0003(MT-0320 x1) 5세트 → from_set 15
lines = [
{"sku_code": "MT-0320", "qty": 0},
{"sku_code": "MY-0002", "qty": 10},
{"sku_code": "MY-0003", "qty": 5},
]
res = store.explode_stocktake(lines, _bom_map())
assert res["MT-0320"]["from_set_qty"] == 15
assert res["MT-0320"]["total_qty"] == 15
def test_missing_bom_sets():
lines = [
{"sku_code": "MY-0002", "qty": 1}, # BOM 있음
{"sku_code": "MY-0005", "qty": 1}, # BOM 없음 → 경고
]
missing = store.missing_bom_sets(lines, _bom_map())
assert missing == ["MY-0005"]
# ────────────────────────────────────────────────────────────
# 랙 입력 — 레이아웃 / 집계
# ────────────────────────────────────────────────────────────
def test_rack_cell_codes_count_and_format():
codes = store.rack_cell_codes()
# A/B 2면 × 3행 × 3열 × 2분할 = 36칸
assert len(codes) == 36
assert len(set(codes)) == 36
assert "A3-1-1" in codes
assert "B1-3-2" in codes
# 그림과 동일: 첫 칸은 A면 3행 1열 1분할
assert codes[0] == "A3-1-1"
def test_rack_layout_structure():
layout = store.rack_layout()
assert [s["side"] for s in layout] == ["A", "B"]
side_a = layout[0]
assert [r["row"] for r in side_a["rows"]] == [3, 2, 1]
# 각 행 3열, 각 열 2분할
first_row = side_a["rows"][0]
assert len(first_row["cols"]) == 3
assert first_row["cols"][0] == ["A3-1-1", "A3-1-2"]
def test_aggregate_rack_sums_units_times_boxes():
entries = [
{"sku_code": "MT-0320", "units_per_box": 24, "box_count": 3}, # 72
{"sku_code": "MT-0320", "units_per_box": 24, "box_count": 1}, # +24 (다른 칸)
{"sku_code": "MY-0002", "units_per_box": 6, "box_count": 2}, # 12 (콤보도 집계)
]
out = store.aggregate_rack(entries)
assert out == {"MT-0320": 96, "MY-0002": 12}
def test_aggregate_rack_skips_incomplete():
entries = [
{"sku_code": "", "units_per_box": 10, "box_count": 2}, # 코드 없음
{"sku_code": "MT-0550", "units_per_box": 0, "box_count": 5}, # 입수량 0
{"sku_code": "MT-0750", "units_per_box": 5, "box_count": 0}, # 박스 0
{"sku_code": "MT-0950", "units_per_box": "x", "box_count": 2}, # 정수 아님
]
assert store.aggregate_rack(entries) == {}
# pytest 미설치 환경 폴백
if __name__ == "__main__":
import traceback
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
total = 0
failed = 0
for fn in fns:
params = getattr(fn, "_params", None)
cases: list[tuple] = [()]
if params:
argnames, argvalues = params
cases = [(v if isinstance(v, tuple) else (v,)) for v in argvalues]
for case in cases:
total += 1
label = f"{fn.__name__}{case if case else ''}"
try:
fn(*case)
print(f"PASS: {label}")
except Exception: # noqa: BLE001
failed += 1
print(f"FAIL: {label}")
traceback.print_exc()
print(f"\n{total} tests, {failed} failed")
sys.exit(1 if failed else 0)