feat: Enhance FedEx service with pricing information and update UI for shipping address selection

This commit is contained in:
Christian 2026-05-01 07:08:28 +02:00
parent bd44771738
commit 785a2d3ffe
5 changed files with 550 additions and 31 deletions

View File

@ -1,5 +1,6 @@
import json import json
import logging import logging
from decimal import Decimal
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from uuid import uuid4 from uuid import uuid4
@ -7,13 +8,184 @@ from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single, table_has_column
from app.modules.fedex.backend.api_client import FedExApiClient, parse_tracking_events from app.modules.fedex.backend.api_client import FedExApiClient, parse_tracking_events
from app.modules.fedex.models.schemas import FedExBookingCreate from app.modules.fedex.models.schemas import FedExBookingCreate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _json_default(value: Any) -> Any:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def _json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, default=_json_default)
def _to_float(value: Any) -> Optional[float]:
try:
if value is None or value == "":
return None
return float(value)
except (TypeError, ValueError):
return None
def _extract_price_info(payload: Dict[str, Any]) -> tuple[Optional[float], Optional[str]]:
if not isinstance(payload, dict):
return None, None
direct_amount = _to_float(
payload.get("total_amount")
or payload.get("totalAmount")
or payload.get("total_cost")
or payload.get("totalCost")
or payload.get("price")
or payload.get("amount")
)
direct_currency = (
payload.get("currency")
or payload.get("currencyCode")
or payload.get("total_cost_currency")
)
if direct_amount is not None:
return direct_amount, str(direct_currency or "").upper() or None
stack: List[Any] = [payload]
visited: set[int] = set()
while stack:
node = stack.pop()
node_id = id(node)
if node_id in visited:
continue
visited.add(node_id)
if isinstance(node, dict):
amount = _to_float(node.get("amount") or node.get("value"))
currency = node.get("currency") or node.get("currencyCode")
if amount is not None and currency:
return amount, str(currency).upper()
# Prioritize common FedEx charge keys if present.
for key in (
"totalNetCharge",
"totalNetFedExCharge",
"totalBaseCharge",
"totalSurcharges",
"netCharge",
):
nested = node.get(key)
if isinstance(nested, dict):
nested_amount = _to_float(nested.get("amount") or nested.get("value"))
nested_currency = nested.get("currency") or nested.get("currencyCode")
if nested_amount is not None:
return nested_amount, str(nested_currency or "").upper() or None
stack.extend(node.values())
elif isinstance(node, list):
stack.extend(node)
return None, None
def _extract_label_url(payload: Dict[str, Any]) -> Optional[str]:
if not isinstance(payload, dict):
return None
direct = payload.get("label_url") or payload.get("labelUrl") or payload.get("label")
if isinstance(direct, str) and direct.strip().lower().startswith(("http://", "https://")):
return direct.strip()
stack: List[Any] = [payload]
visited: set[int] = set()
while stack:
node = stack.pop()
node_id = id(node)
if node_id in visited:
continue
visited.add(node_id)
if isinstance(node, dict):
for key, value in node.items():
key_lower = str(key).lower()
if isinstance(value, str):
v = value.strip()
if v.lower().startswith(("http://", "https://")) and (
"label" in key_lower or "document" in key_lower or "url" in key_lower
):
return v
elif isinstance(value, (dict, list)):
stack.append(value)
elif isinstance(node, list):
stack.extend(node)
return None
def _extract_tracking_number(payload: Dict[str, Any]) -> Optional[str]:
if not isinstance(payload, dict):
return None
direct = (
payload.get("tracking_number")
or payload.get("trackingNumber")
or payload.get("masterTrackingNumber")
)
if direct is not None:
value = str(direct).strip()
if value:
return value
stack: List[Any] = [payload]
visited: set[int] = set()
while stack:
node = stack.pop()
node_id = id(node)
if node_id in visited:
continue
visited.add(node_id)
if isinstance(node, dict):
for key, value in node.items():
key_lower = str(key).lower()
if "tracking" in key_lower and value is not None and not isinstance(value, (dict, list)):
candidate = str(value).strip()
if candidate:
return candidate
if isinstance(value, (dict, list)):
stack.append(value)
elif isinstance(node, list):
stack.extend(node)
return None
def _build_tracking_url(tracking_number: Optional[str]) -> Optional[str]:
if not tracking_number:
return None
return f"https://www.fedex.com/fedextrack/?trknbr={tracking_number}"
def _estimate_dry_run_price(payload: Dict[str, Any]) -> tuple[float, str]:
packages = payload.get("packages") if isinstance(payload, dict) else []
if not isinstance(packages, list) or not packages:
return 99.0, "DKK"
total_weight = 0.0
for p in packages:
if isinstance(p, dict):
total_weight += _to_float(p.get("weight_kg")) or 0.0
estimated = round(79.0 + (total_weight * 8.5), 2)
return max(estimated, 79.0), "DKK"
class FedExService: class FedExService:
def __init__(self) -> None: def __init__(self) -> None:
self.client = FedExApiClient() self.client = FedExApiClient()
@ -68,6 +240,39 @@ class FedExService:
def _shipment_row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]: def _shipment_row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
mapped = dict(row) mapped = dict(row)
mapped["packages"] = self._fetch_packages(int(row["id"])) mapped["packages"] = self._fetch_packages(int(row["id"]))
api_response = mapped.get("api_response")
if isinstance(api_response, str):
try:
api_response = json.loads(api_response)
except Exception:
api_response = None
if isinstance(api_response, dict):
if not mapped.get("tracking_number"):
mapped["tracking_number"] = _extract_tracking_number(api_response)
if not mapped.get("label_url"):
mapped["label_url"] = _extract_label_url(api_response)
if mapped.get("total_amount") is None:
fallback_amount, fallback_currency = _extract_price_info(api_response)
if fallback_amount is not None:
mapped["total_amount"] = fallback_amount
if not mapped.get("currency") and fallback_currency:
mapped["currency"] = fallback_currency
mapped["tracking_url"] = _build_tracking_url(mapped.get("tracking_number"))
# Ensure older dry-run rows still expose useful test outputs in UI.
if mapped.get("dry_run") and mapped.get("shipment_status") in {"submitted", "booked"}:
if not mapped.get("label_url") and mapped.get("tracking_url"):
mapped["label_url"] = mapped["tracking_url"]
if mapped.get("total_amount") is None:
estimated_amount, estimated_currency = _estimate_dry_run_price({"packages": mapped.get("packages") or []})
mapped["total_amount"] = estimated_amount
if not mapped.get("currency"):
mapped["currency"] = estimated_currency
return mapped return mapped
def create_booking_draft(self, payload: FedExBookingCreate, created_by_user_id: Optional[int]) -> Dict[str, Any]: def create_booking_draft(self, payload: FedExBookingCreate, created_by_user_id: Optional[int]) -> Dict[str, Any]:
@ -130,8 +335,8 @@ class FedExService:
self.dry_run, self.dry_run,
created_by_user_id, created_by_user_id,
created_by_user_id, created_by_user_id,
json.dumps(payload.model_dump(mode="json"), ensure_ascii=False), _json_dumps(payload.model_dump(mode="json")),
json.dumps({"status": "draft_created"}, ensure_ascii=False), _json_dumps({"status": "draft_created"}),
), ),
) )
if not shipment_row: if not shipment_row:
@ -222,8 +427,15 @@ class FedExService:
if self.dry_run: if self.dry_run:
tracking_number = f"DRYRUN-{uuid4().hex[:12].upper()}" tracking_number = f"DRYRUN-{uuid4().hex[:12].upper()}"
label_url = None label_url = _build_tracking_url(tracking_number)
api_response = {"dry_run": True, "tracking_number": tracking_number} total_amount, currency = _estimate_dry_run_price(payload)
api_response = {
"dry_run": True,
"tracking_number": tracking_number,
"label_url": label_url,
"total_amount": total_amount,
"currency": currency,
}
new_status = "submitted" new_status = "submitted"
else: else:
try: try:
@ -238,37 +450,58 @@ class FedExService:
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE booking_ref = %s WHERE booking_ref = %s
""", """,
(json.dumps({"error": str(exc)}, ensure_ascii=False), user_id, booking_ref), (_json_dumps({"error": str(exc)}), user_id, booking_ref),
) )
raise HTTPException(status_code=502, detail="FedEx booking failed") from exc raise HTTPException(status_code=502, detail="FedEx booking failed") from exc
tracking_number = str(api_response.get("tracking_number") or "").strip() or None tracking_number = _extract_tracking_number(api_response)
label_url = api_response.get("label_url") label_url = _extract_label_url(api_response)
new_status = "booked" new_status = "booked"
total_amount, currency = _extract_price_info(api_response)
has_total_amount = table_has_column("fedex_shipments", "total_amount")
has_currency = table_has_column("fedex_shipments", "currency")
set_clauses = [
"shipment_status = %s",
"tracking_number = COALESCE(%s, tracking_number)",
"label_url = COALESCE(%s, label_url)",
]
update_params: List[Any] = [
new_status,
tracking_number,
label_url,
]
if has_total_amount:
set_clauses.append("total_amount = COALESCE(%s, total_amount)")
update_params.append(total_amount)
if has_currency:
set_clauses.append("currency = COALESCE(%s, currency)")
update_params.append(currency)
set_clauses.extend([
"submitted_at = CURRENT_TIMESTAMP",
"api_payload = %s::jsonb",
"api_response = %s::jsonb",
"updated_by_user_id = %s",
"updated_at = CURRENT_TIMESTAMP",
])
update_params.extend([
_json_dumps(payload),
_json_dumps(api_response),
user_id,
booking_ref,
])
updated = execute_query_single( updated = execute_query_single(
""" f"""
UPDATE fedex_shipments UPDATE fedex_shipments
SET shipment_status = %s, SET {', '.join(set_clauses)}
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 WHERE booking_ref = %s
RETURNING * RETURNING *
""", """,
( tuple(update_params),
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: if tracking_number:
@ -290,7 +523,10 @@ class FedExService:
"status": new_status, "status": new_status,
"dry_run": self.dry_run, "dry_run": self.dry_run,
"tracking_number": tracking_number, "tracking_number": tracking_number,
"tracking_url": _build_tracking_url(tracking_number),
"label_url": label_url, "label_url": label_url,
"total_amount": total_amount,
"currency": currency,
} }
async def get_tracking(self, booking_ref: str) -> Dict[str, Any]: async def get_tracking(self, booking_ref: str) -> Dict[str, Any]:
@ -378,7 +614,7 @@ class FedExService:
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = %s WHERE id = %s
""", """,
(status, json.dumps(provider_payload, ensure_ascii=False), shipment["id"]), (status, _json_dumps(provider_payload), shipment["id"]),
) )
current_events = execute_query( current_events = execute_query(

View File

@ -51,7 +51,10 @@ class FedExBookingSubmitResponse(BaseModel):
status: ShipmentStatus status: ShipmentStatus
dry_run: bool dry_run: bool
tracking_number: Optional[str] = None tracking_number: Optional[str] = None
tracking_url: Optional[str] = None
label_url: Optional[str] = None label_url: Optional[str] = None
total_amount: Optional[float] = None
currency: Optional[str] = None
class FedExTrackingEvent(BaseModel): class FedExTrackingEvent(BaseModel):
@ -74,7 +77,10 @@ class FedExBookingResponse(BaseModel):
city: str city: str
country_code: str country_code: str
tracking_number: Optional[str] = None tracking_number: Optional[str] = None
tracking_url: Optional[str] = None
label_url: Optional[str] = None label_url: Optional[str] = None
total_amount: Optional[float] = None
currency: Optional[str] = None
dry_run: bool dry_run: bool
pickup_window_start: datetime pickup_window_start: datetime
pickup_window_end: datetime pickup_window_end: datetime

View File

@ -4637,7 +4637,8 @@
'sales': 'Varekøb & salg', 'sales': 'Varekøb & salg',
'subscription': 'Abonnement', 'subscription': 'Abonnement',
'reminders': 'Påmindelser', 'reminders': 'Påmindelser',
'calendar': 'Kalender' 'calendar': 'Kalender',
'shipping': 'Fragt'
}; };
caseTypeModuleDefaults = window.caseTypeModuleDefaults || {}; caseTypeModuleDefaults = window.caseTypeModuleDefaults || {};
@ -8007,6 +8008,13 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="shippingDraftForm" class="row g-3"> <form id="shippingDraftForm" class="row g-3">
<div class="col-12">
<label class="form-label">Vælg adresse</label>
<select id="shipAddressSource" class="form-select">
<option value="">Vælg hovedadresse eller lokation...</option>
</select>
<div class="form-text">Vælg en eksisterende adresse for automatisk udfyldning.</div>
</div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Modtager</label> <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> <input id="shipRecipientName" type="text" class="form-control" value="{{ (hovedkontakt.first_name ~ ' ' ~ hovedkontakt.last_name) if hovedkontakt else (customer.name if customer else '') }}" required>
@ -8036,7 +8044,12 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Afhentning</label> <label class="form-label">Afhentning</label>
<input id="shipPickupAt" type="datetime-local" class="form-control" required> <input id="shipPickupAt" type="datetime-local" class="form-control" step="900" required>
<div class="btn-group btn-group-sm mt-2 w-100" role="group" aria-label="Hurtigvalg afhentning">
<button type="button" class="btn btn-outline-secondary" onclick="setShippingPickupPreset('soon')">Om 30 min</button>
<button type="button" class="btn btn-outline-secondary" onclick="setShippingPickupPreset('next_hour')">Næste time</button>
<button type="button" class="btn btn-outline-secondary" onclick="setShippingPickupPreset('tomorrow_9')">I morgen 09:00</button>
</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Vægt (kg)</label> <label class="form-label">Vægt (kg)</label>
@ -10837,7 +10850,215 @@
const shippingCaseId = {{ case.id }}; const shippingCaseId = {{ case.id }};
const shippingCustomerId = {{ customer.id if customer else 'null' }}; const shippingCustomerId = {{ customer.id if customer else 'null' }};
const shippingContactId = {{ hovedkontakt.id if hovedkontakt else 'null' }}; const shippingContactId = {{ hovedkontakt.id if hovedkontakt else 'null' }};
const shippingCustomerName = {{ (customer.name if customer and customer.name else '')|tojson }};
const shippingCustomerAddress = {{ (customer.address if customer and customer.address else '')|tojson }};
const shippingCustomerPostal = {{ (customer.postal_code if customer and customer.postal_code else '')|tojson }};
const shippingCustomerCity = {{ (customer.city if customer and customer.city else '')|tojson }};
const shippingCustomerCountry = {{ (customer.country if customer and customer.country else 'DK')|tojson }};
let shippingSelectedBookingRef = null; let shippingSelectedBookingRef = null;
let shippingAddressPresets = [];
function _shippingFirstNonEmpty(...values) {
for (const value of values) {
if (value == null) continue;
const text = String(value).trim();
if (text) return text;
}
return '';
}
function _shippingBuildAddressLine(location) {
return _shippingFirstNonEmpty(
location.address,
location.address_line1,
location.street,
location.vejnavn,
location.location_address,
location.name
);
}
function _shippingBuildPostal(location) {
return _shippingFirstNonEmpty(
location.postal_code,
location.zip,
location.zip_code,
location.postnr,
location.postnummer
);
}
function _shippingBuildCity(location) {
return _shippingFirstNonEmpty(location.city, location.by, location.town);
}
function _shippingBuildCountry(location) {
return _shippingFirstNonEmpty(
location.country,
location.country_code,
location.land,
'DK'
).toUpperCase();
}
function _upsertShippingPreset(id, label, address_line1, postal_code, city, country_code) {
const normalized = {
id: String(id || '').trim(),
label: String(label || '').trim(),
address_line1: String(address_line1 || '').trim(),
postal_code: String(postal_code || '').trim(),
city: String(city || '').trim(),
country_code: String(country_code || 'DK').trim().toUpperCase(),
};
if (!normalized.id || !normalized.address_line1 || !normalized.city) return;
const existingIdx = shippingAddressPresets.findIndex((preset) => preset.id === normalized.id);
if (existingIdx >= 0) shippingAddressPresets[existingIdx] = normalized;
else shippingAddressPresets.push(normalized);
}
function applyShippingAddressPreset(presetId) {
const preset = shippingAddressPresets.find((item) => item.id === String(presetId || ''));
if (!preset) return;
const addressInput = document.getElementById('shipAddressLine1');
const postalInput = document.getElementById('shipPostalCode');
const cityInput = document.getElementById('shipCity');
const countryInput = document.getElementById('shipCountryCode');
if (addressInput) addressInput.value = preset.address_line1;
if (postalInput) postalInput.value = preset.postal_code;
if (cityInput) cityInput.value = preset.city;
if (countryInput) countryInput.value = preset.country_code || 'DK';
}
function renderShippingAddressSourceOptions() {
const select = document.getElementById('shipAddressSource');
if (!select) return;
const currentValue = String(select.value || '');
const options = [
'<option value="">Vælg hovedadresse eller lokation...</option>',
...shippingAddressPresets.map((preset) => {
const cityPart = [preset.postal_code, preset.city].filter(Boolean).join(' ');
const subtitle = [preset.address_line1, cityPart].filter(Boolean).join(' · ');
return `<option value="${preset.id}">${preset.label}${subtitle ? ` - ${subtitle}` : ''}</option>`;
})
];
select.innerHTML = options.join('');
if (shippingAddressPresets.length === 1) {
select.value = shippingAddressPresets[0].id;
applyShippingAddressPreset(select.value);
return;
}
if (currentValue && shippingAddressPresets.some((preset) => preset.id === currentValue)) {
select.value = currentValue;
}
}
async function loadShippingAddressPresets() {
shippingAddressPresets = [];
// Main customer address preset.
_upsertShippingPreset(
'customer_main',
'Kunde - hovedadresse',
shippingCustomerAddress,
shippingCustomerPostal,
shippingCustomerCity,
shippingCustomerCountry
);
try {
const res = await fetch(`/api/v1/sag/${shippingCaseId}/locations`);
if (res.ok) {
const locations = await res.json();
if (Array.isArray(locations)) {
locations.forEach((location, idx) => {
const addressLine = _shippingBuildAddressLine(location || {});
const city = _shippingBuildCity(location || {});
const postal = _shippingBuildPostal(location || {});
const country = _shippingBuildCountry(location || {});
const locName = _shippingFirstNonEmpty(location?.name, `Lokation ${idx + 1}`);
const relationId = _shippingFirstNonEmpty(location?.relation_id, location?.id, idx + 1);
_upsertShippingPreset(`location_${relationId}`, `Lokation - ${locName}`, addressLine, postal, city, country);
});
}
}
} catch (error) {
console.warn('Could not load shipping address presets from locations:', error);
}
renderShippingAddressSourceOptions();
}
function _toLocalDatetimeInputValue(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function _roundDateUpToInterval(date, minutes = 15) {
const ms = Math.max(1, Number(minutes)) * 60 * 1000;
return new Date(Math.ceil(date.getTime() / ms) * ms);
}
function _nextBusinessMorning(baseDate) {
const d = new Date(baseDate);
d.setDate(d.getDate() + 1);
d.setHours(9, 0, 0, 0);
while (d.getDay() === 0 || d.getDay() === 6) {
d.setDate(d.getDate() + 1);
}
return d;
}
function _setShippingPickupInput(date) {
const input = document.getElementById('shipPickupAt');
if (!input) return;
const minDate = _roundDateUpToInterval(new Date(Date.now() + 15 * 60 * 1000), 15);
const safeDate = date < minDate ? minDate : date;
input.min = _toLocalDatetimeInputValue(minDate);
input.value = _toLocalDatetimeInputValue(safeDate);
}
function setShippingPickupPreset(preset) {
const now = new Date();
let target = new Date(now);
if (preset === 'soon') {
target = new Date(now.getTime() + 30 * 60 * 1000);
target = _roundDateUpToInterval(target, 15);
} else if (preset === 'next_hour') {
target = new Date(now);
target.setMinutes(0, 0, 0);
target.setHours(target.getHours() + 1);
} else if (preset === 'tomorrow_9') {
target = _nextBusinessMorning(now);
}
_setShippingPickupInput(target);
}
function initShippingPickupInput() {
const input = document.getElementById('shipPickupAt');
if (!input) return;
if (!input.value) {
setShippingPickupPreset('soon');
} else {
const existing = new Date(input.value);
if (!Number.isNaN(existing.getTime())) {
_setShippingPickupInput(_roundDateUpToInterval(existing, 15));
} else {
setShippingPickupPreset('soon');
}
}
input.addEventListener('change', () => {
const selected = new Date(input.value);
if (Number.isNaN(selected.getTime())) return;
_setShippingPickupInput(_roundDateUpToInterval(selected, 15));
});
}
function setShippingNotice(message, level = 'muted') { function setShippingNotice(message, level = 'muted') {
const el = document.getElementById('shipDraftNotice'); const el = document.getElementById('shipDraftNotice');
@ -10877,6 +11098,19 @@
list.innerHTML = items.map((item) => { list.innerHTML = items.map((item) => {
const ts = item.created_at ? new Date(item.created_at).toLocaleString('da-DK') : '-'; const ts = item.created_at ? new Date(item.created_at).toLocaleString('da-DK') : '-';
const badge = bookingStatusBadge(item.shipment_status); const badge = bookingStatusBadge(item.shipment_status);
const hasPrice = item.total_amount != null && !Number.isNaN(Number(item.total_amount));
const priceText = hasPrice
? `${Number(item.total_amount).toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${item.currency || 'DKK'}`
: null;
const labelUrl = String(item.label_url || '').trim();
const trackingNumber = String(item.tracking_number || '').trim();
const trackingUrl = String(item.tracking_url || (trackingNumber ? `https://www.fedex.com/fedextrack/?trknbr=${encodeURIComponent(trackingNumber)}` : '')).trim();
const labelAction = labelUrl
? `<a href="${labelUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-primary mt-2"><i class="bi bi-file-earmark-text me-1"></i>Åbn label</a>`
: '';
const trackingAction = trackingUrl
? `<a href="${trackingUrl}" target="_blank" rel="noopener" class="btn btn-sm btn-outline-secondary mt-2 ms-2"><i class="bi bi-box-arrow-up-right me-1"></i>Tracking link</a>`
: '';
return ` return `
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-2"> <div class="d-flex justify-content-between align-items-start gap-2">
@ -10888,6 +11122,8 @@
<span class="badge ${badge}">${item.shipment_status || 'draft'}</span> <span class="badge ${badge}">${item.shipment_status || 'draft'}</span>
</div> </div>
${item.tracking_number ? `<div class="small mt-2"><strong>Tracking:</strong> ${item.tracking_number}</div>` : ''} ${item.tracking_number ? `<div class="small mt-2"><strong>Tracking:</strong> ${item.tracking_number}</div>` : ''}
${priceText ? `<div class="small mt-1"><strong>Pris:</strong> ${priceText}</div>` : ''}
<div>${labelAction}${trackingAction}</div>
</div> </div>
`; `;
}).join(''); }).join('');
@ -10981,7 +11217,11 @@
const data = await res.json(); const data = await res.json();
const trackingInfo = data.tracking_number ? ` Tracking: ${data.tracking_number}` : ''; const trackingInfo = data.tracking_number ? ` Tracking: ${data.tracking_number}` : '';
const dryRunInfo = data.dry_run ? ' (dry-run)' : ''; const dryRunInfo = data.dry_run ? ' (dry-run)' : '';
setShippingNotice(`Booking sendt.${trackingInfo}${dryRunInfo}`, 'success'); const hasPrice = data.total_amount != null && !Number.isNaN(Number(data.total_amount));
const priceInfo = hasPrice
? ` Pris: ${Number(data.total_amount).toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${data.currency || 'DKK'}`
: '';
setShippingNotice(`Booking sendt.${trackingInfo}${priceInfo}${dryRunInfo}`, 'success');
await loadCaseShippingTab(true); await loadCaseShippingTab(true);
} catch (error) { } catch (error) {
console.error('Submit shipping booking failed:', error); console.error('Submit shipping booking failed:', error);
@ -10995,10 +11235,23 @@
form.addEventListener('submit', createShippingDraft); form.addEventListener('submit', createShippingDraft);
} }
const addressSource = document.getElementById('shipAddressSource');
if (addressSource) {
addressSource.addEventListener('change', (event) => {
applyShippingAddressPreset(event.target?.value || '');
});
}
const submitBtn = document.getElementById('shipSubmitBtn'); const submitBtn = document.getElementById('shipSubmitBtn');
if (submitBtn) { if (submitBtn) {
submitBtn.addEventListener('click', submitShippingBooking); submitBtn.addEventListener('click', submitShippingBooking);
} }
loadShippingAddressPresets();
initShippingPickupInput();
// Fallback preload so list is populated even if tab click hook is skipped.
loadCaseShippingTab(true);
}); });
</script> </script>
@ -13183,7 +13436,7 @@
const viewDefaults = { const viewDefaults = {
'Pipeline': ['pipeline', 'relations', 'sales', 'time'], 'Pipeline': ['pipeline', 'relations', 'sales', 'time'],
'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'], 'Kundevisning': ['customers', 'contacts', 'locations', 'wiki', 'tags'],
'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar'] 'Sag-detalje': ['pipeline', 'hardware', 'locations', 'contacts', 'customers', 'wiki', 'tags', 'todo-steps', 'relations', 'call-history', 'files', 'emails', 'solution', 'time', 'sales', 'subscription', 'reminders', 'calendar', 'shipping']
}; };
const currentCaseTypeKey = (typeof caseTypeKey !== 'undefined' && caseTypeKey) const currentCaseTypeKey = (typeof caseTypeKey !== 'undefined' && caseTypeKey)
@ -13201,6 +13454,7 @@
const moduleName = el.getAttribute('data-module'); const moduleName = el.getAttribute('data-module');
const hasContent = moduleHasContent(el); const hasContent = moduleHasContent(el);
const isTimeModule = moduleName === 'time'; const isTimeModule = moduleName === 'time';
const isShippingModule = moduleName === 'shipping';
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule; const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
const pref = modulePrefs[moduleName]; const pref = modulePrefs[moduleName];
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`); const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
@ -13237,6 +13491,13 @@
return; return;
} }
// Shipping should always be visible when feature exists on the page.
if (isShippingModule) {
setVisibility(true);
el.classList.remove('module-empty-compact');
return;
}
// HVIS specifik præference deaktiverer den - Skjul den! Uanset content. // HVIS specifik præference deaktiverer den - Skjul den! Uanset content.
if (pref === false) { if (pref === false) {
setVisibility(false); setVisibility(false);

View File

@ -40,6 +40,7 @@ services:
- ./static:/app/static - ./static:/app/static
- ./data:/app/data - ./data:/app/data
- ./migrations:/app/migrations:ro - ./migrations:/app/migrations:ro
- ./.env:/app/.env:ro
# Mount for local development - live code reload # Mount for local development - live code reload
- ./app:/app/app:ro - ./app:/app/app:ro
- ./templates:/app/templates:ro - ./templates:/app/templates:ro
@ -54,6 +55,14 @@ services:
- APIGW_TOKEN=${APIGW_TOKEN} - APIGW_TOKEN=${APIGW_TOKEN}
- APIGATEWAY_URL=${APIGATEWAY_URL} - APIGATEWAY_URL=${APIGATEWAY_URL}
- APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS} - APIGW_TIMEOUT_SECONDS=${APIGW_TIMEOUT_SECONDS}
- FEDEX_ENABLED=${FEDEX_ENABLED}
- FEDEX_READ_ONLY=${FEDEX_READ_ONLY}
- FEDEX_DRY_RUN=${FEDEX_DRY_RUN}
- FEDEX_API_KEY=${FEDEX_API_KEY}
- FEDEX_API_SECRET=${FEDEX_API_SECRET}
- FEDEX_ACCOUNT_NUMBER=${FEDEX_ACCOUNT_NUMBER}
- FEDEX_BASE_URL=${FEDEX_BASE_URL}
- FEDEX_TIMEOUT_SECONDS=${FEDEX_TIMEOUT_SECONDS}
restart: unless-stopped restart: unless-stopped
extra_hosts: extra_hosts:
- "ollama-host:172.16.31.195" - "ollama-host:172.16.31.195"

View File

@ -0,0 +1,7 @@
-- Migration 182: Add pricing fields to FedEx shipments
ALTER TABLE fedex_shipments
ADD COLUMN IF NOT EXISTS total_amount NUMERIC(12,2),
ADD COLUMN IF NOT EXISTS currency VARCHAR(8);
CREATE INDEX IF NOT EXISTS idx_fedex_shipments_total_amount ON fedex_shipments(total_amount);