diff --git a/app/modules/fedex/backend/service.py b/app/modules/fedex/backend/service.py index 2eb49ff..a3862e3 100644 --- a/app/modules/fedex/backend/service.py +++ b/app/modules/fedex/backend/service.py @@ -1,5 +1,6 @@ import json import logging +from decimal import Decimal from datetime import datetime, timezone from typing import Any, Dict, List, Optional from uuid import uuid4 @@ -7,13 +8,184 @@ 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.core.database import execute_query, execute_query_single, table_has_column from app.modules.fedex.backend.api_client import FedExApiClient, parse_tracking_events from app.modules.fedex.models.schemas import FedExBookingCreate 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 _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_price_info(payload: Dict[str, Any]) -> tuple[Optional[float], Optional[str]]: + if not isinstance(payload, dict): + return None, None + + direct_amount = _to_float( + payload.get("total_amount") + or payload.get("totalAmount") + or payload.get("total_cost") + or payload.get("totalCost") + or payload.get("price") + or payload.get("amount") + ) + direct_currency = ( + payload.get("currency") + or payload.get("currencyCode") + or payload.get("total_cost_currency") + ) + if direct_amount is not None: + return direct_amount, str(direct_currency or "").upper() or None + + stack: List[Any] = [payload] + visited: set[int] = set() + + while stack: + node = stack.pop() + node_id = id(node) + if node_id in visited: + continue + visited.add(node_id) + + if isinstance(node, dict): + amount = _to_float(node.get("amount") or node.get("value")) + currency = node.get("currency") or node.get("currencyCode") + if amount is not None and currency: + return amount, str(currency).upper() + + # Prioritize common FedEx charge keys if present. + for key in ( + "totalNetCharge", + "totalNetFedExCharge", + "totalBaseCharge", + "totalSurcharges", + "netCharge", + ): + nested = node.get(key) + if isinstance(nested, dict): + nested_amount = _to_float(nested.get("amount") or nested.get("value")) + nested_currency = nested.get("currency") or nested.get("currencyCode") + if nested_amount is not None: + return nested_amount, str(nested_currency or "").upper() or None + + stack.extend(node.values()) + elif isinstance(node, list): + stack.extend(node) + + return None, None + + +def _extract_label_url(payload: Dict[str, Any]) -> Optional[str]: + if not isinstance(payload, dict): + return None + + direct = payload.get("label_url") or payload.get("labelUrl") or payload.get("label") + if isinstance(direct, str) and direct.strip().lower().startswith(("http://", "https://")): + return direct.strip() + + stack: List[Any] = [payload] + visited: set[int] = set() + while stack: + node = stack.pop() + node_id = id(node) + if node_id in visited: + continue + visited.add(node_id) + + if isinstance(node, dict): + for key, value in node.items(): + key_lower = str(key).lower() + if isinstance(value, str): + v = value.strip() + if v.lower().startswith(("http://", "https://")) and ( + "label" in key_lower or "document" in key_lower or "url" in key_lower + ): + return v + elif isinstance(value, (dict, list)): + stack.append(value) + elif isinstance(node, list): + stack.extend(node) + + return None + + +def _extract_tracking_number(payload: Dict[str, Any]) -> Optional[str]: + if not isinstance(payload, dict): + return None + + direct = ( + payload.get("tracking_number") + or payload.get("trackingNumber") + or payload.get("masterTrackingNumber") + ) + if direct is not None: + value = str(direct).strip() + if value: + return value + + stack: List[Any] = [payload] + visited: set[int] = set() + while stack: + node = stack.pop() + node_id = id(node) + if node_id in visited: + continue + visited.add(node_id) + + if isinstance(node, dict): + for key, value in node.items(): + key_lower = str(key).lower() + if "tracking" in key_lower and value is not None and not isinstance(value, (dict, list)): + candidate = str(value).strip() + if candidate: + return candidate + if isinstance(value, (dict, list)): + stack.append(value) + elif isinstance(node, list): + stack.extend(node) + + return None + + +def _build_tracking_url(tracking_number: Optional[str]) -> Optional[str]: + if not tracking_number: + return None + return f"https://www.fedex.com/fedextrack/?trknbr={tracking_number}" + + +def _estimate_dry_run_price(payload: Dict[str, Any]) -> tuple[float, str]: + packages = payload.get("packages") if isinstance(payload, dict) else [] + if not isinstance(packages, list) or not packages: + return 99.0, "DKK" + + total_weight = 0.0 + for p in packages: + if isinstance(p, dict): + total_weight += _to_float(p.get("weight_kg")) or 0.0 + + estimated = round(79.0 + (total_weight * 8.5), 2) + return max(estimated, 79.0), "DKK" + + class FedExService: def __init__(self) -> None: self.client = FedExApiClient() @@ -68,6 +240,39 @@ class FedExService: def _shipment_row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]: mapped = dict(row) mapped["packages"] = self._fetch_packages(int(row["id"])) + + api_response = mapped.get("api_response") + if isinstance(api_response, str): + try: + api_response = json.loads(api_response) + except Exception: + api_response = None + + if isinstance(api_response, dict): + if not mapped.get("tracking_number"): + mapped["tracking_number"] = _extract_tracking_number(api_response) + if not mapped.get("label_url"): + mapped["label_url"] = _extract_label_url(api_response) + + if mapped.get("total_amount") is None: + fallback_amount, fallback_currency = _extract_price_info(api_response) + if fallback_amount is not None: + mapped["total_amount"] = fallback_amount + if not mapped.get("currency") and fallback_currency: + mapped["currency"] = fallback_currency + + mapped["tracking_url"] = _build_tracking_url(mapped.get("tracking_number")) + + # Ensure older dry-run rows still expose useful test outputs in UI. + if mapped.get("dry_run") and mapped.get("shipment_status") in {"submitted", "booked"}: + if not mapped.get("label_url") and mapped.get("tracking_url"): + mapped["label_url"] = mapped["tracking_url"] + if mapped.get("total_amount") is None: + estimated_amount, estimated_currency = _estimate_dry_run_price({"packages": mapped.get("packages") or []}) + mapped["total_amount"] = estimated_amount + if not mapped.get("currency"): + mapped["currency"] = estimated_currency + return mapped def create_booking_draft(self, payload: FedExBookingCreate, created_by_user_id: Optional[int]) -> Dict[str, Any]: @@ -130,8 +335,8 @@ class FedExService: 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), + _json_dumps(payload.model_dump(mode="json")), + _json_dumps({"status": "draft_created"}), ), ) if not shipment_row: @@ -222,8 +427,15 @@ class FedExService: if self.dry_run: tracking_number = f"DRYRUN-{uuid4().hex[:12].upper()}" - label_url = None - api_response = {"dry_run": True, "tracking_number": tracking_number} + label_url = _build_tracking_url(tracking_number) + total_amount, currency = _estimate_dry_run_price(payload) + api_response = { + "dry_run": True, + "tracking_number": tracking_number, + "label_url": label_url, + "total_amount": total_amount, + "currency": currency, + } new_status = "submitted" else: try: @@ -238,37 +450,58 @@ class FedExService: updated_at = CURRENT_TIMESTAMP WHERE booking_ref = %s """, - (json.dumps({"error": str(exc)}, ensure_ascii=False), user_id, booking_ref), + (_json_dumps({"error": str(exc)}), 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") + tracking_number = _extract_tracking_number(api_response) + label_url = _extract_label_url(api_response) new_status = "booked" + total_amount, currency = _extract_price_info(api_response) + + has_total_amount = table_has_column("fedex_shipments", "total_amount") + has_currency = table_has_column("fedex_shipments", "currency") + + set_clauses = [ + "shipment_status = %s", + "tracking_number = COALESCE(%s, tracking_number)", + "label_url = COALESCE(%s, label_url)", + ] + update_params: List[Any] = [ + new_status, + tracking_number, + label_url, + ] + + if has_total_amount: + set_clauses.append("total_amount = COALESCE(%s, total_amount)") + update_params.append(total_amount) + if has_currency: + set_clauses.append("currency = COALESCE(%s, currency)") + update_params.append(currency) + + set_clauses.extend([ + "submitted_at = CURRENT_TIMESTAMP", + "api_payload = %s::jsonb", + "api_response = %s::jsonb", + "updated_by_user_id = %s", + "updated_at = CURRENT_TIMESTAMP", + ]) + update_params.extend([ + _json_dumps(payload), + _json_dumps(api_response), + user_id, + booking_ref, + ]) updated = execute_query_single( - """ + f""" 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 + SET {', '.join(set_clauses)} 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, - ), + tuple(update_params), ) if tracking_number: @@ -290,7 +523,10 @@ class FedExService: "status": new_status, "dry_run": self.dry_run, "tracking_number": tracking_number, + "tracking_url": _build_tracking_url(tracking_number), "label_url": label_url, + "total_amount": total_amount, + "currency": currency, } async def get_tracking(self, booking_ref: str) -> Dict[str, Any]: @@ -378,7 +614,7 @@ class FedExService: updated_at = CURRENT_TIMESTAMP WHERE id = %s """, - (status, json.dumps(provider_payload, ensure_ascii=False), shipment["id"]), + (status, _json_dumps(provider_payload), shipment["id"]), ) current_events = execute_query( diff --git a/app/modules/fedex/models/schemas.py b/app/modules/fedex/models/schemas.py index 86241dc..1233e95 100644 --- a/app/modules/fedex/models/schemas.py +++ b/app/modules/fedex/models/schemas.py @@ -51,7 +51,10 @@ class FedExBookingSubmitResponse(BaseModel): status: ShipmentStatus dry_run: bool tracking_number: Optional[str] = None + tracking_url: Optional[str] = None label_url: Optional[str] = None + total_amount: Optional[float] = None + currency: Optional[str] = None class FedExTrackingEvent(BaseModel): @@ -74,7 +77,10 @@ class FedExBookingResponse(BaseModel): city: str country_code: str tracking_number: Optional[str] = None + tracking_url: Optional[str] = None label_url: Optional[str] = None + total_amount: Optional[float] = None + currency: Optional[str] = None dry_run: bool pickup_window_start: datetime pickup_window_end: datetime diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index 1cefe68..26882d2 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -4637,7 +4637,8 @@ 'sales': 'Varekøb & salg', 'subscription': 'Abonnement', 'reminders': 'Påmindelser', - 'calendar': 'Kalender' + 'calendar': 'Kalender', + 'shipping': 'Fragt' }; caseTypeModuleDefaults = window.caseTypeModuleDefaults || {}; @@ -8007,6 +8008,13 @@