diff --git a/Dockerfile b/Dockerfile index 163eaee..496d91f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,4 +25,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ # APP_ROOT_PATH 를 uvicorn --root-path 로 넘긴다. (shell form 필요 — 환경변수 확장) # 비어 있으면 빈 문자열이 그대로 전달되어 root_path 없이 동작한다. -CMD uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --root-path "${APP_ROOT_PATH:-}" +CMD uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers diff --git a/app/routers/orders.py b/app/routers/orders.py index 40c66fa..cd62daa 100644 --- a/app/routers/orders.py +++ b/app/routers/orders.py @@ -16,7 +16,7 @@ from sqlalchemy.orm import Session from sqlalchemy import String, and_, cast, func, or_, text from urllib.parse import quote -from app.database import SessionLocal, get_db +from app.database import SessionLocal, get_db, get_secondary_engine from app.models import Order from app.auth import get_current_user from app.event_logger import log_event @@ -562,46 +562,188 @@ def run_upload_job(files_data: list[dict], user_id: str, user_data: dict): def upload_file( files: list[UploadFile] = File(...), upload_token: str = Form(...), - current_user = Depends(get_current_user) + custom_date: str = Form(...), + current_user = Depends(get_current_user), + db: Session = Depends(get_db) ): + # 1. 파일 확장자 검증 for file in files: filename = file.filename or "" if not filename.endswith(".xls") and not filename.endswith(".xlsx"): raise HTTPException(status_code=400, detail="XLS 또는 XLSX 파일만 업로드할 수 있습니다.") - user_id = f"{current_user.id}:{upload_token}" - files_data = [] - for file in files: - contents = file.file.read() - files_data.append({ - "contents": contents, - "filename": file.filename or "" - }) - - user_data = { - "id": getattr(current_user, "id", ""), - "email": getattr(current_user, "email", None), - "name": getattr(current_user, "name", None), - } - - filenames_str = ", ".join([f["filename"] for f in files_data]) - log_event(current_user, "파일 업로드 시작", "started", {"filenames": filenames_str}) - set_upload_state( - user_id, - status="queued", - message="업로드 작업이 등록되었습니다.", + filenames_str = ", ".join([f.filename for f in files]) + log_event( + current_user, + "파일 업로드 시작 (낱개 출고 자동분석)", + "started", + {"filenames": filenames_str, "custom_date": custom_date} ) - threading.Thread( - target=run_upload_job, - args=(files_data, user_id, user_data), - daemon=True, - ).start() - return { - "status": "queued", - "upload_token": upload_token, - "message": "업로드 작업이 시작되었습니다. 진행률 창에서 완료 상태를 확인해주세요.", - } + db_records = [] + try: + # 2. 파일 파싱 및 데이터베이스 레코드 임시 적재 + for file in files: + contents = file.file.read() + df = pd.read_excel(io.BytesIO(contents)) + df = df.where(pd.notnull(df), None) + column_map = find_template_columns(list(df.columns)) + + for index, row in df.iterrows(): + rlist = list(row) + recipient_phone = row_value(rlist, column_map, "recipient_phone") + recipient_mobile = row_value(rlist, column_map, "recipient_mobile") + tracking_number = row_value(rlist, column_map, "tracking_number") + address = row_value(rlist, column_map, "address") + + order = Order( + order_date=custom_date, # 사용자가 지정한 주문 날짜 강제 입력 + sequence_num=row_value(rlist, column_map, "sequence_num"), + order_no=row_value(rlist, column_map, "order_no"), + order_no_mall=row_value(rlist, column_map, "order_no_mall"), + recipient_name=row_value(rlist, column_map, "recipient_name"), + product_code=row_value(rlist, column_map, "product_code"), + product_name=row_value(rlist, column_map, "product_name"), + order_quantity=row_value(rlist, column_map, "order_quantity"), + address=address, + address_normalized=normalize_address_key(address), + postal_code=row_value(rlist, column_map, "postal_code"), + recipient_phone=recipient_phone, + recipient_mobile=recipient_mobile, + recipient_phone_normalized=normalize_phone(recipient_phone), + recipient_mobile_normalized=normalize_phone(recipient_mobile), + delivery_memo=row_value(rlist, column_map, "delivery_memo"), + tracking_number=tracking_number, + tracking_number_normalized=normalize_tracking(tracking_number), + vendor=row_value(rlist, column_map, "vendor"), + order_list_1=row_value(rlist, column_map, "order_list_1"), + order_note=row_value(rlist, column_map, "order_note"), + upload_date=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ) + db_records.append(order) + + # 3. 데이터베이스 벌크 저장 및 커밋 + batch_size = 1000 + for i in range(0, len(db_records), batch_size): + chunk = db_records[i:i + batch_size] + db.bulk_save_objects(chunk) + db.commit() + log_event( + current_user, + "파일 업로드 DB 저장 완료", + "success", + {"filenames": filenames_str, "uploaded_count": len(db_records)} + ) + + except Exception as e: + db.rollback() + log_event(current_user, "파일 업로드 실패", "error", {"filenames": filenames_str, "error": str(e)}) + raise HTTPException(status_code=500, detail=f"데이터베이스 저장 실패: {str(e)}") + + # 4. 품목 마스터(itemcode_db) 연동 및 낱개 수량 환산 합계 계산 + try: + item_engine = get_secondary_engine("ITEM_DB_NAME") + + single_items = {} + set_components = {} + + if item_engine: + with item_engine.connect() as conn: + # 1) 단품 마스터 맵 구성 + rs_single = conn.execute(text("SELECT item_code, sabangnet_code, name FROM single_items")) + for row in rs_single: + single_items[row[0]] = { + "sabangnet_code": row[1] or "", + "name": row[2] or "" + } + # 2) 세트 구성품 맵 구성 + rs_comp = conn.execute(text("SELECT set_code, single_code, quantity FROM set_components")) + for row in rs_comp: + s_code = row[0] + if s_code not in set_components: + set_components[s_code] = [] + set_components[s_code].append({ + "single_code": row[1], + "quantity": row[2] + }) + + # 3) 낱개 환산 처리 + single_qty_sums = {} + for order in db_records: + prod_code = (order.product_code or "").strip() + if not prod_code: + continue + + qty = 1 + try: + qty = int(order.order_quantity or 1) + except: + pass + + if prod_code in single_items: + single_qty_sums[prod_code] = single_qty_sums.get(prod_code, 0) + qty + elif prod_code in set_components: + for comp in set_components[prod_code]: + s_code = comp["single_code"] + comp_qty = comp["quantity"] + single_qty_sums[s_code] = single_qty_sums.get(s_code, 0) + (qty * comp_qty) + else: + # DB에 등록되어 있지 않은 코드는 단품으로 간주하고, 사방넷코드와 이름을 보존 + single_items[prod_code] = { + "sabangnet_code": prod_code, + "name": order.product_name or "" + } + single_qty_sums[prod_code] = single_qty_sums.get(prod_code, 0) + qty + + # 5. 결과 엑셀 데이터 빌드 + excel_rows = [] + for s_code, total_qty in single_qty_sums.items(): + s_info = single_items.get(s_code, {"sabangnet_code": s_code, "name": ""}) + excel_rows.append({ + "상품코드[필수]": s_info["sabangnet_code"] or s_code, + "가용수량": total_qty, + "불용수량": s_info["name"] or "" # C열 헤더는 "불용수량", 실데이터는 이름! + }) + + df_out = pd.DataFrame(excel_rows) + if df_out.empty: + df_out = pd.DataFrame(columns=["상품코드[필수]", "가용수량", "불용수량"]) + else: + df_out = df_out[["상품코드[필수]", "가용수량", "불용수량"]] + + # 엑셀 파일 쓰기 + excel_io = io.BytesIO() + with pd.ExcelWriter(excel_io, engine='openpyxl') as writer: + df_out.to_excel(writer, index=False) + excel_io.seek(0) + + # 6. 다운로드 파일명 날짜 포맷 조합 + try: + dt = datetime.strptime(custom_date, "%Y-%m-%d") + except: + dt = datetime.now() + + weekdays = ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"] + weekday_str = weekdays[dt.weekday()] + + # 형식 예: 05월 10일 (일요일)_발주_(낱개 상품 출고).xls + filename = f"{dt.strftime('%m')}월 {dt.strftime('%d')}일 ({weekday_str})_발주_(낱개 상품 출고).xls" + encoded_filename = quote(filename) + + headers = { + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}", + "Access-Control-Expose-Headers": "Content-Disposition" + } + + return StreamingResponse( + excel_io, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers=headers + ) + + except Exception as e: + log_event(current_user, "낱개 출고 분석 오류", "error", {"error": str(e)}) + raise HTTPException(status_code=500, detail=f"낱개 출고 분석 및 엑셀 생성 실패: {str(e)}") @router.get("/progress") async def upload_progress( diff --git a/static/css/style.css b/static/css/style.css index 9f1d1a3..7540f67 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1835,4 +1835,153 @@ input[type="checkbox"] { .nav-profile-area .btn-logout-text span { display: none; } } -/* Force UI Update */ +/* ========================================================================== + Upload Date Picker Custom Calendar Styles + ========================================================================== */ + +#upload-date-modal .modal-content { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.4); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); +} + +.modal-instruction-text { + font-size: 0.88rem; + color: #64748b; + margin-bottom: 1.25rem; + line-height: 1.4; +} + +.custom-calendar-picker { + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 1rem; + background: #ffffff; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.02); + margin-bottom: 1.25rem; +} + +.calendar-nav { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.calendar-nav .icon-btn { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: #f1f5f9; + color: #475569; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.calendar-nav .icon-btn:hover { + background: #e2e8f0; + color: #0f172a; +} + +.calendar-title { + font-size: 0.98rem; + font-weight: 700; + color: #1e293b; +} + +.calendar-weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + text-align: center; + font-weight: 700; + font-size: 0.78rem; + color: #64748b; + margin-bottom: 0.5rem; + border-bottom: 1px solid #f1f5f9; + padding-bottom: 0.5rem; +} + +.calendar-weekdays .weekday.holiday { + color: #dc2626; /* Sunday red */ +} + +.calendar-weekdays .weekday.sat { + color: #2563eb; /* Saturday blue */ +} + +.calendar-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + row-gap: 0.35rem; + text-align: center; +} + +.calendar-day { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + margin: 0 auto; + font-size: 0.85rem; + font-weight: 500; + color: #334155; + border-radius: 50%; + cursor: pointer; + transition: all 0.15s ease; + user-select: none; +} + +.calendar-day:hover:not(.other-month):not(.selected) { + background-color: #f1f5f9; +} + +.calendar-day.other-month { + color: #cbd5e1; + cursor: default; +} + +.calendar-day.sat:not(.other-month):not(.selected) { + color: #2563eb; /* Saturday blue */ +} + +.calendar-day.holiday:not(.other-month):not(.selected) { + color: #dc2626; /* Sunday / Korean holiday red */ +} + +.calendar-day.selected { + background-color: var(--primary-color) !important; + color: #ffffff !important; + font-weight: 700; + transform: scale(1.1); + box-shadow: 0 4px 10px rgba(79, 70, 229, 0.3); +} + +.selected-date-preview { + background: #f8fafc; + border: 1px dashed #cbd5e1; + border-radius: 8px; + padding: 0.75rem; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.88rem; +} + +.selected-date-preview span { + color: #64748b; +} + +.selected-date-preview strong { + color: var(--primary-color); + font-weight: 700; + font-size: 0.95rem; +} + + diff --git a/static/index.html b/static/index.html index 7b5cd6b..67c642c 100644 --- a/static/index.html +++ b/static/index.html @@ -406,6 +406,51 @@ + + +
diff --git a/static/js/app.js b/static/js/app.js index 2dbccde..92e39c1 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -270,82 +270,202 @@ document.addEventListener('DOMContentLoaded', () => { uploadProgressModal.style.display = 'none'; }); - fileUpload.addEventListener('change', async (e) => { + // --- Upload Date picker calendar modal elements --- + const uploadDateModal = document.getElementById('upload-date-modal'); + const uploadDateClose = document.getElementById('upload-date-close'); + const btnCancelUploadDate = document.getElementById('btn-cancel-upload-date'); + const btnSubmitUploadDate = document.getElementById('btn-submit-upload-date'); + const calPrevMonth = document.getElementById('cal-prev-month'); + const calNextMonth = document.getElementById('cal-next-month'); + const calMonthYearDisplay = document.getElementById('cal-month-year-display'); + const calendarDaysGrid = document.getElementById('calendar-days-grid'); + const selectedDateText = document.getElementById('selected-date-text'); + + let pendingFiles = null; + let calendarActiveDate = new Date(); + let calendarSelectedDate = null; + + function isKoreanHoliday(year, month, date) { + const mm = String(month + 1).padStart(2, '0'); + const dd = String(date).padStart(2, '0'); + const mmdd = `${mm}-${dd}`; + + const solarHolidays = [ + "01-01", "03-01", "05-05", "06-06", "08-15", "10-03", "10-09", "12-25" + ]; + if (solarHolidays.includes(mmdd)) return true; + + if (year === 2026) { + const holidays2026 = [ + "02-16", "02-17", "02-18", "02-19", + "05-25", + "09-24", "09-25", "09-26", "09-28" + ]; + return holidays2026.includes(mmdd); + } + if (year === 2025) { + const holidays2025 = [ + "01-27", "01-28", "01-29", "01-30", + "05-06", + "10-05", "10-06", "10-07", "10-08" + ]; + return holidays2025.includes(mmdd); + } + return false; + } + + function renderCalendar() { + const year = calendarActiveDate.getFullYear(); + const month = calendarActiveDate.getMonth(); + + calMonthYearDisplay.textContent = `${year}년 ${String(month + 1).padStart(2, '0')}월`; + calendarDaysGrid.innerHTML = ''; + + const firstDayIndex = new Date(year, month, 1).getDay(); + const lastDay = new Date(year, month + 1, 0).getDate(); + const prevLastDay = new Date(year, month, 0).getDate(); + + for (let i = firstDayIndex - 1; i >= 0; i--) { + const dayDiv = document.createElement('div'); + dayDiv.className = 'calendar-day other-month'; + dayDiv.textContent = prevLastDay - i; + calendarDaysGrid.appendChild(dayDiv); + } + + const weekdays = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"]; + for (let day = 1; day <= lastDay; day++) { + const dayDiv = document.createElement('div'); + dayDiv.className = 'calendar-day'; + dayDiv.textContent = day; + + const currentDate = new Date(year, month, day); + const dayOfWeek = currentDate.getDay(); + + if (dayOfWeek === 0 || isKoreanHoliday(year, month, day)) { + dayDiv.classList.add('holiday'); + } else if (dayOfWeek === 6) { + dayDiv.classList.add('sat'); + } + + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + if (calendarSelectedDate === dateStr) { + dayDiv.classList.add('selected'); + } + + dayDiv.addEventListener('click', () => { + calendarSelectedDate = dateStr; + calendarDaysGrid.querySelectorAll('.calendar-day.selected').forEach(el => { + el.classList.remove('selected'); + }); + dayDiv.classList.add('selected'); + selectedDateText.textContent = `${year}년 ${month + 1}월 ${day}일 (${weekdays[dayOfWeek]})`; + btnSubmitUploadDate.disabled = false; + }); + + calendarDaysGrid.appendChild(dayDiv); + } + + const totalCells = firstDayIndex + lastDay; + const nextDaysToFill = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7); + for (let day = 1; day <= nextDaysToFill; day++) { + const dayDiv = document.createElement('div'); + dayDiv.className = 'calendar-day other-month'; + dayDiv.textContent = day; + calendarDaysGrid.appendChild(dayDiv); + } + } + + calPrevMonth.addEventListener('click', () => { + calendarActiveDate.setMonth(calendarActiveDate.getMonth() - 1); + renderCalendar(); + }); + + calNextMonth.addEventListener('click', () => { + calendarActiveDate.setMonth(calendarActiveDate.getMonth() + 1); + renderCalendar(); + }); + + const closeUploadDateModal = () => { + uploadDateModal.classList.remove('show'); + pendingFiles = null; + fileUpload.value = ''; + }; + + if (uploadDateClose) uploadDateClose.addEventListener('click', closeUploadDateModal); + if (btnCancelUploadDate) btnCancelUploadDate.addEventListener('click', closeUploadDateModal); + + fileUpload.addEventListener('change', (e) => { const files = e.target.files; if (!files || files.length === 0) return; - logUiEvent('파일 업로드 파일 선택', { - count: files.length, - filenames: Array.from(files).map(f => f.name) - }); + + pendingFiles = files; + calendarActiveDate = new Date(); + calendarSelectedDate = null; + btnSubmitUploadDate.disabled = true; + selectedDateText.textContent = '선택 안 됨'; + + renderCalendar(); + uploadDateModal.classList.add('show'); + }); + btnSubmitUploadDate.addEventListener('click', async () => { + if (!pendingFiles || !calendarSelectedDate) return; + + btnSubmitUploadDate.disabled = true; + const originalHtml = btnSubmitUploadDate.innerHTML; + btnSubmitUploadDate.innerHTML = ' 업로드 및 분석 중...'; + const uploadToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const formData = new FormData(); - for (let i = 0; i < files.length; i++) { - formData.append('files', files[i]); + for (let i = 0; i < pendingFiles.length; i++) { + formData.append('files', pendingFiles[i]); } formData.append('upload_token', uploadToken); - - resetUploadProgressModal(); - uploadProgressModal.style.display = 'flex'; - fileUpload.disabled = true; - - const eventSource = new EventSource(appUrl(`/api/orders/progress?upload_token=${encodeURIComponent(uploadToken)}`)); - - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.status !== 'idle') { - setUploadProgress(data); - } - if (data.status === 'completed' || data.status === 'error') { - eventSource.close(); - fileUpload.disabled = false; - fileUpload.value = ''; - if (data.status === 'completed') { - showToast(data.message || '업로드와 검색 인덱스 반영이 완료되었습니다.', 'success'); - } else { - showToast(data.message || '업로드 실패', 'error'); - } - } - } catch (err) { - console.error('Upload progress parse error', err); - } - }; - + formData.append('custom_date', calendarSelectedDate); + try { - const res = await fetch(appUrl(`/api/orders/upload`), { + const response = await fetch(appUrl(`/api/orders/upload`), { method: 'POST', body: formData }); - - const data = await res.json(); - if (res.ok) { - if (!['completed', 'error'].includes(latestUploadProgress?.status)) { - setUploadProgress({ - ...(latestUploadProgress || {}), - status: data.status || 'queued', - message: data.message || '업로드 작업이 시작되었습니다.' - }); + + if (response.ok) { + let downloadFilename = '낱개상품출고.xls'; + const disposition = response.headers.get('Content-Disposition'); + if (disposition && disposition.indexOf('filename*=') !== -1) { + const matches = disposition.split("filename*=UTF-8''"); + if (matches.length > 1) { + downloadFilename = decodeURIComponent(matches[1]); + } } + + const fileBlob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(fileBlob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = downloadFilename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(downloadUrl); + + showToast('업로드 완료 및 출고 파일 다운로드 완료', 'success'); + uploadDateModal.classList.remove('show'); } else { - showToast(data.detail || 'Upload failed', 'error'); - setUploadProgress({ - status: 'error', - message: data.detail || '업로드 실패' - }); - eventSource.close(); - fileUpload.disabled = false; - fileUpload.value = ''; + let errMsg = '업로드 분석 실패'; + try { + const data = await response.json(); + errMsg = data.detail || errMsg; + } catch(_) {} + showToast(errMsg, 'error'); } } catch (error) { showToast('네트워크 오류가 발생했습니다.', 'error'); - setUploadProgress({ - status: 'error', - message: '네트워크 오류가 발생했습니다.' - }); - eventSource.close(); - fileUpload.disabled = false; + } finally { + btnSubmitUploadDate.disabled = false; + btnSubmitUploadDate.innerHTML = originalHtml; fileUpload.value = ''; + pendingFiles = null; } });