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

View File

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

View File

@ -4637,7 +4637,8 @@
'sales': 'Varekøb & salg',
'subscription': 'Abonnement',
'reminders': 'Påmindelser',
'calendar': 'Kalender'
'calendar': 'Kalender',
'shipping': 'Fragt'
};
caseTypeModuleDefaults = window.caseTypeModuleDefaults || {};
@ -8007,6 +8008,13 @@
</div>
<div class="card-body">
<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">
<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>
@ -8036,7 +8044,12 @@
</div>
<div class="col-md-3">
<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 class="col-md-3">
<label class="form-label">Vægt (kg)</label>
@ -10837,7 +10850,215 @@
const shippingCaseId = {{ case.id }};
const shippingCustomerId = {{ customer.id if customer 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 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') {
const el = document.getElementById('shipDraftNotice');
@ -10877,6 +11098,19 @@
list.innerHTML = items.map((item) => {
const ts = item.created_at ? new Date(item.created_at).toLocaleString('da-DK') : '-';
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 `
<div class="list-group-item">
<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>
</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>
`;
}).join('');
@ -10981,7 +11217,11 @@
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');
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);
} catch (error) {
console.error('Submit shipping booking failed:', error);
@ -10995,10 +11235,23 @@
form.addEventListener('submit', createShippingDraft);
}
const addressSource = document.getElementById('shipAddressSource');
if (addressSource) {
addressSource.addEventListener('change', (event) => {
applyShippingAddressPreset(event.target?.value || '');
});
}
const submitBtn = document.getElementById('shipSubmitBtn');
if (submitBtn) {
submitBtn.addEventListener('click', submitShippingBooking);
}
loadShippingAddressPresets();
initShippingPickupInput();
// Fallback preload so list is populated even if tab click hook is skipped.
loadCaseShippingTab(true);
});
</script>
@ -13183,7 +13436,7 @@
const viewDefaults = {
'Pipeline': ['pipeline', 'relations', 'sales', 'time'],
'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)
@ -13201,6 +13454,7 @@
const moduleName = el.getAttribute('data-module');
const hasContent = moduleHasContent(el);
const isTimeModule = moduleName === 'time';
const isShippingModule = moduleName === 'shipping';
const shouldCompactWhenEmpty = moduleName !== 'wiki' && moduleName !== 'pipeline' && !isTimeModule;
const pref = modulePrefs[moduleName];
const tabButton = document.querySelector(`[data-module-tab="${moduleName}"]`);
@ -13237,6 +13491,13 @@
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.
if (pref === false) {
setVisibility(false);

View File

@ -40,6 +40,7 @@ services:
- ./static:/app/static
- ./data:/app/data
- ./migrations:/app/migrations:ro
- ./.env:/app/.env:ro
# Mount for local development - live code reload
- ./app:/app/app:ro
- ./templates:/app/templates:ro
@ -54,6 +55,14 @@ services:
- APIGW_TOKEN=${APIGW_TOKEN}
- APIGATEWAY_URL=${APIGATEWAY_URL}
- 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
extra_hosts:
- "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);