diff --git a/.env.example b/.env.example index 3b946da..ea49a59 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,12 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/ # 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과. # VACATION_DB_URL=postgresql://vacation_app:replace-me@postgres-db:5432/vacation_db +# ─── 말레이시아 창고 재고관리 모듈 (malaysia_stock_db) ─── +# 설정해야 모듈이 동작한다(미설정 시 "설정 필요" 안내, JSON 폴백 없음). +# DB/역할/스키마/창고·아이템 seed 생성: scripts/sql/malaysia_stock_db_init.sql 참고. +# 권한키: malaysia(접근). admin 은 항상 통과. 상품명은 아래 ITEMCODE_DB_URL 재사용. +# MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:replace-me@postgres-db:5432/malaysia_stock_db + # ─── 상품 검색 (itemcode_db 읽기 전용) ─── # cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만). # 미설정 시 검색 비활성 → 수동 등록만 가능. diff --git a/CLAUDE.md b/CLAUDE.md index 3dcc9fc..7800539 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아 - 개인경비 (`app/modules/expense/`, `expense_db`) - 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용 - 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` +- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-) 제외. 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia` 상세는 `docs/PROJECT_OVERVIEW.md`. diff --git a/app/main.py b/app/main.py index f869e93..ce0f45c 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,8 @@ 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 router as malaysia_router from .modules.vacation import build_vacation_store from .modules.vacation import router as vacation_router from .store import ( @@ -36,6 +38,7 @@ MODULE_LABELS: dict[str, str] = { "expense": "개인경비", "vacation": "휴가", "cupang": "쿠팡 밀크런", + "malaysia": "말레이시아 재고", "expense_approver": "개인경비", "vacation_approver": "휴가", } @@ -108,6 +111,7 @@ _MODULE_TEMPLATE_DIRS = [ BASE_DIR / "modules" / "expense" / "templates", BASE_DIR / "modules" / "cupang" / "templates", BASE_DIR / "modules" / "vacation" / "templates", + BASE_DIR / "modules" / "malaysia" / "templates", ] templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates.env.loader = ChoiceLoader( @@ -135,11 +139,15 @@ app.state.cupang_store = build_cupang_store(dsn=env("CUPANG_DB_URL") or None) app.state.itemcode_reader = build_itemcode_reader() # 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내). app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or None) +# 말레이시아 재고관리: MALAYSIA_STOCK_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내). +# 상품명은 위 itemcode_reader(읽기 전용) 재사용. +app.state.malaysia_store = build_malaysia_store(dsn=env("MALAYSIA_STOCK_DB_URL") or None) # 모듈 라우터 등록 — 신규 모듈 추가 시 여기 한 줄. app.include_router(expense_router) app.include_router(cupang_router) app.include_router(vacation_router) +app.include_router(malaysia_router) def public_url_for(request: Request, route_name: str) -> str: @@ -282,6 +290,16 @@ def _menu_items_for(user_rec: dict[str, Any]) -> list[dict[str, Any]]: "status": "ready", "category": "관리", }, + { + "key": "malaysia", + "title": "말레이시아 재고", + "subtitle": "Malaysia Stock", + "description": "말레이시아 창고 입출고·세트 BOM·일일 재고조사를 관리합니다.", + "url": "/malaysia/", + "health_url": "/malaysia/health", + "status": "ready", + "category": "운영", + }, ] allowed = allowed_modules(user_rec) for item in items: @@ -298,6 +316,7 @@ def _icon_svg(name: str) -> str: "corm": '', "order": '', "cupang": '', + "malaysia": '', "modules": '', } body = paths.get(name, paths["modules"]) diff --git a/app/modules/malaysia/README.md b/app/modules/malaysia/README.md new file mode 100644 index 0000000..1d4d094 --- /dev/null +++ b/app/modules/malaysia/README.md @@ -0,0 +1,97 @@ +# 말레이시아 창고 재고관리 모듈 (`malaysia`) + +말레이시아 현지 창고의 입고/출고/조정과 세트 BOM, 일일 재고조사를 관리한다. +cupang 모듈과 동일한 패턴: **malaysia_stock_db(PostgreSQL) 전용**, JSON 폴백 없음. +상품명은 기존 `itemcode_db`(읽기 전용)을 재사용한다. + +- 경로(prefix): `/malaysia` +- 권한키: `malaysia` (admin 은 항상 통과) +- 환경변수: `MALAYSIA_STOCK_DB_URL` (미설정 시 "설정 필요" 안내) +- DB: `malaysia_stock_db` / 역할 `malaysia_app` + +--- + +## 재고 원칙 + +- **입고/출고/조정은 낱개 아이템(MT-/MX-/MZ-) 기준**으로만 movement 저장. +- **세트(MY-)는 movement 불가.** 세트는 ① 재고조사 입력, ② BOM 구성 계산에만 사용. +- 세트 주문 출고는 `set_bom` 으로 분해해 구성품별 `OUT` movement 를 생성(`/malaysia/movements/set-out`). +- 뚜껑(MD-)은 재고관리 대상에서 **완전 제외**(어디에도 사용 불가). + +### 현재고 계산 + +``` +현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST) + SUM(STOCKTAKE) +``` + +- `IN`/`OUT` qty 는 양수만. `ADJUST`/`STOCKTAKE` 는 음수(±) 허용. +- 재고조사 확정 시 **시스템 재고와 조사 최종치의 "차이"만** `STOCKTAKE` movement 로 기록 → 이력 보존 + 계산 단순. + +### 일일 재고조사 집계 + +``` +낱개 최종 재고(total_qty) + = direct_qty(낱개 직접 조사) + + from_set_qty(세트 수량을 set_bom 으로 분해한 합) +``` + +예) `MT-0320` 낱개 100 + `MY-0002`(MT-0320 1개 포함) 20세트 → `MT-0320` total = **120**. +> 낱개 입력 시 **세트 안에 든 건 빼고** 낱개 보관분만 입력한다. 세트 안 상품은 BOM 으로 자동 계산. + +--- + +## 테이블 (`scripts/sql/malaysia_stock_db_init.sql`) + +| 테이블 | 용도 | +| --- | --- | +| `warehouses` | 창고. seed: `MY-WH-01 / Malaysia Warehouse` | +| `malaysia_items` | 관리 대상 코드 스코프(낱개/세트) + 이름 스냅샷. seed: 낱개 12 + 세트 5 | +| `set_bom` | 세트 구성표. `UNIQUE(set_code, component_code)`. prefix CHECK 내장 | +| `stock_movement` | 입고/출고/조정/조사 이력. `item_code` 는 낱개만(CHECK) | +| `daily_stocktake` | 재고조사 헤더. 같은 날짜+창고 finalized 1건(부분 유니크) | +| `daily_stocktake_line` | 조사 라인. `sku_code` 낱개+세트 허용, `UNIQUE(stocktake_id, sku_code)` | + +검증 규칙(요구사항 8)은 **DB CHECK + 서비스 레이어(`store.py`)** 양쪽에 걸려 있다. + +--- + +## 주요 화면 / API + +화면: `/malaysia/`(재고 현황) · `/malaysia/movements`(입출고) · `/malaysia/stocktakes`(재고조사) · `/malaysia/sets`(세트 구성) + +JSON API: + +| Method | 경로 | 설명 | +| --- | --- | --- | +| GET | `/malaysia/api/warehouses` | 창고 목록 | +| GET | `/malaysia/api/items?kind=individual\|set` | 아이템 목록 | +| GET/POST | `/malaysia/api/bom` | 세트 구성 조회/등록 | +| GET | `/malaysia/api/stock?wh=` | 현재고 현황 | +| POST | `/malaysia/api/movements` | 낱개 movement 등록 | +| POST | `/malaysia/api/movements/set-out` | 세트 출고(BOM 분해) | +| GET | `/malaysia/api/stocktakes/{id}/result` | 재고조사 최종 계산 | + +--- + +## 초기화 (운영, 1회 — 사용자 승인 후) + +```bash +read -s -p "malaysia_app password: " APP_PWD; echo +docker exec -i postgres-db psql -U postgres \ + -v app_password="$APP_PWD" \ + < scripts/sql/malaysia_stock_db_init.sql + +# main-app .env 에 추가: +# MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:@postgres-db:5432/malaysia_stock_db +cd /opt/www/main && docker compose up -d --build +``` + +> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음. + +## 테스트 + +```bash +python -m pytest tests/test_malaysia_stock.py -v # pytest 있으면 +python tests/test_malaysia_stock.py # 없으면 standalone 폴백 +``` +순수 로직(코드 검증 / 세트 분해 / 재고조사 집계)만 검증하므로 DB 불필요. diff --git a/app/modules/malaysia/__init__.py b/app/modules/malaysia/__init__.py new file mode 100644 index 0000000..61172c3 --- /dev/null +++ b/app/modules/malaysia/__init__.py @@ -0,0 +1,47 @@ +"""말레이시아 창고 재고관리(malaysia) 모듈. + +라우터/저장소/템플릿을 한 디렉토리에서 관리한다. +- 라우터: `router.py` (FastAPI APIRouter, prefix=/malaysia) +- 저장소: `db.py` (malaysia_stock_db / PostgreSQL 전용) + `store.py` (상수/순수 계산) +- 상품명: 전역 `app.state.itemcode_reader`(itemcode_db 읽기 전용) 재사용 +- 템플릿: `templates/malaysia/` + +데이터 저장은 malaysia_stock_db 전용이다. MALAYSIA_STOCK_DB_URL 미설정 시 +build_malaysia_store 는 None 을 반환하고, 라우터가 "설정 필요" 안내 페이지를 +보여준다(앱은 죽지 않음). +""" + +from typing import Any + +from . import store +from .router import router +from .store import ( + MOVEMENT_TYPES, + STOCKTAKE_STATUSES, + explode_set, + explode_stocktake, + missing_bom_sets, +) + +__all__ = [ + "router", + "store", + "MOVEMENT_TYPES", + "STOCKTAKE_STATUSES", + "explode_set", + "explode_stocktake", + "missing_bom_sets", + "build_malaysia_store", +] + + +def build_malaysia_store(*, dsn: str | None) -> Any: + """MALAYSIA_STOCK_DB_URL 이 있으면 MalaysiaStockStore, 없으면 None. + + JSON 폴백을 두지 않는다(운영 데이터 분기 방지). None 이면 라우터가 안내 페이지 표시. + """ + if not dsn: + return None + from .db import MalaysiaStockStore # 지연 import (개발 환경 deps 없을 수 있음) + + return MalaysiaStockStore(dsn) diff --git a/app/modules/malaysia/db.py b/app/modules/malaysia/db.py new file mode 100644 index 0000000..dec1bff --- /dev/null +++ b/app/modules/malaysia/db.py @@ -0,0 +1,685 @@ +"""malaysia_stock_db PostgreSQL 저장소. + +- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — cupang/expense 모듈과 동일 패턴. +- 연결 정보: 환경변수 `MALAYSIA_STOCK_DB_URL` + (예: postgresql://malaysia_app:@postgres-db:5432/malaysia_stock_db) +- 스키마(테이블/인덱스/트리거/seed)는 앱이 만들지 않는다. + `scripts/sql/malaysia_stock_db_init.sql` 을 superuser 가 사전 적용한다. + 앱 계정(malaysia_app)은 SELECT/INSERT/UPDATE/DELETE 권한만 받는다. +- 연결 풀은 lazy open — 부팅 시 DB 가 잠시 끊겨도 컨테이너가 죽지 않게. + +재고 계산 / BOM 분해의 순수 로직은 `store.py` 에 있고, 여기서는 DB I/O 와 +조립만 담당한다(클라이언트 계산을 신뢰하지 않고 서버에서 재계산). +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +from psycopg.rows import dict_row +from psycopg_pool import ConnectionPool + +from app.timezone import KST, now_kst + +from . import store + + +class MalaysiaStockStore: + def __init__(self, dsn: str, *, min_size: int = 1, max_size: int = 5): + self._pool = ConnectionPool( + conninfo=dsn, + min_size=min_size, + max_size=max_size, + kwargs={"row_factory": dict_row, "autocommit": True}, + open=False, + ) + self._pool.open(wait=False) + + def close(self) -> None: + self._pool.close() + + # ════════════════════════════════════════════════════════════ + # 창고 (warehouses) + # ════════════════════════════════════════════════════════════ + def list_warehouses(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 warehouses {where} " + "ORDER BY active DESC, warehouse_code ASC" + ).fetchall() + return [self._serialize(r) for r in rows] + + def get_warehouse(self, *, warehouse_code: str) -> dict[str, Any] | None: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT * FROM warehouses WHERE warehouse_code = %s", + (warehouse_code,), + ).fetchone() + return self._serialize(row) if row else None + + def create_warehouse( + self, *, warehouse_code: str, warehouse_name: str, country: str = "MY" + ) -> dict[str, Any]: + code = (warehouse_code or "").strip() + name = (warehouse_name or "").strip() + if not code or not name: + raise ValueError("창고코드와 창고명은 필수입니다.") + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO warehouses (warehouse_code, warehouse_name, country) + VALUES (%s, %s, %s) + ON CONFLICT (warehouse_code) DO UPDATE + SET warehouse_name = EXCLUDED.warehouse_name, + country = EXCLUDED.country, + active = TRUE + RETURNING * + """, + (code, name, (country or "MY").strip()), + ).fetchone() + return self._serialize(row) + + # ════════════════════════════════════════════════════════════ + # 재고관리 대상 아이템 (malaysia_items) + # ════════════════════════════════════════════════════════════ + def list_items( + self, *, kind: str | None = None, include_inactive: bool = False + ) -> list[dict[str, Any]]: + """kind: 'individual' | 'set' | None(전체).""" + clauses: list[str] = [] + params: list[Any] = [] + if not include_inactive: + clauses.append("active = TRUE") + if kind in ("individual", "set"): + clauses.append("kind = %s") + params.append(kind) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + with self._pool.connection() as conn: + rows = conn.execute( + f"SELECT * FROM malaysia_items {where} " + "ORDER BY sort_order ASC, item_code ASC", + params, + ).fetchall() + return [self._serialize(r) for r in rows] + + def item_name_map(self) -> dict[str, str]: + """item_code → item_name (이름 붙이기용).""" + with self._pool.connection() as conn: + rows = conn.execute( + "SELECT item_code, item_name FROM malaysia_items" + ).fetchall() + return {r["item_code"]: (r["item_name"] or r["item_code"]) for r in rows} + + def upsert_item( + self, *, item_code: str, item_name: str, kind: str, sort_order: int = 0 + ) -> dict[str, Any]: + code = store._norm(item_code) + name = (item_name or "").strip() + if kind not in ("individual", "set"): + raise ValueError("kind 는 'individual' 또는 'set' 이어야 합니다.") + # prefix 와 kind 정합성 검증 + if kind == "individual" and not store.is_individual_code(code): + raise ValueError(f"낱개 아이템은 MT-/MX-/MZ- 만 가능합니다: {code}") + if kind == "set" and not store.is_set_code(code): + raise ValueError(f"세트 아이템은 MY- 만 가능합니다: {code}") + if store.is_blocked_code(code): + raise ValueError(f"뚜껑 코드(MD-)는 등록할 수 없습니다: {code}") + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO malaysia_items (item_code, item_name, kind, sort_order) + VALUES (%s, %s, %s, %s) + ON CONFLICT (item_code) DO UPDATE + SET item_name = EXCLUDED.item_name, + kind = EXCLUDED.kind, + sort_order = EXCLUDED.sort_order, + active = TRUE + RETURNING * + """, + (code, name or code, kind, sort_order), + ).fetchone() + return self._serialize(row) + + def sync_item_names(self, name_lookup: dict[str, str]) -> int: + """itemcode_db 등 외부 소스에서 받은 {code: name} 으로 이름 갱신. + + 등록된 코드만 갱신(신규 추가 안 함). 반환: 갱신 건수. + """ + updated = 0 + with self._pool.connection() as conn: + for code, name in name_lookup.items(): + if not code or not name: + continue + cur = conn.execute( + "UPDATE malaysia_items SET item_name = %s " + "WHERE item_code = %s AND item_name <> %s", + (name.strip(), store._norm(code), name.strip()), + ) + updated += cur.rowcount or 0 + return updated + + # ════════════════════════════════════════════════════════════ + # 세트 구성표 (set_bom) + # ════════════════════════════════════════════════════════════ + 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) + # ════════════════════════════════════════════════════════════ + def create_movement( + self, + *, + movement_date: str, + warehouse_code: str, + item_code: str, + movement_type: str, + qty: int, + ref_type: str = "", + ref_no: str = "", + memo: str = "", + created_by: str = "", + ) -> dict[str, Any]: + """단일 낱개 아이템 movement 1건 생성. 세트 코드는 차단.""" + if movement_type not in store.MOVEMENT_TYPES: + raise ValueError(f"허용되지 않는 이동 종류: {movement_type}") + code = store.validate_movement_code(item_code) # MY-/MD- 차단 + # IN/OUT 은 양수, ADJUST/STOCKTAKE 는 ± 허용 + if movement_type in ("IN", "OUT"): + n = store.validate_qty(qty, allow_zero=False, allow_negative=False) + else: + n = store.validate_qty(qty, allow_zero=True, allow_negative=True) + if not (movement_date or "").strip(): + raise ValueError("이동일자는 필수입니다.") + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO stock_movement + (movement_date, warehouse_code, item_code, movement_type, + qty, ref_type, ref_no, memo, created_by) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING * + """, + ( + movement_date.strip(), + warehouse_code.strip(), + code, + movement_type, + n, + (ref_type or "").strip(), + (ref_no or "").strip(), + (memo or "").strip(), + (created_by or "").lower().strip(), + ), + ).fetchone() + return self._serialize(row) + + def create_set_out( + self, + *, + movement_date: str, + warehouse_code: str, + set_code: str, + set_qty: int, + ref_type: str = "", + ref_no: str = "", + memo: str = "", + created_by: str = "", + ) -> list[dict[str, Any]]: + """세트 주문 출고 → set_bom 으로 분해해 구성품별 OUT movement 생성. + + 예: MY-0002 1세트 출고 → MT-0320, MT-0550 ... 각각 OUT. + BOM 이 없으면 ValueError(요구사항 8). + """ + 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()) + if not exploded: + raise ValueError(f"세트 {sc} 의 BOM 구성이 없습니다. 세트구성을 먼저 등록하세요.") + ref_t = (ref_type or "set").strip() or "set" + results: list[dict[str, Any]] = [] + with self._pool.connection() as conn: + with conn.transaction(): + for comp_code, comp_qty in exploded.items(): + row = conn.execute( + """ + INSERT INTO stock_movement + (movement_date, warehouse_code, item_code, movement_type, + qty, ref_type, ref_no, memo, created_by) + VALUES (%s,%s,%s,'OUT',%s,%s,%s,%s,%s) + RETURNING * + """, + ( + movement_date.strip(), + warehouse_code.strip(), + comp_code, + comp_qty, + ref_t, + (ref_no or "").strip(), + (memo or f"{sc} {q}세트 분해").strip(), + (created_by or "").lower().strip(), + ), + ).fetchone() + results.append(self._serialize(row)) + return results + + def list_movements( + self, + *, + warehouse_code: str | None = None, + item_code: str | None = None, + movement_type: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + limit: int = 500, + ) -> list[dict[str, Any]]: + clauses: list[str] = [] + params: list[Any] = [] + if warehouse_code: + clauses.append("warehouse_code = %s") + params.append(warehouse_code) + if item_code: + clauses.append("item_code = %s") + params.append(store._norm(item_code)) + if movement_type in store.MOVEMENT_TYPES: + clauses.append("movement_type = %s") + params.append(movement_type) + if date_from: + clauses.append("movement_date >= %s") + params.append(date_from) + if date_to: + clauses.append("movement_date <= %s") + params.append(date_to) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + params.append(int(limit)) + with self._pool.connection() as conn: + rows = conn.execute( + f"SELECT * FROM stock_movement {where} " + "ORDER BY movement_date DESC, id DESC LIMIT %s", + params, + ).fetchall() + return [self._serialize(r) for r in rows] + + def current_stock(self, *, warehouse_code: str) -> dict[str, int]: + """창고별 현재고(낱개 아이템 기준). + + 현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST) + SUM(STOCKTAKE) + 반환: { item_code: qty } + """ + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT item_code, + SUM(CASE + WHEN movement_type = 'IN' THEN qty + WHEN movement_type = 'OUT' THEN -qty + ELSE qty -- ADJUST/STOCKTAKE 는 부호 포함 저장 + END) AS qty + FROM stock_movement + WHERE warehouse_code = %s + GROUP BY item_code + """, + (warehouse_code,), + ).fetchall() + return {r["item_code"]: int(r["qty"] or 0) for r in rows} + + def stock_status(self, *, warehouse_code: str) -> list[dict[str, Any]]: + """재고 현황 — 등록된 낱개 아이템 전체 + 현재고 + 마지막 조사일. + + 등록 아이템 기준으로 0 재고도 표시(좌측 조인 효과). + """ + stock = self.current_stock(warehouse_code=warehouse_code) + last_dates = self._last_stocktake_dates(warehouse_code=warehouse_code) + items = self.list_items(kind="individual") + out: list[dict[str, Any]] = [] + for it in items: + code = it["item_code"] + out.append( + { + "item_code": code, + "item_name": it.get("item_name") or code, + "current_qty": stock.get(code, 0), + "last_stocktake_date": last_dates.get(code), + } + ) + # 등록 아이템에 없지만 movement 가 있는 코드도 노출(누락 방지) + for code, qty in stock.items(): + if code not in {i["item_code"] for i in out}: + out.append( + { + "item_code": code, + "item_name": code, + "current_qty": qty, + "last_stocktake_date": last_dates.get(code), + } + ) + out.sort(key=lambda x: x["item_code"]) + return out + + def _last_stocktake_dates(self, *, warehouse_code: str) -> dict[str, str]: + """아이템별 마지막 STOCKTAKE movement 날짜.""" + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT item_code, MAX(movement_date) AS d + FROM stock_movement + WHERE warehouse_code = %s AND movement_type = 'STOCKTAKE' + GROUP BY item_code + """, + (warehouse_code,), + ).fetchall() + out: dict[str, str] = {} + for r in rows: + d = r["d"] + out[r["item_code"]] = d.isoformat() if isinstance(d, date) else str(d) + return out + + # ════════════════════════════════════════════════════════════ + # 일일 재고조사 (daily_stocktake + daily_stocktake_line) + # ════════════════════════════════════════════════════════════ + def list_stocktakes( + self, *, warehouse_code: str | None = None, limit: int = 200 + ) -> list[dict[str, Any]]: + clauses: list[str] = [] + params: list[Any] = [] + if warehouse_code: + clauses.append("warehouse_code = %s") + params.append(warehouse_code) + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + params.append(int(limit)) + with self._pool.connection() as conn: + rows = conn.execute( + f"SELECT * FROM daily_stocktake {where} " + "ORDER BY stocktake_date DESC, id DESC LIMIT %s", + params, + ).fetchall() + return [self._serialize(r) for r in rows] + + def get_stocktake( + self, *, stocktake_id: int, with_lines: bool = True + ) -> dict[str, Any] | None: + with self._pool.connection() as conn: + row = conn.execute( + "SELECT * FROM daily_stocktake WHERE id = %s", (stocktake_id,) + ).fetchone() + if not row: + return None + head = self._serialize(row) + if with_lines: + line_rows = conn.execute( + "SELECT * FROM daily_stocktake_line WHERE stocktake_id = %s " + "ORDER BY sku_code ASC", + (stocktake_id,), + ).fetchall() + head["lines"] = [self._serialize(r) for r in line_rows] + return head + + def create_stocktake( + self, + *, + stocktake_date: str, + warehouse_code: str, + memo: str = "", + created_by: str = "", + ) -> dict[str, Any]: + if not (stocktake_date or "").strip(): + raise ValueError("조사 날짜는 필수입니다.") + if not (warehouse_code or "").strip(): + raise ValueError("창고는 필수입니다.") + # 같은 날짜+창고에 이미 finalized 가 있으면 신규 생성 차단(요구사항 8) + with self._pool.connection() as conn: + dup = conn.execute( + "SELECT 1 FROM daily_stocktake " + "WHERE stocktake_date = %s AND warehouse_code = %s AND status = 'finalized' " + "LIMIT 1", + (stocktake_date.strip(), warehouse_code.strip()), + ).fetchone() + if dup: + raise ValueError( + f"{stocktake_date} / {warehouse_code} 에 이미 확정된 재고조사가 있습니다." + ) + row = conn.execute( + """ + INSERT INTO daily_stocktake + (stocktake_date, warehouse_code, status, memo, created_by) + VALUES (%s, %s, 'draft', %s, %s) + RETURNING * + """, + ( + stocktake_date.strip(), + warehouse_code.strip(), + (memo or "").strip(), + (created_by or "").lower().strip(), + ), + ).fetchone() + return self._serialize(row) + + def _assert_draft(self, conn: Any, stocktake_id: int) -> dict[str, Any]: + row = conn.execute( + "SELECT * FROM daily_stocktake WHERE id = %s", (stocktake_id,) + ).fetchone() + if not row: + raise KeyError(stocktake_id) + if row["status"] != "draft": + raise ValueError("확정/취소된 재고조사는 라인을 수정할 수 없습니다.") + return row + + def upsert_line( + self, *, stocktake_id: int, sku_code: str, qty: int, memo: str = "" + ) -> dict[str, Any]: + code = store.validate_stocktake_code(sku_code) # MD- 차단, MY/낱개 허용 + n = store.validate_qty(qty, allow_zero=True, allow_negative=False) + with self._pool.connection() as conn: + self._assert_draft(conn, stocktake_id) + row = conn.execute( + """ + INSERT INTO daily_stocktake_line (stocktake_id, sku_code, qty, memo) + VALUES (%s, %s, %s, %s) + ON CONFLICT (stocktake_id, sku_code) DO UPDATE + SET qty = EXCLUDED.qty, memo = EXCLUDED.memo + RETURNING * + """, + (stocktake_id, code, n, (memo or "").strip()), + ).fetchone() + return self._serialize(row) + + def delete_line(self, *, stocktake_id: int, line_id: int) -> None: + with self._pool.connection() as conn: + self._assert_draft(conn, stocktake_id) + cur = conn.execute( + "DELETE FROM daily_stocktake_line WHERE id = %s AND stocktake_id = %s", + (line_id, stocktake_id), + ) + if cur.rowcount == 0: + raise KeyError(line_id) + + def cancel_stocktake(self, *, stocktake_id: int) -> dict[str, Any]: + with self._pool.connection() as conn: + row = conn.execute( + "UPDATE daily_stocktake SET status = 'cancelled' " + "WHERE id = %s AND status <> 'finalized' RETURNING *", + (stocktake_id,), + ).fetchone() + if not row: + raise ValueError("확정된 조사는 취소할 수 없거나, 대상이 없습니다.") + return self._serialize(row) + + def compute_result(self, *, stocktake_id: int) -> dict[str, Any]: + """재고조사 최종 계산 결과(낱개 아이템 기준). + + 반환: + { + "stocktake": {...헤더...}, + "rows": [{item_code,item_name,direct_qty,from_set_qty,total_qty}, ...], + "missing_bom": [MY-... BOM 없는 세트], + } + """ + head = self.get_stocktake(stocktake_id=stocktake_id, with_lines=True) + 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) + names = self.item_name_map() + rows = [] + for code in sorted(exploded.keys()): + v = exploded[code] + rows.append( + { + "stocktake_id": stocktake_id, + "stocktake_date": head["stocktake_date"], + "warehouse_code": head["warehouse_code"], + "item_code": code, + "item_name": names.get(code, code), + "direct_qty": v["direct_qty"], + "from_set_qty": v["from_set_qty"], + "total_qty": v["total_qty"], + } + ) + return {"stocktake": head, "rows": rows, "missing_bom": missing} + + def finalize_stocktake(self, *, stocktake_id: int) -> dict[str, Any]: + """재고조사 확정 — 최종 낱개 total_qty 와 현재 시스템 재고의 '차이'만 + STOCKTAKE movement 로 생성한다(요구사항 7). + + - 차이 = total_qty - current_system_qty → qty 부호 그대로 STOCKTAKE 저장 + - movement 이력이 남고, 현재고 계산은 단순 합산으로 유지된다. + - BOM 없는 MY- 세트가 있으면 확정 거부(요구사항 8). + 반환: {"stocktake": {...}, "adjustments": [{item_code, before, after, diff}], "rows": [...]} + """ + computed = self.compute_result(stocktake_id=stocktake_id) + head = computed["stocktake"] + if head["status"] != "draft": + raise ValueError("이미 확정/취소된 재고조사입니다.") + if computed["missing_bom"]: + raise ValueError( + "BOM 구성이 없는 세트가 있어 확정할 수 없습니다: " + + ", ".join(computed["missing_bom"]) + ) + wh = head["warehouse_code"] + st_date = head["stocktake_date"] + current = self.current_stock(warehouse_code=wh) + rows = computed["rows"] + adjustments: list[dict[str, Any]] = [] + with self._pool.connection() as conn: + with conn.transaction(): + for r in rows: + code = r["item_code"] + before = current.get(code, 0) + after = r["total_qty"] + diff = after - before + if diff != 0: + conn.execute( + """ + INSERT INTO stock_movement + (movement_date, warehouse_code, item_code, movement_type, + qty, ref_type, ref_no, memo, created_by) + VALUES (%s,%s,%s,'STOCKTAKE',%s,'stocktake',%s,%s,%s) + """, + ( + st_date, + wh, + code, + diff, + str(stocktake_id), + f"재고조사 #{stocktake_id} 반영(차이 {diff:+d})", + head.get("created_by", ""), + ), + ) + adjustments.append( + {"item_code": code, "before": before, "after": after, "diff": diff} + ) + conn.execute( + "UPDATE daily_stocktake SET status = 'finalized', finalized_at = %s " + "WHERE id = %s", + (now_kst(), stocktake_id), + ) + head = self.get_stocktake(stocktake_id=stocktake_id, with_lines=True) + return {"stocktake": head, "adjustments": adjustments, "rows": rows} + + # ════════════════════════════════════════════════════════════ + # 직렬화 + # ════════════════════════════════════════════════════════════ + @staticmethod + def _serialize(row: dict[str, Any] | None) -> dict[str, Any] | None: + if row is None: + return None + out = dict(row) + for k, v in list(out.items()): + if isinstance(v, datetime): + out[k] = v.astimezone(KST).isoformat(timespec="seconds") + elif isinstance(v, date): + out[k] = v.isoformat() + for k in ("id", "stocktake_id", "qty", "component_qty", "sort_order", "line_no"): + if k in out and out[k] is not None: + try: + out[k] = int(out[k]) + except (TypeError, ValueError): + pass + if "active" in out: + out["active"] = bool(out["active"]) + return out diff --git a/app/modules/malaysia/router.py b/app/modules/malaysia/router.py new file mode 100644 index 0000000..895cb0c --- /dev/null +++ b/app/modules/malaysia/router.py @@ -0,0 +1,602 @@ +"""말레이시아 창고 재고관리 모듈 라우터. + +- 경로: /malaysia +- 권한: 로그인 + `malaysia` 모듈 권한 (관리자는 항상 통과). 서버 측 검사. +- 데이터: MalaysiaStockStore (malaysia_stock_db / PostgreSQL) 전용. + MALAYSIA_STOCK_DB_URL 미설정 시 store 가 None 이며, 각 페이지는 "설정 필요" 안내. +- 상품명: 전역 itemcode_reader(itemcode_db 읽기 전용). 미설정 시 등록 스냅샷 사용. + +화면(HTML) + JSON API 를 함께 제공한다. JSON API 는 /malaysia/api/* 아래. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from app.timezone import today_kst + +from . import store + +router = APIRouter(prefix="/malaysia", tags=["malaysia"]) + + +# ──────────────────────────────────────────────────────────── +# 공용 헬퍼 +# ──────────────────────────────────────────────────────────── +def _store(request: Request) -> Any: + return getattr(request.app.state, "malaysia_store", None) + + +def _itemcode(request: Request) -> Any: + return getattr(request.app.state, "itemcode_reader", None) + + +def _require_user(request: Request) -> dict[str, Any]: + from app.main import get_current_user_record # noqa: WPS433 + from app.store import has_module # noqa: WPS433 + + user = get_current_user_record(request) + if user is None: + raise HTTPException(status_code=401, detail="로그인이 필요합니다.") + if not has_module(user, "malaysia"): + raise HTTPException(status_code=403, detail="말레이시아 재고관리 모듈 권한이 없습니다.") + return user + + +def _render_config_needed(request: Request, user: dict[str, Any]) -> HTMLResponse: + from app.main import build_erp_nav, render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + return render_template( + request, + "denied.html", + { + "reason": "말레이시아 재고관리 모듈이 아직 설정되지 않았습니다. " + "MALAYSIA_STOCK_DB_URL 환경변수를 설정하고 " + "scripts/sql/malaysia_stock_db_init.sql 로 malaysia_stock_db 를 " + "초기화한 뒤 컨테이너를 재기동하세요.", + "user": user, + "is_admin": is_admin(user), + "nav_items": build_erp_nav(user, active="malaysia"), + }, + status_code=503, + ) + + +def _guard(request: Request) -> tuple[Any, dict[str, Any]] | HTMLResponse | RedirectResponse: + """로그인+권한+store 점검. 페이지 핸들러 진입부에서 사용.""" + from app.main import get_current_user_record, render_template # noqa: WPS433 + from app.store import has_module, is_admin # noqa: WPS433 + + user = get_current_user_record(request) + if user is None: + return RedirectResponse(url="/login", status_code=303) + if not has_module(user, "malaysia"): + return render_template( + request, + "denied.html", + {"reason": "말레이시아 재고관리 모듈 접근 권한이 없습니다.", "is_admin": is_admin(user)}, + status_code=403, + ) + st = _store(request) + if st is None: + return _render_config_needed(request, user) + return st, user + + +def _selected_wh(request: Request, st: Any) -> str: + """쿼리 ?wh= 또는 기본(첫 활성 창고).""" + wh = (request.query_params.get("wh") or "").strip() + warehouses = st.list_warehouses() + codes = [w["warehouse_code"] for w in warehouses] + if wh in codes: + return wh + return codes[0] if codes else "MY-WH-01" + + +def _base_ctx(request: Request, user: dict[str, Any], st: Any, *, wh: str) -> dict[str, Any]: + from app.main import build_erp_nav # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + return { + "user": user, + "is_admin": is_admin(user), + "nav_items": build_erp_nav(user, active="malaysia"), + "warehouses": st.list_warehouses(), + "selected_wh": wh, + } + + +# ════════════════════════════════════════════════════════════ +# 재고 현황 (메인) +# ════════════════════════════════════════════════════════════ +@router.get("/", response_class=HTMLResponse) +async def index(request: Request) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + st, user = guard + wh = _selected_wh(request, st) + ctx = _base_ctx(request, user, st, wh=wh) + ctx.update( + { + "page_title": "말레이시아 재고관리", + "page_subtitle": f"{wh} · 재고 현황", + "rows": st.stock_status(warehouse_code=wh), + } + ) + return render_template(request, "malaysia/index.html", ctx) + + +# ════════════════════════════════════════════════════════════ +# 입고 / 출고 등록 +# ════════════════════════════════════════════════════════════ +@router.get("/movements", response_class=HTMLResponse) +async def movements_page(request: Request) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + st, user = guard + wh = _selected_wh(request, st) + mtype = (request.query_params.get("type") or "").strip().upper() + ctx = _base_ctx(request, user, st, wh=wh) + ctx.update( + { + "page_title": "말레이시아 재고관리 — 입출고", + "page_subtitle": f"{wh} · 입고/출고 등록", + "individual_items": st.list_items(kind="individual"), + "set_items": st.list_items(kind="set"), + "movements": st.list_movements( + warehouse_code=wh, + movement_type=mtype if mtype in store.MOVEMENT_TYPES else None, + limit=200, + ), + "today": today_kst().isoformat(), + "filter_type": mtype if mtype in store.MOVEMENT_TYPES else "", + } + ) + 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: + 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) + + +@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 생성.""" + 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) + + +# ════════════════════════════════════════════════════════════ +# 세트 구성 관리 (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) + + +# ════════════════════════════════════════════════════════════ +# 일일 재고조사 +# ════════════════════════════════════════════════════════════ +@router.get("/stocktakes", response_class=HTMLResponse) +async def stocktakes_page(request: Request) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + st, user = guard + wh = _selected_wh(request, st) + ctx = _base_ctx(request, user, st, wh=wh) + ctx.update( + { + "page_title": "말레이시아 재고관리 — 일일 재고조사", + "page_subtitle": f"{wh} · 조사 목록", + "stocktakes": st.list_stocktakes(warehouse_code=wh), + "today": today_kst().isoformat(), + } + ) + return render_template(request, "malaysia/stocktakes.html", ctx) + + +@router.post("/stocktakes") +async def stocktake_create( + request: Request, + stocktake_date: str = Form(...), + warehouse_code: str = Form(...), + memo: str = Form(""), + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") + try: + head = st.create_stocktake( + stocktake_date=stocktake_date, + warehouse_code=warehouse_code, + memo=memo, + created_by=user["email"], + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return RedirectResponse(url=f"/malaysia/stocktakes/{head['id']}", status_code=303) + + +@router.get("/stocktakes/{stocktake_id:int}", response_class=HTMLResponse) +async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse: + from app.main import render_template # noqa: WPS433 + from app.store import is_admin # noqa: WPS433 + + guard = _guard(request) + if not isinstance(guard, tuple): + return guard + st, user = guard + try: + computed = st.compute_result(stocktake_id=stocktake_id) + except KeyError: + return render_template( + request, "denied.html", + {"reason": "재고조사를 찾을 수 없습니다.", "is_admin": is_admin(user)}, + status_code=404, + ) + head = computed["stocktake"] + wh = head["warehouse_code"] + ctx = _base_ctx(request, user, st, wh=wh) + ctx.update( + { + "page_title": f"재고조사 #{stocktake_id}", + "page_subtitle": f"{head['stocktake_date']} · {wh} · {head['status']}", + "stocktake": head, + "individual_items": st.list_items(kind="individual"), + "set_items": st.list_items(kind="set"), + "result_rows": computed["rows"], + "missing_bom": computed["missing_bom"], + "is_draft": head["status"] == "draft", + } + ) + return render_template(request, "malaysia/stocktake_detail.html", ctx) + + +@router.post("/stocktakes/{stocktake_id:int}/lines") +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), +) -> RedirectResponse: + 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)) + return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303) + + +@router.post("/stocktakes/{stocktake_id:int}/lines/{line_id:int}/delete") +async def stocktake_line_delete( + request: Request, + stocktake_id: int, + line_id: int, + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") + try: + st.delete_line(stocktake_id=stocktake_id, line_id=line_id) + except KeyError: + raise HTTPException(status_code=404, detail="라인을 찾을 수 없습니다.") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303) + + +@router.post("/stocktakes/{stocktake_id:int}/finalize") +async def stocktake_finalize( + request: Request, + stocktake_id: int, + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") + try: + st.finalize_stocktake(stocktake_id=stocktake_id) + except KeyError: + raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303) + + +@router.post("/stocktakes/{stocktake_id:int}/cancel") +async def stocktake_cancel( + request: Request, + stocktake_id: int, + user: dict[str, Any] = Depends(_require_user), +) -> RedirectResponse: + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") + try: + st.cancel_stocktake(stocktake_id=stocktake_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303) + + +# ════════════════════════════════════════════════════════════ +# JSON API (요구사항 6) — /malaysia/api/* +# ════════════════════════════════════════════════════════════ +@router.get("/api/warehouses") +async def api_warehouses( + request: Request, _: dict[str, Any] = Depends(_require_user) +) -> JSONResponse: + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") + return JSONResponse({"warehouses": st.list_warehouses()}) + + +@router.get("/api/items") +async def api_items( + request: Request, kind: str = "", _: dict[str, Any] = Depends(_require_user) +) -> JSONResponse: + """kind=individual → MT/MX/MZ, kind=set → MY, 미지정 → 전체.""" + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") + k = kind if kind in ("individual", "set") else None + return JSONResponse({"items": st.list_items(kind=k)}) + + +@router.get("/api/bom") +async def api_bom( + request: Request, _: dict[str, Any] = Depends(_require_user) +) -> JSONResponse: + 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") +async def api_stock( + request: Request, wh: str = "", _: dict[str, Any] = Depends(_require_user) +) -> JSONResponse: + st = _store(request) + if st is None: + raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정") + wh = wh.strip() or _selected_wh(request, st) + return JSONResponse({"warehouse_code": wh, "rows": st.stock_status(warehouse_code=wh)}) + + +@router.post("/api/movements") +async def api_movement_create( + request: Request, + payload: dict[str, Any] = Body(...), + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + """단일 낱개 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 미설정") + try: + row = st.create_movement( + movement_date=str(payload.get("movement_date") or ""), + warehouse_code=str(payload.get("warehouse_code") or ""), + item_code=str(payload.get("item_code") or ""), + movement_type=str(payload.get("movement_type") or ""), + qty=int(payload.get("qty") or 0), + ref_type=str(payload.get("ref_type") or ""), + ref_no=str(payload.get("ref_no") or ""), + memo=str(payload.get("memo") or ""), + created_by=user["email"], + ) + except (ValueError, TypeError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return JSONResponse({"ok": True, "movement": row}) + + +@router.post("/api/movements/set-out") +async def api_set_out( + request: Request, + payload: dict[str, Any] = Body(...), + user: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + """세트 출고(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 미설정") + try: + rows = st.create_set_out( + movement_date=str(payload.get("movement_date") or ""), + warehouse_code=str(payload.get("warehouse_code") or ""), + set_code=str(payload.get("set_code") or ""), + set_qty=int(payload.get("set_qty") or 0), + ref_type=str(payload.get("ref_type") or "set"), + ref_no=str(payload.get("ref_no") or ""), + memo=str(payload.get("memo") or ""), + created_by=user["email"], + ) + except (ValueError, TypeError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return JSONResponse({"ok": True, "movements": rows}) + + +@router.get("/api/stocktakes/{stocktake_id:int}/result") +async def api_stocktake_result( + request: Request, + stocktake_id: int, + _: dict[str, Any] = Depends(_require_user), +) -> JSONResponse: + """재고조사 최종 계산 결과(낱개 기준 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) + except KeyError: + raise HTTPException(status_code=404, detail="재고조사를 찾을 수 없습니다.") + return JSONResponse( + { + "stocktake": computed["stocktake"], + "rows": computed["rows"], + "missing_bom": computed["missing_bom"], + } + ) + + +@router.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok", "module": "malaysia"} diff --git a/app/modules/malaysia/store.py b/app/modules/malaysia/store.py new file mode 100644 index 0000000..6ed312f --- /dev/null +++ b/app/modules/malaysia/store.py @@ -0,0 +1,226 @@ +"""말레이시아 창고 재고관리 모듈 — 상수 및 순수 계산/검증 헬퍼. + +- 데이터 저장은 malaysia_stock_db(PostgreSQL) 전용이다(`db.py`). + 운영 데이터가 JSON 과 DB 로 갈라지는 것을 막기 위해 JSON 폴백을 두지 않는다. + MALAYSIA_STOCK_DB_URL 미설정 시 라우터가 "설정 필요" 안내 페이지를 보여준다. +- 이 모듈에는 DB 의존이 없는 상수와 순수 함수만 둔다(테스트 용이). + prefix 검증, 세트 BOM 분해, 재고조사 집계가 모두 여기 있다. + +코드 prefix 규칙(요구사항 8 검증): + - 낱개(개별) 아이템 : MT-, MX-, MZ- → 입고/출고/조정 movement 가능 + - 세트 아이템 : MY- → movement 불가, 재고조사/BOM 만 가능 + - 뚜껑(제외) : MD- → 어디에도 사용 불가 +""" + +from __future__ import annotations + +from typing import Any, Iterable + +# ── 이동(movement) 종류 ── +MOVEMENT_TYPES: tuple[str, ...] = ("IN", "OUT", "ADJUST", "STOCKTAKE") + +# ── 재고조사 상태 ── +STOCKTAKE_STATUSES: tuple[str, ...] = ("draft", "finalized", "cancelled") + +# ── 코드 prefix ── +INDIVIDUAL_PREFIXES: tuple[str, ...] = ("MT-", "MX-", "MZ-") +SET_PREFIX: str = "MY-" +BLOCKED_PREFIX: str = "MD-" # 뚜껑 — 재고관리 대상에서 제외 + + +# ════════════════════════════════════════════════════════════ +# 코드 분류 / 검증 (순수 함수) +# ════════════════════════════════════════════════════════════ +def _norm(code: str) -> str: + return (code or "").strip().upper() + + +def is_individual_code(code: str) -> bool: + """낱개 아이템(MT-/MX-/MZ-) 여부.""" + return _norm(code).startswith(INDIVIDUAL_PREFIXES) + + +def is_set_code(code: str) -> bool: + """세트 아이템(MY-) 여부.""" + return _norm(code).startswith(SET_PREFIX) + + +def is_blocked_code(code: str) -> bool: + """제외 대상(MD- 뚜껑) 여부.""" + return _norm(code).startswith(BLOCKED_PREFIX) + + +def classify_code(code: str) -> str: + """코드 종류를 반환. 'individual' | 'set' | 'blocked' | 'unknown'.""" + if is_blocked_code(code): + return "blocked" + if is_individual_code(code): + return "individual" + if is_set_code(code): + return "set" + return "unknown" + + +def validate_movement_code(code: str) -> str: + """입고/출고/조정 movement 에 쓸 수 있는 코드인지 검증. + + 낱개(MT-/MX-/MZ-)만 허용. MY-/MD-/기타는 ValueError. + 반환: 정규화된 코드(대문자/trim). + """ + c = _norm(code) + if not c: + raise ValueError("아이템 코드가 비어 있습니다.") + kind = classify_code(c) + if kind == "blocked": + raise ValueError(f"뚜껑 코드(MD-)는 재고관리 대상이 아닙니다: {c}") + if kind == "set": + raise ValueError(f"세트 코드(MY-)는 입고/출고에 직접 쓸 수 없습니다: {c}") + if kind != "individual": + raise ValueError(f"허용되지 않는 코드입니다(MT-/MX-/MZ- 만 가능): {c}") + return c + + +def validate_stocktake_code(code: str) -> str: + """재고조사 라인(daily_stocktake_line)에 쓸 수 있는 코드인지 검증. + + 낱개(MT-/MX-/MZ-) 또는 세트(MY-) 허용. MD-/기타는 ValueError. + """ + c = _norm(code) + if not c: + raise ValueError("SKU 코드가 비어 있습니다.") + kind = classify_code(c) + if kind == "blocked": + raise ValueError(f"뚜껑 코드(MD-)는 재고조사 대상이 아닙니다: {c}") + if kind not in ("individual", "set"): + raise ValueError(f"허용되지 않는 코드입니다(MT-/MX-/MZ-/MY- 만 가능): {c}") + return c + + +def validate_bom_set_code(code: str) -> str: + """set_bom 의 set_code 검증 — MY- 만 허용.""" + c = _norm(code) + if not is_set_code(c): + raise ValueError(f"세트 코드는 MY- 로 시작해야 합니다: {c}") + return c + + +def validate_bom_component_code(code: str) -> str: + """set_bom 의 component_code 검증 — MT-/MX-/MZ- 만 허용(MY-/MD- 금지).""" + c = _norm(code) + if is_blocked_code(c): + raise ValueError(f"뚜껑 코드(MD-)는 세트 구성품이 될 수 없습니다: {c}") + if is_set_code(c): + raise ValueError(f"세트(MY-)가 세트의 구성품이 될 수 없습니다(중첩 불가): {c}") + if not is_individual_code(c): + raise ValueError(f"구성품은 MT-/MX-/MZ- 만 가능합니다: {c}") + return c + + +def validate_qty(qty: Any, *, allow_zero: bool = True, allow_negative: bool = False) -> int: + """수량 검증/정규화. 정수 변환 + 음수/0 정책 적용.""" + try: + n = int(qty) + except (TypeError, ValueError): + raise ValueError(f"수량은 정수여야 합니다: {qty!r}") + if not allow_negative and n < 0: + raise ValueError(f"수량은 음수가 될 수 없습니다: {n}") + if not allow_zero and n == 0: + raise ValueError("수량은 0이 될 수 없습니다.") + return n + + +# ════════════════════════════════════════════════════════════ +# 세트 BOM 분해 / 재고조사 집계 (순수 함수 — DB 무관, 테스트 용이) +# ════════════════════════════════════════════════════════════ +def explode_set( + set_code: str, set_qty: int, bom_map: dict[str, list[dict[str, Any]]] +) -> dict[str, int]: + """세트 1종 × 수량을 BOM 기준으로 낱개 아이템 수량으로 분해. + + bom_map: { set_code: [{"component_code": str, "component_qty": int}, ...] } + 반환: { component_code: 분해수량 } + BOM 이 없으면 빈 dict(호출측에서 경고 처리). + """ + out: dict[str, int] = {} + for comp in bom_map.get(_norm(set_code), []): + code = _norm(comp.get("component_code")) + try: + per = int(comp.get("component_qty") or 0) + except (TypeError, ValueError): + per = 0 + if not code or per <= 0: + continue + out[code] = out.get(code, 0) + per * int(set_qty) + return out + + +def explode_stocktake( + lines: Iterable[dict[str, Any]], + bom_map: dict[str, list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + """재고조사 라인을 낱개 아이템 기준 최종 수량으로 집계. + + 입력 라인: [{"sku_code": str, "qty": int}, ...] + - 낱개 코드(MT/MX/MZ) qty → direct_qty 로 누적 + - 세트 코드(MY) qty → set_bom 으로 분해해 from_set_qty 로 누적 + + 반환(낱개 아이템 코드 기준): + { item_code: { + "direct_qty": 직접 조사 수량 합, + "from_set_qty": 세트 분해 수량 합, + "total_qty": direct_qty + from_set_qty, + } } + + 추가로 결과에 포함되는 진단 키는 없다(순수 수량만). 세트 BOM 누락 + 경고는 `missing_bom_sets()` 로 별도 확인한다. + """ + result: dict[str, dict[str, int]] = {} + + def _bucket(code: str) -> dict[str, int]: + return result.setdefault(code, {"direct_qty": 0, "from_set_qty": 0}) + + for ln in lines: + code = _norm(ln.get("sku_code")) + try: + qty = int(ln.get("qty") or 0) + except (TypeError, ValueError): + qty = 0 + if not code or qty < 0: + continue + if is_individual_code(code): + _bucket(code)["direct_qty"] += qty + elif is_set_code(code): + for comp_code, comp_qty in explode_set(code, qty, bom_map).items(): + _bucket(comp_code)["from_set_qty"] += comp_qty + # blocked/unknown 은 무시(검증 단계에서 이미 막힘) + + out: dict[str, dict[str, Any]] = {} + for code, v in result.items(): + total = v["direct_qty"] + v["from_set_qty"] + out[code] = { + "item_code": code, + "direct_qty": v["direct_qty"], + "from_set_qty": v["from_set_qty"], + "total_qty": total, + } + return out + + +def missing_bom_sets( + lines: Iterable[dict[str, Any]], + bom_map: dict[str, list[dict[str, Any]]], +) -> list[str]: + """재고조사에 입력된 MY- 세트 중 BOM 구성이 없는 코드 목록. + + 요구사항 8: 'BOM 이 없는 MY- 코드가 재고조사에 입력되면 경고/오류'. + """ + missing: list[str] = [] + seen: set[str] = set() + for ln in lines: + code = _norm(ln.get("sku_code")) + if not is_set_code(code) or code in seen: + continue + seen.add(code) + if not bom_map.get(code): + missing.append(code) + return missing diff --git a/app/modules/malaysia/templates/malaysia/_nav.html b/app/modules/malaysia/templates/malaysia/_nav.html new file mode 100644 index 0000000..e083a3a --- /dev/null +++ b/app/modules/malaysia/templates/malaysia/_nav.html @@ -0,0 +1,18 @@ +{# 말레이시아 재고관리 공용 상단 바: 서브탭 + 창고 선택 #} +
+ 재고 현황 + 입출고 + 일일 재고조사 + 세트 구성 + + + +
+ + +
+
diff --git a/app/modules/malaysia/templates/malaysia/index.html b/app/modules/malaysia/templates/malaysia/index.html new file mode 100644 index 0000000..08d08b9 --- /dev/null +++ b/app/modules/malaysia/templates/malaysia/index.html @@ -0,0 +1,35 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ {% set active_tab = 'status' %} + {% include "malaysia/_nav.html" %} + +
+
+

