Files
dbx-corm/static/js/milkrun_gsheet.js
king 065e81bf97 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>
2026-08-31 23:56:17 +09:00

364 lines
17 KiB
JavaScript

// 쿠팡 밀크런 캘린더
//
// 화면이 열리면 구글 드라이브의 밀크런 출고리스트를 자동으로 읽어
// 시트 이름(YYYYMMDD)을 달력에 표시한다.
// 달력에서 날짜를 고르면 그 시트의 제품코드/제품명/수량을 읽어
// 붙여넣기 입력을 채우고 곧바로 분석까지 실행한다.
// (시트 주소는 서버에 설정되어 있어 화면에서 입력하지 않는다)
(function () {
'use strict';
const DOW = ['일', '월', '화', '수', '목', '금', '토'];
document.addEventListener('DOMContentLoaded', function () {
const refreshBtn = document.getElementById('btn-gsheet-refresh');
const messageBox = document.getElementById('gsheet-message');
const fileInfo = document.getElementById('gsheet-file-info');
const calendarBox = document.getElementById('gsheet-calendar');
const otherBox = document.getElementById('gsheet-other-sheets');
if (!calendarBox) return;
// 붙여넣기 입력 쪽 요소 (분석은 기존 로직을 그대로 재사용한다)
const pasteInput = document.getElementById('milkrun-input');
const analyzeBtn = document.getElementById('btn-milkrun-analyze');
let sheetsByDate = {}; // 'YYYY-MM-DD' -> 시트 이름
let otherSheets = []; // 날짜로 못 읽은 시트 이름
let viewYear = null;
let viewMonth = null; // 0-based
let selectedDate = null;
let busy = false;
function escapeHtml(str) {
return String(str == null ? '' : str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
let messageTimer = null;
function showMessage(text, kind, autoHideMs) {
if (messageTimer) {
clearTimeout(messageTimer);
messageTimer = null;
}
if (!text) {
messageBox.style.display = 'none';
messageBox.innerHTML = '';
return;
}
const palette = {
error: { bg: '#fff5f5', border: '#feb2b2', color: '#c53030' },
info: { bg: '#ebf8ff', border: '#bee3f8', color: '#2b6cb0' },
success: { bg: '#f0fff4', border: '#9ae6b4', color: '#276749' },
// 눈에 잘 띄어야 하는 안내 (오늘 출고 없음 등)
alert: { bg: '#fffbeb', border: '#f6ad55', color: '#c05621' }
};
const c = palette[kind] || palette.info;
messageBox.style.display = 'block';
messageBox.style.background = c.bg;
messageBox.style.border = '2px solid ' + c.border;
messageBox.style.color = c.color;
messageBox.innerHTML = text;
if (autoHideMs) {
messageTimer = setTimeout(function () {
messageBox.style.display = 'none';
messageBox.innerHTML = '';
messageTimer = null;
}, autoHideMs);
}
}
function dateKeyOf(date) {
return date.getFullYear() + '-' +
String(date.getMonth() + 1).padStart(2, '0') + '-' +
String(date.getDate()).padStart(2, '0');
}
// 달력은 [본문 + 로딩 덮개] 두 겹으로 만들어 둔다.
// 본문만 다시 그리므로 덮개가 지워지지 않는다.
function ensureCalendarShell() {
if (calendarBox.dataset.ready === '1') return;
calendarBox.style.position = 'relative';
calendarBox.innerHTML =
'<div id="cal-body"></div>' +
'<div id="cal-overlay" style="display:none; position:absolute; inset:0; z-index:5;' +
' background:rgba(255,255,255,0.82); border-radius:4px;' +
' align-items:center; justify-content:center; gap:8px; flex-direction:column;">' +
'<div style="width:22px; height:22px; border:3px solid #cbd5e0; border-top-color:#3182ce;' +
' border-radius:50%; animation:milkrun-spin 0.8s linear infinite;"></div>' +
'<div id="cal-overlay-text" style="font-size:0.82rem; font-weight:600; color:#2d3748;">데이터 읽는 중...</div>' +
'</div>';
calendarBox.dataset.ready = '1';
}
function setBusy(isBusy, label) {
busy = isBusy;
if (refreshBtn) {
refreshBtn.disabled = isBusy;
refreshBtn.style.opacity = isBusy ? '0.6' : '1';
refreshBtn.textContent = isBusy ? '읽는 중...' : '새로고침';
}
ensureCalendarShell();
const overlay = document.getElementById('cal-overlay');
const overlayText = document.getElementById('cal-overlay-text');
const body = document.getElementById('cal-body');
if (overlay) overlay.style.display = isBusy ? 'flex' : 'none';
if (overlayText && label) overlayText.textContent = label;
if (body) {
// 읽는 동안에는 날짜를 누를 수 없게 한다
body.style.pointerEvents = isBusy ? 'none' : '';
body.style.opacity = isBusy ? '0.45' : '';
}
}
// ---- 시트 이름 → 날짜 ----
// '20260807(금)' 처럼 앞 8자리가 날짜인 이름을 달력에 올린다
function parseSheetDate(name) {
const m = String(name).match(/(\d{4})(\d{2})(\d{2})/);
if (!m) return null;
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
const date = new Date(y, mo - 1, d);
if (date.getFullYear() !== y || date.getMonth() !== mo - 1 || date.getDate() !== d) return null;
return { key: m[1] + '-' + m[2] + '-' + m[3], year: y, month: mo - 1, day: d };
}
function indexSheets(sheets) {
sheetsByDate = {};
otherSheets = [];
let latest = null;
sheets.forEach(function (sheet) {
const parsed = parseSheetDate(sheet.name);
if (!parsed) {
otherSheets.push(sheet.name);
return;
}
sheetsByDate[parsed.key] = sheet.name;
if (!latest || parsed.key > latest.key) latest = parsed;
});
return latest;
}
// ---- 달력 ----
function renderCalendar(placeholderText) {
calendarBox.style.display = 'block';
ensureCalendarShell();
const body = document.getElementById('cal-body');
if (viewYear === null) {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
}
const first = new Date(viewYear, viewMonth, 1);
const startDow = first.getDay();
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const monthPrefix = viewYear + '-' + String(viewMonth + 1).padStart(2, '0');
const monthCount = Object.keys(sheetsByDate).filter(function (key) {
return key.indexOf(monthPrefix) === 0;
}).length;
let html = '' +
'<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:6px;">' +
'<button type="button" class="cal-nav" data-move="-1" style="border:1px solid #e2e8f0; background:#fff; border-radius:4px; cursor:pointer; padding:2px 8px; font-size:0.8rem;">◀</button>' +
'<div style="font-size:0.88rem; font-weight:600; color:#2d3748;">' + viewYear + '년 ' + (viewMonth + 1) + '월' +
'<span style="color:#a0aec0; font-weight:400; font-size:0.75rem;"> · 출고 ' + monthCount + '일</span></div>' +
'<button type="button" class="cal-nav" data-move="1" style="border:1px solid #e2e8f0; background:#fff; border-radius:4px; cursor:pointer; padding:2px 8px; font-size:0.8rem;">▶</button>' +
'</div>' +
'<div style="display:grid; grid-template-columns:repeat(7,1fr); gap:2px;">';
DOW.forEach(function (label, index) {
const color = index === 0 ? '#e53e3e' : (index === 6 ? '#3182ce' : '#718096');
html += '<div style="text-align:center; font-size:0.7rem; color:' + color + '; padding:2px 0;">' + label + '</div>';
});
for (let i = 0; i < startDow; i++) html += '<div></div>';
const todayKey = dateKeyOf(new Date());
for (let day = 1; day <= daysInMonth; day++) {
const key = monthPrefix + '-' + String(day).padStart(2, '0');
const sheetName = sheetsByDate[key];
const isSelected = selectedDate === key;
let style = 'text-align:center; padding:5px 0; border-radius:4px; font-size:0.78rem;';
// 오늘 날짜는 테두리로 표시해 기준점을 알 수 있게 한다
if (key === todayKey) style += ' outline:2px solid #ed8936; outline-offset:-2px;';
let attrs = '';
if (sheetName) {
attrs = ' class="cal-day" data-date="' + key + '" title="' + escapeHtml(sheetName) + '"';
style += isSelected
? ' background:#2b6cb0; color:#fff; font-weight:700; cursor:pointer;'
: ' background:#bee3f8; color:#2b6cb0; font-weight:600; cursor:pointer;';
} else {
style += ' color:#cbd5e0;';
}
html += '<div' + attrs + ' style="' + style + '">' + day + '</div>';
}
html += '</div>';
if (placeholderText) {
html += '<div style="text-align:center; color:#a0aec0; font-size:0.75rem; padding:6px 0 2px;">' +
escapeHtml(placeholderText) + '</div>';
}
body.innerHTML = html;
body.querySelectorAll('.cal-nav').forEach(function (btn) {
btn.addEventListener('click', function () {
const date = new Date(viewYear, viewMonth + Number(btn.dataset.move), 1);
viewYear = date.getFullYear();
viewMonth = date.getMonth();
renderCalendar();
});
});
body.querySelectorAll('.cal-day').forEach(function (cell) {
cell.addEventListener('click', function () {
if (busy) return;
selectDate(cell.dataset.date);
});
});
}
function renderOtherSheets() {
if (!otherSheets.length) {
otherBox.style.display = 'none';
otherBox.innerHTML = '';
return;
}
otherBox.style.display = 'block';
otherBox.innerHTML = '날짜로 읽지 못한 시트 ' + otherSheets.length + '개: ' +
otherSheets.slice(0, 5).map(escapeHtml).join(', ') +
(otherSheets.length > 5 ? ' 외' : '');
}
// ---- 시트 목록 불러오기 ----
async function loadSheetTabs(forceRefresh) {
selectedDate = null;
showMessage('구글 시트에서 출고일을 읽는 중...', 'info');
setBusy(true, '출고일을 읽는 중...');
try {
const res = await fetch('/api/coupang-milkrun/sheet-tabs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh: !!forceRefresh })
});
if (!res.ok) {
let detail = '출고일을 불러오지 못했습니다. (서버 응답 ' + res.status + ')';
try {
const data = await res.json();
if (data && data.detail) detail = data.detail;
} catch (e) { /* 본문이 JSON이 아니면 기본 메시지 */ }
showMessage(escapeHtml(detail), 'error');
renderCalendar('출고일을 불러오지 못했습니다');
return;
}
const data = await res.json();
const sheets = data.sheets || [];
indexSheets(sheets);
fileInfo.style.display = 'block';
fileInfo.innerHTML =
'<b>' + escapeHtml(data.file_name || '쿠팡 밀크런 출고리스트') + '</b>' +
' <span style="color:#a0aec0;">· 출고일 ' + Object.keys(sheetsByDate).length + '일' +
(data.cached ? ' · 최근 결과' : '') + '</span>';
// 항상 오늘 날짜를 기준으로 본다
const today = new Date();
const todayKey = dateKeyOf(today);
viewYear = today.getFullYear();
viewMonth = today.getMonth();
renderCalendar();
renderOtherSheets();
if (sheetsByDate[todayKey]) {
// 오늘 출고가 있으면 바로 읽어서 분석까지 진행 (메시지는 selectDate가 남긴다)
await selectDate(todayKey);
} else {
showMessage(
'<span style="font-size:0.95rem; font-weight:700;">오늘 출고할 밀크런은 없습니다.</span>',
'alert', 2000);
}
} catch (err) {
console.error(err);
showMessage('출고일 조회 중 오류가 발생했습니다.', 'error');
renderCalendar('출고일을 불러오지 못했습니다');
} finally {
setBusy(false);
}
}
// ---- 날짜 선택 → 값 읽기 → 분석 ----
async function selectDate(dateKey) {
const sheetName = sheetsByDate[dateKey];
if (!sheetName) return;
selectedDate = dateKey;
renderCalendar();
showMessage('<b>' + escapeHtml(sheetName) + '</b> 출고리스트를 읽는 중...', 'info');
setBusy(true, '출고리스트를 읽는 중...');
try {
const res = await fetch('/api/coupang-milkrun/sheet-rows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sheet: sheetName })
});
if (!res.ok) {
let detail = '출고리스트를 읽지 못했습니다. (서버 응답 ' + res.status + ')';
try {
const data = await res.json();
if (data && data.detail) detail = data.detail;
} catch (e) { /* 무시 */ }
showMessage(escapeHtml(detail), 'error');
return;
}
const data = await res.json();
const items = data.items || [];
if (!items.length) {
showMessage('<b>' + escapeHtml(sheetName) + '</b>에서 제품코드/수량을 찾지 못했습니다. ' +
'시트 형식을 확인해주세요.', 'error');
return;
}
if (pasteInput) pasteInput.value = data.text || '';
if (analyzeBtn) analyzeBtn.click(); // 기존 분석 로직 재사용
const skipped = data.skipped
? ' <span style="color:#975a16;">(건너뛴 줄 ' + data.skipped + '개)</span>' : '';
showMessage('<b>' + escapeHtml(sheetName) + '</b> · ' + items.length +
'줄을 불러와 분석했습니다.' + skipped, 'success');
} catch (err) {
console.error(err);
showMessage('출고리스트를 읽는 중 오류가 발생했습니다.', 'error');
} finally {
setBusy(false);
}
}
// ---- 시작 ----
if (refreshBtn) refreshBtn.addEventListener('click', function () { loadSheetTabs(true); });
// 밀크런 탭을 누를 때마다 구글 시트를 다시 읽는다 (시트가 추가/수정됐을 수 있으므로)
const milkrunMenu = document.querySelector('[data-tab="milkrun-tab"]');
if (milkrunMenu) {
milkrunMenu.addEventListener('click', function () {
if (!busy) loadSheetTabs(true);
});
}
// 달력 틀을 먼저 보여주고(덮개가 '읽는 중'을 표시), 출고일은 뒤이어 채운다
renderCalendar();
loadSheetTabs(false);
});
})();