From 032fb788673c8ad3cb18cf5f872470f93148e8eb Mon Sep 17 00:00:00 2001 From: king Date: Sun, 14 Jun 2026 16:25:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(malaysia):=20=EC=9E=AC=EA=B3=A0=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=20=EB=9E=99(rack)=20=EB=B0=95=EC=8A=A4=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=EC=9E=85=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 낱개/콤보 평면 수량 그리드를 창고 랙 그림(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 --- app/modules/malaysia/README.md | 20 ++- app/modules/malaysia/db.py | 87 ++++++++++++ app/modules/malaysia/router.py | 51 ++++++- app/modules/malaysia/store.py | 60 ++++++++ .../malaysia/templates/malaysia/_i18n.html | 2 +- .../templates/malaysia/stocktake_detail.html | 132 ++++++++++++------ app/static/malaysia.js | 3 + app/static/malaysia_rack.js | 75 ++++++++++ scripts/sql/malaysia_stock_db_init.sql | 28 +++- tests/test_malaysia_stock.py | 45 ++++++ 10 files changed, 456 insertions(+), 47 deletions(-) create mode 100644 app/static/malaysia_rack.js diff --git a/app/modules/malaysia/README.md b/app/modules/malaysia/README.md index 1478771..3215ba9 100644 --- a/app/modules/malaysia/README.md +++ b/app/modules/malaysia/README.md @@ -43,6 +43,20 @@ cupang 모듈과 동일한 패턴: **malaysia_stock_db(PostgreSQL) 전용**, JSO 예) `MT-0320` 낱개 100 + `MY-0002`(MT-0320 1개 포함) 20세트 → `MT-0320` total = **120**. > 낱개 입력 시 **세트 안에 든 건 빼고** 낱개 보관분만 입력한다. 세트 안 상품은 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`) @@ -54,7 +68,8 @@ cupang 모듈과 동일한 패턴: **malaysia_stock_db(PostgreSQL) 전용**, JSO | `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)` | +| `daily_stocktake_line` | 조사 라인(SKU별 최종 qty). 랙 입력 집계로 **재생성**됨. `UNIQUE(stocktake_id, sku_code)` | +| `daily_stocktake_rack` | 랙 칸별 입력(셀×SKU×입수량×박스수). 라인의 단일 출처. `cell_code` 예 `A3-1-1` | 검증 규칙(요구사항 8)은 **DB CHECK + 서비스 레이어(`store.py`)** 양쪽에 걸려 있다. @@ -75,6 +90,7 @@ JSON API: | POST | `/malaysia/api/movements` | 낱개 movement 등록 | | POST | `/malaysia/api/movements/set-out` | 세트 출고(BOM 분해) | | 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 는 건드리지 않음. +> **랙 입력 추가(`daily_stocktake_rack`)** 후 기존 운영 DB 에 반영하려면 위 init 스크립트를 +> 그대로 1회 재실행한다(`CREATE TABLE IF NOT EXISTS` + grant 만 적용, 기존 데이터 보존). ## 테스트 diff --git a/app/modules/malaysia/db.py b/app/modules/malaysia/db.py index 7e8cb30..a04e0c4 100644 --- a/app/modules/malaysia/db.py +++ b/app/modules/malaysia/db.py @@ -579,6 +579,93 @@ class MalaysiaStockStore: if cur.rowcount == 0: 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]: with self._pool.connection() as conn: row = conn.execute( diff --git a/app/modules/malaysia/router.py b/app/modules/malaysia/router.py index fd44540..52b4a00 100644 --- a/app/modules/malaysia/router.py +++ b/app/modules/malaysia/router.py @@ -468,14 +468,19 @@ async def stocktake_detail(request: Request, stocktake_id: int) -> HTMLResponse: wh = head["warehouse_code"] # 입력된 라인 qty 를 코드별 dict 로 (그리드 기본값) 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.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"), + "rack_layout": store.rack_layout(), + "rack_by_cell": rack_by_cell, + "rack_items": st.list_items(), "entered": entered, "result_rows": computed["rows"], "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) +@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") async def stocktake_line_delete( request: Request, stocktake_id: int, line_id: int, diff --git a/app/modules/malaysia/store.py b/app/modules/malaysia/store.py index 6ed312f..08d44e0 100644 --- a/app/modules/malaysia/store.py +++ b/app/modules/malaysia/store.py @@ -27,6 +27,66 @@ INDIVIDUAL_PREFIXES: tuple[str, ...] = ("MT-", "MX-", "MZ-") SET_PREFIX: str = "MY-" 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 + # ════════════════════════════════════════════════════════════ # 코드 분류 / 검증 (순수 함수) diff --git a/app/modules/malaysia/templates/malaysia/_i18n.html b/app/modules/malaysia/templates/malaysia/_i18n.html index 8904b2a..99b29e7 100644 --- a/app/modules/malaysia/templates/malaysia/_i18n.html +++ b/app/modules/malaysia/templates/malaysia/_i18n.html @@ -2,4 +2,4 @@
- + diff --git a/app/modules/malaysia/templates/malaysia/stocktake_detail.html b/app/modules/malaysia/templates/malaysia/stocktake_detail.html index 1e3aa0a..bfae270 100644 --- a/app/modules/malaysia/templates/malaysia/stocktake_detail.html +++ b/app/modules/malaysia/templates/malaysia/stocktake_detail.html @@ -1,14 +1,56 @@ {% extends "erp_base.html" %} +{% macro rack_row(cell, items, entry=None, disabled=False) %} +
+ + + + × + + {{ (entry.units_per_box * entry.box_count) if entry else 0 }} + {% if not disabled %}{% endif %} +
+{% endmacro %} + {% block head_extra %} {% endblock %} @@ -19,8 +61,8 @@ ◀◀ 조사 목록 -
-
+ +
@@ -40,6 +82,10 @@

⚠ BOM 없는 콤보: {{ missing_bom|join(', ') }}

{% endif %} +

+ 랙 칸에 아이템·입수량(박스당 개수)·박스 수를 입력하면 낱개/콤보 수량이 자동 집계됩니다. 한 칸에 여러 아이템은 + 로 추가하세요. +

+ {% if is_draft %}
@@ -47,48 +93,49 @@ {% endif %}
- +
-

낱개 상품

-
- - - - {% for it in individual_items %} - - - - - +

랙 입력 (박스 단위)

+
+ {% for side in rack_layout %} +
+

Side {{ side.side }}

+ {% for row in side.rows %} +
+ {% for col in row.cols %} + {% for cell in col %} +
+
+ {{ cell }} + 0 + {% if is_draft %}{% endif %} +
+
+ {% 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 %} +
+
+ {% endfor %} {% endfor %} -
-
CodeNameQty
{{ it.item_code }}{{ it.item_name }}
-
-
- - -
-

콤보 상품

-
- - - - {% for it in set_items %} - - - - - - {% endfor %} - -
Set CodeSet NameQty
{{ it.item_code }}{{ it.item_name }}
+
+ {% endfor %} +
+ {% endfor %}
+ + +
{% if is_draft %} @@ -135,4 +182,5 @@
+ {% endblock %} diff --git a/app/static/malaysia.js b/app/static/malaysia.js index edefcdf..ed73957 100644 --- a/app/static/malaysia.js +++ b/app/static/malaysia.js @@ -68,6 +68,9 @@ "입력 저장": "Save", "확정 (전산 반영)": "Finalize (apply)", "삭제 (슈퍼관리자)": "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)", "total_qty = direct_qty(낱개 직접) + from_set_qty(콤보 분해)": "total_qty = direct_qty (single) + from_set_qty (combo)", diff --git a/app/static/malaysia_rack.js b/app/static/malaysia_rack.js new file mode 100644 index 0000000..0f5a40a --- /dev/null +++ b/app/static/malaysia_rack.js @@ -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); + }); + }); + }); +})(); diff --git a/scripts/sql/malaysia_stock_db_init.sql b/scripts/sql/malaysia_stock_db_init.sql index 9dd08f6..0b6ad26 100644 --- a/scripts/sql/malaysia_stock_db_init.sql +++ b/scripts/sql/malaysia_stock_db_init.sql @@ -219,6 +219,32 @@ CREATE TRIGGER trg_stocktake_line_updated BEFORE UPDATE ON daily_stocktake_line 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 — 기본 창고 + 재고관리 대상 아이템 -- ════════════════════════════════════════════════════════════ @@ -254,7 +280,7 @@ ON CONFLICT (item_code) DO NOTHING; 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 + daily_stocktake, daily_stocktake_line, daily_stocktake_rack TO malaysia_app; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO malaysia_app; ALTER DEFAULT PRIVILEGES IN SCHEMA public diff --git a/tests/test_malaysia_stock.py b/tests/test_malaysia_stock.py index 7be3206..7238a00 100644 --- a/tests/test_malaysia_stock.py +++ b/tests/test_malaysia_stock.py @@ -170,6 +170,51 @@ def test_missing_bom_sets(): 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 미설치 환경 폴백 if __name__ == "__main__": import traceback