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) 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 에서 등록) # 설정 — 제품명 카탈로그 관리 (itemcode_db 에서 등록)
# ════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════
@@ -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/products">제품명 설정</a>
<a class="erp-btn erp-btn-outline" href="/cupang/centers">입고센터 관리</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-rules">박스 입수량 설정</a>
<a class="erp-btn erp-btn-outline" href="/cupang/box-calc">박스 계산</a>
</span> </span>
</div> </div>
<div class="cpg-actions-spacer"></div> <div class="cpg-actions-spacer"></div>
+9
View File
@@ -326,3 +326,12 @@
} }
.cpg-sort.is-asc .cpg-sort-ind::after { content: "▲"; opacity: 1; } .cpg-sort.is-asc .cpg-sort-ind::after { content: "▲"; opacity: 1; }
.cpg-sort.is-desc .cpg-sort-ind::after { content: "▼"; opacity: 1; } .cpg-sort.is-desc .cpg-sort-ind::after { content: "▼"; opacity: 1; }
/* 박스 계산기 */
.cpg-calc-table .cpg-calc-num { text-align: right; white-space: nowrap; }
.cpg-calc-table th.cpg-calc-num { text-align: right; }
.cpg-calc-table .cpg-calc-name { min-width: 240px; }
.cpg-calc-table .cpg-calc-qty { width: 96px; text-align: right; }
.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; }