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 urllib.parse import quote 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, RedirectResponse from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.sessions import SessionMiddleware 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() # ========================================== # SSO / 서브경로 호스팅 설정 # ========================================== APP_ROOT_PATH = os.getenv("APP_ROOT_PATH", "").rstrip("/") MAIN_LOGIN_URL = os.getenv("MAIN_LOGIN_URL", "https://dbx.no1king.freeddns.org/login") MAIN_LOGOUT_URL = os.getenv("MAIN_LOGOUT_URL", "https://dbx.no1king.freeddns.org/logout") SESSION_USER_KEY = os.getenv("SESSION_USER_KEY", "user_email") SESSION_NAME_KEY = os.getenv("SESSION_NAME_KEY", "user_name") # 인증 우회 경로 — 정적 자원, 헬스, OAuth 콜백, 로그인/로그아웃 위임 # Starlette scope.path 는 root_path 가 제거된 형태로 들어오므로 prefix 없이 매칭. _AUTH_EXEMPT_PREFIXES = ( "/static/", "/health", "/api/cafe24/callback", "/admin/oauth/callback", "/login", "/logout", "/favicon.ico", ) def _require_session_secret() -> str: secret = os.getenv("SESSION_SECRET_KEY", "").strip() if not secret: raise RuntimeError( "SESSION_SECRET_KEY 환경변수가 설정되지 않았습니다. " "openssl rand -hex 32 로 새 값을 만들어 .env 에 넣고 컨테이너를 재기동하세요. " "dbx-main 과 SSO 하려면 양쪽 .env 에 같은 값이어야 합니다." ) return secret class AuthGuardMiddleware(BaseHTTPMiddleware): """세션에 user_email 이 없으면 차단. - HTML 요청 (Accept: text/html 또는 / 같은 페이지) → dbx-main /login?next=... 302 - 그 외 (API 등) → 401 JSON - _AUTH_EXEMPT_PREFIXES 에 해당하는 경로는 통과 """ async def dispatch(self, request: Request, call_next): # 환경별로 scope.path 가 root_path 를 포함할 수도, 안 할 수도 있어 # 항상 root_path 제외된 형태로 정규화한다. raw = request.scope.get("path", "") or "/" if APP_ROOT_PATH and raw.startswith(APP_ROOT_PATH + "/"): path = raw[len(APP_ROOT_PATH):] elif APP_ROOT_PATH and raw == APP_ROOT_PATH: path = "/" else: path = raw for prefix in _AUTH_EXEMPT_PREFIXES: if path == prefix or path.startswith(prefix): return await call_next(request) # 서비스 간 호출 — X-Service-Token 헤더가 일치하면 세션 없이도 통과. # NPM 은 외부에서 들어온 이 헤더를 빈 값으로 strip 해야 한다 (보안). service_token = os.getenv("INTERNAL_SERVICE_TOKEN", "") if service_token: incoming_token = request.headers.get("x-service-token", "") if incoming_token and incoming_token == service_token: return await call_next(request) email = request.session.get(SESSION_USER_KEY) if email: return await call_next(request) # 미인증 accept = request.headers.get("accept", "") wants_html = "text/html" in accept or not path.startswith("/api/") if wants_html: next_path = f"{APP_ROOT_PATH}{path}" if APP_ROOT_PATH else path if request.scope.get("query_string"): next_path = f"{next_path}?{request.scope['query_string'].decode('latin-1')}" sep = "&" if "?" in MAIN_LOGIN_URL else "?" target = f"{MAIN_LOGIN_URL}{sep}next={quote(next_path, safe='/')}" return RedirectResponse(url=target, status_code=302) return JSONResponse({"detail": "Not authenticated"}, status_code=401) # ========================================== # 데이터베이스 접속 설정 (환경변수 기반) # ========================================== _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") # 미들웨어 등록 순서 주의: # Starlette 은 add_middleware 를 "반대 순서로" 적용한다 (마지막에 추가한 것이 가장 바깥). # AuthGuardMiddleware 가 request.session 을 읽으려면 SessionMiddleware 가 # 먼저 실행돼서 scope 에 session 을 주입해야 한다. # 따라서 AuthGuard 를 먼저 추가 (내부) → Session 을 나중에 추가 (외부, 먼저 실행). app.add_middleware(AuthGuardMiddleware) app.add_middleware( SessionMiddleware, secret_key=_require_session_secret(), session_cookie=os.getenv("SESSION_COOKIE_NAME", "session"), same_site=os.getenv("SESSION_SAME_SITE", "lax"), https_only=os.getenv("SESSION_HTTPS_ONLY", "0") == "1", max_age=int(os.getenv("SESSION_MAX_AGE", "28800")), path="/", ) @app.get("/health/db") async def health_db(): """DB ping — 인증 불필요. dbx-main 의 헬스 배지가 이 경로를 폴링한다.""" try: conn = psycopg2.connect(**DB_CONFIG, connect_timeout=3) conn.close() return JSONResponse({"ok": True, "detail": "ok"}) except Exception as e: return JSONResponse({"ok": False, "detail": str(e)}, status_code=503) @app.get("/login") async def corm_login(request: Request, next: Optional[str] = None): """OMS 와 동일 패턴 — 자체 OAuth 없음. dbx-main 으로 위임.""" root = APP_ROOT_PATH fallback = f"{root}/" if root else "/" safe = next if (next and next.startswith("/") and not next.startswith("//") and not next.startswith("/\\")) else fallback sep = "&" if "?" in MAIN_LOGIN_URL else "?" target = f"{MAIN_LOGIN_URL}{sep}next={quote(safe, safe='/')}" return RedirectResponse(url=target, status_code=302) @app.get("/logout") async def corm_logout(): """전적으로 dbx-main 에 위임. CORM 은 세션을 직접 만지지 않는다 (쿠키 path 분리 방지).""" return RedirectResponse(url=MAIN_LOGOUT_URL, status_code=302) 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, "root_path": APP_ROOT_PATH, "user_email": request.session.get(SESSION_USER_KEY, ""), "user_name": request.session.get(SESSION_NAME_KEY, ""), } ) @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): home = f"{APP_ROOT_PATH}/" if APP_ROOT_PATH else "/" try: cafe24_api.request_new_token(code) return HTMLResponse(f"") 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")))