import base64 import json import logging from datetime import datetime, timezone from decimal import Decimal 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.shipmondo.backend.api_client import ShipmondoApiClient from app.modules.shipmondo.models.schemas import ShipmondoBookingCreate logger = logging.getLogger(__name__) def _json_default(value: Any) -> Any: if isinstance(value, Decimal): return float(value) if isinstance(value, datetime): return value.isoformat() return str(value) def _json_dumps(value: Any) -> str: return json.dumps(value, ensure_ascii=False, default=_json_default) def _json_object(value: Any) -> Dict[str, Any]: if isinstance(value, dict): return value if isinstance(value, str): try: parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {} except ValueError: return {} return {} def _json_list(value: Any) -> List[Any]: if isinstance(value, list): return value if isinstance(value, str): try: parsed = json.loads(value) return parsed if isinstance(parsed, list) else [] except ValueError: return [] return [] def _to_float(value: Any) -> Optional[float]: try: if value is None or value == "": return None return float(value) except (TypeError, ValueError): return None def extract_tracking_number(payload: Dict[str, Any]) -> Optional[str]: for key in ("pkg_no", "tracking_number", "trackingNumber", "external_pkg_no"): value = payload.get(key) if value: return str(value).strip() for parcel in payload.get("parcels") or []: if not isinstance(parcel, dict): continue value = parcel.get("pkg_no") or parcel.get("tracking_number") if value: return str(value).strip() values = parcel.get("pkg_nos") if isinstance(values, list) and values: return str(values[0]).strip() return None def extract_tracking_url(payload: Dict[str, Any]) -> Optional[str]: for key in ("tracking_url", "trackingUrl", "carrier_tracking_url"): value = payload.get(key) if isinstance(value, str) and value.startswith(("https://", "http://")): return value return None def extract_price(payload: Dict[str, Any]) -> tuple[Optional[float], Optional[str]]: amount = _to_float(payload.get("price") or payload.get("total_amount") or payload.get("amount")) currency = payload.get("currency") or payload.get("currency_code") return amount, (str(currency).upper() if currency else ("DKK" if amount is not None else None)) def extract_label_base64(payload: Dict[str, Any]) -> Optional[str]: for key in ("label_base64", "labelBase64", "label"): value = payload.get(key) if isinstance(value, str) and value.strip() and not value.startswith(("http://", "https://")): return value.split(",", 1)[-1].strip() for parcel in payload.get("parcels") or []: if isinstance(parcel, dict): value = parcel.get("label_base64") or parcel.get("labelBase64") if isinstance(value, str) and value.strip(): return value.split(",", 1)[-1].strip() return None def build_shipmondo_payload(shipment: Dict[str, Any]) -> Dict[str, Any]: sender = { "type": "sender", "name": settings.SHIPMONDO_SENDER_NAME.strip(), "attention": settings.SHIPMONDO_SENDER_ATTENTION.strip() or None, "address1": settings.SHIPMONDO_SENDER_ADDRESS1.strip(), "address2": settings.SHIPMONDO_SENDER_ADDRESS2.strip() or None, "postal_code": settings.SHIPMONDO_SENDER_POSTAL_CODE.strip(), "city": settings.SHIPMONDO_SENDER_CITY.strip(), "country_code": (settings.SHIPMONDO_SENDER_COUNTRY_CODE or "DK").strip().upper(), "email": settings.SHIPMONDO_SENDER_EMAIL.strip() or None, "phone": settings.SHIPMONDO_SENDER_PHONE.strip() or None, } receiver = { "type": "receiver", "name": shipment.get("company_name") or shipment["recipient_name"], "attention": shipment["recipient_name"] if shipment.get("company_name") else None, "address1": shipment["address_line1"], "address2": shipment.get("address_line2"), "postal_code": shipment["postal_code"], "city": shipment["city"], "country_code": shipment["country_code"], "email": shipment.get("email"), "phone": shipment.get("phone"), } parcels = [] for index, parcel in enumerate(shipment.get("parcels") or [], start=1): item: Dict[str, Any] = { "quantity": 1, "weight": max(1, round(float(parcel["weight_kg"]) * 1000)), "content": parcel.get("description"), "internal_reference": f"{shipment['booking_ref']}-{index}", } for source, target in (("length_cm", "length"), ("width_cm", "width"), ("height_cm", "height")): if parcel.get(source) is not None: item[target] = max(1, round(float(parcel[source]))) parcels.append(item) return { "own_agreement": bool(shipment.get("own_agreement")), "label_format": "10x19_pdf", "print": False, "product_code": shipment["product_code"], "service_codes": ",".join(_json_list(shipment.get("service_codes"))), "automatic_select_service_point": bool(shipment.get("automatic_select_service_point")), "reference": f"Sag #{shipment['case_id']} · {shipment['booking_ref']}", "parties": [sender, receiver], "parcels": parcels, } class ShipmondoService: def __init__(self) -> None: self.client = ShipmondoApiClient() @property def enabled(self) -> bool: return bool(settings.SHIPMONDO_ENABLED) @property def read_only(self) -> bool: return bool(settings.SHIPMONDO_READ_ONLY) @property def dry_run(self) -> bool: return bool(settings.SHIPMONDO_DRY_RUN) @property def configured(self) -> bool: return self.client.configured def _assert_enabled(self) -> None: if not self.enabled: raise HTTPException(status_code=503, detail="Shipmondo-integrationen er slået fra") def _assert_sender_configured(self) -> None: required = { "navn": settings.SHIPMONDO_SENDER_NAME, "adresse": settings.SHIPMONDO_SENDER_ADDRESS1, "postnummer": settings.SHIPMONDO_SENDER_POSTAL_CODE, "by": settings.SHIPMONDO_SENDER_CITY, } missing = [label for label, value in required.items() if not str(value or "").strip()] if missing: raise HTTPException( status_code=503, detail=f"Shipmondo mangler afsenderens {', '.join(missing)} i konfigurationen", ) def _booking_ref(self) -> str: stamp = datetime.now(timezone.utc).strftime("%Y%m%d") return f"SMD-{stamp}-{uuid4().hex[:8].upper()}" def _validate_relations(self, payload: ShipmondoBookingCreate) -> None: if not execute_query_single("SELECT id FROM sag_sager WHERE id = %s", (payload.case_id,)): raise HTTPException(status_code=404, detail="Sagen findes ikke") if payload.customer_id and not execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,)): raise HTTPException(status_code=404, detail="Kunden findes ikke") if payload.contact_id and not execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,)): raise HTTPException(status_code=404, detail="Kontakten findes ikke") def _fetch_parcels(self, shipment_id: int) -> List[Dict[str, Any]]: rows = execute_query( """ SELECT weight_kg, length_cm, width_cm, height_cm, description FROM shipmondo_shipment_parcels WHERE shipment_id = %s ORDER BY id ASC """, (shipment_id,), ) or [] return [dict(row) for row in rows] def _row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]: mapped = dict(row) mapped["service_codes"] = [str(value) for value in _json_list(mapped.get("service_codes"))] mapped["parcels"] = self._fetch_parcels(int(mapped["id"])) api_response = _json_object(mapped.get("api_response")) mapped["tracking_number"] = mapped.get("tracking_number") or extract_tracking_number(api_response) mapped["tracking_url"] = mapped.get("tracking_url") or extract_tracking_url(api_response) if extract_label_base64(api_response): mapped["label_url"] = f"/api/v1/shipmondo/bookings/{mapped['booking_ref']}/label" else: label_url = api_response.get("label_url") mapped["label_url"] = label_url if isinstance(label_url, str) else None return mapped async def list_products(self, country_code: str) -> List[Dict[str, Any]]: self._assert_enabled() if not self.configured: raise HTTPException(status_code=503, detail="Shipmondo API-bruger og API-nøgle er ikke konfigureret") try: products = await self.client.list_products(country_code) except Exception as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return [product for product in products if product.get("available", True)] def create_booking_draft(self, payload: ShipmondoBookingCreate, user_id: Optional[int]) -> Dict[str, Any]: self._assert_enabled() self._validate_relations(payload) booking_ref = self._booking_ref() row = execute_query_single( """ INSERT INTO shipmondo_shipments ( booking_ref, case_id, customer_id, contact_id, product_code, product_name, carrier_code, carrier_name, service_codes, shipment_status, own_agreement, automatic_select_service_point, 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, %s, %s, %s, %s::jsonb, '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.product_code, payload.product_name, payload.carrier_code, payload.carrier_name, _json_dumps(payload.service_codes), payload.own_agreement, payload.automatic_select_service_point, 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, payload.address.phone, payload.address.email, self.dry_run, user_id, user_id, _json_dumps(payload.model_dump(mode="json")), _json_dumps({"status": "draft_created"}), ), ) if not row: raise HTTPException(status_code=500, detail="Shipmondo-draft kunne ikke oprettes") for parcel in payload.parcels: execute_query( """ INSERT INTO shipmondo_shipment_parcels (shipment_id, weight_kg, length_cm, width_cm, height_cm, description) VALUES (%s, %s, %s, %s, %s, %s) """, (row["id"], parcel.weight_kg, parcel.length_cm, parcel.width_cm, parcel.height_cm, parcel.description), ) return self.get_booking(booking_ref) def list_bookings(self, case_id: Optional[int] = None) -> List[Dict[str, Any]]: params: List[Any] = [] where = "WHERE deleted_at IS NULL" if case_id is not None: where += " AND case_id = %s" params.append(case_id) rows = execute_query( f"SELECT * FROM shipmondo_shipments {where} ORDER BY created_at DESC LIMIT 200", tuple(params), ) or [] return [self._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 shipmondo_shipments WHERE booking_ref = %s AND deleted_at IS NULL", (booking_ref,), ) if not row: raise HTTPException(status_code=404, detail="Shipmondo-forsendelsen findes ikke") return self._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="Kun drafts og fejlede bookinger kan sendes igen") if self.read_only: raise HTTPException(status_code=403, detail="Shipmondo er i skrivebeskyttet tilstand") self._assert_sender_configured() payload = build_shipmondo_payload(shipment) if self.dry_run: api_response = { "id": f"dry-{uuid4().hex[:10]}", "pkg_no": f"SMD-DRY-{uuid4().hex[:10].upper()}", "price": "69.00", "currency": "DKK", "dry_run": True, } new_status = "submitted" else: if not self.configured: raise HTTPException(status_code=503, detail="Shipmondo API-bruger og API-nøgle er ikke konfigureret") try: api_response = await self.client.create_shipment(payload) except Exception as exc: execute_query( """ UPDATE shipmondo_shipments SET shipment_status = 'failed', api_response = %s::jsonb, updated_by_user_id = %s WHERE booking_ref = %s """, (_json_dumps({"error": str(exc)}), user_id, booking_ref), ) raise HTTPException(status_code=502, detail=f"Shipmondo-booking fejlede: {exc}") from exc new_status = "booked" tracking_number = extract_tracking_number(api_response) tracking_url = extract_tracking_url(api_response) total_amount, currency = extract_price(api_response) shipmondo_id = api_response.get("id") execute_query( """ UPDATE shipmondo_shipments SET shipmondo_id = %s, shipment_status = %s, tracking_number = %s, tracking_url = %s, total_amount = %s, currency = %s, submitted_at = CURRENT_TIMESTAMP, api_payload = %s::jsonb, api_response = %s::jsonb, updated_by_user_id = %s WHERE booking_ref = %s """, ( str(shipmondo_id) if shipmondo_id is not None else None, new_status, tracking_number, tracking_url, total_amount, currency, _json_dumps(payload), _json_dumps(api_response), user_id, booking_ref, ), ) booking = self.get_booking(booking_ref) return { "booking_ref": booking_ref, "status": new_status, "dry_run": self.dry_run, "tracking_number": tracking_number, "tracking_url": tracking_url, "label_url": booking.get("label_url"), "total_amount": total_amount, "currency": currency, } def get_label_pdf(self, booking_ref: str) -> bytes: booking = self.get_booking(booking_ref) row = execute_query_single( "SELECT api_response FROM shipmondo_shipments WHERE id = %s", (booking["id"],), ) encoded = extract_label_base64(_json_object(row.get("api_response") if row else None)) if not encoded: raise HTTPException(status_code=404, detail="Forsendelsen har endnu ingen PDF-label") try: return base64.b64decode(encoded, validate=True) except (ValueError, TypeError) as exc: raise HTTPException(status_code=500, detail="Shipmondo-labelen kunne ikke læses") from exc shipmondo_service = ShipmondoService()