From 785a2d3ffe36eb257720629ee0a363b233ed79e8 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 1 May 2026 07:08:28 +0200 Subject: [PATCH] feat: Enhance FedEx service with pricing information and update UI for shipping address selection --- app/modules/fedex/backend/service.py | 290 +++++++++++++++++-- app/modules/fedex/models/schemas.py | 6 + app/modules/sag/templates/detail_v3.html | 269 ++++++++++++++++- docker-compose.yml | 9 + migrations/182_fedex_add_pricing_columns.sql | 7 + 5 files changed, 550 insertions(+), 31 deletions(-) create mode 100644 migrations/182_fedex_add_pricing_columns.sql diff --git a/app/modules/fedex/backend/service.py b/app/modules/fedex/backend/service.py index 2eb49ff..a3862e3 100644 --- a/app/modules/fedex/backend/service.py +++ b/app/modules/fedex/backend/service.py @@ -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( diff --git a/app/modules/fedex/models/schemas.py b/app/modules/fedex/models/schemas.py index 86241dc..1233e95 100644 --- a/app/modules/fedex/models/schemas.py +++ b/app/modules/fedex/models/schemas.py @@ -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 diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index 1cefe68..26882d2 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -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 @@
+
+ + +
Vælg en eksisterende adresse for automatisk udfyldning.
+
@@ -8036,7 +8044,12 @@
- + +
+ + + +
@@ -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 = [ + '', + ...shippingAddressPresets.map((preset) => { + const cityPart = [preset.postal_code, preset.city].filter(Boolean).join(' '); + const subtitle = [preset.address_line1, cityPart].filter(Boolean).join(' · '); + return ``; + }) + ]; + 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 + ? `Åbn label` + : ''; + const trackingAction = trackingUrl + ? `Tracking link` + : ''; return `
@@ -10888,6 +11122,8 @@ ${item.shipment_status || 'draft'}
${item.tracking_number ? `
Tracking: ${item.tracking_number}
` : ''} + ${priceText ? `
Pris: ${priceText}
` : ''} +
${labelAction}${trackingAction}
`; }).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); }); @@ -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); diff --git a/docker-compose.yml b/docker-compose.yml index fe76184..95903b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/migrations/182_fedex_add_pricing_columns.sql b/migrations/182_fedex_add_pricing_columns.sql new file mode 100644 index 0000000..bbc625f --- /dev/null +++ b/migrations/182_fedex_add_pricing_columns.sql @@ -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);