ifixkart-backend/app/services/ServiceJobService.py

293 lines
13 KiB
Python

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}