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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user