from fastapi import APIRouter, Depends, HTTPException, Query, Request from starlette.responses import StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import func, desc, case, and_, or_ from datetime import datetime, date, timedelta from typing import List, Optional from database import get_db from models import Franchise, Store, Member, WaitingList, DailyClosing, User from auth import require_franchise_admin from sse_manager import sse_manager, event_generator router = APIRouter() # Helper function for permission check def check_franchise_permission(current_user: User, franchise_id: int, store_id: Optional[int] = None) -> Optional[List[int]]: """ 권한 체크 및 접근 가능한 매장 ID 목록 반환 - system_admin: None 반환 (모든 매장 접근 가능) - franchise_admin: None 반환 (해당 프랜차이즈 내 모든 매장 접근 가능) - franchise_manager: 관리 매장 ID 목록 반환 (store_id가 있으면 검증 포함) """ if current_user.role == "system_admin": return None if current_user.role == "franchise_admin": # Note: franchise_id is int, current_user.franchise_id is int if int(current_user.franchise_id) != int(franchise_id): raise HTTPException(status_code=403, detail="권한이 없습니다.") return None if current_user.role == "franchise_manager": if int(current_user.franchise_id) != int(franchise_id): raise HTTPException(status_code=403, detail="권한이 없습니다.") managed_ids = [s.id for s in current_user.managed_stores] if store_id: if store_id not in managed_ids: raise HTTPException(status_code=403, detail="해당 매장에 대한 접근 권한이 없습니다.") return [store_id] return managed_ids if managed_ids else [] raise HTTPException(status_code=403, detail="권한이 없습니다.") @router.get("/{franchise_id}/sse/stream") async def stream_franchise_events( franchise_id: str, request: Request, current_user: User = Depends(require_franchise_admin) ): """ 프랜차이즈 관리자용 SSE 스트림 연결 - 해당 프랜차이즈 내 모든 매장의 이벤트를 수신 """ # 권한 체크 allowed_ids = check_franchise_permission(current_user, int(franchise_id)) # Note: SSE manager currently connects to whole franchise. # For manager, ideally we filter events. But SSE manager might not support partial subscription yet. # Allowing connection for now, assuming frontend filters or backend broadcasts everything. # If security critical, SSE logic needs update. # For now, just basic auth check via helper (though helper returns list, we ignore it here slightly risking logic) # Actually, if allowed_ids is list, it means Restricted. # Implementing restricted SSE is complex. queue = await sse_manager.connect_franchise(franchise_id) return StreamingResponse( event_generator(queue), media_type="text/event-stream" ) @router.get("/{franchise_id}/dashboard") async def get_dashboard_stats( franchise_id: int, start_date: date, end_date: date, store_id: Optional[int] = None, current_user: User = Depends(require_franchise_admin), db: Session = Depends(get_db) ): """ 대시보드 통계 조회 - 총 대기, 현 대기, 총 출석 (전체/기존/신규) """ allowed_store_ids = check_franchise_permission(current_user, franchise_id, store_id) today = date.today() # 1. 기본 쿼리 구성 (Store JOIN) # Store 테이블과 조인하여 프랜차이즈 및 활성 상태 필터링 base_query = db.query(WaitingList).join( Store, WaitingList.store_id == Store.id ).filter( Store.franchise_id == franchise_id, Store.is_active == True ) if store_id: base_query = base_query.filter(WaitingList.store_id == store_id) if allowed_store_ids is not None: base_query = base_query.filter(Store.id.in_(allowed_store_ids)) # 2. 헬퍼 함수: 통계 계산 def calculate_stats(query, date_condition, is_current_waiting=False): # 날짜 조건 적용 filtered_query = query.filter(date_condition) # 전체 카운트 total = filtered_query.count() # 기존 회원 카운트 (기간 시작일 이전에 가입한 회원) # 현 대기의 경우 '오늘' 기준이므로, 오늘 이전에 가입한 회원을 기존 회원으로 간주 threshold_date = today if is_current_waiting else start_date existing = filtered_query.join( Member, WaitingList.member_id == Member.id ).filter( Member.created_at < datetime.combine(threshold_date, datetime.min.time()) ).count() # 신규 (전체 - 기존) new = total - existing return {"total": total, "existing": existing, "new": new} # 3. 총 대기 (선택된 기간 내 모든 대기 접수) total_waiting_stats = calculate_stats( base_query, and_( WaitingList.business_date >= start_date, WaitingList.business_date <= end_date ) ) # 4. 현 대기 (오늘 현재 대기 중인 인원) # 기간 필터와 무관하게 '오늘' 기준, status='waiting' current_waiting_query = base_query.filter(WaitingList.status == "waiting") current_waiting_stats = calculate_stats( current_waiting_query, WaitingList.business_date == today, is_current_waiting=True ) # 5. 총 출석 (선택된 기간 내 출석 완료) attendance_query = base_query.filter(WaitingList.status == "attended") attendance_stats = calculate_stats( attendance_query, and_( WaitingList.attended_at >= datetime.combine(start_date, datetime.min.time()), WaitingList.attended_at <= datetime.combine(end_date, datetime.max.time()) ) ) # 6. 매장별 상세 현황 (Store Comparison) # Query all stores in the franchise with their waiting and attendance counts store_stats_query = db.query( Store.id, Store.name, Store.is_active, func.count( case( (and_( WaitingList.business_date >= start_date, WaitingList.business_date <= end_date ), WaitingList.id), else_=None ) ).label("waiting_count"), func.count( case( (and_( WaitingList.status == "attended", WaitingList.attended_at >= datetime.combine(start_date, datetime.min.time()), WaitingList.attended_at <= datetime.combine(end_date, datetime.max.time()) ), WaitingList.id), else_=None ) ).label("attendance_count") ).outerjoin( WaitingList, Store.id == WaitingList.store_id ).filter( Store.franchise_id == franchise_id, Store.is_active == True ) if store_id: store_stats_query = store_stats_query.filter(Store.id == store_id) if allowed_store_ids is not None: store_stats_query = store_stats_query.filter(Store.id.in_(allowed_store_ids)) store_stats_results = store_stats_query.group_by( Store.id, Store.name, Store.is_active ).order_by( Store.name ).all() store_stats = [ { "store_id": r.id, "store_name": r.name, "is_active": r.is_active, "waiting_count": r.waiting_count, "attendance_count": r.attendance_count } for r in store_stats_results ] return { "total_waiting": total_waiting_stats, "current_waiting": current_waiting_stats, "total_attendance": attendance_stats, "store_stats": store_stats } @router.get("/{franchise_id}/attendance/list") async def get_attendance_list( franchise_id: int, start_date: date, end_date: date, store_id: Optional[int] = None, current_user: User = Depends(require_franchise_admin), db: Session = Depends(get_db) ): """ 출석 목록 상세 조회 (전체 매장 또는 특정 매장) - 기간 내 출석 완료된 목록 """ allowed_store_ids = check_franchise_permission(current_user, franchise_id, store_id) # 기본 쿼리: WaitingList와 Store, Member 조인 query = db.query( WaitingList.id, WaitingList.phone, WaitingList.attended_at, WaitingList.status, Store.name.label("store_name"), Member.name.label("member_name"), Member.id.label("member_id") ).join( Store, WaitingList.store_id == Store.id ).outerjoin( Member, WaitingList.member_id == Member.id ).filter( Store.franchise_id == franchise_id, Store.is_active == True, WaitingList.status == 'attended', WaitingList.attended_at >= datetime.combine(start_date, datetime.min.time()), WaitingList.attended_at <= datetime.combine(end_date, datetime.max.time()) ) if store_id: query = query.filter(WaitingList.store_id == store_id) if allowed_store_ids is not None: query = query.filter(Store.id.in_(allowed_store_ids)) results = query.order_by( desc(WaitingList.attended_at) ).all() return [ { "id": r.id, "phone": r.phone, "attended_at": r.attended_at, "status": r.status, "store_name": r.store_name, "member_name": r.member_name or "비회원", "member_id": r.member_id } for r in results ] @router.get("/{franchise_id}/attendance/ranking") async def get_attendance_ranking( franchise_id: int, start_date: date, end_date: date, store_id: Optional[int] = None, limit: int = 10, current_user: User = Depends(require_franchise_admin), db: Session = Depends(get_db) ): """ 회원 출석 순위 조회 - 기간별, 매장별(옵션) 출석이 많은 순으로 조회 """ allowed_store_ids = check_franchise_permission(current_user, franchise_id, store_id) # 기본 쿼리: WaitingList와 Member, Store 조인 query = db.query( Member.id, Member.name, Member.phone, Store.name.label("store_name"), func.count(WaitingList.id).label("attendance_count"), func.max(WaitingList.attended_at).label("last_attended_at") ).join( WaitingList, Member.id == WaitingList.member_id ).join( Store, WaitingList.store_id == Store.id ).filter( WaitingList.status == "attended", WaitingList.attended_at >= datetime.combine(start_date, datetime.min.time()), WaitingList.attended_at <= datetime.combine(end_date, datetime.max.time()) ) # 매장 필터링 if store_id: query = query.filter(WaitingList.store_id == store_id) else: # 프랜차이즈 내 모든 매장 (또는 허용된 매장) if allowed_store_ids is not None: query = query.filter(WaitingList.store_id.in_(allowed_store_ids)) else: store_ids = db.query(Store.id).filter(Store.franchise_id == franchise_id).all() store_ids = [s[0] for s in store_ids] query = query.filter(WaitingList.store_id.in_(store_ids)) # 그룹화 및 정렬 results = query.group_by( Member.id, Member.name, Member.phone, Store.name ).order_by( desc("attendance_count") ).limit(limit).all() return [ { "member_id": r.id, "name": r.name, "phone": r.phone, "store_name": r.store_name, "attendance_count": r.attendance_count, "last_attended_at": r.last_attended_at } for r in results ] @router.get("/{franchise_id}/attendance/trends") async def get_attendance_trends( franchise_id: int, start_date: date, end_date: date, period: str = Query("day", enum=["day", "month", "week"]), store_id: Optional[int] = None, current_user: User = Depends(require_franchise_admin), db: Session = Depends(get_db) ): """ 출석 추세 조회 (일별/주별/월별) """ # 권한 체크: system_admin은 모든 프랜차이즈 접근 가능, franchise_admin은 자신의 프랜차이즈만 if current_user.role == "franchise_admin" and current_user.franchise_id != franchise_id: raise HTTPException(status_code=403, detail="권한이 없습니다.") # 날짜 포맷 설정 (SQLite 기준) if period == "month": date_format = "%Y-%m" elif period == "week": date_format = "%Y-%W" else: date_format = "%Y-%m-%d" # 쿼리 구성 query = db.query( func.strftime(date_format, WaitingList.attended_at).label("period"), func.count(WaitingList.id).label("count") ).filter( WaitingList.status == "attended", WaitingList.attended_at >= datetime.combine(start_date, datetime.min.time()), WaitingList.attended_at <= datetime.combine(end_date, datetime.max.time()) ) # 매장 필터링 if store_id: query = query.filter(WaitingList.store_id == store_id) else: store_ids = db.query(Store.id).filter(Store.franchise_id == franchise_id).all() store_ids = [s[0] for s in store_ids] query = query.filter(WaitingList.store_id.in_(store_ids)) # 그룹화 및 정렬 results = query.group_by("period").order_by("period").all() return [ { "period": r.period, "count": r.count } for r in results ] @router.get("/{franchise_id}/members/{member_id}/history") async def get_member_history( franchise_id: int, member_id: int, start_date: date, end_date: date, current_user: User = Depends(require_franchise_admin), db: Session = Depends(get_db) ): """ 특정 회원의 출석 이력 조회 """ # 권한 체크: system_admin은 모든 프랜차이즈 접근 가능, franchise_admin은 자신의 프랜차이즈만 if current_user.role == "franchise_admin" and current_user.franchise_id != franchise_id: raise HTTPException(status_code=403, detail="권한이 없습니다.") # 회원 존재 확인 및 프랜차이즈 소속 확인 (간소화: 멤버 ID로 조회) member = db.query(Member).filter(Member.id == member_id).first() if not member: raise HTTPException(status_code=404, detail="회원을 찾을 수 없습니다.") # 출석 이력 조회 history = db.query( WaitingList.attended_at, Store.name.label("store_name"), WaitingList.status ).join( Store, WaitingList.store_id == Store.id ).filter( WaitingList.member_id == member_id, WaitingList.status == "attended", WaitingList.attended_at >= datetime.combine(start_date, datetime.min.time()), WaitingList.attended_at <= datetime.combine(end_date, datetime.max.time()) ).order_by( desc(WaitingList.attended_at) ).all() return [ { "attended_at": r.attended_at, "store_name": r.store_name, "status": r.status } for r in history ] @router.get("/{franchise_id}/store_comparison") async def get_store_comparison( franchise_id: int, start_date: date, end_date: date, store_id: Optional[int] = None, current_user: User = Depends(require_franchise_admin), db: Session = Depends(get_db) ): """ 매장별 출석 비교 조회 """ allowed_store_ids = check_franchise_permission(current_user, franchise_id, store_id) # 프랜차이즈 정보 조회 (이름 제거용) franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first() franchise_name = franchise.name if franchise else "" # LEFT JOIN을 사용하여 출석 기록이 없는 매장도 포함 # business_date 기준으로 기간 필터링 query = db.query( Store.id, Store.name, func.count(WaitingList.id).label("waiting_count"), func.count( case( (WaitingList.status == "attended", WaitingList.id), else_=None ) ).label("attendance_count") ).outerjoin( WaitingList, (Store.id == WaitingList.store_id) & (WaitingList.business_date >= start_date) & (WaitingList.business_date <= end_date) ).filter( Store.franchise_id == franchise_id, Store.is_active == True ) # 특정 매장 필터링 if store_id: query = query.filter(Store.id == store_id) if allowed_store_ids is not None: query = query.filter(Store.id.in_(allowed_store_ids)) results = query.group_by( Store.id, Store.name ).order_by( Store.name ).all() return [ { "store_id": r.id, "store_name": r.name, "waiting_count": r.waiting_count, "attendance_count": r.attendance_count } for r in results ] @router.get("/{franchise_id}/waiting/list") async def get_waiting_list_details( franchise_id: int, start_date: Optional[date] = None, end_date: Optional[date] = None, store_id: Optional[int] = None, current_user: User = Depends(require_franchise_admin), db: Session = Depends(get_db) ): """ 대기 목록 상세 조회 (전체 매장 또는 특정 매장) - start_date, end_date가 없으면 오늘 날짜 기준 - 있으면 해당 기간의 대기 목록 조회 """ allowed_store_ids = check_franchise_permission(current_user, franchise_id, store_id) # 날짜 기본값 설정 today = date.today() if not start_date: start_date = today if not end_date: end_date = today query = db.query( WaitingList.id, WaitingList.waiting_number, WaitingList.phone, WaitingList.created_at, WaitingList.business_date, WaitingList.status, Store.name.label("store_name"), Member.name.label("member_name"), Member.id.label("member_id"), Member.created_at.label("member_created_at") ).join( Store, WaitingList.store_id == Store.id ).outerjoin( # 비회원 대기도 있을 수 있으므로 outerjoin Member, WaitingList.member_id == Member.id ).filter( Store.franchise_id == franchise_id, Store.is_active == True, WaitingList.business_date >= start_date, WaitingList.business_date <= end_date ) if store_id: query = query.filter(WaitingList.store_id == store_id) if allowed_store_ids is not None: query = query.filter(Store.id.in_(allowed_store_ids)) results = query.order_by( WaitingList.created_at ).all() return [ { "id": r.id, "waiting_number": r.waiting_number, "phone": r.phone, "party_size": 1, # DB에 컬럼이 없어서 기본값 1로 고정 "created_at": r.created_at, "business_date": r.business_date, "status": r.status, "store_name": r.store_name, "member_name": r.member_name or "비회원", "member_id": r.member_id, "member_created_at": r.member_created_at } for r in results ] @router.get("/{franchise_id}/members/new") async def get_new_members( franchise_id: int, start_date: date, end_date: date, store_id: Optional[int] = None, current_user: User = Depends(require_franchise_admin), db: Session = Depends(get_db) ): """ 신규 회원 목록 조회 """ allowed_store_ids = check_franchise_permission(current_user, franchise_id, store_id) # 프랜차이즈 내 모든 매장 ID 조회 store_ids_query = db.query(Store.id).filter( Store.franchise_id == franchise_id, Store.is_active == True ) if store_id: store_ids_query = store_ids_query.filter(Store.id == store_id) if allowed_store_ids is not None: store_ids_query = store_ids_query.filter(Store.id.in_(allowed_store_ids)) store_ids = [s[0] for s in store_ids_query.all()] if not store_ids: return [] # 신규 회원 조회 query = db.query( Member.id, Member.name, Member.phone, Member.created_at, Store.name.label("store_name") ).join( Store, Member.store_id == Store.id ).filter( Member.store_id.in_(store_ids), Member.created_at >= datetime.combine(start_date, datetime.min.time()), Member.created_at <= datetime.combine(end_date, datetime.max.time()) ).order_by( desc(Member.created_at) ) results = query.all() return [ { "id": r.id, "name": r.name, "phone": r.phone, "created_at": r.created_at, "store_name": r.store_name } for r in results ] @router.get("/{franchise_id}/members/search") async def search_members( franchise_id: int, query: str, current_user: User = Depends(require_franchise_admin), db: Session = Depends(get_db) ): """ 회원 검색 (프랜차이즈 전체) """ # This also needs checking, though store_id param is not present. # Helper without store_id returns all allowed stores. allowed_store_ids = check_franchise_permission(current_user, franchise_id) # If None, query all in franchise. If List, query only those. if not query or len(query) < 2: return [] # 검색어 너무 짧으면 빈 배열 # 프랜차이즈 내 모든 매장 ID 조회 store_ids_query = db.query(Store.id).filter( Store.franchise_id == franchise_id ) if allowed_store_ids is not None: store_ids_query = store_ids_query.filter(Store.id.in_(allowed_store_ids)) store_ids = [s[0] for s in store_ids_query.all()] if not store_ids: return [] # 검색 results = db.query( Member.id, Member.name, Member.phone, Member.created_at, Store.name.label("store_name") ).join( Store, Member.store_id == Store.id ).filter( Member.store_id.in_(store_ids), or_( Member.name.contains(query), Member.phone.endswith(query) ) ).limit(20).all() return [ { "id": r.id, "name": r.name, "phone": r.phone, "created_at": r.created_at, "store_name": r.store_name } for r in results ]