from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session, joinedload from sqlalchemy import func, and_ from datetime import datetime, date from typing import List, Dict import json from database import get_db from models import WaitingList, ClassInfo, StoreSettings, DailyClosing, ClassClosure, Store from auth import get_current_store from schemas import ( WaitingStatusUpdate, WaitingOrderUpdate, WaitingClassUpdate, BatchAttendance, WaitingBoard, WaitingBoardItem, EmptySeatInsert ) from sse_manager import sse_manager from utils import get_today_date router = APIRouter() def get_current_business_date(db: Session, store_id: int) -> date: """ 현재 영업일 조회 (Sync with daily_closing.py) 1. 활성화된 영업일 우선 2. 없으면 시간 기반 계산 """ # 1. 활성화된 영업일 확인 active_closing = db.query(DailyClosing).filter( DailyClosing.store_id == store_id, DailyClosing.is_closed == False ).order_by(DailyClosing.business_date.desc()).first() if active_closing: return active_closing.business_date # 2. 설정 기반 계산 settings = db.query(StoreSettings).filter(StoreSettings.store_id == store_id).first() start_hour = settings.business_day_start if settings else 5 return get_today_date(start_hour) # 요일 매핑 WEEKDAY_MAP = { 0: "mon", # Monday 1: "tue", # Tuesday 2: "wed", # Wednesday 3: "thu", # Thursday 4: "fri", # Friday 5: "sat", # Saturday 6: "sun" # Sunday } DEFAULT_WEEKDAY_SCHEDULE = { "mon": True, "tue": True, "wed": True, "thu": True, "fri": True, "sat": True, "sun": True } def parse_weekday_schedule(schedule_str: str) -> Dict[str, bool]: """ JSON 문자열을 weekday_schedule 딕셔너리로 안전하게 변환 """ if not schedule_str: return DEFAULT_WEEKDAY_SCHEDULE.copy() try: schedule = json.loads(schedule_str) if not isinstance(schedule, dict): return DEFAULT_WEEKDAY_SCHEDULE.copy() return schedule except (json.JSONDecodeError, TypeError, ValueError): return DEFAULT_WEEKDAY_SCHEDULE.copy() def filter_classes_by_weekday(classes: List[ClassInfo], target_date: date) -> List[ClassInfo]: """ 특정 날짜의 요일에 맞는 클래스만 필터링 Args: classes: 필터링할 클래스 목록 target_date: 기준 날짜 Returns: 해당 요일에 운영되는 클래스 목록 """ weekday = WEEKDAY_MAP[target_date.weekday()] filtered_classes = [] for cls in classes: schedule = parse_weekday_schedule(cls.weekday_schedule) if schedule.get(weekday, True): filtered_classes.append(cls) return filtered_classes def convert_class_to_dict(cls: ClassInfo) -> dict: """ ClassInfo 모델 객체를 dict로 변환 (Pydantic validation용) weekday_schedule을 JSON 문자열에서 dict로 파싱 Args: cls: ClassInfo 모델 인스턴스 Returns: dict: 변환된 딕셔너리 """ return { "id": cls.id, "class_number": cls.class_number, "class_name": cls.class_name, "start_time": cls.start_time, "end_time": cls.end_time, "max_capacity": cls.max_capacity, "is_active": cls.is_active, "weekday_schedule": parse_weekday_schedule(cls.weekday_schedule), "class_type": cls.class_type if hasattr(cls, 'class_type') else 'all', "created_at": cls.created_at, "updated_at": cls.updated_at } @router.get("/display", response_model=WaitingBoard) async def get_waiting_board( db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기현황판 데이터 조회 - 매장 설정에 따라 표시할 클래스 개수 결정 - 대기자 목록을 클래스별로 정렬하여 반환 """ today = get_current_business_date(db, current_store.id) # 매장 설정 조회 settings = db.query(StoreSettings).filter( StoreSettings.store_id == current_store.id ).first() if not settings: raise HTTPException(status_code=404, detail="매장 설정을 찾을 수 없습니다.") # 영업 정보 조회 business = db.query(DailyClosing).filter( DailyClosing.business_date == today, DailyClosing.store_id == current_store.id ).first() # 대기 중인 목록 조회 (먼저 조회) waiting_list = db.query(WaitingList).options( joinedload(WaitingList.member) ).filter( WaitingList.business_date == today, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).order_by(WaitingList.class_id, WaitingList.class_order).all() # 대기자가 있는 클래스 ID 목록 classes_with_waiting = set(w.class_id for w in waiting_list) # 일괄 출석이 완료된 클래스 ID 목록 (출석한 사람은 있지만 대기자는 없는 클래스) completed_classes = db.query(WaitingList.class_id).filter( WaitingList.business_date == today, WaitingList.status == "attended", WaitingList.store_id == current_store.id ).distinct().all() completed_class_ids = set(c.class_id for c in completed_classes if c.class_id not in classes_with_waiting) # 마감된 클래스 ID 목록 closed_classes = db.query(ClassClosure.class_id).filter( ClassClosure.business_date == today, ClassClosure.store_id == current_store.id ).all() closed_class_ids = set(c.class_id for c in closed_classes) # 활성화된 클래스 조회 및 오늘 요일에 맞는 클래스만 필터링 all_classes_raw = db.query(ClassInfo).filter( ClassInfo.is_active == True, ClassInfo.store_id == current_store.id ).order_by(ClassInfo.class_number).all() # 헬퍼 함수를 사용하여 오늘 요일에 맞는 클래스만 필터링 all_classes = filter_classes_by_weekday(all_classes_raw, today) # 완료된 클래스와 마감된 클래스는 제외 # 대기자가 있는 클래스를 우선 표시하되, 설정된 개수만큼 채우기 classes_with_waiting_list = [c for c in all_classes if c.id in classes_with_waiting and c.id not in closed_class_ids] classes_without_waiting = [c for c in all_classes if c.id not in classes_with_waiting and c.id not in completed_class_ids and c.id not in closed_class_ids] # 대기자 있는 클래스 우선 배치 + 부족한 만큼 다음 교시로 채우기 selected_classes = classes_with_waiting_list[:settings.display_classes_count] # 설정된 개수에 미달하면 대기자 없는 클래스로 채우기 remaining_slots = settings.display_classes_count - len(selected_classes) if remaining_slots > 0: selected_classes.extend(classes_without_waiting[:remaining_slots]) classes = selected_classes # 표시 데이터 변환 board_items = [] for waiting in waiting_list: class_info = next((c for c in classes if c.id == waiting.class_id), None) if not class_info: continue if waiting.member and waiting.member.name: display_name = waiting.member.name else: display_name = waiting.name if waiting.name else waiting.phone[-4:] board_items.append(WaitingBoardItem( id=waiting.id, waiting_number=waiting.waiting_number, display_name=display_name, class_id=waiting.class_id, class_name=class_info.class_name, class_order=waiting.class_order, is_empty_seat=waiting.is_empty_seat or False, status=waiting.status )) # ClassInfo 객체들을 dict로 변환 (weekday_schedule 파싱 포함) classes_dict = [convert_class_to_dict(cls) for cls in classes] return WaitingBoard( store_name=settings.store_name, business_date=today, classes=classes_dict, waiting_list=board_items ) @router.put("/{waiting_id}/status") async def update_waiting_status( waiting_id: int, status_update: WaitingStatusUpdate, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기자 상태 변경 (출석/취소) """ waiting = db.query(WaitingList).filter( WaitingList.id == waiting_id, WaitingList.store_id == current_store.id ).first() if not waiting: raise HTTPException(status_code=404, detail="대기자를 찾을 수 없습니다.") if waiting.status != "waiting": raise HTTPException(status_code=400, detail="이미 처리된 대기입니다.") old_class_id = waiting.class_id old_business_date = waiting.business_date waiting.status = status_update.status if status_update.status == "attended": waiting.attended_at = datetime.now() elif status_update.status == "cancelled": waiting.cancelled_at = datetime.now() # 해당 클래스의 남은 대기자들 순서 정규화: 1번째, 2번째, 3번째... 순서로 재정렬 remaining_waitings = db.query(WaitingList).filter( WaitingList.business_date == old_business_date, WaitingList.class_id == old_class_id, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).order_by(WaitingList.class_order).all() for idx, w in enumerate(remaining_waitings, start=1): w.class_order = idx db.commit() # SSE 브로드캐스트: 상태 변경 알림 await sse_manager.broadcast( store_id=str(current_store.id), event_type="status_changed", data={ "waiting_id": waiting_id, "status": status_update.status, "waiting_number": waiting.waiting_number }, franchise_id=str(current_store.franchise_id) ) return {"message": f"상태가 {status_update.status}(으)로 변경되었습니다."} @router.post("/{waiting_id}/call") async def call_waiting( waiting_id: int, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기자 호출 """ waiting = db.query(WaitingList).filter( WaitingList.id == waiting_id, WaitingList.store_id == current_store.id ).first() if not waiting: raise HTTPException(status_code=404, detail="대기자를 찾을 수 없습니다.") waiting.call_count += 1 waiting.last_called_at = datetime.now() db.commit() # SSE 브로드캐스트: 호출 알림 await sse_manager.broadcast( store_id=str(current_store.id), event_type="user_called", data={ "waiting_id": waiting_id, "waiting_number": waiting.waiting_number, "call_count": waiting.call_count }, franchise_id=str(current_store.franchise_id) ) return { "message": f"대기번호 {waiting.waiting_number}번이 호출되었습니다.", "call_count": waiting.call_count } @router.put("/{waiting_id}/swap/{target_id}") async def swap_waiting_order( waiting_id: int, target_id: int, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기자를 다른 위치에 삽입 (드래그 앤 드롭용) dragged item을 target item 위치에 삽입하고, 나머지 항목들을 이동 """ dragged = db.query(WaitingList).filter( WaitingList.id == waiting_id, WaitingList.store_id == current_store.id ).first() target = db.query(WaitingList).filter( WaitingList.id == target_id, WaitingList.store_id == current_store.id ).first() if not dragged or not target: raise HTTPException(status_code=404, detail="대기자를 찾을 수 없습니다.") if dragged.status != "waiting" or target.status != "waiting": raise HTTPException(status_code=400, detail="대기 중인 상태만 순서 변경이 가능합니다.") # 같은 클래스 내에서만 이동 가능 if dragged.class_id != target.class_id: raise HTTPException(status_code=400, detail="같은 클래스 내에서만 순서를 변경할 수 있습니다.") old_order = dragged.class_order new_order = target.class_order # 같은 위치면 아무것도 하지 않음 if old_order == new_order: return {"message": "순서가 변경되지 않았습니다."} # 같은 클래스 내의 모든 대기자 조회 class_waitings = db.query(WaitingList).filter( WaitingList.class_id == dragged.class_id, WaitingList.business_date == dragged.business_date, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).all() # 순서 재조정 if old_order < new_order: # 아래로 이동: old_order < x <= new_order인 항목들을 위로 한 칸씩 올림 for waiting in class_waitings: if waiting.id != dragged.id and old_order < waiting.class_order <= new_order: waiting.class_order -= 1 dragged.class_order = new_order else: # 위로 이동: new_order <= x < old_order인 항목들을 아래로 한 칸씩 내림 for waiting in class_waitings: if waiting.id != dragged.id and new_order <= waiting.class_order < old_order: waiting.class_order += 1 dragged.class_order = new_order # 순서 정규화: 1번째, 2번째, 3번째... 순서로 재정렬 normalized_waitings = sorted(class_waitings, key=lambda x: x.class_order) for idx, waiting in enumerate(normalized_waitings, start=1): waiting.class_order = idx db.commit() # SSE 브로드캐스트: 순서 변경 알림 await sse_manager.broadcast( store_id=str(current_store.id), event_type="order_changed", data={ "waiting_id": waiting_id, "target_id": target_id }, franchise_id=str(current_store.franchise_id) ) return {"message": "순서가 변경되었습니다."} @router.put("/{waiting_id}/order") async def change_waiting_order( waiting_id: int, order_update: WaitingOrderUpdate, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기 순서 변경 (위/아래) """ waiting = db.query(WaitingList).filter( WaitingList.id == waiting_id, WaitingList.store_id == current_store.id ).first() if not waiting: raise HTTPException(status_code=404, detail="대기자를 찾을 수 없습니다.") if waiting.status != "waiting": raise HTTPException(status_code=400, detail="대기 중인 상태만 순서 변경이 가능합니다.") # 같은 클래스 내에서 순서 변경 if order_update.direction == "up": # 위로 이동 - 바로 위 대기자와 순서 교체 target = db.query(WaitingList).filter( WaitingList.business_date == waiting.business_date, WaitingList.class_id == waiting.class_id, WaitingList.class_order < waiting.class_order, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).order_by(WaitingList.class_order.desc()).first() if not target: raise HTTPException(status_code=400, detail="이미 맨 위입니다.") # 순서 교체 waiting.class_order, target.class_order = target.class_order, waiting.class_order elif order_update.direction == "down": # 아래로 이동 - 바로 아래 대기자와 순서 교체 target = db.query(WaitingList).filter( WaitingList.business_date == waiting.business_date, WaitingList.class_id == waiting.class_id, WaitingList.class_order > waiting.class_order, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).order_by(WaitingList.class_order).first() if not target: raise HTTPException(status_code=400, detail="이미 맨 아래입니다.") # 순서 교체 waiting.class_order, target.class_order = target.class_order, waiting.class_order db.commit() # SSE 브로드캐스트: 순서 변경 알림 await sse_manager.broadcast( store_id=str(current_store.id), event_type="order_changed", data={ "waiting_id": waiting_id, "direction": order_update.direction }, franchise_id=str(current_store.franchise_id) ) return {"message": "순서가 변경되었습니다."} @router.put("/{waiting_id}/move-class") async def move_to_another_class( waiting_id: int, class_update: WaitingClassUpdate, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 다른 클래스로 이동 """ waiting = db.query(WaitingList).filter( WaitingList.id == waiting_id, WaitingList.store_id == current_store.id ).first() if not waiting: raise HTTPException(status_code=404, detail="대기자를 찾을 수 없습니다.") if waiting.status != "waiting": raise HTTPException(status_code=400, detail="대기 중인 상태만 이동이 가능합니다.") # 대상 클래스 확인 target_class = db.query(ClassInfo).filter( ClassInfo.id == class_update.target_class_id, ClassInfo.store_id == current_store.id ).first() if not target_class: raise HTTPException(status_code=404, detail="대상 클래스를 찾을 수 없습니다.") # 대상 클래스의 마지막 순서 찾기 max_order = db.query(func.max(WaitingList.class_order)).filter( WaitingList.business_date == waiting.business_date, WaitingList.class_id == target_class.id, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).scalar() new_order = (max_order or 0) + 1 # 클래스 이동 old_class_id = waiting.class_id old_business_date = waiting.business_date waiting.class_id = target_class.id waiting.class_order = new_order # 변경사항을 DB에 즉시 반영 (쿼리 전에 flush 필수) db.flush() # 기존 클래스의 순서 정규화: 1번째, 2번째, 3번째... 순서로 재정렬 old_class_waitings = db.query(WaitingList).filter( WaitingList.business_date == old_business_date, WaitingList.class_id == old_class_id, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).order_by(WaitingList.class_order).all() for idx, w in enumerate(old_class_waitings, start=1): w.class_order = idx # 새 클래스의 순서 정규화: 1번째, 2번째, 3번째... 순서로 재정렬 new_class_waitings = db.query(WaitingList).filter( WaitingList.business_date == old_business_date, WaitingList.class_id == target_class.id, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).order_by(WaitingList.class_order).all() for idx, w in enumerate(new_class_waitings, start=1): w.class_order = idx db.commit() # SSE 브로드캐스트: 클래스 이동 알림 await sse_manager.broadcast( store_id=str(current_store.id), event_type="class_moved", data={ "waiting_id": waiting_id, "old_class_id": old_class_id, "new_class_id": target_class.id, "new_class_name": target_class.class_name }, franchise_id=str(current_store.franchise_id) ) return {"message": f"{target_class.class_name}(으)로 이동되었습니다."} @router.post("/batch-attendance") async def batch_attendance( batch: BatchAttendance, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 교시 마감 처리 - 특정 교시를 마감하여 더 이상 대기자를 등록할 수 없게 함 - 대기자 상태는 변경하지 않고 그대로 유지 (비활성화 상태로 표시) """ today = get_current_business_date(db, current_store.id) # 클래스 정보 조회 class_info = db.query(ClassInfo).filter( ClassInfo.id == batch.class_id, ClassInfo.store_id == current_store.id ).first() if not class_info: raise HTTPException(status_code=404, detail="클래스를 찾을 수 없습니다.") # 이미 마감된 교시인지 확인 existing_closure = db.query(ClassClosure).filter( ClassClosure.business_date == today, ClassClosure.class_id == batch.class_id, ClassClosure.store_id == current_store.id ).first() if existing_closure: raise HTTPException(status_code=400, detail="이미 마감된 교시입니다.") # 해당 클래스의 대기 중인 목록 조회 (카운트용) -> 상태 변경용으로 수정 waiting_list = db.query(WaitingList).filter( WaitingList.business_date == today, WaitingList.class_id == batch.class_id, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).all() waiting_count = len(waiting_list) # 대기자 상태를 'attended'로 변경하고 출석 시간 기록 for waiting in waiting_list: waiting.status = "attended" waiting.attended_at = datetime.now() # 교시 마감 레코드 생성 closure = ClassClosure( business_date=today, class_id=batch.class_id, closed_at=datetime.now(), store_id=current_store.id ) db.add(closure) db.commit() # SSE 브로드캐스트: 교시 마감 알림 await sse_manager.broadcast( store_id=str(current_store.id), event_type="class_closed", data={ "class_id": batch.class_id, "class_name": class_info.class_name, "waiting_count": waiting_count }, franchise_id=str(current_store.franchise_id) ) # SSE 브로드캐스트: 일괄 출석 알림 (출석현황 업데이트용) await sse_manager.broadcast( store_id=str(current_store.id), event_type="batch_attendance", data={ "class_id": batch.class_id, "class_name": class_info.class_name, "count": waiting_count }, franchise_id=str(current_store.franchise_id) ) return { "message": f"{class_info.class_name}이(가) 마감되고 {waiting_count}명이 일괄 출석 처리되었습니다.", "waiting_count": waiting_count } @router.delete("/close-class/{class_id}") async def unclose_class( class_id: int, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 교시 마감 해제 - 실수로 마감한 교시를 다시 열어 대기자를 등록할 수 있게 함 """ today = get_current_business_date(db, current_store.id) # 마감 레코드 조회 closure = db.query(ClassClosure).filter( ClassClosure.business_date == today, ClassClosure.class_id == class_id, ClassClosure.store_id == current_store.id ).first() if not closure: raise HTTPException(status_code=404, detail="마감되지 않은 교시입니다.") # 클래스 정보 조회 class_info = db.query(ClassInfo).filter( ClassInfo.id == class_id, ClassInfo.store_id == current_store.id ).first() if not class_info: raise HTTPException(status_code=404, detail="클래스를 찾을 수 없습니다.") # 마감 레코드 삭제 db.delete(closure) db.commit() # SSE 브로드캐스트: 교시 마감 해제 알림 await sse_manager.broadcast( store_id=str(current_store.id), event_type="class_reopened", data={ "class_id": class_id, "class_name": class_info.class_name }, franchise_id=str(current_store.franchise_id) ) return { "message": f"{class_info.class_name}의 마감이 해제되었습니다." } @router.get("/next-batch-class") async def get_next_batch_class( db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 다음 교시 마감 대상 클래스 조회 - 대기자가 있고 마감되지 않은 첫 번째 클래스 반환 """ today = get_current_business_date(db, current_store.id) # 이미 마감된 교시 ID 목록 closed_class_ids = db.query(ClassClosure.class_id).filter( ClassClosure.business_date == today, ClassClosure.store_id == current_store.id ).all() closed_class_ids = set(c.class_id for c in closed_class_ids) # 활성화된 클래스 조회 및 오늘 요일에 맞는 클래스만 필터링 classes_raw = db.query(ClassInfo).filter( ClassInfo.is_active == True, ClassInfo.store_id == current_store.id ).order_by(ClassInfo.class_number).all() # 헬퍼 함수를 사용하여 오늘 요일에 맞는 클래스만 필터링 classes = filter_classes_by_weekday(classes_raw, today) for cls in classes: # 마감된 교시는 건너뜀 if cls.id in closed_class_ids: continue count = db.query(func.count(WaitingList.id)).filter( WaitingList.business_date == today, WaitingList.class_id == cls.id, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).scalar() if count > 0: return { "class_id": cls.id, "class_name": cls.class_name, "class_number": cls.class_number, "waiting_count": count } return { "class_id": None, "message": "대기자가 없습니다." } @router.get("/closed-classes") async def get_closed_classes( db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 오늘 마감된 교시 목록 조회 """ today = get_current_business_date(db, current_store.id) closed_classes = db.query(ClassClosure).filter( ClassClosure.business_date == today, ClassClosure.store_id == current_store.id ).all() return { "closed_class_ids": [c.class_id for c in closed_classes] } @router.post("/insert-empty-seat") async def insert_empty_seat( empty_seat: EmptySeatInsert, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 빈 좌석 삽입 - 선택한 대기자 뒤에 빈 좌석을 삽입 - 뒤의 대기자들 순서는 자동으로 밀림 """ # 기준 대기자 조회 base_waiting = db.query(WaitingList).filter( WaitingList.id == empty_seat.waiting_id, WaitingList.store_id == current_store.id ).first() if not base_waiting: raise HTTPException(status_code=404, detail="대기자를 찾을 수 없습니다.") if base_waiting.status != "waiting": raise HTTPException(status_code=400, detail="대기 중인 상태만 빈 좌석을 삽입할 수 있습니다.") today = get_current_business_date(db, current_store.id) # 해당 클래스에서 기준 대기자보다 뒤에 있는 모든 대기자들의 순서를 1씩 증가 following_waitings = db.query(WaitingList).filter( WaitingList.business_date == today, WaitingList.class_id == base_waiting.class_id, WaitingList.class_order > base_waiting.class_order, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).all() for waiting in following_waitings: waiting.class_order += 1 # 빈 좌석 생성 (기준 대기자 바로 뒤) empty_seat_entry = WaitingList( business_date=today, waiting_number=0, # 빈 좌석은 대기번호 0 phone="empty", name="빈좌석", class_id=base_waiting.class_id, class_order=base_waiting.class_order + 1, is_empty_seat=True, status="waiting", registered_at=datetime.now(), store_id=current_store.id ) db.add(empty_seat_entry) # 해당 클래스의 모든 대기자 순서 정규화: 1번째, 2번째, 3번째... 순서로 재정렬 all_class_waitings = db.query(WaitingList).filter( WaitingList.business_date == today, WaitingList.class_id == base_waiting.class_id, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).order_by(WaitingList.class_order).all() for idx, w in enumerate(all_class_waitings, start=1): w.class_order = idx db.commit() db.refresh(empty_seat_entry) # 클래스 정보 조회 class_info = db.query(ClassInfo).filter( ClassInfo.id == base_waiting.class_id, ClassInfo.store_id == current_store.id ).first() # SSE 브로드캐스트: 빈 좌석 삽입 알림 await sse_manager.broadcast( store_id=str(current_store.id), event_type="empty_seat_inserted", data={ "id": empty_seat_entry.id, "class_id": base_waiting.class_id, "class_name": class_info.class_name, "class_order": empty_seat_entry.class_order } ) return { "message": f"{class_info.class_name} {base_waiting.class_order}번 뒤에 빈 좌석이 삽입되었습니다.", "empty_seat_id": empty_seat_entry.id }