재고 현황 ({{ rows|length }})

+ 현재고 = 입고 − 출고 + 조정 + 재고조사반영 (낱개 아이템 기준) +
+
+ + + + + + {% for r in rows %} + + + + + + + {% endfor %} + {% if not rows %} + + {% endif %} + +
Item CodeProduct NameCurrent QtyLast Stocktake
{{ r.item_code }}{{ r.item_name }}{{ r.current_qty }}{{ r.last_stocktake_date or '—' }}
표시할 재고가 없습니다.
+
+
+
+{% endblock %} diff --git a/app/modules/malaysia/templates/malaysia/movements.html b/app/modules/malaysia/templates/malaysia/movements.html new file mode 100644 index 0000000..4aed52f --- /dev/null +++ b/app/modules/malaysia/templates/malaysia/movements.html @@ -0,0 +1,103 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ {% set active_tab = 'move' %} + {% include "malaysia/_nav.html" %} + +
+ + +
+

입고 / 출고 등록 (낱개)

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

세트 출고 (BOM 분해)

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

최근 이동 이력

+ + 전체 + {% for t in ['IN','OUT','ADJUST','STOCKTAKE'] %} + {{ t }} + {% endfor %} + +
+
+ + + + {% for m in movements %} + + + + + + + + + + {% endfor %} + {% if not movements %} + + {% endif %} + +
DateTypeItemQtyRefMemoBy
{{ m.movement_date }}{{ m.movement_type }}{{ m.item_code }}{{ m.qty }}{{ m.ref_type }}{% if m.ref_no %} / {{ m.ref_no }}{% endif %}{{ m.memo or '—' }}{{ m.created_by or '—' }}
이동 이력이 없습니다.
+
+
+
+{% endblock %} diff --git a/app/modules/malaysia/templates/malaysia/sets.html b/app/modules/malaysia/templates/malaysia/sets.html new file mode 100644 index 0000000..b54d942 --- /dev/null +++ b/app/modules/malaysia/templates/malaysia/sets.html @@ -0,0 +1,69 @@ +{% 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 new file mode 100644 index 0000000..d920827 --- /dev/null +++ b/app/modules/malaysia/templates/malaysia/stocktake_detail.html @@ -0,0 +1,122 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ + +
+
+

재고조사 #{{ stocktake.id }} — {{ stocktake.stocktake_date }} · {{ stocktake.warehouse_code }}

+ + {% if stocktake.status=='finalized' %}확정 ({{ stocktake.finalized_at }}) + {% elif stocktake.status=='cancelled' %}취소 + {% else %}작성중{% endif %} + +
+ + {% if missing_bom %} +

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

+ {% endif %} +
+ + {% if is_draft %} +
+ +
+

① 낱개 아이템 입력

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

② 세트 아이템 입력

+ 미리 포장된 세트 재고 수량 (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 '—' }}
입력된 라인이 없습니다.
+
+
+ + +
+

