import io import json import math import os import threading from types import SimpleNamespace from typing import Optional from datetime import date, datetime import asyncio import httpx import pandas as pd from fastapi import APIRouter, Depends, HTTPException, File, Form, UploadFile from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import String, and_, cast, func, or_, text from urllib.parse import quote from app.database import SessionLocal, get_db from app.models import Order from app.auth import get_current_user from app.event_logger import log_event from app.search_utils import ( can_use_trigram_fts, fts_phrase, normalize_phone, normalize_tracking, prefix_upper_bound, ) router = APIRouter() CS_RETURN_REQUEST_URL = os.getenv( "CS_RETURN_REQUEST_URL", "http://127.0.0.1:8001/api/return/request", ) # Global dict to hold upload progress. In a real world scenario this should be in Redis or DB. upload_progress_state = {} TEMPLATE_COLUMN_ALIASES = { "order_date": ["주문날짜"], "sequence_num": ["번호"], "order_no": ["주문번호"], "recipient_name": ["수령인명"], "product_code": ["상품코드"], "product_name": ["상품명"], "order_quantity": ["수량"], "address": ["주소"], "postal_code": ["우편번호"], "recipient_phone": ["수령인전화번호", "수령인전화"], "recipient_mobile": ["수령인휴대폰"], "delivery_memo": ["배송시요구사항", "배송시요구"], "tracking_number": ["송장번호"], "vendor": ["쇼핑몰명"], "order_note": ["비고"], "order_no_mall": ["주문번호(쇼핑몰)"], "order_list_1": ["주문목록"], } TEMPLATE_COLUMN_FALLBACK_INDEXES = { "order_no_mall": 15, # Excel P column "order_list_1": 16, # Excel Q column } REQUIRED_TEMPLATE_FIELDS = [ "order_date", "order_no", "recipient_name", "product_code", "product_name", "order_quantity", "address", "recipient_mobile", "tracking_number", "vendor", ] def normalize_template_column(value) -> str: return str(value).replace("\n", "").replace(" ", "").strip() def clean_excel_value(value) -> str | None: if value is None or pd.isna(value): return None if isinstance(value, datetime): return value.strftime("%Y-%m-%d %H:%M:%S") if isinstance(value, date): return datetime.combine(value, datetime.min.time()).strftime("%Y-%m-%d %H:%M:%S") text = str(value).strip() return text or None def find_template_columns(raw_columns) -> dict[str, int]: normalized_columns = [normalize_template_column(column) for column in raw_columns] column_map = {} for field, aliases in TEMPLATE_COLUMN_ALIASES.items(): normalized_aliases = [normalize_template_column(alias) for alias in aliases] for alias in normalized_aliases: if alias in normalized_columns: column_map[field] = normalized_columns.index(alias) break for field, column_index in TEMPLATE_COLUMN_FALLBACK_INDEXES.items(): if field not in column_map and column_index < len(normalized_columns): column_map[field] = column_index missing_required = [ TEMPLATE_COLUMN_ALIASES[field][0] for field in REQUIRED_TEMPLATE_FIELDS if field not in column_map ] if missing_required: raise ValueError("템플릿 필수 컬럼이 없습니다: " + ", ".join(missing_required)) return column_map def row_value(row_values, column_map: dict[str, int], field: str) -> str | None: index = column_map.get(field) if index is None or index >= len(row_values): return None return clean_excel_value(row_values[index]) def normalize_address_key(value) -> str: if not value: return "" return str(value).strip(" \t\r\n\u00a0\u200b\u200c\u200d\ufeff") def is_manual_vendor(value) -> bool: return str(value or "").replace(" ", "") == "수동발주" def set_upload_state( user_id: str, *, status: str, message: str, upload_progress: int = 0, index_progress: int = 0, total_rows: int = 0, processed_rows: int = 0, indexed_rows: int = 0, ): upload_progress_state[user_id] = { "status": status, "message": message, "upload_progress": max(0, min(100, int(upload_progress))), "index_progress": max(0, min(100, int(index_progress))), "total_rows": int(total_rows or 0), "processed_rows": int(processed_rows or 0), "indexed_rows": int(indexed_rows or 0), } def set_upload_error(user_id: str, message: str): previous = upload_progress_state.get(user_id, {}) set_upload_state( user_id, status="error", message=message, upload_progress=previous.get("upload_progress", 0), index_progress=previous.get("index_progress", 0), total_rows=previous.get("total_rows", 0), processed_rows=previous.get("processed_rows", 0), indexed_rows=previous.get("indexed_rows", 0), ) def build_order_search_query( db: Session, *, name: Optional[str] = None, tracking: Optional[str] = None, phone: Optional[str] = None, address: Optional[str] = None, order_start_date: Optional[str] = None, order_end_date: Optional[str] = None, ): query = db.query(Order) params = {} is_sqlite = db.get_bind().dialect.name == "sqlite" if name: if is_sqlite and can_use_trigram_fts(name): params["name_fts"] = f"recipient_name:{fts_phrase(name)}" query = query.filter( text( "orders.id IN (" "SELECT rowid FROM orders_name_address_fts " "WHERE orders_name_address_fts MATCH :name_fts" ")" ) ) else: query = query.filter(Order.recipient_name.ilike(f"%{name}%")) if tracking: clean_tracking = normalize_tracking(tracking) if clean_tracking: tracking_upper = prefix_upper_bound(clean_tracking) if tracking_upper: query = query.filter( Order.tracking_number_normalized >= clean_tracking, Order.tracking_number_normalized < tracking_upper, ) else: query = query.filter(Order.tracking_number_normalized == clean_tracking) if phone: clean_phone = normalize_phone(phone) if clean_phone: if is_sqlite and can_use_trigram_fts(clean_phone): params["phone_fts"] = ( f"recipient_phone_normalized:{fts_phrase(clean_phone)} OR " f"recipient_mobile_normalized:{fts_phrase(clean_phone)}" ) query = query.filter( text( "orders.id IN (" "SELECT rowid FROM orders_phone_fts " "WHERE orders_phone_fts MATCH :phone_fts" ")" ) ) else: query = query.filter( or_( Order.recipient_phone_normalized.like(f"%{clean_phone}%"), Order.recipient_mobile_normalized.like(f"%{clean_phone}%"), ) ) if address: if is_sqlite and can_use_trigram_fts(address): params["address_fts"] = f"address:{fts_phrase(address)}" query = query.filter( text( "orders.id IN (" "SELECT rowid FROM orders_name_address_fts " "WHERE orders_name_address_fts MATCH :address_fts" ")" ) ) else: query = query.filter(Order.address.ilike(f"%{address}%")) if order_start_date: query = query.filter(Order.order_date >= f"{order_start_date} 00:00:00") if order_end_date: query = query.filter(Order.order_date <= f"{order_end_date} 23:59:59") if params: query = query.params(**params) has_filters = any([name, tracking, phone, address, order_start_date, order_end_date]) return query, has_filters def customer_key(name: str | None, phone: str | None): normalized_name = (name or "").strip() normalized_phone = normalize_phone(phone) if not normalized_name or not normalized_phone: return None return normalized_name, normalized_phone def order_note_key(order: Order) -> str: return (order.order_no_mall or order.order_no or "").strip() def apply_customer_notes(db: Session, orders: list[Order]): keys = { key for order in orders if (key := customer_key(order.recipient_name, order.recipient_mobile or order.recipient_phone)) } if not keys: for order in orders: order.customer_note = "" return names = sorted({name for name, _phone in keys}) phones = sorted({phone for _name, phone in keys}) note_rows = ( db.query( Order.recipient_name, Order.recipient_mobile_normalized, Order.recipient_phone_normalized, Order.customer_note, Order.note, ) .filter( or_( and_(Order.customer_note.isnot(None), Order.customer_note != ""), and_(Order.note.isnot(None), Order.note != ""), ) ) .filter(func.trim(Order.recipient_name).in_(names)) .filter( or_( Order.recipient_mobile_normalized.in_(phones), Order.recipient_phone_normalized.in_(phones), ) ) .order_by(Order.id.desc()) .all() ) notes_by_customer = {} for name, mobile_normalized, phone_normalized, customer_note, legacy_note in note_rows: note = customer_note or legacy_note or "" for phone_value in (mobile_normalized, phone_normalized): key = customer_key(name, phone_value) if key in keys and key not in notes_by_customer: notes_by_customer[key] = note for order in orders: key = customer_key(order.recipient_name, order.recipient_mobile or order.recipient_phone) order.customer_note = notes_by_customer.get(key, "") if key else "" def apply_order_notes(db: Session, orders: list[Order]): keys = sorted({key for order in orders if (key := order_note_key(order))}) if not keys: for order in orders: order.order_note = "" return note_rows = ( db.query( Order.order_no_mall, Order.order_no, Order.order_note, ) .filter(Order.order_note.isnot(None), Order.order_note != "") .filter( or_( Order.order_no_mall.in_(keys), Order.order_no.in_(keys), ) ) .order_by(Order.id.desc()) .all() ) notes_by_order = {} for order_no_mall, order_no, note in note_rows: for key in ((order_no_mall or "").strip(), (order_no or "").strip()): if key in keys and key not in notes_by_order: notes_by_order[key] = note or "" for order in orders: key = order_note_key(order) order.order_note = notes_by_order.get(key, "") if key else "" def order_to_search_item(order: Order): return { "id": order.id, "order_date": order.order_date, "sequence_num": order.sequence_num, "order_no": order.order_no, "order_no_mall": order.order_no_mall, "recipient_name": order.recipient_name, "product_code": order.product_code, "product_name": order.product_name, "order_quantity": order.order_quantity, "address": order.address, "postal_code": order.postal_code, "recipient_phone": order.recipient_phone, "recipient_mobile": order.recipient_mobile, "delivery_memo": order.delivery_memo, "tracking_number": order.tracking_number, "vendor": order.vendor, "order_list_1": order.order_list_1, "order_list_2": order.order_list_2, "note": order.note, "upload_date": order.upload_date, "address_order_count": getattr(order, "address_order_count", 0), "phone_order_count": getattr(order, "phone_order_count", 0), "customer_note": getattr(order, "customer_note", "") or "", "order_note": getattr(order, "order_note", "") or "", } def orders_to_excel_response(orders, filename: str, sheet_name: str = "Orders"): data = [] for o in orders: data.append({ "주문날짜": o.order_date, "수령인명": o.recipient_name, "상품코드": o.product_code, "상품명": o.product_name, "수량": o.order_quantity, "주소": o.address, "수령인 전화번호": o.recipient_phone, "휴대폰": o.recipient_mobile, "송장번호": o.tracking_number, "발주처": o.vendor, "주문번호": o.order_no, "주문번호(쇼핑몰)": o.order_no_mall, "주문목록": o.order_list_1, "추가날짜": o.upload_date, }) df = pd.DataFrame(data) output = io.BytesIO() with pd.ExcelWriter(output, engine="openpyxl") as writer: df.to_excel(writer, index=False, sheet_name=sheet_name) output.seek(0) encoded_filename = quote(filename) return Response( content=output.getvalue(), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"}, ) def run_upload_job(contents: bytes, filename: str, user_id: str, user_data: dict): current_user = SimpleNamespace(**user_data) db = SessionLocal() try: set_upload_state( user_id, status="reading", message="엑셀 파일을 읽는 중입니다...", ) # read_excel requires xlrd for .xls and openpyxl for .xlsx df = pd.read_excel(io.BytesIO(contents)) # Replace NaN with None for SQLAlchemy df = df.where(pd.notnull(df), None) column_map = find_template_columns(list(df.columns)) total_rows = len(df) set_upload_state( user_id, status="processing", message=f"총 {total_rows}건의 엑셀 데이터를 정리하는 중입니다...", total_rows=total_rows, ) db_records = [] batch_size = 1000 for index, row in df.iterrows(): rlist = list(row) recipient_phone = row_value(rlist, column_map, "recipient_phone") recipient_mobile = row_value(rlist, column_map, "recipient_mobile") tracking_number = row_value(rlist, column_map, "tracking_number") address = row_value(rlist, column_map, "address") order = Order( order_date=row_value(rlist, column_map, "order_date"), sequence_num=row_value(rlist, column_map, "sequence_num"), order_no=row_value(rlist, column_map, "order_no"), order_no_mall=row_value(rlist, column_map, "order_no_mall"), recipient_name=row_value(rlist, column_map, "recipient_name"), product_code=row_value(rlist, column_map, "product_code"), product_name=row_value(rlist, column_map, "product_name"), order_quantity=row_value(rlist, column_map, "order_quantity"), address=address, address_normalized=normalize_address_key(address), postal_code=row_value(rlist, column_map, "postal_code"), recipient_phone=recipient_phone, recipient_mobile=recipient_mobile, recipient_phone_normalized=normalize_phone(recipient_phone), recipient_mobile_normalized=normalize_phone(recipient_mobile), delivery_memo=row_value(rlist, column_map, "delivery_memo"), tracking_number=tracking_number, tracking_number_normalized=normalize_tracking(tracking_number), vendor=row_value(rlist, column_map, "vendor"), order_list_1=row_value(rlist, column_map, "order_list_1"), order_note=row_value(rlist, column_map, "order_note"), upload_date=datetime.now().strftime("%Y-%m-%d %H:%M:%S") ) db_records.append(order) # Update progress periodically if index % 1000 == 0 and index > 0: calc_progress = float(index) / total_rows if total_rows else 1 set_upload_state( user_id, status="processing", message=f"{index}/{total_rows}건 엑셀 데이터 정리 중...", upload_progress=calc_progress * 100, total_rows=total_rows, processed_rows=index, ) set_upload_state( user_id, status="indexing", message="DB 저장과 검색 인덱스 반영을 시작합니다...", upload_progress=100, index_progress=0, total_rows=total_rows, processed_rows=total_rows, ) for i in range(0, len(db_records), batch_size): chunk = db_records[i:i + batch_size] db.bulk_save_objects(chunk) indexed_rows = min(i + len(chunk), total_rows) index_progress = (indexed_rows / total_rows * 100) if total_rows else 100 set_upload_state( user_id, status="indexing", message=f"{indexed_rows}/{total_rows}건 DB 저장 및 검색 인덱스 반영 중...", upload_progress=100, index_progress=index_progress, total_rows=total_rows, processed_rows=total_rows, indexed_rows=indexed_rows, ) db.commit() set_upload_state( user_id, status="completed", message=f"완료: {len(db_records)}건 저장 및 검색 인덱스 반영", upload_progress=100, index_progress=100, total_rows=total_rows, processed_rows=total_rows, indexed_rows=total_rows, ) log_event( current_user, "파일 업로드 완료", "success", {"filename": filename, "uploaded_count": len(db_records)}, ) except ValueError as e: db.rollback() set_upload_error(user_id, str(e)) log_event(current_user, "파일 업로드 실패", "error", {"filename": filename, "error": str(e)}) except Exception as e: db.rollback() set_upload_error(user_id, f"에러 발생: {str(e)}") log_event(current_user, "파일 업로드 실패", "error", {"filename": filename, "error": str(e)}) finally: db.close() @router.post("/upload") def upload_file( file: UploadFile = File(...), upload_token: str = Form(...), current_user = Depends(get_current_user) ): filename = file.filename or "" if not filename.endswith(".xls") and not filename.endswith(".xlsx"): raise HTTPException(status_code=400, detail="Only XLS or XLSX files are allowed.") user_id = f"{current_user.id}:{upload_token}" contents = file.file.read() user_data = { "id": getattr(current_user, "id", ""), "email": getattr(current_user, "email", None), "name": getattr(current_user, "name", None), } log_event(current_user, "파일 업로드 시작", "started", {"filename": filename}) set_upload_state( user_id, status="queued", message="업로드 작업이 등록되었습니다.", ) threading.Thread( target=run_upload_job, args=(contents, filename, user_id, user_data), daemon=True, ).start() return { "status": "queued", "upload_token": upload_token, "message": "업로드 작업이 시작되었습니다. 진행률 창에서 완료 상태를 확인해주세요.", } @router.get("/progress") async def upload_progress( upload_token: str, current_user = Depends(get_current_user) ): user_id = f"{current_user.id}:{upload_token}" async def event_generator(): while True: if user_id in upload_progress_state: state = upload_progress_state[user_id] yield f"data: {json.dumps(state, ensure_ascii=False)}\n\n" # Stop streaming connection if done or error if state["status"] in ["completed", "error"]: upload_progress_state.pop(user_id, None) break else: yield f"data: {json.dumps({'status': 'idle'}, ensure_ascii=False)}\n\n" await asyncio.sleep(0.5) return StreamingResponse(event_generator(), media_type="text/event-stream") @router.get("/search") async def search_orders( name: Optional[str] = None, tracking: Optional[str] = None, phone: Optional[str] = None, address: Optional[str] = None, order_start_date: Optional[str] = None, order_end_date: Optional[str] = None, page: int = 1, size: int = 27, db: Session = Depends(get_db), current_user = Depends(get_current_user) ): query, has_filters = build_order_search_query( db, name=name, tracking=tracking, phone=phone, address=address, order_start_date=order_start_date, order_end_date=order_end_date, ) # If no parameters are provided, return empty if not has_filters: return {"items": [], "total": 0, "page": page, "size": size} total = query.count() offset = (page - 1) * size orders = query.order_by(Order.order_date.desc(), Order.id.desc()).offset(offset).limit(size).all() page_address_keys = sorted({normalize_address_key(order.address) for order in orders if normalize_address_key(order.address)}) page_phones = sorted( { normalize_phone(order.recipient_mobile or order.recipient_phone) for order in orders if normalize_phone(order.recipient_mobile or order.recipient_phone) } ) address_date_counts = {} phone_date_counts = {} order_date_key = func.substr(Order.order_date, 1, 10) if page_address_keys: address_date_counts = dict( db.query( Order.address_normalized, func.count(func.distinct(order_date_key)), ) .filter(Order.address_normalized.in_(page_address_keys)) .group_by(Order.address_normalized) .all() ) if page_phones: phone_date_sets = {phone_value: set() for phone_value in page_phones} phone_rows = ( db.query( Order.recipient_mobile_normalized, Order.recipient_phone_normalized, order_date_key, ) .filter( or_( Order.recipient_mobile_normalized.in_(page_phones), Order.recipient_phone_normalized.in_(page_phones), ) ) .all() ) for mobile_normalized, phone_normalized, date_key in phone_rows: if mobile_normalized in phone_date_sets: if date_key: phone_date_sets[mobile_normalized].add(date_key) if phone_normalized in phone_date_sets: if date_key: phone_date_sets[phone_normalized].add(date_key) phone_date_counts = { phone_value: len(date_keys) for phone_value, date_keys in phone_date_sets.items() } for order in orders: address_key = normalize_address_key(order.address) order.address_order_count = address_date_counts.get(address_key, 0) if address_key else 0 display_phone_normalized = normalize_phone(order.recipient_mobile or order.recipient_phone) order.phone_order_count = phone_date_counts.get(display_phone_normalized, 0) if display_phone_normalized else 0 apply_customer_notes(db, orders) apply_order_notes(db, orders) return { "items": [order_to_search_item(order) for order in orders], "total": total, "page": page, "size": size, "total_pages": math.ceil(total / size) if size > 0 else 0 } @router.get("/export-search") async def export_search_orders( name: Optional[str] = None, tracking: Optional[str] = None, phone: Optional[str] = None, address: Optional[str] = None, order_start_date: Optional[str] = None, order_end_date: Optional[str] = None, db: Session = Depends(get_db), current_user = Depends(get_current_user) ): query, has_filters = build_order_search_query( db, name=name, tracking=tracking, phone=phone, address=address, order_start_date=order_start_date, order_end_date=order_end_date, ) if not has_filters: raise HTTPException(status_code=400, detail="검색 조건이 없습니다.") orders = query.order_by(Order.order_date.desc(), Order.id.desc()).all() if not orders: raise HTTPException(status_code=404, detail="다운로드할 검색 결과가 없습니다.") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") log_event( current_user, "검색 결과 엑셀 다운로드", "success", { "download_count": len(orders), "name": name, "phone": phone, "address": address, "tracking": tracking, "start_date": order_start_date, "end_date": order_end_date, }, ) return orders_to_excel_response(orders, f"검색결과_{timestamp}.xlsx", "Search_Results") class DeleteOrdersRequest(BaseModel): order_ids: list[int] class UpdateOrderContactRequest(BaseModel): recipient_name: str address: str recipient_mobile: str customer_note: Optional[str] = None order_note: Optional[str] = None note: str = "" class DeleteSingleOrderRequest(BaseModel): password: str class ReturnRequestPayload(BaseModel): request_date: str request_type: str remarks: str = "" tracking_no: Optional[str] = None receiver_name: Optional[str] = None address: Optional[str] = None phone: Optional[str] = None @router.put("/{order_id}/contact") async def update_order_contact( order_id: int, req: UpdateOrderContactRequest, db: Session = Depends(get_db), current_user = Depends(get_current_user) ): order = db.query(Order).filter(Order.id == order_id).first() if not order: log_event(current_user, "주문 정보 수정", "error", {"order_id": order_id, "error": "not_found"}) raise HTTPException(status_code=404, detail="주문 데이터를 찾을 수 없습니다.") before = { "recipient_name": order.recipient_name, "address": order.address, "recipient_mobile": order.recipient_mobile, "customer_note": order.customer_note, "order_note": order.order_note, "note": order.note, } new_name = req.recipient_name.strip() or None new_phone = req.recipient_mobile.strip() or None new_phone_normalized = normalize_phone(new_phone) new_customer_note = ( req.customer_note if req.customer_note is not None else req.note ).strip() or None new_order_note = (req.order_note or "").strip() or None order.recipient_name = new_name order.address = req.address.strip() or None order.address_normalized = normalize_address_key(order.address) order.recipient_mobile = new_phone order.recipient_mobile_normalized = new_phone_normalized customer_note_updated_count = 1 customer_phone_normalized = new_phone_normalized or normalize_phone(order.recipient_phone) if new_name and customer_phone_normalized: customer_note_targets = ( db.query(Order) .filter(func.trim(Order.recipient_name) == new_name) .filter( or_( Order.recipient_mobile_normalized == customer_phone_normalized, Order.recipient_phone_normalized == customer_phone_normalized, ) ) .all() ) for target_order in customer_note_targets: target_order.customer_note = new_customer_note customer_note_updated_count = len(customer_note_targets) order_note_updated_count = 1 current_order_note_key = order_note_key(order) if current_order_note_key: order_note_targets = ( db.query(Order) .filter( or_( Order.order_no_mall == current_order_note_key, Order.order_no == current_order_note_key, ) ) .all() ) for target_order in order_note_targets: target_order.order_note = new_order_note order_note_updated_count = len(order_note_targets) order.customer_note = new_customer_note order.order_note = new_order_note db.commit() db.refresh(order) log_event( current_user, "주문 정보 수정", "success", { "order_id": order.id, "order_no": order.order_no, "order_no_mall": order.order_no_mall, "before": before, "after": { "recipient_name": order.recipient_name, "address": order.address, "recipient_mobile": order.recipient_mobile, "customer_note": order.customer_note, "order_note": order.order_note, "note": order.note, }, "customer_note_updated_count": customer_note_updated_count, "order_note_updated_count": order_note_updated_count, "submitted_customer_note_length": len( req.customer_note if req.customer_note is not None else req.note or "" ), "submitted_order_note_length": len(req.order_note or ""), }, ) return { "message": "주문 정보가 수정되었습니다.", "order_id": order.id, "customer_note": order.customer_note, "order_note": order.order_note, "note": order.note, "customer_note_updated_count": customer_note_updated_count, "order_note_updated_count": order_note_updated_count, } @router.post("/{order_id}/return-request") async def send_return_request( order_id: int, req: ReturnRequestPayload, db: Session = Depends(get_db), current_user = Depends(get_current_user), ): order = db.query(Order).filter(Order.id == order_id).first() if not order: log_event(current_user, "반품 신청 전송", "error", {"order_id": order_id, "error": "not_found"}) raise HTTPException(status_code=404, detail="주문 데이터를 찾을 수 없습니다.") payload = { "request_date": (req.request_date or "").strip(), "request_type": (req.request_type or "").strip(), "receiver_name": (req.receiver_name or order.recipient_name or "").strip(), "address": (req.address or order.address or "").strip(), "phone": (req.phone or order.recipient_mobile or order.recipient_phone or "").strip(), "tracking_no": (req.tracking_no or order.tracking_number or "").strip(), "mall": (order.vendor or "").strip(), "remarks": (req.remarks or "").strip(), } missing_fields = [ key for key in ("request_date", "request_type") if payload[key] == "" ] if missing_fields: log_event( current_user, "반품 신청 전송", "error", { "order_id": order_id, "missing_fields": missing_fields, "payload": payload, }, ) raise HTTPException( status_code=400, detail=f"반품 신청 필수값이 비어 있습니다: {', '.join(missing_fields)}", ) log_event( current_user, "반품 신청 전송 요청", "info", { "order_id": order_id, "endpoint": CS_RETURN_REQUEST_URL, "payload": payload, }, ) try: timeout = httpx.Timeout(connect=2.0, read=5.0, write=3.0, pool=2.0) async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: response = await client.post(CS_RETURN_REQUEST_URL, json=payload) response.raise_for_status() except httpx.HTTPStatusError as exc: response_text = exc.response.text[:1000] if exc.response is not None else "" log_event( current_user, "반품 신청 전송", "error", { "order_id": order_id, "status_code": exc.response.status_code if exc.response is not None else None, "response": response_text, "payload": payload, }, ) raise HTTPException( status_code=502, detail=f"CS 관리 프로그램 반품 신청 오류: HTTP {exc.response.status_code}", ) except httpx.RequestError as exc: log_event( current_user, "반품 신청 전송", "error", { "order_id": order_id, "endpoint": CS_RETURN_REQUEST_URL, "error": str(exc), "payload": payload, }, ) raise HTTPException(status_code=502, detail=f"CS 관리 프로그램 연결 오류: {str(exc)}") log_event( current_user, "반품 신청 전송", "success", { "order_id": order_id, "endpoint": CS_RETURN_REQUEST_URL, "payload": payload, "status_code": response.status_code, }, ) return { "message": "반품 신청 데이터가 CS 관리 프로그램으로 전송되었습니다.", "status_code": response.status_code, "payload": payload, } @router.post("/{order_id}/delete") @router.delete("/{order_id}") async def delete_single_order( order_id: int, req: DeleteSingleOrderRequest, db: Session = Depends(get_db), current_user = Depends(get_current_user) ): log_event(current_user, "주문 단건 삭제 요청", "info", {"order_id": order_id}) if req.password != "1225": log_event(current_user, "주문 단건 삭제", "error", {"order_id": order_id, "error": "invalid_password"}) raise HTTPException(status_code=403, detail="비밀번호가 올바르지 않습니다.") order = db.query(Order).filter(Order.id == order_id).first() if not order: log_event(current_user, "주문 단건 삭제", "error", {"order_id": order_id, "error": "not_found"}) raise HTTPException(status_code=404, detail="주문 데이터를 찾을 수 없습니다.") deleted_order = { "order_id": order.id, "order_date": order.order_date, "order_no": order.order_no, "order_no_mall": order.order_no_mall, "recipient_name": order.recipient_name, "recipient_mobile": order.recipient_mobile, "recipient_phone": order.recipient_phone, "product_code": order.product_code, "product_name": order.product_name, "order_quantity": order.order_quantity, "vendor": order.vendor, "note": order.note, } try: db.delete(order) db.commit() log_event(current_user, "주문 단건 삭제", "success", deleted_order) return {"message": "주문이 삭제되었습니다.", "deleted_count": 1, "order_id": order_id} except Exception as e: db.rollback() log_event(current_user, "주문 단건 삭제", "error", {**deleted_order, "error": str(e)}) raise HTTPException(status_code=500, detail=f"주문 삭제 오류: {str(e)}") @router.post("/delete") async def delete_orders( req: DeleteOrdersRequest, db: Session = Depends(get_db), current_user = Depends(get_current_user) ): if not current_user.is_admin: raise HTTPException(status_code=403, detail="Not authorized to delete data.") if not req.order_ids: return {"message": "No orders selected for deletion.", "deleted_count": 0} try: deleted_count = db.query(Order).filter(Order.id.in_(req.order_ids)).delete(synchronize_session=False) db.commit() return {"message": f"성공적으로 {deleted_count}건의 데이터를 삭제했습니다.", "deleted_count": deleted_count} except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e))