import json import logging from datetime import datetime, timezone from typing import Any, Dict, List, Optional from uuid import uuid4 from fastapi import HTTPException from app.core.config import settings from app.core.database import execute_query, execute_query_single from app.modules.fedex.backend.api_client import FedExApiClient, parse_tracking_events from app.modules.fedex.models.schemas import FedExBookingCreate logger = logging.getLogger(__name__) class FedExService: def __init__(self) -> None: self.client = FedExApiClient() @property def enabled(self) -> bool: return bool(settings.FEDEX_ENABLED) @property def read_only(self) -> bool: return bool(settings.FEDEX_READ_ONLY) @property def dry_run(self) -> bool: return bool(settings.FEDEX_DRY_RUN) def _assert_enabled(self) -> None: if not self.enabled: raise HTTPException(status_code=503, detail="FedEx integration is disabled") def _booking_ref(self) -> str: stamp = datetime.now(timezone.utc).strftime("%Y%m%d") return f"FDX-{stamp}-{uuid4().hex[:8].upper()}" def _validate_relations(self, payload: FedExBookingCreate) -> None: case_exists = execute_query_single("SELECT id FROM sag_sager WHERE id = %s", (payload.case_id,)) if not case_exists: raise HTTPException(status_code=404, detail="Case not found") if payload.customer_id: customer_exists = execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,)) if not customer_exists: raise HTTPException(status_code=404, detail="Customer not found") if payload.contact_id: contact_exists = execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,)) if not contact_exists: raise HTTPException(status_code=404, detail="Contact not found") def _fetch_packages(self, shipment_id: int) -> List[Dict[str, Any]]: rows = execute_query( """ SELECT weight_kg, length_cm, width_cm, height_cm, description FROM fedex_shipment_packages WHERE shipment_id = %s ORDER BY id ASC """, (shipment_id,), ) or [] return [dict(row) for row in rows] def _shipment_row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]: mapped = dict(row) mapped["packages"] = self._fetch_packages(int(row["id"])) return mapped def create_booking_draft(self, payload: FedExBookingCreate, created_by_user_id: Optional[int]) -> Dict[str, Any]: self._assert_enabled() self._validate_relations(payload) if payload.pickup_window_end <= payload.pickup_window_start: raise HTTPException(status_code=400, detail="pickup_window_end must be after pickup_window_start") booking_ref = self._booking_ref() shipment_row = execute_query_single( """ INSERT INTO fedex_shipments ( booking_ref, case_id, customer_id, contact_id, service_type, shipment_status, pickup_window_start, pickup_window_end, recipient_name, company_name, address_line1, address_line2, postal_code, city, country_code, phone, email, dry_run, created_by_user_id, updated_by_user_id, api_payload, api_response ) VALUES ( %s, %s, %s, %s, %s, 'draft', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb ) RETURNING * """, ( booking_ref, payload.case_id, payload.customer_id, payload.contact_id, payload.service_type, payload.pickup_window_start, payload.pickup_window_end, payload.address.recipient_name, payload.address.company_name, payload.address.address_line1, payload.address.address_line2, payload.address.postal_code, payload.address.city, payload.address.country_code.upper(), payload.address.phone, payload.address.email, self.dry_run, created_by_user_id, created_by_user_id, json.dumps(payload.model_dump(mode="json"), ensure_ascii=False), json.dumps({"status": "draft_created"}, ensure_ascii=False), ), ) if not shipment_row: raise HTTPException(status_code=500, detail="Failed to create booking draft") shipment_id = int(shipment_row["id"]) for package in payload.packages: execute_query( """ INSERT INTO fedex_shipment_packages ( shipment_id, weight_kg, length_cm, width_cm, height_cm, description ) VALUES (%s, %s, %s, %s, %s, %s) """, ( shipment_id, package.weight_kg, package.length_cm, package.width_cm, package.height_cm, package.description, ), ) logger.info("✅ FedEx draft created: %s (case=%s)", booking_ref, payload.case_id) return self._shipment_row_to_dict(dict(shipment_row)) def list_bookings(self, case_id: Optional[int] = None) -> List[Dict[str, Any]]: params: List[Any] = [] where_sql = "WHERE deleted_at IS NULL" if case_id is not None: where_sql += " AND case_id = %s" params.append(case_id) rows = execute_query( f""" SELECT * FROM fedex_shipments {where_sql} ORDER BY created_at DESC LIMIT 200 """, tuple(params), ) or [] return [self._shipment_row_to_dict(dict(row)) for row in rows] def get_booking(self, booking_ref: str) -> Dict[str, Any]: row = execute_query_single( """ SELECT * FROM fedex_shipments WHERE booking_ref = %s AND deleted_at IS NULL """, (booking_ref,), ) if not row: raise HTTPException(status_code=404, detail="Booking not found") return self._shipment_row_to_dict(dict(row)) async def submit_booking(self, booking_ref: str, user_id: Optional[int]) -> Dict[str, Any]: self._assert_enabled() shipment = self.get_booking(booking_ref) if shipment["shipment_status"] not in {"draft", "failed"}: raise HTTPException(status_code=409, detail="Only draft/failed bookings can be submitted") if self.read_only: raise HTTPException(status_code=403, detail="FedEx write actions are disabled (read-only mode)") payload = { "booking_ref": shipment["booking_ref"], "service_type": shipment["service_type"], "pickup_window_start": shipment["pickup_window_start"].isoformat() if shipment.get("pickup_window_start") else None, "pickup_window_end": shipment["pickup_window_end"].isoformat() if shipment.get("pickup_window_end") else None, "recipient": { "recipient_name": shipment["recipient_name"], "company_name": shipment.get("company_name"), "address_line1": shipment["address_line1"], "address_line2": shipment.get("address_line2"), "postal_code": shipment["postal_code"], "city": shipment["city"], "country_code": shipment["country_code"], "phone": shipment.get("phone"), "email": shipment.get("email"), }, "packages": shipment["packages"], } if self.dry_run: tracking_number = f"DRYRUN-{uuid4().hex[:12].upper()}" label_url = None api_response = {"dry_run": True, "tracking_number": tracking_number} new_status = "submitted" else: try: api_response = await self.client.create_shipment(payload) except Exception as exc: execute_query( """ UPDATE fedex_shipments SET shipment_status = 'failed', api_response = %s::jsonb, updated_by_user_id = %s, updated_at = CURRENT_TIMESTAMP WHERE booking_ref = %s """, (json.dumps({"error": str(exc)}, ensure_ascii=False), user_id, booking_ref), ) raise HTTPException(status_code=502, detail="FedEx booking failed") from exc tracking_number = str(api_response.get("tracking_number") or "").strip() or None label_url = api_response.get("label_url") new_status = "booked" updated = execute_query_single( """ UPDATE fedex_shipments SET shipment_status = %s, tracking_number = COALESCE(%s, tracking_number), label_url = COALESCE(%s, label_url), submitted_at = CURRENT_TIMESTAMP, api_payload = %s::jsonb, api_response = %s::jsonb, updated_by_user_id = %s, updated_at = CURRENT_TIMESTAMP WHERE booking_ref = %s RETURNING * """, ( new_status, tracking_number, label_url, json.dumps(payload, ensure_ascii=False), json.dumps(api_response, ensure_ascii=False), user_id, booking_ref, ), ) if tracking_number: execute_query( """ INSERT INTO fedex_tracking_events ( shipment_id, status, description, event_timestamp ) VALUES (%s, %s, %s, CURRENT_TIMESTAMP) """, ( updated["id"], "submitted" if self.dry_run else "booked", "Shipment submitted from BMC Hub", ), ) return { "booking_ref": booking_ref, "status": new_status, "dry_run": self.dry_run, "tracking_number": tracking_number, "label_url": label_url, } async def get_tracking(self, booking_ref: str) -> Dict[str, Any]: self._assert_enabled() shipment = self.get_booking(booking_ref) tracking_number = shipment.get("tracking_number") if not tracking_number: events = execute_query( """ SELECT status, event_timestamp, description, location_city, location_country FROM fedex_tracking_events WHERE shipment_id = %s ORDER BY event_timestamp DESC """, (shipment["id"],), ) or [] return { "booking_ref": booking_ref, "shipment_status": shipment["shipment_status"], "tracking_number": None, "events": [dict(row) for row in events], } if self.dry_run: events = execute_query( """ SELECT status, event_timestamp, description, location_city, location_country FROM fedex_tracking_events WHERE shipment_id = %s ORDER BY event_timestamp DESC """, (shipment["id"],), ) or [] return { "booking_ref": booking_ref, "shipment_status": shipment["shipment_status"], "tracking_number": tracking_number, "events": [dict(row) for row in events], } try: provider_payload = await self.client.get_tracking(tracking_number) except Exception as exc: raise HTTPException(status_code=502, detail="Failed to fetch FedEx tracking") from exc events = parse_tracking_events(provider_payload) if events: execute_query( "DELETE FROM fedex_tracking_events WHERE shipment_id = %s", (shipment["id"],), ) for event in events: execute_query( """ INSERT INTO fedex_tracking_events ( shipment_id, status, description, event_timestamp, location_city, location_country ) VALUES ( %s, %s, %s, COALESCE(%s::timestamp, CURRENT_TIMESTAMP), %s, %s ) """, ( shipment["id"], event.get("status") or "unknown", event.get("description"), event.get("event_timestamp"), event.get("location_city"), event.get("location_country"), ), ) status = str(provider_payload.get("shipment_status") or shipment["shipment_status"]) execute_query( """ UPDATE fedex_shipments SET shipment_status = %s, api_response = %s::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = %s """, (status, json.dumps(provider_payload, ensure_ascii=False), shipment["id"]), ) current_events = execute_query( """ SELECT status, event_timestamp, description, location_city, location_country FROM fedex_tracking_events WHERE shipment_id = %s ORDER BY event_timestamp DESC """, (shipment["id"],), ) or [] return { "booking_ref": booking_ref, "shipment_status": status, "tracking_number": tracking_number, "events": [dict(row) for row in current_events], } async def cancel_booking(self, booking_ref: str, reason: Optional[str], user_id: Optional[int]) -> Dict[str, Any]: self._assert_enabled() if self.read_only: raise HTTPException(status_code=403, detail="FedEx write actions are disabled (read-only mode)") shipment = self.get_booking(booking_ref) if shipment["shipment_status"] == "cancelled": return {"booking_ref": booking_ref, "status": "cancelled", "cancelled": True} if not self.dry_run and shipment.get("tracking_number"): try: await self.client.cancel_shipment(str(shipment["tracking_number"])) except Exception as exc: raise HTTPException(status_code=502, detail="Failed to cancel shipment at FedEx") from exc execute_query( """ UPDATE fedex_shipments SET shipment_status = 'cancelled', cancel_reason = %s, updated_by_user_id = %s, updated_at = CURRENT_TIMESTAMP WHERE booking_ref = %s """, (reason, user_id, booking_ref), ) execute_query( """ INSERT INTO fedex_tracking_events (shipment_id, status, description, event_timestamp) VALUES (%s, 'cancelled', %s, CURRENT_TIMESTAMP) """, (shipment["id"], reason or "Cancelled from BMC Hub"), ) return {"booking_ref": booking_ref, "status": "cancelled", "cancelled": True} fedex_service = FedExService()