change(cupang): 미배치 자투리 제거 — 상자 간 직접 이동만, ③ 스크롤 보정

- "미배치로 빼기" 옵션과 미배치 풀 UI/상태 삭제
- [−] 는 자리가 있는 다른 상자로 1개 이동(없으면 새 상자 자동 생성),
  [+] 는 같은 제품이 있는 다른 상자에서 1개 가져오기 → 총량 항상 보존
- 혼합 상자 ✕ 는 내용물을 다른 상자로 옮긴 뒤 상자를 없앤다
- 3열이 2열/1열로 접히는 폭에서 카드 max-height 를 줘서 ③ 목록이
  카드 안에서 스크롤되도록 수정(편집으로 줄이 늘어나도 페이지가 안 밀림)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 16:33:32 +09:00
parent f3a681ccfe
commit 58ebf1c232
7 changed files with 117 additions and 194 deletions
+100 -174
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901p" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901q" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -351,7 +351,6 @@
var had = Object.keys(alloc).some(function (k) { return alloc[k].length; });
alloc = {};
stamped = {};
pool = {};
allStamped = false; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
var skipped = restore ? applyRestore(restore) : 0;
render();
@@ -433,7 +432,7 @@
units: units, labels: labels, contents: contents,
openCenters: openCenters.slice(), alloc: alloc, methods: methods,
stamped: stamped, allStamped: allStamped,
mixType: mixType, mixOwner: mixOwner, pool: pool
mixType: mixType, mixOwner: mixOwner
};
}
@@ -450,7 +449,6 @@
alloc = st.alloc; methods = st.methods;
stamped = st.stamped; allStamped = st.allStamped;
mixType = st.mixType || {}; mixOwner = st.mixOwner || {};
pool = st.pool || {};
tbody.innerHTML = "";
(st.items || []).forEach(function (it) { addRow(it.product_code, it.quantity); });
@@ -482,7 +480,7 @@
units: {}, labels: {}, contents: {},
openCenters: [], alloc: {}, methods: {},
stamped: {}, allStamped: false,
mixType: {}, mixOwner: {}, pool: {}
mixType: {}, mixOwner: {}
};
function put(cid, key, count) {
@@ -691,8 +689,6 @@
// ── 혼합 상자 편집 ────────────────────────────────
// 상자 용량은 부피 합으로 본다: 제품 1개 = 1/입수량 상자.
// 빼낸 수량은 센터별 "미배치 자투리" 풀에 남고, 거기서 다시 담는다.
var pool = {}; // centerId -> [{product_code, product_name, quantity}]
function upbOf(code) {
var hit = calc.results.filter(function (r) { return r.product_code === code; })[0];
return hit ? (hit.units_per_box || 0) : 0;
@@ -766,36 +762,6 @@
.map(function (a) { return a.key; });
}
function poolOf(cid) {
cid = String(cid);
if (!pool[cid]) pool[cid] = [];
return pool[cid];
}
function poolAdd(cid, code, name, qty) {
if (qty <= 0) return;
var list = poolOf(cid);
var hit = list.filter(function (it) { return it.product_code === code; })[0];
if (hit) { hit.quantity += qty; return; }
list.push({ product_code: code, product_name: name, quantity: qty });
}
function poolTake(cid, code, qty) {
var list = poolOf(cid);
var hit = list.filter(function (it) { return it.product_code === code; })[0];
if (!hit) return 0;
var take = Math.min(hit.quantity, qty);
hit.quantity -= take;
pool[String(cid)] = list.filter(function (it) { return it.quantity > 0; });
return take;
}
function poolTotal(cid) {
var n = 0;
poolOf(cid).forEach(function (it) { n += it.quantity; });
return n;
}
function mixItem(key, code) {
return (contents[key] || []).filter(function (it) { return it.product_code === code; })[0];
}
@@ -812,19 +778,48 @@
return put;
}
// 상자에서 빼기 — 뺀 수량은 그 센터의 미배치 풀로.
function mixTake(key, code, qty) {
// 상자에서 덜어내기(내부용) — 호출한 쪽이 곧바로 다른 상자에 담는다.
function mixPull(key, code, qty) {
var hit = mixItem(key, code);
if (!hit) return 0;
var take = Math.min(hit.quantity, qty);
if (take <= 0) return 0;
var cid = centerOfKey(key);
hit.quantity -= take;
syncMix(key);
if (cid) poolAdd(cid, code, hit.product_name, take);
return take;
}
// 자리가 있는 다른 상자 찾기 (없으면 "")
function boxWithRoom(cid, code, exceptKey) {
var found = "";
mixKeysOf(cid).forEach(function (k) {
if (found || k === exceptKey) return;
if (mixRoomFor(k, code) > 0) found = k;
});
return found;
}
// 그 제품이 담겨 있는 다른 상자 찾기 (없으면 "")
function boxHaving(cid, code, exceptKey) {
var found = "";
mixKeysOf(cid).forEach(function (k) {
if (found || k === exceptKey) return;
if (mixItem(k, code)) found = k;
});
return found;
}
// 상자 → 상자 이동. 실제로 옮긴 수량을 돌려준다(자리만큼만).
function moveUnits(fromKey, toKey, code, name, qty) {
var room = mixRoomFor(toKey, code);
var move = Math.min(qty, room);
if (move <= 0) return 0;
var pulled = mixPull(fromKey, code, move);
if (!pulled) return 0;
mixPut(toKey, code, name, pulled);
return pulled;
}
// 새 혼합 상자 (같은 센터, 비어 있는 상태로 추가)
function newMixBox(cid, boxName) {
var name = boxName || (calc.mixes[0] && calc.mixes[0].box_name) || MIX_TYPES[0];
@@ -983,7 +978,6 @@
esc(labels[k] || k) + (room ? " (여유 " + room + "개)" : " (가득)") + "</option>";
});
opts += '<option value="__new__">+ 새 상자로</option>';
opts += '<option value="__pool__">미배치로 빼기</option>';
return opts;
}
@@ -1000,15 +994,16 @@
(entry.count > 1 ? '<span class="cpg-tree-per">(' + it.quantity + "개 × " + entry.count + "상자)</span>" : "") +
"</li>";
}
var canAdd = mixRoomFor(entry.key, it.product_code) > 0;
var canAdd = mixRoomFor(entry.key, it.product_code) > 0 &&
!!boxHaving(cid, it.product_code, entry.key);
return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" +
'<span class="cpg-mix-ctl">' +
'<button type="button" class="cpg-mix-step" data-mix-dec data-key="' + esc(entry.key) +
'" data-code="' + esc(it.product_code) + '" title="1개 기"></button>' +
'" data-code="' + esc(it.product_code) + '" title="다른 상자로 1개 옮기기"></button>' +
'<span class="cpg-tree-qty">' + qty + "개</span>" +
'<button type="button" class="cpg-mix-step" data-mix-inc data-key="' + esc(entry.key) +
'" data-code="' + esc(it.product_code) + '"' + (canAdd ? "" : " disabled") +
' title="미배치에서 1개 기">+</button>' +
' title="다른 상자에서 1개 가져오기">+</button>' +
'<select class="erp-select cpg-mix-move" data-key="' + esc(entry.key) +
'" data-code="' + esc(it.product_code) + '" aria-label="이동">' +
moveOptions(entry.key, cid, it.product_code) +
@@ -1017,34 +1012,6 @@
}).join("") + "</ul>";
}
// 센터별 미배치 자투리 — 상자에서 뺀 수량이 여기 모인다.
function poolHtml(cid) {
var list = poolOf(cid);
if (!list.length) return "";
var rows = list.map(function (it) {
var opts = '<option value="">담을 상자…</option>';
mixKeysOf(cid).forEach(function (k) {
var room = mixRoomFor(k, it.product_code);
opts += '<option value="' + esc(k) + '"' + (room ? "" : " disabled") + ">" +
esc(labels[k] || k) + (room ? " (여유 " + room + "개)" : " (가득)") + "</option>";
});
opts += '<option value="__new__">+ 새 상자에</option>';
return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" +
'<span class="cpg-mix-ctl">' +
'<span class="cpg-tree-qty">' + it.quantity + "개</span>" +
'<select class="erp-select cpg-pool-move" data-center="' + esc(cid) +
'" data-code="' + esc(it.product_code) + '" aria-label="담을 상자">' + opts + "</select>" +
"</span></li>";
}).join("");
return '<div class="cpg-pool">' +
'<div class="cpg-pool-head">미배치 자투리 <b>' + poolTotal(cid) + "개</b>" +
'<button type="button" class="erp-btn erp-btn-outline cpg-pool-auto" data-center="' + esc(cid) +
'">자동 담기</button>' +
"</div>" +
'<ul class="cpg-dist-tree">' + rows + "</ul>" +
"</div>";
}
// ── ③ 센터 분배 렌더 ──────────────────────────────
function renderDist() {
if (!distList) return;
@@ -1090,7 +1057,6 @@
(rows.length
? '<ul class="cpg-dist-items">' + items + "</ul>"
: '<p class="cpg-dist-hint">여기로 상자를 끌어다 놓기</p>') +
poolHtml(cid) +
(rows.length
? '<div class="cpg-dist-foot">' +
'<button type="button" class="erp-btn erp-btn-outline cpg-mix-new" data-center="' +
@@ -1292,33 +1258,10 @@
});
// ── 혼합 상자 내용물 편집 이벤트 ──────────────────
// 수량은 상자 사이에서만 오간다(총량 보존). 갈 곳이 없으면 새 상자를 만든다.
if (distList) {
distList.addEventListener("click", function (e) {
var dec = e.target.closest("[data-mix-dec]");
if (dec) {
var moved = mixTake(dec.getAttribute("data-key"), dec.getAttribute("data-code"), 1);
if (moved) { render(); msg.textContent = "1개를 미배치로 뺐습니다."; }
return;
}
var inc = e.target.closest("[data-mix-inc]");
if (inc) {
var key = inc.getAttribute("data-key");
var code = inc.getAttribute("data-code");
var cid = centerOfKey(key);
var got = poolTake(cid, code, Math.min(1, mixRoomFor(key, code)));
if (!got) {
msg.textContent = mixRoomFor(key, code)
? "미배치 자투리에 남은 수량이 없습니다."
: "이 상자에 더 들어갈 자리가 없습니다.";
return;
}
var name = (mixItem(key, code) || {}).product_name || code;
mixPut(key, code, name, got);
render();
msg.textContent = "1개를 담았습니다.";
return;
}
// 혼합 상자의 ✕ — 내용물을 미배치로 옮기고 상자를 없앤다.
// 혼합 상자의 ✕ — 내용물을 다른 상자로 옮기고 상자를 없앤다.
var del = e.target.closest(".cpg-dist-del");
if (del) {
var li = del.closest(".cpg-dist-item");
@@ -1330,95 +1273,81 @@
if (entry && entry.key.indexOf("m:") === 0) {
e.stopImmediatePropagation(); // 뒤 핸들러가 또 지우지 않게
(contents[entry.key] || []).slice().forEach(function (it) {
poolAdd(dcid, it.product_code, it.product_name, it.quantity);
var left = it.quantity;
while (left > 0) {
var dest = boxWithRoom(dcid, it.product_code, entry.key) || newMixBox(dcid);
var moved = moveUnits(entry.key, dest, it.product_code, it.product_name, left);
if (!moved) break;
left -= moved;
}
});
contents[entry.key] = [];
syncMix(entry.key);
render();
msg.textContent = "혼합 상자를 비우고 미배치로 옮겼습니다.";
msg.textContent = "혼합 상자를 비우고 내용물을 다른 상자로 옮겼습니다.";
}
return;
}
var dec = e.target.closest("[data-mix-dec]");
if (dec) {
var key = dec.getAttribute("data-key");
var code = dec.getAttribute("data-code");
var cid = centerOfKey(key);
var item = mixItem(key, code);
if (!item) return;
var to = boxWithRoom(cid, code, key) || newMixBox(cid);
var n = moveUnits(key, to, code, item.product_name, 1);
if (!n) { msg.textContent = "옮길 자리가 없습니다."; return; }
render();
msg.textContent = "1개를 " + (labels[to] || "다른 상자") + " 로 옮겼습니다.";
return;
}
var inc = e.target.closest("[data-mix-inc]");
if (inc) {
var ikey = inc.getAttribute("data-key");
var icode = inc.getAttribute("data-code");
var icid = centerOfKey(ikey);
if (mixRoomFor(ikey, icode) <= 0) {
msg.textContent = "이 상자에 더 들어갈 자리가 없습니다."; return;
}
var from = boxHaving(icid, icode, ikey);
if (!from) { msg.textContent = "다른 상자에 이 제품이 없습니다."; return; }
var src = mixItem(from, icode);
var got = moveUnits(from, ikey, icode, src.product_name, 1);
if (!got) { msg.textContent = "가져올 수 없습니다."; return; }
render();
msg.textContent = "1개를 " + (labels[from] || "다른 상자") + " 에서 가져왔습니다.";
return;
}
var neu = e.target.closest(".cpg-mix-new");
if (neu) {
newMixBox(neu.getAttribute("data-center"));
render();
msg.textContent = "빈 혼합 상자를 추가했습니다.";
return;
}
var auto = e.target.closest(".cpg-pool-auto");
if (auto) {
var acid = auto.getAttribute("data-center");
var left = 0;
poolOf(acid).slice().forEach(function (it) {
var need = it.quantity;
mixKeysOf(acid).forEach(function (k) {
if (need <= 0) return;
var put = mixPut(k, it.product_code, it.product_name, Math.min(need, mixRoomFor(k, it.product_code)));
if (put) { poolTake(acid, it.product_code, put); need -= put; }
});
if (need > 0) {
var nk = newMixBox(acid);
var put2 = mixPut(nk, it.product_code, it.product_name, Math.min(need, mixRoomFor(nk, it.product_code)));
if (put2) { poolTake(acid, it.product_code, put2); need -= put2; }
}
left += Math.max(need, 0);
});
render();
msg.textContent = left ? "일부는 담지 못했습니다 (" + left + "개)" : "미배치 자투리를 모두 담았습니다.";
}
});
distList.addEventListener("change", function (e) {
var mv = e.target.closest(".cpg-mix-move");
if (mv) {
var fromKey = mv.getAttribute("data-key");
var code = mv.getAttribute("data-code");
var target = mv.value;
mv.value = "";
if (!target) return;
var src = mixItem(fromKey, code);
if (!src) return;
var qty = src.quantity;
var name = src.product_name;
var cid = centerOfKey(fromKey);
if (target === "__pool__") {
mixTake(fromKey, code, qty);
render();
msg.textContent = name + " " + qty + "개를 미배치로 뺐습니다.";
return;
}
var toKey = target === "__new__" ? newMixBox(cid) : target;
var room = mixRoomFor(toKey, code);
var move = Math.min(qty, room);
if (move <= 0) { msg.textContent = "옮길 자리가 없습니다."; render(); return; }
mixTake(fromKey, code, move); // 일단 미배치로
poolTake(cid, code, move); // 바로 꺼내서
mixPut(toKey, code, name, move); // 대상 상자에 담기
render();
msg.textContent = name + " " + move + "개를 " + (labels[toKey] || "새 상자") + " 로 옮겼습니다." +
(move < qty ? " (자리가 부족해 " + (qty - move) + "개는 남음)" : "");
return;
}
var pm = e.target.closest(".cpg-pool-move");
if (pm) {
var pcid = pm.getAttribute("data-center");
var pcode = pm.getAttribute("data-code");
var dest = pm.value;
pm.value = "";
if (!dest) return;
var entry = poolOf(pcid).filter(function (it) { return it.product_code === pcode; })[0];
if (!entry) return;
var key2 = dest === "__new__" ? newMixBox(pcid) : dest;
var put = Math.min(entry.quantity, mixRoomFor(key2, pcode));
if (put <= 0) { msg.textContent = "그 상자에는 자리가 없습니다."; render(); return; }
poolTake(pcid, pcode, put);
mixPut(key2, pcode, entry.product_name, put);
render();
msg.textContent = entry.product_name + " " + put + "개를 담았습니다.";
}
if (!mv) return;
var fromKey = mv.getAttribute("data-key");
var code = mv.getAttribute("data-code");
var target = mv.value;
mv.value = "";
if (!target) return;
var src = mixItem(fromKey, code);
if (!src) return;
var qty = src.quantity;
var name = src.product_name;
var cid = centerOfKey(fromKey);
var toKey = target === "__new__" ? newMixBox(cid) : target;
var moved = moveUnits(fromKey, toKey, code, name, qty);
if (!moved) { msg.textContent = "옮길 자리가 없습니다."; render(); return; }
render();
msg.textContent = name + " " + moved + "개를 " + (labels[toKey] || "새 상자") + " 로 옮겼습니다." +
(moved < qty ? " (자리가 부족해 " + (qty - moved) + "개는 남음)" : "");
});
}
@@ -1573,9 +1502,6 @@
if (!cids.length) return "센터에 담긴 상자가 없습니다.";
var missing = cids.filter(function (cid) { return !methods[String(cid)]; });
if (missing.length) return "출고방식 미선택: " + missing.map(centerName).join(", ");
var leftover = 0;
openCenters.forEach(function (cid) { leftover += poolTotal(cid); });
if (leftover) return "미배치 자투리 " + leftover + "개가 남았습니다.";
return "";
}
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901p" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901q" />{% 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=20260901p" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901q" />{% 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=20260901p" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901q" />{% 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=20260901p" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901q" />{% 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=20260901p" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901q" />{% endblock %}
{% block content %}
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}