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>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""말레이시아 재고관리 — 순수 로직 테스트 (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)
|
||||
Reference in New Issue
Block a user