Files
waiting-system/routers/statistics.py
Jun-dev f699a29a85 Add waiting system application files
- Add main application files (main.py, models.py, schemas.py, etc.)
- Add routers for all features (waiting, attendance, members, etc.)
- Add HTML templates for admin and user interfaces
- Add migration scripts and utility files
- Add Docker configuration
- Add documentation files
- Add .gitignore to exclude database and cache files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 00:29:39 +09:00

698 lines
22 KiB
Python

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
]