032fb78867
낱개/콤보 평면 수량 그리드를 창고 랙 그림(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>
243 lines
10 KiB
Python
243 lines
10 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"]
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────
|
||
# 랙 입력 — 레이아웃 / 집계
|
||
# ────────────────────────────────────────────────────────────
|
||
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)
|