- 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>
463 lines
16 KiB
Python
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)
|