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
+49 -2
View File
@@ -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,