From 6b43d9926ddd9fadd38307b4c87717efaf2f4777 Mon Sep 17 00:00:00 2001 From: king Date: Thu, 11 Jun 2026 15:35:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(malaysia):=20=EC=84=B8=ED=8A=B8=20BOM?= =?UTF-8?q?=EC=9D=84=20itemcode=5Fdb=EC=97=90=EC=84=9C=20=EC=9D=BD?= =?UTF-8?q?=EA=B3=A0=20=EC=9E=85=EB=A0=A5=EC=9D=84=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=EC=9D=BC=EA=B4=84=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세트구성(set_bom) 수동 관리 메뉴/라우트/API/템플릿 제거 - 세트 BOM은 itemcode_db set_components(set_code/single_code/quantity)에서 읽음 (MalaysiaItemcodeReader, ITEMCODE_DB_URL 재사용) - db.py: create_set_out/compute_result/finalize_stocktake 에 bom_map 주입식 - 입출고: 낱개 전체 그리드 일괄 등록 + 세트 출고 그리드(BOM 분해) - 일일 재고조사: 낱개+세트 전체 그리드에 수량만 키인 → 일괄 저장 - malaysia_stock_db.set_bom 테이블은 레거시(미사용) Co-Authored-By: Claude Opus 4.8 --- app/main.py | 4 +- app/modules/malaysia/README.md | 7 +- app/modules/malaysia/__init__.py | 8 + app/modules/malaysia/db.py | 91 +---- app/modules/malaysia/itemcode.py | 120 ++++++ app/modules/malaysia/router.py | 356 +++++++----------- .../malaysia/templates/malaysia/_nav.html | 1 - .../templates/malaysia/movements.html | 97 ++--- .../malaysia/templates/malaysia/sets.html | 69 ---- .../templates/malaysia/stocktake_detail.html | 107 +++--- 10 files changed, 395 insertions(+), 465 deletions(-) create mode 100644 app/modules/malaysia/itemcode.py delete mode 100644 app/modules/malaysia/templates/malaysia/sets.html diff --git a/app/main.py b/app/main.py index ce0f45c..6929573 100644 --- a/app/main.py +++ b/app/main.py @@ -17,7 +17,7 @@ from .modules.cupang import build_cupang_store, build_itemcode_reader from .modules.cupang import router as cupang_router from .modules.expense import CategoryStore, build_expense_store from .modules.expense import router as expense_router -from .modules.malaysia import build_malaysia_store +from .modules.malaysia import build_malaysia_itemcode, build_malaysia_store from .modules.malaysia import router as malaysia_router from .modules.vacation import build_vacation_store from .modules.vacation import router as vacation_router @@ -142,6 +142,8 @@ app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or No # 말레이시아 재고관리: MALAYSIA_STOCK_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내). # 상품명은 위 itemcode_reader(읽기 전용) 재사용. app.state.malaysia_store = build_malaysia_store(dsn=env("MALAYSIA_STOCK_DB_URL") or None) +# 세트 BOM 은 itemcode_db(set_components)에서 읽는다(ITEMCODE_DB_URL 재사용). +app.state.malaysia_itemcode = build_malaysia_itemcode() # 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄. app.include_router(expense_router) diff --git a/app/modules/malaysia/README.md b/app/modules/malaysia/README.md index 1d4d094..1478771 100644 --- a/app/modules/malaysia/README.md +++ b/app/modules/malaysia/README.md @@ -6,9 +6,14 @@ cupang 모듈과 동일한 패턴: **malaysia_stock_db(PostgreSQL) 전용**, JSO - 경로(prefix): `/malaysia` - 권한키: `malaysia` (admin 은 항상 통과) -- 환경변수: `MALAYSIA_STOCK_DB_URL` (미설정 시 "설정 필요" 안내) +- 환경변수: `MALAYSIA_STOCK_DB_URL` (미설정 시 "설정 필요" 안내), `ITEMCODE_DB_URL`(세트 BOM/이름) - DB: `malaysia_stock_db` / 역할 `malaysia_app` +> **세트 구성(BOM)은 별도 관리하지 않는다.** itemcode_db `set_components` +> (set_code / single_code / quantity)에서 읽는다. 모듈 안에 세트구성 메뉴 없음. +> `malaysia_stock_db.set_bom` 테이블은 더 이상 쓰지 않는다(레거시, 무시). +> 입출고·재고조사는 전체 아이템(+세트) 그리드에 **수량만 키인**해 일괄 등록한다. + --- ## 재고 원칙 diff --git a/app/modules/malaysia/__init__.py b/app/modules/malaysia/__init__.py index 61172c3..56fca38 100644 --- a/app/modules/malaysia/__init__.py +++ b/app/modules/malaysia/__init__.py @@ -32,6 +32,7 @@ __all__ = [ "explode_stocktake", "missing_bom_sets", "build_malaysia_store", + "build_malaysia_itemcode", ] @@ -45,3 +46,10 @@ def build_malaysia_store(*, dsn: str | None) -> Any: from .db import MalaysiaStockStore # 지연 import (개발 환경 deps 없을 수 있음) return MalaysiaStockStore(dsn) + + +def build_malaysia_itemcode() -> Any: + """itemcode_db 읽기 전용 세트 BOM 리더. ITEMCODE_DB_URL 없으면 비활성.""" + from .itemcode import MalaysiaItemcodeReader # 지연 import + + return MalaysiaItemcodeReader() diff --git a/app/modules/malaysia/db.py b/app/modules/malaysia/db.py index dec1bff..5b162eb 100644 --- a/app/modules/malaysia/db.py +++ b/app/modules/malaysia/db.py @@ -161,71 +161,11 @@ class MalaysiaStockStore: return updated # ════════════════════════════════════════════════════════════ - # 세트 구성표 (set_bom) + # 세트 구성표 (BOM) + # 별도 테이블/메뉴를 두지 않고 itemcode_db(set_components)에서 읽는다. + # 라우터가 MalaysiaItemcodeReader.set_bom_map() 결과를 아래 메서드들에 + # bom_map 인자로 주입한다(cross-DB 조인 회피, store 는 DB I/O 만 담당). # ════════════════════════════════════════════════════════════ - def list_bom(self, *, include_inactive: bool = False) -> list[dict[str, Any]]: - where = "" if include_inactive else "WHERE active = TRUE" - with self._pool.connection() as conn: - rows = conn.execute( - f"SELECT * FROM set_bom {where} " - "ORDER BY set_code ASC, component_code ASC" - ).fetchall() - return [self._serialize(r) for r in rows] - - def list_bom_for_set(self, *, set_code: str) -> list[dict[str, Any]]: - with self._pool.connection() as conn: - rows = conn.execute( - "SELECT * FROM set_bom WHERE set_code = %s AND active = TRUE " - "ORDER BY component_code ASC", - (store._norm(set_code),), - ).fetchall() - return [self._serialize(r) for r in rows] - - def bom_map(self) -> dict[str, list[dict[str, Any]]]: - """{ set_code: [{component_code, component_qty}, ...] } — active 만. - - store.explode_stocktake / explode_set 에 그대로 전달. - """ - out: dict[str, list[dict[str, Any]]] = {} - with self._pool.connection() as conn: - rows = conn.execute( - "SELECT set_code, component_code, component_qty " - "FROM set_bom WHERE active = TRUE" - ).fetchall() - for r in rows: - out.setdefault(r["set_code"], []).append( - { - "component_code": r["component_code"], - "component_qty": int(r["component_qty"]), - } - ) - return out - - def upsert_bom_line( - self, *, set_code: str, component_code: str, component_qty: int, active: bool = True - ) -> dict[str, Any]: - sc = store.validate_bom_set_code(set_code) - cc = store.validate_bom_component_code(component_code) - qty = store.validate_qty(component_qty, allow_zero=False) - with self._pool.connection() as conn: - row = conn.execute( - """ - INSERT INTO set_bom (set_code, component_code, component_qty, active) - VALUES (%s, %s, %s, %s) - ON CONFLICT (set_code, component_code) DO UPDATE - SET component_qty = EXCLUDED.component_qty, - active = EXCLUDED.active - RETURNING * - """, - (sc, cc, qty, bool(active)), - ).fetchone() - return self._serialize(row) - - def delete_bom_line(self, *, bom_id: int) -> None: - with self._pool.connection() as conn: - cur = conn.execute("DELETE FROM set_bom WHERE id = %s", (bom_id,)) - if cur.rowcount == 0: - raise KeyError(bom_id) # ════════════════════════════════════════════════════════════ # 재고 이동 (stock_movement) @@ -284,19 +224,20 @@ class MalaysiaStockStore: warehouse_code: str, set_code: str, set_qty: int, + bom_map: dict[str, list[dict[str, Any]]], ref_type: str = "", ref_no: str = "", memo: str = "", created_by: str = "", ) -> list[dict[str, Any]]: - """세트 주문 출고 → set_bom 으로 분해해 구성품별 OUT movement 생성. + """세트 주문 출고 → BOM(itemcode_db)으로 분해해 구성품별 OUT movement 생성. 예: MY-0002 1세트 출고 → MT-0320, MT-0550 ... 각각 OUT. - BOM 이 없으면 ValueError(요구사항 8). + bom_map 은 라우터가 itemcode 리더에서 받아 주입. 구성 없으면 ValueError. """ sc = store.validate_bom_set_code(set_code) q = store.validate_qty(set_qty, allow_zero=False) - exploded = store.explode_set(sc, q, self.bom_map()) + exploded = store.explode_set(sc, q, bom_map) if not exploded: raise ValueError(f"세트 {sc} 의 BOM 구성이 없습니다. 세트구성을 먼저 등록하세요.") ref_t = (ref_type or "set").strip() or "set" @@ -567,9 +508,12 @@ class MalaysiaStockStore: raise ValueError("확정된 조사는 취소할 수 없거나, 대상이 없습니다.") return self._serialize(row) - def compute_result(self, *, stocktake_id: int) -> dict[str, Any]: + def compute_result( + self, *, stocktake_id: int, bom_map: dict[str, list[dict[str, Any]]] + ) -> dict[str, Any]: """재고조사 최종 계산 결과(낱개 아이템 기준). + bom_map 은 라우터가 itemcode 리더에서 받아 주입. 반환: { "stocktake": {...헤더...}, @@ -581,9 +525,8 @@ class MalaysiaStockStore: if not head: raise KeyError(stocktake_id) lines = head.get("lines", []) - bmap = self.bom_map() - exploded = store.explode_stocktake(lines, bmap) - missing = store.missing_bom_sets(lines, bmap) + exploded = store.explode_stocktake(lines, bom_map) + missing = store.missing_bom_sets(lines, bom_map) names = self.item_name_map() rows = [] for code in sorted(exploded.keys()): @@ -602,7 +545,9 @@ class MalaysiaStockStore: ) return {"stocktake": head, "rows": rows, "missing_bom": missing} - def finalize_stocktake(self, *, stocktake_id: int) -> dict[str, Any]: + def finalize_stocktake( + self, *, stocktake_id: int, bom_map: dict[str, list[dict[str, Any]]] + ) -> dict[str, Any]: """재고조사 확정 — 최종 낱개 total_qty 와 현재 시스템 재고의 '차이'만 STOCKTAKE movement 로 생성한다(요구사항 7). @@ -611,7 +556,7 @@ class MalaysiaStockStore: - BOM 없는 MY- 세트가 있으면 확정 거부(요구사항 8). 반환: {"stocktake": {...}, "adjustments": [{item_code, before, after, diff}], "rows": [...]} """ - computed = self.compute_result(stocktake_id=stocktake_id) + computed = self.compute_result(stocktake_id=stocktake_id, bom_map=bom_map) head = computed["stocktake"] if head["status"] != "draft": raise ValueError("이미 확정/취소된 재고조사입니다.") diff --git a/app/modules/malaysia/itemcode.py b/app/modules/malaysia/itemcode.py new file mode 100644 index 0000000..0676c97 --- /dev/null +++ b/app/modules/malaysia/itemcode.py @@ -0,0 +1,120 @@ +"""itemcode_db 읽기 전용 — 세트 BOM(set_components) 조회. + +말레이시아 재고관리는 세트 구성(어느 세트에 어떤 낱개가 몇 개)을 별도로 +관리하지 않고 itemcode_db 에서 직접 읽는다. + +itemcode_db 실제 스키마(운영 확인됨): + set_items(item_code, sabangnet_code, name) -- 세트 마스터 + single_items(item_code, sabangnet_code, name) -- 낱개 마스터 + set_components(set_code, single_code, quantity) -- 세트 ↔ 낱개 구성(BOM) + FK: set_code → set_items, single_code → single_items + +환경변수 `ITEMCODE_DB_URL`(읽기 전용 역할 itemcode_ro). cupang 모듈과 공유. +미설정/실패 시 enabled=False, 빈 결과(앱/모듈을 죽이지 않음). +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +logger = logging.getLogger("malaysia.itemcode") + +# BOM 구성품으로 인정할 prefix (낱개). MD- 뚜껑은 제외. +_COMPONENT_PREFIXES = ("MT-", "MX-", "MZ-") + + +class MalaysiaItemcodeReader: + """itemcode_db 읽기 전용 풀 + 세트 BOM 조회. + + 설정이 없으면 enabled=False, 메서드는 빈 결과를 돌려준다. + """ + + def __init__(self) -> None: + self._pool: Any = None + self.enabled = False + self.reason = "" + self.last_error = "" + self._configure() + + def _configure(self) -> None: + dsn = os.getenv("ITEMCODE_DB_URL", "").strip() + if not dsn: + self.reason = "ITEMCODE_DB_URL 미설정 — 세트 BOM 조회 비활성." + return + try: + from psycopg.rows import dict_row + from psycopg_pool import ConnectionPool + + self._pool = ConnectionPool( + conninfo=dsn, + min_size=1, + max_size=3, + kwargs={"row_factory": dict_row, "autocommit": True}, + open=False, + ) + self._pool.open(wait=False) + self.enabled = True + self.reason = "" + except Exception as exc: # noqa: BLE001 — 설정/드라이버 문제로 모듈을 죽이지 않음 + self.reason = f"itemcode_db 연결 풀 생성 실패: {type(exc).__name__}" + + def set_bom_map(self) -> dict[str, list[dict[str, Any]]]: + """MY- 세트의 BOM 전체. + + 반환: { set_code: [{"component_code","component_qty"}, ...] } + store.explode_stocktake / explode_set 에 그대로 전달 가능. + 구성품은 낱개(MT/MX/MZ)만 포함(혹시 모를 비낱개/뚜껑은 제외). + """ + if not self.enabled or not self._pool: + return {} + try: + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT set_code, single_code, quantity " + "FROM set_components WHERE set_code LIKE 'MY-%'" + ).fetchall() + self.last_error = "" + except Exception as exc: # noqa: BLE001 — 모듈을 죽이지 않음 + self.last_error = f"{type(exc).__name__}: {exc}" + logger.exception("itemcode set_components 조회 실패") + return {} + out: dict[str, list[dict[str, Any]]] = {} + for r in rows: + comp = str(r.get("single_code") or "").strip().upper() + if not comp.startswith(_COMPONENT_PREFIXES): + continue + try: + qty = int(r.get("quantity") or 0) + except (TypeError, ValueError): + qty = 0 + if qty <= 0: + continue + out.setdefault(str(r["set_code"]).strip().upper(), []).append( + {"component_code": comp, "component_qty": qty} + ) + return out + + def name_map(self) -> dict[str, str]: + """낱개+세트 코드 → 이름(itemcode_db 기준). 표시 보강용(선택).""" + if not self.enabled or not self._pool: + return {} + try: + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT item_code, name FROM single_items " + "UNION ALL SELECT item_code, name FROM set_items" + ).fetchall() + self.last_error = "" + except Exception as exc: # noqa: BLE001 + self.last_error = f"{type(exc).__name__}: {exc}" + return {} + return { + str(r["item_code"]).strip().upper(): str(r.get("name") or "").strip() + for r in rows + } + + def close(self) -> None: + if self._pool is not None: + self._pool.close() diff --git a/app/modules/malaysia/router.py b/app/modules/malaysia/router.py index 895cb0c..e4cb38b 100644 --- a/app/modules/malaysia/router.py +++ b/app/modules/malaysia/router.py @@ -4,9 +4,9 @@ - 권한: 로그인 + `malaysia` 모듈 권한 (관리자는 항상 통과). 서버 측 검사. - 데이터: MalaysiaStockStore (malaysia_stock_db / PostgreSQL) 전용. MALAYSIA_STOCK_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내. -- 상품명: 전역 itemcode_reader(itemcode_db 읽기 전용). 미설정 시 등록 스냅샷 사용. +- 세트 BOM: itemcode_db(set_components) 읽기 전용(MalaysiaItemcodeReader). 별도 관리 메뉴 없음. -화면(HTML) + JSON API 를 함께 제공한다. JSON API 는 /malaysia/api/* 아래. +입력 방식: 입출고/재고조사 모두 전체 아이템(+세트) 그리드에 수량만 키인 → 일괄 등록. """ from __future__ import annotations @@ -30,8 +30,14 @@ 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 _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 {} def _require_user(request: Request) -> dict[str, Any]: @@ -90,8 +96,7 @@ def _guard(request: Request) -> tuple[Any, dict[str, Any]] | HTMLResponse | Redi 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] + 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" @@ -134,7 +139,7 @@ async def index(request: Request) -> HTMLResponse: # ════════════════════════════════════════════════════════════ -# 입고 / 출고 등록 +# 입고 / 출고 등록 (그리드 일괄) # ════════════════════════════════════════════════════════════ @router.get("/movements", response_class=HTMLResponse) async def movements_page(request: Request) -> HTMLResponse: @@ -150,7 +155,7 @@ async def movements_page(request: Request) -> HTMLResponse: ctx.update( { "page_title": "말레이시아 재고관리 — 입출고", - "page_subtitle": f"{wh} · 입고/출고 등록", + "page_subtitle": f"{wh} · 입고/출고/조정 일괄 등록", "individual_items": st.list_items(kind="individual"), "set_items": st.list_items(kind="set"), "movements": st.list_movements( @@ -165,138 +170,94 @@ async def movements_page(request: Request) -> HTMLResponse: 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: +@router.post("/movements/bulk") +async def movements_bulk(request: Request, user: dict[str, Any] = Depends(_require_user)) -> RedirectResponse: + """낱개 아이템 그리드 일괄 등록. form: movement_type, movement_date, + warehouse_code, ref_type, ref_no, memo, qty_... + + IN/OUT 은 양수만 등록(0/빈칸 스킵), ADJUST 는 0 아닌 값만 등록(±). + """ 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) + 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() + + created = 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"], + ) + created += 1 + except ValueError as exc: + errors.append(f"{code}: {exc}") + if errors and created == 0: + raise HTTPException(status_code=400, detail="; ".join(errors[:10])) + return RedirectResponse(url=f"/malaysia/movements?wh={wh}&type={mtype}", 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 생성.""" +@router.post("/movements/set-out-bulk") +async def set_out_bulk(request: Request, user: dict[str, Any] = Depends(_require_user)) -> RedirectResponse: + """세트 그리드 일괄 출고. form: movement_date, warehouse_code, ref_no, memo, + qty_... → 각 세트를 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) + form = await request.form() + wh = str(form.get("warehouse_code") or "").strip() + mdate = str(form.get("movement_date") or "").strip() + ref_no = str(form.get("ref_no") or "").strip() + memo = str(form.get("memo") or "").strip() + bmap = _bom_map(request) - -# ════════════════════════════════════════════════════════════ -# 세트 구성 관리 (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) + done = 0 + errors: list[str] = [] + 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: + 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"], + ) + done += 1 + except ValueError as exc: + errors.append(f"{code}: {exc}") + if errors and done == 0: + raise HTTPException(status_code=400, detail="; ".join(errors[:10])) + return RedirectResponse(url=f"/malaysia/movements?wh={wh}&type=OUT", status_code=303) # ════════════════════════════════════════════════════════════ @@ -336,10 +297,8 @@ async def stocktake_create( 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"], + 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)) @@ -356,7 +315,7 @@ async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse: return guard st, user = guard try: - computed = st.compute_result(stocktake_id=stocktake_id) + computed = st.compute_result(stocktake_id=stocktake_id, bom_map=_bom_map(request)) except KeyError: return render_template( request, "denied.html", @@ -365,6 +324,8 @@ async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse: ) 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( { @@ -373,6 +334,7 @@ async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse: "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", @@ -381,32 +343,41 @@ async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse: 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), +@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_... (낱개+세트). 빈칸은 스킵, 0 허용.""" 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)) + 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, + request: Request, stocktake_id: int, line_id: int, user: dict[str, Any] = Depends(_require_user), ) -> RedirectResponse: st = _store(request) @@ -423,15 +394,13 @@ async def stocktake_line_delete( @router.post("/stocktakes/{stocktake_id:int}/finalize") async def stocktake_finalize( - request: Request, - stocktake_id: int, - user: dict[str, Any] = Depends(_require_user), + 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) + st.finalize_stocktake(stocktake_id=stocktake_id, bom_map=_bom_map(request)) except KeyError: raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.") except ValueError as exc: @@ -441,9 +410,7 @@ async def stocktake_finalize( @router.post("/stocktakes/{stocktake_id:int}/cancel") async def stocktake_cancel( - request: Request, - stocktake_id: int, - user: dict[str, Any] = Depends(_require_user), + request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user) ) -> RedirectResponse: st = _store(request) if st is None: @@ -456,12 +423,10 @@ async def stocktake_cancel( # ════════════════════════════════════════════════════════════ -# JSON API (요구사항 6) — /malaysia/api/* +# JSON API # ════════════════════════════════════════════════════════════ @router.get("/api/warehouses") -async def api_warehouses( - request: Request, _: dict[str, Any] = Depends(_require_user) -) -> JSONResponse: +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 미설정") @@ -469,10 +434,7 @@ async def api_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, 미지정 → 전체.""" +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 미설정") @@ -481,39 +443,13 @@ async def api_items( @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}) +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: +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 미설정") @@ -523,11 +459,8 @@ async def api_stock( @router.post("/api/movements") async def api_movement_create( - request: Request, - payload: dict[str, Any] = Body(...), - user: dict[str, Any] = Depends(_require_user), + 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 미설정") @@ -550,11 +483,8 @@ async def api_movement_create( @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), + 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 미설정") @@ -564,6 +494,7 @@ async def api_set_out( 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 ""), @@ -576,24 +507,17 @@ async def api_set_out( @router.get("/api/stocktakes/{stocktake_id:int}/result") async def api_stocktake_result( - request: Request, - stocktake_id: int, - _: dict[str, Any] = Depends(_require_user), + 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) + 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"], - } + {"stocktake": computed["stocktake"], "rows": computed["rows"], "missing_bom": computed["missing_bom"]} ) diff --git a/app/modules/malaysia/templates/malaysia/_nav.html b/app/modules/malaysia/templates/malaysia/_nav.html index e083a3a..6e5cf66 100644 --- a/app/modules/malaysia/templates/malaysia/_nav.html +++ b/app/modules/malaysia/templates/malaysia/_nav.html @@ -3,7 +3,6 @@ 재고 현황 입출고 일일 재고조사 - 세트 구성 diff --git a/app/modules/malaysia/templates/malaysia/movements.html b/app/modules/malaysia/templates/malaysia/movements.html index 4aed52f..2d0cfff 100644 --- a/app/modules/malaysia/templates/malaysia/movements.html +++ b/app/modules/malaysia/templates/malaysia/movements.html @@ -5,65 +5,76 @@ {% set active_tab = 'move' %} {% include "malaysia/_nav.html" %} -
- - -
-

