feat(dispatch): 날짜별 사방넷 재고 엑셀 다운로드 + 달력 UI

- 배치 목록을 좌(리스트)/우(달력) 2단 배치
- 달력에서 배치 있는 날짜 클릭 → 그날 전 배치 출고를 낱개로 분해해 다운로드
  · _N 배수 × 주문수량 × 콤보 BOM 구성수량, MD-(뚜껑) 제외
  · 사방넷 4열 템플릿(A 상품코드[필수]=사방넷코드 / B 가용수량=수량 /
    C 불용수량=0 / D 바코드=아이템코드), 주황 헤더, xlsx
- db.stock_aggregate_for_date, export.explode_to_singles/build_stock_xlsx
- 라우터 GET /dispatch/stock-export?date=YYYY-MM-DD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:25:05 +09:00
parent 2c7f98ef30
commit 9221efe925
4 changed files with 274 additions and 2 deletions
@@ -8,6 +8,18 @@
/* 플랫폼 로고: 아이콘마다 여백이 달라 시각 크기를 맞춘다 */
.dsp-pf-logo { height:26px;width:auto;max-width:120px;vertical-align:middle;object-fit:contain; }
.dsp-pf-logo[src*="shopee"] { height:34px; }
/* 좌(리스트) / 우(달력) 2단 */
.dsp-layout { display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap; }
.dsp-list { flex:1 1 620px;min-width:320px; }
.dsp-calwrap { flex:0 1 340px;min-width:280px; }
/* 달력 */
.dsp-cal-head { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px; }
.dsp-cal-grid { display:grid;grid-template-columns:repeat(7,1fr);gap:4px; }
.dsp-cal-wd { text-align:center;font-size:12px;color:#94a3b8;padding:2px 0;font-weight:600; }
.dsp-cal-day { text-align:center;padding:8px 0;border-radius:8px;font-size:14px;color:#cbd5e1; }
.dsp-cal-day.has { color:#0f172a;background:#eef2ff;font-weight:700;cursor:pointer;border:1px solid #c7d2fe; }
.dsp-cal-day.has:hover { background:#e0e7ff; }
.dsp-cal-day.sel { background:#16a34a !important;border-color:#16a34a !important;color:#fff !important; }
</style>
{% endblock %}
@@ -15,7 +27,8 @@
<section class="dsp">
{% include "dispatch/_nav.html" %}
<div class="erp-card">
<div class="dsp-layout">
<div class="erp-card dsp-list">
<div class="cpg-card-head" style="display:flex;justify-content:space-between;align-items:center;">
<h2><span data-ko="출고 배치" data-en="Batches">출고 배치</span> ({{ batches|length }})</h2>
<a class="erp-btn erp-btn-primary" href="/dispatch/batches/new" data-ko=" 새 업로드" data-en="+ New Upload"> 새 업로드</a>
@@ -72,5 +85,101 @@
</table>
</div>
</div>
<div class="erp-card dsp-calwrap">
<div class="cpg-card-head"><h3 style="margin:0;" data-ko="재고 다운로드 (사방넷)" data-en="Stock Download (Sabangnet)">재고 다운로드 (사방넷)</h3></div>
<p class="erp-muted" style="margin:4px 0 10px;font-size:13px;"
data-ko="날짜를 클릭하면 그날 출고를 낱개로 분해한 사방넷 재고 엑셀을 받습니다."
data-en="Pick a date to download the Sabangnet stock Excel (combos split into singles).">날짜를 클릭하면 그날 출고를 낱개로 분해한 사방넷 재고 엑셀을 받습니다.</p>
<div class="dsp-cal" id="dsp-cal">
<div class="dsp-cal-head">
<button type="button" class="erp-btn erp-btn-outline" id="dsp-cal-prev"></button>
<span id="dsp-cal-title" style="font-weight:700;"></span>
<button type="button" class="erp-btn erp-btn-outline" id="dsp-cal-next"></button>
</div>
<div class="dsp-cal-grid" id="dsp-cal-grid"></div>
</div>
<div style="margin-top:12px;display:flex;flex-direction:column;gap:8px;">
<div class="erp-muted" style="font-size:13px;">
<span data-ko="선택한 날짜" data-en="Selected">선택한 날짜</span>:
<b id="dsp-cal-sel"></b>
</div>
<a id="dsp-cal-dl" class="erp-btn erp-btn-primary" href="#"
data-ko="📥 사방넷 재고 엑셀 다운로드" data-en="📥 Download Sabangnet Stock Excel"
style="pointer-events:none;opacity:.5;">📥 사방넷 재고 엑셀 다운로드</a>
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
(function () {
// 배치가 있는 날짜(YYYY-MM-DD)
var BATCH_DATES = {{ batch_dates|tojson }};
var dateSet = {};
BATCH_DATES.forEach(function (d) { dateSet[d] = true; });
var grid = document.getElementById('dsp-cal-grid');
var title = document.getElementById('dsp-cal-title');
var selLabel = document.getElementById('dsp-cal-sel');
var dlBtn = document.getElementById('dsp-cal-dl');
var selected = null;
// 초기 표시 월: 가장 최근 배치 날짜 기준(없으면 오늘)
var base = BATCH_DATES.length ? new Date(BATCH_DATES[BATCH_DATES.length - 1] + 'T00:00:00') : new Date();
var viewY = base.getFullYear(), viewM = base.getMonth();
var WD = { ko: ['일','월','화','수','목','금','토'], en: ['Su','Mo','Tu','We','Th','Fr','Sa'] };
function lang() { return (window.dspLang && window.dspLang() === 'en') ? 'en' : 'ko'; }
function pad(n) { return (n < 10 ? '0' : '') + n; }
function render() {
var l = lang();
title.textContent = viewY + '.' + pad(viewM + 1);
grid.innerHTML = '';
WD[l].forEach(function (w) {
var c = document.createElement('div');
c.className = 'dsp-cal-wd'; c.textContent = w; grid.appendChild(c);
});
var first = new Date(viewY, viewM, 1).getDay();
var days = new Date(viewY, viewM + 1, 0).getDate();
for (var i = 0; i < first; i++) grid.appendChild(document.createElement('div'));
for (var d = 1; d <= days; d++) {
var iso = viewY + '-' + pad(viewM + 1) + '-' + pad(d);
var cell = document.createElement('div');
cell.className = 'dsp-cal-day';
cell.textContent = d;
if (dateSet[iso]) {
cell.classList.add('has');
cell.setAttribute('data-iso', iso);
cell.onclick = function () { pick(this.getAttribute('data-iso')); };
}
if (iso === selected) cell.classList.add('sel');
grid.appendChild(cell);
}
}
function pick(iso) {
selected = iso;
selLabel.textContent = iso;
dlBtn.href = '/dispatch/stock-export?date=' + encodeURIComponent(iso);
dlBtn.style.pointerEvents = '';
dlBtn.style.opacity = '';
render();
}
document.getElementById('dsp-cal-prev').onclick = function () {
viewM--; if (viewM < 0) { viewM = 11; viewY--; } render();
};
document.getElementById('dsp-cal-next').onclick = function () {
viewM++; if (viewM > 11) { viewM = 0; viewY++; } render();
};
document.addEventListener('dsp:lang', render);
render();
})();
</script>
{% endblock %}