feat: Update sag links to include versioning in URLs across multiple templates and services

- Updated links in index_old.html, varekob_salg.html, log.html, opportunities.html, detail.html, and various frontend files to point to the new versioned sag URLs.
- Modified reminder_notification_service.py to reflect the new sag URL structure in notifications.
- Added FedEx shipment management functionality, including API client, service layer, and router for handling FedEx bookings, tracking, and cancellations.
- Created database migration for FedEx shipments, including tables for shipments, packages, and tracking events.
This commit is contained in:
Christian 2026-04-30 23:06:00 +02:00
parent ec2c8fe784
commit bd44771738
42 changed files with 1176 additions and 72 deletions

View File

@ -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)
# =====================================================

View File

@ -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)
# =====================================================

View File

@ -648,7 +648,7 @@ function renderTable(sessions) {
: `<span class="text-secondary" style="font-size:.78rem"></span>`;
const sagCell = s.sag
? `<a href="/sag/${s.sag.id}" class="badge-link badge-sag"><i class="bi bi-card-text"></i>${s.sag.titel||'Sag #'+s.sag.id}</a>`
? `<a href="/sag/${s.sag.id}/v3" class="badge-link badge-sag"><i class="bi bi-card-text"></i>${s.sag.titel||'Sag #'+s.sag.id}</a>`
: `<span class="text-secondary" style="font-size:.78rem"></span>`;
const statusBadge = isUnreg

View File

@ -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

View File

@ -2651,13 +2651,13 @@ async function loadCustomerCases() {
return `
<tr>
<td><a href="/sag/${id}" class="fw-semibold text-decoration-none">#${id}</a></td>
<td><a href="/sag/${id}/v3" class="fw-semibold text-decoration-none">#${id}</a></td>
<td>${title}</td>
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
<td><span class="badge bg-light text-dark border">${priority}</span></td>
<td>${created}</td>
<td class="text-end">
<a class="btn btn-sm btn-outline-primary" href="/sag/${id}" title="Åbn sag">
<a class="btn btn-sm btn-outline-primary" href="/sag/${id}/v3" title="Åbn sag">
<i class="bi bi-arrow-right"></i>
</a>
</td>

View File

@ -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) {

View File

@ -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) {

View File

@ -81,7 +81,7 @@
<td>{{ item.pipeline_stage or '-' }}</td>
<td>{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr.</td>
<td>{{ "%.0f"|format((item.pipeline_probability or 0)|float) }}%</td>
<td><a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary">Åbn</a></td>
<td><a href="/sag/{{ item.id }}/v3" class="btn btn-sm btn-outline-primary">Åbn</a></td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted py-4">Ingen opportunities fundet.</td></tr>
@ -102,7 +102,7 @@
<div class="fw-semibold">{{ item.titel }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.owner_name }}</div>
<div class="small text-muted">Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</div>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-secondary mt-2">Åbn</a>
<a href="/sag/{{ item.id }}/v3" class="btn btn-sm btn-outline-secondary mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen deadlines de næste 14 dage.</p>

View File

@ -2194,7 +2194,7 @@ function renderEmailDetail(email) {
</div>
${email.linked_case_id ? `
<div class="mt-3">
<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none">
<a href="/sag/${email.linked_case_id}/v3" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none">
<i class="bi bi-link-45deg me-1"></i>SAG-${email.linked_case_id}${email.linked_case_title ? `: ${escapeHtml(email.linked_case_title)}` : ''}
</a>
</div>
@ -2232,7 +2232,7 @@ function renderEmailDetail(email) {
<i class="bi bi-diagram-3 me-1"></i>Workflows
</button>
${email.linked_case_id ? `
<a class="btn btn-sm btn-outline-primary border" href="/sag/${email.linked_case_id}" title="Åbn SAG-${email.linked_case_id}">
<a class="btn btn-sm btn-outline-primary border" href="/sag/${email.linked_case_id}/v3" title="Åbn SAG-${email.linked_case_id}">
<i class="bi bi-box-arrow-up-right me-1"></i>SAG-${email.linked_case_id}
</a>
` : '<span class="triage-priority-badge">Ingen sag linket</span>'}
@ -3768,7 +3768,7 @@ function getCaseBadge(email) {
}
const title = email.linked_case_title ? ` title="${escapeHtml(email.linked_case_title)}"` : '';
return `<a href="/sag/${email.linked_case_id}" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none ms-1"${title}>SAG-${email.linked_case_id}</a>`;
return `<a href="/sag/${email.linked_case_id}/v3" class="badge bg-primary-subtle text-primary-emphasis text-decoration-none ms-1"${title}>SAG-${email.linked_case_id}</a>`;
}
function getPriorityBadge(email) {

View File

@ -243,7 +243,7 @@
</td>
<td>{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }}</td>
<td>
<a href="/sag/{{ sag.id }}" class="btn btn-sm btn-outline-primary">
<a href="/sag/{{ sag.id }}/v3" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Vis
</a>
</td>
@ -284,7 +284,7 @@
<td>{{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }}</td>
<td>
{% if entry.sag_id %}
<a href="/sag/{{ entry.sag_id }}">#{{ entry.sag_id }}</a>
<a href="/sag/{{ entry.sag_id }}/v3">#{{ entry.sag_id }}</a>
{% if entry.sag_titel %}
<br><small class="text-muted">{{ entry.sag_titel[:30] }}</small>
{% endif %}

View File

@ -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"),
}
)

