feat(malaysia): 재고조사 랙(rack) 박스 단위 입력
낱개/콤보 평면 수량 그리드를 창고 랙 그림(A/B 36칸) 입력으로 대체.
각 칸에 아이템·박스당 입수량·박스 수를 넣고 + 로 한 칸 여러 아이템 추가.
저장 시 SKU별 qty=SUM(입수량×박스수) 집계로 daily_stocktake_line 재생성.
이후 분해/확정 로직은 라인 기준 그대로 동작.
- DB: daily_stocktake_rack 테이블 + grant (멱등)
- store: rack_cell_codes/rack_layout/aggregate_rack 순수함수
- db: list_rack_entries/replace_rack(1 트랜잭션, draft만)
- router: rack 컨텍스트 + POST /stocktakes/{id}/rack/bulk
- UI: 랙 보드 템플릿 + malaysia_rack.js(행 추가/삭제·실시간 소계)
- 테스트 4건 추가(18 전부 통과)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,20 @@ cupang 모듈과 동일한 패턴: **malaysia_stock_db(PostgreSQL) 전용**, JSO
|
|||||||
예) `MT-0320` 낱개 100 + `MY-0002`(MT-0320 1개 포함) 20세트 → `MT-0320` total = **120**.
|
예) `MT-0320` 낱개 100 + `MY-0002`(MT-0320 1개 포함) 20세트 → `MT-0320` total = **120**.
|
||||||
> 낱개 입력 시 **세트 안에 든 건 빼고** 낱개 보관분만 입력한다. 세트 안 상품은 BOM 으로 자동 계산.
|
> 낱개 입력 시 **세트 안에 든 건 빼고** 낱개 보관분만 입력한다. 세트 안 상품은 BOM 으로 자동 계산.
|
||||||
|
|
||||||
|
### 랙(rack) 입력 — 재고조사 상세
|
||||||
|
|
||||||
|
재고조사 상세는 평면 수량 그리드 대신 **창고 랙 그림(A/B 두 면, 각 3행×3열×2분할 = 36칸)**
|
||||||
|
으로 입력한다. 각 칸에 `아이템 · 박스당 입수량 · 박스 수`를 넣고, 한 칸에 여러 아이템은
|
||||||
|
`+` 로 행을 추가한다. 저장하면 서버가 SKU별로
|
||||||
|
|
||||||
|
```
|
||||||
|
qty = SUM(units_per_box × box_count)
|
||||||
|
```
|
||||||
|
|
||||||
|
집계해 `daily_stocktake_line`을 **재생성**한다(랙이 라인의 단일 출처). 낱개·콤보(MY-)
|
||||||
|
모두 랙에 입력 가능하며, 이후 분해/확정 로직(`compute_result`/`finalize`)은 라인 기준으로
|
||||||
|
기존과 동일하게 동작한다. 칸 코드 형식: `{면}{행}-{열}-{분할}` (예 `A3-1-1`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 테이블 (`scripts/sql/malaysia_stock_db_init.sql`)
|
## 테이블 (`scripts/sql/malaysia_stock_db_init.sql`)
|
||||||
@@ -54,7 +68,8 @@ cupang 모듈과 동일한 패턴: **malaysia_stock_db(PostgreSQL) 전용**, JSO
|
|||||||
| `set_bom` | 세트 구성표. `UNIQUE(set_code, component_code)`. prefix CHECK 내장 |
|
| `set_bom` | 세트 구성표. `UNIQUE(set_code, component_code)`. prefix CHECK 내장 |
|
||||||
| `stock_movement` | 입고/출고/조정/조사 이력. `item_code` 는 낱개만(CHECK) |
|
| `stock_movement` | 입고/출고/조정/조사 이력. `item_code` 는 낱개만(CHECK) |
|
||||||
| `daily_stocktake` | 재고조사 헤더. 같은 날짜+창고 finalized 1건(부분 유니크) |
|
| `daily_stocktake` | 재고조사 헤더. 같은 날짜+창고 finalized 1건(부분 유니크) |
|
||||||
| `daily_stocktake_line` | 조사 라인. `sku_code` 낱개+세트 허용, `UNIQUE(stocktake_id, sku_code)` |
|
| `daily_stocktake_line` | 조사 라인(SKU별 최종 qty). 랙 입력 집계로 **재생성**됨. `UNIQUE(stocktake_id, sku_code)` |
|
||||||
|
| `daily_stocktake_rack` | 랙 칸별 입력(셀×SKU×입수량×박스수). 라인의 단일 출처. `cell_code` 예 `A3-1-1` |
|
||||||
|
|
||||||
검증 규칙(요구사항 8)은 **DB CHECK + 서비스 레이어(`store.py`)** 양쪽에 걸려 있다.
|
검증 규칙(요구사항 8)은 **DB CHECK + 서비스 레이어(`store.py`)** 양쪽에 걸려 있다.
|
||||||
|
|
||||||
@@ -75,6 +90,7 @@ JSON API:
|
|||||||
| POST | `/malaysia/api/movements` | 낱개 movement 등록 |
|
| POST | `/malaysia/api/movements` | 낱개 movement 등록 |
|
||||||
| POST | `/malaysia/api/movements/set-out` | 세트 출고(BOM 분해) |
|
| POST | `/malaysia/api/movements/set-out` | 세트 출고(BOM 분해) |
|
||||||
| GET | `/malaysia/api/stocktakes/{id}/result` | 재고조사 최종 계산 |
|
| GET | `/malaysia/api/stocktakes/{id}/result` | 재고조사 최종 계산 |
|
||||||
|
| POST | `/malaysia/stocktakes/{id}/rack/bulk` | 랙 입력 일괄 저장(병렬배열 `rk_cell/rk_sku/rk_upb/rk_box`) → 라인 재생성 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -92,6 +108,8 @@ cd /opt/www/main && docker compose up -d --build
|
|||||||
```
|
```
|
||||||
|
|
||||||
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음.
|
> 멱등 스크립트. 기존 DB 가 있으면 DROP 하지 않음. itemcode_db 는 건드리지 않음.
|
||||||
|
> **랙 입력 추가(`daily_stocktake_rack`)** 후 기존 운영 DB 에 반영하려면 위 init 스크립트를
|
||||||
|
> 그대로 1회 재실행한다(`CREATE TABLE IF NOT EXISTS` + grant 만 적용, 기존 데이터 보존).
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
|
|||||||
@@ -579,6 +579,93 @@ class MalaysiaStockStore:
|
|||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise KeyError(line_id)
|
raise KeyError(line_id)
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
# 재고조사 랙(rack) 입력 (daily_stocktake_rack)
|
||||||
|
# 랙 칸 단위로 SKU×입수량×박스수 를 저장하고, 그 집계로
|
||||||
|
# daily_stocktake_line(SKU별 qty)을 재생성한다(랙이 라인의 단일 출처).
|
||||||
|
# ════════════════════════════════════════════════════════════
|
||||||
|
def list_rack_entries(self, *, stocktake_id: int) -> list[dict[str, Any]]:
|
||||||
|
"""재고조사의 랙 입력 항목 전체. 칸→순서대로 정렬."""
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM daily_stocktake_rack WHERE stocktake_id = %s "
|
||||||
|
"ORDER BY cell_code ASC, line_no ASC, id ASC",
|
||||||
|
(stocktake_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [self._serialize(r) for r in rows]
|
||||||
|
|
||||||
|
def replace_rack(
|
||||||
|
self, *, stocktake_id: int, entries: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""랙 입력 전체 교체 → SKU별 집계로 재고조사 라인 재생성.
|
||||||
|
|
||||||
|
entries: [{"cell_code","sku_code","units_per_box","box_count"}, ...]
|
||||||
|
(입력 순서가 칸 안 line_no 가 된다.)
|
||||||
|
한 트랜잭션:
|
||||||
|
1) 기존 랙 항목 + 라인 전체 삭제
|
||||||
|
2) 검증한 랙 항목 삽입(칸별 line_no 부여)
|
||||||
|
3) aggregate_rack 결과를 daily_stocktake_line 으로 삽입
|
||||||
|
draft 가 아니면 ValueError. 알 수 없는 칸/코드도 ValueError.
|
||||||
|
"""
|
||||||
|
valid_cells = set(store.rack_cell_codes())
|
||||||
|
clean: list[tuple[str, str, int, int]] = []
|
||||||
|
for e in entries:
|
||||||
|
cell = (e.get("cell_code") or "").strip().upper()
|
||||||
|
if cell not in valid_cells:
|
||||||
|
raise ValueError(f"알 수 없는 랙 위치: {cell or '(빈값)'}")
|
||||||
|
code = store.validate_stocktake_code(str(e.get("sku_code") or ""))
|
||||||
|
upb = store.validate_qty(
|
||||||
|
e.get("units_per_box"), allow_zero=False, allow_negative=False
|
||||||
|
)
|
||||||
|
box = store.validate_qty(
|
||||||
|
e.get("box_count"), allow_zero=False, allow_negative=False
|
||||||
|
)
|
||||||
|
clean.append((cell, code, upb, box))
|
||||||
|
|
||||||
|
totals = store.aggregate_rack(
|
||||||
|
[
|
||||||
|
{"sku_code": c, "units_per_box": u, "box_count": b}
|
||||||
|
for (_cell, c, u, b) in clean
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
cell_seq: dict[str, int] = {}
|
||||||
|
with self._pool.connection() as conn:
|
||||||
|
with conn.transaction():
|
||||||
|
self._assert_draft(conn, stocktake_id)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM daily_stocktake_rack WHERE stocktake_id = %s",
|
||||||
|
(stocktake_id,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM daily_stocktake_line WHERE stocktake_id = %s",
|
||||||
|
(stocktake_id,),
|
||||||
|
)
|
||||||
|
for cell, code, upb, box in clean:
|
||||||
|
ln = cell_seq.get(cell, 0)
|
||||||
|
cell_seq[cell] = ln + 1
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_stocktake_rack
|
||||||
|
(stocktake_id, cell_code, line_no, sku_code,
|
||||||
|
units_per_box, box_count)
|
||||||
|
VALUES (%s,%s,%s,%s,%s,%s)
|
||||||
|
""",
|
||||||
|
(stocktake_id, cell, ln, code, upb, box),
|
||||||
|
)
|
||||||
|
for code in sorted(totals.keys()):
|
||||||
|
qty = totals[code]
|
||||||
|
if qty <= 0:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_stocktake_line (stocktake_id, sku_code, qty)
|
||||||
|
VALUES (%s,%s,%s)
|
||||||
|
""",
|
||||||
|
(stocktake_id, code, qty),
|
||||||
|
)
|
||||||
|
return {"entries": len(clean), "lines": {k: v for k, v in totals.items() if v > 0}}
|
||||||
|
|
||||||
def cancel_stocktake(self, *, stocktake_id: int) -> dict[str, Any]:
|
def cancel_stocktake(self, *, stocktake_id: int) -> dict[str, Any]:
|
||||||
with self._pool.connection() as conn:
|
with self._pool.connection() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
|
|||||||
@@ -468,14 +468,19 @@ async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse:
|
|||||||
wh = head["warehouse_code"]
|
wh = head["warehouse_code"]
|
||||||
# 입력된 라인 qty 를 코드별 dict 로 (그리드 기본값)
|
# 입력된 라인 qty 를 코드별 dict 로 (그리드 기본값)
|
||||||
entered = {ln["sku_code"]: ln["qty"] for ln in head.get("lines", [])}
|
entered = {ln["sku_code"]: ln["qty"] for ln in head.get("lines", [])}
|
||||||
|
# 랙 입력 항목을 칸별로 묶어 전달(그림 안 입력행 복원용)
|
||||||
|
rack_by_cell: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for e in st.list_rack_entries(stocktake_id=stocktake_id):
|
||||||
|
rack_by_cell.setdefault(e["cell_code"], []).append(e)
|
||||||
ctx = _base_ctx(request, user, st, wh=wh)
|
ctx = _base_ctx(request, user, st, wh=wh)
|
||||||
ctx.update(
|
ctx.update(
|
||||||
{
|
{
|
||||||
"page_title": f"재고조사 #{stocktake_id}",
|
"page_title": f"재고조사 #{stocktake_id}",
|
||||||
"page_subtitle": f"{head['stocktake_date']} · {wh} · {head['status']}",
|
"page_subtitle": f"{head['stocktake_date']} · {wh} · {head['status']}",
|
||||||
"stocktake": head,
|
"stocktake": head,
|
||||||
"individual_items": st.list_items(kind="individual"),
|
"rack_layout": store.rack_layout(),
|
||||||
"set_items": st.list_items(kind="set"),
|
"rack_by_cell": rack_by_cell,
|
||||||
|
"rack_items": st.list_items(),
|
||||||
"entered": entered,
|
"entered": entered,
|
||||||
"result_rows": computed["rows"],
|
"result_rows": computed["rows"],
|
||||||
"missing_bom": computed["missing_bom"],
|
"missing_bom": computed["missing_bom"],
|
||||||
@@ -517,6 +522,48 @@ async def stocktake_lines_bulk(
|
|||||||
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
|
return RedirectResponse(url=f"/malaysia/stocktakes/{stocktake_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stocktakes/{stocktake_id:int}/rack/bulk")
|
||||||
|
async def stocktake_rack_bulk(
|
||||||
|
request: Request, stocktake_id: int, user: dict[str, Any] = Depends(_require_user)
|
||||||
|
) -> RedirectResponse:
|
||||||
|
"""랙 입력 일괄 저장.
|
||||||
|
|
||||||
|
form 병렬 배열: rk_cell[], rk_sku[], rk_upb[], rk_box[] (DOM 순서 유지).
|
||||||
|
SKU 미선택/입수량·박스수 ≤ 0 인 행은 스킵(미입력 행 취급).
|
||||||
|
저장 시 랙 항목 교체 + SKU별 집계로 daily_stocktake_line 재생성.
|
||||||
|
"""
|
||||||
|
st = _store(request)
|
||||||
|
if st is None:
|
||||||
|
raise HTTPException(status_code=503, detail="malaysia_stock_db 미설정")
|
||||||
|
form = await request.form()
|
||||||
|
cells = form.getlist("rk_cell")
|
||||||
|
skus = form.getlist("rk_sku")
|
||||||
|
upbs = form.getlist("rk_upb")
|
||||||
|
boxes = form.getlist("rk_box")
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
for cell, sku, upb, box in zip(cells, skus, upbs, boxes):
|
||||||
|
code = str(sku or "").strip()
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
u = int(str(upb).strip() or 0)
|
||||||
|
b = int(str(box).strip() or 0)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if u <= 0 or b <= 0:
|
||||||
|
continue
|
||||||
|
entries.append(
|
||||||
|
{"cell_code": cell, "sku_code": code, "units_per_box": u, "box_count": b}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
st.replace_rack(stocktake_id=stocktake_id, entries=entries)
|
||||||
|
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")
|
@router.post("/stocktakes/{stocktake_id:int}/lines/{line_id:int}/delete")
|
||||||
async def stocktake_line_delete(
|
async def stocktake_line_delete(
|
||||||
request: Request, stocktake_id: int, line_id: int,
|
request: Request, stocktake_id: int, line_id: int,
|
||||||
|
|||||||
@@ -27,6 +27,66 @@ INDIVIDUAL_PREFIXES: tuple[str, ...] = ("MT-", "MX-", "MZ-")
|
|||||||
SET_PREFIX: str = "MY-"
|
SET_PREFIX: str = "MY-"
|
||||||
BLOCKED_PREFIX: str = "MD-" # 뚜껑 — 재고관리 대상에서 제외
|
BLOCKED_PREFIX: str = "MD-" # 뚜껑 — 재고관리 대상에서 제외
|
||||||
|
|
||||||
|
# ── 창고 랙 레이아웃 (재고조사 랙 입력용) ──
|
||||||
|
# 그림 고정: A/B 두 면, 각 면 3행 × 3열, 각 열은 2분할 → 면당 18칸, 총 36칸.
|
||||||
|
# 행은 위→아래로 3,2,1 (그림과 동일). 칸 코드: '{면}{행}-{열}-{분할}' 예) A3-1-1.
|
||||||
|
RACK_SIDES: tuple[str, ...] = ("A", "B")
|
||||||
|
RACK_ROWS: tuple[int, ...] = (3, 2, 1)
|
||||||
|
RACK_COLS: tuple[int, ...] = (1, 2, 3)
|
||||||
|
RACK_SUBS: tuple[int, ...] = (1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def rack_cell_codes() -> list[str]:
|
||||||
|
"""모든 랙 칸 코드 목록(검증용). 예: 'A3-1-1' ... 'B1-3-2'."""
|
||||||
|
return [
|
||||||
|
f"{side}{row}-{col}-{sub}"
|
||||||
|
for side in RACK_SIDES
|
||||||
|
for row in RACK_ROWS
|
||||||
|
for col in RACK_COLS
|
||||||
|
for sub in RACK_SUBS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def rack_layout() -> list[dict[str, Any]]:
|
||||||
|
"""템플릿 렌더용 중첩 구조.
|
||||||
|
|
||||||
|
[{"side":"A","rows":[{"row":3,"cols":[["A3-1-1","A3-1-2"], ...]}, ...]}, ...]
|
||||||
|
"""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for side in RACK_SIDES:
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for row in RACK_ROWS:
|
||||||
|
cols = [
|
||||||
|
[f"{side}{row}-{col}-{sub}" for sub in RACK_SUBS]
|
||||||
|
for col in RACK_COLS
|
||||||
|
]
|
||||||
|
rows.append({"row": row, "cols": cols})
|
||||||
|
out.append({"side": side, "rows": rows})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_rack(entries: Iterable[dict[str, Any]]) -> dict[str, int]:
|
||||||
|
"""랙 입력 항목을 SKU 코드별 총수량으로 집계.
|
||||||
|
|
||||||
|
각 항목: {"sku_code", "units_per_box", "box_count"}.
|
||||||
|
qty = SUM(units_per_box * box_count). 입수량/박스수 ≤ 0 또는 코드 공백은 스킵.
|
||||||
|
반환: { sku_code: total_qty } (낱개·세트 코드 모두 포함 가능)
|
||||||
|
"""
|
||||||
|
totals: dict[str, int] = {}
|
||||||
|
for e in entries:
|
||||||
|
code = _norm(e.get("sku_code"))
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
upb = int(e.get("units_per_box") or 0)
|
||||||
|
box = int(e.get("box_count") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if upb <= 0 or box <= 0:
|
||||||
|
continue
|
||||||
|
totals[code] = totals.get(code, 0) + upb * box
|
||||||
|
return totals
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════
|
||||||
# 코드 분류 / 검증 (순수 함수)
|
# 코드 분류 / 검증 (순수 함수)
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
<div style="display:flex;justify-content:flex-end;margin-bottom:8px;">
|
<div style="display:flex;justify-content:flex-end;margin-bottom:8px;">
|
||||||
<button type="button" id="mys-lang-toggle" class="erp-btn erp-btn-outline">ENG</button>
|
<button type="button" id="mys-lang-toggle" class="erp-btn erp-btn-outline">ENG</button>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/malaysia.js?v=20260612b"></script>
|
<script src="/static/malaysia.js?v=20260614a"></script>
|
||||||
|
|||||||
@@ -1,14 +1,56 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
|
{% macro rack_row(cell, items, entry=None, disabled=False) %}
|
||||||
|
<div class="rk-row">
|
||||||
|
<input type="hidden" name="rk_cell" value="{{ cell }}">
|
||||||
|
<select name="rk_sku" class="erp-input rk-sku" {% if disabled %}disabled{% endif %}>
|
||||||
|
<option value="">— 선택 —</option>
|
||||||
|
{% for it in items %}
|
||||||
|
<option value="{{ it.item_code }}" {% if entry and entry.sku_code == it.item_code %}selected{% endif %}>{{ it.item_code }} · {{ it.item_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input class="erp-input rk-upb" type="number" min="1" name="rk_upb" placeholder="입수량"
|
||||||
|
value="{{ entry.units_per_box if entry else '' }}" {% if disabled %}disabled{% endif %} />
|
||||||
|
<span class="rk-x">×</span>
|
||||||
|
<input class="erp-input rk-box" type="number" min="1" name="rk_box" placeholder="박스"
|
||||||
|
value="{{ entry.box_count if entry else '' }}" {% if disabled %}disabled{% endif %} />
|
||||||
|
<span class="rk-sub">{{ (entry.units_per_box * entry.box_count) if entry else 0 }}</span>
|
||||||
|
{% if not disabled %}<button type="button" class="rk-del" title="행 삭제">×</button>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% block head_extra %}
|
{% block head_extra %}
|
||||||
<style>
|
<style>
|
||||||
.mys-compact .erp-table th,
|
.mys-compact .erp-table th,
|
||||||
.mys-compact .erp-table td { padding-top:4px; padding-bottom:4px; }
|
.mys-compact .erp-table td { padding-top:4px; padding-bottom:4px; }
|
||||||
.mys-compact .erp-input { padding-top:4px; padding-bottom:4px; }
|
/* 랙 보드 */
|
||||||
.mys-compact td.nm { white-space:nowrap; }
|
.rack-board { display:flex; gap:24px; flex-wrap:wrap; }
|
||||||
.mys-compact th.ccol, .mys-compact td.ccol { white-space:nowrap; width:88px; }
|
.rack-side { flex:1 1 480px; }
|
||||||
.mys-compact .qcol { width:110px; }
|
.rack-side h3 { text-align:center; margin:0 0 8px; }
|
||||||
.mys-compact .qcol .erp-input { width:100px; }
|
.rack-row { display:grid; grid-template-columns:repeat(6, 1fr); gap:6px; margin-bottom:6px; }
|
||||||
|
.rack-cell {
|
||||||
|
border:1px solid #cbd5e1; border-radius:6px; padding:4px;
|
||||||
|
background:#f8fafc; min-height:84px; display:flex; flex-direction:column;
|
||||||
|
}
|
||||||
|
.rack-cell-head {
|
||||||
|
display:flex; align-items:center; justify-content:space-between;
|
||||||
|
font-size:11px; font-weight:600; color:#475569; margin-bottom:3px;
|
||||||
|
}
|
||||||
|
.rack-cell-head .rk-cell-total { color:#0f172a; }
|
||||||
|
.rack-cell-head .rk-add {
|
||||||
|
border:1px solid #94a3b8; background:#fff; border-radius:4px;
|
||||||
|
width:18px; height:18px; line-height:1; padding:0; cursor:pointer; font-weight:700;
|
||||||
|
}
|
||||||
|
.rk-rows { display:flex; flex-direction:column; gap:3px; }
|
||||||
|
.rk-row { display:grid; grid-template-columns: 1fr 38px 8px 32px 30px 16px; gap:2px; align-items:center; }
|
||||||
|
.rk-row .rk-sku { font-size:10px; padding:1px 2px; }
|
||||||
|
.rk-row .rk-upb, .rk-row .rk-box { font-size:10px; padding:1px 2px; text-align:right; }
|
||||||
|
.rk-row .rk-x { font-size:10px; color:#94a3b8; text-align:center; }
|
||||||
|
.rk-row .rk-sub { font-size:10px; text-align:right; color:#2563eb; font-weight:600; }
|
||||||
|
.rk-row .rk-del {
|
||||||
|
border:none; background:transparent; color:#dc2626; cursor:pointer;
|
||||||
|
font-size:13px; line-height:1; padding:0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -19,8 +61,8 @@
|
|||||||
<a class="erp-btn erp-btn-primary" href="/malaysia/stocktakes?wh={{ stocktake.warehouse_code }}">◀◀ 조사 목록</a>
|
<a class="erp-btn erp-btn-primary" href="/malaysia/stocktakes?wh={{ stocktake.warehouse_code }}">◀◀ 조사 목록</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/lines/bulk">
|
<form method="post" action="/malaysia/stocktakes/{{ stocktake.id }}/rack/bulk">
|
||||||
<div style="display:grid;grid-template-columns:280px 1fr 1fr;gap:16px;align-items:start;">
|
<div style="display:grid;grid-template-columns:280px 1fr;gap:16px;align-items:start;">
|
||||||
|
|
||||||
<!-- 좌: 정보 + 동작 -->
|
<!-- 좌: 정보 + 동작 -->
|
||||||
<div class="erp-card">
|
<div class="erp-card">
|
||||||
@@ -40,6 +82,10 @@
|
|||||||
<p style="color:#b91c1c;font-weight:600;margin-top:10px;">⚠ BOM 없는 콤보: {{ missing_bom|join(', ') }}</p>
|
<p style="color:#b91c1c;font-weight:600;margin-top:10px;">⚠ BOM 없는 콤보: {{ missing_bom|join(', ') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="erp-muted" style="margin-top:10px;font-size:12px;">
|
||||||
|
<span class="i18n">랙 칸에 아이템·입수량(박스당 개수)·박스 수를 입력하면 낱개/콤보 수량이 자동 집계됩니다. 한 칸에 여러 아이템은 + 로 추가하세요.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if is_draft %}
|
{% if is_draft %}
|
||||||
<div class="erp-page-actions" style="margin-top:12px;">
|
<div class="erp-page-actions" style="margin-top:12px;">
|
||||||
<button type="submit" class="erp-btn erp-btn-primary">입력 저장</button>
|
<button type="submit" class="erp-btn erp-btn-primary">입력 저장</button>
|
||||||
@@ -47,48 +93,49 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 중: 낱개 -->
|
<!-- 우: 랙 보드 -->
|
||||||
<div class="erp-card">
|
<div class="erp-card">
|
||||||
<div class="cpg-card-head"><h2>낱개 상품</h2></div>
|
<div class="cpg-card-head"><h2>랙 입력 (박스 단위)</h2></div>
|
||||||
<div class="erp-table-wrap" style="margin-top:8px;">
|
<div class="rack-board" style="margin-top:10px;">
|
||||||
<table class="erp-table">
|
{% for side in rack_layout %}
|
||||||
<thead><tr><th class="ccol">Code</th><th>Name</th><th class="qcol">Qty</th></tr></thead>
|
<div class="rack-side">
|
||||||
<tbody>
|
<h3>Side {{ side.side }}</h3>
|
||||||
{% for it in individual_items %}
|
{% for row in side.rows %}
|
||||||
<tr>
|
<div class="rack-row">
|
||||||
<td class="ccol">{{ it.item_code }}</td>
|
{% for col in row.cols %}
|
||||||
<td class="nm" data-en="{{ it.item_name }}" data-ko="{{ ko_names.get(it.item_code, it.item_name) }}">{{ it.item_name }}</td>
|
{% for cell in col %}
|
||||||
<td class="qcol"><input class="erp-input" type="number" min="0" name="qty_{{ it.item_code }}"
|
<div class="rack-cell" data-cell="{{ cell }}">
|
||||||
value="{{ entered.get(it.item_code, '') }}" placeholder="—" {% if not is_draft %}disabled{% endif %} /></td>
|
<div class="rack-cell-head">
|
||||||
</tr>
|
<span>{{ cell }}</span>
|
||||||
|
<span class="rk-cell-total">∑ <b class="rk-cell-total-v">0</b></span>
|
||||||
|
{% if is_draft %}<button type="button" class="rk-add" title="아이템 추가">+</button>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="rk-rows">
|
||||||
|
{% set entries = rack_by_cell.get(cell, []) %}
|
||||||
|
{% for e in entries %}
|
||||||
|
{{ rack_row(cell, rack_items, entry=e, disabled=(not is_draft)) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% if is_draft and not entries %}
|
||||||
|
{{ rack_row(cell, rack_items) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
|
|
||||||
<!-- 우: 콤보 -->
|
|
||||||
<div class="erp-card">
|
|
||||||
<div class="cpg-card-head"><h2>콤보 상품</h2></div>
|
|
||||||
<div class="erp-table-wrap" style="margin-top:8px;">
|
|
||||||
<table class="erp-table">
|
|
||||||
<thead><tr><th class="ccol">Set Code</th><th>Set Name</th><th class="qcol">Qty</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for it in set_items %}
|
|
||||||
<tr>
|
|
||||||
<td class="ccol">{{ it.item_code }}</td>
|
|
||||||
<td class="nm" data-en="{{ it.item_name }}" data-ko="{{ ko_names.get(it.item_code, it.item_name) }}">{{ it.item_name }}</td>
|
|
||||||
<td class="qcol"><input class="erp-input" type="number" min="0" name="qty_{{ it.item_code }}"
|
|
||||||
value="{{ entered.get(it.item_code, '') }}" placeholder="—" {% if not is_draft %}disabled{% endif %} /></td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- JS 클론용 빈 행 템플릿(옵션 1벌만 보유) -->
|
||||||
|
<template id="rk-row-tpl">
|
||||||
|
{{ rack_row('', rack_items) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 확정/취소/삭제 -->
|
<!-- 확정/취소/삭제 -->
|
||||||
<div class="erp-page-actions" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
<div class="erp-page-actions" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
{% if is_draft %}
|
{% if is_draft %}
|
||||||
@@ -135,4 +182,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<script src="/static/malaysia_rack.js?v=20260614a"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -68,6 +68,9 @@
|
|||||||
"입력 저장": "Save",
|
"입력 저장": "Save",
|
||||||
"확정 (전산 반영)": "Finalize (apply)",
|
"확정 (전산 반영)": "Finalize (apply)",
|
||||||
"삭제 (슈퍼관리자)": "Delete (super)",
|
"삭제 (슈퍼관리자)": "Delete (super)",
|
||||||
|
"랙 입력 (박스 단위)": "Rack Input (by box)",
|
||||||
|
"랙 칸에 아이템·입수량(박스당 개수)·박스 수를 입력하면 낱개/콤보 수량이 자동 집계됩니다. 한 칸에 여러 아이템은 + 로 추가하세요.":
|
||||||
|
"Enter item · units-per-box · box count in each rack cell; single/combo qty is auto-summed. Use + to add more items in one cell.",
|
||||||
"최종 계산 (낱개 기준)": "Final Calc (by single)",
|
"최종 계산 (낱개 기준)": "Final Calc (by single)",
|
||||||
"total_qty = direct_qty(낱개 직접) + from_set_qty(콤보 분해)":
|
"total_qty = direct_qty(낱개 직접) + from_set_qty(콤보 분해)":
|
||||||
"total_qty = direct_qty (single) + from_set_qty (combo)",
|
"total_qty = direct_qty (single) + from_set_qty (combo)",
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/* 말레이시아 재고조사 — 랙(rack) 입력 보조 스크립트.
|
||||||
|
*
|
||||||
|
* - 칸(.rack-cell) 헤더의 + 버튼: 빈 입력행을 칸에 추가(템플릿 #rk-row-tpl 클론).
|
||||||
|
* 클론 시 숨김필드 rk_cell 에 해당 칸 코드를 채워 서버 병렬배열과 정렬.
|
||||||
|
* - 행의 × 버튼: 그 행 제거(칸에 행이 0개여도 무방 — 서버가 빈 칸 스킵).
|
||||||
|
* - 입수량/박스수 입력 시 행 소계(rk-sub)와 칸 합계(rk-cell-total-v) 실시간 갱신.
|
||||||
|
* 저장은 폼 제출(서버에서 SKU별 집계 → 재고조사 라인 재생성).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function num(el) {
|
||||||
|
var v = parseInt((el && el.value) || "0", 10);
|
||||||
|
return isNaN(v) || v < 0 ? 0 : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcRow(row) {
|
||||||
|
var upb = num(row.querySelector(".rk-upb"));
|
||||||
|
var box = num(row.querySelector(".rk-box"));
|
||||||
|
var sub = row.querySelector(".rk-sub");
|
||||||
|
if (sub) sub.textContent = String(upb * box);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recalcCell(cell) {
|
||||||
|
var total = 0;
|
||||||
|
cell.querySelectorAll(".rk-row").forEach(function (row) {
|
||||||
|
total += num(row.querySelector(".rk-upb")) * num(row.querySelector(".rk-box"));
|
||||||
|
});
|
||||||
|
var out = cell.querySelector(".rk-cell-total-v");
|
||||||
|
if (out) out.textContent = String(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireRow(row) {
|
||||||
|
row.querySelectorAll(".rk-upb, .rk-box").forEach(function (inp) {
|
||||||
|
inp.addEventListener("input", function () {
|
||||||
|
recalcRow(row);
|
||||||
|
var cell = row.closest(".rack-cell");
|
||||||
|
if (cell) recalcCell(cell);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var del = row.querySelector(".rk-del");
|
||||||
|
if (del) {
|
||||||
|
del.addEventListener("click", function () {
|
||||||
|
var cell = row.closest(".rack-cell");
|
||||||
|
row.remove();
|
||||||
|
if (cell) recalcCell(cell);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow(cell) {
|
||||||
|
var tpl = document.getElementById("rk-row-tpl");
|
||||||
|
if (!tpl) return;
|
||||||
|
var frag = tpl.content.cloneNode(true);
|
||||||
|
var row = frag.querySelector(".rk-row");
|
||||||
|
if (!row) return;
|
||||||
|
var hidden = row.querySelector('input[name="rk_cell"]');
|
||||||
|
if (hidden) hidden.value = cell.getAttribute("data-cell") || "";
|
||||||
|
cell.querySelector(".rk-rows").appendChild(row);
|
||||||
|
wireRow(row);
|
||||||
|
var sku = row.querySelector(".rk-sku");
|
||||||
|
if (sku) sku.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
document.querySelectorAll(".rack-cell .rk-row").forEach(wireRow);
|
||||||
|
document.querySelectorAll(".rack-cell").forEach(recalcCell);
|
||||||
|
document.querySelectorAll(".rack-cell .rk-add").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var cell = btn.closest(".rack-cell");
|
||||||
|
if (cell) addRow(cell);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -219,6 +219,32 @@ CREATE TRIGGER trg_stocktake_line_updated
|
|||||||
BEFORE UPDATE ON daily_stocktake_line
|
BEFORE UPDATE ON daily_stocktake_line
|
||||||
FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION malaysia_set_updated_at();
|
||||||
|
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
-- 6.5) 일일 재고조사 랙(rack) 입력
|
||||||
|
-- 창고 랙(A/B면, 행/열/분할 칸)별로 "어떤 SKU 가 한 박스당 몇 개씩
|
||||||
|
-- 몇 박스 있는지"를 기록한다. 칸 단위 입력 → 서버가 SKU 별
|
||||||
|
-- qty = SUM(units_per_box * box_count) 로 집계해 daily_stocktake_line 을
|
||||||
|
-- 재생성한다(랙이 라인의 단일 출처). 한 칸(파렛트)에 여러 SKU 가능.
|
||||||
|
-- cell_code 예: 'A3-1-1' (면 A, 행 3, 열 1, 분할 1).
|
||||||
|
-- sku_code: 낱개(MT-/MX-/MZ-) 또는 세트(MY-) 허용. MD- 금지.
|
||||||
|
-- ════════════════════════════════════════════════════════════
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_stocktake_rack (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
stocktake_id BIGINT NOT NULL REFERENCES daily_stocktake(id) ON DELETE CASCADE,
|
||||||
|
cell_code TEXT NOT NULL, -- 랙 위치(예: A3-1-1)
|
||||||
|
line_no INTEGER NOT NULL DEFAULT 0, -- 칸 안 입력 순서
|
||||||
|
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-%'),
|
||||||
|
units_per_box INTEGER NOT NULL CHECK (units_per_box > 0),
|
||||||
|
box_count INTEGER NOT NULL CHECK (box_count > 0),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stocktake_rack_st ON daily_stocktake_rack (stocktake_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_stocktake_rack_cell ON daily_stocktake_rack (stocktake_id, cell_code);
|
||||||
|
|
||||||
-- ════════════════════════════════════════════════════════════
|
-- ════════════════════════════════════════════════════════════
|
||||||
-- 7) seed — 기본 창고 + 재고관리 대상 아이템
|
-- 7) seed — 기본 창고 + 재고관리 대상 아이템
|
||||||
-- ════════════════════════════════════════════════════════════
|
-- ════════════════════════════════════════════════════════════
|
||||||
@@ -254,7 +280,7 @@ ON CONFLICT (item_code) DO NOTHING;
|
|||||||
GRANT USAGE ON SCHEMA public TO malaysia_app;
|
GRANT USAGE ON SCHEMA public TO malaysia_app;
|
||||||
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
GRANT SELECT, INSERT, UPDATE, DELETE ON
|
||||||
warehouses, malaysia_items, set_bom, stock_movement,
|
warehouses, malaysia_items, set_bom, stock_movement,
|
||||||
daily_stocktake, daily_stocktake_line
|
daily_stocktake, daily_stocktake_line, daily_stocktake_rack
|
||||||
TO malaysia_app;
|
TO malaysia_app;
|
||||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO malaysia_app;
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO malaysia_app;
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||||
|
|||||||
@@ -170,6 +170,51 @@ def test_missing_bom_sets():
|
|||||||
assert missing == ["MY-0005"]
|
assert missing == ["MY-0005"]
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
# 랙 입력 — 레이아웃 / 집계
|
||||||
|
# ────────────────────────────────────────────────────────────
|
||||||
|
def test_rack_cell_codes_count_and_format():
|
||||||
|
codes = store.rack_cell_codes()
|
||||||
|
# A/B 2면 × 3행 × 3열 × 2분할 = 36칸
|
||||||
|
assert len(codes) == 36
|
||||||
|
assert len(set(codes)) == 36
|
||||||
|
assert "A3-1-1" in codes
|
||||||
|
assert "B1-3-2" in codes
|
||||||
|
# 그림과 동일: 첫 칸은 A면 3행 1열 1분할
|
||||||
|
assert codes[0] == "A3-1-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rack_layout_structure():
|
||||||
|
layout = store.rack_layout()
|
||||||
|
assert [s["side"] for s in layout] == ["A", "B"]
|
||||||
|
side_a = layout[0]
|
||||||
|
assert [r["row"] for r in side_a["rows"]] == [3, 2, 1]
|
||||||
|
# 각 행 3열, 각 열 2분할
|
||||||
|
first_row = side_a["rows"][0]
|
||||||
|
assert len(first_row["cols"]) == 3
|
||||||
|
assert first_row["cols"][0] == ["A3-1-1", "A3-1-2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_rack_sums_units_times_boxes():
|
||||||
|
entries = [
|
||||||
|
{"sku_code": "MT-0320", "units_per_box": 24, "box_count": 3}, # 72
|
||||||
|
{"sku_code": "MT-0320", "units_per_box": 24, "box_count": 1}, # +24 (다른 칸)
|
||||||
|
{"sku_code": "MY-0002", "units_per_box": 6, "box_count": 2}, # 12 (콤보도 집계)
|
||||||
|
]
|
||||||
|
out = store.aggregate_rack(entries)
|
||||||
|
assert out == {"MT-0320": 96, "MY-0002": 12}
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_rack_skips_incomplete():
|
||||||
|
entries = [
|
||||||
|
{"sku_code": "", "units_per_box": 10, "box_count": 2}, # 코드 없음
|
||||||
|
{"sku_code": "MT-0550", "units_per_box": 0, "box_count": 5}, # 입수량 0
|
||||||
|
{"sku_code": "MT-0750", "units_per_box": 5, "box_count": 0}, # 박스 0
|
||||||
|
{"sku_code": "MT-0950", "units_per_box": "x", "box_count": 2}, # 정수 아님
|
||||||
|
]
|
||||||
|
assert store.aggregate_rack(entries) == {}
|
||||||
|
|
||||||
|
|
||||||
# pytest 미설치 환경 폴백
|
# pytest 미설치 환경 폴백
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import traceback
|
import traceback
|
||||||
|
|||||||
Reference in New Issue
Block a user