feat(cupang): 쿠팡 발주 엑셀 업로드 → 센터별 박스 자동 계산

- ① 카드를 "쿠팡 발주 업로드"로 개편, xlsx 다중 선택 업로드
- POST /cupang/api/box-calc/upload (openpyxl): F13 입고예정일 −1일 = 출고일,
  22행부터 B=쿠팡상품코드 / F=센터명 / G=수량을 읽어 파일들을 합산
- 쿠팡상품코드 → cupang_products.coupang_item_code 로 제품 매칭,
  센터명 → cupang_centers.name 매칭. 미매칭 코드/센터/박스규칙은 경고로 표시
- 박스는 센터 단위로 포장되므로 센터마다 박스 계산을 돌려 ② 요약에 합치고
  ③ 센터에 자동 배분(자투리 혼합 박스는 소속 센터를 라벨에 표시)
- 확정 대화상자 기본 출고일도 업로드한 발주서 날짜로 채움

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:23:33 +09:00
parent eef18f7eb9
commit a6d7de709d
10 changed files with 437 additions and 15 deletions
@@ -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);
@@ -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">
@@ -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">
@@ -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">
@@ -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">
@@ -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">
@@ -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">