diff --git a/delivery_roulier/README.rst b/delivery_roulier/README.rst new file mode 100644 index 0000000000..76a121f972 --- /dev/null +++ b/delivery_roulier/README.rst @@ -0,0 +1,139 @@ +======================== +Delivery Carrier Roulier +======================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdelivery--carrier-lightgray.png?logo=github + :target: https://github.com/OCA/delivery-carrier/tree/14.0/delivery_roulier + :alt: OCA/delivery-carrier +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/delivery-carrier-14-0/delivery-carrier-14-0-delivery_roulier + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/99/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Integration of multiple carriers with Roulier library + +Base module for integration with Roulier. + +`Roulier `_ is a python library which implements carriers API. +This modules contains the core functions for this implementation. + +You should install one of the specific modules : + +- delivery_roulier_laposte +- delivery_roulier_dpd +- delivery_roulier_geodis +- delivery_carrier_label_gls +- more to come + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Here is some methods you can use for your carrier implementation +allowing to have a consistent code accross different carrier modules: + +.. code-block:: python + + def _mycarrier_get_sender(...): + + + def _mycarrier_get_receiver(...): + + + def _mycarrier_get_shipping_date(...): + + + def _mycarrier_get_account(...): + + + def _mycarrier_get_auth(...): + + + def _mycarrier_get_service(...): + + + def _mycarrier_convert_address(...): + + +| + + +Instead of calling `super()` you can use: + +.. code-block:: python + + def _mycarrier_get_service(...): + + result = _roulier_get_service(...) + + result["specific_key"] = "blabla" + + return result + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Akretion + +Contributors +~~~~~~~~~~~~ + +* Raphaël Reverdy +* David Béal + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-florian-dacosta| image:: https://github.com/florian-dacosta.png?size=40px + :target: https://github.com/florian-dacosta + :alt: florian-dacosta + +Current `maintainer `__: + +|maintainer-florian-dacosta| + +This module is part of the `OCA/delivery-carrier `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/delivery_roulier/__init__.py b/delivery_roulier/__init__.py new file mode 100644 index 0000000000..ebff83db95 --- /dev/null +++ b/delivery_roulier/__init__.py @@ -0,0 +1,3 @@ +from . import models + +from .decorator import implemented_by_carrier diff --git a/delivery_roulier/__manifest__.py b/delivery_roulier/__manifest__.py new file mode 100644 index 0000000000..b7a4fbfdfa --- /dev/null +++ b/delivery_roulier/__manifest__.py @@ -0,0 +1,27 @@ +# @author Raphael Reverdy +# David BEAL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Delivery Carrier Roulier", + "version": "16.0.1.0.0", + "author": "Akretion,Odoo Community Association (OCA)", + "summary": "Integration of multiple carriers", + "maintainers": ["florian-dacosta"], + "category": "Delivery", + "depends": [ + "base_delivery_carrier_label", + "delivery_carrier_account", + ], + "website": "https://github.com/OCA/delivery-carrier", + "data": [], + "demo": [ + "demo/product.xml", + ], + "external_dependencies": { + "python": [ + "roulier", # '>0.2.0' + ], + }, + "installable": True, + "license": "AGPL-3", +} diff --git a/delivery_roulier/decorator.py b/delivery_roulier/decorator.py new file mode 100644 index 0000000000..f61cc69253 --- /dev/null +++ b/delivery_roulier/decorator.py @@ -0,0 +1,42 @@ +# @author Raphael Reverdy +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from functools import wraps + + +def implemented_by_carrier(func): + """Decorator: call _carrier_prefixed method instead. + + Usage: + @implemented_by_carrier + def _do_something() + def _laposte_do_something() + def _gls_do_something() + + At runtime, picking._do_something() will try to call + the carrier spectific method or fallback to generic _do_something + + """ + + @wraps(func) + def wrapper(cls, *args, **kwargs): + fun_name = func.__name__ + + def get_delivery_type(cls, *args, **kwargs): + if hasattr(cls, "delivery_type"): + return cls.delivery_type + pickings = [ + obj for obj in args if getattr(obj, "_name", "") == "stock.picking" + ] + if len(pickings) > 0: + return pickings[0].delivery_type + if cls[0].carrier_id: + return cls[0].carrier_id.delivery_type + + delivery_type = get_delivery_type(cls, *args, **kwargs) + fun = "_{}{}".format(delivery_type, fun_name) + if not hasattr(cls, fun): + fun = "_roulier%s" % (fun_name) + return getattr(cls, fun)(*args, **kwargs) + + return wrapper diff --git a/delivery_roulier/demo/product.xml b/delivery_roulier/demo/product.xml new file mode 100644 index 0000000000..76fed5214b --- /dev/null +++ b/delivery_roulier/demo/product.xml @@ -0,0 +1,16 @@ + + + + + + carrier 3.7 kg + product + 3.7 + + + carrier 1.3 kg + product + 1.3 + + + diff --git a/delivery_roulier/i18n/delivery_roulier.pot b/delivery_roulier/i18n/delivery_roulier.pot new file mode 100644 index 0000000000..c24354807e --- /dev/null +++ b/delivery_roulier/i18n/delivery_roulier.pot @@ -0,0 +1,134 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * delivery_roulier +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: delivery_roulier +#: code:addons/delivery_roulier/models/stock_quant_package.py:0 +#, python-format +msgid "Bad input: %s\n" +msgstr "" + +#. module: delivery_roulier +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_quant_package__carrier_id +msgid "Carrier" +msgstr "" + +#. module: delivery_roulier +#: model:ir.model.fields,field_description:delivery_roulier.field_delivery_carrier__display_name +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_move_line__display_name +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_picking__display_name +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_quant_package__display_name +msgid "Display Name" +msgstr "" + +#. module: delivery_roulier +#: model:ir.model.fields,field_description:delivery_roulier.field_delivery_carrier__id +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_move_line__id +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_picking__id +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_quant_package__id +msgid "ID" +msgstr "" + +#. module: delivery_roulier +#: model:ir.model.fields,field_description:delivery_roulier.field_delivery_carrier____last_update +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_move_line____last_update +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_picking____last_update +#: model:ir.model.fields,field_description:delivery_roulier.field_stock_quant_package____last_update +msgid "Last Modified on" +msgstr "" + +#. module: delivery_roulier +#: code:addons/delivery_roulier/models/stock_picking.py:0 +#, python-format +msgid "No account available with name '%s' for this carrier" +msgstr "" + +#. module: delivery_roulier +#: code:addons/delivery_roulier/models/stock_picking.py:0 +#, python-format +msgid "No packages found for this picking" +msgstr "" + +#. module: delivery_roulier +#: model:ir.model,name:delivery_roulier.model_stock_quant_package +msgid "Packages" +msgstr "" + +#. module: delivery_roulier +#: model:ir.model,name:delivery_roulier.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "" + +#. module: delivery_roulier +#: code:addons/delivery_roulier/models/stock_quant_package.py:0 +#, python-format +msgid "" +"Roulier library Exception for '%s' carrier:\n" +"\n" +"%s\n" +"\n" +"Sent data:\n" +"%s" +msgstr "" + +#. module: delivery_roulier +#: model:ir.model,name:delivery_roulier.model_delivery_carrier +msgid "Shipping Methods" +msgstr "" + +#. module: delivery_roulier +#: model_terms:ir.ui.view,arch_db:delivery_roulier.view_quant_package_form +msgid "Tracking" +msgstr "" + +#. module: delivery_roulier +#: model:ir.model,name:delivery_roulier.model_stock_picking +msgid "Transfer" +msgstr "" + +#. module: delivery_roulier +#: model:product.product,uom_name:delivery_roulier.product_big +#: model:product.product,uom_name:delivery_roulier.product_small +#: model:product.template,uom_name:delivery_roulier.product_big_product_template +#: model:product.template,uom_name:delivery_roulier.product_small_product_template +msgid "Units" +msgstr "" + +#. module: delivery_roulier +#: model:product.product,name:delivery_roulier.product_small +#: model:product.template,name:delivery_roulier.product_small_product_template +msgid "carrier 1.3 kg" +msgstr "" + +#. module: delivery_roulier +#: model:product.product,name:delivery_roulier.product_big +#: model:product.template,name:delivery_roulier.product_big_product_template +msgid "carrier 3.7 kg" +msgstr "" + +#. module: delivery_roulier +#: model:product.product,weight_uom_name:delivery_roulier.product_big +#: model:product.product,weight_uom_name:delivery_roulier.product_small +#: model:product.template,weight_uom_name:delivery_roulier.product_big_product_template +#: model:product.template,weight_uom_name:delivery_roulier.product_small_product_template +msgid "kg" +msgstr "" + +#. module: delivery_roulier +#: model:product.product,volume_uom_name:delivery_roulier.product_big +#: model:product.product,volume_uom_name:delivery_roulier.product_small +#: model:product.template,volume_uom_name:delivery_roulier.product_big_product_template +#: model:product.template,volume_uom_name:delivery_roulier.product_small_product_template +msgid "m³" +msgstr "" diff --git a/delivery_roulier/models/__init__.py b/delivery_roulier/models/__init__.py new file mode 100644 index 0000000000..d486ae45a7 --- /dev/null +++ b/delivery_roulier/models/__init__.py @@ -0,0 +1,4 @@ +from . import stock_picking +from . import stock_quant_package +from . import stock_move_line +from . import delivery_carrier diff --git a/delivery_roulier/models/delivery_carrier.py b/delivery_roulier/models/delivery_carrier.py new file mode 100644 index 0000000000..482e0bde2b --- /dev/null +++ b/delivery_roulier/models/delivery_carrier.py @@ -0,0 +1,63 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import models + +_logger = logging.getLogger(__name__) +try: + from roulier import roulier +except ImportError: + _logger.debug("Cannot `import roulier`.") + + +class DeliveryCarrier(models.Model): + _inherit = "delivery.carrier" + + def alternative_send_shipping(self, pickings): + self.ensure_one() + if self._is_roulier(): + return pickings._roulier_generate_labels() + else: + return super().alternative_send_shipping(pickings) + + def _is_roulier(self): + self.ensure_one() + available_carrier_actions = roulier.get_carriers_action_available() or {} + return "get_label" in available_carrier_actions.get(self.delivery_type, []) + + def cancel_shipment(self, pickings): + if self._is_roulier: + pickings._cancel_shipment() + else: + return super().cancel_shipment(pickings) + + # For now we keep our own roulier method _get_tracking_link instead of the + # native one because the roulier logic is on packages when the Odoo logic + # is on picking. An we could have multiple urls for 1 picking, if there + # are multiple package... + # Maybe we will merge all this in future versions + def get_tracking_link(self, pickings): + if self._is_roulier(): + trackings = [] + for picking in pickings: + packages = picking.package_ids + first_package = packages and packages[0] + if first_package: + trackings.append(first_package._get_tracking_link()) + return trackings + else: + return super().get_tracking_link(pickings) + + def rate_shipment(self, order): + res = super().rate_shipment(order) + # for roulier carrier, usually getting the price by carrier webservice + # is usually not available for now. Avoid failure in that case. + if not res and self.is_roulier(): + res = { + "success": True, + "price": 0.0, + "error_message": False, + "warning_message": False, + } + return res diff --git a/delivery_roulier/models/stock_move_line.py b/delivery_roulier/models/stock_move_line.py new file mode 100644 index 0000000000..5dfabde7ea --- /dev/null +++ b/delivery_roulier/models/stock_move_line.py @@ -0,0 +1,41 @@ +# Copyright 2018 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models +from odoo.tools import float_is_zero + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + def get_unit_price_for_customs(self): + """This method is designed to be inherited for specific scenarios""" + self.ensure_one() + prec = self.env["decimal.precision"].precision_get("Product Unit of Measure") + soline = self.get_sale_order_line() + # if product is different, it must be a phantom bom we then take price on the + # product and apply discount + if ( + soline + and not float_is_zero(soline.product_uom_qty, precision_digits=prec) + and soline.product_id == self.product_id + ): + price_unit_so_uom = soline.price_subtotal / soline.product_uom_qty + price_unit = soline.product_uom._compute_price( + price_unit_so_uom, self.product_uom_id + ) + else: + product = self.product_id + ato = self.env["account.tax"] + price_unit = ato._fix_tax_included_price_company( + product.lst_price, product.taxes_id, ato, self.picking_id.company_id + ) + # case of phantom bom + if soline.discount: + price_unit = price_unit * (1 - (soline.discount or 0.0) / 100.0) + return price_unit + + def get_sale_order_line(self): + self.ensure_one() + return self.move_id.sale_line_id diff --git a/delivery_roulier/models/stock_picking.py b/delivery_roulier/models/stock_picking.py new file mode 100644 index 0000000000..382741c028 --- /dev/null +++ b/delivery_roulier/models/stock_picking.py @@ -0,0 +1,292 @@ +# @author Raphael Reverdy +# David BEAL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from datetime import date, timedelta + +from odoo import fields, models +from odoo.exceptions import UserError +from odoo.tools.translate import _ + +from ..decorator import implemented_by_carrier + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + # API: + + @implemented_by_carrier + def _get_sender(self, package=None): + pass + + @implemented_by_carrier + def _get_receiver(self, package=None): + pass + + @implemented_by_carrier + def _get_shipping_date(self, package): + pass + + @implemented_by_carrier + def _get_account(self, package=None): + pass + + @implemented_by_carrier + def _get_auth(self, account, package=None): + pass + + @implemented_by_carrier + def _get_service(self, account, package=None): + pass + + @implemented_by_carrier + def _convert_address(self, partner): + pass + + @implemented_by_carrier + def _get_label_format(self, account): + pass + + @implemented_by_carrier + def _get_from_address(self): + pass + + @implemented_by_carrier + def _get_to_address(self): + pass + + @implemented_by_carrier + def _cancel_shipment(self): + pass + + @implemented_by_carrier + def _support_multi_tracking(self): + pass + + # End of API. + + # Implementations for base_delivery_carrier_label + def _is_roulier(self): + self.ensure_one() + return self.carrier_id._is_roulier() + + def _roulier_generate_labels(self): + """ + Return format expected by send_shipping : a list of dict (one dict per + picking). + { + 'exact_price': 0.0, + 'tracking_number': "concatenated numbers", + 'labels': list of dict of labels, managed by base_delivery_carrier_label + } + """ + label_info = [] + for picking in self: + move_line_no_pack = picking.move_line_ids.filtered( + lambda ml: ml.qty_done > 0.0 and not ml.result_package_id + ) + if move_line_no_pack: + raise UserError( + _( + "Some products have no destination package in picking %s, " + "please add a destination package in order to be able to " + "generate the carrier label." + ) + % picking.name + ) + label_info.append(picking.package_ids._generate_labels(picking)) + return label_info + + # Default implementations of _roulier_*() + def _roulier_get_auth(self, account, package=None): + """Login/password of the carrier account. + + Returns: + a dict with login and password keys + """ + auth = { + "login": account.account, + "password": account.password, + "isTest": not self.carrier_id.prod_environment, + } + return auth + + def _roulier_cancel_shipment(self): + self.write({"carrier_tracking_ref": False}) + labels = self.env["shipping.label"].search( + [("res_id", "in", self.ids), ("res_model", "=", "stock.picking")] + ) + labels.mapped("attachment_id").unlink() + + def _roulier_get_account(self, package=None): + """Return an 'account'. + + By default, the first account encoutered for this type. + Depending on your case, you may store it on the picking or + compute it from your business rules. + + Accounts are resolved at runtime (can be != for dev/prod) + """ + self.ensure_one() + account = self._get_carrier_account() + if not account: + raise UserError( + _("No account available with name '%s' " "for this carrier") + % self.carrier_id.delivery_type + ) + return account + + def _roulier_get_sender(self, package=None): + """Sender of the picking (for the label). + + Return: + (res.partner) + """ + return self.company_id.partner_id + + def _roulier_get_label_format(self, account): + """format of the label asked for carrier + + Return: + label format (string) + """ + return getattr(account, "%s_file_format" % self.delivery_type, None) + + def _roulier_get_receiver(self, package=None): + """The guy whom the shippment is for. + + At home or at a distribution point, it's always + the same receiver address. + + Return: + (res.partner) + """ + return self.partner_id + + def _roulier_get_shipping_date(self, package=None): + """Choose a shipping date. + + By default, it's tomorrow.""" + tomorrow = date.today() + timedelta(1) + return tomorrow + + def _get_address_info_from_parent(self, partner, address): + res = {} + if not address.get("company") and partner.parent_id.is_company: + res["company"] = partner.parent_id.name + + # these fields could be filled only on parent + if not address.get("email") and partner.parent_id.email: + res["email"] = partner.parent_id.email + if not address.get("mobile") and partner.parent_id.mobile: + res["mobile"] = partner.parent_id.mobile + return res + + def _roulier_convert_address(self, partner): + """Convert a partner to an address for roulier. + + params: + partner: a res.partner + return: + dict + """ + address = {} + extract_fields = [ + "company", + "name", + "zip", + "city", + "phone", + "mobile", + "email", + "street2", + ] + for elm in extract_fields: + if elm in partner: + # because a value can't be None in odoo's ORM + # you don't want to mix (bool) False and None + if partner._fields[elm].type != fields.Boolean.type: + if partner[elm]: + address[elm] = partner[elm] + # else: + # it's a None: nothing to do + else: # it's a boolean: keep the value + address[elm] = partner[elm] + # Roulier needs street1 (mandatory) not street + address["street1"] = partner.street + # Codet ISO 3166-1-alpha-2 (2 letters code) + address["country"] = partner.country_id.code + + # case the partner is not a contact + if partner.is_company and not address.get("company"): + address["company"] = partner.name + + # keep in a separated method to easily override if not a desirable behavior + address.update(self._get_address_info_from_parent(partner, address)) + + for tel in ["mobile", "phone"]: + if address.get(tel): + address[tel] = address[tel].replace("\u00A0", "").replace(" ", "") + + address["phone"] = address.get("mobile", address.get("phone")) or "" + + return address + + def _roulier_get_from_address(self, package=None): + sender = self._get_sender(package=package) + return self._convert_address(sender) + + def _roulier_get_to_address(self, package=None): + receiver = self._get_receiver(package=package) + return self._convert_address(receiver) + + def _roulier_get_service(self, account, package=None): + """Return a basic dict. + + The carrier implementation may add stuff + like agency or options. + + return: + dict + """ + shipping_date = self._get_shipping_date(package) + + service = { + "product": self.carrier_code, + "shippingDate": shipping_date, + "labelFormat": self._get_label_format(account), + } + return service + + def _roulier_support_multi_tracking(self): + # By default roulier carrier may have one tracking ref per pack. + # override this method for your carrier if you always have a unique + # tracking per picking + return True + + def open_website_url(self): + """Open tracking page. + + More than 1 tracking number: display a list of packages + Else open directly the tracking page + """ + self.ensure_one() + if not self._is_roulier(): + return super().open_website_url() + + packages = self.package_ids + if len(packages) == 0: + raise UserError(_("No packages found for this picking")) + else: + if not self._support_multi_tracking(): + packages = packages[0] + if len(packages) == 1: + return packages.open_website_url() # shortpath + + # display a list of pickings + xmlid = "stock.action_package_view" + action = self.env["ir.actions.act_window"]._for_xml_id(xmlid) + action["domain"] = [("id", "in", packages.ids)] + action["context"] = {"picking_id": self.id} + return action diff --git a/delivery_roulier/models/stock_quant_package.py b/delivery_roulier/models/stock_quant_package.py new file mode 100644 index 0000000000..c0fe30c958 --- /dev/null +++ b/delivery_roulier/models/stock_quant_package.py @@ -0,0 +1,294 @@ +# @author Raphael Reverdy +# David BEAL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import _, fields, models +from odoo.exceptions import UserError + +from ..decorator import implemented_by_carrier + +_logger = logging.getLogger(__name__) +try: + from roulier import roulier + from roulier.exception import CarrierError, InvalidApiInput +except ImportError: + _logger.debug("Cannot `import roulier`.") + + +class StockQuantPackage(models.Model): + _inherit = "stock.quant.package" + + carrier_id = fields.Many2one("delivery.carrier", string="Carrier") + + # helper : move it to base ? + def get_operations(self): + """Get operations of the package. + + Usefull for having products and quantities + """ + self.ensure_one() + return self.env["stock.move.line"].search( + [ + ("product_id", "!=", False), + "|", + ("package_id", "=", self.id), + ("result_package_id", "=", self.id), + ] + ) + + # API + # Each method in this class have at least picking arg to directly + # deal with stock.picking if required by your carrier use case + @implemented_by_carrier + def _before_call(self, picking, payload): + pass + + @implemented_by_carrier + def _after_call(self, picking, response): + pass + + @implemented_by_carrier + def _get_parcel(self, picking): + pass + + @implemented_by_carrier + def _carrier_error_handling(self, payload, response): + pass + + @implemented_by_carrier + def _invalid_api_input_handling(self, payload, response): + pass + + @implemented_by_carrier + def _prepare_attachments(self, picking, response): + pass + + @implemented_by_carrier + def _handle_attachments(self, label, response): + pass + + @implemented_by_carrier + def _get_tracking_link(self): + pass + + @implemented_by_carrier + def _generate_labels(self, picking): + pass + + @implemented_by_carrier + def _get_parcels(self, picking): + pass + + @implemented_by_carrier + def _parse_response(self, picking, response): + pass + + # end of API + + # Core functions + def _roulier_generate_labels(self, picking): + # send all packs to roulier. It will decide if it makes one call per pack or + # one call for all pack depending on the carrier. + response = self._call_roulier_api(picking) + self._handle_attachments(picking, response) + return self._parse_response(picking, response) + + def _roulier_parse_response(self, picking, response): + res = { + # price is not managed in roulier...not yet at least + "exact_price": 0.0, + } + parcels_data = [] + parcels = response.get("parcels") + tracking_refs = [] + for parcel in parcels: + tracking_number = parcel.get("tracking", {}).get("number") + if tracking_number and tracking_number not in tracking_refs: + tracking_refs.append(tracking_number) + # expected format by base_delivery_carrier_label module + label = parcel.get("label") + # find for which package the label is. tracking number will be updated on + # this pack later on (in base_delivery_carrier_label) + package_id = False + if len(self) == 1: + package_id = self.id + else: + pack = self.filtered(lambda p: p.name == parcel.get("reference")) + if len(pack) == 1: + package_id = pack.id + + parcels_data.append( + { + "tracking_number": tracking_number, + "parcel_tracking_uri": parcel.get("tracking", {}).get("url", False), + "package_id": package_id, + "file": label.get("data"), + "name": "%s.%s" + % ( + parcel.get("reference") or tracking_number or label.get("name"), + label.get("type", "").lower(), + ), + "file_type": label.get("type"), + } + ) + res["tracking_number"] = ";".join(tracking_refs) + res["labels"] = parcels_data + return res + + def _roulier_get_parcels(self, picking): + return [pack._get_parcel(picking) for pack in self] + + def open_website_url(self): + """Open website for parcel tracking. + + Each carrier should implement _get_tracking_link + There is low chance you need to override this method. + returns: + action + """ + self.ensure_one() + url = self._get_tracking_link() + if not url: + raise UserError(_("The tracking url is not available.")) + client_action = { + "type": "ir.actions.act_url", + "name": "Shipment Tracking Page", + "target": "new", + "url": url, + } + return client_action + + def _call_roulier_api(self, picking): + """Create a label for a given package_id (self).""" + # There is low chance you need to override it. + # Don't forget to implement _a-carrier_before_call + # and _a-carrier_after_call + account = picking._get_account(self) + self.write({"carrier_id": picking.carrier_id.id}) + + payload = {} + + payload["auth"] = picking._get_auth(account, package=self) + + payload["from_address"] = picking._get_from_address(package=self) + payload["to_address"] = picking._get_to_address(package=self) + + payload["service"] = picking._get_service(account, package=self) + payload["parcels"] = self._get_parcels(picking) + + # hook to override request / payload + payload = self._before_call(picking, payload) + try: + # api call + ret = roulier.get(picking.delivery_type, "get_label", payload) + except InvalidApiInput as e: + raise UserError(self._invalid_api_input_handling(payload, e)) from e + except CarrierError as e: + raise UserError(self._carrier_error_handling(payload, e)) from e + + # give result to someone else + return self._after_call(picking, ret) + + # default implementations + def _roulier_get_parcel(self, picking): + self.ensure_one() + weight = self.shipping_weight or self.weight + parcel = {"weight": weight, "reference": self.name} + return parcel + + def _roulier_before_call(self, picking, payload): + """Add stuff to payload just before api call. + + Put here what you can't put in other methods + (like _get_parcel, _get_service...) + + It's totally ok to do nothing here. + + returns: + dict + """ + return payload + + def _roulier_after_call(self, picking, response): + """Do stuff just after api call. + + It's totally ok to do nothing here. + """ + return response + + def _roulier_get_tracking_link(self): + """Build a tracking url. + + You have to implement it for your carrier. + It's like : + 'https://the-carrier.com/?track=%s' % self.parcel_tracking + returns: + string (url) + """ + _logger.warning("not implemented") + + def _roulier_carrier_error_handling(self, payload, exception): + """Build exception message for carrier error. + + It's happen when the carrier WS returns something unexpected. + You may improve this for your carrier. + returns: + string + """ + if payload.get("auth", {}).get("password"): + payload["auth"]["password"] = "*****" + try: + _logger.debug(exception.response.text) + _logger.debug(exception.response.request.body) + except AttributeError: + _logger.debug("No request available") + carrier = dict( + self.env["delivery.carrier"]._fields["delivery_type"].selection + ).get(self.carrier_id.delivery_type) + return _( + "Roulier library Exception for '%(carrier)s' carrier:\n" + "\n%(exception)s\n\nSent data:\n%(payload)s" + ) % {"carrier": carrier, "exception": str(exception), "payload": payload} + + def _roulier_invalid_api_input_handling(self, payload, exception): + """Build exception message for bad input. + + It's happend when your data is not valid, like a missing value + in the payload. + + You may improve this for your carrier. + returns: + string + """ + return _("Bad input: %s\n") % str(exception) + + # There is low chance you need to override the following methods. + def _roulier_handle_attachments(self, picking, response): + attachments = [ + self.env["ir.attachment"].create(attachment) + for attachment in self[0]._roulier_prepare_attachments(picking, response) + ] # do it once for all + return attachments + + def _roulier_prepare_attachments(self, picking, response): + """Prepare a list of dicts for building ir.attachments. + Attachements are annexes like customs declarations, summary + etc. + returns: + list + """ + self.ensure_one() + attachments = response.get("annexes") + return [ + { + "res_id": picking.id, + "res_model": "stock.picking", + "datas": attachment["data"], + "type": "binary", + "name": "%s-%s.%s" + % (self.name, attachment["name"], attachment["type"]), + } + for attachment in attachments + ] diff --git a/delivery_roulier/readme/CONFIGURE.rst b/delivery_roulier/readme/CONFIGURE.rst new file mode 100644 index 0000000000..e7cf93ad3c --- /dev/null +++ b/delivery_roulier/readme/CONFIGURE.rst @@ -0,0 +1,2 @@ +This module needs package in order to work. +To generate the carrier labels on the picking, each products need to be assigned to a destination package. In case you don't want to bother with the use of package because the whole content of the picking usually fit in a unique package, you may consider installing the module delivery_automatic_package from the same repository (delivery-carrier). The package will be assigned automatically when asking the carrier labels. diff --git a/delivery_roulier/readme/CONTRIBUTORS.rst b/delivery_roulier/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..849f52eb53 --- /dev/null +++ b/delivery_roulier/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Raphaël Reverdy +* David Béal diff --git a/delivery_roulier/readme/DESCRIPTION.rst b/delivery_roulier/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..a6b0e65bc0 --- /dev/null +++ b/delivery_roulier/readme/DESCRIPTION.rst @@ -0,0 +1,14 @@ +Integration of multiple carriers with Roulier library + +Base module for integration with Roulier. + +`Roulier `_ is a python library which implements carriers API. +This modules contains the core functions for this implementation. + +You should install one of the specific modules : + +- delivery_roulier_laposte +- delivery_roulier_dpd +- delivery_roulier_geodis +- delivery_carrier_label_gls +- more to come diff --git a/delivery_roulier/readme/USAGE.rst b/delivery_roulier/readme/USAGE.rst new file mode 100644 index 0000000000..84d1c0e75a --- /dev/null +++ b/delivery_roulier/readme/USAGE.rst @@ -0,0 +1,40 @@ +Here is some methods you can use for your carrier implementation +allowing to have a consistent code accross different carrier modules: + +.. code-block:: python + + def _mycarrier_get_sender(...): + + + def _mycarrier_get_receiver(...): + + + def _mycarrier_get_shipping_date(...): + + + def _mycarrier_get_account(...): + + + def _mycarrier_get_auth(...): + + + def _mycarrier_get_service(...): + + + def _mycarrier_convert_address(...): + + +| + + +Instead of calling `super()` you can use: + +.. code-block:: python + + def _mycarrier_get_service(...): + + result = _roulier_get_service(...) + + result["specific_key"] = "blabla" + + return result diff --git a/delivery_roulier/static/description/icon.png b/delivery_roulier/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/delivery_roulier/static/description/icon.png differ diff --git a/delivery_roulier/static/description/index.html b/delivery_roulier/static/description/index.html new file mode 100644 index 0000000000..d8e7ab023f --- /dev/null +++ b/delivery_roulier/static/description/index.html @@ -0,0 +1,473 @@ + + + + + + +Delivery Carrier Roulier + + + +
+

