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