76ba2eb8bf
전역 border-box 가 없어 height:100% + padding/border 가 고정 행 높이를 넘어 아래 주와 겹쳤다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1857 lines
80 KiB
HTML
1857 lines
80 KiB
HTML
{% extends "erp_base.html" %}
|
||
|
||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260903m" />{% endblock %}
|
||
|
||
{% block content %}
|
||
<section class="cpg">
|
||
|
||
<div class="erp-page-actions">
|
||
<a class="erp-btn erp-btn-primary" href="/cupang/">◀◀ 달력</a>
|
||
{% if box_rules %}
|
||
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-save">임시 저장</button>
|
||
<button type="button" class="erp-btn erp-btn-outline" id="cpg-calc-load">불러오기</button>
|
||
{% endif %}
|
||
</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="cpg-calc3">
|
||
|
||
<!-- ① 상자 계산 — 제품명 + 수량만 입력 -->
|
||
<div class="erp-card cpg-form-card cpg-calc-card">
|
||
<div class="cpg-card-head">
|
||
<h2>① 쿠팡 발주 업로드</h2>
|
||
</div>
|
||
|
||
<!-- 쿠팡 발주서(xlsx) 업로드 → 센터별 수량 합산 → ②③ 자동 채움 -->
|
||
<div class="cpg-po-box">
|
||
<div class="cpg-po-row">
|
||
<label class="erp-btn erp-btn-primary cpg-po-pick" for="cpg-po-file">쿠팡 발주파일 업로드</label>
|
||
<input class="cpg-po-file" type="file" id="cpg-po-file"
|
||
accept=".xlsx,.xlsm" multiple aria-label="쿠팡 발주 엑셀 선택" />
|
||
<span class="erp-muted cpg-po-msg" id="cpg-po-msg"></span>
|
||
</div>
|
||
<div class="cpg-po-result" id="cpg-po-result" hidden></div>
|
||
</div>
|
||
|
||
<h3 class="cpg-sum-h3">출고일 <span class="erp-muted">업로드한 발주서 기준 · 날짜를 골라 확정합니다</span></h3>
|
||
|
||
<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>
|
||
|
||
<!-- ② 상자 요약 — 센터에 배분하고 남은 수량이 실시간 반영 -->
|
||
<div class="erp-card cpg-form-card cpg-calc-sum">
|
||
<div class="cpg-card-head">
|
||
<h2>② 상자 요약</h2>
|
||
<span class="erp-muted">카드를 ③ 센터로 끌어다 놓거나, 카드를 클릭해 담습니다.</span>
|
||
</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>
|
||
|
||
<div class="cpg-sum-scroll">
|
||
<h3 class="cpg-sum-h3">제품별 상자 <span class="erp-muted">잔여 기준</span></h3>
|
||
<div class="cpg-sum-prods" id="cpg-sum-prods"></div>
|
||
|
||
<h3 class="cpg-sum-h3">자투리 혼합 상자 <span class="erp-muted" id="cpg-sum-mix-note"></span></h3>
|
||
<div class="cpg-sum-boxes" id="cpg-sum-boxes"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ③ 센터 분배 — 선택한 센터만 표시 -->
|
||
<div class="erp-card cpg-form-card cpg-dist-card">
|
||
<div class="cpg-card-head cpg-dist-head-bar">
|
||
<h2>③ 센터 분배</h2>
|
||
<span class="erp-muted">담을 센터를 골라 추가하세요.</span>
|
||
<button type="button" class="erp-btn erp-btn-primary cpg-confirm-btn" id="cpg-confirm" disabled
|
||
title="모든 상자를 배분하고 센터마다 출고방식을 고르면 활성화됩니다.">분배 확정</button>
|
||
</div>
|
||
|
||
{% if not centers %}
|
||
<p class="erp-muted">
|
||
등록된 입고센터가 없습니다. 관리자에게 문의하세요.
|
||
</p>
|
||
{% else %}
|
||
<div class="cpg-dist-list" id="cpg-dist-list"></div>
|
||
<p class="erp-muted" id="cpg-dist-empty">발주 파일을 업로드하면 센터가 여기에 표시됩니다.</p>
|
||
{% endif %}
|
||
</div>
|
||
|
||
</div><!-- /cpg-calc3 -->
|
||
|
||
<!-- 담을 상자 수량 입력 대화상자 -->
|
||
<div class="cpg-modal" id="cpg-dlg" hidden>
|
||
<div class="cpg-modal-back" data-dlg-close></div>
|
||
<div class="cpg-modal-box" role="dialog" aria-modal="true" aria-labelledby="cpg-dlg-title">
|
||
<h3 id="cpg-dlg-title">센터에 담기</h3>
|
||
<p class="cpg-dlg-item" id="cpg-dlg-item"></p>
|
||
<div class="erp-field"><span>센터</span>
|
||
<div class="cpg-dlg-centers" id="cpg-dlg-centers" role="group" aria-label="담을 센터"></div></div>
|
||
<label class="erp-field"><span>상자 수량</span>
|
||
<input class="erp-input" type="number" id="cpg-dlg-qty" min="1" step="1" /></label>
|
||
<p class="erp-muted" id="cpg-dlg-hint"></p>
|
||
<div class="erp-page-actions cpg-dlg-actions">
|
||
<button type="button" class="erp-btn erp-btn-primary" id="cpg-dlg-ok">담기</button>
|
||
<button type="button" class="erp-btn erp-btn-outline" id="cpg-dlg-all">잔여 전부 담기</button>
|
||
<button type="button" class="erp-btn erp-btn-outline" data-dlg-close>취소</button>
|
||
</div>
|
||
</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>
|
||
<div class="cpg-modal-box cpg-saving-box" role="alertdialog" aria-live="assertive"
|
||
aria-labelledby="cpg-saving-title">
|
||
<div class="cpg-spinner" aria-hidden="true"></div>
|
||
<strong id="cpg-saving-title">저장 중…</strong>
|
||
<span class="erp-muted" id="cpg-saving-note"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 임시 저장 / 불러오기 -->
|
||
<div class="cpg-modal" id="cpg-draft-dlg" hidden>
|
||
<div class="cpg-modal-back" data-draft-close></div>
|
||
<div class="cpg-modal-box cpg-draft-box" role="dialog" aria-modal="true" aria-labelledby="cpg-draft-title">
|
||
<h3 id="cpg-draft-title">임시 저장</h3>
|
||
|
||
<label class="erp-field"><span>제목</span>
|
||
<input class="erp-input" type="text" id="cpg-draft-name" maxlength="100"
|
||
placeholder="예: 2026.08.31(월) 오후 04시 50분" autocomplete="off" /></label>
|
||
<div class="cpg-draft-saveact">
|
||
<button type="button" class="erp-btn erp-btn-primary" id="cpg-draft-save">이 내용 저장</button>
|
||
<span class="erp-muted" id="cpg-draft-msg"></span>
|
||
</div>
|
||
|
||
<h4 class="cpg-draft-h4">저장된 목록</h4>
|
||
<div class="cpg-draft-list" id="cpg-draft-list">
|
||
<p class="erp-muted">불러오는 중…</p>
|
||
</div>
|
||
|
||
<div class="cpg-dlg-actions cpg-draft-foot">
|
||
<button type="button" class="erp-btn erp-btn-outline" data-draft-close>닫기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
|
||
<script type="application/json" id="cpg-centers">{{ centers | tojson }}</script>
|
||
<script>
|
||
// 상자 계산 3열 화면.
|
||
// ① 제품명·수량 입력 → 서버(POST /cupang/api/box-calc)에서 계산
|
||
// ② 계산 결과 요약 — 드래그(또는 클릭) 소스
|
||
// ③ 선택한 센터에만 분배. 담은 수량만큼 ② 의 잔여가 즉시 줄어든다.
|
||
// 분배 내역은 화면 안에서만 유지된다(DB 저장 없음).
|
||
(function () {
|
||
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");
|
||
var sumProds = document.getElementById("cpg-sum-prods");
|
||
var sumBoxes = document.getElementById("cpg-sum-boxes");
|
||
var sumMixNote = document.getElementById("cpg-sum-mix-note");
|
||
var distList = document.getElementById("cpg-dist-list");
|
||
var distCard = document.querySelector(".cpg-dist-card");
|
||
var distEmpty = document.getElementById("cpg-dist-empty");
|
||
var centerPick = document.getElementById("cpg-center-pick");
|
||
var dlg = document.getElementById("cpg-dlg");
|
||
var dlgItem = document.getElementById("cpg-dlg-item");
|
||
var dlgCenters = document.getElementById("cpg-dlg-centers");
|
||
var dlgCenterId = null; // 대화상자에서 고른 센터
|
||
var dlgQty = document.getElementById("cpg-dlg-qty");
|
||
var dlgHint = document.getElementById("cpg-dlg-hint");
|
||
var dlgOk = document.getElementById("cpg-dlg-ok");
|
||
var dlgAll = document.getElementById("cpg-dlg-all");
|
||
|
||
function readJson(id) {
|
||
var el = document.getElementById(id);
|
||
try { return JSON.parse((el && el.textContent) || "[]"); } catch (e) { return []; }
|
||
}
|
||
var rules = readJson("cpg-calc-rules");
|
||
var centers = readJson("cpg-centers");
|
||
|
||
function esc(s) { var d = document.createElement("div"); d.textContent = s == null ? "" : s; return d.innerHTML; }
|
||
|
||
var optionsHtml = '<option value="">— 제품명 선택 —</option>';
|
||
rules.forEach(function (r) {
|
||
optionsHtml += '<option value="' + esc(r.product_code) + '">' +
|
||
esc(r.product_name_snapshot || r.product_code) + "</option>";
|
||
});
|
||
|
||
// ── 상태 ─────────────────────────────────────────
|
||
var calc = { results: [], mixes: [] }; // 서버 계산 결과
|
||
var units = {}; // key -> 상자 1개당 상품 수
|
||
var contents = {}; // key -> 혼합 상자 내용물 [{product_name, quantity}]
|
||
var labels = {}; // key -> 표시 이름
|
||
var openCenters = []; // ③ 에 추가한 센터 id (문자열)
|
||
var alloc = {}; // centerId -> [{id, key, count}]
|
||
var methods = {}; // centerId -> 출고방식(택배/파렛트)
|
||
var seq = 0;
|
||
var pending = null; // 대화상자 대상 {key, centerId}
|
||
|
||
function prodKey(code) { return "p:" + code; }
|
||
// 혼합 상자 키는 위치가 아니라 상자마다 붙는 고유 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호상자"];
|
||
var mixType = {}; // mixKey -> 고른 상자 종류
|
||
var mixOwner = {}; // mixKey -> 센터명(업로드로 만들어진 경우)
|
||
|
||
function mixTypeOf(key) { return mixType[key] || MIX_TYPES[0]; }
|
||
|
||
function mixLabel(key, idx) {
|
||
return mixTypeOf(key) + " 혼합 #" + (idx + 1) +
|
||
(mixOwner[key] ? " · " + mixOwner[key] : "");
|
||
}
|
||
|
||
function mixTypeSelect(key) {
|
||
var cur = mixTypeOf(key);
|
||
return '<select class="erp-select cpg-box-type" data-type-for="' + esc(key) + '"' +
|
||
' aria-label="상자 종류">' +
|
||
MIX_TYPES.map(function (t) {
|
||
return '<option value="' + esc(t) + '"' + (t === cur ? " selected" : "") + ">" + esc(t) + "</option>";
|
||
}).join("") + "</select>";
|
||
}
|
||
|
||
function centerName(cid) {
|
||
var hit = centers.filter(function (c) { return String(c.id) === String(cid); })[0];
|
||
return hit ? hit.name : cid;
|
||
}
|
||
|
||
// ── 계산 ─────────────────────────────────────────
|
||
// 품목은 업로드(또는 임시 저장 불러오기)로만 들어온다. 직접 입력 표는 없앴다.
|
||
function currentItems() {
|
||
return calc.results.map(function (r) {
|
||
return { product_code: r.product_code, quantity: r.quantity };
|
||
});
|
||
}
|
||
|
||
function run(restore, items) {
|
||
var list = items || currentItems();
|
||
if (!list.length) { msg.textContent = "먼저 쿠팡 발주 파일을 업로드하세요."; return; }
|
||
msg.textContent = "계산 중…";
|
||
fetch("/cupang/api/box-calc", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ items: list })
|
||
})
|
||
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||
.then(function (data) {
|
||
calc.results = ((data && data.results) || []).filter(function (r) { return r.configured; });
|
||
calc.mixes = (data && data.mixes) || [];
|
||
units = {};
|
||
labels = {};
|
||
contents = {};
|
||
calc.results.forEach(function (r) {
|
||
units[prodKey(r.product_code)] = r.units_per_box;
|
||
labels[prodKey(r.product_code)] = r.product_name;
|
||
});
|
||
mixOwner = {};
|
||
calc.mixes.forEach(function (m) {
|
||
m.boxes.forEach(function (b, i) {
|
||
var k = mixKeyFor(b);
|
||
var n = 0;
|
||
b.items.forEach(function (it) { n += it.quantity; });
|
||
units[k] = n;
|
||
labels[k] = mixLabel(k, i);
|
||
contents[k] = b.items.map(function (it) {
|
||
return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity };
|
||
});
|
||
});
|
||
});
|
||
alloc = {};
|
||
stamped = {};
|
||
allStamped = false; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
|
||
var skipped = restore ? applyRestore(restore) : 0;
|
||
render();
|
||
if (activeDate) { saveActive(); }
|
||
renderTabs();
|
||
msg.textContent = restore
|
||
? "불러오기 완료 (" + calc.results.length + "건)" +
|
||
(skipped ? " — 담을 수 없는 " + skipped + "건은 제외" : "")
|
||
: "계산 완료 (" + calc.results.length + "건)";
|
||
warnLowMixes();
|
||
})
|
||
.catch(function () { msg.textContent = "계산 실패 — 새로고침 후 다시 시도하세요."; });
|
||
}
|
||
|
||
// ── 쿠팡 발주 엑셀 업로드 ─────────────────────────
|
||
// 업로드 → 센터별 수량 합산(서버) → 센터마다 상자 계산 → ②③ 자동 채움.
|
||
// 상자는 센터별로 포장하므로 계산도 센터 단위로 한다(자투리 혼합 상자 포함).
|
||
var poFile = document.getElementById("cpg-po-file");
|
||
var poMsg = document.getElementById("cpg-po-msg");
|
||
var poResult = document.getElementById("cpg-po-result");
|
||
var uploadShipDate = ""; // F13 − 1일 (확정 대화상자 기본값)
|
||
|
||
function calcForItems(items) {
|
||
return fetch("/cupang/api/box-calc", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ items: items })
|
||
}).then(function (r) {
|
||
if (!r.ok) throw new Error("계산 실패 (http " + r.status + ")");
|
||
return r.json();
|
||
});
|
||
}
|
||
|
||
// ── 출고일별 작업 세트 ────────────────────────────
|
||
// 업로드한 발주서의 출고일이 다르면 날짜마다 ①②③ 상태를 따로 들고,
|
||
// 탭으로 전환한다. 확정은 현재 보고 있는 날짜만 대상으로 한다.
|
||
var sets = {}; // "YYYY-MM-DD" -> 상태 스냅샷
|
||
var dateOrder = []; // 탭 순서
|
||
var activeDate = ""; // "" = 업로드 전(수동 입력 모드)
|
||
var tabsList = document.getElementById("cpg-date-list");
|
||
var tabsEmpty = document.getElementById("cpg-date-empty");
|
||
|
||
function centersOf(allocObj) {
|
||
return Object.keys(allocObj || {}).filter(function (cid) {
|
||
return (allocObj[cid] || []).length > 0;
|
||
});
|
||
}
|
||
|
||
function boxesOf(allocObj) {
|
||
var n = 0;
|
||
Object.keys(allocObj || {}).forEach(function (cid) {
|
||
(allocObj[cid] || []).forEach(function (a) { n += a.count; });
|
||
});
|
||
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 (!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(dateLabel(d)) + "</strong>" +
|
||
'<span>센터 ' + cn + "곳 · " + bx + "상자 · " + qty + "개</span>" +
|
||
"</button>";
|
||
}).join("");
|
||
}
|
||
|
||
// 현재 화면 상태를 활성 날짜 세트에 저장
|
||
function saveActive() {
|
||
if (!activeDate) return;
|
||
sets[activeDate] = {
|
||
items: currentItems(),
|
||
calc: { results: calc.results, mixes: calc.mixes },
|
||
units: units, labels: labels, contents: contents,
|
||
openCenters: openCenters.slice(), alloc: alloc, methods: methods,
|
||
stamped: stamped, allStamped: allStamped,
|
||
mixType: mixType, mixOwner: mixOwner
|
||
};
|
||
}
|
||
|
||
// 날짜 세트를 화면(①②③)에 올린다
|
||
function loadDate(d) {
|
||
var st = sets[d];
|
||
if (!st) return;
|
||
activeDate = d;
|
||
uploadShipDate = d;
|
||
calc.results = st.calc.results;
|
||
calc.mixes = st.calc.mixes;
|
||
units = st.units; labels = st.labels; contents = st.contents;
|
||
openCenters = st.openCenters.slice();
|
||
alloc = st.alloc; methods = st.methods;
|
||
stamped = st.stamped; allStamped = st.allStamped;
|
||
mixType = st.mixType || {}; mixOwner = st.mixOwner || {};
|
||
|
||
render();
|
||
renderTabs();
|
||
warnLowMixes();
|
||
}
|
||
|
||
if (tabsList) {
|
||
tabsList.addEventListener("click", function (e) {
|
||
var btn = e.target.closest("[data-date]");
|
||
if (!btn) return;
|
||
var d = btn.getAttribute("data-date");
|
||
if (d === activeDate) return;
|
||
saveActive();
|
||
loadDate(d);
|
||
msg.textContent = "출고일 " + (d || "미확인") + " 작업으로 전환했습니다.";
|
||
});
|
||
}
|
||
|
||
// 센터별 계산 결과를 하나의 ② 요약으로 합치고, 각 상자를 그 센터에 자동 배분한다.
|
||
// (한 출고일 안에서만 합친다 — 상자는 센터 단위로 포장)
|
||
function buildDataset(groups, perCenter) {
|
||
var mergedProds = {}; // code -> 합산 결과
|
||
var mergedMixes = {}; // box_name -> {box_name, boxes:[]}
|
||
var st = {
|
||
items: [], calc: { results: [], mixes: [] },
|
||
units: {}, labels: {}, contents: {},
|
||
openCenters: [], alloc: {}, methods: {},
|
||
stamped: {}, allStamped: false,
|
||
mixType: {}, mixOwner: {}
|
||
};
|
||
|
||
function put(cid, key, count) {
|
||
if (count <= 0) return;
|
||
st.alloc[cid] = st.alloc[cid] || [];
|
||
var same = st.alloc[cid].filter(function (a) { return a.key === key; })[0];
|
||
if (same) { same.count += count; return; }
|
||
seq += 1;
|
||
st.alloc[cid].push({ id: seq, key: key, count: count });
|
||
}
|
||
|
||
groups.forEach(function (g, gi) {
|
||
var cid = String(g.center_id);
|
||
var data = perCenter[gi] || {};
|
||
if (st.openCenters.indexOf(cid) < 0) st.openCenters.push(cid);
|
||
(data.results || []).filter(function (r) { return r.configured; }).forEach(function (r) {
|
||
var m = mergedProds[r.product_code];
|
||
if (!m) {
|
||
m = mergedProds[r.product_code] = {
|
||
product_code: r.product_code, product_name: r.product_name,
|
||
box_name: r.box_name, units_per_box: r.units_per_box,
|
||
configured: true, quantity: 0, full_boxes: 0,
|
||
remainder_units: 0, required_boxes: 0
|
||
};
|
||
}
|
||
m.quantity += r.quantity;
|
||
m.full_boxes += r.full_boxes;
|
||
m.remainder_units += r.remainder_units;
|
||
m.required_boxes += r.required_boxes;
|
||
put(cid, prodKey(r.product_code), r.full_boxes);
|
||
});
|
||
(data.mixes || []).forEach(function (mx) {
|
||
var bucket = mergedMixes[mx.box_name];
|
||
if (!bucket) bucket = mergedMixes[mx.box_name] = { box_name: mx.box_name, boxes: [] };
|
||
mx.boxes.forEach(function (b) {
|
||
var idx = bucket.boxes.length;
|
||
bucket.boxes.push(b);
|
||
var k = mixKeyFor(b);
|
||
st.mixOwner[k] = g.center_name;
|
||
put(cid, k, 1);
|
||
});
|
||
});
|
||
});
|
||
|
||
st.calc.results = Object.keys(mergedProds).map(function (k) { return mergedProds[k]; });
|
||
st.calc.mixes = Object.keys(mergedMixes).map(function (k) { return mergedMixes[k]; });
|
||
|
||
st.calc.results.forEach(function (r) {
|
||
st.units[prodKey(r.product_code)] = r.units_per_box;
|
||
st.labels[prodKey(r.product_code)] = r.product_name;
|
||
});
|
||
st.calc.mixes.forEach(function (m) {
|
||
m.boxes.forEach(function (b, i) {
|
||
var n = 0;
|
||
b.items.forEach(function (it) { n += it.quantity; });
|
||
var k = mixKeyFor(b);
|
||
st.units[k] = n;
|
||
st.mixType[k] = MIX_TYPES[0];
|
||
st.labels[k] = st.mixType[k] + " 혼합 #" + (i + 1) +
|
||
(st.mixOwner[k] ? " · " + st.mixOwner[k] : "");
|
||
st.contents[k] = b.items.map(function (it) {
|
||
return { product_code: it.product_code, product_name: it.product_name, quantity: it.quantity };
|
||
});
|
||
});
|
||
});
|
||
|
||
st.items = st.calc.results.slice().sort(function (a, b) {
|
||
return String(a.product_name).localeCompare(String(b.product_name), "ko");
|
||
}).map(function (r) {
|
||
return { product_code: r.product_code, quantity: r.quantity };
|
||
});
|
||
|
||
return st;
|
||
}
|
||
|
||
// 분석 결과 상세는 화면에 두지 않는다. 놓치면 안 되는 경고만 남긴다.
|
||
function renderPoResult(data, skipped) {
|
||
if (!poResult) return;
|
||
var warns = (data.warnings || []).slice();
|
||
if (skipped && skipped.length) {
|
||
warns.push("등록되지 않은 센터라 자동 배분에서 제외: " + skipped.join(", "));
|
||
}
|
||
if (!warns.length) { poResult.hidden = true; poResult.innerHTML = ""; return; }
|
||
poResult.innerHTML = '<ul class="cpg-po-warn">' + warns.map(function (w) {
|
||
return "<li>" + esc(w) + "</li>";
|
||
}).join("") + "</ul>";
|
||
poResult.hidden = false;
|
||
}
|
||
|
||
function runUpload() {
|
||
if (!poFile || !poFile.files || !poFile.files.length) {
|
||
poMsg.textContent = "엑셀 파일을 선택하세요."; return;
|
||
}
|
||
var fd = new FormData();
|
||
Array.prototype.forEach.call(poFile.files, function (f) { fd.append("files", f); });
|
||
poMsg.textContent = "분석 중… (" + poFile.files.length + "개)";
|
||
fetch("/cupang/api/box-calc/upload", { method: "POST", body: fd })
|
||
.then(function (r) {
|
||
return r.json().catch(function () { return {}; }).then(function (d) {
|
||
if (!r.ok) throw new Error(d.detail || "업로드 실패 (http " + r.status + ")");
|
||
return d;
|
||
});
|
||
})
|
||
.then(function (data) {
|
||
var dateGroups = (data.groups || []).map(function (g) {
|
||
return {
|
||
ship_date: g.ship_date || "",
|
||
centers: (g.centers || []).filter(function (c) { return c.center_id != null; }),
|
||
skipped: (g.centers || []).filter(function (c) { return c.center_id == null; })
|
||
.map(function (c) { return c.center_name; })
|
||
};
|
||
}).filter(function (g) { return g.centers.length; });
|
||
|
||
var allSkipped = [];
|
||
(data.groups || []).forEach(function (g) {
|
||
(g.centers || []).forEach(function (c) {
|
||
if (c.center_id == null && allSkipped.indexOf(c.center_name) < 0) {
|
||
allSkipped.push(c.center_name);
|
||
}
|
||
});
|
||
});
|
||
|
||
if (!dateGroups.length) {
|
||
renderPoResult(data, allSkipped);
|
||
poMsg.textContent = "배분할 수 있는 센터가 없습니다.";
|
||
return;
|
||
}
|
||
|
||
// 날짜 × 센터 단위로 상자 계산 (상자는 센터별로 포장되므로)
|
||
var jobs = [];
|
||
dateGroups.forEach(function (g) {
|
||
g.centers.forEach(function (c) {
|
||
jobs.push(calcForItems(c.items.map(function (it) {
|
||
return { product_code: it.product_code, quantity: it.quantity };
|
||
})));
|
||
});
|
||
});
|
||
|
||
return Promise.all(jobs).then(function (all) {
|
||
var at = 0;
|
||
sets = {}; dateOrder = []; activeDate = "";
|
||
dateGroups.forEach(function (g) {
|
||
var per = all.slice(at, at + g.centers.length);
|
||
at += g.centers.length;
|
||
sets[g.ship_date] = buildDataset(g.centers, per);
|
||
dateOrder.push(g.ship_date);
|
||
});
|
||
loadDate(dateOrder[0]);
|
||
renderPoResult(data, allSkipped);
|
||
var pieces = 0, centerCount = 0;
|
||
dateGroups.forEach(function (g) {
|
||
centerCount += g.centers.length;
|
||
g.centers.forEach(function (c) {
|
||
c.items.forEach(function (it) { pieces += it.quantity; });
|
||
});
|
||
});
|
||
poMsg.textContent = "분석 완료 — 출고일 " + dateOrder.length + "개 · 센터 " +
|
||
centerCount + "곳 · " + pieces + "개 자동 배분";
|
||
msg.textContent = dateOrder.length > 1
|
||
? "출고일이 " + dateOrder.length + "개입니다. 위 탭에서 날짜를 골라 확정하세요."
|
||
: "업로드 결과로 계산했습니다. 센터마다 출고방식을 고르면 확정할 수 있습니다.";
|
||
});
|
||
})
|
||
.catch(function (err) { poMsg.textContent = (err && err.message) || "업로드 실패"; });
|
||
}
|
||
|
||
// 파일을 고르고 [열기] 를 누르면 곧바로 분석한다(별도 버튼 없음).
|
||
if (poFile) {
|
||
poFile.addEventListener("change", function () {
|
||
if (poFile.files && poFile.files.length) runUpload();
|
||
});
|
||
}
|
||
|
||
// ── 배분 집계 ─────────────────────────────────────
|
||
function allocatedFor(key) {
|
||
var n = 0;
|
||
Object.keys(alloc).forEach(function (cid) {
|
||
alloc[cid].forEach(function (a) { if (a.key === key) n += a.count; });
|
||
});
|
||
return n;
|
||
}
|
||
|
||
function totalFor(key) {
|
||
if (key.indexOf("p:") === 0) {
|
||
var code = key.slice(2);
|
||
var hit = calc.results.filter(function (r) { return r.product_code === code; })[0];
|
||
return hit ? hit.full_boxes : 0;
|
||
}
|
||
return 1; // 혼합 상자는 1장 = 1상자
|
||
}
|
||
|
||
function remainFor(key) { return Math.max(0, totalFor(key) - allocatedFor(key)); }
|
||
|
||
function addAlloc(cid, key, count) {
|
||
cid = String(cid);
|
||
var room = remainFor(key);
|
||
var n = Math.min(Math.max(parseInt(count, 10) || 0, 0), room);
|
||
if (n <= 0) return 0;
|
||
alloc[cid] = alloc[cid] || [];
|
||
var same = alloc[cid].filter(function (a) { return a.key === key; })[0];
|
||
if (same) { same.count += n; }
|
||
else { seq += 1; alloc[cid].push({ id: seq, key: key, count: n }); }
|
||
return n;
|
||
}
|
||
|
||
// ── 혼합 상자 편집 ────────────────────────────────
|
||
// 상자 용량은 부피 합으로 본다: 제품 1개 = 1/입수량 상자.
|
||
// 빼낸 수량은 센터별 "미배치 자투리" 풀에 남고, 거기서 다시 담는다.
|
||
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 mixItem(key, code) {
|
||
return (contents[key] || []).filter(function (it) { return it.product_code === code; })[0];
|
||
}
|
||
|
||
// 상자에 담기 — 용량은 표시용일 뿐, 이동은 막지 않는다(넘치면 100% 초과 표시).
|
||
function mixPut(key, code, name, qty) {
|
||
var put = 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 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;
|
||
hit.quantity -= take;
|
||
syncMix(key);
|
||
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 pulled = mixPull(fromKey, code, qty);
|
||
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];
|
||
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));
|
||
var h = 40 * f / 100;
|
||
return '' +
|
||
'<svg class="cpg-box-svg" viewBox="0 0 72 64" role="img" aria-label="' + esc(label) + '">' +
|
||
'<rect class="cpg-box-fill" x="10" y="' + (54 - h) + '" width="52" height="' + h + '" rx="2" />' +
|
||
'<path class="cpg-box-body" d="M10 20 H62 V54 H10 Z" />' +
|
||
'<path class="cpg-box-flapL" d="M10 20 L2 8 H30 L36 20 Z" />' +
|
||
'<path class="cpg-box-flapR" d="M62 20 L70 8 H42 L36 20 Z" />' +
|
||
'<line class="cpg-box-seam" x1="36" y1="20" x2="36" y2="54" />' +
|
||
'<text class="cpg-box-label" x="36" y="42" text-anchor="middle">' + esc(label || "") + "</text>" +
|
||
"</svg>";
|
||
}
|
||
|
||
// 배분이 끝난 카드에 찍는 도장. 애니메이션은 "이번에 막 완료된" 것만.
|
||
var stamped = {}; // key -> 이미 도장이 찍혀 있던 항목
|
||
function stampHtml(key) {
|
||
var fresh = !stamped[key];
|
||
stamped[key] = true;
|
||
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) {
|
||
return '<div class="cpg-kpi"><span class="cpg-kpi-label">' + esc(label) + "</span>" +
|
||
'<strong class="cpg-kpi-value">' + esc(value) + "</strong>" +
|
||
'<span class="cpg-kpi-hint">' + esc(hint) + "</span>" +
|
||
(extra || "") + "</div>";
|
||
}
|
||
|
||
function renderSummary() {
|
||
if (!calc.results.length) {
|
||
sumEmpty.hidden = false;
|
||
sumBody.hidden = true;
|
||
return;
|
||
}
|
||
sumEmpty.hidden = true;
|
||
sumBody.hidden = false;
|
||
|
||
var totalProd = 0, remainProd = 0, leftoverUnits = 0;
|
||
calc.results.forEach(function (r) {
|
||
totalProd += r.full_boxes;
|
||
remainProd += remainFor(prodKey(r.product_code));
|
||
leftoverUnits += r.remainder_units;
|
||
});
|
||
var totalMix = 0, remainMix = 0;
|
||
calc.mixes.forEach(function (m) {
|
||
m.boxes.forEach(function (b, i) {
|
||
totalMix += 1;
|
||
remainMix += remainFor(mixKeyFor(b));
|
||
});
|
||
});
|
||
var assigned = (totalProd + totalMix) - (remainProd + remainMix);
|
||
|
||
// 모든 상자를 센터에 배분하면 "센터 배분" 칸에 전체 완료 도장을 찍는다.
|
||
var totalBoxes = totalProd + totalMix;
|
||
var allDone = totalBoxes > 0 && (remainProd + remainMix) === 0;
|
||
var allStamp = "";
|
||
if (allDone) {
|
||
allStamp = '<span class="cpg-stamp cpg-stamp-all' + (allStamped ? "" : " is-new") + '"' +
|
||
' aria-label="전체 배분 완료">전체 배분 완료</span>';
|
||
}
|
||
allStamped = allDone;
|
||
|
||
sumKpis.innerHTML =
|
||
kpi("총 상자", totalBoxes + "상자", "제품별 " + totalProd + " + 혼합 " + totalMix) +
|
||
kpi("센터 배분", assigned + "상자", "③ 에 담긴 수량", allStamp) +
|
||
kpi("미배분 잔여", (remainProd + remainMix) + "상자", "자투리 " + leftoverUnits + "개 포함");
|
||
|
||
var ph = "";
|
||
calc.results.forEach(function (r) {
|
||
var key = prodKey(r.product_code);
|
||
var remain = remainFor(key);
|
||
var used = r.full_boxes - remain;
|
||
if (remain) delete stamped[key];
|
||
ph += '<div class="cpg-sum-prod' + (remain ? " is-draggable" : " is-done") + '"' +
|
||
' data-key="' + esc(key) + '">' +
|
||
'<div class="cpg-sum-prod-head">' +
|
||
'<span class="cpg-sum-prod-name">' + esc(r.product_name) + "</span>" +
|
||
(remain ? "" : stampHtml(key)) +
|
||
"</div>" +
|
||
'<div class="cpg-sum-prod-nums">' +
|
||
"<strong>" + remain + "상자</strong>" +
|
||
'<span class="erp-muted">' + (remain * r.units_per_box) + "개 · 총 " + r.full_boxes + "상자" +
|
||
(used ? " · 배분 " + used : "") + "</span>" +
|
||
(r.remainder_units
|
||
? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>"
|
||
: '<span class="cpg-sum-exact">딱 맞음</span>') +
|
||
"</div>" +
|
||
"</div>";
|
||
});
|
||
sumProds.innerHTML = ph;
|
||
|
||
if (!calc.mixes.length) {
|
||
sumMixNote.textContent = "— 자투리 없음";
|
||
sumBoxes.innerHTML = '<p class="erp-muted">남는 낱개가 없습니다.</p>';
|
||
} else {
|
||
sumMixNote.textContent = "— 1장 = 1상자";
|
||
var bh = "", n = 0;
|
||
calc.mixes.forEach(function (m) {
|
||
m.boxes.forEach(function (b, i) {
|
||
var key = mixKeyFor(b);
|
||
var remain = remainFor(key);
|
||
if (remain) delete stamped[key];
|
||
var items = b.items.map(function (it) {
|
||
return "<li><span>" + esc(it.product_name) + "</span><b>" + it.quantity + "개</b></li>";
|
||
}).join("");
|
||
bh += '<div class="cpg-box-card' + (remain ? " is-draggable" : " is-done") + '"' +
|
||
' data-key="' + esc(key) + '"' +
|
||
' style="animation-delay:' + (n * 60) + 'ms">' +
|
||
'<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) +
|
||
(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>' +
|
||
'<div class="cpg-box-fillnum">' + b.fill_percent + "% 채움 · " + units[key] + "개</div>" +
|
||
"</div>" +
|
||
"</div>";
|
||
n += 1;
|
||
});
|
||
});
|
||
sumBoxes.innerHTML = bh;
|
||
}
|
||
}
|
||
|
||
// 혼합 상자는 안에 무엇이 몇 개 들어가는지 트리로 펼치고, 그 자리에서
|
||
// 수량 조절(− +)과 다른 상자로 이동(⇄)을 할 수 있게 한다.
|
||
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) + '">' +
|
||
esc(labels[k] || k) + (room ? " (여유 " + room + "개)" : " (가득)") + "</option>";
|
||
});
|
||
opts += '<option value="__new__">+ 새 상자로</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 = !!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>' +
|
||
'<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 renderDist() {
|
||
if (!distList) return;
|
||
if (distEmpty) distEmpty.hidden = openCenters.length > 0;
|
||
|
||
var html = "";
|
||
openCenters.forEach(function (cid) {
|
||
var rows = alloc[cid] || [];
|
||
var boxes = 0, pieces = 0, items = "";
|
||
rows.forEach(function (a) {
|
||
var per = units[a.key] || 0;
|
||
boxes += a.count;
|
||
pieces += a.count * per;
|
||
var isMix = a.key.indexOf("m:") === 0;
|
||
var fillPct = isMix ? Math.round(mixFill(a.key) * 100) : 0;
|
||
var fillTxt = isMix ? fillPct + "% 채움" : "";
|
||
var fillCls = fillPct > 100 ? " is-over" : "";
|
||
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) +
|
||
(fillTxt ? ' <em class="cpg-mix-fill' + fillCls + '">' + 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>' +
|
||
"</div>" +
|
||
treeHtml(a) +
|
||
"</li>";
|
||
});
|
||
var method = methods[String(cid)] || "";
|
||
html += '<div class="cpg-dist-center' + (rows.length ? " has-items" : "") + '" data-drop-for="' + esc(cid) + '">' +
|
||
'<div class="cpg-dist-head">' +
|
||
"<strong>" + esc(centerName(cid)) + "</strong>" +
|
||
'<select class="erp-select cpg-dist-method' + (method ? "" : " is-unset") + '"' +
|
||
' data-method-for="' + esc(cid) + '" aria-label="출고방식">' +
|
||
'<option value=""' + (method ? "" : " selected") + '>출고방식</option>' +
|
||
'<option value="택배"' + (method === "택배" ? " selected" : "") + '>택배</option>' +
|
||
'<option value="파렛트"' + (method === "파렛트" ? " selected" : "") + '>파렛트</option>' +
|
||
"</select>" +
|
||
'<span class="cpg-dist-sum">' +
|
||
'<span class="erp-badge erp-badge-neutral">' + boxes + "상자</span>" +
|
||
'<span class="erp-badge erp-badge-neutral">' + pieces + "개</span>" +
|
||
'<button type="button" class="erp-btn erp-btn-outline cpg-dist-close" title="센터 빼기" aria-label="센터 빼기">✕</button>' +
|
||
"</span>" +
|
||
"</div>" +
|
||
(rows.length
|
||
? '<ul class="cpg-dist-items">' + items + "</ul>"
|
||
: '<p class="cpg-dist-hint">여기로 상자를 끌어다 놓기</p>') +
|
||
(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;
|
||
syncConfirm();
|
||
}
|
||
|
||
// ── 스크롤 영역 높이 ──────────────────────────────
|
||
// ①② 만 대상. ③ 센터 분배는 카드 자체가 스크롤러다(CSS).
|
||
// 넓은 화면(3열): 카드 아래끝까지를 높이로 준다.
|
||
// 좁은 화면(2열/1열): 높이를 풀고 페이지가 스크롤.
|
||
var FRAME_MIN_WIDTH = 1501;
|
||
|
||
function fitScrollAreas() {
|
||
var wide = window.innerWidth >= FRAME_MIN_WIDTH;
|
||
var panes = [
|
||
[".cpg-date-list", document.getElementById("cpg-date-list")],
|
||
[".cpg-sum-scroll", document.querySelector(".cpg-calc-sum .cpg-sum-scroll")]
|
||
];
|
||
panes.forEach(function (pair) {
|
||
var el = pair[1];
|
||
if (!el) return;
|
||
if (!wide) { // 좁은 화면: 제한 해제(페이지가 스크롤)
|
||
el.style.removeProperty("max-height");
|
||
el.style.removeProperty("overflow-y");
|
||
el.removeAttribute("data-fit");
|
||
return;
|
||
}
|
||
var card = el.closest(".erp-card");
|
||
var top = el.getBoundingClientRect().top;
|
||
var bottom = card ? card.getBoundingClientRect().bottom - 16 : window.innerHeight - 16;
|
||
var h = Math.max(160, Math.floor(Math.min(bottom, window.innerHeight - 16) - top));
|
||
el.style.setProperty("max-height", h + "px", "important");
|
||
el.style.setProperty("overflow-y", "auto", "important");
|
||
el.style.setProperty("min-height", "0", "important");
|
||
el.setAttribute("data-fit", h);
|
||
if (window.CPG_DEBUG) {
|
||
console.log("[cpg fit]", pair[0], "top=", Math.round(top), "cardBottom=", Math.round(bottom),
|
||
"set=", h, "client=", el.clientHeight, "scroll=", el.scrollHeight);
|
||
}
|
||
});
|
||
}
|
||
|
||
window.addEventListener("resize", fitScrollAreas);
|
||
window.cpgFit = fitScrollAreas; // 콘솔에서 수동 확인용
|
||
|
||
function render() {
|
||
renderSummary();
|
||
renderDist();
|
||
// 레이아웃이 확정된 다음 프레임에 높이를 잡는다
|
||
if (window.requestAnimationFrame) { window.requestAnimationFrame(fitScrollAreas); }
|
||
else { fitScrollAreas(); }
|
||
}
|
||
|
||
// ── 수량 대화상자 ─────────────────────────────────
|
||
function openDialog(key, centerId) {
|
||
if (!openCenters.length) { msg.textContent = "먼저 ③ 에서 센터를 추가하세요."; return; }
|
||
var remain = remainFor(key);
|
||
if (remain <= 0) { msg.textContent = "남은 상자가 없습니다."; return; }
|
||
|
||
pending = { key: key };
|
||
dlgItem.textContent = (labels[key] || key) + " — 잔여 " + remain + "상자 (" + (remain * (units[key] || 0)) + "개)";
|
||
dlgCenterId = String(centerId != null ? centerId : openCenters[0]);
|
||
renderDialogCenters();
|
||
dlgQty.max = remain;
|
||
dlgQty.value = key.indexOf("m:") === 0 ? 1 : remain;
|
||
dlgHint.textContent = "1상자 = " + (units[key] || 0) + "개 · 최대 " + remain + "상자";
|
||
dlg.hidden = false;
|
||
dlgQty.focus();
|
||
dlgQty.select();
|
||
}
|
||
|
||
function renderDialogCenters() {
|
||
dlgCenters.innerHTML = openCenters.map(function (cid) {
|
||
var on = String(cid) === String(dlgCenterId);
|
||
return '<button type="button" class="erp-btn ' + (on ? "erp-btn-primary" : "erp-btn-outline") +
|
||
' cpg-dlg-center-btn" data-center="' + esc(cid) + '"' + (on ? ' aria-pressed="true"' : '') + ">" +
|
||
esc(centerName(cid)) + "</button>";
|
||
}).join("");
|
||
}
|
||
|
||
dlgCenters.addEventListener("click", function (e) {
|
||
var btn = e.target.closest(".cpg-dlg-center-btn");
|
||
if (!btn) return;
|
||
dlgCenterId = btn.getAttribute("data-center");
|
||
renderDialogCenters();
|
||
});
|
||
|
||
function closeDialog() { dlg.hidden = true; pending = null; }
|
||
|
||
function commitDialog(all) {
|
||
if (!pending) return;
|
||
var key = pending.key;
|
||
var cid = dlgCenterId;
|
||
var want = all ? remainFor(key) : parseInt(dlgQty.value, 10);
|
||
var got = addAlloc(cid, key, want);
|
||
closeDialog();
|
||
render();
|
||
msg.textContent = got
|
||
? centerName(cid) + " ← " + (labels[key] || key) + " " + got + "상자"
|
||
: "담을 수량이 없습니다.";
|
||
}
|
||
|
||
dlgOk.addEventListener("click", function () { commitDialog(false); });
|
||
dlgAll.addEventListener("click", function () { commitDialog(true); });
|
||
dlg.addEventListener("click", function (e) {
|
||
if (e.target.hasAttribute && e.target.hasAttribute("data-dlg-close")) closeDialog();
|
||
});
|
||
dlgQty.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter") { e.preventDefault(); commitDialog(false); }
|
||
});
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key === "Escape" && !dlg.hidden) closeDialog();
|
||
});
|
||
|
||
// ── 드래그 (마우스 이벤트 기반) ───────────────────
|
||
// HTML5 네이티브 DnD(draggable + dragstart)는 브라우저에 따라 드래그가
|
||
// 아예 시작되지 않는다(웨일에서 미리보기조차 안 생김) → 직접 구현한다.
|
||
// · mousedown 후 4px 이상 움직이면 드래그 시작, 카드 복제본(고스트)이 커서를 따라온다
|
||
// · 커서 아래 요소를 elementFromPoint 로 찾아 센터 패널을 강조
|
||
// · mouseup 위치의 센터에 놓으면 수량 대화상자가 열린다
|
||
// · 드래그 없이 클릭하면 첫 번째 센터를 기본값으로 대화상자가 열린다
|
||
var drag = null; // {key, card, ghost, startX, startY, moved}
|
||
var suppressClickOn = null; // 드래그로 끝난 카드의 잔여 click 1회 무시
|
||
var DRAG_THRESHOLD = 4;
|
||
|
||
function clearOver() {
|
||
if (!distList) return;
|
||
Array.prototype.forEach.call(distList.querySelectorAll(".is-over"), function (el) {
|
||
el.classList.remove("is-over");
|
||
});
|
||
}
|
||
|
||
function zoneAt(x, y) {
|
||
if (!distList) return null;
|
||
var el = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
|
||
var zone = el && el.closest ? el.closest("[data-drop-for]") : null;
|
||
if (zone) return zone;
|
||
// ③ 영역 안(패널 사이 여백 포함)에서 놓았고 센터가 하나뿐이면 그 센터로.
|
||
if (el && el.closest && el.closest(".cpg-dist-card") && openCenters.length === 1) {
|
||
return distList.querySelector("[data-drop-for]");
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function moveGhost(x, y) {
|
||
if (!drag || !drag.ghost) return;
|
||
drag.ghost.style.left = (x + 12) + "px";
|
||
drag.ghost.style.top = (y + 12) + "px";
|
||
}
|
||
|
||
function startDrag(card, x, y) {
|
||
var ghost = card.cloneNode(true);
|
||
ghost.className = card.className + " cpg-drag-ghost";
|
||
ghost.removeAttribute("style");
|
||
ghost.style.width = card.offsetWidth ? card.offsetWidth + "px" : "240px";
|
||
document.body.appendChild(ghost);
|
||
drag.ghost = ghost;
|
||
drag.moved = true;
|
||
card.classList.add("is-dragging");
|
||
document.body.classList.add("cpg-dragging");
|
||
if (distCard) distCard.classList.add("is-drop-ready");
|
||
moveGhost(x, y);
|
||
}
|
||
|
||
function endDrag(commit, x, y, upTarget) {
|
||
if (!drag) return;
|
||
var key = drag.key, moved = drag.moved, card = drag.card;
|
||
if (drag.ghost && drag.ghost.parentNode) drag.ghost.parentNode.removeChild(drag.ghost);
|
||
if (drag.card) drag.card.classList.remove("is-dragging");
|
||
document.body.classList.remove("cpg-dragging");
|
||
if (distCard) distCard.classList.remove("is-drop-ready");
|
||
clearOver();
|
||
drag = null;
|
||
if (!moved) return; // 움직이지 않았으면 클릭으로 처리
|
||
// 카드 위에서 손을 뗀 경우에만 뒤따르는 click 을 1회 무시한다.
|
||
suppressClickOn = (card && upTarget && card.contains(upTarget)) ? card : null;
|
||
if (!commit) return;
|
||
var zone = zoneAt(x, y);
|
||
if (!zone) { msg.textContent = "센터 위에 놓아야 담깁니다."; return; }
|
||
openDialog(key, zone.getAttribute("data-drop-for"));
|
||
}
|
||
|
||
sumBody.addEventListener("mousedown", function (e) {
|
||
if (e.button !== 0) return;
|
||
if (e.target.closest(".cpg-box-type")) return; // 상자 종류 고르는 중
|
||
var card = e.target.closest("[data-key]");
|
||
if (!card || card.classList.contains("is-done")) return;
|
||
drag = { key: card.getAttribute("data-key"), card: card, startX: e.clientX, startY: e.clientY, moved: false, ghost: null };
|
||
e.preventDefault(); // 텍스트 선택 방지
|
||
});
|
||
|
||
document.addEventListener("mousemove", function (e) {
|
||
if (!drag) return;
|
||
if (!drag.moved) {
|
||
if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD &&
|
||
Math.abs(e.clientY - drag.startY) < DRAG_THRESHOLD) return;
|
||
startDrag(drag.card, e.clientX, e.clientY);
|
||
}
|
||
moveGhost(e.clientX, e.clientY);
|
||
var zone = zoneAt(e.clientX, e.clientY);
|
||
clearOver();
|
||
if (zone) zone.classList.add("is-over");
|
||
});
|
||
|
||
document.addEventListener("mouseup", function (e) {
|
||
if (!drag) return;
|
||
endDrag(true, e.clientX, e.clientY, e.target);
|
||
});
|
||
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key === "Escape" && drag) endDrag(false, 0, 0, null);
|
||
});
|
||
|
||
// 드래그하지 않고 클릭한 경우 — 첫 센터를 기본값으로 대화상자를 연다.
|
||
// (드래그를 끝낸 카드에서 브라우저가 한 번 더 쏘는 click 은 1회만 무시)
|
||
sumBody.addEventListener("click", function (e) {
|
||
if (e.target.closest(".cpg-box-type")) return; // 상자 종류 고르는 중
|
||
var card = e.target.closest("[data-key]");
|
||
if (!card) return;
|
||
if (card === suppressClickOn) { suppressClickOn = null; return; }
|
||
openDialog(card.getAttribute("data-key"), openCenters[0]);
|
||
});
|
||
|
||
// 상자 종류 변경 — 라벨(②③)만 바뀌고 배분 수량은 그대로 유지된다.
|
||
sumBody.addEventListener("change", function (e) {
|
||
var sel = e.target.closest(".cpg-box-type");
|
||
if (!sel) return;
|
||
var key = sel.getAttribute("data-type-for");
|
||
mixType[key] = sel.value;
|
||
calc.mixes.forEach(function (m) {
|
||
m.boxes.forEach(function (b, i) {
|
||
var k = mixKeyFor(b);
|
||
if (k === key) labels[k] = mixLabel(k, i);
|
||
});
|
||
});
|
||
render();
|
||
msg.textContent = "상자 종류를 " + sel.value + " 로 바꿨습니다.";
|
||
});
|
||
|
||
// ── 혼합 상자 내용물 편집 이벤트 ──────────────────
|
||
// 수량은 상자 사이에서만 오간다(총량 보존). 갈 곳이 없으면 새 상자를 만든다.
|
||
if (distList) {
|
||
distList.addEventListener("click", function (e) {
|
||
// 혼합 상자의 ✕ — 내용물을 다른 상자로 옮기고 상자를 없앤다.
|
||
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) {
|
||
var rest = mixKeysOf(dcid).filter(function (k) { return k !== entry.key; });
|
||
var dest = boxWithRoom(dcid, it.product_code, entry.key) || rest[0] || newMixBox(dcid);
|
||
moveUnits(entry.key, dest, it.product_code, it.product_name, it.quantity);
|
||
});
|
||
syncMix(entry.key);
|
||
render();
|
||
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 others = mixKeysOf(cid).filter(function (k) { return k !== key; });
|
||
var to = boxWithRoom(cid, code, key) || others[0] || 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);
|
||
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 = "빈 혼합 상자를 추가했습니다.";
|
||
}
|
||
});
|
||
|
||
distList.addEventListener("change", function (e) {
|
||
var mv = e.target.closest(".cpg-mix-move");
|
||
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) + "개는 남음)" : "");
|
||
});
|
||
}
|
||
|
||
if (distList) {
|
||
distList.addEventListener("input", function (e) {
|
||
if (!e.target.classList.contains("cpg-dist-qty")) return;
|
||
var li = e.target.closest(".cpg-dist-item");
|
||
var cid = e.target.closest("[data-drop-for]").getAttribute("data-drop-for");
|
||
var entry = (alloc[cid] || []).filter(function (a) { return String(a.id) === li.getAttribute("data-entry"); })[0];
|
||
if (!entry) return;
|
||
var want = parseInt(e.target.value, 10);
|
||
if (isNaN(want) || want < 1) want = 1;
|
||
var max = entry.count + remainFor(entry.key);
|
||
if (want > max) { want = max; e.target.value = max; }
|
||
entry.count = want;
|
||
li.querySelector(".cpg-dist-unit").textContent = "상자 · " + (want * (units[entry.key] || 0)) + "개";
|
||
var tree = li.querySelector(".cpg-dist-tree");
|
||
if (tree) tree.outerHTML = treeHtml(entry);
|
||
renderSummary();
|
||
updateCenterTotals(cid);
|
||
syncConfirm();
|
||
});
|
||
|
||
distList.addEventListener("click", function (e) {
|
||
var zone = e.target.closest("[data-drop-for]");
|
||
if (!zone) return;
|
||
var cid = zone.getAttribute("data-drop-for");
|
||
if (e.target.closest(".cpg-dist-close")) { // 센터 자체를 목록에서 뺀다
|
||
openCenters = openCenters.filter(function (x) { return x !== cid; });
|
||
delete alloc[cid];
|
||
delete methods[cid];
|
||
render();
|
||
return;
|
||
}
|
||
if (e.target.closest(".cpg-dist-del")) { // 담은 항목 제거 → 잔여로 복귀
|
||
var li = e.target.closest(".cpg-dist-item");
|
||
alloc[cid] = (alloc[cid] || []).filter(function (a) { return String(a.id) !== li.getAttribute("data-entry"); });
|
||
render();
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateCenterTotals(cid) {
|
||
var zone = distList.querySelector('[data-drop-for="' + cid + '"]');
|
||
if (!zone) return;
|
||
var boxes = 0, pieces = 0;
|
||
(alloc[cid] || []).forEach(function (a) {
|
||
boxes += a.count;
|
||
pieces += a.count * (units[a.key] || 0);
|
||
});
|
||
var badges = zone.querySelectorAll(".cpg-dist-sum .erp-badge");
|
||
if (badges[0]) badges[0].textContent = boxes + "상자";
|
||
if (badges[1]) badges[1].textContent = pieces + "개";
|
||
}
|
||
|
||
if (centerPick) {
|
||
// 목록에서 센터를 고르는 즉시 ③ 에 추가한다(별도 추가 버튼 없음).
|
||
centerPick.addEventListener("change", function () {
|
||
var cid = centerPick.value;
|
||
centerPick.value = ""; // 같은 센터를 다시 고를 수 있게 초기화
|
||
if (!cid) return;
|
||
if (openCenters.indexOf(cid) >= 0) { msg.textContent = "이미 추가된 센터입니다."; return; }
|
||
openCenters.push(cid);
|
||
renderDist();
|
||
msg.textContent = centerName(cid) + " 추가됨";
|
||
});
|
||
}
|
||
|
||
// ── 분배 확정 ─────────────────────────────────────
|
||
var confirmBtn = document.getElementById("cpg-confirm");
|
||
var savingDlg = document.getElementById("cpg-saving");
|
||
var savingNote = document.getElementById("cpg-saving-note");
|
||
|
||
function pad2(n) { return (n < 10 ? "0" : "") + n; }
|
||
function iso(y, m, d) { return y + "-" + pad2(m + 1) + "-" + pad2(d); }
|
||
|
||
// 확정에 쓸 출고일 — 업로드한 발주서의 출고일(F13 − 1일), 없으면 오늘.
|
||
function shipDateForConfirm() {
|
||
if (activeDate) return activeDate;
|
||
if (uploadShipDate) return uploadShipDate;
|
||
var t = new Date();
|
||
return iso(t.getFullYear(), t.getMonth(), t.getDate());
|
||
}
|
||
|
||
function showSaving(text) {
|
||
if (!savingDlg) return;
|
||
if (savingNote) savingNote.textContent = text || "";
|
||
savingDlg.hidden = false;
|
||
}
|
||
function hideSaving() { if (savingDlg) savingDlg.hidden = true; }
|
||
|
||
// 배분이 남았는지 — ② 요약과 같은 기준
|
||
function boxTotals() {
|
||
var total = 0, remain = 0;
|
||
calc.results.forEach(function (r) {
|
||
total += r.full_boxes;
|
||
remain += remainFor(prodKey(r.product_code));
|
||
});
|
||
calc.mixes.forEach(function (m) {
|
||
m.boxes.forEach(function (b, i) {
|
||
total += 1;
|
||
remain += remainFor(mixKeyFor(b));
|
||
});
|
||
});
|
||
return { total: total, remain: remain };
|
||
}
|
||
|
||
function filledCenters() {
|
||
return openCenters.filter(function (cid) { return (alloc[cid] || []).length > 0; });
|
||
}
|
||
|
||
// 확정 가능 여부 + 왜 안 되는지 사유
|
||
function confirmBlockReason() {
|
||
var t = boxTotals();
|
||
if (!t.total) return "먼저 ① 에서 계산하세요.";
|
||
if (t.remain) return "아직 " + t.remain + "상자가 남았습니다.";
|
||
var cids = filledCenters();
|
||
if (!cids.length) return "센터에 담긴 상자가 없습니다.";
|
||
var missing = cids.filter(function (cid) { return !methods[String(cid)]; });
|
||
if (missing.length) return "출고방식 미선택: " + missing.map(centerName).join(", ");
|
||
return "";
|
||
}
|
||
|
||
function syncConfirm() {
|
||
if (!confirmBtn) return;
|
||
var why = confirmBlockReason();
|
||
confirmBtn.disabled = !!why;
|
||
confirmBtn.title = why || ("출고일 " + shipDateForConfirm() + " 로 바로 확정합니다.");
|
||
}
|
||
|
||
// 센터별 전송 payload — 혼합 상자는 내용물 제품으로 풀어서 담는다.
|
||
function buildPlans() {
|
||
return filledCenters().map(function (cid) {
|
||
var rows = alloc[cid] || [];
|
||
var items = [];
|
||
var boxes = 0;
|
||
rows.forEach(function (a) {
|
||
boxes += a.count;
|
||
if (a.key.indexOf("p:") === 0) {
|
||
items.push({ product_code: a.key.slice(2), quantity: a.count * (units[a.key] || 0) });
|
||
} else {
|
||
(contents[a.key] || []).forEach(function (it) {
|
||
items.push({ product_code: it.product_code, quantity: it.quantity * a.count });
|
||
});
|
||
}
|
||
});
|
||
// 출고 엑셀 "출고" 칸 — 상자 종류별 상자 수 (예: "쿠팡상자, 41개")
|
||
// 종류가 여럿이면 " / " 로 잇는다 (예: "쿠팡상자, 41개 / 3호상자, 2개")
|
||
var byType = {};
|
||
var order = [];
|
||
rows.forEach(function (a) {
|
||
var t;
|
||
if (a.key.indexOf("m:") === 0) {
|
||
t = mixTypeOf(a.key);
|
||
} else {
|
||
var code = a.key.slice(2);
|
||
var hit = calc.results.filter(function (r) { return r.product_code === code; })[0];
|
||
t = (hit && hit.box_name) || "쿠팡상자";
|
||
}
|
||
if (!(t in byType)) { byType[t] = 0; order.push(t); }
|
||
byType[t] += a.count;
|
||
});
|
||
var boxSummary = order.map(function (t) {
|
||
return t + ", " + byType[t] + "개";
|
||
}).join(" / ");
|
||
|
||
// 확정 후 상세 화면에서 "어느 상자에 무엇이" 를 보여주기 위한 스냅샷
|
||
var boxPlan = rows.map(function (a) {
|
||
if (a.key.indexOf("m:") === 0) {
|
||
return {
|
||
kind: "mix",
|
||
name: labels[a.key] || a.key,
|
||
box_type: mixTypeOf(a.key),
|
||
count: a.count,
|
||
units: units[a.key] || 0,
|
||
items: (contents[a.key] || []).map(function (it) {
|
||
return {
|
||
product_code: it.product_code,
|
||
product_name: it.product_name,
|
||
quantity: it.quantity * a.count
|
||
};
|
||
})
|
||
};
|
||
}
|
||
return {
|
||
kind: "product",
|
||
name: labels[a.key] || a.key,
|
||
product_code: a.key.slice(2),
|
||
count: a.count,
|
||
units: units[a.key] || 0
|
||
};
|
||
});
|
||
|
||
return {
|
||
center_id: cid,
|
||
ship_method: methods[String(cid)] || "",
|
||
boxes: boxes,
|
||
box_summary: boxSummary,
|
||
box_plan: boxPlan,
|
||
items: items
|
||
};
|
||
});
|
||
}
|
||
|
||
function submitConfirm() {
|
||
var why = confirmBlockReason();
|
||
if (why) { msg.textContent = why; return; }
|
||
var plans = buildPlans();
|
||
if (!plans.length) { msg.textContent = "담긴 품목이 없습니다."; return; }
|
||
|
||
var shipDate = shipDateForConfirm();
|
||
if (confirmBtn) confirmBtn.disabled = true;
|
||
showSaving(shipDate + " 출고 " + plans.length + "건");
|
||
|
||
fetch("/cupang/api/box-calc/confirm", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ ship_date: shipDate, centers: plans })
|
||
})
|
||
.then(function (r) {
|
||
if (!r.ok) {
|
||
return r.json().catch(function () { return {}; }).then(function (e) {
|
||
throw new Error(e.detail || "http " + r.status);
|
||
});
|
||
}
|
||
return r.json();
|
||
})
|
||
.then(function (data) {
|
||
var d = (data && data.ship_date) || shipDate;
|
||
// 출고리스트는 구글 시트에 기록한다(설정돼 있을 때만).
|
||
// 엑셀이 필요하면 /cupang/export.xlsx?date=YYYY-MM-DD 로 따로 받는다.
|
||
var sh = (data && data.sheet) || {};
|
||
var sheetNote = sh.ok
|
||
? "구글 시트 " + (sh.title || "") + " 기록 완료"
|
||
: "구글 시트 기록 안 됨 — " + (sh.reason || "사유 불명");
|
||
|
||
// 업로드한 출고일이 여러 개면 남은 날짜 작업을 이어서 한다.
|
||
var idx = dateOrder.indexOf(activeDate);
|
||
if (idx >= 0 && dateOrder.length > 1) {
|
||
var done = activeDate;
|
||
dateOrder.splice(idx, 1);
|
||
delete sets[done];
|
||
activeDate = "";
|
||
loadDate(dateOrder[Math.min(idx, dateOrder.length - 1)]);
|
||
hideSaving();
|
||
syncConfirm();
|
||
msg.textContent = done + " 출고 확정 완료 — 남은 출고일 " + dateOrder.length + "개 · " + sheetNote;
|
||
poMsg.textContent = done + " 확정됨. 남은 날짜: " + dateOrder.join(", ");
|
||
return;
|
||
}
|
||
|
||
// 저장이 끝났으면 창을 닫고 달력으로 이동한다.
|
||
hideSaving();
|
||
var parts = d.split("-");
|
||
window.location.href = "/cupang/?year=" + parts[0] +
|
||
"&month=" + parseInt(parts[1], 10) + "&date=" + d;
|
||
})
|
||
.catch(function (err) {
|
||
hideSaving();
|
||
if (confirmBtn) confirmBtn.disabled = false;
|
||
msg.textContent = (err && err.message) || "확정 실패 — 다시 시도하세요.";
|
||
});
|
||
}
|
||
|
||
if (confirmBtn) confirmBtn.addEventListener("click", submitConfirm);
|
||
|
||
// 센터 헤더의 출고방식 선택
|
||
if (distList) {
|
||
distList.addEventListener("change", function (e) {
|
||
var sel = e.target.closest("[data-method-for]");
|
||
if (!sel) return;
|
||
var cid = String(sel.getAttribute("data-method-for"));
|
||
if (sel.value) { methods[cid] = sel.value; } else { delete methods[cid]; }
|
||
sel.classList.toggle("is-unset", !sel.value);
|
||
syncConfirm();
|
||
});
|
||
}
|
||
|
||
// ── 임시 저장 / 불러오기 ──────────────────────────
|
||
var saveBtn = document.getElementById("cpg-calc-save");
|
||
var loadBtn = document.getElementById("cpg-calc-load");
|
||
var draftDlg = document.getElementById("cpg-draft-dlg");
|
||
var draftName = document.getElementById("cpg-draft-name");
|
||
var draftSave = document.getElementById("cpg-draft-save");
|
||
var draftMsg = document.getElementById("cpg-draft-msg");
|
||
var draftList = document.getElementById("cpg-draft-list");
|
||
|
||
function snapshot() {
|
||
return {
|
||
items: currentItems(),
|
||
ship_date: activeDate || uploadShipDate || "",
|
||
openCenters: openCenters.slice(),
|
||
alloc: alloc,
|
||
methods: methods,
|
||
stamped: stamped,
|
||
allStamped: allStamped,
|
||
mixType: mixType
|
||
};
|
||
}
|
||
|
||
// 불러온 분배 내역을 현재 계산 결과 위에 다시 얹는다.
|
||
// 계산이 달라져 사라진 항목은 건너뛰고 그 건수를 돌려준다.
|
||
function applyRestore(snap) {
|
||
var skipped = 0;
|
||
openCenters = [];
|
||
(snap.openCenters || []).forEach(function (cid) {
|
||
cid = String(cid);
|
||
if (openCenters.indexOf(cid) < 0) openCenters.push(cid);
|
||
});
|
||
var src = snap.alloc || {};
|
||
Object.keys(src).forEach(function (cid) {
|
||
(src[cid] || []).forEach(function (a) {
|
||
if (!a || !(a.key in units)) { skipped += 1; return; }
|
||
if (openCenters.indexOf(String(cid)) < 0) openCenters.push(String(cid));
|
||
if (addAlloc(cid, a.key, a.count) <= 0) skipped += 1;
|
||
});
|
||
});
|
||
methods = snap.methods && typeof snap.methods === "object" ? snap.methods : {};
|
||
stamped = snap.stamped && typeof snap.stamped === "object" ? snap.stamped : {};
|
||
allStamped = !!snap.allStamped;
|
||
if (snap.mixType && typeof snap.mixType === "object") {
|
||
Object.keys(snap.mixType).forEach(function (k) {
|
||
if (k in units) mixType[k] = snap.mixType[k];
|
||
});
|
||
calc.mixes.forEach(function (m) {
|
||
m.boxes.forEach(function (b, i) {
|
||
var k = mixKeyFor(b);
|
||
labels[k] = mixLabel(k, i);
|
||
});
|
||
});
|
||
}
|
||
return skipped;
|
||
}
|
||
|
||
var DOW_KO = ["일", "월", "화", "수", "목", "금", "토"];
|
||
|
||
function defaultTitle() {
|
||
var d = new Date();
|
||
var h = d.getHours();
|
||
var ampm = h < 12 ? "오전" : "오후";
|
||
var h12 = h % 12 || 12;
|
||
return d.getFullYear() + "." + pad2(d.getMonth() + 1) + "." + pad2(d.getDate()) +
|
||
"(" + DOW_KO[d.getDay()] + ") " + ampm + " " + pad2(h12) + "시 " + pad2(d.getMinutes()) + "분";
|
||
}
|
||
|
||
function closeDraft() { draftDlg.hidden = true; }
|
||
|
||
function openDraft(mode) {
|
||
draftMsg.textContent = "";
|
||
document.getElementById("cpg-draft-title").textContent =
|
||
mode === "load" ? "저장된 내용 불러오기" : "임시 저장";
|
||
if (mode !== "load") draftName.value = defaultTitle();
|
||
draftDlg.hidden = false;
|
||
loadDrafts();
|
||
if (mode !== "load") { draftName.focus(); draftName.select(); }
|
||
}
|
||
|
||
function loadDrafts() {
|
||
draftList.innerHTML = '<p class="erp-muted">불러오는 중…</p>';
|
||
fetch("/cupang/api/box-calc/drafts")
|
||
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||
.then(function (data) {
|
||
var rows = (data && data.drafts) || [];
|
||
if (!rows.length) {
|
||
draftList.innerHTML = '<p class="erp-muted">저장된 내용이 없습니다.</p>';
|
||
return;
|
||
}
|
||
draftList.innerHTML = rows.map(function (d) {
|
||
return '<div class="cpg-draft-row">' +
|
||
'<span class="cpg-draft-rname">' + esc(d.title) + "</span>" +
|
||
'<span class="cpg-draft-rmeta">' + esc((d.updated_at || "").replace("T", " ").slice(5, 16)) +
|
||
(d.created_by ? " · " + esc(d.created_by) : "") + "</span>" +
|
||
'<button type="button" class="erp-btn erp-btn-outline cpg-btn-sm" data-draft-load="' + d.id + '">불러오기</button>' +
|
||
'<button type="button" class="erp-btn erp-btn-danger cpg-btn-sm" data-draft-del="' + d.id + '">삭제</button>' +
|
||
"</div>";
|
||
}).join("");
|
||
})
|
||
.catch(function () { draftList.innerHTML = '<p class="erp-muted">목록을 불러오지 못했습니다.</p>'; });
|
||
}
|
||
|
||
function doSave() {
|
||
var title = (draftName.value || "").trim();
|
||
if (!title) { draftMsg.textContent = "제목을 입력하세요."; draftName.focus(); return; }
|
||
draftSave.disabled = true;
|
||
draftMsg.textContent = "저장 중…";
|
||
fetch("/cupang/api/box-calc/drafts", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ title: title, payload: snapshot() })
|
||
})
|
||
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||
.then(function () {
|
||
draftMsg.textContent = "저장됨 (같은 제목은 덮어씀)";
|
||
msg.textContent = '"' + title + '" 임시 저장됨';
|
||
loadDrafts();
|
||
})
|
||
.catch(function () { draftMsg.textContent = "저장 실패 — 다시 시도하세요."; })
|
||
.then(function () { draftSave.disabled = false; });
|
||
}
|
||
|
||
function doLoad(id) {
|
||
draftMsg.textContent = "불러오는 중…";
|
||
fetch("/cupang/api/box-calc/drafts/" + id)
|
||
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||
.then(function (data) {
|
||
var d = (data && data.draft) || {};
|
||
var snap = d.payload || {};
|
||
var items = snap.items || [];
|
||
draftName.value = d.title || "";
|
||
closeDraft();
|
||
// 저장해 둔 출고일을 그대로 이어 쓴다(없으면 확정 시 오늘 날짜).
|
||
sets = {}; dateOrder = []; activeDate = "";
|
||
uploadShipDate = snap.ship_date || "";
|
||
if (uploadShipDate) { activeDate = uploadShipDate; dateOrder = [uploadShipDate]; }
|
||
// 상자 수는 서버에서 다시 계산한 뒤 분배 내역을 얹는다.
|
||
run(snap, items);
|
||
})
|
||
.catch(function () { draftMsg.textContent = "불러오기 실패 — 다시 시도하세요."; });
|
||
}
|
||
|
||
function doDelete(id) {
|
||
fetch("/cupang/api/box-calc/drafts/" + id, { method: "DELETE" })
|
||
.then(function (r) { if (!r.ok) throw new Error("http " + r.status); return r.json(); })
|
||
.then(function () { draftMsg.textContent = "삭제됨"; loadDrafts(); })
|
||
.catch(function () { draftMsg.textContent = "삭제 실패 — 다시 시도하세요."; });
|
||
}
|
||
|
||
if (saveBtn) saveBtn.addEventListener("click", function () { openDraft("save"); });
|
||
if (loadBtn) loadBtn.addEventListener("click", function () { openDraft("load"); });
|
||
if (draftSave) draftSave.addEventListener("click", doSave);
|
||
if (draftName) {
|
||
draftName.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter") { e.preventDefault(); doSave(); }
|
||
});
|
||
}
|
||
if (draftDlg) {
|
||
Array.prototype.forEach.call(draftDlg.querySelectorAll("[data-draft-close]"), function (el) {
|
||
el.addEventListener("click", closeDraft);
|
||
});
|
||
draftDlg.addEventListener("click", function (e) {
|
||
var l = e.target.closest("[data-draft-load]");
|
||
if (l) { doLoad(l.getAttribute("data-draft-load")); return; }
|
||
var x = e.target.closest("[data-draft-del]");
|
||
if (x) { doDelete(x.getAttribute("data-draft-del")); }
|
||
});
|
||
}
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key === "Escape" && draftDlg && !draftDlg.hidden) closeDraft();
|
||
});
|
||
|
||
render();
|
||
renderTabs();
|
||
})();
|
||
</script>
|
||
{% endif %}
|
||
|
||
</section>
|
||
{% endblock %}
|