f137f2a6a6
- 신규 모듈 app/modules/malaysia (prefix /malaysia, 권한키 malaysia) - malaysia_stock_db: warehouses/malaysia_items/set_bom/stock_movement/ daily_stocktake/daily_stocktake_line (멱등 init SQL) - 낱개(MT/MX/MZ) 입출고·조정 movement, 세트(MY) BOM 관리, 세트 출고 BOM 분해 - 일일 재고조사: 세트→낱개 자동 분해(direct+from_set=total), 확정 시 차이만 STOCKTAKE - prefix 검증 이중화(DB CHECK + store.py 순수함수), MD- 전면 제외 - 상품명은 itemcode_db 읽기 전용 재사용(중복 마스터 없음) - 화면 4종 + JSON API, 순수로직 테스트 14건, README/docs 갱신 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
603 lines
23 KiB
Python
603 lines
23 KiB
Python
"""말레이시아 창고 재고관리 모듈 라우터.
|
|
|
|
- 경로: /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"}
|