From c6ee37ee48e1f4782e6c4e18773c27e8607126cc Mon Sep 17 00:00:00 2001 From: king Date: Fri, 28 Aug 2026 12:28:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(cupang):=20=EB=B0=95=EC=8A=A4=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=9A=94=EC=95=BD=20=E2=80=94=20=EC=9E=90=ED=88=AC?= =?UTF-8?q?=EB=A6=AC=20=ED=98=BC=ED=95=A9=20=EB=B0=95=EC=8A=A4=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하단 "박스명별 합계" 표를 요약 카드로 교체. - KPI 3개: 총 박스 / 제품별 박스 / 혼합 박스(자투리 N개) - 제품별 카드: 몇 박스 + 자투리 몇 개(딱 맞으면 "딱 맞음") - 자투리 혼합 박스: 박스 1개당 카드로 어떤 상품이 몇 개 들어가는지 표시. 상자 SVG(뚜껑 열림·내용물 차오름), 채움률 바, 카드 등장 애니메이션. prefers-reduced-motion 에서는 애니메이션 비활성. 혼합 계산은 서버 _pack_leftovers: 박스 용량을 1 로 두고 제품 1개 = 1/units_per_box 부피로 환산, 같은 박스명끼리만 채운다. 부동소수 오차를 피하려고 Fraction 사용. 한 제품 자투리가 두 박스로 나뉘는 것은 허용해 박스 수가 최소가 되게 한다. Co-Authored-By: Claude Opus 5 --- app/modules/cupang/router.py | 62 +++++++ .../cupang/templates/cupang/box_calc.html | 134 +++++++++++---- .../cupang/templates/cupang/box_rules.html | 2 +- .../cupang/templates/cupang/centers.html | 2 +- .../cupang/templates/cupang/detail.html | 2 +- app/modules/cupang/templates/cupang/form.html | 2 +- .../cupang/templates/cupang/index.html | 2 +- .../cupang/templates/cupang/products.html | 2 +- app/static/cupang.css | 154 ++++++++++++++++++ 9 files changed, 324 insertions(+), 38 deletions(-) diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index 832a8d4..38cf4da 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -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 에서 등록) # ════════════════════════════════════════════════════════════ diff --git a/app/modules/cupang/templates/cupang/box_calc.html b/app/modules/cupang/templates/cupang/box_calc.html index bcde74e..0abaad1 100644 --- a/app/modules/cupang/templates/cupang/box_calc.html +++ b/app/modules/cupang/templates/cupang/box_calc.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
@@ -53,22 +53,17 @@ @@ -83,7 +78,10 @@ var resetBtn = document.getElementById("cpg-calc-reset"); var msg = document.getElementById("cpg-calc-msg"); var sumCard = document.getElementById("cpg-calc-sum-card"); - var sumRows = document.getElementById("cpg-calc-sum-rows"); + var sumKpis = document.getElementById("cpg-sum-kpis"); + var sumProds = document.getElementById("cpg-sum-prods"); + var sumBoxes = document.getElementById("cpg-sum-boxes"); + var sumMixNote = document.getElementById("cpg-sum-mix-note"); var rules = []; try { rules = JSON.parse(document.getElementById("cpg-calc-rules").textContent || "[]"); } catch (e) {} @@ -124,7 +122,10 @@ tr.classList.remove("cpg-calc-warn"); }); sumCard.hidden = true; - sumRows.innerHTML = ""; + sumKpis.innerHTML = ""; + sumProds.innerHTML = ""; + sumBoxes.innerHTML = ""; + sumMixNote.textContent = ""; } function collect() { @@ -167,26 +168,95 @@ tr.classList.add("cpg-calc-warn"); } }); - var totals = (data && data.totals) || []; - if (totals.length) { - var html = ""; - totals.forEach(function (t) { - html += "" + esc(t.box_name || "—") + "" + - '' + t.full_boxes + "박스" + - '' + t.remainder_units + "개" + - '' + t.required_boxes + "박스"; - }); - sumRows.innerHTML = html; - sumCard.hidden = false; - } else { - sumCard.hidden = true; - } + renderSummary(results, (data && data.mixes) || [], (data && data.grand_total_boxes) || 0); msg.textContent = "계산 완료 (" + results.length + "건)"; }) .catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; }) .then(function () { runBtn.disabled = false; }); } + // ── 요약 렌더 ───────────────────────────────────────── + // 상자 그림: fill(0~100) 만큼 안쪽이 차오르고, 뚜껑이 살짝 열린 채 표시. + function boxSvg(fill) { + var f = Math.max(0, Math.min(100, fill || 0)); + var h = 40 * f / 100; + return '' + + ''; + } + + function renderSummary(results, mixes, grandTotal) { + var ok = results.filter(function (r) { return r.configured; }); + if (!ok.length) { sumCard.hidden = true; return; } + + var fullSum = 0, mixSum = 0, leftoverSum = 0; + ok.forEach(function (r) { fullSum += r.full_boxes; leftoverSum += r.remainder_units; }); + mixes.forEach(function (m) { mixSum += m.box_count; }); + + sumKpis.innerHTML = + kpi("총 박스", (grandTotal || (fullSum + mixSum)) + "박스", "제품별 박스 + 혼합 박스") + + kpi("제품별 박스", fullSum + "박스", "한 제품만 가득 채운 박스") + + kpi("혼합 박스", mixSum + "박스", "자투리 " + leftoverSum + "개를 모아 담음"); + + // 제품별 + var ph = ""; + ok.forEach(function (r) { + ph += '
' + + '
' + + '' + esc(r.product_name) + "" + + '' + esc(r.box_name) + "" + + "
" + + '
' + + '' + r.full_boxes + "박스" + + '' + r.quantity + "개 ÷ " + r.units_per_box + "개" + + (r.remainder_units + ? '자투리 ' + r.remainder_units + "개" + : '딱 맞음') + + "
" + + "
"; + }); + sumProds.innerHTML = ph; + + // 자투리 혼합 박스 + if (!mixes.length) { + sumMixNote.textContent = "— 자투리 없음"; + sumBoxes.innerHTML = '

