diff --git a/app/modules/cupang/templates/cupang/box_calc.html b/app/modules/cupang/templates/cupang/box_calc.html index 7065d6f..133f701 100644 --- a/app/modules/cupang/templates/cupang/box_calc.html +++ b/app/modules/cupang/templates/cupang/box_calc.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
@@ -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 = ''; + mixKeysOf(cid).forEach(function (k) { + if (k === fromKey) return; + var room = mixRoomFor(k, code); + opts += '"; + }); + opts += ''; + opts += ''; + 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 '"; } + // 센터별 미배치 자투리 — 상자에서 뺀 수량이 여기 모인다. + function poolHtml(cid) { + var list = poolOf(cid); + if (!list.length) return ""; + var rows = list.map(function (it) { + var opts = ''; + mixKeysOf(cid).forEach(function (k) { + var room = mixRoomFor(k, it.product_code); + opts += '"; + }); + opts += ''; + return '
  • ' + esc(it.product_name) + "" + + '' + + '' + it.quantity + "개" + + '" + + "
  • "; + }).join(""); + return '
    ' + + '
    미배치 자투리 ' + poolTotal(cid) + "개" + + '' + + "
    " + + '" + + "
    "; + } + // ── ③ 센터 분배 렌더 ────────────────────────────── function renderDist() { if (!distList) return; @@ -834,9 +1058,12 @@ var per = units[a.key] || 0; boxes += a.count; pieces += a.count * per; - items += '
  • ' + + var isMix = a.key.indexOf("m:") === 0; + var fillTxt = isMix ? Math.round(mixFill(a.key) * 100) + "% 채움" : ""; + items += '
  • ' + '
    ' + - '' + esc(labels[a.key] || a.key) + "" + + '' + esc(labels[a.key] || a.key) + + (fillTxt ? ' ' + fillTxt + "" : "") + "" + '' + '상자 · ' + (a.count * per) + "개" + '' + @@ -863,6 +1090,13 @@ (rows.length ? '
      ' + items + "
    " : '

    여기로 상자를 끌어다 놓기

    ') + + poolHtml(cid) + + (rows.length + ? '
    ' + + '' + + "
    " + : "") + "
    "; }); 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); }); }); diff --git a/app/modules/cupang/templates/cupang/box_rules.html b/app/modules/cupang/templates/cupang/box_rules.html index ecb7fc1..bd52dc9 100644 --- a/app/modules/cupang/templates/cupang/box_rules.html +++ b/app/modules/cupang/templates/cupang/box_rules.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/centers.html b/app/modules/cupang/templates/cupang/centers.html index d83136a..154cba6 100644 --- a/app/modules/cupang/templates/cupang/centers.html +++ b/app/modules/cupang/templates/cupang/centers.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/index.html b/app/modules/cupang/templates/cupang/index.html index afa421d..40375aa 100644 --- a/app/modules/cupang/templates/cupang/index.html +++ b/app/modules/cupang/templates/cupang/index.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/products.html b/app/modules/cupang/templates/cupang/products.html index 599eb61..a4a9435 100644 --- a/app/modules/cupang/templates/cupang/products.html +++ b/app/modules/cupang/templates/cupang/products.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/view.html b/app/modules/cupang/templates/cupang/view.html index 16fb03e..bbb38d5 100644 --- a/app/modules/cupang/templates/cupang/view.html +++ b/app/modules/cupang/templates/cupang/view.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %} {# 출고 묶음 보기 — 상자 계산 화면과 같은 3열 구성. 읽기 전용(수정 없음). #} diff --git a/app/static/cupang.css b/app/static/cupang.css index 46f1ced..499a582 100644 --- a/app/static/cupang.css +++ b/app/static/cupang.css @@ -1422,3 +1422,37 @@ body.cpg-dragging { cursor: grabbing; user-select: none; } .cpg-list .cpg-ship-meta { padding: 0 10px 8px; font-size: 11px; } /* 혼합 상자 줄은 굵게 — 어떤 상자인지 바로 보이게 */ .cpg-dist-item.is-mix .cpg-dist-name { font-weight: 600; } + +/* ── ③ 혼합 상자 내용물 편집 (수량 스테퍼 / 이동 / 미배치 풀) ── */ +.cpg-mix-fill { font-style: normal; font-size: 11px; color: var(--color-midtone-gray); } +.cpg-mix-ctl { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; } +.cpg-mix-step { + width: 20px; height: 20px; line-height: 1; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--color-subtle-ash); border-radius: 6px; + background: #fff; cursor: pointer; font-size: 13px; padding: 0; +} +.cpg-mix-step:hover:not(:disabled) { background: var(--color-ghost-gray); border-color: var(--color-midtone-gray); } +.cpg-mix-step:disabled { opacity: .35; cursor: not-allowed; } +.cpg-mix-move, .cpg-pool-move { + width: auto; min-width: 74px; max-width: 132px; + padding: 1px 4px; font-size: 11px; line-height: 1.5; cursor: pointer; +} +.cpg-dist-tree .cpg-tree-qty { min-width: 38px; text-align: right; } + +.cpg-pool { + margin: 6px 10px 8px; + padding: 8px 10px; + border: 1px dashed var(--color-midtone-gray); border-radius: 10px; + background: var(--color-ghost-gray); +} +.cpg-pool-head { + display: flex; align-items: center; gap: 8px; + font-size: 12px; font-weight: 600; margin-bottom: 4px; +} +.cpg-pool-head > b { font-family: var(--font-geist-mono); } +.cpg-pool-auto { margin-left: auto; padding: 2px 8px; font-size: 11px; line-height: 1.5; } +.cpg-pool .cpg-dist-tree { border-left: 0; padding-left: 0; } +.cpg-pool .cpg-dist-tree li::before { content: ""; } +.cpg-dist-foot { padding: 0 10px 10px; } +.cpg-mix-new { padding: 3px 10px; font-size: 12px; line-height: 1.5; }