# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import re import math import requests from werkzeug.urls import url_join from odoo import fields, _ from odoo.exceptions import UserError from odoo.tools import float_repr, float_compare, float_is_zero # More information at : https://api.sendcloud.dev/docs/sendcloud-public-api/integrations BASE_URL = "https://panel.sendcloud.sc/api/v2/" MULTICOLLO_MAX_PACKAGE = 20 class SendCloud: def __init__(self, public_key, private_key, logger): self.logger = logger self.session = requests.Session() self.session.auth = (public_key, private_key) def _get_shipping_functionalities(self): return self._send_request('shipping-functionalities') def _get_shipping_products(self, from_country, is_return=False, carrier=None, weight=None, to_country=None, sizes=None, size_unit='centimeter'): params = {'from_country': from_country, 'returns': is_return} if carrier: params.update({'carrier': carrier}) if to_country: params.update({'to_country': to_country}) if weight: params.update({'weight': weight, 'weight_unit': 'gram'}) if sizes: params.update({ 'length': sizes.get('length', 0), 'height': sizes.get('height', 0), 'width': sizes.get('width', 0), 'length_unit': size_unit, 'height_unit': size_unit, 'width_unit': size_unit, }) return self._send_request('shipping-products', params=params) def _get_shipping_rate(self, carrier, order=None, picking=None, parcel=None, order_weight=None): # Get source, destination and weight if order: to_country = order.partner_shipping_id.country_id.code from_country = order.warehouse_id.partner_id.country_id.code error_lines = order.order_line.filtered(lambda line: not line.product_id.weight and not line.is_delivery and line.product_id.type != 'service' and not line.display_type) if error_lines: raise UserError(_("The estimated shipping price cannot be computed because the weight is missing for the following product(s): \n %s", ", ".join(error_lines.product_id.mapped('name')))) packages = carrier._get_packages_from_order(order, carrier.sendcloud_default_package_type_id) total_weight = order_weight if order_weight else sum(pack.weight for pack in packages) elif picking: to_country = picking.destination_country_code from_country = picking.location_id.warehouse_id.partner_id.country_id.code total_weight = float(parcel['weight']) else: raise UserError(_('No picking or order provided')) if not to_country or not from_country: raise UserError(_('Make sure country codes are set in partner country and warehouse country')) total_weight = int(carrier.sendcloud_convert_weight(total_weight, grams=True)) if total_weight < carrier.sendcloud_shipping_id.min_weight and order: raise UserError(_('Order below minimum weight of carrier')) if parcel: shipping_methods = [{ 'id': parcel.get('shipment', {}).get('id'), 'weight': int(float(parcel.get('weight', 0))*1000), # parcel weight is defined in kg }] else: shipping_methods = self._get_shipping_methods(carrier, from_country, to_country, total_weight=total_weight) if not shipping_methods or (len(shipping_methods) == 1 and not shipping_methods[0]): raise UserError(_('There is no shipping method available for this order with the selected carrier')) packages_no = 1 if order: default_package = carrier.sendcloud_default_package_type_id target_weight = default_package.max_weight if default_package else False target_weight = int(carrier.sendcloud_convert_weight(target_weight, grams=True)) if target_weight else False packages_no, total_weight = self._split_shipping(carrier.sendcloud_shipping_id, total_weight, target_weight) if packages_no > 1: # We're forcefully calling this method from a sale order, as we only want an estimation of the rating, take the 'heaviest' methods shipping_methods = [m for m in shipping_methods if m['properties']['max_weight'] == carrier.sendcloud_shipping_id.max_weight] # We're sure here there's at least one matching method as max_weight was updated in _get_shipping_methods shipping_prices = self._get_shipping_prices(shipping_methods, to_country, from_country, total_weight) if not shipping_prices: return False shipping_price = min(shipping_prices.items(), key=lambda p: float(p[1]['price'])) price = float(shipping_price[1].get('price')) * packages_no currency = shipping_price[1].get('currency') currency_id = carrier.env['res.currency'].with_context(active_test=False).search([('name', '=', currency)]) if not currency_id: raise UserError(_('Could not find currency %s', currency)) to_currency_id = order.currency_id if order else picking.sale_id.currency_id converted_price = currency_id._convert(price, to_currency_id, carrier.env.company, fields.Date.context_today(carrier)) return converted_price, packages_no def _send_shipment(self, picking, is_return=False): sender_id = None if not is_return: # get warehouse for each picking and get the sender address to use for shipment sender_id = self._get_pick_sender_address(picking) parcels = self._prepare_parcel(picking, sender_id, is_return) # the id of the access_point needs to be passed as parameter following Sendcloud API's if 'access_point_address' in picking.sale_id and picking.sale_id.access_point_address: for parcel in parcels: parcel['to_service_point'] = picking.sale_id.access_point_address['id'] data = { 'parcels': parcels } parameters = { 'errors': 'verbose-carrier', } res = self._send_request('parcels', 'post', data, params=parameters) res_parcels = res.get('parcels') if not res_parcels: error_message = res['failed_parcels'][0].get('errors', False) raise UserError(_("Something went wrong, parcel not returned from Sendcloud:\n %s'.", error_message)) return res_parcels def _track_shipment(self, parcel_id): parcel = self._send_request(f'parcels/{parcel_id}') return parcel['parcel'] def _cancel_shipment(self, parcel_id): res = self._send_request(f'parcels/{parcel_id}/cancel', method='post') return res def _get_document(self, url): ''' Returns pdf content of document to print ''' self.logger(f'get {url}', 'sendcloud get document') try: res = self.session.request(method='get', url=url, timeout=60) except Exception as err: self.logger(str(err), f'sendcloud response {url}') raise UserError(_('Something went wrong, please try again later!!')) self.logger(f'{res.content}', 'sendcloud get document response') if res.status_code != 200: raise UserError(_('Could not get document!')) return res.content def _get_addresses(self): res = self._send_request('user/addresses/sender') return res.get('sender_addresses', []) def _split_shipping(self, shipping_product_id, total_weight, target_weight=False): # if the weight is greater than max weight and source is order (initial estimate) # split the weight into packages instead of returning no price / offer shipping_count = 1 shipping_weight = total_weight # max weight from sendcloud is 1 gram extra (eg. if max allowed weight = 3000g, sendcloud_shipping_id.max_weight = 3001g) max_weight = target_weight if target_weight else shipping_product_id.max_weight - 1 if target_weight or total_weight > max_weight: shipping_count = math.ceil(total_weight / max_weight) shipping_weight = max_weight return shipping_count, shipping_weight def _get_shipping_prices(self, shipping_methods, to_country, from_country, weight=None): shipping_prices = dict() params = { 'shipping_method_id': None, 'to_country': to_country, 'from_country': from_country, 'weight': weight, 'weight_unit': 'gram', } for shipping_method in shipping_methods: shipping_id = params['shipping_method_id'] = shipping_method['id'] if not weight: params['weight'] = shipping_method['properties']['max_weight'] - 1 # the weight of a shipping_method is always in gram # the API response is an Array of 1 dict with price and currency (usually EUR) res = self._send_request('shipping-price', params=params)[0] if res.get('price'): shipping_prices[shipping_id] = res elif shipping_id == 8: # Sendcloud Unstamped Letter # shipping id 8 is a test shipping and does not provide a price, but we still need the flow to continue # the check is done after the request since in the future if price is actually returned it will be passed correctly shipping_prices = {8: {'price': 0.0, 'currency': 'EUR'}} return shipping_prices def _get_shipping_methods(self, carrier_id, from_country, to_country, total_weight=None, is_return=False, sizes=None): """ We're now working with a sendcloud's PRODUCT We must fetch the differents METHODS in that product, in order to find the most appropriate ! We may however still be in the case where there isn't a single method able to handle the total weight of the order. In this last case, we may split the shipping in multiple packages as before. """ sendcloud_product_id = carrier_id.sendcloud_return_id if is_return else carrier_id.sendcloud_shipping_id if not sendcloud_product_id: return None shipping_code = sendcloud_product_id.sendcloud_code shipping_carrier = sendcloud_product_id.carrier # Despite the fact that the sendcloud's documentation says the product # weight range is inclusive, a search with total_weight == max_weight # returns no result. single_shipping = total_weight and total_weight < sendcloud_product_id.max_weight if single_shipping: shipping_products = self._get_shipping_products(from_country, is_return=is_return, carrier=shipping_carrier, to_country=to_country, weight=total_weight, sizes=sizes) else: shipping_products = self._get_shipping_products(from_country, is_return=is_return, carrier=shipping_carrier, to_country=to_country, sizes=sizes) shipping_product = next(filter(lambda p: p['code'] == shipping_code, shipping_products), None) if not shipping_product: if single_shipping: # single_shipping may be false-positive due to the local value 'sendcloud_product_id.max_weight' # we call back this method without filtering the call by weight to reach the update of local cache return self._get_shipping_methods(carrier_id, from_country, to_country, is_return=is_return, sizes=sizes) else: return None if not single_shipping: # Update product local values max_allowed_weight = shipping_product['weight_range']['max_weight'] # This data is only valid if we didn't set the 'weight' arg in _get_shipping_products call self._validate_shipping_product_max_weight(sendcloud_product_id, max_allowed_weight) user_filters = None if is_return else carrier_id.sendcloud_product_functionalities shipping_ids = self._filtered_shipping_method_ids(shipping_product['methods'], user_filters) if not shipping_ids: raise UserError(_("There's no shipping method matching all your selected filters for this picking/order.")) return list(filter(lambda m: m['id'] in shipping_ids, shipping_product['methods'])) def _filtered_shipping_method_ids(self, shipping_methods, user_filters): """ Apply user filters on methods for each filter key : [values] if the ONLY value for a key is None: then the key MUST NOT be present in the method functions if there are "other values" AND None: then OR : the key IS present AND it's value IN filter's values the key IS NOT present if there are ONLY "other values": the key IS present AND it's value IN filter's values Default : return all the ids when there's no user_filters Return : set of id """ shipping_ids = {m['id'] for m in shipping_methods} if not user_filters: return shipping_ids for func, options in user_filters.items(): def pass_filter(shipping_method): return (func not in shipping_method['functionalities'] and 'None' in options) or (func in shipping_method['functionalities'] and shipping_method['functionalities'][func] in options) filtered_ids = {m['id'] for m in shipping_methods if pass_filter(m)} if not filtered_ids: return [] elif not shipping_ids: shipping_ids = filtered_ids else: shipping_ids &= filtered_ids return shipping_ids def _send_request(self, endpoint, method='get', data=None, params=None, route=BASE_URL): url = url_join(route, endpoint) self.logger(f'{url}\n{method}\n{data}\n{params}', f'sendcloud request {endpoint}') if method not in ['get', 'post']: raise Exception(f'Unhandled request method {method}') try: res = self.session.request(method=method, url=url, json=data, params=params, timeout=60) self.logger(f'{res.status_code} {res.text}', f'sendcloud response {endpoint}') res = res.json() except Exception as err: self.logger(str(err), f'sendcloud response {endpoint}') raise UserError(_('Something went wrong, please try again later!!')) if 'error' in res: raise UserError(res['error']['message']) return res def _prepare_parcel_items(self, packages, carrier, products_values=None): if not isinstance(packages, list): packages = [packages] if not products_values: products_values = dict() parcel_items = {} for pkg in packages: for commodity in pkg.commodities: key = commodity.product_id.id if key in parcel_items: parcel_items[key]['quantity'] += commodity.qty continue if commodity.product_id.id in products_values: value = products_values[commodity.product_id.id]['avg_value'] else: value = commodity.monetary_value hs_code = commodity.product_id.hs_code or '' for ch in [' ', '.']: hs_code = hs_code.replace(ch, '') parcel_items[key] = { 'description': commodity.product_id.name, 'quantity': commodity.qty, 'weight': float_repr(carrier.sendcloud_convert_weight(commodity.product_id.weight), 3), 'value': round(value, 2), 'hs_code': hs_code[:8], 'origin_country': commodity.country_of_origin or '', 'sku': commodity.product_id.barcode or '', } return list(parcel_items.values()) def _get_house_number(self, address): house_number = re.search(r"(\d+[-\/]?\d* ?[a-zA-Z]?\d*)(?![a-zA-Z])", address) if house_number: return house_number.group() return ' ' def _validate_partner_details(self, partner): if not partner.phone and not partner.mobile: raise UserError(_('%(partner_name)s phone required', partner_name=partner.name)) if not partner.email: raise UserError(_('%(partner_name)s email required', partner_name=partner.name)) if not all([partner.street, partner.city, partner.zip, partner.country_id]): raise UserError(_('The %s address needs to have the street, city, zip, and country', partner.name)) if (partner.street and len(partner.street) > 75) or (partner.street2 and len(partner.street2) > 75): raise UserError(_('Each address line can only contain a maximum of 75 characters. You can split the address into multiple lines to try to avoid this limitation.')) def _validate_shipping_product_max_weight(self, shipping_product_id, fresh_max_weight): if shipping_product_id.max_weight != fresh_max_weight: shipping_product_id.max_weight = fresh_max_weight return False return True def _get_max_package_sizes(self, package_ids): """ Return the largest size for each dimension of a list of package :param delivery.carrier self: the Sendcloud delivery carrier :param list[DeliveryPackage] package_ids: A list of package :return: A dict of size for each dimension or None if all sizes are 0 :rtype: dict[str, int] or None """ sizes = { "length": 0, "height": 0, "width": 0, } for pkg in package_ids: for axis in ("length", "height", "width"): sizes[axis] = max(sizes[axis], pkg.dimension.get(axis)) return sizes def _prepare_parcel(self, picking, sender_id, is_return): # Pre-checks carrier_id = picking.carrier_id delivery_packages = carrier_id._get_packages_from_picking(picking, carrier_id.sendcloud_default_package_type_id) # If nothing to return -> Error if any(not pkg.weight for pkg in delivery_packages): raise UserError(_("Ensure picking has shipping weight, if using packages, each package should have a shipping weight")) #Prepare API call and process data to_partner_id = picking.partner_id from_partner_id = picking.picking_type_id.warehouse_id.partner_id if is_return: to_partner_id, from_partner_id = from_partner_id, to_partner_id from_country, to_country = from_partner_id.country_id.code, to_partner_id.country_id.code self._validate_partner_details(to_partner_id) shipping_weight = int(carrier_id.sendcloud_convert_weight(picking.shipping_weight, grams=True)) to_europe = to_partner_id.country_id.code in to_partner_id.env.ref('base.europe').country_ids.mapped('code') use_multicollo = carrier_id.sendcloud_use_batch_shipping and to_europe single_shipping = len(delivery_packages) == 1 or (use_multicollo and len(delivery_packages) <= 20) api_weight = shipping_weight if single_shipping else None # Fetch shipping methods compatible with current picking shipping_methods = self._get_shipping_methods(picking.carrier_id, from_country, to_country, api_weight, is_return) sendcloud_product_id = carrier_id.sendcloud_return_id if is_return else carrier_id.sendcloud_shipping_id user_uom_max_weight = carrier_id.sendcloud_convert_weight(sendcloud_product_id.max_weight - 1, grams=True, reverse=True) # grams to user uom user_weight_uom = carrier_id.env['product.template'].sudo()._get_weight_uom_id_from_ir_config_parameter() if not shipping_methods: raise UserError(_('There is no shipping method available for this picking with the selected carrier')) elif any(float_compare(pkg.weight, user_uom_max_weight, precision_rounding=user_weight_uom.rounding) > 0 for pkg in delivery_packages): overweight_products = picking.move_ids.filtered(lambda m: float_compare(m.product_id.weight, user_uom_max_weight, precision_rounding=m.product_uom.rounding) > 0) not_packed = bool(not picking.package_ids and picking.weight_bulk) if not_packed: message = _('The total weight of your transfer is too heavy for the heaviest available shipping method.') else: message = _('Some packages in your transfer are too heavy for the heaviest available shipping method.') message += _("\nTry to distribute your products across your packages so that they weigh less than %(max_weight)s %(unit)s or choose another carrier.", max_weight=user_uom_max_weight, unit=user_weight_uom.name) if overweight_products: product_moves = ", ".join(overweight_products.mapped('name')) message += _("""\nAdditionally, some individual product(s) are too heavy for the heaviest available shipping method. \nDivide the quantity of the following product(s) across your packages if possible or choose another carrier:\n\t%s""", product_moves) raise UserError(message) shipping_prices = self._get_shipping_prices(shipping_methods, to_country, from_country) # Assign consequent price to each method, delete the method if no price is available for shipping_method in reversed(shipping_methods): price = shipping_prices.get(shipping_method['id'], {}).get('price') shipping_method['price'] = float(price) if price else 0.0 method_shipments = self._assign_packages_to_methods(carrier_id, delivery_packages, shipping_methods, use_multicollo) parcel_common = self._prepare_parcel_common_data(picking, is_return, sender_id) products_values = self._get_products_values(picking.sale_id) total_value = 0.0 if picking.sale_id: total_value = sum(line.price_reduce_taxinc * line.product_uom_qty for line in picking.sale_id.order_line.filtered( lambda l: l.product_id.type in ('consu', 'product') and not l.display_type ) ) else: total_value = sum([line.product_id.lst_price * line.product_qty for line in picking.move_ids]) total_value = float_repr(total_value, 2) parcels = [] for shipping in method_shipments: # shipping = { 'id': (int), 'packages': [pkg or [pkg]]}, each id is unique among the whole list parcel_common['shipment'] = { 'id': shipping['id'], } for pkg in shipping['packages']: # pkg is either a single pkg or a list of pkg parcel = dict(parcel_common) if isinstance(pkg, list): max_sizes = self._get_max_package_sizes(pkg) parcel.update({ 'weight': float_repr(sum(p.weight for p in pkg), 3), 'length': max_sizes['length'], 'width': max_sizes['width'], 'height': max_sizes['height'], 'quantity': len(pkg), }) else: parcel.update({ 'weight': float_repr(pkg.weight, 3), 'length': pkg.dimension['length'], 'width': pkg.dimension['width'], 'height': pkg.dimension['height'], }) parcel['parcel_items'] = self._prepare_parcel_items(pkg, carrier_id, products_values) parcel['total_order_value'] = total_value parcels.append(parcel) return parcels def _assign_packages_to_methods(self, carrier_id, delivery_packages, shipping_methods, use_multicollo=False): sorted_methods = self._get_cheapest_method_by_weight_ranges(shipping_methods) # Now methods are sorted by price : # For regular shippings, we can fit packages in the first matching method # For multicollo : # Group packages wisely to minimize the cost if there's more than 20 of them # Fitting of method is done by batch of packages failed_assignation = [] for package in delivery_packages: sendcloud_uom_package_weight = int(carrier_id.sendcloud_convert_weight(package.weight, grams=True)) # user uom to grams cheapest_method = next((m for m in sorted_methods if m['weight']['min'] <= sendcloud_uom_package_weight <= m['weight']['max']), None) if cheapest_method: cheapest_method['packages'].append(package) else: failed_assignation.append(package) if failed_assignation: details = "\n\t- ".join(f"{pkg.name}: {pkg.weight}" for pkg in failed_assignation) raise UserError(_("There's no method with matching weight range for packages :\n%s\nYou can either choose another carrier, change your filters or redefine the content of your package(s).") % details) sorted_methods = [m for m in sorted_methods if m['packages']] # Remove methods without package if not use_multicollo: return sorted_methods multicollo_batch = [{ 'id': False, 'packages': [[]], }] pkg_number = math.ceil(len(delivery_packages)/MULTICOLLO_MAX_PACKAGE) min_batch_size = len(delivery_packages)%MULTICOLLO_MAX_PACKAGE max_batch_size = MULTICOLLO_MAX_PACKAGE for method in reversed(sorted_methods): while True: # the current method may be set for more than 20pkg, ensure to assign all of them batch_size = len(multicollo_batch[-1]['packages'][-1]) method_pkg_size = len(method['packages']) if not multicollo_batch[-1]['id']: multicollo_batch[-1]['id'] = method['id'] if (batch_size + method_pkg_size) <= max_batch_size: multicollo_batch[-1]['packages'][-1].extend(method['packages']) break # Exit the 'infinite' loop, as the else condition always minimize batch_size and method_pkg_size that are defined on basis of finite sets, we will forcefully reach this point else: to_fulfill = max_batch_size - batch_size multicollo_batch[-1]['packages'][-1].extend(method['packages'][:to_fulfill-1]) method['packages'] = method['packages'][to_fulfill:] pkg_number -= 1 max_batch_size = MULTICOLLO_MAX_PACKAGE multicollo_batch[-1]['packages'].append([]) if min_batch_size <= len(multicollo_batch[-1]['packages'][-1]): # As we're iterating in reverse order (on price), we want to ship a min. of package in early batches to minimize the cost pkg_number -= 1 if pkg_number == 0: break min_batch_size = (min_batch_size - len(multicollo_batch[-1]['packages'][-1])) % 20 max_batch_size = MULTICOLLO_MAX_PACKAGE multicollo_batch.append({ 'id': False, 'packages': [[]], }) else: max_batch_size = min_batch_size # Apply minimum append strategy on next iteration return multicollo_batch def _get_products_values(self, sale_order=None): """ If the parcel come from a sale order, we take the price from it. However, as we may have the same product sold at different prices in the same SO, and as sendcloud is strict (particularly when it comes to customs), we define the average price per product """ products_values = dict() if not sale_order: return products_values for line in sale_order.order_line: if line.product_id.type not in ('consu', 'product') or line.display_type or float_is_zero(line.product_uom_qty, precision_rounding=line.product_uom.rounding): continue if line.product_id.id in products_values: products_values[line.product_id.id]['tot_qty'] += line.product_uom_qty products_values[line.product_id.id]['tot_value'] += line.price_reduce_taxinc * line.product_uom_qty else: products_values[line.product_id.id] = { 'tot_qty': line.product_uom_qty, 'tot_value': line.price_reduce_taxinc * line.product_uom_qty, } for val in products_values.values(): val.update({ 'avg_value': float(val['tot_value'])/float(val['tot_qty']) }) return products_values def _get_cheapest_method_by_weight_ranges(self, shipping_methods): # order methods by min_weight(1st) and max_weight(2nd) and price(3rd) shipping_methods = sorted(shipping_methods, key=lambda m: (m['properties']['min_weight'], m['properties']['max_weight'], m['price'])) sorted_methods = [] # Define best method by weight range for method in shipping_methods: if sorted_methods and method['properties']['min_weight'] == sorted_methods[-1]['weight']['min'] and method['properties']['max_weight'] == sorted_methods[-1]['weight']['max']: continue # Due to previous sort, price of current method is forcefully greater for the same weight range (which is worse) sorted_methods.append({ 'id': method['id'], 'price': method['price'], 'weight': { 'min': method['properties']['min_weight'], 'max': method['properties']['max_weight'], }, 'packages': [], }) sorted_methods = sorted(sorted_methods, key=lambda m: m['price']) return sorted_methods def _prepare_parcel_common_data(self, picking, is_return, sender_id=False): if 'access_point_address' in picking.sale_id and picking.sale_id.access_point_address: # actual partner data is stored on partner_id's parent, partner_id contains access_point data if not picking.partner_id.parent_id: raise UserError(_('The delivery address of the customer has been removed from the pickup location. This information is required by Sendcloud. Please go to the delivery partner via the delivery order and make sure the parent of the delivery partner is the partner you want to ship to.')) to_partner_id = picking.partner_id.parent_id else: to_partner_id = picking.partner_id from_partner_id = picking.picking_type_id.warehouse_id.partner_id if is_return: to_partner_id, from_partner_id = from_partner_id, to_partner_id carrier_id = picking.carrier_id apply_rules = carrier_id.sendcloud_shipping_rules sendcloud_product_id = carrier_id.sendcloud_return_id if is_return else carrier_id.sendcloud_shipping_id if picking.sale_id: currency_name = picking.sale_id.currency_id.name else: currency_name = picking.company_id.currency_id.name parcel_common = { 'name': (to_partner_id.name or to_partner_id.parent_id.name or '')[:75], 'company_name': to_partner_id.commercial_company_name[:50] if to_partner_id.commercial_company_name else '', 'address': to_partner_id.street, 'address_2': to_partner_id.street2 or '', 'house_number': self._get_house_number(to_partner_id.street), 'city': to_partner_id.city or '', 'country_state': to_partner_id.state_id.code or '', 'postal_code': to_partner_id.zip, 'country': to_partner_id.country_id.code, 'telephone': to_partner_id.mobile or to_partner_id.phone or '', 'email': to_partner_id.email or '', 'request_label': True, 'apply_shipping_rules': apply_rules, 'is_return': is_return, 'shipping_method_checkout_name': sendcloud_product_id.name, 'order_number': picking.sale_id.name or picking.name, 'customs_shipment_type': 4 if is_return else 2, 'customs_invoice_nr': picking.origin or '', 'total_order_value_currency': currency_name } if sender_id: # "sender_id" implies that "not is_return" (c.f. send_shipment()) # So we're sure here that sender_id and from_partner_id holds the warehouse's address parcel_common.update({ 'sender_address': sender_id }) elif from_partner_id: # As we can't use 'sender_address' and 'from_*' fields at the same time in the API call # we only use from_partner_id in case sender_id is false self._validate_partner_details(from_partner_id) parcel_common.update({ 'from_name': (from_partner_id.name or from_partner_id.parent_id.name or '')[:75], 'from_company_name': from_partner_id.commercial_company_name[:50] if from_partner_id.commercial_company_name else '', 'from_house_number': self._get_house_number(from_partner_id.street), 'from_address_1': from_partner_id.street or '', 'from_address_2': from_partner_id.street2 or '', 'from_city': from_partner_id.city or '', 'from_state': from_partner_id.state_id.code or '', 'from_postal_code': from_partner_id.zip or '', 'from_country': from_partner_id.country_id.code, 'from_telephone': from_partner_id.mobile or from_partner_id.phone or '', 'from_email': from_partner_id.email or '', }) return parcel_common def _get_pick_sender_address(self, picking): warehouse_name = picking.location_id.warehouse_id.name.lower().replace(' ', '') addresses = self._get_addresses() res_id = None for addr in addresses: label = addr.get('label', '').lower().replace(' ', '') contact_name = addr.get('contact_name', '').lower().replace(' ', '') if warehouse_name in (label, contact_name): res_id = addr['id'] break if not res_id: raise UserError(_('No address found with contact name %s on your sendcloud account.', picking.location_id.warehouse_id.name)) return res_id