392 lines
16 KiB
Python
392 lines
16 KiB
Python
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}
|