from fastapi import APIRouter, Depends, HTTPException from core.logger import logger from sqlalchemy.orm import Session, joinedload from sqlalchemy import func, and_ from datetime import datetime, date from typing import List, Optional, Dict import json from database import get_db from models import WaitingList, ClassInfo, Member, DailyClosing, ClassClosure, Store, StoreSettings from auth import get_current_store from schemas import ( WaitingListCreate, WaitingListResponse, WaitingList as WaitingListSchema, WaitingListDetail ) 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", 1: "tue", 2: "wed", 3: "thu", 4: "fri", 5: "sat", 6: "sun" } 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]: """특정 날짜의 요일에 맞는 클래스만 필터링""" 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 get_next_waiting_number(db: Session, business_date: date, store_id: int) -> int: """다음 대기번호 생성""" max_number = db.query(func.max(WaitingList.waiting_number)).filter( WaitingList.business_date == business_date, WaitingList.store_id == store_id ).scalar() return (max_number or 0) + 1 def get_available_class(db: Session, business_date: date, store_id: int): """배치 가능한 클래스 찾기 - 순차적으로 다음 클래스에 배치 (마감된 교시 제외)""" classes_raw = db.query(ClassInfo).filter( ClassInfo.is_active == True, ClassInfo.store_id == store_id ).order_by(ClassInfo.class_number).all() # 헬퍼 함수를 사용하여 오늘 요일에 맞는 클래스만 필터링 classes = filter_classes_by_weekday(classes_raw, business_date) if not classes: raise HTTPException(status_code=400, detail="오늘 운영하는 클래스가 없습니다.") # 마감된 교시 ID 목록 조회 closed_class_ids = db.query(ClassClosure.class_id).filter( ClassClosure.business_date == business_date, ClassClosure.store_id == store_id ).all() closed_class_ids = set(c.class_id for c in closed_class_ids) # 마감되지 않은 교시만 필터링 available_classes = [c for c in classes if c.id not in closed_class_ids] if not available_classes: raise HTTPException(status_code=400, detail="모든 교시가 마감되었습니다. 대기 접수를 받을 수 없습니다.") # 순차적 배정 로직 개선: 1교시부터 차레대로 빈 자리 확인 logger.debug(f"[ClassAssign] Finding slot for Store {store_id} on {business_date}") # 순차적 배정 로직 개선: 1교시부터 차레대로 빈 자리 확인 # "마지막 등록자" 기준이 아니라 "빈 자리" 기준으로 변경하여 중간에 빈 교시가 있으면 채워넣도록 함 for cls in available_classes: # 해당 클래스의 총 정원 점유율 계산 (Waiting + Called + Attended) total_occupancy = db.query(func.count(WaitingList.id)).filter( WaitingList.class_id == cls.id, WaitingList.business_date == business_date, WaitingList.status.in_(["waiting", "called", "attended"]), WaitingList.store_id == store_id ).scalar() logger.debug(f"[ClassAssign] Checking {cls.class_name} (ID: {cls.id}): {total_occupancy}/{cls.max_capacity}") if total_occupancy < cls.max_capacity: logger.info(f"[ClassAssign] Assigned {cls.class_name} (ID: {cls.id}). Occupancy before: {total_occupancy}") return cls, total_occupancy + 1 # 모든 교시가 꽉 찬 경우 logger.warning("[ClassAssign] All classes are full.") raise HTTPException(status_code=400, detail="모든 교시의 정원이 마감되었습니다.") @router.post("/register", response_model=WaitingListResponse) async def register_waiting( waiting: WaitingListCreate, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기 접수 - 핸드폰번호로 접수 - 회원인 경우 자동으로 이름 매칭 - 자동으로 클래스 배치 """ today = get_current_business_date(db, current_store.id) # 영업 중인지 확인 business = db.query(DailyClosing).filter( DailyClosing.business_date == today, DailyClosing.is_closed == False, DailyClosing.store_id == current_store.id ).first() if not business: raise HTTPException(status_code=400, detail="영업 중이 아닙니다. 개점을 먼저 진행해주세요.") # 이미 대기 중인지 확인 existing = db.query(WaitingList).filter( WaitingList.business_date == today, WaitingList.phone == waiting.phone, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).first() if existing: raise HTTPException(status_code=400, detail="이미 대기 중인 번호입니다.\n핸드폰번호를 다시 확인하여 주세요.") # 매장 설정 조회 from models import StoreSettings settings = db.query(StoreSettings).filter(StoreSettings.store_id == current_store.id).first() # 1. 최대 대기 인원 제한 체크 (use_max_waiting_limit가 활성화된 경우에만) if settings and settings.use_max_waiting_limit and settings.max_waiting_limit > 0: # 현재 대기 중인 총 인원 확인 current_waiting_count = db.query(func.count(WaitingList.id)).filter( WaitingList.business_date == today, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).scalar() if current_waiting_count >= settings.max_waiting_limit: raise HTTPException( status_code=400, detail=f"대기 인원이 가득 찼습니다. (최대 {settings.max_waiting_limit}명)" ) # 2. 마지막 교시 정원 초과 차단 체크 if settings and settings.block_last_class_registration: # 오늘 운영되는 클래스 조회 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) if classes: # 마지막 교시 찾기 (class_number가 가장 큰 것) last_class = max(classes, key=lambda c: c.class_number) # 마지막 교시의 현재 대기 인원 확인 # Current count must include waiting and attended users to respect total capacity last_class_count = db.query(func.count(WaitingList.id)).filter( WaitingList.class_id == last_class.id, WaitingList.business_date == today, WaitingList.status.in_(["waiting", "called", "attended"]), WaitingList.store_id == current_store.id ).scalar() # 정원 초과 시 차단 if last_class_count >= last_class.max_capacity: raise HTTPException( status_code=400, detail="교시 접수가 마감되었습니다." ) # 회원 정보 조회 member = db.query(Member).filter( Member.phone == waiting.phone, Member.store_id == current_store.id ).first() is_new_member = (member is None) # 자동 회원가입 로직 if not member and settings and settings.auto_register_member: # 이름이 없는 경우 핸드폰 번호 뒷자리 사용 member_name = waiting.name if waiting.name else waiting.phone[-4:] new_member = Member( store_id=current_store.id, name=member_name, phone=waiting.phone, created_at=datetime.now(), updated_at=datetime.now() ) db.add(new_member) db.flush() # ID 생성을 위해 flush member = new_member print(f"자동 회원가입 완료: {member.name} ({member.phone})") member_id = member.id if member else None name = member.name if member else waiting.name # 다음 대기번호 생성 waiting_number = get_next_waiting_number(db, today, current_store.id) # 배치 가능한 클래스 찾기 all_classes_raw = db.query(ClassInfo).filter( ClassInfo.store_id == current_store.id, ClassInfo.is_active == True ).order_by(ClassInfo.class_number).all() # 오늘 요일에 운영되는 클래스만 필터링 all_classes = filter_classes_by_weekday(all_classes_raw, today) if not all_classes: raise HTTPException(status_code=400, detail="오늘 운영하는 교시가 없습니다.") # 마감된 교시 목록 조회 closed_class_ids = [ cc.class_id for cc in db.query(ClassClosure).filter( ClassClosure.store_id == current_store.id, ClassClosure.business_date == today ).all() ] # 시작 교시 인덱스 결정 및 유효성 검증 start_index = 0 if waiting.class_id: # 요청된 class_id가 실제로 오늘 운영되는 교시 목록에 있는지 확인 class_found = False for i, cls in enumerate(all_classes): if cls.id == waiting.class_id: start_index = i class_found = True print(f"[REGISTER] Requested class_id={waiting.class_id} found at index {i}") break if not class_found: # 요청된 교시가 없으면 경고 로그 출력하고 자동 배치로 전환 print(f"[WARNING] Requested class_id={waiting.class_id} not found in active classes for today. Available class IDs: {[c.id for c in all_classes]}") print(f"[REGISTER] Falling back to automatic class assignment") start_index = 0 # 순차 탐색 (Overflow Logic) target_class = None class_order = 0 for i in range(start_index, len(all_classes)): cls = all_classes[i] # 1. 마감 여부 체크 if cls.id in closed_class_ids: print(f"[REGISTER] Class {cls.id} ({cls.class_name}) is closed, skipping") continue # 2. 정원 체크 (대기 + 호출 + 출석 모두 포함) current_count = db.query(func.count(WaitingList.id)).filter( WaitingList.class_id == cls.id, WaitingList.business_date == today, WaitingList.status.in_(["waiting", "called", "attended"]), WaitingList.store_id == current_store.id ).scalar() print(f"[REGISTER] Class {cls.id} ({cls.class_name}): {current_count}/{cls.max_capacity}") if current_count < cls.max_capacity: target_class = cls class_order = current_count + 1 print(f"[REGISTER] Assigned to class {cls.id} ({cls.class_name}) as order {class_order}") break if not target_class: # 모든 교시가 마감되었거나 정원 초과 print(f"[REGISTER ERROR] No available class found. Requested class_id={waiting.class_id}, Available classes: {len(all_classes)}, Closed: {len(closed_class_ids)}") if waiting.class_id: raise HTTPException(status_code=400, detail="선택한 교시 및 이후 모든 교시가 마감되었거나 정원이 초과되었습니다.") else: raise HTTPException(status_code=400, detail="등록 가능한 교시가 없습니다 (모두 마감 또는 정원 초과).") # 대기자 등록 new_waiting = WaitingList( business_date=today, waiting_number=waiting_number, phone=waiting.phone, name=name, class_id=target_class.id, class_order=class_order, member_id=member_id, status="waiting", registered_at=datetime.now(), store_id=current_store.id ) db.add(new_waiting) db.commit() db.refresh(new_waiting) # SSE 브로드캐스트: 새로운 대기자 등록 알림 print(f"Broadcasting new_user event: store_id={current_store.id}, franchise_id={current_store.franchise_id}") await sse_manager.broadcast( store_id=str(current_store.id), event_type="new_user", data={ "id": new_waiting.id, "waiting_id": new_waiting.id, # 프론트엔드 호환성을 위해 추가 "waiting_number": waiting_number, "class_id": target_class.id, "class_name": target_class.class_name, "class_order": class_order, "name": name, "phone": waiting.phone, "display_name": name if name else waiting.phone[-4:] }, franchise_id=str(current_store.franchise_id) ) # 응답 메시지 생성 message = f"대기번호: {waiting_number}번\n{target_class.class_name} {class_order}번째\n대기 등록이 완료되었습니다." return WaitingListResponse( id=new_waiting.id, waiting_number=waiting_number, class_id=target_class.id, class_name=target_class.class_name, class_order=class_order, phone=waiting.phone, name=name, status="waiting", registered_at=new_waiting.registered_at, message=message, is_new_member=is_new_member ) @router.post("/", response_model=WaitingListResponse) async def create_waiting( waiting: WaitingListCreate, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기 접수 (Alias for /register) - dashboard 등에서 호출 표준화 """ return await register_waiting(waiting, db, current_store) @router.get("/next-slot") async def get_next_slot( db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 다음 대기 등록 시 배정될 예정인 교시 조회 (Reception Desk용 Single Source of Truth) """ today = get_current_business_date(db, current_store.id) # 총 대기 인원 (waiting only) for overall status total_waiting = db.query(func.count(WaitingList.id)).filter( WaitingList.business_date == today, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).scalar() # 1. Available Classes (Same logic as register_waiting) all_classes_raw = db.query(ClassInfo).filter( ClassInfo.store_id == current_store.id, ClassInfo.is_active == True ).order_by(ClassInfo.class_number).all() classes = filter_classes_by_weekday(all_classes_raw, today) if not classes: return { "class_id": -1, "class_name": "운영 교시 없음", "class_order": 0, "max_capacity": 0, "is_full": True, "total_waiting": total_waiting } # 2. Closed Classes closed_ids = [ cc.class_id for cc in db.query(ClassClosure).filter( ClassClosure.store_id == current_store.id, ClassClosure.business_date == today ).all() ] # 3. Find First Available Slot (Sequential) next_class = None next_order = 0 is_fully_booked = True for cls in classes: if cls.id in closed_ids: continue # Get Occupancy (Waiting + Called + Attended) total_occupancy = db.query(func.count(WaitingList.id)).filter( WaitingList.class_id == cls.id, WaitingList.business_date == today, WaitingList.status.in_(["waiting", "called", "attended"]), WaitingList.store_id == current_store.id ).scalar() if total_occupancy < cls.max_capacity: next_class = cls next_order = total_occupancy + 1 is_fully_booked = False break if is_fully_booked: return { "class_id": -1, "class_name": "접수 마감", "class_order": 0, "max_capacity": 0, "is_full": True, "total_waiting": total_waiting } return { "class_id": next_class.id, "class_name": next_class.class_name, "class_order": next_order, "max_capacity": next_class.max_capacity, "is_full": False, "total_waiting": total_waiting } @router.get("/check/{phone}") async def check_waiting_status( phone: str, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기 현황 조회 (모바일용) - 핸드폰번호로 조회 """ today = date.today() waiting = db.query(WaitingList).filter( WaitingList.business_date == today, WaitingList.phone == phone, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).first() if not waiting: return { "found": False, "message": "대기 내역이 없습니다." } # 클래스 정보 조회 class_info = db.query(ClassInfo).filter( ClassInfo.id == waiting.class_id, ClassInfo.store_id == current_store.id ).first() # 앞에 대기 중인 사람 수 계산 ahead_count = db.query(func.count(WaitingList.id)).filter( WaitingList.business_date == today, WaitingList.status == "waiting", WaitingList.waiting_number < waiting.waiting_number, WaitingList.store_id == current_store.id ).scalar() return { "found": True, "waiting_number": waiting.waiting_number, "class_name": class_info.class_name, "class_order": waiting.class_order, "ahead_count": ahead_count, "registered_at": waiting.registered_at, "message": f"대기번호 {waiting.waiting_number}번\n{class_info.class_name} {waiting.class_order}번째\n앞에 {ahead_count}명 대기 중" } @router.get("/list") async def get_waiting_list( business_date: Optional[date] = None, status: Optional[str] = None, class_id: Optional[int] = None, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기자 목록 조회 - 날짜별, 상태별, 클래스별 필터링 가능 수동으로 응답 형식을 생성하여 weekday_schedule 파싱 문제 해결 """ if not business_date: business_date = get_current_business_date(db, current_store.id) # class_info와 member를 eager load query = db.query(WaitingList).options( joinedload(WaitingList.class_info), joinedload(WaitingList.member) ).filter( WaitingList.business_date == business_date, WaitingList.store_id == current_store.id ) if status: query = query.filter(WaitingList.status == status) if class_id: query = query.filter(WaitingList.class_id == class_id) # 교시별로 정렬 (class_id 우선, 그 다음 교시 내 순서인 class_order) waiting_list = query.order_by( WaitingList.class_id, WaitingList.class_order ).all() # 최근 30일 출석 수 일괄 조회 (N+1 문제 방지) member_ids = [w.member_id for w in waiting_list if w.member_id] member_attendance_counts = {} if member_ids: from datetime import timedelta # 출석 카운트 설정 조회 settings = db.query(StoreSettings).filter(StoreSettings.store_id == current_store.id).first() count_type = settings.attendance_count_type if settings else 'days' start_date = business_date if count_type == 'monthly': # 이번 달 1일 부터 조회 start_date = business_date.replace(day=1) else: # 최근 N일 (기본 30일) lookback_days = settings.attendance_lookback_days if settings else 30 start_date = business_date - timedelta(days=lookback_days) attendance_counts = db.query( WaitingList.member_id, func.count(WaitingList.id) ).filter( WaitingList.member_id.in_(member_ids), WaitingList.status == 'attended', WaitingList.business_date >= start_date, WaitingList.business_date <= business_date # 미래 날짜 제외 ).group_by(WaitingList.member_id).all() member_attendance_counts = {member_id: count for member_id, count in attendance_counts} # 수동으로 dict 생성 (weekday_schedule 파싱 포함) result = [] for waiting in waiting_list: # class_info 변환 class_info_dict = { "id": waiting.class_info.id, "class_number": waiting.class_info.class_number, "class_name": waiting.class_info.class_name, "start_time": waiting.class_info.start_time, "end_time": waiting.class_info.end_time, "max_capacity": waiting.class_info.max_capacity, "is_active": waiting.class_info.is_active, "weekday_schedule": parse_weekday_schedule(waiting.class_info.weekday_schedule), "class_type": waiting.class_info.class_type if hasattr(waiting.class_info, 'class_type') else 'all', "created_at": waiting.class_info.created_at, "updated_at": waiting.class_info.updated_at, "current_count": 0 # 이 엔드포인트에서는 current_count 계산하지 않음 } # member 변환 (있는 경우) member_dict = None if waiting.member: member_dict = { "id": waiting.member.id, "name": waiting.member.name, "phone": waiting.member.phone, "created_at": waiting.member.created_at } # waiting 정보 + class_info + member waiting_dict = { "id": waiting.id, "business_date": waiting.business_date, "waiting_number": waiting.waiting_number, "phone": waiting.phone, "name": waiting.member.name if waiting.member and waiting.member.name else waiting.name, "class_id": waiting.class_id, "class_order": waiting.class_order, "member_id": waiting.member_id, "is_empty_seat": waiting.is_empty_seat, "status": waiting.status, "registered_at": waiting.registered_at, "attended_at": waiting.attended_at, "cancelled_at": waiting.cancelled_at, "call_count": waiting.call_count, "last_called_at": waiting.last_called_at, "message": f"대기번호 {waiting.waiting_number}번\n{waiting.class_info.class_name} {waiting.class_order}번째", # 최근 30일 출석 수 (회원이 없는 경우 0) "last_month_attendance_count": member_attendance_counts.get(waiting.member_id, 0), "created_at": waiting.created_at, "updated_at": waiting.updated_at, "class_info": class_info_dict, "member": member_dict } result.append(waiting_dict) return result @router.get("/list/by-class") async def get_waiting_list_by_class( business_date: Optional[date] = None, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 클래스별로 그룹화된 대기자 목록 조회 오늘 요일에 운영되는 클래스만 반환 """ if not business_date: business_date = get_current_business_date(db, current_store.id) # 모든 활성 클래스 조회 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, business_date) result = [] for cls in classes: waiting_list = db.query(WaitingList).options( joinedload(WaitingList.member) ).filter( WaitingList.business_date == business_date, WaitingList.class_id == cls.id, WaitingList.status == "waiting", WaitingList.store_id == current_store.id ).order_by(WaitingList.class_order).all() # 현재 대기 중인 인원 수 (Display용) current_count = len(waiting_list) # 총 정원 계산용 (Waiting + Called + Attended) total_registered_count = db.query(func.count(WaitingList.id)).filter( WaitingList.class_id == cls.id, WaitingList.business_date == business_date, WaitingList.status.in_(["waiting", "called", "attended"]), WaitingList.store_id == current_store.id ).scalar() # Member 이름 우선 사용 로직 def get_display_name(w): if w.member and w.member.name: return w.member.name return w.name if w.name else w.phone[-4:] result.append({ "class_id": cls.id, "class_name": cls.class_name, "class_number": cls.class_number, "start_time": cls.start_time.strftime("%H:%M"), "end_time": cls.end_time.strftime("%H:%M"), "max_capacity": cls.max_capacity, "current_count": current_count, "total_count": total_registered_count, # Predict Logic용 "waiting_list": [ { "id": w.id, "waiting_number": w.waiting_number, "name": w.member.name if w.member and w.member.name else w.name, "phone": w.phone, "display_name": get_display_name(w), "class_order": w.class_order, "registered_at": w.registered_at, "member_id": w.member_id } for w in waiting_list ] }) return result @router.get("/{waiting_id}", response_model=WaitingListResponse) async def get_waiting_detail( waiting_id: int, db: Session = Depends(get_db), current_store: Store = Depends(get_current_store) ): """ 대기 상세 조회 """ waiting = db.query(WaitingList).options( joinedload(WaitingList.class_info), joinedload(WaitingList.member) ).filter( WaitingList.id == waiting_id, WaitingList.store_id == current_store.id ).first() if not waiting: raise HTTPException(status_code=404, detail="대기 내역을 찾을 수 없습니다.") # 응답 메시지 생성 message = f"대기번호: {waiting.waiting_number}번\n{waiting.class_info.class_name} {waiting.class_order}번째\n대기 중입니다." return WaitingListResponse( id=waiting.id, waiting_number=waiting.waiting_number, class_id=waiting.class_id, class_name=waiting.class_info.class_name, class_order=waiting.class_order, phone=waiting.phone, name=waiting.member.name if waiting.member and waiting.member.name else waiting.name, status=waiting.status, registered_at=waiting.registered_at, message=message ) @router.delete("/{waiting_id}") async def cancel_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="대기 내역을 찾을 수 없습니다.") if waiting.status != "waiting": raise HTTPException(status_code=400, detail="이미 처리된 대기입니다.") waiting.status = "cancelled" waiting.cancelled_at = datetime.now() db.commit() return {"message": "대기가 취소되었습니다."}