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 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)
|
||||||
|
|
||||||
updated = execute_query_single(
|
has_total_amount = table_has_column("fedex_shipments", "total_amount")
|
||||||
"""
|
has_currency = table_has_column("fedex_shipments", "currency")
|
||||||
UPDATE fedex_shipments
|
|
||||||
SET shipment_status = %s,
|
set_clauses = [
|
||||||
tracking_number = COALESCE(%s, tracking_number),
|
"shipment_status = %s",
|
||||||
label_url = COALESCE(%s, label_url),
|
"tracking_number = COALESCE(%s, tracking_number)",
|
||||||
submitted_at = CURRENT_TIMESTAMP,
|
"label_url = COALESCE(%s, label_url)",
|
||||||
api_payload = %s::jsonb,
|
]
|
||||||
api_response = %s::jsonb,
|
update_params: List[Any] = [
|
||||||
updated_by_user_id = %s,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE booking_ref = %s
|
|
||||||
RETURNING *
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
new_status,
|
new_status,
|
||||||
tracking_number,
|
tracking_number,
|
||||||
label_url,
|
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,
|
user_id,
|
||||||
booking_ref,
|
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:
|
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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
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