forked from Mapan/odoo17e
346 lines
17 KiB
Python
346 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import base64
|
|
from binascii import a2b_base64
|
|
import io
|
|
import logging
|
|
import re
|
|
import requests
|
|
from lxml import html
|
|
from xml.etree import ElementTree as etree
|
|
from werkzeug.urls import url_join
|
|
|
|
from odoo import _
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import float_round
|
|
from odoo.tools.pdf import PdfFileWriter, PdfFileReader
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
COUNTRIES_WITHOUT_POSTCODES = [
|
|
'AO', 'AG', 'AW', 'BS', 'BZ', 'BJ', 'BW', 'BF', 'BI', 'CM', 'CF', 'KM',
|
|
'CG', 'CD', 'CK', 'CI', 'DJ', 'DM', 'GQ', 'ER', 'FJ', 'TF', 'GM', 'GH',
|
|
'GD', 'GN', 'GY', 'HK', 'IE', 'JM', 'KE', 'KI', 'MO', 'MW', 'ML', 'MR',
|
|
'MU', 'MS', 'NR', 'AN', 'NU', 'KP', 'PA', 'QA', 'RW', 'KN', 'LC', 'ST',
|
|
'SC', 'SL', 'SB', 'SO', 'ZA', 'SR', 'SY', 'TZ', 'TL', 'TK', 'TO', 'TT',
|
|
'TV', 'UG', 'AE', 'VU', 'YE', 'ZW'
|
|
]
|
|
|
|
def _grams(kilograms):
|
|
return int(kilograms * 1000)
|
|
|
|
|
|
class BpostRequest():
|
|
|
|
def __init__(self, prod_environment, debug_logger):
|
|
self.debug_logger = debug_logger
|
|
if prod_environment:
|
|
self.base_url = 'https://api-parcel.bpost.be/services/shm/'
|
|
else:
|
|
self.base_url = 'https://api-parcel.bpost.be/services/shm/'
|
|
|
|
def check_required_value(self, recipient, delivery_nature, shipper, order=False, picking=False):
|
|
recipient_required_fields = ['city', 'country_id']
|
|
if recipient.country_id.code not in COUNTRIES_WITHOUT_POSTCODES:
|
|
recipient_required_fields.append('zip')
|
|
# The street isn't required if we compute the rate with a partial delivery address in the
|
|
# express checkout flow.
|
|
if not recipient.street and not recipient.street2 and not recipient._context.get(
|
|
'express_checkout_partial_delivery_address', False
|
|
):
|
|
recipient_required_fields.append('street')
|
|
shipper_required_fields = ['city', 'zip', 'country_id']
|
|
if not shipper.street and not shipper.street2:
|
|
shipper_required_fields.append('street')
|
|
|
|
res = [field for field in recipient_required_fields if not recipient[field]]
|
|
if res:
|
|
return _("The recipient address is incomplete or wrong (Missing field(s): \n %s)", ", ".join(res).replace("_id", ""))
|
|
if recipient.country_id.code == "BE" and delivery_nature == 'International':
|
|
return _("bpost International is used only to ship outside Belgium. Please change the delivery method into bpost Domestic.")
|
|
if recipient.country_id.code != "BE" and delivery_nature == 'Domestic':
|
|
return _("bpost Domestic is used only to ship inside Belgium. Please change the delivery method into bpost International.")
|
|
|
|
res = [field for field in shipper_required_fields if not shipper[field]]
|
|
if res:
|
|
return _("The address of your company/warehouse is incomplete or wrong (Missing field(s): \n %s)", ", ".join(res).replace("_id", ""))
|
|
if shipper.country_id.code != 'BE':
|
|
return _("Your company/warehouse address must be in Belgium to ship with bpost")
|
|
|
|
if order:
|
|
if order.order_line and all(order.order_line.mapped(lambda l: l.product_id.type == 'service')):
|
|
return _("The estimated shipping price cannot be computed because all your products are service.")
|
|
if not order.order_line:
|
|
return _("Please provide at least one item to ship.")
|
|
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:
|
|
return _("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')))
|
|
return False
|
|
|
|
def _parse_address(self, partner):
|
|
if partner.street and partner.street2:
|
|
street = '%s %s' % (partner.street, partner.street2)
|
|
else:
|
|
street = partner.street or partner.street2
|
|
match = re.match(r'^(.*?)(\S*\d+\S*)?\s*$', street, re.DOTALL)
|
|
street = match.group(1)
|
|
street_number = match.group(2) # None if no number found
|
|
if street_number and len(street_number) > 8:
|
|
street = match.group(0)
|
|
street_number = None
|
|
return (street, street_number)
|
|
|
|
def rate(self, order, carrier):
|
|
weight_in_kg = carrier._bpost_convert_weight(order._get_estimated_weight())
|
|
return self._get_rate(carrier, _grams(weight_in_kg), order.partner_shipping_id.country_id)
|
|
|
|
def _get_rate(self, carrier, weight, country):
|
|
'''@param carrier: a record of the delivery.carrier
|
|
@param weight: in grams
|
|
@param country: a record of the destination res.country'''
|
|
|
|
# Surprisingly, bpost does not require to send other data while asking for prices;
|
|
# they simply return a price grid for all activated products for this account.
|
|
code, response = self._send_request('rate', None, carrier)
|
|
if code == 401 and response:
|
|
# If the authentication fails, the server returns plain HTML instead of XML
|
|
error_page = html.fromstring(response)
|
|
error_message = error_page.body.text_content()
|
|
raise UserError(_("Authentication error -- wrong credentials\n(Detailed error: %s)", error_message))
|
|
else:
|
|
xml_response = etree.fromstring(response)
|
|
|
|
# Find price by product and country
|
|
price = 0.0
|
|
ns = {'ns1': 'http://schema.post.be/shm/deepintegration/v3/'}
|
|
bpost_delivery_type = carrier.bpost_domestic_deliver_type if carrier.bpost_delivery_nature == 'Domestic' else carrier.bpost_international_deliver_type
|
|
for delivery_method in xml_response.findall('ns1:deliveryMethod/[@name="home or office"]/ns1:product/[@name="%s"]/ns1:price' % bpost_delivery_type, ns):
|
|
if delivery_method.attrib['countryIso2Code'] == country.code:
|
|
price = float(self._get_price_by_weight(weight, delivery_method))/100
|
|
sale_price_digits = carrier.env['decimal.precision'].precision_get('Product Price')
|
|
price = float_round(price, precision_digits=sale_price_digits)
|
|
if not price:
|
|
raise UserError(_("bpost did not return prices for this destination country."))
|
|
|
|
# If delivery on saturday is enabled, there are additional fees
|
|
additional_fees = 0.0
|
|
if carrier.bpost_saturday is True:
|
|
for option_price in xml_response.findall('ns1:deliveryMethod/[@name="home or office"]/ns1:product/[@name="%s"]/ns1:option/[@name="Saturday"]' % bpost_delivery_type, ns):
|
|
additional_fees = float(option_price.attrib['price'])
|
|
|
|
return price + additional_fees
|
|
|
|
def _get_price_by_weight(self, weight, price):
|
|
if weight <= 2000:
|
|
return price.attrib['priceLessThan2']
|
|
elif weight <= 5000:
|
|
return price.attrib['price2To5']
|
|
elif weight <= 10000:
|
|
return price.attrib['price5To10']
|
|
elif weight <= 20000:
|
|
return price.attrib['price10To20']
|
|
elif weight <= 30000:
|
|
return price.attrib['price20To30']
|
|
else:
|
|
raise UserError(_("Packages over 30 Kg are not accepted by bpost."))
|
|
|
|
def send_shipping(self, picking, carrier, with_return_label, is_return_label=False):
|
|
|
|
if is_return_label:
|
|
receiver = picking.picking_type_id.warehouse_id.partner_id
|
|
receiver_company = ''
|
|
sender = picking.partner_id
|
|
boxes = self._compute_return_boxes(picking, carrier)
|
|
else:
|
|
receiver = picking.partner_id
|
|
receiver_company = receiver.commercial_partner_id.name if receiver.commercial_partner_id != receiver else ''
|
|
sender = picking.picking_type_id.warehouse_id.partner_id
|
|
boxes = self._compute_boxes(picking, carrier)
|
|
|
|
###### need to change the get_rate !!!!!!!!!!
|
|
price = 0.0
|
|
for box in boxes:
|
|
price += self._get_rate(carrier, int(box['weight']), picking.partner_id.country_id)
|
|
|
|
# Announce shipment to bpost
|
|
reference_id = str(picking.name.replace("/", ""))[:50]
|
|
ss, sn = self._parse_address(sender)
|
|
rs, rn = self._parse_address(receiver)
|
|
|
|
# bpsot only allow a zip with a size of 8 characters. In some country
|
|
# (e.g. brazil) the postalCode could be longer than 8. In this case we
|
|
# set the zip in the locality.
|
|
receiver_postal_code = receiver.zip
|
|
receiver_locality = receiver.city
|
|
|
|
# Some country do not use zip code (Saudi Arabia, Congo, ...). Bpost
|
|
# always require at least a zip or a PO box.
|
|
if not receiver_postal_code:
|
|
receiver_postal_code = '/'
|
|
elif len(receiver_postal_code) > 8:
|
|
receiver_locality = '%s %s' % (receiver_locality, receiver_postal_code)
|
|
receiver_postal_code = '/'
|
|
|
|
if receiver.state_id:
|
|
receiver_locality = '%s, %s' % (receiver_locality, picking.partner_id.state_id.display_name)
|
|
|
|
values = {'accountId': carrier.sudo().bpost_account_number,
|
|
'reference': reference_id,
|
|
'sender': {'_record': sender,
|
|
'streetName': ss,
|
|
'number': sn,
|
|
},
|
|
'receiver': {'_record': receiver,
|
|
'company': receiver_company,
|
|
'streetName': rs,
|
|
'number': rn,
|
|
'locality': receiver_locality,
|
|
'postalCode': receiver_postal_code,
|
|
},
|
|
'is_domestic': carrier.bpost_delivery_nature == 'Domestic',
|
|
# domestic
|
|
'product': 'bpack Easy Retour' if is_return_label else carrier.bpost_domestic_deliver_type,
|
|
'saturday': carrier.bpost_saturday,
|
|
# international
|
|
'international_product': carrier.bpost_international_deliver_type,
|
|
'shipmentType': carrier.bpost_shipment_type,
|
|
'parcelReturnInstructions': carrier.bpost_parcel_return_instructions,
|
|
'boxes': boxes,
|
|
'_record': picking,
|
|
}
|
|
xml = carrier.env['ir.qweb']._render('delivery_bpost.bpost_shipping_request', values)
|
|
code, response = self._send_request('send', xml.encode(), carrier)
|
|
if code != 201 and response:
|
|
try:
|
|
root = etree.fromstring(response)
|
|
ns = {'ns1': 'http://schema.post.be/shm/deepintegration/v3/'}
|
|
for errors_return in root.findall("ns1:error", ns):
|
|
raise UserError(errors_return.text)
|
|
except etree.ParseError:
|
|
raise UserError(response)
|
|
|
|
# Grab printable label and tracking code
|
|
code, response2 = self._send_request('label', None, carrier, reference=reference_id, with_return_label=with_return_label)
|
|
root = etree.fromstring(response2)
|
|
ns = {'ns1': 'http://schema.post.be/shm/deepintegration/v3/'}
|
|
for labels in root.findall('ns1:label', ns):
|
|
if with_return_label:
|
|
main_label, return_label = self._split_labels(labels, ns)
|
|
else:
|
|
main_label = {
|
|
'tracking_codes': [label.text for label in labels.findall("ns1:barcode", ns)],
|
|
'label': a2b_base64(labels.find("ns1:bytes", ns).text)
|
|
}
|
|
return_label = False
|
|
return {
|
|
'price': price,
|
|
'main_label': main_label,
|
|
'return_label': return_label
|
|
}
|
|
|
|
def _split_labels(self, labels, ns):
|
|
|
|
def _get_page(src_pdf, page_nums):
|
|
with io.BytesIO(base64.b64decode(src_pdf)) as stream:
|
|
try:
|
|
pdf = PdfFileReader(stream)
|
|
writer = PdfFileWriter()
|
|
for page in page_nums:
|
|
writer.addPage(pdf.getPage(page))
|
|
stream2 = io.BytesIO()
|
|
writer.write(stream2)
|
|
return a2b_base64(base64.b64encode(stream2.getvalue()))
|
|
except Exception:
|
|
_logger.error('Error ')
|
|
return False
|
|
|
|
barcodes = labels.findall("ns1:barcode", ns)
|
|
src_pdf = labels.find("ns1:bytes", ns).text
|
|
|
|
# return barcodes ends with '050'
|
|
main_indeces = [index for index, barcode in enumerate(barcodes) if barcode.text[-3:] != '050']
|
|
return_indeces = [index for index, barcode in enumerate(barcodes) if barcode.text[-3:] == '050']
|
|
|
|
main_label = {
|
|
'tracking_codes': [barcodes[index].text for index in main_indeces],
|
|
'label': _get_page(src_pdf, main_indeces)
|
|
}
|
|
|
|
return_label = False
|
|
if len(barcodes) > 1:
|
|
return_label = {
|
|
'tracking_codes': [barcodes[index].text for index in return_indeces],
|
|
'label': _get_page(src_pdf, return_indeces)
|
|
}
|
|
|
|
return (main_label, return_label)
|
|
|
|
def _send_request(self, action, xml, carrier, reference=None, with_return_label=False):
|
|
supercarrier = carrier.sudo()
|
|
passphrase = supercarrier._bpost_passphrase()
|
|
METHODS = {'rate': 'GET',
|
|
'send': 'POST',
|
|
'label': 'GET'}
|
|
HEADERS = {'rate': {'authorization': 'Basic %s' % passphrase,
|
|
'accept': 'application/vnd.bpost.shm-productConfiguration-v3.1+XML'},
|
|
'send': {'authorization': 'Basic %s' % passphrase,
|
|
'content-Type': 'application/vnd.bpost.shm-order-v3.3+XML'},
|
|
'label': {'authorization': 'Basic %s' % passphrase,
|
|
'accept': 'application/vnd.bpost.shm-label-%s-v3+XML' % ('pdf' if carrier.bpost_label_format == 'PDF' else 'image'),
|
|
'content-Type': 'application/vnd.bpost.shm-labelRequest-v3+XML'}}
|
|
label_url = url_join(self.base_url, '%s/orders/%s/labels/%s' % (supercarrier.bpost_account_number, reference, carrier.bpost_label_stock_type))
|
|
if with_return_label:
|
|
label_url += '/withReturnLabels'
|
|
URLS = {'rate': url_join(self.base_url, '%s/productconfig' % supercarrier.bpost_account_number),
|
|
'send': url_join(self.base_url, '%s/orders' % supercarrier.bpost_account_number),
|
|
'label': label_url}
|
|
self.debug_logger("%s\n%s\n%s" % (URLS[action], HEADERS[action], xml if xml else None), 'bpost_request_%s' % action)
|
|
try:
|
|
response = requests.request(METHODS[action], URLS[action], headers=HEADERS[action], data=xml, timeout=15)
|
|
except requests.exceptions.Timeout:
|
|
raise UserError(_('The BPost shipping service is unresponsive, please retry later.'))
|
|
self.debug_logger("%s\n%s" % (response.status_code, response.text), 'bpost_response_%s' % action)
|
|
|
|
return response.status_code, response.text
|
|
|
|
def _compute_boxes(self, picking, carrier):
|
|
"""Group the move lines in the picking to different boxes.
|
|
|
|
Lines with the same result_package_id belong to the same box,
|
|
and lines without result_package_id are assigned to one box.
|
|
This method returns a list of summary of each box which will be
|
|
used in creating the request in making order in bpost.
|
|
"""
|
|
boxes = []
|
|
for package in picking.package_ids:
|
|
package_lines = picking.move_line_ids.filtered(lambda sml: sml.result_package_id.id == package.id)
|
|
parcel_value = sum(sml.sale_price for sml in package_lines)
|
|
weight_in_kg = carrier._bpost_convert_weight(package.shipping_weight)
|
|
boxes.append({
|
|
'weight': str(_grams(weight_in_kg)),
|
|
'parcelValue': max(min(int(parcel_value * 100), 2500000), 100),
|
|
'contentDescription': ' '.join(["%d %s" % (line.quantity, re.sub(r'[\W_]+', ' ', line.product_id.name or '')) for line in package_lines])[:50],
|
|
})
|
|
lines_without_package = picking.move_line_ids.filtered(lambda sml: not sml.result_package_id)
|
|
if lines_without_package:
|
|
parcel_value = sum(sml.sale_price for sml in lines_without_package)
|
|
weight_in_kg = carrier._bpost_convert_weight(sum(sml.quantity * sml.product_id.weight for sml in lines_without_package))
|
|
boxes.append({
|
|
'weight': str(_grams(weight_in_kg)),
|
|
'parcelValue': max(min(int(parcel_value * 100), 2500000), 100),
|
|
'contentDescription': ' '.join(["%d %s" % (line.quantity, re.sub(r'[\W_]+', ' ', line.product_id.name or '')) for line in lines_without_package])[:50],
|
|
})
|
|
return boxes
|
|
|
|
def _compute_return_boxes(self, picking, carrier):
|
|
weight = sum(move.product_qty * move.product_id.weight for move in picking.move_ids)
|
|
weight_in_kg = carrier._bpost_convert_weight(weight)
|
|
parcel_value = sum(move.product_qty * move.product_id.lst_price for move in picking.move_ids)
|
|
boxes = [{
|
|
'weight': str(_grams(weight_in_kg)),
|
|
'parcelValue': max(min(int(parcel_value * 100), 2500000), 100),
|
|
'contentDescription': ' '.join(["%d %s" % (line.product_qty, re.sub(r'[\W_]+', ' ', line.product_id.name or '')) for line in picking.move_ids])[:50],
|
|
}]
|
|
return boxes
|