feat(cupang): 채움률 낮은 혼합 상자 경고, 직접 입력 제거하고 출고일 목록으로 대체

- 혼합 상자 채움률 60% 이하면 "상자 변경 필요"(오렌지) 도장 + 업로드/날짜 전환 시 안내 팝업
- ① 직접 입력 표와 계산/제품 추가/초기화 버튼·이벤트 제거. 품목은 업로드(또는 임시 저장 불러오기)로만
- 상단 출고일 탭 바를 없애고 ① 카드 안 세로 목록으로 이동 (날짜·센터수·상자수·수량)
- 임시 저장에 ship_date 를 함께 담아 불러올 때 출고일 유지

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 01:55:51 +09:00
parent 7d9f7fa364
commit 15f181318b
6 changed files with 167 additions and 160 deletions
+129 -138
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903g" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903h" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -22,12 +22,6 @@
</div>
{% else %}
<!-- 업로드한 발주서의 출고일이 여러 개면 날짜별로 작업 영역을 나눈다 -->
<div class="cpg-date-tabs" id="cpg-date-tabs" hidden>
<span class="cpg-date-tabs-label">출고일</span>
<div class="cpg-date-tabs-list" id="cpg-date-tabs-list"></div>
</div>
<div class="cpg-calc3">
<!-- ① 상자 계산 — 제품명 + 수량만 입력 -->
@@ -47,32 +41,11 @@
<div class="cpg-po-result" id="cpg-po-result" hidden></div>
</div>
<h3 class="cpg-sum-h3">직접 입력 <span class="erp-muted">업로드 결과도 여기서 고칠 수 있습니다</span></h3>
<h3 class="cpg-sum-h3">출고일 <span class="erp-muted">업로드한 발주서 기준 · 날짜를 골라 확정합니다</span></h3>
<div class="erp-table-wrap">
<table class="erp-table cpg-calc-table">
<colgroup>
<col class="cpg-col-name" />
<col class="cpg-col-qty" />
<col class="cpg-col-act" />
</colgroup>
<thead>
<tr>
<th>제품명</th>
<th>수량</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 class="cpg-date-list" id="cpg-date-list"></div>
<p class="erp-muted" id="cpg-date-empty">발주 파일을 업로드하면 출고일이 여기에 표시됩니다.</p>
<p class="erp-muted cpg-calc-msg" id="cpg-calc-msg"></p>
</div>
<!-- ② 상자 요약 — 센터에 배분하고 남은 수량이 실시간 반영 -->
@@ -82,7 +55,7 @@
<span class="erp-muted">카드를 ③ 센터로 끌어다 놓거나, 카드를 클릭해 담습니다.</span>
</div>
<div id="cpg-sum-empty" class="erp-muted">왼쪽에서 제품과 수량을 넣고 계산하세요.</div>
<div id="cpg-sum-empty" class="erp-muted">에서 쿠팡 발주 파일을 업로드하면 상자가 계산됩니다.</div>
<div id="cpg-sum-body" hidden>
<div class="cpg-sum-kpis" id="cpg-sum-kpis"></div>
@@ -137,6 +110,21 @@
</div>
</div>
<!-- 채움률이 낮은 혼합 상자 안내 -->
<div class="cpg-modal" id="cpg-low-dlg" hidden>
<div class="cpg-modal-back" data-low-close></div>
<div class="cpg-modal-box cpg-low-box" role="dialog" aria-modal="true" aria-labelledby="cpg-low-title">
<h3 id="cpg-low-title">상자 변경 필요</h3>
<p class="cpg-low-lead">
아래 혼합 상자는 채움률이 60% 이하입니다. 더 작은 상자로 바꾸거나 내용물을 합치세요.
</p>
<ul class="cpg-low-list" id="cpg-low-list"></ul>
<div class="cpg-dlg-actions">
<button type="button" class="erp-btn erp-btn-primary" data-low-close>확인</button>
</div>
</div>
</div>
<!-- 저장 중 -->
<div class="cpg-modal cpg-saving" id="cpg-saving" hidden>
<div class="cpg-modal-back"></div>
@@ -182,12 +170,8 @@
// ③ 선택한 센터에만 분배. 담은 수량만큼 ② 의 잔여가 즉시 줄어든다.
// 분배 내역은 화면 안에서만 유지된다(DB 저장 없음).
(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");
if (!msg) return;
var sumEmpty = document.getElementById("cpg-sum-empty");
var sumBody = document.getElementById("cpg-sum-body");
var sumKpis = document.getElementById("cpg-sum-kpis");
@@ -268,42 +252,22 @@
return hit ? hit.name : cid;
}
// ── ① 입력 표 ─────────────────────────────────────
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><input class="erp-input cpg-calc-qty" type="number" min="0" step="1" value="' + (qty || 0) + '" /></td>' +
'<td class="cpg-calc-act"><button type="button" class="erp-btn erp-btn-danger cpg-calc-del" title="행 삭제" aria-label="행 삭제">✕</button></td>';
tbody.appendChild(tr);
if (code) tr.querySelector(".cpg-calc-name").value = code;
return tr;
}
function rowsArr() {
return Array.prototype.slice.call(tbody.querySelectorAll(".cpg-calc-row"));
}
function collect() {
var items = [];
rowsArr().forEach(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 });
// ── 계산 ─────────────────────────────────────────
// 품목은 업로드(또는 임시 저장 불러오기)로만 들어온다. 직접 입력 표는 없앴다.
function currentItems() {
return calc.results.map(function (r) {
return { product_code: r.product_code, quantity: r.quantity };
});
return items;
}
function run(restore) {
var items = collect();
if (!items.length) { msg.textContent = "제품명을 하나 이상 선택하세요."; return; }
function run(restore, items) {
var list = items || currentItems();
if (!list.length) { 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 })
body: JSON.stringify({ items: list })
})
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
.then(function (data) {
@@ -329,21 +293,20 @@
});
});
});
var had = Object.keys(alloc).some(function (k) { return alloc[k].length; });
alloc = {};
stamped = {};
allStamped = false; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
var skipped = restore ? applyRestore(restore) : 0;
render();
if (restore) {
msg.textContent = "불러오기 완료 (" + calc.results.length + "건)" +
(skipped ? " — 담을 수 없는 " + skipped + "건은 제외" : "");
} else {
msg.textContent = "계산 완료 (" + calc.results.length + "건)" + (had ? " — 센터 분배 초기화됨" : "");
}
if (activeDate) { saveActive(); }
renderTabs();
msg.textContent = restore
? "불러오기 완료 (" + calc.results.length + "건)" +
(skipped ? " — 담을 수 없는 " + skipped + "건은 제외" : "")
: "계산 완료 (" + calc.results.length + "건)";
warnLowMixes();
})
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; })
.then(function () { runBtn.disabled = false; });
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; });
}
// ── 쿠팡 발주 엑셀 업로드 ─────────────────────────
@@ -371,8 +334,8 @@
var sets = {}; // "YYYY-MM-DD" -> 상태 스냅샷
var dateOrder = []; // 탭 순서
var activeDate = ""; // "" = 업로드 전(수동 입력 모드)
var tabsBar = document.getElementById("cpg-date-tabs");
var tabsList = document.getElementById("cpg-date-tabs-list");
var tabsList = document.getElementById("cpg-date-list");
var tabsEmpty = document.getElementById("cpg-date-empty");
function centersOf(allocObj) {
return Object.keys(allocObj || {}).filter(function (cid) {
@@ -388,18 +351,29 @@
return n;
}
var DOW = ["일", "월", "화", "수", "목", "금", "토"];
function dateLabel(d) {
var p = (d || "").split("-");
if (p.length !== 3) return d || "날짜 미확인";
var dt = new Date(+p[0], +p[1] - 1, +p[2]);
return d + "(" + DOW[dt.getDay()] + ")";
}
function renderTabs() {
if (!tabsBar) return;
if (dateOrder.length < 1) { tabsBar.hidden = true; tabsList.innerHTML = ""; return; }
tabsBar.hidden = false;
if (!tabsList) return;
if (tabsEmpty) tabsEmpty.hidden = dateOrder.length > 0;
if (dateOrder.length < 1) { tabsList.innerHTML = ""; return; }
tabsList.innerHTML = dateOrder.map(function (d) {
var st = sets[d] || {};
var cn = centersOf(st.alloc).length;
var bx = boxesOf(st.alloc);
var qty = 0;
(st.items || []).forEach(function (it) { qty += it.quantity; });
return '<button type="button" class="cpg-date-tab' + (d === activeDate ? " is-active" : "") + '"' +
' data-date="' + esc(d) + '">' +
'<strong>' + esc(d || "날짜 미확인") + "</strong>" +
'<span>센터 ' + cn + "곳 · " + bx + "상자</span>" +
'<strong>' + esc(dateLabel(d)) + "</strong>" +
'<span>센터 ' + cn + "곳 · " + bx + "상자 · " + qty + "개</span>" +
"</button>";
}).join("");
}
@@ -408,7 +382,7 @@
function saveActive() {
if (!activeDate) return;
sets[activeDate] = {
items: collect(),
items: currentItems(),
calc: { results: calc.results, mixes: calc.mixes },
units: units, labels: labels, contents: contents,
openCenters: openCenters.slice(), alloc: alloc, methods: methods,
@@ -431,12 +405,9 @@
stamped = st.stamped; allStamped = st.allStamped;
mixType = st.mixType || {}; mixOwner = st.mixOwner || {};
tbody.innerHTML = "";
(st.items || []).forEach(function (it) { addRow(it.product_code, it.quantity); });
if (!(st.items || []).length) addRow();
render();
renderTabs();
warnLowMixes();
}
if (tabsList) {
@@ -838,6 +809,65 @@
return '<span class="cpg-stamp' + (fresh ? " is-new" : "") + '" aria-label="배분 완료">배분 완료</span>';
}
// 자투리 혼합 상자가 이 비율 이하로 차면 더 작은 상자로 바꾸는 편이 낫다.
var LOW_FILL = 60;
function fillPercentOf(key) { return Math.round(mixFill(key) * 1000) / 10; }
function isLowMix(key) { return fillPercentOf(key) <= LOW_FILL; }
// 배분 완료 대신 찍는 경고 도장(오렌지).
function stampLowHtml(key) {
var fresh = !stamped[key];
stamped[key] = true;
return '<span class="cpg-stamp cpg-stamp-warn' + (fresh ? " is-new" : "") + '"' +
' aria-label="상자 변경 필요">상자 변경 필요</span>';
}
// ── 채움률이 낮은 상자 안내 ───────────────────────
var lowDlg = document.getElementById("cpg-low-dlg");
var lowList = document.getElementById("cpg-low-list");
var lowSeen = ""; // 같은 상태로 팝업을 반복해서 띄우지 않도록
function lowMixes() {
var out = [];
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var key = mixKeyFor(b);
if (!isLowMix(key)) return;
out.push({
key: key,
name: mixTypeOf(key) + " 혼합 #" + (i + 1) + (mixOwner[key] ? " · " + mixOwner[key] : ""),
fill: fillPercentOf(key),
units: units[key] || 0
});
});
});
return out;
}
function warnLowMixes() {
if (!lowDlg || !lowList) return;
var rows = lowMixes();
var sign = rows.map(function (r) { return r.key + ":" + r.fill; }).join("|");
if (!rows.length) { lowSeen = ""; return; }
if (sign === lowSeen) return; // 이미 안내한 그대로면 다시 띄우지 않는다
lowSeen = sign;
lowList.innerHTML = rows.map(function (r) {
return "<li><span class=\"cpg-low-name\">" + esc(r.name) + "</span>" +
"<span class=\"cpg-low-fill\">" + r.fill + "% · " + r.units + "개</span></li>";
}).join("");
lowDlg.hidden = false;
}
if (lowDlg) {
Array.prototype.forEach.call(lowDlg.querySelectorAll("[data-low-close]"), function (el) {
el.addEventListener("click", function () { lowDlg.hidden = true; });
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !lowDlg.hidden) lowDlg.hidden = true;
});
}
var allStamped = false; // 전체 배분 완료 도장이 이미 찍혀 있었는지
function kpi(label, value, hint, extra) {
@@ -930,7 +960,8 @@
'<div class="cpg-box-art">' + boxSvg(b.fill_percent, mixTypeOf(key)) + "</div>" +
'<div class="cpg-box-info">' +
'<div class="cpg-box-title">' + esc(mixTypeOf(key)) + " 혼합 #" + (i + 1) +
(remain ? "" : stampHtml(key)) + "</div>" +
(isLowMix(key) ? stampLowHtml(key)
: (remain ? "" : stampHtml(key))) + "</div>" +
'<div class="cpg-box-typerow">' + mixTypeSelect(key) + "</div>" +
'<ul class="cpg-box-items">' + items + "</ul>" +
'<div class="cpg-box-bar"><span style="width:' + b.fill_percent + '%"></span></div>' +
@@ -1056,7 +1087,7 @@
function fitScrollAreas() {
var wide = window.innerWidth >= FRAME_MIN_WIDTH;
var panes = [
[".cpg-calc-card .erp-table-wrap", document.querySelector(".cpg-calc-card .erp-table-wrap")],
[".cpg-date-list", document.getElementById("cpg-date-list")],
[".cpg-sum-scroll", document.querySelector(".cpg-calc-sum .cpg-sum-scroll")]
];
panes.forEach(function (pair) {
@@ -1433,48 +1464,6 @@
});
}
// ── 입력 표 이벤트 ────────────────────────────────
tbody.addEventListener("click", function (e) {
if (!e.target.closest(".cpg-calc-del")) return;
if (rowsArr().length <= 1) addRow("", 0);
e.target.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 = "제품 변경됨 — 재계산하세요.";
});
// 수량 칸: Tab = 다음 행 수량(마지막 행이면 새 행 추가), Enter = 계산
tbody.addEventListener("keydown", function (e) {
if (!e.target.classList.contains("cpg-calc-qty")) return;
if (e.key === "Enter") { e.preventDefault(); run(); return; }
if (e.key !== "Tab" || e.shiftKey) return;
e.preventDefault();
var rows = rowsArr();
var idx = rows.indexOf(e.target.closest("tr"));
var next = rows[idx + 1] || addRow("", 0);
var input = next.querySelector(".cpg-calc-qty");
input.focus();
input.select();
});
runBtn.addEventListener("click", run);
addBtn.addEventListener("click", function () {
addRow("", 0).querySelector(".cpg-calc-name").focus();
});
resetBtn.addEventListener("click", function () {
tbody.innerHTML = "";
addRow("", 0);
calc = { results: [], mixes: [] };
alloc = {};
render();
msg.textContent = "";
});
// ── 분배 확정 ─────────────────────────────────────
var confirmBtn = document.getElementById("cpg-confirm");
var savingDlg = document.getElementById("cpg-saving");
@@ -1696,7 +1685,8 @@
function snapshot() {
return {
items: collect(),
items: currentItems(),
ship_date: activeDate || uploadShipDate || "",
openCenters: openCenters.slice(),
alloc: alloc,
methods: methods,
@@ -1814,13 +1804,14 @@
var d = (data && data.draft) || {};
var snap = d.payload || {};
var items = snap.items || [];
tbody.innerHTML = "";
items.forEach(function (it) { addRow(it.product_code, it.quantity); });
if (!items.length) addRow("", 0);
draftName.value = d.title || "";
closeDraft();
// 저장해 둔 출고일을 그대로 이어 쓴다(없으면 확정 시 오늘 날짜).
sets = {}; dateOrder = []; activeDate = "";
uploadShipDate = snap.ship_date || "";
if (uploadShipDate) { activeDate = uploadShipDate; dateOrder = [uploadShipDate]; }
// 상자 수는 서버에서 다시 계산한 뒤 분배 내역을 얹는다.
run(snap);
run(snap, items);
})
.catch(function () { draftMsg.textContent = "불러오기 실패 — 다시 시도하세요."; });
}
@@ -1855,8 +1846,8 @@
if (e.key === "Escape" && draftDlg && !draftDlg.hidden) closeDraft();
});
addRow("", 0);
render();
renderTabs();
})();
</script>
{% endif %}
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903g" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903h" />{% 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=20260903g" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903h" />{% 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=20260903g" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903h" />{% 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=20260903g" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903h" />{% endblock %}
{% block content %}
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}
+34 -18
View File
@@ -905,6 +905,34 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
/* 도장은 카드가 흐려져도 또렷하게 유지 */
.cpg-sum-prod.is-done .cpg-stamp, .cpg-box-card.is-done .cpg-stamp { opacity: 1; }
/* 상자 변경 필요 도장 — 채움률이 낮은 혼합 상자 */
.cpg-stamp-warn {
--stamp: #e8730c;
box-shadow: inset 0 0 0 1px rgba(232, 115, 12, .25);
transform: rotate(-5deg);
}
.cpg-box-card.is-done .cpg-stamp-warn { opacity: 1; }
/* 채움률이 낮은 상자 안내 대화상자 */
.cpg-low-box { width: min(420px, calc(100vw - 32px)); }
.cpg-low-lead { margin: 0; font-size: 13px; line-height: 1.6; word-break: keep-all; }
.cpg-low-list {
list-style: none; margin: 0; padding: 0;
border: 1px solid var(--color-subtle-ash); border-radius: 8px;
max-height: 220px; overflow-y: auto;
}
.cpg-low-list > li {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; font-size: 13px;
}
.cpg-low-list > li + li { border-top: 1px solid var(--color-ghost-gray); }
.cpg-low-name { flex: 1 1 auto; min-width: 0; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cpg-low-fill {
flex: 0 0 auto; color: #e8730c; font-weight: 700;
font-family: var(--font-geist-mono); font-size: 12px;
}
/* 전체 배분 완료 도장 (센터 배분 KPI 칸) */
.cpg-kpi { position: relative; overflow: hidden; }
.cpg-stamp-all {
@@ -1175,19 +1203,12 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
}
.cpg-po-warn > li { padding: 2px 0; }
/* ── 출고일 탭 (업로드한 발주서의 출고일이 여러 개일 때) ── */
.cpg-date-tabs {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 12px;
}
.cpg-date-tabs[hidden] { display: none; }
.cpg-date-tabs-label {
font-size: 12px; color: var(--color-midtone-gray); font-weight: 600;
}
.cpg-date-tabs-list { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── 출고일 목록 (① 카드 안, 업로드한 발주서 기준) ── */
.cpg-date-list { display: flex; flex-direction: column; gap: 6px; }
.cpg-date-tab {
display: flex; flex-direction: column; gap: 2px; align-items: flex-start;
padding: 6px 14px;
width: 100%; box-sizing: border-box;
padding: 8px 12px;
border: 1px solid var(--color-subtle-ash); border-radius: 10px;
background: #fff; cursor: pointer; font: inherit; text-align: left;
transition: background .12s, border-color .12s;
@@ -1260,13 +1281,8 @@ body.cpg-dragging { cursor: grabbing; user-select: none; }
}
.cpg-po-row { align-items: center; }
.cpg-po-msg { margin: 0; font-size: 12px; }
/* 상단 액션/출고일 탭은 줄어들지 않게 (3열 프레임만 남은 높이를 차지) */
.cpg > .erp-page-actions,
.cpg > .cpg-date-tabs { flex-shrink: 0; }
/* ① 직접 입력 — 수량 입력란 70px 고정 */
.cpg-calc-card .cpg-calc-table col.cpg-col-qty { width: 70px; }
.cpg-calc-card .cpg-calc-table .cpg-calc-qty { width: 70px; max-width: 70px; margin-left: auto; }
/* 상단 액션은 줄어들지 않게 (3열 프레임만 남은 높이를 차지) */
.cpg > .erp-page-actions { flex-shrink: 0; }
/* 출고 보기(읽기 전용) — ③ 하단 메타 정보 */
/* ── 출고 보기 ② 상자 목록 — 상자번호 · 제품명 · 제품코드 · 수량 ── */