Delivery Carrier Roulier

+ + +

Beta License: AGPL-3 OCA/delivery-carrier Translate me on Weblate Try me on Runbot

+

Integration of multiple carriers with Roulier library

+

Base module for integration with Roulier.

+

Roulier is a python library which implements carriers API. +This modules contains the core functions for this implementation.

+

You should install one of the specific modules :

+
    +
  • delivery_roulier_laposte
  • +
  • delivery_roulier_dpd
  • +
  • delivery_roulier_geodis
  • +
  • delivery_carrier_label_gls
  • +
  • more to come
  • +
+

Table of contents

+ +
+

Usage

+

Here is some methods you can use for your carrier implementation +allowing to have a consistent code accross different carrier modules:

+
+def _mycarrier_get_sender(...):
+
+
+def _mycarrier_get_receiver(...):
+
+
+def _mycarrier_get_shipping_date(...):
+
+
+def _mycarrier_get_account(...):
+
+
+def _mycarrier_get_auth(...):
+
+
+def _mycarrier_get_service(...):
+
+
+def _mycarrier_convert_address(...):
+
+
+

+
+

Instead of calling super() you can use:

+
+def _mycarrier_get_service(...):
+
+    result = _roulier_get_service(...)
+
+    result["specific_key"] = "blabla"
+
+    return result
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

