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