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:
+3
-1
@@ -17,7 +17,7 @@ from .modules.cupang import build_cupang_store, build_itemcode_reader
|
|||||||
from .modules.cupang import router as cupang_router
|
from .modules.cupang import router as cupang_router
|
||||||
from .modules.expense import CategoryStore, build_expense_store
|
from .modules.expense import CategoryStore, build_expense_store
|
||||||
from .modules.expense import router as expense_router
|
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.malaysia import router as malaysia_router
|
||||||
from .modules.vacation import build_vacation_store
|
from .modules.vacation import build_vacation_store
|
||||||
from .modules.vacation import router as vacation_router
|
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(라우터가 "설정 필요" 안내).
|
# 말레이시아 재고관리: MALAYSIA_STOCK_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
# 상품명은 위 itemcode_reader(읽기 전용) 재사용.
|
# 상품명은 위 itemcode_reader(읽기 전용) 재사용.
|
||||||
app.state.malaysia_store = build_malaysia_store(dsn=env("MALAYSIA_STOCK_DB_URL") or None)
|
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)
|
app.include_router(expense_router)
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ cupang 모듈과 동일한 패턴: **malaysia_stock_db(PostgreSQL) 전용**, JSO
|
|||||||
|
|
||||||
- 경로(prefix): `/malaysia`
|
- 경로(prefix): `/malaysia`
|
||||||
- 권한키: `malaysia` (admin 은 항상 통과)
|
- 권한키: `malaysia` (admin 은 항상 통과)
|
||||||
- 환경변수: `MALAYSIA_STOCK_DB_URL` (미설정 시 "설정 필요" 안내)
|
- 환경변수: `MALAYSIA_STOCK_DB_URL` (미설정 시 "설정 필요" 안내), `ITEMCODE_DB_URL`(세트 BOM/이름)
|
||||||
- DB: `malaysia_stock_db` / 역할 `malaysia_app`
|
- DB: `malaysia_stock_db` / 역할 `malaysia_app`
|
||||||
|
|
||||||
|
> **세트 구성(BOM)은 별도 관리하지 않는다.** itemcode_db `set_components`
|
||||||
|
> (set_code / single_code / quantity)에서 읽는다. 모듈 안에 세트구성 메뉴 없음.
|
||||||
|
> `malaysia_stock_db.set_bom` 테이블은 더 이상 쓰지 않는다(레거시, 무시).
|
||||||
|
> 입출고·재고조사는 전체 아이템(+세트) 그리드에 **수량만 키인**해 일괄 등록한다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 재고 원칙
|
## 재고 원칙
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ __all__ = [
|
|||||||
"explode_stocktake",
|
"explode_stocktake",
|
||||||
"missing_bom_sets",
|
"missing_bom_sets",
|
||||||
"build_malaysia_store",
|
"build_malaysia_store",
|
||||||
|
"build_malaysia_itemcode",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -45,3 +46,10 @@ def build_malaysia_store(*, dsn: str | None) -> Any:
|
|||||||
from .db import MalaysiaStockStore # 지연 import (개발 환경 deps 없을 수 있음)
|
from .db import MalaysiaStockStore # 지연 import (개발 환경 deps 없을 수 있음)
|
||||||
|
|
||||||
return MalaysiaStockStore(dsn)
|
return MalaysiaStockStore(dsn)
|
||||||
|
|
||||||
|
|
||||||
|
def build_malaysia_itemcode() -> Any:
|
||||||
|
"""itemcode_db 읽기 전용 세트 BOM 리더. ITEMCODE_DB_URL 없으면 비활성."""
|
||||||
|
from .itemcode import MalaysiaItemcodeReader # 지연 import
|
||||||
|
|
||||||
|
return MalaysiaItemcodeReader()
|
||||||
|
|||||||
+18
-73
@@ -161,71 +161,11 @@ class MalaysiaStockStore:
|
|||||||
return updated
|
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)
|
# 재고 이동 (stock_movement)
|
||||||
@@ -284,19 +224,20 @@ class MalaysiaStockStore:
|
|||||||
warehouse_code: str,
|
warehouse_code: str,
|
||||||
set_code: str,
|
set_code: str,
|
||||||
set_qty: int,
|
set_qty: int,
|
||||||
|
bom_map: dict[str, list[dict[str, Any]]],
|
||||||
ref_type: str = "",
|
ref_type: str = "",
|
||||||
ref_no: str = "",
|
ref_no: str = "",
|
||||||
memo: str = "",
|
memo: str = "",
|
||||||
created_by: str = "",
|
created_by: str = "",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""세트 주문 출고 → set_bom 으로 분해해 구성품별 OUT movement 생성.
|
"""세트 주문 출고 → BOM(itemcode_db)으로 분해해 구성품별 OUT movement 생성.
|
||||||
|
|
||||||
예: MY-0002 1세트 출고 → MT-0320, MT-0550 ... 각각 OUT.
|
예: MY-0002 1세트 출고 → MT-0320, MT-0550 ... 각각 OUT.
|
||||||
BOM 이 없으면 ValueError(요구사항 8).
|
bom_map 은 라우터가 itemcode 리더에서 받아 주입. 구성 없으면 ValueError.
|
||||||
"""
|
"""
|
||||||
sc = store.validate_bom_set_code(set_code)
|
sc = store.validate_bom_set_code(set_code)
|
||||||
q = store.validate_qty(set_qty, allow_zero=False)
|
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:
|
if not exploded:
|
||||||
raise ValueError(f"세트 {sc} 의 BOM 구성이 없습니다. 세트구성을 먼저 등록하세요.")
|
raise ValueError(f"세트 {sc} 의 BOM 구성이 없습니다. 세트구성을 먼저 등록하세요.")
|
||||||
ref_t = (ref_type or "set").strip() or "set"
|
ref_t = (ref_type or "set").strip() or "set"
|
||||||
@@ -567,9 +508,12 @@ class MalaysiaStockStore:
|
|||||||
raise ValueError("확정된 조사는 취소할 수 없거나, 대상이 없습니다.")
|
raise ValueError("확정된 조사는 취소할 수 없거나, 대상이 없습니다.")
|
||||||
return self._serialize(row)
|
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": {...헤더...},
|
"stocktake": {...헤더...},
|
||||||
@@ -581,9 +525,8 @@ class MalaysiaStockStore:
|
|||||||
if not head:
|
if not head:
|
||||||
raise KeyError(stocktake_id)
|
raise KeyError(stocktake_id)
|
||||||
lines = head.get("lines", [])
|
lines = head.get("lines", [])
|
||||||
bmap = self.bom_map()
|
exploded = store.explode_stocktake(lines, bom_map)
|
||||||
exploded = store.explode_stocktake(lines, bmap)
|
missing = store.missing_bom_sets(lines, bom_map)
|
||||||
missing = store.missing_bom_sets(lines, bmap)
|
|
||||||
names = self.item_name_map()
|
names = self.item_name_map()
|
||||||
rows = []
|
rows = []
|
||||||
for code in sorted(exploded.keys()):
|
for code in sorted(exploded.keys()):
|
||||||
@@ -602,7 +545,9 @@ class MalaysiaStockStore:
|
|||||||
)
|
)
|
||||||
return {"stocktake": head, "rows": rows, "missing_bom": missing}
|
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 와 현재 시스템 재고의 '차이'만
|
"""재고조사 확정 — 최종 낱개 total_qty 와 현재 시스템 재고의 '차이'만
|
||||||
STOCKTAKE movement 로 생성한다(요구사항 7).
|
STOCKTAKE movement 로 생성한다(요구사항 7).
|
||||||
|
|
||||||
@@ -611,7 +556,7 @@ class MalaysiaStockStore:
|
|||||||
- BOM 없는 MY- 세트가 있으면 확정 거부(요구사항 8).
|
- BOM 없는 MY- 세트가 있으면 확정 거부(요구사항 8).
|
||||||
반환: {"stocktake": {...}, "adjustments": [{item_code, before, after, diff}], "rows": [...]}
|
반환: {"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"]
|
head = computed["stocktake"]
|
||||||
if head["status"] != "draft":
|
if head["status"] != "draft":
|
||||||
raise ValueError("이미 확정/취소된 재고조사입니다.")
|
raise ValueError("이미 확정/취소된 재고조사입니다.")
|
||||||
|
|||||||
@@ -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()
|
||||||
+140
-216
@@ -4,9 +4,9 @@
|
|||||||
- 권한: 로그인 + `malaysia` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
- 권한: 로그인 + `malaysia` 모듈 권한 (관리자는 항상 통과). 서버 측 검사.
|
||||||
- 데이터: MalaysiaStockStore (malaysia_stock_db / PostgreSQL) 전용.
|
- 데이터: MalaysiaStockStore (malaysia_stock_db / PostgreSQL) 전용.
|
||||||
MALAYSIA_STOCK_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내.
|
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
|
from __future__ import annotations
|
||||||
@@ -30,8 +30,14 @@ def _store(request: Request) -> Any:
|
|||||||
return getattr(request.app.state, "malaysia_store", None)
|
return getattr(request.app.state, "malaysia_store", None)
|
||||||
|
|
||||||
|
|
||||||
def _itemcode(request: Request) -> Any:
|
def _reader(request: Request) -> Any:
|
||||||
return getattr(request.app.state, "itemcode_reader", None)
|
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]:
|
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:
|
def _selected_wh(request: Request, st: Any) -> str:
|
||||||
"""쿼리 ?wh= 또는 기본(첫 활성 창고)."""
|
"""쿼리 ?wh= 또는 기본(첫 활성 창고)."""
|
||||||
wh = (request.query_params.get("wh") or "").strip()
|
wh = (request.query_params.get("wh") or "").strip()
|
||||||
warehouses = st.list_warehouses()
|
codes = [w["warehouse_code"] for w in st.list_warehouses()]
|
||||||
codes = [w["warehouse_code"] for w in warehouses]
|
|
||||||
if wh in codes:
|
if wh in codes:
|
||||||
return wh
|
return wh
|
||||||
return codes[0] if codes else "MY-WH-01"
|
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)
|
@router.get("/movements", response_class=HTMLResponse)
|
||||||
async def movements_page(request: Request) -> HTMLResponse:
|
async def movements_page(request: Request) -> HTMLResponse:
|
||||||
@@ -150,7 +155,7 @@ async def movements_page(request: Request) -> HTMLResponse:
|
|||||||
ctx.update(
|
ctx.update(
|
||||||
{
|
{
|
||||||
"page_title": "말레이시아 재고관리 — 입출고",
|
"page_title": "말레이시아 재고관리 — 입출고",
|
||||||
"page_subtitle": f"{wh} · 입고/출고 등록",
|
"page_subtitle": f"{wh} · 입고/출고/조정 일괄 등록",
|
||||||
"individual_items": st.list_items(kind="individual"),
|
"individual_items": st.list_items(kind="individual"),
|
||||||
"set_items": st.list_items(kind="set"),
|
"set_items": st.list_items(kind="set"),
|
||||||
"movements": st.list_movements(
|
"movements": st.list_movements(
|
||||||
@@ -165,138 +170,94 @@ async def movements_page(request: Request) -> HTMLResponse:
|
|||||||
return render_template(request, "malaysia/movements.html", ctx)
|
return render_template(request, "malaysia/movements.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/movements")
|
@router.post("/movements/bulk")
|
||||||
async def movement_create(
|
async def movements_bulk(request: Request, user: dict[str, Any] = Depends(_require_user)) -> RedirectResponse:
|
||||||
request: Request,
|
"""낱개 아이템 그리드 일괄 등록. form: movement_type, movement_date,
|
||||||
movement_date: str = Form(...),
|
warehouse_code, ref_type, ref_no, memo, qty_<ITEMCODE>...
|
||||||
warehouse_code: str = Form(...),
|
|
||||||
item_code: str = Form(...),
|
IN/OUT 은 양수만 등록(0/빈칸 스킵), ADJUST 는 0 아닌 값만 등록(±).
|
||||||
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)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
try:
|
form = await request.form()
|
||||||
st.create_movement(
|
mtype = str(form.get("movement_type") or "").strip().upper()
|
||||||
movement_date=movement_date,
|
if mtype not in ("IN", "OUT", "ADJUST"):
|
||||||
warehouse_code=warehouse_code,
|
raise HTTPException(status_code=400, detail="이동 종류 오류")
|
||||||
item_code=item_code,
|
wh = str(form.get("warehouse_code") or "").strip()
|
||||||
movement_type=movement_type,
|
mdate = str(form.get("movement_date") or "").strip()
|
||||||
qty=qty,
|
ref_type = str(form.get("ref_type") or "").strip()
|
||||||
ref_type=ref_type,
|
ref_no = str(form.get("ref_no") or "").strip()
|
||||||
ref_no=ref_no,
|
memo = str(form.get("memo") or "").strip()
|
||||||
memo=memo,
|
|
||||||
created_by=user["email"],
|
created = 0
|
||||||
)
|
errors: list[str] = []
|
||||||
except ValueError as exc:
|
for it in st.list_items(kind="individual"):
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
code = it["item_code"]
|
||||||
return RedirectResponse(url=f"/malaysia/movements?wh={warehouse_code}", status_code=303)
|
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")
|
@router.post("/movements/set-out-bulk")
|
||||||
async def movement_set_out(
|
async def set_out_bulk(request: Request, user: dict[str, Any] = Depends(_require_user)) -> RedirectResponse:
|
||||||
request: Request,
|
"""세트 그리드 일괄 출고. form: movement_date, warehouse_code, ref_no, memo,
|
||||||
movement_date: str = Form(...),
|
qty_<SETCODE>... → 각 세트를 BOM 분해해 구성품별 OUT 생성.
|
||||||
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)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
try:
|
form = await request.form()
|
||||||
st.create_set_out(
|
wh = str(form.get("warehouse_code") or "").strip()
|
||||||
movement_date=movement_date,
|
mdate = str(form.get("movement_date") or "").strip()
|
||||||
warehouse_code=warehouse_code,
|
ref_no = str(form.get("ref_no") or "").strip()
|
||||||
set_code=set_code,
|
memo = str(form.get("memo") or "").strip()
|
||||||
set_qty=set_qty,
|
bmap = _bom_map(request)
|
||||||
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)
|
|
||||||
|
|
||||||
|
done = 0
|
||||||
# ════════════════════════════════════════════════════════════
|
errors: list[str] = []
|
||||||
# 세트 구성 관리 (set_bom)
|
for it in st.list_items(kind="set"):
|
||||||
# ════════════════════════════════════════════════════════════
|
code = it["item_code"]
|
||||||
@router.get("/sets", response_class=HTMLResponse)
|
raw = str(form.get(f"qty_{code}") or "").strip()
|
||||||
async def sets_page(request: Request) -> HTMLResponse:
|
if raw == "":
|
||||||
from app.main import render_template # noqa: WPS433
|
continue
|
||||||
|
try:
|
||||||
guard = _guard(request)
|
n = int(raw)
|
||||||
if not isinstance(guard, tuple):
|
except ValueError:
|
||||||
return guard
|
continue
|
||||||
st, user = guard
|
if n <= 0:
|
||||||
wh = _selected_wh(request, st)
|
continue
|
||||||
ctx = _base_ctx(request, user, st, wh=wh)
|
try:
|
||||||
# 세트별로 묶어서 표시
|
st.create_set_out(
|
||||||
set_items = st.list_items(kind="set")
|
movement_date=mdate, warehouse_code=wh, set_code=code, set_qty=n,
|
||||||
bom = st.list_bom(include_inactive=True)
|
bom_map=bmap, ref_type="set", ref_no=ref_no, memo=memo,
|
||||||
by_set: dict[str, list[dict[str, Any]]] = {}
|
created_by=user["email"],
|
||||||
for b in bom:
|
)
|
||||||
by_set.setdefault(b["set_code"], []).append(b)
|
done += 1
|
||||||
names = st.item_name_map()
|
except ValueError as exc:
|
||||||
ctx.update(
|
errors.append(f"{code}: {exc}")
|
||||||
{
|
if errors and done == 0:
|
||||||
"page_title": "말레이시아 재고관리 — 세트 구성",
|
raise HTTPException(status_code=400, detail="; ".join(errors[:10]))
|
||||||
"page_subtitle": "MY- 세트 BOM 관리 (구성품은 MT-/MX-/MZ-)",
|
return RedirectResponse(url=f"/malaysia/movements?wh={wh}&type=OUT", status_code=303)
|
||||||
"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)
|
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
@@ -336,10 +297,8 @@ async def stocktake_create(
|
|||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
try:
|
try:
|
||||||
head = st.create_stocktake(
|
head = st.create_stocktake(
|
||||||
stocktake_date=stocktake_date,
|
stocktake_date=stocktake_date, warehouse_code=warehouse_code,
|
||||||
warehouse_code=warehouse_code,
|
memo=memo, created_by=user["email"],
|
||||||
memo=memo,
|
|
||||||
created_by=user["email"],
|
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(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
|
return guard
|
||||||
st, user = guard
|
st, user = guard
|
||||||
try:
|
try:
|
||||||
computed = st.compute_result(stocktake_id=stocktake_id)
|
computed = st.compute_result(stocktake_id=stocktake_id, bom_map=_bom_map(request))
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return render_template(
|
return render_template(
|
||||||
request, "denied.html",
|
request, "denied.html",
|
||||||
@@ -365,6 +324,8 @@ async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse:
|
|||||||
)
|
)
|
||||||
head = computed["stocktake"]
|
head = computed["stocktake"]
|
||||||
wh = head["warehouse_code"]
|
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 = _base_ctx(request, user, st, wh=wh)
|
||||||
ctx.update(
|
ctx.update(
|
||||||
{
|
{
|
||||||
@@ -373,6 +334,7 @@ async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse:
|
|||||||
"stocktake": head,
|
"stocktake": head,
|
||||||
"individual_items": st.list_items(kind="individual"),
|
"individual_items": st.list_items(kind="individual"),
|
||||||
"set_items": st.list_items(kind="set"),
|
"set_items": st.list_items(kind="set"),
|
||||||
|
"entered": entered,
|
||||||
"result_rows": computed["rows"],
|
"result_rows": computed["rows"],
|
||||||
"missing_bom": computed["missing_bom"],
|
"missing_bom": computed["missing_bom"],
|
||||||
"is_draft": head["status"] == "draft",
|
"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)
|
return render_template(request, "malaysia/stocktake_detail.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stocktakes/{stocktake_id:int}/lines")
|
@router.post("/stocktakes/{stocktake_id:int}/lines/bulk")
|
||||||
async def stocktake_line_upsert(
|
async def stocktake_lines_bulk(
|
||||||
request: Request,
|
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
|
||||||
stocktake_id: int,
|
|
||||||
sku_code: str = Form(...),
|
|
||||||
qty: int = Form(...),
|
|
||||||
memo: str = Form(""),
|
|
||||||
user: dict[str, Any] = Depends(_require_user),
|
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
|
"""그리드 일괄 저장. form: qty_<SKUCODE>... (낱개+세트). 빈칸은 스킵, 0 허용."""
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
try:
|
form = await request.form()
|
||||||
st.upsert_line(stocktake_id=stocktake_id, sku_code=sku_code, qty=qty, memo=memo)
|
codes = [i["item_code"] for i in st.list_items(kind="individual")]
|
||||||
except KeyError:
|
codes += [i["item_code"] for i in st.list_items(kind="set")]
|
||||||
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
|
errors: list[str] = []
|
||||||
except ValueError as exc:
|
for code in codes:
|
||||||
raise HTTPException(status_code=400, detail=str(exc))
|
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)
|
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stocktakes/{stocktake_id:int}/lines/{line_id:int}/delete")
|
@router.post("/stocktakes/{stocktake_id:int}/lines/{line_id:int}/delete")
|
||||||
async def stocktake_line_delete(
|
async def stocktake_line_delete(
|
||||||
request: Request,
|
request: Request, stocktake_id: int, line_id: int,
|
||||||
stocktake_id: int,
|
|
||||||
line_id: int,
|
|
||||||
user: dict[str, Any] = Depends(_require_user),
|
user: dict[str, Any] = Depends(_require_user),
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
@@ -423,15 +394,13 @@ async def stocktake_line_delete(
|
|||||||
|
|
||||||
@router.post("/stocktakes/{stocktake_id:int}/finalize")
|
@router.post("/stocktakes/{stocktake_id:int}/finalize")
|
||||||
async def stocktake_finalize(
|
async def stocktake_finalize(
|
||||||
request: Request,
|
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
|
||||||
stocktake_id: int,
|
|
||||||
user: dict[str, Any] = Depends(_require_user),
|
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
try:
|
try:
|
||||||
st.finalize_stocktake(stocktake_id=stocktake_id)
|
st.finalize_stocktake(stocktake_id=stocktake_id, bom_map=_bom_map(request))
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
|
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
@@ -441,9 +410,7 @@ async def stocktake_finalize(
|
|||||||
|
|
||||||
@router.post("/stocktakes/{stocktake_id:int}/cancel")
|
@router.post("/stocktakes/{stocktake_id:int}/cancel")
|
||||||
async def stocktake_cancel(
|
async def stocktake_cancel(
|
||||||
request: Request,
|
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
|
||||||
stocktake_id: int,
|
|
||||||
user: dict[str, Any] = Depends(_require_user),
|
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
@@ -456,12 +423,10 @@ async def stocktake_cancel(
|
|||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# JSON API (요구사항 6) — /malaysia/api/*
|
# JSON API
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
@router.get("/api/warehouses")
|
@router.get("/api/warehouses")
|
||||||
async def api_warehouses(
|
async def api_warehouses(request: Request, _: dict[str, Any] = Depends(_require_user)) -> JSONResponse:
|
||||||
request: Request, _: dict[str, Any] = Depends(_require_user)
|
|
||||||
) -> JSONResponse:
|
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
@@ -469,10 +434,7 @@ async def api_warehouses(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/items")
|
@router.get("/api/items")
|
||||||
async def api_items(
|
async def api_items(request: Request, kind: str = "", _: dict[str, Any] = Depends(_require_user)) -> JSONResponse:
|
||||||
request: Request, kind: str = "", _: dict[str, Any] = Depends(_require_user)
|
|
||||||
) -> JSONResponse:
|
|
||||||
"""kind=individual → MT/MX/MZ, kind=set → MY, 미지정 → 전체."""
|
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
@@ -481,39 +443,13 @@ async def api_items(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/bom")
|
@router.get("/api/bom")
|
||||||
async def api_bom(
|
async def api_bom(request: Request, _: dict[str, Any] = Depends(_require_user)) -> JSONResponse:
|
||||||
request: Request, _: dict[str, Any] = Depends(_require_user)
|
"""itemcode_db(set_components)에서 읽은 MY- 세트 BOM (읽기 전용)."""
|
||||||
) -> JSONResponse:
|
return JSONResponse({"bom": _bom_map(request)})
|
||||||
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")
|
@router.get("/api/stock")
|
||||||
async def api_stock(
|
async def api_stock(request: Request, wh: str = "", _: dict[str, Any] = Depends(_require_user)) -> JSONResponse:
|
||||||
request: Request, wh: str = "", _: dict[str, Any] = Depends(_require_user)
|
|
||||||
) -> JSONResponse:
|
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
@@ -523,11 +459,8 @@ async def api_stock(
|
|||||||
|
|
||||||
@router.post("/api/movements")
|
@router.post("/api/movements")
|
||||||
async def api_movement_create(
|
async def api_movement_create(
|
||||||
request: Request,
|
request: Request, payload: dict[str, Any] = Body(...), user: dict[str, Any] = Depends(_require_user)
|
||||||
payload: dict[str, Any] = Body(...),
|
|
||||||
user: dict[str, Any] = Depends(_require_user),
|
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""단일 낱개 movement 생성. body: {movement_date,warehouse_code,item_code,movement_type,qty,...}."""
|
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
@@ -550,11 +483,8 @@ async def api_movement_create(
|
|||||||
|
|
||||||
@router.post("/api/movements/set-out")
|
@router.post("/api/movements/set-out")
|
||||||
async def api_set_out(
|
async def api_set_out(
|
||||||
request: Request,
|
request: Request, payload: dict[str, Any] = Body(...), user: dict[str, Any] = Depends(_require_user)
|
||||||
payload: dict[str, Any] = Body(...),
|
|
||||||
user: dict[str, Any] = Depends(_require_user),
|
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""세트 출고(BOM 분해). body: {movement_date,warehouse_code,set_code,set_qty,...}."""
|
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
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 ""),
|
warehouse_code=str(payload.get("warehouse_code") or ""),
|
||||||
set_code=str(payload.get("set_code") or ""),
|
set_code=str(payload.get("set_code") or ""),
|
||||||
set_qty=int(payload.get("set_qty") or 0),
|
set_qty=int(payload.get("set_qty") or 0),
|
||||||
|
bom_map=_bom_map(request),
|
||||||
ref_type=str(payload.get("ref_type") or "set"),
|
ref_type=str(payload.get("ref_type") or "set"),
|
||||||
ref_no=str(payload.get("ref_no") or ""),
|
ref_no=str(payload.get("ref_no") or ""),
|
||||||
memo=str(payload.get("memo") or ""),
|
memo=str(payload.get("memo") or ""),
|
||||||
@@ -576,24 +507,17 @@ async def api_set_out(
|
|||||||
|
|
||||||
@router.get("/api/stocktakes/{stocktake_id:int}/result")
|
@router.get("/api/stocktakes/{stocktake_id:int}/result")
|
||||||
async def api_stocktake_result(
|
async def api_stocktake_result(
|
||||||
request: Request,
|
request: Request, stocktake_id: int, _: dict[str, Any] = Depends(_require_user)
|
||||||
stocktake_id: int,
|
|
||||||
_: dict[str, Any] = Depends(_require_user),
|
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
"""재고조사 최종 계산 결과(낱개 기준 direct/from_set/total)."""
|
|
||||||
st = _store(request)
|
st = _store(request)
|
||||||
if st is None:
|
if st is None:
|
||||||
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
try:
|
try:
|
||||||
computed = st.compute_result(stocktake_id=stocktake_id)
|
computed = st.compute_result(stocktake_id=stocktake_id, bom_map=_bom_map(request))
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
|
raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{"stocktake": computed["stocktake"], "rows": computed["rows"], "missing_bom": computed["missing_bom"]}
|
||||||
"stocktake": computed["stocktake"],
|
|
||||||
"rows": computed["rows"],
|
|
||||||
"missing_bom": computed["missing_bom"],
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<a class="erp-btn {% if active_tab=='status' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/?wh={{ selected_wh }}">재고 현황</a>
|
<a class="erp-btn {% if active_tab=='status' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/?wh={{ selected_wh }}">재고 현황</a>
|
||||||
<a class="erp-btn {% if active_tab=='move' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/movements?wh={{ selected_wh }}">입출고</a>
|
<a class="erp-btn {% if active_tab=='move' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/movements?wh={{ selected_wh }}">입출고</a>
|
||||||
<a class="erp-btn {% if active_tab=='stocktake' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/stocktakes?wh={{ selected_wh }}">일일 재고조사</a>
|
<a class="erp-btn {% if active_tab=='stocktake' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/stocktakes?wh={{ selected_wh }}">일일 재고조사</a>
|
||||||
<a class="erp-btn {% if active_tab=='sets' %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/sets?wh={{ selected_wh }}">세트 구성</a>
|
|
||||||
|
|
||||||
<span style="flex:1 1 auto"></span>
|
<span style="flex:1 1 auto"></span>
|
||||||
|
|
||||||
|
|||||||
@@ -5,65 +5,76 @@
|
|||||||
{% set active_tab = 'move' %}
|
{% set active_tab = 'move' %}
|
||||||
{% include "malaysia/_nav.html" %}
|
{% include "malaysia/_nav.html" %}
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;">
|
<!-- 낱개 입출고 그리드 일괄 -->
|
||||||
|
<div class="erp-card">
|
||||||
<!-- 입고/출고 등록 (낱개) -->
|
<div class="cpg-card-head"><h2>입고 / 출고 / 조정 — 낱개 일괄</h2></div>
|
||||||
<div class="erp-card">
|
<span class="erp-muted">전체 낱개 아이템에 수량만 입력. 빈칸/0은 제외. IN/OUT 양수, ADJUST는 ±. (세트는 아래에서 출고)</span>
|
||||||
<div class="cpg-card-head"><h2>입고 / 출고 등록 (낱개)</h2></div>
|
<form method="post" action="/malaysia/movements/bulk" style="margin-top:10px;">
|
||||||
<span class="erp-muted">허용: MT-/MX-/MZ- · 차단: MY-/MD-</span>
|
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
|
||||||
<form method="post" action="/malaysia/movements" style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
|
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-bottom:10px;">
|
||||||
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
|
|
||||||
<label class="erp-field"><span>이동 종류</span>
|
<label class="erp-field"><span>이동 종류</span>
|
||||||
<select class="erp-select" name="movement_type" required>
|
<select class="erp-select" name="movement_type" required>
|
||||||
<option value="IN">IN (입고)</option>
|
<option value="IN">IN (입고)</option>
|
||||||
<option value="OUT">OUT (출고)</option>
|
<option value="OUT">OUT (출고)</option>
|
||||||
<option value="ADJUST">ADJUST (조정, ± 가능)</option>
|
<option value="ADJUST">ADJUST (조정 ±)</option>
|
||||||
</select></label>
|
</select></label>
|
||||||
<label class="erp-field"><span>날짜</span>
|
<label class="erp-field"><span>날짜</span>
|
||||||
<input class="erp-input" type="date" name="movement_date" value="{{ today }}" required /></label>
|
<input class="erp-input" type="date" name="movement_date" value="{{ today }}" required /></label>
|
||||||
<label class="erp-field"><span>아이템</span>
|
|
||||||
<select class="erp-select" name="item_code" required>
|
|
||||||
<option value="">— 선택 —</option>
|
|
||||||
{% for it in individual_items %}
|
|
||||||
<option value="{{ it.item_code }}">{{ it.item_code }} · {{ it.item_name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select></label>
|
|
||||||
<label class="erp-field"><span>수량</span>
|
|
||||||
<input class="erp-input" type="number" name="qty" required placeholder="IN/OUT 은 양수, ADJUST 는 ±" /></label>
|
|
||||||
<label class="erp-field"><span>Ref Type</span>
|
<label class="erp-field"><span>Ref Type</span>
|
||||||
<input class="erp-input" type="text" name="ref_type" placeholder="manual / shopee / lazada ..." /></label>
|
<input class="erp-input" type="text" name="ref_type" placeholder="manual / shopee / lazada" /></label>
|
||||||
<label class="erp-field"><span>Ref No</span>
|
<label class="erp-field"><span>Ref No</span>
|
||||||
<input class="erp-input" type="text" name="ref_no" placeholder="주문번호 등" /></label>
|
<input class="erp-input" type="text" name="ref_no" /></label>
|
||||||
<label class="erp-field"><span>메모</span>
|
<label class="erp-field" style="flex:1 1 200px"><span>메모</span>
|
||||||
<input class="erp-input" type="text" name="memo" /></label>
|
<input class="erp-input" type="text" name="memo" /></label>
|
||||||
<div class="erp-page-actions"><button type="submit" class="erp-btn erp-btn-primary">등록</button></div>
|
</div>
|
||||||
</form>
|
<div class="erp-table-wrap">
|
||||||
</div>
|
<table class="erp-table">
|
||||||
|
<thead><tr><th>Item Code</th><th>Product Name</th><th style="width:140px">Qty</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in individual_items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ it.item_code }}</td>
|
||||||
|
<td>{{ it.item_name }}</td>
|
||||||
|
<td><input class="erp-input" type="number" name="qty_{{ it.item_code }}" placeholder="0" style="width:120px" /></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="erp-page-actions" style="margin-top:10px;"><button type="submit" class="erp-btn erp-btn-primary">일괄 등록</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 세트 출고 (BOM 분해) -->
|
<!-- 세트 출고 그리드 (BOM 분해) -->
|
||||||
<div class="erp-card">
|
<div class="erp-card" style="margin-top:16px;">
|
||||||
<div class="cpg-card-head"><h2>세트 출고 (BOM 분해)</h2></div>
|
<div class="cpg-card-head"><h2>세트 출고 — BOM 분해 (itemcode_db)</h2></div>
|
||||||
<span class="erp-muted">MY- 세트 1건 → set_bom 기준으로 구성품별 OUT 자동 생성</span>
|
<span class="erp-muted">세트 수량 입력 시 set_components 기준으로 구성품별 OUT 자동 생성. 빈칸/0 제외.</span>
|
||||||
<form method="post" action="/malaysia/movements/set-out" style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
|
<form method="post" action="/malaysia/movements/set-out-bulk" style="margin-top:10px;">
|
||||||
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
|
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;margin-bottom:10px;">
|
||||||
<label class="erp-field"><span>날짜</span>
|
<label class="erp-field"><span>날짜</span>
|
||||||
<input class="erp-input" type="date" name="movement_date" value="{{ today }}" required /></label>
|
<input class="erp-input" type="date" name="movement_date" value="{{ today }}" required /></label>
|
||||||
<label class="erp-field"><span>세트</span>
|
|
||||||
<select class="erp-select" name="set_code" required>
|
|
||||||
<option value="">— 선택 —</option>
|
|
||||||
{% for it in set_items %}
|
|
||||||
<option value="{{ it.item_code }}">{{ it.item_code }} · {{ it.item_name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select></label>
|
|
||||||
<label class="erp-field"><span>세트 수량</span>
|
|
||||||
<input class="erp-input" type="number" name="set_qty" min="1" required /></label>
|
|
||||||
<label class="erp-field"><span>Ref No</span>
|
<label class="erp-field"><span>Ref No</span>
|
||||||
<input class="erp-input" type="text" name="ref_no" placeholder="주문번호 등" /></label>
|
<input class="erp-input" type="text" name="ref_no" placeholder="주문번호 등" /></label>
|
||||||
<label class="erp-field"><span>메모</span>
|
<label class="erp-field" style="flex:1 1 200px"><span>메모</span>
|
||||||
<input class="erp-input" type="text" name="memo" /></label>
|
<input class="erp-input" type="text" name="memo" /></label>
|
||||||
<div class="erp-page-actions"><button type="submit" class="erp-btn erp-btn-primary">세트 출고</button></div>
|
</div>
|
||||||
</form>
|
<div class="erp-table-wrap">
|
||||||
</div>
|
<table class="erp-table">
|
||||||
|
<thead><tr><th>Set Code</th><th>Set Name</th><th style="width:140px">Set Qty (OUT)</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in set_items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ it.item_code }}</td>
|
||||||
|
<td>{{ it.item_name }}</td>
|
||||||
|
<td><input class="erp-input" type="number" min="0" name="qty_{{ it.item_code }}" placeholder="0" style="width:120px" /></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="erp-page-actions" style="margin-top:10px;"><button type="submit" class="erp-btn erp-btn-primary">세트 출고</button></div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 이동 이력 -->
|
<!-- 이동 이력 -->
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
{% extends "erp_base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<section class="mys">
|
|
||||||
{% set active_tab = 'sets' %}
|
|
||||||
{% include "malaysia/_nav.html" %}
|
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:340px 1fr;gap:16px;align-items:start;">
|
|
||||||
|
|
||||||
<!-- 구성품 추가 -->
|
|
||||||
<div class="erp-card">
|
|
||||||
<div class="cpg-card-head"><h2>세트 구성 추가/수정</h2></div>
|
|
||||||
<span class="erp-muted">같은 세트+구성품은 덮어씁니다.</span>
|
|
||||||
<form method="post" action="/malaysia/sets/bom" style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
|
|
||||||
<label class="erp-field"><span>세트 (MY-)</span>
|
|
||||||
<select class="erp-select" name="set_code" required>
|
|
||||||
<option value="">— 선택 —</option>
|
|
||||||
{% for it in set_items %}
|
|
||||||
<option value="{{ it.item_code }}">{{ it.item_code }} · {{ it.item_name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select></label>
|
|
||||||
<label class="erp-field"><span>구성품 (MT-/MX-/MZ-)</span>
|
|
||||||
<select class="erp-select" name="component_code" required>
|
|
||||||
<option value="">— 선택 —</option>
|
|
||||||
{% for it in individual_items %}
|
|
||||||
<option value="{{ it.item_code }}">{{ it.item_code }} · {{ it.item_name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select></label>
|
|
||||||
<label class="erp-field"><span>구성 수량</span>
|
|
||||||
<input class="erp-input" type="number" name="component_qty" min="1" required /></label>
|
|
||||||
<div class="erp-page-actions"><button type="submit" class="erp-btn erp-btn-primary">저장</button></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 세트별 구성 목록 -->
|
|
||||||
<div class="erp-card">
|
|
||||||
<div class="cpg-card-head"><h2>세트 구성표 (set_bom)</h2></div>
|
|
||||||
{% for it in set_items %}
|
|
||||||
<div style="margin-bottom:18px;">
|
|
||||||
<h3 style="margin:6px 0;">{{ it.item_code }} · {{ it.item_name }}</h3>
|
|
||||||
<div class="erp-table-wrap">
|
|
||||||
<table class="erp-table">
|
|
||||||
<thead><tr><th>Component</th><th>Name</th><th style="text-align:right">Qty</th><th>Active</th><th></th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for b in by_set.get(it.item_code, []) %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ b.component_code }}</td>
|
|
||||||
<td>{{ names.get(b.component_code, '—') }}</td>
|
|
||||||
<td style="text-align:right">{{ b.component_qty }}</td>
|
|
||||||
<td>{% if b.active %}<span class="erp-badge erp-badge-success">활성</span>{% else %}<span class="erp-badge erp-badge-neutral">비활성</span>{% endif %}</td>
|
|
||||||
<td>
|
|
||||||
<form method="post" action="/malaysia/sets/bom/{{ b.id }}/delete" onsubmit="return confirm('이 구성품을 삭제합니다. 계속?');">
|
|
||||||
<button type="submit" class="erp-btn erp-btn-danger">삭제</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% if not by_set.get(it.item_code) %}
|
|
||||||
<tr><td colspan="5" class="erp-muted">구성품이 없습니다. (재고조사에 입력 시 경고)</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -15,73 +15,58 @@
|
|||||||
{% else %}<span class="erp-badge erp-badge-inverse">작성중</span>{% endif %}
|
{% else %}<span class="erp-badge erp-badge-inverse">작성중</span>{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if missing_bom %}
|
{% if missing_bom %}
|
||||||
<p style="color:#b91c1c;font-weight:600;">⚠ BOM 구성이 없는 세트: {{ missing_bom|join(', ') }} — 세트 구성 등록 전에는 확정할 수 없습니다.</p>
|
<p style="color:#b91c1c;font-weight:600;">⚠ BOM 구성이 없는 세트: {{ missing_bom|join(', ') }} — itemcode_db set_components 확인 필요. 확정 불가.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if is_draft %}
|
{% if is_draft %}
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;margin-top:16px;">
|
<!-- 그리드 일괄 입력: 낱개 + 세트 한 폼에서 -->
|
||||||
<!-- 낱개 입력 -->
|
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/lines/bulk" style="margin-top:16px;">
|
||||||
<div class="erp-card">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;">
|
||||||
<div class="cpg-card-head"><h2>① 낱개 아이템 입력</h2></div>
|
<div class="erp-card">
|
||||||
<span class="erp-muted">세트 안에 든 건 빼고, 낱개로 보관 중인 수량만 입력</span>
|
<div class="cpg-card-head"><h2>① 낱개 아이템</h2></div>
|
||||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/lines" style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:end;">
|
<span class="erp-muted">세트 안에 든 건 빼고, 낱개 보관분만. 빈칸=미조사, 0 입력 가능.</span>
|
||||||
<label class="erp-field" style="flex:2 1 180px"><span>아이템</span>
|
<div class="erp-table-wrap" style="margin-top:8px;">
|
||||||
<select class="erp-select" name="sku_code" required>
|
<table class="erp-table">
|
||||||
<option value="">— 선택 —</option>
|
<thead><tr><th>Code</th><th>Name</th><th style="width:120px">Qty</th></tr></thead>
|
||||||
{% for it in individual_items %}<option value="{{ it.item_code }}">{{ it.item_code }} · {{ it.item_name }}</option>{% endfor %}
|
<tbody>
|
||||||
</select></label>
|
{% for it in individual_items %}
|
||||||
<label class="erp-field" style="flex:1 1 90px"><span>수량</span>
|
<tr>
|
||||||
<input class="erp-input" type="number" name="qty" min="0" required /></label>
|
<td>{{ it.item_code }}</td>
|
||||||
<button type="submit" class="erp-btn erp-btn-primary">입력</button>
|
<td>{{ it.item_name }}</td>
|
||||||
</form>
|
<td><input class="erp-input" type="number" min="0" name="qty_{{ it.item_code }}"
|
||||||
|
value="{{ entered.get(it.item_code, '') }}" placeholder="—" style="width:100px" /></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="erp-card">
|
||||||
|
<div class="cpg-card-head"><h2>② 세트 아이템</h2></div>
|
||||||
|
<span class="erp-muted">미리 포장된 세트 재고. BOM(itemcode_db)으로 낱개 분해됨.</span>
|
||||||
|
<div class="erp-table-wrap" style="margin-top:8px;">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead><tr><th>Set Code</th><th>Set Name</th><th style="width:120px">Qty</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in set_items %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ it.item_code }}</td>
|
||||||
|
<td>{{ it.item_name }}</td>
|
||||||
|
<td><input class="erp-input" type="number" min="0" name="qty_{{ it.item_code }}"
|
||||||
|
value="{{ entered.get(it.item_code, '') }}" placeholder="—" style="width:100px" /></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="erp-page-actions" style="margin-top:12px;"><button type="submit" class="erp-btn erp-btn-primary">입력 저장</button></div>
|
||||||
<!-- 세트 입력 -->
|
</form>
|
||||||
<div class="erp-card">
|
|
||||||
<div class="cpg-card-head"><h2>② 세트 아이템 입력</h2></div>
|
|
||||||
<span class="erp-muted">미리 포장된 세트 재고 수량 (BOM 으로 낱개 분해됨)</span>
|
|
||||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/lines" style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;align-items:end;">
|
|
||||||
<label class="erp-field" style="flex:2 1 180px"><span>세트</span>
|
|
||||||
<select class="erp-select" name="sku_code" required>
|
|
||||||
<option value="">— 선택 —</option>
|
|
||||||
{% for it in set_items %}<option value="{{ it.item_code }}">{{ it.item_code }} · {{ it.item_name }}</option>{% endfor %}
|
|
||||||
</select></label>
|
|
||||||
<label class="erp-field" style="flex:1 1 90px"><span>세트 수량</span>
|
|
||||||
<input class="erp-input" type="number" name="qty" min="0" required /></label>
|
|
||||||
<button type="submit" class="erp-btn erp-btn-primary">입력</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- 입력된 라인 -->
|
|
||||||
<div class="erp-card" style="margin-top:16px;">
|
|
||||||
<div class="cpg-card-head"><h2>입력 라인 ({{ stocktake.lines|length }})</h2></div>
|
|
||||||
<div class="erp-table-wrap">
|
|
||||||
<table class="erp-table">
|
|
||||||
<thead><tr><th>SKU</th><th style="text-align:right">Qty</th><th>Memo</th>{% if is_draft %}<th></th>{% endif %}</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for ln in stocktake.lines %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ ln.sku_code }}</td>
|
|
||||||
<td style="text-align:right">{{ ln.qty }}</td>
|
|
||||||
<td>{{ ln.memo or '—' }}</td>
|
|
||||||
{% if is_draft %}
|
|
||||||
<td><form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/lines/{{ ln.id }}/delete" onsubmit="return confirm('삭제?');"><button class="erp-btn erp-btn-danger">삭제</button></form></td>
|
|
||||||
{% endif %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% if not stocktake.lines %}
|
|
||||||
<tr><td colspan="{% if is_draft %}4{% else %}3{% endif %}" class="erp-muted">입력된 라인이 없습니다.</td></tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 최종 계산 결과 -->
|
<!-- 최종 계산 결과 -->
|
||||||
<div class="erp-card" style="margin-top:16px;">
|
<div class="erp-card" style="margin-top:16px;">
|
||||||
<div class="cpg-card-head"><h2>최종 계산 (낱개 기준)</h2></div>
|
<div class="cpg-card-head"><h2>최종 계산 (낱개 기준)</h2></div>
|
||||||
@@ -100,7 +85,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if not result_rows %}
|
{% if not result_rows %}
|
||||||
<tr><td colspan="5" class="erp-muted">계산 결과가 없습니다.</td></tr>
|
<tr><td colspan="5" class="erp-muted">입력된 수량이 없습니다.</td></tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -110,7 +95,7 @@
|
|||||||
{% if is_draft %}
|
{% if is_draft %}
|
||||||
<div class="erp-page-actions" style="margin-top:16px;display:flex;gap:8px;">
|
<div class="erp-page-actions" style="margin-top:16px;display:flex;gap:8px;">
|
||||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/finalize"
|
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/finalize"
|
||||||
onsubmit="return confirm('확정하면 시스템 재고와의 차이가 STOCKTAKE 이동으로 기록되고, 이후 라인 수정이 불가합니다. 계속?');">
|
onsubmit="return confirm('확정하면 시스템 재고와의 차이가 STOCKTAKE 이동으로 기록되고, 이후 수정이 불가합니다. 계속?');">
|
||||||
<button type="submit" class="erp-btn erp-btn-primary" {% if missing_bom %}disabled title="BOM 누락 세트 해결 필요"{% endif %}>확정 (재고 반영)</button>
|
<button type="submit" class="erp-btn erp-btn-primary" {% if missing_bom %}disabled title="BOM 누락 세트 해결 필요"{% endif %}>확정 (재고 반영)</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/cancel" onsubmit="return confirm('이 조사를 취소합니다. 계속?');">
|
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/cancel" onsubmit="return confirm('이 조사를 취소합니다. 계속?');">
|
||||||
|
|||||||
Reference in New Issue
Block a user