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

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

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

442 lines
17 KiB
Python

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
}