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:
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)
|
||||
Reference in New Issue
Block a user