from fastapi import APIRouter, Depends, Query, HTTPException, status from sqlalchemy.orm import Session from sqlalchemy import select from datetime import date from typing import List, Dict, Any, Optional from app.core.database.db_session import get_db from app.core.permissions.RoleChecker import RoleChecker from app.models.UserModel import User # For customer oauth verification from app.api.v1.routers.CheckoutRouter import get_current_customer from app.models.EcomCustomerModel import EcomCustomer from app.models.ServiceModel import ServiceJob from app.schemas.ServiceSchema import ( ServiceJobCreate, ServiceJobResponse, ServiceJobIntakeCreate, ServiceJobIntakeResponse, ServiceJobInspectionCreate, ServiceJobInspectionResponse, ServiceJobRescheduleRequest, ServiceCatalogResponse ) from app.schemas.ServiceQuoteSchema import ServiceJobQuoteCreate, ServiceJobQuoteResponse from app.schemas.ServicePaymentSchema import ServicePaymentCreate, RazorpayVerificationRequest from app.services.ServiceJobService import ServiceJobService from app.services.SlotAllocationService import SlotAllocationService from app.services.InspectionService import InspectionService from app.services.QuoteService import QuoteService from app.services.ServicePaymentService import ServicePaymentService from app.repositories.ServiceRepository import ServiceRepository from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials security = HTTPBearer(auto_error=False) def get_current_actor( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), db: Session = Depends(get_db) ): if not credentials: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"} ) token = credentials.credentials try: from app.core.Token import verify_access_token payload = verify_access_token(token) except Exception as e: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Token validation failed: {str(e)}", headers={"WWW-Authenticate": "Bearer"} ) role = payload.get("role", "") sub = payload.get("sub") if role == "customer": customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == sub).first() if not customer: raise HTTPException(status_code=404, detail="Customer not found") return {"type": "customer", "obj": customer} elif role in ["Super Admin", "Admin", "Manager", "Technician"]: user = db.query(User).filter(User.user_id == sub).first() if not user: raise HTTPException(status_code=404, detail="Admin user not found") return {"type": "admin", "obj": user} else: raise HTTPException(status_code=403, detail="Not authorized") router = APIRouter(prefix="/api/v1/service", tags=["Repairs & Scheduling Services"]) job_service = ServiceJobService() allocation_service = SlotAllocationService() inspection_service = InspectionService() quote_service = QuoteService() payment_service = ServicePaymentService() service_repo = ServiceRepository() @router.get("/catalog", response_model=List[ServiceCatalogResponse]) def get_catalog_services(db: Session = Depends(get_db)): """Get active repair & diagnostics catalog options.""" return service_repo.get_all_catalog_services(db) @router.get("/slots/available") def get_available_appointment_slots( service_id: str, target_date: date = Query(...), db: Session = Depends(get_db) ): """Dynamically fetch schedule slots based on technician availability and duration.""" return allocation_service.get_available_slots(db, target_date, service_id) @router.post("/booking/create") def create_online_service_booking( payload: ServiceJobCreate, actor: dict = Depends(get_current_actor), db: Session = Depends(get_db) ): """Book a repair job slot and lock capacity (starts 10-minute slot hold).""" if actor["type"] == "customer": customer_id = actor["obj"].customer_id else: # Admin flow if payload.customer_id: customer_id = payload.customer_id elif payload.customer_name: import ulid parts = payload.customer_name.strip().split(maxsplit=1) first_name = parts[0] last_name = parts[1] if len(parts) > 1 else "" email = payload.customer_email or f"walkin_{str(ulid.ULID())}@ifixkart.com" phone = payload.customer_phone or f"walkin_{str(ulid.ULID())}" existing = None if payload.customer_phone: existing = db.query(EcomCustomer).filter(EcomCustomer.phone == payload.customer_phone).first() if existing: customer_id = existing.customer_id else: new_cust = EcomCustomer( customer_id=str(ulid.ULID()), first_name=first_name, last_name=last_name, email=email, phone=phone, is_active=True ) db.add(new_cust) db.commit() db.refresh(new_cust) customer_id = new_cust.customer_id else: raise HTTPException(status_code=400, detail="customer_id or customer_name required for admin booking") return job_service.create_online_booking(db, customer_id, payload) @router.post("/jobs/{job_id}/intake", response_model=ServiceJobIntakeResponse) def record_walk_in_device_intake( job_id: str, payload: ServiceJobIntakeCreate, current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager"])), db: Session = Depends(get_db) ): """Record physical checklists (SIM tray, scratches, power status) during device receipt.""" return inspection_service.create_device_intake(db, job_id, current_user.user_id, payload) @router.post("/jobs/{job_id}/inspect", response_model=ServiceJobInspectionResponse) def submit_technician_diagnostic_findings( job_id: str, payload: ServiceJobInspectionCreate, current_user: User = Depends(RoleChecker(["Technician", "Super Admin", "Admin"])), db: Session = Depends(get_db) ): """Technicians submit verified damages, diagnostic results, and notes.""" return inspection_service.submit_inspection(db, job_id, current_user.user_id, payload) @router.post("/jobs/{job_id}/quotes/create", response_model=ServiceJobQuoteResponse) def create_or_revise_repair_quote( job_id: str, payload: ServiceJobQuoteCreate, current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])), db: Session = Depends(get_db) ): """Generate or revise quote estimates. Marks older versions as SUPERSEDED.""" return quote_service.create_or_revise_quote(db, job_id, current_user.user_id, payload) @router.post("/jobs/{job_id}/quotes/{quote_id}/respond", response_model=ServiceJobQuoteResponse) def respond_to_quote_estimate( job_id: str, quote_id: str, action: str = Query(..., regex="^(ACCEPT|REJECT)$"), actor: dict = Depends(get_current_actor), db: Session = Depends(get_db) ): """Customers or admins accept or reject estimates.""" from app.models.ServiceModel import ServiceJob as _SJ from app.models.ServiceQuoteModel import ServiceJobQuote as _SJQ quote = db.get(_SJQ, quote_id) if not quote: from fastapi import HTTPException raise HTTPException(status_code=404, detail="Quote not found") job = db.get(_SJ, quote.service_job_id) if not job: from fastapi import HTTPException raise HTTPException(status_code=404, detail="Job not found") # Admins/Technicians can respond on behalf of customers if actor["type"] == "customer": customer_id = actor["obj"].customer_id else: customer_id = job.customer_id # admin acting on behalf if action == "REJECT": result = quote_service.respond_to_quote(db, quote_id, customer_id, action) refund_res = payment_service.process_quote_rejection_refund(db, quote.service_job_id) result.update(refund_res) return result return quote_service.respond_to_quote(db, quote_id, customer_id, action) @router.post("/jobs/{job_id}/payments/initiate") def initiate_milestone_payment( job_id: str, payload: ServicePaymentCreate, customer: EcomCustomer = Depends(get_current_customer), db: Session = Depends(get_db) ): """Generate Razorpay order for advance deposits or final balances.""" return payment_service.initiate_milestone_payment( db, job_id, customer.customer_id, payload.payment_type, payload.quote_id ) @router.post("/payments/verify") def verify_milestone_payment( payload: RazorpayVerificationRequest, customer: EcomCustomer = Depends(get_current_customer), db: Session = Depends(get_db) ): """Verify cryptographic Razorpay payment signature callback.""" return payment_service.verify_milestone_payment(db, payload) @router.post("/jobs/{job_id}/reschedule") def reschedule_active_appointment( job_id: str, payload: ServiceJobRescheduleRequest, actor: dict = Depends(get_current_actor), db: Session = Depends(get_db) ): """Reschedule slot time. Cancels old slot, locks new slot, logs audit trail.""" if actor["type"] == "customer": customer_id = actor["obj"].customer_id else: # Admin is rescheduling job = db.get(ServiceJob, job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") customer_id = job.customer_id return job_service.reschedule_appointment(db, job_id, customer_id, payload) @router.get("/jobs/{job_id}") def get_service_job_details( job_id: str, actor: dict = Depends(get_current_actor), db: Session = Depends(get_db) ): """Retrieve service job, current appointment, timeline events, and active quotes.""" job = db.get(ServiceJob, job_id) or db.query(ServiceJob).filter(ServiceJob.job_no == job_id).first() if not job: raise HTTPException(status_code=404, detail="Service job not found") if actor["type"] == "customer" and job.customer_id != actor["obj"].customer_id: raise HTTPException(status_code=403, detail="Not authorized to view this job") from app.models.ServiceModel import ServiceAppointment, ServiceJobEvent from app.models.ServiceQuoteModel import ServiceJobQuote from app.models.ServicePaymentModel import ServicePayment # Active appointment app_stmt = select(ServiceAppointment).where( ServiceAppointment.service_job_id == job.job_id, ServiceAppointment.status.in_(["HELD", "CONFIRMED"]) ) appt = db.execute(app_stmt).scalar_one_or_none() # Timeline events event_stmt = select(ServiceJobEvent).where(ServiceJobEvent.job_id == job.job_id).order_by(ServiceJobEvent.timestamp.asc()) events = list(db.execute(event_stmt).scalars().all()) # Latest quote quote_stmt = select(ServiceJobQuote).where( ServiceJobQuote.service_job_id == job.job_id, ServiceJobQuote.status.in_(["PENDING_CUSTOMER", "ACCEPTED", "REJECTED"]) ).order_by(ServiceJobQuote.version.desc()) quote = db.execute(quote_stmt).scalars().first() # Payments pay_stmt = select(ServicePayment).where(ServicePayment.service_job_id == job.job_id) payments = list(db.execute(pay_stmt).scalars().all()) # Latest inspection (for damage description) from app.models.ServiceModel import ServiceJobInspection insp_stmt = select(ServiceJobInspection).where( ServiceJobInspection.service_job_id == job.job_id ).order_by(ServiceJobInspection.created_at.desc()) inspection = db.execute(insp_stmt).scalars().first() from app.models.EcomCustomerModel import EcomCustomer cust = db.get(EcomCustomer, job.customer_id) if job.customer_id else None # Build response payload return { "job_id": job.job_id, "job_no": job.job_no, "status": job.status, "customer": { "customer_id": job.customer_id, "name": f"{cust.first_name} {cust.last_name}".strip() if cust else "Guest Customer", "email": cust.email if cust else "N/A", "phone": cust.phone if cust else "N/A" }, "service_name": job.service_name_snapshot or (job.service.name if job.service else "Repair Service"), "base_price": float(job.base_price_snapshot if job.base_price_snapshot is not None else (job.service.base_price if job.service else 0.0)), "device_brand": job.device.brand if job.device else (job.brand_id or "Generic"), "device_model": job.device.model if job.device else (job.model_id or "Device"), "appointment": { "appointment_id": appt.appointment_id, "scheduled_start": appt.scheduled_start.isoformat(), "scheduled_end": appt.scheduled_end.isoformat(), "status": appt.status } if appt else None, "quote": { "quote_id": quote.quote_id, "version": quote.version, "subtotal": float(quote.subtotal), "tax": float(quote.tax), "additional_damage_amount": float(quote.additional_damage_amount), "total": float(quote.total), "status": quote.status, "reason": quote.reason, "expires_at": quote.expires_at.isoformat() if quote.expires_at else None, "additional_damage_description": inspection.additional_damage if inspection else None, } if quote else None, "events": [ { "event_type": ev.event_type, "timestamp": ev.timestamp.isoformat(), "notes": ev.notes } for ev in events ], "payments": [ { "payment_type": p.payment_type, "amount": float(p.amount), "status": p.status, "paid_at": p.paid_at.isoformat() if p.paid_at else None } for p in payments ] } @router.get("/jobs") def list_service_jobs_admin( current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])), db: Session = Depends(get_db) ): """List all repair & diagnostics jobs for dashboard queues.""" from app.models.ServiceModel import ServiceJob from app.models.EcomCustomerModel import EcomCustomer stmt = select(ServiceJob).order_by(ServiceJob.created_at.desc()) jobs = db.execute(stmt).scalars().all() cust_ids = {j.customer_id for j in jobs if j.customer_id} cust_map = {} if cust_ids: custs = db.execute(select(EcomCustomer).where(EcomCustomer.customer_id.in_(cust_ids))).scalars().all() for c in custs: cust_map[c.customer_id] = { "name": f"{c.first_name} {c.last_name}".strip(), "email": c.email, "phone": c.phone or "N/A" } return [ { "job_id": j.job_id, "job_no": j.job_no, "customer_id": j.customer_id, "customer_name": cust_map.get(j.customer_id, {}).get("name") or (f"Customer ({j.customer_id[:8]})" if j.customer_id else "Guest Customer"), "customer_email": cust_map.get(j.customer_id, {}).get("email") or "N/A", "customer_phone": cust_map.get(j.customer_id, {}).get("phone") or "N/A", "status": j.status, "service_name": j.service_name_snapshot or (j.custom_service_name if j.custom_service_name else (j.service.name if j.service else "Custom Repair")), "base_price": float(j.base_price_snapshot if j.base_price_snapshot is not None else (j.service.base_price if j.service else 0.0)), "device_brand": j.device.brand if j.device else (j.brand_id or "Generic"), "device_model": j.device.model if j.device else (j.model_id or "Device"), "created_at": j.created_at.isoformat() } for j in jobs ] @router.post("/jobs/{job_id}/status") def update_service_job_status_admin( job_id: str, status: str = Query(..., description="New status value"), current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])), db: Session = Depends(get_db) ): """Admin/Technician manual status override for a service job.""" from app.models.ServiceModel import ServiceJob job = db.get(ServiceJob, job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") job.status = status db.commit() db.refresh(job) return {"status": "success", "new_status": job.status}