Files
dbx-main/app/modules/malaysia/router.py
T
king 9039d1d80b ui(malaysia): 이동이력 날짜필터+요일, Ref열 제거, 헤딩 변경(낱개/콤보 상품)
- 이동 이력에 날짜 선택 필터(요일 포함 표시), 날짜+종류 조합 유지
- 이력 Ref 컬럼 삭제
- "낱개 아이템"→"낱개 상품", "세트 아이템 — 출고(OUT)"→"콤보 상품"
- 안내 문구 삭제

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:05:50 +09:00

538 lines
22 KiB
Python

"""말레이시아 창고 재고관리 모듈 라우터.
- 경로: /malaysia
- 권한: 로그인 + `malaysia` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
- 데이터: MalaysiaStockStore (malaysia_stock_db / PostgreSQL) 전용.
MALAYSIA_STOCK_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
- 세트 BOM: itemcode_db(set_components) 읽기 전용(MalaysiaItemcodeReader). 별도 관리 메뉴 없음.
입력 방식: 입출고/재고조사 모두 전체 아이템(+세트) 그리드에 수량만 키인 → 일괄 등록.
"""
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 _reader(request: Request) -> Any:
return getattr(request.app.state, "malaysia_itemcode", None)
def _bom_map(request: Request) -> dict[str, list[dict[str, Any]]]:
"""itemcode_db(set_components)에서 MY- 세트 BOM 을 읽어 반환. 리더 없으면 {}."""
r = _reader(request)
return r.set_bom_map() if r else {}
_WEEKDAYS_KO = ("", "", "", "", "", "", "")
def _date_label(iso: str | None) -> str:
"""'2026-06-11''2026-06-11 (목)'. 파싱 실패 시 원문."""
from datetime import date as _date # noqa: WPS433
s = (iso or "").strip()
try:
d = _date.fromisoformat(s[:10])
except (ValueError, TypeError):
return s
return f"{d.isoformat()} ({_WEEKDAYS_KO[d.weekday()]})"
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()
codes = [w["warehouse_code"] for w in st.list_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()
fdate = (request.query_params.get("date") or "").strip()
movements = st.list_movements(
warehouse_code=wh,
movement_type=mtype if mtype in store.MOVEMENT_TYPES else None,
date_from=fdate or None,
date_to=fdate or None,
limit=300,
)
for m in movements:
m["date_label"] = _date_label(m.get("movement_date"))
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": movements,
"today": today_kst().isoformat(),
"filter_type": mtype if mtype in store.MOVEMENT_TYPES else "",
"filter_date": fdate,
}
)
return render_template(request, "malaysia/movements.html", ctx)
@router.post("/movements/apply")
async def movements_apply(request: Request, user: dict[str, Any] = Depends(_require_user)) -> RedirectResponse:
"""낱개 + 세트 한 화면 일괄 적용 (버튼 하나).
form: movement_type(낱개용 IN/OUT/ADJUST), movement_date, warehouse_code,
ref_type, ref_no, memo, qty_<CODE>...
- 낱개(individual): 선택한 movement_type 으로 등록. IN/OUT 양수만, ADJUST ±(0 스킵).
- 세트(set): 항상 OUT 으로 처리 — BOM(itemcode_db) 분해해 구성품별 OUT 생성.
빈칸/0 은 스킵. 코드별 에러는 모아서 부분 적용 후 안내.
"""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
form = await request.form()
mtype = str(form.get("movement_type") or "").strip().upper()
if mtype not in ("IN", "OUT", "ADJUST"):
raise HTTPException(status_code=400, detail="이동 종류 오류")
wh = str(form.get("warehouse_code") or "").strip()
mdate = str(form.get("movement_date") or "").strip()
ref_type = str(form.get("ref_type") or "").strip()
ref_no = str(form.get("ref_no") or "").strip()
memo = str(form.get("memo") or "").strip()
bmap = _bom_map(request)
applied = 0
errors: list[str] = []
# 낱개 — 선택한 종류로
for it in st.list_items(kind="individual"):
code = it["item_code"]
raw = str(form.get(f"qty_{code}") or "").strip()
if raw == "":
continue
try:
n = int(raw)
except ValueError:
errors.append(f"{code}: 숫자 아님")
continue
if n == 0:
continue
try:
st.create_movement(
movement_date=mdate, warehouse_code=wh, item_code=code,
movement_type=mtype, qty=n, ref_type=ref_type, ref_no=ref_no,
memo=memo, created_by=user["email"],
)
applied += 1
except ValueError as exc:
errors.append(f"{code}: {exc}")
# 세트 — 항상 OUT(BOM 분해)
for it in st.list_items(kind="set"):
code = it["item_code"]
raw = str(form.get(f"qty_{code}") or "").strip()
if raw == "":
continue
try:
n = int(raw)
except ValueError:
errors.append(f"{code}: 숫자 아님")
continue
if n <= 0:
continue
try:
st.create_set_out(
movement_date=mdate, warehouse_code=wh, set_code=code, set_qty=n,
bom_map=bmap, ref_type="set", ref_no=ref_no, memo=memo,
created_by=user["email"],
)
applied += 1
except ValueError as exc:
errors.append(f"{code}: {exc}")
if errors and applied == 0:
raise HTTPException(status_code=400, detail="; ".join(errors[:10]))
return RedirectResponse(url=f"/malaysia/movements?wh={wh}&type={mtype}", 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, bom_map=_bom_map(request))
except KeyError:
return render_template(
request, "denied.html",
{"reason": "재고조사를 찾을 수 없습니다.", "is_admin": is_admin(user)},
status_code=404,
)
head = computed["stocktake"]
wh = head["warehouse_code"]
# 입력된 라인 qty 를 코드별 dict 로 (그리드 기본값)
entered = {ln["sku_code"]: ln["qty"] for ln in head.get("lines", [])}
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"),
"entered": entered,
"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/bulk")
async def stocktake_lines_bulk(
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
) -> RedirectResponse:
"""그리드 일괄 저장. form: qty_<SKUCODE>... (낱개+세트). 빈칸은 스킵, 0 허용."""
st = _store(request)
if st is None:
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
form = await request.form()
codes = [i["item_code"] for i in st.list_items(kind="individual")]
codes += [i["item_code"] for i in st.list_items(kind="set")]
errors: list[str] = []
for code in codes:
raw = str(form.get(f"qty_{code}") or "").strip()
if raw == "":
continue
try:
n = int(raw)
except ValueError:
errors.append(f"{code}: 숫자 아님")
continue
try:
st.upsert_line(stocktake_id=stocktake_id, sku_code=code, qty=n)
except KeyError:
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
except ValueError as exc:
errors.append(f"{code}: {exc}")
if errors:
raise HTTPException(status_code=400, detail="; ".join(errors[:10]))
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, bom_map=_bom_map(request))
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
# ════════════════════════════════════════════════════════════
@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:
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:
"""itemcode_db(set_components)에서 읽은 MY- 세트 BOM (읽기 전용)."""
return JSONResponse({"bom": _bom_map(request)})
@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:
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:
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),
bom_map=_bom_map(request),
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:
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, bom_map=_bom_map(request))
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"}