"""말레이시아 창고 재고관리 모듈 라우터. - 경로: /malaysia - 권한: 로그인 + `malaysia` 모듈 권한 (관리자는 항상 통과). 서버 측 검사. - 데이터: MalaysiaStockStore (malaysia_stock_db / PostgreSQL) 전용. MALAYSIA_STOCK_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내. - 상품명: 전역 itemcode_reader(itemcode_db 읽기 전용). 미설정 시 등록 스냅샷 사용. 화면(HTML) + JSON API 를 함께 제공한다. JSON API 는 /malaysia/api/* 아래. """ from __future__ import annotations from typing import Any from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app.timezone import today_kst from . import store router = APIRouter(prefix="/malaysia", tags=["malaysia"]) # ──────────────────────────────────────────────────────────── # 공용 헬퍼 # ──────────────────────────────────────────────────────────── def _store(request: Request) -> Any: return getattr(request.app.state, "malaysia_store", None) def _itemcode(request: Request) -> Any: return getattr(request.app.state, "itemcode_reader", None) def _require_user(request: Request) -> dict[str, Any]: from app.main import get_current_user_record # noqa: WPS433 from app.store import has_module # noqa: WPS433 user = get_current_user_record(request) if user is None: raise HTTPException(status_code=401, detail="로그인이 필요합니다.") if not has_module(user, "malaysia"): raise HTTPException(status_code=403, detail="말레이시아 재고관리 모듈 권한이 없습니다.") return user def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse: from app.main import build_erp_nav, render_template # noqa: WPS433 from app.store import is_admin # noqa: WPS433 return render_template( request, "denied.html", { "reason": "말레이시아 재고관리 모듈이 아직 설정되지 않았습니다. " "MALAYSIA_STOCK_DB_URL 환경변수를 설정하고 " "scripts/sql/malaysia_stock_db_init.sql 로 malaysia_stock_db 를 " "초기화한 뒤 컨테이너를 재기동하세요.", "user": user, "is_admin": is_admin(user), "nav_items": build_erp_nav(user, active="malaysia"), }, status_code=503, ) def _guard(request: Request) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse: """로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용.""" from app.main import get_current_user_record, render_template # noqa: WPS433 from app.store import has_module, is_admin # noqa: WPS433 user = get_current_user_record(request) if user is None: return RedirectResponse(url="/login", status_code=303) if not has_module(user, "malaysia"): return render_template( request, "denied.html", {"reason": "말레이시아 재고관리 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)}, status_code=403, ) st = _store(request) if st is None: return _render_config_needed(request, user) return st, user def _selected_wh(request: Request, st: Any) -> str: """쿼리 ?wh= 또는 기본(첫 활성 창고).""" wh = (request.query_params.get("wh") or "").strip() warehouses = st.list_warehouses() codes = [w["warehouse_code"] for w in warehouses] if wh in codes: return wh return codes[0] if codes else "MY-WH-01" def _base_ctx(request: Request, user: dict[str, Any], st: Any, *, wh: str) -> dict[str, Any]: from app.main import build_erp_nav # noqa: WPS433 from app.store import is_admin # noqa: WPS433 return { "user": user, "is_admin": is_admin(user), "nav_items": build_erp_nav(user, active="malaysia"), "warehouses": st.list_warehouses(), "selected_wh": wh, } # ════════════════════════════════════════════════════════════ # 재고 현황 (메인) # ════════════════════════════════════════════════════════════ @router.get("/", response_class=HTMLResponse) async def index(request: Request) -> HTMLResponse: from app.main import render_template # noqa: WPS433 guard = _guard(request) if not isinstance(guard, tuple): return guard st, user = guard wh = _selected_wh(request, st) ctx = _base_ctx(request, user, st, wh=wh) ctx.update( { "page_title": "말레이시아 재고관리", "page_subtitle": f"{wh} · 재고 현황", "rows": st.stock_status(warehouse_code=wh), } ) return render_template(request, "malaysia/index.html", ctx) # ════════════════════════════════════════════════════════════ # 입고 / 출고 등록 # ════════════════════════════════════════════════════════════ @router.get("/movements", response_class=HTMLResponse) async def movements_page(request: Request) -> HTMLResponse: from app.main import render_template # noqa: WPS433 guard = _guard(request) if not isinstance(guard, tuple): return guard st, user = guard wh = _selected_wh(request, st) mtype = (request.query_params.get("type") or "").strip().upper() ctx = _base_ctx(request, user, st, wh=wh) ctx.update( { "page_title": "말레이시아 재고관리 — 입출고", "page_subtitle": f"{wh} · 입고/출고 등록", "individual_items": st.list_items(kind="individual"), "set_items": st.list_items(kind="set"), "movements": st.list_movements( warehouse_code=wh, movement_type=mtype if mtype in store.MOVEMENT_TYPES else None, limit=200, ), "today": today_kst().isoformat(), "filter_type": mtype if mtype in store.MOVEMENT_TYPES else "", } ) return render_template(request, "malaysia/movements.html", ctx) @router.post("/movements") async def movement_create( request: Request, movement_date: str = Form(...), warehouse_code: str = Form(...), item_code: str = Form(...), movement_type: str = Form(...), qty: int = Form(...), ref_type: str = Form(""), ref_no: str = Form(""), memo: str = Form(""), user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: st.create_movement( movement_date=movement_date, warehouse_code=warehouse_code, item_code=item_code, movement_type=movement_type, qty=qty, ref_type=ref_type, ref_no=ref_no, memo=memo, created_by=user["email"], ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return RedirectResponse(url=f"/malaysia/movements?wh={warehouse_code}", status_code=303) @router.post("/movements/set-out") async def movement_set_out( request: Request, movement_date: str = Form(...), warehouse_code: str = Form(...), set_code: str = Form(...), set_qty: int = Form(...), ref_type: str = Form("set"), ref_no: str = Form(""), memo: str = Form(""), user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: """세트 주문 출고 → BOM 분해 후 구성품별 OUT 생성.""" st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: st.create_set_out( movement_date=movement_date, warehouse_code=warehouse_code, set_code=set_code, set_qty=set_qty, ref_type=ref_type, ref_no=ref_no, memo=memo, created_by=user["email"], ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return RedirectResponse(url=f"/malaysia/movements?wh={warehouse_code}", status_code=303) # ════════════════════════════════════════════════════════════ # 세트 구성 관리 (set_bom) # ════════════════════════════════════════════════════════════ @router.get("/sets", response_class=HTMLResponse) async def sets_page(request: Request) -> HTMLResponse: from app.main import render_template # noqa: WPS433 guard = _guard(request) if not isinstance(guard, tuple): return guard st, user = guard wh = _selected_wh(request, st) ctx = _base_ctx(request, user, st, wh=wh) # 세트별로 묶어서 표시 set_items = st.list_items(kind="set") bom = st.list_bom(include_inactive=True) by_set: dict[str, list[dict[str, Any]]] = {} for b in bom: by_set.setdefault(b["set_code"], []).append(b) names = st.item_name_map() ctx.update( { "page_title": "말레이시아 재고관리 — 세트 구성", "page_subtitle": "MY- 세트 BOM 관리 (구성품은 MT-/MX-/MZ-)", "set_items": set_items, "individual_items": st.list_items(kind="individual"), "by_set": by_set, "names": names, } ) return render_template(request, "malaysia/sets.html", ctx) @router.post("/sets/bom") async def bom_upsert( request: Request, set_code: str = Form(...), component_code: str = Form(...), component_qty: int = Form(...), user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: st.upsert_bom_line( set_code=set_code, component_code=component_code, component_qty=component_qty ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return RedirectResponse(url="/malaysia/sets", status_code=303) @router.post("/sets/bom/{bom_id:int}/delete") async def bom_delete( request: Request, bom_id: int, user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: st.delete_bom_line(bom_id=bom_id) except KeyError: raise HTTPException(status_code=404, detail="BOM 라인을 찾을 수 없습니다.") return RedirectResponse(url="/malaysia/sets", status_code=303) # ════════════════════════════════════════════════════════════ # 일일 재고조사 # ════════════════════════════════════════════════════════════ @router.get("/stocktakes", response_class=HTMLResponse) async def stocktakes_page(request: Request) -> HTMLResponse: from app.main import render_template # noqa: WPS433 guard = _guard(request) if not isinstance(guard, tuple): return guard st, user = guard wh = _selected_wh(request, st) ctx = _base_ctx(request, user, st, wh=wh) ctx.update( { "page_title": "말레이시아 재고관리 — 일일 재고조사", "page_subtitle": f"{wh} · 조사 목록", "stocktakes": st.list_stocktakes(warehouse_code=wh), "today": today_kst().isoformat(), } ) return render_template(request, "malaysia/stocktakes.html", ctx) @router.post("/stocktakes") async def stocktake_create( request: Request, stocktake_date: str = Form(...), warehouse_code: str = Form(...), memo: str = Form(""), user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: head = st.create_stocktake( stocktake_date=stocktake_date, warehouse_code=warehouse_code, memo=memo, created_by=user["email"], ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return RedirectResponse(url=f"/malaysia/stocktakes/{head['id']}", status_code=303) @router.get("/stocktakes/{stocktake_id:int}", response_class=HTMLResponse) async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse: from app.main import render_template # noqa: WPS433 from app.store import is_admin # noqa: WPS433 guard = _guard(request) if not isinstance(guard, tuple): return guard st, user = guard try: computed = st.compute_result(stocktake_id=stocktake_id) except KeyError: return render_template( request, "denied.html", {"reason": "재고조사를 찾을 수 없습니다.", "is_admin": is_admin(user)}, status_code=404, ) head = computed["stocktake"] wh = head["warehouse_code"] ctx = _base_ctx(request, user, st, wh=wh) ctx.update( { "page_title": f"재고조사 #{stocktake_id}", "page_subtitle": f"{head['stocktake_date']} · {wh} · {head['status']}", "stocktake": head, "individual_items": st.list_items(kind="individual"), "set_items": st.list_items(kind="set"), "result_rows": computed["rows"], "missing_bom": computed["missing_bom"], "is_draft": head["status"] == "draft", } ) return render_template(request, "malaysia/stocktake_detail.html", ctx) @router.post("/stocktakes/{stocktake_id:int}/lines") async def stocktake_line_upsert( request: Request, stocktake_id: int, sku_code: str = Form(...), qty: int = Form(...), memo: str = Form(""), user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: st.upsert_line(stocktake_id=stocktake_id, sku_code=sku_code, qty=qty, memo=memo) except KeyError: raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.") except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303) @router.post("/stocktakes/{stocktake_id:int}/lines/{line_id:int}/delete") async def stocktake_line_delete( request: Request, stocktake_id: int, line_id: int, user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: st.delete_line(stocktake_id=stocktake_id, line_id=line_id) except KeyError: raise HTTPException(status_code=404, detail="라인을 찾을 수 없습니다.") except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303) @router.post("/stocktakes/{stocktake_id:int}/finalize") async def stocktake_finalize( request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: st.finalize_stocktake(stocktake_id=stocktake_id) except KeyError: raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.") except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303) @router.post("/stocktakes/{stocktake_id:int}/cancel") async def stocktake_cancel( request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: st.cancel_stocktake(stocktake_id=stocktake_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303) # ════════════════════════════════════════════════════════════ # JSON API (요구사항 6) — /malaysia/api/* # ════════════════════════════════════════════════════════════ @router.get("/api/warehouses") async def api_warehouses( request: Request, _: dict[str, Any] = Depends(_require_user) ) -> JSONResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") return JSONResponse({"warehouses": st.list_warehouses()}) @router.get("/api/items") async def api_items( request: Request, kind: str = "", _: dict[str, Any] = Depends(_require_user) ) -> JSONResponse: """kind=individual → MT/MX/MZ, kind=set → MY, 미지정 → 전체.""" st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") k = kind if kind in ("individual", "set") else None return JSONResponse({"items": st.list_items(kind=k)}) @router.get("/api/bom") async def api_bom( request: Request, _: dict[str, Any] = Depends(_require_user) ) -> JSONResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") return JSONResponse({"bom": st.list_bom(include_inactive=True)}) @router.post("/api/bom") async def api_bom_upsert( request: Request, payload: dict[str, Any] = Body(...), _: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: row = st.upsert_bom_line( set_code=str(payload.get("set_code") or ""), component_code=str(payload.get("component_code") or ""), component_qty=int(payload.get("component_qty") or 0), ) except (ValueError, TypeError) as exc: raise HTTPException(status_code=400, detail=str(exc)) return JSONResponse({"ok": True, "bom": row}) @router.get("/api/stock") async def api_stock( request: Request, wh: str = "", _: dict[str, Any] = Depends(_require_user) ) -> JSONResponse: st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") wh = wh.strip() or _selected_wh(request, st) return JSONResponse({"warehouse_code": wh, "rows": st.stock_status(warehouse_code=wh)}) @router.post("/api/movements") async def api_movement_create( request: Request, payload: dict[str, Any] = Body(...), user: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: """단일 낱개 movement 생성. body: {movement_date,warehouse_code,item_code,movement_type,qty,...}.""" st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: row = st.create_movement( movement_date=str(payload.get("movement_date") or ""), warehouse_code=str(payload.get("warehouse_code") or ""), item_code=str(payload.get("item_code") or ""), movement_type=str(payload.get("movement_type") or ""), qty=int(payload.get("qty") or 0), ref_type=str(payload.get("ref_type") or ""), ref_no=str(payload.get("ref_no") or ""), memo=str(payload.get("memo") or ""), created_by=user["email"], ) except (ValueError, TypeError) as exc: raise HTTPException(status_code=400, detail=str(exc)) return JSONResponse({"ok": True, "movement": row}) @router.post("/api/movements/set-out") async def api_set_out( request: Request, payload: dict[str, Any] = Body(...), user: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: """세트 출고(BOM 분해). body: {movement_date,warehouse_code,set_code,set_qty,...}.""" st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: rows = st.create_set_out( movement_date=str(payload.get("movement_date") or ""), warehouse_code=str(payload.get("warehouse_code") or ""), set_code=str(payload.get("set_code") or ""), set_qty=int(payload.get("set_qty") or 0), ref_type=str(payload.get("ref_type") or "set"), ref_no=str(payload.get("ref_no") or ""), memo=str(payload.get("memo") or ""), created_by=user["email"], ) except (ValueError, TypeError) as exc: raise HTTPException(status_code=400, detail=str(exc)) return JSONResponse({"ok": True, "movements": rows}) @router.get("/api/stocktakes/{stocktake_id:int}/result") async def api_stocktake_result( request: Request, stocktake_id: int, _: dict[str, Any] = Depends(_require_user), ) -> JSONResponse: """재고조사 최종 계산 결과(낱개 기준 direct/from_set/total).""" st = _store(request) if st is None: raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") try: computed = st.compute_result(stocktake_id=stocktake_id) except KeyError: raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.") return JSONResponse( { "stocktake": computed["stocktake"], "rows": computed["rows"], "missing_bom": computed["missing_bom"], } ) @router.get("/health") async def health() -> dict[str, str]: return {"status": "ok", "module": "malaysia"}