diff --git a/.env.example b/.env.example index 7211161..cb1da34 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,20 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer ECONOMIC_READ_ONLY=true # Set to false ONLY after testing ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes + +# ===================================================== +# FedEx Integration (Optional) +# ===================================================== +FEDEX_ENABLED=false +FEDEX_API_KEY= +FEDEX_API_SECRET= +FEDEX_ACCOUNT_NUMBER= +FEDEX_BASE_URL= +FEDEX_TIMEOUT_SECONDS=20 + +# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede forsendelser +FEDEX_READ_ONLY=true +FEDEX_DRY_RUN=true # ===================================================== # Nextcloud Integration (Optional) # ===================================================== diff --git a/.env.prod.example b/.env.prod.example index ebbad72..2f636c4 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -82,6 +82,21 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here ECONOMIC_READ_ONLY=true ECONOMIC_DRY_RUN=true +# ===================================================== +# FedEx Integration - Production +# ===================================================== +FEDEX_ENABLED=false +FEDEX_API_KEY= +FEDEX_API_SECRET= +FEDEX_ACCOUNT_NUMBER= +FEDEX_BASE_URL= +FEDEX_TIMEOUT_SECONDS=20 + +# 🚨 SAFETY SWITCHES +# Start ALTID med begge sat til true i ny production deployment! +FEDEX_READ_ONLY=true +FEDEX_DRY_RUN=true + # ===================================================== # Links / Endpoints Module - Production (Optional) # ===================================================== diff --git a/app/anydesk/frontend/sessions.html b/app/anydesk/frontend/sessions.html index d5760b1..ba6a39f 100644 --- a/app/anydesk/frontend/sessions.html +++ b/app/anydesk/frontend/sessions.html @@ -648,7 +648,7 @@ function renderTable(sessions) { : `–`; const sagCell = s.sag - ? `${s.sag.titel||'Sag #'+s.sag.id}` + ? `${s.sag.titel||'Sag #'+s.sag.id}` : `–`; const statusBadge = isUnreg diff --git a/app/core/config.py b/app/core/config.py index e105dd5..65a1f1f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -288,6 +288,16 @@ class Settings(BaseSettings): SMS_SENDER: str = "BMC Networks" SMS_WEBHOOK_SECRET: str = "" + # FedEx Integration + FEDEX_ENABLED: bool = False + FEDEX_READ_ONLY: bool = True + FEDEX_DRY_RUN: bool = True + FEDEX_API_KEY: str = "" + FEDEX_API_SECRET: str = "" + FEDEX_ACCOUNT_NUMBER: str = "" + FEDEX_BASE_URL: str = "" + FEDEX_TIMEOUT_SECONDS: int = 20 + # Bottom bar module BOTTOM_BAR_ENABLED: bool = False diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index 1dd76a4..a2090b5 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -2651,13 +2651,13 @@ async function loadCustomerCases() { return ` - #${id} + #${id} ${title} ${statusLabel} ${priority} ${created} - + diff --git a/app/customers/frontend/pipeline.html b/app/customers/frontend/pipeline.html index 03d7e94..01fc629 100644 --- a/app/customers/frontend/pipeline.html +++ b/app/customers/frontend/pipeline.html @@ -289,7 +289,7 @@ async function createOpportunity() { } function goToDetail(id) { - window.location.href = `/sag/${id}`; + window.location.href = `/sag/${id}/v3`; } function formatCurrency(value, currency) { diff --git a/app/dashboard/frontend/mission_control.html b/app/dashboard/frontend/mission_control.html index 066c24c..f83d5d8 100644 --- a/app/dashboard/frontend/mission_control.html +++ b/app/dashboard/frontend/mission_control.html @@ -786,7 +786,7 @@ function getCaseHref(caseId) { const id = Number(caseId || 0); if (!Number.isFinite(id) || id <= 0) return '/sag'; - return `/sag/${id}`; + return `/sag/${id}/v3`; } function getEmailHref(emailId) { diff --git a/app/dashboard/frontend/sales.html b/app/dashboard/frontend/sales.html index beec233..610a702 100644 --- a/app/dashboard/frontend/sales.html +++ b/app/dashboard/frontend/sales.html @@ -81,7 +81,7 @@ {{ item.pipeline_stage or '-' }} {{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr. {{ "%.0f"|format((item.pipeline_probability or 0)|float) }}% - Åbn + Åbn {% else %} Ingen opportunities fundet. @@ -102,7 +102,7 @@
{{ item.titel }}
{{ item.customer_name }} · {{ item.owner_name }}
Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}
- Ă…bn + Ă…bn {% else %}

