diff --git a/cafe24_api.py b/cafe24_api.py index 6548e04..7a348e7 100644 --- a/cafe24_api.py +++ b/cafe24_api.py @@ -2,6 +2,7 @@ import requests import json import os import time +from pathlib import Path from datetime import datetime, timedelta # 환경변수에서 카페24 인증 정보 로드 @@ -9,14 +10,21 @@ CLIENT_ID = os.getenv("CAFE24_CLIENT_ID", "") CLIENT_SECRET = os.getenv("CAFE24_CLIENT_SECRET", "") MALL_ID = os.getenv("CAFE24_MALL_ID", "miraskitchen") REDIRECT_URI = os.getenv("CAFE24_REDIRECT_URI", "") -TOKEN_FILE = "cafe24_tokens.json" +TOKEN_FILE = os.getenv("CAFE24_TOKEN_FILE", str(Path(__file__).resolve().parent / "cafe24_tokens.json")) +REQUEST_TIMEOUT = 30 -def get_auth_url(): - # state parameter could be added for security but keeping it simple for local app +def get_auth_url(redirect_uri=None, state=None): + redirect_uri = redirect_uri or REDIRECT_URI url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/authorize" - url += f"?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}" - url += f"&scope=mall.read_order,mall.write_order,mall.read_product" - return url + params = { + "response_type": "code", + "client_id": CLIENT_ID, + "redirect_uri": redirect_uri, + "scope": "mall.read_order mall.write_order mall.read_product", + } + if state: + params["state"] = state + return requests.Request("GET", url, params=params).prepare().url import base64 @@ -25,7 +33,8 @@ def _get_basic_auth_header(): encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8') return f"Basic {encoded}" -def request_new_token(auth_code: str): +def request_new_token(auth_code: str, redirect_uri=None): + redirect_uri = redirect_uri or REDIRECT_URI url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token" headers = { "Authorization": _get_basic_auth_header(), @@ -34,15 +43,15 @@ def request_new_token(auth_code: str): data = { "grant_type": "authorization_code", "code": auth_code, - "redirect_uri": REDIRECT_URI + "redirect_uri": redirect_uri } - response = requests.post(url, headers=headers, data=data) + response = requests.post(url, headers=headers, data=data, timeout=REQUEST_TIMEOUT) if response.status_code == 200: _save_tokens(response.json()) return True else: - raise Exception(f"Failed to get token: {response.text}") + raise Exception(f"카페24 인증 토큰 발급에 실패했습니다. (HTTP {response.status_code})") def refresh_access_token(refresh_token: str): url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token" @@ -55,15 +64,13 @@ def refresh_access_token(refresh_token: str): "refresh_token": refresh_token } - response = requests.post(url, headers=headers, data=data) + response = requests.post(url, headers=headers, data=data, timeout=REQUEST_TIMEOUT) if response.status_code == 200: _save_tokens(response.json()) return True else: - # Refresh token might be expired. Need to re-authenticate. - if os.path.exists(TOKEN_FILE): - os.remove(TOKEN_FILE) - raise Exception("Refresh token expired or invalid. Please re-authenticate.") + # Preserve the token file so a failed refresh never destroys diagnostics. + raise Exception("카페24 인증이 만료되었습니다. 화면의 '재연동 필요' 버튼을 눌러 다시 연동해주세요.") def _save_tokens(token_data): # Cafe24 returns expires_at in string format like "2023-10-01T12:00:00.000" @@ -76,6 +83,62 @@ def _save_tokens(token_data): with open(TOKEN_FILE, 'w', encoding='utf-8') as f: json.dump(token_data, f) +def _parse_cafe24_datetime(value): + if not value: + return None + + text = str(value).strip() + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + pass + + for date_format in ("%m/%d/%Y %H:%M:%S", "%Y/%m/%d %H:%M:%S"): + try: + return datetime.strptime(text, date_format) + except ValueError: + continue + return None + +def _is_expired(value, *, leeway_seconds=0): + expires_at = _parse_cafe24_datetime(value) + if expires_at is None: + return True + now = datetime.now(expires_at.tzinfo) if expires_at.tzinfo else datetime.now() + return now >= expires_at - timedelta(seconds=leeway_seconds) + +def get_token_status(): + if not os.path.exists(TOKEN_FILE): + return { + "authenticated": False, + "reauth_required": True, + "message": "카페24 연동이 필요합니다.", + } + + try: + with open(TOKEN_FILE, "r", encoding="utf-8") as f: + tokens = json.load(f) + except (OSError, json.JSONDecodeError): + return { + "authenticated": False, + "reauth_required": True, + "message": "카페24 인증 파일을 읽을 수 없습니다. 다시 연동해주세요.", + } + + refresh_expired = _is_expired(tokens.get("refresh_token_expires_at")) + access_expired = _is_expired(tokens.get("expires_at"), leeway_seconds=60) + authenticated = bool(tokens.get("access_token")) and not refresh_expired + return { + "authenticated": authenticated, + "reauth_required": not authenticated, + "access_expired": access_expired, + "message": ( + "카페24 인증이 만료되었습니다. 다시 연동해주세요." + if not authenticated + else "카페24가 연동되어 있습니다." + ), + } + def get_valid_access_token(): if not os.path.exists(TOKEN_FILE): raise Exception("No tokens found. Please authenticate first.") @@ -83,18 +146,9 @@ def get_valid_access_token(): with open(TOKEN_FILE, 'r', encoding='utf-8') as f: tokens = json.load(f) - expires_at_str = tokens.get('expires_at') - # Parse Cafe24 typical datetime format or isoformat - try: - if '.' in expires_at_str: # e.g. "2023-10-01T12:00:00.000" - expires_at = datetime.strptime(expires_at_str[:19], "%Y-%m-%dT%H:%M:%S") - else: - expires_at = datetime.fromisoformat(expires_at_str) - except: - expires_at = datetime.now() - timedelta(minutes=1) # force refresh on parse error - - # Check if expired - if datetime.now() >= expires_at: + if _is_expired(tokens.get("expires_at"), leeway_seconds=60): + if not tokens.get("refresh_token") or _is_expired(tokens.get("refresh_token_expires_at")): + raise Exception("카페24 인증이 만료되었습니다. 화면의 '재연동 필요' 버튼을 눌러 다시 연동해주세요.") print("Cafe24 Access token expired, refreshing...") refresh_access_token(tokens.get('refresh_token')) return get_valid_access_token() @@ -119,7 +173,7 @@ def get_cafe24_orders_count(status="N20"): "order_status": status } - response = requests.get(url, headers=headers, params=params) + response = requests.get(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT) if response.status_code == 200: return response.json().get('count', 0) return 0 @@ -155,7 +209,7 @@ def get_cafe24_orders(status="N20", progress_callback=None): "embed": "receivers,items" # embed items and receiver addresses } - response = requests.get(url, headers=headers, params=params) + response = requests.get(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT) if response.status_code != 200: raise Exception(f"Failed to fetch Cafe24 orders: {response.text}") @@ -205,7 +259,7 @@ def update_cafe24_tracking(dispatch_list): } } - response = requests.put(url, headers=headers, json=payload) + response = requests.put(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT) if response.status_code in [200, 201]: results["success"].append(order_id) @@ -236,7 +290,7 @@ def get_product_details(product_no): } url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/products/{product_no}?embed=variants" - response = requests.get(url, headers=headers) + response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT) if response.status_code == 200: return response.json().get('product', {}) return {} diff --git a/main.py b/main.py index 7c9a88e..2f8beee 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import os import sys import psycopg2 import psycopg2.extras +import os import time import json import re @@ -13,6 +14,7 @@ import threading import urllib.request import urllib.parse import uuid +import secrets from urllib.parse import quote from dotenv import load_dotenv from fastapi import FastAPI, HTTPException, Request, Body, UploadFile, File, Form, Query, BackgroundTasks @@ -20,6 +22,10 @@ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, Fil from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.sessions import SessionMiddleware import pandas as pd + +# Load local settings before application modules read environment values. +load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) + from naver_api import get_access_token, get_payment_done_orders, get_product_order_details, dispatch_orders, confirm_orders, get_product_details import cafe24_api from fastapi.staticfiles import StaticFiles @@ -27,9 +33,6 @@ from fastapi.templating import Jinja2Templates from pydantic import BaseModel from typing import List, Optional, Dict, Any -# .env 로드 -load_dotenv() - # ========================================== # SSO / 서브경로 호스팅 설정 # ========================================== @@ -204,6 +207,12 @@ async def corm_logout(): from routers.returns import router as returns_router app.include_router(returns_router) +from routers.coupang_milkrun import router as coupang_milkrun_router, invalidate_code_map_cache as invalidate_milkrun_code_cache +app.include_router(coupang_milkrun_router) + +from routers.mall_event import router as mall_event_router +app.include_router(mall_event_router) + # Ensure directories exist os.makedirs("static/css", exist_ok=True) os.makedirs("static/js", exist_ok=True) @@ -213,14 +222,21 @@ app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") # Globals -SMS_HISTORY_FILE = 'sms_history.dat' +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +SMS_HISTORY_FILE = os.getenv('SMS_HISTORY_FILE', os.path.join(BASE_DIR, 'sms_history.dat')) +GSPREAD_CRED_FILE = os.getenv('GSPREAD_CRED_FILE', os.path.join(BASE_DIR, 'manual-ordering.json')) +GSPREAD_CRED_JSON = os.getenv('GSPREAD_CRED_JSON', '') +MANUAL_SPREADSHEET_ID = os.getenv('MANUAL_SPREADSHEET_ID', '1QxFtjDurPPZd8NmtXBy4XEUIzkf93aXD5Mlx5kB33xw') +SETTINGS_SPREADSHEET_ID = os.getenv('SETTINGS_SPREADSHEET_ID', '1k7SNEdIaRtGXzQNdJSQAiNSDRwzOePDRTLHY_mNCO8M') +MANUAL_HISTORY_FILE = os.getenv('MANUAL_HISTORY_FILE', os.path.join(BASE_DIR, 'manual_history.dat')) SMS_HISTORY_HEADERS = ["일시", "주문유형", "입금액", "주문아이템", "메시지 내용", "이름", "연락처", "주소"] -GSPREAD_CRED_FILE = 'manual-ordering.json' -MANUAL_SPREADSHEET_ID = '1QxFtjDurPPZd8NmtXBy4XEUIzkf93aXD5Mlx5kB33xw' -SETTINGS_SPREADSHEET_ID = '1k7SNEdIaRtGXzQNdJSQAiNSDRwzOePDRTLHY_mNCO8M' -MANUAL_HISTORY_FILE = 'manual_history.dat' -# ----- Utility Functions ----- +def env_bool(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "y", "on"} + def first_nonempty(data: dict, *keys: str) -> str: for key in keys: value = data.get(key) @@ -228,20 +244,43 @@ def first_nonempty(data: dict, *keys: str) -> str: return str(value) return "" +def load_service_account_credentials(scopes): + if Credentials is None: + raise Exception("google-auth is not installed.") + + if GSPREAD_CRED_JSON: + try: + info = json.loads(GSPREAD_CRED_JSON) + return Credentials.from_service_account_info(info, scopes=scopes) + except json.JSONDecodeError as e: + raise Exception("GSPREAD_CRED_JSON is not valid JSON.") from e + + if os.path.exists(GSPREAD_CRED_FILE): + return Credentials.from_service_account_file(GSPREAD_CRED_FILE, scopes=scopes) + + raise Exception( + f"Google service account key not found. " + f"Set GSPREAD_CRED_JSON or put key file at: {GSPREAD_CRED_FILE}" + ) + +# ----- Utility Functions ----- def read_config(force_refresh=False): global _CONFIG_ROWS_CACHE, _CONFIG_ROWS_CACHE_TIME - + + config = configparser.ConfigParser(allow_no_value=True) + config.optionxform = str + rows = [] - - # 1. 캐시 유효성 판단 (force_refresh가 아니고, 캐시가 존재하며, TTL이 지나지 않은 경우) + + # 1. 캐시 유효성 판단 (force_refresh 가 아니고, 캐시가 존재하며, TTL 이 지나지 않은 경우) if not force_refresh and _CONFIG_ROWS_CACHE is not None: if time.time() - _CONFIG_ROWS_CACHE_TIME < CONFIG_CACHE_TTL: rows = _CONFIG_ROWS_CACHE - + # 2. 캐시가 없거나 만료된 경우 구글 시트에서 직접 새로 로드 if not rows: with _CONFIG_LOCK: - # 락 대기 중에 다른 스레드에서 캐시를 갱신했을 수 있으므로 다시 검사 (Double-Checked Locking) + # 락 대기 중 다른 스레드가 캐시를 갱신했을 수 있으므로 다시 검사 (Double-Checked Locking) if not force_refresh and _CONFIG_ROWS_CACHE is not None and time.time() - _CONFIG_ROWS_CACHE_TIME < CONFIG_CACHE_TTL: rows = _CONFIG_ROWS_CACHE else: @@ -253,15 +292,11 @@ def read_config(force_refresh=False): except Exception as e: time.sleep(1.5) print(f"Google Sheets 불러오기 재시도 중...: {e}") - + if rows: _CONFIG_ROWS_CACHE = rows _CONFIG_ROWS_CACHE_TIME = time.time() - - # 3. 로드된 로우 데이터를 ConfigParser 형식으로 파싱 - config = configparser.ConfigParser(allow_no_value=True) - config.optionxform = str - + if rows and len(rows) > 1: for row in rows: if not row or not row[0].strip(): @@ -269,22 +304,23 @@ def read_config(force_refresh=False): section = row[0].strip() if section == "대분류 (Section)": continue - + key = row[1].strip() if len(row) > 1 else "" - + c_cells = [x.strip() for x in row[2:]] while c_cells and not c_cells[-1]: c_cells.pop() val = " | ".join(c_cells) - + if not config.has_section(section): config.add_section(section) - + if key: if val: config.set(section, key, val) else: config.set(section, key) + config.set(section, key) return config def write_config(config): @@ -301,13 +337,13 @@ def write_config(config): else: v_parts = [p.strip() for p in v.split("|")] rows.append([section, k] + v_parts) - + for attempt in range(3): try: ws = get_settings_worksheet() ws.clear() ws.update(values=rows) - + # 구글 시트에 저장이 성공하면 로컬 캐시도 즉시 최신화 with _CONFIG_LOCK: _CONFIG_ROWS_CACHE = rows @@ -316,7 +352,7 @@ def write_config(config): except Exception as e: print(f"Google Sheets 저장 시도 {attempt+1}/3 실패: {e}") time.sleep(1.5) - + print("Google Sheets에 설정을 저장하는 데 최종 실패했습니다.") raise Exception("구글 시트 연동 오류 (할당량 초과). 잠시 후 다시 시도해주세요.") @@ -351,7 +387,6 @@ async def get_config(): data[section]["__order"] = list(config.options(section)) return JSONResponse(data) - @app.post("/api/config/refresh") async def refresh_config(): """구글 시트로부터 실시간으로 설정을 동기화하여 캐시를 강제 갱신합니다.""" @@ -382,23 +417,28 @@ class ProductsPayload(BaseModel): groups: List[ProductGroup] products: Dict[str, List[ProductItem]] +class SubgroupNamePayload(BaseModel): + group_key: str + subgroup_index: int + name: str = "" + @app.post("/api/products/settings") async def save_products_settings(payload: ProductsPayload): config = read_config() - + if config.has_section("GROUP_NAMES"): config.remove_section("GROUP_NAMES") config.add_section("GROUP_NAMES") - + for g in payload.groups: config.set("GROUP_NAMES", g.new_code, g.name) - + for g in payload.groups: if config.has_section(g.old_code): config.remove_section(g.old_code) if config.has_section(g.new_code): config.remove_section(g.new_code) - + for g in payload.groups: config.add_section(g.new_code) items = payload.products.get(g.new_code, []) @@ -416,10 +456,36 @@ async def save_products_settings(payload: ProductsPayload): val_parts.append(str(item.dprice)) val = " | ".join(val_parts) config.set(g.new_code, key, val) - + write_config(config) return {"status": "success"} +@app.post("/api/products/subgroup-name") +async def save_product_subgroup_name(payload: SubgroupNamePayload): + group_key = payload.group_key.strip() + subgroup_index = payload.subgroup_index + subgroup_name = payload.name.strip() + + if not group_key or subgroup_index < 0: + raise HTTPException(status_code=400, detail="잘못된 상품 그룹 정보입니다.") + if len(subgroup_name) > 40: + raise HTTPException(status_code=400, detail="그룹 이름은 40자 이내로 입력해주세요.") + + config = read_config() + if not config.has_section(group_key): + raise HTTPException(status_code=404, detail="상품 분류를 찾을 수 없습니다.") + if not config.has_section("SUBGROUP_NAMES"): + config.add_section("SUBGROUP_NAMES") + + setting_key = f"{group_key}__{subgroup_index}" + if subgroup_name: + config.set("SUBGROUP_NAMES", setting_key, subgroup_name) + else: + config.remove_option("SUBGROUP_NAMES", setting_key) + + write_config(config) + return {"status": "success", "name": subgroup_name} + class OrderItem(BaseModel): code: str @@ -433,7 +499,7 @@ class OrderPayload(BaseModel): address: str phone: str comment: str - order_type: str # e.g., 'noolak', 'pason', 'bullyang', 'ellen', 'mira', 'normal' + order_type: str # e.g., 'noolak', 'pason', 'bullyang', 'misdelivery', 'ellen', 'mira', 'normal' no_lid: bool items: List[OrderItem] is_ellen: bool = False @@ -460,7 +526,8 @@ def format_phone_number(num_str): -# ----- Google Sheets Config Cache & Global Sheets Cache ----- +# ----- Google Sheets Global Cache ----- +# ----- Google Sheets Config Cache ----- _CONFIG_ROWS_CACHE = None _CONFIG_ROWS_CACHE_TIME = 0.0 _CONFIG_LOCK = threading.Lock() @@ -478,7 +545,7 @@ def get_gspread_client(): raise Exception("gspread 라이브러리가 없습니다.") if gs_client_global is None: scopes = ['https://www.googleapis.com/auth/spreadsheets'] - creds = Credentials.from_service_account_file(GSPREAD_CRED_FILE, scopes=scopes) + creds = load_service_account_credentials(scopes) gs_client_global = gspread.authorize(creds) return gs_client_global @@ -516,7 +583,10 @@ def get_sms_history_worksheet(): worksheet_sms_history_global = ss.worksheet("문자내역") except gspread.exceptions.WorksheetNotFound: worksheet_sms_history_global = ss.add_worksheet(title="문자내역", rows=1000, cols=10) - worksheet_sms_history_global.append_row(SMS_HISTORY_HEADERS) + if worksheet_sms_history_global.col_count < len(SMS_HISTORY_HEADERS): + worksheet_sms_history_global.add_cols(len(SMS_HISTORY_HEADERS) - worksheet_sms_history_global.col_count) + if worksheet_sms_history_global.row_values(1)[:len(SMS_HISTORY_HEADERS)] != SMS_HISTORY_HEADERS: + worksheet_sms_history_global.update(values=[SMS_HISTORY_HEADERS], range_name="A1:H1", value_input_option='USER_ENTERED') return worksheet_sms_history_global def get_manual_history_worksheet(): @@ -533,55 +603,67 @@ def get_manual_history_worksheet(): @app.on_event("startup") async def startup_event(): - init_db() - try: - print("구글 시트 연동 초기화 중...") - get_manual_worksheet() - get_settings_worksheet() - get_sms_history_worksheet() - get_manual_history_worksheet() - print("구글 시트 연동 완료!") - - # 캐시 초기 적재 (Warm up cache) - print("구글 시트 설정 캐시 초기 적재 시작...") - read_config(force_refresh=True) - print("구글 시트 설정 캐시 적재 완료!") - except Exception as e: - print(f"구글 시트 연동 초기화 실패 (사용 전 권한을 확인하세요): {str(e)}") + skip_db_init = env_bool("SKIP_DB_INIT", False) + continue_without_db = env_bool("CONTINUE_WITHOUT_DB", False) + if skip_db_init: + print("SKIP_DB_INIT=1 이므로 DB 초기화를 건너뜁니다.") + else: + try: + init_db() + except Exception as e: + if continue_without_db: + print(f"[Warning] DB 초기화 실패. CONTINUE_WITHOUT_DB=1 이므로 서버는 계속 시작합니다: {e}") + else: + raise + if env_bool("SKIP_GOOGLE_INIT", False): + print("SKIP_GOOGLE_INIT=1 이므로 구글 시트 선초기화를 건너뜁니다.") + else: + try: + print("구글 시트 연동 초기화 중...") + get_manual_worksheet() + get_settings_worksheet() + get_sms_history_worksheet() + get_manual_history_worksheet() + print("구글 시트 연동 완료!") + except Exception as e: + print(f"구글 시트 연동 초기화 실패 (사용 전 권한을 확인하세요): {str(e)}") @app.post("/api/order/submit") async def submit_order_submit(payload: OrderPayload): date_str, time_str, current_datetime_str = get_current_time_info() c_tel = format_phone_number(payload.phone) - + + order_status_map = {'noolak': '누락', 'pason': '파손', 'bullyang': '불량', 'misdelivery': '오배송'} + order_status = order_status_map.get(payload.order_type, "") + if payload.is_ellen: title = "수동발주(엘렌)" else: - title_map = {'noolak': '누락', 'pason': '파손', 'bullyang': '불량', 'normal': '수동발주'} - title = title_map.get(payload.order_type, "수동발주") - + title = "수동발주" if not payload.customer_name: title = "재구매" - + try: worksheet = get_manual_worksheet() - + rows = [] lines = [] for i, item in enumerate(payload.items): lid_suffix = "(뚜껑 없이 통만)" if payload.no_lid and item.item_type == "ORDER_SINGLE" else "" - sub_text = f"{title}, {item.name}{lid_suffix} {item.quantity}개{' 발송' if lid_suffix else ''}" - lines.append(f"{payload.customer_name} 고객님 {item.name}{lid_suffix} {item.quantity}개 발송, {title}") - + status_prefix = f", {order_status}" if order_status else "" + sub_text = f"{title}{status_prefix}, {item.name}{lid_suffix} {item.quantity}개{' 발송' if lid_suffix else ''}" + clipboard_status_suffix = f", {order_status}" if order_status else "" + lines.append(f"{payload.customer_name} 고객님 {item.name}{lid_suffix} {item.quantity}개 발송{clipboard_status_suffix}") + c_comment = payload.comment or "빠른배송 부탁드립니다." c_amount = payload.total_amount if i == len(payload.items) - 1 else "" row_data = [current_datetime_str, "", c_amount, payload.customer_name, item.code, item.name, item.quantity, payload.address, "", c_tel, c_tel, c_comment, "", title, sub_text] rows.append(row_data) - + if rows: worksheet.append_rows(rows, value_input_option='USER_ENTERED') - + clipboard_text = "\r\n".join(lines) + "\r\n" return {"status": "success", "message": f"{len(rows)}개 항목 구글 시트 입력 및 클립보드 준비 완료.", "clipboard_text": clipboard_text, "timestamp": current_datetime_str} except Exception as e: @@ -594,31 +676,31 @@ async def submit_order_submit(payload: OrderPayload): async def download_and_clear_sheet(): try: scopes = ['https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive'] - creds = Credentials.from_service_account_file(GSPREAD_CRED_FILE, scopes=scopes) - + creds = load_service_account_credentials(scopes) + import google.auth.transport.requests request = google.auth.transport.requests.Request() creds.refresh(request) access_token = creds.token - + worksheet = get_manual_worksheet() sheet_gid = worksheet.id - + url = f"https://docs.google.com/spreadsheets/export?id={MANUAL_SPREADSHEET_ID}&exportFormat=xlsx" req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}"}) with urllib.request.urlopen(req) as response: content = response.read() - + korea_tz = datetime.timezone(datetime.timedelta(hours=9)) now = datetime.datetime.now(korea_tz) weekdays = ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"] weekday_str = weekdays[now.weekday()] date_str = f"{now.strftime('%m')}월 {now.strftime('%d')}일 ({weekday_str})" filename = f"{date_str} 5 수동발주.xlsx" - + # Clear sheet values instantly worksheet.batch_clear(["A2:Z10000"]) - + # Clear formatting (colors, borders, etc) from row 2 downwards body = { "requests": [ @@ -634,7 +716,7 @@ async def download_and_clear_sheet(): ] } worksheet.spreadsheet.batch_update(body) - + encoded_filename = urllib.parse.quote(filename) headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" @@ -712,7 +794,7 @@ async def get_history(): return history except Exception as e: print(f"구글 시트에서 기록을 읽어오는 중 에러: {e}") - + if os.path.exists(SMS_HISTORY_FILE): return [json.loads(line) for line in open(SMS_HISTORY_FILE, 'r', encoding='utf-8') if line.strip()] return [] @@ -722,17 +804,17 @@ async def add_history(data: dict): with open(SMS_HISTORY_FILE, 'a', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False) f.write('\n') - + # 구글 시트에 기록 try: ws = get_sms_history_worksheet() - + # 주문 품목 텍스트화 items_str = "" items = data.get("items", []) if items: items_str = ", ".join([f"{i.get('label', '')}[{i.get('code', '')}] {i.get('qty', 0)}개" for i in items]) - + # 행 높이가 길어지지 않도록 줄바꿈을 띄어쓰기로 변경 preview_text = data.get("previewText", "").replace("\n", " ").replace("\r", "") customer_name = first_nonempty(data, "custName", "customer_name", "customerName", "name") @@ -753,20 +835,20 @@ async def add_history(data: dict): ws.update(values=[row], range_name=f"A{next_row}:H{next_row}", value_input_option='USER_ENTERED') except Exception as e: print(f"구글 시트 문자내역 기록 실패: {e}") - + return {"status": "success"} @app.delete("/api/sms/history") async def clear_history(): if os.path.exists(SMS_HISTORY_FILE): os.remove(SMS_HISTORY_FILE) - + try: ws = get_sms_history_worksheet() ws.batch_clear(["A2:Z10000"]) except Exception as e: print(f"구글 시트 문자내역 전체 삭제 실패: {e}") - + return {"status": "success"} @app.delete("/api/sms/history/{index}") @@ -777,9 +859,9 @@ async def delete_history_item(index: int): timestamp_to_delete = None if len(rows) > index + 1: timestamp_to_delete = rows[index + 1][0] if len(rows[index+1]) > 0 else None - + ws.delete_rows(index + 2) - + if timestamp_to_delete and os.path.exists(SMS_HISTORY_FILE): local_history = [] with open(SMS_HISTORY_FILE, 'r', encoding='utf-8') as f: @@ -789,16 +871,16 @@ async def delete_history_item(index: int): local_history.append(json.loads(line)) except: pass - + local_history = [item for item in local_history if item.get("timestamp") != timestamp_to_delete] - + with open(SMS_HISTORY_FILE, 'w', encoding='utf-8') as f: for entry in local_history: json.dump(entry, f, ensure_ascii=False) f.write('\n') except Exception as e: print(f"구글 시트 문자내역 항목 삭제 실패: {e}") - + return {"status": "success"} @@ -808,7 +890,7 @@ import sys async def get_manual_history(): korea_tz = datetime.timezone(datetime.timedelta(hours=9)) today_prefix = datetime.datetime.now(korea_tz).strftime("%Y-%m-%d") - + local_history = {} if os.path.exists(MANUAL_HISTORY_FILE): with open(MANUAL_HISTORY_FILE, 'r', encoding='utf-8') as f: @@ -820,7 +902,7 @@ async def get_manual_history(): local_history[item["timestamp"]] = item except: pass - + history = [] try: ws = get_manual_history_worksheet() @@ -832,7 +914,7 @@ async def get_manual_history(): timestamp = row[0] if not timestamp.startswith(today_prefix): continue - + if timestamp in local_history: history.append(local_history[timestamp]) else: @@ -847,7 +929,7 @@ async def get_manual_history(): return history except Exception as e: print(f"구글 시트에서 수동발주 기록을 읽어오는 중 에러: {e}") - + return list(local_history.values()) @app.post("/api/manual/history") @@ -855,7 +937,7 @@ async def add_manual_history(data: dict): local_history = [] korea_tz = datetime.timezone(datetime.timedelta(hours=9)) today_prefix = datetime.datetime.now(korea_tz).strftime("%Y-%m-%d") - + if os.path.exists(MANUAL_HISTORY_FILE): with open(MANUAL_HISTORY_FILE, 'r', encoding='utf-8') as f: for line in f: @@ -866,14 +948,14 @@ async def add_manual_history(data: dict): local_history.append(item) except: pass - + local_history.append(data) - + with open(MANUAL_HISTORY_FILE, 'w', encoding='utf-8') as f: for entry in local_history: json.dump(entry, f, ensure_ascii=False) f.write('\n') - + try: ws = get_manual_history_worksheet() row = [ @@ -884,7 +966,7 @@ async def add_manual_history(data: dict): ws.append_row(row, value_input_option='USER_ENTERED') except Exception as e: print(f"구글 시트 수동발주내역 기록 실패: {e}") - + return {"status": "success"} @app.delete("/api/manual/history/{timestamp}") @@ -904,7 +986,7 @@ async def delete_manual_history(timestamp: str): ws.delete_rows(start_idx, end_idx) except Exception as e: print(f"구글 시트 수동발주 삭제 실패: {e}") - + try: ws2 = get_manual_history_worksheet() rows2 = ws2.get_all_values() @@ -914,7 +996,7 @@ async def delete_manual_history(timestamp: str): break except Exception as e: print(f"구글 시트 수동발주내역 시트 삭제 실패: {e}") - + # 로컬에서 삭제 if os.path.exists(MANUAL_HISTORY_FILE): local_history = [] @@ -951,7 +1033,7 @@ async def get_fonts(): pass except Exception as e: print(f"폰트 불러오기 오류: {e}") - + if not fonts: fonts_list = ["Inter", "Malgun Gothic", "Arial", "sans-serif"] else: @@ -975,23 +1057,34 @@ async def save_gift_settings(payload: dict): else: config.remove_section(section) config.add_section(section) - + for k, v in rules.items(): if v: config.set(section, k, str(v)) - + if app_settings: app_section = "APP_SETTINGS" if app_section not in config: config.add_section(app_section) - else: - config.remove_section(app_section) - config.add_section(app_section) - + for k, v in app_settings.items(): if v: config.set(app_section, k, str(v)) - + + write_config(config) + return {"status": "success"} + +@app.post("/api/app/settings") +async def save_app_settings(payload: dict): + config = read_config() + app_section = "APP_SETTINGS" + if app_section not in config: + config.add_section(app_section) + + for k, v in payload.items(): + if v is not None: + config.set(app_section, k, str(v)) + write_config(config) return {"status": "success"} @@ -1001,7 +1094,7 @@ DB_PATH = "codes.db" def init_db(): conn = psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) cursor = conn.cursor() - + # 단품 테이블 cursor.execute(''' CREATE TABLE IF NOT EXISTS single_items ( @@ -1036,7 +1129,7 @@ def init_db(): try: conn_return = psycopg2.connect(**RETURN_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) cursor_return = conn_return.cursor() - + # 반품 신청 테이블 cursor_return.execute(''' CREATE TABLE IF NOT EXISTS return_requests ( @@ -1052,7 +1145,7 @@ def init_db(): created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') - + # 반품 입고 테이블 cursor_return.execute(''' CREATE TABLE IF NOT EXISTS return_receivings ( @@ -1125,6 +1218,7 @@ async def add_single_item(payload: SingleItemAdd): (payload.item_code, payload.sabangnet_code, payload.name) ) conn.commit() + invalidate_milkrun_code_cache() return {"status": "success"} except psycopg2.IntegrityError: conn.close() @@ -1143,6 +1237,7 @@ async def update_single_item(item_code: str, payload: SingleItemAdd): ) conn.commit() conn.close() + invalidate_milkrun_code_cache() return {"status": "success"} @app.delete("/api/codes/single/{item_code}") @@ -1152,6 +1247,7 @@ async def delete_single_item(item_code: str): cursor.execute("DELETE FROM single_items WHERE item_code=%s", (item_code,)) conn.commit() conn.close() + invalidate_milkrun_code_cache() return {"status": "success"} @@ -1161,7 +1257,7 @@ async def get_set_items(): cursor = conn.cursor() cursor.execute("SELECT * FROM set_items ORDER BY item_code ASC") sets = cursor.fetchall() - + result = [] for s in sets: s_dict = dict(s) @@ -1169,10 +1265,10 @@ async def get_set_items(): comps = cursor.fetchall() s_dict["components"] = [dict(c) for c in comps] result.append(s_dict) - + conn.close() return {"status": "success", "data": result} - + @app.post("/api/codes/set") async def add_set_item(payload: SetItemAdd): conn = get_db_conn() @@ -1188,6 +1284,7 @@ async def add_set_item(payload: SetItemAdd): (payload.item_code, comp.single_code, comp.quantity) ) conn.commit() + invalidate_milkrun_code_cache() return {"status": "success"} except psycopg2.IntegrityError: conn.rollback() @@ -1208,13 +1305,14 @@ async def update_set_item(item_code: str, payload: SetItemAdd): (item_code, comp.single_code, comp.quantity) ) conn.commit() + invalidate_milkrun_code_cache() return {"status": "success"} except psycopg2.IntegrityError: conn.rollback() raise HTTPException(status_code=400, detail="구성품 업데이트 중 오류 발생. 유효한 단품 코드인지 확인해주세요.") finally: if conn: conn.close() - + @app.delete("/api/codes/set/{item_code}") async def delete_set_item(item_code: str): conn = get_db_conn() @@ -1222,19 +1320,20 @@ async def delete_set_item(item_code: str): cursor.execute("DELETE FROM set_items WHERE item_code=%s", (item_code,)) conn.commit() conn.close() + invalidate_milkrun_code_cache() return {"status": "success"} # ----- 스마트스토어 & 카페24 자동발주 공통 ----- EXCEL_COLUMNS = [ - '주문날짜', '번호', '주문번호', '수령인명', '상품코드', '상품명', '수량', - '주소', '우편번호', '수령인 전화번호', '수령인 휴대폰', '배송시 요구사항', + '주문날짜', '번호', '주문번호', '수령인명', '상품코드', '상품명', '수량', + '주소', '우편번호', '수령인 전화번호', '수령인 휴대폰', '배송시 요구사항', '송장번호', '쇼핑몰명', '비고', '주문번호(쇼핑몰)', '주문목록' ] def generate_order_excel(data, numeric_date, filename, is_cafe24=False, return_bytes=False): df = pd.DataFrame(data, columns=EXCEL_COLUMNS) - + address_replacements = { "경기 ": "경기도 ", "강원도 ": "강원특별자치도 ", @@ -1252,31 +1351,31 @@ def generate_order_excel(data, numeric_date, filename, is_cafe24=False, return_b "충남 ": "충청남도 ", "충북 ": "충청북도 " } - + if '주소' in df.columns: for old_val, new_val in address_replacements.items(): df['주소'] = df['주소'].str.replace(old_val, new_val, regex=False) - + df = df.sort_values(by=['주소', '주문번호']).reset_index(drop=True) df['번호'] = range(1, len(df) + 1) - + output = io.BytesIO() with pd.ExcelWriter(output, engine='openpyxl') as writer: df.to_excel(writer, index=False, sheet_name='Sheet1') - + worksheet = writer.sheets['Sheet1'] from openpyxl.utils import get_column_letter from openpyxl.styles import Alignment, PatternFill, Font - + if is_cafe24: header_fill = PatternFill(start_color="002060", end_color="002060", fill_type="solid") else: header_fill = PatternFill(start_color="009900", end_color="009900", fill_type="solid") - + header_font = Font(name="나눔고딕", bold=True, color="FFFFFF") data_font = Font(name="나눔고딕") center_alignment = Alignment(horizontal="center", vertical="center") - + for row in worksheet.iter_rows(min_row=1, max_row=worksheet.max_row, min_col=1, max_col=17): for cell in row: if cell.row == 1: @@ -1285,18 +1384,18 @@ def generate_order_excel(data, numeric_date, filename, is_cafe24=False, return_b cell.alignment = center_alignment else: cell.font = data_font - + duplicate_groups = {} for i, rowTuple in df.iterrows(): key = (rowTuple['수령인명'], rowTuple['주소']) if key not in duplicate_groups: duplicate_groups[key] = [] duplicate_groups[key].append(i + 2) - + color_60_lighter = PatternFill(start_color="B4C6E7", end_color="B4C6E7", fill_type="solid") color_40_lighter = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid") current_toggle = True - + for key, row_indices in duplicate_groups.items(): if len(row_indices) > 1: fill = color_60_lighter if current_toggle else color_40_lighter @@ -1304,10 +1403,10 @@ def generate_order_excel(data, numeric_date, filename, is_cafe24=False, return_b for c_idx in range(1, 18): worksheet.cell(row=r_idx, column=c_idx).fill = fill current_toggle = not current_toggle - + worksheet.freeze_panes = "H2" worksheet.auto_filter.ref = worksheet.dimensions - + for idx, col in enumerate(df.columns): col_letter = get_column_letter(idx + 1) max_length = 0 @@ -1317,18 +1416,18 @@ def generate_order_excel(data, numeric_date, filename, is_cafe24=False, return_b length = sum(1.8 if ord(c) > 127 else 1.1 for c in val_str) if length > max_length: max_length = length - + adjusted_width = max_length + 2 if adjusted_width > 60: adjusted_width = 60 worksheet.column_dimensions[col_letter].width = adjusted_width - + output.seek(0) - + encoded_filename = urllib.parse.quote(filename) if return_bytes: return output.getvalue(), encoded_filename - + headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" } @@ -1368,21 +1467,21 @@ async def start_smartstore_download(background_tasks: BackgroundTasks, confirm: "filename": None, "error": None } - + def run_task(tid): try: token = get_access_token() statuses = get_payment_done_orders(token) - + if not statuses: task_store[tid]["status"] = "error" task_store[tid]["error"] = "새로운 결제완료(배송대기) 주문이 없습니다." task_store[tid]["status_code"] = 404 return - + po_ids = [s.get('productOrderId') for s in statuses if s.get('productOrderId')] task_store[tid]["total"] = len(po_ids) - + all_order_details = [] chunk_size = 100 for i in range(0, len(po_ids), chunk_size): @@ -1390,35 +1489,35 @@ async def start_smartstore_download(background_tasks: BackgroundTasks, confirm: details = get_product_order_details(token, chunk) all_order_details.extend(details) task_store[tid]["current"] += len(chunk) - + if confirm: for i in range(0, len(po_ids), chunk_size): chunk = po_ids[i:i+chunk_size] confirm_orders(token, chunk) - + data = [] now = datetime.datetime.now() numeric_date = int(now.strftime('%Y%m%d')) - + product_cache = {} code_to_name = get_all_code_to_name_from_db() - + for idx, detail in enumerate(all_order_details): po = detail.get('productOrder', {}) order = detail.get('order', {}) shippingAddress = po.get('shippingAddress', {}) - + product_id = po.get('productId') option_code = str(po.get('optionCode', '')) seller_code = '' - + if product_id: if product_id not in product_cache: product_cache[product_id] = get_product_details(token, product_id) - + p_data = product_cache[product_id] seller_code = p_data.get('originProduct', {}).get('detailAttribute', {}).get('sellerCodeInfo', {}).get('sellerManagementCode', '') - + if not seller_code and option_code: option_info = p_data.get('originProduct', {}).get('detailAttribute', {}).get('optionInfo', {}) found_opt_code = '' @@ -1433,28 +1532,28 @@ async def start_smartstore_download(background_tasks: BackgroundTasks, confirm: break if found_opt_code: seller_code = found_opt_code - + final_quantity = int(po.get('quantity', 1)) if seller_code and '_' in seller_code: parts = seller_code.rsplit('_', 1) if len(parts) == 2 and parts[1].isdigit(): seller_code = parts[0] final_quantity = final_quantity * int(parts[1]) - + base_address = shippingAddress.get('baseAddress', '') detailed_address = shippingAddress.get('detailedAddress', '') full_address = f"{base_address} {detailed_address}".strip() - + final_item_code = seller_code or po.get('optionManageCode', '') or po.get('sellerProductCode', '') original_product_name = po.get('productName', '') product_name = code_to_name.get(final_item_code, original_product_name) - + option_info = po.get('productOption', '') if option_info: order_list = f"{product_name} [{option_info}]=/{po.get('quantity', 1)}" else: order_list = f"{product_name}=/{po.get('quantity', 1)}" - + row = { '주문날짜': numeric_date, '번호': idx + 1, @@ -1475,16 +1574,16 @@ async def start_smartstore_download(background_tasks: BackgroundTasks, confirm: '주문목록': order_list } data.append(row) - + weekdays = ['월요일', '화요일', '수요일', '목요일', '금요일', '토요일', '일요일'] weekday_str = weekdays[now.weekday()] if now.hour >= 15: filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 1 미라네 스마트 스토어-3시.xlsx" else: filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 1 미라네 스마트 스토어-오전.xlsx" - + file_bytes, enc_name = generate_order_excel(data, numeric_date, filename, return_bytes=True) - + task_store[tid]["current"] = task_store[tid]["total"] task_store[tid]["file_bytes"] = file_bytes task_store[tid]["filename"] = enc_name @@ -1503,7 +1602,7 @@ async def start_smartstore_download(background_tasks: BackgroundTasks, confirm: async def upload_invoices(file: UploadFile = File(...), deliveryCompanyCode: str = Form("CJGLS")): try: content = await file.read() - + if file.filename.endswith('.xls'): try: df = pd.read_excel(io.BytesIO(content), engine='xlrd') @@ -1514,23 +1613,23 @@ async def upload_invoices(file: UploadFile = File(...), deliveryCompanyCode: str return JSONResponse({"status": "error", "message": f"xls 파일 파싱 실패: {str(e2)}"}, status_code=400) else: df = pd.read_excel(io.BytesIO(content), engine='openpyxl') - + if '주문번호(쇼핑몰)' not in df.columns or '송장번호' not in df.columns: return JSONResponse({"status": "error", "message": "엑셀에 '주문번호(쇼핑몰)' 또는 '송장번호' 컬럼이 없습니다."}, status_code=400) - + df = df[df['송장번호'].notna() & (df['송장번호'] != '')] - + if df.empty: return JSONResponse({"status": "error", "message": "송장번호가 입력된 데이터가 없습니다."}, status_code=400) - + dispatch_list = [] for index, row in df.iterrows(): po_id = str(row['주문번호(쇼핑몰)']).replace('.0', '').strip() tracking_num = str(row['송장번호']).replace('.0', '').strip() - + if ',' in tracking_num: tracking_num = tracking_num.split(',')[0].strip() - + if po_id and tracking_num and tracking_num != 'nan': dispatch_list.append({ "productOrderId": po_id, @@ -1538,28 +1637,28 @@ async def upload_invoices(file: UploadFile = File(...), deliveryCompanyCode: str "deliveryCompanyCode": deliveryCompanyCode, "trackingNumber": tracking_num }) - + if not dispatch_list: return JSONResponse({"status": "error", "message": "유효한 송장 매핑 데이터가 없습니다."}, status_code=400) - + token = get_access_token() success_count = 0 fail_list = [] - + chunk_size = 100 for i in range(0, len(dispatch_list), chunk_size): chunk = dispatch_list[i:i+chunk_size] result = dispatch_orders(token, chunk) - + results = result.get('data', {}).get('successProductOrderIds', []) success_count += len(results) - + fails = result.get('data', {}).get('failProductOrderInfos', []) for f in fails: fail_list.append(f) - + return { - "status": "success", + "status": "success", "success_count": success_count, "fail_count": len(fail_list), "fails": fail_list, @@ -1571,20 +1670,50 @@ async def upload_invoices(file: UploadFile = File(...), deliveryCompanyCode: str from fastapi.responses import RedirectResponse +CAFE24_OAUTH_STATE_TTL_SECONDS = 600 +cafe24_oauth_states = {} + +def _get_cafe24_redirect_uri(request: Request) -> str: + configured_uri = os.getenv("CAFE24_REDIRECT_URI", "").strip() + if configured_uri: + return configured_uri + return f"{str(request.base_url).rstrip('/')}/admin/oauth/callback" + +@app.get("/api/cafe24/status") +async def cafe24_status(): + return cafe24_api.get_token_status() + @app.get("/api/cafe24/login") -async def cafe24_login(): - url = cafe24_api.get_auth_url() +async def cafe24_login(request: Request): + now = time.time() + for saved_state, saved_data in list(cafe24_oauth_states.items()): + if now - saved_data["created_at"] > CAFE24_OAUTH_STATE_TTL_SECONDS: + cafe24_oauth_states.pop(saved_state, None) + + state = secrets.token_urlsafe(32) + redirect_uri = _get_cafe24_redirect_uri(request) + cafe24_oauth_states[state] = {"redirect_uri": redirect_uri, "created_at": now} + url = cafe24_api.get_auth_url(redirect_uri=redirect_uri, state=state) return RedirectResponse(url) @app.get("/api/cafe24/callback") @app.get("/admin/oauth/callback") -async def cafe24_callback(code: str): - home = f"{APP_ROOT_PATH}/" if APP_ROOT_PATH else "/" +async def cafe24_callback(code: str = "", state: str = "", error: str = ""): + if error: + message = json.dumps(f"카페24 인증이 취소되었거나 실패했습니다: {error}", ensure_ascii=False) + return HTMLResponse(f"", status_code=400) + + saved = cafe24_oauth_states.pop(state, None) if state else None + if not saved or time.time() - saved["created_at"] > CAFE24_OAUTH_STATE_TTL_SECONDS: + message = json.dumps("인증 요청이 만료되었거나 올바르지 않습니다. 다시 연동해주세요.", ensure_ascii=False) + return HTMLResponse(f"", status_code=400) + try: - cafe24_api.request_new_token(code) - return HTMLResponse(f"") + cafe24_api.request_new_token(code, redirect_uri=saved["redirect_uri"]) + return HTMLResponse("") except Exception as e: - return HTMLResponse(f"") + message = json.dumps(f"인증 실패: {e}", ensure_ascii=False) + return HTMLResponse(f"", status_code=400) task_store = {} @@ -1599,38 +1728,38 @@ async def start_cafe24_download(background_tasks: BackgroundTasks): "filename": None, "error": None } - + def run_task(tid): try: total_count = cafe24_api.get_cafe24_orders_count(status="N20") task_store[tid]["total"] = total_count - + def progress_callback(current): task_store[tid]["current"] = current - + orders = cafe24_api.get_cafe24_orders(status="N20", progress_callback=progress_callback) if not orders: task_store[tid]["status"] = "error" task_store[tid]["error"] = "새로운 배송준비중 주문이 없습니다." return - + data = [] now = datetime.datetime.now() numeric_date = int(now.strftime('%Y%m%d')) code_to_name = get_all_code_to_name_from_db() exchange_customers = [] - + for idx, order in enumerate(orders): items = order.get('items', []) receivers = order.get('receivers', []) receiver = receivers[0] if receivers else {} - + has_exchange = False for item in items: if item.get('exchange_request_date') or item.get('exchange_date') or str(item.get('status_code', '')).startswith('E'): has_exchange = True break - + if has_exchange: exchange_customers.append({ "name": receiver.get('name', ''), @@ -1638,35 +1767,35 @@ async def start_cafe24_download(background_tasks: BackgroundTasks): "order_no": order.get('order_id', '') }) continue - + address_full = receiver.get('address_full', '') if not address_full: address_full = f"{receiver.get('address1', '')} {receiver.get('address2', '')}".strip() full_address = address_full - + for item in items: if not item.get('status_code', '').startswith('N'): continue - + seller_code = item.get('custom_variant_code') or item.get('item_code') or item.get('custom_product_code') or item.get('product_code', '') - + final_quantity = int(item.get('quantity', 1)) if seller_code and '_' in seller_code: parts = seller_code.rsplit('_', 1) if len(parts) == 2 and parts[1].isdigit(): seller_code = parts[0] final_quantity = final_quantity * int(parts[1]) - + original_product_name = item.get('product_name', '') product_name = code_to_name.get(seller_code, original_product_name) original_quantity = item.get('quantity', 1) - + option_info = item.get('option_value', '') if option_info: order_list = f"{original_product_name}/{option_info}/{original_quantity}" else: order_list = f"{original_product_name}/{original_quantity}" - + row = { '주문날짜': numeric_date, '번호': 0, @@ -1687,26 +1816,32 @@ async def start_cafe24_download(background_tasks: BackgroundTasks): '주문목록': order_list } data.append(row) - + weekdays = ['월요일', '화요일', '수요일', '목요일', '금요일', '토요일', '일요일'] weekday_str = weekdays[now.weekday()] - + if now.hour >= 15: filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 0 미라네 자사몰 발주-3시.xlsx" else: filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 0 미라네 자사몰 발주-오전.xlsx" - + file_bytes, enc_name = generate_order_excel(data, numeric_date, filename, is_cafe24=True, return_bytes=True) - + task_store[tid]["current"] = task_store[tid]["total"] task_store[tid]["file_bytes"] = file_bytes task_store[tid]["filename"] = enc_name task_store[tid]["exchange_customers"] = exchange_customers task_store[tid]["status"] = "completed" - + except Exception as e: status_code = 500 - if "Authentication" in str(e) or "Please authenticate" in str(e) or "expired" in str(e): + if ( + "Authentication" in str(e) + or "authenticate" in str(e).lower() + or "expired" in str(e).lower() + or "인증" in str(e) + or "연동" in str(e) + ): status_code = 401 task_store[tid]["status"] = "error" task_store[tid]["error"] = str(e) @@ -1719,12 +1854,12 @@ async def start_cafe24_download(background_tasks: BackgroundTasks): async def get_task_progress(task_id: str): if task_id not in task_store: return JSONResponse({"status": "error", "message": "작업을 찾을 수 없습니다."}, status_code=404) - + task_data = task_store[task_id] if task_data["status"] == "error": status_code = task_data.get("status_code", 500) return JSONResponse({"status": "error", "message": task_data["error"]}, status_code=status_code) - + return { "status": task_data["status"], "current": task_data["current"], @@ -1735,18 +1870,18 @@ async def get_task_progress(task_id: str): async def download_task_file(task_id: str): if task_id not in task_store or task_store[task_id]["status"] != "completed": return JSONResponse({"status": "error", "message": "파일이 아직 준비되지 않았거나 만료되었습니다."}, status_code=404) - + task_data = task_store[task_id] output = io.BytesIO(task_data["file_bytes"]) enc_name = task_data["filename"] - + headers = { "Content-Disposition": f"attachment; filename*=UTF-8''{enc_name}" } - + # 메모리 정리 (1회 다운로드 후 삭제) del task_store[task_id] - + return StreamingResponse( output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -1765,28 +1900,28 @@ async def cafe24_upload_invoices(file: UploadFile = File(...), deliveryCompanyCo df = pd.read_html(io.BytesIO(content))[0] else: df = pd.read_excel(io.BytesIO(content), engine='openpyxl') - + if '주문번호(쇼핑몰)' not in df.columns or '송장번호' not in df.columns: return JSONResponse({"status": "error", "message": "엑셀에 '주문번호(쇼핑몰)' 또는 '송장번호' 컬럼이 없습니다."}, status_code=400) - + df = df[df['송장번호'].notna() & (df['송장번호'] != '')] if df.empty: return JSONResponse({"status": "error", "message": "송장번호가 입력된 데이터가 없습니다."}, status_code=400) - + dispatch_list = [] for index, row in df.iterrows(): po_id = str(row['주문번호(쇼핑몰)']).replace('.0', '').strip() tracking_num = str(row['송장번호']).replace('.0', '').strip() if ',' in tracking_num: tracking_num = tracking_num.split(',')[0].strip() - + if po_id and tracking_num and tracking_num != 'nan': dispatch_list.append({ "order_id": po_id, "tracking_no": tracking_num, "shipping_company_code": deliveryCompanyCode }) - + results = cafe24_api.update_cafe24_tracking(dispatch_list) return { "status": "success", @@ -1797,7 +1932,13 @@ async def cafe24_upload_invoices(file: UploadFile = File(...), deliveryCompanyCo } except Exception as e: status_code = 500 - if "Authentication" in str(e) or "Please authenticate" in str(e) or "expired" in str(e): + if ( + "Authentication" in str(e) + or "authenticate" in str(e).lower() + or "expired" in str(e).lower() + or "인증" in str(e) + or "연동" in str(e) + ): status_code = 401 return JSONResponse({"status": "error", "message": str(e)}, status_code=status_code) @@ -1807,4 +1948,6 @@ async def cafe24_upload_invoices(file: UploadFile = File(...), deliveryCompanyCo if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("APP_PORT", "8002"))) + host = os.getenv("HOST", "0.0.0.0") + port = int(os.getenv("PORT", "8002")) + uvicorn.run(app, host=host, port=port) diff --git a/routers/coupang_milkrun.py b/routers/coupang_milkrun.py new file mode 100644 index 0000000..b6aa4fc --- /dev/null +++ b/routers/coupang_milkrun.py @@ -0,0 +1,701 @@ +# -*- coding: utf-8 -*- +""" +쿠팡 밀크런 붙여넣기 분석기. + +쿠팡 밀크런 발주서(제품코드 / 제품명 / 수량, 탭 구분)를 붙여넣으면 +코드표 DB(itemcode_db)의 세트-단품 구성을 참조해 낱개 코드별 합계 수량을 계산합니다. + +제품코드 규칙: "MT-7000_2" 형태에서 마지막 "_숫자"는 앞 코드를 해당 숫자만큼 +곱하라는 뜻입니다 (예: MT-7000_2 108개 = MT-7000 216개로 계산). +""" + +import io +import json +import os +import re +import time +import urllib.error +import urllib.parse +import urllib.request + +import pandas as pd +import psycopg2 +import psycopg2.extras +import requests +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +router = APIRouter(prefix="/api/coupang-milkrun", tags=["Coupang Milkrun"]) + +# 환경변수 기반 DB 설정 +DB_CONFIG = { + 'host': os.getenv('POSTGRES_HOST', 'postgres-db'), + 'port': int(os.getenv('POSTGRES_PORT', '5432')), + 'dbname': os.getenv('ITEMCODE_DB', 'itemcode_db'), + 'user': os.getenv('POSTGRES_USER', 'king'), + 'password': os.getenv('POSTGRES_PASSWORD', ''), +} + + +def get_db_conn(): + return psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) + + +# 분석 요청마다 SSH 터널로 새 DB 커넥션을 맺으면 매번 수백ms가 소요되므로, +# 코드표(단품/세트 구성)는 잘 바뀌지 않는 참조 데이터로 보고 짧게 캐싱한다. +_CODE_MAP_CACHE_TTL_SECONDS = 120 +_code_map_cache = {"loaded_at": 0.0, "single_map": {}, "set_components": {}} + + +def _get_code_maps(): + now = time.monotonic() + if now - _code_map_cache["loaded_at"] < _CODE_MAP_CACHE_TTL_SECONDS: + return _code_map_cache["single_map"], _code_map_cache["set_components"] + + conn = get_db_conn() + try: + cursor = conn.cursor() + cursor.execute("SELECT item_code, name, sabangnet_code FROM single_items") + single_map = {r["item_code"]: dict(r) for r in cursor.fetchall()} + + cursor.execute("SELECT set_code, single_code, quantity FROM set_components") + set_components = {} + for r in cursor.fetchall(): + set_components.setdefault(r["set_code"], []).append( + (r["single_code"], r["quantity"]) + ) + finally: + conn.close() + + _code_map_cache["single_map"] = single_map + _code_map_cache["set_components"] = set_components + _code_map_cache["loaded_at"] = now + return single_map, set_components + + +def invalidate_code_map_cache(): + """코드표(단품/세트)가 수정되면 즉시 반영되도록 캐시를 비운다.""" + _code_map_cache["loaded_at"] = 0.0 + + +class MilkrunAnalyzeRequest(BaseModel): + text: str + + +_MULTIPLIER_SUFFIX_RE = re.compile(r'^(?P.+)_(?P\d+)$') +_HEADER_LABELS = {"제품코드", "상품코드", "아이템코드", "item_code", "code"} + + +def _parse_pasted_rows(text: str): + """탭(또는 2칸 이상 공백)으로 구분된 '코드/이름/수량' 3열을 줄 단위로 파싱.""" + rows = [] + for raw_line in text.splitlines(): + line = raw_line.strip('\r\n') + if not line.strip(): + continue + + parts = [p.strip() for p in line.split('\t')] + while parts and parts[-1] == '': + parts.pop() + if len(parts) < 3: + parts = [p for p in re.split(r'\s{2,}', line.strip()) if p != ''] + + if len(parts) < 3: + continue + + code_raw, name, qty_raw = parts[0], parts[1], parts[-1] + if not code_raw or code_raw in _HEADER_LABELS: + continue + + qty_clean = qty_raw.replace(',', '').strip() + if not qty_clean.isdigit(): + continue + + qty = int(qty_clean) + if qty <= 0: + continue + + rows.append({"code_raw": code_raw, "name": name, "qty": qty}) + return rows + + +def _resolve_code(code_raw, single_map, set_components): + """코드 원문을 (기준코드, 배수, 종류) 로 해석. + + 1순위: 코드 원문이 DB에 그대로 존재하면 배수 없이 사용 (코드 자체에 '_'가 + 포함된 실제 등록 코드를 오탐하지 않기 위함). + 2순위: 끝의 '_숫자'를 배수로 떼어낸 기준코드가 DB에 존재하면 그것을 사용. + 3순위: 둘 다 없으면 미등록으로 처리하되, 배수 해석 결과는 화면 표시용으로 보존. + """ + if code_raw in set_components: + return code_raw, 1, "set" + if code_raw in single_map: + return code_raw, 1, "single" + + m = _MULTIPLIER_SUFFIX_RE.match(code_raw) + if m: + base = m.group('base') + mult = int(m.group('mult')) + if base in set_components: + return base, mult, "set" + if base in single_map: + return base, mult, "single" + return base, mult, None + + return code_raw, 1, None + + +def _compute_milkrun_result(text: str): + rows = _parse_pasted_rows(text) + if not rows: + return {"lines": [], "totals": [], "unresolved": []} + + single_map, set_components = _get_code_maps() + + totals = {} + unresolved = {} + line_results = [] + + for row in rows: + base_code, multiplier, kind = _resolve_code(row["code_raw"], single_map, set_components) + effective_qty = row["qty"] * multiplier + + if kind == "set": + breakdown = [] + for single_code, per_set_qty in set_components[base_code]: + add_qty = effective_qty * per_set_qty + totals[single_code] = totals.get(single_code, 0) + add_qty + breakdown.append({ + "single_code": single_code, + "per_unit_qty": per_set_qty, + "qty": add_qty, + }) + line_results.append({ + **row, + "base_code": base_code, + "multiplier": multiplier, + "effective_qty": effective_qty, + "resolved": True, + "type": "set", + "breakdown": breakdown, + }) + elif kind == "single": + totals[base_code] = totals.get(base_code, 0) + effective_qty + line_results.append({ + **row, + "base_code": base_code, + "multiplier": multiplier, + "effective_qty": effective_qty, + "resolved": True, + "type": "single", + "breakdown": [{"single_code": base_code, "qty": effective_qty}], + }) + else: + unresolved[base_code] = unresolved.get(base_code, 0) + effective_qty + line_results.append({ + **row, + "base_code": base_code, + "multiplier": multiplier, + "effective_qty": effective_qty, + "resolved": False, + "type": "unknown", + "breakdown": [], + }) + + totals_list = [ + { + "single_code": code, + "name": single_map.get(code, {}).get("name", ""), + "sabangnet_code": single_map.get(code, {}).get("sabangnet_code", ""), + "total_qty": qty, + } + for code, qty in totals.items() + ] + totals_list.sort(key=lambda x: x["single_code"]) + + unresolved_list = [ + {"code": code, "qty": qty} for code, qty in sorted(unresolved.items()) + ] + + return { + "lines": line_results, + "totals": totals_list, + "unresolved": unresolved_list, + } + + +@router.post("/analyze") +async def analyze_milkrun(payload: MilkrunAnalyzeRequest): + result = _compute_milkrun_result(payload.text) + return {"status": "success", **result} + + +@router.post("/download") +async def download_milkrun(payload: MilkrunAnalyzeRequest): + result = _compute_milkrun_result(payload.text) + + df = pd.DataFrame([ + { + "상품코드[필수]": t["sabangnet_code"], + "가용수량": t["total_qty"], + "불용수량": t["name"], + "바코드": "", + } + for t in result["totals"] + ], columns=["상품코드[필수]", "가용수량", "불용수량", "바코드"]) + + output = io.BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Sheet1") + + worksheet = writer.sheets["Sheet1"] + from openpyxl.utils import get_column_letter + from openpyxl.styles import Alignment, PatternFill, Font + + header_fill = PatternFill(start_color="833C0C", end_color="833C0C", fill_type="solid") + header_font = Font(name="나눔고딕", bold=True, color="FFFFFF") + data_font = Font(name="나눔고딕") + center_alignment = Alignment(horizontal="center", vertical="center") + + for row in worksheet.iter_rows(min_row=1, max_row=worksheet.max_row, min_col=1, max_col=4): + for cell in row: + if cell.row == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = center_alignment + else: + cell.font = data_font + + for idx, col in enumerate(df.columns): + col_letter = get_column_letter(idx + 1) + max_length = 0 + for cell in worksheet[col_letter]: + if cell.value is not None: + val_str = str(cell.value) + length = sum(1.8 if ord(c) > 127 else 1.1 for c in val_str) + if length > max_length: + max_length = length + worksheet.column_dimensions[col_letter].width = min(max_length + 2, 60) + + output.seek(0) + + filename = "쿠팡_밀크런_낱개코드.xlsx" + encoded_filename = urllib.parse.quote(filename) + headers = { + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + } + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers=headers, + ) + + +# ===================================================================== +# 구글 드라이브 시트 불러오기 +# +# 대상 파일은 두 가지 형태일 수 있어 둘 다 지원한다. +# 1) 네이티브 구글 시트 → Sheets API(gspread)로 탭 목록을 읽는다 +# 2) Drive에 올라간 .xlsx → Drive API로 내려받아 openpyxl로 시트명을 읽는다 +# (이 경우 구글 클라우드 프로젝트에서 Drive API가 켜져 있어야 한다) +# ===================================================================== +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +GSPREAD_CRED_FILE = os.getenv("GSPREAD_CRED_FILE", os.path.join(BASE_DIR, "manual-ordering.json")) +GSPREAD_CRED_JSON = os.getenv("GSPREAD_CRED_JSON", "") + +SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets" +DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.readonly" + +# 같은 파일을 연달아 부르므로 내려받은 통합문서와 시트 목록을 잠시 들고 있는다. +# 구글로 나가는 요청은 왕복이 길어서(수 초~수십 초) 캐시 효과가 크다. +_DRIVE_CACHE_TTL_SECONDS = 300 +_drive_cache = {} # file_id -> (저장시각, 파일 bytes) +_sheet_tabs_cache = {} # file_id -> (저장시각, 시트 목록 응답) + +# 구글이 응답을 안 주면 무한정 매달리지 않도록 (연결 대기, 응답 대기) 초 +_HTTP_TIMEOUT = (10, 120) +_HTTP_READ_TIMEOUT = 120 + +# 밀크런 출고리스트 구글 시트 (화면에서 주소를 입력하지 않아도 되도록 기본값으로 둔다) +MILKRUN_SHEET_URL = os.getenv( + "MILKRUN_SHEET_URL", + "https://docs.google.com/spreadsheets/d/1J74op7lBZOgE27p4R28I3RWsv0EtXdii/edit", +) + +_FILE_ID_RE = re.compile(r"/spreadsheets/d/([a-zA-Z0-9_-]+)|/file/d/([a-zA-Z0-9_-]+)|[?&]id=([a-zA-Z0-9_-]+)") + + +class SheetTabsRequest(BaseModel): + url: str = "" # 비우면 MILKRUN_SHEET_URL 을 쓴다 + refresh: bool = False # true면 캐시를 무시하고 구글에서 다시 읽는다 + + +class SheetRowsRequest(BaseModel): + url: str = "" + sheet: str + refresh: bool = False + + +# 밀크런 출고리스트에서 찾을 열 제목. 위치가 바뀌어도 제목으로 찾아낸다. +MILKRUN_COLUMN_LABELS = {"code": "제품코드", "name": "제품명", "qty": "수량"} +# 제목을 못 찾았을 때 쓸 기본 위치 (H=8 제품코드, I=9 제품명, J=10 수량) +MILKRUN_DEFAULT_COLUMNS = {"code": 8, "name": 9, "qty": 10} +MILKRUN_HEADER_SCAN_ROWS = 30 +# 데이터가 끝났다고 판단할 연속 빈 행 수 (중간에 빈 줄이 있어도 넘어가도록 여유를 둔다) +MILKRUN_BLANK_RUN_LIMIT = 30 + + +def extract_file_id(url_or_id): + """구글 문서 주소에서 파일 ID를 뽑는다. ID를 그대로 넣어도 받는다.""" + text = (url_or_id or "").strip() + if not text: + raise HTTPException(status_code=400, detail="구글 시트 주소를 입력해주세요.") + + match = _FILE_ID_RE.search(text) + if match: + return next(group for group in match.groups() if group) + + if "/" not in text and len(text) >= 20: + return text # 주소 대신 ID만 붙여넣은 경우 + + raise HTTPException( + status_code=400, + detail="주소에서 파일 ID를 찾지 못했습니다. " + "https://docs.google.com/spreadsheets/d/<파일ID>/edit 형태의 주소를 넣어주세요.", + ) + + +def _service_account_info(): + if GSPREAD_CRED_JSON: + try: + return json.loads(GSPREAD_CRED_JSON) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="GSPREAD_CRED_JSON 형식이 올바르지 않습니다.") + if os.path.exists(GSPREAD_CRED_FILE): + with open(GSPREAD_CRED_FILE, "r", encoding="utf-8") as fh: + return json.load(fh) + raise HTTPException( + status_code=500, + detail=f"구글 서비스 계정 키 파일을 찾을 수 없습니다: {GSPREAD_CRED_FILE}", + ) + + +def _load_credentials(scopes): + try: + from google.oauth2.service_account import Credentials + except ImportError: + raise HTTPException(status_code=500, detail="google-auth 라이브러리가 설치되어 있지 않습니다.") + return Credentials.from_service_account_info(_service_account_info(), scopes=scopes) + + +def _service_account_email(): + try: + return _service_account_info().get("client_email", "") + except HTTPException: + return "" + + +def _drive_error_detail(response): + """Drive API 오류를 사용자가 무엇을 해야 할지 알 수 있는 문장으로 바꾼다.""" + try: + message = response.json().get("error", {}).get("message", "") + except ValueError: + message = response.text[:300] + + email = _service_account_email() + if response.status_code == 403 and "has not been used in project" in message: + return ("구글 드라이브 API가 꺼져 있고, 파일도 링크 공개 상태가 아니라 읽을 수 없습니다. " + "파일 공유 설정을 '링크가 있는 모든 사용자(뷰어)'로 바꾸거나, " + "구글 클라우드 콘솔에서 'Google Drive API'를 사용 설정해주세요. " + f"(원본 메시지: {message[:200]})") + if response.status_code in (403, 404): + return (f"파일에 접근할 수 없습니다. 구글 드라이브에서 이 파일을 '{email}' 계정에 공유(뷰어)하거나 " + f"'링크가 있는 모든 사용자'로 설정해주세요. (원본 메시지: {message[:200]})") + return f"구글 드라이브에서 파일을 가져오지 못했습니다 [{response.status_code}]: {message[:200]}" + + +def _drive_get(session, file_id, params): + return session.get( + f"https://www.googleapis.com/drive/v3/files/{file_id}", + params={**params, "supportsAllDrives": "true"}, + timeout=60, + ) + + +# 링크가 공개된 파일은 인증 없이 바로 받을 수 있다 (Drive API 불필요) +_PUBLIC_EXPORT_URLS = ( + "https://docs.google.com/spreadsheets/d/{file_id}/export?format=xlsx", + "https://drive.google.com/uc?export=download&id={file_id}", +) + + +def _fetch_public_workbook(file_id): + """링크 공개 파일을 인증 없이 내려받는다. 받지 못하면 None. + + 비공개 파일이면 구글이 로그인 HTML을 200으로 돌려주므로 + xlsx(ZIP) 시그니처 'PK'를 확인해서 진짜 파일인지 가려낸다. + + requests(urllib3) 대신 표준 라이브러리 urllib을 쓴다. + 같은 파일을 받는 데 requests는 약 20초, urllib은 약 1.4초로 차이가 커서다. + (연결 수립 단계에서 지연이 생기며, 본문 전송 자체는 0.3초다) + """ + for template in _PUBLIC_EXPORT_URLS: + request = urllib.request.Request( + template.format(file_id=file_id), + headers={"User-Agent": "Mozilla/5.0 (compatible; CS-Integrated-Manager)"}, + ) + try: + with urllib.request.urlopen(request, timeout=_HTTP_READ_TIMEOUT) as response: + if response.status != 200: + continue + content = response.read() + except (urllib.error.URLError, OSError): + continue + if content[:2] == b"PK": + return content + return None + + +def _fetch_drive_workbook_authenticated(file_id): + try: + from google.auth.transport.requests import AuthorizedSession + except ImportError: + raise HTTPException(status_code=500, detail="google-auth 라이브러리가 설치되어 있지 않습니다.") + + session = AuthorizedSession(_load_credentials([DRIVE_SCOPE])) + response = _drive_get(session, file_id, {"alt": "media"}) + if response.status_code != 200: + raise HTTPException(status_code=400, detail=_drive_error_detail(response)) + return response.content + + +def fetch_drive_workbook(file_id): + """엑셀 파일을 내려받아 bytes로 돌려준다. (짧게 캐시) + + 1순위: 링크 공개 파일을 인증 없이 받기 (Drive API가 꺼져 있어도 동작) + 2순위: 서비스 계정으로 Drive API 호출 (비공개 파일용) + """ + cached = _drive_cache.get(file_id) + if cached and time.monotonic() - cached[0] < _DRIVE_CACHE_TTL_SECONDS: + return cached[1] + + content = _fetch_public_workbook(file_id) + if content is None: + content = _fetch_drive_workbook_authenticated(file_id) + + _drive_cache[file_id] = (time.monotonic(), content) + return content + + +def fetch_drive_file_name(file_id): + """파일 이름은 있으면 좋은 정보일 뿐이라, 못 가져와도 조용히 빈 값으로 둔다.""" + try: + from google.auth.transport.requests import AuthorizedSession + + session = AuthorizedSession(_load_credentials([DRIVE_SCOPE])) + response = _drive_get(session, file_id, {"fields": "name"}) + if response.status_code == 200: + return response.json().get("name", "") + except Exception: + pass + return "" + + +def _sheet_names_from_bytes(content): + from openpyxl import load_workbook + + try: + workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=True) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"엑셀 파일을 열지 못했습니다: {exc}") + try: + return list(workbook.sheetnames) + finally: + workbook.close() + + +def _sheet_tabs_from_excel(file_id): + """비공개 파일: Drive API로 내려받아 시트 이름을 읽는다.""" + content = fetch_drive_workbook(file_id) + return { + "source": "excel", + "file_name": fetch_drive_file_name(file_id), + "sheets": [{"name": name} for name in _sheet_names_from_bytes(content)], + } + + +def _read_sheet_tabs(file_id): + """시트 이름 목록을 읽는다. 구글 왕복을 최소로 줄이는 순서로 시도한다. + + 1) 링크가 공개된 파일이면 xlsx로 한 번에 내려받는다. + (네이티브 구글 시트도 export?format=xlsx 로 받아지므로 대부분 여기서 끝난다) + 2) 비공개면 서비스 계정으로 Sheets API → 오피스 파일이면 Drive API + """ + content = _fetch_public_workbook(file_id) + if content is not None: + # 다음 단계(선택한 시트의 값 읽기)에서 다시 받지 않도록 함께 캐시해 둔다 + _drive_cache[file_id] = (time.monotonic(), content) + return { + "source": "public_xlsx", + "file_name": "", + "sheets": [{"name": name} for name in _sheet_names_from_bytes(content)], + } + + try: + import gspread + except ImportError: + raise HTTPException(status_code=500, detail="gspread 라이브러리가 설치되어 있지 않습니다.") + + try: + client = gspread.authorize(_load_credentials([SHEETS_SCOPE])) + spreadsheet = client.open_by_key(file_id) + return { + "source": "google_sheet", + "file_name": spreadsheet.title, + "sheets": [ + {"name": ws.title, "rows": ws.row_count, "cols": ws.col_count} + for ws in spreadsheet.worksheets() + ], + } + except HTTPException: + raise + except Exception as exc: + message = str(exc) + if "Office file" in message or "not supported for this document" in message: + return _sheet_tabs_from_excel(file_id) + if "PERMISSION_DENIED" in message or "not found" in message.lower() or "404" in message: + raise HTTPException( + status_code=400, + detail=f"파일을 열 수 없습니다. 구글 드라이브에서 '{_service_account_email()}' 계정에 " + f"공유하거나 '링크가 있는 모든 사용자'로 설정해주세요. (원본 메시지: {message[:200]})", + ) + raise HTTPException(status_code=400, detail=f"구글 시트를 열지 못했습니다: {message[:300]}") + + +def _cell_str(value): + """셀 값을 문자열로. 숫자로 저장된 코드가 '7000.0'이 되지 않게 처리한다.""" + if value is None: + return "" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value).strip() + + +def _to_int(value): + """수량을 정수로. 정수가 아니면 None.""" + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) if value.is_integer() else None + text = _cell_str(value).replace(",", "") + if not text: + return None + try: + return int(float(text)) + except ValueError: + return None + + +def _find_milkrun_columns(rows): + """'제품코드 / 제품명 / 수량' 제목이 있는 행을 찾아 열 위치를 정한다. + + 반환: (헤더 행 번호(1-based), {code/name/qty: 열 번호(1-based)}) + 못 찾으면 사진 기준 기본 위치(H/I/J)와 헤더 5행을 쓴다. + """ + for index, row in enumerate(rows[:MILKRUN_HEADER_SCAN_ROWS]): + texts = [_cell_str(cell) for cell in row] + if MILKRUN_COLUMN_LABELS["code"] in texts and MILKRUN_COLUMN_LABELS["qty"] in texts: + columns = {} + for key, label in MILKRUN_COLUMN_LABELS.items(): + columns[key] = (texts.index(label) + 1) if label in texts else MILKRUN_DEFAULT_COLUMNS[key] + return index + 1, columns + return 5, dict(MILKRUN_DEFAULT_COLUMNS) + + +def _read_milkrun_sheet(content, sheet_name): + """선택한 시트에서 제품코드/제품명/수량을 끝까지 읽어온다.""" + from openpyxl import load_workbook + + try: + workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=True) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"엑셀 파일을 열지 못했습니다: {exc}") + + try: + if sheet_name not in workbook.sheetnames: + raise HTTPException( + status_code=400, + detail=f"'{sheet_name}' 시트를 찾지 못했습니다. 시트 목록을 새로고침해주세요.", + ) + sheet = workbook[sheet_name] + rows = list(sheet.iter_rows(values_only=True)) + finally: + workbook.close() + + header_row, columns = _find_milkrun_columns(rows) + + def value_at(row, column): + return row[column - 1] if len(row) >= column else None + + items = [] + skipped = 0 + blank_run = 0 + for row in rows[header_row:]: # 헤더 다음 행부터 + code = _cell_str(value_at(row, columns["code"])) + name = _cell_str(value_at(row, columns["name"])) + qty = _to_int(value_at(row, columns["qty"])) + + if not code and not name and qty is None: + blank_run += 1 + if blank_run >= MILKRUN_BLANK_RUN_LIMIT: + break + continue + blank_run = 0 + + if not code or qty is None or qty <= 0: + skipped += 1 + continue + items.append({"code": code, "name": name, "qty": qty}) + + return { + "sheet": sheet_name, + "header_row": header_row, + "columns": columns, + "items": items, + "skipped": skipped, + # 붙여넣기 입력에 그대로 넣을 수 있는 형태 (탭 구분) + "text": "\n".join(f"{i['code']}\t{i['name']}\t{i['qty']}" for i in items), + } + + +@router.post("/sheet-rows") +async def get_sheet_rows(payload: SheetRowsRequest): + """선택한 시트의 제품코드/제품명/수량을 읽어 분석용 텍스트로 돌려준다.""" + file_id = extract_file_id(payload.url or MILKRUN_SHEET_URL) + if not payload.sheet: + raise HTTPException(status_code=400, detail="시트를 선택해주세요.") + + if payload.refresh: + _drive_cache.pop(file_id, None) + + content = fetch_drive_workbook(file_id) + result = _read_milkrun_sheet(content, payload.sheet) + return {"status": "success", "file_id": file_id, **result} + + +@router.post("/sheet-tabs") +async def get_sheet_tabs(payload: SheetTabsRequest): + """구글 드라이브 파일의 시트(탭) 이름 목록을 돌려준다.""" + file_id = extract_file_id(payload.url or MILKRUN_SHEET_URL) + + if not payload.refresh: + cached = _sheet_tabs_cache.get(file_id) + if cached and time.monotonic() - cached[0] < _DRIVE_CACHE_TTL_SECONDS: + return {"status": "success", "file_id": file_id, "cached": True, **cached[1]} + + result = _read_sheet_tabs(file_id) + _sheet_tabs_cache[file_id] = (time.monotonic(), result) + return {"status": "success", "file_id": file_id, "cached": False, **result} diff --git a/routers/mall_event.py b/routers/mall_event.py new file mode 100644 index 0000000..bc081bc --- /dev/null +++ b/routers/mall_event.py @@ -0,0 +1,1123 @@ +# -*- coding: utf-8 -*- +"""자사몰 행사 - 카페24 주문/발주 파일 가공 모듈. + +카페24에서 받은 파일을 업로드하면 행사 조건을 적용해 +가공된 파일을 다시 내려받을 수 있게 한다. + +현재 제공 기능 + 1) 선착순 사은품 추가 (발주주문서 xlsx 업로드 → 사은품 행이 추가된 xlsx) + 2) 행사 주문 추출 (주문 CSV 업로드 → 조건에 맞는 행만 남긴 xlsx) + +앞으로 다른 가공 기능(업로드 → 분석 → 변환 파일 다운로드)이 이 라우터에 계속 추가된다. +새 기능을 넣을 때는 `_first_come_*`, `_event_extract_*` 처럼 기능 접두사를 붙여 헬퍼를 +분리하고, 엔드포인트 경로도 `/api/mall-event/<기능명>/...` 형태로 나눈다. + +성능 메모 + 분석/미리보기는 값만 있으면 되므로 openpyxl read_only 모드로 한 번만 훑는다. + 서식을 유지해야 하는 다운로드 단계에서만 쓰기 가능한 워크북을 연다. + 같은 파일을 분석 → 미리보기 → 다운로드로 연달아 올리므로, + 파일 내용 해시를 키로 스캔 결과를 짧게 캐싱해 재파싱을 피한다. + +[기능 1] 발주주문서 열 위치 (카페24 양식 기준, 사용자 확정) + A: 주문날짜 C: 품목 순번(끝 숫자 증가 대상) E: 아이템코드 + F: 상품이름 G: 수량 P: 주문번호 Q: 주문목록 + +[기능 2] 주문 CSV 열 위치 (사용자 확정) + B: 주문번호 K: 주문상품명(세트상품 포함) L, M: N열 수식의 피연산자 + N: L×M 수식을 넣는 열 +""" + +import csv +import hashlib +import io +import json +import re +import time +import urllib.parse +from bisect import bisect_left +from collections import OrderedDict +from copy import copy +from datetime import date, datetime + +from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi.responses import StreamingResponse +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter + +router = APIRouter(prefix="/api/mall-event", tags=["Mall Event"]) + +# --- 발주주문서 열 위치 (1-based) --- +COL_ORDER_DATE = 1 # A 주문날짜 +COL_SEQ = 3 # C 품목 순번 +COL_ITEM_CODE = 5 # E 아이템코드 +COL_ITEM_NAME = 6 # F 상품이름 +COL_QTY = 7 # G 수량 +COL_ORDER_NO = 16 # P 주문번호 +COL_ORDER_LIST = 17 # Q 주문목록 +COL_LAST = COL_ORDER_LIST # 사은품 행에 색을 칠하는 범위: A~Q + +GIFT_LABEL = "선착순 사은품" +YELLOW_FILL = PatternFill(start_color="FFFFFF00", end_color="FFFFFF00", fill_type="solid") + +# --- 주문 CSV 열 위치 (1-based) --- +EE_COL_ORDER_NO = 2 # B 주문번호 +EE_COL_PRODUCT = 11 # K 주문상품명(세트상품 포함) +EE_COL_L = 12 # L (N열 수식의 왼쪽 피연산자) +EE_COL_M = 13 # M (N열 수식의 오른쪽 피연산자) +EE_COL_N = 14 # N (=L×M 수식을 넣는 열) +EE_SPLIT_TOKEN = " -" # 주문상품명에서 이 문자열 앞까지를 상품명으로 본다 + +# --- 추출 결과 파일 서식 범위 (1-based) --- +EE_FORMAT_LAST_COL = 28 # AB. 제목행 서식/자동필터/열너비를 적용하는 마지막 열 +EE_NUM_FIRST_COL = 12 # L. 숫자 변환 + 쉼표 서식 + 0은 빈 셀 처리 시작 +EE_NUM_LAST_COL = 19 # S. 위 처리의 마지막 열 +EE_DEDUPE_FIRST_COL = 15 # O. 같은 주문번호에서 중복 값을 지우는 범위 시작 +EE_DEDUPE_LAST_COL = 19 # S. 위 처리의 마지막 열 +EE_SORT_COL = 4 # D. 이 열 기준으로 텍스트 오름차순 정렬 + +EE_COMMA_FORMAT = "#,##0" +EE_COMMA_FORMAT_DECIMAL = "#,##0.##" +# 주문번호가 바뀔 때마다 두 색을 번갈아 칠해 주문 묶음을 눈으로 구분한다 +EE_GROUP_FILLS = ( + PatternFill(start_color="FFB4C6E7", end_color="FFB4C6E7", fill_type="solid"), + PatternFill(start_color="FFFFD966", end_color="FFFFD966", fill_type="solid"), +) +EE_HEADER_FILL = PatternFill(start_color="FF3182CE", end_color="FF3182CE", fill_type="solid") +EE_HEADER_FONT = Font(bold=True, color="FFFFFFFF") +EE_HEADER_ALIGN = Alignment(horizontal="center", vertical="center", wrap_text=False) +EE_MIN_COL_WIDTH = 8 +EE_MAX_COL_WIDTH = 60 + +# 길고 장황한 카페24 제목을 짧게 바꾼다 (공백 차이는 무시하고 비교) +EE_HEADER_RENAMES = { + "결제일시(입금확인일)": "결제일시", + "총 배송비 (전체 품목에 표시)": "총 배송비", + "쿠폰 할인금액(최종)": "쿠폰 할인", + "사용한 적립금액(최종)": "사용 적립금", + "네이버 포인트": "N포인트", + "총 실결제금액(최초정보)": "총 실결제금액", +} +_EE_HEADER_RENAMES_LOOSE = { + re.sub(r"\s+", "", key): value for key, value in EE_HEADER_RENAMES.items() +} + +# 픽셀 → 엑셀 열 너비 환산 기준 (사용자 확인: 145px = 너비 17.5) +EE_PIXELS_PER_WIDTH_UNIT = 8 +EE_WIDTH_PADDING_PIXELS = 5 + +# 지정한 열은 픽셀 단위로 고정, 나머지는 내용에 맞춰 자동 +EE_MANUAL_COL_PIXELS = { + 1: 145, # A + 2: 135, # B + 3: 155, # C + 4: 150, # D + 5: 80, # E + 6: 110, # F + 7: 120, # G + 8: 120, # H + 12: 60, # L +} +EE_MANUAL_COL_PIXELS.update({col: 110 for col in range(13, 21)}) # M~T + +# 헤더 행을 찾을 때 훑어볼 최대 행 수 (양식 위에 안내문이 몇 줄 있어도 잡히도록) +_HEADER_SCAN_ROWS = 15 + +# 업로드 → 미리보기 → 다운로드가 같은 파일로 이어지므로 스캔 결과를 잠시 들고 있는다. +_SCAN_CACHE_MAX = 4 +_SCAN_CACHE_TTL_SECONDS = 600 +_scan_cache = OrderedDict() # sha1 -> (저장시각, 스캔결과) + +_DATE_FORMATS = ( + "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d", + "%Y.%m.%d %H:%M:%S", "%Y.%m.%d %H:%M", "%Y.%m.%d", + "%Y/%m/%d %H:%M:%S", "%Y/%m/%d %H:%M", "%Y/%m/%d", + "%Y%m%d%H%M%S", "%Y%m%d", +) + + +# ===================================================================== +# 공용 헬퍼 +# ===================================================================== +def cell_text(value): + """셀 값을 비교/표시에 쓸 문자열로 정규화.""" + if value is None: + return "" + if isinstance(value, bool): + return str(value) + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(value, date): + return value.strftime("%Y-%m-%d") + return str(value).strip() + + +def parse_order_date(value): + """주문날짜 셀을 datetime으로. 해석 불가면 None.""" + if isinstance(value, datetime): + return value + if isinstance(value, date): + return datetime(value.year, value.month, value.day) + text = cell_text(value) + if not text: + return None + for fmt in _DATE_FORMATS: + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + +def first_segment(order_list_value): + """주문목록 텍스트에서 첫 번째 '/' 앞부분만 잘라낸다.""" + text = cell_text(order_list_value) + if not text: + return "" + return text.split("/")[0].strip() + + +def increment_trailing_number(value): + """끝에 붙은 숫자를 한 단계 올린다. ('...-01' → '...-02', 3 → 4) + + 반환: (새 값, 성공 여부). 끝 숫자가 없으면 원본을 그대로 돌려주고 False. + """ + if value is None: + return None, False + if isinstance(value, bool): + return value, False + if isinstance(value, int): + return value + 1, True + if isinstance(value, float): + return (int(value) + 1) if value.is_integer() else value + 1, True + + text = str(value) + matches = list(re.finditer(r"\d+", text)) + if not matches: + return text, False + last = matches[-1] + digits = last.group(0) + bumped = str(int(digits) + 1).zfill(len(digits)) + return text[:last.start()] + bumped + text[last.end():], True + + +def _validate_extension(filename): + name = (filename or "").lower() + if name.endswith(".xls"): + raise HTTPException( + status_code=400, + detail="구형 .xls 파일은 서식을 유지한 채 가공할 수 없습니다. " + "엑셀에서 '다른 이름으로 저장 → .xlsx'로 변환한 뒤 다시 업로드해주세요.", + ) + if not name.endswith((".xlsx", ".xlsm")): + raise HTTPException(status_code=400, detail="엑셀 파일(.xlsx)만 업로드할 수 있습니다.") + + +def _value_at(values, column): + return values[column - 1] if len(values) >= column else None + + +def _is_header_values(values): + order_list_label = cell_text(_value_at(values, COL_ORDER_LIST)) + order_date_label = cell_text(_value_at(values, COL_ORDER_DATE)) + return "주문목록" in order_list_label or "주문날짜" in order_date_label + + +def excel_response(workbook, filename, extra_headers=None): + stream = io.BytesIO() + workbook.save(stream) + stream.seek(0) + encoded = urllib.parse.quote(filename) + headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{encoded}"} + if extra_headers: + headers.update(extra_headers) + return StreamingResponse( + stream, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers=headers, + ) + + +def _base_name(original_filename): + """업로드된 파일 이름에서 경로와 확장자를 떼어낸다.""" + base = (original_filename or "발주서").rsplit("/", 1)[-1].rsplit("\\", 1)[-1] + for ext in (".xlsx", ".xlsm", ".xls", ".csv"): + if base.lower().endswith(ext): + return base[: -len(ext)] + return base + + +def _output_name(original_filename, suffix): + return f"{_base_name(original_filename)}_{suffix}.xlsx" + + +def _same_name(original_filename): + """업로드한 파일과 같은 이름으로 내려준다. (저장 형식이 xlsx이므로 확장자만 맞춤)""" + return f"{_base_name(original_filename)}.xlsx" + + +# ===================================================================== +# 발주서 스캔 (read_only 1회 통과 + 해시 캐시) +# ===================================================================== +def _cache_get(key): + entry = _scan_cache.get(key) + if entry is None: + return None + saved_at, scan = entry + if time.monotonic() - saved_at > _SCAN_CACHE_TTL_SECONDS: + _scan_cache.pop(key, None) + return None + _scan_cache.move_to_end(key) + return scan + + +def _cache_put(key, scan): + _scan_cache[key] = (time.monotonic(), scan) + _scan_cache.move_to_end(key) + while len(_scan_cache) > _SCAN_CACHE_MAX: + _scan_cache.popitem(last=False) + + +def _collect_row(row_index, values, orders, sequence): + """한 행을 주문 묶음에 반영. 내용이 있는 행이면 True.""" + if not any(v is not None and str(v).strip() != "" for v in values): + return False + + order_no = cell_text(_value_at(values, COL_ORDER_NO)) + segment = first_segment(_value_at(values, COL_ORDER_LIST)) + if not order_no and not segment: + return True # 합계행 등: 데이터 범위에는 포함하되 주문으로 세지 않는다 + + key = order_no or f"__행{row_index}" + info = orders.get(key) + if info is None: + info = { + "key": key, + "order_no": order_no, + "first_row": row_index, + "last_row": row_index, + "order_date": parse_order_date(_value_at(values, COL_ORDER_DATE)), + "order_date_text": cell_text(_value_at(values, COL_ORDER_DATE)), + "seq_value": _value_at(values, COL_SEQ), # 마지막 행 값으로 계속 덮어씀 + "segments": [], + "rows": [], + } + orders[key] = info + sequence.append(key) + else: + info["last_row"] = row_index + info["seq_value"] = _value_at(values, COL_SEQ) + if info["order_date"] is None: + parsed = parse_order_date(_value_at(values, COL_ORDER_DATE)) + if parsed is not None: + info["order_date"] = parsed + info["order_date_text"] = cell_text(_value_at(values, COL_ORDER_DATE)) + + info["rows"].append(row_index) + if segment and segment not in info["segments"]: + info["segments"].append(segment) + return True + + +def scan_order_sheet(file_bytes, filename): + """발주서를 값만 읽어 주문 단위로 묶는다. 같은 파일은 캐시에서 즉시 반환.""" + cache_key = "first-come:" + hashlib.sha1(file_bytes).hexdigest() + cached = _cache_get(cache_key) + if cached is not None: + return cached + + _validate_extension(filename) + try: + workbook = load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"엑셀 파일을 열 수 없습니다: {exc}") + + try: + sheet = workbook.active + sheet_name = sheet.title + rows_iter = sheet.iter_rows(min_col=1, max_col=COL_LAST, values_only=True) + + # 헤더 후보 구간만 먼저 버퍼링해서 헤더 행을 정한다 + buffered = [] + header_row = 1 + for index, values in enumerate(rows_iter, start=1): + buffered.append((index, values)) + if _is_header_values(values): + header_row = index + break + if index >= _HEADER_SCAN_ROWS: + break + + orders = {} + sequence = [] + last_row = header_row + + for index, values in buffered: + if index > header_row and _collect_row(index, values, orders, sequence): + last_row = index + for index, values in enumerate(rows_iter, start=len(buffered) + 1): + if _collect_row(index, values, orders, sequence): + last_row = index + finally: + workbook.close() + + if last_row <= header_row: + raise HTTPException(status_code=400, detail="발주 데이터가 없는 파일입니다. 파일을 확인해주세요.") + + counts = {} + for key in sequence: + for segment in orders[key]["segments"]: + counts[segment] = counts.get(segment, 0) + 1 + + scan = { + "sheet_name": sheet_name, + "header_row": header_row, + "last_row": last_row, + "orders": orders, + "sequence": sequence, + "items": [{"name": name, "order_count": count} for name, count in sorted(counts.items())], + } + _cache_put(cache_key, scan) + return scan + + +# ===================================================================== +# 기능 1) 선착순 사은품 추가 +# ===================================================================== +def _first_come_sort_key(info): + """주문날짜 오름차순. 날짜를 못 읽은 주문은 뒤로 보내고 파일 등장 순서를 따른다.""" + order_date = info["order_date"] + if order_date is None: + return (1, 0.0, info["first_row"]) + return (0, order_date.timestamp(), info["first_row"]) + + +def _first_come_plan(scan, selected_names, limit): + """선착순 대상 주문과 사은품 행이 들어갈 위치를 계산한다. (파일을 열지 않는다) + + 사은품 행은 그 주문의 마지막 행 바로 아래에 삽입되므로, + 위쪽에 먼저 삽입된 행 수만큼 아래 행들의 최종 번호가 밀린다. + """ + selected = set(selected_names) + matched = [] + for key in scan["sequence"]: + info = scan["orders"][key] + hits = [s for s in info["segments"] if s in selected] + if hits: + matched.append((info, hits)) + + matched.sort(key=lambda pair: _first_come_sort_key(pair[0])) + picked = matched[:limit] + + # 삽입 위치(원본 좌표) 오름차순 = 위에서 아래 순서 + by_position = sorted(picked, key=lambda pair: pair[0]["last_row"]) + position_of = {pair[0]["key"]: i for i, pair in enumerate(by_position)} + insert_rows_sorted = [pair[0]["last_row"] for pair in by_position] + + def shifted(original_row): + """앞서 삽입된 행 수를 반영한 최종 행 번호.""" + return original_row + bisect_left(insert_rows_sorted, original_row) + + plan = [] + for info, hits in picked: # 선착순(날짜) 순서 유지 + rank = position_of[info["key"]] + new_seq, seq_ok = increment_trailing_number(info["seq_value"]) + plan.append({ + "key": info["key"], + "order_no": info["order_no"], + "order_date": info["order_date_text"], + "source_row": info["last_row"], + "new_row": info["last_row"] + 1 + rank, + "seq": cell_text(new_seq), + "seq_ok": seq_ok, + "rows": info["rows"], + "matched": hits, + }) + + gift_order_rows = set() + for entry in plan: + for row in entry["rows"]: + gift_order_rows.add(shifted(row)) + gift_order_rows.add(entry["new_row"]) + + return { + "matched_order_count": len(matched), + "plan": plan, + "gift_order_rows": sorted(gift_order_rows), + "final_last_row": scan["last_row"] + len(plan), + } + + +def _first_come_warnings(result, limit): + warnings = [] + matched_total = result["matched_order_count"] + if matched_total == 0: + warnings.append("선택한 주문목록에 해당하는 주문을 찾지 못했습니다.") + elif matched_total < limit: + warnings.append( + f"조건에 맞는 주문이 {matched_total}건뿐이라 선착순 {limit}명을 채우지 못했습니다." + ) + + no_seq = [entry["order_no"] for entry in result["plan"] if not entry["seq_ok"]] + if no_seq: + preview = ", ".join(no_seq[:3]) + more = f" 외 {len(no_seq) - 3}건" if len(no_seq) > 3 else "" + warnings.append(f"C열에 끝 숫자가 없어 순번을 올리지 못한 주문이 있습니다: {preview}{more}") + return warnings + + +def _bulk_shift_rows(sheet, insert_points): + """insert_points(원본 행 번호)들의 '바로 아래'에 빈 행 한 줄씩을 한 번에 만든다. + + openpyxl의 insert_rows는 호출할 때마다 아래쪽 셀 전체를 리스트로 만들고 정렬하므로 + (Worksheet._move_cells), 삽입 지점마다 부르면 O(삽입건수 × 전체셀)이 되어 매우 느리다. + 삽입 지점을 모아 한 번만 훑으면서 각 셀의 최종 행 번호를 계산해 옮긴다. + 행 높이(row_dimensions)는 insert_rows도 옮겨주지 않으므로 여기서 같이 처리한다. + """ + points = sorted(insert_points) + if not points: + return + + def shift_of(row): + return bisect_left(points, row) # 이 행보다 위에서 삽입된 행 수 + + moved_cells = {} + for (row, column), cell in sheet._cells.items(): + shift = shift_of(row) + if shift: + row += shift + cell.row = row + moved_cells[(row, column)] = cell + sheet._cells = moved_cells + sheet._current_row = max((r for r, _c in moved_cells), default=0) + + # 행 높이도 같은 규칙으로 이동 (없으면 건너뜀) + dimensions = sheet.row_dimensions + saved = [ + (row, dim.height, dim.hidden, dim.customHeight) + for row, dim in dimensions.items() + ] + if saved: + dimensions.clear() + for row, height, hidden, custom_height in saved: + new_row = row + shift_of(row) + dim = dimensions[new_row] + dim.height = height + dim.hidden = hidden + dim.customHeight = custom_height + # 새로 생긴 행은 바로 위(복사 원본) 행의 높이를 따라간다 + for rank, point in enumerate(points): + source_row = point + rank + if source_row in dimensions and dimensions[source_row].height is not None: + target = dimensions[source_row + 1] + target.height = dimensions[source_row].height + target.customHeight = dimensions[source_row].customHeight + + +def _first_come_write(sheet, plan_result, gift): + """계획대로 각 주문의 마지막 행 아래에 사은품 행을 끼워 넣는다.""" + plan = plan_result["plan"] + if not plan: + return + + # 먼저 필요한 빈 행을 한 번에 확보한다. 이후 각 사은품 행의 위치는 + # 계획 단계에서 계산해 둔 new_row와 정확히 일치한다 (원본 행은 new_row - 1). + _bulk_shift_rows(sheet, [entry["source_row"] for entry in plan]) + + for entry in plan: + target_row = entry["new_row"] + source_row = target_row - 1 + + for col in range(1, COL_LAST + 1): + source_cell = sheet.cell(row=source_row, column=col) + target_cell = sheet.cell(row=target_row, column=col) + target_cell.value = source_cell.value + if source_cell.has_style: + target_cell._style = copy(source_cell._style) + + seq_value, _seq_ok = increment_trailing_number(sheet.cell(row=source_row, column=COL_SEQ).value) + sheet.cell(row=target_row, column=COL_SEQ).value = seq_value + + sheet.cell(row=target_row, column=COL_ITEM_CODE).value = gift["item_code"] + sheet.cell(row=target_row, column=COL_ITEM_NAME).value = gift["name"] + sheet.cell(row=target_row, column=COL_QTY).value = gift["qty"] + sheet.cell(row=target_row, column=COL_ORDER_LIST).value = " / ".join(entry["matched"] + [GIFT_LABEL]) + + for col in range(1, COL_LAST + 1): + sheet.cell(row=target_row, column=col).fill = YELLOW_FILL + + +def _prune_to_rows(sheet, header_row, keep_rows, last_row): + """헤더와 keep_rows만 남기고 나머지 데이터 행을 지운다.""" + keep = set(keep_rows) + to_delete = [row for row in range(header_row + 1, last_row + 1) if row not in keep] + if not to_delete: + return + + groups = [] + for row in to_delete: + if groups and groups[-1][0] + groups[-1][1] == row: + groups[-1][1] += 1 + else: + groups.append([row, 1]) + + for start, count in reversed(groups): + sheet.delete_rows(start, count) + + +def _parse_selected(selected_json, empty_detail="상품을 최소 1개 선택해주세요."): + try: + parsed = json.loads(selected_json or "[]") + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="선택한 목록 값을 읽지 못했습니다.") + if not isinstance(parsed, list): + raise HTTPException(status_code=400, detail="선택한 목록 형식이 올바르지 않습니다.") + + names = [str(v).strip() for v in parsed if str(v).strip()] + if not names: + raise HTTPException(status_code=400, detail=empty_detail) + return names + + +def _validate_gift(gift_item_code, gift_name, gift_qty, limit): + if limit < 1: + raise HTTPException(status_code=400, detail="선착순 인원은 1명 이상이어야 합니다.") + if gift_qty < 1: + raise HTTPException(status_code=400, detail="사은품 수량은 1개 이상이어야 합니다.") + code = (gift_item_code or "").strip() + if not code: + raise HTTPException(status_code=400, detail="사은품(세트 코드)을 선택해주세요.") + return {"item_code": code, "name": (gift_name or "").strip(), "qty": gift_qty} + + +@router.post("/first-come/analyze") +async def first_come_analyze(file: UploadFile = File(...)): + """업로드된 발주서에서 주문목록(Q열) 후보를 뽑아 가나다순으로 돌려준다.""" + file_bytes = await file.read() + scan = scan_order_sheet(file_bytes, file.filename) + return { + "status": "success", + "file_name": file.filename, + "sheet_name": scan["sheet_name"], + "header_row": scan["header_row"], + "total_rows": scan["last_row"] - scan["header_row"], + "total_orders": len(scan["sequence"]), + "order_list_items": scan["items"], + } + + +@router.post("/first-come/preview") +async def first_come_preview( + file: UploadFile = File(...), + selected_json: str = Form(...), + limit: int = Form(...), + gift_item_code: str = Form(...), + gift_name: str = Form(""), + gift_qty: int = Form(1), +): + """다운로드 전에 어떤 주문에 사은품이 붙는지 미리 보여준다. (파일을 다시 쓰지 않는다)""" + selected_names = _parse_selected(selected_json, "사은품이 지급되는 주문을 최소 1개 선택해주세요.") + _validate_gift(gift_item_code, gift_name, gift_qty, limit) + + file_bytes = await file.read() + scan = scan_order_sheet(file_bytes, file.filename) + result = _first_come_plan(scan, selected_names, limit) + + added = [ + { + "order_no": entry["order_no"], + "order_date": entry["order_date"], + "source_row": entry["source_row"], + "new_row": entry["new_row"], + "seq": entry["seq"], + "matched": entry["matched"], + } + for entry in result["plan"] + ] + + return { + "status": "success", + "matched_order_count": result["matched_order_count"], + "applied_count": len(result["plan"]), + "added": added, + "warnings": _first_come_warnings(result, limit), + } + + +@router.post("/first-come/download") +async def first_come_download( + file: UploadFile = File(...), + selected_json: str = Form(...), + limit: int = Form(...), + gift_item_code: str = Form(...), + gift_name: str = Form(""), + gift_qty: int = Form(1), + mode: str = Form("full"), +): + """가공된 발주 파일을 내려준다. + + mode=full : 원본 전체 + 각 주문 아래에 끼워 넣은 사은품 행 + mode=gift : 사은품이 적용된 주문의 모든 행 + 사은품 행 + """ + selected_names = _parse_selected(selected_json, "사은품이 지급되는 주문을 최소 1개 선택해주세요.") + gift = _validate_gift(gift_item_code, gift_name, gift_qty, limit) + + file_bytes = await file.read() + scan = scan_order_sheet(file_bytes, file.filename) + result = _first_come_plan(scan, selected_names, limit) + + if not result["plan"]: + raise HTTPException( + status_code=400, + detail="조건에 맞는 주문이 없어 사은품 행을 추가하지 못했습니다. 선택 항목을 확인해주세요.", + ) + + try: + workbook = load_workbook(io.BytesIO(file_bytes)) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"엑셀 파일을 열 수 없습니다: {exc}") + sheet = workbook.active + + _first_come_write(sheet, result, gift) + + if mode == "gift": + _prune_to_rows(sheet, scan["header_row"], result["gift_order_rows"], result["final_last_row"]) + filename = _output_name(file.filename, "선착순사은품_대상주문") + else: + # 전체 발주 파일은 업로드한 파일 이름 그대로 내려준다 + filename = _same_name(file.filename) + + return excel_response(workbook, filename) + + +# ===================================================================== +# 기능 2) 행사 주문 추출 +# 주문 CSV를 올려 K열(주문상품명)로 상품을 고른 뒤, +# - product 모드: 그 상품이 담긴 행만 +# - order 모드 : 그 상품을 산 주문번호(B열)의 모든 행 +# 만 남긴 엑셀을 내려준다. N열에는 =L×M 수식을 넣고 노란색을 칠한다. +# ===================================================================== +_CSV_ENCODINGS = ("utf-8-sig", "cp949", "utf-8") + + +def _decode_csv(file_bytes): + """카페24 CSV는 UTF-8(BOM), CP949(EUC-KR), UTF-16 중 하나로 내려온다. + + UTF-16 파일을 CP949로 잘못 읽으면 글자 사이에 NUL이 끼어 csv 파서가 죽으므로 + BOM과 NUL 비율로 UTF-16을 먼저 걸러낸다. + """ + if file_bytes[:2] in (b"\xff\xfe", b"\xfe\xff"): + try: + return file_bytes.decode("utf-16"), "utf-16" + except UnicodeDecodeError: + pass + + # BOM이 없는 UTF-16은 NUL 바이트가 많다는 특징으로 판별한다 + head = file_bytes[:4096] + if head and head.count(0) > len(head) * 0.2: + for encoding in ("utf-16-le", "utf-16-be"): + try: + return file_bytes.decode(encoding), encoding + except UnicodeDecodeError: + continue + + for encoding in _CSV_ENCODINGS: + try: + return file_bytes.decode(encoding), encoding + except UnicodeDecodeError: + continue + return file_bytes.decode("utf-8", errors="replace"), "utf-8(일부 깨짐)" + + +def _normalize_csv_text(text): + """줄바꿈을 \\n으로 통일하고 NUL을 제거한다. + + CR(\\r)만 쓰는 파일이나 UTF-16 잔재가 남으면 csv 파서가 + 'new-line character seen in unquoted field' 오류로 죽는다. + """ + if "\x00" in text: + text = text.replace("\x00", "") + if "\r" in text: + text = text.replace("\r\n", "\n").replace("\r", "\n") + return text.lstrip("") + + +def _sniff_delimiter(sample_line): + """구분자 추정. 카페24는 쉼표지만 탭으로 받는 경우도 있어 대비한다.""" + counts = {d: sample_line.count(d) for d in (",", "\t", ";", "|")} + best = max(counts, key=counts.get) + return best if counts[best] > 0 else "," + + +def _cell_at(row, column): + """1-based 열 번호로 CSV 행에서 값을 꺼낸다. 없으면 빈 문자열.""" + return row[column - 1].strip() if len(row) >= column else "" + + +def event_extract_product_name(product_value): + """주문상품명에서 첫 번째 ' -' 앞부분만 잘라낸다.""" + text = (product_value or "").strip() + if not text: + return "" + return text.split(EE_SPLIT_TOKEN)[0].strip() + + +def _to_number(value): + """수식이 계산되도록 L·M 값을 숫자로 바꾼다. 숫자가 아니면 원본 그대로.""" + text = (value or "").strip().replace(",", "") + if not text: + return value + try: + return int(text) + except ValueError: + pass + try: + return float(text) + except ValueError: + return value + + +def scan_event_csv(file_bytes, filename): + """주문 CSV를 읽어 상품명 후보와 행/주문 정보를 뽑는다. 같은 파일은 캐시 사용. + + 예상 못한 오류도 화면에 이유가 보이도록 400으로 감싸서 돌려준다. + (그냥 두면 500이 되어 사용자에게는 원인 없는 실패로만 보인다) + """ + cache_key = "event-extract:" + hashlib.sha1(file_bytes).hexdigest() + cached = _cache_get(cache_key) + if cached is not None: + return cached + + name = (filename or "").lower() + if not name.endswith(".csv"): + raise HTTPException(status_code=400, detail="CSV 파일(.csv)만 업로드할 수 있습니다.") + + try: + return _scan_event_csv_uncached(file_bytes, cache_key) + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + status_code=400, + detail=f"CSV를 분석하지 못했습니다 ({type(exc).__name__}: {exc}). " + "파일이 손상되었거나 예상과 다른 형식일 수 있습니다.", + ) + + +def _scan_event_csv_uncached(file_bytes, cache_key): + text, encoding = _decode_csv(file_bytes) + text = _normalize_csv_text(text) + if not text.strip(): + raise HTTPException(status_code=400, detail="내용이 비어 있는 파일입니다.") + + lines = text.splitlines() + first_line = lines[0] if lines else "" + delimiter = _sniff_delimiter(first_line) + try: + all_rows = list(csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)) + except csv.Error as exc: + raise HTTPException(status_code=400, detail=f"CSV 형식을 읽지 못했습니다: {exc}") + if not all_rows: + raise HTTPException(status_code=400, detail="CSV에서 읽어낼 행이 없습니다.") + + # 헤더 행: K열에 '주문상품명'이 들어간 줄. 못 찾으면 첫 줄을 헤더로 본다. + header_index = 0 + for index, row in enumerate(all_rows[:_HEADER_SCAN_ROWS]): + if "주문상품명" in _cell_at(row, EE_COL_PRODUCT): + header_index = index + break + + header = all_rows[header_index] + data_rows = [row for row in all_rows[header_index + 1:] if any(c.strip() for c in row)] + if not data_rows: + raise HTTPException(status_code=400, detail="헤더 아래에 주문 데이터가 없습니다.") + + stats = {} + meta = [] + orders_of_product = {} + for row in data_rows: + order_no = _cell_at(row, EE_COL_ORDER_NO) + product = event_extract_product_name(_cell_at(row, EE_COL_PRODUCT)) + meta.append({"order_no": order_no, "product": product}) + if not product: + continue + entry = stats.setdefault(product, {"name": product, "row_count": 0, "orders": set()}) + entry["row_count"] += 1 + if order_no: + entry["orders"].add(order_no) + orders_of_product.setdefault(product, set()) + if order_no: + orders_of_product[product].add(order_no) + + items = [ + {"name": e["name"], "row_count": e["row_count"], "order_count": len(e["orders"])} + for e in sorted(stats.values(), key=lambda x: x["name"]) + ] + + column_count = max([len(header)] + [len(r) for r in data_rows]) + + scan = { + "encoding": encoding, + "delimiter": delimiter, + "header_row": header_index + 1, + "header": header, + "rows": data_rows, + "meta": meta, + "items": items, + "orders_of_product": orders_of_product, + "column_count": column_count, + } + _cache_put(cache_key, scan) + return scan + + +def _event_extract_select(scan, selected_names, mode): + """조건에 맞는 행 번호(데이터 행 기준 0-based)를 고른다.""" + selected = set(selected_names) + matched_rows = [i for i, m in enumerate(scan["meta"]) if m["product"] in selected] + + if mode == "order": + # 해당 상품을 산 주문번호(B열)와 같은 주문번호를 가진 모든 행을 남긴다 + target_orders = {scan["meta"][i]["order_no"] for i in matched_rows if scan["meta"][i]["order_no"]} + keep = [i for i, m in enumerate(scan["meta"]) if m["order_no"] and m["order_no"] in target_orders] + else: + keep = matched_rows + + order_numbers = {scan["meta"][i]["order_no"] for i in keep if scan["meta"][i]["order_no"]} + return { + "keep": keep, + "matched_row_count": len(matched_rows), + "row_count": len(keep), + "order_count": len(order_numbers), + } + + +def _is_number(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _pixels_to_column_width(pixels): + """픽셀을 엑셀 열 너비 단위로 환산. + + 엑셀의 열 너비는 픽셀이 아니라 '기본 글꼴의 숫자 한 글자 폭' 단위라서 + 글꼴에 따라 배율이 달라진다. 이 프로그램이 쓰는 환경 기준으로 + 145px = 너비 17.5 이므로 1글자 = 8px, 좌우 여백 5px로 계산한다. + """ + return round(max(pixels - EE_WIDTH_PADDING_PIXELS, 0) / EE_PIXELS_PER_WIDTH_UNIT, 3) + + +def _rename_header(value): + """장황한 카페24 제목을 짧은 제목으로 바꾼다. 목록에 없으면 원본 유지.""" + text = (value or "").strip() + if not text: + return value + if text in EE_HEADER_RENAMES: + return EE_HEADER_RENAMES[text] + return _EE_HEADER_RENAMES_LOOSE.get(re.sub(r"\s+", "", text), text) + + +def _display_width(value): + """열 너비 자동 맞춤용 글자 폭 추정. 한글은 영문보다 넓게 잡는다.""" + if value is None or value == "": + return 0 + if _is_number(value): + text = f"{value:,.0f}" if float(value).is_integer() else f"{value:,.2f}" + else: + text = str(value) + return sum(1.8 if ord(ch) > 127 else 1.1 for ch in text) + + +def _event_extract_build_workbook(scan, keep_indexes): + """고른 행만 담아 서식까지 적용한 워크북을 만든다. + + 정렬 : D열 기준 텍스트 오름차순 (같은 값끼리는 원래 순서 유지) + A~AB : 1행은 가운데 정렬·파랑 배경·흰 글자, 자동 필터, A2 틀고정 + 열 너비는 A~H·L~T는 픽셀 고정, 나머지는 내용에 맞춰 자동 + L~S : 문자로 들어온 숫자를 숫자로 바꾸고 쉼표 서식, 값이 0이면 빈 셀 + O~S : 같은 주문번호(B열) 안에서 첫 행과 같은 값이면 지운다 + 배경 : 주문번호가 바뀔 때마다 #B4C6E7 / #FFD966 을 번갈아 칠하고, + 마지막에 N열(=L×M 수식이 들어간 셀)만 노란색으로 덮어쓴다 + N : =L×M 수식(계산값 아님) + """ + workbook = Workbook() + sheet = workbook.active + sheet.title = "행사 주문 추출" + + column_count = max(scan["column_count"], EE_COL_N) + format_last = min(column_count, EE_FORMAT_LAST_COL) + widths = {} + + def note_width(col, value): + width = _display_width(value) + if width > widths.get(col, 0): + widths[col] = width + + # D열 텍스트 오름차순. 파이썬 정렬은 안정적이라 같은 값끼리는 원래 순서가 유지된다. + keep_indexes = sorted( + keep_indexes, + key=lambda index: _cell_at(scan["rows"][index], EE_SORT_COL), + ) + + # ---- 제목행 ---- + for col in range(1, column_count + 1): + value = _rename_header(_cell_at(scan["header"], col)) or None + cell = sheet.cell(row=1, column=col, value=value) + if col <= format_last: + cell.fill = EE_HEADER_FILL + cell.font = EE_HEADER_FONT + cell.alignment = EE_HEADER_ALIGN + note_width(col, value) + + # ---- 데이터 행 ---- + first_seen = {} # (주문번호, 열) -> 그 주문에서 처음 만난 값 + row_orders = [] # (엑셀 행번호, 주문번호) - 아래 배경색 칠하기에 쓴다 + for offset, data_index in enumerate(keep_indexes): + excel_row = offset + 2 + source = scan["rows"][data_index] + order_no = _cell_at(source, EE_COL_ORDER_NO) + row_orders.append((excel_row, order_no)) + + for col in range(1, column_count + 1): + if col == EE_COL_N: + continue # 아래에서 수식으로 채운다 + + raw = _cell_at(source, col) + + # O~S: 같은 주문번호에서 이미 나온 값과 같으면 빈 셀로 둔다 + if order_no and EE_DEDUPE_FIRST_COL <= col <= EE_DEDUPE_LAST_COL: + key = (order_no, col) + if key in first_seen: + if first_seen[key] == raw: + continue + elif raw != "": + first_seen[key] = raw + + # L~S: 숫자로 변환 + 쉼표 서식, 0이면 빈 셀 + if EE_NUM_FIRST_COL <= col <= EE_NUM_LAST_COL: + value = _to_number(raw) + if _is_number(value): + if value == 0: + continue + cell = sheet.cell(row=excel_row, column=col, value=value) + cell.number_format = ( + EE_COMMA_FORMAT if float(value).is_integer() else EE_COMMA_FORMAT_DECIMAL + ) + note_width(col, value) + continue + + if raw != "": + sheet.cell(row=excel_row, column=col, value=raw) + note_width(col, raw) + + # 계산된 값이 아니라 수식 자체를 그대로 넣는다 (배경색은 아래에서 한 번에) + cell = sheet.cell(row=excel_row, column=EE_COL_N) + cell.value = f"=L{excel_row}*M{excel_row}" + cell.number_format = EE_COMMA_FORMAT + # 수식 결과는 저장 시점에 알 수 없으므로 L×M을 미리 계산해 너비만 가늠한다 + left = sheet.cell(row=excel_row, column=EE_COL_L).value + right = sheet.cell(row=excel_row, column=EE_COL_M).value + if _is_number(left) and _is_number(right): + note_width(EE_COL_N, left * right) + + # ---- 주문번호 묶음별 배경색 (두 색을 번갈아) ---- + previous_order = None + color_index = 0 + for excel_row, order_no in row_orders: + if previous_order is not None and order_no != previous_order: + color_index = 1 - color_index # 주문번호가 바뀌면 다른 색으로 + previous_order = order_no + fill = EE_GROUP_FILLS[color_index] + for col in range(1, format_last + 1): + sheet.cell(row=excel_row, column=col).fill = fill + + # ---- 마지막으로 N열(수식이 들어간 셀)만 노란색으로 덮어쓴다 ---- + for excel_row, _order_no in row_orders: + cell = sheet.cell(row=excel_row, column=EE_COL_N) + if cell.value not in (None, ""): + cell.fill = YELLOW_FILL + + # ---- 열 너비: 지정한 열은 픽셀 고정, 나머지는 자동 ---- + for col in range(1, format_last + 1): + pixels = EE_MANUAL_COL_PIXELS.get(col) + if pixels is not None: + width = _pixels_to_column_width(pixels) + else: + width = min(max(widths.get(col, 0) + 2, EE_MIN_COL_WIDTH), EE_MAX_COL_WIDTH) + sheet.column_dimensions[get_column_letter(col)].width = width + + # ---- 자동 필터 + A2 틀고정 ---- + last_letter = get_column_letter(format_last) + sheet.auto_filter.ref = f"A1:{last_letter}{sheet.max_row}" + if sheet.max_row > 1: + # 행은 이미 D열 오름차순으로 써 두었고, 필터에도 정렬 기준을 남겨 둔다 + sort_letter = get_column_letter(EE_SORT_COL) + sheet.auto_filter.add_sort_condition(f"{sort_letter}2:{sort_letter}{sheet.max_row}") + sheet.freeze_panes = "A2" + + return workbook + + +def _parse_extract_mode(mode): + if mode not in ("product", "order"): + raise HTTPException(status_code=400, detail="추출 조건이 올바르지 않습니다.") + return mode + + +@router.post("/event-extract/analyze") +async def event_extract_analyze(file: UploadFile = File(...)): + """업로드된 주문 CSV에서 상품명 후보를 뽑아 가나다순으로 돌려준다.""" + file_bytes = await file.read() + scan = scan_event_csv(file_bytes, file.filename) + + warnings = [] + if scan["column_count"] < EE_COL_N: + warnings.append( + f"CSV의 열이 {scan['column_count']}개뿐이라 L·M열 값이 비어 있을 수 있습니다. " + "N열 수식은 그대로 넣지만 결과가 0이 될 수 있습니다." + ) + + return { + "status": "success", + "file_name": file.filename, + "encoding": scan["encoding"], + "header_row": scan["header_row"], + "total_rows": len(scan["rows"]), + "total_orders": len({m["order_no"] for m in scan["meta"] if m["order_no"]}), + "column_count": scan["column_count"], + "product_items": scan["items"], + "warnings": warnings, + } + + +@router.post("/event-extract/download") +async def event_extract_download( + file: UploadFile = File(...), + selected_json: str = Form(...), + mode: str = Form("product"), +): + """조건에 맞는 행만 남기고 N열에 =L×M 수식을 넣은 엑셀을 내려준다.""" + selected_names = _parse_selected(selected_json) + _parse_extract_mode(mode) + + file_bytes = await file.read() + scan = scan_event_csv(file_bytes, file.filename) + result = _event_extract_select(scan, selected_names, mode) + + if not result["keep"]: + raise HTTPException( + status_code=400, + detail="조건에 맞는 주문이 없어 추출할 행이 없습니다. 선택 항목을 확인해주세요.", + ) + + workbook = _event_extract_build_workbook(scan, result["keep"]) + suffix = "해당상품만" if mode == "product" else "해당주문" + # 화면에 추출 결과를 알려주기 위한 값. 파일과 함께 헤더로 실어 보낸다. + summary = { + "X-Extract-Matched-Rows": str(result["matched_row_count"]), + "X-Extract-Rows": str(result["row_count"]), + "X-Extract-Orders": str(result["order_count"]), + "X-Extract-Total-Rows": str(len(scan["rows"])), + } + return excel_response(workbook, _output_name(file.filename, f"행사추출_{suffix}"), summary) diff --git a/routers/returns.py b/routers/returns.py index e874838..7c684cf 100644 --- a/routers/returns.py +++ b/routers/returns.py @@ -1,7 +1,7 @@ -import os from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from typing import Optional, List +import os import psycopg2 import psycopg2.extras import datetime @@ -65,16 +65,17 @@ class BulkReturnReceive(BaseModel): @router.get("/lookup") async def return_lookup(tracking_no: str): try: + tracking_no = tracking_no.strip() conn = get_order_db_conn() cur = conn.cursor() # 주문일시(order_date 또는 upload_date) 기준 가장 최신 1건 조회 - cur.execute(r""" + cur.execute(""" SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address FROM orders WHERE tracking_number = %s OR (tracking_number IS NOT NULL AND tracking_number != '' - AND %s = ANY(regexp_split_to_array(tracking_number, '[,\s/;]+'))) + AND %s = ANY(regexp_split_to_array(tracking_number, '[,\\s/;]+'))) ORDER BY order_date DESC LIMIT 1 """, (tracking_no, tracking_no)) row = cur.fetchone() @@ -168,13 +169,13 @@ async def create_return_receive_bulk(req: BulkReturnReceive): else: remarks = "고객이 반품" # 2. Check orders - cur_order.execute(r""" + cur_order.execute(""" SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address FROM orders WHERE tracking_number = %s OR (tracking_number IS NOT NULL AND tracking_number != '' - AND %s = ANY(regexp_split_to_array(tracking_number, '[,\s/;]+'))) + AND %s = ANY(regexp_split_to_array(tracking_number, '[,\\s/;]+'))) ORDER BY order_date DESC LIMIT 1 """, (tracking_no, tracking_no)) ord_row = cur_order.fetchone() @@ -207,21 +208,21 @@ async def create_return_receive(req: ReturnReceive): req.tracking_no = req.tracking_no.strip() conn = get_return_db_conn() cur = conn.cursor() - + # 중복 송장 검사 if req.id: cur.execute(""" - SELECT receive_date, receiver_name, phone, mall, receive_status, remarks - FROM return_receivings + SELECT receive_date, receiver_name, phone, mall, receive_status, remarks + FROM return_receivings WHERE TRIM(tracking_no) = %s AND id != %s """, (req.tracking_no, req.id)) else: cur.execute(""" - SELECT receive_date, receiver_name, phone, mall, receive_status, remarks - FROM return_receivings + SELECT receive_date, receiver_name, phone, mall, receive_status, remarks + FROM return_receivings WHERE TRIM(tracking_no) = %s """, (req.tracking_no,)) - + dup = cur.fetchone() if dup: conn.close() @@ -230,7 +231,7 @@ async def create_return_receive(req: ReturnReceive): "error_type": "duplicate", "data": dict(dup) } - + if not req.id: cur.execute("SELECT request_type, remarks FROM return_requests WHERE TRIM(tracking_no) = %s", (req.tracking_no,)) req_row = cur.fetchone() diff --git a/static/css/styles.css b/static/css/styles.css index 2d8f42f..e3c86b0 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -107,13 +107,47 @@ p { } .nav-links li { - /* 탭 글자 크기 */ - padding: 11px 10px; + /* 탭 글자 크기 — 사이드바(130px)에서 모든 메뉴가 한 줄에 들어가는 크기. + 메뉴는 전부 이 크기 하나로 통일한다. */ + padding: 11px 8px; cursor: pointer; font-weight: 200; - font-size: 1.0rem; + font-size: 0.8rem; transition: var(--transition); border-left: 3px solid transparent; + /* 어떤 경우에도 두 줄로 넘어가지 않게 */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 메뉴 아이콘: 글자 크기와 무관하게 원래 크기(1rem)를 유지한다. + 1em이 아니라 rem으로 고정해야 메뉴 글자를 줄여도 아이콘이 같이 작아지지 않는다. + width는 글자 크기가 아니라 아이콘 칸 너비로, 메뉴 글자 시작선을 맞추는 용도. */ +.nav-links li .nav-icon { + font-size: 1rem; + line-height: 1; + display: inline-block; + width: 1.25rem; + margin-right: 5px; + text-align: center; + font-style: normal; + vertical-align: -0.1em; + flex-shrink: 0; + /* 흑백 글리프로 떨어지지 않도록 컬러 이모지 폰트를 우선 지정 */ + font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Segoe UI Symbol", sans-serif; +} + +/* '준비중' 같은 상태 뱃지도 좁은 사이드바에 맞춰 축소 */ +.nav-links li .nav-badge { + font-size: 0.5rem; + background: #e53e3e; + color: #fff; + padding: 1px 2px; + border-radius: 4px; + margin-left: 3px; + flex-shrink: 0; + white-space: nowrap; } .nav-links li:hover { @@ -158,52 +192,787 @@ p { .main-grid { display: grid; grid-template-columns: 1fr 525px; + grid-template-rows: minmax(0, 1fr); gap: 10px; height: calc(100vh - 45px); overflow: hidden; } -/* ★★★ CSS Multi-Column 레이아웃 적용 ★★★ */ -/* 빈틈없이 차례대로 컬럼을 채우는 가장 강력한 속성 */ -.left-col { +.order-workspace { + min-width: 0; + height: 100%; display: flex; flex-direction: column; - flex-wrap: wrap; - align-content: flex-start; + overflow: hidden; +} + +.order-category-panel { + padding: 8px; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(203, 213, 224, 0.9); + border-radius: 8px; +} + +.order-category-heading { + display: flex; + align-items: center; gap: 8px; + margin-bottom: 6px; + color: #2d3748; +} + +.order-category-heading strong { + font-size: 0.82rem; +} + +.order-category-heading span { + color: #4a5568; + font-size: 0.68rem; +} + +.order-group-buttons { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.order-group-button { + min-height: 28px; + padding: 4px 9px; + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid #cbd5e0; + border-radius: 4px; + background: #fff; + color: #2d3748; + cursor: pointer; + font-size: 0.75rem; + font-weight: 700; +} + +.order-group-button:hover, +.order-group-button.has-selection, +.order-group-button.is-active { + border-color: var(--primary-color); + background: #ebf8ff; +} + +.order-group-count { + min-width: 18px; + height: 18px; + padding: 0 4px; + display: none; + align-items: center; + justify-content: center; + border-radius: 9px; + background: var(--primary-color); + color: #fff; + font-size: 0.65rem; + line-height: 1; +} + +.order-group-button.has-selection .order-group-count { + display: inline-flex; +} + +.order-workspace-body { + min-height: 0; + flex: 1; + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(270px, 38%); + gap: 10px; + margin-top: 8px; +} + +.order-summary-column { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.order-summary-column .order-selected-panel { + flex: 1; +} + +.new-cs-options-slot { + display: none; +} + +.order-items-inline-host { + min-width: 0; + min-height: 0; + padding: 8px; + overflow: hidden; + border: 1px solid #cbd5e0; + border-radius: 8px; + background: rgba(255, 255, 255, 0.32); +} + +.order-items-inline-empty { height: 100%; - overflow-x: auto; - overflow-y: hidden; - padding-bottom: 5px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + color: #4a5568; + font-size: 0.78rem; + text-align: center; } -.left-col::-webkit-scrollbar { - width: 5px; +.order-items-inline-empty span:first-child { + font-size: 2rem; } -.left-col::-webkit-scrollbar-thumb { - background: rgba(0, 0, 0, 0.2); +.order-selected-panel { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 9px; + border: 1px solid #cbd5e0; + border-radius: 8px; + background: rgba(255, 255, 255, 0.82); + overflow: hidden; +} + +.order-selected-heading { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 6px; + border-bottom: 2px solid var(--primary-color); +} + +.order-selected-heading h3 { + margin: 0; + padding: 0; + border: 0; + font-size: 0.85rem; +} + +.order-selected-heading span { + color: #2b6cb0; + font-size: 0.72rem; + font-weight: 700; +} + +.order-selected-items { + min-height: 0; + flex: 1; + padding-top: 5px; + overflow-y: auto; +} + +.order-selected-empty { + padding: 20px 5px; + color: #718096; + font-size: 0.72rem; + text-align: center; +} + +.order-selected-row { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; + padding: 6px 3px; + border-bottom: 1px solid #e2e8f0; + font-size: 0.72rem; +} + +.order-selected-remove { + width: 18px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; border-radius: 3px; + background: transparent; + color: #e53e3e; + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.order-selected-remove:hover { + background: #fff5f5; +} + +.order-selected-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 700; +} + +.order-selected-qty { + width: 58px; + height: 24px; + padding: 1px 3px; + border: 1px solid #cbd5e0; + border-radius: 4px; + background: #fff; + color: #2b6cb0; + font-size: 0.7rem; + font-weight: 700; + white-space: nowrap; +} + +.order-selected-price { + min-width: 68px; + color: #2d3748; + font-weight: 700; + text-align: right; + white-space: nowrap; +} + +.order-selected-totals { + padding: 8px 2px; + border-top: 2px solid #cbd5e0; + flex-shrink: 0; +} + +.order-selected-totals > div { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 1px; + font-size: 0.78rem; +} + +.order-selected-totals strong { + color: #e53e3e; + font-size: 0.85rem; +} + +.order-selected-totals > .order-summary-adjustments { + display: block; + padding: 0; +} + +.order-summary-adjustments > div { + display: flex; + align-items: center; + justify-content: space-between; + padding: 2px 1px; + color: #4a5568; + font-size: 0.72rem; +} + +.order-summary-adjustments b { + color: #2d3748; +} + +.order-selected-totals > .order-summary-final { + margin-top: 3px; + padding-top: 5px; + border-top: 1px dashed #cbd5e0; +} + +.order-preview-button { + width: 100%; + padding: 7px; + flex-shrink: 0; +} + +.order-items-inline-host .order-item-group { + width: 100%; + height: 100%; + display: none; + flex-direction: column; + padding: 8px; + overflow: hidden; + background: rgba(255, 255, 255, 0.9); +} + +.order-items-inline-host .order-item-group.is-active { + display: flex; +} + +.order-item-group .order-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 6px; + border-bottom: 2px solid var(--primary-color); + flex-shrink: 0; +} + +.order-item-group .order-popup-header h3 { + margin: 0; + padding: 0; + border: 0; + font-size: 0.9rem; +} + +.order-item-group .item-list { + min-height: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 3px 8px; + padding: 8px 3px 2px 0; + overflow-y: auto; +} + +.order-item-subgroup { + display: contents; +} + +.order-item-subgroup-title { + display: none; +} + +.classic-product-column { + display: contents; +} + +.order-item-group .item-row { + min-width: 0; + background: #f7fafc; +} + +.sms-preview-modal { + position: fixed; + z-index: 2600; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.52); +} + +.sms-preview-modal.is-open { + display: flex; +} + +.sms-preview-dialog { + width: min(520px, calc(100vw - 30px)); + height: min(760px, calc(100vh - 40px)); + display: flex; + flex-direction: column; + padding: 12px; + overflow: hidden; + background: #fff; +} + +.sms-preview-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + border-bottom: 2px solid var(--primary-color); + flex-shrink: 0; +} + +.sms-preview-heading h3 { + margin: 0; + padding: 0; + border: 0; + font-size: 1rem; +} + +#sms-preview { + min-height: 0; + flex: 1; + margin-top: 10px; + overflow-y: auto; } .right-col { display: grid; grid-template-columns: 260px 250px; + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); gap: 15px; height: 100%; } .right-panel-col1 { - display: flex; - flex-direction: column; - gap: 8px; + display: contents; } .right-panel-col2 { + display: contents; +} + +.right-col .customer-info { + grid-column: 2; + grid-row: 1; + min-height: 0; +} + +.right-col .history-box { + grid-column: 1; + grid-row: 1; + height: auto !important; + min-height: 0; + margin-bottom: 0 !important; +} + +.right-col .manual-history-box { + grid-column: 1; + grid-row: 2; + height: auto !important; + min-height: 0; +} + +.right-col .sms-options { + grid-column: 2; + grid-row: 2; + align-self: end; +} + +/* New CS 전용 배치: 기존 CS 작업 화면과 입력 요소를 공유합니다. */ +#order-tab.order-view-new .main-grid { + grid-template-columns: minmax(0, 1fr) 260px; +} + +#order-tab.order-view-new .order-category-heading strong { + font-size: 0.98rem; +} + +#order-tab.order-view-new .order-category-heading span { + font-size: 0.82rem; +} + +#order-tab.order-view-new .order-group-button { + min-height: 34px; + padding: 5px 11px; + font-size: 0.9rem; +} + +#order-tab.order-view-new .order-group-count { + min-width: 21px; + height: 21px; + border-radius: 11px; + font-size: 0.78rem; +} + +#order-tab.order-view-new .order-workspace-body { + grid-template-columns: minmax(0, 1fr) 370px; +} + +#order-tab.order-view-new .order-summary-column { + width: 370px; + gap: 10px; +} + +#order-tab.order-view-new .new-cs-options-slot { + display: block; + flex: 0 0 auto; +} + +#order-tab.order-view-new .new-cs-options-slot .sms-options { + width: 370px; +} + +#order-tab.order-view-new .right-col { + display: block; + min-width: 0; +} + +#order-tab.order-view-new .right-panel-col1 { + height: 100%; + display: grid; + grid-template-rows: 370px minmax(0, 1fr) minmax(0, 1fr); + gap: 10px; +} + +#order-tab.order-view-new .right-col .customer-info, +#order-tab.order-view-new .right-col .history-box, +#order-tab.order-view-new .right-col .manual-history-box { + grid-column: 1; + min-height: 0; + height: auto !important; + margin: 0 !important; +} + +#order-tab.order-view-new .right-col .customer-info { + grid-row: 1; + height: 370px !important; + overflow-y: auto; +} + +#order-tab.order-view-new .customer-info > h3 { + margin-bottom: 4px !important; +} + +#order-tab.order-view-new .customer-info .form-group { + margin-bottom: 3px; +} + +#order-tab.order-view-new .customer-info .form-group input { + padding-top: 3px; + padding-bottom: 3px; +} + +#order-tab.order-view-new .customer-info .radio-group.mt-1, +#order-tab.order-view-new .customer-info .action-buttons.mt-1 { + margin-top: 2px; +} + +#order-tab.order-view-new .customer-info .btn { + padding-top: 5px; + padding-bottom: 5px; +} + +#order-tab.order-view-new .right-col .history-box { + grid-row: 2; +} + +#order-tab.order-view-new .right-col .manual-history-box { + grid-row: 3; +} + +#order-tab.order-view-new #cust-address { + height: 100px; + min-height: 100px; + resize: vertical; +} + +#order-tab.order-view-new .order-item-group .item-list { + grid-template-columns: repeat(var(--subgroup-count, 1), minmax(0, 250px)); + align-items: start; + justify-content: start; + gap: 12px; + overflow-x: auto; +} + +#order-tab.order-view-new .order-item-subgroup { + width: 100%; + min-width: 180px; + max-width: 250px; display: flex; flex-direction: column; - gap: 8px; + gap: 3px; + padding: 6px; + overflow: hidden; + border: 1px solid #cbd5e0; + border-radius: 7px; + background: #f8fafc; + box-shadow: 0 1px 3px rgba(45, 55, 72, 0.08); +} + +#order-tab.order-view-new .order-item-subgroup-title { + min-height: 28px; + display: flex; + align-items: center; + margin: -6px -6px 3px; + padding: 5px 8px; + overflow: hidden; + border-bottom: 1px solid #bee3f8; + border-left: 4px solid #3182ce; + background: #ebf8ff; + color: #2d3748; + font-size: 0.78rem; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +#order-tab.order-view-new .order-item-subgroup-title-text { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#order-tab.order-view-new .order-item-subgroup-edit { + width: 24px; + height: 22px; + flex: 0 0 24px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 5px; + padding: 0; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + font-size: 0.78rem; + cursor: pointer; +} + +#order-tab.order-view-new .order-item-subgroup-edit:hover, +#order-tab.order-view-new .order-item-subgroup-edit:focus-visible { + border-color: #90cdf4; + background: #fff; + outline: none; +} + +#order-tab.order-view-new .order-item-subgroup-title-input { + width: 100%; + height: 24px; + padding: 2px 6px; + border: 1px solid #3182ce; + border-radius: 4px; + background: #fff; + color: #1a202c; + font-size: 0.78rem; + font-weight: 700; + outline: none; +} + +#order-tab.order-view-new .order-preview-button { + border: 1px solid #cbd5e0; + background: #e2e8f0; + color: #4a5568; +} + +#order-tab.order-view-new .order-preview-button:hover { + background: #cbd5e0; +} + +/* CS 작업: 상품 전체가 펼쳐지는 기존 UI */ +#order-tab.order-view-classic .order-category-panel, +#order-tab.order-view-classic .order-summary-column { + display: none; +} + +#order-tab.order-view-classic .order-workspace-body { height: 100%; + display: block; + margin-top: 0; +} + +#order-tab.order-view-classic .order-items-inline-host { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: repeat(6, max-content); + align-content: start; + align-items: start; + gap: 8px; + padding: 0; + overflow: auto; + border: 0; + border-radius: 0; + background: transparent; +} + +#order-tab.order-view-classic .classic-product-column { + width: max-content; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +#order-tab.order-view-classic .order-items-inline-empty { + display: none !important; +} + +#order-tab.order-view-classic .order-items-inline-host .order-item-group { + width: max-content; + max-width: none; + height: auto; + display: flex; + padding: 8px; +} + +#order-tab.order-view-classic .order-item-group .item-list, +#order-tab.order-view-classic .order-item-subgroup { + display: flex; + flex-direction: column; + gap: 1px; +} + +#order-tab.order-view-classic .order-item-subgroup + .order-item-subgroup { + margin-top: 8px; + padding-top: 8px; + border-top: 1.5px dashed #1a202c; +} + +#order-tab.order-view-classic .right-col { + display: grid; + grid-template-columns: 260px 250px; + grid-template-rows: minmax(0, 1fr); + gap: 15px; +} + +#order-tab.order-view-classic .right-panel-col1, +#order-tab.order-view-classic .right-panel-col2 { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; +} + +#order-tab.order-view-classic .right-panel-col1 { + grid-column: 1; + grid-row: 1; +} + +#order-tab.order-view-classic .right-panel-col2 { + grid-column: 2; + grid-row: 1; +} + +#order-tab.order-view-classic .right-col .customer-info { + width: 100%; + height: 330px !important; + flex: 0 0 330px; +} + +#order-tab.order-view-classic .right-col .history-box { + width: 100%; + height: 260px !important; + flex: 0 0 260px; +} + +#order-tab.order-view-classic .right-col .manual-history-box { + width: 100%; + height: auto !important; + flex: 1 1 0; +} + +#order-tab.order-view-classic .sms-preview-modal { + position: static; + width: 100%; + height: auto; + flex: 1 1 0; + min-height: 0; + display: block; + padding: 8px; + overflow: hidden; + border: 1px solid var(--card-border); + border-radius: 8px; + background: var(--card-bg); + box-shadow: var(--card-shadow); +} + +#order-tab.order-view-classic .sms-preview-dialog { + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + box-shadow: none; +} + +#order-tab.order-view-classic .sms-preview-heading { + display: none; +} + +#order-tab.order-view-classic #sms-preview { + height: 100%; + margin: 0; +} + +#order-tab.order-view-classic .right-col .sms-options { + width: 100%; + flex: 0 0 auto; + align-self: auto; } .right-col::-webkit-scrollbar { @@ -732,6 +1501,11 @@ p { border-left-color: transparent; border-bottom-color: var(--primary-color); } + /* 가로 스크롤 메뉴에서는 아이콘 칸을 좁혀 폭을 아낀다 */ + .nav-links li .nav-icon { + width: auto; + margin-right: 4px; + } /* 3. Main Grid & Left Col (Product List) */ .main-grid { @@ -740,16 +1514,13 @@ p { height: auto; gap: 15px; } - .left-col { - height: 45vh; /* Fixed height with internal scroll */ - flex-direction: column; - flex-wrap: nowrap; - overflow-y: auto; - overflow-x: hidden; - border: 1px solid rgba(0, 0, 0, 0.1); - padding: 5px; - border-radius: 8px; - background: rgba(255, 255, 255, 0.5); + .order-workspace { + height: auto; + min-height: 80vh; + } + .order-workspace-body { + grid-template-columns: 1fr; + grid-template-rows: minmax(45vh, 1fr) minmax(35vh, auto); } .item-group-card { width: 100%; @@ -764,6 +1535,9 @@ p { gap: 15px; } .right-panel-col1, .right-panel-col2 { + display: flex; + flex-direction: column; + gap: 15px; width: 100%; height: auto; } @@ -1096,4 +1870,53 @@ p { .toggle-switch input:checked + .switch-label:before { transform: translateX(26px); -} \ No newline at end of file +} + +/* 반품 송장 일괄 입력: 편집기처럼 입력 줄과 함께 움직이는 줄 번호 */ +.bulk-tracking-editor { + --bulk-tracking-border: #cbd5e0; + position: relative; + width: 100%; +} + +.bulk-tracking-line-numbers { + position: absolute; + z-index: 1; + top: 1px; + bottom: 1px; + left: 1px; + width: 38px; + padding: 10px 7px 10px 4px; + overflow: hidden; + border-right: 1px solid var(--bulk-tracking-border); + border-radius: 3px 0 0 3px; + background: #f7fafc; + color: #718096; + font-family: monospace; + font-size: 13px; + line-height: 20px; + text-align: right; + white-space: pre; + user-select: none; + pointer-events: none; +} + +.bulk-tracking-input { + display: block; + width: 100%; + height: 200px; + padding: 10px 10px 10px 48px; + overflow: auto; + border: 1px solid var(--bulk-tracking-border); + border-radius: 4px; + resize: vertical; + font-family: monospace; + font-size: 13px; + line-height: 20px; + white-space: pre; +} + +.bulk-tracking-input:focus { + outline: none; + border-color: var(--bulk-tracking-border); +} diff --git a/static/js/app.js b/static/js/app.js index 94483a2..db52bbb 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,4 +1,3 @@ - // ============================================================ // 서브경로 호스팅 — fetch wrapper // 모든 /api/* 호출에 APP_BASE_PATH 를 prepend, 401 응답 시 dbx-main 로그인으로 이동. @@ -40,6 +39,38 @@ function showToast(message) { setTimeout(() => toast.remove(), 300); }, 1500); } +async function copyTextToClipboard(text) { + if (!text) return false; + + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (err) { + console.warn('navigator.clipboard failed, trying fallback:', err); + } + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + try { + return document.execCommand('copy'); + } catch (err) { + console.error('Clipboard fallback failed:', err); + return false; + } finally { + textarea.remove(); + } +} + function formatShortDate(dateStr) { if (!dateStr) return ""; const d = new Date(dateStr); @@ -52,10 +83,23 @@ function formatShortDate(dateStr) { } document.addEventListener('DOMContentLoaded', () => { + const ORDER_TYPE_LABELS = { noolak: '누락', pason: '파손', bullyang: '불량', misdelivery: '오배송', normal: '수동발주', ellen: '엘렌' }; + const SPECIAL_ORDER_TYPES = new Set(['noolak', 'pason', 'bullyang', 'misdelivery']); + // Tab switching logic const navLinks = document.querySelectorAll('.nav-links li'); const tabContents = document.querySelectorAll('.tab-content'); + function setOrderView(view) { + const orderTab = document.getElementById('order-tab'); + const smsOptions = document.querySelector('.sms-options'); + const targetSlot = document.getElementById(view === 'new' ? 'new-cs-options-slot' : 'classic-options-slot'); + if (!orderTab || !smsOptions || !targetSlot) return; + orderTab.classList.toggle('order-view-new', view === 'new'); + orderTab.classList.toggle('order-view-classic', view !== 'new'); + targetSlot.appendChild(smsOptions); + } + navLinks.forEach(link => { link.addEventListener('click', () => { navLinks.forEach(l => l.classList.remove('active')); @@ -66,6 +110,7 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById(target).classList.add('active'); if (target === 'order-tab') { + setOrderView(link.dataset.orderView || 'new'); const estExtra = document.getElementById('setting-ship-extra'); const lblObj = document.getElementById('lbl-ship-extra'); if (estExtra && lblObj) { @@ -83,6 +128,455 @@ document.addEventListener('DOMContentLoaded', () => { }); }); + setOrderView(document.querySelector('.nav-links li.active[data-order-view]')?.dataset.orderView || 'new'); + + // ---- 카페24 자동발주 ---- + (function initCafe24AutoOrder() { + const authBtn = document.getElementById('cafe24AuthBtn'); + const downloadBtn = document.getElementById('cafe24DownloadBtn'); + const progressWrapper = document.getElementById('cafe24ProgressWrapper'); + const progressText = document.getElementById('cafe24ProgressText'); + const progressPercent = document.getElementById('cafe24ProgressPercent'); + const progressBar = document.getElementById('cafe24ProgressBar'); + const uploadForm = document.getElementById('cafe24UploadForm'); + const uploadBtn = document.getElementById('cafe24UploadBtn'); + const uploadSpinner = document.getElementById('cafe24UploadSpinner'); + const fileDropArea = document.getElementById('cafe24FileDropArea'); + const fileInput = document.getElementById('cafe24InvoiceFile'); + const fileMessage = document.getElementById('cafe24FileMsg'); + const resultBox = document.getElementById('cafe24ResultBox'); + const resultTitle = document.getElementById('cafe24ResultTitle'); + const resultText = document.getElementById('cafe24ResultText'); + + if (!authBtn || !downloadBtn) return; + + const sleep = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds)); + + async function responseMessage(response, fallback) { + try { + const data = await response.json(); + return data.message || data.detail || data.error || fallback; + } catch (_) { + return fallback; + } + } + + function showResult(success, title, message) { + if (!resultBox || !resultTitle || !resultText) return; + resultBox.classList.remove('hidden'); + resultBox.style.backgroundColor = success ? 'rgba(47, 133, 90, 0.85)' : 'rgba(197, 48, 48, 0.85)'; + resultBox.style.border = success ? '1px solid #68d391' : '1px solid #fc8181'; + resultTitle.textContent = title; + resultText.textContent = message; + } + + function hideResult() { + if (resultBox) resultBox.classList.add('hidden'); + } + + function setAuthState(authenticated, message) { + authBtn.textContent = authenticated ? '연동됨' : '재연동 필요'; + authBtn.style.backgroundColor = authenticated ? '#2f855a' : '#c05621'; + authBtn.title = message || ''; + } + + async function refreshAuthState() { + try { + const response = await fetch('/api/cafe24/status', { cache: 'no-store' }); + if (!response.ok) throw new Error('status request failed'); + const data = await response.json(); + setAuthState(Boolean(data.authenticated), data.message); + } catch (_) { + setAuthState(false, '카페24 인증 상태를 확인할 수 없습니다.'); + } + } + + function setDownloadBusy(isBusy) { + downloadBtn.disabled = isBusy; + downloadBtn.style.opacity = isBusy ? '0.65' : '1'; + if (progressWrapper) progressWrapper.classList.toggle('hidden', !isBusy); + if (!isBusy) return; + if (progressText) progressText.textContent = '주문 조회 준비 중...'; + if (progressPercent) progressPercent.textContent = '0%'; + if (progressBar) progressBar.style.width = '0%'; + } + + function updateProgress(current, total) { + const hasTotal = Number(total) > 0; + const percent = hasTotal ? Math.min(100, Math.round((Number(current) / Number(total)) * 100)) : 0; + if (progressText) { + progressText.textContent = hasTotal + ? `주문 수집 중... ${Number(current).toLocaleString()} / ${Number(total).toLocaleString()}건` + : '배송준비중 주문을 조회하고 있습니다...'; + } + if (progressPercent) progressPercent.textContent = hasTotal ? `${percent}%` : '조회 중'; + if (progressBar) progressBar.style.width = hasTotal ? `${percent}%` : '12%'; + } + + function downloadResponseBlob(response, blob) { + const disposition = response.headers.get('Content-Disposition') || ''; + const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i); + const basicMatch = disposition.match(/filename="?([^";]+)"?/i); + let filename = '카페24_발주.xlsx'; + try { + if (utf8Match) filename = decodeURIComponent(utf8Match[1]); + else if (basicMatch) filename = basicMatch[1]; + } catch (_) {} + + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.URL.revokeObjectURL(url); + } + + authBtn.addEventListener('click', () => { + window.location.href = (window.APP_BASE_PATH || '') + '/api/cafe24/login'; + }); + + downloadBtn.addEventListener('click', async () => { + hideResult(); + setDownloadBusy(true); + try { + const startResponse = await fetch('/api/cafe24/start_download', { method: 'POST' }); + if (!startResponse.ok) { + throw new Error(await responseMessage(startResponse, '카페24 주문 조회를 시작하지 못했습니다.')); + } + const startData = await startResponse.json(); + if (!startData.task_id) throw new Error('카페24 작업 번호를 받지 못했습니다.'); + + while (true) { + await sleep(700); + const progressResponse = await fetch(`/api/tasks/progress/${encodeURIComponent(startData.task_id)}`, { cache: 'no-store' }); + if (!progressResponse.ok) { + const error = new Error(await responseMessage(progressResponse, '카페24 주문 조회에 실패했습니다.')); + error.isAuthError = progressResponse.status === 401; + throw error; + } + + const progressData = await progressResponse.json(); + updateProgress(progressData.current, progressData.total); + if (progressData.status !== 'completed') continue; + + if (progressText) progressText.textContent = '엑셀 파일 생성 완료'; + if (progressPercent) progressPercent.textContent = '100%'; + if (progressBar) progressBar.style.width = '100%'; + + const fileResponse = await fetch(`/api/tasks/download_file/${encodeURIComponent(startData.task_id)}`); + if (!fileResponse.ok) { + throw new Error(await responseMessage(fileResponse, '완성된 엑셀 파일을 내려받지 못했습니다.')); + } + downloadResponseBlob(fileResponse, await fileResponse.blob()); + showResult(true, '다운로드 완료', '카페24 발주 엑셀 파일을 저장했습니다.'); + break; + } + } catch (error) { + if (error.isAuthError || /인증|연동|authenticate|expired/i.test(error.message || '')) { + setAuthState(false, error.message); + } + showResult(false, '카페24 주문 다운로드 실패', error.message || '처리 중 오류가 발생했습니다.'); + } finally { + setDownloadBusy(false); + } + }); + + function updateSelectedFile() { + const file = fileInput && fileInput.files ? fileInput.files[0] : null; + if (fileMessage) { + fileMessage.textContent = file + ? `선택 파일: ${file.name}` + : '엑셀 파일을 이곳에 드래그하거나 클릭하여 업로드하세요. (.xls, .xlsx)'; + } + if (uploadBtn) uploadBtn.disabled = !file; + } + + if (fileDropArea && fileInput) { + fileDropArea.addEventListener('click', event => { + if (event.target !== fileInput) fileInput.click(); + }); + fileInput.addEventListener('change', updateSelectedFile); + ['dragenter', 'dragover'].forEach(eventName => { + fileDropArea.addEventListener(eventName, event => { + event.preventDefault(); + fileDropArea.style.borderColor = '#63b3ed'; + fileDropArea.style.background = 'rgba(66, 153, 225, 0.15)'; + }); + }); + ['dragleave', 'drop'].forEach(eventName => { + fileDropArea.addEventListener(eventName, event => { + event.preventDefault(); + fileDropArea.style.borderColor = '#718096'; + fileDropArea.style.background = 'rgba(255, 255, 255, 0.05)'; + }); + }); + fileDropArea.addEventListener('drop', event => { + if (!event.dataTransfer || !event.dataTransfer.files.length) return; + fileInput.files = event.dataTransfer.files; + updateSelectedFile(); + }); + } + + if (uploadForm && fileInput && uploadBtn) { + uploadForm.addEventListener('submit', async event => { + event.preventDefault(); + if (!fileInput.files || !fileInput.files[0]) return; + + hideResult(); + uploadBtn.disabled = true; + uploadBtn.textContent = '송장 등록 중...'; + if (uploadSpinner) uploadSpinner.classList.remove('hidden'); + + try { + const formData = new FormData(uploadForm); + const response = await fetch('/api/cafe24/upload_invoices', { method: 'POST', body: formData }); + if (!response.ok) { + const error = new Error(await responseMessage(response, '송장 등록에 실패했습니다.')); + error.isAuthError = response.status === 401; + throw error; + } + + const data = await response.json(); + const successCount = Number(data.success_count || 0); + const failCount = Number(data.fail_count || 0); + showResult( + failCount === 0, + failCount === 0 ? '송장 등록 완료' : '송장 등록 결과', + `성공 ${successCount.toLocaleString()}건 / 실패 ${failCount.toLocaleString()}건` + ); + uploadForm.reset(); + updateSelectedFile(); + } catch (error) { + if (error.isAuthError || /인증|연동|authenticate|expired/i.test(error.message || '')) { + setAuthState(false, error.message); + } + showResult(false, '송장 등록 실패', error.message || '처리 중 오류가 발생했습니다.'); + } finally { + uploadBtn.textContent = '🚀 송장 일괄 발송처리'; + if (uploadSpinner) uploadSpinner.classList.add('hidden'); + updateSelectedFile(); + } + }); + } + + updateSelectedFile(); + refreshAuthState(); + })(); + + // ---- 쿠팡 밀크런 낱개 분해 ---- + (function initCoupangMilkrun() { + const input = document.getElementById('milkrun-input'); + const analyzeBtn = document.getElementById('btn-milkrun-analyze'); + const clearBtn = document.getElementById('btn-milkrun-clear'); + const downloadBtn = document.getElementById('btn-milkrun-download'); + const linesTbody = document.getElementById('milkrun-lines-tbody'); + const linesInfo = document.getElementById('milkrun-lines-info'); + const totalsTbody = document.getElementById('milkrun-totals-tbody'); + const unresolvedBox = document.getElementById('milkrun-unresolved-box'); + const summaryInfo = document.getElementById('milkrun-summary-info'); + const loadingOverlay = document.getElementById('milkrun-loading-overlay'); + const loadingText = document.getElementById('milkrun-loading-text'); + if (!input || !analyzeBtn || !totalsTbody) return; + + const LOADING_MESSAGES = [ + { delay: 0, text: '계산 중...' }, + { delay: 4000, text: '조금만 더 기다려줘' }, + { delay: 8000, text: '이제 거의 다 되었어' }, + { delay: 12000, text: '다 끝나가는 중' } + ]; + let loadingTimers = []; + + function setMilkrunLoading(isLoading) { + if (loadingOverlay) loadingOverlay.style.display = isLoading ? 'flex' : 'none'; + analyzeBtn.disabled = isLoading; + + loadingTimers.forEach(t => clearTimeout(t)); + loadingTimers = []; + + if (isLoading && loadingText) { + LOADING_MESSAGES.forEach(m => { + if (m.delay === 0) { + loadingText.textContent = m.text; + } else { + loadingTimers.push(setTimeout(() => { loadingText.textContent = m.text; }, m.delay)); + } + }); + } + } + + function escapeHtml(str) { + return String(str == null ? '' : str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function resetMilkrunResult() { + if (linesTbody) linesTbody.innerHTML = ''; + if (linesInfo) linesInfo.textContent = ''; + totalsTbody.innerHTML = ''; + unresolvedBox.style.display = 'none'; + unresolvedBox.innerHTML = ''; + summaryInfo.textContent = ''; + } + + function renderLineRow(line) { + const hasMultiplier = line.multiplier && line.multiplier !== 1; + const codeCell = hasMultiplier + ? '
' + escapeHtml(line.code_raw) + '
→ ' + escapeHtml(line.base_code) + ' × ' + line.multiplier + '
' + : escapeHtml(line.code_raw); + const qtyCell = hasMultiplier + ? line.qty.toLocaleString() + ' → ' + line.effective_qty.toLocaleString() + '' + : line.qty.toLocaleString(); + + let breakdownCell; + let rowStyle = ''; + if (line.resolved && line.type === 'set') { + const badge = '세트'; + const breakdown = line.breakdown || []; + const compositionParts = breakdown.map(function (b) { + return escapeHtml(b.single_code) + ' × ' + (b.per_unit_qty != null ? b.per_unit_qty.toLocaleString() : '?'); + }).join(', '); + const resultParts = breakdown.map(function (b) { + return escapeHtml(b.single_code) + ' × ' + b.qty.toLocaleString(); + }).join(', '); + breakdownCell = + '
' + badge + + '구성(1세트): ' + compositionParts + '
' + + '
→ ' + line.effective_qty.toLocaleString() + '개 → ' + resultParts + '
'; + } else if (line.resolved) { + const badge = '단품'; + const parts = (line.breakdown || []).map(function (b) { + return escapeHtml(b.single_code) + ' × ' + b.qty.toLocaleString(); + }).join(', '); + breakdownCell = '' + badge + + '' + parts + ''; + } else { + rowStyle = 'background:#000000; color:#f6e05e;'; + breakdownCell = '⚠ 미등록 코드 — 낱개 합계 계산에서 제외됨'; + } + + return '' + + '' + codeCell + '' + + '' + qtyCell + '' + + '' + breakdownCell + '' + + ''; + } + + async function runMilkrunAnalysis() { + const text = input.value; + if (!text || !text.trim()) { + resetMilkrunResult(); + return; + } + setMilkrunLoading(true); + try { + const res = await fetch('/api/coupang-milkrun/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }) + }); + const data = await res.json(); + if (data.status !== 'success') { + alert('분석 중 오류가 발생했습니다.'); + return; + } + + if (linesTbody) { + if (!data.lines.length) { + linesTbody.innerHTML = '인식된 줄이 없습니다.'; + } else { + linesTbody.innerHTML = data.lines.map(renderLineRow).join(''); + } + } + if (linesInfo) { + const failCount = data.lines.filter(l => !l.resolved).length; + linesInfo.textContent = failCount > 0 + ? `총 ${data.lines.length}줄 · 미등록 ${failCount}줄` + : `총 ${data.lines.length}줄 · 전부 인식됨`; + } + + if (!data.totals.length) { + totalsTbody.innerHTML = '인식된 항목이 없습니다. 붙여넣은 형식을 확인해주세요.'; + } else { + totalsTbody.innerHTML = data.totals.map(t => ` + + ${t.sabangnet_code || ''} + ${t.single_code} + ${t.name || ''} + ${t.total_qty.toLocaleString()} + + `).join(''); + } + + if (data.unresolved && data.unresolved.length > 0) { + unresolvedBox.style.display = 'block'; + unresolvedBox.innerHTML = '⚠ 코드표에 등록되지 않은 코드: ' + + data.unresolved.map(u => `${u.code} (${u.qty.toLocaleString()}개)`).join(', '); + } else { + unresolvedBox.style.display = 'none'; + unresolvedBox.innerHTML = ''; + } + + const totalUnits = data.totals.reduce((sum, t) => sum + t.total_qty, 0); + summaryInfo.innerHTML = `${data.lines.length}줄 입력
낱개 합계 ${totalUnits.toLocaleString()}개`; + } catch (err) { + console.error(err); + alert('밀크런 분석 요청 중 오류가 발생했습니다.'); + } finally { + setMilkrunLoading(false); + } + } + + analyzeBtn.addEventListener('click', runMilkrunAnalysis); + if (clearBtn) { + clearBtn.addEventListener('click', () => { + input.value = ''; + resetMilkrunResult(); + }); + } + input.addEventListener('paste', () => { + setTimeout(runMilkrunAnalysis, 50); + }); + + if (downloadBtn) { + downloadBtn.addEventListener('click', async () => { + const text = input.value; + if (!text || !text.trim()) { + alert('먼저 밀크런 내용을 붙여넣고 분석해주세요.'); + return; + } + try { + const res = await fetch('/api/coupang-milkrun/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }) + }); + if (!res.ok) { + alert('엑셀 다운로드 중 오류가 발생했습니다.'); + return; + } + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = '쿠팡_밀크런_낱개코드.xlsx'; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error(err); + alert('엑셀 다운로드 요청 중 오류가 발생했습니다.'); + } + }); + } + })(); + // Auto format phone number document.getElementById('cust-phone').addEventListener('input', function (e) { let val = this.value.replace(/[^0-9]/g, ''); @@ -104,6 +598,33 @@ document.addEventListener('DOMContentLoaded', () => { this.value = res; }); + function parseReshipmentInfo(text) { + const lines = text + .replace(/\r\n?/g, '\n') + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + const headerIndex = lines.indexOf('[재발송 정보]'); + + if (headerIndex === -1) return null; + + const fields = {}; + lines.slice(headerIndex + 1).forEach(line => { + const match = line.match(/^(이름|주소|휴대폰번호)\s*[::]\s*(.+)$/); + if (match) fields[match[1]] = match[2].trim(); + }); + + if (!fields['이름'] || !fields['주소'] || !fields['휴대폰번호']) { + return null; + } + + return { + name: fields['이름'], + phone: fields['휴대폰번호'], + address: fields['주소'] + }; + } + async function handleClipboardPaste(targetField) { try { if (!navigator.clipboard || !navigator.clipboard.readText) { @@ -114,7 +635,25 @@ document.addEventListener('DOMContentLoaded', () => { const trimmed = text.trim(); const digits = text.replace(/[^0-9]/g, ''); const hasPhone = digits.length >= 9 && digits.length <= 12; - + + if (targetField === 'name') { + const reshipmentInfo = parseReshipmentInfo(text); + if (reshipmentInfo) { + const nameInput = document.getElementById('cust-name'); + const phoneInput = document.getElementById('cust-phone'); + const addressInput = document.getElementById('cust-address'); + + nameInput.value = reshipmentInfo.name; + nameInput.dispatchEvent(new Event('input', { bubbles: true })); + phoneInput.value = reshipmentInfo.phone; + phoneInput.dispatchEvent(new Event('input', { bubbles: true })); + addressInput.value = reshipmentInfo.address; + addressInput.dispatchEvent(new Event('input', { bubbles: true })); + showToast('재발송 정보를 자동 입력했습니다.'); + return; + } + } + // 이름은 한글 2~5글자를 인식하도록 완화 const nameMatch = trimmed.match(/^[가-힣]{2,5}/); @@ -264,8 +803,166 @@ document.addEventListener('DOMContentLoaded', () => { // ============================================ // BUILD ORDER TAB // ============================================ + + const orderItemsContainer = document.getElementById('order-items-container'); + const orderGroupButtons = document.getElementById('order-group-buttons'); + const orderSelectedItems = document.getElementById('order-selected-items'); + const orderSelectedCount = document.getElementById('order-selected-count'); + const orderSummaryOrderAmount = document.getElementById('order-summary-order-amount'); + const orderSummaryDepositAmount = document.getElementById('order-summary-deposit-amount'); + const orderSummaryAdjustments = document.getElementById('order-summary-adjustments'); + + function showOrderItemGroup(card, button) { + if (!orderItemsContainer || !card) return; + orderItemsContainer.querySelectorAll('.order-item-group.is-active').forEach(item => { + item.classList.remove('is-active'); + }); + orderGroupButtons.querySelectorAll('.order-group-button.is-active').forEach(item => { + item.classList.remove('is-active'); + }); + const emptyGuide = orderItemsContainer.querySelector('.order-items-inline-empty'); + if (emptyGuide) emptyGuide.style.display = 'none'; + card.classList.add('is-active'); + if (button) button.classList.add('is-active'); + } + + function getOrderRowQuantity(row) { + const qtyControl = row ? row.querySelector('.qty-order') : null; + return Math.max(1, parseInt(qtyControl?.value, 10) || 1); + } + + function updateOrderGroupBadges() { + if (!orderGroupButtons || !orderItemsContainer) return; + orderGroupButtons.querySelectorAll('.order-group-button').forEach(button => { + const card = Array.from(orderItemsContainer.querySelectorAll('.order-item-group')) + .find(item => item.dataset.groupKey === button.dataset.groupKey); + const selectedCount = card ? card.querySelectorAll('.chk-order:checked').length : 0; + button.classList.toggle('has-selection', selectedCount > 0); + const badge = button.querySelector('.order-group-count'); + if (badge) badge.textContent = selectedCount; + }); + } + + function getSelectedOrderItemTotal(checkbox, quantity) { + const cost = parseInt(checkbox.dataset.cost, 10) || 0; + const dcost = checkbox.dataset.dcost === 'null' ? null : (parseInt(checkbox.dataset.dcost, 10) || null); + const orderType = document.querySelector('input[name="order-type"]:checked')?.value; + const noLidChecked = document.getElementById('chk-no-lid')?.checked; + if (SPECIAL_ORDER_TYPES.has(orderType) || noLidChecked) return 0; + if (dcost !== null && quantity >= 3) { + return Math.floor(quantity / 3) * dcost * 2 + (quantity % 3) * cost; + } + return cost * quantity; + } + + function updateSelectedOrderItemsSummary() { + if (!orderItemsContainer || !orderSelectedItems || !orderSelectedCount) return; + const selected = new Map(); + orderItemsContainer.querySelectorAll('.chk-order:checked').forEach(checkbox => { + const row = checkbox.closest('.item-row'); + const label = checkbox.dataset.label || checkbox.dataset.name || '상품'; + const quantity = getOrderRowQuantity(row); + selected.set(checkbox, { + label, + quantity, + amount: getSelectedOrderItemTotal(checkbox, quantity), + checkbox + }); + }); + + orderSelectedItems.innerHTML = ''; + if (selected.size === 0) { + const empty = document.createElement('p'); + empty.className = 'order-selected-empty'; + empty.textContent = '선택된 상품이 없습니다.'; + orderSelectedItems.appendChild(empty); + } else { + selected.forEach(item => { + const row = document.createElement('div'); + row.className = 'order-selected-row'; + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'order-selected-remove'; + remove.setAttribute('aria-label', `${item.label} 삭제`); + remove.title = '선택 상품 삭제'; + remove.textContent = '×'; + const name = document.createElement('span'); + name.className = 'order-selected-name'; + name.title = item.label; + name.textContent = item.label; + const quantity = document.createElement('select'); + quantity.className = 'order-selected-qty'; + const qtyValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 30, 40, 50]; + if (!qtyValues.includes(item.quantity)) qtyValues.push(item.quantity); + qtyValues.sort((a, b) => a - b).forEach(value => { + const option = document.createElement('option'); + option.value = value; + option.textContent = `${value}개`; + option.selected = value === item.quantity; + quantity.appendChild(option); + }); + const price = document.createElement('span'); + price.className = 'order-selected-price'; + price.textContent = `${item.amount.toLocaleString()}원`; + remove.addEventListener('click', () => { + item.checkbox.checked = false; + const sourceRow = item.checkbox.closest('.item-row'); + if (sourceRow) sourceRow.style.backgroundColor = ''; + updateOrderNoLidState(); + updateSelectedOrderItemsSummary(); + }); + quantity.addEventListener('change', () => { + const sourceRow = item.checkbox.closest('.item-row'); + const sourceQty = sourceRow?.querySelector('.qty-order'); + if (!sourceQty) return; + const nextValue = quantity.value; + if (sourceQty.tagName === 'SELECT' && !Array.from(sourceQty.options).some(option => option.value === nextValue)) { + const option = document.createElement('option'); + option.value = nextValue; + option.textContent = nextValue; + sourceQty.appendChild(option); + } + sourceQty.value = nextValue; + updateSelectedOrderItemsSummary(); + }); + row.append(remove, name, quantity, price); + orderSelectedItems.appendChild(row); + }); + } + orderSelectedCount.textContent = `${selected.size}종`; + const payload = getOrderPayload(); + const displayOrderAmount = selected.size > 0 ? payload.raw_total : 0; + const displayDepositAmount = selected.size > 0 ? payload.total_amount : 0; + if (orderSummaryOrderAmount) orderSummaryOrderAmount.textContent = `${displayOrderAmount.toLocaleString()}원`; + if (orderSummaryDepositAmount) orderSummaryDepositAmount.textContent = `${displayDepositAmount.toLocaleString()}원`; + if (orderSummaryAdjustments) { + orderSummaryAdjustments.innerHTML = ''; + const detailRows = []; + if (selected.size > 0) { + if (payload.discount_5_amount > 0) detailRows.push(['5% 할인 금액', -payload.discount_5_amount]); + if (payload.coupon_amount > 0) detailRows.push(['쿠폰 할인 금액', -payload.coupon_amount]); + if (payload.point_deduction > 0) detailRows.push(['적립금 차감', -payload.point_deduction]); + if (payload.shipping_cost > 0) detailRows.push(['택배비', payload.shipping_cost]); + if (document.getElementById('sms-ship-free')?.checked || payload.free_reason) { + detailRows.push(['배송비', payload.shipping_cost > 0 ? payload.shipping_cost : '무료배송']); + } + if (payload.extra_shipping_cost > 0) detailRows.push(['추가배송비', payload.extra_shipping_cost]); + if (document.getElementById('sms-june-promo')?.checked) detailRows.push(['금액별 추가상품', '적용']); + } + detailRows.forEach(([label, value]) => { + const detail = document.createElement('div'); + const valueText = typeof value === 'number' + ? `${value < 0 ? '-' : ''}${Math.abs(value).toLocaleString()}원` + : value; + detail.innerHTML = `${label}${valueText}`; + orderSummaryAdjustments.appendChild(detail); + }); + } + updateOrderGroupBadges(); + refreshLiveSmsPreview(); + } - document.getElementById('order-items-container').addEventListener('change', function(e) { + orderItemsContainer.addEventListener('change', function(e) { if (e.target.classList.contains('qty-order') && e.target.tagName === 'SELECT' && e.target.value === 'manual') { const input = document.createElement('input'); input.type = 'number'; @@ -277,6 +974,11 @@ document.addEventListener('DOMContentLoaded', () => { input.focus(); input.select(); } + updateSelectedOrderItemsSummary(); + }); + + orderItemsContainer.addEventListener('input', function(e) { + if (e.target.classList.contains('qty-order')) updateSelectedOrderItemsSummary(); }); document.getElementById('order-items-container').addEventListener('blur', function(e) { @@ -293,10 +995,17 @@ document.addEventListener('DOMContentLoaded', () => { } else if (parseInt(e.target.value) < 1) { e.target.value = '1'; } + updateSelectedOrderItemsSummary(); } }, true); function buildOrderTab(data) { const container = document.getElementById('order-items-container'); + container.innerHTML = ''; + if (orderGroupButtons) orderGroupButtons.innerHTML = ''; + const emptyGuide = document.createElement('div'); + emptyGuide.className = 'order-items-inline-empty'; + emptyGuide.innerHTML = '📦위의 상품 분류를 선택하세요.'; + container.appendChild(emptyGuide); let sections = []; if (data.GROUP_NAMES) { @@ -327,11 +1036,31 @@ document.addEventListener('DOMContentLoaded', () => { sections.forEach(sec => { if (!data[sec.key]) return; const card = document.createElement('div'); - card.className = 'glass-card item-group-card'; + card.className = 'glass-card item-group-card order-item-group'; + card.dataset.groupKey = sec.key; + + const popupHeader = document.createElement('div'); + popupHeader.className = 'order-popup-header'; const title = document.createElement('h3'); title.textContent = sec.title; - card.appendChild(title); + popupHeader.appendChild(title); + card.appendChild(popupHeader); + + if (orderGroupButtons) { + const groupButton = document.createElement('button'); + groupButton.type = 'button'; + groupButton.className = 'order-group-button'; + groupButton.dataset.groupKey = sec.key; + const buttonTitle = document.createElement('span'); + buttonTitle.textContent = sec.title; + const buttonCount = document.createElement('span'); + buttonCount.className = 'order-group-count'; + buttonCount.textContent = '0'; + groupButton.append(buttonTitle, buttonCount); + groupButton.addEventListener('click', () => showOrderItemGroup(card, groupButton)); + orderGroupButtons.appendChild(groupButton); + } const grid = document.createElement('div'); grid.className = 'item-list'; @@ -411,12 +1140,145 @@ document.addEventListener('DOMContentLoaded', () => { }); + const subgroupColumns = []; + let subgroupColumn = document.createElement('div'); + subgroupColumn.className = 'order-item-subgroup'; + Array.from(grid.children).forEach(child => { + if (child.tagName === 'HR') { + if (subgroupColumn.children.length > 0) subgroupColumns.push(subgroupColumn); + subgroupColumn = document.createElement('div'); + subgroupColumn.className = 'order-item-subgroup'; + return; + } + subgroupColumn.appendChild(child); + }); + if (subgroupColumn.children.length > 0) subgroupColumns.push(subgroupColumn); + subgroupColumns.forEach((column, index) => { + const labels = Array.from(column.querySelectorAll('.chk-order')) + .map(checkbox => (checkbox.dataset.label || '').trim()) + .filter(Boolean); + const families = labels.map(label => label + .replace(/^\*/, '') + .replace(/_\d+개$/, '') + .replace(/\([^)]*\)/g, '') + .replace(/\d[\d,.]*\s*(?:ml|mL|ML|ℓ|L|l|개|매|종)?/g, '') + .replace(/[_+,/-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + ).filter(Boolean); + const wordLists = families.map(family => family.split(' ')); + const commonWords = []; + if (wordLists.length > 0) { + const shortestLength = Math.min(...wordLists.map(words => words.length)); + for (let wordIndex = 0; wordIndex < shortestLength; wordIndex += 1) { + const candidate = wordLists[0][wordIndex]; + if (!wordLists.every(words => words[wordIndex] === candidate)) break; + commonWords.push(candidate); + } + } + const firstFamily = families[0] || `그룹 ${index + 1}`; + const automaticTitle = commonWords.length > 0 + ? commonWords.join(' ') + : `${firstFamily}${labels.length > 1 ? ` 외 ${labels.length - 1}종` : ''}`; + const subgroupSettingKey = `${sec.key}__${index}`; + const savedTitle = (data.SUBGROUP_NAMES?.[subgroupSettingKey] || '').trim(); + const subgroupTitle = document.createElement('div'); + subgroupTitle.className = 'order-item-subgroup-title'; + const renderSubgroupTitle = titleText => { + subgroupTitle.replaceChildren(); + subgroupTitle.title = titleText; + const titleLabel = document.createElement('span'); + titleLabel.className = 'order-item-subgroup-title-text'; + titleLabel.textContent = titleText; + const editButton = document.createElement('button'); + editButton.type = 'button'; + editButton.className = 'order-item-subgroup-edit'; + editButton.textContent = '✏️'; + editButton.title = '그룹 이름 수정'; + editButton.setAttribute('aria-label', `${titleText} 그룹 이름 수정`); + editButton.addEventListener('click', event => { + event.stopPropagation(); + const previousTitle = titleLabel.textContent; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'order-item-subgroup-title-input'; + input.value = previousTitle; + input.maxLength = 40; + input.setAttribute('aria-label', '그룹 이름 입력'); + subgroupTitle.replaceChildren(input); + input.focus(); + input.select(); + let finished = false; + const finishEditing = async shouldSave => { + if (finished) return; + finished = true; + if (!shouldSave) { + renderSubgroupTitle(previousTitle); + return; + } + const enteredName = input.value.trim(); + const nextTitle = enteredName || automaticTitle; + renderSubgroupTitle(nextTitle); + if (nextTitle === previousTitle) return; + try { + const response = await fetch('/api/products/subgroup-name', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + group_key: sec.key, + subgroup_index: index, + name: enteredName + }) + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.detail || '그룹 이름 저장에 실패했습니다.'); + } + if (!configData.SUBGROUP_NAMES) configData.SUBGROUP_NAMES = {}; + if (enteredName) configData.SUBGROUP_NAMES[subgroupSettingKey] = enteredName; + else delete configData.SUBGROUP_NAMES[subgroupSettingKey]; + showToast('그룹 이름이 저장되었습니다.'); + } catch (error) { + renderSubgroupTitle(previousTitle); + alert(error.message || '그룹 이름 저장에 실패했습니다.'); + } + }; + input.addEventListener('keydown', keyEvent => { + if (keyEvent.key === 'Enter') { + keyEvent.preventDefault(); + input.blur(); + } else if (keyEvent.key === 'Escape') { + keyEvent.preventDefault(); + finishEditing(false); + } + }); + input.addEventListener('blur', () => finishEditing(true)); + }); + subgroupTitle.append(titleLabel, editButton); + }; + renderSubgroupTitle(savedTitle || automaticTitle); + column.prepend(subgroupTitle); + }); + grid.replaceChildren(...subgroupColumns); + grid.style.setProperty('--subgroup-count', Math.max(1, subgroupColumns.length)); + card.appendChild(grid); container.appendChild(card); }); + const classicCards = Array.from(container.querySelectorAll(':scope > .order-item-group')); + const classicColumnPlan = [[0], [1], [2], [3, 4], [5, 6], [7, 8]]; + classicColumnPlan.forEach(cardIndexes => { + const column = document.createElement('div'); + column.className = 'classic-product-column'; + cardIndexes.forEach(index => { + if (classicCards[index]) column.appendChild(classicCards[index]); + }); + if (column.children.length > 0) container.appendChild(column); + }); + // Event listeners for config checks document.getElementById('sms-discount').addEventListener('change', e => { if (e.target.checked) document.getElementById('sms-june-promo').checked = false; @@ -439,6 +1301,7 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('sms-ship-free').addEventListener('change', e => { // No need to handle old static shipping checkboxes }); + updateSelectedOrderItemsSummary(); } function updateOrderNoLidState() { @@ -501,7 +1364,7 @@ document.addEventListener('DOMContentLoaded', () => { const orderType = document.querySelector('input[name="order-type"]:checked').value; const noLidChecked = document.getElementById('chk-no-lid').checked; - if (orderType === 'noolak' || orderType === 'pason' || orderType === 'bullyang' || noLidChecked) { + if (SPECIAL_ORDER_TYPES.has(orderType) || noLidChecked) { itemTotal = 0; } @@ -534,14 +1397,33 @@ document.addEventListener('DOMContentLoaded', () => { let cleanLabel = sGiftStr.trim(); if (cleanLabel.match(/_([ORBGP])$/i)) cleanLabel = cleanLabel.slice(0, -2).trim(); + let foundConfigValue = null; for (const secKey of Object.keys(configData)) { - if (secKey === 'SMS_PRICING' || secKey === 'SMS_GIFT_RULES' || secKey === 'GROUP_NAMES') continue; - if (configData[secKey][sGiftStr]) { - const parts = configData[secKey][sGiftStr].split('|').map(s => s.trim()); - foundCode = parts[0]; - foundName = parts.length > 1 ? parts[1] : cleanLabel; + if (secKey === 'SMS_PRICING' || secKey === 'SMS_GIFT_RULES' || secKey === 'GROUP_NAMES' || secKey === 'SUBGROUP_NAMES') continue; + const sectionData = configData[secKey] || {}; + if (sectionData[sGiftStr]) { + foundConfigValue = sectionData[sGiftStr]; break; } + + for (const [itemKey, itemValue] of Object.entries(sectionData)) { + if (itemKey === '__order' || typeof itemValue !== 'string') continue; + const parts = itemValue.split('|').map(s => s.trim()); + const itemName = parts.length > 1 ? parts[1] : itemKey; + if (itemName === sGiftStr || itemName === cleanLabel) { + foundConfigValue = itemValue; + break; + } + } + if (foundConfigValue) break; + } + + if (foundConfigValue) { + const parts = foundConfigValue.split('|').map(s => s.trim()); + foundCode = parts[0] || foundCode; + foundName = parts.length > 1 ? parts[1] : cleanLabel; + } else { + console.warn('Gift item code not found, using fallback ZZ-0000:', sGiftStr); } items.push({ @@ -602,7 +1484,7 @@ document.addEventListener('DOMContentLoaded', () => { } const currentOrderType = document.querySelector('input[name="order-type"]:checked').value; - if (currentOrderType === 'noolak' || currentOrderType === 'pason' || currentOrderType === 'bullyang') { + if (SPECIAL_ORDER_TYPES.has(currentOrderType)) { shippingCost = 0; extraShippingCost = 0; } @@ -754,14 +1636,12 @@ document.addEventListener('DOMContentLoaded', () => { const orderType = document.querySelector('input[name="order-type"]:checked').value; const noLidChecked = document.getElementById('chk-no-lid').checked; - if (orderType === 'noolak' || orderType === 'pason' || orderType === 'bullyang' || noLidChecked) { + if (SPECIAL_ORDER_TYPES.has(orderType) || noLidChecked) { itemTotal = 0; } let badges = []; - if (orderType === 'noolak') badges.push('누락'); - else if (orderType === 'pason') badges.push('파손'); - else if (orderType === 'bullyang') badges.push('불량'); + if (SPECIAL_ORDER_TYPES.has(orderType)) badges.push(ORDER_TYPE_LABELS[orderType]); if (noLidChecked && chk.dataset.type === 'ORDER_SINGLE') badges.push('뚜껑(X)통(O)'); let reasonBadge = badges.length > 0 ? `${badges.join(', ')}` : ''; @@ -822,8 +1702,8 @@ document.addEventListener('DOMContentLoaded', () => { const payload = getOrderPayload(excludedIndices); payload.is_ellen = isEllen; - const isSpecialType = (payload.order_type === 'noolak' || payload.order_type === 'pason' || payload.order_type === 'bullyang'); - const typeLabelMap = { 'noolak': '누락', 'pason': '파손', 'bullyang': '불량', 'normal': '수동발주', 'ellen': '엘렌' }; + const isSpecialType = SPECIAL_ORDER_TYPES.has(payload.order_type); + const typeLabelMap = ORDER_TYPE_LABELS; let finalPayloadAmount = payload.total_amount; if (isSpecialType) finalPayloadAmount = 0; @@ -871,16 +1751,14 @@ document.addEventListener('DOMContentLoaded', () => { }); } - // Invoke browser-native clipboard copy protocol with the newly prepared text + // Copy the same text that is written to the sheet remarks/comparison field. if (data.clipboard_text) { - navigator.clipboard.writeText(data.clipboard_text).catch(err => { - console.error('Clipboard copy failed:', err); - alert("클립보드 접근 권한이 없어 복사하지 못했습니다. (HTTPS 환경 필요)"); - - + copyTextToClipboard(data.clipboard_text).then(copied => { + if (!copied) { + alert("클립보드 복사에 실패했습니다. 브라우저 권한을 확인해주세요."); + } }); } - document.getElementById('btn-reset').click(); if (url.includes('submit')) { const iframe = document.querySelector('#manual-tab iframe'); @@ -1007,6 +1885,7 @@ document.addEventListener('DOMContentLoaded', () => { const ptInput = document.getElementById('sms-point-deduction'); if (ptInput) ptInput.value = ''; document.getElementById('sms-preview').innerHTML = ''; + updateSelectedOrderItemsSummary(); }); @@ -1048,13 +1927,13 @@ document.addEventListener('DOMContentLoaded', () => { return "\n(금액별 사은품 증정)\n" + matched.join("\n") + "\n"; } - document.getElementById('btn-sms-calc').addEventListener('click', () => { + function buildSmsPreviewResult() { let totalCost = 0; let orderListStr = ""; let hasMarkedFreeShipping = false; const checkedItems = document.querySelectorAll('.chk-order:checked'); - if (checkedItems.length === 0) { alert("선택된 상품이 없습니다."); return; } + if (checkedItems.length === 0) return null; checkedItems.forEach(chk => { const row = chk.closest('.item-row'); @@ -1076,7 +1955,7 @@ document.addEventListener('DOMContentLoaded', () => { const orderType = document.querySelector('input[name="order-type"]:checked').value; const noLidChecked = document.getElementById('chk-no-lid').checked; - if (orderType === 'noolak' || orderType === 'pason' || orderType === 'bullyang' || noLidChecked) { + if (SPECIAL_ORDER_TYPES.has(orderType) || noLidChecked) { itemTotal = 0; } @@ -1142,7 +2021,7 @@ document.addEventListener('DOMContentLoaded', () => { const currentOrderType = document.querySelector('input[name="order-type"]:checked').value; const noLidChecked = document.getElementById('chk-no-lid').checked; - if (currentOrderType === 'noolak' || currentOrderType === 'pason' || currentOrderType === 'bullyang' || noLidChecked) { + if (SPECIAL_ORDER_TYPES.has(currentOrderType) || noLidChecked) { shippingCost = 0; extraShippingCost = 0; } @@ -1175,10 +2054,71 @@ document.addEventListener('DOMContentLoaded', () => { msg += `==========================\n${depositLine}\n\n${footerText}`; + return { msg, finalPayable, pointDeduction, checkedItems }; + } + + function refreshLiveSmsPreview() { const previewDiv = document.getElementById('sms-preview'); - previewDiv.innerHTML = msg; - navigator.clipboard.writeText(previewDiv.innerText).catch(err => { - console.error('Clipboard copy failed:', err); + const result = buildSmsPreviewResult(); + if (!result) { + previewDiv.textContent = '선택된 상품이 없습니다.'; + return null; + } + previewDiv.innerHTML = result.msg; + return result; + } + + const smsPreviewModal = document.getElementById('sms-preview-modal'); + const btnSmsPreviewOpen = document.getElementById('btn-sms-preview-open'); + const btnSmsPreviewClose = document.getElementById('btn-sms-preview-close'); + + function closeSmsPreviewModal() { + smsPreviewModal.classList.remove('is-open'); + smsPreviewModal.setAttribute('aria-hidden', 'true'); + } + + if (btnSmsPreviewOpen) { + btnSmsPreviewOpen.addEventListener('click', () => { + refreshLiveSmsPreview(); + smsPreviewModal.classList.add('is-open'); + smsPreviewModal.setAttribute('aria-hidden', 'false'); + btnSmsPreviewClose.focus(); + }); + } + if (btnSmsPreviewClose) btnSmsPreviewClose.addEventListener('click', closeSmsPreviewModal); + smsPreviewModal.addEventListener('click', event => { + if (event.target === smsPreviewModal) closeSmsPreviewModal(); + }); + document.addEventListener('keydown', event => { + if (event.key === 'Escape' && smsPreviewModal.classList.contains('is-open')) { + closeSmsPreviewModal(); + } + }); + + function scheduleLiveOrderRefresh() { + queueMicrotask(updateSelectedOrderItemsSummary); + } + + const smsOptionsPanel = document.querySelector('.sms-options'); + if (smsOptionsPanel) { + smsOptionsPanel.addEventListener('change', scheduleLiveOrderRefresh); + smsOptionsPanel.addEventListener('input', scheduleLiveOrderRefresh); + } + document.querySelectorAll('input[name="order-type"]').forEach(input => { + input.addEventListener('change', scheduleLiveOrderRefresh); + }); + document.getElementById('chk-no-lid').addEventListener('change', scheduleLiveOrderRefresh); + + document.getElementById('btn-sms-calc').addEventListener('click', () => { + const result = refreshLiveSmsPreview(); + if (!result) { + alert('선택된 상품이 없습니다.'); + return; + } + const { msg, finalPayable, pointDeduction, checkedItems } = result; + const previewDiv = document.getElementById('sms-preview'); + copyTextToClipboard(previewDiv.innerText).then(copied => { + if (copied) showToast('문자 내용이 복사되었습니다.'); }); @@ -1246,6 +2186,53 @@ document.addEventListener('DOMContentLoaded', () => { // ============================================ // SMS HISTORY // ============================================ + function loadSmsHistory() { + const u = document.getElementById('sms-history-list'); + u.innerHTML = ''; + fetch('/api/sms/history').then(r => r.json()).then(data => { + data.slice().reverse().forEach((d, idx) => { + const actualIdx = data.length - 1 - idx; + const li = document.createElement('li'); + li.style.cursor = 'pointer'; + li.style.padding = '4px 0'; + li.style.borderBottom = '1px solid #eee'; + li.onclick = () => window.loadHistoryItem(actualIdx); + + const cachedCustomer = readSmsCustomerCache()[d.timestamp] || {}; + const name = firstNonEmpty(d.custName, d.customer_name, cachedCustomer.custName, cachedCustomer.customer_name); + const phone = firstNonEmpty(d.custPhone, d.phone, cachedCustomer.custPhone, cachedCustomer.phone); + const address = firstNonEmpty(d.custAddress, d.address, cachedCustomer.custAddress, cachedCustomer.address); + + const hasCustInfo = (name && name.trim() && name.trim().toLowerCase() !== 'none') || + (phone && phone.trim() && phone.trim().toLowerCase() !== 'none') || + (address && address.trim() && address.trim().toLowerCase() !== 'none'); + + let amountStr = d.deposit_amount_str || ''; + if (hasCustInfo) { + amountStr = amountStr.replace('원', '원*'); + } + + const spanDate = document.createElement('span'); + spanDate.textContent = `${d.timestamp} | ${amountStr}`; + li.appendChild(spanDate); + + const spanDel = document.createElement('span'); + spanDel.className = 'action-text ms-2'; + spanDel.style.marginLeft = '8px'; + spanDel.title = '삭제'; + spanDel.textContent = '❌'; + spanDel.onclick = (e) => window.deleteHistory(actualIdx, e); + li.appendChild(spanDel); + + u.appendChild(li); + + + }); + + + }); + } + const SMS_CUSTOMER_CACHE_KEY = 'smsCustomerHistoryByTimestamp'; function readSmsCustomerCache() { @@ -1272,39 +2259,6 @@ document.addEventListener('DOMContentLoaded', () => { return ''; } - function loadSmsHistory() { - const u = document.getElementById('sms-history-list'); - u.innerHTML = ''; - fetch('/api/sms/history').then(r => r.json()).then(data => { - data.slice().reverse().forEach((d, idx) => { - const actualIdx = data.length - 1 - idx; - const li = document.createElement('li'); - li.style.cursor = 'pointer'; - li.style.padding = '4px 0'; - li.style.borderBottom = '1px solid #eee'; - li.onclick = () => window.loadHistoryItem(actualIdx); - - const spanDate = document.createElement('span'); - spanDate.textContent = `${d.timestamp} | ${d.deposit_amount_str}`; - li.appendChild(spanDate); - - const spanDel = document.createElement('span'); - spanDel.className = 'action-text ms-2'; - spanDel.style.marginLeft = '8px'; - spanDel.title = '삭제'; - spanDel.textContent = '❌'; - spanDel.onclick = (e) => window.deleteHistory(actualIdx, e); - li.appendChild(spanDel); - - u.appendChild(li); - - - }); - - - }); - } - window.loadManualHistory = function () { const u = document.getElementById('manual-history-list'); if (!u) return; @@ -1444,6 +2398,7 @@ document.addEventListener('DOMContentLoaded', () => { if (anyChk) { anyChk.dispatchEvent(new Event('change')); } + updateSelectedOrderItemsSummary(); if (d.noLid !== undefined) document.getElementById('chk-no-lid').checked = d.noLid; } @@ -1465,6 +2420,7 @@ document.addEventListener('DOMContentLoaded', () => { if (d.previewText !== undefined) { document.getElementById('sms-preview').innerHTML = d.previewText; } + updateSelectedOrderItemsSummary(); }); @@ -1623,14 +2579,14 @@ document.addEventListener('DOMContentLoaded', () => { extra_shipping_fee: document.getElementById('setting-ship-extra').value.replace(/[^0-9]/g, '') } }; - for (let i = 1; i <= 10; i++) { + for (let i = 1; i <= 4; i++) { const minEl = document.getElementById(`rule${i}-min`); const maxEl = document.getElementById(`rule${i}-max`); - const itemEl = document.getElementById(`rule${i}-item`); - if (minEl && maxEl && itemEl) { - payload.rules[`rule${i}_min`] = minEl.value.replace(/[^0-9]/g, ''); - payload.rules[`rule${i}_max`] = maxEl.value.replace(/[^0-9]/g, ''); - payload.rules[`rule${i}_item`] = itemEl.value; + const giftEl = document.querySelector(`input[name="rule${i}-gift"]:checked`); + if (minEl && maxEl && giftEl) { + payload.rules[`condition${i}_min`] = minEl.value.replace(/[^0-9]/g, ''); + payload.rules[`condition${i}_max`] = maxEl.value.replace(/[^0-9]/g, ''); + payload.rules[`condition${i}_gift`] = giftEl.value; } } @@ -1730,36 +2686,88 @@ document.addEventListener('DOMContentLoaded', () => { const btnBulkCancel = document.getElementById('btn-bulk-cancel'); const btnBulkSubmit = document.getElementById('btn-bulk-submit'); const bulkTrackingNumbers = document.getElementById('bulk-tracking-numbers'); + const bulkTrackingLineNumbers = document.getElementById('bulk-tracking-line-numbers'); const bulkPrefixInput = document.getElementById('bulk-prefix-input'); const btnBulkPrefixSave = document.getElementById('btn-bulk-prefix-save'); + const BULK_TRACKING_PREFIX_SETTING = 'bulk_tracking_prefix'; + + function getBulkTrackingPrefix() { + if (!bulkPrefixInput) return ''; + const prefix = bulkPrefixInput.value.replace(/\D/g, '').slice(0, 4); + bulkPrefixInput.value = prefix; + return prefix; + } + + function loadBulkTrackingPrefix() { + if (!bulkPrefixInput) return; + bulkPrefixInput.value = (configData.APP_SETTINGS && configData.APP_SETTINGS[BULK_TRACKING_PREFIX_SETTING]) || ''; + if (btnBulkPrefixSave) btnBulkPrefixSave.style.backgroundColor = '#4a5568'; + } + + async function saveBulkTrackingPrefix({ silent = false } = {}) { + const prefix = getBulkTrackingPrefix(); + const res = await fetch('/api/app/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [BULK_TRACKING_PREFIX_SETTING]: prefix }) + }); + + if (!res.ok) throw new Error('Failed to save bulk tracking prefix'); + if (!configData.APP_SETTINGS) configData.APP_SETTINGS = {}; + configData.APP_SETTINGS[BULK_TRACKING_PREFIX_SETTING] = prefix; + if (btnBulkPrefixSave) btnBulkPrefixSave.style.backgroundColor = '#4a5568'; + if (!silent) alert('저장되었습니다.'); + return prefix; + } + + loadBulkTrackingPrefix(); + + function updateBulkTrackingLineNumbers() { + if (!bulkTrackingNumbers || !bulkTrackingLineNumbers) return; + const lineCount = bulkTrackingNumbers.value.split(/\r\n|\r|\n/).length; + bulkTrackingLineNumbers.textContent = Array.from( + { length: lineCount }, + (_, index) => index + 1 + ).join('\n'); + bulkTrackingLineNumbers.scrollTop = bulkTrackingNumbers.scrollTop; + } if (btnBulkReceive && bulkReceiveModal) { btnBulkReceive.addEventListener('click', () => { bulkReceiveModal.style.display = 'flex'; - if (bulkPrefixInput) { - bulkPrefixInput.value = localStorage.getItem('bulkTrackingPrefix') || ''; - } + loadBulkTrackingPrefix(); + updateBulkTrackingLineNumbers(); }); if (btnBulkPrefixSave && bulkPrefixInput) { bulkPrefixInput.addEventListener('input', () => { + getBulkTrackingPrefix(); btnBulkPrefixSave.style.backgroundColor = '#e53e3e'; }); - btnBulkPrefixSave.addEventListener('click', () => { - const prefix = bulkPrefixInput.value.trim(); - localStorage.setItem('bulkTrackingPrefix', prefix); - btnBulkPrefixSave.style.backgroundColor = '#4a5568'; - alert('저장되었습니다.'); + btnBulkPrefixSave.addEventListener('click', async () => { + try { + await saveBulkTrackingPrefix(); + } catch (err) { + console.error('Bulk prefix save failed:', err); + alert('송장 앞 숫자 저장에 실패했습니다.'); + } }); } btnBulkCancel.addEventListener('click', () => { bulkReceiveModal.style.display = 'none'; bulkTrackingNumbers.value = ''; + updateBulkTrackingLineNumbers(); }); if (bulkTrackingNumbers) { + bulkTrackingNumbers.addEventListener('input', updateBulkTrackingLineNumbers); + bulkTrackingNumbers.addEventListener('scroll', () => { + if (bulkTrackingLineNumbers) { + bulkTrackingLineNumbers.scrollTop = bulkTrackingNumbers.scrollTop; + } + }); bulkTrackingNumbers.addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); @@ -1770,7 +2778,14 @@ document.addEventListener('DOMContentLoaded', () => { btnBulkSubmit.addEventListener('click', async () => { const lines = bulkTrackingNumbers.value.split('\n'); - const savedPrefix = localStorage.getItem('bulkTrackingPrefix') || ''; + let savedPrefix = getBulkTrackingPrefix(); + try { + savedPrefix = await saveBulkTrackingPrefix({ silent: true }); + } catch (err) { + console.error('Bulk prefix save failed:', err); + alert('송장 앞 숫자 저장에 실패했습니다.'); + return; + } const trackingNumbers = lines.map(l => { let val = l.trim(); @@ -1799,6 +2814,7 @@ document.addEventListener('DOMContentLoaded', () => { const data = await res.json(); bulkReceiveModal.style.display = 'none'; bulkTrackingNumbers.value = ''; + updateBulkTrackingLineNumbers(); if (typeof loadReturnReceivings === 'function') { loadReturnReceivings(); } @@ -2088,7 +3104,7 @@ document.addEventListener('DOMContentLoaded', () => { } } - // If not found in requests, fallback to orderlist_db + // If not found in requests, fallback to orderlist_app if(!foundInReq) { const res2 = await fetch('/api/return/lookup?tracking_no=' + encodeURIComponent(no)); if(res2.ok) { @@ -2242,55 +3258,100 @@ document.addEventListener('DOMContentLoaded', () => { const items = window.productRulesListData[gId]; const fmt = (v) => v ? parseInt(String(v).replace(/,/g, ''), 10).toLocaleString() : ''; + // 엑셀 시트처럼 보이도록 +colgroup을 사용합니다. 헤더(thead)와 + // 본문(tbody)이 같은 표(table)에 속하므로 table-layout:fixed 컬럼 폭이 + // 브라우저에 의해 강제로 100% 동일하게 맞춰집니다. table-layout:fixed는 + // width가 auto이면 컨테이너 크기에 따라 폭 계산이 애매해질 수 있어, + // colgroup 합계와 정확히 같은 고정 width를 표에 명시적으로 지정합니다. + const COL_WIDTHS = [34, 170, 90, 210, 80, 80, 350, 85, 55]; + const TABLE_WIDTH = COL_WIDTHS.reduce((a, b) => a + b, 0); + const CELL_BORDER = 'border-right: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0;'; + const ROW_BOTTOM = 'border-bottom: 1px solid #e2e8f0;'; + const HEADER_TH = 'box-sizing: border-box; text-align: center; vertical-align: middle; padding: 8px 4px; font-size: 0.82rem; font-weight: 700;'; + const TD_BASE = 'box-sizing: border-box; vertical-align: middle; height: 30px;'; + const colgroup = COL_WIDTHS.map(w => ``).join(''); + let html = `
${gData.name}
-
+
+
+ ${colgroup} + + + + + + + + + + + + + + `; items.forEach((item, idx) => { if (item.is_separator) { html += ` -
- - --- 구분선 --- - -
+ + + + + `; } else { html += ` -
- - - - - - - -
- - - - - - -
- - - - -
-
+ + + + + + + + + + + `; } }); html += ` + +
표기명코드정식옵션명가격2+1가격색상 옵션무료배송관리
--- 구분선 --- + +
+ + + + + + + + + + +
+ + + + + + +
+
+ + + +
@@ -2300,7 +3361,7 @@ document.addEventListener('DOMContentLoaded', () => { card.innerHTML = html; - const rows = card.querySelectorAll('.product-items-list > .item-row'); + const rows = card.querySelectorAll('.product-items-list > .pr-item-row'); rows.forEach((row, i) => { if (!items[i].is_separator) { row.querySelector('.p-label').addEventListener('input', e => items[i].label = e.target.value); @@ -2449,6 +3510,13 @@ document.addEventListener('DOMContentLoaded', () => { let setItemsList = []; let singleSort = { key: 'item_code', asc: true }; let setSort = { key: 'item_code', asc: true }; + let singleSearch = ''; + let setSearch = ''; + + // 검색어(공백 무시, 대소문자 무시)로 이름을 필터링 + const normalizeKeyword = (str) => (str || '').toString().toLowerCase().replace(/\s+/g, ''); + const matchKeyword = (name, keyword) => !keyword || normalizeKeyword(name).includes(keyword); + const emptyRowHtml = (colspan) => `검색 결과가 없습니다.`; // Render Single Items const renderSingleItems = () => { @@ -2463,7 +3531,10 @@ document.addEventListener('DOMContentLoaded', () => { return 0; }); - singleItemsList.forEach(item => { + const visibleSingleItems = singleItemsList.filter(item => matchKeyword(item.name, singleSearch)); + if (visibleSingleItems.length === 0) tbody.innerHTML = emptyRowHtml(4); + + visibleSingleItems.forEach(item => { const tr = document.createElement('tr'); tr.className = 'hover-row'; tr.style.height = '27px'; @@ -2548,7 +3619,10 @@ document.addEventListener('DOMContentLoaded', () => { return 0; }); - setItemsList.forEach(item => { + const visibleSetItems = setItemsList.filter(item => matchKeyword(item.name, setSearch)); + if (visibleSetItems.length === 0) tbody.innerHTML = emptyRowHtml(4); + + visibleSetItems.forEach(item => { const tr = document.createElement('tr'); tr.className = 'hover-row'; tr.style.height = '27px'; @@ -2685,6 +3759,36 @@ document.addEventListener('DOMContentLoaded', () => { loadSingleItemsAndSetItems(); + // 실시간 이름 검색 (단품 / 세트) + const bindSearchBox = (inputId, clearBtnId, onChange) => { + const input = document.getElementById(inputId); + const clearBtn = document.getElementById(clearBtnId); + if (!input) return; + input.addEventListener('input', () => onChange(normalizeKeyword(input.value))); + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + input.value = ''; + onChange(''); + } + }); + if (clearBtn) { + clearBtn.addEventListener('click', () => { + input.value = ''; + onChange(''); + input.focus(); + }); + } + }; + + bindSearchBox('single-search-input', 'btn-single-search-clear', (kw) => { + singleSearch = kw; + renderSingleItems(); + }); + bindSearchBox('set-search-input', 'btn-set-search-clear', (kw) => { + setSearch = kw; + renderSetItems(); + }); + // Sorting events document.querySelectorAll('.th-sortable').forEach(th => { th.addEventListener('click', (e) => { @@ -2849,6 +3953,7 @@ document.addEventListener('DOMContentLoaded', () => { const div = document.createElement('div'); div.style.display = 'flex'; div.style.gap = '5px'; + div.style.alignItems = 'center'; let optionsHtml = ''; singleItemsList.forEach(s => { @@ -2857,6 +3962,9 @@ document.addEventListener('DOMContentLoaded', () => { }); div.innerHTML = ` +
+ +
@@ -2900,12 +4008,16 @@ document.addEventListener('DOMContentLoaded', () => { } const components = []; - const rows = document.getElementById('set-components-wrapper').querySelectorAll('div'); + const rows = document.getElementById('set-components-wrapper').querySelectorAll(':scope > div'); rows.forEach(r => { - const sc = r.querySelector('.comp-code').value.trim(); - const qt = parseInt(r.querySelector('.comp-qty').value, 10); - if(sc && !isNaN(qt) && qt > 0) { - components.push({single_code: sc, quantity: qt}); + const compCodeEl = r.querySelector('.comp-code'); + const compQtyEl = r.querySelector('.comp-qty'); + if (compCodeEl && compQtyEl) { + const sc = compCodeEl.value.trim(); + const qt = parseInt(compQtyEl.value, 10); + if(sc && !isNaN(qt) && qt > 0) { + components.push({single_code: sc, quantity: qt}); + } } }); @@ -2925,6 +4037,17 @@ document.addEventListener('DOMContentLoaded', () => { } }); }); + + // Initialize SortableJS on set components wrapper + const setCompWrapper = document.getElementById('set-components-wrapper'); + if (setCompWrapper && typeof Sortable !== 'undefined') { + new Sortable(setCompWrapper, { + handle: '.drag-handle', + animation: 150, + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen' + }); + } } initCodeTab(); @@ -3065,7 +4188,7 @@ if (recFormContainer) { // Version Update Notice // ============================================ document.addEventListener('DOMContentLoaded', () => { - let CURRENT_APP_VERSION = "8.54"; + let CURRENT_APP_VERSION = "9.0"; const versionEl = document.getElementById('app-version-text'); if (versionEl) { const text = versionEl.textContent.trim(); diff --git a/static/js/mall_event.js b/static/js/mall_event.js new file mode 100644 index 0000000..39bd9f1 --- /dev/null +++ b/static/js/mall_event.js @@ -0,0 +1,841 @@ +// 자사몰 행사 탭 - 카페24 주문/발주 파일 가공 +// +// 업로드 → 조건 선택 → 미리보기 → 가공된 엑셀 다운로드 흐름을 담당한다. +// 기능이 늘어나면 initFirstComeGift() 처럼 기능별 초기화 함수를 추가하고 +// 왼쪽 '기능' 목록 버튼(.me-feature-btn)과 패널(#me-panel-)을 연결한다. +(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, '''); + } + + 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 = '조건이 바뀌었습니다. "사은품 적용 결과 보기"를 다시 눌러주세요.'; + } + + // ---- 파일 업로드 ---- + function resetAll() { + currentFile = null; + orderItems = []; + selectedNames.clear(); + searchKeyword = ''; + fileInput.value = ''; + if (orderSearch) orderSearch.value = ''; + fileInfo.style.display = 'none'; + fileInfo.innerHTML = ''; + showFileError(''); + orderListBox.innerHTML = '
발주서를 업로드하면 주문 목록이 표시됩니다.
'; + 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 = + '
📄 ' + escapeHtml(file.name) + '
' + + '
시트: ' + escapeHtml(data.sheet_name) + ' · 헤더 ' + data.header_row + '행
' + + '
데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' + + Number(data.total_orders).toLocaleString() + '건 · 상품 ' + orderItems.length + '종
'; + + 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 = '
Q열(주문목록)에서 상품을 찾지 못했습니다.
'; + updateSelectedInfo(); + return; + } + + const visible = orderItems.filter(function (item) { + return !searchKeyword || normalize(item.name).includes(searchKeyword); + }); + + if (!visible.length) { + orderListBox.innerHTML = '
검색 결과가 없습니다.
'; + updateSelectedInfo(); + return; + } + + orderListBox.innerHTML = visible.map(function (item) { + const checked = selectedNames.has(item.name) ? ' checked' : ''; + return '' + + ''; + }).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 = '선택 ' + names.length + '개 — ' + 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 = ''; + return; + } + sets.sort(function (a, b) { + return String(a.name || '').localeCompare(String(b.name || ''), 'ko'); + }); + giftSelect.innerHTML = '' + sets.map(function (s) { + return ''; + }).join(''); + } catch (err) { + console.error(err); + giftSelect.innerHTML = ''; + } + } + 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('
'); + } else { + warningBox.style.display = 'none'; + warningBox.innerHTML = ''; + } + + const added = data.added || []; + if (!added.length) { + resultTbody.innerHTML = '사은품이 적용된 주문이 없습니다.'; + resultInfo.innerHTML = '적용 대상 0건'; + setDownloadEnabled(false); + return; + } + + resultTbody.innerHTML = added.map(function (row, idx) { + return '' + + '' + (idx + 1) + '' + + '' + escapeHtml(row.order_no) + '' + + '' + escapeHtml(row.order_date) + '' + + '' + escapeHtml(row.seq) + '' + + ''; + }).join(''); + + resultInfo.innerHTML = '조건 충족 주문 ' + Number(data.matched_order_count).toLocaleString() + '건 중 ' + + '' + Number(data.applied_count).toLocaleString() + '건에 사은품 행 추가'; + 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 = '
CSV를 업로드하면 상품 목록이 표시됩니다.
'; + 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 = + '
📄 ' + escapeHtml(file.name) + '
' + + '
인코딩: ' + escapeHtml(data.encoding) + ' · 헤더 ' + data.header_row + '행 · ' + data.column_count + '개 열
' + + '
데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' + + Number(data.total_orders).toLocaleString() + '건 · 상품 ' + productItems.length + '종
'; + + if (data.warnings && data.warnings.length) { + warningBox.style.display = 'block'; + warningBox.innerHTML = data.warnings.map(function (w) { + return '⚠ ' + escapeHtml(w); + }).join('
'); + } + + 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 = '
K열(주문상품명)에서 상품을 찾지 못했습니다.
'; + updateSelectedInfo(); + return; + } + + const visible = productItems.filter(function (item) { + return !searchKeyword || normalize(item.name).includes(searchKeyword); + }); + + if (!visible.length) { + productListBox.innerHTML = '
검색 결과가 없습니다.
'; + updateSelectedInfo(); + return; + } + + productListBox.innerHTML = visible.map(function (item) { + const checked = selectedNames.has(item.name) ? ' checked' : ''; + return '' + + ''; + }).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 = '선택 ' + names.length + '개 — ' + 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 = + '
✓ 다운로드 완료
' + + '
조건: ' + modeLabel + '
' + + '
상품이 들어있는 행 ' + matched.toLocaleString() + '행
' + + '
추출 결과 ' + rows.toLocaleString() + '행' + + ' · 주문 ' + orders.toLocaleString() + '건
' + + '
전체 ' + total.toLocaleString() + '행 중
'; + } catch (err) { + console.error(err); + alert('엑셀 다운로드 요청 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + }); + + clearResult(); + } +})(); diff --git a/static/js/milkrun_gsheet.js b/static/js/milkrun_gsheet.js new file mode 100644 index 0000000..2502638 --- /dev/null +++ b/static/js/milkrun_gsheet.js @@ -0,0 +1,363 @@ +// 쿠팡 밀크런 캘린더 +// +// 화면이 열리면 구글 드라이브의 밀크런 출고리스트를 자동으로 읽어 +// 시트 이름(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, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + 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 = + '
' + + ''; + 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 = '' + + '
' + + '' + + '
' + viewYear + '년 ' + (viewMonth + 1) + '월' + + ' · 출고 ' + monthCount + '일
' + + '' + + '
' + + '
'; + + DOW.forEach(function (label, index) { + const color = index === 0 ? '#e53e3e' : (index === 6 ? '#3182ce' : '#718096'); + html += '
' + label + '
'; + }); + + for (let i = 0; i < startDow; i++) html += '
'; + + 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 += '' + day + '
'; + } + + html += '
'; + + if (placeholderText) { + html += '
' + + escapeHtml(placeholderText) + '
'; + } + 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 = + '' + escapeHtml(data.file_name || '쿠팡 밀크런 출고리스트') + '' + + ' · 출고일 ' + Object.keys(sheetsByDate).length + '일' + + (data.cached ? ' · 최근 결과' : '') + ''; + + // 항상 오늘 날짜를 기준으로 본다 + 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( + '오늘 출고할 밀크런은 없습니다.', + '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('' + escapeHtml(sheetName) + ' 출고리스트를 읽는 중...', '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('' + escapeHtml(sheetName) + '에서 제품코드/수량을 찾지 못했습니다. ' + + '시트 형식을 확인해주세요.', 'error'); + return; + } + + if (pasteInput) pasteInput.value = data.text || ''; + if (analyzeBtn) analyzeBtn.click(); // 기존 분석 로직 재사용 + + const skipped = data.skipped + ? ' (건너뛴 줄 ' + data.skipped + '개)' : ''; + showMessage('' + escapeHtml(sheetName) + ' · ' + 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); + }); +})(); diff --git a/templates/index.html b/templates/index.html index 1514061..d29c02f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,1056 +1,4219 @@ - + - - - - + + + [No.1 King] CS 통합 관리앱 (미라클주방) - - + + - + - +
- - - -
- + + +
+ -
-
-
- -
-
- -
- -
-

- 📝수동 일반 발주

-
- - -
-
- - -
-
- - -
-
- - - - - -
-
- - - - -
-
-
-

- 💬최근 전송 메시지 전체 삭제

-
    -
    - - -
    -

    - 📝오늘 수동발주 내역 -

    -
    -
    - 0건 - / - 0원 -
    - -
    -
      -
    -
    -
    -
    -
    -
    -
    -
    - -
    -

    - 📋알림 문자 계산 및 복사

    -
    - - - - - - -
    -
    - - -
    -
    -
    -
    +
    +
    +
    +
    +
    + 상품 분류 + 제목을 클릭하면 하위 상품이 표시됩니다.
    -
    - +
    +
    +
    +
    +

    선택 상품

    + 0종 +
    +
    +

    선택된 상품이 없습니다.

    +
    +
    +
    + 주문 금액 + 0원 +
    +
    +
    + 입금 금액 + 0원 +
    +
    + +
    +
    +
    +
    + +
    + +
    + +
    +

    + 📝수동 일반 발주 +

    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + + + + +
    +
    + + + + +
    +
    +
    +

    + 💬최근 전송 메시지 + 전체 삭제 +

    +
      +
      + + +
      +

      + 📝오늘 수동발주 내역 +

      +
      +
      + 0건 / + 0원 +
      + +
      +
        +
        +
        +
        + + +
        +

        + 📋알림 문자 계산 및 복사 +

        +
        + + + + + + +
        +
        + + +
        +
        +
        +
        + + + -
        -
        -

        🔥 통합 자동발주 시스템

        -

        카페24 및 스마트스토어의 주문 내역을 한 곳에서 관리하세요.

        -
        +
        +
        +

        🔥 통합 자동발주 시스템

        +

        + 카페24 및 스마트스토어의 주문 내역을 한 곳에서 관리하세요. +

        +
        + +
        + +
        +

        + 🛒 카페24 (자사몰) +

        + +
        + +
        +

        + 1. 신규 주문 내역 다운로드 +

        + +
        +

        + 배송준비중(N20) 상태인 자사몰 주문 내역을 다운로드합니다. +

        + + +
        + +
        - + id="cafe24ProgressWrapper" + class="hidden" + style="margin-top: 15px" + > +
        + 수집 준비 중... + 0% +
        +
        -

        - 🛒 카페24 (자사몰)

        - -
        - -
        -

        1. 신규 주문 내역 - 다운로드

        - -
        -

        배송준비중(N20) 상태인 자사몰 주문 내역을 다운로드합니다.

        - - -
        - - - - - -
        - -
        - -
        -
        -

        2. 송장 일괄 등록

        -
        -

        송장번호가 입력된 엑셀 파일을 업로드합니다.

        - -
        -
        - - -
        - -
        - 엑셀 파일을 이곳에 드래그하거나 - 클릭하여 업로드하세요. (.xls, .xlsx) - -
        - - - -
        -
        - - -
        - - -
        -

        - 🟩 스마트스토어

        - -
        -
        -

        1. 신규 주문 내역 다운로드

        -
        - 발주확인 요망
        -
        -

        결제완료 상태인 주문 내역을 다운로드합니다.

        - -
        - -
        - - -
        -
        - - - - -
        - -
        - -
        -
        -

        2. 송장 일괄 등록

        -
        -

        송장번호가 입력된 엑셀 파일을 업로드합니다.

        - -
        -
        - - -
        - -
        - 엑셀 파일을 이곳에 드래그하거나 클릭하여 업로드하세요. (.xls, .xlsx) - -
        - - - -
        -
        - - -
        - - -
        - ? - 준비 - 중... -
        - - -
        - ? - 준비 - 중... -
        + id="cafe24ProgressBar" + style=" + width: 0%; + height: 100%; + background-color: #4299e1; + transition: width 0.3s ease; + " + >
        +
        -
        + - +
        +

        + 🟩 스마트스토어 +

        + +
        +
        +

        + 1. 신규 주문 내역 다운로드 +

        +
        + 발주확인 요망 +
        +
        +

        + 결제완료 상태인 주문 내역을 다운로드합니다. +

        + +
        + +
        + + +
        +
        + + + + +
        + +
        + ⬇ +
        + +
        +
        +

        2. 송장 일괄 등록

        +
        +

        + 송장번호가 입력된 엑셀 파일을 업로드합니다. +

        + +
        +
        + + +
        + +
        + 엑셀 파일을 이곳에 드래그하거나 클릭하여 업로드하세요. (.xls, .xlsx) + +
        + + + +
        +
        + + +
        + + +
        + ? + 준비 중... +
        + + +
        + ? + 준비 중... +
        + +
        + + -
        -
        - - 새 창에서 구글 시트 직접 열기 (로그인 오류 시 클릭) - -
        -
        - - -
        -
        - -
        -
        -
        -

        금액별 추가상품 설정

        -
        -
        - -
        -
        -
        -
        -

        문자 메시지 설정

        -
        -
        -

        머리말

        - -

        꼬리말(입금 금액은 {최종금액} 으로 입력)

        - -
        -
        -
        -
        -

        화면 폰트 및 배송비 설정

        -
        -
        -

        폰트 선택

        -
        - - -
        +
        +
        +
        +

        금액별 추가상품 설정

        +
        +
        + +
        +
        +
        +
        +

        문자 메시지 설정

        +
        +
        +

        머리말

        + +

        + 꼬리말(입금 금액은 {최종금액} 으로 입력) +

        + +
        +
        +
        +
        +

        화면 폰트 및 배송비 설정

        +
        +
        +

        폰트 선택

        +
        + + +
        -

        배송비 설정

        -
        - - -
        -
        -
        -
        - -
        -
        - -
        -
        -

        상품 설정

        -
        -
        - -
        -
        - -
        -
        - +
        +
        + +
        +
        + -
        -
        -

        코드표 관리

        -
        -
        - -
        -
        -

        단품 코드

        - -
        -
        - - - - - - - - - - - -
        - 아이템코드 - 사방넷 코드 - 이름 - 관리
        -
        -
        - -
        -
        -

        세트 코드

        - -
        -
        - - - - - - - - - - - -
        - 아이템코드 - 세트 이름 - 구성 단품 목록관리
        -
        -
        +
        +
        +

        코드표 관리

        +
        +
        + +
        +
        +

        + 단품 코드 +

        +
        + +
        -
        + +
        +
        + + + + + + + + + + +
        + 아이템코드 + + + 사방넷 코드 + + + 이름 + + 관리
        +
        + + +
        +
        +

        + 세트 코드 +

        +
        + + +
        + +
        +
        + + + + + + + + + + +
        + 아이템코드 + + + 세트 이름 + + 구성 단품 목록관리
        +
        +
        + +
        - +
        +
        +

        자사몰 행사 (카페24 발주서 가공)

        +
        +
        +
        +
        + 처리 중... +
        +
        +
        + +
        +

        기능

        +
        + + +
        +
        + 기능은 계속 추가됩니다. +
        +
        + + +
        + +
        +
        +

        1. 발주서 업로드

        + +
        +
        +
        📥
        +
        + 엑셀 파일을 여기로 끌어다 놓으세요 +
        +
        + 또는 아래 버튼으로 직접 선택 (.xlsx) +
        + + +
        +
        +
        +
        + 카페24에서 받은 발주주문서를 그대로 올리면 됩니다. Q열(주문목록)의 첫 "/" 앞부분을 + 기준으로 상품 목록을 만듭니다. +
        +
        + + +
        +
        +

        + 2. 사은품이 지급되는 주문 +

        +
        + + + +
        +
        +
        +
        + 발주서를 업로드하면 주문 목록이 표시됩니다. +
        +
        +
        +
        + + +
        +

        + 3. 사은품 조건 +

        + + +
        + + + + + + +
        + + + +
        + 코드표 세트 중 이름에 "사은품"이 들어간 것만 표시됩니다. +
        + + + + + + +
        + +
        +
        + + + + + + + + + + +
        # + 주문번호 + + 주문날짜 + + 새 C열 +
        +
        + +
        + + +
        +
        +
        + + + +
        +
        + + +
        +
        +

        쿠팡 밀크런 낱개 분해

        +
        +
        +
        +
        + 계산 중... +
        +
        +
        + +
        + +
        +
        +

        밀크런 캘린더

        + +
        + + + + +
        + + +
        +
        +

        붙여넣기 입력

        +
        + + +
        +
        +
        + 쿠팡 밀크런 내용을 그대로 붙여넣으세요 (제품코드 / 제품명 / 수량). "MT-7000_2"처럼 + 코드 끝에 "_숫자"가 붙으면 해당 숫자만큼 곱해서 계산합니다. +
        + +
        +
        + +
        +
        +

        라인별 분해 확인

        + +
        +
        + + + + + + + + + +
        + 입력 코드 + + 입력 수량 + 분해 결과
        +
        +
        + +
        +
        +

        낱개 코드 합계

        +
        + + +
        +
        +
        +
        + + + + + + + + + + +
        + 사방넷 코드 + + 낱개 코드 + + 상품 이름 + + 합계 수량 +
        +
        +
        +
        +
        + + -
        -
        -

        반품관리 (반품 신청 및 입고 처리)

        -
        -
        - -
        - -
        -
        -

        - 📋 반품 신청 - -

        -
        - - -
        -
        - -
        -
        -
        - - -
        -
        - - -
        -
        - - -
        -
        - - -
        -
        -
        -
        - - -
        -
        - - -
        -
        - - -
        -
        -
        -
        - - -
        -
        -
        -
        - - -

        - 신청 목록 -

        -
        - - - - - - - - - - - - - - - -
        날짜수령인휴대폰번호송장번호상태비고관리
        -
        -
        - - -
        - -
        -
        -

        - 📦 반품 입고 - -

        -
        - - -
        -
        - -
        - -
        -
        - - -
        -
        - - -
        -
        - - -
        -
        - - -
        -
        - -
        -
        - - -
        -
        - - -
        -
        - - -
        -
        - -
        -
        - - -
        -
        -
        -
        - - - - - -

        - 입고 목록 -

        -
        - - - - - - - - - - - - - - - -
        - 입고날짜 - 수령인 - 휴대폰번호 - 송장번호 - 쇼핑몰 - 입고상태비고 - 관리
        -
        -
        +
        +
        +

        반품관리 (반품 신청 및 입고 처리)

        +
        +
        + +
        + +
        +
        +

        + 📋 반품 신청 + +

        +
        + + +
        -
        - +

        + 신청 목록 +

        +
        + + + + + + + + + + + + + + +
        + + + 날짜 + + 수령인 + + 휴대폰번호 + + 송장번호 + + 상태 + + 비고 + + 관리 +
        +
        +
        + + +
        + + +
        +

        + 📦 반품 입고 + +

        +
        + + +
        +
        + +
        + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + +
        +
        + + +
        +
        +
        + + + +
        +
        +

        송장일괄 입고

        +
        + + 송장 앞 숫자 4자리 +
        제외하고 입력 하세요. +
        + +
        +
        + + +
        +
        + + +
        +
        +
        + + +

        + 입고 목록 +

        +
        + + + + + + + + + + + + + + +
        + 입고날짜 + + 수령인 + + 휴대폰번호 + + 송장번호 + + 쇼핑몰 + + 입고상태 + + 비고 + + 관리 +
        +
        +
        + +
        + - {% include 'version_history.html' %} - + {% include 'version_history.html' %} + - + + + -
        - 클립보드로 주문 내용이 복사되었습니다. +
        + 클립보드로 주문 내용이 복사되었습니다.
        -