입고 / 출고 등록 (낱개)

- 허용: MT-/MX-/MZ- · 차단: MY-/MD- -
- + +
+

입고 / 출고 / 조정 — 낱개 일괄

+ 전체 낱개 아이템에 수량만 입력. 빈칸/0은 제외. IN/OUT 양수, ADJUST는 ±. (세트는 아래에서 출고) + + +
- - + - + -
- -
+
+
+ + + + {% for it in individual_items %} + + + + + + {% endfor %} + +
Item CodeProduct NameQty
{{ it.item_code }}{{ it.item_name }}
+
+
+ +
- -
-

세트 출고 (BOM 분해)

- MY- 세트 1건 → set_bom 기준으로 구성품별 OUT 자동 생성 -
- + +
+

세트 출고 — BOM 분해 (itemcode_db)

+ 세트 수량 입력 시 set_components 기준으로 구성품별 OUT 자동 생성. 빈칸/0 제외. + + +
- - -
+
+
+ + + + {% for it in set_items %} + + + + + + {% endfor %} + +
Set CodeSet NameSet Qty (OUT)
{{ it.item_code }}{{ it.item_name }}
+
+
+
diff --git a/app/modules/malaysia/templates/malaysia/sets.html b/app/modules/malaysia/templates/malaysia/sets.html deleted file mode 100644 index b54d942..0000000 --- a/app/modules/malaysia/templates/malaysia/sets.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "erp_base.html" %} - -{% block content %} -
- {% set active_tab = 'sets' %} - {% include "malaysia/_nav.html" %} - -
- - -
-

