From 5217c4b6bb328adb634e5e71e7325dfe2fec6ddc Mon Sep 17 00:00:00 2001 From: king Date: Tue, 1 Sep 2026 13:49:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(cupang):=20=EB=B0=9C=EC=A3=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=B6=9C?= =?UTF-8?q?=EA=B3=A0=EC=9D=BC=EB=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 업로드 API가 (출고일, 센터, 제품)으로 합산해 groups[] 로 반환 - 화면 상단에 출고일 탭 — 날짜마다 ①직접입력 / ②박스요약 / ③센터분배 상태를 따로 보관하고 탭으로 전환 (박스 계산은 날짜 × 센터 단위로 실행) - 확정은 현재 날짜만 대상. 남은 날짜가 있으면 달력으로 나가지 않고 다음 탭으로 이동 - 업로드 결과 카드에 출고일별 센터 수·수량 요약 추가 Co-Authored-By: Claude Opus 5 --- app/modules/cupang/router.py | 42 ++- .../cupang/templates/cupang/box_calc.html | 252 ++++++++++++++---- .../cupang/templates/cupang/box_rules.html | 2 +- .../cupang/templates/cupang/centers.html | 2 +- .../cupang/templates/cupang/detail.html | 2 +- app/modules/cupang/templates/cupang/form.html | 2 +- .../cupang/templates/cupang/index.html | 2 +- .../cupang/templates/cupang/products.html | 2 +- app/static/cupang.css | 34 +++ 9 files changed, 270 insertions(+), 70 deletions(-) diff --git a/app/modules/cupang/router.py b/app/modules/cupang/router.py index 7165e2e..9e1e465 100644 --- a/app/modules/cupang/router.py +++ b/app/modules/cupang/router.py @@ -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, } ) diff --git a/app/modules/cupang/templates/cupang/box_calc.html b/app/modules/cupang/templates/cupang/box_calc.html index 5ea9aad..e8eb600 100644 --- a/app/modules/cupang/templates/cupang/box_calc.html +++ b/app/modules/cupang/templates/cupang/box_calc.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
@@ -22,6 +22,12 @@ {% else %} + + +
@@ -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 '"; + }).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 @@ '파일 ' + ((data.files || []).length) + "개" + '센터 ' + ((data.centers || []).length) + "곳" + "
"; + if ((data.groups || []).length > 1) { + html += '
    ' + data.groups.map(function (g) { + return "
  • " + esc(g.ship_date || "날짜 미확인") + "" + + "센터 " + ((g.centers || []).length) + "곳 · " + (g.quantity || 0) + "개
  • "; + }).join("") + "
"; + } html += '
    '; (data.files || []).forEach(function (f) { html += "
  • " + esc(f.filename) + "" + @@ -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; diff --git a/app/modules/cupang/templates/cupang/box_rules.html b/app/modules/cupang/templates/cupang/box_rules.html index c4ee099..7703d05 100644 --- a/app/modules/cupang/templates/cupang/box_rules.html +++ b/app/modules/cupang/templates/cupang/box_rules.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/centers.html b/app/modules/cupang/templates/cupang/centers.html index b6be68e..aaf034b 100644 --- a/app/modules/cupang/templates/cupang/centers.html +++ b/app/modules/cupang/templates/cupang/centers.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/detail.html b/app/modules/cupang/templates/cupang/detail.html index ca0110f..7be3f8d 100644 --- a/app/modules/cupang/templates/cupang/detail.html +++ b/app/modules/cupang/templates/cupang/detail.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/form.html b/app/modules/cupang/templates/cupang/form.html index f192ddb..675582d 100644 --- a/app/modules/cupang/templates/cupang/form.html +++ b/app/modules/cupang/templates/cupang/form.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/index.html b/app/modules/cupang/templates/cupang/index.html index 901d8b3..cdf93f4 100644 --- a/app/modules/cupang/templates/cupang/index.html +++ b/app/modules/cupang/templates/cupang/index.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/modules/cupang/templates/cupang/products.html b/app/modules/cupang/templates/cupang/products.html index 77ebc1c..9a917f2 100644 --- a/app/modules/cupang/templates/cupang/products.html +++ b/app/modules/cupang/templates/cupang/products.html @@ -1,6 +1,6 @@ {% extends "erp_base.html" %} -{% block head_extra %}{% endblock %} +{% block head_extra %}{% endblock %} {% block content %}
    diff --git a/app/static/cupang.css b/app/static/cupang.css index 230b990..c155254 100644 --- a/app/static/cupang.css +++ b/app/static/cupang.css @@ -1301,3 +1301,37 @@ body.cpg-dragging { cursor: grabbing; user-select: none; } color: var(--color-callout-red); } .cpg-po-warn > li { padding: 2px 0; } + +/* ── 출고일 탭 (업로드한 발주서의 출고일이 여러 개일 때) ── */ +.cpg-date-tabs { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + margin-bottom: 12px; +} +.cpg-date-tabs[hidden] { display: none; } +.cpg-date-tabs-label { + font-size: 12px; color: var(--color-midtone-gray); font-weight: 600; +} +.cpg-date-tabs-list { display: flex; gap: 8px; flex-wrap: wrap; } +.cpg-date-tab { + display: flex; flex-direction: column; gap: 2px; align-items: flex-start; + padding: 6px 14px; + border: 1px solid var(--color-subtle-ash); border-radius: 10px; + background: #fff; cursor: pointer; font: inherit; text-align: left; + transition: background .12s, border-color .12s; +} +.cpg-date-tab:hover { border-color: var(--color-midtone-gray); background: var(--color-ghost-gray); } +.cpg-date-tab > strong { font-size: 14px; font-family: var(--font-geist-mono); } +.cpg-date-tab > span { font-size: 11px; color: var(--color-midtone-gray); } +.cpg-date-tab.is-active { + background: var(--color-deep-black); border-color: var(--color-deep-black); color: #fff; +} +.cpg-date-tab.is-active > span { color: #c9c9c9; } + +/* 업로드 결과 — 출고일별 요약 */ +.cpg-po-dates { list-style: none; margin: 0 0 8px; padding: 0; font-size: 12px; } +.cpg-po-dates > li { + display: flex; gap: 8px; align-items: baseline; + padding: 3px 0; +} +.cpg-po-dates > li > b { font-family: var(--font-geist-mono); } +.cpg-po-dates > li > span { color: var(--color-midtone-gray); }