feat(malaysia): 말레이시아 창고 재고관리 모듈 추가
- 신규 모듈 app/modules/malaysia (prefix /malaysia, 권한키 malaysia) - malaysia_stock_db: warehouses/malaysia_items/set_bom/stock_movement/ daily_stocktake/daily_stocktake_line (멱등 init SQL) - 낱개(MT/MX/MZ) 입출고·조정 movement, 세트(MY) BOM 관리, 세트 출고 BOM 분해 - 일일 재고조사: 세트→낱개 자동 분해(direct+from_set=total), 확정 시 차이만 STOCKTAKE - prefix 검증 이중화(DB CHECK + store.py 순수함수), MD- 전면 제외 - 상품명은 itemcode_db 읽기 전용 재사용(중복 마스터 없음) - 화면 4종 + JSON API, 순수로직 테스트 14건, README/docs 갱신 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,12 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
|
|||||||
# 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과.
|
# 권한키: vacation(접근) / vacation_approver(승인·반려). admin 은 항상 통과.
|
||||||
# VACATION_DB_URL=postgresql://vacation_app:replace-me@postgres-db:5432/vacation_db
|
# 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 읽기 전용) ───
|
# ─── 상품 검색 (itemcode_db 읽기 전용) ───
|
||||||
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
# cupang 설정 화면에서 제품명을 itemcode_db 에서 검색해 등록한다(읽기만).
|
||||||
# 미설정 시 검색 비활성 → 수동 등록만 가능.
|
# 미설정 시 검색 비활성 → 수동 등록만 가능.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
|
|||||||
- 개인경비 (`app/modules/expense/`, `expense_db`)
|
- 개인경비 (`app/modules/expense/`, `expense_db`)
|
||||||
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
|
- 쿠팡 밀크런 (`app/modules/cupang/`, `cupang_db`) — 출고 달력/박스 입수량 계산/입고센터 관리, 상품은 `itemcode_db` 읽기 전용
|
||||||
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
|
- 휴가 관리 (`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`.
|
상세는 `docs/PROJECT_OVERVIEW.md`.
|
||||||
|
|
||||||
|
|||||||
+19
@@ -17,6 +17,8 @@ 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 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
|
||||||
from .store import (
|
from .store import (
|
||||||
@@ -36,6 +38,7 @@ MODULE_LABELS: dict[str, str] = {
|
|||||||
"expense": "개인경비",
|
"expense": "개인경비",
|
||||||
"vacation": "휴가",
|
"vacation": "휴가",
|
||||||
"cupang": "쿠팡 밀크런",
|
"cupang": "쿠팡 밀크런",
|
||||||
|
"malaysia": "말레이시아 재고",
|
||||||
"expense_approver": "개인경비",
|
"expense_approver": "개인경비",
|
||||||
"vacation_approver": "휴가",
|
"vacation_approver": "휴가",
|
||||||
}
|
}
|
||||||
@@ -108,6 +111,7 @@ _MODULE_TEMPLATE_DIRS = [
|
|||||||
BASE_DIR / "modules" / "expense" / "templates",
|
BASE_DIR / "modules" / "expense" / "templates",
|
||||||
BASE_DIR / "modules" / "cupang" / "templates",
|
BASE_DIR / "modules" / "cupang" / "templates",
|
||||||
BASE_DIR / "modules" / "vacation" / "templates",
|
BASE_DIR / "modules" / "vacation" / "templates",
|
||||||
|
BASE_DIR / "modules" / "malaysia" / "templates",
|
||||||
]
|
]
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||||
templates.env.loader = ChoiceLoader(
|
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()
|
app.state.itemcode_reader = build_itemcode_reader()
|
||||||
# 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
# 휴가 관리: VACATION_DB_URL 없으면 store=None(라우터가 "설정 필요" 안내).
|
||||||
app.state.vacation_store = build_vacation_store(dsn=env("VACATION_DB_URL") or 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(expense_router)
|
||||||
app.include_router(cupang_router)
|
app.include_router(cupang_router)
|
||||||
app.include_router(vacation_router)
|
app.include_router(vacation_router)
|
||||||
|
app.include_router(malaysia_router)
|
||||||
|
|
||||||
|
|
||||||
def public_url_for(request: Request, route_name: str) -> str:
|
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",
|
"status": "ready",
|
||||||
"category": "관리",
|
"category": "관리",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "malaysia",
|
||||||
|
"title": "말레이시아 재고",
|
||||||
|
"subtitle": "Malaysia Stock",
|
||||||
|
"description": "말레이시아 창고 입출고·세트 BOM·일일 재고조사를 관리합니다.",
|
||||||
|
"url": "/malaysia/",
|
||||||
|
"health_url": "/malaysia/health",
|
||||||
|
"status": "ready",
|
||||||
|
"category": "운영",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
allowed = allowed_modules(user_rec)
|
allowed = allowed_modules(user_rec)
|
||||||
for item in items:
|
for item in items:
|
||||||
@@ -298,6 +316,7 @@ def _icon_svg(name: str) -> str:
|
|||||||
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
|
"corm": '<path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z"/>',
|
||||||
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
|
"order": '<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>',
|
||||||
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
|
"cupang": '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/>',
|
||||||
|
"malaysia": '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>',
|
||||||
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
"modules": '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>',
|
||||||
}
|
}
|
||||||
body = paths.get(name, paths["modules"])
|
body = paths.get(name, paths["modules"])
|
||||||
|
|||||||
@@ -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:<APP_PWD>@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 불필요.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
"""malaysia_stock_db PostgreSQL 저장소.
|
||||||
|
|
||||||
|
- 드라이버: psycopg 3 (`psycopg[binary,pool]`) — cupang/expense 모듈과 동일 패턴.
|
||||||
|
- 연결 정보: 환경변수 `MALAYSIA_STOCK_DB_URL`
|
||||||
|
(예: postgresql://malaysia_app:<pwd>@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
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{# 말레이시아 재고관리 공용 상단 바: 서브탭 + 창고 선택 #}
|
||||||
|
<div class="erp-page-actions" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<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=='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>
|
||||||
|
|
||||||
|
<form method="get" style="display:flex;gap:6px;align-items:center;">
|
||||||
|
<label class="erp-muted" for="wh-sel">창고</label>
|
||||||
|
<select class="erp-select" id="wh-sel" name="wh" onchange="this.form.submit()">
|
||||||
|
{% for w in warehouses %}
|
||||||
|
<option value="{{ w.warehouse_code }}" {% if w.warehouse_code==selected_wh %}selected{% endif %}>{{ w.warehouse_code }} · {{ w.warehouse_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="mys">
|
||||||
|
{% set active_tab = 'status' %}
|
||||||
|
{% include "malaysia/_nav.html" %}
|
||||||
|
|
||||||
|
<div class="erp-card">
|
||||||
|
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<h2>재고 현황 ({{ rows|length }})</h2>
|
||||||
|
<span class="erp-muted">현재고 = 입고 − 출고 + 조정 + 재고조사반영 (낱개 아이템 기준)</span>
|
||||||
|
</div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Item Code</th><th>Product Name</th><th style="text-align:right">Current Qty</th><th>Last Stocktake</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.item_code }}</td>
|
||||||
|
<td>{{ r.item_name }}</td>
|
||||||
|
<td style="text-align:right;font-weight:600;">{{ r.current_qty }}</td>
|
||||||
|
<td>{{ r.last_stocktake_date or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not rows %}
|
||||||
|
<tr><td colspan="4" class="erp-muted">표시할 재고가 없습니다.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="mys">
|
||||||
|
{% set active_tab = 'move' %}
|
||||||
|
{% 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>
|
||||||
|
<span class="erp-muted">허용: MT-/MX-/MZ- · 차단: MY-/MD-</span>
|
||||||
|
<form method="post" action="/malaysia/movements" style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
|
||||||
|
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
|
||||||
|
<label class="erp-field"><span>이동 종류</span>
|
||||||
|
<select class="erp-select" name="movement_type" required>
|
||||||
|
<option value="IN">IN (입고)</option>
|
||||||
|
<option value="OUT">OUT (출고)</option>
|
||||||
|
<option value="ADJUST">ADJUST (조정, ± 가능)</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="erp-field"><span>날짜</span>
|
||||||
|
<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>
|
||||||
|
<input class="erp-input" type="text" name="ref_type" placeholder="manual / shopee / lazada ..." /></label>
|
||||||
|
<label class="erp-field"><span>Ref No</span>
|
||||||
|
<input class="erp-input" type="text" name="ref_no" placeholder="주문번호 등" /></label>
|
||||||
|
<label class="erp-field"><span>메모</span>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 세트 출고 (BOM 분해) -->
|
||||||
|
<div class="erp-card">
|
||||||
|
<div class="cpg-card-head"><h2>세트 출고 (BOM 분해)</h2></div>
|
||||||
|
<span class="erp-muted">MY- 세트 1건 → set_bom 기준으로 구성품별 OUT 자동 생성</span>
|
||||||
|
<form method="post" action="/malaysia/movements/set-out" style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
|
||||||
|
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
|
||||||
|
<label class="erp-field"><span>날짜</span>
|
||||||
|
<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>
|
||||||
|
<input class="erp-input" type="text" name="ref_no" placeholder="주문번호 등" /></label>
|
||||||
|
<label class="erp-field"><span>메모</span>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 이동 이력 -->
|
||||||
|
<div class="erp-card" style="margin-top:16px;">
|
||||||
|
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<h2>최근 이동 이력</h2>
|
||||||
|
<span>
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/malaysia/movements?wh={{ selected_wh }}">전체</a>
|
||||||
|
{% for t in ['IN','OUT','ADJUST','STOCKTAKE'] %}
|
||||||
|
<a class="erp-btn {% if filter_type==t %}erp-btn-primary{% else %}erp-btn-outline{% endif %}" href="/malaysia/movements?wh={{ selected_wh }}&type={{ t }}">{{ t }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead><tr><th>Date</th><th>Type</th><th>Item</th><th style="text-align:right">Qty</th><th>Ref</th><th>Memo</th><th>By</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for m in movements %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ m.movement_date }}</td>
|
||||||
|
<td>{{ m.movement_type }}</td>
|
||||||
|
<td>{{ m.item_code }}</td>
|
||||||
|
<td style="text-align:right">{{ m.qty }}</td>
|
||||||
|
<td>{{ m.ref_type }}{% if m.ref_no %} / {{ m.ref_no }}{% endif %}</td>
|
||||||
|
<td>{{ m.memo or '—' }}</td>
|
||||||
|
<td>{{ m.created_by or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not movements %}
|
||||||
|
<tr><td colspan="7" class="erp-muted">이동 이력이 없습니다.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="mys">
|
||||||
|
<div class="erp-page-actions">
|
||||||
|
<a class="erp-btn erp-btn-outline" href="/malaysia/stocktakes?wh={{ stocktake.warehouse_code }}">◀◀ 조사 목록</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="erp-card">
|
||||||
|
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<h2>재고조사 #{{ stocktake.id }} — {{ stocktake.stocktake_date }} · {{ stocktake.warehouse_code }}</h2>
|
||||||
|
<span>
|
||||||
|
{% if stocktake.status=='finalized' %}<span class="erp-badge erp-badge-success">확정 ({{ stocktake.finalized_at }})</span>
|
||||||
|
{% elif stocktake.status=='cancelled' %}<span class="erp-badge erp-badge-neutral">취소</span>
|
||||||
|
{% else %}<span class="erp-badge erp-badge-inverse">작성중</span>{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if missing_bom %}
|
||||||
|
<p style="color:#b91c1c;font-weight:600;">⚠ BOM 구성이 없는 세트: {{ missing_bom|join(', ') }} — 세트 구성 등록 전에는 확정할 수 없습니다.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_draft %}
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;align-items:start;margin-top:16px;">
|
||||||
|
<!-- 낱개 입력 -->
|
||||||
|
<div class="erp-card">
|
||||||
|
<div class="cpg-card-head"><h2>① 낱개 아이템 입력</h2></div>
|
||||||
|
<span class="erp-muted">세트 안에 든 건 빼고, 낱개로 보관 중인 수량만 입력</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 individual_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 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 %}
|
||||||
|
|
||||||
|
<!-- 입력된 라인 -->
|
||||||
|
<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="cpg-card-head"><h2>최종 계산 (낱개 기준)</h2></div>
|
||||||
|
<span class="erp-muted">total_qty = direct_qty(낱개 직접) + from_set_qty(세트 분해)</span>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead><tr><th>Item Code</th><th>Product Name</th><th style="text-align:right">Direct</th><th style="text-align:right">From Set</th><th style="text-align:right">Total</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in result_rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.item_code }}</td>
|
||||||
|
<td>{{ r.item_name }}</td>
|
||||||
|
<td style="text-align:right">{{ r.direct_qty }}</td>
|
||||||
|
<td style="text-align:right">{{ r.from_set_qty }}</td>
|
||||||
|
<td style="text-align:right;font-weight:600;">{{ r.total_qty }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not result_rows %}
|
||||||
|
<tr><td colspan="5" class="erp-muted">계산 결과가 없습니다.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_draft %}
|
||||||
|
<div class="erp-page-actions" style="margin-top:16px;display:flex;gap:8px;">
|
||||||
|
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/finalize"
|
||||||
|
onsubmit="return confirm('확정하면 시스템 재고와의 차이가 STOCKTAKE 이동으로 기록되고, 이후 라인 수정이 불가합니다. 계속?');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-primary" {% if missing_bom %}disabled title="BOM 누락 세트 해결 필요"{% endif %}>확정 (재고 반영)</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/cancel" onsubmit="return confirm('이 조사를 취소합니다. 계속?');">
|
||||||
|
<button type="submit" class="erp-btn erp-btn-danger">취소</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="mys">
|
||||||
|
{% set active_tab = 'stocktake' %}
|
||||||
|
{% 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>
|
||||||
|
<form method="post" action="/malaysia/stocktakes" style="margin-top:10px;display:flex;flex-direction:column;gap:8px;">
|
||||||
|
<input type="hidden" name="warehouse_code" value="{{ selected_wh }}" />
|
||||||
|
<label class="erp-field"><span>조사 날짜</span>
|
||||||
|
<input class="erp-input" type="date" name="stocktake_date" value="{{ today }}" required /></label>
|
||||||
|
<label class="erp-field"><span>메모</span>
|
||||||
|
<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>
|
||||||
|
</form>
|
||||||
|
<p class="erp-muted">생성 후 상세에서 낱개/세트 수량을 입력하고 확정하세요.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 조사 목록 -->
|
||||||
|
<div class="erp-card">
|
||||||
|
<div class="cpg-card-head"><h2>조사 목록 ({{ stocktakes|length }})</h2></div>
|
||||||
|
<div class="erp-table-wrap">
|
||||||
|
<table class="erp-table">
|
||||||
|
<thead><tr><th>#</th><th>Date</th><th>Warehouse</th><th>Status</th><th>By</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in stocktakes %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.id }}</td>
|
||||||
|
<td>{{ s.stocktake_date }}</td>
|
||||||
|
<td>{{ s.warehouse_code }}</td>
|
||||||
|
<td>
|
||||||
|
{% if s.status=='finalized' %}<span class="erp-badge erp-badge-success">확정</span>
|
||||||
|
{% elif s.status=='cancelled' %}<span class="erp-badge erp-badge-neutral">취소</span>
|
||||||
|
{% else %}<span class="erp-badge erp-badge-inverse">작성중</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ s.created_by or '—' }}</td>
|
||||||
|
<td><a class="erp-btn erp-btn-outline" href="/malaysia/stocktakes/{{ s.id }}">열기</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not stocktakes %}
|
||||||
|
<tr><td colspan="6" class="erp-muted">재고조사가 없습니다.</td></tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -28,6 +28,7 @@ MODULE_KEYS: tuple[str, ...] = (
|
|||||||
"expense",
|
"expense",
|
||||||
"vacation",
|
"vacation",
|
||||||
"cupang",
|
"cupang",
|
||||||
|
"malaysia",
|
||||||
"expense_approver",
|
"expense_approver",
|
||||||
"vacation_approver",
|
"vacation_approver",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
| `return_db` | 반품·교환·CS 데이터 |
|
| `return_db` | 반품·교환·CS 데이터 |
|
||||||
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
|
| `expense_db` | 개인경비 / 법인카드 사용내역 / 정산 |
|
||||||
| `cupang_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:<APP_PWD>@postgres-db:5432/malaysia_stock_db
|
||||||
|
cd /opt/www/main && docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 백업 / 복구 (안전 절차)
|
## 백업 / 복구 (안전 절차)
|
||||||
|
|
||||||
### 백업
|
### 백업
|
||||||
|
|||||||
@@ -20,13 +20,14 @@
|
|||||||
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
|
| 개인경비 | 법인카드/개인지출 등록·증빙·정산 신청 (`app/modules/expense/`) — 결재 워크플로 / 첨부(영수증·기타) / 월별 집계 / 엑셀 내보내기 |
|
||||||
| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 |
|
| 쿠팡 밀크런 | 쿠팡 출고 일정 관리 (`app/modules/cupang/`) — 월간 달력 / 출고 묶음(헤더+라인) / 박스 입수량 자동계산 / 입고센터 관리 / 엑셀 내보내기. 상품은 `itemcode_db` 읽기 전용 참조 |
|
||||||
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
|
| 휴가 (준비중) | 연차/반차/특별휴가 신청·잔여일수 관리 |
|
||||||
|
| 말레이시아 재고 | 말레이시아 창고 재고관리 (`app/modules/malaysia/`) — 낱개 입출고/조정, 세트 BOM, 일일 재고조사(세트 분해 자동 반영), 현재고 현황. 상품명은 `itemcode_db` 읽기 전용 참조 |
|
||||||
|
|
||||||
### 권한 키 (`MODULE_KEYS`)
|
### 권한 키 (`MODULE_KEYS`)
|
||||||
|
|
||||||
| 키 | 종류 | 설명 |
|
| 키 | 종류 | 설명 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `corm` / `order` | 접근 | 외부 모듈 진입 |
|
| `corm` / `order` | 접근 | 외부 모듈 진입 |
|
||||||
| `expense` / `vacation` / `cupang` | 접근 | 내부 모듈 진입 |
|
| `expense` / `vacation` / `cupang` / `malaysia` | 접근 | 내부 모듈 진입 |
|
||||||
| `expense_approver` | 결재 | 개인경비 승인/반려/정산 |
|
| `expense_approver` | 결재 | 개인경비 승인/반려/정산 |
|
||||||
| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) |
|
| `vacation_approver` | 결재 | 휴가 승인/반려 (모듈 미개발) |
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ app/modules/<name>/
|
|||||||
| `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) |
|
| `expense_db` | 개인경비 / 법인카드 / 정산 (`EXPENSE_DB_URL` 미설정 시 JSON 폴백) |
|
||||||
| `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) |
|
| `cupang_db` | 쿠팡 밀크런 출고/입고센터/박스규칙 (`CUPANG_DB_URL` 필수, JSON 폴백 없음) |
|
||||||
| `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) |
|
| `vacation_db` | 휴가 신청/공휴일/연차잔여 (`VACATION_DB_URL` 필수, JSON 폴백 없음) |
|
||||||
|
| `malaysia_stock_db` | 말레이시아 창고 재고/세트BOM/재고조사 (`MALAYSIA_STOCK_DB_URL` 필수, JSON 폴백 없음) |
|
||||||
|
|
||||||
> 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`.
|
> 신규 DB가 필요하면 **승인 요청 후** 생성하며, 이름은 `_db`로 끝낸다. 상세는 `DATABASES.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -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:<APP_PWD>@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;
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user