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
@@ -0,0 +1,225 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260828s" />{% endblock %}
{% block content %}
<section class="cpg">
<div class="erp-page-actions">
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
</div>
{% if not box_rules %}
<div class="erp-card cpg-form-card">
<p class="erp-muted">
등록된 박스 입수량 규칙이 없습니다.
<a href="/cupang/box-rules">박스 입수량 설정</a>에서 먼저 제품별 입수량을 등록하세요.
</p>
</div>
{% else %}
<div class="erp-card cpg-form-card cpg-calc-card">
<div class="cpg-card-head">
<h2>박스 계산</h2>
<span class="erp-muted">수량을 고치고 다시 계산하면 결과가 갱신됩니다.</span>
</div>
<div class="erp-table-wrap">
<table class="erp-table cpg-calc-table">
<thead>
<tr>
<th>제품명</th>
<th class="cpg-calc-num">수량</th>
<th>박스명</th>
<th class="cpg-calc-num">입수량</th>
<th class="cpg-calc-num">박스 수</th>
<th class="cpg-calc-num">남은 낱개</th>
<th class="cpg-calc-num">총 필요 박스</th>
<th>동작</th>
</tr>
</thead>
<tbody id="cpg-calc-rows"></tbody>
</table>
</div>
<div class="erp-page-actions cpg-calc-actions">
<button type="button" class="erp-btn erp-btn-primary" id="cpg-calc-run">계산 / 재계산</button>
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-add">+ 제품 추가</button>
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-reset">초기화</button>
<span class="erp-muted" id="cpg-calc-msg"></span>
</div>
</div>
<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>
</div>
</div>
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
<script>
// 박스 계산기 — 행 추가/삭제, 수량 수정 후 재계산.
// 계산 자체는 서버(POST /cupang/api/box-calc, store.compute_boxes)에서 수행한다.
(function () {
var tbody = document.getElementById("cpg-calc-rows");
if (!tbody) return;
var runBtn = document.getElementById("cpg-calc-run");
var addBtn = document.getElementById("cpg-calc-add");
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 rules = [];
try { rules = JSON.parse(document.getElementById("cpg-calc-rules").textContent || "[]"); } catch (e) {}
var optionsHtml = '<option value="">— 제품명 선택 —</option>';
rules.forEach(function (r) {
var label = (r.product_name_snapshot || r.product_code) +
" (" + (r.box_name || "-") + " / " + r.units_per_box + "개)";
optionsHtml += '<option value="' + esc(r.product_code) + '">' + esc(label) + "</option>";
});
function esc(s) { var d = document.createElement("div"); d.textContent = s == null ? "" : s; return d.innerHTML; }
function addRow(code, qty) {
var tr = document.createElement("tr");
tr.className = "cpg-calc-row";
tr.innerHTML =
'<td><select class="erp-select cpg-calc-name">' + optionsHtml + "</select></td>" +
'<td class="cpg-calc-num"><input class="erp-input cpg-calc-qty" type="number" min="0" step="1" value="' + (qty || 0) + '" /></td>' +
'<td class="cpg-calc-box">—</td>' +
'<td class="cpg-calc-num cpg-calc-upb">—</td>' +
'<td class="cpg-calc-num cpg-calc-boxes">—</td>' +
'<td class="cpg-calc-num cpg-calc-rem">—</td>' +
'<td class="cpg-calc-num cpg-calc-req">—</td>' +
'<td><button type="button" class="erp-btn erp-btn-danger cpg-calc-del">삭제</button></td>';
tbody.appendChild(tr);
if (code) tr.querySelector(".cpg-calc-name").value = code;
return tr;
}
function clearResults() {
Array.prototype.forEach.call(tbody.querySelectorAll(".cpg-calc-row"), function (tr) {
tr.querySelector(".cpg-calc-box").textContent = "—";
tr.querySelector(".cpg-calc-upb").textContent = "—";
tr.querySelector(".cpg-calc-boxes").textContent = "—";
tr.querySelector(".cpg-calc-rem").textContent = "—";
tr.querySelector(".cpg-calc-req").textContent = "—";
tr.classList.remove("cpg-calc-warn");
});
sumCard.hidden = true;
sumRows.innerHTML = "";
}
function collect() {
var items = [];
Array.prototype.forEach.call(tbody.querySelectorAll(".cpg-calc-row"), function (tr) {
var code = tr.querySelector(".cpg-calc-name").value;
if (!code) return;
items.push({ product_code: code, quantity: parseInt(tr.querySelector(".cpg-calc-qty").value, 10) || 0, _tr: tr });
});
return items;
}
function run() {
var items = collect();
if (!items.length) { clearResults(); msg.textContent = "제품명을 하나 이상 선택하세요."; return; }
msg.textContent = "계산 중…";
runBtn.disabled = true;
fetch("/cupang/api/box-calc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items.map(function (it) { return { product_code: it.product_code, quantity: it.quantity }; }) })
})
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function (data) {
var results = (data && data.results) || [];
results.forEach(function (res, i) {
var tr = items[i] && items[i]._tr;
if (!tr) return;
tr.querySelector(".cpg-calc-box").textContent = res.box_name || "—";
tr.querySelector(".cpg-calc-upb").textContent = res.units_per_box ? res.units_per_box + "개" : "미설정";
if (res.configured) {
tr.querySelector(".cpg-calc-boxes").textContent = res.full_boxes + "박스";
tr.querySelector(".cpg-calc-rem").textContent = res.remainder_units + "개";
tr.querySelector(".cpg-calc-req").textContent = res.required_boxes + "박스";
tr.classList.remove("cpg-calc-warn");
} else {
tr.querySelector(".cpg-calc-boxes").textContent = "—";
tr.querySelector(".cpg-calc-rem").textContent = "—";
tr.querySelector(".cpg-calc-req").textContent = "—";
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;
}
msg.textContent = "계산 완료 (" + results.length + "건)";
})
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; })
.then(function () { runBtn.disabled = false; });
}
tbody.addEventListener("click", function (e) {
var del = e.target.closest(".cpg-calc-del");
if (!del) return;
var rows = tbody.querySelectorAll(".cpg-calc-row");
if (rows.length <= 1) { addRow("", 0); }
del.closest("tr").remove();
msg.textContent = "";
});
// 수량/제품을 바꾸면 이전 결과는 지운다 — 낡은 숫자 오독 방지.
tbody.addEventListener("input", function (e) {
if (e.target.classList.contains("cpg-calc-qty")) { msg.textContent = "수량 변경됨 — 재계산하세요."; }
});
tbody.addEventListener("change", function (e) {
if (e.target.classList.contains("cpg-calc-name")) { msg.textContent = "제품 변경됨 — 재계산하세요."; }
});
tbody.addEventListener("keydown", function (e) {
if (e.key === "Enter" && e.target.classList.contains("cpg-calc-qty")) { e.preventDefault(); run(); }
});
runBtn.addEventListener("click", run);
addBtn.addEventListener("click", function () { addRow("", 0); });
resetBtn.addEventListener("click", function () {
tbody.innerHTML = "";
addRow("", 0);
clearResults();
msg.textContent = "";
});
addRow("", 0);
})();
</script>
{% endif %}
</section>
{% endblock %}
@@ -13,6 +13,7 @@
<a class="erp-btn erp-btn-outline" href="/cupang/products">제품명 설정</a>
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-rules">박스 입수량 설정</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-calc">박스 계산</a>
</span>
</div>
<div class="cpg-actions-spacer"></div>