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:
2026-06-14 16:25:40 +09:00
parent 65997adfd3
commit 032fb78867
10 changed files with 456 additions and 47 deletions
+87
View File
@@ -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(