293 lines
13 KiB
Python
293 lines
13 KiB
Python
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
|
|
pkg['description'] = picking.sc_content_desc
|
|
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
|