"""말레이시아 재고관리 — 순수 로직 테스트 (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)