diff --git a/CLAUDE.md b/CLAUDE.md index 7800539..3b92b4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/app/modules/malaysia/db.py b/app/modules/malaysia/db.py index 8d5bac1..522d830 100644 --- a/app/modules/malaysia/db.py +++ b/app/modules/malaysia/db.py @@ -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 ) diff --git a/app/modules/malaysia/router.py b/app/modules/malaysia/router.py index 9238c82..455e6f3 100644 --- a/app/modules/malaysia/router.py +++ b/app/modules/malaysia/router.py @@ -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"], diff --git a/app/modules/malaysia/store.py b/app/modules/malaysia/store.py index dd6a038..d1e7306 100644 --- a/app/modules/malaysia/store.py +++ b/app/modules/malaysia/store.py @@ -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)