shipcloud.io integration to odoo (as delivery carrier)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
odoo_shipcloud/models/shipcloud_delivery_carrier.py

291 lines
12 KiB

from six import string_types
from openerp import api, fields, models
import logging
from openerp.exceptions import Warning
import pycountry
import shipcloud
_logger = logging.getLogger(__name__)
# FIXME: unify with odoo-internetmarke
# split the last word of a string containing stree name + house number
def split_street_house(streethouse):
# first try to split at last space
r = streethouse.rsplit(' ', 1)
# if that fails, try to split at last dot
if len(r) < 2:
r = streethouse.rsplit('.', 1)
# if that also fails, return empty house number
if len(r) < 2:
return (streethouse, '')
return (r[0], r[1])
def split_first_lastname(name):
# try to split at last space
r = name.rsplit(' ', 1)
# if this fails, simply claim everything is the last name
if len(r) < 2:
return ("", name)
return (r[0], r[1])
def build_sc_addr(partner):
"""Convert an Odoo partner object into a shipcloud address."""
addr = {}
(street, house) = split_street_house(partner.street)
addr['street'] = street
addr['street_no'] = house
if partner.street2:
addr['care_of'] = partner.street2
addr['zip_code'] = partner.zip
addr['city'] = partner.city
if partner.state_id and partner.state_id.code:
addr['state'] = partner.state_id.code
addr['country'] = partner.country_id.code
if partner.is_company:
addr['company'] = partner.name
else:
if partner.parent_id.name:
addr['company'] = partner.parent_id.name
if partner.name:
(first, last) = split_first_lastname(partner.name)
addr['first_name'] = first
addr['last_name'] = last
if partner.email:
addr['email'] = partner.email
if partner.mobile:
addr['phone'] = partner.mobile
elif partner.phone:
addr['phone'] = partner.phone
elif partner.parent_id and partner.parent_id.phone:
addr['phone'] = partner.parent_id.phone
# We had trouble communicating with the carrier: ShipFrom phone number cannot be more than 15 digits long
if 'phone' in addr and len(addr['phone']) > 15:
addr['phone'] = ''.join(c for c in addr['phone'] if c.isdigit())
# strip all leading or trailing spaces, see SYS#5414
for k in addr:
if isinstance(addr[k], string_types):
addr[k] = addr[k].strip()
return addr
class SCDeliveryCarrier(models.Model):
_inherit = 'delivery.carrier'
@staticmethod
def estimate_dimensions(weight_kg, density_kg_per_dm3):
"""Estimate the dimensions of a given package, given its weight and mass density,
assuming a 3:2:1 ration between length:width:height"""
def cbrt(x):
"""Return cubic root of 'x'"""
return x**(1.0/3)
volume_dm3 = float(weight_kg) / float(density_kg_per_dm3)
volume_cm3 = 1000 * volume_dm3
# assuming l=3x, w=2x, h=1x -> x=6
x = cbrt(volume_cm3 / 6)
return (3.0*x, 2.0*x, x)
def build_sc_pkg(self, order=None, picking=None):
"""Convert an Odoo stock.picking or sale.order into a shipcloud package"""
pkg = {}
pkg['type'] = 'parcel'
if picking:
pkg['weight'] = self._get_weight_with_tare(order, picking)
pkg['length'] = picking.packaging_length
pkg['width'] = picking.packaging_width
pkg['height'] = picking.packaging_height
else:
# we assume an average mass density of 0.5kg per dm3 (litre)
pkg['weight'] = self._get_weight(order, picking)
est = self.estimate_dimensions(pkg['weight'], 0.5)
pkg['length'] = est[0]
pkg['width'] = est[1]
pkg['height'] = est[2]
return pkg
def build_sc_customs_item(self, line):
"""Generate a shipcloud customs_item from a stock.move (line of a picking)"""
product_uom_obj = self.env['product.uom']
q = product_uom_obj._compute_qty_obj(self._get_default_uom(), line.product_uom_qty, self.uom_id)
product = line.product_id
if product:
if product.x_sysmo_customs_code:
hts = product.x_sysmo_customs_code
else:
raise Warning('Product Variant %s has no HTS defined' % (product.name))
if product.x_country_of_origin:
orig = product.x_country_of_origin.code
elif line.product_tmpl_id and line.product_tmpl_id.x_country_of_origin:
orig = line.product_tmpl_id.x_country_of_origin.code
else:
raise Warning('Product Variant %s has no Country of Origin defined' % (product.name))
weight = product.weight
elif line.product_tmpl_id:
ptmpl = line.product_tmpl_id
if ptempl.x_sysmo_default_customs_code:
hts = ptempl.x_sysmo_default_customs_code
else:
raise Warning('Product %s has no HTS defined' % (ptempl.name))
if ptempl.x_country_of_origin:
orig = ptempl.x_country_of_origin.code
else:
raise Warning('Product %s has no Country of Origin defined' % (ptempl.name))
weight = ptempl.weight
if line.procurement_id and line.procurement_id.sale_line_id:
price_unit = line.procurement_id.sale_line_id.price_unit
else:
raise Warning('Line has no procurement or procurement no sale order line?!?')
# remove any spaces, as DHL is not happy about them
hts = ''.join(c for c in hts if c.isdigit())
res = {
'origin_country': orig,
'description': line.name,
'hs_tariff_number': hts,
'quantity': q,
'value_amount': price_unit,
'net_weight': weight,
}
return res
def build_sc_customs_decl(self, picking, explanation, currency='EUR'):
items = [self.build_sc_customs_item(x) for x in picking.move_lines if picking.state == 'assigned']
total = 0.0
for i in items:
total += i['value_amount']
invoice_number = picking.name
invoice_date = picking.date
if picking.sale_id and picking.sale_id.invoice_ids:
invoices = picking.sale_id.invoice_ids.filtered(lambda r: r.type == 'out_invoice' and
r.state not in ['draft', 'cancel'])
if len(invoices):
invoice_number = invoices[0].number
invoice_date = invoices[0].date_invoice
customs = {
'contents_type': 'commercial_goods',
'contents_explanation': explanation,
'currency': currency,
'invoice_number': invoice_number,
#'invoice_date': ''.join(c for c in invoice_date if c.isdigit()),
'total_value_amount': total,
'items': items
}
return customs
def _shipcloud_api(self):
config = self._get_config()
api_key = config['sc_api_key_prod']
sandbox_api_key = None if config['sc_api_use_prod'] else config['sc_api_key_sandbox']
return shipcloud.api(api_key, sandbox_api_key)
# 'public' methods used by delivery_carrier
def sc_get_shipping_price_from_so(self, order):
"""Obtain a shipping quote for the given sale.order"""
recipient = order.partner_shipping_id if order.partner_shipping_id else order.partner_id
warehouse = order.warehouse_id.partner_id
carrier_service = self.sudo().sc_carrier_service
# build individual sub-objects of the shipment
from_addr = build_sc_addr(warehouse)
to_addr = build_sc_addr(recipient)
pkg = self.build_sc_pkg(order=order)
# build the actual shipment object
shp = shipcloud.gen_shipment(from_addr, to_addr, pkg, order.name,
carrier=carrier_service.carrier, service=carrier_service.service,
label_fmt=carrier_service.label_size)
# convert shipment to quote object
api = self._shipcloud_api()
try:
result = api.get_shipment_quote(shp)
except shipcloud.ApiError as err:
raise Warning(str(err))
# { "shipment_quote": { "price": 42.12 } }
shipping_cost = result['shipment_quote']['price']
# determine net value in EUR of order
if order.currency_id != order.company_id.currency_id:
eur_amount = order.currency_id.compute(order.amount_untaxed, order.company_id.currency_id)
_logger.info("Converted %5.2f %s -> %5.2f %s" % (order.amount_untaxed, order.currency_id.name, eur_amount, order.company_id.currency_id.name))
else:
eur_amount = order.amount_untaxed
# compute 0.35% of net value for transport insurance
insurance_cost = eur_amount * 0.0035
_logger.info("Order %s, Shipping Service %s, cost=%5.2f, insurance=%5.2f", order.name, carrier_service.name, shipping_cost, insurance_cost)
# return sum of shipcloud shipping cost + insurance cost
return shipping_cost + insurance_cost
def _is_outside_eu(self, country):
if country.code == 'DE':
return False
eu_grp = self.env['res.country.group'].search([('name','=','EU-outside-Germany'), ('country_ids','=',country.id)])
if eu_grp:
return False
return True
@api.one
def sc_send_shipping(self, pickings):
"""Generate a shipping label from the given stock.picking"""
order = pickings.sale_id
recipient = pickings.partner_id
warehouse = pickings.picking_type_id.warehouse_id.partner_id
content_desc = pickings.sc_content_desc
carrier_service = self.sudo().sc_carrier_service
# build individual sub-objects of the shipment
from_addr = build_sc_addr(warehouse)
to_addr = build_sc_addr(recipient)
pkg = self.build_sc_pkg(picking=pickings)
if warehouse.country_id.code != recipient.country_id.code and self._is_outside_eu(recipient.country_id) and carrier_service.carrier != 'ups':
customs = self.build_sc_customs_decl(pickings, content_desc)
else:
customs = None
# build the actual shipment object
shp = shipcloud.gen_shipment(from_addr, to_addr, pkg, pickings.name, customs_decl=customs,
carrier=carrier_service.carrier, service=carrier_service.service,
label_fmt=carrier_service.label_size, descr=content_desc,
notification_mail=recipient.email or None)
api = self._shipcloud_api()
try:
_logger.debug("shipcloud Shipment dict: %s", shp)
result = api.create_shipment(shp, gen_label=True)
#print("RES: %s" % result)
except shipcloud.ApiError as err:
raise Warning(str(err))
# result = ["id", "carrier_tracking_no", "tracking_url", "label_url", "price"]
# {u'label_url': u'https://shipping-labels.shipcloud.io/shipments/a948e8c2/e3fb26be59/label/shipping_label_e3fb26be59.pdf', u'price': 0.0, u'id': u'e3fb26be59a68acd04d565dda027efd415ca8117', u'tracking_url': u'https://track.shipcloud.io/e3fb26be59a68acd04d565dda027efd415ca8117', u'carrier_tracking_no': u'1ZV306W00493609016'}
filename = '%s_%s.pdf' % (carrier_service.carrier, result['carrier_tracking_no'])
pickings.update({'sc_shipment_id': result['id'],
'sc_tracking_url': result['tracking_url']})
# build list of attachments
attachments = [(filename, result['label_bin'])]
if 'customs_declaration_bin' in result:
filename = '%s_%s_customs.pdf' % (carrier_service.carrier, result['carrier_tracking_no'])
attachments.append((filename, result['customs_declaration_bin']))
# log the (filtered) result
result.pop('label_bin', None)
result.pop('customs_declaration_bin', None)
_logger.info("shipcloud Shipment result: %s", result)
res = {'exact_price': result['price'],
'weight': pkg['weight'],
'date_delivery': None,
'tracking_number': ' ' + result['carrier_tracking_no'],
'attachments': attachments}
return res
@api.one
def sc_cancel_shipment(self, picking):
"""Cancel a shipping label"""
api = self._shipcloud_api()
api.delete_shipment(picking.sc_shipment_id)
@api.one
def sc_get_tracking_link(self, pickings):
"""Return a tracking link for the given picking"""
return pickings.sc_tracking_url