bmc_hub/app/modules/shipmondo/backend/service.py

389 lines
16 KiB
Python
Raw Normal View History

2026-08-25 01:09:45 +02:00
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()