Files
dbx-main/app/modules/malaysia/store.py
T
king 55a2fc8793 feat(malaysia): 뚜껑(MD-) 랙 위치확인 배치 허용, 재고는 미반영
뚜껑(MD-0000~0005)을 재고조사 랙 입력 드롭다운에 노출해 창고 랙
어디에 있는지 위치만 기록할 수 있게 함. 재고에는 반영하지 않음.

- store.LID_ITEMS / lid_name_map 추가
- validate_rack_code 신설(개별/세트/뚜껑 허용) → replace_rack·
  reposition_rack 검증에 적용. 직접 라인 입력(validate_stocktake_code)은
  여전히 MD- 차단해 재고 오염 방지.
- aggregate_rack 에서 MD- 집계 제외 → daily_stocktake_line 미생성.
- 랙 드롭다운 목록에 LID_ITEMS 추가, 랙 보기 이름맵에 뚜껑 이름 병합.

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

331 lines
13 KiB
Python
Raw 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.
"""말레이시아 창고 재고관리 모듈 — 상수 및 순수 계산/검증 헬퍼.
- 데이터 저장은 malaysia_stock_db(PostgreSQL) 전용이다(`db.py`).
운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다.
MALAYSIA_STOCK_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다.
- 이 모듈에는 DB 의존이 없는 상수와 순수 함수만 둔다(테스트 용이).
prefix 검증, 세트 BOM 분해, 재고조사 집계가 모두 여기 있다.
코드 prefix 규칙(요구사항 8 검증):
- 낱개(개별) 아이템 : MT-, MX-, MZ- → 입고/출고/조정 movement 가능
- 세트 아이템 : MY- → movement 불가, 재고조사/BOM 만 가능
- 뚜껑(제외) : MD- → 어디에도 사용 불가
"""
from __future__ import annotations
from typing import Any, Iterable
# ── 이동(movement) 종류 ──
MOVEMENT_TYPES: tuple[str, ...] = ("IN", "OUT", "ADJUST", "STOCKTAKE")
# ── 재고조사 상태 ──
STOCKTAKE_STATUSES: tuple[str, ...] = ("draft", "finalized", "cancelled")
# ── 코드 prefix ──
INDIVIDUAL_PREFIXES: tuple[str, ...] = ("MT-", "MX-", "MZ-")
SET_PREFIX: str = "MY-"
BLOCKED_PREFIX: str = "MD-" # 뚜껑 — 재고(입출고/조사 집계) 대상에서 제외
# 뚜껑(MD-) 아이템 목록 — 재고에는 반영하지 않고 '창고 랙 위치 확인' 용도로만
# 랙 칸에 배치할 수 있다(드롭다운 노출). malaysia_items 테이블엔 넣지 않는다.
LID_ITEMS: tuple[dict[str, str], ...] = (
{"item_code": "MD-0000", "item_name": "Lid XS", "kind": "lid"},
{"item_code": "MD-0001", "item_name": "Lid S", "kind": "lid"},
{"item_code": "MD-0002", "item_name": "Lid M", "kind": "lid"},
{"item_code": "MD-0003", "item_name": "Lid L", "kind": "lid"},
{"item_code": "MD-0005", "item_name": "Lid 5", "kind": "lid"},
)
def lid_name_map() -> dict[str, str]:
"""뚜껑 코드 → 이름(랙 보기 표시용)."""
return {it["item_code"]: it["item_name"] for it in LID_ITEMS}
# ── 창고 랙 레이아웃 (재고조사 랙 입력용) ──
# 그림 고정: A/B 두 면, 각 면 3행 × 3열, 각 열은 2분할 → 면당 18칸, 총 36칸.
# 행은 위→아래로 3,2,1 (그림과 동일). 칸 코드: '{면}{행}-{열}-{분할}' 예) A3-1-1.
RACK_SIDES: tuple[str, ...] = ("A", "B")
RACK_ROWS: tuple[int, ...] = (3, 2, 1)
RACK_COLS: tuple[int, ...] = (1, 2, 3)
RACK_SUBS: tuple[int, ...] = (1, 2)
# Side B 아래 바닥(Ground) 파렛트 6칸. 코드: 'G1' ~ 'G6'.
RACK_GROUND: tuple[str, ...] = ("G1", "G2", "G3", "G4", "G5", "G6")
def rack_cell_codes() -> list[str]:
"""모든 랙 칸 코드 목록(검증용). 예: 'A3-1-1' ... 'B1-3-2', 'G1'~'G6'."""
codes = [
f"{side}{row}-{col}-{sub}"
for side in RACK_SIDES
for row in RACK_ROWS
for col in RACK_COLS
for sub in RACK_SUBS
]
codes.extend(RACK_GROUND)
return codes
def rack_layout() -> list[dict[str, Any]]:
"""템플릿 렌더용 중첩 구조.
[{"side":"A","rows":[{"row":3,"cols":[["A3-1-1","A3-1-2"], ...]}, ...]}, ...]
"""
out: list[dict[str, Any]] = []
for side in RACK_SIDES:
rows: list[dict[str, Any]] = []
for row in RACK_ROWS:
cols = [
[f"{side}{row}-{col}-{sub}" for sub in RACK_SUBS]
for col in RACK_COLS
]
rows.append({"row": row, "cols": cols})
out.append({"side": side, "label": f"Side {side}", "rows": rows})
# Side B 아래 Ground: 6칸(G1~G6)을 한 줄에. 각 칸은 분할 없는 단일 파렛트.
out.append({
"side": "Ground",
"label": "Ground",
"rows": [{"row": 0, "cols": [[code] for code in RACK_GROUND]}],
})
return out
def aggregate_rack(entries: Iterable[dict[str, Any]]) -> dict[str, int]:
"""랙 입력 항목을 SKU 코드별 총수량으로 집계.
각 항목: {"sku_code", "units_per_box", "box_count"}.
qty = SUM(units_per_box * box_count). 입수량/박스수 ≤ 0 또는 코드 공백은 스킵.
반환: { sku_code: total_qty } (낱개·세트 코드 모두 포함 가능)
"""
totals: dict[str, int] = {}
for e in entries:
code = _norm(e.get("sku_code"))
if not code:
continue
# 뚜껑(MD-)은 위치 확인용일 뿐 재고에 반영하지 않으므로 집계 제외.
if is_blocked_code(code):
continue
try:
upb = int(e.get("units_per_box") or 0)
box = int(e.get("box_count") or 0)
except (TypeError, ValueError):
continue
if upb <= 0 or box <= 0:
continue
totals[code] = totals.get(code, 0) + upb * box
return totals
# ════════════════════════════════════════════════════════════
# 코드 분류 / 검증 (순수 함수)
# ════════════════════════════════════════════════════════════
def _norm(code: str) -> str:
return (code or "").strip().upper()
def is_individual_code(code: str) -> bool:
"""낱개 아이템(MT-/MX-/MZ-) 여부."""
return _norm(code).startswith(INDIVIDUAL_PREFIXES)
def is_set_code(code: str) -> bool:
"""세트 아이템(MY-) 여부."""
return _norm(code).startswith(SET_PREFIX)
def is_blocked_code(code: str) -> bool:
"""제외 대상(MD- 뚜껑) 여부."""
return _norm(code).startswith(BLOCKED_PREFIX)
def classify_code(code: str) -> str:
"""코드 종류를 반환. 'individual' | 'set' | 'blocked' | 'unknown'."""
if is_blocked_code(code):
return "blocked"
if is_individual_code(code):
return "individual"
if is_set_code(code):
return "set"
return "unknown"
def validate_movement_code(code: str) -> str:
"""입고/출고/조정 movement 에 쓸 수 있는 코드인지 검증.
낱개(MT-/MX-/MZ-)만 허용. MY-/MD-/기타는 ValueError.
반환: 정규화된 코드(대문자/trim).
"""
c = _norm(code)
if not c:
raise ValueError("아이템 코드가 비어 있습니다.")
kind = classify_code(c)
if kind == "blocked":
raise ValueError(f"뚜껑 코드(MD-)는 재고관리 대상이 아닙니다: {c}")
if kind == "set":
raise ValueError(f"세트 코드(MY-)는 입고/출고에 직접 쓸 수 없습니다: {c}")
if kind != "individual":
raise ValueError(f"허용되지 않는 코드입니다(MT-/MX-/MZ- 만 가능): {c}")
return c
def validate_stocktake_code(code: str) -> str:
"""재고조사 라인(daily_stocktake_line)에 쓸 수 있는 코드인지 검증.
낱개(MT-/MX-/MZ-) 또는 세트(MY-) 허용. MD-/기타는 ValueError.
"""
c = _norm(code)
if not c:
raise ValueError("SKU 코드가 비어 있습니다.")
kind = classify_code(c)
if kind == "blocked":
raise ValueError(f"뚜껑 코드(MD-)는 재고조사 대상이 아닙니다: {c}")
if kind not in ("individual", "set"):
raise ValueError(f"허용되지 않는 코드입니다(MT-/MX-/MZ-/MY- 만 가능): {c}")
return c
def validate_rack_code(code: str) -> str:
"""창고 랙 칸에 배치할 수 있는 코드 검증.
낱개(MT-/MX-/MZ-)·세트(MY-)에 더해 뚜껑(MD-)도 허용한다.
뚜껑은 위치 확인 용도로만 랙에 두며, aggregate_rack 에서 집계 제외되어
재고(daily_stocktake_line)에는 반영되지 않는다.
"""
c = _norm(code)
if not c:
raise ValueError("SKU 코드가 비어 있습니다.")
kind = classify_code(c)
if kind not in ("individual", "set", "blocked"):
raise ValueError(f"허용되지 않는 코드입니다(MT-/MX-/MZ-/MY-/MD- 만 가능): {c}")
return c
def validate_bom_set_code(code: str) -> str:
"""set_bom 의 set_code 검증 — MY- 만 허용."""
c = _norm(code)
if not is_set_code(c):
raise ValueError(f"세트 코드는 MY- 로 시작해야 합니다: {c}")
return c
def validate_bom_component_code(code: str) -> str:
"""set_bom 의 component_code 검증 — MT-/MX-/MZ- 만 허용(MY-/MD- 금지)."""
c = _norm(code)
if is_blocked_code(c):
raise ValueError(f"뚜껑 코드(MD-)는 세트 구성품이 될 수 없습니다: {c}")
if is_set_code(c):
raise ValueError(f"세트(MY-)가 세트의 구성품이 될 수 없습니다(중첩 불가): {c}")
if not is_individual_code(c):
raise ValueError(f"구성품은 MT-/MX-/MZ- 만 가능합니다: {c}")
return c
def validate_qty(qty: Any, *, allow_zero: bool = True, allow_negative: bool = False) -> int:
"""수량 검증/정규화. 정수 변환 + 음수/0 정책 적용."""
try:
n = int(qty)
except (TypeError, ValueError):
raise ValueError(f"수량은 정수여야 합니다: {qty!r}")
if not allow_negative and n < 0:
raise ValueError(f"수량은 음수가 될 수 없습니다: {n}")
if not allow_zero and n == 0:
raise ValueError("수량은 0이 될 수 없습니다.")
return n
# ════════════════════════════════════════════════════════════
# 세트 BOM 분해 / 재고조사 집계 (순수 함수 — DB 무관, 테스트 용이)
# ════════════════════════════════════════════════════════════
def explode_set(
set_code: str, set_qty: int, bom_map: dict[str, list[dict[str, Any]]]
) -> dict[str, int]:
"""세트 1종 × 수량을 BOM 기준으로 낱개 아이템 수량으로 분해.
bom_map: { set_code: [{"component_code": str, "component_qty": int}, ...] }
반환: { component_code: 분해수량 }
BOM 이 없으면 빈 dict(호출측에서 경고 처리).
"""
out: dict[str, int] = {}
for comp in bom_map.get(_norm(set_code), []):
code = _norm(comp.get("component_code"))
try:
per = int(comp.get("component_qty") or 0)
except (TypeError, ValueError):
per = 0
if not code or per <= 0:
continue
out[code] = out.get(code, 0) + per * int(set_qty)
return out
def explode_stocktake(
lines: Iterable[dict[str, Any]],
bom_map: dict[str, list[dict[str, Any]]],
) -> dict[str, dict[str, Any]]:
"""재고조사 라인을 낱개 아이템 기준 최종 수량으로 집계.
입력 라인: [{"sku_code": str, "qty": int}, ...]
- 낱개 코드(MT/MX/MZ) qty → direct_qty 로 누적
- 세트 코드(MY) qty → set_bom 으로 분해해 from_set_qty 로 누적
반환(낱개 아이템 코드 기준):
{ item_code: {
"direct_qty": 직접 조사 수량 합,
"from_set_qty": 세트 분해 수량 합,
"total_qty": direct_qty + from_set_qty,
} }
추가로 결과에 포함되는 진단 키는 없다(순수 수량만). 세트 BOM 누락
경고는 `missing_bom_sets()` 로 별도 확인한다.
"""
result: dict[str, dict[str, int]] = {}
def _bucket(code: str) -> dict[str, int]:
return result.setdefault(code, {"direct_qty": 0, "from_set_qty": 0})
for ln in lines:
code = _norm(ln.get("sku_code"))
try:
qty = int(ln.get("qty") or 0)
except (TypeError, ValueError):
qty = 0
if not code or qty < 0:
continue
if is_individual_code(code):
_bucket(code)["direct_qty"] += qty
elif is_set_code(code):
for comp_code, comp_qty in explode_set(code, qty, bom_map).items():
_bucket(comp_code)["from_set_qty"] += comp_qty
# blocked/unknown 은 무시(검증 단계에서 이미 막힘)
out: dict[str, dict[str, Any]] = {}
for code, v in result.items():
total = v["direct_qty"] + v["from_set_qty"]
out[code] = {
"item_code": code,
"direct_qty": v["direct_qty"],
"from_set_qty": v["from_set_qty"],
"total_qty": total,
}
return out
def missing_bom_sets(
lines: Iterable[dict[str, Any]],
bom_map: dict[str, list[dict[str, Any]]],
) -> list[str]:
"""재고조사에 입력된 MY- 세트 중 BOM 구성이 없는 코드 목록.
요구사항 8: 'BOM 이 없는 MY- 코드가 재고조사에 입력되면 경고/오류'.
"""
missing: list[str] = []
seen: set[str] = set()
for ln in lines:
code = _norm(ln.get("sku_code"))
if not is_set_code(code) or code in seen:
continue
seen.add(code)
if not bom_map.get(code):
missing.append(code)
return missing