feat(cupang): 혼합 상자 내용물 편집 (수량 스테퍼 + 이동 + 미배치 풀)

- ③ 센터 분배의 혼합 상자 트리에서 항목마다 [−] n [+] 와 이동 드롭다운
  (다른 혼합 상자 / 새 상자 / 미배치로 빼기). 용량(부피 합)을 넘지 않게 제한
- 센터마다 "미배치 자투리" 풀 + [자동 담기], [+ 새 혼합 상자] 버튼
- 혼합 상자 ✕ 는 내용물을 미배치로 옮기고 상자를 없앤다
- 상자가 비면 자동 제거, 채움률은 헤더에 실시간 표시
- 확정 조건에 "미배치 자투리 0" 추가
- 혼합 상자 키를 위치 index → 상자별 고유 id 로 바꿔 추가/삭제에도 배분 유지
- 미배치 풀은 출고일 탭 전환 시에도 함께 보관

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 16:26:58 +09:00
parent ba3b3a5ad8
commit f3a681ccfe
7 changed files with 425 additions and 23 deletions
+386 -18
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901p" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -253,7 +253,13 @@
var pending = null; // 대화상자 대상 {key, centerId}
function prodKey(code) { return "p:" + code; }
function mixKey(boxName, idx) { return "m:" + boxName + "#" + idx; }
// 혼합 상자 키는 위치가 아니라 상자마다 붙는 고유 id 로 잡는다.
// (상자를 새로 만들거나 지워도 이미 담긴 배분이 어긋나지 않게)
var mixSeq = 0;
function mixKeyFor(b) {
if (!b.uid) { mixSeq += 1; b.uid = "b" + mixSeq; }
return "m:" + b.uid;
}
// 자투리 혼합 상자에 쓸 상자 종류 — 요약 카드에서 고른다(표시용).
var MIX_TYPES = ["쿠팡상자", "3호상자", "6호상자"];
@@ -332,7 +338,7 @@
mixOwner = {};
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var k = mixKey(m.box_name, i);
var k = mixKeyFor(b);
var n = 0;
b.items.forEach(function (it) { n += it.quantity; });
units[k] = n;
@@ -345,6 +351,7 @@
var had = Object.keys(alloc).some(function (k) { return alloc[k].length; });
alloc = {};
stamped = {};
pool = {};
allStamped = false; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
var skipped = restore ? applyRestore(restore) : 0;
render();
@@ -426,7 +433,7 @@
units: units, labels: labels, contents: contents,
openCenters: openCenters.slice(), alloc: alloc, methods: methods,
stamped: stamped, allStamped: allStamped,
mixType: mixType, mixOwner: mixOwner
mixType: mixType, mixOwner: mixOwner, pool: pool
};
}
@@ -443,6 +450,7 @@
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); });
@@ -474,7 +482,7 @@
units: {}, labels: {}, contents: {},
openCenters: [], alloc: {}, methods: {},
stamped: {}, allStamped: false,
mixType: {}, mixOwner: {}
mixType: {}, mixOwner: {}, pool: {}
};
function put(cid, key, count) {
@@ -512,7 +520,7 @@
mx.boxes.forEach(function (b) {
var idx = bucket.boxes.length;
bucket.boxes.push(b);
var k = mixKey(mx.box_name, idx);
var k = mixKeyFor(b);
st.mixOwner[k] = g.center_name;
put(cid, k, 1);
});
@@ -530,7 +538,7 @@
m.boxes.forEach(function (b, i) {
var n = 0;
b.items.forEach(function (it) { n += it.quantity; });
var k = mixKey(m.box_name, i);
var k = mixKeyFor(b);
st.units[k] = n;
st.mixType[k] = MIX_TYPES[0];
st.labels[k] = st.mixType[k] + " 혼합 #" + (i + 1) +
@@ -680,6 +688,161 @@
return n;
}
// ── 혼합 상자 편집 ────────────────────────────────
// 상자 용량은 부피 합으로 본다: 제품 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;
}
function mixRef(key) {
var found = null;
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b) { if (mixKeyFor(b) === key) found = { m: m, b: b }; });
});
return found;
}
function mixFill(key) {
var vol = 0;
(contents[key] || []).forEach(function (it) {
var u = upbOf(it.product_code);
if (u) vol += it.quantity / u;
});
return vol;
}
function mixRoomFor(key, code) {
var u = upbOf(code);
if (!u) return 0;
return Math.max(0, Math.floor((1 - mixFill(key)) * u + 1e-9));
}
// 내용물이 바뀐 상자를 ②(요약) 쪽 데이터와 맞춘다. 비면 상자를 없앤다.
function syncMix(key) {
var list = (contents[key] || []).filter(function (it) { return it.quantity > 0; });
contents[key] = list;
var ref = mixRef(key);
if (!list.length) {
if (ref) {
var idx = ref.m.boxes.indexOf(ref.b);
if (idx >= 0) ref.m.boxes.splice(idx, 1);
if (!ref.m.boxes.length) {
calc.mixes = calc.mixes.filter(function (m) { return m !== ref.m; });
}
}
Object.keys(alloc).forEach(function (cid) {
alloc[cid] = (alloc[cid] || []).filter(function (a) { return a.key !== key; });
});
delete units[key]; delete labels[key]; delete contents[key];
delete mixType[key]; delete mixOwner[key]; delete stamped[key];
return;
}
var total = 0;
list.forEach(function (it) { total += it.quantity; });
units[key] = total;
if (ref) {
ref.b.items = list.map(function (it) {
return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity };
});
ref.b.fill_percent = Math.round(mixFill(key) * 1000) / 10;
}
}
function centerOfKey(key) {
var hit = "";
Object.keys(alloc).forEach(function (cid) {
(alloc[cid] || []).forEach(function (a) { if (a.key === key) hit = String(cid); });
});
return hit;
}
function mixKeysOf(cid) {
return (alloc[String(cid)] || [])
.filter(function (a) { return a.key.indexOf("m:") === 0; })
.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];
}
// 상자에 담기 — 용량만큼만 들어간다. 담은 수량을 돌려준다.
function mixPut(key, code, name, qty) {
var room = mixRoomFor(key, code);
var put = Math.min(room, qty);
if (put <= 0) return 0;
var hit = mixItem(key, code);
if (hit) { hit.quantity += put; }
else { (contents[key] = contents[key] || []).push({ product_code: code, product_name: name, quantity: put }); }
syncMix(key);
return put;
}
// 상자에서 빼기 — 뺀 수량은 그 센터의 미배치 풀로.
function mixTake(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 newMixBox(cid, boxName) {
var name = boxName || (calc.mixes[0] && calc.mixes[0].box_name) || MIX_TYPES[0];
var bucket = calc.mixes.filter(function (m) { return m.box_name === name; })[0];
if (!bucket) { bucket = { box_name: name, boxes: [] }; calc.mixes.push(bucket); }
var b = { items: [], fill_percent: 0 };
bucket.boxes.push(b);
var key = mixKeyFor(b);
contents[key] = [];
units[key] = 0;
mixType[key] = MIX_TYPES[0];
mixOwner[key] = centerName(cid);
labels[key] = mixLabel(key, bucket.boxes.length - 1);
seq += 1;
(alloc[String(cid)] = alloc[String(cid)] || []).push({ id: seq, key: key, count: 1 });
return key;
}
// ── ② 요약 렌더 ───────────────────────────────────
function boxSvg(fill, label) {
var f = Math.max(0, Math.min(100, fill || 0));
@@ -731,7 +894,7 @@
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
totalMix += 1;
remainMix += remainFor(mixKey(m.box_name, i));
remainMix += remainFor(mixKeyFor(b));
});
});
var assigned = (totalProd + totalMix) - (remainProd + remainMix);
@@ -783,7 +946,7 @@
var bh = "", n = 0;
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var key = mixKey(m.box_name, i);
var key = mixKeyFor(b);
var remain = remainFor(key);
if (remain) delete stamped[key];
var items = b.items.map(function (it) {
@@ -809,18 +972,79 @@
}
}
// 혼합 상자는 안에 무엇이 몇 개 들어가는지 트리로 펼쳐 보여준다.
// 혼합 상자는 안에 무엇이 몇 개 들어가는지 트리로 펼치고, 그 자리에서
// 수량 조절(− +)과 다른 상자로 이동(⇄)을 할 수 있게 한다.
function moveOptions(fromKey, cid, code) {
var opts = '<option value="">이동…</option>';
mixKeysOf(cid).forEach(function (k) {
if (k === fromKey) return;
var room = mixRoomFor(k, code);
opts += '<option value="' + esc(k) + '"' + (room ? "" : " disabled") + ">" +
esc(labels[k] || k) + (room ? " (여유 " + room + "개)" : " (가득)") + "</option>";
});
opts += '<option value="__new__">+ 새 상자로</option>';
opts += '<option value="__pool__">미배치로 빼기</option>';
return opts;
}
function treeHtml(entry) {
var list = contents[entry.key];
if (!list || !list.length) return "";
var editable = entry.key.indexOf("m:") === 0 && entry.count === 1;
var cid = editable ? centerOfKey(entry.key) : "";
return '<ul class="cpg-dist-tree">' + list.map(function (it) {
var qty = it.quantity * entry.count;
if (!editable) {
return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" +
'<span class="cpg-tree-qty">' + qty + "개</span>" +
(entry.count > 1 ? '<span class="cpg-tree-per">(' + it.quantity + "개 × " + entry.count + "상자)</span>" : "") +
"</li>";
}
var canAdd = mixRoomFor(entry.key, it.product_code) > 0;
return '<li><span class="cpg-tree-name">' + esc(it.product_name) + "</span>" +
'<span class="cpg-tree-qty">' + (it.quantity * entry.count) + "개</span>" +
(entry.count > 1 ? '<span class="cpg-tree-per">(' + it.quantity + "개 × " + entry.count + "상자)</span>" : "") +
"</li>";
'<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>' +
'<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>' +
'<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) +
"</select>" +
"</span></li>";
}).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;
@@ -834,9 +1058,12 @@
var per = units[a.key] || 0;
boxes += a.count;
pieces += a.count * per;
items += '<li class="cpg-dist-item" data-entry="' + a.id + '">' +
var isMix = a.key.indexOf("m:") === 0;
var fillTxt = isMix ? Math.round(mixFill(a.key) * 100) + "% 채움" : "";
items += '<li class="cpg-dist-item' + (isMix ? " is-mix" : "") + '" data-entry="' + a.id + '">' +
'<div class="cpg-dist-line">' +
'<span class="cpg-dist-name">' + esc(labels[a.key] || a.key) + "</span>" +
'<span class="cpg-dist-name">' + esc(labels[a.key] || a.key) +
(fillTxt ? ' <em class="cpg-mix-fill">' + fillTxt + "</em>" : "") + "</span>" +
'<input class="erp-input cpg-dist-qty" type="number" min="1" max="999" step="1" inputmode="numeric" value="' + a.count + '" />' +
'<span class="cpg-dist-unit">상자 · ' + (a.count * per) + "개</span>" +
'<button type="button" class="erp-btn erp-btn-danger cpg-dist-del" title="빼기" aria-label="빼기">✕</button>' +
@@ -863,6 +1090,13 @@
(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="' +
esc(cid) + '">+ 새 혼합 상자</button>' +
"</div>"
: "") +
"</div>";
});
distList.innerHTML = html;
@@ -1049,7 +1283,7 @@
mixType[key] = sel.value;
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var k = mixKey(m.box_name, i);
var k = mixKeyFor(b);
if (k === key) labels[k] = mixLabel(k, i);
});
});
@@ -1057,6 +1291,137 @@
msg.textContent = "상자 종류를 " + sel.value + " 로 바꿨습니다.";
});
// ── 혼합 상자 내용물 편집 이벤트 ──────────────────
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");
var zone = del.closest("[data-drop-for]");
var dcid = zone && zone.getAttribute("data-drop-for");
var entry = (alloc[dcid] || []).filter(function (a) {
return String(a.id) === li.getAttribute("data-entry");
})[0];
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);
});
contents[entry.key] = [];
syncMix(entry.key);
render();
msg.textContent = "혼합 상자를 비우고 미배치로 옮겼습니다.";
}
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 (distList) {
distList.addEventListener("input", function (e) {
if (!e.target.classList.contains("cpg-dist-qty")) return;
@@ -1189,7 +1554,7 @@
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
total += 1;
remain += remainFor(mixKey(m.box_name, i));
remain += remainFor(mixKeyFor(b));
});
});
return { total: total, remain: remain };
@@ -1208,6 +1573,9 @@
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 "";
}
@@ -1478,7 +1846,7 @@
});
calc.mixes.forEach(function (m) {
m.boxes.forEach(function (b, i) {
var k = mixKey(m.box_name, i);
var k = mixKeyFor(b);
labels[k] = mixLabel(k, i);
});
});
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901p" />{% 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=20260901o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901p" />{% 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=20260901o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901p" />{% 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=20260901o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901p" />{% 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=20260901o" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901p" />{% endblock %}
{% block content %}
{# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #}