- 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>
860 lines
29 KiB
Python
860 lines
29 KiB
Python
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
|
|
}
|