feat(cupang): 박스 계산 요약 — 자투리 혼합 박스 시각화
하단 "박스명별 합계" 표를 요약 카드로 교체. - KPI 3개: 총 박스 / 제품별 박스 / 혼합 박스(자투리 N개) - 제품별 카드: 몇 박스 + 자투리 몇 개(딱 맞으면 "딱 맞음") - 자투리 혼합 박스: 박스 1개당 카드로 어떤 상품이 몇 개 들어가는지 표시. 상자 SVG(뚜껑 열림·내용물 차오름), 채움률 바, 카드 등장 애니메이션. prefers-reduced-motion 에서는 애니메이션 비활성. 혼합 계산은 서버 _pack_leftovers: 박스 용량을 1 로 두고 제품 1개 = 1/units_per_box 부피로 환산, 같은 박스명끼리만 채운다. 부동소수 오차를 피하려고 Fraction 사용. 한 제품 자투리가 두 박스로 나뉘는 것은 허용해 박스 수가 최소가 되게 한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import calendar as _calendar
|
||||
import json
|
||||
from fractions import Fraction
|
||||
from typing import Any
|
||||
|
||||
from app.timezone import today_kst
|
||||
@@ -656,14 +657,75 @@ async def box_calc_api(
|
||||
agg["required_boxes"] += calc["required_boxes"]
|
||||
agg["remainder_units"] += calc["remainder_units"]
|
||||
|
||||
mixes = _pack_leftovers(results)
|
||||
grand_total = sum(r["full_boxes"] or 0 for r in results if r["configured"])
|
||||
grand_total += sum(m["box_count"] for m in mixes)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"results": results,
|
||||
"totals": sorted(totals.values(), key=lambda t: t["box_name"]),
|
||||
"mixes": mixes,
|
||||
"grand_total_boxes": grand_total,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _pack_leftovers(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""제품별 자투리(remainder_units)를 같은 박스명끼리 모아 혼합 박스에 담는다.
|
||||
|
||||
- 한 박스의 용량을 1 로 두고, 제품 1개가 차지하는 부피를 1/units_per_box 로 본다.
|
||||
(예: 쿠팡 2호 = 8개들이 → 1개 = 1/8 박스)
|
||||
- 같은 박스명끼리만 섞는다. 서로 다른 입수량이 섞여도 부피 합으로 정확히 계산된다.
|
||||
- 한 제품의 자투리가 두 박스에 나뉘어 담기는 것은 허용(그래야 박스 수가 최소).
|
||||
- 오차 없이 계산하려고 float 대신 Fraction 을 쓴다.
|
||||
"""
|
||||
groups: dict[str, list[dict[str, Any]]] = {}
|
||||
for r in results:
|
||||
if not r["configured"] or not r["remainder_units"]:
|
||||
continue
|
||||
groups.setdefault(r["box_name"], []).append(r)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for box_name in sorted(groups):
|
||||
# 자투리가 많은 제품부터 담아 박스 안 품목 수를 줄인다.
|
||||
items = sorted(groups[box_name], key=lambda r: -r["remainder_units"])
|
||||
boxes: list[dict[str, Any]] = []
|
||||
cur: list[dict[str, Any]] = []
|
||||
free = Fraction(1)
|
||||
|
||||
for r in items:
|
||||
unit = Fraction(1, int(r["units_per_box"]))
|
||||
left = int(r["remainder_units"])
|
||||
while left > 0:
|
||||
take = min(left, int(free / unit))
|
||||
if take == 0: # 남은 자리 없음 → 새 박스
|
||||
boxes.append({"items": cur, "fill_percent": float(round((1 - free) * 100, 1))})
|
||||
cur, free = [], Fraction(1)
|
||||
continue
|
||||
cur.append(
|
||||
{
|
||||
"product_code": r["product_code"],
|
||||
"product_name": r["product_name"],
|
||||
"quantity": take,
|
||||
}
|
||||
)
|
||||
free -= unit * take
|
||||
left -= take
|
||||
if cur:
|
||||
boxes.append({"items": cur, "fill_percent": float(round((1 - free) * 100, 1))})
|
||||
|
||||
out.append(
|
||||
{
|
||||
"box_name": box_name,
|
||||
"box_count": len(boxes),
|
||||
"leftover_units": sum(int(r["remainder_units"]) for r in items),
|
||||
"boxes": boxes,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user