세트 구성 추가/수정

- 같은 세트+구성품은 덮어씁니다. -
- - - -
-
-
- - -
-

세트 구성표 (set_bom)

- {% for it in set_items %} -
-

{{ it.item_code }} · {{ it.item_name }}

-
- - - - {% for b in by_set.get(it.item_code, []) %} - - - - - - - - {% endfor %} - {% if not by_set.get(it.item_code) %} - - {% endif %} - -
ComponentNameQtyActive
{{ b.component_code }}{{ names.get(b.component_code, '—') }}{{ b.component_qty }}{% if b.active %}활성{% else %}비활성{% endif %} -
- -
-
구성품이 없습니다. (재고조사에 입력 시 경고)
-
-
- {% endfor %} -
-
-
-{% endblock %} diff --git a/app/modules/malaysia/templates/malaysia/stocktake_detail.html b/app/modules/malaysia/templates/malaysia/stocktake_detail.html index d920827..1cf69af 100644 --- a/app/modules/malaysia/templates/malaysia/stocktake_detail.html +++ b/app/modules/malaysia/templates/malaysia/stocktake_detail.html @@ -15,73 +15,58 @@ {% else %}작성중{% endif %}
- {% if missing_bom %} -

⚠ BOM 구성이 없는 세트: {{ missing_bom|join(', ') }} — 세트 구성 등록 전에는 확정할 수 없습니다.

