Files
dbx-main/tests/test_malaysia_stock.py
T
king f137f2a6a6 feat(malaysia): 말레이시아 창고 재고관리 모듈 추가
- 신규 모듈 app/modules/malaysia (prefix /malaysia, 권한키 malaysia)
- malaysia_stock_db: warehouses/malaysia_items/set_bom/stock_movement/
  daily_stocktake/daily_stocktake_line (멱등 init SQL)
- 낱개(MT/MX/MZ) 입출고·조정 movement, 세트(MY) BOM 관리, 세트 출고 BOM 분해
- 일일 재고조사: 세트→낱개 자동 분해(direct+from_set=total), 확정 시 차이만 STOCKTAKE
- prefix 검증 이중화(DB CHECK + store.py 순수함수), MD- 전면 제외
- 상품명은 itemcode_db 읽기 전용 재사용(중복 마스터 없음)
- 화면 4종 + JSON API, 순수로직 테스트 14건, README/docs 갱신

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:34:31 +09:00

198 lines
8.2 KiB
Python

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