|
|
|
@@ -1,6 +1,6 @@
|
|
|
|
|
{% extends "erp_base.html" %}
|
|
|
|
|
|
|
|
|
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901h" />{% endblock %}
|
|
|
|
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
|
|
|
|
|
|
|
|
|
|
{% block content %}
|
|
|
|
|
<section class="cpg">
|
|
|
|
@@ -27,10 +27,25 @@
|
|
|
|
|
<!-- ① 박스 계산 — 제품명 + 수량만 입력 -->
|
|
|
|
|
<div class="erp-card cpg-form-card cpg-calc-card">
|
|
|
|
|
<div class="cpg-card-head">
|
|
|
|
|
<h2>① 박스 계산</h2>
|
|
|
|
|
<span class="erp-muted">수량 칸에서 Tab = 다음 상품 수량, Enter = 계산</span>
|
|
|
|
|
<h2>① 쿠팡 발주 업로드</h2>
|
|
|
|
|
<span class="erp-muted">엑셀 여러 개 선택 가능 · F13 입고예정일의 하루 전 = 출고일</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 쿠팡 발주서(xlsx) 업로드 → 센터별 수량 합산 → ②③ 자동 채움 -->
|
|
|
|
|
<div class="cpg-po-box">
|
|
|
|
|
<div class="cpg-po-row">
|
|
|
|
|
<input class="cpg-po-file" type="file" id="cpg-po-file"
|
|
|
|
|
accept=".xlsx,.xlsm" multiple aria-label="쿠팡 발주 엑셀 선택" />
|
|
|
|
|
<button type="button" class="erp-btn erp-btn-primary" id="cpg-po-run">업로드 분석</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="erp-muted cpg-po-msg" id="cpg-po-msg">
|
|
|
|
|
B열 쿠팡상품코드 · F열 센터 · G열 수량을 22행부터 읽습니다.
|
|
|
|
|
</p>
|
|
|
|
|
<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="erp-table-wrap">
|
|
|
|
|
<table class="erp-table cpg-calc-table">
|
|
|
|
|
<colgroup>
|
|
|
|
@@ -324,6 +339,181 @@
|
|
|
|
|
.then(function () { runBtn.disabled = false; });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 쿠팡 발주 엑셀 업로드 ─────────────────────────
|
|
|
|
|
// 업로드 → 센터별 수량 합산(서버) → 센터마다 박스 계산 → ②③ 자동 채움.
|
|
|
|
|
// 박스는 센터별로 포장하므로 계산도 센터 단위로 한다(자투리 혼합 박스 포함).
|
|
|
|
|
var poFile = document.getElementById("cpg-po-file");
|
|
|
|
|
var poRun = document.getElementById("cpg-po-run");
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 센터별 계산 결과를 하나의 ② 요약으로 합치고, 각 박스를 그 센터에 자동 배분한다.
|
|
|
|
|
function applyUploaded(groups, perCenter) {
|
|
|
|
|
var mergedProds = {}; // code -> 합산 결과
|
|
|
|
|
var mergedMixes = {}; // box_name -> {box_name, boxes:[]}
|
|
|
|
|
var autoAlloc = []; // [{cid, key, count}]
|
|
|
|
|
var mixOwner = {}; // mixKey -> 센터명
|
|
|
|
|
|
|
|
|
|
groups.forEach(function (g, gi) {
|
|
|
|
|
var cid = String(g.center_id);
|
|
|
|
|
var data = perCenter[gi] || {};
|
|
|
|
|
(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;
|
|
|
|
|
if (r.full_boxes > 0) {
|
|
|
|
|
autoAlloc.push({ cid: cid, key: prodKey(r.product_code), count: 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 = mixKey(mx.box_name, idx);
|
|
|
|
|
mixOwner[k] = g.center_name;
|
|
|
|
|
autoAlloc.push({ cid: cid, key: k, count: 1 });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
calc.results = Object.keys(mergedProds).map(function (k) { return mergedProds[k]; });
|
|
|
|
|
calc.mixes = Object.keys(mergedMixes).map(function (k) { return mergedMixes[k]; });
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
});
|
|
|
|
|
calc.mixes.forEach(function (m) {
|
|
|
|
|
m.boxes.forEach(function (b, i) {
|
|
|
|
|
var n = 0;
|
|
|
|
|
b.items.forEach(function (it) { n += it.quantity; });
|
|
|
|
|
var k = mixKey(m.box_name, i);
|
|
|
|
|
units[k] = n;
|
|
|
|
|
labels[k] = m.box_name + " 혼합 #" + (i + 1) +
|
|
|
|
|
(mixOwner[k] ? " · " + mixOwner[k] : "");
|
|
|
|
|
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;
|
|
|
|
|
openCenters = [];
|
|
|
|
|
groups.forEach(function (g) {
|
|
|
|
|
var cid = String(g.center_id);
|
|
|
|
|
if (openCenters.indexOf(cid) < 0) openCenters.push(cid);
|
|
|
|
|
});
|
|
|
|
|
autoAlloc.forEach(function (a) { addAlloc(a.cid, a.key, a.count); });
|
|
|
|
|
|
|
|
|
|
// ① 표에는 제품별 합계를 채운다(수정 후 재계산 가능).
|
|
|
|
|
tbody.innerHTML = "";
|
|
|
|
|
calc.results.slice().sort(function (a, b) {
|
|
|
|
|
return String(a.product_name).localeCompare(String(b.product_name), "ko");
|
|
|
|
|
}).forEach(function (r) { addRow(r.product_code, r.quantity); });
|
|
|
|
|
if (!calc.results.length) addRow();
|
|
|
|
|
|
|
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderPoResult(data, skipped) {
|
|
|
|
|
if (!poResult) return;
|
|
|
|
|
var html = '<div class="cpg-po-sum">' +
|
|
|
|
|
'<span class="erp-badge erp-badge-inverse">출고일 ' + esc(data.ship_date || "미확인") + "</span>" +
|
|
|
|
|
'<span class="erp-badge erp-badge-neutral">파일 ' + ((data.files || []).length) + "개</span>" +
|
|
|
|
|
'<span class="erp-badge erp-badge-neutral">센터 ' + ((data.centers || []).length) + "곳</span>" +
|
|
|
|
|
"</div>";
|
|
|
|
|
html += '<ul class="cpg-po-files">';
|
|
|
|
|
(data.files || []).forEach(function (f) {
|
|
|
|
|
html += "<li><span>" + esc(f.filename) + "</span>" +
|
|
|
|
|
"<em>입고 " + esc(f.arrival_date || "?") + " → 출고 " + esc(f.ship_date || "?") + "</em>" +
|
|
|
|
|
"<b>" + f.quantity + "개</b></li>";
|
|
|
|
|
});
|
|
|
|
|
html += "</ul>";
|
|
|
|
|
var warns = (data.warnings || []).slice();
|
|
|
|
|
if (skipped && skipped.length) {
|
|
|
|
|
warns.push("등록되지 않은 센터라 자동 배분에서 제외: " + skipped.join(", "));
|
|
|
|
|
}
|
|
|
|
|
if (warns.length) {
|
|
|
|
|
html += '<ul class="cpg-po-warn">' + warns.map(function (w) {
|
|
|
|
|
return "<li>" + esc(w) + "</li>";
|
|
|
|
|
}).join("") + "</ul>";
|
|
|
|
|
}
|
|
|
|
|
poResult.innerHTML = html;
|
|
|
|
|
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); });
|
|
|
|
|
poRun.disabled = true;
|
|
|
|
|
poMsg.textContent = "분석 중…";
|
|
|
|
|
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) {
|
|
|
|
|
uploadShipDate = data.ship_date || "";
|
|
|
|
|
var groups = (data.centers || []).filter(function (g) { return g.center_id != null; });
|
|
|
|
|
var skipped = (data.centers || []).filter(function (g) { return g.center_id == null; })
|
|
|
|
|
.map(function (g) { return g.center_name; });
|
|
|
|
|
if (!groups.length) {
|
|
|
|
|
renderPoResult(data, skipped);
|
|
|
|
|
poMsg.textContent = "배분할 수 있는 센터가 없습니다.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return Promise.all(groups.map(function (g) {
|
|
|
|
|
return calcForItems(g.items.map(function (it) {
|
|
|
|
|
return { product_code: it.product_code, quantity: it.quantity };
|
|
|
|
|
}));
|
|
|
|
|
})).then(function (perCenter) {
|
|
|
|
|
applyUploaded(groups, perCenter);
|
|
|
|
|
renderPoResult(data, skipped);
|
|
|
|
|
var pieces = 0;
|
|
|
|
|
groups.forEach(function (g) {
|
|
|
|
|
g.items.forEach(function (it) { pieces += it.quantity; });
|
|
|
|
|
});
|
|
|
|
|
poMsg.textContent = "분석 완료 — 센터 " + groups.length + "곳 · " + pieces + "개 자동 배분";
|
|
|
|
|
msg.textContent = "업로드 결과로 계산했습니다. 센터마다 출고방식을 고르면 확정할 수 있습니다.";
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
.catch(function (err) { poMsg.textContent = (err && err.message) || "업로드 실패"; })
|
|
|
|
|
.then(function () { poRun.disabled = false; });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (poRun) poRun.addEventListener("click", runUpload);
|
|
|
|
|
|
|
|
|
|
// ── 배분 집계 ─────────────────────────────────────
|
|
|
|
|
function allocatedFor(key) {
|
|
|
|
|
var n = 0;
|
|
|
|
@@ -928,8 +1118,10 @@
|
|
|
|
|
if (why) { msg.textContent = why; return; }
|
|
|
|
|
var t = new Date();
|
|
|
|
|
picked = iso(t.getFullYear(), t.getMonth(), t.getDate());
|
|
|
|
|
calY = t.getFullYear();
|
|
|
|
|
calM = t.getMonth();
|
|
|
|
|
if (uploadShipDate) picked = uploadShipDate; // 업로드한 발주서의 출고일(F13 − 1일)
|
|
|
|
|
var pp = picked.split("-");
|
|
|
|
|
calY = parseInt(pp[0], 10);
|
|
|
|
|
calM = parseInt(pp[1], 10) - 1;
|
|
|
|
|
cfmMsg.textContent = "";
|
|
|
|
|
cfmList.innerHTML = buildPlans().map(function (pl) {
|
|
|
|
|
var pieces = pl.items.reduce(function (n, it) { return n + it.quantity; }, 0);
|
|
|
|
|