feat(cupang): 박스 계산 화면 추가

달력 상단 "박스 계산" 버튼 → /cupang/box-calc. 제품명+수량을 여러 행
입력하면 제품별 박스 수·남은 낱개·총 필요 박스와 박스명별 합계를 계산.
수량/제품을 고치고 "계산 / 재계산" 으로 다시 계산할 수 있다(수량 칸에서
Enter 도 동일). 저장하지 않는 계산 전용 화면.

계산은 POST /cupang/api/box-calc 에서 store.compute_boxes 로 수행 —
클라이언트 계산을 신뢰하지 않는다. 입수량 규칙이 없는 제품은 "미설정"
으로 표시하고 합계에서 제외.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 12:18:52 +09:00
parent 7eb63ff664
commit c72c0710ad
4 changed files with 331 additions and 0 deletions
+96
View File
@@ -568,6 +568,102 @@ async def box_rule_delete(
return RedirectResponse(url="/cupang/box-rules", status_code=303)
# ════════════════════════════════════════════════════════════
# 박스 계산기 — 제품명 + 수량 → 박스 수 / 남은 낱개
# 저장하지 않는 계산 전용 화면. 규칙은 cupang_box_rules 를 그대로 사용한다.
# ════════════════════════════════════════════════════════════
@router.get("/box-calc", response_class=HTMLResponse)
async def box_calc_page(request: Request) -> HTMLResponse:
from app.main import build_erp_nav, render_template # noqa: WPS433
from app.store import is_admin # noqa: WPS433
guard = _guard(request)
if not isinstance(guard, tuple):
return guard
store, user = guard
return render_template(
request,
"cupang/box_calc.html",
{
"user": user,
"is_admin": is_admin(user),
"nav_items": build_erp_nav(user, active="cupang"),
"page_title": "쿠팡 밀크런 — 박스 계산",
"page_subtitle": "제품명과 수량을 넣으면 박스 수와 남은 낱개를 계산합니다.",
"box_rules": store.list_box_rules(),
},
)
@router.post("/api/box-calc")
async def box_calc_api(
request: Request,
payload: dict[str, Any] = Body(...),
user: dict[str, Any] = Depends(_require_user),
) -> JSONResponse:
"""[{product_code, quantity}] → 제품별 박스 계산 + 박스명별 합계.
클라이언트 계산을 신뢰하지 않고 store.compute_boxes 로 서버에서 계산한다.
"""
from .store import compute_boxes # noqa: WPS433
store = _store(request)
if store is None:
raise HTTPException(status_code=503, detail="cupang_db 미설정")
raw_items = payload.get("items")
if not isinstance(raw_items, list):
raise HTTPException(status_code=400, detail="items 는 배열이어야 합니다.")
rules = {r["product_code"]: r for r in store.list_box_rules()}
results: list[dict[str, Any]] = []
totals: dict[str, dict[str, Any]] = {}
for raw in raw_items:
if not isinstance(raw, dict):
continue
code = str(raw.get("product_code") or "").strip()
if not code:
continue
try:
qty = int(raw.get("quantity") or 0)
except (TypeError, ValueError):
qty = 0
qty = max(qty, 0)
rule = rules.get(code)
upb = rule["units_per_box"] if rule else None
calc = compute_boxes(qty, upb)
box_name = (rule or {}).get("box_name") or ""
results.append(
{
"product_code": code,
"product_name": (rule or {}).get("product_name_snapshot") or code,
"box_name": box_name,
"units_per_box": calc["units_per_box"],
"quantity": qty,
"configured": calc["configured"],
"full_boxes": calc["full_boxes"],
"remainder_units": calc["remainder_units"],
"required_boxes": calc["required_boxes"],
}
)
if calc["configured"]:
agg = totals.setdefault(
box_name, {"box_name": box_name, "full_boxes": 0, "required_boxes": 0, "remainder_units": 0}
)
agg["full_boxes"] += calc["full_boxes"]
agg["required_boxes"] += calc["required_boxes"]
agg["remainder_units"] += calc["remainder_units"]
return JSONResponse(
{
"results": results,
"totals": sorted(totals.values(), key=lambda t: t["box_name"]),
}
)
# ════════════════════════════════════════════════════════════
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
# ════════════════════════════════════════════════════════════