남는 낱개가 없습니다. 모든 박스가 딱 맞습니다.

'; + } else { + sumMixNote.textContent = "— 같은 박스명끼리만 섞습니다"; + var bh = "", n = 0; + mixes.forEach(function (m) { + m.boxes.forEach(function (b, i) { + var items = b.items.map(function (it) { + return '
  • ' + esc(it.product_name) + "" + it.quantity + "개
  • "; + }).join(""); + bh += '
    ' + + '
    ' + boxSvg(b.fill_percent) + "
    " + + '
    ' + + '
    ' + esc(m.box_name) + " 혼합 #" + (i + 1) + "
    " + + '
      ' + items + "
    " + + '
    ' + + '
    ' + b.fill_percent + "% 채움
    " + + "
    " + + "
    "; + n += 1; + }); + }); + sumBoxes.innerHTML = bh; + } + sumCard.hidden = false; + } + + function kpi(label, value, hint) { + return '
    ' + esc(label) + "" + + '' + esc(value) + "" + + '' + esc(hint) + "
    "; + } + tbody.addEventListener("click", function (e) { var del = e.target.closest(".cpg-calc-del"); if (!del) return; diff --git a/app/modules/cupang/templates/cupang/box_rules.html b/app/modules/cupang/templates/cupang/box_rules.html index 44b7752..b6769db 100644 --- a/app/modules/cupang/templates/cupang/box_rules.html +++ b/app/modules/cupang/templates/cupang/box_rules.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/centers.html b/app/modules/cupang/templates/cupang/centers.html index 711a3a2..3e9cca3 100644 --- a/app/modules/cupang/templates/cupang/centers.html +++ b/app/modules/cupang/templates/cupang/centers.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/detail.html b/app/modules/cupang/templates/cupang/detail.html index 8c7e6c6..3aead7a 100644 --- a/app/modules/cupang/templates/cupang/detail.html +++ b/app/modules/cupang/templates/cupang/detail.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/form.html b/app/modules/cupang/templates/cupang/form.html index 69799e1..e258325 100644 --- a/app/modules/cupang/templates/cupang/form.html +++ b/app/modules/cupang/templates/cupang/form.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/index.html b/app/modules/cupang/templates/cupang/index.html index a835994..1ef8ad3 100644 --- a/app/modules/cupang/templates/cupang/index.html +++ b/app/modules/cupang/templates/cupang/index.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/products.html b/app/modules/cupang/templates/cupang/products.html index b41afad..f36f2a2 100644 --- a/app/modules/cupang/templates/cupang/products.html +++ b/app/modules/cupang/templates/cupang/products.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/static/cupang.css b/app/static/cupang.css index c52413e..f64eb3a 100644 --- a/app/static/cupang.css +++ b/app/static/cupang.css @@ -335,3 +335,157 @@ .cpg-calc-row.cpg-calc-warn { background: color-mix(in srgb, var(--color-callout-red) 8%, transparent); } .cpg-calc-actions { margin-top: 12px; gap: 8px; align-items: center; } .cpg-calc-sum { margin-top: 16px; } + +/* ── 박스 계산 요약 ─────────────────────────────── */ +.cpg-sum-h3 { + margin: 20px 0 10px; + font-size: 15px; + font-weight: 700; +} +.cpg-sum-h3 .erp-muted { font-weight: 400; font-size: 13px; } + +.cpg-sum-kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} +.cpg-kpi { + display: flex; + flex-direction: column; + gap: 2px; + padding: 12px 14px; + border: 1px solid var(--color-subtle-ash); + border-radius: 10px; + background: var(--color-ghost-gray); +} +.cpg-kpi-label { font-size: 12px; color: var(--color-midtone-gray); } +.cpg-kpi-value { font-size: 24px; line-height: 1.2; } +.cpg-kpi-hint { font-size: 12px; color: var(--color-midtone-gray); } + +.cpg-sum-prods { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 8px; +} +.cpg-sum-prod { + padding: 10px 12px; + border: 1px solid var(--color-subtle-ash); + border-radius: 10px; +} +.cpg-sum-prod-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} +.cpg-sum-prod-name { font-weight: 600; } +.cpg-sum-prod-nums { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + font-size: 13px; +} +.cpg-sum-prod-nums strong { font-size: 17px; } +.cpg-sum-left { color: var(--color-callout-red); font-weight: 600; } +.cpg-sum-exact { color: var(--color-success-green); font-weight: 600; } + +/* 혼합 박스 카드 */ +.cpg-sum-boxes { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; +} +.cpg-box-card { + display: flex; + gap: 12px; + padding: 12px; + border: 1px solid var(--color-subtle-ash); + border-radius: 12px; + background: var(--color-canvas-white); + opacity: 0; + animation: cpg-box-in .38s ease-out forwards; +} +@keyframes cpg-box-in { + from { opacity: 0; transform: translateY(10px) scale(.96); } + to { opacity: 1; transform: none; } +} +.cpg-box-art { flex: 0 0 auto; } +.cpg-box-svg { width: 72px; height: 64px; } +.cpg-box-body { + fill: #f6e2c3; + stroke: #b98a4a; + stroke-width: 2; +} +.cpg-box-flapL, .cpg-box-flapR { + fill: #e8cfa6; + stroke: #b98a4a; + stroke-width: 2; + transform-origin: 36px 20px; + animation: cpg-flap .5s ease-out both; +} +.cpg-box-flapL { animation-delay: .12s; } +.cpg-box-flapR { animation-delay: .2s; } +@keyframes cpg-flap { + from { transform: rotate(0deg) translateY(6px); opacity: .3; } + to { transform: none; opacity: 1; } +} +.cpg-box-fill { + fill: var(--color-rich-black); + opacity: .16; + animation: cpg-fill-up .55s ease-out both; + transform-origin: 36px 54px; +} +@keyframes cpg-fill-up { + from { transform: scaleY(0); } + to { transform: scaleY(1); } +} +.cpg-box-seam { stroke: #b98a4a; stroke-width: 1.5; opacity: .5; } + +.cpg-box-info { min-width: 0; flex: 1 1 auto; } +.cpg-box-title { font-weight: 700; margin-bottom: 6px; } +.cpg-box-items { + list-style: none; + margin: 0 0 8px; + padding: 0; + font-size: 13px; +} +.cpg-box-items li { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 2px 0; + border-bottom: 1px dashed var(--color-subtle-ash); +} +.cpg-box-items li:last-child { border-bottom: 0; } +.cpg-box-bar { + height: 6px; + border-radius: 3px; + background: var(--color-ghost-gray); + overflow: hidden; +} +.cpg-box-bar span { + display: block; + height: 100%; + background: var(--color-rich-black); + animation: cpg-bar-grow .6s ease-out both; + transform-origin: left center; +} +@keyframes cpg-bar-grow { + from { transform: scaleX(0); } + to { transform: scaleX(1); } +} +.cpg-box-fillnum { + margin-top: 4px; + font-size: 12px; + color: var(--color-midtone-gray); +} + +@media (prefers-reduced-motion: reduce) { + .cpg-box-card, .cpg-box-fill, .cpg-box-flapL, .cpg-box-flapR, .cpg-box-bar span { + animation: none; + opacity: 1; + } + .cpg-box-fill { opacity: .16; } +}