feat: v9.0 업그레이드 — CS 작업 탭 개편 + 밀크런/자사몰 행사 모듈
CS 통합 프로그램.ZIP(로컬 개발본 v9.0)의 기능 소스를 반영. DB 스키마는 기존과 동일해 마이그레이션 없이 그대로 사용한다. 가져온 것 - main.py / cafe24_api.py / templates / static: v9.0 기능 코드 - routers/coupang_milkrun.py, routers/mall_event.py (신규) - static/js/mall_event.js, static/js/milkrun_gsheet.js (신규) 운영 설정은 기존 것을 유지·재적용 - DB/카페24/네이버 접속정보를 하드코딩 대신 환경변수 기반으로 복원 - SSO(AuthGuardMiddleware, SessionMiddleware), /login, /logout, /health/db 복원 - APP_ROOT_PATH 서브경로 호스팅(root_path 템플릿 변수, app.js fetch 래퍼) 복원 - 구글시트 설정 인메모리 캐시(TTL 10분)와 /api/config/refresh 복원 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,841 @@
|
||||
// 자사몰 행사 탭 - 카페24 주문/발주 파일 가공
|
||||
//
|
||||
// 업로드 → 조건 선택 → 미리보기 → 가공된 엑셀 다운로드 흐름을 담당한다.
|
||||
// 기능이 늘어나면 initFirstComeGift() 처럼 기능별 초기화 함수를 추가하고
|
||||
// 왼쪽 '기능' 목록 버튼(.me-feature-btn)과 패널(#me-panel-<feature>)을 연결한다.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const tab = document.getElementById('mall-event-tab');
|
||||
if (!tab) return;
|
||||
|
||||
initFeatureSwitcher();
|
||||
initFirstComeGift();
|
||||
initEventExtract();
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 기능 공통 헬퍼
|
||||
// ------------------------------------------------------------------
|
||||
function escapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalize(str) {
|
||||
return String(str || '').toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
async function readError(res, fallback) {
|
||||
// 413은 앱이 아니라 앞단 웹서버(nginx 등)가 용량 제한으로 막은 것이라
|
||||
// 응답에 이유가 없다. 원인을 바로 알 수 있게 따로 안내한다.
|
||||
if (res.status === 413) {
|
||||
return '파일이 너무 커서 서버가 업로드를 거부했습니다 (413). ' +
|
||||
'서버 웹서버(nginx 등)의 업로드 용량 제한(client_max_body_size)을 늘려야 합니다.';
|
||||
}
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data && data.detail) {
|
||||
// FastAPI 검증 오류는 detail이 배열로 온다
|
||||
if (Array.isArray(data.detail)) {
|
||||
return data.detail.map(function (d) { return d.msg || JSON.stringify(d); }).join(', ');
|
||||
}
|
||||
return data.detail;
|
||||
}
|
||||
} catch (e) { /* 본문이 JSON이 아니면 아래 기본 메시지 */ }
|
||||
// 서버 오류(500 등)는 본문이 없으므로 상태 코드라도 남겨 원인을 좁힌다
|
||||
return fallback + ' (서버 응답 ' + res.status + ')';
|
||||
}
|
||||
|
||||
// 서버가 Content-Disposition으로 내려준 파일명을 꺼낸다
|
||||
function filenameFromResponse(res, fallback) {
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (match) {
|
||||
try { return decodeURIComponent(match[1]); } catch (e) { /* 무시 */ }
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// 탭 전체를 덮는 로딩 오버레이 (기능들이 공유)
|
||||
function setOverlay(isLoading, text) {
|
||||
const overlay = document.getElementById('me-loading-overlay');
|
||||
const overlayText = document.getElementById('me-loading-text');
|
||||
if (overlay) overlay.style.display = isLoading ? 'flex' : 'none';
|
||||
if (overlayText && text) overlayText.textContent = text;
|
||||
}
|
||||
|
||||
// 파일 선택 버튼 + 드래그 앤 드롭을 한 번에 붙인다
|
||||
function bindDropzone(dropzone, fileInput, pickBtn, onFile) {
|
||||
if (!dropzone || !fileInput) return;
|
||||
|
||||
if (pickBtn) {
|
||||
pickBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
dropzone.addEventListener('click', function () { fileInput.click(); });
|
||||
fileInput.addEventListener('change', function () {
|
||||
if (fileInput.files && fileInput.files[0]) onFile(fileInput.files[0]);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(function (type) {
|
||||
dropzone.addEventListener(type, function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.style.background = '#ebf8ff';
|
||||
dropzone.style.borderColor = '#3182ce';
|
||||
});
|
||||
});
|
||||
['dragleave', 'drop'].forEach(function (type) {
|
||||
dropzone.addEventListener(type, function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropzone.style.background = '#f7fafc';
|
||||
dropzone.style.borderColor = '#cbd5e0';
|
||||
});
|
||||
});
|
||||
dropzone.addEventListener('drop', function (e) {
|
||||
const files = e.dataTransfer && e.dataTransfer.files;
|
||||
if (files && files.length > 0) onFile(files[0]);
|
||||
});
|
||||
}
|
||||
|
||||
// 탭 밖에 파일을 떨어뜨렸을 때 브라우저가 파일을 열어버리는 것 방지
|
||||
['dragover', 'drop'].forEach(function (type) {
|
||||
window.addEventListener(type, function (e) {
|
||||
const inDropzone = e.target.closest && e.target.closest('#me-dropzone, #ee-dropzone');
|
||||
if (!inDropzone) e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
// blob 응답을 파일로 저장
|
||||
async function saveBlob(res, fallbackName) {
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filenameFromResponse(res, fallbackName);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 기능 전환
|
||||
// ------------------------------------------------------------------
|
||||
function initFeatureSwitcher() {
|
||||
const buttons = document.querySelectorAll('.me-feature-btn');
|
||||
if (buttons.length < 2) return; // 기능이 하나면 전환할 것이 없다
|
||||
|
||||
buttons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
buttons.forEach(function (b) {
|
||||
b.classList.remove('active');
|
||||
b.style.background = '#fff';
|
||||
b.style.color = '#4a5568';
|
||||
b.style.borderColor = '#e2e8f0';
|
||||
});
|
||||
btn.classList.add('active');
|
||||
btn.style.background = '#ebf8ff';
|
||||
btn.style.color = '#2b6cb0';
|
||||
btn.style.borderColor = '#bee3f8';
|
||||
|
||||
document.querySelectorAll('.me-feature-panel').forEach(function (panel) {
|
||||
panel.style.display = 'none';
|
||||
});
|
||||
const target = document.getElementById('me-panel-' + btn.dataset.feature);
|
||||
if (target) target.style.display = 'flex';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 기능 1) 선착순 사은품 추가
|
||||
// ------------------------------------------------------------------
|
||||
function initFirstComeGift() {
|
||||
const dropzone = document.getElementById('me-dropzone');
|
||||
const fileInput = document.getElementById('me-file-input');
|
||||
const pickBtn = document.getElementById('btn-me-pick-file');
|
||||
const resetBtn = document.getElementById('btn-me-reset');
|
||||
const fileInfo = document.getElementById('me-file-info');
|
||||
const fileError = document.getElementById('me-file-error');
|
||||
const orderListBox = document.getElementById('me-order-list');
|
||||
const orderSearch = document.getElementById('me-order-search');
|
||||
const selectAllBtn = document.getElementById('btn-me-select-all');
|
||||
const selectNoneBtn = document.getElementById('btn-me-select-none');
|
||||
const selectedInfo = document.getElementById('me-order-selected-info');
|
||||
const limitInput = document.getElementById('me-limit');
|
||||
const giftSelect = document.getElementById('me-gift-select');
|
||||
const giftQtyInput = document.getElementById('me-gift-qty');
|
||||
const applyBtn = document.getElementById('btn-me-apply');
|
||||
const warningBox = document.getElementById('me-warning-box');
|
||||
const resultInfo = document.getElementById('me-result-info');
|
||||
const resultTbody = document.getElementById('me-result-tbody');
|
||||
const downloadFullBtn = document.getElementById('btn-me-download-full');
|
||||
const downloadGiftBtn = document.getElementById('btn-me-download-gift');
|
||||
const overlay = document.getElementById('me-loading-overlay');
|
||||
const overlayText = document.getElementById('me-loading-text');
|
||||
if (!dropzone || !fileInput) return;
|
||||
|
||||
let currentFile = null;
|
||||
let orderItems = []; // [{name, order_count}]
|
||||
const selectedNames = new Set();
|
||||
let searchKeyword = '';
|
||||
let previewReady = false;
|
||||
|
||||
function setLoading(isLoading, text) {
|
||||
setOverlay(isLoading, text);
|
||||
applyBtn.disabled = isLoading;
|
||||
}
|
||||
|
||||
function showFileError(message) {
|
||||
if (!fileError) return;
|
||||
if (!message) {
|
||||
fileError.style.display = 'none';
|
||||
fileError.textContent = '';
|
||||
return;
|
||||
}
|
||||
fileError.style.display = 'block';
|
||||
fileError.textContent = message;
|
||||
}
|
||||
|
||||
// ---- 다운로드 버튼 활성/비활성 ----
|
||||
function setDownloadEnabled(enabled) {
|
||||
previewReady = enabled;
|
||||
[downloadFullBtn, downloadGiftBtn].forEach(function (btn) {
|
||||
if (!btn) return;
|
||||
btn.disabled = !enabled;
|
||||
btn.style.opacity = enabled ? '1' : '0.5';
|
||||
btn.style.cursor = enabled ? 'pointer' : 'default';
|
||||
});
|
||||
}
|
||||
|
||||
// 조건이 바뀌면 이전 미리보기 결과는 더 이상 유효하지 않다
|
||||
function invalidatePreview() {
|
||||
if (!previewReady) return;
|
||||
setDownloadEnabled(false);
|
||||
resultInfo.innerHTML = '<span style="color:#975a16;">조건이 바뀌었습니다. "사은품 적용 결과 보기"를 다시 눌러주세요.</span>';
|
||||
}
|
||||
|
||||
// ---- 파일 업로드 ----
|
||||
function resetAll() {
|
||||
currentFile = null;
|
||||
orderItems = [];
|
||||
selectedNames.clear();
|
||||
searchKeyword = '';
|
||||
fileInput.value = '';
|
||||
if (orderSearch) orderSearch.value = '';
|
||||
fileInfo.style.display = 'none';
|
||||
fileInfo.innerHTML = '';
|
||||
showFileError('');
|
||||
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">발주서를 업로드하면 주문 목록이 표시됩니다.</div>';
|
||||
selectedInfo.textContent = '';
|
||||
resultTbody.innerHTML = '';
|
||||
resultInfo.textContent = '';
|
||||
warningBox.style.display = 'none';
|
||||
warningBox.innerHTML = '';
|
||||
setDownloadEnabled(false);
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const lower = file.name.toLowerCase();
|
||||
if (lower.endsWith('.xls')) {
|
||||
showFileError('구형 .xls 파일은 처리할 수 없습니다. 엑셀에서 .xlsx로 저장한 뒤 올려주세요.');
|
||||
return;
|
||||
}
|
||||
if (!lower.endsWith('.xlsx') && !lower.endsWith('.xlsm')) {
|
||||
showFileError('엑셀 파일(.xlsx)만 업로드할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
showFileError('');
|
||||
currentFile = file;
|
||||
selectedNames.clear();
|
||||
searchKeyword = '';
|
||||
if (orderSearch) orderSearch.value = '';
|
||||
setDownloadEnabled(false);
|
||||
resultTbody.innerHTML = '';
|
||||
resultInfo.textContent = '';
|
||||
warningBox.style.display = 'none';
|
||||
|
||||
setLoading(true, '발주서를 분석하는 중...');
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch('/api/mall-event/first-come/analyze', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
showFileError(await readError(res, '발주서를 분석하지 못했습니다.'));
|
||||
currentFile = null;
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
orderItems = data.order_list_items || [];
|
||||
|
||||
fileInfo.style.display = 'block';
|
||||
fileInfo.innerHTML =
|
||||
'<div style="font-weight:600; word-break:break-all;">📄 ' + escapeHtml(file.name) + '</div>' +
|
||||
'<div style="color:#718096;">시트: ' + escapeHtml(data.sheet_name) + ' · 헤더 ' + data.header_row + '행</div>' +
|
||||
'<div style="color:#718096;">데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
|
||||
Number(data.total_orders).toLocaleString() + '건 · 상품 ' + orderItems.length + '종</div>';
|
||||
|
||||
renderOrderList();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showFileError('업로드 중 오류가 발생했습니다.');
|
||||
currentFile = null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
bindDropzone(dropzone, fileInput, pickBtn, handleFile);
|
||||
|
||||
if (resetBtn) resetBtn.addEventListener('click', resetAll);
|
||||
|
||||
// ---- 주문목록 리스트 ----
|
||||
function renderOrderList() {
|
||||
if (!orderItems.length) {
|
||||
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">Q열(주문목록)에서 상품을 찾지 못했습니다.</div>';
|
||||
updateSelectedInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = orderItems.filter(function (item) {
|
||||
return !searchKeyword || normalize(item.name).includes(searchKeyword);
|
||||
});
|
||||
|
||||
if (!visible.length) {
|
||||
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">검색 결과가 없습니다.</div>';
|
||||
updateSelectedInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
orderListBox.innerHTML = visible.map(function (item) {
|
||||
const checked = selectedNames.has(item.name) ? ' checked' : '';
|
||||
return '' +
|
||||
'<label class="me-order-row" style="display:flex; align-items:center; gap:8px; padding:5px 8px; border-bottom:1px solid #edf2f7; cursor:pointer; font-size:0.85rem;">' +
|
||||
'<input type="checkbox" class="me-order-check" value="' + escapeHtml(item.name) + '"' + checked + ' style="width:15px; height:15px; cursor:pointer;">' +
|
||||
'<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(item.name) + '</span>' +
|
||||
'<span style="color:#3182ce; font-weight:600; white-space:nowrap;">' + Number(item.order_count).toLocaleString() + '건</span>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
|
||||
updateSelectedInfo();
|
||||
}
|
||||
|
||||
function updateSelectedInfo() {
|
||||
if (!selectedNames.size) {
|
||||
selectedInfo.textContent = orderItems.length ? '선택된 상품이 없습니다.' : '';
|
||||
return;
|
||||
}
|
||||
const names = Array.from(selectedNames);
|
||||
const preview = names.slice(0, 2).join(', ');
|
||||
const more = names.length > 2 ? ' 외 ' + (names.length - 2) + '개' : '';
|
||||
selectedInfo.innerHTML = '선택 <b style="color:#3182ce;">' + names.length + '개</b> — ' + escapeHtml(preview + more);
|
||||
}
|
||||
|
||||
orderListBox.addEventListener('change', function (e) {
|
||||
const check = e.target.closest('.me-order-check');
|
||||
if (!check) return;
|
||||
if (check.checked) selectedNames.add(check.value);
|
||||
else selectedNames.delete(check.value);
|
||||
updateSelectedInfo();
|
||||
invalidatePreview();
|
||||
});
|
||||
|
||||
if (orderSearch) {
|
||||
orderSearch.addEventListener('input', function () {
|
||||
searchKeyword = normalize(orderSearch.value);
|
||||
renderOrderList();
|
||||
});
|
||||
}
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.addEventListener('click', function () {
|
||||
orderItems.forEach(function (item) {
|
||||
if (!searchKeyword || normalize(item.name).includes(searchKeyword)) selectedNames.add(item.name);
|
||||
});
|
||||
renderOrderList();
|
||||
invalidatePreview();
|
||||
});
|
||||
}
|
||||
if (selectNoneBtn) {
|
||||
selectNoneBtn.addEventListener('click', function () {
|
||||
selectedNames.clear();
|
||||
renderOrderList();
|
||||
invalidatePreview();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 사은품 목록 (코드표 세트 코드 중 이름에 '사은품'이 들어간 것만) ----
|
||||
const GIFT_NAME_KEYWORD = '사은품';
|
||||
|
||||
async function loadGiftOptions() {
|
||||
try {
|
||||
const res = await fetch('/api/codes/set');
|
||||
if (!res.ok) throw new Error('status ' + res.status);
|
||||
const data = await res.json();
|
||||
const allSets = (data && data.data) || [];
|
||||
const sets = allSets.filter(function (s) {
|
||||
return String(s.name || '').includes(GIFT_NAME_KEYWORD);
|
||||
});
|
||||
if (!sets.length) {
|
||||
giftSelect.innerHTML = '<option value="">이름에 "' + GIFT_NAME_KEYWORD +
|
||||
'"이 들어간 세트가 코드표에 없습니다</option>';
|
||||
return;
|
||||
}
|
||||
sets.sort(function (a, b) {
|
||||
return String(a.name || '').localeCompare(String(b.name || ''), 'ko');
|
||||
});
|
||||
giftSelect.innerHTML = '<option value="">사은품을 선택하세요</option>' + sets.map(function (s) {
|
||||
return '<option value="' + escapeHtml(s.item_code) + '" data-name="' + escapeHtml(s.name || '') + '">' +
|
||||
escapeHtml(s.name || '(이름 없음)') + ' — ' + escapeHtml(s.item_code) + '</option>';
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
giftSelect.innerHTML = '<option value="">세트 코드를 불러오지 못했습니다 (DB 연결 확인)</option>';
|
||||
}
|
||||
}
|
||||
loadGiftOptions();
|
||||
|
||||
// 코드표 탭에서 세트를 수정하고 돌아왔을 때 목록을 새로 받는다
|
||||
const mallEventMenu = document.querySelector('[data-tab="mall-event-tab"]');
|
||||
if (mallEventMenu) {
|
||||
mallEventMenu.addEventListener('click', function () {
|
||||
if (!giftSelect.value) loadGiftOptions();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 조건 변경 감지 ----
|
||||
document.querySelectorAll('.me-limit-preset').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
limitInput.value = btn.dataset.value;
|
||||
invalidatePreview();
|
||||
});
|
||||
});
|
||||
[limitInput, giftSelect, giftQtyInput].forEach(function (el) {
|
||||
if (el) el.addEventListener('change', invalidatePreview);
|
||||
});
|
||||
if (limitInput) limitInput.addEventListener('input', invalidatePreview);
|
||||
if (giftQtyInput) giftQtyInput.addEventListener('input', invalidatePreview);
|
||||
|
||||
// ---- 조건 수집/검증 ----
|
||||
function buildFormData() {
|
||||
if (!currentFile) {
|
||||
alert('먼저 발주서 엑셀 파일을 업로드해주세요.');
|
||||
return null;
|
||||
}
|
||||
if (!selectedNames.size) {
|
||||
alert('사은품이 지급되는 주문을 최소 1개 선택해주세요.');
|
||||
return null;
|
||||
}
|
||||
const limit = parseInt(limitInput.value, 10);
|
||||
if (!limit || limit < 1) {
|
||||
alert('선착순 인원을 1명 이상으로 입력해주세요.');
|
||||
return null;
|
||||
}
|
||||
const giftCode = giftSelect.value;
|
||||
if (!giftCode) {
|
||||
alert('사은품(세트 코드)을 선택해주세요.');
|
||||
return null;
|
||||
}
|
||||
const giftQty = parseInt(giftQtyInput.value, 10);
|
||||
if (!giftQty || giftQty < 1) {
|
||||
alert('사은품 수량을 1개 이상으로 입력해주세요.');
|
||||
return null;
|
||||
}
|
||||
const giftName = giftSelect.options[giftSelect.selectedIndex].dataset.name || '';
|
||||
|
||||
const form = new FormData();
|
||||
form.append('file', currentFile);
|
||||
form.append('selected_json', JSON.stringify(Array.from(selectedNames)));
|
||||
form.append('limit', String(limit));
|
||||
form.append('gift_item_code', giftCode);
|
||||
form.append('gift_name', giftName);
|
||||
form.append('gift_qty', String(giftQty));
|
||||
return form;
|
||||
}
|
||||
|
||||
// ---- 미리보기 ----
|
||||
applyBtn.addEventListener('click', async function () {
|
||||
const form = buildFormData();
|
||||
if (!form) return;
|
||||
|
||||
setLoading(true, '사은품 대상을 계산하는 중...');
|
||||
try {
|
||||
const res = await fetch('/api/mall-event/first-come/preview', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
alert(await readError(res, '사은품 적용 중 오류가 발생했습니다.'));
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
if (data.warnings && data.warnings.length) {
|
||||
warningBox.style.display = 'block';
|
||||
warningBox.innerHTML = data.warnings.map(function (w) {
|
||||
return '⚠ ' + escapeHtml(w);
|
||||
}).join('<br>');
|
||||
} else {
|
||||
warningBox.style.display = 'none';
|
||||
warningBox.innerHTML = '';
|
||||
}
|
||||
|
||||
const added = data.added || [];
|
||||
if (!added.length) {
|
||||
resultTbody.innerHTML = '<tr><td colspan="4" style="padding:14px; text-align:center; color:#a0aec0;">사은품이 적용된 주문이 없습니다.</td></tr>';
|
||||
resultInfo.innerHTML = '<span style="color:#c53030;">적용 대상 0건</span>';
|
||||
setDownloadEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
resultTbody.innerHTML = added.map(function (row, idx) {
|
||||
return '<tr>' +
|
||||
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; color:#a0aec0;">' + (idx + 1) + '</td>' +
|
||||
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap;">' + escapeHtml(row.order_no) + '</td>' +
|
||||
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap; color:#4a5568;">' + escapeHtml(row.order_date) + '</td>' +
|
||||
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap; color:#2b6cb0;">' + escapeHtml(row.seq) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
|
||||
resultInfo.innerHTML = '조건 충족 주문 <b>' + Number(data.matched_order_count).toLocaleString() + '건</b> 중 ' +
|
||||
'<b style="color:#3182ce;">' + Number(data.applied_count).toLocaleString() + '건</b>에 사은품 행 추가';
|
||||
setDownloadEnabled(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('사은품 적용 요청 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 다운로드 ----
|
||||
async function download(mode, fallbackName, label) {
|
||||
const form = buildFormData();
|
||||
if (!form) return;
|
||||
form.append('mode', mode);
|
||||
|
||||
setLoading(true, label + ' 파일을 만드는 중...');
|
||||
try {
|
||||
const res = await fetch('/api/mall-event/first-come/download', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
alert(await readError(res, '엑셀 다운로드 중 오류가 발생했습니다.'));
|
||||
return;
|
||||
}
|
||||
await saveBlob(res, fallbackName);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 서버가 Content-Disposition으로 이름을 내려주지만, 못 읽었을 때 쓸 대비책
|
||||
function baseName() {
|
||||
const name = (currentFile && currentFile.name) || '발주서.xlsx';
|
||||
return name.replace(/\.(xlsx|xlsm|xls)$/i, '');
|
||||
}
|
||||
|
||||
if (downloadFullBtn) {
|
||||
downloadFullBtn.addEventListener('click', function () {
|
||||
if (downloadFullBtn.disabled) return;
|
||||
// 전체 발주 파일은 업로드한 파일과 같은 이름으로 받는다
|
||||
download('full', baseName() + '.xlsx', '전체 발주');
|
||||
});
|
||||
}
|
||||
if (downloadGiftBtn) {
|
||||
downloadGiftBtn.addEventListener('click', function () {
|
||||
if (downloadGiftBtn.disabled) return;
|
||||
download('gift', baseName() + '_선착순사은품_대상주문.xlsx', '사은품 대상 주문');
|
||||
});
|
||||
}
|
||||
|
||||
setDownloadEnabled(false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 기능 2) 행사 주문 추출
|
||||
// 주문 CSV → K열(주문상품명) 첫 " -" 앞부분으로 상품 선택 →
|
||||
// 해당상품만/해당주문 조건으로 행을 걸러 엑셀로 내려받는다.
|
||||
// ------------------------------------------------------------------
|
||||
function initEventExtract() {
|
||||
const dropzone = document.getElementById('ee-dropzone');
|
||||
const fileInput = document.getElementById('ee-file-input');
|
||||
const pickBtn = document.getElementById('btn-ee-pick-file');
|
||||
const resetBtn = document.getElementById('btn-ee-reset');
|
||||
const fileInfo = document.getElementById('ee-file-info');
|
||||
const fileError = document.getElementById('ee-file-error');
|
||||
const productListBox = document.getElementById('ee-product-list');
|
||||
const productSearch = document.getElementById('ee-product-search');
|
||||
const selectAllBtn = document.getElementById('btn-ee-select-all');
|
||||
const selectNoneBtn = document.getElementById('btn-ee-select-none');
|
||||
const selectedInfo = document.getElementById('ee-selected-info');
|
||||
const warningBox = document.getElementById('ee-warning-box');
|
||||
const resultInfo = document.getElementById('ee-result-info');
|
||||
const downloadBtn = document.getElementById('btn-ee-download');
|
||||
const modeRadios = document.querySelectorAll('input[name="ee-mode"]');
|
||||
if (!dropzone || !fileInput) return;
|
||||
|
||||
let currentFile = null;
|
||||
let productItems = []; // [{name, row_count, order_count}]
|
||||
const selectedNames = new Set();
|
||||
let searchKeyword = '';
|
||||
|
||||
function setLoading(isLoading, text) {
|
||||
setOverlay(isLoading, text);
|
||||
downloadBtn.disabled = isLoading;
|
||||
downloadBtn.style.opacity = isLoading ? '0.6' : '1';
|
||||
}
|
||||
|
||||
// 조건이 바뀌면 직전 다운로드 결과 안내는 지운다
|
||||
function clearResult() {
|
||||
resultInfo.innerHTML = '';
|
||||
}
|
||||
|
||||
function currentMode() {
|
||||
const checked = document.querySelector('input[name="ee-mode"]:checked');
|
||||
return checked ? checked.value : 'product';
|
||||
}
|
||||
|
||||
function showFileError(message) {
|
||||
if (!fileError) return;
|
||||
fileError.style.display = message ? 'block' : 'none';
|
||||
fileError.textContent = message || '';
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
currentFile = null;
|
||||
productItems = [];
|
||||
selectedNames.clear();
|
||||
searchKeyword = '';
|
||||
fileInput.value = '';
|
||||
if (productSearch) productSearch.value = '';
|
||||
fileInfo.style.display = 'none';
|
||||
fileInfo.innerHTML = '';
|
||||
showFileError('');
|
||||
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">CSV를 업로드하면 상품 목록이 표시됩니다.</div>';
|
||||
selectedInfo.textContent = '';
|
||||
resultInfo.innerHTML = '';
|
||||
warningBox.style.display = 'none';
|
||||
warningBox.innerHTML = '';
|
||||
clearResult();
|
||||
}
|
||||
|
||||
// ---- 업로드 & 분석 ----
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||
showFileError('CSV 파일(.csv)만 업로드할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
showFileError('');
|
||||
currentFile = file;
|
||||
selectedNames.clear();
|
||||
searchKeyword = '';
|
||||
if (productSearch) productSearch.value = '';
|
||||
clearResult();
|
||||
resultInfo.innerHTML = '';
|
||||
warningBox.style.display = 'none';
|
||||
|
||||
setLoading(true, 'CSV를 분석하는 중...');
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch('/api/mall-event/event-extract/analyze', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
showFileError(await readError(res, 'CSV를 분석하지 못했습니다.'));
|
||||
currentFile = null;
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
productItems = data.product_items || [];
|
||||
|
||||
fileInfo.style.display = 'block';
|
||||
fileInfo.innerHTML =
|
||||
'<div style="font-weight:600; word-break:break-all;">📄 ' + escapeHtml(file.name) + '</div>' +
|
||||
'<div style="color:#718096;">인코딩: ' + escapeHtml(data.encoding) + ' · 헤더 ' + data.header_row + '행 · ' + data.column_count + '개 열</div>' +
|
||||
'<div style="color:#718096;">데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
|
||||
Number(data.total_orders).toLocaleString() + '건 · 상품 ' + productItems.length + '종</div>';
|
||||
|
||||
if (data.warnings && data.warnings.length) {
|
||||
warningBox.style.display = 'block';
|
||||
warningBox.innerHTML = data.warnings.map(function (w) {
|
||||
return '⚠ ' + escapeHtml(w);
|
||||
}).join('<br>');
|
||||
}
|
||||
|
||||
renderProductList();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showFileError('업로드 중 오류가 발생했습니다.');
|
||||
currentFile = null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
bindDropzone(dropzone, fileInput, pickBtn, handleFile);
|
||||
if (resetBtn) resetBtn.addEventListener('click', resetAll);
|
||||
|
||||
// ---- 상품 목록 ----
|
||||
function renderProductList() {
|
||||
if (!productItems.length) {
|
||||
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">K열(주문상품명)에서 상품을 찾지 못했습니다.</div>';
|
||||
updateSelectedInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = productItems.filter(function (item) {
|
||||
return !searchKeyword || normalize(item.name).includes(searchKeyword);
|
||||
});
|
||||
|
||||
if (!visible.length) {
|
||||
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">검색 결과가 없습니다.</div>';
|
||||
updateSelectedInfo();
|
||||
return;
|
||||
}
|
||||
|
||||
productListBox.innerHTML = visible.map(function (item) {
|
||||
const checked = selectedNames.has(item.name) ? ' checked' : '';
|
||||
return '' +
|
||||
'<label style="display:flex; align-items:center; gap:8px; padding:5px 8px; border-bottom:1px solid #edf2f7; cursor:pointer; font-size:0.85rem;">' +
|
||||
'<input type="checkbox" class="ee-product-check" value="' + escapeHtml(item.name) + '"' + checked + ' style="width:15px; height:15px; cursor:pointer;">' +
|
||||
'<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(item.name) + '</span>' +
|
||||
'<span style="color:#718096; white-space:nowrap; font-size:0.78rem;">' + Number(item.row_count).toLocaleString() + '행</span>' +
|
||||
'<span style="color:#3182ce; font-weight:600; white-space:nowrap;">' + Number(item.order_count).toLocaleString() + '건</span>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
|
||||
updateSelectedInfo();
|
||||
}
|
||||
|
||||
function updateSelectedInfo() {
|
||||
if (!selectedNames.size) {
|
||||
selectedInfo.textContent = productItems.length ? '선택된 상품이 없습니다.' : '';
|
||||
return;
|
||||
}
|
||||
const names = Array.from(selectedNames);
|
||||
const preview = names.slice(0, 2).join(', ');
|
||||
const more = names.length > 2 ? ' 외 ' + (names.length - 2) + '개' : '';
|
||||
selectedInfo.innerHTML = '선택 <b style="color:#3182ce;">' + names.length + '개</b> — ' + escapeHtml(preview + more);
|
||||
}
|
||||
|
||||
productListBox.addEventListener('change', function (e) {
|
||||
const check = e.target.closest('.ee-product-check');
|
||||
if (!check) return;
|
||||
if (check.checked) selectedNames.add(check.value);
|
||||
else selectedNames.delete(check.value);
|
||||
updateSelectedInfo();
|
||||
clearResult();
|
||||
});
|
||||
|
||||
if (productSearch) {
|
||||
productSearch.addEventListener('input', function () {
|
||||
searchKeyword = normalize(productSearch.value);
|
||||
renderProductList();
|
||||
});
|
||||
}
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.addEventListener('click', function () {
|
||||
productItems.forEach(function (item) {
|
||||
if (!searchKeyword || normalize(item.name).includes(searchKeyword)) selectedNames.add(item.name);
|
||||
});
|
||||
renderProductList();
|
||||
clearResult();
|
||||
});
|
||||
}
|
||||
if (selectNoneBtn) {
|
||||
selectNoneBtn.addEventListener('click', function () {
|
||||
selectedNames.clear();
|
||||
renderProductList();
|
||||
clearResult();
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 추출 조건 라디오 ----
|
||||
modeRadios.forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
modeRadios.forEach(function (r) {
|
||||
const box = r.closest('label');
|
||||
if (!box) return;
|
||||
const on = r.checked;
|
||||
box.style.borderColor = on ? '#bee3f8' : '#e2e8f0';
|
||||
box.style.background = on ? '#ebf8ff' : '#fff';
|
||||
const title = box.querySelector('span > span');
|
||||
if (title) title.style.color = on ? '#2b6cb0' : '#2d3748';
|
||||
});
|
||||
clearResult();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 조건 수집 ----
|
||||
function buildFormData() {
|
||||
if (!currentFile) {
|
||||
alert('먼저 주문 CSV 파일을 업로드해주세요.');
|
||||
return null;
|
||||
}
|
||||
if (!selectedNames.size) {
|
||||
alert('추출할 상품을 최소 1개 선택해주세요.');
|
||||
return null;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append('file', currentFile);
|
||||
form.append('selected_json', JSON.stringify(Array.from(selectedNames)));
|
||||
form.append('mode', currentMode());
|
||||
return form;
|
||||
}
|
||||
|
||||
// ---- 다운로드 (중간 확인 단계 없이 바로 파일 생성) ----
|
||||
downloadBtn.addEventListener('click', async function () {
|
||||
if (downloadBtn.disabled) return;
|
||||
const form = buildFormData();
|
||||
if (!form) return;
|
||||
|
||||
const modeLabel = currentMode() === 'product' ? '해당상품만 추출' : '해당 주문 추출';
|
||||
const suffix = currentMode() === 'product' ? '_행사추출_해당상품만' : '_행사추출_해당주문';
|
||||
const fallback = ((currentFile && currentFile.name) || '주문').replace(/\.csv$/i, '') + suffix + '.xlsx';
|
||||
|
||||
warningBox.style.display = 'none';
|
||||
warningBox.innerHTML = '';
|
||||
clearResult();
|
||||
|
||||
setLoading(true, '추출 파일을 만드는 중...');
|
||||
try {
|
||||
const res = await fetch('/api/mall-event/event-extract/download', { method: 'POST', body: form });
|
||||
if (!res.ok) {
|
||||
alert(await readError(res, '엑셀 다운로드 중 오류가 발생했습니다.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버가 헤더로 실어 보낸 추출 결과를 화면에 남긴다
|
||||
const rows = Number(res.headers.get('X-Extract-Rows') || 0);
|
||||
const orders = Number(res.headers.get('X-Extract-Orders') || 0);
|
||||
const matched = Number(res.headers.get('X-Extract-Matched-Rows') || 0);
|
||||
const total = Number(res.headers.get('X-Extract-Total-Rows') || 0);
|
||||
|
||||
await saveBlob(res, fallback);
|
||||
|
||||
resultInfo.innerHTML =
|
||||
'<div style="color:#2f855a; font-weight:600;">✓ 다운로드 완료</div>' +
|
||||
'<div>조건: <b>' + modeLabel + '</b></div>' +
|
||||
'<div>상품이 들어있는 행 <b>' + matched.toLocaleString() + '행</b></div>' +
|
||||
'<div>추출 결과 <b style="color:#3182ce;">' + rows.toLocaleString() + '행</b>' +
|
||||
' · 주문 <b style="color:#3182ce;">' + orders.toLocaleString() + '건</b></div>' +
|
||||
'<div style="color:#718096; font-size:0.78rem;">전체 ' + total.toLocaleString() + '행 중</div>';
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
clearResult();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user