293 lines
13 KiB
Python
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}
|