feat: Enhance FedEx service with pricing information and update UI for shipping address selection
This commit is contained in:
parent
bd44771738
commit
785a2d3ffe
@ -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)
|
||||
|
||||
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(
|
||||
"""
|
||||
f"""
|
||||
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
|
||||
SET {', '.join(set_clauses)}
|
||||
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,
|
||||
),
|
||||
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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
7
migrations/182_fedex_add_pricing_columns.sql
Normal file
7
migrations/182_fedex_add_pricing_columns.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user