744 lines
35 KiB
Python
744 lines
35 KiB
Python
# 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("<a href='%s'>%s</a><br/>") % (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}<br/><b>{tracking_header}</b> {tracking_link}<br/>"
|
|
).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('<li><a href="%s">%s</a></li>') % (label_url, label_url)
|
|
|
|
for pick in lognote_pickings:
|
|
pick.message_post(body=logmessage, attachments=label_data)
|
|
|
|
if additional_files:
|
|
logmessage = Markup("{header}<br/>").format(header=picking.env._("Envia Documents:"))
|
|
forms_data = []
|
|
for form_url in additional_files:
|
|
try:
|
|
response = self.session.get(form_url, timeout=30)
|
|
response.raise_for_status()
|
|
forms_data.append(('%s-%s' % (self.carrier._get_delivery_doc_prefix(), form_url.split('/')[-1]), response.content))
|
|
except (requests.exceptions.HTTPError, requests.exceptions.ReadTimeout, requests.exceptions.ConnectionError):
|
|
logmessage += Markup('<li><a href="%s">%s</a></li>') % (form_url, form_url)
|
|
for pick in lognote_pickings:
|
|
pick.message_post(body=logmessage, attachments=forms_data)
|
|
|
|
price = sum(order.get('totalPrice') for order in data_list)
|
|
res['exact_price'] = self._convert_currency(price, picking=picking, from_envia=True)
|
|
res['tracking_number'] = ','.join(tracking_numbers)
|
|
return res
|
|
|
|
def _cancel_picking(self, picking):
|
|
""" Cancel the individual order
|
|
Can end up failing still even if we check that it can be canceled.
|
|
Returns a list of any tracking that failed to cancel.
|
|
url: ship/cancel/
|
|
"""
|
|
carrier = self.carrier.envia_carrier_code
|
|
tracking_numbers = picking.carrier_tracking_ref
|
|
|
|
invalid_trackings = []
|
|
for tracking_number in tracking_numbers.split(','):
|
|
body = {
|
|
'carrier': carrier,
|
|
'trackingNumber': tracking_number
|
|
}
|
|
|
|
cancel_response = self._make_api_request('ship/cancel', method='POST', data=body)
|
|
if isinstance(cancel_response, dict) and cancel_response.get('meta') != 'cancel':
|
|
invalid_trackings.append(tracking_number)
|
|
return invalid_trackings
|
|
|
|
def _get_envia_state_code(self, partner):
|
|
""" Envia requires the state code to be sent for the partner,
|
|
however, the codes they use are slightly different then
|
|
the ISO codes we store in Odoo. If we don't have a special
|
|
code we fall back on the default state code.
|
|
"""
|
|
envia_state_code = STATE_CODE_MAP_ENVIA.get((partner.country_id.code, partner.state_id.code))
|
|
return envia_state_code or partner.state_id.code
|
|
|
|
def _get_envia_vat(self, partner):
|
|
""" Envia requires the vat of the partner.
|
|
If the partner is a delivery address then the vat is on the parent_id
|
|
|
|
In colombia, Envia doesn't expect any periods or - in the vat even
|
|
though we store it so we must strip it.
|
|
"""
|
|
vat = partner.parent_id.vat if partner.type != 'contact' else partner.vat
|
|
if vat and partner.country_id == partner.env.ref("base.co"):
|
|
vat = vat.split("-", 1)[0]
|
|
vat = re.sub("[^+0-9]", "", vat)
|
|
return vat
|
|
|
|
def _get_envia_city(self, partner):
|
|
city = partner.city_id.name or partner.city
|
|
return city
|
|
|
|
def _get_envia_street(self, partner):
|
|
street = partner.street_name or partner.street
|
|
return street
|
|
|
|
def _get_envia_district(self, partner):
|
|
""" Envia requires the city to be sent twice for chile.
|
|
Skip district code and do that instead.
|
|
|
|
For Mexico envia requires the colony field if it's set and
|
|
l10n_mx_edi_extended is installed.
|
|
"""
|
|
if partner.country_id == partner.env.ref("base.cl"):
|
|
return self._get_envia_city(partner)
|
|
|
|
l10n_mx = partner.env['ir.module.module'].search([('name', '=', 'l10n_mx_edi_extended')])
|
|
if l10n_mx.state == 'installed' and partner.l10n_mx_edi_colony:
|
|
return partner.l10n_mx_edi_colony
|
|
|
|
district = partner.street2
|
|
return district
|
|
|
|
def _geolocate_zip(self, partner):
|
|
""" In some countries the idea of a zipcode/pincode is not something
|
|
that is required in envia's front-end. However, they expect specific codes
|
|
that may be not filled in the partner form or slightly different.
|
|
If it's not set on the partner we try and geo-locate it before throwing
|
|
an error.
|
|
|
|
url: locate/{country_code}/{state_code}/{city_name}
|
|
"""
|
|
if not (partner.country_id and (partner.city_id or partner.city) and partner.state_id):
|
|
return False
|
|
country_code = quote(partner.country_id.code)
|
|
city_name = quote(self._get_envia_city(partner))
|
|
state_code = quote(self._get_envia_state_code(partner))
|
|
|
|
geolocate_data = self._make_api_request(f'locate/{country_code}/{state_code}/{city_name}', geolocate=True)
|
|
if not geolocate_data or isinstance(geolocate_data, dict):
|
|
return False
|
|
|
|
geolocate_data = geolocate_data[0]
|
|
|
|
zip_data = geolocate_data.get('zip_codes', [])
|
|
if not zip_data:
|
|
return False
|
|
return zip_data[0].get('zip_code')
|
|
|
|
def _get_envia_zip(self, partner):
|
|
# l10n_co_edi module stores the state code for colombia
|
|
# If that module is installed on the db we can shortcut the api call
|
|
# and return the value immediately.
|
|
l10n_co = partner.env['ir.module.module'].search([('name', '=', 'l10n_co_edi')])
|
|
|
|
if l10n_co.state == 'installed' and partner.city_id.l10n_co_edi_code:
|
|
base_code = str(partner.city_id.l10n_co_edi_code)
|
|
return base_code
|
|
|
|
if partner.country_id == partner.env.ref("base.co") and partner.zip:
|
|
country_code = quote(partner.country_id.code)
|
|
zipcode = quote(partner.zip)
|
|
geolocate_data = self._make_api_request(f'zipcode/{country_code}/{zipcode}', geolocate=True)
|
|
if isinstance(geolocate_data, list) and geolocate_data:
|
|
return geolocate_data[0].get('info', {}).get('stat_8digit') or partner.zip
|
|
|
|
if not (partner.zip or partner.city_id.zipcode):
|
|
zipcode = self._geolocate_zip(partner)
|
|
if not zipcode:
|
|
msg = partner.env._(
|
|
"Envia was unable to locate a postal code for the partner: %(partner_name)s. "
|
|
"Make sure city/commune and state/region are set otherwise enter a postal code directly",
|
|
partner_name=partner.display_name
|
|
)
|
|
raise ValidationError(msg)
|
|
return zipcode
|
|
return partner.zip or partner.city_id.zipcode
|
|
|
|
def _get_envia_phone(self, partner):
|
|
""" Envia requires all phone numbers to be in national format
|
|
(Even for international shipments)
|
|
"""
|
|
phone: str = partner._phone_format(number=partner.phone, force_format='NATIONAL') or partner.phone
|
|
if phone:
|
|
# Clean phonenumber of non numeric digits.
|
|
phone = re.sub("[^+0-9]", "", phone)[:20]
|
|
|
|
if not phone:
|
|
msg = partner.env._("A phone number must be set on Partner: %(partner_name)s", partner_name=partner.display_name)
|
|
raise ValidationError(msg)
|
|
return phone
|
|
|
|
def _prepare_address_values(self, partner, is_cust=False):
|
|
""" Try and prepare the address dictionary required for origin/destination.
|
|
Throw Validation error if it fails.
|
|
Each country has a different set of required fields, we pull the information
|
|
from Envia and then use a mapping of their field names to ours. Some of their
|
|
requirements are more specific than one field on the model so we need functions
|
|
to perform the data validation/computation.
|
|
|
|
url: generic-form?country_code={country_code}&form=address_info
|
|
|
|
"""
|
|
c_code = partner.country_id.code
|
|
params = {
|
|
'country_code': c_code,
|
|
'form': 'address_info'
|
|
}
|
|
country_requirements = self._make_api_request('generic-form', is_query=True, params=params)
|
|
if isinstance(country_requirements, dict) and any(field in country_requirements for field in ['error', 'errors', 'message']):
|
|
raise ValidationError(self._envia_parse_error(country_requirements))
|
|
|
|
# Map Envia address keys to functions that take in the partner and return the correct data.
|
|
field_map = {
|
|
'district': self._get_envia_district,
|
|
'state': self._get_envia_state_code,
|
|
'city': self._get_envia_city,
|
|
'identificationNumber': self._get_envia_vat,
|
|
'city_select': self._get_envia_city,
|
|
'postalCode': self._get_envia_zip,
|
|
'phone': self._get_envia_phone,
|
|
'street': self._get_envia_street,
|
|
}
|
|
missing_fields = []
|
|
address_dict = {
|
|
'name': partner.name,
|
|
'country': partner.country_id.code,
|
|
'company': partner.commercial_company_name or '',
|
|
'number': partner.street_number or partner.street2 or '',
|
|
'street': self._get_envia_street(partner),
|
|
'email': partner.email,
|
|
'phone': self._get_envia_phone(partner),
|
|
'type': 'destination' if is_cust else 'origin'
|
|
}
|
|
|
|
for field in country_requirements:
|
|
envia_name = field.get('fieldId')
|
|
envia_label = field.get('fieldName')
|
|
rules = field.get('rules', {})
|
|
required = rules.get('required', False)
|
|
|
|
key = envia_name if envia_name in field_map else envia_label
|
|
# Skip pre-populated fields
|
|
if address_dict.get(key) or key not in field_map:
|
|
continue
|
|
|
|
envia_func = field_map[key]
|
|
value = envia_func(partner)
|
|
if value:
|
|
address_dict[key] = value
|
|
elif required:
|
|
# Only complain if it's a required field.
|
|
missing_fields.append(partner.env._(
|
|
'%(field_name)s must be set on Partner: %(partner_name)s.',
|
|
field_name=envia_name,
|
|
partner_name=partner.display_name
|
|
))
|
|
|
|
if missing_fields:
|
|
msg = "\n".join(missing_fields)
|
|
raise ValidationError(partner.env._('Missing Fields:\n%s', msg))
|
|
|
|
if partner.country_id == partner.env.ref('base.co'):
|
|
# Colombia requires their city field to be the municipality code,
|
|
# not the city name. Keep the l10n_co_edi path as-is, otherwise rely
|
|
# on Envia geocodes to avoid fabricating codes from the raw ZIP.
|
|
zipcode = address_dict['postalCode']
|
|
if len(zipcode) in [4, 5]:
|
|
zipcode = zipcode.rjust(5, '0').ljust(8, '0')
|
|
address_dict['city'] = address_dict['postalCode'] = zipcode
|
|
|
|
return address_dict
|