fix(cupang): 센터 분배 드롭 동작·수량 대화상자·센터 선택
드래그가 먹지 않던 원인: 드롭 대상이 점선 상자(.cpg-dist-drop) 안쪽으로 한정돼 센터 카드 여백에 놓으면 dragover 에서 preventDefault 가 걸리지 않았다 → 드롭 대상을 센터 패널 전체로 넓혔다. 카드를 클릭해도 같은 대화상자가 열리는 대체 경로도 추가. - ③ 은 처음에 비어 있고, 셀렉트에서 고른 센터만 패널로 추가된다(✕ 로 제거, 제거하면 담긴 박스는 잔여로 복귀) - 드롭/클릭 시 대화상자에서 박스 수량을 입력한다. "잔여 전부 담기" 버튼으로 남은 수량을 한 번에 담을 수 있고, Enter 로 확정 / Esc 로 취소 - 센터에 담긴 줄과 센터 합계에 박스 수와 상품 수(박스×입수량)를 함께 표시 - 요약 카드에도 잔여 개수(잔여 박스 × 입수량)를 표시 tests/js: jsdom 으로 계산→드래그→대화상자→수량 수정→제거까지 36개 항목을 검증하는 UI 테스트 추가(README 에 실행법). 전체 통과 확인. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,3 +23,4 @@ __pycache__/
|
|||||||
|
|
||||||
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
|
# dispatch 라벨 PDF 샘플(실제 고객 PII — 커밋 금지)
|
||||||
docs/samples/
|
docs/samples/
|
||||||
|
tests/js/.out/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829c" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<div class="erp-card cpg-form-card cpg-calc-sum">
|
<div class="erp-card cpg-form-card cpg-calc-sum">
|
||||||
<div class="cpg-card-head">
|
<div class="cpg-card-head">
|
||||||
<h2>② 박스 요약</h2>
|
<h2>② 박스 요약</h2>
|
||||||
<span class="erp-muted">카드를 ③ 센터로 끌어다 놓으면 잔여가 줄어듭니다.</span>
|
<span class="erp-muted">카드를 ③ 센터로 끌어다 놓거나, 카드를 클릭해 담습니다.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="cpg-sum-empty" class="erp-muted">왼쪽에서 제품과 수량을 넣고 계산하세요.</div>
|
<div id="cpg-sum-empty" class="erp-muted">왼쪽에서 제품과 수량을 넣고 계산하세요.</div>
|
||||||
@@ -75,11 +75,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ③ 센터 분배 — 드래그해서 담고 수량 입력 -->
|
<!-- ③ 센터 분배 — 선택한 센터만 표시 -->
|
||||||
<div class="erp-card cpg-form-card cpg-dist-card">
|
<div class="erp-card cpg-form-card cpg-dist-card">
|
||||||
<div class="cpg-card-head">
|
<div class="cpg-card-head">
|
||||||
<h2>③ 센터 분배</h2>
|
<h2>③ 센터 분배</h2>
|
||||||
<span class="erp-muted">입고센터 관리에 등록한 센터만 표시됩니다.</span>
|
<span class="erp-muted">담을 센터를 골라 추가하세요.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not centers %}
|
{% if not centers %}
|
||||||
@@ -87,31 +87,47 @@
|
|||||||
등록된 입고센터가 없습니다. <a href="/cupang/centers">입고센터 관리</a>에서 먼저 센터를 추가하세요.
|
등록된 입고센터가 없습니다. <a href="/cupang/centers">입고센터 관리</a>에서 먼저 센터를 추가하세요.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="cpg-dist-list" id="cpg-dist-list">
|
<div class="cpg-inline-form cpg-center-pick">
|
||||||
|
<select class="erp-select" id="cpg-center-pick">
|
||||||
{% for c in centers %}
|
{% for c in centers %}
|
||||||
<div class="cpg-dist-center" data-center-id="{{ c.id }}" data-center-name="{{ c.name }}">
|
<option value="{{ c.id }}">{{ c.name }}</option>
|
||||||
<div class="cpg-dist-head">
|
|
||||||
<strong>{{ c.name }}</strong>
|
|
||||||
<span class="erp-badge erp-badge-neutral cpg-dist-total" data-total-for="{{ c.id }}">0박스</span>
|
|
||||||
</div>
|
|
||||||
<div class="cpg-dist-drop" data-drop-for="{{ c.id }}">
|
|
||||||
<span class="cpg-dist-hint">여기로 박스를 끌어다 놓기</span>
|
|
||||||
<ul class="cpg-dist-items" data-items-for="{{ c.id }}"></ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="erp-btn erp-btn-primary" id="cpg-center-add">+ 센터 추가</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cpg-dist-list" id="cpg-dist-list"></div>
|
||||||
|
<p class="erp-muted" id="cpg-dist-empty">추가한 센터가 여기에 표시됩니다.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /cpg-calc3 -->
|
</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>
|
||||||
|
<label class="erp-field"><span>센터</span>
|
||||||
|
<select class="erp-select" id="cpg-dlg-center"></select></label>
|
||||||
|
<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>
|
||||||
|
|
||||||
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
|
<script type="application/json" id="cpg-calc-rules">{{ box_rules | tojson }}</script>
|
||||||
|
<script type="application/json" id="cpg-centers">{{ centers | tojson }}</script>
|
||||||
<script>
|
<script>
|
||||||
// 박스 계산 3열 화면.
|
// 박스 계산 3열 화면.
|
||||||
// ① 제품명·수량 입력 → 서버(POST /cupang/api/box-calc)에서 계산
|
// ① 제품명·수량 입력 → 서버(POST /cupang/api/box-calc)에서 계산
|
||||||
// ② 계산 결과 요약(제품별 박스 / 자투리 혼합 박스) — 드래그 소스
|
// ② 계산 결과 요약 — 드래그(또는 클릭) 소스
|
||||||
// ③ 센터별 분배 — 드롭 대상. 배분한 수량만큼 ② 의 잔여가 줄어든다.
|
// ③ 선택한 센터에만 분배. 담은 수량만큼 ② 의 잔여가 즉시 줄어든다.
|
||||||
// 분배 내역은 화면 안에서만 유지된다(DB 저장 없음).
|
// 분배 내역은 화면 안에서만 유지된다(DB 저장 없음).
|
||||||
(function () {
|
(function () {
|
||||||
var tbody = document.getElementById("cpg-calc-rows");
|
var tbody = document.getElementById("cpg-calc-rows");
|
||||||
@@ -127,9 +143,23 @@
|
|||||||
var sumBoxes = document.getElementById("cpg-sum-boxes");
|
var sumBoxes = document.getElementById("cpg-sum-boxes");
|
||||||
var sumMixNote = document.getElementById("cpg-sum-mix-note");
|
var sumMixNote = document.getElementById("cpg-sum-mix-note");
|
||||||
var distList = document.getElementById("cpg-dist-list");
|
var distList = document.getElementById("cpg-dist-list");
|
||||||
|
var distEmpty = document.getElementById("cpg-dist-empty");
|
||||||
|
var centerPick = document.getElementById("cpg-center-pick");
|
||||||
|
var centerAdd = document.getElementById("cpg-center-add");
|
||||||
|
var dlg = document.getElementById("cpg-dlg");
|
||||||
|
var dlgItem = document.getElementById("cpg-dlg-item");
|
||||||
|
var dlgCenter = document.getElementById("cpg-dlg-center");
|
||||||
|
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");
|
||||||
|
|
||||||
var rules = [];
|
function readJson(id) {
|
||||||
try { rules = JSON.parse(document.getElementById("cpg-calc-rules").textContent || "[]"); } catch (e) {}
|
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; }
|
function esc(s) { var d = document.createElement("div"); d.textContent = s == null ? "" : s; return d.innerHTML; }
|
||||||
|
|
||||||
@@ -139,10 +169,22 @@
|
|||||||
esc(r.product_name_snapshot || r.product_code) + "</option>";
|
esc(r.product_name_snapshot || r.product_code) + "</option>";
|
||||||
});
|
});
|
||||||
|
|
||||||
// 계산 결과 + 센터 배분 상태
|
// ── 상태 ─────────────────────────────────────────
|
||||||
var calc = { results: [], mixes: [] };
|
var calc = { results: [], mixes: [] }; // 서버 계산 결과
|
||||||
var alloc = {}; // centerId -> [{id, key, kind, label, count}]
|
var units = {}; // key -> 박스 1개당 상품 수
|
||||||
|
var labels = {}; // key -> 표시 이름
|
||||||
|
var openCenters = []; // ③ 에 추가한 센터 id (문자열)
|
||||||
|
var alloc = {}; // centerId -> [{id, key, count}]
|
||||||
var seq = 0;
|
var seq = 0;
|
||||||
|
var pending = null; // 대화상자 대상 {key, centerId}
|
||||||
|
|
||||||
|
function prodKey(code) { return "p:" + code; }
|
||||||
|
function mixKey(boxName, idx) { return "m:" + boxName + "#" + idx; }
|
||||||
|
|
||||||
|
function centerName(cid) {
|
||||||
|
var hit = centers.filter(function (c) { return String(c.id) === String(cid); })[0];
|
||||||
|
return hit ? hit.name : cid;
|
||||||
|
}
|
||||||
|
|
||||||
// ── ① 입력 표 ─────────────────────────────────────
|
// ── ① 입력 표 ─────────────────────────────────────
|
||||||
function addRow(code, qty) {
|
function addRow(code, qty) {
|
||||||
@@ -185,6 +227,20 @@
|
|||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
calc.results = ((data && data.results) || []).filter(function (r) { return r.configured; });
|
calc.results = ((data && data.results) || []).filter(function (r) { return r.configured; });
|
||||||
calc.mixes = (data && data.mixes) || [];
|
calc.mixes = (data && data.mixes) || [];
|
||||||
|
units = {};
|
||||||
|
labels = {};
|
||||||
|
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; });
|
||||||
|
units[mixKey(m.box_name, i)] = n;
|
||||||
|
labels[mixKey(m.box_name, i)] = m.box_name + " 혼합 #" + (i + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
var had = Object.keys(alloc).some(function (k) { return alloc[k].length; });
|
var had = Object.keys(alloc).some(function (k) { return alloc[k].length; });
|
||||||
alloc = {}; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
|
alloc = {}; // 계산이 바뀌면 이전 배분은 근거가 사라지므로 비운다.
|
||||||
render();
|
render();
|
||||||
@@ -203,9 +259,6 @@
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mixKey(boxName, idx) { return "m:" + boxName + "#" + idx; }
|
|
||||||
function prodKey(code) { return "p:" + code; }
|
|
||||||
|
|
||||||
function totalFor(key) {
|
function totalFor(key) {
|
||||||
if (key.indexOf("p:") === 0) {
|
if (key.indexOf("p:") === 0) {
|
||||||
var code = key.slice(2);
|
var code = key.slice(2);
|
||||||
@@ -217,6 +270,18 @@
|
|||||||
|
|
||||||
function remainFor(key) { return Math.max(0, totalFor(key) - allocatedFor(key)); }
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ── ② 요약 렌더 ───────────────────────────────────
|
// ── ② 요약 렌더 ───────────────────────────────────
|
||||||
function boxSvg(fill, label) {
|
function boxSvg(fill, label) {
|
||||||
var f = Math.max(0, Math.min(100, fill || 0));
|
var f = Math.max(0, Math.min(100, fill || 0));
|
||||||
@@ -274,14 +339,15 @@
|
|||||||
var used = r.full_boxes - remain;
|
var used = r.full_boxes - remain;
|
||||||
ph += '<div class="cpg-sum-prod' + (remain ? "" : " is-done") + '"' +
|
ph += '<div class="cpg-sum-prod' + (remain ? "" : " is-done") + '"' +
|
||||||
(remain ? ' draggable="true"' : "") +
|
(remain ? ' draggable="true"' : "") +
|
||||||
' data-key="' + esc(key) + '" data-kind="product" data-label="' + esc(r.product_name) + '">' +
|
' data-key="' + esc(key) + '">' +
|
||||||
'<div class="cpg-sum-prod-head">' +
|
'<div class="cpg-sum-prod-head">' +
|
||||||
'<span class="cpg-sum-prod-name">' + esc(r.product_name) + "</span>" +
|
'<span class="cpg-sum-prod-name">' + esc(r.product_name) + "</span>" +
|
||||||
'<span class="erp-badge erp-badge-neutral">' + esc(r.box_name) + "</span>" +
|
'<span class="erp-badge erp-badge-neutral">' + esc(r.box_name) + "</span>" +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
'<div class="cpg-sum-prod-nums">' +
|
'<div class="cpg-sum-prod-nums">' +
|
||||||
"<strong>" + remain + "박스</strong>" +
|
"<strong>" + remain + "박스</strong>" +
|
||||||
'<span class="erp-muted">총 ' + r.full_boxes + "박스" + (used ? " · 배분 " + used : "") + "</span>" +
|
'<span class="erp-muted">' + (remain * r.units_per_box) + "개 · 총 " + r.full_boxes + "박스" +
|
||||||
|
(used ? " · 배분 " + used : "") + "</span>" +
|
||||||
(r.remainder_units
|
(r.remainder_units
|
||||||
? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>"
|
? '<span class="cpg-sum-left">자투리 ' + r.remainder_units + "개</span>"
|
||||||
: '<span class="cpg-sum-exact">딱 맞음</span>') +
|
: '<span class="cpg-sum-exact">딱 맞음</span>') +
|
||||||
@@ -305,8 +371,7 @@
|
|||||||
}).join("");
|
}).join("");
|
||||||
bh += '<div class="cpg-box-card' + (remain ? "" : " is-done") + '"' +
|
bh += '<div class="cpg-box-card' + (remain ? "" : " is-done") + '"' +
|
||||||
(remain ? ' draggable="true"' : "") +
|
(remain ? ' draggable="true"' : "") +
|
||||||
' data-key="' + esc(key) + '" data-kind="mix"' +
|
' data-key="' + esc(key) + '"' +
|
||||||
' data-label="' + esc(m.box_name + " 혼합 #" + (i + 1)) + '"' +
|
|
||||||
' style="animation-delay:' + (n * 60) + 'ms">' +
|
' style="animation-delay:' + (n * 60) + 'ms">' +
|
||||||
'<div class="cpg-box-art">' + boxSvg(b.fill_percent, m.box_name) + "</div>" +
|
'<div class="cpg-box-art">' + boxSvg(b.fill_percent, m.box_name) + "</div>" +
|
||||||
'<div class="cpg-box-info">' +
|
'<div class="cpg-box-info">' +
|
||||||
@@ -314,7 +379,7 @@
|
|||||||
(remain ? "" : ' <span class="erp-badge erp-badge-success">배분됨</span>') + "</div>" +
|
(remain ? "" : ' <span class="erp-badge erp-badge-success">배분됨</span>') + "</div>" +
|
||||||
'<ul class="cpg-box-items">' + items + "</ul>" +
|
'<ul class="cpg-box-items">' + items + "</ul>" +
|
||||||
'<div class="cpg-box-bar"><span style="width:' + b.fill_percent + '%"></span></div>' +
|
'<div class="cpg-box-bar"><span style="width:' + b.fill_percent + '%"></span></div>' +
|
||||||
'<div class="cpg-box-fillnum">' + b.fill_percent + "% 채움</div>" +
|
'<div class="cpg-box-fillnum">' + b.fill_percent + "% 채움 · " + units[key] + "개</div>" +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
"</div>";
|
"</div>";
|
||||||
n += 1;
|
n += 1;
|
||||||
@@ -327,41 +392,94 @@
|
|||||||
// ── ③ 센터 분배 렌더 ──────────────────────────────
|
// ── ③ 센터 분배 렌더 ──────────────────────────────
|
||||||
function renderDist() {
|
function renderDist() {
|
||||||
if (!distList) return;
|
if (!distList) return;
|
||||||
Array.prototype.forEach.call(distList.querySelectorAll(".cpg-dist-center"), function (box) {
|
if (distEmpty) distEmpty.hidden = openCenters.length > 0;
|
||||||
var cid = box.getAttribute("data-center-id");
|
|
||||||
var list = box.querySelector("[data-items-for]");
|
|
||||||
var totalEl = box.querySelector("[data-total-for]");
|
|
||||||
var rows = alloc[cid] || [];
|
|
||||||
var total = 0;
|
|
||||||
var html = "";
|
var html = "";
|
||||||
|
openCenters.forEach(function (cid) {
|
||||||
|
var rows = alloc[cid] || [];
|
||||||
|
var boxes = 0, pieces = 0, items = "";
|
||||||
rows.forEach(function (a) {
|
rows.forEach(function (a) {
|
||||||
total += a.count;
|
var per = units[a.key] || 0;
|
||||||
var max = a.count + remainFor(a.key);
|
boxes += a.count;
|
||||||
html += '<li class="cpg-dist-item" data-entry="' + a.id + '">' +
|
pieces += a.count * per;
|
||||||
'<span class="cpg-dist-name">' + esc(a.label) + "</span>" +
|
items += '<li class="cpg-dist-item" data-entry="' + a.id + '">' +
|
||||||
'<input class="erp-input cpg-dist-qty" type="number" min="1" max="' + max + '" value="' + a.count + '" />' +
|
'<span class="cpg-dist-name">' + esc(labels[a.key] || a.key) + "</span>" +
|
||||||
'<span class="cpg-dist-unit">박스</span>' +
|
'<input class="erp-input cpg-dist-qty" type="number" min="1" step="1" 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>' +
|
'<button type="button" class="erp-btn erp-btn-danger cpg-dist-del" title="빼기" aria-label="빼기">✕</button>' +
|
||||||
"</li>";
|
"</li>";
|
||||||
});
|
});
|
||||||
list.innerHTML = html;
|
html += '<div class="cpg-dist-center' + (rows.length ? " has-items" : "") + '" data-drop-for="' + esc(cid) + '">' +
|
||||||
box.classList.toggle("has-items", rows.length > 0);
|
'<div class="cpg-dist-head">' +
|
||||||
totalEl.textContent = total + "박스";
|
"<strong>" + esc(centerName(cid)) + "</strong>" +
|
||||||
|
'<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>') +
|
||||||
|
"</div>";
|
||||||
});
|
});
|
||||||
|
distList.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() { renderSummary(); renderDist(); }
|
function render() { renderSummary(); renderDist(); }
|
||||||
|
|
||||||
// ── 드래그 앤 드롭 ────────────────────────────────
|
// ── 수량 대화상자 ─────────────────────────────────
|
||||||
|
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)) + "개)";
|
||||||
|
dlgCenter.innerHTML = openCenters.map(function (cid) {
|
||||||
|
return '<option value="' + esc(cid) + '"' + (String(cid) === String(centerId) ? " selected" : "") + ">" +
|
||||||
|
esc(centerName(cid)) + "</option>";
|
||||||
|
}).join("");
|
||||||
|
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 closeDialog() { dlg.hidden = true; pending = null; }
|
||||||
|
|
||||||
|
function commitDialog(all) {
|
||||||
|
if (!pending) return;
|
||||||
|
var key = pending.key;
|
||||||
|
var cid = dlgCenter.value;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 드래그 앤 드롭 (+ 클릭 대체 경로) ─────────────
|
||||||
sumBody.addEventListener("dragstart", function (e) {
|
sumBody.addEventListener("dragstart", function (e) {
|
||||||
var card = e.target.closest("[data-key]");
|
var card = e.target.closest("[data-key]");
|
||||||
if (!card || card.getAttribute("draggable") !== "true") return;
|
if (!card || card.getAttribute("draggable") !== "true") return;
|
||||||
var payload = {
|
e.dataTransfer.setData("text/plain", card.getAttribute("data-key"));
|
||||||
key: card.getAttribute("data-key"),
|
|
||||||
kind: card.getAttribute("data-kind"),
|
|
||||||
label: card.getAttribute("data-label")
|
|
||||||
};
|
|
||||||
e.dataTransfer.setData("text/plain", JSON.stringify(payload));
|
|
||||||
e.dataTransfer.effectAllowed = "copy";
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
card.classList.add("is-dragging");
|
card.classList.add("is-dragging");
|
||||||
});
|
});
|
||||||
@@ -369,8 +487,14 @@
|
|||||||
var card = e.target.closest("[data-key]");
|
var card = e.target.closest("[data-key]");
|
||||||
if (card) card.classList.remove("is-dragging");
|
if (card) card.classList.remove("is-dragging");
|
||||||
});
|
});
|
||||||
|
sumBody.addEventListener("click", function (e) {
|
||||||
|
var card = e.target.closest("[data-key]");
|
||||||
|
if (!card) return;
|
||||||
|
openDialog(card.getAttribute("data-key"), openCenters[0]);
|
||||||
|
});
|
||||||
|
|
||||||
if (distList) {
|
if (distList) {
|
||||||
|
// 드롭 대상은 센터 패널 전체 — 여백에 놓아도 인식된다.
|
||||||
distList.addEventListener("dragover", function (e) {
|
distList.addEventListener("dragover", function (e) {
|
||||||
var zone = e.target.closest("[data-drop-for]");
|
var zone = e.target.closest("[data-drop-for]");
|
||||||
if (!zone) return;
|
if (!zone) return;
|
||||||
@@ -387,27 +511,15 @@
|
|||||||
if (!zone) return;
|
if (!zone) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
zone.classList.remove("is-over");
|
zone.classList.remove("is-over");
|
||||||
var data;
|
var key = e.dataTransfer.getData("text/plain");
|
||||||
try { data = JSON.parse(e.dataTransfer.getData("text/plain") || "{}"); } catch (err) { return; }
|
if (!key) return;
|
||||||
if (!data.key) return;
|
openDialog(key, zone.getAttribute("data-drop-for"));
|
||||||
if (remainFor(data.key) <= 0) { msg.textContent = "남은 박스가 없습니다."; return; }
|
|
||||||
|
|
||||||
var cid = zone.getAttribute("data-drop-for");
|
|
||||||
alloc[cid] = alloc[cid] || [];
|
|
||||||
var same = alloc[cid].filter(function (a) { return a.key === data.key; })[0];
|
|
||||||
if (same) { same.count += 1; }
|
|
||||||
else {
|
|
||||||
seq += 1;
|
|
||||||
alloc[cid].push({ id: seq, key: data.key, kind: data.kind, label: data.label, count: 1 });
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
distList.addEventListener("input", function (e) {
|
distList.addEventListener("input", function (e) {
|
||||||
if (!e.target.classList.contains("cpg-dist-qty")) return;
|
if (!e.target.classList.contains("cpg-dist-qty")) return;
|
||||||
var li = e.target.closest(".cpg-dist-item");
|
var li = e.target.closest(".cpg-dist-item");
|
||||||
var centerEl = e.target.closest(".cpg-dist-center");
|
var cid = e.target.closest("[data-drop-for]").getAttribute("data-drop-for");
|
||||||
var cid = centerEl.getAttribute("data-center-id");
|
|
||||||
var entry = (alloc[cid] || []).filter(function (a) { return String(a.id) === li.getAttribute("data-entry"); })[0];
|
var entry = (alloc[cid] || []).filter(function (a) { return String(a.id) === li.getAttribute("data-entry"); })[0];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
var want = parseInt(e.target.value, 10);
|
var want = parseInt(e.target.value, 10);
|
||||||
@@ -415,19 +527,49 @@
|
|||||||
var max = entry.count + remainFor(entry.key);
|
var max = entry.count + remainFor(entry.key);
|
||||||
if (want > max) { want = max; e.target.value = max; }
|
if (want > max) { want = max; e.target.value = max; }
|
||||||
entry.count = want;
|
entry.count = want;
|
||||||
// 입력 중에는 목록을 다시 그리지 않는다(포커스 유지) — 합계와 요약만 갱신.
|
li.querySelector(".cpg-dist-unit").textContent = "박스 · " + (want * (units[entry.key] || 0)) + "개";
|
||||||
var sum = 0;
|
|
||||||
(alloc[cid] || []).forEach(function (a) { sum += a.count; });
|
|
||||||
centerEl.querySelector("[data-total-for]").textContent = sum + "박스";
|
|
||||||
renderSummary();
|
renderSummary();
|
||||||
|
updateCenterTotals(cid);
|
||||||
});
|
});
|
||||||
|
|
||||||
distList.addEventListener("click", function (e) {
|
distList.addEventListener("click", function (e) {
|
||||||
if (!e.target.closest(".cpg-dist-del")) return;
|
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];
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest(".cpg-dist-del")) { // 담은 항목 제거 → 잔여로 복귀
|
||||||
var li = e.target.closest(".cpg-dist-item");
|
var li = e.target.closest(".cpg-dist-item");
|
||||||
var cid = e.target.closest(".cpg-dist-center").getAttribute("data-center-id");
|
|
||||||
alloc[cid] = (alloc[cid] || []).filter(function (a) { return String(a.id) !== li.getAttribute("data-entry"); });
|
alloc[cid] = (alloc[cid] || []).filter(function (a) { return String(a.id) !== li.getAttribute("data-entry"); });
|
||||||
render();
|
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 (centerAdd) {
|
||||||
|
centerAdd.addEventListener("click", function () {
|
||||||
|
var cid = centerPick.value;
|
||||||
|
if (!cid) return;
|
||||||
|
if (openCenters.indexOf(cid) >= 0) { msg.textContent = "이미 추가된 센터입니다."; return; }
|
||||||
|
openCenters.push(cid);
|
||||||
|
renderDist();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829c" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829c" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829c" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829c" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829c" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "erp_base.html" %}
|
{% extends "erp_base.html" %}
|
||||||
|
|
||||||
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829b" />{% endblock %}
|
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260829c" />{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="cpg">
|
<section class="cpg">
|
||||||
|
|||||||
+36
-10
@@ -625,19 +625,25 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.cpg-dist-drop {
|
/* 드롭 대상은 센터 패널 전체 */
|
||||||
border: 1.5px dashed var(--color-subtle-ash);
|
.cpg-dist-center { transition: background .15s ease, border-color .15s ease; }
|
||||||
border-radius: 8px;
|
.cpg-dist-center.is-over {
|
||||||
padding: 8px;
|
|
||||||
min-height: 52px;
|
|
||||||
transition: background .15s ease, border-color .15s ease;
|
|
||||||
}
|
|
||||||
.cpg-dist-drop.is-over {
|
|
||||||
border-color: var(--color-rich-black);
|
border-color: var(--color-rich-black);
|
||||||
background: var(--color-ghost-gray);
|
background: var(--color-ghost-gray);
|
||||||
}
|
}
|
||||||
.cpg-dist-hint { font-size: 12px; color: var(--color-midtone-gray); }
|
.cpg-dist-hint {
|
||||||
.cpg-dist-center.has-items .cpg-dist-hint { display: none; }
|
margin: 0;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border: 1.5px dashed var(--color-subtle-ash);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-midtone-gray);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cpg-dist-sum { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.cpg-dist-close { padding: 0 6px; line-height: 1.5; }
|
||||||
|
.cpg-center-pick { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||||
|
.cpg-center-pick .erp-select { flex: 1 1 auto; min-width: 0; }
|
||||||
.cpg-dist-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
.cpg-dist-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
.cpg-dist-item {
|
.cpg-dist-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -649,3 +655,23 @@
|
|||||||
.cpg-dist-qty { width: 62px; min-width: 0; text-align: right; padding: 2px 6px; }
|
.cpg-dist-qty { width: 62px; min-width: 0; text-align: right; padding: 2px 6px; }
|
||||||
.cpg-dist-unit { font-size: 12px; color: var(--color-midtone-gray); }
|
.cpg-dist-unit { font-size: 12px; color: var(--color-midtone-gray); }
|
||||||
.cpg-dist-del { padding: 2px 8px; line-height: 1.4; }
|
.cpg-dist-del { padding: 2px 8px; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* 박스 수량 입력 대화상자 */
|
||||||
|
.cpg-modal { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.cpg-modal[hidden] { display: none; }
|
||||||
|
.cpg-modal-back { position: absolute; inset: 0; background: rgba(10, 10, 10, .45); }
|
||||||
|
.cpg-modal-box {
|
||||||
|
position: relative;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
background: var(--color-canvas-white);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 18px 48px rgba(0, 0, 0, .28);
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.cpg-modal-box h3 { margin: 0; font-size: 17px; }
|
||||||
|
.cpg-dlg-item { margin: 0; font-weight: 600; }
|
||||||
|
.cpg-dlg-actions { margin: 4px 0 0; flex-wrap: wrap; }
|
||||||
|
.cpg-modal-box .erp-input, .cpg-modal-box .erp-select { width: 100%; min-width: 0; }
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 박스 계산 화면 UI 테스트 (jsdom)
|
||||||
|
|
||||||
|
`app/modules/cupang/templates/cupang/box_calc.html` 의 드래그 분배·수량 대화상자·
|
||||||
|
잔여 계산을 브라우저 없이 검증한다. 서버 API 응답은 고정값으로 대체한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 템플릿을 HTML 로 렌더 (프로젝트 루트에서)
|
||||||
|
python tests/js/render_box_calc.py # → tests/js/.out/box_calc_render.html
|
||||||
|
|
||||||
|
# 2) jsdom 설치 후 실행
|
||||||
|
npm install jsdom
|
||||||
|
node tests/js/box_calc_ui.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
성공하면 마지막 줄에 `ALL PASS`, 실패하면 실패한 항목명이 출력되고 종료코드 1.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const { JSDOM } = require("jsdom");
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
const RENDER = path.join(__dirname, ".out", "box_calc_render.html");
|
||||||
|
const html = fs.readFileSync(RENDER, "utf8"); // 먼저 render_box_calc.py 실행
|
||||||
|
|
||||||
|
// 서버 응답 고정: 미라네 1호 18개(2박스+자투리2), 미라네 4호 12개(1박스+자투리4)
|
||||||
|
const apiResponse = {
|
||||||
|
results: [
|
||||||
|
{product_code:"MS-1001", product_name:"미라네 1호 세트", box_name:"쿠팡박스", units_per_box:8,
|
||||||
|
quantity:18, configured:true, full_boxes:2, remainder_units:2, required_boxes:3},
|
||||||
|
{product_code:"MS-1004", product_name:"미라네 4호 세트", box_name:"쿠팡박스", units_per_box:8,
|
||||||
|
quantity:12, configured:true, full_boxes:1, remainder_units:4, required_boxes:2},
|
||||||
|
],
|
||||||
|
totals: [],
|
||||||
|
mixes: [{box_name:"쿠팡박스", box_count:1, leftover_units:6, boxes:[
|
||||||
|
{items:[{product_code:"MS-1004",product_name:"미라네 4호 세트",quantity:4},
|
||||||
|
{product_code:"MS-1001",product_name:"미라네 1호 세트",quantity:2}], fill_percent:75.0}]}],
|
||||||
|
grand_total_boxes: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dom = new JSDOM(html, { runScripts: "dangerously", pretendToBeVisual: true });
|
||||||
|
const w = dom.window, d = w.document;
|
||||||
|
w.fetch = () => Promise.resolve({ ok: true, json: () => Promise.resolve(apiResponse) });
|
||||||
|
|
||||||
|
const fails = [];
|
||||||
|
function check(name, cond, extra) {
|
||||||
|
if (cond) console.log("PASS " + name);
|
||||||
|
else { console.log("FAIL " + name + (extra ? " → " + extra : "")); fails.push(name); }
|
||||||
|
}
|
||||||
|
const $ = (s) => d.querySelector(s);
|
||||||
|
const $$ = (s) => Array.from(d.querySelectorAll(s));
|
||||||
|
const text = (s) => ($(s) ? $(s).textContent.replace(/\s+/g, " ").trim() : "<none>");
|
||||||
|
|
||||||
|
function fire(el, type, init) { el.dispatchEvent(new w.Event(type, Object.assign({bubbles:true}, init))); }
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// ── ① 입력 → 계산 ──────────────────────────────────
|
||||||
|
const row1 = $(".cpg-calc-row");
|
||||||
|
row1.querySelector(".cpg-calc-name").value = "MS-1001";
|
||||||
|
row1.querySelector(".cpg-calc-qty").value = "18";
|
||||||
|
|
||||||
|
// Tab 으로 다음 행 생성 + 포커스 이동
|
||||||
|
const ke = new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
|
||||||
|
row1.querySelector(".cpg-calc-qty").dispatchEvent(ke);
|
||||||
|
check("Tab 이 새 행을 만든다", $$(".cpg-calc-row").length === 2, "행수=" + $$(".cpg-calc-row").length);
|
||||||
|
check("Tab 후 포커스가 새 행 수량", d.activeElement === $$(".cpg-calc-row")[1].querySelector(".cpg-calc-qty"));
|
||||||
|
|
||||||
|
const row2 = $$(".cpg-calc-row")[1];
|
||||||
|
row2.querySelector(".cpg-calc-name").value = "MS-1004";
|
||||||
|
row2.querySelector(".cpg-calc-qty").value = "12";
|
||||||
|
|
||||||
|
$("#cpg-calc-run").click();
|
||||||
|
await new Promise(r => setTimeout(r, 20));
|
||||||
|
|
||||||
|
check("요약이 표시된다", $("#cpg-sum-body").hidden === false);
|
||||||
|
check("제품 카드 2장", $$(".cpg-sum-prod").length === 2, String($$(".cpg-sum-prod").length));
|
||||||
|
check("혼합 박스 1장", $$(".cpg-box-card").length === 1);
|
||||||
|
check("제품별 잔여 = 2박스(16개)", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("2박스16개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
check("KPI 총 박스 4", text(".cpg-kpi .cpg-kpi-value") === "4박스", text(".cpg-kpi .cpg-kpi-value"));
|
||||||
|
|
||||||
|
// ── ③ 센터는 추가한 것만 표시 ─────────────────────
|
||||||
|
check("초기 센터 패널 없음", $$(".cpg-dist-center").length === 0);
|
||||||
|
$("#cpg-center-pick").value = "1";
|
||||||
|
$("#cpg-center-add").click();
|
||||||
|
check("센터 추가 후 1개 표시", $$(".cpg-dist-center").length === 1, String($$(".cpg-dist-center").length));
|
||||||
|
check("추가한 센터 이름", text(".cpg-dist-center strong") === "대구3", text(".cpg-dist-center strong"));
|
||||||
|
|
||||||
|
// ── 드래그 → 드롭 → 대화상자 ──────────────────────
|
||||||
|
const card = $(".cpg-sum-prod[draggable='true']");
|
||||||
|
const dt = { data: {}, setData(t, v) { this.data[t] = v; }, getData(t) { return this.data[t]; } };
|
||||||
|
const ds = new w.Event("dragstart", { bubbles: true }); ds.dataTransfer = dt;
|
||||||
|
card.dispatchEvent(ds);
|
||||||
|
check("dragstart 가 key 를 싣는다", dt.getData("text/plain") === "p:MS-1001", dt.getData("text/plain"));
|
||||||
|
|
||||||
|
const zone = $(".cpg-dist-center");
|
||||||
|
const dov = new w.Event("dragover", { bubbles: true, cancelable: true }); dov.dataTransfer = dt;
|
||||||
|
zone.dispatchEvent(dov);
|
||||||
|
check("dragover 가 기본동작을 막는다(드롭 허용)", dov.defaultPrevented);
|
||||||
|
|
||||||
|
const drop = new w.Event("drop", { bubbles: true, cancelable: true }); drop.dataTransfer = dt;
|
||||||
|
zone.dispatchEvent(drop);
|
||||||
|
check("드롭하면 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||||
|
check("대화상자에 잔여/개수 표시", text("#cpg-dlg-item").includes("잔여 2박스 (16개)"), text("#cpg-dlg-item"));
|
||||||
|
check("대화상자 센터 = 드롭한 센터", $("#cpg-dlg-center").value === "1");
|
||||||
|
|
||||||
|
// 1박스만 담기
|
||||||
|
$("#cpg-dlg-qty").value = "1";
|
||||||
|
$("#cpg-dlg-ok").click();
|
||||||
|
check("대화상자가 닫힌다", $("#cpg-dlg").hidden === true);
|
||||||
|
check("센터에 항목 1줄", $$(".cpg-dist-item").length === 1);
|
||||||
|
check("센터 항목에 박스·개수 표시", text(".cpg-dist-item .cpg-dist-unit") === "박스 · 8개", text(".cpg-dist-item .cpg-dist-unit"));
|
||||||
|
check("센터 합계 배지 1박스/8개", text(".cpg-dist-sum").startsWith("1박스 8개✕") || text(".cpg-dist-sum").startsWith("1박스8개"), text(".cpg-dist-sum"));
|
||||||
|
check("요약 잔여가 1박스로 줄어든다", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("1박스8개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
check("KPI 센터 배분 1박스", $$(".cpg-kpi-value")[1].textContent === "1박스", $$(".cpg-kpi-value")[1].textContent);
|
||||||
|
|
||||||
|
// ── 잔여 전부 담기 ────────────────────────────────
|
||||||
|
$$(".cpg-sum-prod")[0].click(); // 클릭 대체 경로
|
||||||
|
check("클릭으로도 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||||
|
$("#cpg-dlg-all").click();
|
||||||
|
check("전량 담기 후 잔여 0", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("0박스0개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
check("잔여 0 카드는 드래그 불가", $$(".cpg-sum-prod")[0].getAttribute("draggable") === null);
|
||||||
|
check("센터 합계 2박스/16개", text(".cpg-dist-sum").replace(/\s/g,"").startsWith("2박스16개"), text(".cpg-dist-sum"));
|
||||||
|
|
||||||
|
// ── 수량 직접 수정 (상한 = 잔여+자기수량) ─────────
|
||||||
|
const qty = $(".cpg-dist-item .cpg-dist-qty");
|
||||||
|
qty.value = "5"; fire(qty, "input");
|
||||||
|
check("잔여 초과 입력은 최대치로 잘림", qty.value === "2", qty.value);
|
||||||
|
|
||||||
|
qty.value = "1"; fire(qty, "input");
|
||||||
|
check("수량 줄이면 개수 표시 갱신", text(".cpg-dist-item .cpg-dist-unit") === "박스 · 8개", text(".cpg-dist-item .cpg-dist-unit"));
|
||||||
|
check("수량 줄이면 요약 잔여 복귀", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("1박스8개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
|
||||||
|
// ── 혼합 박스 담기 ────────────────────────────────
|
||||||
|
const mix = $(".cpg-box-card[draggable='true']");
|
||||||
|
const dt2 = { data:{}, setData(t,v){this.data[t]=v;}, getData(t){return this.data[t];} };
|
||||||
|
const ds2 = new w.Event("dragstart", { bubbles: true }); ds2.dataTransfer = dt2;
|
||||||
|
mix.dispatchEvent(ds2);
|
||||||
|
const drop2 = new w.Event("drop", { bubbles: true, cancelable: true }); drop2.dataTransfer = dt2;
|
||||||
|
$(".cpg-dist-center").dispatchEvent(drop2);
|
||||||
|
check("혼합 박스도 대화상자가 열린다", $("#cpg-dlg").hidden === false);
|
||||||
|
check("혼합 박스 잔여 1박스(6개)", text("#cpg-dlg-item").includes("잔여 1박스 (6개)"), text("#cpg-dlg-item"));
|
||||||
|
$("#cpg-dlg-ok").click();
|
||||||
|
check("혼합 박스가 센터에 담긴다", $$(".cpg-dist-item").length === 2);
|
||||||
|
check("혼합 담은 뒤 배분됨 표시", $(".cpg-box-card").className.includes("is-done"));
|
||||||
|
|
||||||
|
// ── 항목 빼기 / 센터 빼기 ─────────────────────────
|
||||||
|
$$(".cpg-dist-del")[1].click();
|
||||||
|
check("항목 빼면 줄이 사라진다", $$(".cpg-dist-item").length === 1);
|
||||||
|
check("항목 빼면 혼합 박스 복귀", !$(".cpg-box-card").className.includes("is-done"));
|
||||||
|
|
||||||
|
$(".cpg-dist-close").click();
|
||||||
|
check("센터 빼기", $$(".cpg-dist-center").length === 0);
|
||||||
|
check("센터 빼면 전량 잔여 복귀", text(".cpg-sum-prod .cpg-sum-prod-nums").startsWith("2박스16개"), text(".cpg-sum-prod .cpg-sum-prod-nums"));
|
||||||
|
|
||||||
|
console.log("\n" + (fails.length ? "FAILED: " + fails.join(" | ") : "ALL PASS"));
|
||||||
|
process.exit(fails.length ? 1 : 0);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""box_calc.html 을 테스트용 HTML 로 렌더한다 (erp_base.html 은 최소 스텁으로 대체)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
OUT_DIR = os.path.join(ROOT, "tests", "js", ".out")
|
||||||
|
|
||||||
|
BASE_STUB = "{% block head_extra %}{% endblock %}<body>{% block content %}{% endblock %}</body>"
|
||||||
|
|
||||||
|
BOX_RULES = [
|
||||||
|
{"product_code": "MS-1001", "product_name_snapshot": "미라네 1호 세트",
|
||||||
|
"box_name": "쿠팡박스", "units_per_box": 8},
|
||||||
|
{"product_code": "MS-1004", "product_name_snapshot": "미라네 4호 세트",
|
||||||
|
"box_name": "쿠팡박스", "units_per_box": 8},
|
||||||
|
]
|
||||||
|
CENTERS = [{"id": 1, "name": "대구3"}, {"id": 2, "name": "인천32"}]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
env = jinja2.Environment(
|
||||||
|
loader=jinja2.ChoiceLoader([
|
||||||
|
jinja2.DictLoader({"erp_base.html": BASE_STUB}),
|
||||||
|
jinja2.FileSystemLoader(os.path.join(ROOT, "app", "modules", "cupang", "templates")),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
html = env.get_template("cupang/box_calc.html").render(box_rules=BOX_RULES, centers=CENTERS)
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
path = os.path.join(OUT_DIR, "box_calc_render.html")
|
||||||
|
io.open(path, "w", encoding="utf-8", newline="\n").write(html)
|
||||||
|
print("rendered:", path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user