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:
parent
ec2c8fe784
commit
bd44771738
14
.env.example
14
.env.example
@ -64,6 +64,20 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_agreement_grant_token_here
|
|||||||
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede ændringer
|
||||||
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
ECONOMIC_READ_ONLY=true # Set to false ONLY after testing
|
||||||
ECONOMIC_DRY_RUN=true # Set to false ONLY when ready for production writes
|
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)
|
# Nextcloud Integration (Optional)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
@ -82,6 +82,21 @@ ECONOMIC_AGREEMENT_GRANT_TOKEN=your_production_grant_here
|
|||||||
ECONOMIC_READ_ONLY=true
|
ECONOMIC_READ_ONLY=true
|
||||||
ECONOMIC_DRY_RUN=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)
|
# Links / Endpoints Module - Production (Optional)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
@ -648,7 +648,7 @@ function renderTable(sessions) {
|
|||||||
: `<span class="text-secondary" style="font-size:.78rem">–</span>`;
|
: `<span class="text-secondary" style="font-size:.78rem">–</span>`;
|
||||||
|
|
||||||
const sagCell = s.sag
|
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>`;
|
: `<span class="text-secondary" style="font-size:.78rem">–</span>`;
|
||||||
|
|
||||||
const statusBadge = isUnreg
|
const statusBadge = isUnreg
|
||||||
|
|||||||
@ -288,6 +288,16 @@ class Settings(BaseSettings):
|
|||||||
SMS_SENDER: str = "BMC Networks"
|
SMS_SENDER: str = "BMC Networks"
|
||||||
SMS_WEBHOOK_SECRET: str = ""
|
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 module
|
||||||
BOTTOM_BAR_ENABLED: bool = False
|
BOTTOM_BAR_ENABLED: bool = False
|
||||||
|
|
||||||
|
|||||||
@ -2651,13 +2651,13 @@ async function loadCustomerCases() {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<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>${title}</td>
|
||||||
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
|
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
|
||||||
<td><span class="badge bg-light text-dark border">${priority}</span></td>
|
<td><span class="badge bg-light text-dark border">${priority}</span></td>
|
||||||
<td>${created}</td>
|
<td>${created}</td>
|
||||||
<td class="text-end">
|
<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>
|
<i class="bi bi-arrow-right"></i>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -289,7 +289,7 @@ async function createOpportunity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goToDetail(id) {
|
function goToDetail(id) {
|
||||||
window.location.href = `/sag/${id}`;
|
window.location.href = `/sag/${id}/v3`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value, currency) {
|
function formatCurrency(value, currency) {
|
||||||
|
|||||||
@ -786,7 +786,7 @@
|
|||||||
function getCaseHref(caseId) {
|
function getCaseHref(caseId) {
|
||||||
const id = Number(caseId || 0);
|
const id = Number(caseId || 0);
|
||||||
if (!Number.isFinite(id) || id <= 0) return '/sag';
|
if (!Number.isFinite(id) || id <= 0) return '/sag';
|
||||||
return `/sag/${id}`;
|
return `/sag/${id}/v3`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEmailHref(emailId) {
|
function getEmailHref(emailId) {
|
||||||
|
|||||||
@ -81,7 +81,7 @@
|
|||||||
<td>{{ item.pipeline_stage or '-' }}</td>
|
<td>{{ item.pipeline_stage or '-' }}</td>
|
||||||
<td>{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr.</td>
|
<td>{{ "{:,.0f}".format((item.pipeline_amount or 0)|float).replace(',', '.') }} kr.</td>
|
||||||
<td>{{ "%.0f"|format((item.pipeline_probability or 0)|float) }}%</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>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr><td colspan="7" class="text-center text-muted py-4">Ingen opportunities fundet.</td></tr>
|
<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="fw-semibold">{{ item.titel }}</div>
|
||||||
<div class="small text-muted">{{ item.customer_name }} · {{ item.owner_name }}</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>
|
<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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted mb-0">Ingen deadlines de næste 14 dage.</p>
|
<p class="text-muted mb-0">Ingen deadlines de næste 14 dage.</p>
|
||||||
|
|||||||
@ -2194,7 +2194,7 @@ function renderEmailDetail(email) {
|
|||||||
</div>
|
</div>
|
||||||
${email.linked_case_id ? `
|
${email.linked_case_id ? `
|
||||||
<div class="mt-3">
|
<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)}` : ''}
|
<i class="bi bi-link-45deg me-1"></i>SAG-${email.linked_case_id}${email.linked_case_title ? `: ${escapeHtml(email.linked_case_title)}` : ''}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -2232,7 +2232,7 @@ function renderEmailDetail(email) {
|
|||||||
<i class="bi bi-diagram-3 me-1"></i>Workflows
|
<i class="bi bi-diagram-3 me-1"></i>Workflows
|
||||||
</button>
|
</button>
|
||||||
${email.linked_case_id ? `
|
${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}
|
<i class="bi bi-box-arrow-up-right me-1"></i>SAG-${email.linked_case_id}
|
||||||
</a>
|
</a>
|
||||||
` : '<span class="triage-priority-badge">Ingen sag linket</span>'}
|
` : '<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)}"` : '';
|
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) {
|
function getPriorityBadge(email) {
|
||||||
|
|||||||
@ -243,7 +243,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }}</td>
|
<td>{{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }}</td>
|
||||||
<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
|
<i class="bi bi-eye"></i> Vis
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@ -284,7 +284,7 @@
|
|||||||
<td>{{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }}</td>
|
<td>{{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if entry.sag_id %}
|
{% 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 %}
|
{% if entry.sag_titel %}
|
||||||
<br><small class="text-muted">{{ entry.sag_titel[:30] }}</small>
|
<br><small class="text-muted">{{ entry.sag_titel[:30] }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -560,7 +560,7 @@ def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]
|
|||||||
"sag_id": row.get("sag_id"),
|
"sag_id": row.get("sag_id"),
|
||||||
"case_title": row.get("case_title"),
|
"case_title": row.get("case_title"),
|
||||||
"customer_name": row.get("customer_name"),
|
"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"),
|
"created_at": row.get("next_check_at"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -127,7 +127,7 @@ def _get_calendar_events(
|
|||||||
"case_deadline",
|
"case_deadline",
|
||||||
title,
|
title,
|
||||||
start_value,
|
start_value,
|
||||||
f"/sag/{row.get('id')}",
|
f"/sag/{row.get('id')}/v3",
|
||||||
{
|
{
|
||||||
"reference_id": row.get("id"),
|
"reference_id": row.get("id"),
|
||||||
"reference_type": "case",
|
"reference_type": "case",
|
||||||
@ -170,7 +170,7 @@ def _get_calendar_events(
|
|||||||
"case_deferred",
|
"case_deferred",
|
||||||
title,
|
title,
|
||||||
start_value,
|
start_value,
|
||||||
f"/sag/{row.get('id')}",
|
f"/sag/{row.get('id')}/v3",
|
||||||
{
|
{
|
||||||
"reference_id": row.get("id"),
|
"reference_id": row.get("id"),
|
||||||
"reference_type": "case",
|
"reference_type": "case",
|
||||||
@ -224,7 +224,7 @@ def _get_calendar_events(
|
|||||||
"case_reminder",
|
"case_reminder",
|
||||||
title,
|
title,
|
||||||
start_value,
|
start_value,
|
||||||
f"/sag/{row.get('sag_id')}",
|
f"/sag/{row.get('sag_id')}/v3",
|
||||||
{
|
{
|
||||||
"reference_id": row.get("id"),
|
"reference_id": row.get("id"),
|
||||||
"reference_type": "reminder",
|
"reference_type": "reminder",
|
||||||
|
|||||||
0
app/modules/fedex/__init__.py
Normal file
0
app/modules/fedex/__init__.py
Normal file
0
app/modules/fedex/backend/__init__.py
Normal file
0
app/modules/fedex/backend/__init__.py
Normal file
87
app/modules/fedex/backend/api_client.py
Normal file
87
app/modules/fedex/backend/api_client.py
Normal 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
|
||||||
66
app/modules/fedex/backend/router.py
Normal file
66
app/modules/fedex/backend/router.py
Normal 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))
|
||||||
439
app/modules/fedex/backend/service.py
Normal file
439
app/modules/fedex/backend/service.py
Normal 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()
|
||||||
0
app/modules/fedex/models/__init__.py
Normal file
0
app/modules/fedex/models/__init__.py
Normal file
105
app/modules/fedex/models/schemas.py
Normal file
105
app/modules/fedex/models/schemas.py
Normal 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
|
||||||
@ -541,7 +541,7 @@
|
|||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
{% if cases and cases|length > 0 %}
|
{% if cases and cases|length > 0 %}
|
||||||
{% for case in cases[:5] %}
|
{% 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="d-flex w-100 justify-content-between align-items-center">
|
||||||
<div class="text-truncate" style="max-width: 70%;">
|
<div class="text-truncate" style="max-width: 70%;">
|
||||||
<i class="bi bi-ticket me-1 text-muted small"></i>
|
<i class="bi bi-ticket me-1 text-muted small"></i>
|
||||||
|
|||||||
@ -2415,7 +2415,7 @@ async def get_case_calendar_events(sag_id: int, include_children: bool = True):
|
|||||||
"message": row.get("message"),
|
"message": row.get("message"),
|
||||||
"event_kind": row.get("event_type") or "reminder",
|
"event_kind": row.get("event_type") or "reminder",
|
||||||
"start": start_value.isoformat(),
|
"start": start_value.isoformat(),
|
||||||
"url": f"/sag/{row['sag_id']}"
|
"url": f"/sag/{row['sag_id']}/v3"
|
||||||
})
|
})
|
||||||
|
|
||||||
for row in case_dates:
|
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')}",
|
"title": f"Deadline: {row.get('titel')}",
|
||||||
"event_kind": "deadline",
|
"event_kind": "deadline",
|
||||||
"start": row["deadline"].isoformat(),
|
"start": row["deadline"].isoformat(),
|
||||||
"url": f"/sag/{row['id']}"
|
"url": f"/sag/{row['id']}/v3"
|
||||||
})
|
})
|
||||||
if row.get("deferred_until"):
|
if row.get("deferred_until"):
|
||||||
events_by_case[row["id"]].append({
|
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')}",
|
"title": f"Deferred: {row.get('titel')}",
|
||||||
"event_kind": "deferred",
|
"event_kind": "deferred",
|
||||||
"start": row["deferred_until"].isoformat(),
|
"start": row["deferred_until"].isoformat(),
|
||||||
"url": f"/sag/{row['id']}"
|
"url": f"/sag/{row['id']}/v3"
|
||||||
})
|
})
|
||||||
|
|
||||||
current_events = events_by_case.get(sag_id, [])
|
current_events = events_by_case.get(sag_id, [])
|
||||||
|
|||||||
@ -1035,7 +1035,7 @@
|
|||||||
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
|
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/sag/${result.id}`;
|
window.location.href = `/sag/${result.id}/v3`;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
|
|||||||
@ -3508,7 +3508,7 @@
|
|||||||
<span class="fw-semibold" style="color: var(--accent);">{{ node.case.titel }}</span>
|
<span class="fw-semibold" style="color: var(--accent);">{{ node.case.titel }}</span>
|
||||||
<span class="badge ms-1" style="background: var(--accent);">Aktuel</span>
|
<span class="badge ms-1" style="background: var(--accent);">Aktuel</span>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -14611,7 +14611,7 @@
|
|||||||
// Filter: hide pipeline item if case already has one; show "Se pipeline" link instead
|
// 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 items = QA_ITEMS.filter(i => i.action !== 'pipeline' || !hasPipeline);
|
||||||
const extra = 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
|
if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned
|
||||||
@ -14660,7 +14660,7 @@
|
|||||||
else if (action === 'solution') openRelSolutionModal(caseId, caseTitle);
|
else if (action === 'solution') openRelSolutionModal(caseId, caseTitle);
|
||||||
else if (action === 'sales') openRelSalesModal(caseId, caseTitle);
|
else if (action === 'sales') openRelSalesModal(caseId, caseTitle);
|
||||||
else if (action === 'subscription') openRelSubscriptionModal(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 ──────────────────────────────────────────
|
// ── Quick Pipeline modal ──────────────────────────────────────────
|
||||||
|
|||||||
@ -3693,6 +3693,12 @@
|
|||||||
<span class="case-tab-count-badge" id="remindersTabCountBadge"></span>
|
<span class="case-tab-count-badge" id="remindersTabCountBadge"></span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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">
|
<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')">
|
<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
|
<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="fw-semibold" style="color: var(--accent);">{{ node.case.titel }}</span>
|
||||||
<span class="badge ms-1" style="background: var(--accent);">Aktuel</span>
|
<span class="badge ms-1" style="background: var(--accent);">Aktuel</span>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@ -4746,6 +4752,8 @@
|
|||||||
} else if (tabId === 'reminders') {
|
} else if (tabId === 'reminders') {
|
||||||
if (typeof loadReminders === 'function') await loadReminders();
|
if (typeof loadReminders === 'function') await loadReminders();
|
||||||
if (typeof loadCaseCalendar === 'function') await loadCaseCalendar();
|
if (typeof loadCaseCalendar === 'function') await loadCaseCalendar();
|
||||||
|
} else if (tabId === 'shipping' && typeof loadCaseShippingTab === 'function') {
|
||||||
|
await loadCaseShippingTab();
|
||||||
}
|
}
|
||||||
} catch (tabLoadError) {
|
} catch (tabLoadError) {
|
||||||
console.error('Tab data reload failed:', tabLoadError);
|
console.error('Tab data reload failed:', tabLoadError);
|
||||||
@ -7988,6 +7996,99 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="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-shell">
|
||||||
<div class="history-timeline-toolbar">
|
<div class="history-timeline-toolbar">
|
||||||
@ -10732,6 +10833,175 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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) -->
|
<!-- Modals for Solution (Inserted here) -->
|
||||||
<div class="modal fade" id="createSolutionModal" tabindex="-1" aria-hidden="true">
|
<div class="modal fade" id="createSolutionModal" tabindex="-1" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
@ -15442,7 +15712,7 @@
|
|||||||
// Filter: hide pipeline item if case already has one; show "Se pipeline" link instead
|
// 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 items = QA_ITEMS.filter(i => i.action !== 'pipeline' || !hasPipeline);
|
||||||
const extra = 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
|
if (!_openPopover || _openPopover !== menu) return; // closed before fetch returned
|
||||||
@ -15491,7 +15761,7 @@
|
|||||||
else if (action === 'solution') openRelSolutionModal(caseId, caseTitle);
|
else if (action === 'solution') openRelSolutionModal(caseId, caseTitle);
|
||||||
else if (action === 'sales') openRelSalesModal(caseId, caseTitle);
|
else if (action === 'sales') openRelSalesModal(caseId, caseTitle);
|
||||||
else if (action === 'subscription') openRelSubscriptionModal(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 ──────────────────────────────────────────
|
// ── Quick Pipeline modal ──────────────────────────────────────────
|
||||||
|
|||||||
@ -238,7 +238,7 @@
|
|||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button type="submit" class="btn-submit">Gem Ændringer</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -317,7 +317,7 @@
|
|||||||
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
|
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
|
||||||
document.getElementById('success').style.display = 'block';
|
document.getElementById('success').style.display = 'block';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/sag/${caseId}`;
|
window.location.href = `/sag/${caseId}/v3`;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
|
|||||||
@ -507,22 +507,22 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 '-' }}
|
{{ sag.customer_name if sag.customer_name else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
|
||||||
</td>
|
</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>
|
<div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
|
||||||
</td>
|
</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>
|
<span class="badge bg-light text-dark border">{{ sag.template_key or sag.type or 'ticket' }}</span>
|
||||||
</td>
|
</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' }}
|
{{ sag.priority if sag.priority else 'normal' }}
|
||||||
</td>
|
</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 %}
|
{% if sag.ansvarlig_navn %}
|
||||||
{% set owner_name = sag.ansvarlig_navn.strip() %}
|
{% set owner_name = sag.ansvarlig_navn.strip() %}
|
||||||
{% set owner_parts = owner_name.split() %}
|
{% set owner_parts = owner_name.split() %}
|
||||||
@ -536,10 +536,10 @@
|
|||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 '-' }}
|
{{ sag.assigned_group_name if sag.assigned_group_name else '-' }}
|
||||||
</td>
|
</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 %}
|
{% if sag.next_todo_title %}
|
||||||
<div>{{ sag.next_todo_title }}</div>
|
<div>{{ sag.next_todo_title }}</div>
|
||||||
{% if sag.next_todo_due_date %}
|
{% if sag.next_todo_due_date %}
|
||||||
@ -549,16 +549,16 @@
|
|||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 '-' }}
|
{{ sag.created_at.strftime('%d/%m-%Y') if sag.created_at else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ sag.start_date.strftime('%d/%m-%Y') if sag.start_date else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ sag.deferred_until.strftime('%d/%m-%Y') if sag.deferred_until else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ sag.deadline.strftime('%d/%m-%Y') if sag.deadline else '-' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -579,25 +579,25 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 '-' }}
|
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
|
||||||
</td>
|
</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 %}
|
{% for rt in all_rel_types %}
|
||||||
<span class="relation-badge">{{ rt }}</span>
|
<span class="relation-badge">{{ rt }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="sag-titel" style="display: inline;" {% if related_sag.beskrivelse %}title="{{ related_sag.beskrivelse }}"{% endif %}>{{ related_sag.titel }}</div>
|
<div class="sag-titel" style="display: inline;" {% if related_sag.beskrivelse %}title="{{ related_sag.beskrivelse }}"{% endif %}>{{ related_sag.titel }}</div>
|
||||||
</td>
|
</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>
|
<span class="badge bg-light text-dark border">{{ related_sag.template_key or related_sag.type or 'ticket' }}</span>
|
||||||
</td>
|
</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' }}
|
{{ related_sag.priority if related_sag.priority else 'normal' }}
|
||||||
</td>
|
</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 %}
|
{% if related_sag.ansvarlig_navn %}
|
||||||
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
|
{% set owner_name = related_sag.ansvarlig_navn.strip() %}
|
||||||
{% set owner_parts = owner_name.split() %}
|
{% set owner_parts = owner_name.split() %}
|
||||||
@ -611,10 +611,10 @@
|
|||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 '-' }}
|
{{ related_sag.assigned_group_name if related_sag.assigned_group_name else '-' }}
|
||||||
</td>
|
</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 %}
|
{% if related_sag.next_todo_title %}
|
||||||
<div>{{ related_sag.next_todo_title }}</div>
|
<div>{{ related_sag.next_todo_title }}</div>
|
||||||
{% if related_sag.next_todo_due_date %}
|
{% if related_sag.next_todo_due_date %}
|
||||||
@ -624,16 +624,16 @@
|
|||||||
-
|
-
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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 '-' }}
|
{{ related_sag.created_at.strftime('%d/%m-%Y') if related_sag.created_at else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ related_sag.start_date.strftime('%d/%m-%Y') if related_sag.start_date else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ related_sag.deferred_until.strftime('%d/%m-%Y') if related_sag.deferred_until else '-' }}
|
||||||
</td>
|
</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 '-' }}
|
{{ related_sag.deadline.strftime('%d/%m-%Y') if related_sag.deadline else '-' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -304,7 +304,7 @@
|
|||||||
<div id="casesList">
|
<div id="casesList">
|
||||||
{% if sager %}
|
{% if sager %}
|
||||||
{% for sag in 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>
|
<div class="sag-title">{{ sag.titel }}</div>
|
||||||
{% if sag.beskrivelse %}
|
{% 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>
|
<div style="color: #666; font-size: 0.9rem; margin-bottom: 0.5rem;">{{ sag.beskrivelse[:100] }}{% if sag.beskrivelse|length > 100 %}...{% endif %}</div>
|
||||||
|
|||||||
@ -263,7 +263,7 @@
|
|||||||
|
|
||||||
// Case header row (clickable to expand/collapse)
|
// Case header row (clickable to expand/collapse)
|
||||||
const caseLink = group.sag_id
|
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>`;
|
: `<span class="fw-bold">${group.sag_titel}</span>`;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
|
|||||||
@ -419,7 +419,7 @@ async function loadCalls() {
|
|||||||
|
|
||||||
const sagHtml = r.sag_id
|
const sagHtml = r.sag_id
|
||||||
? `<div class="d-flex gap-2 align-items-center flex-wrap">
|
? `<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-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>
|
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${Number(r.id)})">Fjern link</button>
|
||||||
</div>`
|
</div>`
|
||||||
|
|||||||
@ -272,7 +272,7 @@ function renderTable(data) {
|
|||||||
: '<span class="text-muted fst-italic">Ingen beskrivelse</span>';
|
: '<span class="text-muted fst-italic">Ingen beskrivelse</span>';
|
||||||
|
|
||||||
return `
|
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 class="fw-semibold ps-4">${escapeHtml(o.titel)}</td>
|
||||||
<td>${escapeHtml(o.customer_name)}</td>
|
<td>${escapeHtml(o.customer_name)}</td>
|
||||||
<td>${stage}</td>
|
<td>${stage}</td>
|
||||||
|
|||||||
@ -308,7 +308,7 @@ function renderTimelogs(timelogs) {
|
|||||||
const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-';
|
const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-';
|
||||||
let sourceHtml = '-';
|
let sourceHtml = '-';
|
||||||
if (t.source === 'sag' && t.source_id) {
|
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) {
|
} else if (t.source === 'ticket' && t.source_id) {
|
||||||
const ticketLabel = t.ticket_number ? `#${t.ticket_number}` : `#${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>`;
|
sourceHtml = `<a href="/ticket/tickets/${t.source_id}" class="text-decoration-none">${ticketLabel} - ${t.source_title || 'Ticket'}</a>`;
|
||||||
|
|||||||
@ -265,7 +265,7 @@ class ReminderNotificationService:
|
|||||||
'text': f'🔔 **{title}**',
|
'text': f'🔔 **{title}**',
|
||||||
'attachments': [{
|
'attachments': [{
|
||||||
'title': case_title,
|
'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',
|
'text': message or additional_info or 'Se reminder i systemet',
|
||||||
'color': color_map.get(priority, color_map['normal']),
|
'color': color_map.get(priority, color_map['normal']),
|
||||||
'fields': [
|
'fields': [
|
||||||
@ -284,7 +284,7 @@ class ReminderNotificationService:
|
|||||||
'name': 'Åbn sag',
|
'name': 'Åbn sag',
|
||||||
'type': 'button',
|
'type': 'button',
|
||||||
'text': 'Se mere',
|
'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,
|
'deadline': deadline,
|
||||||
'assigned_user': assigned_user or 'Ikke tildelt',
|
'assigned_user': assigned_user or 'Ikke tildelt',
|
||||||
'additional_info': additional_info or '',
|
'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")
|
'footer_date': datetime.now().strftime("%d. %B %Y")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1568,7 +1568,7 @@ window.addEventListener('unhandledrejection', function(event) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = `/sag/${sagId}`;
|
window.location.href = `/sag/${sagId}/v3`;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -558,7 +558,7 @@
|
|||||||
// TODO: Add tags via separate endpoint if any exist
|
// TODO: Add tags via separate endpoint if any exist
|
||||||
|
|
||||||
// Redirect to case detail
|
// Redirect to case detail
|
||||||
window.location.href = `/sag/${newCase.id}`;
|
window.location.href = `/sag/${newCase.id}/v3`;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating case:', error);
|
console.error('Error creating case:', error);
|
||||||
|
|||||||
@ -327,7 +327,7 @@ async function loadStagingOverview() {
|
|||||||
const hubCustomer = row.hub_customer_name
|
const hubCustomer = row.hub_customer_name
|
||||||
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
|
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
|
||||||
: (row.hub_customer_id ? `#${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 `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${row.id}</td>
|
<td>${row.id}</td>
|
||||||
@ -561,7 +561,7 @@ function renderSubscriptions(subscriptions) {
|
|||||||
tbody.innerHTML = subscriptions.map(sub => {
|
tbody.innerHTML = subscriptions.map(sub => {
|
||||||
const intervalLabel = formatInterval(sub.billing_interval);
|
const intervalLabel = formatInterval(sub.billing_interval);
|
||||||
const statusBadge = getStatusBadge(sub.status);
|
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}`;
|
const subNumber = sub.subscription_number || `#${sub.id}`;
|
||||||
|
|
||||||
// Show product name with item count if available
|
// Show product name with item count if available
|
||||||
|
|||||||
@ -129,7 +129,7 @@ function renderSubscriptions(subscriptions) {
|
|||||||
tbody.innerHTML = subscriptions.map(sub => {
|
tbody.innerHTML = subscriptions.map(sub => {
|
||||||
const intervalLabel = formatInterval(sub.billing_interval);
|
const intervalLabel = formatInterval(sub.billing_interval);
|
||||||
const statusBadge = getStatusBadge(sub.status);
|
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}`;
|
const subNumber = sub.subscription_number || `#${sub.id}`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|||||||
@ -236,7 +236,7 @@ async function loadStagingOverview() {
|
|||||||
const hubCustomer = row.hub_customer_name
|
const hubCustomer = row.hub_customer_name
|
||||||
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
|
? `${escapeHtml(row.hub_customer_name)} (#${row.hub_customer_id})`
|
||||||
: (row.hub_customer_id ? `#${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 `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${row.id}</td>
|
<td>${row.id}</td>
|
||||||
|
|||||||
@ -52,7 +52,7 @@ def _entity_reference_payload(entity_type: Optional[str], entity_id: Optional[in
|
|||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
title = str(row.get("titel") or "Sag").strip()
|
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":
|
elif etype == "email":
|
||||||
row = execute_query_single(
|
row = execute_query_single(
|
||||||
|
|||||||
@ -408,7 +408,7 @@ async function showCaseDetails(id, type) {
|
|||||||
document.getElementById('modulePills').style.display = 'none';
|
document.getElementById('modulePills').style.display = 'none';
|
||||||
document.getElementById('contactRow').style.display = 'none';
|
document.getElementById('contactRow').style.display = 'none';
|
||||||
document.getElementById('kommentarFeed').innerHTML = '';
|
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._currentDetailId = id;
|
||||||
window._currentDetailType = type;
|
window._currentDetailType = type;
|
||||||
|
|||||||
@ -48,7 +48,7 @@
|
|||||||
<div class="border rounded p-2 mb-2">
|
<div class="border rounded p-2 mb-2">
|
||||||
<div class="fw-semibold">#{{ item.id }} · {{ item.titel }}</div>
|
<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>
|
<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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted mb-0">Ingen aktive sager.</p>
|
<p class="text-muted mb-0">Ingen aktive sager.</p>
|
||||||
@ -103,7 +103,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in new_cases %}
|
{% 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.id }}</td>
|
||||||
<td>{{ item.customer_name or '-' }}</td>
|
<td>{{ item.customer_name or '-' }}</td>
|
||||||
<td>{{ item.kontakt_navn if item.kontakt_navn and item.kontakt_navn.strip() else '-' }}</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="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">{{ item.customer_name }} · {{ item.pipeline_stage or 'Uden stage' }}</div>
|
||||||
<div class="small text-muted">Sandsynlighed: {{ "%.0f"|format(item.pipeline_probability or 0) }}%</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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted mb-0">Ingen opportunities.</p>
|
<p class="text-muted mb-0">Ingen opportunities.</p>
|
||||||
|
|||||||
2
main.py
2
main.py
@ -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.calendar.frontend import views as calendar_views
|
||||||
from app.modules.orders.backend import router as orders_api
|
from app.modules.orders.backend import router as orders_api
|
||||||
from app.modules.orders.frontend import views as orders_views
|
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.backend import router as manual_api
|
||||||
from app.modules.manual.frontend import views as manual_views
|
from app.modules.manual.frontend import views as manual_views
|
||||||
from app.modules.bottom_bar.backend import router as bottom_bar_api
|
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(telefoni_api.router, prefix="/api/v1", tags=["Telefoni"])
|
||||||
app.include_router(calendar_api.router, prefix="/api/v1", tags=["Calendar"])
|
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(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(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_api.router, prefix="/api/v1/bottom-bar", tags=["Bottom Bar"])
|
||||||
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
|
app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
|
||||||
|
|||||||
96
migrations/181_fedex_shipments.sql
Normal file
96
migrations/181_fedex_shipments.sql
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user