Files
dbx-main/app/modules/malaysia/itemcode.py
T
king 6d5c3f3181 fix(dispatch): 콤보 BOM 차감 실패 원인별 메시지 분리
기존엔 콤보 BOM 누락 시 (a)set_components 미등록 (b)세트는 등록됐으나
유효 낱개(MT/MX/MZ) 구성품 없음 (c)itemcode_ro 권한/조회 실패 를 한
덩어리로 "콤보 BOM 이 없어 차감할 수 없습니다"로만 알려, 실제 원인을
못 짚어 같은 에러가 반복됐다.

- itemcode.py: set_codes_present() 추가 — set_components 의 MY- 세트
  코드 전체를 구성품 유효성 무관하게 조회. (a)/(b) 구분용.
- router.py stock_deduct: 누락 콤보를 등록여부로 분기, 권한/조회 실패는
  별도 안내. 정확한 원인과 조치를 메시지에 노출.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:08:06 +09:00

164 lines
6.5 KiB
Python

"""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 set_codes_present(self) -> set[str]:
"""set_components 에 등록된 MY- 세트 코드 전체(구성품 유효성 무관).
set_bom_map() 은 낱개(MT/MX/MZ) 구성품이 하나도 없는 세트를 결과에서
떨어뜨린다. 그래서 'BOM 누락'이 두 가지 원인을 가린다:
(a) set_components 에 set_code 자체가 없음 → 세트구성 미등록
(b) set_code 는 있으나 유효 낱개 구성품이 없음 → 구성품 점검 필요
이 메서드로 (a)/(b)를 구분해 정확한 에러 메시지를 만든다.
"""
if not self.enabled or not self._pool:
return set()
try:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT DISTINCT set_code 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 set_code 조회 실패")
return set()
return {str(r["set_code"]).strip().upper() for r in rows}
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 sabangnet_code_map(self) -> dict[str, str]:
"""낱개+세트 item_code → 사방넷 코드(itemcode_db). 엑셀 내보내기용."""
if not self.enabled or not self._pool:
return {}
try:
with self._pool.connection() as conn:
rows = conn.execute(
"SELECT item_code, sabangnet_code FROM single_items "
"UNION ALL SELECT item_code, sabangnet_code 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("sabangnet_code") or "").strip()
for r in rows
}
def close(self) -> None:
if self._pool is not None:
self._pool.close()