- 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>
442 lines
17 KiB
Python
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
|
|
}
|