+

⚠ BOM 구성이 없는 세트: {{ missing_bom|join(', ') }} — itemcode_db set_components 확인 필요. 확정 불가.

{% endif %} {% if is_draft %} -
- -
-

① 낱개 아이템 입력

- 세트 안에 든 건 빼고, 낱개로 보관 중인 수량만 입력 -
- - - -
+ +
+
+
+

① 낱개 아이템

+ 세트 안에 든 건 빼고, 낱개 보관분만. 빈칸=미조사, 0 입력 가능. +
+ + + + {% for it in individual_items %} + + + + + + {% endfor %} + +
CodeNameQty
{{ it.item_code }}{{ it.item_name }}
+
+
+
+

② 세트 아이템

+ 미리 포장된 세트 재고. BOM(itemcode_db)으로 낱개 분해됨. +
+ + + + {% for it in set_items %} + + + + + + {% endfor %} + +
Set CodeSet NameQty
{{ it.item_code }}{{ it.item_name }}
+
+
- - -
-

② 세트 아이템 입력

- 미리 포장된 세트 재고 수량 (BOM 으로 낱개 분해됨) - - - - - -
-
+
+ {% endif %} - -
-

입력 라인 ({{ stocktake.lines|length }})

-
- - {% if is_draft %}{% endif %} - - {% for ln in stocktake.lines %} - - - - - {% if is_draft %} - - {% endif %} - - {% endfor %} - {% if not stocktake.lines %} - - {% endif %} - -
SKUQtyMemo
{{ ln.sku_code }}{{ ln.qty }}{{ ln.memo or '—' }}
입력된 라인이 없습니다.
-
-
-

최종 계산 (낱개 기준)

@@ -100,7 +85,7 @@ {% endfor %} {% if not result_rows %} - 계산 결과가 없습니다. + 입력된 수량이 없습니다. {% endif %} @@ -110,7 +95,7 @@ {% if is_draft %}
+ onsubmit="return confirm('확정하면 시스템 재고와의 차이가 STOCKTAKE 이동으로 기록되고, 이후 수정이 불가합니다. 계속?');">