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:
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user