commit 6e438344d088139bb7e7d8b69439c1d3791d80b0 Author: king Date: Sun May 24 15:11:34 2026 +0900 init: dbx-corm 프로젝트 Docker 기반 마이그레이션 주요 변경사항: - DB 접속정보를 환경변수(.env) 방식으로 분리 - orderlist_app → orderlist_db 변경 (main.py, routers/returns.py) - 네이버/카페24 API 키도 환경변수로 분리 - Dockerfile / docker-compose.yml 작성 (외부 PostgreSQL 네트워크 연결) - .gitignore: 민감파일(.env, cafe24_tokens.json, manual-ordering.json) 제외 - 불필요 파일 정리 (venv, __pycache__, 일회성 스크립트, xlsx 등) - README.md / .env.example / push_to_gitea.bat 추가 Co-Authored-By: Claude Sonnet 4.6 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5a3e8f9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +__pycache__/ +*.pyc +*.pyo +venv/ +env/ +.venv/ +.git/ +.gitignore +.dockerignore +.vscode/ +.idea/ +.antigravitycli/ +.pytest_cache/ +build/ +dist/ + +# 환경변수는 env_file로 전달 +.env + +# 민감정보는 별도 마운트 +# (cafe24_tokens.json, manual-ordering.json은 컨테이너 안에는 있어야 하므로 제외 안 함) + +# 로컬 데이터 +*.db +*.dat +sync.ffs_db +*.zip +*.log + +# 문서/스크립트 +README.md +docker-compose.yml +docker-compose*.yml +push_to_gitea.bat +deploy.bat +deploy.ps1 +start.bat +test.ps1 +*.xlsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5773591 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# ============================================ +# PostgreSQL 접속 정보 +# ============================================ +# 서버에서 docker network 확인 후 host 값 수정 +# docker ps --filter "ancestor=postgres" --format "{{.Names}}" +POSTGRES_HOST=postgres-db +POSTGRES_PORT=5432 +POSTGRES_USER=king +POSTGRES_PASSWORD=여기에_비밀번호_입력 + +# 데이터베이스 이름 (3개 분리되어 있음) +ITEMCODE_DB=itemcode_db +ORDERLIST_DB=orderlist_db +RETURN_DB=return_db + +# ============================================ +# 네이버 커머스 API +# ============================================ +NAVER_CLIENT_ID= +NAVER_CLIENT_SECRET= + +# ============================================ +# 카페24 API +# ============================================ +CAFE24_CLIENT_ID= +CAFE24_CLIENT_SECRET= +CAFE24_MALL_ID=miraskitchen +CAFE24_REDIRECT_URI= + +# ============================================ +# 앱 설정 +# ============================================ +APP_PORT=8002 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bf07813 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# 환경변수 +.env +.env.* +!.env.example + +# 민감정보 (절대 커밋 금지) +cafe24_tokens.json +manual-ordering.json +*.pem +*.key +*credential* +*secret* +*token* + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +venv/ +env/ +.venv/ +.pytest_cache/ +*.egg-info/ +build/ +dist/ + +# 로컬 데이터 +*.db +*.dat +sync.ffs_db +*.zip +*.log + +# IDE / OS +.vscode/ +.idea/ +.antigravitycli/ +.DS_Store +Thumbs.db + +# 배포 임시 (push_to_gitea.bat은 공유 가능하므로 제외 안 함) +deploy.bat +deploy.ps1 +start.bat +test.ps1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..22c0c4b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.10-slim + +WORKDIR /app + +# psycopg2 런타임 라이브러리 +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* + +# 의존성 먼저 (캐시 활용) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 소스 복사 +COPY . . + +EXPOSE 8002 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8002", "--proxy-headers", "--forwarded-allow-ips=*"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b751ce --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# DBX CORM (CS Web Program) + +CS / 발주 / 반품 통합 관리 웹 애플리케이션. +FastAPI + PostgreSQL 기반, Docker로 실행. + +--- + +## 기술 스택 + +- **Backend**: Python 3.10, FastAPI, Uvicorn +- **DB**: PostgreSQL (외부 Docker 컨테이너 연결) +- **외부 연동**: 네이버 커머스 API, 카페24 API, Google Sheets +- **Frontend**: Jinja2 템플릿 + Vanilla JS + +--- + +## 사용 DB + +| DB 이름 | 용도 | +|---|---| +| `itemcode_db` | 품목코드 마스터 | +| `orderlist_db` | 주문 정보 조회 | +| `return_db` | 반품 데이터 저장 | + +모두 DB 계정은 `king` 통일. + +--- + +## 서버 배포 절차 (`/opt/dbx-corm`) + +### 1. 코드 받기 + +```bash +sudo mkdir -p /opt/dbx-corm +sudo chown $USER:$USER /opt/dbx-corm +cd /opt +git clone https://gitea.no1king.freeddns.org/king/dbx-corm.git +cd /opt/dbx-corm +``` + +### 2. 환경변수 설정 + +```bash +cp .env.example .env +nano .env +``` + +`.env` 파일에 다음을 채워주세요: + +- `POSTGRES_PASSWORD` (DB 비밀번호) +- `POSTGRES_HOST` (PostgreSQL 컨테이너 이름. 아래로 확인) +- `NAVER_CLIENT_ID`, `NAVER_CLIENT_SECRET` +- `CAFE24_CLIENT_ID`, `CAFE24_CLIENT_SECRET`, `CAFE24_REDIRECT_URI` + +PostgreSQL 컨테이너 이름 확인: + +```bash +docker ps --filter "ancestor=postgres" --format "{{.Names}}" +``` + +### 3. PostgreSQL 네트워크 확인 / 수정 + +```bash +docker network ls | grep postgres +``` + +`docker-compose.yml`의 `networks.postgres-net.name` 값을 실제 네트워크 이름으로 수정. + +### 4. 민감 파일 업로드 (Git에 없음) + +로컬 PC에서 서버로 직접 복사: + +```bash +# 로컬 PC에서 실행 +scp cafe24_tokens.json king@<서버>:/opt/dbx-corm/ +scp manual-ordering.json king@<서버>:/opt/dbx-corm/ +``` + +### 5. 빌드 및 실행 + +```bash +cd /opt/dbx-corm +docker compose up -d --build +``` + +### 6. 상태 확인 + +```bash +docker compose ps +docker compose logs -f +``` + +--- + +## 업데이트 (Git pull 후 재배포) + +```bash +cd /opt/dbx-corm +git pull +docker compose up -d --build +``` + +--- + +## 접속 + +- 컨테이너 포트: `8002` +- 호스트 포트: `8002` +- 헬스체크: `http://<서버>:8002/` + +Nginx Proxy Manager 등을 사용한다면 `<서버>:8002`로 프록시 설정. + +--- + +## 로컬 개발 (Windows) + +```cmd +python -m venv venv +venv\Scripts\activate +pip install -r requirements.txt + +copy .env.example .env +:: .env 편집 + +python main.py +``` + +--- + +## Gitea 푸시 (Windows) + +리포지토리에 변경사항을 올릴 때: + +```cmd +push_to_gitea.bat +``` + +스크립트가 변경사항 확인 → 커밋 메시지 입력 → push 자동 수행. + +--- + +## 주의사항 + +- `.env`, `cafe24_tokens.json`, `manual-ordering.json` 등 **민감 파일은 절대 Git에 올리지 마세요**. +- `.gitignore`에 자동 제외 설정되어 있습니다. +- DB 이름은 반드시 `orderlist_db`를 사용 (예전 이름 `orderlist_app` 사용 금지). diff --git a/cafe24_api.py b/cafe24_api.py new file mode 100644 index 0000000..6548e04 --- /dev/null +++ b/cafe24_api.py @@ -0,0 +1,242 @@ +import requests +import json +import os +import time +from datetime import datetime, timedelta + +# 환경변수에서 카페24 인증 정보 로드 +CLIENT_ID = os.getenv("CAFE24_CLIENT_ID", "") +CLIENT_SECRET = os.getenv("CAFE24_CLIENT_SECRET", "") +MALL_ID = os.getenv("CAFE24_MALL_ID", "miraskitchen") +REDIRECT_URI = os.getenv("CAFE24_REDIRECT_URI", "") +TOKEN_FILE = "cafe24_tokens.json" + +def get_auth_url(): + # state parameter could be added for security but keeping it simple for local app + url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/authorize" + url += f"?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}" + url += f"&scope=mall.read_order,mall.write_order,mall.read_product" + return url + +import base64 + +def _get_basic_auth_header(): + credentials = f"{CLIENT_ID}:{CLIENT_SECRET}" + encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8') + return f"Basic {encoded}" + +def request_new_token(auth_code: str): + url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token" + headers = { + "Authorization": _get_basic_auth_header(), + "Content-Type": "application/x-www-form-urlencoded" + } + data = { + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": REDIRECT_URI + } + + response = requests.post(url, headers=headers, data=data) + if response.status_code == 200: + _save_tokens(response.json()) + return True + else: + raise Exception(f"Failed to get token: {response.text}") + +def refresh_access_token(refresh_token: str): + url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token" + headers = { + "Authorization": _get_basic_auth_header(), + "Content-Type": "application/x-www-form-urlencoded" + } + data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token + } + + response = requests.post(url, headers=headers, data=data) + if response.status_code == 200: + _save_tokens(response.json()) + return True + else: + # Refresh token might be expired. Need to re-authenticate. + if os.path.exists(TOKEN_FILE): + os.remove(TOKEN_FILE) + raise Exception("Refresh token expired or invalid. Please re-authenticate.") + +def _save_tokens(token_data): + # Cafe24 returns expires_at in string format like "2023-10-01T12:00:00.000" + if 'expires_at' not in token_data: + # Fallback if expires_at is not provided (Cafe24 provides expires_at) + expires_in = token_data.get('expires_in', 7200) # usually 2 hours + expires_at = datetime.now() + timedelta(seconds=expires_in - 60) # 1 min buffer + token_data['expires_at'] = expires_at.isoformat() + + with open(TOKEN_FILE, 'w', encoding='utf-8') as f: + json.dump(token_data, f) + +def get_valid_access_token(): + if not os.path.exists(TOKEN_FILE): + raise Exception("No tokens found. Please authenticate first.") + + with open(TOKEN_FILE, 'r', encoding='utf-8') as f: + tokens = json.load(f) + + expires_at_str = tokens.get('expires_at') + # Parse Cafe24 typical datetime format or isoformat + try: + if '.' in expires_at_str: # e.g. "2023-10-01T12:00:00.000" + expires_at = datetime.strptime(expires_at_str[:19], "%Y-%m-%dT%H:%M:%S") + else: + expires_at = datetime.fromisoformat(expires_at_str) + except: + expires_at = datetime.now() - timedelta(minutes=1) # force refresh on parse error + + # Check if expired + if datetime.now() >= expires_at: + print("Cafe24 Access token expired, refreshing...") + refresh_access_token(tokens.get('refresh_token')) + return get_valid_access_token() + + return tokens.get('access_token') + +def get_cafe24_orders_count(status="N20"): + token = get_valid_access_token() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-Cafe24-Api-Version": "2026-03-01" + } + + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + + url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/orders/count" + params = { + "start_date": start_date, + "end_date": end_date, + "order_status": status + } + + response = requests.get(url, headers=headers, params=params) + if response.status_code == 200: + return response.json().get('count', 0) + return 0 + +def get_cafe24_orders(status="N20", progress_callback=None): + """ + Fetch orders with specific status (default N20: 배송준비중) + """ + token = get_valid_access_token() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-Cafe24-Api-Version": "2026-03-01" + } + + # Calculate search date range (e.g. last 7 days) + end_date = datetime.now().strftime("%Y-%m-%d") + start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + + url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/orders" + + all_orders = [] + limit = 100 + offset = 0 + + while True: + params = { + "start_date": start_date, + "end_date": end_date, + "order_status": status, + "limit": limit, + "offset": offset, + "embed": "receivers,items" # embed items and receiver addresses + } + + response = requests.get(url, headers=headers, params=params) + if response.status_code != 200: + raise Exception(f"Failed to fetch Cafe24 orders: {response.text}") + + json_data = response.json() + orders = json_data.get('orders', []) + all_orders.extend(orders) + + if progress_callback: + progress_callback(len(all_orders)) + + if len(orders) < limit: + break + + offset += limit + time.sleep(0.5) # rate limit prevention + + return all_orders + +def update_cafe24_tracking(dispatch_list): + """ + dispatch_list is a list of dict: {"order_id": "...", "tracking_no": "...", "shipping_company_code": "0004"} + Cafe24 API allows updating order shipments per order or item. + Assuming we update the whole order (not item by item): PUT /api/v2/admin/orders/{order_id}/shipments + """ + token = get_valid_access_token() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-Cafe24-Api-Version": "2026-03-01" + } + + results = { + "success": [], + "fail": [] + } + + for item in dispatch_list: + order_id = item.get("order_id") + tracking_no = item.get("tracking_no") + company_code = item.get("shipping_company_code") + + url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/orders/{order_id}/shipments" + payload = { + "request": { + "tracking_no": tracking_no, + "shipping_company_code": company_code + } + } + + response = requests.put(url, headers=headers, json=payload) + + if response.status_code in [200, 201]: + results["success"].append(order_id) + else: + reason = "Unknown Error" + try: + err_data = response.json() + reason = err_data.get('error', {}).get('message', response.text) + except: + reason = response.text + + results["fail"].append({ + "order_id": order_id, + "reason": reason + }) + + time.sleep(0.3) # API rate limit + + return results + +def get_product_details(product_no): + """ Fetch product details to get custom product codes/seller codes """ + token = get_valid_access_token() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-Cafe24-Api-Version": "2026-03-01" + } + url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/products/{product_no}?embed=variants" + + response = requests.get(url, headers=headers) + if response.status_code == 200: + return response.json().get('product', {}) + return {} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3a752b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + corm-app: + build: . + image: dbx-corm:latest + container_name: dbx-corm + restart: unless-stopped + ports: + - "8002:8002" + env_file: + - .env + volumes: + # 민감 파일은 Git에 안 올리고 서버 호스트에서 직접 마운트 + - ./cafe24_tokens.json:/app/cafe24_tokens.json + - ./manual-ordering.json:/app/manual-ordering.json + # 토큰 파일 자동 갱신 결과를 호스트에 영구 보존 + networks: + - postgres-net + +networks: + postgres-net: + external: true + name: postgres_default # 서버의 실제 PostgreSQL 네트워크 이름으로 수정 diff --git a/main.py b/main.py new file mode 100644 index 0000000..41ddb18 --- /dev/null +++ b/main.py @@ -0,0 +1,1598 @@ +import os +import sys +import psycopg2 +import psycopg2.extras +import time +import json +import re +import io +import math +import datetime +import configparser +import urllib.request +import urllib.parse +import uuid +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Request, Body, UploadFile, File, Form, Query, BackgroundTasks +from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse +import pandas as pd +from naver_api import get_access_token, get_payment_done_orders, get_product_order_details, dispatch_orders, confirm_orders, get_product_details +import cafe24_api +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel +from typing import List, Optional, Dict, Any + +# .env 로드 +load_dotenv() + +# ========================================== +# 데이터베이스 접속 설정 (환경변수 기반) +# ========================================== +_PG_HOST = os.getenv('POSTGRES_HOST', 'postgres-db') +_PG_PORT = int(os.getenv('POSTGRES_PORT', '5432')) +_PG_USER = os.getenv('POSTGRES_USER', 'king') +_PG_PASSWORD = os.getenv('POSTGRES_PASSWORD', '') + +# 1. 메인 프로그램용 DB (품목코드) +DB_CONFIG = { + 'host': _PG_HOST, + 'port': _PG_PORT, + 'dbname': os.getenv('ITEMCODE_DB', 'itemcode_db'), + 'user': _PG_USER, + 'password': _PG_PASSWORD, +} + +# 2. 반품 데이터 저장용 DB +RETURN_DB_CONFIG = { + 'host': _PG_HOST, + 'port': _PG_PORT, + 'dbname': os.getenv('RETURN_DB', 'return_db'), + 'user': _PG_USER, + 'password': _PG_PASSWORD, +} + +# 3. 주문정보 조회용 DB +ORDER_DB_CONFIG = { + 'host': _PG_HOST, + 'port': _PG_PORT, + 'dbname': os.getenv('ORDERLIST_DB', 'orderlist_db'), + 'user': _PG_USER, + 'password': _PG_PASSWORD, +} +# ========================================== + +# Google Sheets +try: + import gspread + from google.oauth2.service_account import Credentials +except ImportError: + gspread = None + Credentials = None + +app = FastAPI(title="CS Web Program") + +from routers.returns import router as returns_router +app.include_router(returns_router) + +# Ensure directories exist +os.makedirs("static/css", exist_ok=True) +os.makedirs("static/js", exist_ok=True) +os.makedirs("templates", exist_ok=True) + +app.mount("/static", StaticFiles(directory="static"), name="static") +templates = Jinja2Templates(directory="templates") + +# Globals +SMS_HISTORY_FILE = 'sms_history.dat' +GSPREAD_CRED_FILE = 'manual-ordering.json' +MANUAL_SPREADSHEET_ID = '1QxFtjDurPPZd8NmtXBy4XEUIzkf93aXD5Mlx5kB33xw' +SETTINGS_SPREADSHEET_ID = '1k7SNEdIaRtGXzQNdJSQAiNSDRwzOePDRTLHY_mNCO8M' +MANUAL_HISTORY_FILE = 'manual_history.dat' + +# ----- Utility Functions ----- +def read_config(): + config = configparser.ConfigParser(allow_no_value=True) + config.optionxform = str + + rows = [] + for _ in range(3): + try: + ws = get_settings_worksheet() + rows = ws.get_all_values() + break + except Exception as e: + time.sleep(1.5) + print(f"Google Sheets 불러오기 재시도 중...: {e}") + + if rows and len(rows) > 1: + for row in rows: + if not row or not row[0].strip(): + continue + section = row[0].strip() + if section == "대분류 (Section)": + continue + + key = row[1].strip() if len(row) > 1 else "" + + c_cells = [x.strip() for x in row[2:]] + while c_cells and not c_cells[-1]: + c_cells.pop() + val = " | ".join(c_cells) + + if not config.has_section(section): + config.add_section(section) + + if key: + if val: + config.set(section, key, val) + else: + config.set(section, key) + config.set(section, key) + return config + +def write_config(config): + rows = [["대분류 (Section)", "항목 (Key)", "설정값 1", "설정값 2", "설정값 3", "설정값 4", "설정값 5"]] + for section in config.sections(): + items = config.items(section) + if not items: + rows.append([section, "", ""]) + else: + for k, v in items: + if v is None: + rows.append([section, k]) + else: + v_parts = [p.strip() for p in v.split("|")] + rows.append([section, k] + v_parts) + + for attempt in range(3): + try: + ws = get_settings_worksheet() + ws.clear() + ws.update(values=rows) + return + except Exception as e: + print(f"Google Sheets 저장 시도 {attempt+1}/3 실패: {e}") + time.sleep(1.5) + + print("Google Sheets에 설정을 저장하는 데 최종 실패했습니다.") + raise Exception("구글 시트 연동 오류 (할당량 초과). 잠시 후 다시 시도해주세요.") + +# ----- API Endpoints ----- + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + try: + with open("version_history.json", "r", encoding="utf-8") as f: + version_history = json.load(f) + except Exception as e: + print("버전 히스토리 로드 실패:", e) + version_history = [] + + return templates.TemplateResponse( + request=request, + name="index.html", + context={"version_history": version_history} + ) + +@app.get("/api/config") +async def get_config(): + config = read_config() + data = {} + for section in config.sections(): + data[section] = dict(config.items(section)) + data[section]["__order"] = list(config.options(section)) + return JSONResponse(data) + + + +class ProductGroup(BaseModel): + old_code: str + new_code: str + name: str + +class ProductItem(BaseModel): + is_separator: bool = False + code: str = "" + name: str = "" + label: str = "" + price: int = 0 + dprice: int = 0 + color: str = "" + is_free: bool = False + +class ProductsPayload(BaseModel): + groups: List[ProductGroup] + products: Dict[str, List[ProductItem]] + +@app.post("/api/products/settings") +async def save_products_settings(payload: ProductsPayload): + config = read_config() + + if config.has_section("GROUP_NAMES"): + config.remove_section("GROUP_NAMES") + config.add_section("GROUP_NAMES") + + for g in payload.groups: + config.set("GROUP_NAMES", g.new_code, g.name) + + for g in payload.groups: + if config.has_section(g.old_code): + config.remove_section(g.old_code) + if config.has_section(g.new_code): + config.remove_section(g.new_code) + + for g in payload.groups: + config.add_section(g.new_code) + items = payload.products.get(g.new_code, []) + sep_idx = 1 + for item in items: + if item.is_separator: + config.set(g.new_code, f"---separator_{sep_idx}---", "") + sep_idx += 1 + else: + key = f"*{item.label}{item.color}" if item.is_free else f"{item.label}{item.color}" + val_parts = [item.code, item.name] + if item.price > 0 or item.dprice > 0: + val_parts.append(str(item.price)) + if item.dprice > 0: + val_parts.append(str(item.dprice)) + val = " | ".join(val_parts) + config.set(g.new_code, key, val) + + write_config(config) + return {"status": "success"} + + +class OrderItem(BaseModel): + code: str + name: str + quantity: int + label: str + item_type: str + +class OrderPayload(BaseModel): + customer_name: str + address: str + phone: str + comment: str + order_type: str # e.g., 'noolak', 'pason', 'bullyang', 'ellen', 'mira', 'normal' + no_lid: bool + items: List[OrderItem] + is_ellen: bool = False + total_amount: int = 0 + +def get_current_time_info(): + korea_tz = datetime.timezone(datetime.timedelta(hours=9)) + now = datetime.datetime.now(korea_tz) + time_str = "오후" if now.hour >= 12 else "오전" + day_names_kr = ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"] + day_name = day_names_kr[now.weekday()] + date_str = now.strftime(f"%m월 %d일 ({day_name})") + datetime_str = now.strftime(f"%Y-%m-%d %H:%M:%S ({day_name})") + return date_str, time_str, datetime_str + +def format_phone_number(num_str): + digits = re.sub(r'\D', '', num_str) + if len(digits) == 10: return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" + elif len(digits) == 11: return f"{digits[:3]}-{digits[3:7]}-{digits[7:]}" + elif len(digits) >= 8: return f"{digits[:4]}-{digits[4:]}" + return num_str + + + + + +# ----- Google Sheets Global Cache ----- +gs_client_global = None +worksheet_manual_global = None +worksheet_settings_global = None +worksheet_sms_history_global = None +worksheet_manual_history_global = None + +def get_gspread_client(): + global gs_client_global + if gspread is None: + raise Exception("gspread 라이브러리가 없습니다.") + if gs_client_global is None: + scopes = ['https://www.googleapis.com/auth/spreadsheets'] + creds = Credentials.from_service_account_file(GSPREAD_CRED_FILE, scopes=scopes) + gs_client_global = gspread.authorize(creds) + return gs_client_global + +def get_manual_worksheet(): + global worksheet_manual_global + if worksheet_manual_global is None: + client = get_gspread_client() + ss = client.open_by_key(MANUAL_SPREADSHEET_ID) + ws = ss.get_worksheet(0) + if ws.title == "시트1": + try: + ws.update_title("수동 발주") + except Exception: + pass + worksheet_manual_global = ws + return worksheet_manual_global + +def get_settings_worksheet(): + global worksheet_settings_global + if worksheet_settings_global is None: + client = get_gspread_client() + ss = client.open_by_key(SETTINGS_SPREADSHEET_ID) + try: + worksheet_settings_global = ss.worksheet("설정") + except gspread.exceptions.WorksheetNotFound: + worksheet_settings_global = ss.add_worksheet(title="설정", rows=500, cols=3) + return worksheet_settings_global + +def get_sms_history_worksheet(): + global worksheet_sms_history_global + if worksheet_sms_history_global is None: + client = get_gspread_client() + ss = client.open_by_key(SETTINGS_SPREADSHEET_ID) + try: + worksheet_sms_history_global = ss.worksheet("문자내역") + except gspread.exceptions.WorksheetNotFound: + worksheet_sms_history_global = ss.add_worksheet(title="문자내역", rows=1000, cols=10) + worksheet_sms_history_global.append_row(["일시", "주문유형", "입금액", "주문아이템", "메시지 내용"]) + return worksheet_sms_history_global + +def get_manual_history_worksheet(): + global worksheet_manual_history_global + if worksheet_manual_history_global is None: + client = get_gspread_client() + ss = client.open_by_key(SETTINGS_SPREADSHEET_ID) + try: + worksheet_manual_history_global = ss.worksheet("수동발주내역") + except gspread.exceptions.WorksheetNotFound: + worksheet_manual_history_global = ss.add_worksheet(title="수동발주내역", rows=1000, cols=3) + worksheet_manual_history_global.append_row(["일시", "고객명", "주문금액"]) + return worksheet_manual_history_global + +@app.on_event("startup") +async def startup_event(): + init_db() + try: + print("구글 시트 연동 초기화 중...") + get_manual_worksheet() + get_settings_worksheet() + get_sms_history_worksheet() + get_manual_history_worksheet() + print("구글 시트 연동 완료!") + except Exception as e: + print(f"구글 시트 연동 초기화 실패 (사용 전 권한을 확인하세요): {str(e)}") + + +@app.post("/api/order/submit") +async def submit_order_submit(payload: OrderPayload): + date_str, time_str, current_datetime_str = get_current_time_info() + c_tel = format_phone_number(payload.phone) + + if payload.is_ellen: + title = "수동발주(엘렌)" + else: + title_map = {'noolak': '누락', 'pason': '파손', 'bullyang': '불량', 'normal': '수동발주'} + title = title_map.get(payload.order_type, "수동발주") + + if not payload.customer_name: + title = "재구매" + + try: + worksheet = get_manual_worksheet() + + rows = [] + lines = [] + for i, item in enumerate(payload.items): + lid_suffix = "(뚜껑 없이 통만)" if payload.no_lid and item.item_type == "ORDER_SINGLE" else "" + sub_text = f"{title}, {item.name}{lid_suffix} {item.quantity}개{' 발송' if lid_suffix else ''}" + lines.append(f"{payload.customer_name} 고객님 {item.name}{lid_suffix} {item.quantity}개 발송, {title}") + + c_comment = payload.comment or "빠른배송 부탁드립니다." + c_amount = payload.total_amount if i == len(payload.items) - 1 else "" + row_data = [current_datetime_str, "", c_amount, payload.customer_name, item.code, item.name, item.quantity, payload.address, "", c_tel, c_tel, c_comment, "", title, sub_text] + rows.append(row_data) + + if rows: + worksheet.append_rows(rows, value_input_option='USER_ENTERED') + + clipboard_text = "\r\n".join(lines) + "\r\n" + return {"status": "success", "message": f"{len(rows)}개 항목 구글 시트 입력 및 클립보드 준비 완료.", "clipboard_text": clipboard_text, "timestamp": current_datetime_str} + except Exception as e: + err_str = str(e) + if not err_str or err_str.strip() == "{}": + err_str = repr(e) + raise HTTPException(status_code=500, detail=f"수동발주 시트 접근 에러 (스프레드시트에 이메일 편집 권한을 추가했는지 확인해주세요!): {err_str}") + +@app.post("/api/sheet/download_and_clear") +async def download_and_clear_sheet(): + try: + scopes = ['https://www.googleapis.com/auth/spreadsheets', 'https://www.googleapis.com/auth/drive'] + creds = Credentials.from_service_account_file(GSPREAD_CRED_FILE, scopes=scopes) + + import google.auth.transport.requests + request = google.auth.transport.requests.Request() + creds.refresh(request) + access_token = creds.token + + worksheet = get_manual_worksheet() + sheet_gid = worksheet.id + + url = f"https://docs.google.com/spreadsheets/export?id={MANUAL_SPREADSHEET_ID}&exportFormat=xlsx" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {access_token}"}) + with urllib.request.urlopen(req) as response: + content = response.read() + + korea_tz = datetime.timezone(datetime.timedelta(hours=9)) + now = datetime.datetime.now(korea_tz) + weekdays = ["월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일"] + weekday_str = weekdays[now.weekday()] + date_str = f"{now.strftime('%m')}월 {now.strftime('%d')}일 ({weekday_str})" + filename = f"{date_str} 5 수동발주.xlsx" + + # Clear sheet values instantly + worksheet.batch_clear(["A2:Z10000"]) + + # Clear formatting (colors, borders, etc) from row 2 downwards + body = { + "requests": [ + { + "updateCells": { + "range": { + "sheetId": worksheet.id, + "startRowIndex": 1 + }, + "fields": "userEnteredFormat" + } + } + ] + } + worksheet.spreadsheet.batch_update(body) + + encoded_filename = urllib.parse.quote(filename) + headers = { + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + } + return StreamingResponse( + io.BytesIO(content), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers=headers + ) + except Exception as e: + err_str = str(e) + if not err_str or err_str.strip() == "{}": + err_str = repr(e) + raise HTTPException(status_code=500, detail=f"저장 및 삭제 실패: {err_str}") + + + +@app.get("/api/sms/history") +async def get_history(): + local_history = {} + if os.path.exists(SMS_HISTORY_FILE): + with open(SMS_HISTORY_FILE, 'r', encoding='utf-8') as f: + for line in f: + if line.strip(): + try: + item = json.loads(line) + if "timestamp" in item: + local_history[item["timestamp"]] = item + except: + pass + + history = [] + try: + ws = get_sms_history_worksheet() + rows = ws.get_all_values() + if rows and len(rows) > 1: + for row in rows[1:]: + if not row or not row[0].strip(): + continue + timestamp = row[0] + + if timestamp in local_history: + history.append(local_history[timestamp]) + else: + order_type = row[1] if len(row) > 1 else "" + deposit_amt = row[2] if len(row) > 2 else "" + items_str = row[3] if len(row) > 3 else "" + preview_text = row[4] if len(row) > 4 else "" + preview_text = preview_text.replace(" ", "\n") + + history.append({ + "timestamp": timestamp, + "orderType": order_type, + "deposit_amount_str": deposit_amt, + "items_str": items_str, + "previewText": preview_text + }) + return history + except Exception as e: + print(f"구글 시트에서 기록을 읽어오는 중 에러: {e}") + + if os.path.exists(SMS_HISTORY_FILE): + return [json.loads(line) for line in open(SMS_HISTORY_FILE, 'r', encoding='utf-8') if line.strip()] + return [] + +@app.post("/api/sms/history") +async def add_history(data: dict): + with open(SMS_HISTORY_FILE, 'a', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False) + f.write('\n') + + # 구글 시트에 기록 + try: + ws = get_sms_history_worksheet() + + # 주문 품목 텍스트화 + items_str = "" + items = data.get("items", []) + if items: + items_str = ", ".join([f"{i.get('label', '')}[{i.get('code', '')}] {i.get('qty', 0)}개" for i in items]) + + # 행 높이가 길어지지 않도록 줄바꿈을 띄어쓰기로 변경 + preview_text = data.get("previewText", "").replace("\n", " ").replace("\r", "") + + row = [ + data.get("timestamp", ""), + data.get("orderType", ""), + data.get("deposit_amount_str", ""), + items_str, + preview_text + ] + ws.append_row(row, value_input_option='USER_ENTERED') + except Exception as e: + print(f"구글 시트 문자내역 기록 실패: {e}") + + return {"status": "success"} + +@app.delete("/api/sms/history") +async def clear_history(): + if os.path.exists(SMS_HISTORY_FILE): + os.remove(SMS_HISTORY_FILE) + + try: + ws = get_sms_history_worksheet() + ws.batch_clear(["A2:Z10000"]) + except Exception as e: + print(f"구글 시트 문자내역 전체 삭제 실패: {e}") + + return {"status": "success"} + +@app.delete("/api/sms/history/{index}") +async def delete_history_item(index: int): + try: + ws = get_sms_history_worksheet() + rows = ws.get_all_values() + timestamp_to_delete = None + if len(rows) > index + 1: + timestamp_to_delete = rows[index + 1][0] if len(rows[index+1]) > 0 else None + + ws.delete_rows(index + 2) + + if timestamp_to_delete and os.path.exists(SMS_HISTORY_FILE): + local_history = [] + with open(SMS_HISTORY_FILE, 'r', encoding='utf-8') as f: + for line in f: + if line.strip(): + try: + local_history.append(json.loads(line)) + except: + pass + + local_history = [item for item in local_history if item.get("timestamp") != timestamp_to_delete] + + with open(SMS_HISTORY_FILE, 'w', encoding='utf-8') as f: + for entry in local_history: + json.dump(entry, f, ensure_ascii=False) + f.write('\n') + except Exception as e: + print(f"구글 시트 문자내역 항목 삭제 실패: {e}") + + return {"status": "success"} + + +import sys + +@app.get("/api/manual/history") +async def get_manual_history(): + korea_tz = datetime.timezone(datetime.timedelta(hours=9)) + today_prefix = datetime.datetime.now(korea_tz).strftime("%Y-%m-%d") + + local_history = {} + if os.path.exists(MANUAL_HISTORY_FILE): + with open(MANUAL_HISTORY_FILE, 'r', encoding='utf-8') as f: + for line in f: + if line.strip(): + try: + item = json.loads(line) + if item.get("timestamp", "").startswith(today_prefix): + local_history[item["timestamp"]] = item + except: + pass + + history = [] + try: + ws = get_manual_history_worksheet() + rows = ws.get_all_values() + if rows and len(rows) > 1: + for row in rows[1:]: + if not row or not row[0].strip(): + continue + timestamp = row[0] + if not timestamp.startswith(today_prefix): + continue + + if timestamp in local_history: + history.append(local_history[timestamp]) + else: + customer_name = row[1] if len(row) > 1 else "" + total_amount_str = str(row[2]).replace(",", "") if len(row) > 2 else "0" + total_amount = int(total_amount_str) if total_amount_str.lstrip('-').isdigit() else total_amount_str + history.append({ + "timestamp": timestamp, + "customer_name": customer_name, + "total_amount": total_amount + }) + return history + except Exception as e: + print(f"구글 시트에서 수동발주 기록을 읽어오는 중 에러: {e}") + + return list(local_history.values()) + +@app.post("/api/manual/history") +async def add_manual_history(data: dict): + local_history = [] + korea_tz = datetime.timezone(datetime.timedelta(hours=9)) + today_prefix = datetime.datetime.now(korea_tz).strftime("%Y-%m-%d") + + if os.path.exists(MANUAL_HISTORY_FILE): + with open(MANUAL_HISTORY_FILE, 'r', encoding='utf-8') as f: + for line in f: + if line.strip(): + try: + item = json.loads(line) + if item.get("timestamp", "").startswith(today_prefix): + local_history.append(item) + except: + pass + + local_history.append(data) + + with open(MANUAL_HISTORY_FILE, 'w', encoding='utf-8') as f: + for entry in local_history: + json.dump(entry, f, ensure_ascii=False) + f.write('\n') + + try: + ws = get_manual_history_worksheet() + row = [ + data.get("timestamp", ""), + data.get("customer_name", ""), + data.get("total_amount", 0) + ] + ws.append_row(row, value_input_option='USER_ENTERED') + except Exception as e: + print(f"구글 시트 수동발주내역 기록 실패: {e}") + + return {"status": "success"} + +@app.delete("/api/manual/history/{timestamp}") +async def delete_manual_history(timestamp: str): + # 구글 시트에서 삭제 + try: + ws = get_manual_worksheet() + rows = ws.get_all_values() + start_idx = None + end_idx = None + for i, row in enumerate(rows): + if row and row[0] == timestamp: + if start_idx is None: + start_idx = i + 1 + end_idx = i + 1 + if start_idx: + ws.delete_rows(start_idx, end_idx) + except Exception as e: + print(f"구글 시트 수동발주 삭제 실패: {e}") + + try: + ws2 = get_manual_history_worksheet() + rows2 = ws2.get_all_values() + for i, row in enumerate(rows2): + if row and row[0] == timestamp: + ws2.delete_rows(i + 1) + break + except Exception as e: + print(f"구글 시트 수동발주내역 시트 삭제 실패: {e}") + + # 로컬에서 삭제 + if os.path.exists(MANUAL_HISTORY_FILE): + local_history = [] + with open(MANUAL_HISTORY_FILE, 'r', encoding='utf-8') as f: + for line in f: + if line.strip(): + try: + local_history.append(json.loads(line)) + except: + pass + local_history = [item for item in local_history if item.get("timestamp") != timestamp] + with open(MANUAL_HISTORY_FILE, 'w', encoding='utf-8') as f: + for entry in local_history: + json.dump(entry, f, ensure_ascii=False) + f.write('\n') + return {"status": "success"} + +@app.get("/api/fonts") +async def get_fonts(): + fonts = set() + if sys.platform == 'win32': + try: + import winreg + for hkey in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]: + try: + key = winreg.OpenKey(hkey, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts") + for i in range(0, winreg.QueryInfoKey(key)[1]): + name, _, _ = winreg.EnumValue(key, i) + font_name = name.split(' (')[0].strip() + if font_name: + fonts.add(font_name) + winreg.CloseKey(key) + except Exception: + pass + except Exception as e: + print(f"폰트 불러오기 오류: {e}") + + if not fonts: + fonts_list = ["Inter", "Malgun Gothic", "Arial", "sans-serif"] + else: + fonts_list = sorted(list(fonts)) + return fonts_list + +@app.post("/api/gift/settings") +async def save_gift_settings(payload: dict): + # 하위 호환성 (기존 rules만 오는 경우) + if "rules" in payload: + rules = payload["rules"] + app_settings = payload.get("app_settings", {}) + else: + rules = payload + app_settings = {} + + config = read_config() + section = "SMS_GIFT_RULES" + if section not in config: + config.add_section(section) + else: + config.remove_section(section) + config.add_section(section) + + for k, v in rules.items(): + if v: + config.set(section, k, str(v)) + + if app_settings: + app_section = "APP_SETTINGS" + if app_section not in config: + config.add_section(app_section) + else: + config.remove_section(app_section) + config.add_section(app_section) + + for k, v in app_settings.items(): + if v: + config.set(app_section, k, str(v)) + + write_config(config) + return {"status": "success"} + +# ----- Code DB (SQLite) Data Definition ----- +DB_PATH = "codes.db" + +def init_db(): + conn = psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) + cursor = conn.cursor() + + # 단품 테이블 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS single_items ( + item_code TEXT PRIMARY KEY, + sabangnet_code TEXT, + name TEXT + ) + ''') + # 세트 테이블 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS set_items ( + item_code TEXT PRIMARY KEY, + sabangnet_code TEXT, + name TEXT + ) + ''') + # 세트 구성품 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS set_components ( + set_code TEXT, + single_code TEXT, + quantity INTEGER, + PRIMARY KEY (set_code, single_code), + FOREIGN KEY(set_code) REFERENCES set_items(item_code) ON DELETE CASCADE, + FOREIGN KEY(single_code) REFERENCES single_items(item_code) ON DELETE CASCADE + ) + ''') + conn.commit() + conn.close() + + # 반품 데이터베이스 (return_db) 초기화 시도 + try: + conn_return = psycopg2.connect(**RETURN_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) + cursor_return = conn_return.cursor() + + # 반품 신청 테이블 + cursor_return.execute(''' + CREATE TABLE IF NOT EXISTS return_requests ( + id SERIAL PRIMARY KEY, + request_date VARCHAR(20), + request_type VARCHAR(50), + receiver_name VARCHAR(100), + address TEXT, + phone VARCHAR(50), + tracking_no VARCHAR(100), + mall VARCHAR(100), + remarks TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 반품 입고 테이블 + cursor_return.execute(''' + CREATE TABLE IF NOT EXISTS return_receivings ( + id SERIAL PRIMARY KEY, + receive_date VARCHAR(20), + tracking_no VARCHAR(100), + mall VARCHAR(100), + receiver_name VARCHAR(100), + phone VARCHAR(50), + remarks TEXT, + receive_status VARCHAR(50) DEFAULT '환불 대기', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + # 혹시 기존에 생성된 테이블에 receive_status 컬럼이 없다면 추가 + try: + cursor_return.execute('ALTER TABLE return_receivings ADD COLUMN IF NOT EXISTS receive_status VARCHAR(50) DEFAULT \'환불 대기\'') + cursor_return.execute('ALTER TABLE return_receivings ADD COLUMN IF NOT EXISTS address TEXT') + except: + pass + conn_return.commit() + conn_return.close() + except Exception as e: + print(f"[Warning] return_db 초기화 실패. 데이터베이스가 아직 생성되지 않았거나 권한 문제가 있을 수 있습니다: {e}") + +def get_db_conn(): + conn = psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) + return conn + +def get_return_db_conn(): + conn = psycopg2.connect(**RETURN_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) + return conn + +def get_order_db_conn(): + conn = psycopg2.connect(**ORDER_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) + return conn + +# Code API Models +class SingleItemAdd(BaseModel): + item_code: str + sabangnet_code: str + name: str + +class SetComponentData(BaseModel): + single_code: str + quantity: int + +class SetItemAdd(BaseModel): + item_code: str + components: List[SetComponentData] + sabangnet_code: str = "" + name: str = "" + +@app.get("/api/codes/single") +async def get_single_items(): + conn = get_db_conn() + cursor = conn.cursor() + cursor.execute("SELECT * FROM single_items ORDER BY item_code ASC") + rows = cursor.fetchall() + conn.close() + return {"status": "success", "data": [dict(r) for r in rows]} + +@app.post("/api/codes/single") +async def add_single_item(payload: SingleItemAdd): + conn = get_db_conn() + cursor = conn.cursor() + try: + cursor.execute( + "INSERT INTO single_items (item_code, sabangnet_code, name) VALUES (%s, %s, %s)", + (payload.item_code, payload.sabangnet_code, payload.name) + ) + conn.commit() + return {"status": "success"} + except psycopg2.IntegrityError: + conn.close() + raise HTTPException(status_code=400, detail="이미 존재하는 단품 코드입니다.") + finally: + if conn: + conn.close() + +@app.put("/api/codes/single/{item_code}") +async def update_single_item(item_code: str, payload: SingleItemAdd): + conn = get_db_conn() + cursor = conn.cursor() + cursor.execute( + "UPDATE single_items SET sabangnet_code=%s, name=%s WHERE item_code=%s", + (payload.sabangnet_code, payload.name, item_code) + ) + conn.commit() + conn.close() + return {"status": "success"} + +@app.delete("/api/codes/single/{item_code}") +async def delete_single_item(item_code: str): + conn = get_db_conn() + cursor = conn.cursor() + cursor.execute("DELETE FROM single_items WHERE item_code=%s", (item_code,)) + conn.commit() + conn.close() + return {"status": "success"} + + +@app.get("/api/codes/set") +async def get_set_items(): + conn = get_db_conn() + cursor = conn.cursor() + cursor.execute("SELECT * FROM set_items ORDER BY item_code ASC") + sets = cursor.fetchall() + + result = [] + for s in sets: + s_dict = dict(s) + cursor.execute("SELECT single_code, quantity FROM set_components WHERE set_code=%s", (s_dict["item_code"],)) + comps = cursor.fetchall() + s_dict["components"] = [dict(c) for c in comps] + result.append(s_dict) + + conn.close() + return {"status": "success", "data": result} + +@app.post("/api/codes/set") +async def add_set_item(payload: SetItemAdd): + conn = get_db_conn() + cursor = conn.cursor() + try: + cursor.execute( + "INSERT INTO set_items (item_code, sabangnet_code, name) VALUES (%s, %s, %s)", + (payload.item_code, payload.sabangnet_code, payload.name) + ) + for comp in payload.components: + cursor.execute( + "INSERT INTO set_components (set_code, single_code, quantity) VALUES (%s, %s, %s)", + (payload.item_code, comp.single_code, comp.quantity) + ) + conn.commit() + return {"status": "success"} + except psycopg2.IntegrityError: + conn.rollback() + raise HTTPException(status_code=400, detail="중복되는 세트 코드이거나 구성품에 존재하지 않는 단품 코드가 있습니다.") + finally: + if conn: conn.close() + +@app.put("/api/codes/set/{item_code}") +async def update_set_item(item_code: str, payload: SetItemAdd): + conn = get_db_conn() + cursor = conn.cursor() + try: + cursor.execute("UPDATE set_items SET sabangnet_code=%s, name=%s WHERE item_code=%s", (payload.sabangnet_code, payload.name, item_code)) + cursor.execute("DELETE FROM set_components WHERE set_code=%s", (item_code,)) + for comp in payload.components: + cursor.execute( + "INSERT INTO set_components (set_code, single_code, quantity) VALUES (%s, %s, %s)", + (item_code, comp.single_code, comp.quantity) + ) + conn.commit() + return {"status": "success"} + except psycopg2.IntegrityError: + conn.rollback() + raise HTTPException(status_code=400, detail="구성품 업데이트 중 오류 발생. 유효한 단품 코드인지 확인해주세요.") + finally: + if conn: conn.close() + +@app.delete("/api/codes/set/{item_code}") +async def delete_set_item(item_code: str): + conn = get_db_conn() + cursor = conn.cursor() + cursor.execute("DELETE FROM set_items WHERE item_code=%s", (item_code,)) + conn.commit() + conn.close() + return {"status": "success"} + +# ----- 스마트스토어 & 카페24 자동발주 공통 ----- + +EXCEL_COLUMNS = [ + '주문날짜', '번호', '주문번호', '수령인명', '상품코드', '상품명', '수량', + '주소', '우편번호', '수령인 전화번호', '수령인 휴대폰', '배송시 요구사항', + '송장번호', '쇼핑몰명', '비고', '주문번호(쇼핑몰)', '주문목록' +] + +def generate_order_excel(data, numeric_date, filename, is_cafe24=False, return_bytes=False): + df = pd.DataFrame(data, columns=EXCEL_COLUMNS) + + address_replacements = { + "경기 ": "경기도 ", + "강원도 ": "강원특별자치도 ", + "경남 ": "경상남도 ", + "경북 ": "경상북도 ", + "광주 ": "광주광역시 ", + "대구 ": "대구광역시 ", + "대전 ": "대전광역시 ", + "부산 ": "부산광역시 ", + "서울 ": "서울특별시 ", + "울산 ": "울산광역시 ", + "인천 ": "인천광역시 ", + "전남 ": "전라남도 ", + "전북 ": "전라북도 ", + "충남 ": "충청남도 ", + "충북 ": "충청북도 " + } + + if '주소' in df.columns: + for old_val, new_val in address_replacements.items(): + df['주소'] = df['주소'].str.replace(old_val, new_val, regex=False) + + df = df.sort_values(by=['주소', '주문번호']).reset_index(drop=True) + df['번호'] = range(1, len(df) + 1) + + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Sheet1') + + worksheet = writer.sheets['Sheet1'] + from openpyxl.utils import get_column_letter + from openpyxl.styles import Alignment, PatternFill, Font + + if is_cafe24: + header_fill = PatternFill(start_color="002060", end_color="002060", fill_type="solid") + else: + header_fill = PatternFill(start_color="009900", end_color="009900", fill_type="solid") + + header_font = Font(name="나눔고딕", bold=True, color="FFFFFF") + data_font = Font(name="나눔고딕") + center_alignment = Alignment(horizontal="center", vertical="center") + + for row in worksheet.iter_rows(min_row=1, max_row=worksheet.max_row, min_col=1, max_col=17): + for cell in row: + if cell.row == 1: + cell.fill = header_fill + cell.font = header_font + cell.alignment = center_alignment + else: + cell.font = data_font + + duplicate_groups = {} + for i, rowTuple in df.iterrows(): + key = (rowTuple['수령인명'], rowTuple['주소']) + if key not in duplicate_groups: + duplicate_groups[key] = [] + duplicate_groups[key].append(i + 2) + + color_60_lighter = PatternFill(start_color="B4C6E7", end_color="B4C6E7", fill_type="solid") + color_40_lighter = PatternFill(start_color="FFD966", end_color="FFD966", fill_type="solid") + current_toggle = True + + for key, row_indices in duplicate_groups.items(): + if len(row_indices) > 1: + fill = color_60_lighter if current_toggle else color_40_lighter + for r_idx in row_indices: + for c_idx in range(1, 18): + worksheet.cell(row=r_idx, column=c_idx).fill = fill + current_toggle = not current_toggle + + worksheet.freeze_panes = "H2" + worksheet.auto_filter.ref = worksheet.dimensions + + for idx, col in enumerate(df.columns): + col_letter = get_column_letter(idx + 1) + max_length = 0 + 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 + + adjusted_width = max_length + 2 + if adjusted_width > 60: + adjusted_width = 60 + worksheet.column_dimensions[col_letter].width = adjusted_width + + output.seek(0) + + encoded_filename = urllib.parse.quote(filename) + if return_bytes: + return output.getvalue(), encoded_filename + + headers = { + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}" + } + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers=headers + ) + +def get_all_code_to_name_from_db(): + import psycopg2 + import psycopg2.extras + import os + code_to_name = {} + try: + conn = psycopg2.connect(**DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) + cur = conn.cursor() + cur.execute("SELECT item_code, name FROM single_items") + for row in cur.fetchall(): + code_to_name[row[0]] = row[1] + cur.execute("SELECT item_code, name FROM set_items") + for row in cur.fetchall(): + code_to_name[row[0]] = row[1] + conn.close() + except Exception as e: + print(f"DB 매핑 오류: {e}") + return code_to_name + +@app.post("/api/smartstore/start_download") +async def start_smartstore_download(background_tasks: BackgroundTasks, confirm: bool = Query(False)): + task_id = str(uuid.uuid4()) + task_store[task_id] = { + "status": "processing", + "current": 0, + "total": 0, + "file_bytes": None, + "filename": None, + "error": None + } + + def run_task(tid): + try: + token = get_access_token() + statuses = get_payment_done_orders(token) + + if not statuses: + task_store[tid]["status"] = "error" + task_store[tid]["error"] = "새로운 결제완료(배송대기) 주문이 없습니다." + task_store[tid]["status_code"] = 404 + return + + po_ids = [s.get('productOrderId') for s in statuses if s.get('productOrderId')] + task_store[tid]["total"] = len(po_ids) + + all_order_details = [] + chunk_size = 100 + for i in range(0, len(po_ids), chunk_size): + chunk = po_ids[i:i+chunk_size] + details = get_product_order_details(token, chunk) + all_order_details.extend(details) + task_store[tid]["current"] += len(chunk) + + if confirm: + for i in range(0, len(po_ids), chunk_size): + chunk = po_ids[i:i+chunk_size] + confirm_orders(token, chunk) + + data = [] + now = datetime.datetime.now() + numeric_date = int(now.strftime('%Y%m%d')) + + product_cache = {} + code_to_name = get_all_code_to_name_from_db() + + for idx, detail in enumerate(all_order_details): + po = detail.get('productOrder', {}) + order = detail.get('order', {}) + shippingAddress = po.get('shippingAddress', {}) + + product_id = po.get('productId') + option_code = str(po.get('optionCode', '')) + seller_code = '' + + if product_id: + if product_id not in product_cache: + product_cache[product_id] = get_product_details(token, product_id) + + p_data = product_cache[product_id] + seller_code = p_data.get('originProduct', {}).get('detailAttribute', {}).get('sellerCodeInfo', {}).get('sellerManagementCode', '') + + if not seller_code and option_code: + option_info = p_data.get('originProduct', {}).get('detailAttribute', {}).get('optionInfo', {}) + found_opt_code = '' + for opt in option_info.get('optionCombinations', []): + if str(opt.get('id', '')) == option_code: + found_opt_code = opt.get('sellerManagerCode', '') + break + if not found_opt_code: + for opt in option_info.get('optionStandards', []): + if str(opt.get('id', '')) == option_code: + found_opt_code = opt.get('sellerManagerCode', '') + break + if found_opt_code: + seller_code = found_opt_code + + final_quantity = int(po.get('quantity', 1)) + if seller_code and '_' in seller_code: + parts = seller_code.rsplit('_', 1) + if len(parts) == 2 and parts[1].isdigit(): + seller_code = parts[0] + final_quantity = final_quantity * int(parts[1]) + + base_address = shippingAddress.get('baseAddress', '') + detailed_address = shippingAddress.get('detailedAddress', '') + full_address = f"{base_address} {detailed_address}".strip() + + final_item_code = seller_code or po.get('optionManageCode', '') or po.get('sellerProductCode', '') + original_product_name = po.get('productName', '') + product_name = code_to_name.get(final_item_code, original_product_name) + + option_info = po.get('productOption', '') + if option_info: + order_list = f"{product_name} [{option_info}]=/{po.get('quantity', 1)}" + else: + order_list = f"{product_name}=/{po.get('quantity', 1)}" + + row = { + '주문날짜': numeric_date, + '번호': idx + 1, + '주문번호': po.get('productOrderId', ''), + '수령인명': shippingAddress.get('name', ''), + '상품코드': final_item_code, + '상품명': product_name, + '수량': final_quantity, + '주소': full_address, + '우편번호': '', + '수령인 전화번호': shippingAddress.get('tel1', ''), + '수령인 휴대폰': shippingAddress.get('tel1', ''), + '배송시 요구사항': po.get('shippingMemo', ''), + '송장번호': '', + '쇼핑몰명': '스마트스토어', + '비고': '', + '주문번호(쇼핑몰)': order.get('orderId', ''), + '주문목록': order_list + } + data.append(row) + + weekdays = ['월요일', '화요일', '수요일', '목요일', '금요일', '토요일', '일요일'] + weekday_str = weekdays[now.weekday()] + if now.hour >= 15: + filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 1 미라네 스마트 스토어-3시.xlsx" + else: + filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 1 미라네 스마트 스토어-오전.xlsx" + + file_bytes, enc_name = generate_order_excel(data, numeric_date, filename, return_bytes=True) + + task_store[tid]["current"] = task_store[tid]["total"] + task_store[tid]["file_bytes"] = file_bytes + task_store[tid]["filename"] = enc_name + task_store[tid]["status"] = "completed" + + except Exception as e: + task_store[tid]["status"] = "error" + task_store[tid]["error"] = str(e) + task_store[tid]["status_code"] = 500 + + background_tasks.add_task(run_task, task_id) + return {"status": "success", "task_id": task_id} + + +@app.post("/api/upload_invoices") +async def upload_invoices(file: UploadFile = File(...), deliveryCompanyCode: str = Form("CJGLS")): + try: + content = await file.read() + + if file.filename.endswith('.xls'): + try: + df = pd.read_excel(io.BytesIO(content), engine='xlrd') + except Exception: + try: + df = pd.read_html(io.BytesIO(content))[0] + except Exception as e2: + return JSONResponse({"status": "error", "message": f"xls 파일 파싱 실패: {str(e2)}"}, status_code=400) + else: + df = pd.read_excel(io.BytesIO(content), engine='openpyxl') + + if '주문번호(쇼핑몰)' not in df.columns or '송장번호' not in df.columns: + return JSONResponse({"status": "error", "message": "엑셀에 '주문번호(쇼핑몰)' 또는 '송장번호' 컬럼이 없습니다."}, status_code=400) + + df = df[df['송장번호'].notna() & (df['송장번호'] != '')] + + if df.empty: + return JSONResponse({"status": "error", "message": "송장번호가 입력된 데이터가 없습니다."}, status_code=400) + + dispatch_list = [] + for index, row in df.iterrows(): + po_id = str(row['주문번호(쇼핑몰)']).replace('.0', '').strip() + tracking_num = str(row['송장번호']).replace('.0', '').strip() + + if ',' in tracking_num: + tracking_num = tracking_num.split(',')[0].strip() + + if po_id and tracking_num and tracking_num != 'nan': + dispatch_list.append({ + "productOrderId": po_id, + "deliveryMethod": "DELIVERY", + "deliveryCompanyCode": deliveryCompanyCode, + "trackingNumber": tracking_num + }) + + if not dispatch_list: + return JSONResponse({"status": "error", "message": "유효한 송장 매핑 데이터가 없습니다."}, status_code=400) + + token = get_access_token() + success_count = 0 + fail_list = [] + + chunk_size = 100 + for i in range(0, len(dispatch_list), chunk_size): + chunk = dispatch_list[i:i+chunk_size] + result = dispatch_orders(token, chunk) + + results = result.get('data', {}).get('successProductOrderIds', []) + success_count += len(results) + + fails = result.get('data', {}).get('failProductOrderInfos', []) + for f in fails: + fail_list.append(f) + + return { + "status": "success", + "success_count": success_count, + "fail_count": len(fail_list), + "fails": fail_list, + "total_processed": len(dispatch_list) + } + + except Exception as e: + return JSONResponse({"status": "error", "message": str(e)}, status_code=500) + +from fastapi.responses import RedirectResponse + +@app.get("/api/cafe24/login") +async def cafe24_login(): + url = cafe24_api.get_auth_url() + return RedirectResponse(url) + +@app.get("/api/cafe24/callback") +@app.get("/admin/oauth/callback") +async def cafe24_callback(code: str): + try: + cafe24_api.request_new_token(code) + return HTMLResponse("") + except Exception as e: + return HTMLResponse(f"") + +task_store = {} + +@app.post("/api/cafe24/start_download") +async def start_cafe24_download(background_tasks: BackgroundTasks): + task_id = str(uuid.uuid4()) + task_store[task_id] = { + "status": "processing", + "current": 0, + "total": 0, + "file_bytes": None, + "filename": None, + "error": None + } + + def run_task(tid): + try: + total_count = cafe24_api.get_cafe24_orders_count(status="N20") + task_store[tid]["total"] = total_count + + def progress_callback(current): + task_store[tid]["current"] = current + + orders = cafe24_api.get_cafe24_orders(status="N20", progress_callback=progress_callback) + if not orders: + task_store[tid]["status"] = "error" + task_store[tid]["error"] = "새로운 배송준비중 주문이 없습니다." + return + + data = [] + now = datetime.datetime.now() + numeric_date = int(now.strftime('%Y%m%d')) + code_to_name = get_all_code_to_name_from_db() + exchange_customers = [] + + for idx, order in enumerate(orders): + items = order.get('items', []) + receivers = order.get('receivers', []) + receiver = receivers[0] if receivers else {} + + has_exchange = False + for item in items: + if item.get('exchange_request_date') or item.get('exchange_date') or str(item.get('status_code', '')).startswith('E'): + has_exchange = True + break + + if has_exchange: + exchange_customers.append({ + "name": receiver.get('name', ''), + "phone": receiver.get('cellphone') or receiver.get('phone', ''), + "order_no": order.get('order_id', '') + }) + continue + + address_full = receiver.get('address_full', '') + if not address_full: + address_full = f"{receiver.get('address1', '')} {receiver.get('address2', '')}".strip() + full_address = address_full + + for item in items: + if not item.get('status_code', '').startswith('N'): + continue + + seller_code = item.get('custom_variant_code') or item.get('item_code') or item.get('custom_product_code') or item.get('product_code', '') + + final_quantity = int(item.get('quantity', 1)) + if seller_code and '_' in seller_code: + parts = seller_code.rsplit('_', 1) + if len(parts) == 2 and parts[1].isdigit(): + seller_code = parts[0] + final_quantity = final_quantity * int(parts[1]) + + original_product_name = item.get('product_name', '') + product_name = code_to_name.get(seller_code, original_product_name) + original_quantity = item.get('quantity', 1) + + option_info = item.get('option_value', '') + if option_info: + order_list = f"{original_product_name}/{option_info}/{original_quantity}" + else: + order_list = f"{original_product_name}/{original_quantity}" + + row = { + '주문날짜': numeric_date, + '번호': 0, + '주문번호': item.get('order_item_code') or order.get('order_id', ''), + '수령인명': receiver.get('name', ''), + '상품코드': seller_code, + '상품명': product_name, + '수량': final_quantity, + '주소': full_address, + '우편번호': receiver.get('zipcode', ''), + '수령인 전화번호': receiver.get('phone', ''), + '수령인 휴대폰': receiver.get('cellphone', ''), + '배송시 요구사항': receiver.get('shipping_message', ''), + '송장번호': '', + '쇼핑몰명': '자사몰', + '비고': order.get('admin_additional_amount_name', ''), + '주문번호(쇼핑몰)': order.get('order_id', ''), + '주문목록': order_list + } + data.append(row) + + weekdays = ['월요일', '화요일', '수요일', '목요일', '금요일', '토요일', '일요일'] + weekday_str = weekdays[now.weekday()] + + if now.hour >= 15: + filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 0 미라네 자사몰 발주-3시.xlsx" + else: + filename = f"{now.strftime('%m월 %d일')} ({weekday_str}) 0 미라네 자사몰 발주-오전.xlsx" + + file_bytes, enc_name = generate_order_excel(data, numeric_date, filename, is_cafe24=True, return_bytes=True) + + task_store[tid]["current"] = task_store[tid]["total"] + task_store[tid]["file_bytes"] = file_bytes + task_store[tid]["filename"] = enc_name + task_store[tid]["exchange_customers"] = exchange_customers + task_store[tid]["status"] = "completed" + + except Exception as e: + status_code = 500 + if "Authentication" in str(e) or "Please authenticate" in str(e) or "expired" in str(e): + status_code = 401 + task_store[tid]["status"] = "error" + task_store[tid]["error"] = str(e) + task_store[tid]["status_code"] = status_code + + background_tasks.add_task(run_task, task_id) + return {"status": "success", "task_id": task_id} + +@app.get("/api/tasks/progress/{task_id}") +async def get_task_progress(task_id: str): + if task_id not in task_store: + return JSONResponse({"status": "error", "message": "작업을 찾을 수 없습니다."}, status_code=404) + + task_data = task_store[task_id] + if task_data["status"] == "error": + status_code = task_data.get("status_code", 500) + return JSONResponse({"status": "error", "message": task_data["error"]}, status_code=status_code) + + return { + "status": task_data["status"], + "current": task_data["current"], + "total": task_data["total"] + } + +@app.get("/api/tasks/download_file/{task_id}") +async def download_task_file(task_id: str): + if task_id not in task_store or task_store[task_id]["status"] != "completed": + return JSONResponse({"status": "error", "message": "파일이 아직 준비되지 않았거나 만료되었습니다."}, status_code=404) + + task_data = task_store[task_id] + output = io.BytesIO(task_data["file_bytes"]) + enc_name = task_data["filename"] + + headers = { + "Content-Disposition": f"attachment; filename*=UTF-8''{enc_name}" + } + + # 메모리 정리 (1회 다운로드 후 삭제) + del task_store[task_id] + + return StreamingResponse( + output, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers=headers + ) + + +@app.post("/api/cafe24/upload_invoices") +async def cafe24_upload_invoices(file: UploadFile = File(...), deliveryCompanyCode: str = Form("0004")): + try: + content = await file.read() + if file.filename.endswith('.xls'): + try: + df = pd.read_excel(io.BytesIO(content), engine='xlrd') + except: + df = pd.read_html(io.BytesIO(content))[0] + else: + df = pd.read_excel(io.BytesIO(content), engine='openpyxl') + + if '주문번호(쇼핑몰)' not in df.columns or '송장번호' not in df.columns: + return JSONResponse({"status": "error", "message": "엑셀에 '주문번호(쇼핑몰)' 또는 '송장번호' 컬럼이 없습니다."}, status_code=400) + + df = df[df['송장번호'].notna() & (df['송장번호'] != '')] + if df.empty: + return JSONResponse({"status": "error", "message": "송장번호가 입력된 데이터가 없습니다."}, status_code=400) + + dispatch_list = [] + for index, row in df.iterrows(): + po_id = str(row['주문번호(쇼핑몰)']).replace('.0', '').strip() + tracking_num = str(row['송장번호']).replace('.0', '').strip() + if ',' in tracking_num: + tracking_num = tracking_num.split(',')[0].strip() + + if po_id and tracking_num and tracking_num != 'nan': + dispatch_list.append({ + "order_id": po_id, + "tracking_no": tracking_num, + "shipping_company_code": deliveryCompanyCode + }) + + results = cafe24_api.update_cafe24_tracking(dispatch_list) + return { + "status": "success", + "success_count": len(results["success"]), + "fail_count": len(results["fail"]), + "fails": results["fail"], + "total_processed": len(dispatch_list) + } + except Exception as e: + status_code = 500 + if "Authentication" in str(e) or "Please authenticate" in str(e) or "expired" in str(e): + status_code = 401 + return JSONResponse({"status": "error", "message": str(e)}, status_code=status_code) + +# ========================================== +# 반품 관리 API +# ========================================== + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("APP_PORT", "8002"))) diff --git a/naver_api.py b/naver_api.py new file mode 100644 index 0000000..1b78619 --- /dev/null +++ b/naver_api.py @@ -0,0 +1,155 @@ +import os +import requests +import time +import bcrypt +import base64 + +CLIENT_ID = os.getenv("NAVER_CLIENT_ID", "") +CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET", "") + +def get_access_token(): + timestamp = str(int(time.time() * 1000)) + pwd = f"{CLIENT_ID}_{timestamp}" + + hashed = bcrypt.hashpw(pwd.encode('utf-8'), CLIENT_SECRET.encode('utf-8')) + client_secret_sign = base64.urlsafe_b64encode(hashed).decode('utf-8') + + url = "https://api.commerce.naver.com/external/v1/oauth2/token" + payload = { + "client_id": CLIENT_ID, + "timestamp": timestamp, + "client_secret_sign": client_secret_sign, + "grant_type": "client_credentials", + "type": "SELF" + } + + # URL encoded post + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + + response = requests.post(url, data=payload, headers=headers) + if response.status_code == 200: + return response.json().get('access_token') + else: + raise Exception(f"Failed to get token: {response.text}") + +def get_payment_done_orders(token): + url = "https://api.commerce.naver.com/external/v1/pay-order/seller/product-orders/last-changed-statuses" + headers = { + "Authorization": f"Bearer {token}" + } + + all_statuses = [] + + # 네이버 API는 lastChangedFrom과 lastChangedTo의 간격이 최대 24시간입니다. + # 따라서 최근 7일의 데이터를 가져오기 위해 24시간 단위로 반복 조회합니다. + now = time.time() + for days_ago in range(7, 0, -1): + start_time = now - (days_ago * 24 * 3600) + end_time = now - ((days_ago - 1) * 24 * 3600) + + if days_ago == 1: + end_time = now + + from_date = time.strftime('%Y-%m-%dT%H:%M:%S.000+09:00', time.localtime(start_time)) + to_date = time.strftime('%Y-%m-%dT%H:%M:%S.000+09:00', time.localtime(end_time)) + + params = { + "lastChangedFrom": from_date, + "lastChangedTo": to_date, + "lastChangedType": "PAYED" + } + + response = requests.get(url, headers=headers, params=params) + if response.status_code == 200: + data = response.json() + statuses = data.get('data', {}).get('lastChangeStatuses', []) + all_statuses.extend(statuses) + else: + raise Exception(f"Failed to fetch orders ({from_date}): {response.text}") + + return all_statuses + +def get_product_order_details(token, product_order_ids): + if not product_order_ids: + return [] + + url = "https://api.commerce.naver.com/external/v1/pay-order/seller/product-orders/query" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + payload = { + "productOrderIds": product_order_ids + } + response = requests.post(url, headers=headers, json=payload) + if response.status_code == 200: + return response.json().get('data', []) + else: + raise Exception(f"Failed to fetch order details: {response.text}") + +def dispatch_orders(token, dispatch_list): + """ + dispatch_list 형태: + [ + { + "productOrderId": "...", + "deliveryMethod": "DELIVERY", + "deliveryCompanyCode": "CJGLS", + "trackingNumber": "..." + } + ] + """ + url = "https://api.commerce.naver.com/external/v1/pay-order/seller/product-orders/dispatch" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + # 발송일은 현재 시간으로 셋팅 + dispatch_date = time.strftime('%Y-%m-%dT%H:%M:%S+09:00') + for item in dispatch_list: + item["dispatchDate"] = dispatch_date + + payload = { + "dispatchProductOrders": dispatch_list + } + + response = requests.post(url, headers=headers, json=payload) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to dispatch orders: {response.text}") + +def confirm_orders(token, product_order_ids): + """주문건을 발주확인(배송준비중) 상태로 변경합니다.""" + if not product_order_ids: + return {} + + url = "https://api.commerce.naver.com/external/v1/pay-order/seller/product-orders/confirm" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + payload = { + "productOrderIds": product_order_ids + } + + response = requests.post(url, headers=headers, json=payload) + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to confirm orders: {response.text}") + +def get_product_details(token, product_id): + """지정된 상품의 전체 상세 정보(옵션 설정 포함)를 조회합니다.""" + url = f"https://api.commerce.naver.com/external/v2/products/channel-products/{product_id}" + headers = { + "Authorization": f"Bearer {token}" + } + response = requests.get(url, headers=headers) + + if response.status_code == 200: + return response.json() + return {} diff --git a/push_to_gitea.bat b/push_to_gitea.bat new file mode 100644 index 0000000..bb327b5 --- /dev/null +++ b/push_to_gitea.bat @@ -0,0 +1,101 @@ +@echo off +chcp 65001 > nul +cd /d "%~dp0" + +echo. +echo ========================================= +echo DBX CORM - Gitea Push +echo https://gitea.no1king.freeddns.org/king/dbx-corm +echo ========================================= +echo. + +REM 민감 파일 추적 확인 +echo [1/5] Checking for sensitive files... +set FOUND=0 +for %%F in ( + .env + cafe24_tokens.json + manual-ordering.json +) do ( + git ls-files --error-unmatch "%%F" >nul 2>&1 + if not errorlevel 1 ( + echo [ERROR] Sensitive file is tracked by git: %%F + set FOUND=1 + ) +) +if %FOUND%==1 ( + echo. + echo PUSH BLOCKED. Remove with: + echo git rm --cached ^ + echo git commit -m "remove sensitive file" + echo. + pause + exit /b 1 +) +echo OK. +echo. + +REM Git 상태 +echo [2/5] Git status: +echo ----------------------------------------- +git status -s +echo ----------------------------------------- +echo. + +REM 변경사항 없으면 종료 +git diff --quiet +set DIFF1=%errorlevel% +git diff --cached --quiet +set DIFF2=%errorlevel% +git ls-files --others --exclude-standard | findstr /r "." >nul +set DIFF3=%errorlevel% + +if "%DIFF1%"=="0" if "%DIFF2%"=="0" if not "%DIFF3%"=="0" ( + echo No changes to commit. Exiting. + pause + exit /b 0 +) + +REM 스테이징 +echo [3/5] Staging files... +git add -A +echo OK. +echo. + +REM 커밋 메시지 입력 +echo [4/5] Enter commit message: +set /p MSG=^>^> + +if "%MSG%"=="" ( + echo [ERROR] Empty commit message. + pause + exit /b 1 +) + +git commit -m "%MSG%" +if errorlevel 1 ( + echo [ERROR] Commit failed. + pause + exit /b 1 +) +echo. + +REM Push +echo [5/5] Pushing to Gitea... +git push -u origin master +if errorlevel 1 ( + git push -u origin main +) + +if %errorlevel%==0 ( + echo. + echo ========================================= + echo [OK] Push complete! + echo https://gitea.no1king.freeddns.org/king/dbx-corm + echo ========================================= +) else ( + echo. + echo [ERROR] Push failed. Check credentials or network. +) +echo. +pause diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6c6f2f8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +fastapi +uvicorn +pydantic +gspread +google-auth +jinja2 +python-multipart +pandas +openpyxl +xlrd +bcrypt +requests +psycopg2-binary +python-dotenv \ No newline at end of file diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routers/returns.py b/routers/returns.py new file mode 100644 index 0000000..bed929c --- /dev/null +++ b/routers/returns.py @@ -0,0 +1,276 @@ +import os +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel +from typing import Optional, List +import psycopg2 +import psycopg2.extras +import datetime + +router = APIRouter(prefix="/api/return", tags=["Returns"]) + +# 환경변수 기반 DB 설정 +_PG_HOST = os.getenv('POSTGRES_HOST', 'postgres-db') +_PG_PORT = int(os.getenv('POSTGRES_PORT', '5432')) +_PG_USER = os.getenv('POSTGRES_USER', 'king') +_PG_PASSWORD = os.getenv('POSTGRES_PASSWORD', '') + +RETURN_DB_CONFIG = { + 'host': _PG_HOST, + 'port': _PG_PORT, + 'dbname': os.getenv('RETURN_DB', 'return_db'), + 'user': _PG_USER, + 'password': _PG_PASSWORD, +} + +def get_return_db_conn(): + return psycopg2.connect(**RETURN_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) + + +ORDER_DB_CONFIG = { + 'host': _PG_HOST, + 'port': _PG_PORT, + 'dbname': os.getenv('ORDERLIST_DB', 'orderlist_db'), + 'user': _PG_USER, + 'password': _PG_PASSWORD, +} + +def get_order_db_conn(): + return psycopg2.connect(**ORDER_DB_CONFIG, cursor_factory=psycopg2.extras.DictCursor) + +class ReturnRequest(BaseModel): + id: Optional[int] = None + request_date: str + request_type: str + receiver_name: str + address: str + phone: str + tracking_no: str + mall: str + remarks: str + +class ReturnReceive(BaseModel): + id: Optional[int] = None + receive_date: str + tracking_no: str + mall: str + receiver_name: str + phone: str + address: Optional[str] = '' + remarks: str + receive_status: str + +class BulkReturnReceive(BaseModel): + tracking_numbers: List[str] + +@router.get("/lookup") +async def return_lookup(tracking_no: str): + try: + conn = get_order_db_conn() + cur = conn.cursor() + # 주문일시(order_date 또는 upload_date) 기준 가장 최신 1건 조회 + cur.execute(""" + SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address + FROM orders + WHERE tracking_number = %s + ORDER BY order_date DESC LIMIT 1 + """, (tracking_no,)) + row = cur.fetchone() + conn.close() + + if row: + return {"success": True, "data": dict(row)} + return {"success": False, "message": "송장번호를 찾을 수 없습니다."} + except Exception as e: + print(f"Lookup error: {e}") + return {"success": False, "message": str(e)} + +@router.post("/request") +async def create_return_request(req: ReturnRequest): + try: + conn = get_return_db_conn() + cur = conn.cursor() + if req.id: + cur.execute(""" + UPDATE return_requests + SET request_date=%s, request_type=%s, receiver_name=%s, address=%s, phone=%s, tracking_no=%s, mall=%s, remarks=%s + WHERE id=%s + """, (req.request_date, req.request_type, req.receiver_name, req.address, req.phone, req.tracking_no, req.mall, req.remarks, req.id)) + else: + cur.execute(""" + INSERT INTO return_requests + (request_date, request_type, receiver_name, address, phone, tracking_no, mall, remarks) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, (req.request_date, req.request_type, req.receiver_name, req.address, req.phone, req.tracking_no, req.mall, req.remarks)) + conn.commit() + conn.close() + return {"success": True} + except Exception as e: + print(f"Request error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/request") +async def get_return_requests(tracking_no: Optional[str] = None): + try: + conn = get_return_db_conn() + cur = conn.cursor() + if tracking_no: + cur.execute("SELECT * FROM return_requests WHERE tracking_no = %s ORDER BY created_at DESC LIMIT 1", (tracking_no,)) + else: + cur.execute("SELECT * FROM return_requests ORDER BY id DESC LIMIT 50") + rows = cur.fetchall() + conn.close() + return {"success": True, "data": [dict(r) for r in rows]} + except Exception as e: + return {"success": False, "message": str(e)} + +@router.post("/receive/bulk") +async def create_return_receive_bulk(req: BulkReturnReceive): + try: + from datetime import datetime + today = datetime.now().strftime("%Y-%m-%d") + + conn_return = get_return_db_conn() + cur_return = conn_return.cursor() + + conn_order = get_order_db_conn() + cur_order = conn_order.cursor() + + success_count = 0 + + for tracking_no in req.tracking_numbers: + tracking_no = tracking_no.strip() + if not tracking_no: + continue + + mall = "" + receiver_name = "" + phone = "" + address = "" + + # 1. Check return_requests + cur_return.execute("SELECT mall, receiver_name, phone, address, request_type, remarks FROM return_requests WHERE tracking_no = %s", (tracking_no,)) + req_row = cur_return.fetchone() + if req_row: + mall = req_row['mall'] or "" + receiver_name = req_row['receiver_name'] or "" + phone = req_row['phone'] or "" + address = req_row['address'] or "" + req_type = req_row['request_type'] or "" + req_remarks = req_row['remarks'] or "" + if req_remarks: + remarks = f"{req_type}/{req_remarks}" + else: + remarks = req_type + else: + remarks = "고객이 반품" + # 2. Check orders + cur_order.execute(""" + SELECT vendor, recipient_name, recipient_phone, recipient_mobile, address + FROM orders + WHERE tracking_number = %s + ORDER BY order_date DESC LIMIT 1 + """, (tracking_no,)) + ord_row = cur_order.fetchone() + if ord_row: + mall = ord_row['vendor'] or "" + receiver_name = ord_row['recipient_name'] or "" + phone = ord_row['recipient_phone'] or ord_row['recipient_mobile'] or "" + address = ord_row['address'] or "" + + # Insert into return_receivings + cur_return.execute(""" + INSERT INTO return_receivings + (receive_date, tracking_no, mall, receiver_name, phone, address, remarks, receive_status) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, (today, tracking_no, mall, receiver_name, phone, address, remarks, "환불 대기")) + success_count += 1 + + conn_return.commit() + conn_return.close() + conn_order.close() + + return {"success": True, "count": success_count} + except Exception as e: + print(f"Bulk insert error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/receive") +async def create_return_receive(req: ReturnReceive): + try: + conn = get_return_db_conn() + cur = conn.cursor() + + if not req.id: + cur.execute("SELECT request_type, remarks FROM return_requests WHERE tracking_no = %s", (req.tracking_no,)) + req_row = cur.fetchone() + if not req_row: + if req.remarks: + if "고객이 반품" not in req.remarks: + req.remarks = "고객이 반품 / " + req.remarks + else: + req.remarks = "고객이 반품" + else: + req_type = req_row['request_type'] or "" + req_remarks = req_row['remarks'] or "" + auto_remark = f"{req_type}/{req_remarks}" if req_remarks else req_type + if not req.remarks: + req.remarks = auto_remark + elif auto_remark not in req.remarks: + req.remarks = f"{auto_remark} / {req.remarks}" + + if req.id: + # Update existing record + cur.execute(""" + UPDATE return_receivings + SET receive_date=%s, tracking_no=%s, mall=%s, receiver_name=%s, phone=%s, address=%s, remarks=%s, receive_status=%s + WHERE id=%s + """, (req.receive_date, req.tracking_no, req.mall, req.receiver_name, req.phone, req.address, req.remarks, req.receive_status, req.id)) + else: + # Insert new record + cur.execute(""" + INSERT INTO return_receivings + (receive_date, tracking_no, mall, receiver_name, phone, address, remarks, receive_status) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, (req.receive_date, req.tracking_no, req.mall, req.receiver_name, req.phone, req.address, req.remarks, req.receive_status)) + conn.commit() + conn.close() + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/receive") +async def get_return_receives(): + try: + conn = get_return_db_conn() + cur = conn.cursor() + cur.execute("SELECT * FROM return_receivings ORDER BY receive_date DESC, mall ASC, id DESC LIMIT 50") + rows = cur.fetchall() + conn.close() + return {"success": True, "data": [dict(r) for r in rows]} + except Exception as e: + return {"success": False, "message": str(e)} + +@router.delete("/request/{item_id}") +async def delete_return_request(item_id: int): + try: + conn = get_return_db_conn() + cur = conn.cursor() + cur.execute("DELETE FROM return_requests WHERE id = %s", (item_id,)) + conn.commit() + conn.close() + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.delete("/receive/{item_id}") +async def delete_return_receive(item_id: int): + try: + conn = get_return_db_conn() + cur = conn.cursor() + cur.execute("DELETE FROM return_receivings WHERE id = %s", (item_id,)) + conn.commit() + conn.close() + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..2d8f42f --- /dev/null +++ b/static/css/styles.css @@ -0,0 +1,1099 @@ +:root { + --bg-color-main: #f0f4f8; + --sidebar-bg: #1a202c; + --sidebar-text: #e2e8f0; + --sidebar-active: #2d3748; + + --primary-color: #3182ce; + --primary-hover: #2b6cb0; + --secondary-color: #4a5568; + --accent-color: #ed8936; + --danger-color: #e53e3e; + + --card-bg: rgba(255, 255, 255, 0.75); + --card-border: rgba(255, 255, 255, 0.5); + --card-shadow: 0 4px 12px 0 rgba(31, 38, 135, 0.05); + --glass-blur: blur(8px); + + --text-main: #1a202c; + --text-muted: #4a5568; + + --font-stack: 'Inter', 'Malgun Gothic', sans-serif; + --transition: 0.15s ease; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-stack); + background: var(--bg-color-main); + background-image: + radial-gradient(at 0% 0%, hsla(253, 16%, 7%, 1) 0, transparent 50%), + radial-gradient(at 50% 0%, hsla(225, 39%, 30%, 1) 0, transparent 50%), + radial-gradient(at 100% 0%, hsla(339, 49%, 30%, 1) 0, transparent 50%); + background-attachment: fixed; + color: var(--text-main); + height: 100vh; + overflow: hidden; + font-size: 11px; + /* 극한의 압축을 위한 기본 폰트 더 축소 */ +} + +/* Typography */ +h1 { + font-size: 1rem; + font-weight: 700; + margin-bottom: 2px; + color: #fff; +} + +h2 { + font-size: 0.95rem; + font-weight: 700; + color: #fff; +} + +h3 { + font-size: 0.85rem; + font-weight: 700; + margin-bottom: 4px; + color: var(--text-main); + border-bottom: 2px solid var(--primary-color); + padding-bottom: 2px; + display: inline-block; +} + +p { + line-height: 1.2; +} + +/* Layout */ +.app-container { + display: flex; + height: 100vh; + width: 100vw; +} + +.sidebar { + width: 130px; + background-color: var(--sidebar-bg); + color: var(--sidebar-text); + display: flex; + flex-direction: column; + box-shadow: 2px 0 5px rgba(0, 0, 0, 0.2); + z-index: 10; +} + +.sidebar .logo { + padding: 12px 10px; + text-align: center; + background: rgba(0, 0, 0, 0.1); + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.sidebar .logo p { + color: var(--accent-color); + font-size: 0.75rem; + margin-top: 3px; +} + +.nav-links { + list-style: none; + margin-top: 5px; +} + +.nav-links li { + /* 탭 글자 크기 */ + padding: 11px 10px; + cursor: pointer; + font-weight: 200; + font-size: 1.0rem; + transition: var(--transition); + border-left: 3px solid transparent; +} + +.nav-links li:hover { + background-color: var(--sidebar-active); +} + +.nav-links li.active { + background-color: var(--sidebar-active); + border-left-color: var(--primary-color); + color: #fff; +} + +.content { + flex: 1; + overflow: hidden; + padding: 10px 15px; + display: flex; + flex-direction: column; +} + +.tab-content { + display: none; + height: 100%; +} + +.tab-content.active { + display: flex; + flex-direction: column; +} + +.tab-content.active.grid-layout { + display: grid; + grid-template-columns: 50% 50%; +} + +.tab-content.active.flex-layout { + display: flex; + flex-direction: column; +} + +/* Grid Layout */ +.main-grid { + display: grid; + grid-template-columns: 1fr 525px; + gap: 10px; + height: calc(100vh - 45px); + overflow: hidden; +} + +/* ★★★ CSS Multi-Column 레이아웃 적용 ★★★ */ +/* 빈틈없이 차례대로 컬럼을 채우는 가장 강력한 속성 */ +.left-col { + display: flex; + flex-direction: column; + flex-wrap: wrap; + align-content: flex-start; + gap: 8px; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 5px; +} + +.left-col::-webkit-scrollbar { + width: 5px; +} + +.left-col::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.right-col { + display: grid; + grid-template-columns: 260px 250px; + gap: 15px; + height: 100%; +} + +.right-panel-col1 { + display: flex; + flex-direction: column; + gap: 8px; +} + +.right-panel-col2 { + display: flex; + flex-direction: column; + gap: 8px; + height: 100%; +} + +.right-col::-webkit-scrollbar { + width: 4px; +} + +.right-col::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 2px; +} + +/* Glass Cards */ +.glass-card { + background: var(--card-bg); + backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); + border: 1px solid var(--card-border); + border-radius: 8px; + padding: 8px; + box-shadow: var(--card-shadow); +} + +/* Sections for Items */ +.item-group-card { + width: max-content; + display: flex; + flex-direction: column; +} + +.item-list { + display: flex; + flex-direction: column; + gap: 1px; +} + +.item-row { + display: flex; + align-items: center; + background: rgba(255, 255, 255, 0.4); + padding: 1px 4px; + border-radius: 4px; + border: 1px solid transparent; + gap: 6px; +} + +.item-row:hover { + background: rgba(255, 255, 255, 0.9); + border-color: var(--primary-color); +} + +.item-row:has(input.chk-order:checked) { + background: #fef08a !important; + border-color: #eab308; +} + +.item-row label { + flex: 1; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + font-weight: 500; + white-space: nowrap; +} + +.item-row label input { + margin: 0; + transform: scale(0.85); + /* 체크박스 크기 축소 */ +} + +.item-row input[type="text"], +.item-row input[type="number"], +.item-row select { + width: 32px; + padding: 1px; + border: 1px solid #cbd5e0; + border-radius: 3px; + text-align: center; + outline: none; + font-size: 0.75rem; +} + +/* 스피너(증감 화살표) 숨기기 */ +.item-row input[type="number"]::-webkit-outer-spin-button, +.item-row input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.item-row input[type="number"] { + -moz-appearance: textfield; +} + +.item-row input[type="text"]:focus, +.item-row input[type="number"]:focus, +.item-row select:focus { + border-color: var(--primary-color); +} + +/* Special Text Colors */ +.text-orange { + color: #dd6b20; + font-weight: 700; +} + +/* Forms & Inputs */ +.form-group { + margin-bottom: 5px; +} + +.form-group label { + display: block; + margin-bottom: 1px; + font-size: 0.75rem; + font-weight: 700; + color: var(--text-muted); +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 4px 6px; + border: 1px solid #cbd5e0; + border-radius: 4px; + font-size: 0.8rem; + background: rgba(255, 255, 255, 0.8); +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary-color); + background: #fff; +} + +/* Buttons */ +.btn { + padding: 6px 10px; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + font-size: 0.8rem; + transition: var(--transition); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn:active { + transform: scale(0.96); +} + +.btn-primary { + background: var(--primary-color); + color: #fff; +} + +.btn-primary:hover { + background: var(--primary-hover); +} + +.btn-secondary { + background: var(--secondary-color); + color: #fff; +} + +.btn-secondary:hover { + background: #2d3748; +} + +.btn-accent { + background: var(--accent-color); + color: #fff; +} + +.btn-accent:hover { + background: #dd6b20; +} + +.btn-danger { + background: var(--danger-color); + color: #fff; +} + +.btn-danger:hover { + background: #c53030; +} + +.btn-large { + padding: 8px 12px; + font-size: 0.85rem; +} + +.block { + display: block; + width: 100%; +} + +.mt-1 { + margin-top: 3px; +} + +.mt-2 { + margin-top: 6px; +} + +/* Action Buttons Area */ +.action-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; +} + +.action-buttons.horizontal { + display: flex; +} + +.action-buttons.horizontal .btn { + flex: 1; +} + +.action-buttons .block { + grid-column: 1 / -1; +} + +/* Radio & Checkbox Groups */ +.radio-group { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 4px; +} + +.radio-group label, +.checkbox-label { + display: flex; + align-items: center; + gap: 3px; + cursor: pointer; + font-size: 0.75rem; +} + +.radio-group input[type="radio"], +.checkbox-label input { + transform: scale(0.85); +} + +.chk-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px; +} + +/* Previews */ +.preview-box { + flex: 1; + min-height: 100px; + display: flex; + flex-direction: column; +} + +.preview-box textarea { + flex: 1; + width: 100%; + background: #fff; + border: 1px solid #cbd5e0; + border-radius: 4px; + padding: 6px; + font-size: 0.8rem; + resize: none; + line-height: 1.3; +} + +/* History Box */ +.history-box { + display: flex; + flex-direction: column; +} + +.history-box ul { + list-style: none; + overflow-y: auto; + flex: 1; +} + +.history-box ul::-webkit-scrollbar { + width: 5px; +} + +.history-box ul::-webkit-scrollbar-track { + background: transparent; +} + +.history-box ul::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.15); + border-radius: 2px; +} + +.history-box li { + padding: 4px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + font-size: 0.75rem; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + white-space: nowrap; +} + +.history-box li:hover { + background: rgba(0, 0, 0, 0.05); +} + +.action-text { + float: right; + font-size: 0.7rem; + color: var(--danger-color); + cursor: pointer; +} + +.action-text:hover { + text-decoration: underline; +} + +/* Modals */ +.modal { + display: none; + position: fixed; + z-index: 100; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + align-items: center; + justify-content: center; +} + +.modal-content { + background-color: #fefefe; + padding: 15px; + width: 90%; + max-width: 350px; + border-radius: 8px; +} + +.close { + position: absolute; + right: 12px; + top: 8px; + font-size: 20px; + cursor: pointer; +} + +/* Gift Settings Grid */ +.gift-settings-container { + display: flex; + flex-wrap: wrap; + gap: 15px; + align-content: flex-start; + height: calc(100vh - 100px); +} + +.gift-rule-card { + padding: 10px; + width: 410px; + height: 300px; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.rule-inputs { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 8px; + font-size: 0.8rem; +} + +.rule-inputs input { + width: 85px; + padding: 3px; + font-size: 0.75rem; +} + +.gift-radio-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 3px; + font-size: 0.8rem; +} + +.bottom-action { + position: absolute; + bottom: 15px; + right: 15px; +} + +/* Loading Overlay for Google Sheets */ +.sheet-loader-overlay { + position: absolute; + top: 10px; + left: 10px; + right: 10px; + bottom: 0; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(4px); + z-index: 10; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.spinner { + width: 50px; + height: 50px; + border: 5px solid #e2e8f0; + border-top: 5px solid #3182ce; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.4); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb { + background: rgba(49, 130, 206, 0.5); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(49, 130, 206, 0.8); +} + +@font-face { + font-family: 'NanumSquareNeo'; + src: url('../font/NanumSquareNeo-bRg.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'Pretendard-Regular'; + src: url('../font/PRETENDARD-REGULAR.TTF') format('truetype'); + font-weight: normal; + font-style: normal; +} + +/* ========================================================================== + Mobile Responsive Adjustments (Max-Width 800px) + ========================================================================== */ +@media (max-width: 800px) { + /* 1. Body & Container */ + body { + overflow: auto; + } + .app-container { + flex-direction: column; + height: auto; + padding-bottom: 65px; /* space for bottom nav */ + } + .content { + overflow: visible; + padding: 5px; + } + + /* 2. Sidebar to Bottom Nav */ + .sidebar { + width: 100%; + height: auto; + flex-direction: row; + position: fixed; + bottom: 0; + left: 0; + z-index: 1000; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.2); + } + .sidebar .logo { + display: none; /* Hide logo on mobile to save space */ + } + .nav-links { + display: flex; + flex-direction: row; + width: 100%; + margin: 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + background-color: var(--sidebar-bg); /* ensure background is solid */ + } + .nav-links li { + flex: none; + min-width: 85px; + text-align: center; + padding: 15px 5px; + white-space: nowrap; + border-left: none; + border-bottom: 3px solid transparent; + font-size: 0.9rem; + } + .nav-links li.active { + border-left-color: transparent; + border-bottom-color: var(--primary-color); + } + + /* 3. Main Grid & Left Col (Product List) */ + .main-grid { + display: flex; + flex-direction: column; + height: auto; + gap: 15px; + } + .left-col { + height: 45vh; /* Fixed height with internal scroll */ + flex-direction: column; + flex-wrap: nowrap; + overflow-y: auto; + overflow-x: hidden; + border: 1px solid rgba(0, 0, 0, 0.1); + padding: 5px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.5); + } + .item-group-card { + width: 100%; + margin-bottom: 5px; + } + + /* 4. Right Col (Forms & SMS Preview) */ + .right-col { + display: flex; + flex-direction: column; + height: auto; + gap: 15px; + } + .right-panel-col1, .right-panel-col2 { + width: 100%; + height: auto; + } + + /* 5. Override Inline Styles via !important */ + .preview-box { + height: 200px !important; + } + .history-box, .manual-history-box { + height: 300px !important; + } + + .history-box li, .manual-history-box li { + font-size: 0.65rem; + padding: 6px 4px; + white-space: normal; /* 글씨가 짤리지 않고 줄바꿈 되도록 설정 */ + word-break: break-all; + } + + .history-box li .action-text, .manual-history-box li .action-text { + font-size: 0.6rem; + margin-left: 5px; + flex-shrink: 0; + } + + /* 6. Form Adjustments for Touch */ + .chk-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + } + .chk-grid label { + padding: 8px 4px; /* Larger touch targets */ + background: rgba(255, 255, 255, 0.5); + border-radius: 4px; + border: 1px solid #e2e8f0; + margin: 0; + } + .action-buttons { + flex-wrap: wrap; + } + .btn { + padding: 12px 10px; + font-size: 1rem; + } + input[type="text"], input[type="number"], select { + padding: 10px; + font-size: 1rem; /* Prevent zoom */ + } + + /* Settings / Manual Info */ + .gift-settings-container { + flex-direction: column; + height: auto; + flex-wrap: nowrap; + } + .gift-rule-card { + width: 100%; + } + + /* 7. Ver History UI Adjustments */ + #manual-guide-tab .timeline-item h2 { + font-size: 1.3rem !important; + margin-bottom: 8px !important; + } + #manual-guide-tab .timeline-item h2 span { + font-size: 0.85rem !important; + } + #manual-guide-tab .timeline-item h4 { + font-size: 1.05rem !important; + margin-bottom: 6px !important; + } + #manual-guide-tab .timeline-item h4 span { + font-size: 1.1rem !important; + } + #manual-guide-tab .timeline-item ul { + font-size: 0.9rem !important; + padding-left: 18px !important; + line-height: 1.5 !important; + } + #manual-guide-tab .timeline-item { + margin-bottom: 25px !important; + } + + /* Return Tab Mobile Layout (Split top and bottom) */ + .return-split-container { + flex-direction: column !important; + height: auto !important; + gap: 15px !important; + overflow: visible !important; + } + + .return-split-container > .glass-card { + height: 45vh !important; + min-height: 320px !important; + flex: none !important; + } + + .return-split-container .glass-card > div { + overflow: auto !important; + } + + /* Mobile detail toggle and collapsible form elements */ + .btn-detail-toggle { + display: inline-block !important; + height: 20px; + padding: 2px 6px; + font-size: 0.7rem; + background: #718096; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: normal; + margin-left: 8px; + } + + .mobile-hide { + display: none !important; + } + + form.show-details .mobile-hide { + display: flex !important; + } + + form.show-details div.form-group.mobile-hide { + display: block !important; + } + + /* Product Settings Tab Mobile Horizontal Scroll */ + #product-groups-container { + overflow-x: auto !important; + max-width: 100% !important; + } + + /* Code Settings Tab Mobile Horizontal Scroll & Side-by-Side Layout */ + .code-split-container { + overflow-x: auto !important; + overflow-y: hidden !important; + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + width: 100% !important; + } + + .code-split-container > .glass-card:first-child { + flex: 0 0 560px !important; + width: 560px !important; + max-width: 560px !important; + height: 100% !important; + } + + .code-split-container > .glass-card:last-child { + flex: 0 0 1000px !important; + width: 1000px !important; + max-width: 1000px !important; + height: 100% !important; + } + + /* Settings Tab Mobile Style Optimizations */ + #gift-tab h1 { + color: #2d3748 !important; + } + + select option { + color: #2d3748 !important; + background-color: #ffffff !important; + } + + #gift-tab .left-settings, + #gift-tab .right-settings, + #gift-tab .third-settings { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + height: auto !important; + } + + #gift-tab .glass-card { + width: 100% !important; + max-width: 100% !important; + height: auto !important; + max-height: none !important; + } + + #app-font-select { + flex: 1 !important; + min-width: 0 !important; + width: 100% !important; + max-width: calc(100% - 85px) !important; + } + + .settings-action-container { + position: static !important; + margin-top: 15px !important; + margin-bottom: 80px !important; + width: 100% !important; + display: flex !important; + justify-content: flex-end !important; + } +} + +.btn-detail-toggle { + display: none; +} + +/* ========================================================================== + Auto Order (스마트스토어 발주 자동화) TAB Styles + ========================================================================== */ + +/* File Drop Area */ +.file-drop-area { + position: relative; + display: flex; + align-items: center; + justify-content: center; + max-width: 100%; + padding: 2rem 1rem; + border: 2px dashed rgb(200, 200, 200); + border-radius: 8px; + background-color: #fafbfc; + margin-bottom: 1rem; + transition: background-color 0.2s, border-color 0.2s; + cursor: pointer; +} + +.file-drop-area.is-active { + background-color: #f0fdf4; + border-color: #03C75A; +} + +.file-msg { + font-size: 1rem; + font-weight: 400; + color: var(--text-secondary); + text-align: center; +} + +/* Loading Spinner */ +.spinner-container { + display: flex; + align-items: center; + justify-content: center; + margin-top: 1rem; +} + +#downloadSpinner, #uploadSpinner { + width: 24px; + height: 24px; + border: 3px solid #f3f3f3; + border-top: 3px solid #03C75A; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-left: 0.5rem; + display: inline-block; +} + +.hidden { + display: none !important; +} + +/* Result Box */ +.result-box { + margin-top: 1.5rem; + padding: 1rem; + border-radius: 6px; + border: 1px solid var(--border-color); + background-color: #f8f9fa; +} + +.result-box.success { + border-color: #03C75A; + background-color: #f0fdf4; +} + +.result-box.error { + border-color: #ff4a4a; + background-color: #fff5f5; +} + +.result-box h3 { + margin-bottom: 0.5rem; +} + +/* Toggle Switch CSS */ +.toggle-switch { + position: relative; + width: 50px; + height: 24px; + display: inline-block; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.switch-label { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .4s; + border-radius: 24px; +} + +.switch-label:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: .4s; + border-radius: 50%; +} + +.toggle-switch input:checked + .switch-label { + background-color: #03C75A; +} + +.toggle-switch input:focus + .switch-label { + box-shadow: 0 0 1px #03C75A; +} + +.toggle-switch input:checked + .switch-label:before { + transform: translateX(26px); +} \ No newline at end of file diff --git a/static/font/NanumSquareNeo-bRg.ttf b/static/font/NanumSquareNeo-bRg.ttf new file mode 100644 index 0000000..8680fb3 Binary files /dev/null and b/static/font/NanumSquareNeo-bRg.ttf differ diff --git a/static/font/PRETENDARD-REGULAR.TTF b/static/font/PRETENDARD-REGULAR.TTF new file mode 100644 index 0000000..01147e9 Binary files /dev/null and b/static/font/PRETENDARD-REGULAR.TTF differ diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..1ab917d --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,3018 @@ + +function showToast(message) { + const toast = document.createElement('div'); + toast.textContent = message; + toast.style.position = 'fixed'; + toast.style.bottom = '20px'; + toast.style.left = '50%'; + toast.style.transform = 'translateX(-50%)'; + toast.style.background = 'rgba(0, 0, 0, 0.7)'; + toast.style.color = '#fff'; + toast.style.padding = '8px 16px'; + toast.style.borderRadius = '4px'; + toast.style.zIndex = '9999'; + toast.style.fontSize = '0.9rem'; + toast.style.transition = 'opacity 0.3s ease'; + document.body.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + setTimeout(() => toast.remove(), 300); + }, 1500); +} +function formatShortDate(dateStr) { + if (!dateStr) return ""; + const d = new Date(dateStr); + if (isNaN(d)) return dateStr; + const days = ["일", "월", "화", "수", "목", "금", "토"]; + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const dow = days[d.getDay()]; + return `${m}월 ${day}일(${dow})`; +} + +document.addEventListener('DOMContentLoaded', () => { + // Tab switching logic + const navLinks = document.querySelectorAll('.nav-links li'); + const tabContents = document.querySelectorAll('.tab-content'); + + navLinks.forEach(link => { + link.addEventListener('click', () => { + navLinks.forEach(l => l.classList.remove('active')); + tabContents.forEach(c => c.classList.remove('active')); + + link.classList.add('active'); + const target = link.getAttribute('data-tab'); + document.getElementById(target).classList.add('active'); + + if (target === 'order-tab') { + const estExtra = document.getElementById('setting-ship-extra'); + const lblObj = document.getElementById('lbl-ship-extra'); + if (estExtra && lblObj) { + let valStr = estExtra.value.replace(/,/g, ''); + let val = valStr ? parseInt(valStr, 10) : 0; + if (!isNaN(val)) { + lblObj.textContent = '추가배송비 ' + val.toLocaleString() + '원'; + } + } + } else if (target === 'code-tab') { + if (typeof window.reRenderSetItemsForWrap === 'function') { + window.reRenderSetItemsForWrap(); + } + } + }); + }); + + // Auto format phone number + document.getElementById('cust-phone').addEventListener('input', function (e) { + let val = this.value.replace(/[^0-9]/g, ''); + if (val.length > 0 && val[0] !== '0') { + val = '010' + val; + } + let res = ''; + if (val.startsWith('02')) { + if (val.length < 3) res = val; + else if (val.length < 6) res = val.replace(/(\d{2})(\d+)/, '$1-$2'); + else if (val.length < 10) res = val.replace(/(\d{2})(\d{3})(\d+)/, '$1-$2-$3'); + else res = val.replace(/(\d{2})(\d{4})(\d{4})/, '$1-$2-$3'); + } else { + if (val.length < 4) res = val; + else if (val.length < 7) res = val.replace(/(\d{3})(\d+)/, '$1-$2'); + else if (val.length < 11) res = val.replace(/(\d{3})(\d{3})(\d+)/, '$1-$2-$3'); + else res = val.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3'); + } + this.value = res; + }); + + async function handleClipboardPaste(targetField) { + try { + if (!navigator.clipboard || !navigator.clipboard.readText) { + alert("현재 환경에서는 클립보드 연동 보안 정책으로 인해 자동 입력이 지원되지 않습니다.\\nHTTPS 상태일 때 동작합니다.\\n직접 붙여넣기(Ctrl+V)를 사용해주세요."); + return; + } + const text = await navigator.clipboard.readText(); + const trimmed = text.trim(); + const digits = text.replace(/[^0-9]/g, ''); + const hasPhone = digits.length >= 9 && digits.length <= 12; + + // 이름은 한글 2~5글자를 인식하도록 완화 + const nameMatch = trimmed.match(/^[가-힣]{2,5}/); + + if (targetField === 'phone') { + const phoneInput = document.getElementById('cust-phone'); + if (hasPhone) { + phoneInput.value = digits; + phoneInput.dispatchEvent(new Event('input')); + } + } else if (targetField === 'name') { + const nameInput = document.getElementById('cust-name'); + if (nameMatch) { + nameInput.value = nameMatch[0]; + } + } + } catch (err) { + console.error("Clipboard read error:", err); + alert("클립보드 접근 권한이 없거나 읽어오지 못했습니다. 브라우저 주소창의 권한을 확인해주세요."); + } + } + + document.getElementById('cust-phone').addEventListener('click', function (e) { + if (!this.value) handleClipboardPaste('phone'); + }); + + document.getElementById('cust-name').addEventListener('click', function (e) { + if (!this.value) handleClipboardPaste('name'); + }); + + const pointInput = document.getElementById('sms-point-deduction'); + if (pointInput) { + pointInput.addEventListener('input', function(e) { + let val = this.value.replace(/[^0-9]/g, ''); + this.value = val ? parseInt(val, 10).toLocaleString() : ''; + }); + } + + // Global Data + let configData = {}; + + // Fetch configuration + fetch('/api/config') + .then(res => res.json()) + .then(data => { + configData = data; + + if (data.APP_SETTINGS && data.APP_SETTINGS.font) { + document.body.style.fontFamily = `"${data.APP_SETTINGS.font}", 'Inter', 'Malgun Gothic', sans-serif`; + } else { + document.body.style.fontFamily = ''; + } + + buildOrderTab(data); + buildGiftTab(data); + if (typeof buildProductTab === 'function') { + buildProductTab(data); + } + loadSmsHistory(); + if (typeof loadManualHistory === 'function') { + loadManualHistory(); + } + }) + .catch(err => console.error("Error loading config:", err)); + + + + // Fetch local fonts using hybrid approach (Browser API first, Backend API fallback) + async function initLocalFonts() { + const fontSelect = document.getElementById('app-font-select'); + if (!fontSelect) return; + + let fontNames = []; + + try { + // Attempt to fetch via native browser API (works on HTTPS/localhost for all PC fonts) + if ('queryLocalFonts' in window && window.isSecureContext) { + const localFonts = await window.queryLocalFonts(); + fontNames = [...new Set(localFonts.map(f => f.family))].sort(); + } else { + throw new Error("Local font API not available or context not secure"); + } + } catch (e) { + console.warn("내 컴퓨터 폰트 직접 읽기 실패, 서버 폰트로 대체:", e); + try { + // Fallback to server registry (works locally without HTTPS but fails to get windows fonts on Linux cloud) + const res = await fetch('/api/fonts'); + fontNames = await res.json(); + } catch (err) { + console.error("서버 폰트 불러오기 실패:", err); + } + } + + fontSelect.innerHTML = ''; + + if (fontNames && fontNames.length > 0) { + // 명시적으로 호스팅된 폰트 추가 + const customFonts = ['NanumSquareNeo', 'Pretendard-Regular']; + customFonts.forEach(f => { + if (!fontNames.includes(f)) fontNames.unshift(f); + }); + // 중복 제거 및 정렬 + fontNames = [...new Set(fontNames)].sort((a, b) => { + const aIsCustom = customFonts.includes(a); + const bIsCustom = customFonts.includes(b); + if (aIsCustom && bIsCustom) return customFonts.indexOf(a) - customFonts.indexOf(b); + if (aIsCustom) return -1; + if (bIsCustom) return 1; + return a.localeCompare(b); + }); + + fontNames.forEach(f => { + const opt = document.createElement('option'); + opt.value = f; + + // Truncate display text to prevent native dropdowns from becoming excessively wide + let displayText = f; + if (displayText.length > 35) { + displayText = displayText.substring(0, 35) + '...'; + } + opt.textContent = displayText; + + // Set the font family so users can preview the font in the dropdown + opt.style.fontFamily = `"${f}", sans-serif`; + fontSelect.appendChild(opt); + }); + + if (configData.APP_SETTINGS && configData.APP_SETTINGS.font) { + fontSelect.value = configData.APP_SETTINGS.font; + } + } else { + fontSelect.innerHTML = ''; + } + } + + const btnFontDefault = document.getElementById('btn-font-default'); + if (btnFontDefault) { + btnFontDefault.addEventListener('click', () => { + const fontSelect = document.getElementById('app-font-select'); + if (fontSelect) fontSelect.value = ''; + document.getElementById('btn-gift-save').style.backgroundColor = '#e53e3e'; + }); + } + + // Initialize fonts + initLocalFonts(); + + // ============================================ + // BUILD ORDER TAB + // ============================================ + + document.getElementById('order-items-container').addEventListener('change', function(e) { + if (e.target.classList.contains('qty-order') && e.target.tagName === 'SELECT' && e.target.value === 'manual') { + const input = document.createElement('input'); + input.type = 'number'; + input.min = '1'; + input.value = '1'; + input.className = 'qty-order'; + + e.target.replaceWith(input); + input.focus(); + input.select(); + } + }); + + document.getElementById('order-items-container').addEventListener('blur', function(e) { + if (e.target.classList.contains('qty-order') && e.target.tagName === 'INPUT') { + if (!e.target.value) { + const select = document.createElement('select'); + select.className = 'qty-order'; + const qtyOpts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 30, 40, 50]; + const optHtml = qtyOpts.map(q => ``).join(''); + const manualOpt = ``; + select.innerHTML = manualOpt + optHtml; + select.value = '1'; + e.target.replaceWith(select); + } else if (parseInt(e.target.value) < 1) { + e.target.value = '1'; + } + } + }, true); + function buildOrderTab(data) { + const container = document.getElementById('order-items-container'); + let sections = []; + if (data.GROUP_NAMES) { + + const order_gn = data.GROUP_NAMES.__order || Object.keys(data.GROUP_NAMES).filter(k => k !== '__order'); + for (const key of order_gn) { + if (key === '__order') continue; + const title = data.GROUP_NAMES[key]; + + sections.push({ + key: key, title: title + + }); + } + } else { + // Fallback for older config files without GROUP_NAMES + sections = [ + { key: 'ORDER_SINGLE_1', title: '단품 아이템 (1)' }, + { key: 'ORDER_SINGLE_2', title: '단품 아이템 (2)' }, + { key: 'ORDER_SET_1', title: '세트 아이템 (1)' }, + { key: 'ORDER_SET_2', title: '세트 아이템 (2)' }, + { key: 'ORDER_EVENT_1', title: '이벤트 (1)' }, + { key: 'ORDER_EVENT_2', title: '이벤트 (2)' }, + { key: 'ORDER_GIFT', title: '사은품' } + ]; + } + + + sections.forEach(sec => { + if (!data[sec.key]) return; + const card = document.createElement('div'); + card.className = 'glass-card item-group-card'; + + const title = document.createElement('h3'); + title.textContent = sec.title; + card.appendChild(title); + + const grid = document.createElement('div'); + grid.className = 'item-list'; + + const order_1 = data[sec.key]?.__order || Object.keys(data[sec.key]).filter(k => k !== '__order'); + order_1.forEach(rawLabel => { + if (rawLabel === '__order') return; + const value = data[sec.key][rawLabel]; + let label = rawLabel.trim(); + + if (label.startsWith('---')) { + const hr = document.createElement('hr'); + hr.style.gridColumn = "1 / -1"; + hr.style.border = "none"; + hr.style.borderTop = "1.5px dashed black"; + hr.style.margin = "8px 0"; + grid.appendChild(hr); + return; + } + + let colorStyle = ''; + const match = label.match(/_([ORBGP])$/i); + if (match) { + label = label.slice(0, -2).trim(); + const colorMap = { 'O': '#ed8936', 'R': '#e53e3e', 'B': '#3182ce', 'G': '#1D6F42', 'P': '#d53f8c' }; + colorStyle = `color: ${colorMap[match[1].toUpperCase()]}; font-weight: 700;`; + } + + const safeValue = value || ""; + const parts = safeValue.split('|').map(s => s.trim()); + let code = parts[0] || "NONE"; + + let multiplier = 1; + const matchCode = code.match(/_(\d+)$/); + if (matchCode) { + multiplier = parseInt(matchCode[1], 10) || 1; + code = code.replace(/_\d+$/, ''); + } + + const isFree = label.startsWith('*'); + if (isFree) { + label = label.replace(/^\*/, '').trim(); + } + + let name = label; + if (parts.length > 1 && parts[1] !== "") { + name = parts[1]; + } + + let cost = 0; + let dcost = "null"; + if (parts.length > 2 && parts[2] !== "") { + cost = parseInt(parts[2].replace(/,/g, '')) || 0; + dcost = parts.length > 3 && parts[3] !== "" ? parseInt(parts[3].replace(/,/g, '')) : "null"; + } + + const row = document.createElement('div'); + row.className = 'item-row'; + + let itemType = sec.key; + + const qtyOpts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 20, 30, 40, 50]; + const optHtml = qtyOpts.map(q => ``).join(''); + const manualOpt = ``; + + row.innerHTML = ` + + + `; + grid.appendChild(row); + + row.querySelector('input[type="checkbox"]').addEventListener('change', updateOrderNoLidState); + + + }); + + card.appendChild(grid); + container.appendChild(card); + + + }); + + // Event listeners for config checks + document.getElementById('sms-discount').addEventListener('change', e => { + if (e.target.checked) document.getElementById('sms-june-promo').checked = false; + if (e.target.checked) { + document.getElementById('sms-coupon').disabled = true; + } else { + document.getElementById('sms-coupon').disabled = false; + } + }); + document.getElementById('sms-coupon').addEventListener('change', e => { + if (e.target.checked) { + document.getElementById('sms-discount').disabled = true; + } else { + document.getElementById('sms-discount').disabled = false; + } + }); + document.getElementById('sms-june-promo').addEventListener('change', e => { + if (e.target.checked) document.getElementById('sms-discount').checked = false; + }); + document.getElementById('sms-ship-free').addEventListener('change', e => { + // No need to handle old static shipping checkboxes + }); + } + + function updateOrderNoLidState() { + const checked = Array.from(document.querySelectorAll('.chk-order:checked')); + const noLidChk = document.getElementById('chk-no-lid'); + if (checked.length === 0) { + noLidChk.disabled = true; + noLidChk.checked = false; + return; + } + + const canEnable = checked.every(chk => chk.dataset.type === 'ORDER_SINGLE'); + noLidChk.disabled = !canEnable; + if (!canEnable) noLidChk.checked = false; + } + + + // ============================================ + // ORDER ACTIONS + // ============================================ + function getCouponDiscount(amount) { + if (amount >= 220000) return 25000; + if (amount >= 150000) return 15000; + if (amount >= 100000) return 10000; + if (amount >= 70000) return 5000; + if (amount >= 50000) return 3000; + if (amount >= 30000) return 2000; + return 0; + } + function getOrderPayload(excludedIndices = []) { + const items = []; + let totalCost = 0; + let hasMarkedFreeShipping = false; + + document.querySelectorAll('.chk-order:checked').forEach((chk, idx) => { + const row = chk.closest('.item-row'); + const qty = parseInt(row.querySelector('.qty-order').value) || 1; + const multiplier = parseInt(chk.dataset.multiplier) || 1; + const orderLabel = chk.dataset.label; + + items.push({ + code: chk.dataset.code, + name: chk.dataset.name, + label: orderLabel, + item_type: chk.dataset.type, + quantity: qty * multiplier + + + }); + + const cost = parseInt(chk.dataset.cost) || 0; + const dcost = chk.dataset.dcost === "null" ? null : (parseInt(chk.dataset.dcost) || null); + + let itemTotal = 0; + if (dcost !== null && qty >= 3) { + itemTotal = Math.floor(qty / 3) * dcost * 2 + (qty % 3) * cost; + } else { + itemTotal = cost * qty; + } + + const orderType = document.querySelector('input[name="order-type"]:checked').value; + const noLidChecked = document.getElementById('chk-no-lid').checked; + if (orderType === 'noolak' || orderType === 'pason' || orderType === 'bullyang' || noLidChecked) { + itemTotal = 0; + } + + if (!excludedIndices.includes(idx)) { + totalCost += itemTotal; + if (chk.dataset.isFree === "true") hasMarkedFreeShipping = true; + } + + + }); + + // 🟢 금액별 추가 상품 체킹 시 사은품 자동 탑재 + if (document.getElementById('sms-june-promo').checked && configData['SMS_GIFT_RULES']) { + let seen = new Set(); + for (let i = 1; i <= 4; i++) { + const sMin = configData['SMS_GIFT_RULES'][`condition${i}_min`]; + const sMax = configData['SMS_GIFT_RULES'][`condition${i}_max`]; + const sGiftStr = configData['SMS_GIFT_RULES'][`condition${i}_gift`]; + if (!sGiftStr) continue; + + const minV = sMin ? parseInt(sMin) : 0; + const maxV = sMax ? parseInt(sMax) : null; + + if (totalCost >= minV && (maxV === null || totalCost < maxV)) { + if (!seen.has(sGiftStr)) { + seen.add(sGiftStr); + + let foundCode = 'ZZ-0000'; + let foundName = sGiftStr; + let cleanLabel = sGiftStr.trim(); + if (cleanLabel.match(/_([ORBGP])$/i)) cleanLabel = cleanLabel.slice(0, -2).trim(); + + for (const secKey of Object.keys(configData)) { + if (secKey === 'SMS_PRICING' || secKey === 'SMS_GIFT_RULES' || secKey === 'GROUP_NAMES') continue; + if (configData[secKey][sGiftStr]) { + const parts = configData[secKey][sGiftStr].split('|').map(s => s.trim()); + foundCode = parts[0]; + foundName = parts.length > 1 ? parts[1] : cleanLabel; + break; + } + } + + items.push({ + code: foundCode, + name: foundName, + label: cleanLabel, + item_type: 'event', + quantity: 1 + + + }); + } + } + } + } + + let baseShippingVal = parseInt((configData.APP_SETTINGS && configData.APP_SETTINGS.shipping_fee) ? configData.APP_SETTINGS.shipping_fee : 3500); + let extraShippingVal = parseInt((configData.APP_SETTINGS && configData.APP_SETTINGS.extra_shipping_fee) ? configData.APP_SETTINGS.extra_shipping_fee : 3000); + + let pointDeduction = parseInt(document.getElementById('sms-point-deduction').value.replace(/,/g, '')) || 0; + + let totalAfterDiscount = totalCost; + let discount5Amount = 0; + let couponAmount = 0; + if (document.getElementById('sms-coupon').checked) { + couponAmount = getCouponDiscount(totalCost); + } + if (document.getElementById('sms-discount').checked) { + totalAfterDiscount = Math.floor((totalCost * 0.95) / 10) * 10; + discount5Amount = totalCost - totalAfterDiscount; + } + if (couponAmount > 0) { + totalAfterDiscount = Math.max(0, totalAfterDiscount - couponAmount); + } + if (pointDeduction > 0) { + totalAfterDiscount = Math.max(0, totalAfterDiscount - pointDeduction); + } + + let isAutoFree = totalAfterDiscount >= 50000; + let isManualFree = document.getElementById('sms-ship-free').checked; + let isFreeShip = hasMarkedFreeShipping || isAutoFree || isManualFree; + + let shippingCost = 0; + let freeReason = ""; + if (!isFreeShip) { + shippingCost = baseShippingVal; + } else { + if (isAutoFree) freeReason = "5만원 이상"; + else if (isManualFree) freeReason = "수동배송무료"; + else if (hasMarkedFreeShipping) freeReason = "무료배송상품"; + else freeReason = "무료"; + } + + let extraShippingCost = 0; + if (document.getElementById('sms-ship-extra').checked) { + extraShippingCost = extraShippingVal; + if (isFreeShip) freeReason += " / 도서산간 추가"; + } + + const currentOrderType = document.querySelector('input[name="order-type"]:checked').value; + if (currentOrderType === 'noolak' || currentOrderType === 'pason' || currentOrderType === 'bullyang') { + shippingCost = 0; + extraShippingCost = 0; + } + + let finalPayable = totalAfterDiscount + shippingCost + extraShippingCost; + + return { + customer_name: document.getElementById('cust-name').value.trim(), + address: document.getElementById('cust-address').value.trim(), + phone: document.getElementById('cust-phone').value.trim(), + comment: "", + order_type: document.querySelector('input[name="order-type"]:checked').value, + no_lid: document.getElementById('chk-no-lid').checked, + items: items, + total_amount: finalPayable, + raw_total: totalCost, + discount_amount: discount5Amount + couponAmount, + discount_5_amount: discount5Amount, + coupon_amount: couponAmount, + point_deduction: pointDeduction, + shipping_cost: shippingCost, + extra_shipping_cost: extraShippingCost, + free_reason: freeReason + }; + } + + let currentSubmitContext = null; + + function submitOrder(url, isEllen = false) { + const tempPayload = getOrderPayload(); + tempPayload.is_ellen = isEllen; + + if (tempPayload.items.length === 0) { + alert("발주 아이템을 선택해주세요."); return; + } + + if (url.includes('submit')) { + if (!tempPayload.customer_name) { + alert("이름 항목이 누락되었습니다. 입력해주세요."); + return; + } + if (!tempPayload.phone) { + alert("연락처 항목이 누락되었습니다. 입력해주세요."); + return; + } + if (!tempPayload.address) { + alert("주소 항목이 누락되었습니다. 입력해주세요."); + return; + } + } + + // Setup modal + const listContainer = document.getElementById('exclude-items-list'); + listContainer.innerHTML = ''; + + const updateTotalDisplay = () => { + const excludedIndices = Array.from(document.querySelectorAll('.exclude-chk:checked')).map(c => parseInt(c.dataset.idx)); + const livePayload = getOrderPayload(excludedIndices); + + let displayTotal = livePayload.total_amount; + const chkSampleSend = document.getElementById('chk-sample-send'); + if (chkSampleSend && chkSampleSend.checked) { + displayTotal = 0; + } + + document.getElementById('exclude-modal-totalAmount').innerText = displayTotal.toLocaleString(); + + let extraDiv = document.getElementById('exclude-extra-items'); + if(!extraDiv) { + extraDiv = document.createElement('div'); + extraDiv.id = 'exclude-extra-items'; + } + + let extraHTML = ''; + if (livePayload.discount_5_amount > 0) { + extraHTML += ` +
+ 5% 할인 + - ${livePayload.discount_5_amount.toLocaleString()}원 +
`; + } + if (livePayload.coupon_amount > 0) { + extraHTML += ` +
+ 쿠폰 할인 + - ${livePayload.coupon_amount.toLocaleString()}원 +
`; + } + if (livePayload.point_deduction > 0) { + extraHTML += ` +
+ 적립금 차감 + - ${livePayload.point_deduction.toLocaleString()}원 +
`; + } + if (livePayload.shipping_cost > 0) { + extraHTML += ` +
+ 배송비 + + ${livePayload.shipping_cost.toLocaleString()}원 +
`; + } else { + if(!livePayload.free_reason) livePayload.free_reason = "기본"; + extraHTML += ` +
+ 배송비 (무료 - ${livePayload.free_reason}) + 0원 +
`; + } + if (livePayload.extra_shipping_cost > 0) { + extraHTML += ` +
+ 추가 배송비 + + ${livePayload.extra_shipping_cost.toLocaleString()}원 +
`; + } + extraDiv.innerHTML = extraHTML; + listContainer.appendChild(extraDiv); + + document.querySelectorAll('.exclude-chk').forEach(c => { + const priceSpan = c.closest('label').querySelector('.exclude-price-span'); + if (priceSpan) { + if (c.checked) { + priceSpan.style.textDecoration = 'line-through'; + priceSpan.style.textDecorationColor = 'red'; + priceSpan.style.opacity = '0.5'; + } else { + priceSpan.style.textDecoration = 'none'; + priceSpan.style.opacity = '1'; + } + } + }); + }; + + document.querySelectorAll('.chk-order:checked').forEach((chk, idx) => { + const row = chk.closest('.item-row'); + const qty = parseInt(row.querySelector('.qty-order').value) || 1; + const multiplier = parseInt(chk.dataset.multiplier) || 1; + const finalQty = qty * multiplier; + + const cost = parseInt(chk.dataset.cost) || 0; + const dcost = chk.dataset.dcost === "null" ? null : (parseInt(chk.dataset.dcost) || null); + let itemTotal = 0; + if (dcost !== null && qty >= 3) { + itemTotal = Math.floor(qty / 3) * dcost * 2 + (qty % 3) * cost; + } else { + itemTotal = cost * qty; + } + + const orderType = document.querySelector('input[name="order-type"]:checked').value; + const noLidChecked = document.getElementById('chk-no-lid').checked; + if (orderType === 'noolak' || orderType === 'pason' || orderType === 'bullyang' || noLidChecked) { + itemTotal = 0; + } + + let badges = []; + if (orderType === 'noolak') badges.push('누락'); + else if (orderType === 'pason') badges.push('파손'); + else if (orderType === 'bullyang') badges.push('불량'); + if (noLidChecked && chk.dataset.type === 'ORDER_SINGLE') badges.push('뚜껑(X)통(O)'); + + let reasonBadge = badges.length > 0 ? `${badges.join(', ')}` : ''; + + const div = document.createElement('div'); + div.style.display = 'flex'; + div.style.alignItems = 'center'; + div.style.gap = '8px'; + div.style.padding = '8px'; + div.style.background = '#fff'; + div.style.border = '1px solid #cbd5e0'; + div.style.borderRadius = '4px'; + + div.innerHTML = ` + + `; + + const checkbox = div.querySelector('.exclude-chk'); + checkbox.addEventListener('change', updateTotalDisplay); + + listContainer.appendChild(div); + }); + + currentSubmitContext = { url, isEllen }; + + const chkSampleSend = document.getElementById('chk-sample-send'); + if (chkSampleSend) { + chkSampleSend.checked = false; + chkSampleSend.onchange = (e) => { + const isChecked = e.target.checked; + document.querySelectorAll('.exclude-chk').forEach(c => c.checked = isChecked); + updateTotalDisplay(); + }; + } + + updateTotalDisplay(); + document.getElementById('exclude-modal').style.display = 'flex'; + } + + document.getElementById('btn-exclude-cancel').addEventListener('click', () => { + document.getElementById('exclude-modal').style.display = 'none'; + currentSubmitContext = null; + }); + + document.getElementById('btn-exclude-submit').addEventListener('click', () => { + if (!currentSubmitContext) return; + const excludedIndices = Array.from(document.querySelectorAll('.exclude-chk:checked')).map(c => parseInt(c.dataset.idx)); + document.getElementById('exclude-modal').style.display = 'none'; + doSubmitOrder(currentSubmitContext.url, currentSubmitContext.isEllen, excludedIndices); + currentSubmitContext = null; + }); + + function doSubmitOrder(url, isEllen = false, excludedIndices = []) { + const payload = getOrderPayload(excludedIndices); + payload.is_ellen = isEllen; + + const isSpecialType = (payload.order_type === 'noolak' || payload.order_type === 'pason' || payload.order_type === 'bullyang'); + const typeLabelMap = { 'noolak': '누락', 'pason': '파손', 'bullyang': '불량', 'normal': '수동발주', 'ellen': '엘렌' }; + + let finalPayloadAmount = payload.total_amount; + if (isSpecialType) finalPayloadAmount = 0; + + const chkSampleSend = document.getElementById('chk-sample-send'); + if (chkSampleSend && chkSampleSend.checked) { + finalPayloadAmount = 0; + } + + const submitPayload = { ...payload, total_amount: finalPayloadAmount }; + + if (url.includes('submit')) { + const manualTabLink = document.querySelector('li[data-tab="manual-tab"]'); + if (manualTabLink) manualTabLink.click(); + const loader = document.getElementById('sheet-loader'); + if (loader) loader.style.display = 'flex'; + } + + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(submitPayload) + }).then(res => res.json()).then(data => { + if (url.includes('submit')) { + const loader = document.getElementById('sheet-loader'); + if (loader) loader.style.display = 'none'; + } + if (data.status === 'success') { + if (!url.includes('submit')) { + alert(data.message); + } else if (data.timestamp) { + const histData = { + timestamp: data.timestamp, + customer_name: submitPayload.customer_name, + total_amount: isSpecialType ? typeLabelMap[submitPayload.order_type] : submitPayload.total_amount + }; + fetch('/api/manual/history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(histData) + }).then(() => { + if (typeof loadManualHistory === 'function') { + loadManualHistory(); + } + }); + } + + // Invoke browser-native clipboard copy protocol with the newly prepared text + if (data.clipboard_text) { + navigator.clipboard.writeText(data.clipboard_text).catch(err => { + console.error('Clipboard copy failed:', err); + alert("클립보드 접근 권한이 없어 복사하지 못했습니다. (HTTPS 환경 필요)"); + + + }); + } + + document.getElementById('btn-reset').click(); + if (url.includes('submit')) { + const iframe = document.querySelector('#manual-tab iframe'); + if (iframe) iframe.src = iframe.src; + } + } else { + alert("Error: " + data.detail); + } + }).catch(err => { + if (url.includes('submit')) { + const loader = document.getElementById('sheet-loader'); + if (loader) loader.style.display = 'none'; + } + alert("요청 중 오류 발생: " + err); + + + }); + } + + document.getElementById('btn-submit-normal').addEventListener('click', () => submitOrder('/api/order/submit', false)); + document.getElementById('btn-submit-ellen').addEventListener('click', () => submitOrder('/api/order/submit', true)); + + document.getElementById('btn-download-sheet').addEventListener('click', () => { + if (!confirm("수동발주로 입력한 자료는 엑셀 파일로 저장되며, 입력된 수동발주 내용은 사라집니다.\n진행하시겠습니까?")) return; + + const btn = document.getElementById('btn-download-sheet'); + btn.innerText = '저장 중...'; + btn.disabled = true; + + const progressContainer = document.getElementById('download-progress-container'); + const progressBar = document.getElementById('download-progress-bar'); + const progressText = document.getElementById('download-progress-text'); + + progressContainer.style.display = 'block'; + progressBar.style.width = '0%'; + progressText.innerText = '0%'; + + let progress = 0; + const interval = setInterval(() => { + if (progress < 90) { + progress += Math.floor(Math.random() * 8) + 4; + if (progress > 90) progress = 90; + progressBar.style.width = progress + '%'; + progressText.innerText = progress + '%'; + } + }, 500); + + fetch('/api/sheet/download_and_clear', { method: 'POST' }) + .then(res => { + if (!res.ok) throw new Error("서버에서 오류가 발생했습니다."); + return res.blob(); + }) + .then(blob => { + // Determine save filename client-side + const now = new Date(); + const weekdays = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"]; + const dayStr = weekdays[now.getDay()]; + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + const fileName = `${mm}월 ${dd}일 (${dayStr}) 5 수동발주.xlsx`; + + // Spawn a temporary web link to fetch the constructed Blob and trigger "Save as" + const downloadUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = downloadUrl; + a.download = fileName; + document.body.appendChild(a); + a.click(); + + // Cleanup Blob immediately + window.URL.revokeObjectURL(downloadUrl); + document.body.removeChild(a); + + clearInterval(interval); + progressBar.style.width = '100%'; + progressText.innerText = '100%'; + + setTimeout(() => { + alert("구글 엑셀 파일이 성공적으로 다운로드되고 시트가 초기화되었습니다."); + progressContainer.style.display = 'none'; + btn.innerText = '엑셀로 저장'; + btn.disabled = false; + + const iframe = document.querySelector('#manual-tab iframe'); + if (iframe) iframe.src = iframe.src; + }, 400); + }) + .catch(err => { + clearInterval(interval); + alert("네트워크 오류/파일 저장 실패: " + err); + progressContainer.style.display = 'none'; + btn.innerText = '엑셀로 저장'; + btn.disabled = false; + + + }); + + + }); + + document.getElementById('btn-reset').addEventListener('click', () => { + document.querySelectorAll('.chk-order').forEach(c => { + c.checked = false; + const row = c.closest('.item-row'); + if (row) row.style.backgroundColor = ''; + }); + document.querySelectorAll('.qty-order').forEach(i => i.value = "1"); + document.getElementById('cust-name').value = ''; + document.getElementById('cust-phone').value = ''; + document.getElementById('cust-address').value = ''; + document.getElementById('chk-no-lid').checked = false; + document.getElementById('chk-no-lid').disabled = true; + document.querySelector('input[name="order-type"][value="normal"]').checked = true; + + // sms reset as well + document.getElementById('sms-discount').checked = false; + document.getElementById('sms-discount').disabled = false; + document.getElementById('sms-coupon').checked = false; + document.getElementById('sms-coupon').disabled = false; + document.getElementById('sms-june-promo').checked = false; + document.getElementById('sms-ship-free').checked = false; + document.getElementById('sms-ship-extra').checked = false; + const ptInput = document.getElementById('sms-point-deduction'); + if (ptInput) ptInput.value = ''; + document.getElementById('sms-preview').innerHTML = ''; + + + }); + + // ============================================ + // SMS CALCULATE WITH MATCHED PRICING + // ============================================ + function getAmountGiftText(orderAmount) { + if (!configData['SMS_GIFT_RULES']) return ""; + let matched = []; + let rules = []; + for (let i = 1; i <= 4; i++) { + const sMin = configData['SMS_GIFT_RULES'][`condition${i}_min`]; + const sMax = configData['SMS_GIFT_RULES'][`condition${i}_max`]; + const sGift = configData['SMS_GIFT_RULES'][`condition${i}_gift`]; + if (!sGift) continue; + + const minV = sMin ? parseInt(sMin) : 0; + const maxV = sMax ? parseInt(sMax) : null; + rules.push({ + min: minV, max: maxV, gift: sGift + + }); + } + + let seen = new Set(); + rules.forEach(r => { + if (orderAmount < r.min) return; + if (r.max !== null && orderAmount >= r.max) return; + if (!seen.has(r.gift)) { + seen.add(r.gift); + matched.push(r.gift); + } + + + }); + + if (matched.length === 0) return ""; + return "\n(금액별 사은품 증정)\n" + matched.join("\n") + "\n"; + } + + document.getElementById('btn-sms-calc').addEventListener('click', () => { + let totalCost = 0; + let orderListStr = ""; + let hasMarkedFreeShipping = false; + + const checkedItems = document.querySelectorAll('.chk-order:checked'); + if (checkedItems.length === 0) { alert("선택된 상품이 없습니다."); return; } + + checkedItems.forEach(chk => { + const row = chk.closest('.item-row'); + const qty = parseInt(row.querySelector('.qty-order').value) || 1; + const orderLabel = chk.dataset.label; + + const cost = parseInt(chk.dataset.cost) || 0; + const dcost = chk.dataset.dcost === "null" ? null : (parseInt(chk.dataset.dcost) || null); + const isFree = chk.dataset.isFree === "true"; + + if (isFree) hasMarkedFreeShipping = true; + + let itemTotal = 0; + if (dcost !== null && qty >= 3) { + itemTotal = Math.floor(qty / 3) * dcost * 2 + (qty % 3) * cost; + } else { + itemTotal = cost * qty; + } + + const orderType = document.querySelector('input[name="order-type"]:checked').value; + const noLidChecked = document.getElementById('chk-no-lid').checked; + if (orderType === 'noolak' || orderType === 'pason' || orderType === 'bullyang' || noLidChecked) { + itemTotal = 0; + } + + const cleanLabel = orderLabel.replace(/^\*/, '').trim(); + let dispName = cleanLabel.replace("오리 ", ""); + let smsQty = qty; + + const matchName = dispName.match(/_(\d+)개$/); + if (matchName) { + smsQty = qty * parseInt(matchName[1], 10); + dispName = dispName.replace(/_\d+개$/, '').trim(); + } + + if (cost === 0) { + orderListStr += `${dispName} (가격미입력)\n`; + } else { + orderListStr += `${dispName} ${smsQty}개 ${itemTotal.toLocaleString()}원\n`; + } + totalCost += itemTotal; + + + }); + + let baseShippingVal = parseInt((configData.APP_SETTINGS && configData.APP_SETTINGS.shipping_fee) ? configData.APP_SETTINGS.shipping_fee : 3500); + let extraShippingVal = parseInt((configData.APP_SETTINGS && configData.APP_SETTINGS.extra_shipping_fee) ? configData.APP_SETTINGS.extra_shipping_fee : 3000); + let pointDeduction = parseInt(document.getElementById('sms-point-deduction').value.replace(/,/g, '')) || 0; + + let totalAfterDiscount = totalCost; + let discount5Amount = 0; + let couponAmount = 0; + if (document.getElementById('sms-coupon').checked) { + couponAmount = getCouponDiscount(totalCost); + } + if (document.getElementById('sms-discount').checked) { + totalAfterDiscount = Math.floor((totalCost * 0.95) / 10) * 10; + discount5Amount = totalCost - totalAfterDiscount; + } + if (couponAmount > 0) { + totalAfterDiscount = Math.max(0, totalAfterDiscount - couponAmount); + } + if (pointDeduction > 0) { + totalAfterDiscount = Math.max(0, totalAfterDiscount - pointDeduction); + } + + let junePromoText = ""; + if (document.getElementById('sms-june-promo').checked) { + junePromoText = getAmountGiftText(totalCost); + } + + let isAutoFree = totalAfterDiscount >= 50000; + let isManualFree = document.getElementById('sms-ship-free').checked; + let isFreeShip = hasMarkedFreeShipping || isAutoFree || isManualFree; + + let shippingCost = 0; + if (!isFreeShip) { + shippingCost = baseShippingVal; + } + + let extraShippingCost = 0; + if (document.getElementById('sms-ship-extra').checked) { + extraShippingCost = extraShippingVal; + } + + const currentOrderType = document.querySelector('input[name="order-type"]:checked').value; + const noLidChecked = document.getElementById('chk-no-lid').checked; + if (currentOrderType === 'noolak' || currentOrderType === 'pason' || currentOrderType === 'bullyang' || noLidChecked) { + shippingCost = 0; + extraShippingCost = 0; + } + + let finalPayable = totalAfterDiscount + shippingCost + extraShippingCost; + + let depositVal = `${finalPayable.toLocaleString()}원`; + if (hasMarkedFreeShipping) { + if (extraShippingCost > 0) depositVal += "(무료배송, 추가배송비만 적용)"; + else depositVal += "(무료배송 상품 포함)"; + } + else if (isFreeShip) depositVal += "(무료배송)"; + let depositLine = `입금 금액: ${depositVal}`; + + const defaultHeader = "안녕하세요, 미라네 주방입니다! 😊 \n주문내용 확인 부탁드립니다."; + const defaultFooter = "1. 아래 계좌로 입금해 주세요.\n\n▶입금 금액: {최종금액} \n▶입금 계좌 (농협): 317-0025-1542-51\n▶예금주: (주)더블엑스 코퍼레이션\n\n2. 입금 후 [성함/주소/연락처]를 남겨주세요.\n\n[배송 안내]\n■오후 1시 이전 입금 건은 당일 발송됩니다.\n★이벤트 상품 주문시 1~2일 정도 늦어질 수 있으니 너그러운 양해 부탁드립니다."; + const rules = configData['SMS_GIFT_RULES'] || {}; + + let headerText = rules['header_text'] ? rules['header_text'].replace(/\\n/g, '\n') : defaultHeader; + let footerText = rules['footer_text'] ? rules['footer_text'].replace(/\\n/g, '\n') : defaultFooter; + + footerText = footerText.replace(/{최종금액}/g, depositVal); + + let msg = `${headerText}\n\n*****주문 내용*****\n${orderListStr}${junePromoText}\n주문 금액: ${totalCost.toLocaleString()}원\n`; + if (discount5Amount > 0) msg += `할인 금액: -${discount5Amount.toLocaleString()}원(5%)\n`; + if (couponAmount > 0) msg += `쿠폰 할인: -${couponAmount.toLocaleString()}원\n`; + if (pointDeduction > 0) msg += `적립금 차감: -${pointDeduction.toLocaleString()}원\n`; + if (shippingCost > 0) msg += ` 택배비: ${shippingCost.toLocaleString()}원\n`; + if (extraShippingCost > 0) msg += `추가배송비: ${extraShippingCost.toLocaleString()}원\n`; + + msg += `==========================\n${depositLine}\n\n${footerText}`; + + const previewDiv = document.getElementById('sms-preview'); + previewDiv.innerHTML = msg; + navigator.clipboard.writeText(previewDiv.innerText).catch(err => { + console.error('Clipboard copy failed:', err); + }); + + + // Save History + const now = new Date(); + const stamp = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`; + + const savedItems = []; + checkedItems.forEach(chk => { + const row = chk.closest('.item-row'); + const qty = parseInt(row.querySelector('.qty-order').value) || 1; + savedItems.push({ + code: chk.dataset.code, + label: chk.dataset.label, + qty: qty + + + }); + + + }); + + const histData = { + timestamp: stamp, + deposit_amount_str: finalPayable.toLocaleString() + "원", + custName: document.getElementById('cust-name').value.trim(), + custPhone: document.getElementById('cust-phone').value.trim(), + custAddress: document.getElementById('cust-address').value.trim(), + orderType: document.querySelector('input[name="order-type"]:checked').value, + noLid: document.getElementById('chk-no-lid').checked, + items: savedItems, + smsOpts: { + discount: document.getElementById('sms-discount').checked, + junePromo: document.getElementById('sms-june-promo').checked, + shipExtra: document.getElementById('sms-ship-extra').checked, + shipFree: document.getElementById('sms-ship-free').checked, + pointDeduction: pointDeduction + }, + previewText: msg + }; + + fetch('/api/sms/history', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(histData) + }).then(() => loadSmsHistory()); + + + }); + + // ============================================ + // SMS HISTORY + // ============================================ + function loadSmsHistory() { + const u = document.getElementById('sms-history-list'); + u.innerHTML = ''; + fetch('/api/sms/history').then(r => r.json()).then(data => { + data.slice().reverse().forEach((d, idx) => { + const actualIdx = data.length - 1 - idx; + const li = document.createElement('li'); + li.style.cursor = 'pointer'; + li.style.padding = '4px 0'; + li.style.borderBottom = '1px solid #eee'; + li.onclick = () => window.loadHistoryItem(actualIdx); + + const spanDate = document.createElement('span'); + spanDate.textContent = `${d.timestamp} | ${d.deposit_amount_str}`; + li.appendChild(spanDate); + + const spanDel = document.createElement('span'); + spanDel.className = 'action-text ms-2'; + spanDel.style.marginLeft = '8px'; + spanDel.title = '삭제'; + spanDel.textContent = '❌'; + spanDel.onclick = (e) => window.deleteHistory(actualIdx, e); + li.appendChild(spanDel); + + u.appendChild(li); + + + }); + + + }); + } + + window.loadManualHistory = function () { + const u = document.getElementById('manual-history-list'); + if (!u) return; + u.innerHTML = ''; + fetch('/api/manual/history').then(r => r.json()).then(data => { + let totalCount = data.length; + let totalAmount = 0; + data.forEach((d) => { + if (typeof d.total_amount === 'number') totalAmount += d.total_amount; + else if (typeof d.total_amount === 'string' && !isNaN(parseInt(d.total_amount))) totalAmount += parseInt(d.total_amount); + }); + + let txtCount = document.getElementById('manual-total-count'); + let txtAmount = document.getElementById('manual-total-amount'); + if (txtCount) txtCount.textContent = totalCount; + if (txtAmount) txtAmount.textContent = totalAmount.toLocaleString(); + + data.slice().reverse().forEach((d) => { + const li = document.createElement('li'); + li.style.padding = '4px 0'; + li.style.borderBottom = '1px solid #eee'; + li.style.display = 'flex'; + li.style.justifyContent = 'space-between'; + li.style.alignItems = 'center'; + li.style.fontSize = '0.9rem'; + + const spanInfo = document.createElement('span'); + const timeStr = d.timestamp.split(' ')[1] || d.timestamp; + let amtDisplay = (typeof d.total_amount === 'number' || (typeof d.total_amount === 'string' && !isNaN(parseInt(d.total_amount)))) + ? parseInt(d.total_amount).toLocaleString() + '원' + : d.total_amount; + spanInfo.textContent = `${timeStr} | ${d.customer_name} | ${amtDisplay}`; + li.appendChild(spanInfo); + + const spanDel = document.createElement('span'); + spanDel.className = 'action-text ms-2'; + spanDel.style.marginLeft = '8px'; + spanDel.style.cursor = 'pointer'; + spanDel.title = '삭제'; + spanDel.textContent = '❌'; + spanDel.onclick = (e) => { + e.stopPropagation(); + if (confirm("🚨 [경고] 이 수동발주 기록을 삭제하시겠습니까?\n\n(구글 시트에서도 함께 삭제됩니다!)")) { + const btn = e.target; + btn.textContent = '⏳'; + btn.style.pointerEvents = 'none'; + fetch(`/api/manual/history/${encodeURIComponent(d.timestamp)}`, { method: 'DELETE' }) + .then(() => { + window.loadManualHistory(); + const iframe = document.querySelector('#manual-tab iframe'); + if (iframe) iframe.src = iframe.src; + }) + .catch(() => { + alert("삭제 중 오류가 발생했습니다."); + btn.textContent = '❌'; + btn.style.pointerEvents = 'auto'; + }); + } + }; + li.appendChild(spanDel); + + u.appendChild(li); + }); + }).catch(err => console.error("Error loading manual history", err)); + } + + window.loadHistoryItem = function (idx) { + fetch('/api/sms/history').then(r => r.json()).then(data => { + if (idx < 0 || idx >= data.length) return; + const d = data[idx]; + + document.getElementById('btn-reset').click(); + + if (d.custName !== undefined) document.getElementById('cust-name').value = d.custName; + if (d.custPhone !== undefined) document.getElementById('cust-phone').value = d.custPhone; + if (d.custAddress !== undefined) document.getElementById('cust-address').value = d.custAddress; + if (d.orderType !== undefined) { + const rb = document.querySelector(`input[name="order-type"][value="${d.orderType}"]`); + if (rb) rb.checked = true; + } + + // Fallback for Google Sheets history where items array is missing but items_str exists + if ((!d.items || !Array.isArray(d.items)) && d.items_str) { + d.items = []; + const parts = d.items_str.split(','); + parts.forEach(part => { + const match = part.trim().match(/^(.*)[\[\(](.*?)[\]\)]\s*(\d+)개$/); + if (match) { + d.items.push({ + label: match[1].trim(), + code: match[2].trim(), + qty: parseInt(match[3]) + }); + } + }); + } + + if (d.items && Array.isArray(d.items)) { + d.items.forEach(item => { + let chk = Array.from(document.querySelectorAll('.chk-order:not(:checked)')).find(c => c.dataset.code === item.code && c.dataset.label === item.label); + if (!chk) { + chk = Array.from(document.querySelectorAll('.chk-order:not(:checked)')).find(c => c.dataset.code === item.code || c.dataset.label === item.label); + } + if (chk) { + chk.checked = true; + const row = chk.closest('.item-row'); + if (row) { + const sel = row.querySelector('.qty-order'); + if (sel) { + if (sel.tagName === 'SELECT') { + const optionExists = Array.from(sel.options).some(opt => opt.value == item.qty); + if (!optionExists) { + const input = document.createElement('input'); + input.type = 'number'; + input.min = '1'; + input.value = item.qty; + input.className = 'qty-order'; + sel.replaceWith(input); + } else { + sel.value = item.qty; + } + } else { + sel.value = item.qty; + } + } + } + } + + + }); + + const anyChk = document.querySelector('.chk-order'); + if (anyChk) { + anyChk.dispatchEvent(new Event('change')); + } + + if (d.noLid !== undefined) document.getElementById('chk-no-lid').checked = d.noLid; + } + + if (d.smsOpts) { + document.getElementById('sms-discount').checked = !!d.smsOpts.discount; + document.getElementById('sms-june-promo').checked = !!d.smsOpts.junePromo; + document.getElementById('sms-ship-extra').checked = !!d.smsOpts.shipExtra; + document.getElementById('sms-ship-free').checked = !!d.smsOpts.shipFree; + if (d.smsOpts.pointDeduction) { + const ptInput = document.getElementById('sms-point-deduction'); + if(ptInput) ptInput.value = parseInt(d.smsOpts.pointDeduction).toLocaleString(); + } else { + const ptInput = document.getElementById('sms-point-deduction'); + if(ptInput) ptInput.value = ''; + } + } + + if (d.previewText !== undefined) { + document.getElementById('sms-preview').innerHTML = d.previewText; + } + + + }); + } + + window.deleteHistory = function (idx, e) { + e.stopPropagation(); + if (confirm("이 기록을 삭제하시겠습니까?")) { + fetch(`/api/sms/history/${idx}`, { method: 'DELETE' }).then(() => loadSmsHistory()); + } + } + + document.getElementById('btn-sms-clear-all').addEventListener('click', () => { + if (confirm("정말로 모든 기록을 삭제하시겠습니까?")) { + fetch('/api/sms/history', { method: 'DELETE' }).then(() => loadSmsHistory()); + } + + + }); + + // ============================================ + // GIFT TAB + // ============================================ + function buildGiftTab(data) { + const container = document.getElementById('gift-rules-container'); + container.innerHTML = ''; // 중복 방지를 위해 기존 내용 완전히 비우기 + const giftsRaw = data['ORDER_GIFT'] ? (data['ORDER_GIFT'].__order || Object.keys(data['ORDER_GIFT'])) : []; + const gifts = giftsRaw.filter(k => k !== '__order'); + const rules = data['SMS_GIFT_RULES'] || {}; + + for (let i = 1; i <= 4; i++) { + const card = document.createElement('div'); + card.className = 'glass-card gift-rule-card'; + + const title = document.createElement('h3'); + title.textContent = `조건 ${i}`; + + const minV = rules[`condition${i}_min`] || ''; + const maxV = rules[`condition${i}_max`] || ''; + const selGift = rules[`condition${i}_gift`] || ''; + + const fmt = (v) => { + if (!v) return ''; + let n = parseInt(String(v).replace(/,/g, ''), 10); + return isNaN(n) ? '' : n.toLocaleString(); + }; + const inputs = document.createElement('div'); + inputs.className = 'rule-inputs'; + inputs.innerHTML = ` + 이상 + 미만 + + `; + + const radioList = document.createElement('div'); + radioList.className = 'gift-radio-list'; + + // None option + radioList.innerHTML += ``; + + gifts.forEach(g => { + radioList.innerHTML += ``; + + + }); + + card.appendChild(title); + card.appendChild(inputs); + card.appendChild(radioList); + container.appendChild(card); + } + + document.querySelectorAll('.btn-clear-rule').forEach(btn => { + btn.addEventListener('click', e => { + const i = e.target.dataset.idx; + document.getElementById(`rule${i}-min`).value = ''; + document.getElementById(`rule${i}-max`).value = ''; + document.querySelector(`input[name="rule${i}-gift"][value=""]`).checked = true; + + + }); + + + }); + + const defaultHeader = "안녕하세요, 미라네 주방입니다! 😊 \n주문내용 확인 부탁드립니다."; + const defaultFooter = "1. 아래 계좌로 입금해 주세요.\n\n▶입금 금액: {최종금액} \n▶입금 계좌 (농협): 317-0025-1542-51\n▶예금주: (주)더블엑스 코퍼레이션\n\n2. 입금 후 [성함/주소/연락처]를 남겨주세요.\n\n[배송 안내]\n■오후 1시 이전 입금 건은 당일 발송됩니다.\n★이벤트 상품 주문시 1~2일 정도 늦어질 수 있으니 너그러운 양해 부탁드립니다."; + + let hText = rules['header_text'] ? rules['header_text'].replace(/\\n/g, '\n') : defaultHeader; + let fText = rules['footer_text'] ? rules['footer_text'].replace(/\\n/g, '\n') : defaultFooter; + + document.getElementById('sms-header-text').value = hText; + document.getElementById('sms-footer-text').value = fText; + + let appFont = data['APP_SETTINGS'] ? data['APP_SETTINGS']['font'] : ''; + const fontSelect = document.getElementById('app-font-select'); + if (fontSelect && appFont) { + fontSelect.value = appFont; + } + + let baseShippingVal = data['APP_SETTINGS'] && data['APP_SETTINGS']['shipping_fee'] ? data['APP_SETTINGS']['shipping_fee'] : '3500'; + let extraShippingVal = data['APP_SETTINGS'] && data['APP_SETTINGS']['extra_shipping_fee'] ? data['APP_SETTINGS']['extra_shipping_fee'] : '3000'; + + document.getElementById('setting-ship-base').value = parseInt(baseShippingVal).toLocaleString(); + document.getElementById('setting-ship-extra').value = parseInt(extraShippingVal).toLocaleString(); + + const lblShipExtra = document.getElementById('lbl-ship-extra'); + if (lblShipExtra) { + lblShipExtra.textContent = '추가배송비 ' + parseInt(extraShippingVal).toLocaleString() + '원'; + } + + const btnGiftSave = document.getElementById('btn-gift-save'); + // Reset color in case it was red + btnGiftSave.style.backgroundColor = ''; + + const markUnsaved = () => { + btnGiftSave.style.backgroundColor = '#e53e3e'; + }; + + // Format money fields on input + document.querySelectorAll('input[id^="rule"][id$="-min"], input[id^="rule"][id$="-max"], #setting-ship-base, #setting-ship-extra').forEach(el => { + el.addEventListener('input', e => { + let val = e.target.value.replace(/[^0-9]/g, ''); + e.target.value = val ? parseInt(val, 10).toLocaleString() : ''; + + + }); + + + }); + + // Inputs within gift tab + document.querySelectorAll('#gift-tab input, #gift-tab textarea, #gift-tab select').forEach(el => { + el.removeEventListener('input', markUnsaved); + el.removeEventListener('change', markUnsaved); + el.addEventListener('input', markUnsaved); + el.addEventListener('change', markUnsaved); + + + }); + + document.querySelectorAll('.btn-clear-rule').forEach(btn => { + btn.addEventListener('click', markUnsaved); + + + }); + } + + const btnGiftSave = document.getElementById('btn-gift-save'); + if (btnGiftSave) { + btnGiftSave.addEventListener('click', async () => { + const payload = { + rules: {}, + app_settings: { + shipping_fee: document.getElementById('setting-ship-base').value.replace(/[^0-9]/g, ''), + extra_shipping_fee: document.getElementById('setting-ship-extra').value.replace(/[^0-9]/g, '') + } + }; + for (let i = 1; i <= 10; i++) { + const minEl = document.getElementById(`rule${i}-min`); + const maxEl = document.getElementById(`rule${i}-max`); + const itemEl = document.getElementById(`rule${i}-item`); + if (minEl && maxEl && itemEl) { + payload.rules[`rule${i}_min`] = minEl.value.replace(/[^0-9]/g, ''); + payload.rules[`rule${i}_max`] = maxEl.value.replace(/[^0-9]/g, ''); + payload.rules[`rule${i}_item`] = itemEl.value; + } + } + + const hText = document.getElementById('sms-header-text'); + if (hText) payload.rules['header_text'] = hText.value; + const fText = document.getElementById('sms-footer-text'); + if (fText) payload.rules['footer_text'] = fText.value; + const fontSel = document.getElementById('app-font-select'); + if (fontSel) payload.app_settings.font = fontSel.value; + + try { + const res = await fetch('/api/gift/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (res.ok) { + alert('저장되었습니다.'); + btnGiftSave.style.backgroundColor = ''; + fetch('/api/config').then(r => r.json()).then(d => { + configData = d; + const oc = document.getElementById('order-items-container'); + if (oc) oc.innerHTML = ''; + buildOrderTab(configData); + buildGiftTab(configData); + buildProductTab(configData); + }); + } else { + alert('저장 실패'); + } + } catch (e) { + alert('오류 발생'); + } + }); + } + + const formRec = document.getElementById('form-return-receive'); + if (formRec) { + formRec.addEventListener('submit', async (e) => { + e.preventDefault(); + const payload = { + receive_date: document.getElementById('rec-date').value, + tracking_no: document.getElementById('rec-tracking-no').value, + mall: document.getElementById('rec-mall').value, + receiver_name: document.getElementById('rec-name').value, + phone: document.getElementById('rec-phone').value, + address: document.getElementById('rec-address').value, + remarks: document.getElementById('rec-remarks').value, + receive_status: document.getElementById('rec-status').value + }; + const recId = document.getElementById('rec-id'); + if (recId && recId.value) { + payload.id = parseInt(recId.value, 10); + } + try { + const res = await fetch('/api/return/receive', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + if(res.ok) { + formRec.reset(); + document.getElementById('rec-date').value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]; + if(recId) recId.value = ''; + if (typeof loadReturnReceivings === 'function') { + loadReturnReceivings(); + } + if (typeof loadReturnRequests === 'function') { + loadReturnRequests(); + } + } else { + alert('등록 실패'); + } + } catch(e) { alert('오류 발생'); } + }); + } + + // Bulk receive logic + const btnBulkReceive = document.getElementById('btn-bulk-receive'); + const bulkReceiveModal = document.getElementById('bulk-receive-modal'); + const btnBulkCancel = document.getElementById('btn-bulk-cancel'); + const btnBulkSubmit = document.getElementById('btn-bulk-submit'); + const bulkTrackingNumbers = document.getElementById('bulk-tracking-numbers'); + const bulkPrefixInput = document.getElementById('bulk-prefix-input'); + const btnBulkPrefixSave = document.getElementById('btn-bulk-prefix-save'); + + if (btnBulkReceive && bulkReceiveModal) { + btnBulkReceive.addEventListener('click', () => { + bulkReceiveModal.style.display = 'flex'; + if (bulkPrefixInput) { + bulkPrefixInput.value = localStorage.getItem('bulkTrackingPrefix') || ''; + } + }); + + if (btnBulkPrefixSave && bulkPrefixInput) { + bulkPrefixInput.addEventListener('input', () => { + btnBulkPrefixSave.style.backgroundColor = '#e53e3e'; + }); + + btnBulkPrefixSave.addEventListener('click', () => { + const prefix = bulkPrefixInput.value.trim(); + localStorage.setItem('bulkTrackingPrefix', prefix); + btnBulkPrefixSave.style.backgroundColor = '#4a5568'; + alert('저장되었습니다.'); + }); + } + + btnBulkCancel.addEventListener('click', () => { + bulkReceiveModal.style.display = 'none'; + bulkTrackingNumbers.value = ''; + }); + + if (bulkTrackingNumbers) { + bulkTrackingNumbers.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.key === 'Enter') { + e.preventDefault(); + if (btnBulkSubmit) btnBulkSubmit.click(); + } + }); + } + + btnBulkSubmit.addEventListener('click', async () => { + const lines = bulkTrackingNumbers.value.split('\n'); + const savedPrefix = localStorage.getItem('bulkTrackingPrefix') || ''; + + const trackingNumbers = lines.map(l => { + let val = l.trim(); + if (val && savedPrefix && val.length === 8) { + return savedPrefix + val; + } + return val; + }).filter(l => l); + + if (trackingNumbers.length === 0) { + alert('송장번호를 입력해주세요.'); + return; + } + + btnBulkSubmit.disabled = true; + btnBulkSubmit.textContent = '처리 중...'; + + try { + const res = await fetch('/api/return/receive/bulk', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ tracking_numbers: trackingNumbers }) + }); + + if (res.ok) { + const data = await res.json(); + bulkReceiveModal.style.display = 'none'; + bulkTrackingNumbers.value = ''; + if (typeof loadReturnReceivings === 'function') { + loadReturnReceivings(); + } + if (typeof loadReturnRequests === 'function') { + loadReturnRequests(); + } + } else { + alert('일괄 등록 실패'); + } + } catch (e) { + alert('오류 발생'); + } finally { + btnBulkSubmit.disabled = false; + btnBulkSubmit.textContent = '일괄 등록'; + } + }); + } + + // Load Lists + async function loadReturnRequests() { + const tbody = document.getElementById('tbody-return-requests'); + if(!tbody) return; + try { + const res = await fetch('/api/return/request'); + const recRes = await fetch('/api/return/receive'); + if(res.ok && recRes.ok) { + const data = await res.json(); + const recData = await recRes.json(); + const receivedTrackingNos = new Set((recData.data || []).map(r => r.tracking_no).filter(t => t)); + + tbody.innerHTML = ''; + if(data.data) { + + const excludeReceived = document.getElementById('chk-exclude-received')?.checked; + let displayData = data.data; + if (excludeReceived) { + displayData = displayData.filter(i => !(i.tracking_no && receivedTrackingNos.has(i.tracking_no))); + } + displayData.forEach(item => { + const isReceived = item.tracking_no && receivedTrackingNos.has(item.tracking_no); + const tr = document.createElement('tr'); + tr.style.cursor = 'pointer'; + let displayRemarks = item.remarks || ''; + if (isReceived) { + tr.style.textDecoration = 'line-through'; + tr.style.color = '#a0aec0'; + displayRemarks += ' [입고완료]'; + } + tr.addEventListener('click', () => { + document.getElementById('req-date').value = item.request_date || ''; + document.getElementById('req-type').value = item.request_type || ''; + document.getElementById('req-name').value = item.receiver_name || ''; + document.getElementById('req-address').value = item.address || ''; + document.getElementById('req-phone').value = item.phone || ''; + document.getElementById('req-tracking-no').value = item.tracking_no || ''; + document.getElementById('req-mall').value = item.mall || ''; + document.getElementById('req-remarks').value = item.remarks || ''; + const reqId = document.getElementById('req-id'); + if(reqId) reqId.value = item.id; + }); + const todayStr = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]; + if (item.request_date && !item.request_date.startsWith(todayStr)) { + tr.style.backgroundColor = '#cbd5e0'; + } + const rowDataJson = JSON.stringify(item).replace(/'/g, "'").replace(/"/g, """); + tr.innerHTML = ` + + + + ${formatShortDate(item.request_date)} + ${item.receiver_name} + ${item.phone} + ${item.tracking_no} + ${item.request_type} + ${displayRemarks} + + + + `; + tbody.appendChild(tr); + }); + + + const chkAll = document.getElementById('chk-all-req'); + if (chkAll) { + chkAll.checked = false; + chkAll.onchange = (e) => { + document.querySelectorAll('.chk-req-row').forEach(cb => cb.checked = e.target.checked); + }; + } + + document.querySelectorAll('.btn-delete-req').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + if(confirm('정말로 삭제하시겠습니까?')) { + try { + const delRes = await fetch(`/api/return/request/${e.target.dataset.id}`, {method: 'DELETE'}); + if(delRes.ok) { + if (typeof loadReturnRequests === 'function') loadReturnRequests(); + if (typeof loadReturnReceivings === 'function') loadReturnReceivings(); + } + } catch(err) { alert('삭제 실패'); } + } + }); + }); + } + } + } catch(e) {} + } + + async function loadReturnReceivings() { + const tbody = document.getElementById('tbody-return-receivings'); + if(!tbody) return; + try { + const res = await fetch('/api/return/receive'); + if(res.ok) { + const data = await res.json(); + tbody.innerHTML = ''; + if(data.data) { + + const excludeCompleted = document.getElementById('chk-exclude-completed')?.checked; + let displayData = data.data; + if (excludeCompleted) { + displayData = displayData.filter(i => i.receive_status !== '환불완료' && i.receive_status !== '교환완료'); + } + displayData.forEach(item => { + const tr = document.createElement('tr'); + tr.style.cursor = 'pointer'; + tr.addEventListener('click', () => { + document.getElementById('rec-date').value = item.receive_date || ''; + document.getElementById('rec-tracking-no').value = item.tracking_no || ''; + document.getElementById('rec-mall').value = item.mall || ''; + document.getElementById('rec-name').value = item.receiver_name || ''; + document.getElementById('rec-phone').value = item.phone || ''; + document.getElementById('rec-address').value = item.address || ''; + document.getElementById('rec-remarks').value = item.remarks || ''; + const recStatus = document.getElementById('rec-status'); + if (recStatus) recStatus.value = item.receive_status || '환불 대기'; + const recId = document.getElementById('rec-id'); + if (recId) recId.value = item.id; + }); + const todayStr = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]; + if (item.receive_date && !item.receive_date.startsWith(todayStr)) { + tr.style.backgroundColor = '#cbd5e0'; + } + const isCompleted = (item.receive_status === '환불완료' || item.receive_status === '교환완료'); + const statusStyle = isCompleted ? 'color: #e53e3e; font-weight: bold;' : ''; + tr.innerHTML = ` + ${formatShortDate(item.receive_date)} + ${item.receiver_name} + ${item.phone} + ${item.tracking_no} + ${item.mall || ''} + ${item.receive_status} + ${(item.remarks || '').replace(/맞교환/g, `맞교환`)} + + + + `; + tbody.appendChild(tr); + }); + + document.querySelectorAll('.btn-delete-rec').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + if(confirm('정말로 삭제하시겠습니까?')) { + try { + const delRes = await fetch(`/api/return/receive/${e.target.dataset.id}`, {method: 'DELETE'}); + if(delRes.ok) { + if (typeof loadReturnReceivings === 'function') loadReturnReceivings(); + if (typeof loadReturnRequests === 'function') loadReturnRequests(); + } + } catch(err) { alert('삭제 실패'); } + } + }); + }); + } + } + } catch(e) {} + } + + // Initial load + const returnTabMenu = document.querySelector('[data-tab="return-tab"]'); + + const chkExcludeCompleted = document.getElementById('chk-exclude-completed'); + if (chkExcludeCompleted) { + chkExcludeCompleted.addEventListener('change', () => { + loadReturnReceivings(); + }); + } + + const chkExcludeReceived = document.getElementById('chk-exclude-received'); + if (chkExcludeReceived) { + chkExcludeReceived.addEventListener('change', () => { + loadReturnRequests(); + }); + } + + if(returnTabMenu) { + returnTabMenu.addEventListener('click', () => { + loadReturnRequests(); + loadReturnReceivings(); + }); + + // Load immediately on page load + setTimeout(() => { + loadReturnRequests(); + loadReturnReceivings(); + }, 500); + } + const reqTrackingNo = document.getElementById('req-tracking-no'); + const recTrackingNo = document.getElementById('rec-tracking-no'); + + // Auto-paste 12-digit tracking number on click + async function handleTrackingClick(e) { + try { + const text = await navigator.clipboard.readText(); + if (!text) return; + const cleanText = text.replace(/[-\s]/g, ''); + // Check if exactly 12 digits as requested + if (/^\d{12}$/.test(cleanText)) { + e.target.value = cleanText; + e.target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + } + } catch(err) { + console.log('Clipboard read failed: ', err); + } + } + + if (reqTrackingNo) reqTrackingNo.addEventListener('click', handleTrackingClick); + if (recTrackingNo) recTrackingNo.addEventListener('click', handleTrackingClick); + + + // Default dates to today + const reqDate = document.getElementById('req-date'); + if (reqDate) reqDate.value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]; + const recDate = document.getElementById('rec-date'); + if (recDate) recDate.value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]; + + // Search on Return Request tab + if(reqTrackingNo) { + reqTrackingNo.addEventListener('keydown', async (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const no = reqTrackingNo.value.trim(); + if(!no) return; + try { + const res = await fetch('/api/return/lookup?tracking_no=' + encodeURIComponent(no)); + if(res.ok) { + const data = await res.json(); + if(data.success && data.data) { + document.getElementById('req-mall').value = data.data.vendor || ''; + document.getElementById('req-name').value = data.data.recipient_name || ''; + document.getElementById('req-phone').value = data.data.recipient_phone || data.data.recipient_mobile || ''; + document.getElementById('req-address').value = data.data.address || ''; + } + } + } catch(e) { console.error('Lookup failed', e); } + } + }); + } + + // Search on Return Receive tab + if(recTrackingNo) { + recTrackingNo.addEventListener('keydown', async (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const no = recTrackingNo.value.trim(); + if(!no) return; + try { + // First try to find in return requests + const res1 = await fetch('/api/return/request?tracking_no=' + encodeURIComponent(no)); + let foundInReq = false; + if(res1.ok) { + const data1 = await res1.json(); + if(data1.success && data1.data && data1.data.length > 0) { + const item = data1.data[0]; + document.getElementById('rec-mall').value = item.mall || ''; + document.getElementById('rec-name').value = item.receiver_name || ''; + document.getElementById('rec-phone').value = item.phone || ''; + const reqType = item.request_type || ''; + const reqRemarks = item.remarks || ''; + const autoRemark = reqRemarks ? `${reqType}/${reqRemarks}` : reqType; + document.getElementById('rec-remarks').value = autoRemark; + if (item.address) document.getElementById('rec-address').value = item.address; + foundInReq = true; + } + } + + // If not found in requests, fallback to orderlist_db + if(!foundInReq) { + const res2 = await fetch('/api/return/lookup?tracking_no=' + encodeURIComponent(no)); + if(res2.ok) { + const data2 = await res2.json(); + if(data2.success && data2.data) { + document.getElementById('rec-mall').value = data2.data.vendor || ''; + document.getElementById('rec-name').value = data2.data.recipient_name || ''; + document.getElementById('rec-phone').value = data2.data.recipient_phone || data2.data.recipient_mobile || ''; + document.getElementById('rec-address').value = data2.data.address || ''; + } + } + } + } catch(e) { console.error('Receive lookup failed', e); } + } + }); + } + + // Submit Return Request + const formReq = document.getElementById('form-return-request'); + if(formReq) { + formReq.addEventListener('submit', async (e) => { + e.preventDefault(); + const payload = { + request_date: document.getElementById('req-date').value, + request_type: document.getElementById('req-type').value, + receiver_name: document.getElementById('req-name').value, + address: document.getElementById('req-address').value, + phone: document.getElementById('req-phone').value, + tracking_no: document.getElementById('req-tracking-no').value, + mall: document.getElementById('req-mall').value, + remarks: document.getElementById('req-remarks').value + }; + const reqId = document.getElementById('req-id'); + if (reqId && reqId.value) { + payload.id = parseInt(reqId.value, 10); + } + try { + const res = await fetch('/api/return/request', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload) + }); + if(res.ok) { + formReq.reset(); + document.getElementById('req-date').value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]; + if(reqId) reqId.value = ''; + if (typeof loadReturnRequests === 'function') { + loadReturnRequests(); + } + if (typeof loadReturnReceivings === 'function') { + loadReturnReceivings(); + } + } else { + alert('등록 실패'); + } + } catch(e) { alert('오류 발생'); } + }); + } + // ============================================ + // PRODUCT SETTINGS TAB + // ============================================ + window.productRulesGroupData = {}; + window.productRulesListData = {}; + + function buildProductTab(data) { + const container = document.getElementById('product-groups-container'); + if (!container) return; + container.innerHTML = ''; + + const groupNames = data['GROUP_NAMES'] || {}; + window.productRulesGroupData = {}; + window.productRulesListData = {}; + + let groupIdCounter = 0; + + + const order_group = groupNames.__order || Object.keys(groupNames).filter(k => k !== '__order'); + for (const gKey of order_group) { + if (gKey === '__order') continue; + const gName = groupNames[gKey]; + + groupIdCounter++; + const gId = `pgroup-${groupIdCounter}`; + window.productRulesGroupData[gId] = { old_code: gKey, new_code: gKey, name: gName }; + + const pList = data[gKey] || {}; + window.productRulesListData[gId] = []; + + const order_3 = pList.__order || Object.keys(pList).filter(k => k !== '__order'); + for (const pKey of order_3) { + if (pKey === '__order') continue; + const pVal = pList[pKey]; + + if (pKey.startsWith('---separator_')) { + window.productRulesListData[gId].push({ is_separator: true }); + } else { + let is_free = pKey.startsWith('*'); + let cleanKey = is_free ? pKey.substring(1) : pKey; + + let color = ''; + if (cleanKey.endsWith('_O')) { color = '_O'; cleanKey = cleanKey.slice(0, -2); } + else if (cleanKey.endsWith('_R')) { color = '_R'; cleanKey = cleanKey.slice(0, -2); } + else if (cleanKey.endsWith('_B')) { color = '_B'; cleanKey = cleanKey.slice(0, -2); } + else if (cleanKey.endsWith('_G')) { color = '_G'; cleanKey = cleanKey.slice(0, -2); } + else if (cleanKey.endsWith('_P')) { color = '_P'; cleanKey = cleanKey.slice(0, -2); } + + let label = cleanKey; + let parts = pVal.split('|').map(s => s.trim()); + let code = parts[0] || ''; + let name = parts[1] || ''; + let price = parts.length > 2 ? parseInt(parts[2], 10) : 0; + let dprice = parts.length > 3 ? parseInt(parts[3], 10) : 0; + + window.productRulesListData[gId].push({ + is_separator: false, + code, name, label, price, dprice, color, is_free + }); + } + } + + renderProductGroup(gId, container); + } + + const btnProductSave = document.getElementById('btn-product-save'); + if (btnProductSave) btnProductSave.style.backgroundColor = ''; + + const markProductUnsaved = () => { + if (btnProductSave) btnProductSave.style.backgroundColor = '#e53e3e'; + }; + + container.addEventListener('input', markProductUnsaved); + container.addEventListener('change', markProductUnsaved); + container.addEventListener('click', (e) => { + if (e.target.tagName === 'BUTTON') markProductUnsaved(); + }); + } + + function renderProductGroup(gId, parentContainer) { + let card = document.getElementById(gId); + if (!card) { + card = document.createElement('div'); + card.id = gId; + card.className = 'glass-card'; + card.style.padding = '10px 15px'; + card.style.marginBottom = '10px'; + card.style.width = 'max-content'; // 박스 전체 폭을 콘텐츠에 맞게 축소 + parentContainer.appendChild(card); + } + + const gData = window.productRulesGroupData[gId]; + const items = window.productRulesListData[gId]; + const fmt = (v) => v ? parseInt(String(v).replace(/,/g, ''), 10).toLocaleString() : ''; + + let html = ` +
+
+ ${gData.name} +
+
+
+ `; + + items.forEach((item, idx) => { + if (item.is_separator) { + html += ` +
+ + --- 구분선 --- + +
+ `; + } else { + html += ` +
+ + + + + + + +
+ + + + + + +
+ + + + +
+
+ `; + } + }); + + html += ` +
+
+ + +
+ `; + + card.innerHTML = html; + + const rows = card.querySelectorAll('.product-items-list > .item-row'); + rows.forEach((row, i) => { + if (!items[i].is_separator) { + row.querySelector('.p-label').addEventListener('input', e => items[i].label = e.target.value); + row.querySelector('.p-code').addEventListener('input', e => items[i].code = e.target.value); + row.querySelector('.p-name').addEventListener('input', e => items[i].name = e.target.value); + + ['.p-price', '.p-dprice'].forEach(sel => { + row.querySelector(sel).addEventListener('input', e => { + let val = e.target.value.replace(/[^0-9]/g, ''); + if (sel === '.p-price') items[i].price = parseInt(val, 10) || 0; + else items[i].dprice = parseInt(val, 10) || 0; + e.target.value = val ? parseInt(val, 10).toLocaleString() : ''; + }); + }); + + row.querySelectorAll(`input[name="color-${gId}-${i}"]`).forEach(radio => { + radio.addEventListener('change', e => { if (e.target.checked) items[i].color = e.target.value; }); + }); + + row.querySelector('.p-free').addEventListener('change', e => items[i].is_free = e.target.checked); + } + }); + + // SortableJS for bulletproof drag & drop + new Sortable(card.querySelector('.product-items-list'), { + handle: '.drag-handle', + animation: 150, + onEnd: function (evt) { + if (evt.oldIndex !== evt.newIndex) { + const movedItem = items.splice(evt.oldIndex, 1)[0]; + items.splice(evt.newIndex, 0, movedItem); + const btnSave = document.getElementById('btn-product-save'); + if (btnSave) btnSave.style.backgroundColor = '#e53e3e'; + // Optional: renderProductGroup() if we need to sync data-idx, but items array is already updated in sync with DOM! + // It's safer to re-render to attach listeners based on new indices though: + renderProductGroup(gId, parentContainer); + } + } + }); + + card.querySelectorAll('.p-del').forEach(b => { + b.addEventListener('click', e => { + const parent = document.getElementById('product-groups-container'); + const y = parent ? parent.scrollTop : 0; + const i = parseInt(e.target.dataset.idx, 10); + items.splice(i, 1); + renderProductGroup(gId, parentContainer); + if (parent) requestAnimationFrame(() => parent.scrollTo(0, y)); + }); + }); + card.querySelector('.btn-add-item').addEventListener('click', (e) => { + e.preventDefault(); + const parent = document.getElementById('product-groups-container'); + const y = parent ? parent.scrollTop : 0; + items.push({ is_separator: false, code: '', name: '', label: '', price: 0, dprice: 0, color: '', is_free: false }); + renderProductGroup(gId, parentContainer); + if (parent) requestAnimationFrame(() => parent.scrollTo(0, y)); + }); + card.querySelector('.btn-add-sep').addEventListener('click', (e) => { + e.preventDefault(); + const parent = document.getElementById('product-groups-container'); + const y = parent ? parent.scrollTop : 0; + items.push({ is_separator: true }); + renderProductGroup(gId, parentContainer); + if (parent) requestAnimationFrame(() => parent.scrollTo(0, y)); + }); + } + + const _btnProdSave = document.getElementById('btn-product-save'); + if (_btnProdSave) { + _btnProdSave.addEventListener('click', () => { + const payload = { groups: [], products: {} }; + // window.productRulesGroupData + for (const gId in window.productRulesGroupData) { + const gData = window.productRulesGroupData[gId]; + if (gData.new_code && gData.name) { + payload.groups.push(gData); + payload.products[gData.new_code] = window.productRulesListData[gId]; + } + } + + document.getElementById('btn-product-save').innerText = '저장 중...'; + + fetch('/api/products/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }).then(r => { + if (!r.ok) throw new Error("Network response was not ok (status " + r.status + ")"); + return r.json(); + }).then(res => { + alert("상품 설정이 저장되었습니다."); + document.getElementById('btn-product-save').innerText = '상품 설정 저장'; + fetch('/api/config').then(r => r.json()).then(d => { + configData = d; + document.getElementById('btn-product-save').style.backgroundColor = ''; + + // Clear all UI and rebuild + const oc = document.getElementById('order-items-container'); + if (oc) oc.innerHTML = ''; + + buildOrderTab(configData); + buildGiftTab(configData); + buildProductTab(configData); + }); + }).catch(err => { + console.error(err); + alert("저장 실패!"); + document.getElementById('btn-product-save').innerText = '상품 설정 저장'; + }); + }); + } + + const btnCopyManual = document.getElementById('btn-copy-manual-total'); + if (btnCopyManual) { + btnCopyManual.addEventListener('click', () => { + const count = document.getElementById('manual-total-count').textContent; + const amount = document.getElementById('manual-total-amount').textContent; + const textToCopy = `수기 주문: ${count}건 / ${amount}원`; + + navigator.clipboard.writeText(textToCopy).then(() => { + const toast = document.getElementById('toast-notification'); + if (toast) { + toast.style.opacity = '1'; + setTimeout(() => { + toast.style.opacity = '0'; + }, 1500); + } + }).catch(err => { + console.error('Failed to copy', err); + }); + }); + } + + // ============================================ + // CODE TAB (Master Data Mgmt) + // ============================================ + function initCodeTab() { + const btnAddSin = document.getElementById('btn-add-single'); + const btnAddSet = document.getElementById('btn-add-set'); + if (!btnAddSin) return; + + let editingSingleCode = null; + let editingSetCode = null; + let singleItemsList = []; + let setItemsList = []; + let singleSort = { key: 'item_code', asc: true }; + let setSort = { key: 'item_code', asc: true }; + + // Render Single Items + const renderSingleItems = () => { + const tbody = document.getElementById('single-items-tbody'); + tbody.innerHTML = ''; + + singleItemsList.sort((a,b) => { + let valA = a[singleSort.key] || ''; + let valB = b[singleSort.key] || ''; + if(valA < valB) return singleSort.asc ? -1 : 1; + if(valA > valB) return singleSort.asc ? 1 : -1; + return 0; + }); + + singleItemsList.forEach(item => { + const tr = document.createElement('tr'); + tr.className = 'hover-row'; + tr.style.height = '27px'; + tr.style.borderBottom = '1px solid #e2e8f0'; + tr.innerHTML = ` + ${item.item_code} + ${item.sabangnet_code} + ${item.name} + +
+ + +
+ + `; + tbody.appendChild(tr); + }); + + document.querySelectorAll('.th-sortable[data-list="single"]').forEach(th => { + const icon = th.querySelector('.sort-icon'); + if(!icon) return; + if(th.dataset.key === singleSort.key) { + icon.innerText = singleSort.asc ? '▲' : '▼'; + icon.style.color = '#3182ce'; + } else { + icon.innerText = '↕'; + icon.style.color = '#e2e8f0'; + } + }); + }; + + // Load data + const loadSingleItems = () => { + return fetch('/api/codes/single').then(r=>r.json()).then(res => { + singleItemsList = res.data; + renderSingleItems(); + }); + }; + const renderSetItems = () => { + const tbody = document.getElementById('set-items-tbody'); + tbody.innerHTML = ''; + + let tooltip = document.getElementById('set-comp-tooltip'); + if (!tooltip) { + tooltip = document.createElement('div'); + tooltip.id = 'set-comp-tooltip'; + tooltip.style.position = 'fixed'; + tooltip.style.background = '#fff'; + tooltip.style.border = '1px solid #cbd5e0'; + tooltip.style.padding = '8px'; + tooltip.style.borderRadius = '5px'; + tooltip.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; + tooltip.style.display = 'none'; + tooltip.style.zIndex = '9999'; + tooltip.style.pointerEvents = 'none'; + tooltip.style.flexDirection = 'column'; + tooltip.style.gap = '2px'; + document.body.appendChild(tooltip); + } + + const mapCompInfo = c => { + const sInfo = singleItemsList.find(s => s.item_code === c.single_code); + let displayName = sInfo ? sInfo.name : c.single_code; + displayName = displayName.replace(/미라클 통 /g, '').trim(); + const isHwangto = displayName.includes('황토'); + const bg = isHwangto ? 'background:#feebc8;' : 'background:#edf2f7;'; + const border = isHwangto ? 'border:1px solid #fbd38d;' : 'border:1px solid #cbd5e0;'; + const qtyColor = isHwangto ? '#dd6b20' : '#3182ce'; + const textColor = isHwangto ? 'color:#c05621;' : ''; + return `${displayName} ${c.quantity}`; + }; + + setItemsList.sort((a,b) => { + let valA = a[setSort.key] || ''; + let valB = b[setSort.key] || ''; + if(valA < valB) return setSort.asc ? -1 : 1; + if(valA > valB) return setSort.asc ? 1 : -1; + return 0; + }); + + setItemsList.forEach(item => { + const tr = document.createElement('tr'); + tr.className = 'hover-row'; + tr.style.height = '27px'; + tr.style.borderBottom = '1px solid #e2e8f0'; + + const compsHtml = item.components.map(mapCompInfo).join(''); + + tr.innerHTML = ` + ${item.item_code} + ${item.name || ''} + +
+ ${compsHtml} + +
+ + +
+ + +
+ + `; + tr._itemComps = item.components; + tbody.appendChild(tr); + }); + + // 모든 행이 그려진 후 브라우저가 그리드를 계산할 여유를 주기 위해 지연실행 + setTimeout(() => { + const rows = tbody.querySelectorAll('tr'); + rows.forEach(tr => { + const wrapper = tr.querySelector('.comps-wrapper'); + if (!wrapper) return; + const spans = wrapper.querySelectorAll('.comp-span'); + const indicator = wrapper.querySelector('.more-indicator'); + + if (spans.length > 0) { + let hiddenCount = 0; + let lastVisibleIndex = spans.length - 1; + + // X좌표를 기준으로 컨테이너를 벗어나는 아이템 탐색 + const maxLimit = wrapper.offsetWidth - 48; // 오른쪽 48px는 '외 N개' 표기를 위해 예약 + for (let i = 0; i < spans.length; i++) { + if (spans[i].offsetLeft + spans[i].offsetWidth > maxLimit) { + lastVisibleIndex = i - 1; + break; + } + } + + if (lastVisibleIndex < spans.length - 1 && lastVisibleIndex >= 0) { + hiddenCount = spans.length - 1 - lastVisibleIndex; + + for (let i = lastVisibleIndex + 1; i < spans.length; i++) { + spans[i].style.display = 'none'; + } + + indicator.innerText = `외 ${hiddenCount}개`; + indicator.style.display = 'inline-block'; + } else if (lastVisibleIndex < 0 && spans.length > 1) { + // 매우 좁은 공간일 경우 적어도 1개는 숨기고 처리 + hiddenCount = spans.length - 1; + for (let i = 1; i < spans.length; i++) { + spans[i].style.display = 'none'; + } + indicator.innerText = `외 ${hiddenCount}개`; + indicator.style.display = 'inline-block'; + } + + if (hiddenCount > 0 && tr._itemComps) { + const compsCell = tr.querySelector('.comps-cell'); + compsCell.addEventListener('mouseenter', (e) => { + compsCell._hoverTimeout = setTimeout(() => { + tooltip.innerHTML = tr._itemComps.map(mapCompInfo).map(html => `
${html}
`).join(''); + tooltip.style.display = 'flex'; + tooltip.style.left = (e.clientX + 40) + 'px'; + tooltip.style.top = (e.clientY - 10) + 'px'; + }, 1000); + }); + compsCell.addEventListener('mousemove', (e) => { + if (tooltip.style.display === 'flex') { + tooltip.style.left = (e.clientX + 40) + 'px'; + tooltip.style.top = (e.clientY - 10) + 'px'; + } + }); + compsCell.addEventListener('mouseleave', () => { + clearTimeout(compsCell._hoverTimeout); + tooltip.style.display = 'none'; + }); + } + } + }); + }, 50); + + document.querySelectorAll('.th-sortable[data-list="set"]').forEach(th => { + const icon = th.querySelector('.sort-icon'); + if(!icon) return; + if(th.dataset.key === setSort.key) { + icon.innerText = setSort.asc ? '▲' : '▼'; + icon.style.color = '#3182ce'; + } else { + icon.innerText = '↕'; + icon.style.color = '#e2e8f0'; + } + }); + }; + + const loadSetItems = () => { + return fetch('/api/codes/set').then(r=>r.json()).then(res => { + setItemsList = res.data; + renderSetItems(); + }); + }; + + const loadSingleItemsAndSetItems = () => { + loadSingleItems().then(() => loadSetItems()); + }; + + window.reRenderSetItemsForWrap = () => { + if (setItemsList && setItemsList.length > 0) renderSetItems(); + }; + + window.addEventListener('resize', () => { + if(document.getElementById('code-tab').classList.contains('active')) { + clearTimeout(window._setItemsResizeTimer); + window._setItemsResizeTimer = setTimeout(() => { + if (setItemsList && setItemsList.length > 0) renderSetItems(); + }, 100); + } + }); + + loadSingleItemsAndSetItems(); + + // Sorting events + document.querySelectorAll('.th-sortable').forEach(th => { + th.addEventListener('click', (e) => { + const listType = th.dataset.list; + const key = th.dataset.key; + if(listType === 'single') { + if(singleSort.key === key) singleSort.asc = !singleSort.asc; + else { singleSort.key = key; singleSort.asc = true; } + renderSingleItems(); + } else if(listType === 'set') { + if(setSort.key === key) setSort.asc = !setSort.asc; + else { setSort.key = key; setSort.asc = true; } + renderSetItems(); + } + }); + }); + + document.getElementById('code-tab').addEventListener('click', (e) => { + if(e.target.parentElement && e.target.parentElement.classList.contains('btn-del-sin')) return; // ignore if click is inside not button + const btnDelSin = e.target.closest('.btn-del-sin'); + if(btnDelSin) { + if(confirm('단품 항목을 삭제하시겠습니까? (연결된 세트 구성품도 삭제될 수 있습니다)')) { + fetch('/api/codes/single/' + btnDelSin.dataset.code, {method: 'DELETE'}).then(()=> loadSingleItemsAndSetItems()); + } + return; + } + + const btnDelSet = e.target.closest('.btn-del-set'); + if(btnDelSet) { + if(confirm('세트 항목을 삭제하시겠습니까?')) { + fetch('/api/codes/set/' + btnDelSet.dataset.code, {method: 'DELETE'}).then(()=> { setItemsList = []; loadSetItems(); }); + } + return; + } + + const btnEditSin = e.target.closest('.btn-edit-sin'); + if(btnEditSin) { + const code = btnEditSin.dataset.code; + fetch('/api/codes/single').then(r=>r.json()).then(res => { + const item = res.data.find(d => d.item_code === code); + if(item) { + editingSingleCode = code; + document.getElementById('modal-single-title').innerText = '📦 단품 수정'; + document.getElementById('sin-item-code').value = item.item_code; + document.getElementById('sin-item-code').disabled = true; + document.getElementById('sin-sabangnet-code').value = item.sabangnet_code; + document.getElementById('sin-name').value = item.name; + document.getElementById('single-item-modal').style.display = 'flex'; + } + }); + } + const btnEditSet = e.target.closest('.btn-edit-set'); + if(btnEditSet) { + const code = btnEditSet.dataset.code; + fetch('/api/codes/set').then(r=>r.json()).then(res => { + const item = res.data.find(d => d.item_code === code); + if(item) { + editingSetCode = code; + document.getElementById('modal-set-title').innerText = '🍱 세트 수정'; + document.getElementById('set-item-code').value = item.item_code; + document.getElementById('set-item-code').disabled = true; + document.getElementById('set-name').value = item.name; + const wrapper = document.getElementById('set-components-wrapper'); + wrapper.innerHTML = ''; + item.components.forEach(c => addSetComponentRow(c.single_code, c.quantity)); + document.getElementById('set-item-modal').style.display = 'flex'; + } + }); + } + }); + + // Single Modal + const sinItemCodeInput = document.getElementById('sin-item-code'); + const sinItemWarn = document.getElementById('sin-item-warn'); + + sinItemCodeInput.addEventListener('input', (e) => { + let val = e.target.value.toUpperCase(); + e.target.value = val; + if (val.length > 0 && !/^[A-Z]/.test(val)) { + sinItemWarn.textContent = '⚠️ 아이템 코드 첫 글자는 영문이어야 합니다.'; + sinItemWarn.style.display = 'block'; + } else if(!editingSingleCode && val && singleItemsList.some(s => s.item_code === val)) { + sinItemWarn.textContent = '⚠️ 이미 존재하는 단품 코드입니다!'; + sinItemWarn.style.display = 'block'; + } else { + sinItemWarn.style.display = 'none'; + } + }); + + btnAddSin.addEventListener('click', () => { + editingSingleCode = null; + document.getElementById('modal-single-title').innerText = '📦 새 단품 추가'; + sinItemCodeInput.value = ''; + sinItemCodeInput.disabled = false; + if(sinItemWarn) sinItemWarn.style.display = 'none'; + document.getElementById('sin-sabangnet-code').value = ''; + document.getElementById('sin-name').value = ''; + document.getElementById('single-item-modal').style.display = 'flex'; + }); + document.getElementById('btn-sin-cancel').addEventListener('click', () => { + document.getElementById('single-item-modal').style.display = 'none'; + }); + document.getElementById('btn-sin-save').addEventListener('click', () => { + const itemCode = document.getElementById('sin-item-code').value.trim(); + let sabangnet = document.getElementById('sin-sabangnet-code').value.trim(); + const name = document.getElementById('sin-name').value.trim(); + if(!itemCode || !sabangnet || !name) { alert('모든 항목을 입력해주세요.'); return; } + + if (itemCode.length > 0 && !/^[A-Z]/.test(itemCode)) { + alert('아이템 코드의 첫 글자는 반드시 영문이어야 합니다!'); + return; + } + + if(!editingSingleCode && singleItemsList.some(s => s.item_code === itemCode)) { + alert('이미 존재하는 단품 아이템 코드입니다!'); + return; + } + if(!sabangnet.endsWith('-0001')) sabangnet += '-0001'; + + const payload = {item_code: itemCode, sabangnet_code: sabangnet, name: name}; + const method = editingSingleCode ? 'PUT' : 'POST'; + const url = editingSingleCode ? '/api/codes/single/' + encodeURIComponent(editingSingleCode) : '/api/codes/single'; + + fetch(url, { + method: method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) + }).then(r => r.json()).then(res => { + if(res.detail) { alert(res.detail); } + else { + document.getElementById('single-item-modal').style.display = 'none'; + loadSingleItems(); + } + }); + }); + + // Single sabangnet auto append -0001 on blur + document.getElementById('sin-sabangnet-code').addEventListener('blur', (e) => { + let val = e.target.value.trim(); + if(val && !val.endsWith('-0001') && !val.includes('-')) { + e.target.value = val + '-0001'; + } + }); + + // Set Modal + const setItemCodeInput = document.getElementById('set-item-code'); + const setItemWarn = document.getElementById('set-item-warn'); + + setItemCodeInput.addEventListener('input', (e) => { + let val = e.target.value.toUpperCase(); + e.target.value = val; + if (val.length > 0 && !/^[A-Z]/.test(val)) { + setItemWarn.textContent = '⚠️ 아이템 코드 첫 글자는 영문이어야 합니다.'; + setItemWarn.style.display = 'block'; + } else if(!editingSetCode && val && setItemsList.some(s => s.item_code === val)) { + setItemWarn.textContent = '⚠️ 이미 존재하는 세트 코드입니다!'; + setItemWarn.style.display = 'block'; + } else { + setItemWarn.style.display = 'none'; + } + }); + + const addSetComponentRow = (sc = '', qt = 1) => { + const div = document.createElement('div'); + div.style.display = 'flex'; + div.style.gap = '5px'; + + let optionsHtml = ''; + singleItemsList.forEach(s => { + const selected = (s.item_code === sc) ? 'selected' : ''; + optionsHtml += ``; + }); + + div.innerHTML = ` + + + + `; + div.querySelector('.comp-del').addEventListener('click', () => div.remove()); + document.getElementById('set-components-wrapper').appendChild(div); + }; + + btnAddSet.addEventListener('click', () => { + editingSetCode = null; + document.getElementById('modal-set-title').innerText = '🍱 새 세트 추가'; + setItemCodeInput.value = ''; + setItemCodeInput.disabled = false; + if(setItemWarn) setItemWarn.style.display = 'none'; + document.getElementById('set-name').value = ''; + document.getElementById('set-components-wrapper').innerHTML = ''; + addSetComponentRow(); + document.getElementById('set-item-modal').style.display = 'flex'; + }); + document.getElementById('btn-add-set-component').addEventListener('click', () => { + addSetComponentRow(); + }); + document.getElementById('btn-set-cancel').addEventListener('click', () => { + document.getElementById('set-item-modal').style.display = 'none'; + }); + document.getElementById('btn-set-save').addEventListener('click', () => { + const itemCode = document.getElementById('set-item-code').value.trim(); + const name = document.getElementById('set-name').value.trim(); + if(!itemCode) { alert('세트 아이템 코드를 입력해주세요.'); return; } + + if (itemCode.length > 0 && !/^[A-Z]/.test(itemCode)) { + alert('아이템 코드의 첫 글자는 반드시 영문이어야 합니다!'); + return; + } + + if(!editingSetCode && setItemsList.some(s => s.item_code === itemCode)) { + alert('이미 존재하는 세트 아이템 코드입니다!'); + return; + } + + const components = []; + const rows = document.getElementById('set-components-wrapper').querySelectorAll('div'); + rows.forEach(r => { + const sc = r.querySelector('.comp-code').value.trim(); + const qt = parseInt(r.querySelector('.comp-qty').value, 10); + if(sc && !isNaN(qt) && qt > 0) { + components.push({single_code: sc, quantity: qt}); + } + }); + + if(components.length === 0) { alert('유효한 단품 구성품을 최소 1개 이상 선택해주세요.'); return; } + + const payload = {item_code: itemCode, components: components, sabangnet_code: "", name: name}; + const method = editingSetCode ? 'PUT' : 'POST'; + const url = editingSetCode ? '/api/codes/set/' + encodeURIComponent(editingSetCode) : '/api/codes/set'; + + fetch(url, { + method: method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) + }).then(r => r.json()).then(res => { + if(res.detail) { alert(res.detail); } + else { + document.getElementById('set-item-modal').style.display = 'none'; + loadSetItems(); + } + }); + }); + } + + initCodeTab(); +}); + + const btnEmailReq = document.getElementById('btn-email-req'); + if (btnEmailReq) { + btnEmailReq.addEventListener('click', async () => { + const checkedBoxes = document.querySelectorAll('.chk-req-row:checked'); + if (checkedBoxes.length === 0) { + alert('이메일로 보낼 반품 신청 항목을 선택해주세요.'); + return; + } + + const d = new Date(); + const yyyy = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const days = ['일', '월', '화', '수', '목', '금', '토']; + const dow = days[d.getDay()]; + const subject = `★ ${yyyy}년 ${m}월 ${dd}일(${dow}) 미라네주방 반품 신청`; + + let plainTextGreeting = "안녕하세요.\r\n오늘 반품 신청합니다.\r\n내용은 아래와 같습니다.\r\n감사합니다.\r\n\r\n"; + let ptIndex = 1; + checkedBoxes.forEach(cb => { + try { + const item = JSON.parse(cb.dataset.item); + let ptStatus = item.request_type || ''; + if (ptStatus === '맞교환') { + ptStatus = '★맞교환★'; + } + plainTextGreeting += `${ptIndex}. 이름: ${item.receiver_name || ''} / 연락처: ${item.phone || ''} / 송장번호: ${item.tracking_no || ''} / 상태: ${ptStatus} + +`; + ptIndex++; + } catch(e) {} + }); + + + let htmlTable = ` + + + + + + + + + + + + `; + + let index = 1; + checkedBoxes.forEach(cb => { + try { + const item = JSON.parse(cb.dataset.item); + let statusText = item.request_type || ''; + let trStyle = ''; + if (statusText === '맞교환') { + trStyle = ' style="font-weight: bold; color: red;"'; + } + htmlTable += ` + + + + + + + + `; + } catch(e) {} + }); + htmlTable += `
번호이름전화번호송장번호상태
${index++}${item.receiver_name || ''}${item.phone || ''}${item.tracking_no || ''}${statusText}
`; + + try { + const blobHtml = new Blob([htmlTable], { type: 'text/html' }); + const blobText = new Blob(["표가 클립보드에 복사되었습니다. 메일 본문에 붙여넣기 하세요."], { type: 'text/plain' }); + const data = new ClipboardItem({ + 'text/html': blobHtml, + 'text/plain': blobText + }); + navigator.clipboard.write([data]).then(() => { + showToast("표가 클립보드에 복사되었습니다! 아웃룩 본문에 '붙여넣기(Ctrl+V)' 하세요."); + setTimeout(() => { + const mailtoLink = `mailto:skylove6969@hanmail.net?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(plainTextGreeting)}`; + window.location.href = mailtoLink; + }, 1000); + }).catch(err => { + alert('클립보드 복사에 실패했습니다.'); + const mailtoLink = `mailto:skylove6969@hanmail.net?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(plainTextGreeting)}`; + window.location.href = mailtoLink; + }); + } catch(e) { + const mailtoLink = `mailto:skylove6969@hanmail.net?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(plainTextGreeting)}`; + window.location.href = mailtoLink; + } + }); + } + + +// Clear form when clicking empty space around inputs +function handleFormEmptyClick(e, formId) { + // If the click is on an input, select, button, label, or textarea, do nothing + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'BUTTON' || e.target.tagName === 'LABEL' || e.target.tagName === 'TEXTAREA') return; + if (e.target.closest('button')) return; + + // Otherwise it's an empty space click + const form = document.getElementById(formId); + if (form) { + form.reset(); + // Reset hidden IDs and dates + if (formId === 'form-return-request') { + const reqId = document.getElementById('req-id'); + if (reqId) reqId.value = ''; + const reqDate = document.getElementById('req-date'); + if (reqDate) reqDate.value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]; + } else if (formId === 'form-return-receive') { + const recId = document.getElementById('rec-id'); + if (recId) recId.value = ''; + const recDate = document.getElementById('rec-date'); + if (recDate) recDate.value = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000).toISOString().split('T')[0]; + } + } +} + +const reqFormContainer = document.getElementById('form-return-request'); +if (reqFormContainer) { + reqFormContainer.addEventListener('click', (e) => handleFormEmptyClick(e, 'form-return-request')); +} + +const recFormContainer = document.getElementById('form-return-receive'); +if (recFormContainer) { + recFormContainer.addEventListener('click', (e) => handleFormEmptyClick(e, 'form-return-receive')); +} + +// ============================================ +// Version Update Notice +// ============================================ +document.addEventListener('DOMContentLoaded', () => { + let CURRENT_APP_VERSION = "8.54"; + const versionEl = document.getElementById('app-version-text'); + if (versionEl) { + const text = versionEl.textContent.trim(); + const match = text.match(/Ver\s+([0-9.]+)/i); + if (match) { + CURRENT_APP_VERSION = match[1]; + } + } + const updateModal = document.getElementById('update-notice-modal'); + if (updateModal) { + document.getElementById('update-notice-version').textContent = CURRENT_APP_VERSION; + const storageKey = `hide_update_popup_${CURRENT_APP_VERSION}`; + + if (localStorage.getItem(storageKey) !== 'true') { + updateModal.style.display = 'flex'; + } + + document.getElementById('btn-close-update-notice').addEventListener('click', () => { + if (document.getElementById('chk-hide-update-notice').checked) { + localStorage.setItem(storageKey, 'true'); + } + updateModal.style.display = 'none'; + + // Switch to Version History tab + const historyTab = document.querySelector('[data-tab="version-history-tab"]'); + if (historyTab) { + historyTab.click(); + } + }); + } +}); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..127c654 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,1052 @@ + + + + + + + [No.1 King] CS 통합 관리앱 (미라클주방) + + + + + + + +
+ + + +
+ +
+
+
+ +
+
+ +
+ +
+

+ 📝수동 일반 발주

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

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

+
    +
    + + +
    +

    + 📝오늘 수동발주 내역 +

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

    + 📋알림 문자 계산 및 복사

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

    🔥 통합 자동발주 시스템

    +

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

    +
    + +
    + +
    +

    + 🛒 카페24 (자사몰)

    + +
    + +
    +

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

    + +
    +

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

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

    2. 송장 일괄 등록

    +
    +

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

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

    + 🟩 스마트스토어

    + +
    +
    +

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

    +
    + 발주확인 요망
    +
    +

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

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

    2. 송장 일괄 등록

    +
    +

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

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

    금액별 추가상품 설정

    +
    +
    + +
    +
    +
    +
    +

    문자 메시지 설정

    +
    +
    +

    머리말

    + +

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

    + +
    +
    +
    +
    +

    화면 폰트 및 배송비 설정

    +
    +
    +

    폰트 선택

    +
    + + +
    + +

    배송비 설정

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

    상품 설정

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

    코드표 관리

    +
    +
    + +
    +
    +

    단품 코드

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

    세트 코드

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

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

    +
    +
    + +
    + +
    +
    +

    + 📋 반품 신청 + +

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

    + 신청 목록 +

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

    + 📦 반품 입고 + +

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

    + 입고 목록 +

    +
    + + + + + + + + + + + + + + + +
    + 입고날짜 + 수령인 + 휴대폰번호 + 송장번호 + 쇼핑몰 + 입고상태비고 + 관리
    +
    +
    +
    +
    + + {% include 'version_history.html' %} +
    +
    + + + +
    + 클립보드로 주문 내용이 복사되었습니다. +
    + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/version_history.html b/templates/version_history.html new file mode 100644 index 0000000..da4cdef --- /dev/null +++ b/templates/version_history.html @@ -0,0 +1,58 @@ +
    +
    +

    + 🚀 업데이트 내역 (Ver History) +

    + + {% set section_config = { + 'added': { + 'bg_color': 'rgba(229, 62, 62, 0.05)', + 'border_color': '#e53e3e', + 'title_color': '#c53030', + 'icon': '✨', + 'title': '신규 기능 추가 (Added)' + }, + 'modified': { + 'bg_color': 'rgba(49, 130, 206, 0.05)', + 'border_color': '#3182ce', + 'title_color': '#2b6cb0', + 'icon': '⚡', + 'title': '기능 개선 및 버그 수정 (Modified & Fixed)' + } + } %} +
    + + {% for version in version_history %} + +
    +
    +
    +

    + {{ version.version }} ({{ version.date | safe }}) +

    + + {% for section in version.sections %} + {% set config = section_config[section['type']] %} +
    +

    + {{ config.icon }} {{ config.title }} +

    +
      + {% for item in section['items'] %} +
    • {{ item }}
    • + {% endfor %} +
    +
    + {% endfor %} +
    + {% endfor %} +
    +
    +
    \ No newline at end of file diff --git a/version_history.json b/version_history.json new file mode 100644 index 0000000..01ecd24 --- /dev/null +++ b/version_history.json @@ -0,0 +1,261 @@ +[ + { + "version": "v8.56", + "date": "2026.05.17", + "sections": [ + { + "items": [ + "송장일괄 입고시 송장앞 숫자 4자리 고정 입력 기능 추가" + ], + "type": "added" + }, + { + "items": [ + "반품 관리에서 송장 일괄 입고 시 주소 입력이 누락되는 문제 수정", + "반품 관리에서 송장 일괄 입고 시 Ctrl+Enter키 입력으로도 입고 등록 가능하도록 추가", + "반품 관리에서 반품 신청 및 입고 등록, 목록삭제 시 양쪽 목록 리스트 동시 자동 갱신 적용", + "반품 관리에서 반품 신청/입고 폼의 일부 정보(쇼핑몰, 이름, 휴대폰번호, 주소) 읽기 전용(수정 불가)으로 변경" + ], + "type": "modified" + } + ] + }, + { + "version": "v8.55", + "date": "2026.05.14", + "sections": [ + { + "items": [ + "프로그램 버전업 안내 팝업 기능 추가" + ], + "type": "added" + }, + { + "items": [ + "반품 입고가 완료된 내역은 신청 목록에서 취소선 및 붉은색 [입고완료] 표시로 자동 구분되도록 개선", + "반품 신청 목록 우측에 '입고완료 제외' 체크박스를 신설하여 보기 쉽게 필터링 가능", + "상품의 수가 많아서 직접입력으로 입력하여 계산한 것을 다시 클릭 했을 때 큰 숫자가 안보이는 문제 해결", + "무료배송 상품이어도 추가배송비 설정 하면 추가 배송비를 적용(안내 문구도 수정)", + "문자 메시지 문구 설정 후 적용 안되는 문제 해결" + ], + "type": "modified" + } + ] + }, + { + "version": "v8.5", + "date": "2026.05.12", + "sections": [ + { + "items": [ + "반품 관리(반품 신청 및 입고 처리) 메뉴 추가" + ], + "type": "added" + } + ] + }, + { + "version": "v8.30", + "date": "2026.05.11", + "sections": [ + { + "items": [ + "단품 및 세트 코드표 관리 기능 추가 (PostgreSQL 서버 연동)" + ], + "type": "added" + }, + { + "items": [ + "왼쪽 메뉴(탭) 순서 전면 개편 ('통합 작업'을 'CS 작업'으로 명칭 변경)" + ], + "type": "modified" + } + ] + }, + { + "dot_color": "#ed8936", + "version": "v8.26", + "date": "2026.05.07", + "sections": [ + { + "items": [ + "\"샘플 발송\" 옵션 팝업에 추가 (선택 시 전체 상품 제외 처리 및 최종 금액 0원 반영)" + ], + "type": "added" + } + ] + }, + { + "version": "v8.25", + "date": "2026.05.05", + "sections": [ + { + "items": [ + "적립금 차감 로직 신설 (입력 액수만큼 최종금액 차감 적용)" + ], + "type": "added" + }, + { + "items": [ + "설정 탭에서 기본 배송비, 추가 배송비를 직접 입력하여 관리 가능하도록 기능 개선", + "[배송비 3,500원] 고정 체크박스 삭제", + "추가 배송비 체크박스 텍스트가 설정의 추가배송비 설정 값에 즉시 연동되는 UI 편의 개선" + ], + "type": "modified" + } + ] + }, + { + "dot_color": "#6b46c1", + "version": "v8.21", + "date": "2026.04.29", + "sections": [ + { + "items": [ + "수량 선택 시 기존 드롭다운 리스트 기능과 '직접입력' 방식을 통합", + "직접 입력 칸 내용을 지울 시 기존 드롭다운 리스트로 자동 복구되는 스마트 로직 추가" + ], + "type": "modified" + } + ] + }, + { + "dot_color": "#319795", + "version": "v8.2", + "date": "2026.04.23", + "sections": [ + { + "items": [ + "모바일로 접속 시 모바일 화면에 맞게 UI변경", + "6종 할인 쿠폰 혜택(2천원~2만5천원) 추가 및 확인 문자 연동", + "이름(한글 3글자) 및 연락처 입력란 클릭 시 클립보드 자동 붙여넣기 편의 기능 추가" + ], + "type": "added" + }, + { + "items": [ + "누락, 파손, 불량, 뚜껑 없이 통만 옵션 선택 시 배송비 및 개별 상품 가격 자동 0원 처리 로직 구축", + "수동 일반 발주 팝업 창 리스트에 선택 사유(누락, 파손, 뚜껑(X) 등) 전용 뱃지 표기 기능 추가", + "쿠폰 결제 할인과 기존 5% 할인 동시 접근 시 상호 배타적 디자인(클릭 불가 처리) 적용", + "상품 리스트 세로 간격을 1px 감소 시켜 조금 더 촘촘하고 눈에 잘 보이게 UI 최적화" + ], + "type": "modified" + } + ] + }, + { + "dot_color": "#d53f8c", + "version": "v8.13", + "date": "2026.04.22", + "sections": [ + { + "items": [ + "발주 팝업창에서 배송비(자동/수동 설정 포함) 및 5% 할인 내역을 명시적으로 표기하는 기능 추가", + "발주 확인 창의 최종 결제 금액 산출 방식에 택배비 및 할인 금액을 완전히 연동하여 반영토록 개선" + ], + "type": "added" + } + ] + }, + { + "version": "v8.12", + "date": "2026.04.20", + "sections": [ + { + "items": [ + "발주 입력 전, 결제 계산 금액에서 제외할 항목을 고를 수 있는 금액 제외 팝업창 추가", + "제외된 상품 금액에 빨간색 취소선이 표시되어 시각적으로 확실히 구분되도록 UI 개선" + ], + "type": "added" + }, + { + "items": [ + "수동발주 내역을 접속한 모든 컴퓨터에서 공유할 수 있도록 동기화 구축", + "최근 전송 메시지 등에서 내역 복원 시, 동일 품번(묶음 옵션)의 모든 품목들이 정상적으로 노란색 하이라이트되도록 수정" + ], + "type": "modified" + } + ] + }, + { + "dot_color": "#3182ce", + "version": "v8.1", + "date": "2026.04.18", + "sections": [ + { + "items": [ + "당일 수동발주 리스트 창에 입력 건수와 금액 표기", + "당일 수동발주 리스트 구글 시트 삭제 연동 (엑셀 다운로드 비우기 기능과 무관하게 로컬 기록 독립 유지)", + "당일 누적 수동발주 내역 클립보드 원클릭 복사 버튼" + ], + "type": "added" + }, + { + "items": [ + "연락처 입력 시 '010' 제외 하고 입력 가능", + "아이템 선택 시 선택한 아이템 노란색 하이라이트 효과 적용", + "최근 전송 메시지 및 수동발주 기록 창 세로 높이를 각 260px 맞춤 최적화", + "수동발주 구글 시트 추가 시 최종 결제금액을 마지막 행 C열 숫자로 자동 산출 입력", + "최근 전송 메시지 클릭 시 좌측 옵션 체크박스 자동 선택 및 노란색 뚜렷한 하이라이트 효과 적용" + ], + "type": "modified" + } + ] + }, + { + "dot_color": "#805ad5", + "version": "v8.05", + "date": "2026.04.17", + "sections": [ + { + "items": [ + "화면 글꼴 변경 기능 추가" + ], + "type": "added" + }, + { + "items": [ + "상품 설정 탭에서 상품을 마우스로 이동 하여 편집 가능", + "상품 설정 내용을 별도의 구글시트에 저장" + ], + "type": "modified" + } + ] + }, + { + "dot_color": "#38a169", + "version": "v8.01", + "date": "2026.04.16", + "sections": [ + { + "items": [ + "상품 설정 탭에서 상품을 직접 수정", + "문자메시지 머리말, 꼬리말 사용자 직접 설정" + ], + "type": "added" + }, + { + "items": [ + "그룹 제목에 아이콘 추가", + "연락처 입력 시 전화번호 사이제 \"-\" 자동 삽입" + ], + "type": "modified" + } + ] + }, + { + "dot_color": "#a0aec0", + "version": "v8.0", + "date": "2026.04.14\n  |  초기 버전 런칭", + "sections": [ + { + "items": [ + "기존 로컬에서 실행되던 프로그램을 웹버전으로 전환과 함게 완전 통합된 프로그램", + "주문 처리, 배송비 자동 계산, 조건 금액별 사은품 룰셋 통합 시스템 구성", + "빠른 복사-붙여넣기를 위한 자동 고객 안내 문자 생성, 구글 시트 자동 저장 연동" + ], + "type": "added" + } + ] + } +] \ No newline at end of file