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>
This commit is contained in:
2026-06-17 14:40:17 +09:00
parent fa396bd237
commit 55a2fc8793
4 changed files with 42 additions and 6 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 개인경비 (`app/modules/expense/`, `expense_db`)
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-) 제외. 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
상세는 `docs/PROJECT_OVERVIEW.md`.
+2 -2
View File
@@ -476,7 +476,7 @@ class MalaysiaStockStore:
cell = (e.get("cell_code") or "").strip().upper()
if cell not in valid_cells:
raise ValueError(f"알 수 없는 랙 위치: {cell or '(빈값)'}")
code = store.validate_stocktake_code(str(e.get("sku_code") or ""))
code = store.validate_rack_code(str(e.get("sku_code") or ""))
upb = store.validate_qty(
e.get("units_per_box"), allow_zero=False, allow_negative=False
)
@@ -754,7 +754,7 @@ class MalaysiaStockStore:
cell = (e.get("cell_code") or "").strip().upper()
if cell not in valid_cells:
raise ValueError(f"알 수 없는 랙 위치: {cell or '(빈값)'}")
code = store.validate_stocktake_code(str(e.get("sku_code") or ""))
code = store.validate_rack_code(str(e.get("sku_code") or ""))
upb = store.validate_qty(
e.get("units_per_box"), allow_zero=False, allow_negative=False
)
+4 -2
View File
@@ -190,7 +190,8 @@ async def rack_view(request: Request) -> HTMLResponse:
# 랙보기는 '랙을 가장 최근에 저장한' 조사 기준(조사 날짜 아님).
latest = st.latest_rack_stocktake(warehouse_code=wh)
rack_by_cell: dict[str, list[dict[str, Any]]] = {}
names = st.item_name_map()
# 뚜껑(MD-)은 malaysia_items 에 없으므로 이름맵에 병합해 코드 대신 이름 표시.
names = {**st.item_name_map(), **store.lid_name_map()}
if latest:
for e in st.list_rack_entries(stocktake_id=latest["id"]):
e["item_name"] = names.get(e["sku_code"], e["sku_code"])
@@ -547,7 +548,8 @@ async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse:
"stocktake": head,
"rack_layout": store.rack_layout(),
"rack_by_cell": rack_by_cell,
"rack_items": st.list_items(),
# 낱개·세트 + 뚜껑(MD-, 위치확인용). 뚜껑은 재고 집계엔 빠진다.
"rack_items": st.list_items() + list(store.LID_ITEMS),
"entered": entered,
"result_rows": computed["rows"],
"missing_bom": computed["missing_bom"],
+35 -1
View File
@@ -25,7 +25,22 @@ STOCKTAKE_STATUSES: tuple[str, ...] = ("draft", "finalized", "cancelled")
# ── 코드 prefix ──
INDIVIDUAL_PREFIXES: tuple[str, ...] = ("MT-", "MX-", "MZ-")
SET_PREFIX: str = "MY-"
BLOCKED_PREFIX: str = "MD-" # 뚜껑 — 재고관리 대상에서 제외
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칸.
@@ -87,6 +102,9 @@ def aggregate_rack(entries: Iterable[dict[str, Any]]) -> dict[str, int]:
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)
@@ -166,6 +184,22 @@ def validate_stocktake_code(code: str) -> str:
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)