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)