florian-dacosta

+

This module is part of the OCA/delivery-carrier project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/delivery_roulier/tests/__init__.py b/delivery_roulier/tests/__init__.py new file mode 100644 index 0000000000..4fa381897e --- /dev/null +++ b/delivery_roulier/tests/__init__.py @@ -0,0 +1 @@ +from . import test_delivery_roulier diff --git a/delivery_roulier/tests/models.py b/delivery_roulier/tests/models.py new file mode 100644 index 0000000000..5d1d31da98 --- /dev/null +++ b/delivery_roulier/tests/models.py @@ -0,0 +1,10 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class FakeDeliveryCarrier(models.Model): + _inherit = "delivery.carrier" + + delivery_type = fields.Selection( + selection_add=[("test", "Test Carrier")], ondelete={"test": "set default"} + ) diff --git a/delivery_roulier/tests/test_delivery_roulier.py b/delivery_roulier/tests/test_delivery_roulier.py new file mode 100644 index 0000000000..c04aab2c50 --- /dev/null +++ b/delivery_roulier/tests/test_delivery_roulier.py @@ -0,0 +1,120 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from unittest.mock import MagicMock, patch + +from odoo_test_helper import FakeModelLoader +from roulier import roulier + +from odoo.tests.common import TransactionCase + +roulier_ret = { + "parcels": [ + { + "reference": "", + "tracking": {"url": "", "number": "Test tracking"}, + "label": { + "name": "label_test", + "data": b"dGVzdCBsYWJlbA==", + "type": "zpl2", + }, + "id": 1, + } + ], + "annexes": [{"name": "annexe name", "type": "txt", "data": b"dGVzdCBhbm5leGU="}], +} + + +class DeliveryRoulierCase(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + + # The fake class is imported here !! After the backup_registry + from .models import FakeDeliveryCarrier + + cls.loader.update_registry((FakeDeliveryCarrier,)) + cls.real_get_carriers_action_available = roulier.get_carriers_action_available + + def setUp(self): + super().setUp() + delivery_product = self.env["product.product"].create( + {"name": "test shipping product", "type": "service"} + ) + self.account = self.env["carrier.account"].create( + { + "name": "Test Carrier Account", + "delivery_type": "test", + "account": "test", + "password": "test", + } + ) + self.test_carrier = self.env["delivery.carrier"].create( + { + "name": "Test Carrier", + "delivery_type": "test", + "product_id": delivery_product.id, + "carrier_account_id": self.account.id, + } + ) + partner = self.env["res.partner"].create( + { + "name": "Carrier label test customer", + "country_id": self.env.ref("base.fr").id, + "street": "test street", + "street2": "test street2", + "city": "test city", + "phone": "0000000000", + "email": "test@test.com", + "zip": "00000", + } + ) + product = self.env["product.product"].create( + {"name": "Carrier test product", "type": "product", "weight": 1.2} + ) + self.order = self.env["sale.order"].create( + { + "carrier_id": self.test_carrier.id, + "partner_id": partner.id, + "order_line": [ + (0, 0, {"product_id": product.id, "product_uom_qty": 1}) + ], + } + ) + self.env["stock.quant"].with_context(inventory_mode=True).create( + { + "product_id": product.id, + "location_id": self.order.warehouse_id.lot_stock_id.id, + "inventory_quantity": 1, + } + )._apply_inventory() + self.order.action_confirm() + self.picking = self.order.picking_ids + self.env["stock.immediate.transfer"].create( + {"pick_ids": [(6, 0, self.picking.ids)]} + ).process() + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + roulier.get_carriers_action_available = cls.real_get_carriers_action_available + super().tearDownClass() + + def test_roulier(self): + roulier.get_carriers_action_available = MagicMock( + return_value={"test": ["get_label"]} + ) + with patch("roulier.roulier.get") as mock_roulier: + mock_roulier.return_value = roulier_ret + self.picking.send_to_shipper() + roulier_args = mock_roulier.mock_calls[0][1] + self.assertEqual("get_label", roulier_args[1]) + roulier_payload = roulier_args[2] + self.assertEqual(len(roulier_payload["parcels"]), 1) + self.assertEqual(roulier_payload["parcels"][0].get("weight"), 1.2) + self.assertEqual( + roulier_payload["to_address"].get("street1"), "test street" + ) + self.assertEqual(roulier_payload["to_address"].get("country"), "FR") + self.assertEqual(roulier_payload["auth"].get("isTest"), True) + self.assertEqual(roulier_payload["auth"].get("login"), "test") diff --git a/requirements.txt b/requirements.txt index 9cd1629223..b25984a80d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ # generated from manifests external_dependencies +roulier diff --git a/setup/delivery_roulier/odoo/addons/delivery_roulier b/setup/delivery_roulier/odoo/addons/delivery_roulier new file mode 120000 index 0000000000..f53d6ebd12 --- /dev/null +++ b/setup/delivery_roulier/odoo/addons/delivery_roulier @@ -0,0 +1 @@ +../../../../delivery_roulier \ No newline at end of file diff --git a/setup/delivery_roulier/setup.py b/setup/delivery_roulier/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/delivery_roulier/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test-requirements.txt b/test-requirements.txt index 33be0fbec5..65700ca2fa 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,2 +1,3 @@ +odoo-test-helper vcrpy vcrpy-unittest