Ingen deadlines de næste 14 dage.

diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html index 72985a6..2472835 100644 --- a/app/emails/frontend/emails.html +++ b/app/emails/frontend/emails.html @@ -2194,7 +2194,7 @@ function renderEmailDetail(email) { ${email.linked_case_id ? `
- + SAG-${email.linked_case_id}${email.linked_case_title ? `: ${escapeHtml(email.linked_case_title)}` : ''}
@@ -2232,7 +2232,7 @@ function renderEmailDetail(email) { Workflows ${email.linked_case_id ? ` - + SAG-${email.linked_case_id} ` : 'Ingen sag linket'} @@ -3768,7 +3768,7 @@ function getCaseBadge(email) { } const title = email.linked_case_title ? ` title="${escapeHtml(email.linked_case_title)}"` : ''; - return `SAG-${email.linked_case_id}`; + return `SAG-${email.linked_case_id}`; } function getPriorityBadge(email) { diff --git a/app/fixed_price/frontend/detail.html b/app/fixed_price/frontend/detail.html index 7dd1a07..232008a 100644 --- a/app/fixed_price/frontend/detail.html +++ b/app/fixed_price/frontend/detail.html @@ -243,7 +243,7 @@ {{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }} - + Vis @@ -284,7 +284,7 @@ {{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }} {% if entry.sag_id %} - #{{ entry.sag_id }} + #{{ entry.sag_id }} {% if entry.sag_titel %}
{{ entry.sag_titel[:30] }} {% endif %} diff --git a/app/modules/bottom_bar/backend/service.py b/app/modules/bottom_bar/backend/service.py index 040e22b..20f6116 100644 --- a/app/modules/bottom_bar/backend/service.py +++ b/app/modules/bottom_bar/backend/service.py @@ -560,7 +560,7 @@ def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any] "sag_id": row.get("sag_id"), "case_title": row.get("case_title"), "customer_name": row.get("customer_name"), - "action": f"/sag/{row.get('sag_id')}" if row.get("sag_id") else "/sag", + "action": f"/sag/{row.get('sag_id')}/v3" if row.get("sag_id") else "/sag", "created_at": row.get("next_check_at"), } ) diff --git a/app/modules/calendar/backend/router.py b/app/modules/calendar/backend/router.py index 189cfe5..5a02e1e 100644 --- a/app/modules/calendar/backend/router.py +++ b/app/modules/calendar/backend/router.py @@ -127,7 +127,7 @@ def _get_calendar_events( "case_deadline", title, start_value, - f"/sag/{row.get('id')}", + f"/sag/{row.get('id')}/v3", { "reference_id": row.get("id"), "reference_type": "case", @@ -170,7 +170,7 @@ def _get_calendar_events( "case_deferred", title, start_value, - f"/sag/{row.get('id')}", + f"/sag/{row.get('id')}/v3", { "reference_id": row.get("id"), "reference_type": "case", @@ -224,7 +224,7 @@ def _get_calendar_events( "case_reminder", title, start_value, - f"/sag/{row.get('sag_id')}", + f"/sag/{row.get('sag_id')}/v3", { "reference_id": row.get("id"), "reference_type": "reminder", diff --git a/app/modules/fedex/__init__.py b/app/modules/fedex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/fedex/backend/__init__.py b/app/modules/fedex/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/fedex/backend/api_client.py b/app/modules/fedex/backend/api_client.py new file mode 100644 index 0000000..60861b2 --- /dev/null +++ b/app/modules/fedex/backend/api_client.py @@ -0,0 +1,87 @@ +import logging +from typing import Any, Dict, List + +import aiohttp + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class FedExApiClient: + def __init__(self) -> None: + self.base_url = (settings.FEDEX_BASE_URL or "").rstrip("/") + self.timeout_seconds = max(5, int(settings.FEDEX_TIMEOUT_SECONDS or 20)) + + def _headers(self) -> Dict[str, str]: + return { + "Content-Type": "application/json", + "X-API-KEY": settings.FEDEX_API_KEY or "", + "X-API-SECRET": settings.FEDEX_API_SECRET or "", + "X-FEDEX-ACCOUNT": settings.FEDEX_ACCOUNT_NUMBER or "", + } + + async def create_shipment(self, payload: Dict[str, Any]) -> Dict[str, Any]: + if not self.base_url: + raise RuntimeError("FedEx base URL is not configured") + + url = f"{self.base_url}/shipments" + logger.info("🚀 FedEx create shipment request sent") + + timeout = aiohttp.ClientTimeout(total=self.timeout_seconds) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, json=payload, headers=self._headers()) as response: + body = await response.text() + if response.status >= 400: + logger.error("❌ FedEx create shipment failed (%s): %s", response.status, body) + raise RuntimeError(f"FedEx create shipment failed: HTTP {response.status}") + return await response.json() + + async def get_tracking(self, tracking_number: str) -> Dict[str, Any]: + if not self.base_url: + raise RuntimeError("FedEx base URL is not configured") + + url = f"{self.base_url}/tracking/{tracking_number}" + timeout = aiohttp.ClientTimeout(total=self.timeout_seconds) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url, headers=self._headers()) as response: + body = await response.text() + if response.status >= 400: + logger.error("❌ FedEx tracking failed (%s): %s", response.status, body) + raise RuntimeError(f"FedEx tracking failed: HTTP {response.status}") + return await response.json() + + async def cancel_shipment(self, tracking_number: str) -> Dict[str, Any]: + if not self.base_url: + raise RuntimeError("FedEx base URL is not configured") + + url = f"{self.base_url}/shipments/{tracking_number}/cancel" + timeout = aiohttp.ClientTimeout(total=self.timeout_seconds) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, headers=self._headers()) as response: + body = await response.text() + if response.status >= 400: + logger.error("❌ FedEx cancel failed (%s): %s", response.status, body) + raise RuntimeError(f"FedEx cancel failed: HTTP {response.status}") + return await response.json() + + +def parse_tracking_events(payload: Dict[str, Any]) -> List[Dict[str, Any]]: + raw_events = payload.get("events") or [] + if not isinstance(raw_events, list): + return [] + + normalized: List[Dict[str, Any]] = [] + for event in raw_events: + if not isinstance(event, dict): + continue + normalized.append( + { + "status": str(event.get("status") or "unknown"), + "description": event.get("description"), + "event_timestamp": event.get("event_timestamp") or event.get("timestamp"), + "location_city": event.get("location_city") or event.get("city"), + "location_country": event.get("location_country") or event.get("country"), + } + ) + return normalized diff --git a/app/modules/fedex/backend/router.py b/app/modules/fedex/backend/router.py new file mode 100644 index 0000000..c7d1ef1 --- /dev/null +++ b/app/modules/fedex/backend/router.py @@ -0,0 +1,66 @@ +from typing import Optional + +from fastapi import APIRouter, Query, Request + +from app.modules.fedex.backend.service import fedex_service +from app.modules.fedex.models.schemas import ( + FedExBookingCreate, + FedExBookingListResponse, + FedExBookingResponse, + FedExBookingSubmitResponse, + FedExCancelRequest, + FedExCancelResponse, + FedExTrackingResponse, +) + +router = APIRouter() + + +def _user_id_from_request(request: Request) -> Optional[int]: + raw_user_id = getattr(request.state, "user_id", None) + if raw_user_id is None: + return None + try: + return int(raw_user_id) + except (TypeError, ValueError): + return None + + +@router.get("/fedex/config") +async def fedex_config() -> dict: + return { + "enabled": fedex_service.enabled, + "read_only": fedex_service.read_only, + "dry_run": fedex_service.dry_run, + } + + +@router.post("/fedex/bookings", response_model=FedExBookingResponse) +async def create_booking(payload: FedExBookingCreate, request: Request): + booking = fedex_service.create_booking_draft(payload, _user_id_from_request(request)) + return booking + + +@router.get("/fedex/bookings", response_model=FedExBookingListResponse) +async def list_bookings(case_id: Optional[int] = Query(default=None, gt=0)): + return {"items": fedex_service.list_bookings(case_id=case_id)} + + +@router.get("/fedex/bookings/{booking_ref}", response_model=FedExBookingResponse) +async def get_booking(booking_ref: str): + return fedex_service.get_booking(booking_ref) + + +@router.post("/fedex/bookings/{booking_ref}/submit", response_model=FedExBookingSubmitResponse) +async def submit_booking(booking_ref: str, request: Request): + return await fedex_service.submit_booking(booking_ref, _user_id_from_request(request)) + + +@router.get("/fedex/bookings/{booking_ref}/tracking", response_model=FedExTrackingResponse) +async def get_tracking(booking_ref: str): + return await fedex_service.get_tracking(booking_ref) + + +@router.post("/fedex/bookings/{booking_ref}/cancel", response_model=FedExCancelResponse) +async def cancel_booking(booking_ref: str, payload: FedExCancelRequest, request: Request): + return await fedex_service.cancel_booking(booking_ref, payload.reason, _user_id_from_request(request)) diff --git a/app/modules/fedex/backend/service.py b/app/modules/fedex/backend/service.py new file mode 100644 index 0000000..2eb49ff --- /dev/null +++ b/app/modules/fedex/backend/service.py @@ -0,0 +1,439 @@ +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() diff --git a/app/modules/fedex/models/__init__.py b/app/modules/fedex/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/modules/fedex/models/schemas.py b/app/modules/fedex/models/schemas.py new file mode 100644 index 0000000..86241dc --- /dev/null +++ b/app/modules/fedex/models/schemas.py @@ -0,0 +1,105 @@ +from datetime import datetime +from typing import List, Optional, Literal + +from pydantic import BaseModel, Field + + +ShipmentStatus = Literal[ + "draft", + "submitted", + "booked", + "in_transit", + "delivered", + "cancelled", + "failed", +] + + +class FedExAddress(BaseModel): + recipient_name: str = Field(min_length=2, max_length=150) + company_name: Optional[str] = Field(default=None, max_length=150) + address_line1: str = Field(min_length=2, max_length=200) + address_line2: Optional[str] = Field(default=None, max_length=200) + postal_code: str = Field(min_length=2, max_length=20) + city: str = Field(min_length=2, max_length=120) + country_code: str = Field(min_length=2, max_length=2) + phone: Optional[str] = Field(default=None, max_length=50) + email: Optional[str] = Field(default=None, max_length=150) + + +class FedExPackageInput(BaseModel): + weight_kg: float = Field(gt=0, le=2000) + length_cm: float = Field(gt=0, le=400) + width_cm: float = Field(gt=0, le=400) + height_cm: float = Field(gt=0, le=400) + description: str = Field(min_length=1, max_length=255) + + +class FedExBookingCreate(BaseModel): + case_id: int = Field(gt=0) + customer_id: Optional[int] = Field(default=None, gt=0) + contact_id: Optional[int] = Field(default=None, gt=0) + service_type: Literal["PRIORITY", "ECONOMY"] = "PRIORITY" + pickup_window_start: datetime + pickup_window_end: datetime + address: FedExAddress + packages: List[FedExPackageInput] = Field(min_length=1, max_length=30) + + +class FedExBookingSubmitResponse(BaseModel): + booking_ref: str + status: ShipmentStatus + dry_run: bool + tracking_number: Optional[str] = None + label_url: Optional[str] = None + + +class FedExTrackingEvent(BaseModel): + status: str + event_timestamp: datetime + description: Optional[str] = None + location_city: Optional[str] = None + location_country: Optional[str] = None + + +class FedExBookingResponse(BaseModel): + id: int + booking_ref: str + case_id: int + customer_id: Optional[int] = None + contact_id: Optional[int] = None + service_type: str + shipment_status: ShipmentStatus + recipient_name: str + city: str + country_code: str + tracking_number: Optional[str] = None + label_url: Optional[str] = None + dry_run: bool + pickup_window_start: datetime + pickup_window_end: datetime + submitted_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + packages: List[FedExPackageInput] = Field(default_factory=list) + + +class FedExBookingListResponse(BaseModel): + items: List[FedExBookingResponse] + + +class FedExTrackingResponse(BaseModel): + booking_ref: str + shipment_status: ShipmentStatus + tracking_number: Optional[str] = None + events: List[FedExTrackingEvent] = Field(default_factory=list) + + +class FedExCancelRequest(BaseModel): + reason: Optional[str] = Field(default=None, max_length=400) + + +class FedExCancelResponse(BaseModel): + booking_ref: str + status: ShipmentStatus + cancelled: bool diff --git a/app/modules/hardware/templates/detail.html b/app/modules/hardware/templates/detail.html index a198623..42011bd 100644 --- a/app/modules/hardware/templates/detail.html +++ b/app/modules/hardware/templates/detail.html @@ -541,7 +541,7 @@
{% if cases and cases|length > 0 %} {% for case in cases[:5] %} - +
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index df9fadb..12240a6 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -2415,7 +2415,7 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True): "message": row.get("message"), "event_kind": row.get("event_type") or "reminder", "start": start_value.isoformat(), - "url": f"/sag/{row['sag_id']}" + "url": f"/sag/{row['sag_id']}/v3" }) for row in case_dates: @@ -2425,7 +2425,7 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True): "title": f"Deadline: {row.get('titel')}", "event_kind": "deadline", "start": row["deadline"].isoformat(), - "url": f"/sag/{row['id']}" + "url": f"/sag/{row['id']}/v3" }) if row.get("deferred_until"): events_by_case[row["id"]].append({ @@ -2433,7 +2433,7 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True): "title": f"Deferred: {row.get('titel')}", "event_kind": "deferred", "start": row["deferred_until"].isoformat(), - "url": f"/sag/{row['id']}" + "url": f"/sag/{row['id']}/v3" }) current_events = events_by_case.get(sag_id, []) diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index ba5dfa9..659542f 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -1035,7 +1035,7 @@ document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer..."; setTimeout(() => { - window.location.href = `/sag/${result.id}`; + window.location.href = `/sag/${result.id}/v3`; }, 1000); } else { const errorText = await response.text(); diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index bbeeaba..9246c37 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -3508,7 +3508,7 @@ {{ node.case.titel }} Aktuel {% else %} - {{ node.case.titel }} + {{ node.case.titel }} {% endif %} @@ -14611,7 +14611,7 @@ // Filter: hide pipeline item if case already has one; show "Se pipeline" link instead const items = QA_ITEMS.filter(i => i.action !== 'pipeline' || !hasPipeline); const extra = hasPipeline - ? `
Pipeline (se sagen)
` + ? `
Pipeline (se sagen)
` : ''; if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned @@ -14660,7 +14660,7 @@ else if (action === 'solution') openRelSolutionModal(caseId, caseTitle); else if (action === 'sales') openRelSalesModal(caseId, caseTitle); else if (action === 'subscription') openRelSubscriptionModal(caseId, caseTitle); - else window.open(`/sag/${caseId}`, '_blank'); + else window.open(`/sag/${caseId}/v3`, '_blank'); }; // ── Quick Pipeline modal ────────────────────────────────────────── diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index 01f4a92..1cefe68 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -3693,6 +3693,12 @@ +
+ +
+
+
+
+
+
Book FedEx fragt
+ Draft → Bekræft → Book +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+
Forsendelser pĂĄ sag
+ +
+
+
+
Indlæser...
+
+
+
+
+
+
+
@@ -10732,6 +10833,175 @@ }); + +