# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import re
import requests
from datetime import datetime
from markupsafe import Markup
from textwrap import shorten
from urllib.parse import urlencode, quote
from odoo.exceptions import ValidationError, UserError
from odoo.tools.float_utils import float_round
from odoo.tools import format_list
_logger = logging.getLogger(__name__)
SANDBOX_URL = "https://api-test.envia.com/"
SANDBOX_QUERY_URL = "https://queries-test.envia.com/"
SANDBOX_LABEL_URL = "https://dev.envia.com/"
PROD_URL = "https://api.envia.com/"
PROD_QUERY_URL = "https://queries.envia.com/"
PROD_LABEL_URL = "https://envia.com/"
GEOLOCATE_URL = "https://geocodes.envia.com/"
# Envia uses custom state codes for certain states different then odoo expects.
# Store a map of (country_code, state_code) to envia code.
STATE_CODE_MAP_ENVIA = {
('AR', 'A'): 'SA', ('AR', 'B'): 'BA', ('AR', 'C'): 'DF', ('AR', 'D'): 'SL', ('AR', 'E'): 'ER', ('AR', 'F'): 'LR',
('AR', 'G'): 'SE', ('AR', 'H'): 'CC', ('AR', 'J'): 'SJ', ('AR', 'K'): 'CT', ('AR', 'L'): 'LP', ('AR', 'M'): 'MZ',
('AR', 'N'): 'MN', ('AR', 'P'): 'FM', ('AR', 'Q'): 'NQ', ('AR', 'R'): 'RN', ('AR', 'S'): 'SF', ('AR', 'T'): 'TM',
('AR', 'U'): 'CH', ('AR', 'V'): 'TF', ('AR', 'W'): 'CN', ('AR', 'X'): 'CB', ('AR', 'Y'): 'JY', ('AR', 'Z'): 'SC',
('CA', 'YT'): 'YK', ('CL', 'CL-TA'): 'TA', ('CL', 'CL-AN'): 'AN', ('CL', 'CL-AT'): 'AT', ('CL', 'CL-CO'): 'CO', ('CL', 'CL-VS'): 'VS',
('CL', 'CL-LI'): 'LI', ('CL', 'CL-ML'): 'ML', ('CL', 'CL-BI'): 'BI', ('CL', 'CL-AR'): 'AR', ('CL', 'CL-LL'): 'LL', ('CL', 'CL-AI'): 'AI',
('CL', 'CL-MA'): 'MA', ('CL', 'CL-RM'): 'RM', ('CL', 'CL-LR'): 'LR', ('CL', 'CL-AP'): 'AP', ('CL', 'CL-NB'): 'NB', ('ES', 'ME'): 'ML',
('GT', 'GUA'): 'GU', ('GT', 'QUE'): 'QZ', ('IN', 'CG'): 'CT', ('IN', 'TS'): 'TG', ('IN', 'UK'): 'UT', ('MX', 'AGU'): 'AGS',
('MX', 'DUR'): 'DGO', ('MX', 'GUA'): 'GTO', ('MX', 'HID'): 'HGO', ('MX', 'QUE'): 'QRO', ('PE', '01'): 'AMA', ('PE', '02'): 'ANC',
('PE', '03'): 'APU', ('PE', '04'): 'ARE', ('PE', '05'): 'AYA', ('PE', '06'): 'CAJ', ('PE', '07'): 'CAL', ('PE', '08'): 'CUS',
('PE', '09'): 'HUV', ('PE', '10'): 'HUC', ('PE', '11'): 'ICA', ('PE', '12'): 'JUN', ('PE', '13'): 'LAL', ('PE', '14'): 'LAM',
('PE', '15'): 'LIM', ('PE', '16'): 'LOR', ('PE', '17'): 'MDD', ('PE', '18'): 'MOQ', ('PE', '19'): 'PAS', ('PE', '20'): 'PIU',
('PE', '21'): 'PUN', ('PE', '22'): 'SAM', ('PE', '23'): 'TAC', ('PE', '24'): 'TUM', ('PE', '25'): 'UCA'
}
ENVIA_CONTENT_LENGTH_LIMIT = 35
class Envia:
def __init__(self, carrier, prod_environment, debug_logger):
self.url = PROD_URL if prod_environment else SANDBOX_URL
self.query_url = PROD_QUERY_URL if prod_environment else SANDBOX_QUERY_URL
self.tracking_url = PROD_LABEL_URL if prod_environment else SANDBOX_LABEL_URL
self.session = requests.Session()
self.carrier = carrier
self.debug_logger = debug_logger
self.token = carrier.sudo().envia_production_api_key if prod_environment else carrier.sudo().envia_sandbox_api_key
def _make_api_request(self, endpoint, is_query=False, geolocate=False, method='GET', data=None, params=None):
""" Make an api call, return response for multiple api requests of Envia"""
headers = {
'Content-Type': "application/json; charset=utf-8",
'Accept': "application/json",
'Authorization': f"Bearer {self.token}"
}
access_url = self.url + endpoint
if is_query:
access_url = self.query_url + endpoint
elif geolocate:
access_url = GEOLOCATE_URL + endpoint
try:
# Envia does not handle UTF-8 Strings according to the JSON Spec properly.
# Requests sent with escaped unicode (\\u) characters will fail on Envia's side and not
# be able to be properly parsed. We need to encode the data with ensure_ascii set to
# false to send the data with (\x) instead.
if data:
data = json.dumps(data, ensure_ascii=False).encode('utf8')
# Log the request details for debugging purposes
self.debug_logger("%s\n%s\n%s" % (access_url, method, data), 'envia_request_%s' % endpoint)
# Make the API request
response = self.session.request(method=method, url=access_url, data=data, headers=headers, params=params, timeout=30)
# Parse the response as JSON
response_json = response.json()
# Log the response details for debugging purposes
self.debug_logger("%s\n%s\n%s" % (response.url, response.status_code, response.text), 'envia_response_%s' % endpoint)
return response_json
except requests.exceptions.ConnectionError as error:
_logger.warning('Connection Error: %s with the given URL: %s', error, access_url)
return {'error': {'description': 'timeout', 'message': "Cannot reach the server. Please try again later."}}
except requests.exceptions.ReadTimeout as error:
_logger.warning('Timeout Error: %s with the given URL: %s', error, access_url)
return {'error': {'description': 'timeout', 'message': "Cannot reach the server. Please try again later."}}
except json.decoder.JSONDecodeError as error:
_logger.warning('JSONDecodeError: %s', error)
return {'error': {'description': 'JSONDecodeError', 'message': str(error)}}
except Exception as error:
_logger.warning('UnknownException: %s', error)
return {'error': {'description': 'Exception', 'message': str(error)}}
def _envia_parse_error(self, json_data):
""" Return error message(s) from Envia requests formated.
If not in debug mode, show only a smaller message.
"""
error_msgs = []
if message := json_data.get('message'):
description = json_data.get('description', '')
location = json_data.get('location', '')
reference = json_data.get('reference', 'N/A')
error_msgs.append(self.carrier.env._(
"Envia Error: %(message)s - %(description)s %(location)s (Reference code: %(reference)s)",
message=message, description=description, location=location, reference=reference
))
error = json_data.get('error')
if error and isinstance(error, dict):
error_msgs.append(self.carrier.env._(
"Envia Error: %(description)s - %(message)s",
description=error['description'], message=error['message']
))
return "\n".join(error_msgs)
def _envia_format_services(self, shipper, package_type='pallet'):
services = []
if package_type == 'box':
if shipper.country_id == shipper.env.ref('base.ca'):
service_code = 'return_at_senders_expense' if self.carrier.envia_return_at_senders_expense else 'abandon'
services.append({'data': {}, 'service': service_code})
if package_type == 'pallet':
if shipper.country_id in [shipper.env.ref('base.us'), shipper.env.ref('base.mx')]:
if self.carrier.envia_lift_pickup:
services.append({'service': 'liftgate_pickup'})
if self.carrier.envia_lift_delivery:
services.append({'service': 'liftgate_delivery'})
if shipper.country_id == shipper.env.ref('base.us'):
if self.carrier.envia_residential_pickup:
services.append({'service': 'pickup_residential_zone'})
if self.carrier.envia_residential_delivery:
services.append({'service': 'delivery_residential_zone'})
return services
def _get_rate(self, shipper, recipient, packages_data, order):
""" Fetch rate from Envia API based on the parameters.
url: ship/rate
"""
packages = []
package_type = self.carrier.envia_default_package_type_id.envia_mail_type
for package in packages_data:
pack = {
'name': 'items',
'content': 'items',
'amount': 1,
'type': package_type,
'declaredValue': package['value'],
'weight': package['weight'],
'weightUnit': 'KG',
'lengthUnit': 'CM',
'dimensions': package['dimensions'],
'additionalServices': []
}
pack['additionalServices'].extend(self._envia_format_services(shipper, package_type))
if package['insurance']:
pack['additionalServices'].append({
"service": "envia_insurance",
"data": {
"amount": str(package['insurance']),
},
})
packages.append(pack)
data = {
'origin': self._prepare_address_values(shipper, is_cust=False),
'destination': self._prepare_address_values(recipient, is_cust=True),
'packages': packages,
'shipment': {
'carrier': self.carrier.envia_carrier_code,
'service': self.carrier.envia_service_code,
'type': 2 if package_type == 'pallet' else 1,
},
'settings': {
'currency': self.carrier.envia_currency_id.name
}
}
rate_json = self._make_api_request('ship/rate', method="POST", data=data)
if not rate_json or isinstance(rate_json, dict) and rate_json.get('meta') not in ['rate', 'rateLtl']:
# Error message found
return {'error_found': self._envia_parse_error(rate_json)}
services = rate_json.get('data')
if not isinstance(services, list) or not services:
return {'error_found': order.env._("No rate found")}
return {
'price': self._convert_currency(services[0].get('totalPrice'), order=order, from_envia=True)
}
def _rate_request(self, recipient, shipper, order, order_weight=False):
""" Returns the dictionary of shipment rate from Envia
url: ship/rate
"""
if not order:
raise UserError(order.env._("Sale Order is required to get rate."))
products = order.order_line.product_id
bad_products = products.filtered(lambda prod: not prod.weight and prod.type == 'consu').mapped('name')
if bad_products:
product_names = ",".join(bad_products)
raise ValidationError(order.env._(
"Envia Error: The following products don't have weights set: %(product_names)s",
product_names=product_names
))
default_package = self.carrier.envia_default_package_type_id
packages = self.carrier._get_packages_from_order(order, default_package)
packages_data = []
for package in packages:
if len(packages) == 1:
weight = order_weight or package.weight
else:
weight = package.weight
weight_in_kg = self.carrier._envia_convert_weight(weight)
# Resize dimensions to CM
dimensions = {
'length': self.carrier._envia_convert_size(package.dimension['length']),
'width': self.carrier._envia_convert_size(package.dimension['width']),
'height': self.carrier._envia_convert_size(package.dimension['height'])
}
# Compute cost of package
value = sum(comm.monetary_value * comm.qty for comm in package.commodities)
declared_value = self._convert_currency(value, order=order, from_envia=False)
insurance = float_round(declared_value * self.carrier.shipping_insurance / 100, 2)
packages_data.append({
'weight': weight_in_kg,
'dimensions': dimensions,
'value': declared_value,
'insurance': insurance
})
return self._get_rate(shipper, recipient, packages_data, order)
def _fetch_envia_carriers(self):
""" Import all available carriers from Envia for specific country
query_url: available-service/{country_code}/{international}/{shipment_type}
"""
country_code = quote(self.carrier.country_id.code)
ship_type = 2 if self.carrier.envia_default_package_type_id.envia_mail_type == 'pallet' else 1
carriers = []
for international in [0, 1]:
carrier_json = self._make_api_request(f'available-service/{country_code}/{international}/{ship_type}', is_query=True)
if carrier_json.get('statusCode') == 401:
raise UserError(self.carrier.env._(
"Envia Error: The API key you entered for %(carrier_name)s seems to be invalid",
carrier_name=self.carrier.name
))
carrier_data = carrier_json.get('data')
if not carrier_data or not isinstance(carrier_data, list):
return {'carriers': carriers, 'error': self._envia_parse_error(carrier_json)}
carriers.extend(carrier_data)
return {'carriers': carriers}
def _convert_currency(self, amount, picking=False, order=False, from_envia=False):
""" Convert the currency of an amount either to or from the envia currency
specified on the carrier.
Envia expects all shippings to be within the currency specified in the front end
and as such we need to translate the picking currency one way or another.
"""
if not (order or picking):
raise UserError(self.carrier.env._('Sale Order or Picking is required to convert currency.'))
envia_currency = self.carrier.envia_currency_id
if picking:
currency = picking.sale_id and picking.sale_id.currency_id or picking.company_id.currency_id
company = picking.company_id or picking.env.company
convert_date = picking.date_done or datetime.today()
else:
currency = order.currency_id or order.company_id.currency_id
company = order.company_id or order.env.company
convert_date = datetime.today()
source_currency = envia_currency if from_envia else currency
dest_currency = currency if from_envia else envia_currency
if dest_currency.id != source_currency.id:
return source_currency._convert(amount, dest_currency, company, convert_date)
return amount
def _get_shipping_lines(self, package):
""" Returns the shipping products from the specific
picking to create the order.
"""
line_by_product = []
original_prices = []
picking = package.picking_id
for commodity in package.commodities:
unit_price = round(commodity.monetary_value, 2)
# Price of the item must be in the currency you created your Envia account with
unit_price_in_currency = self._convert_currency(unit_price, picking=picking, from_envia=False)
item = {
'description': shorten(commodity.product_id.name, ENVIA_CONTENT_LENGTH_LIMIT, placeholder="..."),
'quantity': commodity.qty,
'price': unit_price_in_currency,
'weight': self.carrier._envia_convert_weight(commodity.product_id.weight),
}
if commodity.product_id.hs_code:
# Pass international information if it's necessary
item |= {
'productCode': commodity.product_id.hs_code.replace('.', '') or '',
'countryOfManufacture': commodity.country_of_origin or ''
}
line_by_product.append(item)
# Store the original currency price so we don't lose rounding precision for declaredValue
original_prices.append(unit_price * commodity.qty)
return line_by_product, original_prices
def _prepare_parcel(self, package, origin):
""" Prepare parcel for picking shipment based on the package.
Use the origin partner to see if we need to add Mexico specific
code.
"""
dimensions = package.dimension
# Resize dimensions to CM
for dim, size in dimensions.items():
dimensions[dim] = self.carrier._envia_convert_size(size)
net_weight_in_kg = self.carrier._envia_convert_weight(package.weight)
package_type = package.packaging_type
picking = package.picking_id
items, original_prices = self._get_shipping_lines(package)
contents = ", ".join(commodity.product_id.name for commodity in package.commodities)
# Envia limits to 35 characters, if it's too long use categories instead.
if len(contents) > ENVIA_CONTENT_LENGTH_LIMIT:
categories = [comm.product_id.categ_id.name for comm in package.commodities]
contents = shorten(", ".join(categories), ENVIA_CONTENT_LENGTH_LIMIT, placeholder="...")
# Store the total price of all items in this package as a insurance value
sum_price = sum(original_prices)
declared_value = self._convert_currency(sum_price, picking=picking, from_envia=False)
parcel = {
'type': package_type,
'name': package.name,
'content': contents,
'amount': 1,
'declaredValue': declared_value,
'lengthUnit': "CM",
'weightUnit': "KG",
'weight': net_weight_in_kg,
'dimensions': dimensions,
'additionalServices': []
}
if insurance_value := float_round(declared_value * self.carrier.shipping_insurance / 100, 2):
parcel['additionalServices'].append({
"service": "envia_insurance",
"data": {
"amount": str(insurance_value),
},
})
parcel['additionalServices'].extend(self._envia_format_services(origin, package_type))
if package_type == 'box':
parcel['items'] = items
if package_type == 'pallet':
if origin.country_id == origin.env.ref('base.mx'):
# Envia requires an extra field to be set for mexico when sending parcels/ltl shipments.
# It requires the unspsc_code which is in product_unspsc module, however this is only for
# orders shipped from mexico which requires the localization installed which depends on unspsc.
complementData = []
product_unspsc = origin.env['ir.module.module'].search([('name', '=', 'product_unspsc')])
for comm in package.commodities:
unspsc = product_unspsc.state == 'installed' and comm.product_id.unspsc_code_id
if not unspsc:
msg = origin.env._("For LTL shipments in Mexico, a Bill of Landing (Carta Porte) is required, "
"in order to send the required information you need to set the UNSPSC code in "
"the following product: %(product_name)s", product_name=comm.product_id.display_name)
raise ValidationError(msg)
complementData.append({
'productCode': unspsc.code,
'productDescription': shorten(unspsc.name, ENVIA_CONTENT_LENGTH_LIMIT, placeholder="..."),
'weightUnit': "X8A",
'quantity': comm.qty,
'unitPrice': round(comm.monetary_value, 2),
'currency': self.carrier.envia_currency_id.name
})
parcel['bolComplement'] = complementData
return parcel
def _get_shipping_params(self, picking):
""" Returns the shipping data from picking for create a Envia Order."""
envia_carrier = self.carrier.envia_carrier_code
envia_service = self.carrier.envia_service_code
picking_data = {}
ship_from = picking.picking_type_id.warehouse_id.partner_id or picking.warehouse_id.company_id.partner_id
bad_products = picking.move_line_ids.product_id.filtered(lambda prod: not prod.weight and prod.type == 'consu').mapped('name')
if bad_products:
product_names = ",".join(bad_products)
raise ValidationError(picking.env._(
"Envia Error: The following products don't have weights set: %(product_names)s",
product_names=product_names
))
default_package = self.carrier.envia_default_package_type_id
packages = self.carrier._get_packages_from_picking(picking, default_package)
package_types = packages and {package.packaging_type for package in packages}
if not package_types or any(package_type != default_package.envia_mail_type for package_type in package_types):
# The carrier/service selected on the delivery carrier model only works. If the package
# is of the same type as the default_packages' type. They need to use the right type.
raise ValidationError(picking.env._(
"Envia Error: The Envia Mail Type (%(package_types)s) set on the package(s) does "
"not match the type set on the carrier (%(carrier_package_type)s). "
"Use a different package or different carrier that matches the mail type.",
package_types=package_types, carrier_package_type=default_package.envia_mail_type
))
picking_data = {
'origin': self._prepare_address_values(ship_from, is_cust=False),
'destination': self._prepare_address_values(picking.partner_id, is_cust=True),
'packages': [],
'settings': {
'printFormat': self.carrier.envia_label_file_type,
'printSize': self.carrier.envia_label_stock_type,
},
'shipment': {
'carrier': envia_carrier,
'service': envia_service,
'type': 2 if default_package.envia_mail_type == 'pallet' else 1,
},
}
for package in packages:
picking_data['packages'].append(self._prepare_parcel(package, origin=ship_from))
return picking_data
def _send_shipping(self, picking):
""" Returns a dictionary containing:
- Price of the shipment
- All tracking numbers for each package
- Envia Order Ids for cancelation.
url(s): ship/generate
"""
res = {
'exact_price': 0.00,
'tracking_number': '',
}
picking_data = self._get_shipping_params(picking)
ship_response = self._make_api_request('ship/generate', method='POST', data=picking_data)
if not ship_response or isinstance(ship_response, dict) and ship_response.get('meta') not in ['generate', 'generateLtl']:
# Error message found
raise UserError(self._envia_parse_error(ship_response))
data_list = ship_response.get('data')
tracking_numbers = []
label_urls = []
additional_files = []
for order_data in data_list:
tracking_numbers.append(order_data.get('trackingNumber'))
label_urls.append(order_data.get('label'))
additional_files += order_data.get('additionalFiles')
params = {
'label': ','.join(tracking_numbers)
}
tracking_url = self.tracking_url + f"tracking?{urlencode(params)}"
formatted_tracking = format_list(picking.env, tracking_numbers)
carrier_tracking_link = Markup("%s
") % (tracking_url, formatted_tracking)
# pickings where we should leave a lognote
lognote_pickings = picking.sale_id.picking_ids if picking.sale_id else picking
logmessage = Markup(
"{header}
{tracking_header} {tracking_link}
"
).format(
header=picking.env._("Shipment created into Envia"),
tracking_header=picking.env._("Tracking Numbers:"),
tracking_link=carrier_tracking_link
)
label_data = []
for label_url, track_number in zip(label_urls, tracking_numbers):
try:
response = self.session.get(url=label_url, timeout=30)
response.raise_for_status()
label_data.append(('%s-%s.%s' % (self.carrier._get_delivery_label_prefix(), track_number, self.carrier.envia_label_file_type), response.content))
except (requests.exceptions.HTTPError, requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError):
logmessage += Markup('