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, ServiceJobMediaBatchCreate, ServiceJobMediaResponse ) 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") def get_optional_actor( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), db: Session = Depends(get_db) ) -> Optional[dict]: if not credentials: return None token = credentials.credentials try: from app.core.Token import verify_access_token payload = verify_access_token(token) role = payload.get("role", "") sub = payload.get("sub") if role == "customer": customer = db.query(EcomCustomer).filter(EcomCustomer.customer_id == sub).first() if customer: return {"type": "customer", "obj": customer} elif role in ["Super Admin", "Admin", "Manager", "Technician"]: user = db.query(User).filter(User.user_id == sub).first() if user: return {"type": "admin", "obj": user} except Exception: pass return None 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 from sqlalchemy.exc import IntegrityError parts = payload.customer_name.strip().split(maxsplit=1) first_name = parts[0] last_name = parts[1] if len(parts) > 1 else "" u_str = str(ulid.ULID()) raw_phone = (payload.customer_phone or "").strip() digits_only = "".join(c for c in raw_phone if c.isdigit()) phone_suffix = digits_only[-10:] if len(digits_only) >= 10 else digits_only email = (payload.customer_email or f"walkin_{u_str}@ifixkart.com").strip() phone = raw_phone if raw_phone else f"W{u_str[-15:]}" existing = None if phone_suffix: existing = db.query(EcomCustomer).filter( (EcomCustomer.phone.like(f"%{phone_suffix}%")) | (EcomCustomer.email == email) ).first() elif payload.customer_email: existing = db.query(EcomCustomer).filter(EcomCustomer.email == email).first() if existing: customer_id = existing.customer_id else: try: 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 except IntegrityError: db.rollback() found = db.query(EcomCustomer).filter( (EcomCustomer.phone.like(f"%{phone_suffix}%")) if phone_suffix else (EcomCustomer.email == email) ).first() if found: customer_id = found.customer_id else: customer_id = str(ulid.ULID()) 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(["Super Admin", "Admin", "Manager", "Technician"])), db: Session = Depends(get_db) ): """Submit technician diagnostic findings and inspection status.""" return inspection_service.submit_inspection(db, job_id, current_user.user_id, payload) @router.post("/jobs/{job_id}/quotes") @router.post("/jobs/{job_id}/quotes/create") 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 estimate quote for customer approval.""" return quote_service.create_or_revise_quote(db, job_id, current_user.user_id, payload) @router.post("/jobs/{job_id}/quotes/{quote_id}/respond") 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) ): """Customer approves or declines line items in revised estimate quote.""" customer_id = actor["obj"].customer_id if actor["type"] == "customer" else "SYSTEM" 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, actor: dict = Depends(get_current_actor), db: Session = Depends(get_db) ): """Initiate Razorpay order for advance deposit or final balance payment.""" customer_id = actor["obj"].customer_id if actor["type"] == "customer" else None return payment_service.initiate_payment(db, job_id, payload, customer_id) @router.post("/payments/verify") @router.post("/jobs/{job_id}/payments/verify") def verify_milestone_payment( payload: RazorpayVerificationRequest, job_id: Optional[str] = None, db: Session = Depends(get_db) ): """Verify Razorpay payment signature & update job/payment ledger.""" return payment_service.verify_payment(db, job_id or "", 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) ): """Customer or staff reschedules repair appointment time slot.""" customer_id = actor["obj"].customer_id if actor["type"] == "customer" else None 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: Optional[dict] = Depends(get_optional_actor), db: Session = Depends(get_db) ): """Retrieve service job, current appointment, timeline events, active quotes, and media proof.""" 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 and actor["type"] == "customer" and job.customer_id 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 # Fetch job media items media_list = inspection_service.get_job_media(db, job.job_id) # 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"), "fulfillment_type": job.fulfillment_type or "WALK_IN", "fulfillment_fee": float(job.fulfillment_fee or (250.0 if job.fulfillment_type == "DOORSTEP_PICKUP" else 0.0)), "courier_name": getattr(job, "courier_name", None), "awb_number": getattr(job, "awb_number", None), "pickup_status": getattr(job, "pickup_status", None), "delivery_address": job.delivery_address, "media": media_list, "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"), "fulfillment_type": j.fulfillment_type or "WALK_IN", "fulfillment_fee": float(j.fulfillment_fee or (250.0 if j.fulfillment_type == "DOORSTEP_PICKUP" else 0.0)), "courier_name": getattr(j, "courier_name", None), "awb_number": getattr(j, "awb_number", None), "pickup_status": getattr(j, "pickup_status", None), "delivery_address": j.delivery_address, "created_at": j.created_at.isoformat() } for j in jobs ] @router.post("/jobs/{job_id}/logistics") def update_service_job_logistics( job_id: str, payload: dict, current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])), db: Session = Depends(get_db) ): """Update courier name, AWB number, and pickup status for doorstep pickup / courier jobs.""" from app.models.ServiceModel import ServiceJob, ServiceJobEvent import ulid job = db.get(ServiceJob, job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") courier_name = payload.get("courier_name") awb_number = payload.get("awb_number") pickup_status = payload.get("pickup_status") if courier_name is not None: job.courier_name = courier_name if awb_number is not None: job.awb_number = awb_number if pickup_status is not None: job.pickup_status = pickup_status note_parts = [] if courier_name: note_parts.append(f"Courier: {courier_name}") if awb_number: note_parts.append(f"AWB: {awb_number}") if pickup_status: note_parts.append(f"Pickup Status: {pickup_status}") event = ServiceJobEvent( event_id=str(ulid.ULID()), job_id=job_id, event_type="LOGISTICS_UPDATED", performed_by=current_user.user_id, notes="Logistics updated: " + ", ".join(note_parts) if note_parts else "Logistics details updated." ) db.add(event) db.commit() db.refresh(job) return { "status": "success", "job_id": job.job_id, "courier_name": job.courier_name, "awb_number": job.awb_number, "pickup_status": job.pickup_status } @router.post("/jobs/{job_id}/media") def attach_service_job_media( job_id: str, payload: ServiceJobMediaBatchCreate, current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])), db: Session = Depends(get_db) ): """Attach media files (video/photos) under category (INSPECTION_DONE, READY_FOR_DELIVERY, etc.).""" return inspection_service.batch_upload_job_media(db, job_id, payload.category, payload.file_ids) @router.get("/jobs/{job_id}/media") def get_service_job_media( job_id: str, category: Optional[str] = Query(None), db: Session = Depends(get_db) ): """Get media proof uploaded for a service job.""" return inspection_service.get_job_media(db, job_id, category) @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, ServiceJobEvent import ulid from datetime import datetime job = db.get(ServiceJob, job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") job.status = status # Credential Lifecycle: Purge lock credentials upon repair completion / delivery if status in ["DELIVERED", "COMPLETED", "CLOSED", "READY_FOR_DELIVERY"]: if job.lock_credential_encrypted: job.lock_credential_encrypted = None job.lock_credential_deleted_at = datetime.utcnow() event = ServiceJobEvent( event_id=str(ulid.ULID()), job_id=job_id, event_type=status, performed_by=current_user.user_id, notes=f"Status updated to {status} by technician." ) db.add(event) db.commit() db.refresh(job) return {"status": "success", "new_status": job.status} @router.get("/jobs/{job_id}/credentials") def get_service_job_credentials_admin( job_id: str, current_user: User = Depends(RoleChecker(["Super Admin", "Admin", "Manager", "Technician"])), db: Session = Depends(get_db) ): """Retrieve and decrypt customer device lock credentials with access logging audit.""" from app.models.ServiceModel import ServiceJob from app.core.security.LockValidator import decrypt_credential from datetime import datetime job = db.get(ServiceJob, job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.lock_credential_encrypted: return { "lock_type": job.lock_type, "passcode": None, "is_deleted": job.lock_credential_deleted_at is not None, "deleted_at": job.lock_credential_deleted_at } # Record access audit metadata now = datetime.utcnow() job.lock_credential_accessed_at = now job.lock_credential_accessed_by = current_user.user_id db.commit() decrypted = decrypt_credential(job.lock_credential_encrypted) return { "lock_type": job.lock_type, "passcode": decrypted, "accessed_at": now, "accessed_by": current_user.user_id } @router.get("/jobs/{job_id}/private-video") def stream_private_condition_video( job_id: str, actor: Optional[dict] = Depends(get_optional_actor), db: Session = Depends(get_db) ): """Access control endpoint for viewing private pre-courier device condition video.""" from app.models.ServiceModel import ServiceJob from app.models.FileModel import FileUpload from fastapi.responses import FileResponse from pathlib import Path job = db.get(ServiceJob, job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if not job.pre_dispatch_video_id: raise HTTPException(status_code=404, detail="No pre-dispatch video attached to this job") # Authorization Check: Actor must be admin/technician or the customer owning the job is_authorized = False if actor: if actor["type"] == "admin": is_authorized = True elif actor["type"] == "customer" and actor["obj"].customer_id == job.customer_id: is_authorized = True if not is_authorized: raise HTTPException(status_code=403, detail="Not authorized to access this private video") # Fetch file record file_record = db.query(FileUpload).filter(FileUpload.file_id == job.pre_dispatch_video_id).first() if not file_record: raise HTTPException(status_code=404, detail="Video file record not found") file_path = Path(file_record.storage_path.lstrip("/")) if not file_path.is_absolute(): from app.core.config import BACKEND_ROOT file_path = BACKEND_ROOT / file_path if not file_path.exists(): raise HTTPException(status_code=404, detail="Video media file missing on server disk") return FileResponse(file_path, media_type=file_record.mime_type or "video/mp4")