View File

@ -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",

View File

View File

View File

@ -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

View File

@ -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))

View File

@ -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()

View File

View File

@ -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

View File

@ -541,7 +541,7 @@
<div class="list-group list-group-flush">
{% if cases and cases|length > 0 %}
{% for case in cases[:5] %}
<a href="/sag/{{ case.case_id }}" class="list-group-item list-group-item-action border-0 px-3 py-2">
<a href="/sag/{{ case.case_id }}/v3" class="list-group-item list-group-item-action border-0 px-3 py-2">
<div class="d-flex w-100 justify-content-between align-items-center">
<div class="text-truncate" style="max-width: 70%;">
<i class="bi bi-ticket me-1 text-muted small"></i>

View File

@ -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, [])

View File

@ -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();

View File

@ -3508,7 +3508,7 @@
<span class="fw-semibold" style="color: var(--accent);">{{ node.case.titel }}</span>
<span class="badge ms-1" style="background: var(--accent);">Aktuel</span>
{% else %}
<a href="/sag/{{ node.case.id }}" class="text-decoration-none fw-semibold">{{ node.case.titel }}</a>
<a href="/sag/{{ node.case.id }}/v3" class="text-decoration-none fw-semibold">{{ node.case.titel }}</a>
{% endif %}
</td>
<td>
@ -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
? `<div class="qa-item" style="opacity:.55;font-style:italic;" onclick="window.open('/sag/${caseId}','_blank')"><i class="bi bi-graph-up-arrow"></i>Pipeline (se sagen)</div>`
? `<div class="qa-item" style="opacity:.55;font-style:italic;" onclick="window.open('/sag/${caseId}/v3','_blank')"><i class="bi bi-graph-up-arrow"></i>Pipeline (se sagen)</div>`
: '';
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 ──────────────────────────────────────────

View File

