Files
waiting-system/routers/members.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

463 lines
16 KiB
Python

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)