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:
2026-08-28 12:28:34 +09:00
parent c72c0710ad
commit c6ee37ee48
9 changed files with 324 additions and 38 deletions
+102 -32
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828s" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828t" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -53,22 +53,17 @@
<div class="erp-card cpg-form-card cpg-calc-sum" id="cpg-calc-sum-card" hidden>
<div class="cpg-card-head">
<h2>박스명별 합계</h2>
<span class="erp-muted">가득 채운 박스 / 낱개 포함 총 필요 박스</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table">
<thead>
<tr>
<th>박스명</th>
<th class="cpg-calc-num">박스 수</th>
<th class="cpg-calc-num">남은 낱개 합</th>
<th class="cpg-calc-num">총 필요 박스</th>
</tr>
</thead>
<tbody id="cpg-calc-sum-rows"></tbody>
</table>
<h2>박스 요약</h2>
<span class="erp-muted">제품별 박스 + 자투리를 모아 담은 혼합 박스</span>
</div>
<div class="cpg-sum-kpis" id="cpg-sum-kpis"></div>
<h3 class="cpg-sum-h3">제품별 박스</h3>
<div class="cpg-sum-prods" id="cpg-sum-prods"></div>
<h3 class="cpg-sum-h3">자투리 혼합 박스 <span class="erp-muted" id="cpg-sum-mix-note"></span></h3>
<div class="cpg-sum-boxes" id="cpg-sum-boxes"></div>
</div>
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
@@ -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 += "<tr><td>" + esc(t.box_name || "—") + "</td>" +
'<td class="cpg-calc-num">' + t.full_boxes + "박스</td>" +
'<td class="cpg-calc-num">' + t.remainder_units + "개</td>" +
'<td class="cpg-calc-num">' + t.required_boxes + "박스</td></tr>";
});
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 '' +
'<svg class="cpg-box-svg" viewBox="0 0 72 64" aria-hidden="true">' +
'<rect class="cpg-box-fill" x="10" y="' + (54 - h) + '" width="52" height="' + h + '" rx="2" />' +
'<path class="cpg-box-body" d="M10 20 H62 V54 H10 Z" />' +
'<path class="cpg-box-flapL" d="M10 20 L2 8 H30 L36 20 Z" />' +
'<path class="cpg-box-flapR" d="M62 20 L70 8 H42 L36 20 Z" />' +
'<line class="cpg-box-seam" x1="36" y1="20" x2="36" y2="54" />' +
'</svg>';
}
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 += '<div class="cpg-sum-prod">' +
'<div class="cpg-sum-prod-head">' +
'<span class="cpg-sum-prod-name">' + esc(r.product_name) + "</span>" +
'<span class="erp-badge erp-badge-neutral">' + esc(r.box_name) + "</span>" +
"</div>" +
'<div class="cpg-sum-prod-nums">' +
'<strong>' + r.full_boxes + "박스</strong>" +
'<span class="erp-muted">' + r.quantity + "개 ÷ " + r.units_per_box + "개</span>" +
(r.remainder_units
? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>"
: '<span class="cpg-sum-exact">딱 맞음</span>') +
"</div>" +
"</div>";
});
sumProds.innerHTML = ph;
// 자투리 혼합 박스
if (!mixes.length) {
sumMixNote.textContent = "— 자투리 없음";
sumBoxes.innerHTML = '<p class="erp-muted">남는 낱개가 없습니다. 모든 박스가 딱 맞습니다.</p>';
} 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 '<li><span>' + esc(it.product_name) + "</span><b>" + it.quantity + "개</b></li>";
}).join("");
bh += '<div class="cpg-box-card" style="animation-delay:' + (n * 70) + 'ms">' +
'<div class="cpg-box-art">' + boxSvg(b.fill_percent) + "</div>" +
'<div class="cpg-box-info">' +
'<div class="cpg-box-title">' + esc(m.box_name) + " 혼합 #" + (i + 1) + "</div>" +
'<ul class="cpg-box-items">' + items + "</ul>" +
'<div class="cpg-box-bar"><span style="width:' + b.fill_percent + '%"></span></div>' +
'<div class="cpg-box-fillnum">' + b.fill_percent + "% 채움</div>" +
"</div>" +
"</div>";
n += 1;
});
});
sumBoxes.innerHTML = bh;
}
sumCard.hidden = false;
}
function kpi(label, value, hint) {
return '<div class="cpg-kpi"><span class="cpg-kpi-label">' + esc(label) + "</span>" +
'<strong class="cpg-kpi-value">' + esc(value) + "</strong>" +
'<span class="cpg-kpi-hint">' + esc(hint) + "</span></div>";
}
tbody.addEventListener("click", function (e) {
var del = e.target.closest(".cpg-calc-del");
if (!del) return;
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828s" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828t" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828s" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828t" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828s" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828t" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828s" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828t" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828s" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828t" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828s" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828t" />{% endblock %}
{% block content %}
<section class="cpg">