feat: v9.0 업그레이드 — CS 작업 탭 개편 + 밀크런/자사몰 행사 모듈
CS 통합 프로그램.ZIP(로컬 개발본 v9.0)의 기능 소스를 반영. DB 스키마는 기존과 동일해 마이그레이션 없이 그대로 사용한다. 가져온 것 - main.py / cafe24_api.py / templates / static: v9.0 기능 코드 - routers/coupang_milkrun.py, routers/mall_event.py (신규) - static/js/mall_event.js, static/js/milkrun_gsheet.js (신규) 운영 설정은 기존 것을 유지·재적용 - DB/카페24/네이버 접속정보를 하드코딩 대신 환경변수 기반으로 복원 - SSO(AuthGuardMiddleware, SessionMiddleware), /login, /logout, /health/db 복원 - APP_ROOT_PATH 서브경로 호스팅(root_path 템플릿 변수, app.js fetch 래퍼) 복원 - 구글시트 설정 인메모리 캐시(TTL 10분)와 /api/config/refresh 복원 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+85
-31
@@ -2,6 +2,7 @@ import requests
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
# 환경변수에서 카페24 인증 정보 로드
|
# 환경변수에서 카페24 인증 정보 로드
|
||||||
@@ -9,14 +10,21 @@ CLIENT_ID = os.getenv("CAFE24_CLIENT_ID", "")
|
|||||||
CLIENT_SECRET = os.getenv("CAFE24_CLIENT_SECRET", "")
|
CLIENT_SECRET = os.getenv("CAFE24_CLIENT_SECRET", "")
|
||||||
MALL_ID = os.getenv("CAFE24_MALL_ID", "miraskitchen")
|
MALL_ID = os.getenv("CAFE24_MALL_ID", "miraskitchen")
|
||||||
REDIRECT_URI = os.getenv("CAFE24_REDIRECT_URI", "")
|
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():
|
def get_auth_url(redirect_uri=None, state=None):
|
||||||
# state parameter could be added for security but keeping it simple for local app
|
redirect_uri = redirect_uri or REDIRECT_URI
|
||||||
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/authorize"
|
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/authorize"
|
||||||
url += f"?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}"
|
params = {
|
||||||
url += f"&scope=mall.read_order,mall.write_order,mall.read_product"
|
"response_type": "code",
|
||||||
return url
|
"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
|
import base64
|
||||||
|
|
||||||
@@ -25,7 +33,8 @@ def _get_basic_auth_header():
|
|||||||
encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
|
encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
|
||||||
return f"Basic {encoded}"
|
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"
|
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token"
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": _get_basic_auth_header(),
|
"Authorization": _get_basic_auth_header(),
|
||||||
@@ -34,15 +43,15 @@ def request_new_token(auth_code: str):
|
|||||||
data = {
|
data = {
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"code": auth_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:
|
if response.status_code == 200:
|
||||||
_save_tokens(response.json())
|
_save_tokens(response.json())
|
||||||
return True
|
return True
|
||||||
else:
|
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):
|
def refresh_access_token(refresh_token: str):
|
||||||
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token"
|
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
|
"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:
|
if response.status_code == 200:
|
||||||
_save_tokens(response.json())
|
_save_tokens(response.json())
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# Refresh token might be expired. Need to re-authenticate.
|
# Preserve the token file so a failed refresh never destroys diagnostics.
|
||||||
if os.path.exists(TOKEN_FILE):
|
raise Exception("카페24 인증이 만료되었습니다. 화면의 '재연동 필요' 버튼을 눌러 다시 연동해주세요.")
|
||||||
os.remove(TOKEN_FILE)
|
|
||||||
raise Exception("Refresh token expired or invalid. Please re-authenticate.")
|
|
||||||
|
|
||||||
def _save_tokens(token_data):
|
def _save_tokens(token_data):
|
||||||
# Cafe24 returns expires_at in string format like "2023-10-01T12:00:00.000"
|
# 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:
|
with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
|
||||||
json.dump(token_data, 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():
|
def get_valid_access_token():
|
||||||
if not os.path.exists(TOKEN_FILE):
|
if not os.path.exists(TOKEN_FILE):
|
||||||
raise Exception("No tokens found. Please authenticate first.")
|
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:
|
with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
|
||||||
tokens = json.load(f)
|
tokens = json.load(f)
|
||||||
|
|
||||||
expires_at_str = tokens.get('expires_at')
|
if _is_expired(tokens.get("expires_at"), leeway_seconds=60):
|
||||||
# Parse Cafe24 typical datetime format or isoformat
|
if not tokens.get("refresh_token") or _is_expired(tokens.get("refresh_token_expires_at")):
|
||||||
try:
|
raise Exception("카페24 인증이 만료되었습니다. 화면의 '재연동 필요' 버튼을 눌러 다시 연동해주세요.")
|
||||||
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:
|
|
||||||
print("Cafe24 Access token expired, refreshing...")
|
print("Cafe24 Access token expired, refreshing...")
|
||||||
refresh_access_token(tokens.get('refresh_token'))
|
refresh_access_token(tokens.get('refresh_token'))
|
||||||
return get_valid_access_token()
|
return get_valid_access_token()
|
||||||
@@ -119,7 +173,7 @@ def get_cafe24_orders_count(status="N20"):
|
|||||||
"order_status": status
|
"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:
|
if response.status_code == 200:
|
||||||
return response.json().get('count', 0)
|
return response.json().get('count', 0)
|
||||||
return 0
|
return 0
|
||||||
@@ -155,7 +209,7 @@ def get_cafe24_orders(status="N20", progress_callback=None):
|
|||||||
"embed": "receivers,items" # embed items and receiver addresses
|
"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:
|
if response.status_code != 200:
|
||||||
raise Exception(f"Failed to fetch Cafe24 orders: {response.text}")
|
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]:
|
if response.status_code in [200, 201]:
|
||||||
results["success"].append(order_id)
|
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"
|
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:
|
if response.status_code == 200:
|
||||||
return response.json().get('product', {})
|
return response.json().get('product', {})
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -13,6 +14,7 @@ import threading
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import uuid
|
import uuid
|
||||||
|
import secrets
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, HTTPException, Request, Body, UploadFile, File, Form, Query, BackgroundTasks
|
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.base import BaseHTTPMiddleware
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
import pandas as pd
|
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
|
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
|
import cafe24_api
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -27,9 +33,6 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
# .env 로드
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# SSO / 서브경로 호스팅 설정
|
# SSO / 서브경로 호스팅 설정
|
||||||
# ==========================================
|
# ==========================================
|
||||||
@@ -204,6 +207,12 @@ async def corm_logout():
|
|||||||
from routers.returns import router as returns_router
|
from routers.returns import router as returns_router
|
||||||
app.include_router(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
|
# Ensure directories exist
|
||||||
os.makedirs("static/css", exist_ok=True)
|
os.makedirs("static/css", exist_ok=True)
|
||||||
os.makedirs("static/js", 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")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
# Globals
|
# 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 = ["일시", "주문유형", "입금액", "주문아이템", "메시지 내용", "이름", "연락처", "주소"]
|
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:
|
def first_nonempty(data: dict, *keys: str) -> str:
|
||||||
for key in keys:
|
for key in keys:
|
||||||
value = data.get(key)
|
value = data.get(key)
|
||||||
@@ -228,12 +244,35 @@ def first_nonempty(data: dict, *keys: str) -> str:
|
|||||||
return str(value)
|
return str(value)
|
||||||
return ""
|
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):
|
def read_config(force_refresh=False):
|
||||||
global _CONFIG_ROWS_CACHE, _CONFIG_ROWS_CACHE_TIME
|
global _CONFIG_ROWS_CACHE, _CONFIG_ROWS_CACHE_TIME
|
||||||
|
|
||||||
|
config = configparser.ConfigParser(allow_no_value=True)
|
||||||
|
config.optionxform = str
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
|
|
||||||
# 1. 캐시 유효성 판단 (force_refresh가 아니고, 캐시가 존재하며, TTL이 지나지 않은 경우)
|
# 1. 캐시 유효성 판단 (force_refresh 가 아니고, 캐시가 존재하며, TTL 이 지나지 않은 경우)
|
||||||
if not force_refresh and _CONFIG_ROWS_CACHE is not None:
|
if not force_refresh and _CONFIG_ROWS_CACHE is not None:
|
||||||
if time.time() - _CONFIG_ROWS_CACHE_TIME < CONFIG_CACHE_TTL:
|
if time.time() - _CONFIG_ROWS_CACHE_TIME < CONFIG_CACHE_TTL:
|
||||||
rows = _CONFIG_ROWS_CACHE
|
rows = _CONFIG_ROWS_CACHE
|
||||||
@@ -241,7 +280,7 @@ def read_config(force_refresh=False):
|
|||||||
# 2. 캐시가 없거나 만료된 경우 구글 시트에서 직접 새로 로드
|
# 2. 캐시가 없거나 만료된 경우 구글 시트에서 직접 새로 로드
|
||||||
if not rows:
|
if not rows:
|
||||||
with _CONFIG_LOCK:
|
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:
|
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
|
rows = _CONFIG_ROWS_CACHE
|
||||||
else:
|
else:
|
||||||
@@ -258,10 +297,6 @@ def read_config(force_refresh=False):
|
|||||||
_CONFIG_ROWS_CACHE = rows
|
_CONFIG_ROWS_CACHE = rows
|
||||||
_CONFIG_ROWS_CACHE_TIME = time.time()
|
_CONFIG_ROWS_CACHE_TIME = time.time()
|
||||||
|
|
||||||
# 3. 로드된 로우 데이터를 ConfigParser 형식으로 파싱
|
|
||||||
config = configparser.ConfigParser(allow_no_value=True)
|
|
||||||
config.optionxform = str
|
|
||||||
|
|
||||||
if rows and len(rows) > 1:
|
if rows and len(rows) > 1:
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if not row or not row[0].strip():
|
if not row or not row[0].strip():
|
||||||
@@ -285,6 +320,7 @@ def read_config(force_refresh=False):
|
|||||||
config.set(section, key, val)
|
config.set(section, key, val)
|
||||||
else:
|
else:
|
||||||
config.set(section, key)
|
config.set(section, key)
|
||||||
|
config.set(section, key)
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def write_config(config):
|
def write_config(config):
|
||||||
@@ -351,7 +387,6 @@ async def get_config():
|
|||||||
data[section]["__order"] = list(config.options(section))
|
data[section]["__order"] = list(config.options(section))
|
||||||
return JSONResponse(data)
|
return JSONResponse(data)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/config/refresh")
|
@app.post("/api/config/refresh")
|
||||||
async def refresh_config():
|
async def refresh_config():
|
||||||
"""구글 시트로부터 실시간으로 설정을 동기화하여 캐시를 강제 갱신합니다."""
|
"""구글 시트로부터 실시간으로 설정을 동기화하여 캐시를 강제 갱신합니다."""
|
||||||
@@ -382,6 +417,11 @@ class ProductsPayload(BaseModel):
|
|||||||
groups: List[ProductGroup]
|
groups: List[ProductGroup]
|
||||||
products: Dict[str, List[ProductItem]]
|
products: Dict[str, List[ProductItem]]
|
||||||
|
|
||||||
|
class SubgroupNamePayload(BaseModel):
|
||||||
|
group_key: str
|
||||||
|
subgroup_index: int
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
@app.post("/api/products/settings")
|
@app.post("/api/products/settings")
|
||||||
async def save_products_settings(payload: ProductsPayload):
|
async def save_products_settings(payload: ProductsPayload):
|
||||||
config = read_config()
|
config = read_config()
|
||||||
@@ -420,6 +460,32 @@ async def save_products_settings(payload: ProductsPayload):
|
|||||||
write_config(config)
|
write_config(config)
|
||||||
return {"status": "success"}
|
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):
|
class OrderItem(BaseModel):
|
||||||
code: str
|
code: str
|
||||||
@@ -433,7 +499,7 @@ class OrderPayload(BaseModel):
|
|||||||
address: str
|
address: str
|
||||||
phone: str
|
phone: str
|
||||||
comment: 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
|
no_lid: bool
|
||||||
items: List[OrderItem]
|
items: List[OrderItem]
|
||||||
is_ellen: bool = False
|
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 = None
|
||||||
_CONFIG_ROWS_CACHE_TIME = 0.0
|
_CONFIG_ROWS_CACHE_TIME = 0.0
|
||||||
_CONFIG_LOCK = threading.Lock()
|
_CONFIG_LOCK = threading.Lock()
|
||||||
@@ -478,7 +545,7 @@ def get_gspread_client():
|
|||||||
raise Exception("gspread 라이브러리가 없습니다.")
|
raise Exception("gspread 라이브러리가 없습니다.")
|
||||||
if gs_client_global is None:
|
if gs_client_global is None:
|
||||||
scopes = ['https://www.googleapis.com/auth/spreadsheets']
|
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)
|
gs_client_global = gspread.authorize(creds)
|
||||||
return gs_client_global
|
return gs_client_global
|
||||||
|
|
||||||
@@ -516,7 +583,10 @@ def get_sms_history_worksheet():
|
|||||||
worksheet_sms_history_global = ss.worksheet("문자내역")
|
worksheet_sms_history_global = ss.worksheet("문자내역")
|
||||||
except gspread.exceptions.WorksheetNotFound:
|
except gspread.exceptions.WorksheetNotFound:
|
||||||
worksheet_sms_history_global = ss.add_worksheet(title="문자내역", rows=1000, cols=10)
|
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
|
return worksheet_sms_history_global
|
||||||
|
|
||||||
def get_manual_history_worksheet():
|
def get_manual_history_worksheet():
|
||||||
@@ -533,7 +603,22 @@ def get_manual_history_worksheet():
|
|||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
|
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()
|
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:
|
try:
|
||||||
print("구글 시트 연동 초기화 중...")
|
print("구글 시트 연동 초기화 중...")
|
||||||
get_manual_worksheet()
|
get_manual_worksheet()
|
||||||
@@ -541,26 +626,21 @@ async def startup_event():
|
|||||||
get_sms_history_worksheet()
|
get_sms_history_worksheet()
|
||||||
get_manual_history_worksheet()
|
get_manual_history_worksheet()
|
||||||
print("구글 시트 연동 완료!")
|
print("구글 시트 연동 완료!")
|
||||||
|
|
||||||
# 캐시 초기 적재 (Warm up cache)
|
|
||||||
print("구글 시트 설정 캐시 초기 적재 시작...")
|
|
||||||
read_config(force_refresh=True)
|
|
||||||
print("구글 시트 설정 캐시 적재 완료!")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"구글 시트 연동 초기화 실패 (사용 전 권한을 확인하세요): {str(e)}")
|
print(f"구글 시트 연동 초기화 실패 (사용 전 권한을 확인하세요): {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/order/submit")
|
@app.post("/api/order/submit")
|
||||||
async def submit_order_submit(payload: OrderPayload):
|
async def submit_order_submit(payload: OrderPayload):
|
||||||
date_str, time_str, current_datetime_str = get_current_time_info()
|
date_str, time_str, current_datetime_str = get_current_time_info()
|
||||||
c_tel = format_phone_number(payload.phone)
|
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:
|
if payload.is_ellen:
|
||||||
title = "수동발주(엘렌)"
|
title = "수동발주(엘렌)"
|
||||||
else:
|
else:
|
||||||
title_map = {'noolak': '누락', 'pason': '파손', 'bullyang': '불량', 'normal': '수동발주'}
|
title = "수동발주"
|
||||||
title = title_map.get(payload.order_type, "수동발주")
|
|
||||||
|
|
||||||
if not payload.customer_name:
|
if not payload.customer_name:
|
||||||
title = "재구매"
|
title = "재구매"
|
||||||
|
|
||||||
@@ -571,8 +651,10 @@ async def submit_order_submit(payload: OrderPayload):
|
|||||||
lines = []
|
lines = []
|
||||||
for i, item in enumerate(payload.items):
|
for i, item in enumerate(payload.items):
|
||||||
lid_suffix = "(뚜껑 없이 통만)" if payload.no_lid and item.item_type == "ORDER_SINGLE" else ""
|
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 ''}"
|
status_prefix = f", {order_status}" if order_status else ""
|
||||||
lines.append(f"{payload.customer_name} 고객님 {item.name}{lid_suffix} {item.quantity}개 발송, {title}")
|
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_comment = payload.comment or "빠른배송 부탁드립니다."
|
||||||
c_amount = payload.total_amount if i == len(payload.items) - 1 else ""
|
c_amount = payload.total_amount if i == len(payload.items) - 1 else ""
|
||||||
@@ -594,7 +676,7 @@ async def submit_order_submit(payload: OrderPayload):
|
|||||||
async def download_and_clear_sheet():
|
async def download_and_clear_sheet():
|
||||||
try:
|
try:
|
||||||
scopes = ['https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive']
|
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
|
import google.auth.transport.requests
|
||||||
request = google.auth.transport.requests.Request()
|
request = google.auth.transport.requests.Request()
|
||||||
@@ -984,9 +1066,6 @@ async def save_gift_settings(payload: dict):
|
|||||||
app_section = "APP_SETTINGS"
|
app_section = "APP_SETTINGS"
|
||||||
if app_section not in config:
|
if app_section not in config:
|
||||||
config.add_section(app_section)
|
config.add_section(app_section)
|
||||||
else:
|
|
||||||
config.remove_section(app_section)
|
|
||||||
config.add_section(app_section)
|
|
||||||
|
|
||||||
for k, v in app_settings.items():
|
for k, v in app_settings.items():
|
||||||
if v:
|
if v:
|
||||||
@@ -995,6 +1074,20 @@ async def save_gift_settings(payload: dict):
|
|||||||
write_config(config)
|
write_config(config)
|
||||||
return {"status": "success"}
|
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"}
|
||||||
|
|
||||||
# ----- Code DB (SQLite) Data Definition -----
|
# ----- Code DB (SQLite) Data Definition -----
|
||||||
DB_PATH = "codes.db"
|
DB_PATH = "codes.db"
|
||||||
|
|
||||||
@@ -1125,6 +1218,7 @@ async def add_single_item(payload: SingleItemAdd):
|
|||||||
(payload.item_code, payload.sabangnet_code, payload.name)
|
(payload.item_code, payload.sabangnet_code, payload.name)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
invalidate_milkrun_code_cache()
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
except psycopg2.IntegrityError:
|
except psycopg2.IntegrityError:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1143,6 +1237,7 @@ async def update_single_item(item_code: str, payload: SingleItemAdd):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
invalidate_milkrun_code_cache()
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
@app.delete("/api/codes/single/{item_code}")
|
@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,))
|
cursor.execute("DELETE FROM single_items WHERE item_code=%s", (item_code,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
invalidate_milkrun_code_cache()
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
@@ -1188,6 +1284,7 @@ async def add_set_item(payload: SetItemAdd):
|
|||||||
(payload.item_code, comp.single_code, comp.quantity)
|
(payload.item_code, comp.single_code, comp.quantity)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
invalidate_milkrun_code_cache()
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
except psycopg2.IntegrityError:
|
except psycopg2.IntegrityError:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
@@ -1208,6 +1305,7 @@ async def update_set_item(item_code: str, payload: SetItemAdd):
|
|||||||
(item_code, comp.single_code, comp.quantity)
|
(item_code, comp.single_code, comp.quantity)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
invalidate_milkrun_code_cache()
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
except psycopg2.IntegrityError:
|
except psycopg2.IntegrityError:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
@@ -1222,6 +1320,7 @@ async def delete_set_item(item_code: str):
|
|||||||
cursor.execute("DELETE FROM set_items WHERE item_code=%s", (item_code,))
|
cursor.execute("DELETE FROM set_items WHERE item_code=%s", (item_code,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
invalidate_milkrun_code_cache()
|
||||||
return {"status": "success"}
|
return {"status": "success"}
|
||||||
|
|
||||||
# ----- 스마트스토어 & 카페24 자동발주 공통 -----
|
# ----- 스마트스토어 & 카페24 자동발주 공통 -----
|
||||||
@@ -1571,20 +1670,50 @@ async def upload_invoices(file: UploadFile = File(...), deliveryCompanyCode: str
|
|||||||
|
|
||||||
from fastapi.responses import RedirectResponse
|
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")
|
@app.get("/api/cafe24/login")
|
||||||
async def cafe24_login():
|
async def cafe24_login(request: Request):
|
||||||
url = cafe24_api.get_auth_url()
|
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)
|
return RedirectResponse(url)
|
||||||
|
|
||||||
@app.get("/api/cafe24/callback")
|
@app.get("/api/cafe24/callback")
|
||||||
@app.get("/admin/oauth/callback")
|
@app.get("/admin/oauth/callback")
|
||||||
async def cafe24_callback(code: str):
|
async def cafe24_callback(code: str = "", state: str = "", error: str = ""):
|
||||||
home = f"{APP_ROOT_PATH}/" if APP_ROOT_PATH else "/"
|
if error:
|
||||||
|
message = json.dumps(f"카페24 인증이 취소되었거나 실패했습니다: {error}", ensure_ascii=False)
|
||||||
|
return HTMLResponse(f"<script>alert({message}); window.location.href='/';</script>", 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"<script>alert({message}); window.location.href='/';</script>", status_code=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cafe24_api.request_new_token(code)
|
cafe24_api.request_new_token(code, redirect_uri=saved["redirect_uri"])
|
||||||
return HTMLResponse(f"<script>alert('카페24 연동 성공!'); window.location.href='{home}';</script>")
|
return HTMLResponse("<script>alert('카페24 연동 성공!'); window.location.href='/';</script>")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return HTMLResponse(f"<script>alert('인증 실패: {e}'); window.location.href='{home}';</script>")
|
message = json.dumps(f"인증 실패: {e}", ensure_ascii=False)
|
||||||
|
return HTMLResponse(f"<script>alert({message}); window.location.href='/';</script>", status_code=400)
|
||||||
|
|
||||||
task_store = {}
|
task_store = {}
|
||||||
|
|
||||||
@@ -1706,7 +1835,13 @@ async def start_cafe24_download(background_tasks: BackgroundTasks):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status_code = 500
|
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
|
status_code = 401
|
||||||
task_store[tid]["status"] = "error"
|
task_store[tid]["status"] = "error"
|
||||||
task_store[tid]["error"] = str(e)
|
task_store[tid]["error"] = str(e)
|
||||||
@@ -1797,7 +1932,13 @@ async def cafe24_upload_invoices(file: UploadFile = File(...), deliveryCompanyCo
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status_code = 500
|
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
|
status_code = 401
|
||||||
return JSONResponse({"status": "error", "message": str(e)}, status_code=status_code)
|
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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
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)
|
||||||
|
|||||||
@@ -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<base>.+)_(?P<mult>\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}
|
||||||
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,7 +1,7 @@
|
|||||||
import os
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
import os
|
||||||
import psycopg2
|
import psycopg2
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
import datetime
|
import datetime
|
||||||
@@ -65,16 +65,17 @@ class BulkReturnReceive(BaseModel):
|
|||||||
@router.get("/lookup")
|
@router.get("/lookup")
|
||||||
async def return_lookup(tracking_no: str):
|
async def return_lookup(tracking_no: str):
|
||||||
try:
|
try:
|
||||||
|
tracking_no = tracking_no.strip()
|
||||||
conn = get_order_db_conn()
|
conn = get_order_db_conn()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
# 주문일시(order_date 또는 upload_date) 기준 가장 최신 1건 조회
|
# 주문일시(order_date 또는 upload_date) 기준 가장 최신 1건 조회
|
||||||
cur.execute(r"""
|
cur.execute("""
|
||||||
SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address
|
SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE tracking_number = %s
|
WHERE tracking_number = %s
|
||||||
OR (tracking_number IS NOT NULL
|
OR (tracking_number IS NOT NULL
|
||||||
AND tracking_number != ''
|
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
|
ORDER BY order_date DESC LIMIT 1
|
||||||
""", (tracking_no, tracking_no))
|
""", (tracking_no, tracking_no))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@@ -168,13 +169,13 @@ async def create_return_receive_bulk(req: BulkReturnReceive):
|
|||||||
else:
|
else:
|
||||||
remarks = "고객이 반품"
|
remarks = "고객이 반품"
|
||||||
# 2. Check orders
|
# 2. Check orders
|
||||||
cur_order.execute(r"""
|
cur_order.execute("""
|
||||||
SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address
|
SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE tracking_number = %s
|
WHERE tracking_number = %s
|
||||||
OR (tracking_number IS NOT NULL
|
OR (tracking_number IS NOT NULL
|
||||||
AND tracking_number != ''
|
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
|
ORDER BY order_date DESC LIMIT 1
|
||||||
""", (tracking_no, tracking_no))
|
""", (tracking_no, tracking_no))
|
||||||
ord_row = cur_order.fetchone()
|
ord_row = cur_order.fetchone()
|
||||||
|
|||||||
+852
-29
@@ -107,13 +107,47 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-links li {
|
.nav-links li {
|
||||||
/* 탭 글자 크기 */
|
/* 탭 글자 크기 — 사이드바(130px)에서 모든 메뉴가 한 줄에 들어가는 크기.
|
||||||
padding: 11px 10px;
|
메뉴는 전부 이 크기 하나로 통일한다. */
|
||||||
|
padding: 11px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 200;
|
font-weight: 200;
|
||||||
font-size: 1.0rem;
|
font-size: 0.8rem;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
border-left: 3px solid transparent;
|
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 {
|
.nav-links li:hover {
|
||||||
@@ -158,52 +192,787 @@ p {
|
|||||||
.main-grid {
|
.main-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 525px;
|
grid-template-columns: 1fr 525px;
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: calc(100vh - 45px);
|
height: calc(100vh - 45px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ★★★ CSS Multi-Column 레이아웃 적용 ★★★ */
|
.order-workspace {
|
||||||
/* 빈틈없이 차례대로 컬럼을 채우는 가장 강력한 속성 */
|
min-width: 0;
|
||||||
.left-col {
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
overflow: hidden;
|
||||||
align-content: flex-start;
|
}
|
||||||
|
|
||||||
|
.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;
|
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%;
|
height: 100%;
|
||||||
overflow-x: auto;
|
display: flex;
|
||||||
overflow-y: hidden;
|
flex-direction: column;
|
||||||
padding-bottom: 5px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-col::-webkit-scrollbar {
|
.order-items-inline-empty span:first-child {
|
||||||
width: 5px;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-col::-webkit-scrollbar-thumb {
|
.order-selected-panel {
|
||||||
background: rgba(0, 0, 0, 0.2);
|
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;
|
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 {
|
.right-col {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 260px 250px;
|
grid-template-columns: 260px 250px;
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-panel-col1 {
|
.right-panel-col1 {
|
||||||
display: flex;
|
display: contents;
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-panel-col2 {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
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%;
|
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 {
|
.right-col::-webkit-scrollbar {
|
||||||
@@ -732,6 +1501,11 @@ p {
|
|||||||
border-left-color: transparent;
|
border-left-color: transparent;
|
||||||
border-bottom-color: var(--primary-color);
|
border-bottom-color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
/* 가로 스크롤 메뉴에서는 아이콘 칸을 좁혀 폭을 아낀다 */
|
||||||
|
.nav-links li .nav-icon {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 3. Main Grid & Left Col (Product List) */
|
/* 3. Main Grid & Left Col (Product List) */
|
||||||
.main-grid {
|
.main-grid {
|
||||||
@@ -740,16 +1514,13 @@ p {
|
|||||||
height: auto;
|
height: auto;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
.left-col {
|
.order-workspace {
|
||||||
height: 45vh; /* Fixed height with internal scroll */
|
height: auto;
|
||||||
flex-direction: column;
|
min-height: 80vh;
|
||||||
flex-wrap: nowrap;
|
}
|
||||||
overflow-y: auto;
|
.order-workspace-body {
|
||||||
overflow-x: hidden;
|
grid-template-columns: 1fr;
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
grid-template-rows: minmax(45vh, 1fr) minmax(35vh, auto);
|
||||||
padding: 5px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
}
|
||||||
.item-group-card {
|
.item-group-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -764,6 +1535,9 @@ p {
|
|||||||
gap: 15px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
.right-panel-col1, .right-panel-col2 {
|
.right-panel-col1, .right-panel-col2 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
@@ -1097,3 +1871,52 @@ p {
|
|||||||
.toggle-switch input:checked + .switch-label:before {
|
.toggle-switch input:checked + .switch-label:before {
|
||||||
transform: translateX(26px);
|
transform: translateX(26px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 반품 송장 일괄 입력: 편집기처럼 입력 줄과 함께 움직이는 줄 번호 */
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
+1238
-115
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,841 @@
|
|||||||
|
// 자사몰 행사 탭 - 카페24 주문/발주 파일 가공
|
||||||
|
//
|
||||||
|
// 업로드 → 조건 선택 → 미리보기 → 가공된 엑셀 다운로드 흐름을 담당한다.
|
||||||
|
// 기능이 늘어나면 initFirstComeGift() 처럼 기능별 초기화 함수를 추가하고
|
||||||
|
// 왼쪽 '기능' 목록 버튼(.me-feature-btn)과 패널(#me-panel-<feature>)을 연결한다.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const tab = document.getElementById('mall-event-tab');
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
initFeatureSwitcher();
|
||||||
|
initFirstComeGift();
|
||||||
|
initEventExtract();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 기능 공통 헬퍼
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str == null ? '' : str)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(str) {
|
||||||
|
return String(str || '').toLowerCase().replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readError(res, fallback) {
|
||||||
|
// 413은 앱이 아니라 앞단 웹서버(nginx 등)가 용량 제한으로 막은 것이라
|
||||||
|
// 응답에 이유가 없다. 원인을 바로 알 수 있게 따로 안내한다.
|
||||||
|
if (res.status === 413) {
|
||||||
|
return '파일이 너무 커서 서버가 업로드를 거부했습니다 (413). ' +
|
||||||
|
'서버 웹서버(nginx 등)의 업로드 용량 제한(client_max_body_size)을 늘려야 합니다.';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.detail) {
|
||||||
|
// FastAPI 검증 오류는 detail이 배열로 온다
|
||||||
|
if (Array.isArray(data.detail)) {
|
||||||
|
return data.detail.map(function (d) { return d.msg || JSON.stringify(d); }).join(', ');
|
||||||
|
}
|
||||||
|
return data.detail;
|
||||||
|
}
|
||||||
|
} catch (e) { /* 본문이 JSON이 아니면 아래 기본 메시지 */ }
|
||||||
|
// 서버 오류(500 등)는 본문이 없으므로 상태 코드라도 남겨 원인을 좁힌다
|
||||||
|
return fallback + ' (서버 응답 ' + res.status + ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버가 Content-Disposition으로 내려준 파일명을 꺼낸다
|
||||||
|
function filenameFromResponse(res, fallback) {
|
||||||
|
const disposition = res.headers.get('Content-Disposition') || '';
|
||||||
|
const match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||||
|
if (match) {
|
||||||
|
try { return decodeURIComponent(match[1]); } catch (e) { /* 무시 */ }
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭 전체를 덮는 로딩 오버레이 (기능들이 공유)
|
||||||
|
function setOverlay(isLoading, text) {
|
||||||
|
const overlay = document.getElementById('me-loading-overlay');
|
||||||
|
const overlayText = document.getElementById('me-loading-text');
|
||||||
|
if (overlay) overlay.style.display = isLoading ? 'flex' : 'none';
|
||||||
|
if (overlayText && text) overlayText.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 선택 버튼 + 드래그 앤 드롭을 한 번에 붙인다
|
||||||
|
function bindDropzone(dropzone, fileInput, pickBtn, onFile) {
|
||||||
|
if (!dropzone || !fileInput) return;
|
||||||
|
|
||||||
|
if (pickBtn) {
|
||||||
|
pickBtn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
dropzone.addEventListener('click', function () { fileInput.click(); });
|
||||||
|
fileInput.addEventListener('change', function () {
|
||||||
|
if (fileInput.files && fileInput.files[0]) onFile(fileInput.files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(function (type) {
|
||||||
|
dropzone.addEventListener(type, function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dropzone.style.background = '#ebf8ff';
|
||||||
|
dropzone.style.borderColor = '#3182ce';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(function (type) {
|
||||||
|
dropzone.addEventListener(type, function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dropzone.style.background = '#f7fafc';
|
||||||
|
dropzone.style.borderColor = '#cbd5e0';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
dropzone.addEventListener('drop', function (e) {
|
||||||
|
const files = e.dataTransfer && e.dataTransfer.files;
|
||||||
|
if (files && files.length > 0) onFile(files[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭 밖에 파일을 떨어뜨렸을 때 브라우저가 파일을 열어버리는 것 방지
|
||||||
|
['dragover', 'drop'].forEach(function (type) {
|
||||||
|
window.addEventListener(type, function (e) {
|
||||||
|
const inDropzone = e.target.closest && e.target.closest('#me-dropzone, #ee-dropzone');
|
||||||
|
if (!inDropzone) e.preventDefault();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// blob 응답을 파일로 저장
|
||||||
|
async function saveBlob(res, fallbackName) {
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filenameFromResponse(res, fallbackName);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 기능 전환
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
function initFeatureSwitcher() {
|
||||||
|
const buttons = document.querySelectorAll('.me-feature-btn');
|
||||||
|
if (buttons.length < 2) return; // 기능이 하나면 전환할 것이 없다
|
||||||
|
|
||||||
|
buttons.forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
buttons.forEach(function (b) {
|
||||||
|
b.classList.remove('active');
|
||||||
|
b.style.background = '#fff';
|
||||||
|
b.style.color = '#4a5568';
|
||||||
|
b.style.borderColor = '#e2e8f0';
|
||||||
|
});
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.style.background = '#ebf8ff';
|
||||||
|
btn.style.color = '#2b6cb0';
|
||||||
|
btn.style.borderColor = '#bee3f8';
|
||||||
|
|
||||||
|
document.querySelectorAll('.me-feature-panel').forEach(function (panel) {
|
||||||
|
panel.style.display = 'none';
|
||||||
|
});
|
||||||
|
const target = document.getElementById('me-panel-' + btn.dataset.feature);
|
||||||
|
if (target) target.style.display = 'flex';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 기능 1) 선착순 사은품 추가
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
function initFirstComeGift() {
|
||||||
|
const dropzone = document.getElementById('me-dropzone');
|
||||||
|
const fileInput = document.getElementById('me-file-input');
|
||||||
|
const pickBtn = document.getElementById('btn-me-pick-file');
|
||||||
|
const resetBtn = document.getElementById('btn-me-reset');
|
||||||
|
const fileInfo = document.getElementById('me-file-info');
|
||||||
|
const fileError = document.getElementById('me-file-error');
|
||||||
|
const orderListBox = document.getElementById('me-order-list');
|
||||||
|
const orderSearch = document.getElementById('me-order-search');
|
||||||
|
const selectAllBtn = document.getElementById('btn-me-select-all');
|
||||||
|
const selectNoneBtn = document.getElementById('btn-me-select-none');
|
||||||
|
const selectedInfo = document.getElementById('me-order-selected-info');
|
||||||
|
const limitInput = document.getElementById('me-limit');
|
||||||
|
const giftSelect = document.getElementById('me-gift-select');
|
||||||
|
const giftQtyInput = document.getElementById('me-gift-qty');
|
||||||
|
const applyBtn = document.getElementById('btn-me-apply');
|
||||||
|
const warningBox = document.getElementById('me-warning-box');
|
||||||
|
const resultInfo = document.getElementById('me-result-info');
|
||||||
|
const resultTbody = document.getElementById('me-result-tbody');
|
||||||
|
const downloadFullBtn = document.getElementById('btn-me-download-full');
|
||||||
|
const downloadGiftBtn = document.getElementById('btn-me-download-gift');
|
||||||
|
const overlay = document.getElementById('me-loading-overlay');
|
||||||
|
const overlayText = document.getElementById('me-loading-text');
|
||||||
|
if (!dropzone || !fileInput) return;
|
||||||
|
|
||||||
|
let currentFile = null;
|
||||||
|
let orderItems = []; // [{name, order_count}]
|
||||||
|
const selectedNames = new Set();
|
||||||
|
let searchKeyword = '';
|
||||||
|
let previewReady = false;
|
||||||
|
|
||||||
|
function setLoading(isLoading, text) {
|
||||||
|
setOverlay(isLoading, text);
|
||||||
|
applyBtn.disabled = isLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFileError(message) {
|
||||||
|
if (!fileError) return;
|
||||||
|
if (!message) {
|
||||||
|
fileError.style.display = 'none';
|
||||||
|
fileError.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileError.style.display = 'block';
|
||||||
|
fileError.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 다운로드 버튼 활성/비활성 ----
|
||||||
|
function setDownloadEnabled(enabled) {
|
||||||
|
previewReady = enabled;
|
||||||
|
[downloadFullBtn, downloadGiftBtn].forEach(function (btn) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = !enabled;
|
||||||
|
btn.style.opacity = enabled ? '1' : '0.5';
|
||||||
|
btn.style.cursor = enabled ? 'pointer' : 'default';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조건이 바뀌면 이전 미리보기 결과는 더 이상 유효하지 않다
|
||||||
|
function invalidatePreview() {
|
||||||
|
if (!previewReady) return;
|
||||||
|
setDownloadEnabled(false);
|
||||||
|
resultInfo.innerHTML = '<span style="color:#975a16;">조건이 바뀌었습니다. "사은품 적용 결과 보기"를 다시 눌러주세요.</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 파일 업로드 ----
|
||||||
|
function resetAll() {
|
||||||
|
currentFile = null;
|
||||||
|
orderItems = [];
|
||||||
|
selectedNames.clear();
|
||||||
|
searchKeyword = '';
|
||||||
|
fileInput.value = '';
|
||||||
|
if (orderSearch) orderSearch.value = '';
|
||||||
|
fileInfo.style.display = 'none';
|
||||||
|
fileInfo.innerHTML = '';
|
||||||
|
showFileError('');
|
||||||
|
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">발주서를 업로드하면 주문 목록이 표시됩니다.</div>';
|
||||||
|
selectedInfo.textContent = '';
|
||||||
|
resultTbody.innerHTML = '';
|
||||||
|
resultInfo.textContent = '';
|
||||||
|
warningBox.style.display = 'none';
|
||||||
|
warningBox.innerHTML = '';
|
||||||
|
setDownloadEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
const lower = file.name.toLowerCase();
|
||||||
|
if (lower.endsWith('.xls')) {
|
||||||
|
showFileError('구형 .xls 파일은 처리할 수 없습니다. 엑셀에서 .xlsx로 저장한 뒤 올려주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!lower.endsWith('.xlsx') && !lower.endsWith('.xlsm')) {
|
||||||
|
showFileError('엑셀 파일(.xlsx)만 업로드할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showFileError('');
|
||||||
|
currentFile = file;
|
||||||
|
selectedNames.clear();
|
||||||
|
searchKeyword = '';
|
||||||
|
if (orderSearch) orderSearch.value = '';
|
||||||
|
setDownloadEnabled(false);
|
||||||
|
resultTbody.innerHTML = '';
|
||||||
|
resultInfo.textContent = '';
|
||||||
|
warningBox.style.display = 'none';
|
||||||
|
|
||||||
|
setLoading(true, '발주서를 분석하는 중...');
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const res = await fetch('/api/mall-event/first-come/analyze', { method: 'POST', body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
showFileError(await readError(res, '발주서를 분석하지 못했습니다.'));
|
||||||
|
currentFile = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
orderItems = data.order_list_items || [];
|
||||||
|
|
||||||
|
fileInfo.style.display = 'block';
|
||||||
|
fileInfo.innerHTML =
|
||||||
|
'<div style="font-weight:600; word-break:break-all;">📄 ' + escapeHtml(file.name) + '</div>' +
|
||||||
|
'<div style="color:#718096;">시트: ' + escapeHtml(data.sheet_name) + ' · 헤더 ' + data.header_row + '행</div>' +
|
||||||
|
'<div style="color:#718096;">데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
|
||||||
|
Number(data.total_orders).toLocaleString() + '건 · 상품 ' + orderItems.length + '종</div>';
|
||||||
|
|
||||||
|
renderOrderList();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showFileError('업로드 중 오류가 발생했습니다.');
|
||||||
|
currentFile = null;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bindDropzone(dropzone, fileInput, pickBtn, handleFile);
|
||||||
|
|
||||||
|
if (resetBtn) resetBtn.addEventListener('click', resetAll);
|
||||||
|
|
||||||
|
// ---- 주문목록 리스트 ----
|
||||||
|
function renderOrderList() {
|
||||||
|
if (!orderItems.length) {
|
||||||
|
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">Q열(주문목록)에서 상품을 찾지 못했습니다.</div>';
|
||||||
|
updateSelectedInfo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = orderItems.filter(function (item) {
|
||||||
|
return !searchKeyword || normalize(item.name).includes(searchKeyword);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!visible.length) {
|
||||||
|
orderListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">검색 결과가 없습니다.</div>';
|
||||||
|
updateSelectedInfo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
orderListBox.innerHTML = visible.map(function (item) {
|
||||||
|
const checked = selectedNames.has(item.name) ? ' checked' : '';
|
||||||
|
return '' +
|
||||||
|
'<label class="me-order-row" style="display:flex; align-items:center; gap:8px; padding:5px 8px; border-bottom:1px solid #edf2f7; cursor:pointer; font-size:0.85rem;">' +
|
||||||
|
'<input type="checkbox" class="me-order-check" value="' + escapeHtml(item.name) + '"' + checked + ' style="width:15px; height:15px; cursor:pointer;">' +
|
||||||
|
'<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(item.name) + '</span>' +
|
||||||
|
'<span style="color:#3182ce; font-weight:600; white-space:nowrap;">' + Number(item.order_count).toLocaleString() + '건</span>' +
|
||||||
|
'</label>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
updateSelectedInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedInfo() {
|
||||||
|
if (!selectedNames.size) {
|
||||||
|
selectedInfo.textContent = orderItems.length ? '선택된 상품이 없습니다.' : '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const names = Array.from(selectedNames);
|
||||||
|
const preview = names.slice(0, 2).join(', ');
|
||||||
|
const more = names.length > 2 ? ' 외 ' + (names.length - 2) + '개' : '';
|
||||||
|
selectedInfo.innerHTML = '선택 <b style="color:#3182ce;">' + names.length + '개</b> — ' + escapeHtml(preview + more);
|
||||||
|
}
|
||||||
|
|
||||||
|
orderListBox.addEventListener('change', function (e) {
|
||||||
|
const check = e.target.closest('.me-order-check');
|
||||||
|
if (!check) return;
|
||||||
|
if (check.checked) selectedNames.add(check.value);
|
||||||
|
else selectedNames.delete(check.value);
|
||||||
|
updateSelectedInfo();
|
||||||
|
invalidatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orderSearch) {
|
||||||
|
orderSearch.addEventListener('input', function () {
|
||||||
|
searchKeyword = normalize(orderSearch.value);
|
||||||
|
renderOrderList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (selectAllBtn) {
|
||||||
|
selectAllBtn.addEventListener('click', function () {
|
||||||
|
orderItems.forEach(function (item) {
|
||||||
|
if (!searchKeyword || normalize(item.name).includes(searchKeyword)) selectedNames.add(item.name);
|
||||||
|
});
|
||||||
|
renderOrderList();
|
||||||
|
invalidatePreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (selectNoneBtn) {
|
||||||
|
selectNoneBtn.addEventListener('click', function () {
|
||||||
|
selectedNames.clear();
|
||||||
|
renderOrderList();
|
||||||
|
invalidatePreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 사은품 목록 (코드표 세트 코드 중 이름에 '사은품'이 들어간 것만) ----
|
||||||
|
const GIFT_NAME_KEYWORD = '사은품';
|
||||||
|
|
||||||
|
async function loadGiftOptions() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/codes/set');
|
||||||
|
if (!res.ok) throw new Error('status ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
const allSets = (data && data.data) || [];
|
||||||
|
const sets = allSets.filter(function (s) {
|
||||||
|
return String(s.name || '').includes(GIFT_NAME_KEYWORD);
|
||||||
|
});
|
||||||
|
if (!sets.length) {
|
||||||
|
giftSelect.innerHTML = '<option value="">이름에 "' + GIFT_NAME_KEYWORD +
|
||||||
|
'"이 들어간 세트가 코드표에 없습니다</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sets.sort(function (a, b) {
|
||||||
|
return String(a.name || '').localeCompare(String(b.name || ''), 'ko');
|
||||||
|
});
|
||||||
|
giftSelect.innerHTML = '<option value="">사은품을 선택하세요</option>' + sets.map(function (s) {
|
||||||
|
return '<option value="' + escapeHtml(s.item_code) + '" data-name="' + escapeHtml(s.name || '') + '">' +
|
||||||
|
escapeHtml(s.name || '(이름 없음)') + ' — ' + escapeHtml(s.item_code) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
giftSelect.innerHTML = '<option value="">세트 코드를 불러오지 못했습니다 (DB 연결 확인)</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadGiftOptions();
|
||||||
|
|
||||||
|
// 코드표 탭에서 세트를 수정하고 돌아왔을 때 목록을 새로 받는다
|
||||||
|
const mallEventMenu = document.querySelector('[data-tab="mall-event-tab"]');
|
||||||
|
if (mallEventMenu) {
|
||||||
|
mallEventMenu.addEventListener('click', function () {
|
||||||
|
if (!giftSelect.value) loadGiftOptions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 조건 변경 감지 ----
|
||||||
|
document.querySelectorAll('.me-limit-preset').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
limitInput.value = btn.dataset.value;
|
||||||
|
invalidatePreview();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
[limitInput, giftSelect, giftQtyInput].forEach(function (el) {
|
||||||
|
if (el) el.addEventListener('change', invalidatePreview);
|
||||||
|
});
|
||||||
|
if (limitInput) limitInput.addEventListener('input', invalidatePreview);
|
||||||
|
if (giftQtyInput) giftQtyInput.addEventListener('input', invalidatePreview);
|
||||||
|
|
||||||
|
// ---- 조건 수집/검증 ----
|
||||||
|
function buildFormData() {
|
||||||
|
if (!currentFile) {
|
||||||
|
alert('먼저 발주서 엑셀 파일을 업로드해주세요.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!selectedNames.size) {
|
||||||
|
alert('사은품이 지급되는 주문을 최소 1개 선택해주세요.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const limit = parseInt(limitInput.value, 10);
|
||||||
|
if (!limit || limit < 1) {
|
||||||
|
alert('선착순 인원을 1명 이상으로 입력해주세요.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const giftCode = giftSelect.value;
|
||||||
|
if (!giftCode) {
|
||||||
|
alert('사은품(세트 코드)을 선택해주세요.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const giftQty = parseInt(giftQtyInput.value, 10);
|
||||||
|
if (!giftQty || giftQty < 1) {
|
||||||
|
alert('사은품 수량을 1개 이상으로 입력해주세요.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const giftName = giftSelect.options[giftSelect.selectedIndex].dataset.name || '';
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', currentFile);
|
||||||
|
form.append('selected_json', JSON.stringify(Array.from(selectedNames)));
|
||||||
|
form.append('limit', String(limit));
|
||||||
|
form.append('gift_item_code', giftCode);
|
||||||
|
form.append('gift_name', giftName);
|
||||||
|
form.append('gift_qty', String(giftQty));
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 미리보기 ----
|
||||||
|
applyBtn.addEventListener('click', async function () {
|
||||||
|
const form = buildFormData();
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
setLoading(true, '사은품 대상을 계산하는 중...');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mall-event/first-come/preview', { method: 'POST', body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(await readError(res, '사은품 적용 중 오류가 발생했습니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.warnings && data.warnings.length) {
|
||||||
|
warningBox.style.display = 'block';
|
||||||
|
warningBox.innerHTML = data.warnings.map(function (w) {
|
||||||
|
return '⚠ ' + escapeHtml(w);
|
||||||
|
}).join('<br>');
|
||||||
|
} else {
|
||||||
|
warningBox.style.display = 'none';
|
||||||
|
warningBox.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const added = data.added || [];
|
||||||
|
if (!added.length) {
|
||||||
|
resultTbody.innerHTML = '<tr><td colspan="4" style="padding:14px; text-align:center; color:#a0aec0;">사은품이 적용된 주문이 없습니다.</td></tr>';
|
||||||
|
resultInfo.innerHTML = '<span style="color:#c53030;">적용 대상 0건</span>';
|
||||||
|
setDownloadEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultTbody.innerHTML = added.map(function (row, idx) {
|
||||||
|
return '<tr>' +
|
||||||
|
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; color:#a0aec0;">' + (idx + 1) + '</td>' +
|
||||||
|
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap;">' + escapeHtml(row.order_no) + '</td>' +
|
||||||
|
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap; color:#4a5568;">' + escapeHtml(row.order_date) + '</td>' +
|
||||||
|
'<td style="padding:5px 8px; border-top:1px solid #edf2f7; white-space:nowrap; color:#2b6cb0;">' + escapeHtml(row.seq) + '</td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
resultInfo.innerHTML = '조건 충족 주문 <b>' + Number(data.matched_order_count).toLocaleString() + '건</b> 중 ' +
|
||||||
|
'<b style="color:#3182ce;">' + Number(data.applied_count).toLocaleString() + '건</b>에 사은품 행 추가';
|
||||||
|
setDownloadEnabled(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('사은품 적용 요청 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 다운로드 ----
|
||||||
|
async function download(mode, fallbackName, label) {
|
||||||
|
const form = buildFormData();
|
||||||
|
if (!form) return;
|
||||||
|
form.append('mode', mode);
|
||||||
|
|
||||||
|
setLoading(true, label + ' 파일을 만드는 중...');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mall-event/first-come/download', { method: 'POST', body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(await readError(res, '엑셀 다운로드 중 오류가 발생했습니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveBlob(res, fallbackName);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버가 Content-Disposition으로 이름을 내려주지만, 못 읽었을 때 쓸 대비책
|
||||||
|
function baseName() {
|
||||||
|
const name = (currentFile && currentFile.name) || '발주서.xlsx';
|
||||||
|
return name.replace(/\.(xlsx|xlsm|xls)$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloadFullBtn) {
|
||||||
|
downloadFullBtn.addEventListener('click', function () {
|
||||||
|
if (downloadFullBtn.disabled) return;
|
||||||
|
// 전체 발주 파일은 업로드한 파일과 같은 이름으로 받는다
|
||||||
|
download('full', baseName() + '.xlsx', '전체 발주');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (downloadGiftBtn) {
|
||||||
|
downloadGiftBtn.addEventListener('click', function () {
|
||||||
|
if (downloadGiftBtn.disabled) return;
|
||||||
|
download('gift', baseName() + '_선착순사은품_대상주문.xlsx', '사은품 대상 주문');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 기능 2) 행사 주문 추출
|
||||||
|
// 주문 CSV → K열(주문상품명) 첫 " -" 앞부분으로 상품 선택 →
|
||||||
|
// 해당상품만/해당주문 조건으로 행을 걸러 엑셀로 내려받는다.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
function initEventExtract() {
|
||||||
|
const dropzone = document.getElementById('ee-dropzone');
|
||||||
|
const fileInput = document.getElementById('ee-file-input');
|
||||||
|
const pickBtn = document.getElementById('btn-ee-pick-file');
|
||||||
|
const resetBtn = document.getElementById('btn-ee-reset');
|
||||||
|
const fileInfo = document.getElementById('ee-file-info');
|
||||||
|
const fileError = document.getElementById('ee-file-error');
|
||||||
|
const productListBox = document.getElementById('ee-product-list');
|
||||||
|
const productSearch = document.getElementById('ee-product-search');
|
||||||
|
const selectAllBtn = document.getElementById('btn-ee-select-all');
|
||||||
|
const selectNoneBtn = document.getElementById('btn-ee-select-none');
|
||||||
|
const selectedInfo = document.getElementById('ee-selected-info');
|
||||||
|
const warningBox = document.getElementById('ee-warning-box');
|
||||||
|
const resultInfo = document.getElementById('ee-result-info');
|
||||||
|
const downloadBtn = document.getElementById('btn-ee-download');
|
||||||
|
const modeRadios = document.querySelectorAll('input[name="ee-mode"]');
|
||||||
|
if (!dropzone || !fileInput) return;
|
||||||
|
|
||||||
|
let currentFile = null;
|
||||||
|
let productItems = []; // [{name, row_count, order_count}]
|
||||||
|
const selectedNames = new Set();
|
||||||
|
let searchKeyword = '';
|
||||||
|
|
||||||
|
function setLoading(isLoading, text) {
|
||||||
|
setOverlay(isLoading, text);
|
||||||
|
downloadBtn.disabled = isLoading;
|
||||||
|
downloadBtn.style.opacity = isLoading ? '0.6' : '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조건이 바뀌면 직전 다운로드 결과 안내는 지운다
|
||||||
|
function clearResult() {
|
||||||
|
resultInfo.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMode() {
|
||||||
|
const checked = document.querySelector('input[name="ee-mode"]:checked');
|
||||||
|
return checked ? checked.value : 'product';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFileError(message) {
|
||||||
|
if (!fileError) return;
|
||||||
|
fileError.style.display = message ? 'block' : 'none';
|
||||||
|
fileError.textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll() {
|
||||||
|
currentFile = null;
|
||||||
|
productItems = [];
|
||||||
|
selectedNames.clear();
|
||||||
|
searchKeyword = '';
|
||||||
|
fileInput.value = '';
|
||||||
|
if (productSearch) productSearch.value = '';
|
||||||
|
fileInfo.style.display = 'none';
|
||||||
|
fileInfo.innerHTML = '';
|
||||||
|
showFileError('');
|
||||||
|
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">CSV를 업로드하면 상품 목록이 표시됩니다.</div>';
|
||||||
|
selectedInfo.textContent = '';
|
||||||
|
resultInfo.innerHTML = '';
|
||||||
|
warningBox.style.display = 'none';
|
||||||
|
warningBox.innerHTML = '';
|
||||||
|
clearResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 업로드 & 분석 ----
|
||||||
|
async function handleFile(file) {
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||||
|
showFileError('CSV 파일(.csv)만 업로드할 수 있습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showFileError('');
|
||||||
|
currentFile = file;
|
||||||
|
selectedNames.clear();
|
||||||
|
searchKeyword = '';
|
||||||
|
if (productSearch) productSearch.value = '';
|
||||||
|
clearResult();
|
||||||
|
resultInfo.innerHTML = '';
|
||||||
|
warningBox.style.display = 'none';
|
||||||
|
|
||||||
|
setLoading(true, 'CSV를 분석하는 중...');
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const res = await fetch('/api/mall-event/event-extract/analyze', { method: 'POST', body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
showFileError(await readError(res, 'CSV를 분석하지 못했습니다.'));
|
||||||
|
currentFile = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
productItems = data.product_items || [];
|
||||||
|
|
||||||
|
fileInfo.style.display = 'block';
|
||||||
|
fileInfo.innerHTML =
|
||||||
|
'<div style="font-weight:600; word-break:break-all;">📄 ' + escapeHtml(file.name) + '</div>' +
|
||||||
|
'<div style="color:#718096;">인코딩: ' + escapeHtml(data.encoding) + ' · 헤더 ' + data.header_row + '행 · ' + data.column_count + '개 열</div>' +
|
||||||
|
'<div style="color:#718096;">데이터 ' + Number(data.total_rows).toLocaleString() + '행 · 주문 ' +
|
||||||
|
Number(data.total_orders).toLocaleString() + '건 · 상품 ' + productItems.length + '종</div>';
|
||||||
|
|
||||||
|
if (data.warnings && data.warnings.length) {
|
||||||
|
warningBox.style.display = 'block';
|
||||||
|
warningBox.innerHTML = data.warnings.map(function (w) {
|
||||||
|
return '⚠ ' + escapeHtml(w);
|
||||||
|
}).join('<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderProductList();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showFileError('업로드 중 오류가 발생했습니다.');
|
||||||
|
currentFile = null;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bindDropzone(dropzone, fileInput, pickBtn, handleFile);
|
||||||
|
if (resetBtn) resetBtn.addEventListener('click', resetAll);
|
||||||
|
|
||||||
|
// ---- 상품 목록 ----
|
||||||
|
function renderProductList() {
|
||||||
|
if (!productItems.length) {
|
||||||
|
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">K열(주문상품명)에서 상품을 찾지 못했습니다.</div>';
|
||||||
|
updateSelectedInfo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = productItems.filter(function (item) {
|
||||||
|
return !searchKeyword || normalize(item.name).includes(searchKeyword);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!visible.length) {
|
||||||
|
productListBox.innerHTML = '<div style="padding: 20px; text-align: center; color: #a0aec0; font-size: 0.85rem;">검색 결과가 없습니다.</div>';
|
||||||
|
updateSelectedInfo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
productListBox.innerHTML = visible.map(function (item) {
|
||||||
|
const checked = selectedNames.has(item.name) ? ' checked' : '';
|
||||||
|
return '' +
|
||||||
|
'<label style="display:flex; align-items:center; gap:8px; padding:5px 8px; border-bottom:1px solid #edf2f7; cursor:pointer; font-size:0.85rem;">' +
|
||||||
|
'<input type="checkbox" class="ee-product-check" value="' + escapeHtml(item.name) + '"' + checked + ' style="width:15px; height:15px; cursor:pointer;">' +
|
||||||
|
'<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(item.name) + '</span>' +
|
||||||
|
'<span style="color:#718096; white-space:nowrap; font-size:0.78rem;">' + Number(item.row_count).toLocaleString() + '행</span>' +
|
||||||
|
'<span style="color:#3182ce; font-weight:600; white-space:nowrap;">' + Number(item.order_count).toLocaleString() + '건</span>' +
|
||||||
|
'</label>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
updateSelectedInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedInfo() {
|
||||||
|
if (!selectedNames.size) {
|
||||||
|
selectedInfo.textContent = productItems.length ? '선택된 상품이 없습니다.' : '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const names = Array.from(selectedNames);
|
||||||
|
const preview = names.slice(0, 2).join(', ');
|
||||||
|
const more = names.length > 2 ? ' 외 ' + (names.length - 2) + '개' : '';
|
||||||
|
selectedInfo.innerHTML = '선택 <b style="color:#3182ce;">' + names.length + '개</b> — ' + escapeHtml(preview + more);
|
||||||
|
}
|
||||||
|
|
||||||
|
productListBox.addEventListener('change', function (e) {
|
||||||
|
const check = e.target.closest('.ee-product-check');
|
||||||
|
if (!check) return;
|
||||||
|
if (check.checked) selectedNames.add(check.value);
|
||||||
|
else selectedNames.delete(check.value);
|
||||||
|
updateSelectedInfo();
|
||||||
|
clearResult();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (productSearch) {
|
||||||
|
productSearch.addEventListener('input', function () {
|
||||||
|
searchKeyword = normalize(productSearch.value);
|
||||||
|
renderProductList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (selectAllBtn) {
|
||||||
|
selectAllBtn.addEventListener('click', function () {
|
||||||
|
productItems.forEach(function (item) {
|
||||||
|
if (!searchKeyword || normalize(item.name).includes(searchKeyword)) selectedNames.add(item.name);
|
||||||
|
});
|
||||||
|
renderProductList();
|
||||||
|
clearResult();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (selectNoneBtn) {
|
||||||
|
selectNoneBtn.addEventListener('click', function () {
|
||||||
|
selectedNames.clear();
|
||||||
|
renderProductList();
|
||||||
|
clearResult();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 추출 조건 라디오 ----
|
||||||
|
modeRadios.forEach(function (radio) {
|
||||||
|
radio.addEventListener('change', function () {
|
||||||
|
modeRadios.forEach(function (r) {
|
||||||
|
const box = r.closest('label');
|
||||||
|
if (!box) return;
|
||||||
|
const on = r.checked;
|
||||||
|
box.style.borderColor = on ? '#bee3f8' : '#e2e8f0';
|
||||||
|
box.style.background = on ? '#ebf8ff' : '#fff';
|
||||||
|
const title = box.querySelector('span > span');
|
||||||
|
if (title) title.style.color = on ? '#2b6cb0' : '#2d3748';
|
||||||
|
});
|
||||||
|
clearResult();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 조건 수집 ----
|
||||||
|
function buildFormData() {
|
||||||
|
if (!currentFile) {
|
||||||
|
alert('먼저 주문 CSV 파일을 업로드해주세요.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!selectedNames.size) {
|
||||||
|
alert('추출할 상품을 최소 1개 선택해주세요.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', currentFile);
|
||||||
|
form.append('selected_json', JSON.stringify(Array.from(selectedNames)));
|
||||||
|
form.append('mode', currentMode());
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 다운로드 (중간 확인 단계 없이 바로 파일 생성) ----
|
||||||
|
downloadBtn.addEventListener('click', async function () {
|
||||||
|
if (downloadBtn.disabled) return;
|
||||||
|
const form = buildFormData();
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const modeLabel = currentMode() === 'product' ? '해당상품만 추출' : '해당 주문 추출';
|
||||||
|
const suffix = currentMode() === 'product' ? '_행사추출_해당상품만' : '_행사추출_해당주문';
|
||||||
|
const fallback = ((currentFile && currentFile.name) || '주문').replace(/\.csv$/i, '') + suffix + '.xlsx';
|
||||||
|
|
||||||
|
warningBox.style.display = 'none';
|
||||||
|
warningBox.innerHTML = '';
|
||||||
|
clearResult();
|
||||||
|
|
||||||
|
setLoading(true, '추출 파일을 만드는 중...');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mall-event/event-extract/download', { method: 'POST', body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
alert(await readError(res, '엑셀 다운로드 중 오류가 발생했습니다.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버가 헤더로 실어 보낸 추출 결과를 화면에 남긴다
|
||||||
|
const rows = Number(res.headers.get('X-Extract-Rows') || 0);
|
||||||
|
const orders = Number(res.headers.get('X-Extract-Orders') || 0);
|
||||||
|
const matched = Number(res.headers.get('X-Extract-Matched-Rows') || 0);
|
||||||
|
const total = Number(res.headers.get('X-Extract-Total-Rows') || 0);
|
||||||
|
|
||||||
|
await saveBlob(res, fallback);
|
||||||
|
|
||||||
|
resultInfo.innerHTML =
|
||||||
|
'<div style="color:#2f855a; font-weight:600;">✓ 다운로드 완료</div>' +
|
||||||
|
'<div>조건: <b>' + modeLabel + '</b></div>' +
|
||||||
|
'<div>상품이 들어있는 행 <b>' + matched.toLocaleString() + '행</b></div>' +
|
||||||
|
'<div>추출 결과 <b style="color:#3182ce;">' + rows.toLocaleString() + '행</b>' +
|
||||||
|
' · 주문 <b style="color:#3182ce;">' + orders.toLocaleString() + '건</b></div>' +
|
||||||
|
'<div style="color:#718096; font-size:0.78rem;">전체 ' + total.toLocaleString() + '행 중</div>';
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('엑셀 다운로드 요청 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clearResult();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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, '"').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 =
|
||||||
|
'<div id="cal-body"></div>' +
|
||||||
|
'<div id="cal-overlay" style="display:none; position:absolute; inset:0; z-index:5;' +
|
||||||
|
' background:rgba(255,255,255,0.82); border-radius:4px;' +
|
||||||
|
' align-items:center; justify-content:center; gap:8px; flex-direction:column;">' +
|
||||||
|
'<div style="width:22px; height:22px; border:3px solid #cbd5e0; border-top-color:#3182ce;' +
|
||||||
|
' border-radius:50%; animation:milkrun-spin 0.8s linear infinite;"></div>' +
|
||||||
|
'<div id="cal-overlay-text" style="font-size:0.82rem; font-weight:600; color:#2d3748;">데이터 읽는 중...</div>' +
|
||||||
|
'</div>';
|
||||||
|
calendarBox.dataset.ready = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(isBusy, label) {
|
||||||
|
busy = isBusy;
|
||||||
|
if (refreshBtn) {
|
||||||
|
refreshBtn.disabled = isBusy;
|
||||||
|
refreshBtn.style.opacity = isBusy ? '0.6' : '1';
|
||||||
|
refreshBtn.textContent = isBusy ? '읽는 중...' : '새로고침';
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureCalendarShell();
|
||||||
|
const overlay = document.getElementById('cal-overlay');
|
||||||
|
const overlayText = document.getElementById('cal-overlay-text');
|
||||||
|
const body = document.getElementById('cal-body');
|
||||||
|
if (overlay) overlay.style.display = isBusy ? 'flex' : 'none';
|
||||||
|
if (overlayText && label) overlayText.textContent = label;
|
||||||
|
if (body) {
|
||||||
|
// 읽는 동안에는 날짜를 누를 수 없게 한다
|
||||||
|
body.style.pointerEvents = isBusy ? 'none' : '';
|
||||||
|
body.style.opacity = isBusy ? '0.45' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 시트 이름 → 날짜 ----
|
||||||
|
// '20260807(금)' 처럼 앞 8자리가 날짜인 이름을 달력에 올린다
|
||||||
|
function parseSheetDate(name) {
|
||||||
|
const m = String(name).match(/(\d{4})(\d{2})(\d{2})/);
|
||||||
|
if (!m) return null;
|
||||||
|
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
|
||||||
|
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||||
|
const date = new Date(y, mo - 1, d);
|
||||||
|
if (date.getFullYear() !== y || date.getMonth() !== mo - 1 || date.getDate() !== d) return null;
|
||||||
|
return { key: m[1] + '-' + m[2] + '-' + m[3], year: y, month: mo - 1, day: d };
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexSheets(sheets) {
|
||||||
|
sheetsByDate = {};
|
||||||
|
otherSheets = [];
|
||||||
|
let latest = null;
|
||||||
|
sheets.forEach(function (sheet) {
|
||||||
|
const parsed = parseSheetDate(sheet.name);
|
||||||
|
if (!parsed) {
|
||||||
|
otherSheets.push(sheet.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sheetsByDate[parsed.key] = sheet.name;
|
||||||
|
if (!latest || parsed.key > latest.key) latest = parsed;
|
||||||
|
});
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 달력 ----
|
||||||
|
function renderCalendar(placeholderText) {
|
||||||
|
calendarBox.style.display = 'block';
|
||||||
|
ensureCalendarShell();
|
||||||
|
const body = document.getElementById('cal-body');
|
||||||
|
|
||||||
|
if (viewYear === null) {
|
||||||
|
const now = new Date();
|
||||||
|
viewYear = now.getFullYear();
|
||||||
|
viewMonth = now.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = new Date(viewYear, viewMonth, 1);
|
||||||
|
const startDow = first.getDay();
|
||||||
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
|
const monthPrefix = viewYear + '-' + String(viewMonth + 1).padStart(2, '0');
|
||||||
|
const monthCount = Object.keys(sheetsByDate).filter(function (key) {
|
||||||
|
return key.indexOf(monthPrefix) === 0;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
let html = '' +
|
||||||
|
'<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:6px;">' +
|
||||||
|
'<button type="button" class="cal-nav" data-move="-1" style="border:1px solid #e2e8f0; background:#fff; border-radius:4px; cursor:pointer; padding:2px 8px; font-size:0.8rem;">◀</button>' +
|
||||||
|
'<div style="font-size:0.88rem; font-weight:600; color:#2d3748;">' + viewYear + '년 ' + (viewMonth + 1) + '월' +
|
||||||
|
'<span style="color:#a0aec0; font-weight:400; font-size:0.75rem;"> · 출고 ' + monthCount + '일</span></div>' +
|
||||||
|
'<button type="button" class="cal-nav" data-move="1" style="border:1px solid #e2e8f0; background:#fff; border-radius:4px; cursor:pointer; padding:2px 8px; font-size:0.8rem;">▶</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="display:grid; grid-template-columns:repeat(7,1fr); gap:2px;">';
|
||||||
|
|
||||||
|
DOW.forEach(function (label, index) {
|
||||||
|
const color = index === 0 ? '#e53e3e' : (index === 6 ? '#3182ce' : '#718096');
|
||||||
|
html += '<div style="text-align:center; font-size:0.7rem; color:' + color + '; padding:2px 0;">' + label + '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < startDow; i++) html += '<div></div>';
|
||||||
|
|
||||||
|
const todayKey = dateKeyOf(new Date());
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const key = monthPrefix + '-' + String(day).padStart(2, '0');
|
||||||
|
const sheetName = sheetsByDate[key];
|
||||||
|
const isSelected = selectedDate === key;
|
||||||
|
let style = 'text-align:center; padding:5px 0; border-radius:4px; font-size:0.78rem;';
|
||||||
|
// 오늘 날짜는 테두리로 표시해 기준점을 알 수 있게 한다
|
||||||
|
if (key === todayKey) style += ' outline:2px solid #ed8936; outline-offset:-2px;';
|
||||||
|
let attrs = '';
|
||||||
|
|
||||||
|
if (sheetName) {
|
||||||
|
attrs = ' class="cal-day" data-date="' + key + '" title="' + escapeHtml(sheetName) + '"';
|
||||||
|
style += isSelected
|
||||||
|
? ' background:#2b6cb0; color:#fff; font-weight:700; cursor:pointer;'
|
||||||
|
: ' background:#bee3f8; color:#2b6cb0; font-weight:600; cursor:pointer;';
|
||||||
|
} else {
|
||||||
|
style += ' color:#cbd5e0;';
|
||||||
|
}
|
||||||
|
html += '<div' + attrs + ' style="' + style + '">' + day + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (placeholderText) {
|
||||||
|
html += '<div style="text-align:center; color:#a0aec0; font-size:0.75rem; padding:6px 0 2px;">' +
|
||||||
|
escapeHtml(placeholderText) + '</div>';
|
||||||
|
}
|
||||||
|
body.innerHTML = html;
|
||||||
|
|
||||||
|
body.querySelectorAll('.cal-nav').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const date = new Date(viewYear, viewMonth + Number(btn.dataset.move), 1);
|
||||||
|
viewYear = date.getFullYear();
|
||||||
|
viewMonth = date.getMonth();
|
||||||
|
renderCalendar();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
body.querySelectorAll('.cal-day').forEach(function (cell) {
|
||||||
|
cell.addEventListener('click', function () {
|
||||||
|
if (busy) return;
|
||||||
|
selectDate(cell.dataset.date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOtherSheets() {
|
||||||
|
if (!otherSheets.length) {
|
||||||
|
otherBox.style.display = 'none';
|
||||||
|
otherBox.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
otherBox.style.display = 'block';
|
||||||
|
otherBox.innerHTML = '날짜로 읽지 못한 시트 ' + otherSheets.length + '개: ' +
|
||||||
|
otherSheets.slice(0, 5).map(escapeHtml).join(', ') +
|
||||||
|
(otherSheets.length > 5 ? ' 외' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 시트 목록 불러오기 ----
|
||||||
|
async function loadSheetTabs(forceRefresh) {
|
||||||
|
selectedDate = null;
|
||||||
|
showMessage('구글 시트에서 출고일을 읽는 중...', 'info');
|
||||||
|
setBusy(true, '출고일을 읽는 중...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/coupang-milkrun/sheet-tabs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh: !!forceRefresh })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = '출고일을 불러오지 못했습니다. (서버 응답 ' + res.status + ')';
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.detail) detail = data.detail;
|
||||||
|
} catch (e) { /* 본문이 JSON이 아니면 기본 메시지 */ }
|
||||||
|
showMessage(escapeHtml(detail), 'error');
|
||||||
|
renderCalendar('출고일을 불러오지 못했습니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const sheets = data.sheets || [];
|
||||||
|
indexSheets(sheets);
|
||||||
|
|
||||||
|
fileInfo.style.display = 'block';
|
||||||
|
fileInfo.innerHTML =
|
||||||
|
'<b>' + escapeHtml(data.file_name || '쿠팡 밀크런 출고리스트') + '</b>' +
|
||||||
|
' <span style="color:#a0aec0;">· 출고일 ' + Object.keys(sheetsByDate).length + '일' +
|
||||||
|
(data.cached ? ' · 최근 결과' : '') + '</span>';
|
||||||
|
|
||||||
|
// 항상 오늘 날짜를 기준으로 본다
|
||||||
|
const today = new Date();
|
||||||
|
const todayKey = dateKeyOf(today);
|
||||||
|
viewYear = today.getFullYear();
|
||||||
|
viewMonth = today.getMonth();
|
||||||
|
|
||||||
|
renderCalendar();
|
||||||
|
renderOtherSheets();
|
||||||
|
|
||||||
|
if (sheetsByDate[todayKey]) {
|
||||||
|
// 오늘 출고가 있으면 바로 읽어서 분석까지 진행 (메시지는 selectDate가 남긴다)
|
||||||
|
await selectDate(todayKey);
|
||||||
|
} else {
|
||||||
|
showMessage(
|
||||||
|
'<span style="font-size:0.95rem; font-weight:700;">오늘 출고할 밀크런은 없습니다.</span>',
|
||||||
|
'alert', 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showMessage('출고일 조회 중 오류가 발생했습니다.', 'error');
|
||||||
|
renderCalendar('출고일을 불러오지 못했습니다');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 날짜 선택 → 값 읽기 → 분석 ----
|
||||||
|
async function selectDate(dateKey) {
|
||||||
|
const sheetName = sheetsByDate[dateKey];
|
||||||
|
if (!sheetName) return;
|
||||||
|
|
||||||
|
selectedDate = dateKey;
|
||||||
|
renderCalendar();
|
||||||
|
showMessage('<b>' + escapeHtml(sheetName) + '</b> 출고리스트를 읽는 중...', 'info');
|
||||||
|
setBusy(true, '출고리스트를 읽는 중...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/coupang-milkrun/sheet-rows', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sheet: sheetName })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = '출고리스트를 읽지 못했습니다. (서버 응답 ' + res.status + ')';
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && data.detail) detail = data.detail;
|
||||||
|
} catch (e) { /* 무시 */ }
|
||||||
|
showMessage(escapeHtml(detail), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.items || [];
|
||||||
|
if (!items.length) {
|
||||||
|
showMessage('<b>' + escapeHtml(sheetName) + '</b>에서 제품코드/수량을 찾지 못했습니다. ' +
|
||||||
|
'시트 형식을 확인해주세요.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pasteInput) pasteInput.value = data.text || '';
|
||||||
|
if (analyzeBtn) analyzeBtn.click(); // 기존 분석 로직 재사용
|
||||||
|
|
||||||
|
const skipped = data.skipped
|
||||||
|
? ' <span style="color:#975a16;">(건너뛴 줄 ' + data.skipped + '개)</span>' : '';
|
||||||
|
showMessage('<b>' + escapeHtml(sheetName) + '</b> · ' + items.length +
|
||||||
|
'줄을 불러와 분석했습니다.' + skipped, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showMessage('출고리스트를 읽는 중 오류가 발생했습니다.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 시작 ----
|
||||||
|
if (refreshBtn) refreshBtn.addEventListener('click', function () { loadSheetTabs(true); });
|
||||||
|
|
||||||
|
// 밀크런 탭을 누를 때마다 구글 시트를 다시 읽는다 (시트가 추가/수정됐을 수 있으므로)
|
||||||
|
const milkrunMenu = document.querySelector('[data-tab="milkrun-tab"]');
|
||||||
|
if (milkrunMenu) {
|
||||||
|
milkrunMenu.addEventListener('click', function () {
|
||||||
|
if (!busy) loadSheetTabs(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 달력 틀을 먼저 보여주고(덮개가 '읽는 중'을 표시), 출고일은 뒤이어 채운다
|
||||||
|
renderCalendar();
|
||||||
|
loadSheetTabs(false);
|
||||||
|
});
|
||||||
|
})();
|
||||||
+3759
-596
File diff suppressed because it is too large
Load Diff
+104
-1
@@ -1,4 +1,107 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "v9.0",
|
||||||
|
"date": "2026.08.25",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"New CS 작업 탭과 새로운 주문 처리 UI 추가: 상품 분류별 하위 상품 선택, 선택 상품 삭제 및 수량 변경, 주문금액·할인·배송비·입금금액 실시간 계산, 문자 미리보기 기능을 한 화면에서 사용할 수 있도록 개선"
|
||||||
|
],
|
||||||
|
"type": "added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"기존 CS 작업 탭은 전체 상품 그룹을 한 번에 확인할 수 있는 이전 UI로 유지하여 New CS 작업 탭과 선택적으로 사용할 수 있도록 구성"
|
||||||
|
],
|
||||||
|
"type": "modified"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v8.82",
|
||||||
|
"date": "2026.08.25",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"주문 정보 프로그램에서 복사된 재발송 정보를 자동으로 입력 하는 기능 추가"
|
||||||
|
],
|
||||||
|
"type": "added"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v8.81",
|
||||||
|
"date": "2026.08.12",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"자사몰 행사 메뉴에 \"행사주문 추출\" 기능 추가 (아롱님 전용 메뉴): 카페24 주문 CSV를 파일 선택 또는 드래그 앤 드롭으로 업로드하면 주문상품명(K열)의 첫 \" -\" 앞부분을 중복 없이 가나다순으로 정리해 보여주고, 여기서 고른 상품을 기준으로 필요한 행만 남긴 엑셀을 곧바로 내려받을 수 있음",
|
||||||
|
"추출 조건 2가지 제공: \"해당상품만 추출\"은 선택한 상품이 들어있는 행만 남기고, \"해당 주문 추출\"은 그 행의 주문번호(B열)와 같은 주문의 모든 행을 남김"
|
||||||
|
],
|
||||||
|
"type": "added"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v8.8",
|
||||||
|
"date": "2026.08.11",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"자사몰 행사 메뉴 추가: 카페24에서 받은 발주주문서(엑셀)를 업로드해 행사 조건을 적용하고, 가공된 발주 파일을 다시 내려받는 공간 (발주서 가공 기능이 앞으로 계속 추가될 예정)",
|
||||||
|
"선착순 사은품 지급 기능 추가: 발주서를 파일 선택 또는 드래그 앤 드롭으로 업로드하면 주문목록(Q열)의 상품을 중복 없이 가나다순으로 정리해 보여주고, 선택한 상품을 주문한 고객을 주문날짜 순서대로 선착순 N명까지 자동 선별 (인원은 10/30/50/100 버튼 또는 직접 입력)",
|
||||||
|
"선별된 각 주문의 마지막 행 바로 아래에 사은품 행을 자동 삽입 (품목 순번 자동 증가, 사은품 아이템코드·이름·수량 입력, 주문목록에 \"선착순 사은품\" 표기, 삽입된 행의 A~Q열 노란색 표시)",
|
||||||
|
"적용 결과를 미리 확인한 뒤 전체 발주 파일(업로드한 파일과 같은 이름)과 사은품이 적용된 주문만 담은 파일을 각각 엑셀로 다운로드 가능"
|
||||||
|
],
|
||||||
|
"type": "added"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"코드표: 단품 코드와 세트 코드 제목 옆에 이름 검색란 추가 (입력하는 즉시 실시간 검색, 초기화 버튼과 Esc 키로 검색어 해제)",
|
||||||
|
"왼쪽 메뉴에 아이콘 추가 (🎧 CS 작업, ✍️ 수동발주, 📦 상품설정, 🤖 자동발주, 🏷️ 코드표, ↩️ 반품관리, 🎁 자사몰 행사, 🚚 밀크런, ⚙️ 설정, 🚀 History)"
|
||||||
|
],
|
||||||
|
"type": "modified"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v8.7",
|
||||||
|
"date": "2026.08.07",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"쿠팡 밀크런 메뉴 추가: 밀크런 발주서(제품코드/제품명/수량)를 붙여넣으면 코드표 DB의 세트-단품 구성을 기준으로 낱개 코드별 합계 수량을 자동 계산",
|
||||||
|
"쿠팡 밀크런: 사방넷 코드, 낱개 코드, 상품 이름, 합계 수량 순으로 결과 표시, 계산 결과를 사방넷 업로드 양식(상품코드/가용수량/불용수량/바코드) 그대로 엑셀 다운로드 가능"
|
||||||
|
],
|
||||||
|
"type": "added"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v8.61",
|
||||||
|
"date": "2026.06.18",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"세트 수정 및 추가 저장 시 구성품 정보 저장 로직의 JavaScript 오류 수정 (저장이 완료되지 않던 문제 해결)"
|
||||||
|
],
|
||||||
|
"type": "modified"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "v8.6",
|
||||||
|
"date": "2026.06.17",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
"코드표에서 세트코드 수정 시 구성품 드래그 앤 드롭 이동(정렬) 기능 추가",
|
||||||
|
"최근 전송 메시지 화면에서 이름, 연락처, 주소가 입력된 메시지는 메시지 맨 오른쪽에 *표시 추가"
|
||||||
|
],
|
||||||
|
"type": "modified"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "v8.59",
|
"version": "v8.59",
|
||||||
"date": "2026.06.15",
|
"date": "2026.06.15",
|
||||||
@@ -26,7 +129,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"version": "v8.57",
|
"version": "v8.57",
|
||||||
"date": "2026.05.26",
|
"date": "2026.05.28",
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"items": [
|
"items": [
|
||||||
|
|||||||
Reference in New Issue
Block a user