@ -3693,6 +3693,12 @@
<span class="case-tab-count-badge" id="remindersTabCountBadge"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="shipping-tab" data-bs-target="#shipping" type="button" role="tab" data-module-tab="shipping" onclick="return activateCaseTabFromButton(event, 'shipping')">
<i class="bi bi-truck me-2"></i>Fragt
<span class="case-tab-count-badge" id="shippingTabCountBadge"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-target="#history" type="button" role="tab" data-module-tab="history" onclick="return activateCaseTabFromButton(event, 'history')">
<i class="bi bi-clock-history me-2"></i>Historik
@ -3979,7 +3985,7 @@
<span class="fw-semibold" style="color: var(--accent);">{{ node.case.titel }}</span>
<span class="badge ms-1" style="background: var(--accent);">Aktuel</span>
{% else %}
<a href="/sag/{{ node.case.id }}" class="text-decoration-none fw-semibold">{{ node.case.titel }}</a>
<a href="/sag/{{ node.case.id }}/v3" class="text-decoration-none fw-semibold">{{ node.case.titel }}</a>
{% endif %}
</td>
<td>
@ -4746,6 +4752,8 @@
} else if (tabId === 'reminders') {
if (typeof loadReminders === 'function') await loadReminders();
if (typeof loadCaseCalendar === 'function') await loadCaseCalendar();
} else if (tabId === 'shipping' && typeof loadCaseShippingTab === 'function') {
await loadCaseShippingTab();
}
} catch (tabLoadError) {
console.error('Tab data reload failed:', tabLoadError);
@ -7988,6 +7996,99 @@
</div>
</div>
<!-- Shipping Tab -->
<div class="tab-pane fade" id="shipping" role="tabpanel" tabindex="0" data-module="shipping" data-has-content="unknown">
<div class="row g-3">
<div class="col-xl-7 col-lg-8">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-truck me-2"></i>Book FedEx fragt</h6>
<span class="badge text-bg-light border">Draft → Bekræft → Book</span>
</div>
<div class="card-body">
<form id="shippingDraftForm" class="row g-3">
<div class="col-md-6">
<label class="form-label">Modtager</label>
<input id="shipRecipientName" type="text" class="form-control" value="{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else (customer.name if customer else '') }}" required>
</div>
<div class="col-md-6">
<label class="form-label">Service</label>
<select id="shipServiceType" class="form-select" required>
<option value="PRIORITY" selected>Priority</option>
<option value="ECONOMY">Economy</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label">Adresse</label>
<input id="shipAddressLine1" type="text" class="form-control" required>
</div>
<div class="col-md-4">
<label class="form-label">Postnr.</label>
<input id="shipPostalCode" type="text" class="form-control" required>
</div>
<div class="col-md-6">
<label class="form-label">By</label>
<input id="shipCity" type="text" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label">Landekode</label>
<input id="shipCountryCode" type="text" class="form-control" value="DK" maxlength="2" required>
</div>
<div class="col-md-3">
<label class="form-label">Afhentning</label>
<input id="shipPickupAt" type="datetime-local" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label">Vægt (kg)</label>
<input id="shipWeightKg" type="number" min="0.1" step="0.1" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label">Længde (cm)</label>
<input id="shipLengthCm" type="number" min="1" step="0.1" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label">Bredde (cm)</label>
<input id="shipWidthCm" type="number" min="1" step="0.1" class="form-control" required>
</div>
<div class="col-md-3">
<label class="form-label">Højde (cm)</label>
<input id="shipHeightCm" type="number" min="1" step="0.1" class="form-control" required>
</div>
<div class="col-12">
<label class="form-label">Indhold</label>
<input id="shipDescription" type="text" class="form-control" value="Hardware fra sag #{{ case.id }}" required>
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-file-earmark-plus me-1"></i>Opret draft
</button>
<button type="button" id="shipSubmitBtn" class="btn btn-outline-success" disabled>
<i class="bi bi-send-check me-1"></i>Bekræft & book
</button>
</div>
</form>
<div id="shipDraftNotice" class="small text-muted mt-3"></div>
</div>
</div>
</div>
<div class="col-xl-5 col-lg-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 text-primary"><i class="bi bi-list-check me-2"></i>Forsendelser på sag</h6>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadCaseShippingTab(true)">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<div class="card-body p-0">
<div id="shippingBookingsList" class="list-group list-group-flush">
<div class="p-3 text-muted">Indlæser...</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="history" role="tabpanel" tabindex="0" data-module="history" data-has-content="true">
<div class="history-timeline-shell">
<div class="history-timeline-toolbar">
@ -10732,6 +10833,175 @@
});
</script>
<script>
const shippingCaseId = {{ case.id }};
const shippingCustomerId = {{ customer.id if customer else 'null' }};
const shippingContactId = {{ hovedkontakt.id if hovedkontakt else 'null' }};
let shippingSelectedBookingRef = null;
function setShippingNotice(message, level = 'muted') {
const el = document.getElementById('shipDraftNotice');
if (!el) return;
el.className = `small mt-3 text-${level}`;
el.textContent = message || '';
}
function bookingStatusBadge(status) {
const s = String(status || '').toLowerCase();
if (s === 'booked' || s === 'in_transit' || s === 'delivered') return 'bg-success';
if (s === 'failed') return 'bg-danger';
if (s === 'cancelled') return 'bg-secondary';
if (s === 'submitted') return 'bg-info text-dark';
return 'bg-warning text-dark';
}
async function loadCaseShippingTab(force = false) {
const list = document.getElementById('shippingBookingsList');
if (!list) return;
if (!force && list.dataset.loaded === '1') return;
list.innerHTML = '<div class="p-3 text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter forsendelser...</div>';
try {
const res = await fetch(`/api/v1/fedex/bookings?case_id=${shippingCaseId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
const items = Array.isArray(payload?.items) ? payload.items : [];
if (!items.length) {
list.innerHTML = '<div class="p-3 text-muted">Ingen forsendelser endnu.</div>';
setModuleContentState('shipping', false);
list.dataset.loaded = '1';
return;
}
list.innerHTML = items.map((item) => {
const ts = item.created_at ? new Date(item.created_at).toLocaleString('da-DK') : '-';
const badge = bookingStatusBadge(item.shipment_status);
return `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">${item.booking_ref}</div>
<div class="small text-muted">${item.recipient_name || '-'} · ${item.city || '-'} (${item.country_code || '-'})</div>
<div class="small text-muted">${ts}</div>
</div>
<span class="badge ${badge}">${item.shipment_status || 'draft'}</span>
</div>
${item.tracking_number ? `<div class="small mt-2"><strong>Tracking:</strong> ${item.tracking_number}</div>` : ''}
</div>
`;
}).join('');
setModuleContentState('shipping', true);
list.dataset.loaded = '1';
if (typeof _setCaseTabCountBadge === 'function') {
_setCaseTabCountBadge('shippingTabCountBadge', items.length);
}
} catch (error) {
console.error('Shipping tab load failed:', error);
list.innerHTML = '<div class="p-3 text-danger">Kunne ikke hente forsendelser.</div>';
setModuleContentState('shipping', true);
}
}
async function createShippingDraft(event) {
event.preventDefault();
const pickupAtRaw = document.getElementById('shipPickupAt')?.value;
if (!pickupAtRaw) {
setShippingNotice('Vælg afhentningstidspunkt.', 'danger');
return;
}
const pickupStart = new Date(pickupAtRaw);
const pickupEnd = new Date(pickupStart.getTime() + 60 * 60 * 1000);
const payload = {
case_id: shippingCaseId,
customer_id: shippingCustomerId,
contact_id: shippingContactId,
service_type: document.getElementById('shipServiceType')?.value || 'PRIORITY',
pickup_window_start: pickupStart.toISOString(),
pickup_window_end: pickupEnd.toISOString(),
address: {
recipient_name: document.getElementById('shipRecipientName')?.value?.trim() || '',
address_line1: document.getElementById('shipAddressLine1')?.value?.trim() || '',
postal_code: document.getElementById('shipPostalCode')?.value?.trim() || '',
city: document.getElementById('shipCity')?.value?.trim() || '',
country_code: (document.getElementById('shipCountryCode')?.value?.trim() || 'DK').toUpperCase(),
},
packages: [
{
weight_kg: Number(document.getElementById('shipWeightKg')?.value || 0),
length_cm: Number(document.getElementById('shipLengthCm')?.value || 0),
width_cm: Number(document.getElementById('shipWidthCm')?.value || 0),
height_cm: Number(document.getElementById('shipHeightCm')?.value || 0),
description: document.getElementById('shipDescription')?.value?.trim() || 'Pakke',
}
]
};
try {
const res = await fetch('/api/v1/fedex/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
shippingSelectedBookingRef = data.booking_ref;
const submitBtn = document.getElementById('shipSubmitBtn');
if (submitBtn) submitBtn.disabled = false;
setShippingNotice(`Draft oprettet: ${data.booking_ref}. Tryk "Bekræft & book" for at sende til FedEx.`, 'success');
await loadCaseShippingTab(true);
} catch (error) {
console.error('Create shipping draft failed:', error);
setShippingNotice(`Fejl ved oprettelse af draft: ${error.message}`, 'danger');
}
}
async function submitShippingBooking() {
if (!shippingSelectedBookingRef) {
setShippingNotice('Opret først en draft for at kunne booke.', 'warning');
return;
}
if (!confirm(`Book forsendelse ${shippingSelectedBookingRef} hos FedEx?`)) return;
try {
const res = await fetch(`/api/v1/fedex/bookings/${encodeURIComponent(shippingSelectedBookingRef)}/submit`, {
method: 'POST'
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
const trackingInfo = data.tracking_number ? ` Tracking: ${data.tracking_number}` : '';
const dryRunInfo = data.dry_run ? ' (dry-run)' : '';
setShippingNotice(`Booking sendt.${trackingInfo}${dryRunInfo}`, 'success');
await loadCaseShippingTab(true);
} catch (error) {
console.error('Submit shipping booking failed:', error);
setShippingNotice(`Kunne ikke booke forsendelse: ${error.message}`, 'danger');
}
}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('shippingDraftForm');
if (form) {
form.addEventListener('submit', createShippingDraft);
}
const submitBtn = document.getElementById('shipSubmitBtn');
if (submitBtn) {
submitBtn.addEventListener('click', submitShippingBooking);
}
});
</script>
<!-- Modals for Solution (Inserted here) -->
<div class="modal fade" id="createSolutionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
@ -15442,7 +15712,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
? `<div class="qa-item" style="opacity:.55;font-style:italic;" onclick="window.open('/sag/${caseId}','_blank')"><i class="bi bi-graph-up-arrow"></i>Pipeline (se sagen)</div>`
? `<div class="qa-item" style="opacity:.55;font-style:italic;" onclick="window.open('/sag/${caseId}/v3','_blank')"><i class="bi bi-graph-up-arrow"></i>Pipeline (se sagen)</div>`
: '';
if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned
@ -15491,7 +15761,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 ──────────────────────────────────────────

View File

@ -238,7 +238,7 @@
<div class="button-group">
<button type="submit" class="btn-submit">Gem Ændringer</button>
<a href="/sag/{{ case.id }}" class="btn-cancel">Annuller</a>
<a href="/sag/{{ case.id }}/v3" class="btn-cancel">Annuller</a>
</div>
</form>
</div>
@ -317,7 +317,7 @@
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
document.getElementById('success').style.display = 'block';
setTimeout(() => {
window.location.href = `/sag/${caseId}`;
window.location.href = `/sag/${caseId}/v3`;
}, 1000);
} else {
const errorText = await response.text();

View File

@ -507,22 +507,22 @@
</span>
{% endif %}
</td>
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.customer_name if sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}'">
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
<div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'">
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ sag.priority if sag.priority else 'normal' }}
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}'">
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
{% if sag.ansvarlig_navn %}
{% set owner_name = sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
@ -536,10 +536,10 @@
-
{% endif %}
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
{% if sag.next_todo_title %}
<div>{{ sag.next_todo_title }}</div>
{% if sag.next_todo_due_date %}
@ -549,16 +549,16 @@
-
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary);">
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary);">
{{ sag.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary);">
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}'" style="color: var(--text-secondary);">
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary);">
{{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }}
</td>
</tr>
@ -579,25 +579,25 @@
</span>
{% endif %}
</td>
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}'">
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% for rt in all_rel_types %}
<span class="relation-badge">{{ rt }}</span>
{% endfor %}
<div class="sag-titel" style="display: inline;" {% if related_sag.beskrivelse %}title="{{ related_sag.beskrivelse }}"{% endif %}>{{ related_sag.titel }}</div>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'">
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; text-transform: capitalize;">
{{ related_sag.priority if related_sag.priority else 'normal' }}
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}'">
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% if related_sag.ansvarlig_navn %}
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
{% set owner_parts = owner_name.split() %}
@ -611,10 +611,10 @@
-
{% endif %}
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem;">
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem; white-space: normal; max-width: 240px;">
{% if related_sag.next_todo_title %}
<div>{{ related_sag.next_todo_title }}</div>
{% if related_sag.next_todo_due_date %}
@ -624,16 +624,16 @@
-
{% endif %}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary);">
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary);">
{{ related_sag.start_date.strftime('%d/%m-%Y') if related_sag.start_date else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary);">
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}'" style="color: var(--text-secondary);">
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary);">
{{ related_sag.deadline.strftime('%d/%m-%Y') if related_sag.deadline else '-' }}
</td>
</tr>

View File

@ -304,7 +304,7 @@
<div id="casesList">
{% if sager %}
{% for sag in sager %}
<a href="/sag/{{ sag.id }}" class="sag-card">
<a href="/sag/{{ sag.id }}/v3" class="sag-card">
<div class="sag-title">{{ sag.titel }}</div>
{% if sag.beskrivelse %}
<div style="color: #666; font-size: 0.9rem; margin-bottom: 0.5rem;">{{ sag.beskrivelse[:100] }}{% if sag.beskrivelse|length > 100 %}...{% endif %}</div>

View File

@ -263,7 +263,7 @@
// Case header row (clickable to expand/collapse)
const caseLink = group.sag_id
? `<a href="/sag/${group.sag_id}" class="text-decoration-none fw-bold" onclick="event.stopPropagation();">${group.sag_titel} <span class="badge bg-light text-dark border">Sag ${group.sag_id}</span></a>`
? `<a href="/sag/${group.sag_id}/v3" class="text-decoration-none fw-bold" onclick="event.stopPropagation();">${group.sag_titel} <span class="badge bg-light text-dark border">Sag ${group.sag_id}</span></a>`
: `<span class="fw-bold">${group.sag_titel}</span>`;
html += `

View File

@ -419,7 +419,7 @@ async function loadCalls() {
const sagHtml = r.sag_id
? `<div class="d-flex gap-2 align-items-center flex-wrap">
<a href="/sag/${r.sag_id}">${escapeHtml(r.sag_titel || ('Sag #' + r.sag_id))}</a>
<a href="/sag/${r.sag_id}/v3">${escapeHtml(r.sag_titel || ('Sag #' + r.sag_id))}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${Number(r.id)})">Skift link</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${Number(r.id)})">Fjern link</button>
</div>`

View File

@ -272,7 +272,7 @@ function renderTable(data) {
: '<span class="text-muted fst-italic">Ingen beskrivelse</span>';
return `
<tr class="opportunity-row" style="cursor:pointer" onclick="window.location.href='/sag/${o.id}'">
<tr class="opportunity-row" style="cursor:pointer" onclick="window.location.href='/sag/${o.id}/v3'">
<td class="fw-semibold ps-4">${escapeHtml(o.titel)}</td>
<td>${escapeHtml(o.customer_name)}</td>
<td>${stage}</td>

View File

@ -308,7 +308,7 @@ function renderTimelogs(timelogs) {
const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-';
let sourceHtml = '-';
if (t.source === 'sag' && t.source_id) {
sourceHtml = `<a href="/sag/${t.source_id}" class="text-decoration-none">Sag #${t.source_id}${t.source_title ? ' - ' + t.source_title : ''}</a>`;
sourceHtml = `<a href="/sag/${t.source_id}/v3" class="text-decoration-none">Sag #${t.source_id}${t.source_title ? ' - ' + t.source_title : ''}</a>`;
} else if (t.source === 'ticket' && t.source_id) {
const ticketLabel = t.ticket_number ? `#${t.ticket_number}` : `#${t.source_id}`;
sourceHtml = `<a href="/ticket/tickets/${t.source_id}" class="text-decoration-none">${ticketLabel} - ${t.source_title || 'Ticket'}</a>`;

View File

@ -265,7 +265,7 @@ class ReminderNotificationService:
'text': f'🔔 **{title}**',
'attachments': [{
'title': case_title,
'title_link': f"http://localhost:8000/sag/{case_id}",
'title_link': f"http://localhost:8001/sag/{case_id}/v3",
'text': message or additional_info or 'Se reminder i systemet',
'color': color_map.get(priority, color_map['normal']),
'fields': [
@ -284,7 +284,7 @@ class ReminderNotificationService:
'name': 'Åbn sag',
'type': 'button',
'text': 'Se mere',
'url': f"http://localhost:8000/sag/{case_id}"
'url': f"http://localhost:8001/sag/{case_id}/v3"
}]
}]
}
@ -337,7 +337,7 @@ class ReminderNotificationService:
'deadline': deadline,
'assigned_user': assigned_user or 'Ikke tildelt',
'additional_info': additional_info or '',
'action_url': f"http://localhost:8000/sag/{case_id}",
'action_url': f"http://localhost:8001/sag/{case_id}/v3",
'footer_date': datetime.now().strftime("%d. %B %Y")
}

View File

@ -1568,7 +1568,7 @@ window.addEventListener('unhandledrejection', function(event) {
return false;
}
window.location.href = `/sag/${sagId}`;
window.location.href = `/sag/${sagId}/v3`;
return true;
}

View File

@ -558,7 +558,7 @@
// TODO: Add tags via separate endpoint if any exist
// Redirect to case detail
window.location.href = `/sag/${newCase.id}`;
window.location.href = `/sag/${newCase.id}/v3`;
} catch (error) {
console.error('Error creating case:', error);

View File

@ -327,7 +327,7 @@ async function loadStagingOverview() {
const hubCustomer = row.hub_customer_name
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
: (row.hub_customer_id ? `#${row.hub_customer_id}` : '-');
const sag = row.hub_sag_id ? `<a href="/sag/${row.hub_sag_id}">#${row.hub_sag_id}</a>` : '-';
const sag = row.hub_sag_id ? `<a href="/sag/${row.hub_sag_id}/v3">#${row.hub_sag_id}</a>` : '-';
return `
<tr>
<td>${row.id}</td>
@ -561,7 +561,7 @@ function renderSubscriptions(subscriptions) {
tbody.innerHTML = subscriptions.map(sub => {
const intervalLabel = formatInterval(sub.billing_interval);
const statusBadge = getStatusBadge(sub.status);
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}/v3">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
const subNumber = sub.subscription_number || `#${sub.id}`;
// Show product name with item count if available

View File

@ -129,7 +129,7 @@ function renderSubscriptions(subscriptions) {
tbody.innerHTML = subscriptions.map(sub => {
const intervalLabel = formatInterval(sub.billing_interval);
const statusBadge = getStatusBadge(sub.status);
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
const sagLink = sub.sag_id ? `<a href="/sag/${sub.sag_id}/v3">${sub.sag_title || 'Sag #' + sub.sag_id}</a>` : '-';
const subNumber = sub.subscription_number || `#${sub.id}`;
return `

View File

@ -236,7 +236,7 @@ async function loadStagingOverview() {
const hubCustomer = row.hub_customer_name
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
: (row.hub_customer_id ? `#${row.hub_customer_id}` : '-');
const sag = row.hub_sag_id ? `<a href="/sag/${row.hub_sag_id}">#${row.hub_sag_id}</a>` : '-';
const sag = row.hub_sag_id ? `<a href="/sag/${row.hub_sag_id}/v3">#${row.hub_sag_id}</a>` : '-';
return `
<tr>
<td>${row.id}</td>

View File

@ -52,7 +52,7 @@ def _entity_reference_payload(entity_type: Optional[str], entity_id: Optional[in
)
if row:
title = str(row.get("titel") or "Sag").strip()
return {"entity_title": title, "entity_url": f"/sag/{eid}"}
return {"entity_title": title, "entity_url": f"/sag/{eid}/v3"}
elif etype == "email":
row = execute_query_single(

View File

@ -408,7 +408,7 @@ async function showCaseDetails(id, type) {
document.getElementById('modulePills').style.display = 'none';
document.getElementById('contactRow').style.display = 'none';
document.getElementById('kommentarFeed').innerHTML = '';
document.getElementById('detailOpenBtn').href = type === 'case' ? `/sag/${id}` : `/ticket/tickets/${id}`;
document.getElementById('detailOpenBtn').href = type === 'case' ? `/sag/${id}/v3` : `/ticket/tickets/${id}`;
window._currentDetailId = id;
window._currentDetailType = type;

View File

@ -48,7 +48,7 @@
<div class="border rounded p-2 mb-2">
<div class="fw-semibold">#{{ item.id }} · {{ item.titel }}</div>
<div class="small text-muted">{{ item.customer_name }} · Deadline: {{ item.deadline.strftime('%d/%m/%Y') if item.deadline else '-' }}</div>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary mt-2">Åbn</a>
<a href="/sag/{{ item.id }}/v3" class="btn btn-sm btn-outline-primary mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen aktive sager.</p>
@ -103,7 +103,7 @@
</thead>
<tbody>
{% for item in new_cases %}
<tr onclick="window.location.href='/sag/{{ item.id }}'" style="cursor:pointer;">
<tr onclick="window.location.href='/sag/{{ item.id }}/v3'" style="cursor:pointer;">
<td>#{{ item.id }}</td>
<td>{{ item.customer_name or '-' }}</td>
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</td>
@ -138,7 +138,7 @@
<div class="fw-semibold">{{ item.titel }}</div>
<div class="small text-muted">{{ item.customer_name }} · {{ item.pipeline_stage or 'Uden stage' }}</div>
<div class="small text-muted">Sandsynlighed: {{ "%.0f"|format(item.pipeline_probability or 0) }}%</div>
<a href="/sag/{{ item.id }}" class="btn btn-sm btn-outline-primary mt-2">Åbn</a>
<a href="/sag/{{ item.id }}/v3" class="btn btn-sm btn-outline-primary mt-2">Åbn</a>
</div>
{% else %}
<p class="text-muted mb-0">Ingen opportunities.</p>

View File

@ -131,6 +131,7 @@ from app.modules.calendar.backend import router as calendar_api
from app.modules.calendar.frontend import views as calendar_views
from app.modules.orders.backend import router as orders_api
from app.modules.orders.frontend import views as orders_views
from app.modules.fedex.backend import router as fedex_api
from app.modules.manual.backend import router as manual_api
from app.modules.manual.frontend import views as manual_views
from app.modules.bottom_bar.backend import router as bottom_bar_api
@ -448,6 +449,7 @@ app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["Devp
app.include_router(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
app.include_router(orders_api.router, prefix="/api/v1", tags=["Orders"])
app.include_router(fedex_api.router, prefix="/api/v1", tags=["FedEx"])
app.include_router(manual_api.router, prefix="/api/v1", tags=["Manual"])
app.include_router(bottom_bar_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])

View File

@ -0,0 +1,96 @@
-- Migration 181: FedEx shipments foundation (case-linked)
CREATE TABLE IF NOT EXISTS fedex_shipments (
id SERIAL PRIMARY KEY,
booking_ref VARCHAR(64) NOT NULL UNIQUE,
case_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
service_type VARCHAR(32) NOT NULL,
shipment_status VARCHAR(32) NOT NULL DEFAULT 'draft',
pickup_window_start TIMESTAMP NOT NULL,
pickup_window_end TIMESTAMP NOT NULL,
recipient_name VARCHAR(150) NOT NULL,
company_name VARCHAR(150),
address_line1 VARCHAR(200) NOT NULL,
address_line2 VARCHAR(200),
postal_code VARCHAR(20) NOT NULL,
city VARCHAR(120) NOT NULL,
country_code VARCHAR(2) NOT NULL,
phone VARCHAR(50),
email VARCHAR(150),
tracking_number VARCHAR(64),
label_url TEXT,
cancel_reason TEXT,
dry_run BOOLEAN NOT NULL DEFAULT FALSE,
submitted_at TIMESTAMP,
api_payload JSONB,
api_response JSONB,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
updated_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
CONSTRAINT fedex_shipments_service_type_check CHECK (service_type IN ('PRIORITY', 'ECONOMY')),
CONSTRAINT fedex_shipments_status_check CHECK (
shipment_status IN ('draft', 'submitted', 'booked', 'in_transit', 'delivered', 'cancelled', 'failed')
),
CONSTRAINT fedex_shipments_pickup_window_check CHECK (pickup_window_end > pickup_window_start)
);
CREATE TABLE IF NOT EXISTS fedex_shipment_packages (
id SERIAL PRIMARY KEY,
shipment_id INTEGER NOT NULL REFERENCES fedex_shipments(id) ON DELETE CASCADE,
weight_kg NUMERIC(10,3) NOT NULL,
length_cm NUMERIC(10,2) NOT NULL,
width_cm NUMERIC(10,2) NOT NULL,
height_cm NUMERIC(10,2) NOT NULL,
description VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fedex_package_weight_positive CHECK (weight_kg > 0),
CONSTRAINT fedex_package_length_positive CHECK (length_cm > 0),
CONSTRAINT fedex_package_width_positive CHECK (width_cm > 0),
CONSTRAINT fedex_package_height_positive CHECK (height_cm > 0)
);
CREATE TABLE IF NOT EXISTS fedex_tracking_events (
id SERIAL PRIMARY KEY,
shipment_id INTEGER NOT NULL REFERENCES fedex_shipments(id) ON DELETE CASCADE,
status VARCHAR(64) NOT NULL,
description TEXT,
event_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
location_city VARCHAR(120),
location_country VARCHAR(2),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_fedex_shipments_case_id ON fedex_shipments(case_id);
CREATE INDEX IF NOT EXISTS idx_fedex_shipments_status ON fedex_shipments(shipment_status);
CREATE INDEX IF NOT EXISTS idx_fedex_shipments_tracking_number ON fedex_shipments(tracking_number);
CREATE INDEX IF NOT EXISTS idx_fedex_shipments_created_at ON fedex_shipments(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_fedex_packages_shipment_id ON fedex_shipment_packages(shipment_id);
CREATE INDEX IF NOT EXISTS idx_fedex_tracking_shipment_id ON fedex_tracking_events(shipment_id);
CREATE INDEX IF NOT EXISTS idx_fedex_tracking_event_timestamp ON fedex_tracking_events(event_timestamp DESC);
CREATE OR REPLACE FUNCTION update_fedex_shipments_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS fedex_shipments_updated_at_trigger ON fedex_shipments;
CREATE TRIGGER fedex_shipments_updated_at_trigger
BEFORE UPDATE ON fedex_shipments
FOR EACH ROW
EXECUTE FUNCTION update_fedex_shipments_updated_at();