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>
This commit is contained in:
2025-12-14 00:29:39 +09:00
parent dd1322625e
commit f699a29a85
120 changed files with 35602 additions and 0 deletions

1
routers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Router package

494
routers/attendance.py Normal file
View File

@@ -0,0 +1,494 @@
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, and_, or_
from database import get_db
from models import WaitingList, Member, Store, ClassInfo
from auth import get_current_store
from datetime import datetime, timedelta, date
from typing import List, Optional
from pydantic import BaseModel
router = APIRouter(
tags=["attendance"]
)
templates = Jinja2Templates(directory="templates")
# --- API Endpoints ---
@router.get("/status")
async def get_attendance_status(
period: str,
date: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
db: Session = Depends(get_db),
current_store: Store = Depends(get_current_store)
):
target_date = datetime.strptime(date, "%Y-%m-%d").date()
start_date_param = start_date
end_date_param = end_date
# 기간 설정
start_date = target_date
end_date = target_date
if period == 'custom' and start_date_param and end_date_param:
start_date = datetime.strptime(start_date_param, "%Y-%m-%d").date()
end_date = datetime.strptime(end_date_param, "%Y-%m-%d").date()
elif period == 'weekly':
start_date = target_date - timedelta(days=target_date.weekday())
end_date = start_date + timedelta(days=6)
elif period == 'monthly':
start_date = target_date.replace(day=1)
next_month = target_date.replace(day=28) + timedelta(days=4)
end_date = next_month - timedelta(days=next_month.day)
elif period == 'yearly':
start_date = target_date.replace(month=1, day=1)
end_date = target_date.replace(month=12, day=31)
# 전체 출석 조회 (attended 상태인 것만)
attendance_query = db.query(WaitingList).filter(
WaitingList.store_id == current_store.id,
WaitingList.status == 'attended',
func.date(WaitingList.attended_at) >= start_date,
func.date(WaitingList.attended_at) <= end_date
)
total_attendance = attendance_query.count()
# 신규 회원 출석 (해당 기간에 가입한 회원의 출석)
# 1. 해당 기간에 가입한 회원 ID 조회
new_member_ids = db.query(Member.id).filter(
Member.store_id == current_store.id,
func.date(Member.created_at) >= start_date,
func.date(Member.created_at) <= end_date
).all()
new_member_ids = [m[0] for m in new_member_ids]
new_member_attendance = 0
if new_member_ids:
new_member_attendance = attendance_query.filter(
WaitingList.member_id.in_(new_member_ids)
).count()
existing_member_attendance = total_attendance - new_member_attendance
return {
"total": total_attendance,
"existing": existing_member_attendance,
"new": new_member_attendance
}
@router.get("/waiting-status")
async def get_waiting_status(
period: str,
date: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
db: Session = Depends(get_db),
current_store: Store = Depends(get_current_store)
):
target_date = datetime.strptime(date, "%Y-%m-%d").date()
start_date_param = start_date
end_date_param = end_date
# 기간 설정
start_date = target_date
end_date = target_date
if period == 'custom' and start_date_param and end_date_param:
start_date = datetime.strptime(start_date_param, "%Y-%m-%d").date()
end_date = datetime.strptime(end_date_param, "%Y-%m-%d").date()
elif period == 'weekly':
start_date = target_date - timedelta(days=target_date.weekday())
end_date = start_date + timedelta(days=6)
elif period == 'monthly':
start_date = target_date.replace(day=1)
next_month = target_date.replace(day=28) + timedelta(days=4)
end_date = next_month - timedelta(days=next_month.day)
elif period == 'yearly':
start_date = target_date.replace(month=1, day=1)
end_date = target_date.replace(month=12, day=31)
# 전체 대기 조회 (waiting 상태인 것만)
# 주의: 대기 현황은 보통 '현재' 기준이지만, 기간별 통계라면 '해당 기간에 대기 등록된 수' 또는 '해당 기간에 대기했던 수'를 의미할 수 있음.
# 여기서는 '해당 기간에 등록된 대기(registered_at)'를 기준으로 하되, status는 상관없이 '대기 등록' 자체를 카운트할지, 아니면 '현재 waiting' 상태인 것만 카운트할지 결정해야 함.
# 사용자가 "대기현황"이라고 했고 "총 대기"라고 했으므로, 해당 기간의 "총 대기 등록 건수"를 의미하는 것이 일반적임 (취소/출석 포함).
# 하지만 "대기현황"이라는 말은 "현재 대기 중인 사람"을 의미할 수도 있음.
# 탭이 "출석현황"과 대등하게 있다면 "기간 내 대기 등록 수"가 맞을 듯함.
# 출석현황은 "attended" 상태인 것만 셌음.
# 대기현황은 "waiting" 상태인 것만 세면 과거 날짜는 0일 확률이 높음 (다 처리되었을 테니).
# 따라서 "대기현황"은 "해당 기간에 발생한 총 대기 건수" (status 무관) 또는 "waiting" 상태였던 것?
# 요구사항: "총 대기 / 기존회원 대기 / 신규회원 대기"
# 아마도 "총 접수 건수"를 의미할 가능성이 높음.
# 전체 대기 조회 (기간 내 등록된 대기)
# 수정: 등록일 기준뿐만 아니라, 해당 기간에 출석/취소된 건도 포함해야 함 (이월된 대기자 등)
waiting_query = db.query(WaitingList).filter(
WaitingList.store_id == current_store.id,
or_(
# 1. 해당 기간에 등록된 건 (상태 불문)
and_(func.date(WaitingList.registered_at) >= start_date, func.date(WaitingList.registered_at) <= end_date),
# 2. 해당 기간에 출석한 건 (등록일과 무관하게 포함 - 이월 된 대기자 처리)
and_(
WaitingList.status == 'attended',
func.date(WaitingList.attended_at) >= start_date,
func.date(WaitingList.attended_at) <= end_date
)
# 취소/노쇼는 등록일 기준이 아니면 포함하지 않음 (이월 된 대기자의 일괄 취소/마감 등은 통계에서 제외)
)
)
total_waiting = waiting_query.count()
# 현 대기 조회 (기간 내 등록/활동이 있었던 대기 중 현재 status가 waiting인 것)
# 다만, '과거 날짜'를 조회할 때 '현재 waiting'인 것은 의미가 모호할 수 있음 (조회 시점 기준으론 waiting이지만, 그 날짜 기준으론 아닐 수 있음)
# 하지만 시스템상 'status'는 현재 상태만 가지고 있음.
# 따라서 여기서의 current_waiting은 '해당 기간에 관여된 사람 중 아직도 대기 중인 사람'을 의미하게 됨.
current_waiting_query = waiting_query.filter(WaitingList.status == 'waiting')
current_total = current_waiting_query.count()
# 신규 회원 대기 (해당 기간에 가입한 회원의 대기)
new_member_ids = db.query(Member.id).filter(
Member.store_id == current_store.id,
func.date(Member.created_at) >= start_date,
func.date(Member.created_at) <= end_date
).all()
new_member_ids = [m[0] for m in new_member_ids]
new_member_waiting = 0
current_new = 0
if new_member_ids:
# 총 신규회원 대기
new_member_waiting = waiting_query.filter(
WaitingList.member_id.in_(new_member_ids)
).count()
# 현 신규회원 대기
current_new = current_waiting_query.filter(
WaitingList.member_id.in_(new_member_ids)
).count()
existing_member_waiting = total_waiting - new_member_waiting
current_existing = current_total - current_new
return {
"total": total_waiting,
"existing": existing_member_waiting,
"new": new_member_waiting,
"current_total": current_total,
"current_existing": current_existing,
"current_new": current_new
}
@router.get("/individual/search")
async def search_member_for_attendance(
query: str,
db: Session = Depends(get_db),
current_store: Store = Depends(get_current_store)
):
# 이름 또는 전화번호 뒷자리로 검색
members = db.query(Member).filter(
Member.store_id == current_store.id,
(Member.name.contains(query)) | (Member.phone.endswith(query))
).limit(20).all()
return [
{"id": m.id, "name": m.name, "phone": m.phone}
for m in members
]
@router.get("/individual/{member_id}")
async def get_member_attendance_detail(
member_id: int,
period: str = 'monthly',
date: str = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
db: Session = Depends(get_db),
current_store: Store = Depends(get_current_store)
):
try:
# 날짜 파라미터 처리
if date:
target_date = datetime.strptime(date, "%Y-%m-%d").date()
else:
target_date = datetime.now().date()
# 기간 설정
start_date_val = target_date
end_date_val = target_date
if period == 'custom' and start_date and end_date:
start_date_val = datetime.strptime(start_date, "%Y-%m-%d").date()
end_date_val = datetime.strptime(end_date, "%Y-%m-%d").date()
elif period == 'weekly':
start_date_val = target_date - timedelta(days=target_date.weekday())
end_date_val = start_date_val + timedelta(days=6)
elif period == 'monthly':
start_date_val = target_date.replace(day=1)
next_month = target_date.replace(day=28) + timedelta(days=4)
end_date_val = next_month - timedelta(days=next_month.day)
elif period == 'yearly':
start_date_val = target_date.replace(month=1, day=1)
end_date_val = target_date.replace(month=12, day=31)
elif period == 'all':
start_date_val = datetime.strptime("2000-01-01", "%Y-%m-%d").date()
end_date_val = datetime.strptime("2099-12-31", "%Y-%m-%d").date()
# 회원 정보 조회
member = db.query(Member).filter(
Member.id == member_id,
Member.store_id == current_store.id
).first()
if not member:
raise HTTPException(status_code=404, detail="회원을 찾을 수 없습니다.")
# 기간 내 출석 내역 조회 (attended_at이 NULL이 아닌 것만)
query = db.query(WaitingList).filter(
WaitingList.member_id == member_id,
WaitingList.store_id == current_store.id,
WaitingList.status == 'attended',
WaitingList.attended_at.isnot(None) # NULL 체크 추가
)
if period != 'all':
query = query.filter(
func.date(WaitingList.attended_at) >= start_date_val,
func.date(WaitingList.attended_at) <= end_date_val
)
# 총 출석 횟수
total_count = query.count()
# 최근 20개 출석 내역
history = query.order_by(desc(WaitingList.attended_at)).limit(20).all()
# 캘린더용 출석 날짜 목록 (기간 내 모든 출석 날짜)
attendance_dates = db.query(
func.date(WaitingList.attended_at).label('date')
).filter(
WaitingList.member_id == member_id,
WaitingList.store_id == current_store.id,
WaitingList.status == 'attended',
WaitingList.attended_at.isnot(None), # NULL 체크 추가
func.date(WaitingList.attended_at) >= start_date_val,
func.date(WaitingList.attended_at) <= end_date_val
).distinct().all()
# 날짜를 문자열 리스트로 변환 (func.date()는 이미 문자열을 반환함)
calendar_dates = []
for d in attendance_dates:
if d.date: # NULL 체크
# func.date()가 이미 문자열이면 그대로 사용, date 객체면 변환
if isinstance(d.date, str):
calendar_dates.append(d.date)
else:
calendar_dates.append(d.date.strftime("%Y-%m-%d"))
return {
"member": {
"id": member.id,
"name": member.name,
"phone": member.phone
},
"period": {
"type": period,
"start": start_date_val.strftime("%Y-%m-%d"),
"end": end_date_val.strftime("%Y-%m-%d")
},
"total_count": total_count,
"calendar_dates": calendar_dates,
"history": [
{
"id": h.id,
"date": h.attended_at.strftime("%Y-%m-%d %H:%M") if h.attended_at else "N/A",
"class_name": db.query(ClassInfo.class_name).filter(ClassInfo.id == h.class_id).scalar() or "N/A"
}
for h in history
]
}
except ValueError as e:
raise HTTPException(status_code=400, detail=f"날짜 형식 오류: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"서버 오류: {str(e)}")
@router.get("/new-members")
async def get_new_members(
period: str,
date: str = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
current_store: Store = Depends(get_current_store)
):
# 날짜가 없으면 오늘로 설정
if not date or date == '':
target_date = datetime.now().date()
else:
try:
target_date = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
# 날짜 형식이 잘못된 경우 오늘로 설정
target_date = datetime.now().date()
start_date_param = start_date
end_date_param = end_date
start_date = target_date
end_date = target_date
if period == 'custom' and start_date_param and end_date_param:
start_date = datetime.strptime(start_date_param, "%Y-%m-%d").date()
end_date = datetime.strptime(end_date_param, "%Y-%m-%d").date()
elif period == 'weekly':
start_date = target_date - timedelta(days=target_date.weekday())
end_date = start_date + timedelta(days=6)
elif period == 'monthly':
start_date = target_date.replace(day=1)
next_month = target_date.replace(day=28) + timedelta(days=4)
end_date = next_month - timedelta(days=next_month.day)
elif period == 'yearly':
start_date = target_date.replace(month=1, day=1)
end_date = target_date.replace(month=12, day=31)
new_members = db.query(Member).filter(
Member.store_id == current_store.id,
func.date(Member.created_at) >= start_date,
func.date(Member.created_at) <= end_date
).all()
# 전체 회원 수 조회 (총원원수)
total_members_count = db.query(func.count(Member.id)).filter(
Member.store_id == current_store.id
).scalar() or 0
result = []
total_attendance = 0
for member in new_members:
# 출석 횟수 조회
attendance_count = db.query(func.count(WaitingList.id)).filter(
WaitingList.member_id == member.id,
WaitingList.status == 'attended'
).scalar() or 0
# 최초 출석일 조회
first_attendance = db.query(WaitingList).filter(
WaitingList.member_id == member.id,
WaitingList.status == 'attended'
).order_by(WaitingList.attended_at).first()
# 최근 출석일 조회
last_attendance = db.query(WaitingList).filter(
WaitingList.member_id == member.id,
WaitingList.status == 'attended'
).order_by(desc(WaitingList.attended_at)).first()
total_attendance += attendance_count
result.append({
"name": member.name,
"phone": member.phone,
"joined_at": member.created_at.strftime("%Y-%m-%d"),
"first_attendance": first_attendance.attended_at.strftime("%Y-%m-%d") if first_attendance and first_attendance.attended_at else None,
"last_attendance": last_attendance.attended_at.strftime("%Y-%m-%d") if last_attendance and last_attendance.attended_at else None,
"attendance_count": attendance_count
})
# 출석순으로 정렬 (출석 횟수가 많은 순)
result.sort(key=lambda x: x['attendance_count'], reverse=True)
# 평균 출석 횟수 계산
avg_attendance = round(total_attendance / len(new_members), 1) if new_members else 0
# 페이징 적용 (Python Slicing)
paginated_result = result[skip : skip + limit]
return {
"count": len(new_members),
"new_members": paginated_result,
"total_members_count": total_members_count,
"total_attendance": total_attendance,
"avg_attendance": avg_attendance
}
@router.get("/ranking")
async def get_attendance_ranking(
period: str,
min_count: int = 0,
date: str = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
current_store: Store = Depends(get_current_store)
):
# 날짜 처리
if not date or date == '':
target_date = datetime.now().date()
else:
try:
target_date = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
target_date = datetime.now().date()
start_date_param = start_date
end_date_param = end_date
start_date = target_date
end_date = target_date
if period == 'custom' and start_date_param and end_date_param:
start_date = datetime.strptime(start_date_param, "%Y-%m-%d").date()
end_date = datetime.strptime(end_date_param, "%Y-%m-%d").date()
elif period == 'weekly':
start_date = target_date - timedelta(days=target_date.weekday())
end_date = start_date + timedelta(days=6)
elif period == 'monthly':
start_date = target_date.replace(day=1)
next_month = target_date.replace(day=28) + timedelta(days=4)
end_date = next_month - timedelta(days=next_month.day)
elif period == 'yearly':
start_date = target_date.replace(month=1, day=1)
end_date = target_date.replace(month=12, day=31)
query = db.query(
Member.id,
Member.name,
Member.phone,
func.count(WaitingList.id).label('attendance_count'),
func.max(WaitingList.attended_at).label('last_attendance')
).join(WaitingList, Member.id == WaitingList.member_id).filter(
WaitingList.store_id == current_store.id,
WaitingList.status == 'attended',
func.date(WaitingList.attended_at) >= start_date,
func.date(WaitingList.attended_at) <= end_date
)
query = query.group_by(Member.id).having(func.count(WaitingList.id) >= min_count)
query = query.order_by(desc('attendance_count'), desc('last_attendance'))
# 페이징 적용
query = query.offset(skip).limit(limit)
rankings = query.all()
return [
{
"member_id": r.id,
"name": r.name,
"phone": r.phone,
"attendance_count": r.attendance_count,
"last_attendance": r.last_attendance.strftime("%Y-%m-%d") if r.last_attendance else "-"
}
for r in rankings
]

116
routers/auth.py Normal file
View File

@@ -0,0 +1,116 @@
"""
인증 라우터
- 로그인
- 로그아웃
- 현재 사용자 정보 조회
"""
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import timedelta
from database import get_db
from models import User
from schemas import Token, User as UserSchema, UserLogin
from auth import (
verify_password,
create_access_token,
get_current_user,
ACCESS_TOKEN_EXPIRE_HOURS
)
router = APIRouter()
@router.post("/login", response_model=Token)
async def login(
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""로그인
Args:
form_data: OAuth2 폼 데이터 (username, password)
Returns:
Token: JWT 액세스 토큰
HTTP-only 쿠키에도 토큰 저장
"""
# 사용자 조회
user = db.query(User).filter(User.username == form_data.username).first()
# 사용자 존재 여부 및 비밀번호 검증
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="사용자명 또는 비밀번호가 잘못되었습니다",
headers={"WWW-Authenticate": "Bearer"},
)
# 비활성화된 사용자 체크
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="비활성화된 사용자입니다"
)
# JWT 토큰 생성
access_token_expires = timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
access_token = create_access_token(
data={"sub": user.username},
expires_delta=access_token_expires
)
# HTTP-only 쿠키에 토큰 저장
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
max_age=ACCESS_TOKEN_EXPIRE_HOURS * 3600,
secure=False, # HTTPS 사용 시 True로 변경
samesite="lax"
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/logout")
async def logout(response: Response):
"""로그아웃
쿠키에서 토큰 제거
"""
response.delete_cookie(key="access_token")
return {"message": "로그아웃 되었습니다"}
@router.get("/me", response_model=UserSchema)
async def get_me(current_user: User = Depends(get_current_user)):
"""현재 로그인한 사용자 정보 조회
Returns:
User: 현재 사용자 정보
"""
return current_user
@router.get("/check")
async def check_auth(current_user: User = Depends(get_current_user)):
"""인증 상태 확인 (프론트엔드용)
Returns:
dict: 인증 여부 및 사용자 정보
"""
return {
"authenticated": True,
"user": {
"id": current_user.id,
"username": current_user.username,
"role": current_user.role,
"store_id": current_user.store_id,
"franchise_id": current_user.franchise_id
}
}

325
routers/class_management.py Normal file
View File

@@ -0,0 +1,325 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Dict, Optional
from datetime import date
import json
from database import get_db
from models import ClassInfo, WaitingList, Store
from schemas import (
ClassInfo as ClassInfoSchema,
ClassInfoCreate,
ClassInfoUpdate
)
from auth import get_current_store
router = APIRouter()
# 요일 스케줄 기본값
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 딕셔너리로 안전하게 변환
Args:
schedule_str: JSON 형식의 weekday_schedule 문자열
Returns:
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()
# 모든 요일 키가 존재하는지 확인하고 없으면 기본값으로 채움
result = DEFAULT_WEEKDAY_SCHEDULE.copy()
for key in result.keys():
if key in schedule:
result[key] = bool(schedule[key])
return result
except (json.JSONDecodeError, TypeError, ValueError):
return DEFAULT_WEEKDAY_SCHEDULE.copy()
def serialize_weekday_schedule(schedule: Dict[str, bool]) -> str:
"""
weekday_schedule 딕셔너리를 JSON 문자열로 안전하게 변환
Args:
schedule: weekday_schedule 딕셔너리
Returns:
JSON 형식의 문자열
"""
if not schedule:
schedule = DEFAULT_WEEKDAY_SCHEDULE
# 모든 요일 키가 존재하는지 확인
result = DEFAULT_WEEKDAY_SCHEDULE.copy()
for key in result.keys():
if key in schedule:
result[key] = bool(schedule[key])
return json.dumps(result)
def prepare_class_response(db_class: ClassInfo, db: Session, today: date = None) -> dict:
"""
ClassInfo 객체를 API 응답용 딕셔너리로 변환
Args:
db_class: ClassInfo 모델 인스턴스
db: 데이터베이스 세션
today: 기준 날짜 (기본값: 오늘)
Returns:
API 응답용 딕셔너리
"""
if today is None:
today = date.today()
# 현재 대기자 수 조회
current_count = db.query(func.count(WaitingList.id)).filter(
WaitingList.class_id == db_class.id,
WaitingList.business_date == today,
WaitingList.status == "waiting"
).scalar() or 0
# weekday_schedule을 미리 파싱 (Pydantic validation 전에 변환)
parsed_schedule = parse_weekday_schedule(db_class.weekday_schedule)
# 수동으로 딕셔너리 생성 (from_orm 대신)
result = {
"id": db_class.id,
"class_number": db_class.class_number,
"class_name": db_class.class_name,
"start_time": db_class.start_time,
"end_time": db_class.end_time,
"max_capacity": db_class.max_capacity,
"is_active": db_class.is_active,
"weekday_schedule": parsed_schedule,
"class_type": db_class.class_type if hasattr(db_class, 'class_type') else 'all',
"created_at": db_class.created_at,
"updated_at": db_class.updated_at,
"current_count": current_count
}
return result
@router.post("/", response_model=ClassInfoSchema)
async def create_class(
class_info: ClassInfoCreate,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""클래스(교시) 생성"""
# 같은 번호의 클래스가 있는지 확인 (매장별, class_type별)
existing = db.query(ClassInfo).filter(
ClassInfo.store_id == current_store.id,
ClassInfo.class_number == class_info.class_number,
ClassInfo.class_type == class_info.class_type
).first()
if existing:
class_type_name = {'weekday': '평일', 'weekend': '주말', 'all': '전체'}[class_info.class_type]
raise HTTPException(
status_code=400,
detail=f"{class_type_name} {class_info.class_number}교시가 이미 존재합니다."
)
# weekday_schedule을 JSON 문자열로 안전하게 변환
data = class_info.dict()
if 'weekday_schedule' in data:
data['weekday_schedule'] = serialize_weekday_schedule(data['weekday_schedule'])
db_class = ClassInfo(**data, store_id=current_store.id)
db.add(db_class)
db.commit()
db.refresh(db_class)
# 헬퍼 함수를 사용하여 응답 생성
return prepare_class_response(db_class, db)
@router.get("/", response_model=List[ClassInfoSchema])
async def get_classes(
include_inactive: bool = False,
class_type: Optional[str] = None,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""클래스 목록 조회"""
query = db.query(ClassInfo).filter(ClassInfo.store_id == current_store.id)
if not include_inactive:
query = query.filter(ClassInfo.is_active == True)
# class_type 필터링
if class_type:
query = query.filter(ClassInfo.class_type == class_type)
classes = query.order_by(ClassInfo.class_number).all()
# 헬퍼 함수를 사용하여 각 클래스 정보 변환
today = date.today()
result = [prepare_class_response(cls, db, today) for cls in classes]
return result
@router.get("/{class_id}", response_model=ClassInfoSchema)
async def get_class(
class_id: int,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""클래스 상세 조회"""
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="클래스를 찾을 수 없습니다.")
# 헬퍼 함수를 사용하여 응답 생성
return prepare_class_response(class_info, db)
@router.put("/{class_id}", response_model=ClassInfoSchema)
async def update_class(
class_id: int,
class_info: ClassInfoUpdate,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""클래스 수정"""
db_class = db.query(ClassInfo).filter(
ClassInfo.id == class_id,
ClassInfo.store_id == current_store.id
).first()
if not db_class:
raise HTTPException(status_code=404, detail="클래스를 찾을 수 없습니다.")
# 업데이트할 필드만 수정
update_data = class_info.dict(exclude_unset=True)
# 클래스 번호 또는 타입 변경 시 중복 체크 (매장별, class_type별)
if "class_number" in update_data or "class_type" in update_data:
check_class_number = update_data.get("class_number", db_class.class_number)
check_class_type = update_data.get("class_type", db_class.class_type)
existing = db.query(ClassInfo).filter(
ClassInfo.store_id == current_store.id,
ClassInfo.class_number == check_class_number,
ClassInfo.class_type == check_class_type,
ClassInfo.id != class_id
).first()
if existing:
class_type_name = {'weekday': '평일', 'weekend': '주말', 'all': '전체'}[check_class_type]
raise HTTPException(
status_code=400,
detail=f"{class_type_name} {check_class_number}교시가 이미 존재합니다."
)
# weekday_schedule을 JSON 문자열로 안전하게 변환
if 'weekday_schedule' in update_data:
update_data['weekday_schedule'] = serialize_weekday_schedule(update_data['weekday_schedule'])
for field, value in update_data.items():
setattr(db_class, field, value)
db.commit()
db.refresh(db_class)
# 헬퍼 함수를 사용하여 응답 생성
return prepare_class_response(db_class, db)
@router.delete("/{class_id}")
async def delete_class(
class_id: int,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""클래스 삭제 (비활성화)"""
db_class = db.query(ClassInfo).filter(
ClassInfo.id == class_id,
ClassInfo.store_id == current_store.id
).first()
if not db_class:
raise HTTPException(status_code=404, detail="클래스를 찾을 수 없습니다.")
# 실제 삭제 대신 비활성화
db_class.is_active = False
db.commit()
return {"message": f"{db_class.class_name}이(가) 비활성화되었습니다."}
@router.post("/{class_id}/activate")
async def activate_class(
class_id: int,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""클래스 활성화"""
db_class = db.query(ClassInfo).filter(
ClassInfo.id == class_id,
ClassInfo.store_id == current_store.id
).first()
if not db_class:
raise HTTPException(status_code=404, detail="클래스를 찾을 수 없습니다.")
db_class.is_active = True
db.commit()
return {"message": f"{db_class.class_name}이(가) 활성화되었습니다."}
@router.get("/available/next")
async def get_next_available_class(
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""
다음 배치 가능한 클래스 조회
- 각 클래스의 현재 인원과 최대 수용 인원을 비교
- 여유가 있는 첫 번째 클래스 반환
"""
today = date.today()
classes = db.query(ClassInfo).filter(
ClassInfo.store_id == current_store.id,
ClassInfo.is_active == True
).order_by(ClassInfo.class_number).all()
for cls in classes:
current_count = db.query(func.count(WaitingList.id)).filter(
WaitingList.class_id == cls.id,
WaitingList.business_date == today,
WaitingList.status == "waiting"
).scalar()
if current_count < cls.max_capacity:
return {
"class_id": cls.id,
"class_name": cls.class_name,
"class_number": cls.class_number,
"current_count": current_count,
"max_capacity": cls.max_capacity,
"available_slots": cls.max_capacity - current_count
}
# 모든 클래스가 가득 찬 경우
raise HTTPException(status_code=400, detail="모든 클래스가 만석입니다.")

441
routers/daily_closing.py Normal file
View File

@@ -0,0 +1,441 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, date, timedelta
from typing import List, Dict
from database import get_db
from models import DailyClosing, WaitingList, ClassInfo, Store, StoreSettings
from schemas import DailyClosing as DailyClosingSchema, DailyClosingCreate, DailyStatistics
from auth import get_current_store
from utils import get_today_date
router = APIRouter()
def get_current_business_date(db: Session, store_id: int) -> date:
"""
현재 영업일 조회
1. 현재 활성화된(is_closed=False) 영업일이 있으면 그 날짜를 반환 (우선순위 높음 - 당일 2회 개점 등 지원)
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)
@router.get("/predict-date", response_model=Dict[str, str])
async def predict_next_business_date(
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""
개점 예정 날짜 예측
- 현재 상태와 설정(Strict/Flexible)을 기반으로 개점 시 사용할 날짜를 계산
"""
# 현재 활성화된 영업일이 있다면 그 날짜 반환
active_closing = db.query(DailyClosing).filter(
DailyClosing.store_id == current_store.id,
DailyClosing.is_closed == False
).order_by(DailyClosing.business_date.desc()).first()
if active_closing:
return {"business_date": active_closing.business_date.strftime("%Y-%m-%d")}
# 없으면 계산
settings = db.query(StoreSettings).filter(StoreSettings.store_id == current_store.id).first()
start_hour = settings.business_day_start if settings else 5
today = get_today_date(start_hour)
opening_rule = settings.daily_opening_rule if settings and settings.daily_opening_rule else 'strict'
target_date = today
# 로직 시뮬레이션
while True:
existing = db.query(DailyClosing).filter(
DailyClosing.store_id == current_store.id,
DailyClosing.business_date == target_date
).first()
if not existing:
break
if not existing.is_closed:
# 이미 열려있음 (위에서 잡혔겠지만 혹시나)
break
# 마감된 경우
if opening_rule == 'strict':
if target_date == today:
# Strict 모드인데 오늘 이미 마감됨 -> 오늘 개점 불가 (UI에서 처리하겠지만 일단 날짜는 오늘로)
# 하지만 에러 상황이므로... 다음날로 안내? 아니면 그대로 오늘?
# 사용자는 "개점 날짜"를 보고 싶어함.
# 만약 에러가 날 상황이면 에러 메시지를 보여주는게 맞지만,
# 여기서는 "만약 된다면 언제?"를 묻는 것.
# Strict 모드에서 오늘 마감했으면 "개점 불가"가 맞음.
# 하지만 일단 오늘 날짜 리턴하고 실제 시도 시 에러 발생시킴.
pass
else:
target_date = target_date + timedelta(days=1)
else:
# Flexible -> 다음날
target_date = target_date + timedelta(days=1)
return {"business_date": target_date.strftime("%Y년 %m월 %d")}
@router.post("/open", response_model=DailyClosingSchema)
async def open_business(
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""
영업 개점
- 새로운 영업일 생성
- 대기번호 1부터 시작
"""
today = get_current_business_date(db, current_store.id)
# --- Opening Logic with Rules ---
settings = db.query(StoreSettings).filter(StoreSettings.store_id == current_store.id).first()
opening_rule = settings.daily_opening_rule if settings and settings.daily_opening_rule else 'strict'
target_date = today
# 루프를 통해 사용 가능한 영업일 찾기 (flexible 모드 지원을 위해)
while True:
existing = db.query(DailyClosing).filter(
DailyClosing.store_id == current_store.id,
DailyClosing.business_date == target_date
).first()
if not existing:
# 해당 날짜에 기록이 없으면 개점 가능 -> target_date로 개점 진행
break
# 기록이 있는 경우
if not existing.is_closed:
# 이미 개점 상태인 경우
raise HTTPException(status_code=400, detail=f"{target_date.strftime('%Y-%m-%d')} 날짜로 이미 영업 중입니다.")
# 마감된 기록이 있는 경우
if opening_rule == 'strict':
# 당일 개점 1회 제한 (엄격 모드)
if target_date == today:
raise HTTPException(status_code=400, detail="당일 개점은 1회만 가능합니다.\n내일 개점을 해주세요.")
else:
# 미래 날짜의 마감 기록이 있다면? (이론상 드묾) -> 다음 날짜 확인
target_date = target_date + timedelta(days=1)
else:
# 2회 이상 개점 허용 (다음 날로 이월 모드)
# 마감된 날짜가 있으면 다음 날짜로 넘어감
target_date = target_date + timedelta(days=1)
# 이월 로직을 위한 '이전 영업일' 기준은?
# 기본적으로 '오늘 - 1일' 이지만, Next Day 모드에서는 target_date - 1일이 맞음.
today = target_date # today 변수를 실제 개점할 날짜로 업데이트
# --- Logic End ---
# --- Carry Over Logic Start ---
# 이전 영업일이 있고, 자동 마감이 꺼져있는 경우 대기자 이월
# settings는 위에서 이미 조회함
# 1. 이전 영업일 조회
last_business_date = today - timedelta(days=1)
last_daily_closing = db.query(DailyClosing).filter(
DailyClosing.store_id == current_store.id,
DailyClosing.business_date == last_business_date
).first()
if last_daily_closing and settings: # 이전 영업일이 있고, 설정이 있는 경우에만 처리
# 2. 미처리 대기자 처리 (자동 마감인 경우)
if settings.auto_closing:
# 2-1. 이전 영업일의 대기 중인 고객 조회
pending_waitings = db.query(WaitingList).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == last_business_date,
WaitingList.status == 'waiting'
).all()
for waiting in pending_waitings:
if settings.closing_action == 'attended':
# 출석 처리
waiting.status = 'attended'
waiting.attended_at = datetime.now() # 혹은 마감 시간? 현재 시간으로 처리
else:
# 리셋 (취소 처리)
waiting.status = 'cancelled'
waiting.cancelled_at = datetime.now()
# 3. 자동 마감이 아닌 경우 (이월 로직 - 기존 로직 유지)
else:
# 3-1. 이전 영업일의 대기 중인 고객 조회
pending_waitings = db.query(WaitingList).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == last_business_date,
WaitingList.status == 'waiting'
).order_by(WaitingList.waiting_number).all()
# 3-2. 오늘 날짜로 이월
current_max_waiting_number = 0
# 오늘 이미 대기자가 있는지 확인 (드문 경우지만)
today_waitings_count = db.query(WaitingList).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == today
).count()
start_waiting_number = today_waitings_count + 1
for i, waiting in enumerate(pending_waitings):
# 새로운 대기 레코드 생성 (이력 관리를 위해 복사본 생성 추천하지만, 여기서는 업데이트로 가정)
# *중요*: 날짜 기반 파티셔닝이라면 업데이트, 아니라면 새 레코드.
# 여기서는 WaitingList 모델이 business_date를 PK로 쓰지 않으므로 업데이트 가능.
# 하지만 '이월'이라는 명시적 기록을 남기려면 어떻게 할지?
# -> 일단 business_date 변경 및 waiting_number 재발급
# 기존 레코드 정보
old_waiting_number = waiting.waiting_number
# 정보 업데이트
waiting.business_date = today
waiting.waiting_number = start_waiting_number + i
waiting.registered_at = datetime.now() # 재등록 시간? 아니면 유지? -> 이월됨을 알리기 위해 유지 또는 별도 표시 필요하지만, 일단 날짜 변경.
# (선택사항) 이월 로그 남기기 또는 비고란 추가
db.commit() # 이월 처리 확정
# --- Carry Over Logic End ---
# 새로운 영업일 생성
new_business = DailyClosing(
store_id=current_store.id,
business_date=today,
opening_time=datetime.now(),
is_closed=False
)
db.add(new_business)
db.commit()
db.refresh(new_business)
return new_business
@router.post("/close", response_model=DailyClosingSchema)
async def close_business(
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""
일마감
- 현재 영업일 마감
- 통계 계산 및 저장
- 대기 중인 고객 자동 처리 (설정에 따라)
"""
today = get_current_business_date(db, current_store.id)
# 현재 영업일 조회 (매장별)
business = db.query(DailyClosing).filter(
DailyClosing.store_id == current_store.id,
DailyClosing.business_date == today,
DailyClosing.is_closed == False
).first()
if not business:
raise HTTPException(status_code=404, detail="개점된 영업일이 없습니다.")
# 매장 설정 조회
settings = db.query(StoreSettings).filter(StoreSettings.store_id == current_store.id).first()
# 마감 시 대기 중인 고객 자동 처리
if settings and settings.auto_closing:
pending_waitings = db.query(WaitingList).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == today,
WaitingList.status == 'waiting'
).all()
print(f"[CLOSING] Processing {len(pending_waitings)} waiting users for store {current_store.id}")
for waiting in pending_waitings:
if settings.closing_action == 'attended':
# 출석 처리
waiting.status = 'attended'
waiting.attended_at = datetime.now()
print(f"[CLOSING] Marked waiting #{waiting.waiting_number} as attended")
else:
# 취소 처리
waiting.status = 'cancelled'
waiting.cancelled_at = datetime.now()
print(f"[CLOSING] Marked waiting #{waiting.waiting_number} as cancelled")
# 통계 계산 (매장별) - 처리 후 다시 계산
total_waiting = db.query(func.count(WaitingList.id)).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == today
).scalar()
total_attended = db.query(func.count(WaitingList.id)).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == today,
WaitingList.status == "attended"
).scalar()
total_cancelled = db.query(func.count(WaitingList.id)).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == today,
WaitingList.status.in_(["cancelled", "no_show"])
).scalar()
# 마감 처리
business.closing_time = datetime.now()
business.is_closed = True
business.total_waiting = total_waiting
business.total_attended = total_attended
business.total_cancelled = total_cancelled
db.commit()
db.refresh(business)
return business
@router.get("/current", response_model=DailyClosingSchema)
async def get_current_business(
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""현재 영업일 조회"""
today = get_current_business_date(db, current_store.id)
business = db.query(DailyClosing).filter(
DailyClosing.store_id == current_store.id,
DailyClosing.business_date == today
).first()
if not business:
raise HTTPException(status_code=404, detail="영업일 정보가 없습니다. 개점을 진행해주세요.")
return business
@router.get("/history", response_model=List[DailyClosingSchema])
async def get_business_history(
limit: int = 30,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""영업일 이력 조회"""
history = db.query(DailyClosing).filter(
DailyClosing.store_id == current_store.id
).order_by(
DailyClosing.business_date.desc()
).limit(limit).all()
return history
@router.get("/statistics/{business_date}", response_model=DailyStatistics)
async def get_daily_statistics(
business_date: date,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""특정 날짜 통계 조회"""
business = db.query(DailyClosing).filter(
DailyClosing.store_id == current_store.id,
DailyClosing.business_date == business_date
).first()
if not business:
raise HTTPException(status_code=404, detail="해당 날짜의 영업 정보가 없습니다.")
# 노쇼 수 계산 (매장별)
total_no_show = db.query(func.count(WaitingList.id)).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == business_date,
WaitingList.status == "no_show"
).scalar()
# 출석률 계산
attendance_rate = (business.total_attended / business.total_waiting * 100) if business.total_waiting > 0 else 0
# 클래스별 통계 (매장별)
class_stats = []
classes = db.query(ClassInfo).filter(
ClassInfo.store_id == current_store.id,
ClassInfo.is_active == True
).all()
for cls in classes:
cls_total = db.query(func.count(WaitingList.id)).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == business_date,
WaitingList.class_id == cls.id
).scalar()
cls_attended = db.query(func.count(WaitingList.id)).filter(
WaitingList.store_id == current_store.id,
WaitingList.business_date == business_date,
WaitingList.class_id == cls.id,
WaitingList.status == "attended"
).scalar()
class_stats.append({
"class_name": cls.class_name,
"total": cls_total,
"attended": cls_attended,
"attendance_rate": (cls_attended / cls_total * 100) if cls_total > 0 else 0
})
return DailyStatistics(
business_date=business_date,
total_waiting=business.total_waiting,
total_attended=business.total_attended,
total_cancelled=business.total_cancelled,
total_no_show=total_no_show,
attendance_rate=round(attendance_rate, 2),
class_statistics=class_stats
)
@router.get("/check-status")
async def check_business_status(
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""
영업 상태 확인
- 개점 여부 체크
- 자동 개점 필요 여부 반환
"""
today = get_current_business_date(db, current_store.id)
business = db.query(DailyClosing).filter(
DailyClosing.store_id == current_store.id,
DailyClosing.business_date == today
).first()
if not business:
return {
"is_open": False,
"need_open": True,
"message": "영업을 시작해주세요."
}
if business.is_closed:
return {
"is_open": False,
"need_open": True,
"message": "마감된 영업일입니다. 새로운 날짜로 개점해주세요."
}
return {
"is_open": True,
"need_open": False,
"business_date": business.business_date,
"opening_time": business.opening_time
}

211
routers/franchise.py Normal file
View File

@@ -0,0 +1,211 @@
"""
프랜차이즈 관리 라우터
- 프랜차이즈 정보 조회
- 프랜차이즈 수정
- 프랜차이즈 전체 통계
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, date
from database import get_db
from models import Franchise, Store, User, WaitingList, DailyClosing, Member
from schemas import Franchise as FranchiseSchema, FranchiseUpdate
from auth import get_current_user, require_franchise_admin
router = APIRouter()
@router.get("/", response_model=FranchiseSchema)
async def get_franchise(
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 정보 조회
Returns:
Franchise: 프랜차이즈 정보
"""
franchise = db.query(Franchise).filter(
Franchise.id == current_user.franchise_id
).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
return franchise
@router.put("/{franchise_id}", response_model=FranchiseSchema)
async def update_franchise(
franchise_id: int,
franchise_update: FranchiseUpdate,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 정보 수정
Args:
franchise_id: 프랜차이즈 ID
franchise_update: 수정할 프랜차이즈 정보
Returns:
Franchise: 수정된 프랜차이즈 정보
"""
# 권한 체크: 자신의 프랜차이즈만 수정 가능
if current_user.franchise_id != franchise_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="다른 프랜차이즈를 수정할 권한이 없습니다"
)
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 수정
update_data = franchise_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(franchise, key, value)
franchise.updated_at = datetime.now()
db.commit()
db.refresh(franchise)
return franchise
@router.get("/stats")
async def get_franchise_stats(
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 전체 통계 조회
Returns:
dict: 프랜차이즈 전체 통계 정보
"""
franchise_id = current_user.franchise_id
# 매장 수
total_stores = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise_id,
Store.is_active == True
).scalar()
# 활성 매장 수
active_stores = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise_id,
Store.is_active == True
).scalar()
# 오늘 날짜
today = date.today()
# 프랜차이즈 전체 매장의 오늘 통계 Query
query = db.query(Store).filter(
Store.franchise_id == franchise_id,
Store.is_active == True
)
# franchise_manager인 경우 관리 매장만 필터링
if current_user.role == 'franchise_manager':
managed_ids = [s.id for s in current_user.managed_stores]
if not managed_ids:
# 관리 매장이 없는 경우 빈 결과 반환
return {
'franchise_id': franchise_id,
'total_stores': 0,
'active_stores': 0,
'total_users': 0, # Note: This might need adjustment if users are also scoped
'total_members': 0,
'today_stats': {
'total_waiting': 0,
'total_attended': 0,
'total_cancelled': 0
},
'current_waiting': 0,
'stores': []
}
query = query.filter(Store.id.in_(managed_ids))
stores = query.all()
store_ids = [store.id for store in stores]
# 오늘의 대기 통계 (모든 매장 합계)
today_stats = db.query(
func.coalesce(func.sum(DailyClosing.total_waiting), 0).label('total_waiting'),
func.coalesce(func.sum(DailyClosing.total_attended), 0).label('total_attended'),
func.coalesce(func.sum(DailyClosing.total_cancelled), 0).label('total_cancelled')
).filter(
DailyClosing.store_id.in_(store_ids),
DailyClosing.business_date == today
).first()
# 현재 대기 중인 고객 수 (모든 매장 합계)
current_waiting = db.query(func.count(WaitingList.id)).filter(
WaitingList.store_id.in_(store_ids),
WaitingList.status == 'waiting'
).scalar()
# 총 사용자 수
total_users = db.query(func.count(User.id)).filter(
User.franchise_id == franchise_id
).scalar()
# 총 회원 수 (모든 매장 합계)
total_members = db.query(func.count(Member.id)).filter(
Member.store_id.in_(store_ids)
).scalar() if store_ids else 0
# 매장별 간단한 통계
store_stats = []
for store in stores:
# 매장의 오늘 통계
store_today = db.query(DailyClosing).filter(
DailyClosing.store_id == store.id,
DailyClosing.business_date == today
).first()
# 매장의 현재 대기 수
store_waiting = db.query(func.count(WaitingList.id)).filter(
WaitingList.store_id == store.id,
WaitingList.status == 'waiting'
).scalar()
store_stats.append({
'store_id': store.id,
'store_name': store.name,
'store_code': store.code,
'current_waiting': store_waiting,
'today_total': store_today.total_waiting if store_today else 0,
'today_attended': store_today.total_attended if store_today else 0,
'today_cancelled': store_today.total_cancelled if store_today else 0,
'is_open': store_today.is_closed == False if store_today else False
})
return {
'franchise_id': franchise_id,
'total_stores': total_stores,
'active_stores': active_stores,
'total_users': total_users,
'total_members': total_members,
'today_stats': {
'total_waiting': today_stats.total_waiting if today_stats else 0,
'total_attended': today_stats.total_attended if today_stats else 0,
'total_cancelled': today_stats.total_cancelled if today_stats else 0
},
'current_waiting': current_waiting,
'stores': store_stats
}

70
routers/logs.py Normal file
View File

@@ -0,0 +1,70 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from typing import List, Optional
import os
import json
router = APIRouter(prefix="/logs", tags=["System Logs"])
templates = Jinja2Templates(directory="templates")
LOG_FILE_PATH = "logs/system.json.log"
@router.get("/view", response_class=HTMLResponse)
async def view_logs_page(request: Request):
"""
Log Analysis Dashboard Page (UI)
"""
return templates.TemplateResponse("log_viewer.html", {"request": request})
@router.get("/api")
async def get_logs_api(
limit: int = 100,
level: Optional[str] = None,
keyword: Optional[str] = None
):
"""
API to fetch parsed logs from system.json.log
"""
if not os.path.exists(LOG_FILE_PATH):
return {"logs": []}
logs = []
# Read file in reverse is tricky with JSON lines, so read all and filter (for now)
# Optimization: Read file backwards or use `tail`.
# Since it's local file system, reading lines is okay for < 10MB.
try:
with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Parse and Filter
for line in reversed(lines): # Show newest first
try:
if not line.strip(): continue
log_entry = json.loads(line)
# Filter by Level
if level and log_entry.get("level") != level.upper():
continue
# Filter by Keyword
if keyword:
# Search in message or other fields
search_blobs = str(log_entry.values()).lower()
if keyword.lower() not in search_blobs:
continue
logs.append(log_entry)
if len(logs) >= limit:
break
except json.JSONDecodeError:
continue
return {"logs": logs}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

462
routers/members.py Normal file
View File

@@ -0,0 +1,462 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import List, Optional
import openpyxl
from io import BytesIO
from database import get_db
from models import Member, Store, WaitingList
from schemas import (
Member as MemberSchema,
MemberCreate,
MemberUpdate,
MemberBulkCreate
)
from auth import get_current_store
from sse_manager import sse_manager
router = APIRouter()
def check_member_uniqueness(db: Session, store: Store, phone: str = None, barcode: str = None, exclude_member_id: int = None):
"""
회원 중복 체크 로직
- Barcode: 전역적 유일성 체크
- Phone: 매장/프랜차이즈 범위 내 중복 체크
Returns: (conflict_type: str|None, existing_member: Member|None)
conflict_type: 'barcode' or 'phone'
"""
# 1. Barcode Check (Global Uniqueness)
if barcode:
query = db.query(Member).filter(Member.barcode == barcode)
if exclude_member_id:
query = query.filter(Member.id != exclude_member_id)
existing = query.first()
if existing:
return "barcode", existing
# 2. Phone Check (Scoped Uniqueness)
if phone:
# 프랜차이즈 설정 확인 (기본값 store)
member_type = "store"
if store.franchise:
member_type = store.franchise.member_type
query = db.query(Member)
if member_type == "franchise":
# 프랜차이즈 내 모든 매장 검색
store_ids = db.query(Store.id).filter(Store.franchise_id == store.franchise_id).all()
store_ids = [s[0] for s in store_ids]
query = query.filter(Member.store_id.in_(store_ids))
else:
# 매장 내 검색
query = query.filter(Member.store_id == store.id)
# 핸드폰 번호 체크
query = query.filter(Member.phone == phone)
# 수정 시 자기 자신 제외
if exclude_member_id:
query = query.filter(Member.id != exclude_member_id)
existing = query.first()
if existing:
return "phone", existing
return None, None
@router.post("/", response_model=MemberSchema)
async def create_member(
member: MemberCreate,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""회원 등록"""
# 중복 확인
conflict_type, existing = check_member_uniqueness(db, current_store, phone=member.phone, barcode=member.barcode)
if existing:
if conflict_type == "barcode":
raise HTTPException(status_code=400, detail="이미 등록된 바코드입니다.")
else:
msg = "이미 등록된 핸드폰번호입니다."
if current_store.franchise and current_store.franchise.member_type == "franchise":
msg += " (프랜차이즈 통합 관리)"
raise HTTPException(status_code=400, detail=msg)
db_member = Member(**member.dict(), store_id=current_store.id)
db.add(db_member)
db.commit()
db.refresh(db_member)
# 대기 목록 동기화: 핸드폰 번호로 대기 중인 항목 찾아 member_id 연결
active_waitings = db.query(WaitingList).filter(
WaitingList.phone == db_member.phone,
WaitingList.status == "waiting",
WaitingList.store_id == current_store.id
).all()
for w in active_waitings:
w.member_id = db_member.id
# w.name = db_member.name # 이름도 동기화 (선택적)
if active_waitings:
db.commit()
# SSE 브로드캐스트: 회원 정보 업데이트 알림 (대기 목록 실시간 반영용)
await sse_manager.broadcast(
store_id=str(current_store.id),
event_type="member_updated",
data={
"member_id": db_member.id,
"name": db_member.name,
"phone": db_member.phone
},
franchise_id=str(current_store.franchise_id) if current_store.franchise_id else None
)
return db_member
@router.get("/", response_model=List[MemberSchema])
async def get_members(
skip: int = 0,
limit: int = 100,
search: Optional[str] = None,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""회원 목록 조회"""
query = db.query(Member).filter(Member.store_id == current_store.id)
# 검색 조건 (이름 또는 핸드폰번호)
# 검색 조건 (이름 또는 핸드폰번호)
if search:
# 검색어가 4자리 숫자인 경우: 핸드폰 뒷자리 검색으로 간주하여 endswith 사용
if search.isdigit() and len(search) == 4:
query = query.filter(
or_(
Member.name.contains(search),
Member.phone.endswith(search),
Member.barcode == search
)
)
# 그 외의 경우: 포함 여부로 검색 (+바코드 정확 일치)
else:
query = query.filter(
or_(
Member.name.contains(search),
Member.phone.contains(search),
Member.barcode == search
)
)
members = query.offset(skip).limit(limit).all()
return members
@router.get("/{member_id}", response_model=MemberSchema)
async def get_member(
member_id: int,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""회원 상세 조회"""
member = db.query(Member).filter(
Member.id == member_id,
Member.store_id == current_store.id
).first()
if not member:
raise HTTPException(status_code=404, detail="회원을 찾을 수 없습니다.")
return member
@router.get("/phone/{phone}", response_model=MemberSchema)
async def get_member_by_phone(
phone: str,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""핸드폰번호로 회원 조회"""
member = db.query(Member).filter(
Member.phone == phone,
Member.store_id == current_store.id
).first()
if not member:
raise HTTPException(status_code=404, detail="회원을 찾을 수 없습니다.")
return member
@router.put("/{member_id}", response_model=MemberSchema)
async def update_member(
member_id: int,
member: MemberUpdate,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""회원 정보 수정"""
db_member = db.query(Member).filter(
Member.id == member_id,
Member.store_id == current_store.id
).first()
if not db_member:
raise HTTPException(status_code=404, detail="회원을 찾을 수 없습니다.")
# 핸드폰번호/바코드 변경 시 중복 체크
check_phone = member.phone if (member.phone and member.phone != db_member.phone) else None
check_barcode = member.barcode if (member.barcode and member.barcode != db_member.barcode) else None
if check_phone or check_barcode:
conflict_type, existing = check_member_uniqueness(db, current_store, phone=check_phone, barcode=check_barcode, exclude_member_id=member_id)
if existing:
if conflict_type == "barcode":
raise HTTPException(status_code=400, detail="이미 등록된 바코드입니다.")
else:
msg = "이미 등록된 핸드폰번호입니다."
if current_store.franchise and current_store.franchise.member_type == "franchise":
msg += " (프랜차이즈 통합 관리)"
raise HTTPException(status_code=400, detail=msg)
# 업데이트할 필드만 수정
update_data = member.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(db_member, field, value)
db.commit()
db.refresh(db_member)
# SSE 브로드캐스트: 회원 정보 업데이트 알림 (대기 목록 실시간 반영용)
await sse_manager.broadcast(
store_id=str(current_store.id),
event_type="member_updated",
data={
"member_id": db_member.id,
"name": db_member.name,
"phone": db_member.phone
},
franchise_id=str(current_store.franchise_id) if current_store.franchise_id else None
)
# 핸드폰 번호가 변경되었거나, 기존 대기 내역에 member_id가 없는 경우 동기화
# (단순 이름 변경 시에도 기존 waiting list에 member_id가 연결되어 있어야 함)
active_waitings = db.query(WaitingList).filter(
WaitingList.phone == db_member.phone,
WaitingList.status == "waiting",
WaitingList.store_id == current_store.id
).all()
for w in active_waitings:
if w.member_id != db_member.id:
w.member_id = db_member.id
if active_waitings:
db.commit()
return db_member
@router.delete("/{member_id}")
async def delete_member(
member_id: int,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""회원 삭제"""
db_member = db.query(Member).filter(
Member.id == member_id,
Member.store_id == current_store.id
).first()
if not db_member:
raise HTTPException(status_code=404, detail="회원을 찾을 수 없습니다.")
db.delete(db_member)
db.commit()
return {"message": "회원이 삭제되었습니다."}
@router.post("/bulk")
async def bulk_create_members(
members: MemberBulkCreate,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""회원 일괄 등록"""
success_count = 0
error_count = 0
errors = []
processed_phones = set()
for member_data in members.members:
try:
# 배치 내 중복 확인
if member_data.phone in processed_phones:
error_count += 1
errors.append({
"name": member_data.name,
"phone": member_data.phone,
"error": "목록 내 중복된 핸드폰번호"
})
continue
# DB 중복 확인
conflict_type, existing = check_member_uniqueness(db, current_store, phone=member_data.phone, barcode=member_data.barcode)
if existing:
error_count += 1
if conflict_type == "barcode":
msg = "이미 등록된 바코드"
else:
msg = "이미 등록된 핸드폰번호"
if current_store.franchise and current_store.franchise.member_type == "franchise":
msg += " (프랜차이즈 통합)"
errors.append({
"name": member_data.name,
"phone": member_data.phone,
"error": msg
})
continue
# 회원 등록
db_member = Member(**member_data.dict(), store_id=current_store.id)
db.add(db_member)
processed_phones.add(member_data.phone)
success_count += 1
except Exception as e:
error_count += 1
errors.append({
"name": member_data.name,
"phone": member_data.phone,
"error": str(e)
})
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=f"저장 중 오류가 발생했습니다: {str(e)}")
return {
"message": f"{success_count}명 등록, {error_count}명 실패",
"success_count": success_count,
"error_count": error_count,
"errors": errors
}
@router.post("/upload-excel")
async def upload_excel(
file: UploadFile = File(...),
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""
엑셀 파일 업로드 및 검수
- 엑셀 파일을 읽어서 회원 데이터 추출
- 유효성 검사 후 등록 가능한 목록 반환
"""
if not file.filename.endswith(('.xlsx', '.xls')):
raise HTTPException(status_code=400, detail="엑셀 파일만 업로드 가능합니다.")
try:
# 엑셀 파일 읽기
contents = await file.read()
workbook = openpyxl.load_workbook(BytesIO(contents))
sheet = workbook.active
valid_members = []
invalid_members = []
processed_phones = set()
# 첫 번째 행은 헤더로 간주하고 스킵
for row_idx, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
if not row or len(row) < 2:
continue
name = str(row[0]).strip() if row[0] else ""
# 전화번호는 숫자로 읽혀서 변환되는 경우 처리
phone_raw_value = row[1]
if phone_raw_value is None:
phone_raw = ""
elif isinstance(phone_raw_value, (int, float)):
# 숫자로 읽힌 경우 (예: 10이 "010-"로 입력된 경우)
phone_raw = str(int(phone_raw_value))
# 10, 100, 1000 등의 숫자는 010으로 시작하는 것으로 간주하고 앞에 0을 붙임
if len(phone_raw) < 11 and phone_raw.startswith('10'):
phone_raw = '0' + phone_raw
else:
phone_raw = str(phone_raw_value).strip()
# 하이픈 제거 (010-0000-0000 형식도 허용)
phone = phone_raw.replace('-', '').replace(' ', '')
# 유효성 검사
errors = []
if not name:
errors.append("이름 없음")
if not phone_raw:
errors.append("핸드폰번호 없음")
elif not phone.startswith("010") or len(phone) != 11 or not phone.isdigit():
errors.append("핸드폰번호 형식 오류 (010-0000-0000 또는 01000000000)")
# 바코드 읽기 (3번째 열, 옵션)
barcode = None
if len(row) > 2 and row[2]:
barcode = str(row[2]).strip()
# 중복 확인 (매장별)
if phone:
if phone in processed_phones:
errors.append("파일 내 중복된 번호")
else:
conflict_type, existing = check_member_uniqueness(db, current_store, phone=phone, barcode=barcode)
if existing:
if conflict_type == "barcode":
errors.append("이미 등록된 바코드")
else:
msg = "이미 등록된 번호"
if current_store.franchise and current_store.franchise.member_type == "franchise":
msg += " (프랜차이즈 통합)"
errors.append(msg)
else:
processed_phones.add(phone)
if errors:
invalid_members.append({
"row": row_idx,
"name": name,
"phone": phone,
"errors": errors
})
else:
valid_members.append({
"name": name,
"phone": phone
})
return {
"total_count": len(valid_members) + len(invalid_members),
"valid_count": len(valid_members),
"invalid_count": len(invalid_members),
"valid_members": valid_members,
"invalid_members": invalid_members
}
except Exception as e:
raise HTTPException(status_code=400, detail=f"엑셀 파일 처리 중 오류: {str(e)}")
@router.post("/confirm-excel")
async def confirm_excel_upload(
members: MemberBulkCreate,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""
엑셀 검수 후 최종 등록
"""
return await bulk_create_members(members, current_store, db)

38
routers/sse.py Normal file
View File

@@ -0,0 +1,38 @@
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from sse_manager import sse_manager, event_generator
router = APIRouter()
@router.get("/stream")
async def sse_stream(store_id: str):
"""
SSE 스트림 엔드포인트
- 매장별로 실시간 이벤트를 수신
- 클라이언트는 EventSource로 연결
- Query parameter: store_id
"""
# 새로운 연결 등록
print(f"[SSE] Connection Request: store_id={store_id}")
queue = await sse_manager.connect(store_id)
async def cleanup():
"""연결 종료 시 정리"""
sse_manager.disconnect(store_id, queue)
# SSE 응답 생성
response = StreamingResponse(
event_generator(queue),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Nginx 버퍼링 비활성화
}
)
# 연결 종료 시 cleanup 호출하도록 설정
response.background = cleanup
return response

697
routers/statistics.py Normal file
View File

@@ -0,0 +1,697 @@
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
]

235
routers/store_settings.py Normal file
View File

@@ -0,0 +1,235 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Optional
from database import get_db
from models import StoreSettings, Store, User
from schemas import (
StoreSettings as StoreSettingsSchema,
StoreSettingsCreate,
StoreSettingsUpdate
)
from auth import get_current_user, get_current_store
router = APIRouter()
@router.post("/", response_model=StoreSettingsSchema)
async def create_store_settings(
settings: StoreSettingsCreate,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""매장 설정 생성"""
# 기존 설정이 있는지 확인 (매장별)
existing = db.query(StoreSettings).filter(
StoreSettings.store_id == current_store.id
).first()
if existing:
raise HTTPException(status_code=400, detail="매장 설정이 이미 존재합니다.")
db_settings = StoreSettings(**settings.dict(), store_id=current_store.id)
db.add(db_settings)
db.commit()
db.refresh(db_settings)
return db_settings
@router.get("/", response_model=StoreSettingsSchema)
async def get_store_settings(
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""매장 설정 조회"""
settings = db.query(StoreSettings).filter(
StoreSettings.store_id == current_store.id
).first()
if not settings:
# 기본 설정 생성
default_settings = StoreSettings(
store_id=current_store.id,
store_name=current_store.name,
display_classes_count=3,
list_direction="vertical",
rows_per_class=1,
admin_password="1234",
max_waiting_limit=50,
use_max_waiting_limit=True,
block_last_class_registration=False,
show_waiting_number=True,
mask_customer_name=False,
show_order_number=True,
board_display_order="number,name,order",
attendance_count_type="days",
attendance_lookback_days=30
)
db.add(default_settings)
db.commit()
db.refresh(default_settings)
return default_settings
return settings
@router.put("/", response_model=StoreSettingsSchema)
async def update_store_settings(
settings: StoreSettingsUpdate,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""매장 설정 수정"""
db_settings = db.query(StoreSettings).filter(
StoreSettings.store_id == current_store.id
).first()
if not db_settings:
raise HTTPException(status_code=404, detail="매장 설정을 찾을 수 없습니다.")
# 업데이트할 필드만 수정
update_data = settings.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(db_settings, field, value)
db.commit()
db.refresh(db_settings)
return db_settings
@router.post("/verify-password")
async def verify_password(
password: str,
current_store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""관리자 비밀번호 확인"""
settings = db.query(StoreSettings).filter(
StoreSettings.store_id == current_store.id
).first()
if not settings:
raise HTTPException(status_code=404, detail="매장 설정을 찾을 수 없습니다.")
if settings.admin_password != password:
raise HTTPException(status_code=401, detail="비밀번호가 일치하지 않습니다.")
return {"message": "인증 성공", "verified": True}
@router.post("/clone/{source_store_id}", response_model=StoreSettingsSchema)
async def clone_store_settings(
source_store_id: int,
current_store: Store = Depends(get_current_store),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""다른 매장의 설정 복제
Args:
source_store_id: 복제할 원본 매장의 ID
Returns:
복제된 현재 매장의 설정
"""
# 원본 매장 조회
source_store = db.query(Store).filter(Store.id == source_store_id).first()
if not source_store:
raise HTTPException(status_code=404, detail="원본 매장을 찾을 수 없습니다.")
# 같은 프랜차이즈 소속인지 확인
if source_store.franchise_id != current_store.franchise_id:
raise HTTPException(
status_code=403,
detail="같은 프랜차이즈 소속 매장만 복제할 수 있습니다."
)
# 자기 자신을 복제하려는 경우
if source_store_id == current_store.id:
raise HTTPException(
status_code=400,
detail="같은 매장의 설정은 복제할 수 없습니다."
)
# 원본 매장의 설정 조회
source_settings = db.query(StoreSettings).filter(
StoreSettings.store_id == source_store_id
).first()
if not source_settings:
raise HTTPException(
status_code=404,
detail="원본 매장의 설정을 찾을 수 없습니다."
)
# 현재 매장의 기존 설정 조회
target_settings = db.query(StoreSettings).filter(
StoreSettings.store_id == current_store.id
).first()
# 복제할 데이터 준비 (store_id, id 제외)
settings_data = {
"store_name": current_store.name, # 현재 매장 이름 유지
"display_classes_count": source_settings.display_classes_count,
"list_direction": source_settings.list_direction,
"rows_per_class": source_settings.rows_per_class,
"admin_password": source_settings.admin_password,
"max_waiting_limit": source_settings.max_waiting_limit,
"use_max_waiting_limit": source_settings.use_max_waiting_limit,
"block_last_class_registration": source_settings.block_last_class_registration,
"auto_register_member": source_settings.auto_register_member,
"business_day_start": source_settings.business_day_start,
"auto_closing": source_settings.auto_closing,
"closing_action": source_settings.closing_action,
# 대기현황판 표시 설정
"show_waiting_number": source_settings.show_waiting_number,
"mask_customer_name": source_settings.mask_customer_name,
"name_display_length": source_settings.name_display_length,
"show_order_number": source_settings.show_order_number,
"board_display_order": source_settings.board_display_order
}
if target_settings:
# 기존 설정이 있으면 업데이트
for field, value in settings_data.items():
setattr(target_settings, field, value)
else:
# 기존 설정이 없으면 새로 생성
new_settings = StoreSettings(
store_id=current_store.id,
**settings_data
)
db.add(new_settings)
# 클래스 정보 복제
from models import ClassInfo
# 1. 기존 클래스 삭제
db.query(ClassInfo).filter(ClassInfo.store_id == current_store.id).delete()
# 2. 원본 매장의 클래스 조회
source_classes = db.query(ClassInfo).filter(ClassInfo.store_id == source_store_id).all()
# 3. 클래스 복사
for source_class in source_classes:
new_class = ClassInfo(
store_id=current_store.id,
class_number=source_class.class_number,
class_name=source_class.class_name,
start_time=source_class.start_time,
end_time=source_class.end_time,
max_capacity=source_class.max_capacity,
is_active=source_class.is_active,
weekday_schedule=source_class.weekday_schedule,
class_type=source_class.class_type
)
db.add(new_class)
db.commit()
if target_settings:
db.refresh(target_settings)
return target_settings
else:
db.refresh(new_settings)
return new_settings

476
routers/stores.py Normal file
View File

@@ -0,0 +1,476 @@
"""
매장 관리 라우터
- 매장 목록 조회
- 매장 생성
- 매장 상세 조회
- 매장 수정
- 매장 비활성화
- 매장별 통계
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, date
from typing import List
from database import get_db
from models import Store, User, WaitingList, DailyClosing, StoreSettings, Member, ClassInfo
from schemas import (
Store as StoreSchema,
StoreCreate,
StoreUpdate
)
from auth import get_current_user, require_franchise_admin
router = APIRouter()
@router.get("/", response_model=List[StoreSchema])
async def get_stores(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""매장 목록 조회
프랜차이즈 관리자와 매장 관리자 모두 접근 가능
각자 자신의 프랜차이즈 매장 목록만 조회 가능
Returns:
List[Store]: 프랜차이즈의 모든 매장 목록
"""
# system_admin은 모든 매장 조회 가능 (필터 없음)
if current_user.role == 'system_admin':
stores = db.query(Store).order_by(Store.created_at.desc()).all()
# franchise_manager는 관리하는 매장만 조회
elif current_user.role == 'franchise_manager':
if not current_user.managed_stores:
return []
managed_ids = [s.id for s in current_user.managed_stores]
stores = db.query(Store).filter(
Store.id.in_(managed_ids)
).order_by(Store.created_at.desc()).all()
# franchise_admin과 store_admin은 자신의 프랜차이즈 매장만 조회
elif current_user.role in ['franchise_admin', 'store_admin']:
if not current_user.franchise_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="프랜차이즈 정보가 없습니다"
)
stores = db.query(Store).filter(
Store.franchise_id == current_user.franchise_id
).order_by(Store.created_at.desc()).all()
else:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="매장 목록 조회 권한이 없습니다"
)
return stores
@router.post("/", response_model=StoreSchema, status_code=status.HTTP_201_CREATED)
async def create_store(
store_create: StoreCreate,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""매장 생성
프랜차이즈 관리자만 접근 가능
Args:
store_create: 생성할 매장 정보
Returns:
Store: 생성된 매장 정보
"""
# 매장 코드 자동 생성
from models import Franchise
franchise = db.query(Franchise).filter(Franchise.id == current_user.franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 프랜차이즈 코드의 첫 글자 가져오기
prefix = franchise.code[0] if franchise.code else "S"
# 해당 프랜차이즈의 기존 매장 중 같은 prefix를 가진 매장 코드에서 가장 큰 번호 찾기
stores = db.query(Store).filter(
Store.franchise_id == current_user.franchise_id
).all()
max_number = 0
for store in stores:
if store.code.startswith(prefix) and len(store.code) > 1:
try:
number = int(store.code[1:])
if number > max_number:
max_number = number
except ValueError:
continue
# 새로운 매장 코드 생성 (예: S001, S002, S003...)
new_code = f"{prefix}{str(max_number + 1).zfill(3)}"
# 매장 생성
new_store = Store(
franchise_id=current_user.franchise_id,
name=store_create.name,
code=new_code,
is_active=True
)
db.add(new_store)
db.commit()
db.refresh(new_store)
# 기본 매장 설정 생성
default_settings = StoreSettings(
store_id=new_store.id,
store_name=store_create.name,
display_classes_count=3,
list_direction="vertical",
rows_per_class=1,
admin_password="1234",
max_waiting_limit=50,
block_last_class_registration=False,
show_waiting_number=True,
mask_customer_name=False,
show_order_number=True,
board_display_order="number,name,order"
)
db.add(default_settings)
db.commit()
return new_store
@router.get("/{store_id}", response_model=StoreSchema)
async def get_store(
store_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""매장 상세 조회
Args:
store_id: 매장 ID
Returns:
Store: 매장 상세 정보
"""
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
# 권한 확인
if current_user.role == 'franchise_admin':
if current_user.franchise_id != store.franchise_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="접근 권한이 없습니다"
)
elif current_user.role == 'franchise_manager':
managed_ids = [s.id for s in current_user.managed_stores]
if store_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="접근 권한이 없습니다"
)
elif current_user.role == 'store_admin':
if current_user.store_id != store_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="접근 권한이 없습니다"
)
elif current_user.role != 'system_admin':
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="접근 권한이 없습니다"
)
return store
@router.put("/{store_id}", response_model=StoreSchema)
async def update_store(
store_id: int,
store_update: StoreUpdate,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""매장 정보 수정
Args:
store_id: 매장 ID
store_update: 수정할 매장 정보
Returns:
Store: 수정된 매장 정보
"""
store = db.query(Store).filter(
Store.id == store_id,
Store.franchise_id == current_user.franchise_id
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
# 매장 코드 변경 시 중복 체크
if store_update.code and store_update.code != store.code:
existing_store = db.query(Store).filter(Store.code == store_update.code).first()
if existing_store:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 매장 코드입니다"
)
# 수정
update_data = store_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(store, key, value)
store.updated_at = datetime.now()
db.commit()
db.refresh(store)
return store
@router.delete("/{store_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_store(
store_id: int,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""매장 비활성화
실제로 삭제하지 않고 is_active를 False로 변경
Args:
store_id: 매장 ID
"""
store = db.query(Store).filter(
Store.id == store_id,
Store.franchise_id == current_user.franchise_id
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
store.is_active = False
store.updated_at = datetime.now()
db.commit()
@router.post("/{store_id}/deactivate", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_store(
store_id: int,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""매장 비활성화
is_active를 False로 변경
Args:
store_id: 매장 ID
"""
store = db.query(Store).filter(
Store.id == store_id,
Store.franchise_id == current_user.franchise_id
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
store.is_active = False
store.updated_at = datetime.now()
db.commit()
@router.post("/{store_id}/activate", response_model=StoreSchema)
async def activate_store(
store_id: int,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""매장 활성화
is_active를 True로 변경
Args:
store_id: 매장 ID
Returns:
Store: 활성화된 매장 정보
"""
store = db.query(Store).filter(
Store.id == store_id,
Store.franchise_id == current_user.franchise_id
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
store.is_active = True
store.updated_at = datetime.now()
db.commit()
db.refresh(store)
return store
@router.get("/code/{store_code}", response_model=StoreSchema)
async def get_store_by_code(
store_code: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""매장 코드로 매장 조회
URL 파라미터로 매장을 선택할 수 있도록 매장 코드로 조회
모든 인증된 사용자가 접근 가능
Args:
store_code: 매장 코드 (예: S001, S002)
Returns:
Store: 매장 정보
"""
store = db.query(Store).filter(
Store.code == store_code,
Store.is_active == True
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"매장 코드 '{store_code}'를 찾을 수 없습니다"
)
return store
@router.get("/{store_id}/stats")
async def get_store_stats(
store_id: int,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""매장별 통계 조회
Args:
store_id: 매장 ID
Returns:
dict: 매장 통계 정보
"""
# 매장 존재 및 권한 확인
store = db.query(Store).filter(
Store.id == store_id,
Store.franchise_id == current_user.franchise_id
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
today = date.today()
# franchise_manager 권한 확인
if current_user.role == 'franchise_manager':
managed_ids = [s.id for s in current_user.managed_stores]
if store_id not in managed_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="해당 매장에 대한 접근 권한이 없습니다"
)
# 오늘의 대기 통계
today_stats = db.query(DailyClosing).filter(
DailyClosing.store_id == store_id,
DailyClosing.business_date == today
).first()
# 현재 대기 중인 고객 수
current_waiting = db.query(func.count(WaitingList.id)).filter(
WaitingList.store_id == store_id,
WaitingList.status == 'waiting'
).scalar()
# 총 회원 수
total_members = db.query(func.count(Member.id)).filter(
Member.store_id == store_id
).scalar()
# 운영 중인 수업 수
active_classes = db.query(func.count(ClassInfo.id)).filter(
ClassInfo.store_id == store_id,
ClassInfo.is_active == True
).scalar()
# 최근 7일 통계
from datetime import timedelta
week_ago = today - timedelta(days=7)
weekly_stats = db.query(
func.coalesce(func.sum(DailyClosing.total_waiting), 0).label('total_waiting'),
func.coalesce(func.sum(DailyClosing.total_attended), 0).label('total_attended'),
func.coalesce(func.sum(DailyClosing.total_cancelled), 0).label('total_cancelled')
).filter(
DailyClosing.store_id == store_id,
DailyClosing.business_date >= week_ago,
DailyClosing.business_date <= today
).first()
return {
'store_id': store_id,
'store_name': store.name,
'store_code': store.code,
'is_active': store.is_active,
'today': {
'total_waiting': today_stats.total_waiting if today_stats else 0,
'total_attended': today_stats.total_attended if today_stats else 0,
'total_cancelled': today_stats.total_cancelled if today_stats else 0,
'is_open': today_stats.is_closed == False if today_stats else False,
'opening_time': today_stats.opening_time if today_stats else None,
'closing_time': today_stats.closing_time if today_stats else None
},
'current_waiting': current_waiting,
'total_members': total_members,
'active_classes': active_classes,
'weekly': {
'total_waiting': weekly_stats.total_waiting if weekly_stats else 0,
'total_attended': weekly_stats.total_attended if weekly_stats else 0,
'total_cancelled': weekly_stats.total_cancelled if weekly_stats else 0
}
}

791
routers/system_admin.py Normal file
View File

@@ -0,0 +1,791 @@
"""
시스템 관리자 라우터
- 프랜차이즈 CRUD
- 프랜차이즈 관리자 생성
- 전체 시스템 통계
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from datetime import datetime
from typing import List
from database import get_db
from models import Franchise, Store, User, Member, DailyClosing
from schemas import (
Franchise as FranchiseSchema,
FranchiseCreate,
FranchiseUpdate,
User as UserSchema,
UserCreate,
UserUpdate,
Store as StoreSchema,
StoreCreate,
UserListResponse,
StoreListResponse,
MemberListResponse
)
from auth import require_system_admin, get_password_hash
router = APIRouter()
@router.get("/franchises", response_model=List[FranchiseSchema])
async def get_all_franchises(
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""모든 프랜차이즈 조회"""
franchises = db.query(Franchise).all()
return franchises
@router.get("/franchises/{franchise_id}", response_model=FranchiseSchema)
async def get_franchise(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈 조회"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
return franchise
@router.get("/franchises/{franchise_id}/stores", response_model=List[StoreSchema])
async def get_franchise_stores(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 매장 목록 조회"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
stores = db.query(Store).filter(Store.franchise_id == franchise_id).all()
return stores
@router.get("/franchises/{franchise_id}/users", response_model=List[UserSchema])
async def get_franchise_users(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 사용자 목록 조회"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
users = db.query(User).filter(User.franchise_id == franchise_id).all()
return users
@router.get("/franchises/{franchise_id}/stats")
async def get_franchise_stats(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 통계 조회"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
from datetime import date
# 매장 수
total_stores = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise_id
).scalar()
# 활성 매장 수
active_stores = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise_id,
Store.is_active == True
).scalar()
# 사용자 수
total_users = db.query(func.count(User.id)).filter(
User.franchise_id == franchise_id
).scalar()
# 오늘 날짜
today = date.today()
# 프랜차이즈 전체 매장의 오늘 통계
stores = db.query(Store).filter(
Store.franchise_id == franchise_id,
Store.is_active == True
).all()
store_ids = [store.id for store in stores]
# 총 회원 수 (모든 매장 합계)
total_members = db.query(func.count(Member.id)).filter(
Member.store_id.in_(store_ids)
).scalar() if store_ids else 0
return {
'franchise_id': franchise_id,
'total_stores': total_stores,
'active_stores': active_stores,
'total_users': total_users,
'total_members': total_members
}
@router.post("/franchises", response_model=FranchiseSchema, status_code=status.HTTP_201_CREATED)
async def create_franchise(
franchise: FranchiseCreate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""새 프랜차이즈 생성"""
# 코드 중복 체크
existing = db.query(Franchise).filter(Franchise.code == franchise.code).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"프랜차이즈 코드 '{franchise.code}'가 이미 존재합니다"
)
# 프랜차이즈 생성
new_franchise = Franchise(
name=franchise.name,
code=franchise.code,
is_active=True,
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(new_franchise)
db.commit()
db.refresh(new_franchise)
return new_franchise
@router.put("/franchises/{franchise_id}", response_model=FranchiseSchema)
async def update_franchise(
franchise_id: int,
franchise_update: FranchiseUpdate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 정보 수정"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 코드 중복 체크 (코드 변경 시)
if franchise_update.code and franchise_update.code != franchise.code:
existing = db.query(Franchise).filter(Franchise.code == franchise_update.code).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"프랜차이즈 코드 '{franchise_update.code}'가 이미 존재합니다"
)
# 수정
update_data = franchise_update.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(franchise, key, value)
franchise.updated_at = datetime.now()
db.commit()
db.refresh(franchise)
return franchise
@router.delete("/franchises/{franchise_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_franchise(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 삭제 (비활성화)"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 비활성화
franchise.is_active = False
franchise.updated_at = datetime.now()
db.commit()
return None
@router.post("/franchises/{franchise_id}/activate", response_model=FranchiseSchema)
async def activate_franchise(
franchise_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 활성화"""
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
franchise.is_active = True
franchise.updated_at = datetime.now()
db.commit()
db.refresh(franchise)
return franchise
@router.post("/franchises/{franchise_id}/admin", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_franchise_admin(
franchise_id: int,
user_create: UserCreate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""프랜차이즈 관리자 생성"""
# 프랜차이즈 존재 확인
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 사용자명 중복 확인
existing_user = db.query(User).filter(User.username == user_create.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"사용자명 '{user_create.username}'가 이미 존재합니다"
)
# 프랜차이즈 관리자만 생성 가능
if user_create.role != "franchise_admin":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이 엔드포인트는 프랜차이즈 관리자 생성 전용입니다"
)
# 사용자 생성
password_hash = get_password_hash(user_create.password)
new_user = User(
username=user_create.username,
password_hash=password_hash,
role="franchise_admin",
franchise_id=franchise_id,
store_id=None,
is_active=True,
created_at=datetime.now(),
updated_at=datetime.now()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.get("/stats")
async def get_system_stats(
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""전체 시스템 통계"""
# 프랜차이즈 수
total_franchises = db.query(func.count(Franchise.id)).scalar()
active_franchises = db.query(func.count(Franchise.id)).filter(
Franchise.is_active == True
).scalar()
# 매장 수
total_stores = db.query(func.count(Store.id)).scalar()
active_stores = db.query(func.count(Store.id)).filter(
Store.is_active == True
).scalar()
# 사용자 수
total_users = db.query(func.count(User.id)).scalar()
active_users = db.query(func.count(User.id)).filter(
User.is_active == True
).scalar()
# 회원 수
total_members = db.query(func.count(Member.id)).scalar()
# 프랜차이즈별 통계
franchises = db.query(Franchise).all()
franchise_stats = []
for franchise in franchises:
# 프랜차이즈의 매장 수
stores_count = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise.id
).scalar()
# 프랜차이즈의 활성 매장 수
active_stores_count = db.query(func.count(Store.id)).filter(
Store.franchise_id == franchise.id,
Store.is_active == True
).scalar()
# 프랜차이즈의 사용자 수
users_count = db.query(func.count(User.id)).filter(
User.franchise_id == franchise.id
).scalar()
# 프랜차이즈의 매장 ID 목록
store_ids = [s.id for s in db.query(Store.id).filter(
Store.franchise_id == franchise.id
).all()]
# 프랜차이즈의 회원 수
members_count = db.query(func.count(Member.id)).filter(
Member.store_id.in_(store_ids)
).scalar() if store_ids else 0
franchise_stats.append({
"franchise_id": franchise.id,
"franchise_name": franchise.name,
"franchise_code": franchise.code,
"is_active": franchise.is_active,
"stores_count": stores_count,
"active_stores_count": active_stores_count,
"users_count": users_count,
"members_count": members_count
})
return {
"total_franchises": total_franchises,
"active_franchises": active_franchises,
"total_stores": total_stores,
"active_stores": active_stores,
"total_users": total_users,
"active_users": active_users,
"total_members": total_members,
"franchises": franchise_stats
}
# ========== 매장 관리 (Superadmin) ==========
@router.post("/franchises/{franchise_id}/stores", response_model=StoreSchema, status_code=status.HTTP_201_CREATED)
async def create_store_for_franchise(
franchise_id: int,
store_create: StoreCreate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 매장 생성 (Superadmin 전용)"""
# 프랜차이즈 존재 확인
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 매장 코드 자동 생성
prefix = franchise.code[0] if franchise.code else "S"
# 해당 프랜차이즈의 기존 매장 중 같은 prefix를 가진 매장 코드에서 가장 큰 번호 찾기
stores = db.query(Store).filter(Store.franchise_id == franchise_id).all()
max_number = 0
for store in stores:
if store.code.startswith(prefix) and len(store.code) > 1:
try:
number = int(store.code[1:])
if number > max_number:
max_number = number
except ValueError:
continue
# 새로운 매장 코드 생성 (예: S001, S002, S003...)
new_code = f"{prefix}{str(max_number + 1).zfill(3)}"
# 매장 생성
new_store = Store(
franchise_id=franchise_id,
name=store_create.name,
code=new_code,
is_active=True
)
db.add(new_store)
db.commit()
db.refresh(new_store)
return new_store
@router.post("/stores/{store_id}/activate", response_model=StoreSchema)
async def activate_store(
store_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""매장 활성화 (Superadmin 전용)"""
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
store.is_active = True
db.commit()
db.refresh(store)
return store
@router.post("/stores/{store_id}/deactivate", response_model=StoreSchema)
async def deactivate_store(
store_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""매장 비활성화 (Superadmin 전용)"""
store = db.query(Store).filter(Store.id == store_id).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
store.is_active = False
db.commit()
db.refresh(store)
return store
# ========== 사용자 관리 (Superadmin) ==========
@router.post("/franchises/{franchise_id}/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user_for_franchise(
franchise_id: int,
user_create: UserCreate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""특정 프랜차이즈의 사용자 생성 (Superadmin 전용)"""
# 프랜차이즈 존재 확인
franchise = db.query(Franchise).filter(Franchise.id == franchise_id).first()
if not franchise:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="프랜차이즈를 찾을 수 없습니다"
)
# 사용자명 중복 확인
existing_user = db.query(User).filter(User.username == user_create.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 사용자명입니다"
)
# 역할 검증
if user_create.role not in ['franchise_admin', 'store_admin', 'franchise_manager']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="올바르지 않은 역할입니다."
)
# 매장 관리자인 경우 매장 ID 필수
if user_create.role == 'store_admin' and not user_create.store_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="매장 관리자는 매장 ID가 필요합니다"
)
# 매장 ID가 있는 경우 해당 매장이 프랜차이즈에 속하는지 확인
if user_create.store_id:
store = db.query(Store).filter(
Store.id == user_create.store_id,
Store.franchise_id == franchise_id
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없거나 해당 프랜차이즈에 속하지 않습니다"
)
# 사용자 생성
password_hash = get_password_hash(user_create.password)
new_user = User(
username=user_create.username,
password_hash=password_hash,
role=user_create.role,
franchise_id=franchise_id,
store_id=user_create.store_id,
is_active=True,
created_at=datetime.now(),
updated_at=datetime.now()
)
# 중간 관리자의 매장 권한 설정
if user_create.role == 'franchise_manager' and user_create.managed_store_ids:
stores = db.query(Store).filter(
Store.id.in_(user_create.managed_store_ids),
Store.franchise_id == franchise_id
).all()
if len(stores) != len(user_create.managed_store_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="일부 매장을 찾을 수 없거나 해당 프랜차이즈에 속하지 않습니다"
)
new_user.managed_stores = stores
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.put("/users/{user_id}", response_model=UserSchema)
async def update_user(
user_id: int,
user_update: UserUpdate,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""사용자 정보 수정 (Superadmin 전용)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
# 사용자명 변경 시 중복 확인
if user_update.username and user_update.username != user.username:
existing_user = db.query(User).filter(User.username == user_update.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 사용자명입니다"
)
# 역할 변경 시 검증
if user_update.role and user_update.role not in ['franchise_admin', 'store_admin', 'franchise_manager']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="올바르지 않은 역할입니다."
)
# 매장 ID 변경 시 검증
if user_update.store_id:
store = db.query(Store).filter(Store.id == user_update.store_id).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없습니다"
)
# 수정
update_data = user_update.dict(exclude_unset=True, exclude={'password', 'managed_store_ids'})
for key, value in update_data.items():
setattr(user, key, value)
# 중간 관리자의 매장 권한 수정
if user_update.role == 'franchise_manager' and user_update.managed_store_ids is not None:
stores = db.query(Store).filter(
Store.id.in_(user_update.managed_store_ids),
Store.franchise_id == user.franchise_id
).all()
user.managed_stores = stores
elif user_update.role != 'franchise_manager':
user.managed_stores = []
# 비밀번호 변경이 있는 경우
if user_update.password:
user.password_hash = get_password_hash(user_update.password)
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
return user
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def deactivate_user(
user_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""사용자 비활성화 (Superadmin 전용)"""
# 자기 자신은 비활성화할 수 없음
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="자기 자신을 비활성화할 수 없습니다"
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
user.is_active = False
user.updated_at = datetime.now()
db.commit()
return None
@router.post("/users/{user_id}/activate", response_model=UserSchema)
async def activate_user(
user_id: int,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""사용자 활성화 (Superadmin 전용)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
user.is_active = True
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
return user
@router.get("/users", response_model=List[UserListResponse])
async def get_all_users(
skip: int = 0,
limit: int = 1000,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""전체 사용자 조회 (System Admin)"""
users = db.query(User).options(
joinedload(User.franchise),
joinedload(User.store)
).offset(skip).limit(limit).all()
response = []
for user in users:
user_dict = UserListResponse.from_orm(user)
if user.franchise:
user_dict.franchise_name = user.franchise.name
if user.store:
user_dict.store_name = user.store.name
response.append(user_dict)
return response
@router.get("/stores", response_model=List[StoreListResponse])
async def get_all_stores(
skip: int = 0,
limit: int = 1000,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""전체 매장 조회 (System Admin)"""
stores = db.query(Store).options(
joinedload(Store.franchise)
).offset(skip).limit(limit).all()
response = []
for store in stores:
store_dict = StoreListResponse.from_orm(store)
if store.franchise:
store_dict.franchise_name = store.franchise.name
response.append(store_dict)
return response
@router.get("/members", response_model=List[MemberListResponse])
async def search_members(
q: str = None,
skip: int = 0,
limit: int = 100,
current_user: User = Depends(require_system_admin),
db: Session = Depends(get_db)
):
"""회원 검색 및 조회 (System Admin)"""
query = db.query(Member).options(
joinedload(Member.store).joinedload(Store.franchise)
)
if q:
query = query.filter(
(Member.name.ilike(f"%{q}%")) |
(Member.phone.ilike(f"%{q}%"))
)
members = query.order_by(Member.created_at.desc()).offset(skip).limit(limit).all()
response = []
for member in members:
member_dict = MemberListResponse.from_orm(member)
if member.store:
member_dict.store_name = member.store.name
if member.store.franchise:
member_dict.franchise_name = member.store.franchise.name
response.append(member_dict)
return response

309
routers/users.py Normal file
View File

@@ -0,0 +1,309 @@
"""
사용자 관리 라우터
- 사용자 목록 조회
- 사용자 생성
- 사용자 상세 조회
- 사용자 수정
- 사용자 비활성화
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from datetime import datetime
from typing import List
from database import get_db
from models import User, Store
from schemas import User as UserSchema, UserCreate, UserUpdate
from auth import get_current_user, require_franchise_admin, get_password_hash
router = APIRouter()
@router.get("/", response_model=List[UserSchema])
async def get_users(
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""사용자 목록 조회
프랜차이즈 관리자만 접근 가능
자신의 프랜차이즈에 속한 사용자만 조회
Returns:
List[User]: 프랜차이즈의 모든 사용자 목록
"""
users = db.query(User).filter(
User.franchise_id == current_user.franchise_id
).order_by(User.created_at.desc()).all()
return users
@router.post("/", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user(
user_create: UserCreate,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""사용자 생성
프랜차이즈 관리자만 접근 가능
Args:
user_create: 생성할 사용자 정보
Returns:
User: 생성된 사용자 정보
"""
# 사용자명 중복 체크
existing_user = db.query(User).filter(User.username == user_create.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 사용자명입니다"
)
# 역할 검증
if user_create.role not in ['franchise_admin', 'store_admin', 'franchise_manager']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="올바르지 않은 역할입니다."
)
# 매장 관리자인 경우 매장 ID 필수
if user_create.role == 'store_admin' and not user_create.store_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="매장 관리자는 매장 ID가 필요합니다"
)
# 매장 ID가 있는 경우 해당 매장이 자신의 프랜차이즈에 속하는지 확인
if user_create.store_id:
store = db.query(Store).filter(
Store.id == user_create.store_id,
Store.franchise_id == current_user.franchise_id
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없거나 접근 권한이 없습니다"
)
# 사용자 생성
password_hash = get_password_hash(user_create.password)
new_user = User(
username=user_create.username,
password_hash=password_hash,
role=user_create.role,
franchise_id=current_user.franchise_id,
store_id=user_create.store_id,
is_active=True
)
# 중간 관리자의 매장 권한 설정
if user_create.role == 'franchise_manager' and user_create.managed_store_ids:
stores = db.query(Store).filter(
Store.id.in_(user_create.managed_store_ids),
Store.franchise_id == current_user.franchise_id
).all()
if len(stores) != len(user_create.managed_store_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="일부 매장을 찾을 수 없거나 접근 권한이 없습니다"
)
new_user.managed_stores = stores
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.get("/{user_id}", response_model=UserSchema)
async def get_user(
user_id: int,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""사용자 상세 조회
Args:
user_id: 사용자 ID
Returns:
User: 사용자 상세 정보
"""
user = db.query(User).filter(
User.id == user_id,
User.franchise_id == current_user.franchise_id
).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
return user
@router.put("/{user_id}", response_model=UserSchema)
async def update_user(
user_id: int,
user_update: UserUpdate,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""사용자 정보 수정
Args:
user_id: 사용자 ID
user_update: 수정할 사용자 정보
Returns:
User: 수정된 사용자 정보
"""
user = db.query(User).filter(
User.id == user_id,
User.franchise_id == current_user.franchise_id
).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
# 사용자명 변경 시 중복 체크
if user_update.username and user_update.username != user.username:
existing_user = db.query(User).filter(User.username == user_update.username).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="이미 존재하는 사용자명입니다"
)
# 역할 변경 시 검증
if user_update.role and user_update.role not in ['franchise_admin', 'store_admin', 'franchise_manager']:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="올바르지 않은 역할입니다."
)
# 매장 ID 변경 시 검증
if user_update.store_id:
store = db.query(Store).filter(
Store.id == user_update.store_id,
Store.franchise_id == current_user.franchise_id
).first()
if not store:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="매장을 찾을 수 없거나 접근 권한이 없습니다"
)
# 수정
update_data = user_update.dict(exclude_unset=True, exclude={'password', 'managed_store_ids'})
for key, value in update_data.items():
setattr(user, key, value)
# 중간 관리자 매장 권한 업데이트
if user_update.role == 'franchise_manager' or (not user_update.role and user.role == 'franchise_manager'):
if user_update.managed_store_ids is not None:
stores = db.query(Store).filter(
Store.id.in_(user_update.managed_store_ids),
Store.franchise_id == current_user.franchise_id
).all()
user.managed_stores = stores
elif user_update.role and user_update.role != 'franchise_manager':
# 다른 역할로 변경 시 관리 매장 초기화
user.managed_stores = []
# 비밀번호 변경이 있는 경우
if user_update.password:
user.password_hash = get_password_hash(user_update.password)
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""사용자 비활성화
실제로 삭제하지 않고 is_active를 False로 변경
자기 자신은 비활성화할 수 없음
Args:
user_id: 사용자 ID
"""
# 자기 자신은 비활성화할 수 없음
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="자기 자신을 비활성화할 수 없습니다"
)
user = db.query(User).filter(
User.id == user_id,
User.franchise_id == current_user.franchise_id
).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
user.is_active = False
user.updated_at = datetime.now()
db.commit()
@router.post("/{user_id}/activate", response_model=UserSchema)
async def activate_user(
user_id: int,
current_user: User = Depends(require_franchise_admin),
db: Session = Depends(get_db)
):
"""사용자 활성화
Args:
user_id: 사용자 ID
Returns:
User: 활성화된 사용자 정보
"""
user = db.query(User).filter(
User.id == user_id,
User.franchise_id == current_user.franchise_id
).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="사용자를 찾을 수 없습니다"
)
user.is_active = True
user.updated_at = datetime.now()
db.commit()
db.refresh(user)
return user

802
routers/waiting.py Normal file
View File

@@ -0,0 +1,802 @@
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": "대기가 취소되었습니다."}

859
routers/waiting_board.py Normal file
View File

@@ -0,0 +1,859 @@
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
}