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:
1
routers/__init__.py
Normal file
1
routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Router package
|
||||
494
routers/attendance.py
Normal file
494
routers/attendance.py
Normal 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
116
routers/auth.py
Normal 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
325
routers/class_management.py
Normal 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
441
routers/daily_closing.py
Normal 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
211
routers/franchise.py
Normal 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
70
routers/logs.py
Normal 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
462
routers/members.py
Normal 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
38
routers/sse.py
Normal 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
697
routers/statistics.py
Normal 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
235
routers/store_settings.py
Normal 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
476
routers/stores.py
Normal 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
791
routers/system_admin.py
Normal 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
309
routers/users.py
Normal 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
802
routers/waiting.py
Normal 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
859
routers/waiting_board.py
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user