changes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Fusion Shipping",
|
||||
"version": "19.0.1.1.0",
|
||||
"version": "19.0.1.5.0",
|
||||
"category": "Inventory/Delivery",
|
||||
"summary": "All-in-one shipping integration — Canada Post, UPS, FedEx, DHL Express. "
|
||||
"Live pricing, label generation, shipment tracking, and multi-package support.",
|
||||
|
||||
@@ -342,6 +342,34 @@ class FedexRequest:
|
||||
res.append({'number': partner.parent_id.vat, 'tinType': 'BUSINESS_NATIONAL'})
|
||||
return res
|
||||
|
||||
def _strip_customs_for_domestic(self, request_data):
|
||||
"""Remove customsClearanceDetail when shipper + recipient are in
|
||||
the same country and the service isn't FEDEX_REGIONAL_ECONOMY.
|
||||
|
||||
FedEx rejects domestic Ground/Express requests that carry a
|
||||
customs block (TOTALCUSTOMSVALUE.REQUIRED). The upstream model
|
||||
always builds the block; we strip it for clearly-domestic cases.
|
||||
Caller invokes this immediately before _send_fedex_request.
|
||||
"""
|
||||
rs = request_data.get('requestedShipment', {})
|
||||
shipper = rs.get('shipper') or {}
|
||||
ship_addr = shipper.get('address') or {}
|
||||
# Recipient lives under 'recipients' (list) for /ship and
|
||||
# 'recipient' (single) for /rate. Handle both shapes.
|
||||
rec = rs.get('recipients') or []
|
||||
if isinstance(rec, list) and rec:
|
||||
rec_addr = rec[0].get('address') or {}
|
||||
else:
|
||||
rec_addr = (rs.get('recipient') or {}).get('address') or {}
|
||||
ship_country = ship_addr.get('countryCode')
|
||||
rec_country = rec_addr.get('countryCode')
|
||||
if (ship_country and rec_country
|
||||
and ship_country == rec_country
|
||||
and self.service_type != 'FEDEX_REGIONAL_ECONOMY'
|
||||
# India domestic still uses customs per upstream logic.
|
||||
and not (ship_country == 'IN' and rec_country == 'IN')):
|
||||
rs.pop('customsClearanceDetail', None)
|
||||
|
||||
def _get_shipping_price(self, ship_from, ship_to, packages, currency):
|
||||
fedex_currency = _convert_curr_iso_fdx(currency)
|
||||
request_data = {
|
||||
@@ -364,6 +392,7 @@ class FedexRequest:
|
||||
}
|
||||
}
|
||||
self._add_extra_data_to_request(request_data, 'rate')
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/rate/v1/rates/quotes", request_data)
|
||||
try:
|
||||
rate = next(filter(lambda d: d['currency'] == fedex_currency, res['rateReplyDetails'][0]['ratedShipmentDetails']), {})
|
||||
@@ -474,6 +503,7 @@ class FedexRequest:
|
||||
request_data['requestedShipment']['customsClearanceDetail']['customsOption'] = {'type': 'COURTESY_RETURN_LABEL'}
|
||||
|
||||
self._add_extra_data_to_request(request_data, 'ship')
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/ship/v1/shipments", request_data)
|
||||
|
||||
try:
|
||||
@@ -561,6 +591,7 @@ class FedexRequest:
|
||||
}
|
||||
|
||||
self._add_extra_data_to_request(request_data, 'return')
|
||||
self._strip_customs_for_domestic(request_data)
|
||||
res = self._send_fedex_request("/ship/v1/shipments", request_data)
|
||||
|
||||
try:
|
||||
@@ -597,6 +628,62 @@ class FedexRequest:
|
||||
return actual['totalNetChargeWithDutiesAndTaxes']
|
||||
return actual['totalNetCharge']
|
||||
|
||||
def track_shipment(self, tracking_nr):
|
||||
"""Call FedEx /track/v1/trackingnumbers and return the parsed
|
||||
scan-event list. Returns:
|
||||
{
|
||||
'tracking_number': '<str>',
|
||||
'status': '<str — latest status description>',
|
||||
'events': [
|
||||
{
|
||||
'date_time': '<ISO 8601 str>',
|
||||
'description': '<str>',
|
||||
'event_type': '<str — FedEx event code>',
|
||||
'city': '<str>',
|
||||
'state_province': '<str>',
|
||||
'country': '<str>',
|
||||
'signed_by': '<str — present on delivery events>',
|
||||
}, ...
|
||||
]
|
||||
}
|
||||
Empty events list when FedEx returns no scans yet (newly-printed
|
||||
label that hasn't been picked up). Raises ValidationError on
|
||||
HTTP error.
|
||||
"""
|
||||
res = self._send_fedex_request("/track/v1/trackingnumbers", {
|
||||
'includeDetailedScans': True,
|
||||
'trackingInfo': [{
|
||||
'trackingNumberInfo': {
|
||||
'trackingNumber': tracking_nr,
|
||||
},
|
||||
}],
|
||||
})
|
||||
out = {'tracking_number': tracking_nr, 'status': '', 'events': []}
|
||||
try:
|
||||
results = (res.get('completeTrackResults') or [{}])[0]
|
||||
track = (results.get('trackResults') or [{}])[0]
|
||||
except (AttributeError, IndexError):
|
||||
return out
|
||||
latest = track.get('latestStatusDetail') or {}
|
||||
out['status'] = (
|
||||
latest.get('description')
|
||||
or latest.get('statusByLocale')
|
||||
or ''
|
||||
)
|
||||
for scan in (track.get('scanEvents') or []):
|
||||
addr = scan.get('scanLocation') or {}
|
||||
out['events'].append({
|
||||
'date_time': scan.get('date') or '',
|
||||
'description': scan.get('eventDescription') or '',
|
||||
'event_type': scan.get('eventType') or '',
|
||||
'city': addr.get('city') or '',
|
||||
'state_province': addr.get('stateOrProvinceCode') or '',
|
||||
'country': addr.get('countryCode') or '',
|
||||
'signed_by': track.get('deliveryDetails', {}).get(
|
||||
'receivedByName', '') or '',
|
||||
})
|
||||
return out
|
||||
|
||||
def cancel_shipment(self, tracking_nr):
|
||||
res = self._send_fedex_request('/ship/v1/shipments/cancel', {
|
||||
'accountNumber': {'value': self.account_number},
|
||||
|
||||
@@ -292,7 +292,13 @@ class FusionShipment(models.Model):
|
||||
# ── Tracking ──────────────────────────────────────────────
|
||||
|
||||
def action_refresh_tracking(self):
|
||||
"""Fetch latest tracking events from Canada Post VIS API."""
|
||||
"""Fetch latest tracking events from the carrier's API.
|
||||
|
||||
Dispatch by carrier_type:
|
||||
- canada_post → Canada Post VIS API (inline below)
|
||||
- fedex_rest → FedEx /track/v1/trackingnumbers
|
||||
- other carriers → not yet supported; raise with clear message
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.tracking_number:
|
||||
raise ValidationError(
|
||||
@@ -301,6 +307,15 @@ class FusionShipment(models.Model):
|
||||
if not carrier:
|
||||
raise ValidationError(
|
||||
_("No carrier linked to this shipment."))
|
||||
if self.carrier_type == 'fedex_rest':
|
||||
return self._refresh_tracking_fedex_rest()
|
||||
if self.carrier_type != 'canada_post':
|
||||
raise ValidationError(_(
|
||||
"Refresh Tracking is only wired to Canada Post and "
|
||||
"FedEx REST at this time. For %(carrier)s shipments, "
|
||||
"use the Track Shipment button to view live tracking "
|
||||
"on the carrier's website."
|
||||
) % {'carrier': carrier.name})
|
||||
|
||||
# VIS tracking uses /vis/ path, not /rs/
|
||||
if carrier.prod_environment:
|
||||
@@ -745,3 +760,70 @@ class FusionShipment(models.Model):
|
||||
attachment_ids=(
|
||||
self.return_label_attachment_id.ids
|
||||
if self.return_label_attachment_id else []))
|
||||
|
||||
# ── FedEx REST tracking ──────────────────────────────────────────
|
||||
|
||||
def _refresh_tracking_fedex_rest(self):
|
||||
"""Call FedEx /track/v1/trackingnumbers and load scan events.
|
||||
|
||||
Parses the response via the FedexRestRequest.track_shipment
|
||||
helper, replaces the shipment's tracking_event_ids with the
|
||||
latest events, and updates status to 'delivered' if the latest
|
||||
event indicates delivery. The 'delivered' transition cascades
|
||||
to the portal_job via the existing write() hook.
|
||||
"""
|
||||
self.ensure_one()
|
||||
from odoo.addons.fusion_shipping.api.fedex_rest.request import (
|
||||
FedexRequest as FedexRestRequest,
|
||||
)
|
||||
try:
|
||||
fedex = FedexRestRequest(self.carrier_id)
|
||||
result = fedex.track_shipment(self.tracking_number)
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
_("FedEx tracking error: %s") % str(e))
|
||||
# Replace events.
|
||||
self.tracking_event_ids.unlink()
|
||||
vals_list = []
|
||||
delivered = False
|
||||
for evt in result.get('events') or []:
|
||||
evt_date_str = ''
|
||||
evt_time_str = ''
|
||||
evt_datetime = False
|
||||
raw_dt = evt.get('date_time') or ''
|
||||
if raw_dt:
|
||||
# FedEx returns ISO 8601 like 2026-05-18T14:30:00-05:00.
|
||||
try:
|
||||
parsed = dt_mod.fromisoformat(raw_dt)
|
||||
# Strip tzinfo so it stores in Odoo's naive UTC fields.
|
||||
if parsed.tzinfo is not None:
|
||||
import pytz as _pytz
|
||||
parsed = parsed.astimezone(_pytz.UTC).replace(tzinfo=None)
|
||||
evt_datetime = parsed
|
||||
evt_date_str = parsed.strftime('%Y-%m-%d')
|
||||
evt_time_str = parsed.strftime('%H:%M:%S')
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if (evt.get('event_type') or '').upper() == 'DL' or (
|
||||
'delivered' in (evt.get('description') or '').lower()):
|
||||
delivered = True
|
||||
vals_list.append({
|
||||
'shipment_id': self.id,
|
||||
'event_date': evt_date_str or False,
|
||||
'event_time': evt_time_str or '',
|
||||
'event_datetime': evt_datetime,
|
||||
'event_description': evt.get('description') or '',
|
||||
'event_type': evt.get('event_type') or '',
|
||||
'event_site': evt.get('city') or '',
|
||||
'event_province': evt.get('state_province') or '',
|
||||
'signatory_name': evt.get('signed_by') or '',
|
||||
})
|
||||
if vals_list:
|
||||
self.env['fusion.tracking.event'].create(vals_list)
|
||||
self.last_tracking_update = fields.Datetime.now()
|
||||
if delivered and self.status != 'delivered':
|
||||
self.status = 'delivered'
|
||||
self.delivery_date = fields.Datetime.now()
|
||||
self.message_post(body=_(
|
||||
"FedEx tracking refreshed: %(n)d event(s) loaded. Status: %(s)s"
|
||||
) % {'n': len(vals_list), 's': result.get('status') or '—'})
|
||||
|
||||
@@ -78,12 +78,17 @@
|
||||
string="Refresh Tracking"
|
||||
class="btn-primary"
|
||||
icon="fa-refresh"
|
||||
invisible="not tracking_number or status == 'cancelled'"/>
|
||||
invisible="not tracking_number or status == 'cancelled' or carrier_type not in ('canada_post', 'fedex_rest')"/>
|
||||
<button name="action_track_on_carrier" type="object"
|
||||
string="Track Shipment"
|
||||
class="btn-secondary"
|
||||
icon="fa-external-link"
|
||||
invisible="not tracking_number"/>
|
||||
<button name="action_view_label" type="object"
|
||||
string="Print Shipping Label"
|
||||
class="btn-secondary"
|
||||
icon="fa-print"
|
||||
invisible="not label_attachment_id"/>
|
||||
<button name="action_create_return_label" type="object"
|
||||
string="Create Return Label"
|
||||
class="btn-warning"
|
||||
@@ -130,6 +135,14 @@
|
||||
<span class="o_stat_text">Events</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_view_label" type="object"
|
||||
class="oe_stat_button" icon="fa-print"
|
||||
invisible="not label_attachment_id">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">PDF</span>
|
||||
<span class="o_stat_text">Print Label</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
@@ -138,7 +151,8 @@
|
||||
<group>
|
||||
<group string="Shipment Details">
|
||||
<field name="tracking_number"/>
|
||||
<field name="shipment_id"/>
|
||||
<field name="shipment_id"
|
||||
invisible="carrier_type != 'canada_post'"/>
|
||||
<field name="carrier_id"/>
|
||||
<field name="carrier_type"/>
|
||||
<field name="service_type"/>
|
||||
|
||||
Reference in New Issue
Block a user