feat(malaysia): 세트 BOM을 itemcode_db에서 읽고 입력을 그리드 일괄로 전환

- 세트구성(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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 15:35:42 +09:00
parent f137f2a6a6
commit 6b43d9926d
10 changed files with 395 additions and 465 deletions
+18 -73
View File
@@ -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("이미 확정/취소된 재고조사입니다.")