commit
5048d25df2
8 changed files with 524 additions and 0 deletions
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
{ |
||||
'name': 'shipcloud.io Shipping Integration', |
||||
'category': 'Website/Shipping Logistics', |
||||
'summary': 'Integrate shipping via shipcloud.io directly within Odoo', |
||||
'website': 'https://sysmocom.de/', |
||||
'version': '0.1', |
||||
'description':""" |
||||
""", |
||||
'author': 'Harald Welte', |
||||
'depends': ['odoo_shipping_service_apps', 'shipment_packaging'], |
||||
'data': [ |
||||
'views/res_config.xml', |
||||
], |
||||
'installable': True, |
||||
'application': True, |
||||
'external_dependencies': { |
||||
#'python': ['inema'] |
||||
}, |
||||
} |
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
import res_config, shipcloud, shipcloud_delivery_carrier, shipcloud_shipping_service |
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
from openerp import fields, models, api |
||||
import logging |
||||
|
||||
_logger = logging.getLogger(__name__) |
||||
|
||||
class website_config_settings(models.Model): |
||||
_inherit = 'website.config.settings' |
||||
_name = 'website.sc.config.settings' |
||||
|
||||
sc_api_key_sandbox = fields.Char('shipcloud Sandbox API key') |
||||
sc_api_key_prod = fields.Char('shipcloud Production API key', required=1) |
||||
sc_api_use_prod = fields.Boolean('use shipcloud Production API key') |
||||
|
||||
@api.model |
||||
def get_default_sc_values(self, fields): |
||||
ir_values = self.env['ir.values'] |
||||
sc_config_values_list_tuples = ir_values.get_defaults('delivery.carrier') |
||||
sc_config_values = {} |
||||
for item in sc_config_values_list_tuples: |
||||
sc_config_values.update({item[1]:item[2]}) |
||||
return sc_config_values |
||||
|
||||
@api.one |
||||
def set_sc_values(self): |
||||
ir_values = self.env['ir.values'] |
||||
for config in self: |
||||
ir_values.set_default('delivery.carrier', 'sc_api_key_sandbox', config.sc_api_key_sandbox or '') |
||||
ir_values.set_default('delivery.carrier', 'sc_api_key_prod', config.sc_api_key_prod or '') |
||||
ir_values.set_default('delivery.carrier', 'sc_api_use_prod', config.sc_api_use_prod or False) |
||||
|
||||
return True |
@ -0,0 +1,202 @@
@@ -0,0 +1,202 @@
|
||||
# |
||||
# Python module implementing shipcloud.io REST API |
||||
# (C) 2021 by Harald Welte <laforge@gnumonks.org> |
||||
# |
||||
# SPDX-License-Identifier: MIT |
||||
|
||||
|
||||
import sys |
||||
import logging |
||||
import requests |
||||
from requests.auth import HTTPBasicAuth |
||||
|
||||
class ApiError(Exception): |
||||
"""Exception raised in case of a HTTP/REST API Error. |
||||
|
||||
Attributes: |
||||
method -- HTTP method of the request causing the error |
||||
url -- URL of the HTTP request causing the error |
||||
sandbox -- request made in sandbox mode or not? |
||||
req_body -- json-serializable dict of request body causing the error |
||||
status -- HTTP status returned by REST API |
||||
errors -- list of string error messages returned by REST API |
||||
resp_body -- raw response body of failed rquest |
||||
""" |
||||
def __init__(self, method, url, sandbox, req_body, status, errors=[], resp_body=None): |
||||
self.method = method |
||||
self.url = url |
||||
self.sandbox = sandbox |
||||
self.req_body = req_body |
||||
self.status = status |
||||
self.errors = errors |
||||
self.resp_body = resp_body |
||||
def __str__(self): |
||||
sandbox_str = ' SANDBOX' if self.sandbox else '' |
||||
return "%s %s%s -> %s: %s" % (self.method, self.url, sandbox_str, self.status, self.errors) |
||||
|
||||
class transport(object): |
||||
def __init__(self, api_key, api_key_sandbox=None, logger=logging.getLogger(__name__)): |
||||
self._api_key = api_key |
||||
self._auth = HTTPBasicAuth(self._api_key, '') |
||||
self._api_key_sandbox = api_key_sandbox or None |
||||
self._auth_sandbox = HTTPBasicAuth(self._api_key_sandbox, '') if self._api_key_sandbox else None |
||||
self._server_host = 'api.shipcloud.io' |
||||
self._server_port = 443 |
||||
self._base_path= "/v1" |
||||
self._logger = logger |
||||
|
||||
def _get_auth(self, sandbox=False): |
||||
if sandbox: |
||||
return self._auth_sandbox |
||||
else: |
||||
return self._auth |
||||
|
||||
def _build_url(self, suffix): |
||||
return "https://%s:%u%s%s" % (self._server_host, self._server_port, self._base_path, suffix) |
||||
|
||||
def rest_http(self, method, suffix, js = None, sandbox = False): |
||||
url = self._build_url(suffix) |
||||
sandbox_str = ' SANDBOX' if sandbox else '' |
||||
self._logger.debug("%s %s (%s)%s" % (method, url, str(js), sandbox_str)) |
||||
resp = requests.request(method, url, json=js, auth=self._get_auth(sandbox)) |
||||
self._logger.debug("-> %s - %s" % (resp, resp.text)) |
||||
if not resp.ok: |
||||
self._logger.error("%s %s (%s)%s failed: %s - %s" % (method, url, str(js), sandbox_str, resp, resp.text)) |
||||
errors = [] |
||||
try: |
||||
resp_json = resp.json() |
||||
if 'errors' in resp_json: |
||||
errors = resp_json['errors'] |
||||
except ValueError: |
||||
self._logger.error("response contains no valid json: %s" % (resp.text)) |
||||
raise ApiError(method, url, sandbox, js, resp.status_code, errors, resp.text) |
||||
return resp.json() |
||||
|
||||
def rest_post(self, suffix, js=None, sandbox=False): |
||||
return self.rest_http('POST', suffix, js, sandbox) |
||||
|
||||
def rest_get(self, suffix, js=None, sandbox=False): |
||||
return self.rest_http('GET', suffix, js, sandbox) |
||||
|
||||
def rest_delete(self, suffix, js=None, sandbox=False): |
||||
return self.rest_http('DELETE', suffix, js, sandbox) |
||||
|
||||
class api(object): |
||||
def __init__(self, api_key, api_key_sandbox=None, logger=logging.getLogger(__name__)): |
||||
self._transport = transport(api_key, api_key_sandbox, logger) |
||||
|
||||
def get_shipment_quote(self, shipment): |
||||
# quote request cannot be issued against sandbox, so always use production |
||||
res = self._transport.rest_post('/shipment_quotes', gen_quote_req(shipment)) |
||||
return res |
||||
|
||||
def create_shipment(self, shipment, gen_label=False): |
||||
# Assume if the user passed a sandbox API key, we use it for create shipment requests |
||||
sandbox = True if self._transport._auth_sandbox else False |
||||
sh = shipment.copy() |
||||
sh['create_shipping_label'] = gen_label |
||||
res = self._transport.rest_post('/shipments', sh, sandbox) |
||||
return res |
||||
|
||||
|
||||
def gen_customs_item(origin, desc, hts, qty, value, net_weight): |
||||
"""Generate a dict for a customs_declaration.item in accordance with |
||||
https://developers.shipcloud.io/reference/shipments_request_schema.html""" |
||||
customs_item = { |
||||
'origin_country': origin, |
||||
'description': desc, |
||||
'hs_tariff_number': str(hts), |
||||
'quantity': qty, |
||||
'value_amount': value, |
||||
'net_weight': net_weight, |
||||
#'gross_weight': , |
||||
} |
||||
return customs_item |
||||
|
||||
def gen_customs_decl(currency, invoice_nr, net_total, items, importer_ref=None, exporter_ref=None): |
||||
"""Generate a dict for a customs_declaration in accordance with |
||||
https://developers.shipcloud.io/reference/shipments_request_schema.html""" |
||||
customs_decl = { |
||||
'contents_type': 'commercial_goods', |
||||
#'contents_explanation': , |
||||
'currency' : currency, |
||||
'invoice_number': str(invoice_nr), |
||||
'total_value_amount': net_total, |
||||
'items': customs_items, |
||||
} |
||||
if importer_ref: |
||||
customs_decl['importer_reference'] = str(importer_ref) |
||||
if exporter_ref: |
||||
customs_decl['exporter_reference'] = str(exporter_ref) |
||||
return customs_decl |
||||
|
||||
|
||||
def gen_package(width_cm, length_cm, height_cm, weight_kgs, value=None, currency=None): |
||||
"""Generate a dict for a package in accordance with |
||||
https://developers.shipcloud.io/reference/shipments_request_schema.html""" |
||||
package = { |
||||
'width': int(width_cm), |
||||
'length': int(length_cm), |
||||
'height': int(height_cm), |
||||
'weight': weight_kgs, |
||||
'type': 'parcel', |
||||
} |
||||
if value: |
||||
if currency == None: |
||||
currency = 'EUR' |
||||
package['declared_value'] = { |
||||
'amount': value, |
||||
'currency': currency, |
||||
} |
||||
return package |
||||
|
||||
|
||||
def gen_shipment(from_addr, to_addr, pkg, ref, descr=None, customs_decl=None, incoterm='dap'): |
||||
"""Generate a dict for a shipment in accordance with |
||||
https://developers.shipcloud.io/reference/shipments_request_schema.html""" |
||||
shipment = { |
||||
'from': from_addr, |
||||
'to': to_addr, |
||||
'carrier': 'ups', |
||||
'service': 'one_day', |
||||
'package' : pkg, |
||||
'reference_number': ref, |
||||
'label': { |
||||
'format': 'pdf_a5', |
||||
}, |
||||
'notification_mail': 'hwelte@sysmocom.de', |
||||
'incoterm': incoterm, |
||||
'create_shipping_label': False, |
||||
} |
||||
if descr: |
||||
shipment['description'] = descr |
||||
if customs_decl: |
||||
shipment['customs_declaration'] = customs_decl |
||||
return shipment |
||||
|
||||
|
||||
def _filter_dict(indict, permitted_keys): |
||||
"""Filter an input dictionary; keep only those keys listed in permitted_keys""" |
||||
outdict = {} |
||||
for k in permitted_keys: |
||||
if k in indict: |
||||
outdict[k] = indict[k] |
||||
return outdict |
||||
|
||||
def gen_quote_req(shipment): |
||||
"""Generate a dict in accordance with |
||||
https://developers.shipcloud.io/reference/shipment_quotes_request_schema.html""" |
||||
# for some weird reason, the ShipmentQuoteRequest schema doesn't permit all |
||||
# the keys that are permitted when generating a label, making this more complicated |
||||
# than it should |
||||
permitted_sh_keys = [ 'carrier', 'service', 'to', 'from', 'package' ] |
||||
permitted_pkg_keys = [ 'width', 'height', 'length', 'weight', 'type' ] |
||||
permitted_addr_keys = [ 'street', 'street_no', 'city', 'zip_code', 'country' ] |
||||
|
||||
# create a copy so we don't modify the input data |
||||
sh = shipment.copy() |
||||
sh['from'] = _filter_dict(sh['from'], permitted_addr_keys) |
||||
sh['to'] = _filter_dict(sh['to'], permitted_addr_keys) |
||||
sh['package'] = _filter_dict(sh['package'], permitted_pkg_keys) |
||||
|
||||
return _filter_dict(sh, permitted_sh_keys) |
@ -0,0 +1,204 @@
@@ -0,0 +1,204 @@
|
||||
from openerp import api, fields, models |
||||
import logging |
||||
from openerp.exceptions import Warning |
||||
import pycountry |
||||
|
||||
import shipcloud |
||||
|
||||
# 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]) |
||||
|
||||
|
||||
class SCDeliveryCarrier(models.Model): |
||||
_inherit = 'delivery.carrier' |
||||
|
||||
def build_sc_addr(self, 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 |
||||
return addr |
||||
|
||||
@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' |
||||
pkg['weight'] = self._get_weight(order, picking) |
||||
if 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) |
||||
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_oum_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)) |
||||
orig = product.x_country_of_origin |
||||
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)) |
||||
orig = ptempl.x_default_country_of_origin |
||||
weight = ptempl.weight |
||||
res = { |
||||
'origin_country': orig, |
||||
'description': line.name, |
||||
'hs_tariff_number': hts, |
||||
'quantity': q, |
||||
'value_amount': line.price_unit, |
||||
'net_weight': weight, |
||||
} |
||||
|
||||
def build_sc_customs_decl(self, picking): |
||||
items = [build_sc_customs_item(x) for x in picking.move_lines] |
||||
total = 0.0 |
||||
for i in items: |
||||
total += i['value_amount'] |
||||
customs = { |
||||
'contents_type': 'commercial_goods', |
||||
'currency': currency, |
||||
'invoice_number': picking.name, |
||||
'total_value_amount': total, |
||||
'items': items |
||||
} |
||||
return customs |
||||
|
||||
def _shipcloud_api(self): |
||||
config = self._get_config() |
||||
api_key = config['sc_api_key'] |
||||
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 |
||||
|
||||
# build individual sub-objects of the shipment |
||||
from_addr = self.build_sc_addr(warehouse) |
||||
to_addr = self.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) |
||||
# convert shipment to quote object |
||||
api = self._shipcloud_api() |
||||
try: |
||||
result = api.get_shipment_quote(shp) |
||||
except shipcloud.ApiError as err: |
||||
raise Warning(err) |
||||
# { "shipment_quote": { "price": 42.12 } } |
||||
return result['shipment_quote']['price'] |
||||
|
||||
|
||||
def sc_send_shipping(self, pickings): |
||||
"""Generate a shipping label from the given stock.picking""" |
||||
order = self.env['sale.order'].search([('name','=',pickings.origin)]) |
||||
recipient = pickings.partner_id |
||||
warehouse = pickings.picking_type_id.warehouse_id.partner_id |
||||
|
||||
# build individual sub-objects of the shipment |
||||
from_addr = self.build_sc_addr(warehouse) |
||||
to_addr = self.build_sc_addr(recipient) |
||||
pkg = self.build_sc_pkg(pickings=pickings) |
||||
customs = self.build_sc_customs_decl(pickings) |
||||
# build the actual shipment object |
||||
shp = shipcloud.gen_shipment(from_addr, to_addr, pkg, picking.name, customs_decl=customs) |
||||
api = self._shipcloud_api() |
||||
try: |
||||
result = api.create_shipment(shp) |
||||
except shipcloud.ApiError as err: |
||||
raise Warning(err) |
||||
|
||||
# result = ["id", "carrier_tracking_no", "tracking_url", "label_url", "price"] |
||||
self.update({'sc_shipment_id': result['id'], |
||||
'sc_tracking_url': result['tracking_url']}) |
||||
# TODO: download label from label_url so it can be returned as attachment |
||||
|
||||
res = {'exact_price': result['price'], |
||||
'weight': pkg['weight'], |
||||
'tracking_number': result['carrier_tracking_no'], |
||||
'attachments': [(filename, label.pdf_bin)]} |
||||
return res |
||||
|
||||
|
||||
def sc_cancel_shipment(self, pickings): |
||||
"""Cancel a shipping label""" |
||||
# TODO: use sc_shipment_id to issue a cancel request in the API |
||||
# DELETE /v1/shipments/:id -> 204 on success |
||||
|
||||
def sc_get_tracking_link(self, pickings): |
||||
"""Return a tracking link for the given picking""" |
||||
return pickings.sc_tracking_url |
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
from openerp import api, fields, models |
||||
|
||||
# extend deliver.carrier with shipcloud |
||||
class SMCShippingShipcloud(models.Model): |
||||
_inherit = 'delivery.carrier' |
||||
delivery_type = fields.Selection(selection_add=[('sc', 'shipcloud')]) |
||||
|
||||
|
||||
# extend stock.picking with fields related to shipcloud |
||||
class SMCStockPickingShipclodu(models.Model): |
||||
_inherit = 'stock.picking' |
||||
sc_shipment_id = fields.Char(string='shipcloud shipment ID') |
||||
sc_tracking_url = fields.Char(string='shipcloud tracking URL') |
@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
|
||||
<openerp> |
||||
<data> |
||||
<record id="view_website_shipcloud_config_setting" model="ir.ui.view"> |
||||
<field name="name">website.sc.config.settings</field> |
||||
<field name="model">website.sc.config.settings</field> |
||||
<field name="arch" type="xml"> |
||||
<form string="shipcloud Settings" class="oe_form_configuration"> |
||||
<sheet> |
||||
<group string="shipcloud Credentials"> |
||||
<field name="sc_api_key_sandbox" class="oe_inline"/> |
||||
<field name="sc_api_key_prod" class="oe_inline"/> |
||||
<field name="sc_api_use_prod" class="oe_inline"/> |
||||
</group> |
||||
</sheet> |
||||
<footer> |
||||
<button string="Apply" type="object" name="execute" class="oe_highlight"/> |
||||
<button string="Cancel" class="oe_link" special="cancel"/> |
||||
</footer> |
||||
</form> |
||||
</field> |
||||
</record> |
||||
|
||||
<record id="action_module_website_shipcloud_configuration" model="ir.actions.act_window"> |
||||
<field name="name">shipcloud Configuration</field> |
||||
<field name="res_model">website.sc.config.settings</field> |
||||
<field name="view_mode">form</field> |
||||
<field name="target">new</field> |
||||
</record> |
||||
|
||||
<record id="wk_inherit_website_access_sc" model="ir.ui.view"> |
||||
<field name="name">website.sc.inherited.config.settings</field> |
||||
<field name="model">website.config.settings</field> |
||||
<field name="inherit_id" ref="odoo_shipping_service_apps.wk_inherit_website_acess"/> |
||||
<field name="arch" type="xml"> |
||||
<xpath expr="//group[@string='Shipping Service']" position="inside"> |
||||
<label for="module_sc_delivery_carrier" string=""/> |
||||
<div name="module_sc_delivery_carrier" string=""> |
||||
<div class="oe_inline"> |
||||
<button type="action" name="%(odoo_shipcloud.action_module_website_shipcloud_configuration)d" string="shipcloud Configuration" class="oe_link oe_inline"/> |
||||
</div> |
||||
</div> |
||||
</xpath> |
||||
</field> |
||||
</record> |
||||
|
||||
<record id="shipcloud_configuration_installer_todo" model="ir.actions.todo"> |
||||
<field name="action_id" ref="action_module_website_shipcloud_configuration"/> |
||||
<field name="sequence">15</field> |
||||
<field name="type">automatic</field> |
||||
</record> |
||||
|
||||
</data> |
||||
</openerp> |
Loading…
Reference in new issue