feat(cupang): 발주 업로드 결과를 출고일별로 분리

- 업로드 API가 (출고일, 센터, 제품)으로 합산해 groups[] 로 반환
- 화면 상단에 출고일 탭 — 날짜마다 ①직접입력 / ②박스요약 / ③센터분배 상태를
  따로 보관하고 탭으로 전환 (박스 계산은 날짜 × 센터 단위로 실행)
- 확정은 현재 날짜만 대상. 남은 날짜가 있으면 달력으로 나가지 않고 다음 탭으로 이동
- 업로드 결과 카드에 출고일별 센터 수·수량 요약 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 13:49:21 +09:00
parent a6d7de709d
commit 5217c4b6bb
9 changed files with 270 additions and 70 deletions
+30 -12
View File
@@ -769,8 +769,8 @@ async def box_calc_upload(
warnings: list[str] = []
file_infos: list[dict[str, Any]] = []
ship_dates: list[str] = []
# (센터명, 제품코드) → 수량
agg: dict[tuple[str, str], dict[str, Any]] = {}
# (출고일, 센터명, 제품코드) → 수량
agg: dict[tuple[str, str, str], dict[str, Any]] = {}
unknown_codes: set[str] = set()
unknown_centers: set[str] = set()
no_rule: set[str] = set()
@@ -793,6 +793,7 @@ async def box_calc_upload(
else:
warnings.append(f"{name}: F13 입고예정일을 읽지 못했습니다.")
ship_iso = ship.isoformat() if ship else ""
used = 0
for row in parsed["rows"]:
prod = by_coupang.get(row["coupang_item_code"])
@@ -805,10 +806,11 @@ async def box_calc_upload(
code = prod["product_code"]
if code not in rules:
no_rule.add(f'{prod["product_name"]}({code})')
key = (cname, code)
key = (ship_iso, cname, code)
cell = agg.setdefault(
key,
{
"ship_date": ship_iso,
"center_name": cname,
"product_code": code,
"product_name": prod["product_name"],
@@ -823,7 +825,7 @@ async def box_calc_upload(
{
"filename": name,
"arrival_date": arrival.isoformat() if arrival else "",
"ship_date": ship.isoformat() if ship else "",
"ship_date": ship_iso,
"rows": len(parsed["rows"]),
"quantity": used,
}
@@ -841,12 +843,12 @@ async def box_calc_upload(
warnings.append("박스 입수량 미설정: " + ", ".join(sorted(no_rule)))
uniq_dates = sorted(set(ship_dates))
if len(uniq_dates) > 1:
warnings.append("파일마다 출고일이 다릅니다: " + ", ".join(uniq_dates))
grouped: dict[str, dict[str, Any]] = {}
for (cname, _code), cell in agg.items():
g = grouped.setdefault(
# 출고일 → 센터 → 품목. 업로드한 파일의 출고일이 다르면 날짜별로 나뉜다.
by_date: dict[str, dict[str, dict[str, Any]]] = {}
for (ship_iso, cname, _code), cell in agg.items():
centers_of_date = by_date.setdefault(ship_iso, {})
g = centers_of_date.setdefault(
cname,
{
"center_name": cname,
@@ -862,8 +864,24 @@ async def box_calc_upload(
"quantity": cell["quantity"],
}
)
for g in grouped.values():
g["items"].sort(key=lambda it: it["product_code"])
groups: list[dict[str, Any]] = []
for ship_iso in sorted(by_date):
centers_of_date = by_date[ship_iso]
for g in centers_of_date.values():
g["items"].sort(key=lambda it: it["product_code"])
groups.append(
{
"ship_date": ship_iso,
"centers": sorted(centers_of_date.values(), key=lambda g: g["center_name"]),
"files": [f for f in file_infos if f["ship_date"] == ship_iso],
"quantity": sum(
it["quantity"]
for g in centers_of_date.values()
for it in g["items"]
),
}
)
return JSONResponse(
{
@@ -871,7 +889,7 @@ async def box_calc_upload(
"ship_date": uniq_dates[0] if uniq_dates else "",
"ship_dates": uniq_dates,
"files": file_infos,
"centers": sorted(grouped.values(), key=lambda g: g["center_name"]),
"groups": groups,
"warnings": warnings,
}
)
+200 -52
View File
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901j" />{% endblock %}
{% block content %}
<section class="cpg">
@@ -22,6 +22,12 @@
</div>
{% else %}
<!-- 업로드한 발주서의 출고일이 여러 개면 날짜별로 작업 영역을 나눈다 -->
<div class="cpg-date-tabs" id="cpg-date-tabs" hidden>
<span class="cpg-date-tabs-label">출고일</span>
<div class="cpg-date-tabs-list" id="cpg-date-tabs-list"></div>
</div>
<div class="cpg-calc3">
<!-- ① 박스 계산 — 제품명 + 수량만 입력 -->
@@ -359,16 +365,116 @@
});
}
// ── 출고일별 작업 세트 ────────────────────────────
// 업로드한 발주서의 출고일이 다르면 날짜마다 ①②③ 상태를 따로 들고,
// 탭으로 전환한다. 확정은 현재 보고 있는 날짜만 대상으로 한다.
var sets = {}; // "YYYY-MM-DD" -> 상태 스냅샷
var dateOrder = []; // 탭 순서
var activeDate = ""; // "" = 업로드 전(수동 입력 모드)
var tabsBar = document.getElementById("cpg-date-tabs");
var tabsList = document.getElementById("cpg-date-tabs-list");
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;
}
function renderTabs() {
if (!tabsBar) return;
if (dateOrder.length < 1) { tabsBar.hidden = true; tabsList.innerHTML = ""; return; }
tabsBar.hidden = false;
tabsList.innerHTML = dateOrder.map(function (d) {
var st = sets[d] || {};
var cn = centersOf(st.alloc).length;
var bx = boxesOf(st.alloc);
return '<button type="button" class="cpg-date-tab' + (d === activeDate ? " is-active" : "") + '"' +
' data-date="' + esc(d) + '">' +
'<strong>' + esc(d || "날짜 미확인") + "</strong>" +
'<span>센터 ' + cn + "곳 · " + bx + "박스</span>" +
"</button>";
}).join("");
}
// 현재 화면 상태를 활성 날짜 세트에 저장
function saveActive() {
if (!activeDate) return;
sets[activeDate] = {
items: collect(),
calc: { results: calc.results, mixes: calc.mixes },
units: units, labels: labels, contents: contents,
openCenters: openCenters.slice(), alloc: alloc, methods: methods,
stamped: stamped, allStamped: allStamped
};
}
// 날짜 세트를 화면(①②③)에 올린다
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;
tbody.innerHTML = "";
(st.items || []).forEach(function (it) { addRow(it.product_code, it.quantity); });
if (!(st.items || []).length) addRow();
render();
renderTabs();
}
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 applyUploaded(groups, perCenter) {
// (한 출고일 안에서만 합친다 — 박스는 센터 단위로 포장)
function buildDataset(groups, perCenter) {
var mergedProds = {}; // code -> 합산 결과
var mergedMixes = {}; // box_name -> {box_name, boxes:[]}
var autoAlloc = []; // [{cid, key, count}]
var mixOwner = {}; // mixKey -> 센터명
var st = {
items: [], calc: { results: [], mixes: [] },
units: {}, labels: {}, contents: {},
openCenters: [], alloc: {}, methods: {},
stamped: {}, allStamped: false
};
var 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) {
@@ -383,9 +489,7 @@
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 });
}
put(cid, prodKey(r.product_code), r.full_boxes);
});
(data.mixes || []).forEach(function (mx) {
var bucket = mergedMixes[mx.box_name];
@@ -395,50 +499,39 @@
bucket.boxes.push(b);
var k = mixKey(mx.box_name, idx);
mixOwner[k] = g.center_name;
autoAlloc.push({ cid: cid, key: k, count: 1 });
put(cid, k, 1);
});
});
});
calc.results = Object.keys(mergedProds).map(function (k) { return mergedProds[k]; });
calc.mixes = Object.keys(mergedMixes).map(function (k) { return mergedMixes[k]; });
st.calc.results = Object.keys(mergedProds).map(function (k) { return mergedProds[k]; });
st.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;
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;
});
calc.mixes.forEach(function (m) {
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 = 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) {
st.units[k] = n;
st.labels[k] = m.box_name + " 혼합 #" + (i + 1) +
(mixOwner[k] ? " · " + mixOwner[k] : "");
st.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) {
st.items = st.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();
}).map(function (r) {
return { product_code: r.product_code, quantity: r.quantity };
});
render();
return st;
}
function renderPoResult(data, skipped) {
@@ -448,6 +541,12 @@
'<span class="erp-badge erp-badge-neutral">파일 ' + ((data.files || []).length) + "개</span>" +
'<span class="erp-badge erp-badge-neutral">센터 ' + ((data.centers || []).length) + "곳</span>" +
"</div>";
if ((data.groups || []).length > 1) {
html += '<ul class="cpg-po-dates">' + data.groups.map(function (g) {
return "<li><b>" + esc(g.ship_date || "날짜 미확인") + "</b>" +
"<span>센터 " + ((g.centers || []).length) + "곳 · " + (g.quantity || 0) + "개</span></li>";
}).join("") + "</ul>";
}
html += '<ul class="cpg-po-files">';
(data.files || []).forEach(function (f) {
html += "<li><span>" + esc(f.filename) + "</span>" +
@@ -484,28 +583,63 @@
});
})
.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);
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;
}
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; });
// 날짜 × 센터 단위로 박스 계산 (박스는 센터별로 포장되므로)
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 };
})));
});
poMsg.textContent = "분석 완료 — 센터 " + groups.length + "곳 · " + pieces + "개 자동 배분";
msg.textContent = "업로드 결과로 계산했습니다. 센터마다 출고방식을 고르면 확정할 수 있습니다.";
});
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) || "업로드 실패"; })
@@ -1158,6 +1292,20 @@
})
.then(function (data) {
var d = (data && data.ship_date) || picked;
// 업로드한 출고일이 여러 개면 남은 날짜 작업을 이어서 한다.
var idx = dateOrder.indexOf(activeDate);
if (idx >= 0 && dateOrder.length > 1) {
var done = activeDate;
dateOrder.splice(idx, 1);
delete sets[done];
closeConfirm();
activeDate = "";
loadDate(dateOrder[Math.min(idx, dateOrder.length - 1)]);
msg.textContent = done + " 출고 확정 완료 — 남은 출고일 " + dateOrder.length + "개";
poMsg.textContent = done + " 확정됨. 남은 날짜: " + dateOrder.join(", ");
cfmOk.disabled = false;
return;
}
var parts = d.split("-");
// 달력으로 이동해 방금 만든 출고 묶음을 보여준다.
window.location.href = "/cupang/?year=" + parts[0] + "&month=" + parseInt(parts[1], 10) + "&date=" + d;
@@ -1,6 +1,6 @@
{% extends "erp_base.html" %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901j" />{% 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=20260901i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901j" />{% 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=20260901i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901j" />{% 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=20260901i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901j" />{% 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=20260901i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901j" />{% 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=20260901i" />{% endblock %}
{% block head_extra %}<link rel="stylesheet" href="/static/cupang.css?v=20260901j" />{% endblock %}
{% block content %}
<section class="cpg">