import ulid from datetime import datetime, timedelta from sqlalchemy.orm import Session from sqlalchemy import select, func from typing import Optional, List from fastapi import HTTPException, status from app.models.ServiceModel import ServiceCatalog, ServiceJob, ServiceAppointment, ServiceJobEvent, ServiceJobAssignment from app.models.CustomerDeviceModel import CustomerDevice from app.schemas.ServiceSchema import ServiceJobCreate, ServiceJobRescheduleRequest, ServiceAppointmentCreate from app.services.SlotAllocationService import SlotAllocationService class ServiceJobService: def __init__(self): self.allocation_service = SlotAllocationService() def create_online_booking(self, db: Session, customer_id: str, data: ServiceJobCreate) -> dict: # Register device if new device_id = data.device_id if data.new_device: device_id = str(ulid.ULID()) device = CustomerDevice( device_id=device_id, customer_id=customer_id, brand=data.new_device.brand, model=data.new_device.model, model_number=data.new_device.model_number, imei_primary=data.new_device.imei_primary, imei_secondary=data.new_device.imei_secondary, color=data.new_device.color, device_condition=data.new_device.device_condition, device_type=data.new_device.device_type, notes=data.new_device.notes, storage_capacity=data.new_device.storage_capacity ) db.add(device) db.flush() elif not device_id: raise HTTPException(status_code=400, detail="Device details required") # 2. Granular Cashify-style Validation & Snapshots service_name_snapshot = None variant_name_snapshot = None base_price_snapshot = None duration_snapshot = 60 warranty_snapshot = None inspection_fee_snapshot = 300.00 queue_number = None queue_date = None if data.repair_variant_id: from app.models.DeviceCatalogModel import RepairVariant, DeviceModel, ServiceType variant = db.execute(select(RepairVariant).where(RepairVariant.variant_id == data.repair_variant_id)).scalar_one_or_none() if variant: # Cross-database model validation if data.model_id: model = db.execute(select(DeviceModel).where(DeviceModel.model_id == data.model_id)).scalar_one_or_none() if not model: raise HTTPException(status_code=404, detail="Selected device model not found") if variant.repair_service and variant.repair_service.model_id != data.model_id: raise HTTPException(status_code=400, detail="Selected repair variant does not belong to the chosen device model") service_name_snapshot = variant.repair_service.service_type.name if (variant.repair_service and variant.repair_service.service_type) else (data.custom_service_name or "Repair Service") variant_name_snapshot = variant.name base_price_snapshot = float(variant.price) duration_snapshot = variant.duration_minutes warranty_snapshot = variant.warranty_days else: # Handle fallback / dynamic / demo repair variants seamlessly if data.model_id: model = db.execute(select(DeviceModel).where(DeviceModel.model_id == data.model_id)).scalar_one_or_none() if not model: raise HTTPException(status_code=404, detail="Selected device model not found") service_name_snapshot = data.custom_service_name or "Repair Service" if data.service_type_id: st = db.execute(select(ServiceType).where(ServiceType.service_type_id == data.service_type_id)).scalar_one_or_none() if st: service_name_snapshot = st.name variant_name_snapshot = "Standard Repair" base_price_snapshot = 1499.00 duration_snapshot = 60 warranty_snapshot = 180 actual_service_id = data.service_id if data.service_id == "OTHER_SERVICE": actual_service_id = None service_name_snapshot = data.custom_service_name or "Custom Repair Service" base_price_snapshot = 0.0 duration_snapshot = 60 elif data.service_id: service = db.get(ServiceCatalog, data.service_id) if not service: actual_service_id = None service_name_snapshot = data.custom_service_name or "Repair Service" base_price_snapshot = 0.0 duration_snapshot = 60 else: service_name_snapshot = service.name base_price_snapshot = float(service.base_price) duration_snapshot = service.estimated_duration_minutes # Restrict customer priority defaults to NORMAL priority = "NORMAL" if data.priority and data.priority in ["NORMAL", "HIGH", "URGENT"]: priority = data.priority # 3. Fulfillment Mode & Validation Matrix Enforcement from app.core.security.LockValidator import validate_lock_credentials, calculate_fulfillment_pricing, encrypt_credential fulfillment_type = (data.fulfillment_type or "WALK_IN").upper() if fulfillment_type not in ["WALK_IN", "COURIER", "DOORSTEP_PICKUP"]: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid fulfillment_type '{data.fulfillment_type}'. Must be WALK_IN, COURIER, or DOORSTEP_PICKUP." ) # Enforce Validation Matrix if fulfillment_type == "WALK_IN": if not data.appointment: # Fallback default slot if appointment is missing for walk-in from datetime import timedelta now = datetime.utcnow() data.appointment = ServiceAppointmentCreate( scheduled_start=now, scheduled_end=now + timedelta(hours=1) ) delivery_address = None pre_dispatch_video_id = None lock_type = "NONE" cleaned_passcode = None else: # COURIER or DOORSTEP_PICKUP: ignore appointment slots gracefully if passed data.appointment = None delivery_address = (data.delivery_address or "Customer Delivery Address").strip() pre_dispatch_video_id = data.pre_dispatch_video_id lock_type = data.lock_type or "NONE" cleaned_passcode = validate_lock_credentials(lock_type, data.lock_passcode) if lock_type != "NONE" else None # Authoritative Server Pricing Calculation base_val, fee_val, total_val, advance_val, balance_val = calculate_fulfillment_pricing( base_price_snapshot or 1499.00, fulfillment_type ) # Handle Walk-in Queueing / Default Queue initialization today = datetime.utcnow().date() queue_date = today queue_number = None if fulfillment_type == "WALK_IN": count_stmt = select(func.count(ServiceJob.job_id)).where(ServiceJob.queue_date == today) daily_count = db.execute(count_stmt).scalar() or 0 queue_number = f"W-{today.year}-{daily_count + 1:03d}" # Generate unique Job number import uuid timestamp = int(datetime.utcnow().timestamp()) job_no = f"SRV-{timestamp}-{uuid.uuid4().hex[:4].upper()}" # Encrypt lock passcode if present encrypted_passcode = encrypt_credential(cleaned_passcode) if cleaned_passcode else None created_at_passcode = datetime.utcnow() if cleaned_passcode else None # Create ServiceJob in BOOKING_PENDING status job = ServiceJob( job_id=str(ulid.ULID()), job_no=job_no, customer_id=customer_id, device_id=device_id, source=data.source, service_id=actual_service_id, status="BOOKING_PENDING", custom_service_name=data.custom_service_name, device_type=data.device_type, brand_id=data.brand_id, series_id=data.series_id, model_id=data.model_id, service_type_id=data.service_type_id, repair_service_id=data.repair_service_id, repair_variant_id=data.repair_variant_id, currency="INR", service_name_snapshot=service_name_snapshot, variant_name_snapshot=variant_name_snapshot, base_price_snapshot=float(base_val), duration_snapshot=duration_snapshot, warranty_snapshot=warranty_snapshot, inspection_fee_snapshot=inspection_fee_snapshot, queue_number=queue_number, queue_date=queue_date, priority=priority, # Fulfillment & Security Attributes fulfillment_type=fulfillment_type, fulfillment_fee=float(fee_val), alt_phone=data.alt_phone, is_whatsapp_alt=data.is_whatsapp_alt if data.is_whatsapp_alt is not None else True, delivery_address=delivery_address, pre_dispatch_video_id=pre_dispatch_video_id, lock_type=lock_type, lock_credential_encrypted=encrypted_passcode, lock_credential_created_at=created_at_passcode ) db.add(job) db.flush() # Handle Slot Hold appointment = None if data.appointment and fulfillment_type == "WALK_IN": appointment = ServiceAppointment( appointment_id=str(ulid.ULID()), service_job_id=job.job_id, scheduled_start=data.appointment.scheduled_start, scheduled_end=data.appointment.scheduled_end, status="HELD", hold_expires_at=datetime.utcnow() + timedelta(minutes=10) ) db.add(appointment) # Log timeline event event = ServiceJobEvent( event_id=str(ulid.ULID()), job_id=job.job_id, event_type="BOOKING_INITIATED", performed_by=customer_id, notes=f"Customer initiated service booking ({fulfillment_type}). Total: ₹{total_val}, Advance: ₹{advance_val}." ) db.add(event) db.commit() db.refresh(job) return { "job_id": job.job_id, "job_no": job.job_no, "appointment_id": appointment.appointment_id if appointment else None, "hold_expires_at": appointment.hold_expires_at.isoformat() if appointment else None, "total_price": float(total_val), "advance_deposit": float(advance_val), "remaining_balance": float(balance_val) } def reschedule_appointment( self, db: Session, job_id: str, requester_id: str, data: ServiceJobRescheduleRequest ) -> dict: job = db.get(ServiceJob, job_id) if not job: raise HTTPException(status_code=404, detail="Service job not found") # 1. Fetch current active appointment app_stmt = select(ServiceAppointment).where( ServiceAppointment.service_job_id == job_id, ServiceAppointment.status == "CONFIRMED" ) old_appt = db.execute(app_stmt).scalar_one_or_none() # 2. Check if new slot time is available dynamically slots = self.allocation_service.get_available_slots(db, data.scheduled_start.date(), job.service_id) matching_slot = None for slot in slots: if slot["start_time"] == data.scheduled_start.isoformat(): matching_slot = slot break if not matching_slot: raise HTTPException(status_code=400, detail="New requested appointment slot is not available") # 3. Create new CONFIRMED appointment new_appt = ServiceAppointment( appointment_id=str(ulid.ULID()), service_job_id=job_id, scheduled_start=data.scheduled_start, scheduled_end=data.scheduled_end, status="CONFIRMED", confirmed_at=datetime.utcnow() ) db.add(new_appt) # 4. Cancel old appointment if old_appt: old_appt.status = "CANCELLED" old_appt.cancelled_at = datetime.utcnow() # 5. Log Timeline Event event = ServiceJobEvent( event_id=str(ulid.ULID()), job_id=job_id, event_type="RESCHEDULED", performed_by=requester_id, notes=f"Appointment rescheduled from {old_appt.scheduled_start if old_appt else 'N/A'} to {data.scheduled_start}. Reason: {data.reason or 'Not specified'}" ) db.add(event) db.commit() return {"message": "Rescheduling complete", "new_appointment_id": new_appt.appointment_id}