최종 계산 (낱개 기준)

+ total_qty = direct_qty(낱개 직접) + from_set_qty(세트 분해) +
+ + + + {% for r in result_rows %} + + + + + + + + {% endfor %} + {% if not result_rows %} + + {% endif %} + +
Item CodeProduct NameDirectFrom SetTotal
{{ r.item_code }}{{ r.item_name }}{{ r.direct_qty }}{{ r.from_set_qty }}{{ r.total_qty }}
계산 결과가 없습니다.
+
+
+ + {% if is_draft %} +
+
+ +
+
+ +
+
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/malaysia/templates/malaysia/stocktakes.html b/app/modules/malaysia/templates/malaysia/stocktakes.html new file mode 100644 index 0000000..0ca6776 --- /dev/null +++ b/app/modules/malaysia/templates/malaysia/stocktakes.html @@ -0,0 +1,54 @@ +{% extends "erp_base.html" %} + +{% block content %} +
+ {% set active_tab = 'stocktake' %} + {% include "malaysia/_nav.html" %} + +
+ + +
+

재고조사 생성

+
+ + + +
+
+

생성 후 상세에서 낱개/세트 수량을 입력하고 확정하세요.

+
+ + +
+

조사 목록 ({{ stocktakes|length }})

+
+ + + + {% for s in stocktakes %} + + + + + + + + + {% endfor %} + {% if not stocktakes %} + + {% endif %} + +
#DateWarehouseStatusBy
{{ s.id }}{{ s.stocktake_date }}{{ s.warehouse_code }} + {% if s.status=='finalized' %}확정 + {% elif s.status=='cancelled' %}취소 + {% else %}작성중{% endif %} + {{ s.created_by or '—' }}열기
재고조사가 없습니다.
+
+
+
+
+{% endblock %} diff --git a/app/store.py b/app/store.py index 1612b1f..4443371 100644 --- a/app/store.py +++ b/app/store.py @@ -28,6 +28,7 @@ MODULE_KEYS: tuple[str, ...] = ( "expense", "vacation", "cupang", + "malaysia", "expense_approver", "vacation_approver", ) diff --git a/docs/DATABASES.md b/docs/DATABASES.md index 8de1b3d..86392fd 100644 --- a/docs/DATABASES.md +++ b/docs/DATABASES.md @@ -9,6 +9,7 @@ | `return_db` | 반품·교환·CS 데이터 | | `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 | | `cupang_db` | 쿠팡 밀크런 출고 묶음 / 출고 라인 / 입고센터 / 박스 입수량 규칙 | +| `malaysia_stock_db` | 말레이시아 창고 재고관리 — 창고/아이템/세트 BOM/입출고 이력/일일 재고조사 | --- @@ -256,6 +257,39 @@ cd /opt/www/main && docker compose up -d --build --- +## malaysia_stock_db 스키마 / 초기화 + +DDL: `scripts/sql/malaysia_stock_db_init.sql` (멱등). DB·역할(`malaysia_app`)·테이블·인덱스·트리거·창고/아이템 seed 를 한 번에 생성. **JSON 폴백 없음** — `MALAYSIA_STOCK_DB_URL` 미설정 시 모듈이 "설정 필요" 안내만 표시. 상품명은 `itemcode_db` 읽기 전용 재사용. + +테이블: + +| 테이블 | 용도 | +| --- | --- | +| `warehouses` | 창고(`warehouse_code` UNIQUE). seed: `MY-WH-01 / Malaysia Warehouse` | +| `malaysia_items` | 관리 대상 코드 스코프 + 종류(`individual`/`set`) + 이름 스냅샷. prefix CHECK. seed: 낱개 12 + 세트 5 | +| `set_bom` | 세트 구성표. `set_code`(MY-)·`component_code`(MT/MX/MZ) prefix CHECK, `UNIQUE(set_code, component_code)` | +| `stock_movement` | 입고/출고/조정/조사 이력. `item_code` 낱개만(CHECK), `movement_type` IN/OUT/ADJUST/STOCKTAKE. `ref_type`·`ref_no`(Shopee/Lazada 연동용) | +| `daily_stocktake` | 재고조사 헤더. `status` draft/finalized/cancelled. 같은 날짜+창고 finalized 1건(부분 유니크 인덱스) | +| `daily_stocktake_line` | 조사 라인. `sku_code` 낱개+세트 허용(MD- 금지), `qty>=0`, `UNIQUE(stocktake_id, sku_code)` | + +현재고 = `SUM(IN) - SUM(OUT) + SUM(ADJUST) + SUM(STOCKTAKE)`. 재고조사 확정 시 시스템 재고와 조사 최종치의 **차이만** STOCKTAKE movement 로 기록. 세트는 movement 불가, 재고조사/BOM 계산 전용. MD- 뚜껑은 전 영역 제외. + +### 운영 서버 초기화 (1회, 사용자 승인 후) + +```bash +read -s -p "malaysia_app password: " APP_PWD; echo +docker exec -i postgres-db psql -U postgres \ + -v app_password="$APP_PWD" \ + < scripts/sql/malaysia_stock_db_init.sql +# main-app .env 에 추가: +# MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:@postgres-db:5432/malaysia_stock_db +cd /opt/www/main && docker compose up -d --build +``` + +> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음. + +--- + ## 백업 / 복구 (안전 절차) ### 백업 diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md index 2856111..69aa381 100644 --- a/docs/PROJECT_OVERVIEW.md +++ b/docs/PROJECT_OVERVIEW.md @@ -20,13 +20,14 @@ | 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 | | 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 | | 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 | +| 말레이시아 재고 | 말레이시아 창고 재고관리 (`app/modules/malaysia/`) — 낱개 입출고/조정, 세트 BOM, 일일 재고조사(세트 분해 자동 반영), 현재고 현황. 상품명은 `itemcode_db` 읽기 전용 참조 | ### 권한 키 (`MODULE_KEYS`) | 키 | 종류 | 설명 | | --- | --- | --- | | `corm` / `order` | 접근 | 외부 모듈 진입 | -| `expense` / `vacation` / `cupang` | 접근 | 내부 모듈 진입 | +| `expense` / `vacation` / `cupang` / `malaysia` | 접근 | 내부 모듈 진입 | | `expense_approver` | 결재 | 개인경비 승인/반려/정산 | | `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) | @@ -89,6 +90,7 @@ app/modules// | `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) | | `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) | | `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) | +| `malaysia_stock_db` | 말레이시아 창고 재고/세트BOM/재고조사 (`MALAYSIA_STOCK_DB_URL` 필수, JSON 폴백 없음) | > 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`. diff --git a/scripts/sql/malaysia_stock_db_init.sql b/scripts/sql/malaysia_stock_db_init.sql new file mode 100644 index 0000000..df29cc4 --- /dev/null +++ b/scripts/sql/malaysia_stock_db_init.sql @@ -0,0 +1,264 @@ +-- ===================================================================== +-- malaysia_stock_db 초기화 스크립트 (PostgreSQL) — 말레이시아 창고 재고관리 +-- ===================================================================== +-- 멱등(idempotent): 여러 번 실행해도 안전. 기존 데이터를 삭제하지 않는다. +-- +-- ⚠️ 실행 전 사용자 승인 + 백업 확인 필수. DROP/TRUNCATE 없음. +-- +-- 실행 방법 (운영 PostgreSQL 컨테이너명: postgres-db): +-- +-- 1) DB / 역할 / 스키마 생성 (superuser 로 1회) +-- read -s -p "malaysia_app password: " APP_PWD; echo +-- docker exec -i postgres-db psql -U postgres \ +-- -v app_password="$APP_PWD" \ +-- < scripts/sql/malaysia_stock_db_init.sql +-- +-- 2) main-app .env 에 연결 정보 등록 +-- MALAYSIA_STOCK_DB_URL=postgresql://malaysia_app:@postgres-db:5432/malaysia_stock_db +-- +-- 3) main-app 재기동 +-- cd /opt/www/main && docker compose up -d --build +-- +-- 주의: +-- - 기존 DB 가 있으면 DROP 하지 않는다(CREATE DATABASE 는 미존재 시에만). +-- - 비밀번호는 절대 Git 에 커밋하지 않는다. psql -v 로만 전달. +-- - itemcode_db 는 이 스크립트가 건드리지 않는다(상품명은 읽기 전용 참조). +-- - 재고 계산 원칙: +-- 현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST 부호) + SUM(STOCKTAKE 부호) +-- ADJUST/STOCKTAKE 는 음수 qty 허용(±). IN/OUT 은 양수만. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- DB 가 없을 때만 생성 +SELECT 'CREATE DATABASE malaysia_stock_db ENCODING ''UTF8'' TEMPLATE template0' +WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = 'malaysia_stock_db') +\gexec + +-- 앱 전용 로그인 역할 (cupang_app 패턴과 동일) +SELECT 'CREATE ROLE malaysia_app LOGIN PASSWORD ' || quote_literal(:'app_password') +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'malaysia_app') +\gexec + +-- 항상 최신 비밀번호로 동기화 +SELECT 'ALTER ROLE malaysia_app WITH LOGIN PASSWORD ' || quote_literal(:'app_password') +\gexec + +GRANT CONNECT ON DATABASE malaysia_stock_db TO malaysia_app; + +-- malaysia_stock_db 컨텍스트로 전환 +\connect malaysia_stock_db + +-- ── updated_at 자동 갱신 트리거 함수 (멱등: CREATE OR REPLACE) ── +CREATE OR REPLACE FUNCTION malaysia_set_updated_at() RETURNS trigger AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ════════════════════════════════════════════════════════════ +-- 1) 창고 +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS warehouses ( + id BIGSERIAL PRIMARY KEY, + warehouse_code TEXT NOT NULL UNIQUE, + warehouse_name TEXT NOT NULL, + country TEXT NOT NULL DEFAULT 'MY', + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +DROP TRIGGER IF EXISTS trg_warehouses_updated ON warehouses; +CREATE TRIGGER trg_warehouses_updated + BEFORE UPDATE ON warehouses + FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 2) 재고관리 대상 아이템(스코프 카탈로그) +-- 상품 마스터를 복제하는 것이 아니라, "말레이시아 창고에서 관리하는 +-- 코드 목록과 종류(낱개/세트)"만 보관한다. item_name 은 스냅샷이며 +-- itemcode_db(읽기 전용)에서 최신 명칭을 덮어쓸 수 있다. +-- kind: 'individual'(MT/MX/MZ) | 'set'(MY). MD- 뚜껑은 제외(미등록). +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS malaysia_items ( + id BIGSERIAL PRIMARY KEY, + item_code TEXT NOT NULL UNIQUE, + item_name TEXT NOT NULL DEFAULT '', + kind TEXT NOT NULL CHECK (kind IN ('individual', 'set')), + active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- prefix 규칙: 낱개는 MT-/MX-/MZ-, 세트는 MY-. MD- 금지. + CHECK ( + (kind = 'individual' AND (item_code LIKE 'MT-%' OR item_code LIKE 'MX-%' OR item_code LIKE 'MZ-%')) + OR + (kind = 'set' AND item_code LIKE 'MY-%') + ) +); +CREATE INDEX IF NOT EXISTS idx_malaysia_items_kind ON malaysia_items (kind); + +DROP TRIGGER IF EXISTS trg_malaysia_items_updated ON malaysia_items; +CREATE TRIGGER trg_malaysia_items_updated + BEFORE UPDATE ON malaysia_items + FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 3) 세트 구성표 (BOM) +-- set_code(MY-) 안에 component_code(MT-/MX-/MZ-) 가 몇 개 들어가는지. +-- MY- 가 MY- 를 포함하는 중첩은 불가(component 는 MY- 금지). MD- 금지. +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS set_bom ( + id BIGSERIAL PRIMARY KEY, + set_code TEXT NOT NULL CHECK (set_code LIKE 'MY-%'), + component_code TEXT NOT NULL CHECK ( + component_code LIKE 'MT-%' + OR component_code LIKE 'MX-%' + OR component_code LIKE 'MZ-%'), + component_qty INTEGER NOT NULL CHECK (component_qty > 0), + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (set_code, component_code) +); +CREATE INDEX IF NOT EXISTS idx_set_bom_set ON set_bom (set_code); + +DROP TRIGGER IF EXISTS trg_set_bom_updated ON set_bom; +CREATE TRIGGER trg_set_bom_updated + BEFORE UPDATE ON set_bom + FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 4) 재고 이동 이력 (입고/출고/조정/재고조사 반영) +-- item_code 는 낱개(MT-/MX-/MZ-)만 저장한다. MY-/MD- 는 movement 금지. +-- qty 부호 규칙: +-- IN/OUT → 양수만(방향은 movement_type 으로 구분) +-- ADJUST → 음수 허용(±) +-- STOCKTAKE → 음수 허용(±, 재고조사 차이 반영분) +-- 현재고 = SUM(IN) - SUM(OUT) + SUM(ADJUST) + SUM(STOCKTAKE) +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS stock_movement ( + id BIGSERIAL PRIMARY KEY, + movement_date DATE NOT NULL, + warehouse_code TEXT NOT NULL REFERENCES warehouses(warehouse_code), + item_code TEXT NOT NULL CHECK ( + item_code LIKE 'MT-%' + OR item_code LIKE 'MX-%' + OR item_code LIKE 'MZ-%'), + movement_type TEXT NOT NULL CHECK (movement_type IN ('IN', 'OUT', 'ADJUST', 'STOCKTAKE')), + qty INTEGER NOT NULL, + ref_type TEXT NOT NULL DEFAULT '', -- 예: shopee/lazada/stocktake/manual + ref_no TEXT NOT NULL DEFAULT '', -- 외부 주문번호/조사 id 등 + memo TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- IN/OUT 은 양수만. ADJUST/STOCKTAKE 만 음수 허용. + CHECK ( + (movement_type IN ('IN', 'OUT') AND qty > 0) + OR movement_type IN ('ADJUST', 'STOCKTAKE') + ) +); +CREATE INDEX IF NOT EXISTS idx_stock_mv_wh_item ON stock_movement (warehouse_code, item_code); +CREATE INDEX IF NOT EXISTS idx_stock_mv_date ON stock_movement (movement_date); +CREATE INDEX IF NOT EXISTS idx_stock_mv_type ON stock_movement (movement_type); +CREATE INDEX IF NOT EXISTS idx_stock_mv_ref ON stock_movement (ref_type, ref_no); + +-- ════════════════════════════════════════════════════════════ +-- 5) 일일 재고조사 (헤더) +-- 같은 날짜 + 같은 창고에 finalized 는 1건만 허용(부분 유니크 인덱스). +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS daily_stocktake ( + id BIGSERIAL PRIMARY KEY, + stocktake_date DATE NOT NULL, + warehouse_code TEXT NOT NULL REFERENCES warehouses(warehouse_code), + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'finalized', 'cancelled')), + memo TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finalized_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_stocktake_date ON daily_stocktake (stocktake_date); +CREATE INDEX IF NOT EXISTS idx_stocktake_wh ON daily_stocktake (warehouse_code); +-- 같은 날짜+창고 finalized 중복 방지 +CREATE UNIQUE INDEX IF NOT EXISTS uq_stocktake_finalized + ON daily_stocktake (stocktake_date, warehouse_code) + WHERE status = 'finalized'; + +DROP TRIGGER IF EXISTS trg_stocktake_updated ON daily_stocktake; +CREATE TRIGGER trg_stocktake_updated + BEFORE UPDATE ON daily_stocktake + FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 6) 일일 재고조사 라인 +-- sku_code: 낱개(MT-/MX-/MZ-) 또는 세트(MY-) 허용. MD- 금지. +-- 같은 조사 안에서 같은 sku_code 중복 금지. +-- ════════════════════════════════════════════════════════════ +CREATE TABLE IF NOT EXISTS daily_stocktake_line ( + id BIGSERIAL PRIMARY KEY, + stocktake_id BIGINT NOT NULL REFERENCES daily_stocktake(id) ON DELETE CASCADE, + sku_code TEXT NOT NULL CHECK ( + sku_code LIKE 'MT-%' + OR sku_code LIKE 'MX-%' + OR sku_code LIKE 'MZ-%' + OR sku_code LIKE 'MY-%'), + qty INTEGER NOT NULL CHECK (qty >= 0), + memo TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (stocktake_id, sku_code) +); +CREATE INDEX IF NOT EXISTS idx_stocktake_line_st ON daily_stocktake_line (stocktake_id); + +DROP TRIGGER IF EXISTS trg_stocktake_line_updated ON daily_stocktake_line; +CREATE TRIGGER trg_stocktake_line_updated + BEFORE UPDATE ON daily_stocktake_line + FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at(); + +-- ════════════════════════════════════════════════════════════ +-- 7) seed — 기본 창고 + 재고관리 대상 아이템 +-- ════════════════════════════════════════════════════════════ +INSERT INTO warehouses (warehouse_code, warehouse_name, country) VALUES + ('MY-WH-01', 'Malaysia Warehouse', 'MY') +ON CONFLICT (warehouse_code) DO NOTHING; + +-- 낱개 아이템 (MT-/MX-/MZ-). MD- 뚜껑은 의도적으로 제외. +INSERT INTO malaysia_items (item_code, item_name, kind, sort_order) VALUES + ('MT-0320', 'Miracle Container 320ml', 'individual', 1), + ('MT-0550', 'Miracle Container 550ml', 'individual', 2), + ('MT-0750', 'Miracle Container 750ml', 'individual', 3), + ('MT-0950', 'Miracle Container 950ml', 'individual', 4), + ('MT-1800', 'Miracle Container 1800ml', 'individual', 5), + ('MT-2200', 'Miracle Container 2200ml', 'individual', 6), + ('MT-3200', 'Miracle Container 3200ml', 'individual', 7), + ('MT-5000', 'Miracle Container 5000ml', 'individual', 8), + ('MX-0001', 'Miracle Bag 3 Types', 'individual', 9), + ('MX-0003', 'Miracle Bag 5 pcs', 'individual', 10), + ('MZ-0001', 'Miracle Zipperbag 2.5L', 'individual', 11), + ('MZ-0002', 'Miracle Zipperbag 6.0L', 'individual', 12), + ('MY-0001', 'Launching Special', 'set', 21), + ('MY-0002', 'Starter Set', 'set', 22), + ('MY-0003', 'Essential Set', 'set', 23), + ('MY-0004', 'Premium Set', 'set', 24), + ('MY-0005', 'Ultimate Set', 'set', 25) +ON CONFLICT (item_code) DO NOTHING; + +-- ════════════════════════════════════════════════════════════ +-- 8) 권한 (malaysia_app: CRUD only) +-- ════════════════════════════════════════════════════════════ +GRANT USAGE ON SCHEMA public TO malaysia_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON + warehouses, malaysia_items, set_bom, stock_movement, + daily_stocktake, daily_stocktake_line + TO malaysia_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO malaysia_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO malaysia_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO malaysia_app; + +SELECT 'malaysia_stock_db ready' AS status; diff --git a/tests/test_malaysia_stock.py b/tests/test_malaysia_stock.py new file mode 100644 index 0000000..7be3206 --- /dev/null +++ b/tests/test_malaysia_stock.py @@ -0,0 +1,197 @@ +"""말레이시아 재고관리 — 순수 로직 테스트 (DB 불필요). + +실행: + cd /opt/www/main (또는 개발 PC 프로젝트 루트) + python -m pytest tests/test_malaysia_stock.py -v + +pytest 미설치 시: + python tests/test_malaysia_stock.py # __main__ 폴백으로 assert 실행 +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +try: + import pytest +except ModuleNotFoundError: # pytest 미설치 → 최소 shim 으로 standalone 실행 지원 + class _PytestShim: + class mark: + @staticmethod + def parametrize(argnames, argvalues): + def deco(fn): + fn._params = (argnames, list(argvalues)) + return fn + return deco + + class raises: + def __init__(self, exc): + self.exc = exc + + def __enter__(self): + return self + + def __exit__(self, etype, evalue, tb): + if etype is None: + raise AssertionError(f"{self.exc.__name__} 가 발생하지 않음") + return issubclass(etype, self.exc) + + pytest = _PytestShim() # type: ignore + +from app.modules.malaysia import store + + +# ──────────────────────────────────────────────────────────── +# 코드 분류 +# ──────────────────────────────────────────────────────────── +def test_classify_code(): + assert store.classify_code("MT-0320") == "individual" + assert store.classify_code("MX-0001") == "individual" + assert store.classify_code("MZ-0002") == "individual" + assert store.classify_code("MY-0002") == "set" + assert store.classify_code("MD-0000") == "blocked" + assert store.classify_code("ZZ-9999") == "unknown" + # 소문자/공백 정규화 + assert store.classify_code(" mt-0320 ") == "individual" + + +# ──────────────────────────────────────────────────────────── +# movement 코드 검증 — MT/MX/MZ 허용, MY/MD 차단 +# ──────────────────────────────────────────────────────────── +def test_validate_movement_code_ok(): + assert store.validate_movement_code("mt-0320") == "MT-0320" + + +@pytest.mark.parametrize("bad", ["MY-0002", "MD-0000", "ZZ-1", ""]) +def test_validate_movement_code_blocks(bad): + with pytest.raises(ValueError): + store.validate_movement_code(bad) + + +# ──────────────────────────────────────────────────────────── +# 재고조사 코드 검증 — MT/MX/MZ/MY 허용, MD 차단 +# ──────────────────────────────────────────────────────────── +def test_validate_stocktake_code(): + assert store.validate_stocktake_code("MY-0002") == "MY-0002" + assert store.validate_stocktake_code("MT-0320") == "MT-0320" + with pytest.raises(ValueError): + store.validate_stocktake_code("MD-0001") + + +# ──────────────────────────────────────────────────────────── +# set_bom 검증 — set 은 MY-, component 는 MT/MX/MZ (중첩/MD 금지) +# ──────────────────────────────────────────────────────────── +def test_validate_bom(): + assert store.validate_bom_set_code("MY-0001") == "MY-0001" + assert store.validate_bom_component_code("MT-0320") == "MT-0320" + with pytest.raises(ValueError): + store.validate_bom_set_code("MT-0320") # set 자리에 낱개 + with pytest.raises(ValueError): + store.validate_bom_component_code("MY-0002") # 세트 중첩 금지 + with pytest.raises(ValueError): + store.validate_bom_component_code("MD-0000") # 뚜껑 금지 + + +def test_validate_qty(): + assert store.validate_qty(5) == 5 + assert store.validate_qty(0) == 0 + assert store.validate_qty(-3, allow_negative=True) == -3 + with pytest.raises(ValueError): + store.validate_qty(-1) + with pytest.raises(ValueError): + store.validate_qty(0, allow_zero=False) + + +# ──────────────────────────────────────────────────────────── +# 세트 분해 +# ──────────────────────────────────────────────────────────── +def _bom_map(): + # MY-0002 Starter Set = MT-0320 x1 + MT-0550 x2 + return { + "MY-0002": [ + {"component_code": "MT-0320", "component_qty": 1}, + {"component_code": "MT-0550", "component_qty": 2}, + ], + "MY-0003": [ + {"component_code": "MT-0320", "component_qty": 1}, + ], + } + + +def test_explode_set(): + out = store.explode_set("MY-0002", 20, _bom_map()) + assert out == {"MT-0320": 20, "MT-0550": 40} + + +def test_explode_set_missing_returns_empty(): + assert store.explode_set("MY-9999", 5, _bom_map()) == {} + + +# ──────────────────────────────────────────────────────────── +# 재고조사 집계 — 요구사항 예시 검증 +# MT-0320 낱개 100 + MY-0002 20세트(MT-0320 1개 포함) → 120 +# ──────────────────────────────────────────────────────────── +def test_explode_stocktake_example(): + lines = [ + {"sku_code": "MT-0320", "qty": 100}, + {"sku_code": "MY-0002", "qty": 20}, + ] + res = store.explode_stocktake(lines, _bom_map()) + assert res["MT-0320"]["direct_qty"] == 100 + assert res["MT-0320"]["from_set_qty"] == 20 # 20세트 x 1 + assert res["MT-0320"]["total_qty"] == 120 + # MT-0550 은 낱개 직접 입력 없음, 세트에서만 40 + assert res["MT-0550"]["direct_qty"] == 0 + assert res["MT-0550"]["from_set_qty"] == 40 + assert res["MT-0550"]["total_qty"] == 40 + + +def test_explode_stocktake_multiple_sets_same_component(): + # MY-0002(MT-0320 x1) 10세트 + MY-0003(MT-0320 x1) 5세트 → from_set 15 + lines = [ + {"sku_code": "MT-0320", "qty": 0}, + {"sku_code": "MY-0002", "qty": 10}, + {"sku_code": "MY-0003", "qty": 5}, + ] + res = store.explode_stocktake(lines, _bom_map()) + assert res["MT-0320"]["from_set_qty"] == 15 + assert res["MT-0320"]["total_qty"] == 15 + + +def test_missing_bom_sets(): + lines = [ + {"sku_code": "MY-0002", "qty": 1}, # BOM 있음 + {"sku_code": "MY-0005", "qty": 1}, # BOM 없음 → 경고 + ] + missing = store.missing_bom_sets(lines, _bom_map()) + assert missing == ["MY-0005"] + + +# pytest 미설치 환경 폴백 +if __name__ == "__main__": + import traceback + + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + total = 0 + failed = 0 + for fn in fns: + params = getattr(fn, "_params", None) + cases: list[tuple] = [()] + if params: + argnames, argvalues = params + cases = [(v if isinstance(v, tuple) else (v,)) for v in argvalues] + for case in cases: + total += 1 + label = f"{fn.__name__}{case if case else ''}" + try: + fn(*case) + print(f"PASS: {label}") + except Exception: # noqa: BLE001 + failed += 1 + print(f"FAIL: {label}") + traceback.print_exc() + print(f"\n{total} tests, {failed} failed") + sys.exit(1 if failed else 0)