389 lines
16 KiB
Python
389 lines
16 KiB
Python
|
|
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()
|