diff --git a/delivery_sendcloud_oca/README.rst b/delivery_sendcloud_oca/README.rst new file mode 100644 index 0000000000..dfe8a3c209 --- /dev/null +++ b/delivery_sendcloud_oca/README.rst @@ -0,0 +1,337 @@ +================== +Sendcloud Shipping +================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:dbe01d1d34d6b43fe7239a4179838085ee43b5a8e5eef4ad0a384761109282be + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdelivery--carrier-lightgray.png?logo=github + :target: https://github.com/OCA/delivery-carrier/tree/17.0/delivery_sendcloud_oca + :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-17-0/delivery-carrier-17-0-delivery_sendcloud_oca + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/delivery-carrier&target_branch=17.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides sendcloud shipping integration with Odoo + +This module mostly implements what's described in +https://docs.sendcloud.sc/api/v2/shipping/ + +Full documentation for developers is in https://docs.sendcloud.sc/. + +This module works for the Community Edition as well as the Enterprise +Edition. + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +Create an account on sendcloud.com and choose a plan. Go to integrations +and select Odoo integration to use the Odoo integration or select api +integration if you only want to use the api integration (see readme for +more information). + +Odoo Integration +---------------- + +Verify that the value of "web.base.url" parameter in System Parameters +is set with the correct url (eg.: "https://demo.onestein.eu" instead of +"http://localhost:8069"). + +Go to Sendcloud > Configuration > Wizards > Setup the Sendcloud +Integration. A wizard will pop up. + +|image1| + +Select Odoo Integration. Start Setup. You will be redirected to a +Sendcloud page asking you to authorize OdooShop to access your Sendcloud +account. Click on Connect in the Sendcloud page. + +|image2| + +Go back to the Odoo Integration configuration. An integration "OdooShop" +is now present in the Integration list view. Open the OdooShop +Integration form. Edit the OdooShop Integration. The changes you make +will be in sync, Sendcloud side, with the integration configuration. + +|image3| + +In case multiple integrations are present, sort the integrations by +sequence, to allow Odoo to choose the default one that will be used. +Please note that when using the Odoo integration an "incoming order" is +created in Sendcloud as soon as you validate the salesorder. The +“incoming order” has status “in process” in Sendcloud and is not +forwarded to the carrier yet. + +|image4| + +When you validate the delivery in Odoo the label is created and the +pick-up assignment is send to the carrier. + +|image5| + +In previous version there was a possibility to connect to the API +integration instead of the Odoo integration. To benefit from Sendcloud +support we highly recommend you to upgrade to the latest version of this +module with the Odoo integration. + +Sendcloud panel settings +------------------------ + +When you configure the Integration settings in the online Sendcloud +panel (https://panel.sendcloud.sc/) those settings are also sync-ed with +the Integration settings Odoo side. + +Synchronize Sendcloud objects +----------------------------- + +After the setup of the integration with Sendcloud server is completed, +second step is to synchronize the objects present in Sendcloud server to +Odoo. To synchronize Sendcloud objects for the first time: + +- Go to Sendcloud > Configuration > Wizards > Sync the Sendcloud + Objects. A wizard will pop up. + +|image6| + +- Select all the objects. Confirm. This will retrieve the required data + from Sendcloud server. + +|image7| + +Some Sendcloud objects will be automatically synchronized from the +Sendcloud server to Odoo. Those Sendcloud objects are: + +- Parcel Statuses +- Invoices +- Shipping Methods +- Sender Addresses + +To configure how often those objects should be retrieved from the +Sendcloud server: + +- Go to Settings > Technical > Automation > Scheduled Actions. Search + Scheduled Actions for "Sendcloud". + +|image8| + +- Set the "Execute Every" value according to your needs. + +Sender Addresses and Warehouses + +In case of multiple warehouses configured in Odoo (eg.: user belongs to +group "Manage multiple Warehouse"): + +Go to Sendcloud > Configuration > Integration. Click on Configure +Warehouse Addresses. A wizard will pop up. Set the corresponding +Sendcloud Sender Address for each of the warehouse addresses. + +|image9| + +Alternatively, in Inventory > Configuration > Warehouses, select an +address. In the address form, go to Sales and Purchase tab and set the +Sencloud Sender Address. In Sale Order > Delivery: select the Warehouse. +Check that the address of the Warehouse has a Sendcloud Senser Address. + +|image10| + +Initial sync of past orders +--------------------------- + +Once all the previous configuration steps are completed, it is possible +to synchronize all the past Odoo outgoing shipments to Sendcloud. Those +shipments are the ones already setup with a Sendcloud shipping method. + +Go to Sendcloud > Configuration > Wizards > Sync past orders to +Sendcloud. A wizard will pop up. Select the date (by default set to 30 +days back from today) from which the shipments must be synchronized. + +Click on Confirm button: the shipments will be displayed in the Incoming +Order View tab of the Sendcloud panel. They will contain a status “Ready +to Process” if they are ready to generate a label and the order +fulfillment will continue. + +Auto create invoice +------------------- + +When sending a product outside the EU, Sendcloud requires an invoice +number. In case shipment is made with a product that can be invoiced +based on delivered quantities, this combination of factors prevents the +label being created in Sendcloud when confirming the SO. + +A possible solution is to automatically create a 100% down-payment +invoice when shipping to outside the EU. To enable this feature, go to +the "General Settings": under the Sendcloud section you can find the +"Auto create invoice" flag. Notice: this feature is still in beta +testing. + +Test Mode +--------- + +To enable the Test Mode, go to the "General Settings": under the +Sendcloud section you can find the "Enable Test Mode" flag. Enabling the +Test Mode allows you to access extra functionalities that are useful to +test the connector. + +There is no seperate test environment available on the Sendcloud portal. +This means that as soon as you create labels the carries is given the +order to pickup the goods. You can use carrier "unstamped letter" for +testing. When testing with other carriers make sure that you cancel the +labels in the Sendcloud portal within a couple of hours otherwise the +label will be billed and picked up. + +Since there is no test environment it's very important to know that +Sendcloud stores it records based on the delivery number, for instance +WH/OUT/0001, this field is idempotent. So when you start testing and you +will use delivery number WH/OUT/00001 this number is stored in +Sendcloud. When you go live and use the same delivery numbers, in this +case WH/OUT/00001, Sendcloud will treat this as an update of the +existing record and will send back the shipping-address that was already +stored (created while testing). To avoid this problem you should set a +different prefix on the sequence out in your testenvironment. In debug +mode, Technical/Sequences Identifiers/Sequences, select the sequence out +and adjust this to WH/OUT/TEST for instance. + +|image11| + +.. |image1| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_10.png +.. |image2| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_20.png +.. |image3| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_30.png +.. |image4| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_40.png +.. |image5| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_50.png +.. |image6| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_70.png +.. |image7| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_80.png +.. |image8| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_90.png +.. |image9| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_100.png +.. |image10| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_110.png +.. |image11| image:: https://raw.githubusercontent.com/OCA/delivery-carrier/17.0/delivery_sendcloud_oca/static/description/Image_120.png + +Usage +===== + +In short this is how the module works: + +- the user creates a sale order in Odoo; the user clicks on "Add + shipping" button and selects one of the shipping methods provided by + Sendcloud +- when confirming the sale order, a delivery document is generated + (stock.picking) +- when confirming the picking, a parcel (or multiple parcels) for the + specific sales order are created in Sendcloud under Shipping > + Created labels +- the picking is updated with the information from Sendcloud (tracking + number, tracking url, label etc...) + +Map of Sendcloud-Odoo data models +--------------------------------- + +========= ==== +Sendcloud Odoo +========= ==== +========= ==== + +\| \| Brand \| Website Shop \| \| Order \| Sales Order \| \| Shipment \| +Picking \| \| Parcel (colli) \| Picking packs \| \| Sender address \| +Warehouse address \| \| Shipping Method \| Shipping Method \| + +Multicollo parcels +------------------ + +In Inventory > Configuration > Delivery Packages, set the carrier to +Sendcloud. In the out picking, put the products in different Sendcloud +packages to create Multicollo parcels. + +Service Point Picker +-------------------- + +The module contains a widget, the Service Point Picker, that allows the +selection of the service point. The widget is placed in the "Sendcloud +Shipping" tab of the picking. The widget is visible in case the +following is true: + +- the configuration in the Sendcloud panel has the Service Point flag + to True (in the Sendcloud integration config) +- the Shipping Method selected in the picking is provided by Sendcloud +- the Shipping Method has field sendcloud_service_point_input == + "required" +- all the criteria (from country, to country, weight) match with the + current order + +Cancel parcels +-------------- + +When canceling parcels a confirmation popup will ask for confirmation. + +Delivery outside EU +------------------- + +Install either OCA module 'product_harmonized_system' or Enterprise +module 'account_intrastat' for delivery outside of EU. Both include +extra field 'country of origin'. + +Troubleshooting +--------------- + +If the communication to the Sendcloud server fails (eg.: while creating +a parcel), the exchanged message is stored in a Log section, under +Logging > Actions. + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Onestein + +Contributors +------------ + +- ``Onestein ``\ \_\_ + +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. + +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_sendcloud_oca/__init__.py b/delivery_sendcloud_oca/__init__.py new file mode 100644 index 0000000000..4ba7940208 --- /dev/null +++ b/delivery_sendcloud_oca/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import wizards +from . import controllers diff --git a/delivery_sendcloud_oca/__manifest__.py b/delivery_sendcloud_oca/__manifest__.py new file mode 100644 index 0000000000..09d1b17e8c --- /dev/null +++ b/delivery_sendcloud_oca/__manifest__.py @@ -0,0 +1,59 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +{ + "name": "Sendcloud Shipping", + "summary": "Compute shipping costs and ship with Sendcloud", + "category": "Operations/Inventory/Delivery", + "version": "17.0.1.0.0", + "website": "https://github.com/OCA/delivery-carrier", + "author": "Onestein,Odoo Community Association (OCA)", + "license": "LGPL-3", + "depends": ["base_address_extended", "stock_delivery", "web", "sale_management"], + "data": [ + "security/ir.model.access.csv", + "security/sendcloud_security_rule.xml", + "data/delivery_sendcloud_data.xml", + "data/delivery_sendcloud_cron.xml", + "data/onboarding_data.xml", + "wizards/sendcloud_create_return_parcel_wizard_view.xml", + "views/sale_order_view.xml", + "views/stock_picking_view.xml", + "views/stock_warehouse_view.xml", + "views/res_partner_view.xml", + "views/delivery_carrier_view.xml", + "views/sendcloud_parcel_view.xml", + "views/sendcloud_brand_view.xml", + "views/sendcloud_carrier_view.xml", + "views/sendcloud_return_view.xml", + "views/sendcloud_invoice_view.xml", + "views/sendcloud_parcel_status_view.xml", + "views/sendcloud_sender_address_view.xml", + "views/sendcloud_action.xml", + "views/sendcloud_integration_view.xml", + "views/res_config_settings_view.xml", + "wizards/sendcloud_warehouse_address_wizard_view.xml", + "wizards/sendcloud_cancel_shipment_confirm_wizard_view.xml", + "wizards/sendcloud_integration_wizard_view.xml", + "wizards/sendcloud_sync_wizard_view.xml", + "wizards/sendcloud_sync_order_wizard_view.xml", + "wizards/sendcloud_custom_price_details_wizard.xml", + "views/sendcloud_onboarding_views.xml", + "views/sendcloud_shipping_method_country_view.xml", + "views/menu.xml", + ], + "assets": { + "web.assets_backend": [ + "delivery_sendcloud_oca/static/src/js/*", + "delivery_sendcloud_oca/static/src/scss/*", + "delivery_sendcloud_oca/static/src/xml/*", + ] + }, + "external_dependencies": { + "python": [ + # tests dependencies + "vcrpy", + ], + }, + "application": True, +} diff --git a/delivery_sendcloud_oca/controllers/__init__.py b/delivery_sendcloud_oca/controllers/__init__.py new file mode 100644 index 0000000000..12a7e529b6 --- /dev/null +++ b/delivery_sendcloud_oca/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/delivery_sendcloud_oca/controllers/main.py b/delivery_sendcloud_oca/controllers/main.py new file mode 100644 index 0000000000..be2040f515 --- /dev/null +++ b/delivery_sendcloud_oca/controllers/main.py @@ -0,0 +1,116 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +import base64 +import hmac +import io +import json +import logging + +import PyPDF2 + +from odoo import fields, http +from odoo.http import content_disposition, request + +_logger = logging.getLogger(__name__) + + +class DeliverySendcloud(http.Controller): + @http.route(["/sendcloud/picking/download_labels"], type="http", auth="user") + def sendcloud_picking_download_labels(self, ids, **post): + picking_ids = [] + for picking_id in ids.split(","): + picking_ids.append(int(picking_id)) + pickings = request.env["stock.picking"].browse(picking_ids) + file_data = [] + for attachment in pickings.mapped("sendcloud_parcel_ids").mapped( + "attachment_id" + ): + file_data.append(base64.b64decode(attachment.datas)) + + if not file_data: + return + + pdf_merger = PyPDF2.PdfFileMerger() + for pdf_data in file_data: + pdf_file = io.BytesIO(pdf_data) + pdf_merger.append(pdf_file, import_bookmarks=False) + + new_stream = io.BytesIO() + pdf_merger.write(new_stream) + new_stream.seek(0) + pdf = new_stream.read() + + file_name = "labels.pdf" # Change the file name as needed + headers = [ + ("Content-Type", "application/pdf"), + ("Content-Length", len(pdf)), + ("Content-Disposition", content_disposition(file_name)), + ] + return request.make_response(pdf, headers) + + @http.route( + "/shop/sendcloud_integration_webhook/", + methods=["POST"], + type="json", + auth="none", + ) + def sendcloud_integration_webhook(self, company_id, **kwargs): + payload_data = request.get_json_data() + _logger.info("Sendcloud payload_data:%s", str(payload_data)) + integration = self._verify_sendcloud_authentic(payload_data, company_id) + if integration: + _logger.info("Sendcloud integration.id:%s", integration.id) + timestamp = payload_data.get("timestamp") + sendcloud_action = ( + request.env["sendcloud.action"] + .sudo() + .create( + { + "company_id": company_id, + "sendcloud_integration_id": integration.id, + "message_type": "received", + "action": payload_data.get("action"), + "message": json.dumps(payload_data), + "timestamp": str(timestamp) if timestamp else False, + } + ) + ) + sendcloud_action.sudo().reparse_message() + + def _verify_sendcloud_authentic(self, payload, company_id): + received_signature = request.httprequest.headers.get("sendcloud-signature") + _logger.info("Sendcloud received_signature:%s", received_signature) + if received_signature: + encoded_payload = json.dumps(payload).encode("utf-8") + action = payload.get("action") + _logger.info("Sendcloud action:%s", action) + company = request.env["res.company"].sudo().browse(company_id) + integrations = company.sendcloud_integration_ids + if action != "integration_credentials": + integrations = integrations.filtered( + lambda i: i.public_key and i.secret_key + ) + for integration in integrations: + secret_key = integration.secret_key.encode("utf-8") + signature = hmac.new( + key=secret_key, msg=encoded_payload, digestmod="sha256" + ) + if signature.hexdigest() == received_signature: + return integration + else: + secret_key = payload.get("secret_key").encode("utf-8") + signature = hmac.new( + key=secret_key, msg=encoded_payload, digestmod="sha256" + ) + if signature.hexdigest() == received_signature: + _logger.info("Sendcloud signature:%s", signature) + integrations = integrations.filtered( + lambda i: not i.public_key + and not i.secret_key + and not i.sendcloud_code + ) + integration = fields.first(integrations) + _logger.info("Sendcloud integration:%s", integration.id) + return integration + return request.env["sendcloud.integration"] diff --git a/delivery_sendcloud_oca/data/delivery_sendcloud_cron.xml b/delivery_sendcloud_oca/data/delivery_sendcloud_cron.xml new file mode 100644 index 0000000000..b1c121138b --- /dev/null +++ b/delivery_sendcloud_oca/data/delivery_sendcloud_cron.xml @@ -0,0 +1,81 @@ + + + + + Sendcloud: sync Parcel Statuses + + + 3 + days + -1 + + + code + model.sendcloud_sync_parcel_statuses() + + + Sendcloud: sync Invoices + + + 3 + days + -1 + + + code + model.sendcloud_sync_invoices() + + + Sendcloud: sync Shipping Methods + + + 1 + hours + -1 + + + code + model.sendcloud_sync_shipping_method() + + + Sendcloud: sync Sender Addresses + + + 1 + hours + -1 + + + code + model.sendcloud_sync_sender_address() + + + Sendcloud: delete old records from actions log + + + 1 + days + -1 + + + code + model.sendcloud_delete_old_actions(days=7) + + diff --git a/delivery_sendcloud_oca/data/delivery_sendcloud_data.xml b/delivery_sendcloud_oca/data/delivery_sendcloud_data.xml new file mode 100644 index 0000000000..6b42a0b5e4 --- /dev/null +++ b/delivery_sendcloud_oca/data/delivery_sendcloud_data.xml @@ -0,0 +1,15 @@ + + + + + Sendcloud delivery charges + sendcloud_delivery + service + + + + 0.0 + Delivery Cost + + diff --git a/delivery_sendcloud_oca/data/onboarding_data.xml b/delivery_sendcloud_oca/data/onboarding_data.xml new file mode 100644 index 0000000000..40d6aae215 --- /dev/null +++ b/delivery_sendcloud_oca/data/onboarding_data.xml @@ -0,0 +1,52 @@ + + + + + + Setup Integration + Setup Sendcloud Integration. + Setup + Well done! + action_open_sendcloud_onboarding_integration + 1 + + + + Sync Sendcloud objects + Synchronize Sendcloud objects. + Sync + Looks great! + action_sendcloud_onboarding_sync + 3 + + + + Configure Warehouse Addresses + Set Sendcloud Warehouse Addresses. + Configure + Enjoy! + action_open_sendcloud_onboarding_warehouse_address + 4 + + + + + Sendcloud Onboarding + + sendcloud_onboarding_panel + action_close_sendcloud_onboarding + + diff --git a/delivery_sendcloud_oca/models/__init__.py b/delivery_sendcloud_oca/models/__init__.py new file mode 100644 index 0000000000..76084833c4 --- /dev/null +++ b/delivery_sendcloud_oca/models/__init__.py @@ -0,0 +1,25 @@ +from . import abstract_sendcloud_mixin +from . import abstract_sendcloud_request +from . import delivery_carrier +from . import onboarding_onboarding +from . import onboarding_onboarding_step +from . import res_company +from . import res_partner +from . import sale_order +from . import sendcloud_action +from . import sendcloud_brand +from . import sendcloud_carrier +from . import sendcloud_integration +from . import sendcloud_invoice +from . import sendcloud_invoice_item +from . import sendcloud_parcel +from . import sendcloud_parcel_item +from . import sendcloud_parcel_status +from . import sendcloud_return +from . import sendcloud_return_location +from . import sendcloud_sender_address +from . import stock_picking +from . import stock_warehouse +from . import sendcloud_shipping_method_country +from . import sendcloud_shipping_method_country_custom +from . import res_config_settings diff --git a/delivery_sendcloud_oca/models/abstract_sendcloud_mixin.py b/delivery_sendcloud_oca/models/abstract_sendcloud_mixin.py new file mode 100644 index 0000000000..fd1b4f0b0e --- /dev/null +++ b/delivery_sendcloud_oca/models/abstract_sendcloud_mixin.py @@ -0,0 +1,35 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import api, fields, models + + +class SendcloudMixin(models.AbstractModel): + _name = "sendcloud.mixin" + _description = "Sendcloud Mixin Abstract" + + is_sendcloud_test_mode = fields.Boolean(compute="_compute_is_sendcloud_test_mode") + + def _sendcloud_convert_weight_to_kg(self, weight): + uom = self.env["product.template"]._get_weight_uom_id_from_ir_config_parameter() + uom_kgm = self.env.ref("uom.product_uom_kgm") + amount = uom._compute_quantity(weight, uom_kgm, round=True) + return round(amount, 2) # Force round as sometimes odoo fails to round properly + + @api.model + def _get_sendcloud_customs_shipment_type(self): + return [ + ("0", "Gift"), + ("1", "Documents"), + ("2", "Commercial Goods"), + ("3", "Commercial Sample"), + ("4", "Returned Goods"), + ] + + @api.model + def _default_get_sendcloud_customs_shipment_type(self): + return "2" # "Commercial Goods" + + def _compute_is_sendcloud_test_mode(self): + for record in self: + record.is_sendcloud_test_mode = self.env.company.is_sendcloud_test_mode diff --git a/delivery_sendcloud_oca/models/abstract_sendcloud_request.py b/delivery_sendcloud_oca/models/abstract_sendcloud_request.py new file mode 100644 index 0000000000..8c746e65de --- /dev/null +++ b/delivery_sendcloud_oca/models/abstract_sendcloud_request.py @@ -0,0 +1,300 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +import time +from json.decoder import JSONDecodeError +from urllib.parse import urlparse + +import requests + +from odoo import SUPERUSER_ID, _, api, models, registry +from odoo.exceptions import UserError + +TIMEOUT = 60 + + +class SendcloudRequest(models.AbstractModel): + _name = "sendcloud.request" + _description = "Sendcloud Request Abstract" + + def _param_web_base_url(self): + base_url = self.env["ir.config_parameter"].get_param("web.base.url") + parsed_url = urlparse(base_url) + return parsed_url._replace(scheme="https").geturl() + + def _base_panel_url(self): + return "https://panel.sendcloud.sc/api/v2" + + def _default_integration_webhook(self, company_id): + webhook = "/shop/sendcloud_integration_webhook" + return f"{webhook}/{company_id}" + + def _do_auth_request(self, type_request, url, data=None): + self.ensure_one() + auth = (self.public_key, self.secret_key) + return self._do_request(type_request, url, data, auth=auth) + + def _do_request(self, type_request, url, data=None, auth=None, headers=None): + self.ensure_one() + start_time = time.time() + try: + if type_request == "POST": + resp = requests.post( + url=url, json=data, auth=auth, headers=headers, timeout=TIMEOUT + ) + elif type_request == "GET": + resp = requests.get(url=url, params=data, auth=auth, timeout=TIMEOUT) + elif type_request == "PUT": + resp = requests.put(url=url, json=data, auth=auth, timeout=TIMEOUT) + except requests.ConnectionError as CE: + raise UserError( + _("Sendcloud: server not reachable, try again later") + ) from CE + except requests.Timeout as TO: + raise UserError( + _("Sendcloud timeout: the server didn't reply within 30s") + ) from TO + except requests.HTTPError as HE: + error_msg = resp.json().get("error", {}).get("message", "") + raise UserError(_("Sendcloud: %s") % error_msg or resp.text) from HE + + # Handle request limiting (retry after one second) + if resp.status_code == 429: + time.sleep(1) + return self._do_request( + type_request, url, data=data, auth=auth, headers=headers + ) + + end_time = time.time() + response_time = end_time - start_time + with registry(self.env.cr.dbname).cursor() as new_cr: + # Create a new environment with new cursor database + new_env = api.Environment(new_cr, SUPERUSER_ID, self.env.context) + self.with_env(new_env)._log_response_in_action( + resp, type_request, url, str(data), response_time + ) + err_msg = self._check_response_ok(resp) + if err_msg: + err_msg = err_msg + "\n" + _("Request: %s") % data + raise UserError(err_msg) + return resp + + def _check_response_ok(self, resp): + if self.env.context.get("skip_sendcloud_check_response"): + return "" + ok_status = self._ok_response_status() + err_msg = "" + if resp.status_code not in ok_status: + err_msg = _("Sendcloud: %(reason)s (error code %(status_code)s)") % ( + {"reason": resp.reason, "status_code": resp.status_code} + ) + if resp.status_code == 500: + err_msg += "\n" + _("Internal server error.") + else: + resp_dict = resp.json() + if resp_dict.get("error"): + err_msg += "\n" + resp_dict["error"].get("message", "") + elif resp_dict.get("message"): + err_msg += "\n" + resp_dict["message"] + return err_msg + + def _ok_response_status(self): + # 200: OK + # 201: OK, eg.: creating/updating a list of shipments + # 204: No Content, eg.: when deleting a shipment + # 404: Not found, eg.: when deleting a parcel + # 410: Happens when the parcel announcement has failed, the parcel + # status contains id of 1002 and you try to cancel it. + return self.env.context.get( + "sendcloud_ok_response_status", (200, 204, 404, 410) + ) + + def _log_response_in_action( + self, resp, type_request, url, sent_payload, response_time + ): + self.ensure_one() + try: + decoded_content = resp.content.decode() + except Exception: + decoded_content = "Byte content" + if resp.status_code == 401 and not self.env.context.get("skip_raise_error_401"): + error_msg = resp.json().get("error", {}).get("message", "") + raise UserError(_("Sendcloud: %s") % error_msg or resp.text) + company = self.company_id + self.env["sendcloud.action"].create( + { + "company_id": company.id, + "sendcloud_integration_id": self.id, + "message_type": "sent", + "exitcode": str(resp.status_code) if resp.status_code else False, + "action": f"{type_request}: {url}", + "message": decoded_content, + "response_time": response_time, + "sent_payload": sent_payload or False, + "model": self._name, + "resid": self.id, + } + ) + + def _iterate_pagination(self, response, urlpath, list_name): + res = response.get(list_name) + next_response = response.get("next") + while next_response: + parsed_next = urlparse(response.get("next")) + response = self._get_panel_request(urlpath + "?" + parsed_next.query) + res += response.get(list_name) + next_response = response.get("next") + return res + + def _get_request(self, url, params=None): + if params: + res = self._do_auth_request("GET", url, data=params) + else: + res = self._do_auth_request("GET", url) + return self._format_response(res) + + def _post_request(self, url, data=None): + res = self._do_auth_request("POST", url, data) + return self._format_response(res) + + def _put_request(self, url, data): + res = self._do_auth_request("PUT", url, data) + return self._format_response(res) + + def _format_response(self, res): + """ + The HTTP 204 No Content success status response code indicates that the + request has succeeded, but that the reply message is empty. + """ + if res.status_code == 204: + return {} + try: + res = res.json() + except JSONDecodeError: + # If it is not possible get json then + # use response exception message + return { + "error": { + "code": "JSONDecodeError", + "message": ("Unable to read response message"), + } + } + return res + + def _get_panel_request(self, url, params=None): + url = self._base_panel_url() + url + return self._get_request(url, params) + + def _post_panel_request(self, url, data=None): + url = self._base_panel_url() + url + return self._post_request(url, data) + + def _put_panel_request(self, url, data): + url = self._base_panel_url() + url + return self._put_request(url, data) + + def get_sender_address(self): + response = self._get_panel_request("/user/addresses/sender") + return response.get("sender_addresses") + + def get_user_invoices(self): + response = self._get_panel_request("/user/invoices") + return response.get("invoices") + + def get_user_invoice(self, code): + response = self._get_panel_request("/user/invoices/%s" % code) + return response.get("invoice") + + def get_integrations(self): + return self._get_panel_request("/integrations") + + def get_shipping_methods(self, params): + response = self._get_panel_request("/shipping_methods", params) + return response.get("shipping_methods") + + def get_shipping_method(self, code, params): + response = self._get_panel_request("/shipping_methods/%s" % code, params) + return response.get("shipping_method") + + def get_parcels(self): + urlpath = "/parcels" + response = self._get_panel_request(urlpath) + return self._iterate_pagination(response, urlpath, "parcels") + + def get_parcel(self, code): + response = self._get_panel_request("/parcels/%s" % code) + return response.get("parcel") + + def get_parcels_statuses(self): + return self._get_panel_request("/parcels/statuses") + + def get_brands(self): + urlpath = "/brands" + response = self._get_panel_request(urlpath) + return self._iterate_pagination(response, urlpath, "brands") + + def create_parcels(self, post_data): + return self._post_panel_request("/parcels", post_data) + + def create_shipments(self, integration_code, vals_list): + post_data = [] + for vals in vals_list: + shipping_method = vals["shipment"]["id"] + vals.update( + { + "shipping_method": shipping_method, + } + ) + post_data += [vals] + url = "/integrations/%s/shipments" % integration_code + return self._post_panel_request(url, post_data) + + def delete_shipments(self, integration_id, post_data): + url = "/integrations/%s/shipments/delete" % integration_id + return self._post_panel_request(url, post_data) + + def get_parcel_label(self, label_printer_url): + res = self._do_auth_request("GET", label_printer_url) + return res.content + + def get_return_portal_url(self, code): + return self._get_panel_request("/parcels/%d/return_portal_url" % code) + + def get_parcel_document(self, link): + res = self._do_auth_request("GET", link) + return res.content + + def cancel_parcel(self, code): + self = self.with_context(skip_sendcloud_check_response=True) + return self._post_panel_request("/parcels/%s/cancel" % code) + + def update_integration(self, code, data): + return self._put_panel_request("/integrations/%s" % code, data) + + def get_returns(self): + urlpath = "/returns" + response = self._get_panel_request(urlpath) + return self._iterate_pagination(response, urlpath, "returns") + + def get_return(self, code): + return self._get_panel_request("/returns/%s" % code) + + def get_return_portal_settings(self, domain_brand, language=""): + url = "/brand/%s/return-portal" % domain_brand + if language: + url += "?language=" + language + url = self._base_panel_url() + url + res = self._do_request("GET", url) + return res.json() + + def get_return_portal_outgoing_parcel(self, domain_brand, params): + url = "/brand/%s/return-portal/outgoing" % domain_brand + url = self._base_panel_url() + url + res = self._do_request("GET", url, data=params) + return res.json() + + def create_return_portal_incoming_parcel(self, domain_brand, payload, headers): + url = "/brand/%s/return-portal/incoming" % domain_brand + url = self._base_panel_url() + url + res = self._do_request("POST", url, data=payload, headers=headers) + return res.json() diff --git a/delivery_sendcloud_oca/models/delivery_carrier.py b/delivery_sendcloud_oca/models/delivery_carrier.py new file mode 100644 index 0000000000..1d0a00c38d --- /dev/null +++ b/delivery_sendcloud_oca/models/delivery_carrier.py @@ -0,0 +1,476 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools import float_round +from odoo.tools.safe_eval import safe_eval + + +class DeliveryCarrier(models.Model): + _name = "delivery.carrier" + _inherit = ["delivery.carrier", "sendcloud.mixin"] + + delivery_type = fields.Selection( + selection_add=[("sendcloud", "Sendcloud")], + ondelete={ + "sendcloud": lambda recs: recs.write( + { + "delivery_type": "fixed", + "fixed_price": 0, + } + ) + }, + ) + + sendcloud_code = fields.Integer() + sendcloud_carrier = fields.Char() + sendcloud_service_point_input = fields.Selection( + [("none", "None"), ("required", "Required")], default="none" + ) + sendcloud_min_weight = fields.Float() + sendcloud_max_weight = fields.Float() + sendcloud_price = fields.Float( + help="When this value is null, the price is calculated based on the" + " pricelist by countries" + ) + sendcloud_is_return = fields.Boolean() + sendcloud_country_ids = fields.One2many( + "sendcloud.shipping.method.country", + compute="_compute_sendcloud_country_ids", + string="Price per Country", + ) + sendcloud_service_point_required = fields.Boolean( + compute="_compute_sendcloud_service_point_required" + ) + sendcloud_sender_address_id = fields.Many2one("sendcloud.sender.address") + + sendcloud_integration_id = fields.Many2one( + "sendcloud.integration", compute="_compute_sendcloud_integration_id" + ) + sendcloud_sync_countries = fields.Boolean( + string="Synchronize countries with Sendcloud", default=True + ) + + # -------- # + # Computed # + # -------- # + + @api.depends("company_id") + def _compute_sendcloud_integration_id(self): + for carrier in self: + integration = carrier.company_id.sendcloud_default_integration_id + carrier.sendcloud_integration_id = integration + + @api.depends("sendcloud_service_point_input", "delivery_type") + def _compute_sendcloud_service_point_required(self): + for carrier in self: + carrier.sendcloud_service_point_required = False + for carrier in self.filtered( + lambda c: c.delivery_type == "sendcloud" + and c.sendcloud_service_point_input == "required" + ): + carrier.sendcloud_service_point_required = True + + @api.depends() + def _compute_sendcloud_country_ids(self): + for carrier in self: + countries = self.env["sendcloud.shipping.method.country"].search( + [ + ("company_id", "=", carrier.company_id.id), + ("method_code", "=", carrier.sendcloud_code), + ] + ) + carrier.sendcloud_country_ids = countries + + # -------- # + # Onchange # + # -------- # + + @api.onchange("delivery_type") + def _onchange_sendcloud_delivery_type(self): + self._sendcloud_set_countries() + + # -------------------------- # + # API for Sendcloud provider # + # -------------------------- # + + def sendcloud_rate_shipment(self, order): + self.ensure_one() + res = { + "success": False, + "price": 0.0, + "warning_message": False, + "error_message": False, + } + if not order.partner_shipping_id.country_id: + res["error_message"] = _("Partner does not have any country.") + return res + + country = order.partner_shipping_id.country_id + price, method_country = self._sendcloud_get_price_per_country(country.code) + price_digits = self.env["decimal.precision"].precision_get("Product Price") + price = float_round(price, precision_digits=price_digits) + price = order._sendcloud_convert_price_in_euro(price) + res["success"] = True + res["price"] = price + res["sendcloud_country_specific_product"] = method_country.product_id + + if self.sendcloud_service_point_input == "required": + res["warning_message"] = _("This shipping method requires a Service Point.") + + return res + + def sendcloud_send_shipping(self, pickings): + self.ensure_one() + # Dispatch Order and create labels + res = pickings._sendcloud_send_shipping() + parcels = pickings.mapped("sendcloud_parcel_ids") + parcels._generate_parcel_labels() + return res + + def sendcloud_cancel_shipment(self, picking): + ctx = {"skip_sync_picking_to_sendcloud": True, "skip_cancel_parcel": True} + deleted_parcels = [] + for parcel_code in picking.mapped("sendcloud_parcel_ids.sendcloud_code"): + integration = picking.company_id.sendcloud_default_integration_id + res = integration.cancel_parcel(parcel_code) + if res.get("status") == "deleted": + deleted_parcels.append(parcel_code) + elif ( + res.get("status") == "failed" + and res.get("message") == "This shipment is already being cancelled." + ): + deleted_parcels.append(parcel_code) + elif res.get("error", {}).get("code") == 404: + deleted_parcels.append(parcel_code) # ignore "Not Found" error + elif res.get("error"): + raise ValidationError(_("Sendcloud: %s") % res["error"].get("message")) + parcels_to_delete = picking.sendcloud_parcel_ids.filtered( + lambda p: p.sendcloud_code in deleted_parcels + ) + parcels_to_delete.with_context(**ctx).unlink() + picking.with_context(**ctx).write({"carrier_price": 0.0}) + + # ------------------------------- # + # Inherits for Sendcloud provider # + # ------------------------------- # + + def _compute_can_generate_return(self): + res = super()._compute_can_generate_return() + for carrier in self.filtered(lambda c: c.delivery_type == "sendcloud"): + carrier.can_generate_return = True + return res + + def available_carriers(self, partner): + """ + Standard Odoo method, invoked by the super(), already filters shipping + methods, including Sendcloud shipping methods, by the country of the + selected partner. + In addition, this method filters the Sendcloud shipping methods by + - sender address (warehouse address) + - weight range + - enabled/disabled service point in Sendcloud integration. + :param partner: + :return: + """ + res = super().available_carriers(partner) + + sendcloud_carriers = res.filtered( + lambda c: c.delivery_type == "sendcloud" and c.sendcloud_is_return is False + ) + other_carriers = res.filtered(lambda c: c.delivery_type != "sendcloud") + if sendcloud_carriers: + # Retrieve current sale order + order_id = self.env.context.get("sale_order_id") + if not order_id: + order_id = self.env.context.get("default_order_id") + if ( + not order_id + and self.env.context.get("active_model") == "choose.delivery.carrier" + ): + wizard = self.env["choose.delivery.carrier"].browse( + self.env.context.get("active_id") + ) + order_id = wizard.order_id.id + if not order_id: + return other_carriers + order = self.env["sale.order"].browse(order_id) + # get sender address (warehouse) + warehouse = order.warehouse_id + if not warehouse.sencloud_sender_address_id: + # use standard server address + sender_address = self._get_default_sender_address_per_company( + warehouse.company_id.id + ) + else: + sender_address = warehouse.sencloud_sender_address_id + + # filter by weight + # TODO are there sendcloud shipping methods without weight limit? + weight = order.sendcloud_order_weight + sendcloud_carriers = sendcloud_carriers.filtered( + lambda c: c.sendcloud_min_weight <= weight <= c.sendcloud_max_weight + ) + + # filter by sender address (warehouse) and delivery address (partner) + countries = self.env["sendcloud.shipping.method.country"].search( + [ + ("company_id", "=", order.company_id.id), + ("from_iso_2", "=", sender_address.country), + ("iso_2", "=", partner.country_id.code), + ] + ) + method_codes = countries.mapped("method_code") + sendcloud_carriers = sendcloud_carriers.filtered( + lambda c: c.sendcloud_code in method_codes + ) + + # filter out carriers requiring service points that are not enabled + without_service_point = sendcloud_carriers.filtered( + lambda c: c.sendcloud_service_point_input != "required" + ) + with_service_point = sendcloud_carriers.filtered( + lambda c: c.sendcloud_service_point_input == "required" + and c.sendcloud_integration_id.service_point_enabled + ) + enabled_service_point = self.env["delivery.carrier"] + for carrier in with_service_point: + carrier_names = carrier.sendcloud_integration_id.service_point_carriers + current_carrier = carrier.sendcloud_carrier + if ( + current_carrier + and current_carrier in safe_eval(carrier_names) + or [] + ): + enabled_service_point += carrier + sendcloud_carriers = without_service_point + enabled_service_point + + return (sendcloud_carriers + other_carriers).sorted( + key=lambda carrier: carrier.name + ) + + # ----------------- # + # Sendcloud methods # + # ----------------- # + + def _sendcloud_set_countries(self): + for record in self.filtered( + lambda r: r.delivery_type == "sendcloud" and r.sendcloud_sync_countries + ): + record.country_ids = record._sendcloud_get_countries_from_cache() + + def _sendcloud_get_countries_from_cache(self): + self.ensure_one() + countries = self.env["sendcloud.shipping.method.country"].search( + [ + ("company_id", "=", self.company_id.id), + ("method_code", "=", self.sendcloud_code), + ] + ) + iso_2_list = countries.mapped("iso_2") + return self.env["res.country"].search([("code", "in", iso_2_list)]) + + def _sendcloud_get_price_per_country(self, country_code): + self.ensure_one() + if self.sendcloud_price: + return self.sendcloud_price + shipping_method_country = self.env["sendcloud.shipping.method.country"].search( + [ + ("iso_2", "=", country_code), + ("company_id", "=", self.company_id.id), + ("method_code", "=", self.sendcloud_code), + ], + limit=1, + ) + return shipping_method_country.price_custom, shipping_method_country + + @api.model + def _get_default_sender_address_per_company(self, company_id): + # TODO is there a way to get the default sender address from sendcloud? + return self.env["sendcloud.sender.address"].search( + [("company_id", "=", company_id)], limit=1 + ) + + @api.model + def _prepare_sendcloud_shipping_method_from_response(self, carrier): + return { + "name": "Sendcloud " + carrier.get("name"), + "delivery_type": "sendcloud", + "sendcloud_code": carrier.get("id"), + "sendcloud_max_weight": carrier.get("max_weight"), + "sendcloud_min_weight": carrier.get("min_weight"), + "sendcloud_carrier": carrier.get("carrier"), + "sendcloud_service_point_input": carrier.get("service_point_input").lower(), + "sendcloud_price": carrier.get("price"), + } + + @api.model + def _get_sendcloud_product_delivery(self, company_id): + """This method gets a default delivery product for newly created + Sendcloud shipping methods. + :param vals: dict of values to update + :return: updated dict of values + """ + product = self.env["product.product"].search( + [ + ("default_code", "=", "sendcloud_delivery"), + ("company_id", "in", [company_id, False]), + ], + limit=1, + ) + if product: + return product + return self.env["product.product"].create( + { + "name": "Sendcloud delivery charges", + "default_code": "sendcloud_delivery", + "type": "service", + "categ_id": self.env.ref("delivery.product_category_deliveries").id, + "sale_ok": False, + "purchase_ok": False, + "list_price": 0.0, + "description_sale": "Delivery Cost", + "company_id": company_id, + } + ) + + @api.model + def _sendcloud_create_update_shipping_methods( + self, shipping_methods, company_id, is_return=False + ): + """Sync all available shipping methods for a specific company, + regardless of the sender address. + :return: + """ + + product = self._get_sendcloud_product_delivery(company_id) + + # All shipping methods + domain = [ + ("delivery_type", "=", "sendcloud"), + ("company_id", "=", company_id), + ("sendcloud_is_return", "=", is_return), + ] + all_shipping_methods = self.with_context(active_test=False).search(domain) + + # Existing records + shipping_methods_list = [method.get("id") for method in shipping_methods] + existing_shipping_methods = all_shipping_methods.filtered( + lambda c: c.sendcloud_code in shipping_methods_list + ) + + # Existing shipping methods map (internal code -> existing shipping methods) + existing_shipping_methods_map = {} + for existing in existing_shipping_methods: + if existing.sendcloud_code not in existing_shipping_methods_map: + existing_shipping_methods_map[existing.sendcloud_code] = self.env[ + "delivery.carrier" + ] + existing_shipping_methods_map[existing.sendcloud_code] |= existing + + # Disabled shipping methods + disabled_shipping_methods = all_shipping_methods - existing_shipping_methods + disabled_shipping_methods.write({"active": False}) + + # Created shipping methods and related pricelist by countries + new_shipping_methods_vals = [] + new_country_vals = [] + for method in shipping_methods: + vals = self._prepare_sendcloud_shipping_method_from_response(method) + vals["product_id"] = product.id + vals["sendcloud_is_return"] = is_return + if method.get("id") in existing_shipping_methods_map: + vals.pop("name") + existing_shipping_methods_map[method.get("id")].write(vals) + else: + vals["company_id"] = company_id + new_shipping_methods_vals += [vals] + for country in method.get("countries"): + new_country_vals.append( + { + "sendcloud_code": country.get("id"), + "iso_2": country.get("iso_2"), + "iso_3": country.get("iso_3"), + "from_iso_2": country.get("from_iso_2"), + "from_iso_3": country.get("from_iso_3"), + "price": country.get("price"), + "method_code": method.get("id"), + "sendcloud_is_return": is_return, + "company_id": company_id, + } + ) + new_created_shipping_methods = self.create(new_shipping_methods_vals) + self.sudo().env["sendcloud.shipping.method.country"].search( + [ + ("company_id", "=", company_id), + ("sendcloud_is_return", "=", is_return), + ] + ).unlink() + self.sudo().env["sendcloud.shipping.method.country"].create(new_country_vals) + + # Updated shipping methods + updated_shipping_methods = ( + existing_shipping_methods + new_created_shipping_methods + ) + updated_shipping_methods._sendcloud_set_countries() + updated_shipping_methods.write({"active": True}) + + # Carriers + self.sendcloud_update_carriers(updated_shipping_methods) + return shipping_methods + + @api.model + def sendcloud_update_carriers(self, updated_shipping_methods): + retrieved_carriers = updated_shipping_methods.mapped("sendcloud_carrier") + self.env["sendcloud.carrier"]._create_update_carriers(retrieved_carriers) + + @api.model + def sendcloud_sync_shipping_method(self): + for company in self.env["res.company"].search([]): + integration = company.sendcloud_default_integration_id + if integration: + params = {"sender_address": "all"} + shipping_methods = integration.get_shipping_methods(params) + self._sendcloud_create_update_shipping_methods( + shipping_methods, company.id + ) + params = {"sender_address": "all", "is_return": True} + shipping_methods = integration.get_shipping_methods(params) + self._sendcloud_create_update_shipping_methods( + shipping_methods, company.id, is_return=True + ) + + def button_from_sendcloud_sync(self): + self.ensure_one() + if self.delivery_type != "sendcloud": + return + integration = self.company_id.sendcloud_default_integration_id + if integration: + self._update_sendcloud_delivery_carrier(integration) + + def _update_sendcloud_delivery_carrier(self, integration): + self.ensure_one() + internal_code = self.sendcloud_code + params = {"sender_address": "all"} + carrier = integration.get_shipping_method(internal_code, params) + vals = self._prepare_sendcloud_shipping_method_from_response(carrier) + vals.pop("name") + self.write(vals) + + # ----------- # + # Constraints # + # ----------- # + + @api.constrains("delivery_type", "company_id") + def _constrains_sendcloud_integration_company_id(self): + for record in self.filtered(lambda r: r.delivery_type == "sendcloud"): + if not record.company_id: + raise ValidationError( + _("The company is mandatory when delivery carrier is Sendcloud.") + ) + if record.sendcloud_integration_id.company_id != record.company_id: + raise ValidationError( + _("The company is not consistent with the integration company.") + ) diff --git a/delivery_sendcloud_oca/models/onboarding_onboarding.py b/delivery_sendcloud_oca/models/onboarding_onboarding.py new file mode 100644 index 0000000000..11452a34f5 --- /dev/null +++ b/delivery_sendcloud_oca/models/onboarding_onboarding.py @@ -0,0 +1,14 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class Onboarding(models.Model): + _inherit = "onboarding.onboarding" + + # Sendcloud Onboarding + @api.model + def action_close_sendcloud_onboarding(self): + self.action_close_panel( + "delivery_sendcloud_oca.onboarding_onboarding_sendcloud" + ) diff --git a/delivery_sendcloud_oca/models/onboarding_onboarding_step.py b/delivery_sendcloud_oca/models/onboarding_onboarding_step.py new file mode 100644 index 0000000000..54a11e5aba --- /dev/null +++ b/delivery_sendcloud_oca/models/onboarding_onboarding_step.py @@ -0,0 +1,30 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import api, models + + +class OnboardingStep(models.Model): + _inherit = "onboarding.onboarding.step" + + @api.model + def action_open_sendcloud_onboarding_integration(self): + """Called by onboarding panel.""" + action_name = ( + "delivery_sendcloud_oca.action_sendcloud_onboarding_integration_wizard" + ) + return self.env.ref(action_name).read()[0] + + @api.model + def action_sendcloud_onboarding_sync(self): + """Called by onboarding panel.""" + action_name = "delivery_sendcloud_oca.action_sendcloud_onboarding_sync_wizard" + return self.env.ref(action_name).read()[0] + + @api.model + def action_open_sendcloud_onboarding_warehouse_address(self): + """Called by onboarding panel.""" + action_name = ( + "delivery_sendcloud_oca.action_sendcloud_onboarding_warehouse_wizard" + ) + return self.env.ref(action_name).read()[0] diff --git a/delivery_sendcloud_oca/models/res_company.py b/delivery_sendcloud_oca/models/res_company.py new file mode 100644 index 0000000000..a28b817a83 --- /dev/null +++ b/delivery_sendcloud_oca/models/res_company.py @@ -0,0 +1,44 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import api, fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + sendcloud_integration_ids = fields.One2many( + "sendcloud.integration", "company_id", string="Sendcloud Integrations" + ) + sendcloud_brand_ids = fields.One2many( + "sendcloud.brand", "company_id", string="Sendcloud Brands" + ) + sendcloud_return_ids = fields.One2many( + "sendcloud.return", "company_id", string="Sendcloud Returns" + ) + sendcloud_invoice_ids = fields.One2many( + "sendcloud.invoice", "company_id", string="Sendcloud Invoices" + ) + sendcloud_sender_address_ids = fields.One2many( + "sendcloud.sender.address", "company_id", string="Sendcloud Sender Addresses" + ) + + sendcloud_default_integration_id = fields.Many2one( + "sendcloud.integration", compute="_compute_sendcloud_default_integration_id" + ) + + is_sendcloud_test_mode = fields.Boolean() + sendcloud_auto_create_invoice = fields.Boolean() + + @api.depends( + "sendcloud_integration_ids.public_key", + "sendcloud_integration_ids.secret_key", + "sendcloud_integration_ids.sequence", + ) + def _compute_sendcloud_default_integration_id(self): + for company in self: + integrations = company.sendcloud_integration_ids + integrations = integrations.filtered( + lambda i: i.public_key and i.secret_key + ).sorted(key=lambda i: i.sequence) + company.sendcloud_default_integration_id = fields.first(integrations) diff --git a/delivery_sendcloud_oca/models/res_config_settings.py b/delivery_sendcloud_oca/models/res_config_settings.py new file mode 100644 index 0000000000..b1e9be7a28 --- /dev/null +++ b/delivery_sendcloud_oca/models/res_config_settings.py @@ -0,0 +1,17 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + is_sendcloud_test_mode = fields.Boolean( + related="company_id.is_sendcloud_test_mode", + readonly=False, + ) + sendcloud_auto_create_invoice = fields.Boolean( + related="company_id.sendcloud_auto_create_invoice", + readonly=False, + ) diff --git a/delivery_sendcloud_oca/models/res_partner.py b/delivery_sendcloud_oca/models/res_partner.py new file mode 100644 index 0000000000..e9e84a71b6 --- /dev/null +++ b/delivery_sendcloud_oca/models/res_partner.py @@ -0,0 +1,22 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import api, fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + sencloud_sender_address_id = fields.Many2one( + comodel_name="sendcloud.sender.address", string="Sendcloud Sender Address" + ) + sendcloud_is_in_eu = fields.Boolean( + compute="_compute_sendcloud_is_in_eu", + string="Is in EU", + ) + + @api.depends("country_id.code") + def _compute_sendcloud_is_in_eu(self): + europe_codes = self.env.ref("base.europe").country_ids.mapped("code") + for partner in self: + partner.sendcloud_is_in_eu = partner.country_id.code in europe_codes diff --git a/delivery_sendcloud_oca/models/sale_order.py b/delivery_sendcloud_oca/models/sale_order.py new file mode 100644 index 0000000000..02c9a183bb --- /dev/null +++ b/delivery_sendcloud_oca/models/sale_order.py @@ -0,0 +1,178 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +import json +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _name = "sale.order" + _inherit = ["sale.order", "sendcloud.mixin"] + + is_sendcloud_delivery_type = fields.Boolean( + compute="_compute_is_sendcloud_delivery_type", store=True + ) + sendcloud_service_point_required = fields.Boolean( + related="carrier_id.sendcloud_service_point_required" + ) + sendcloud_service_point_address = fields.Text(copy=False) + sendcloud_order_weight = fields.Float(compute="_compute_sendcloud_order_weight") + sendcloud_customs_shipment_type = fields.Selection( + selection="_get_sendcloud_customs_shipment_type", + default=lambda self: self._default_get_sendcloud_customs_shipment_type(), + ) + sendcloud_order_code = fields.Char(index=True) + sendcloud_sp_details = fields.Char(compute="_compute_sendcloud_sp_details") + + @api.depends( + "carrier_id.sendcloud_integration_id", + "carrier_id.sendcloud_carrier", + "partner_id.country_id.code", + "partner_id.zip", + "partner_shipping_id.country_id.code", + "partner_shipping_id.zip", + ) + def _compute_sendcloud_sp_details(self): + user_lang = self.env.user.lang.replace("_", "-").lower() + available_languages = [ + "en-us", + "de-de", + "en-gb", + "es-es", + "fr-fr", + "it-it", + "nl-nl", + ] + for order in self: + partner = order.partner_shipping_id or order.partner_id + vals = { + "api_key": order.sudo().carrier_id.sendcloud_integration_id.public_key, + "country": partner.country_id.code + and partner.country_id.code.lower() + or "", + "postalcode": partner.zip or "", + "language": user_lang if user_lang in available_languages else "en-us", + "carrier": order.carrier_id.sendcloud_carrier or "", + } + order.sendcloud_sp_details = json.dumps(vals) + + @api.depends("carrier_id.delivery_type") + def _compute_is_sendcloud_delivery_type(self): + for order in self: + is_sendcloud = order.carrier_id.delivery_type == "sendcloud" + order.is_sendcloud_delivery_type = is_sendcloud + + def _sendcloud_convert_price_in_euro(self, price): + self.ensure_one() + currency = self.currency_id + if currency.name == "EUR": + return price + euro_curr = self.env["res.currency"].search([("name", "=", "EUR")], limit=1) + if euro_curr: + price = euro_curr._convert( + price, currency, self.company_id, self.date_order + ) + return price + + @api.depends( + "order_line.product_id.weight", + "order_line.product_qty", + "order_line.display_type", + ) + def _compute_sendcloud_order_weight(self): + for order in self: + lines = order.order_line.filtered( + lambda ol: not ol.display_type and ol.product_id.weight + ) + weight = sum( + [(line.product_id.weight * line.product_qty) for line in lines] + ) + order.sendcloud_order_weight = self._sendcloud_convert_weight_to_kg(weight) + + def action_cancel(self): + to_delete_shipments = self.picking_ids.to_delete_sendcloud_pickings() + res = super().action_cancel() + self.env["stock.picking"].delete_sendcloud_pickings(to_delete_shipments) + return res + + def unlink(self): + to_delete_shipments = self.picking_ids.to_delete_sendcloud_pickings() + res = super().unlink() + self.env["stock.picking"].delete_sendcloud_pickings(to_delete_shipments) + return res + + def _sync_sale_order_to_sendcloud(self): + for order in self: + order.picking_ids._sync_picking_to_sendcloud() + + def button_delete_sendcloud_order(self): + self.ensure_one() + to_delete_shipments = self.picking_ids.to_delete_sendcloud_pickings() + self.env["stock.picking"].delete_sendcloud_pickings(to_delete_shipments) + + def button_to_sendcloud_sync(self): + self.ensure_one() + if ( + self.carrier_id.delivery_type != "sendcloud" + or not self.carrier_id.sendcloud_integration_id + ): + return + if self.state != "cancel": + self._sync_sale_order_to_sendcloud() + + def _action_confirm(self): + res = super()._action_confirm() + pickings = self.mapped("picking_ids") + to_sync = pickings.filtered(lambda p: p.carrier_id.sendcloud_integration_id) + to_sync._sync_picking_to_sendcloud() + return res + + def _create_delivery_line(self, carrier, price_unit): + line = super()._create_delivery_line(carrier, price_unit) + sendcloud_specific_product = self.env.context.get( + "sendcloud_country_specific_product" + ) + if sendcloud_specific_product: + line.product_id = sendcloud_specific_product + return line + + def _sendcloud_order_invoice(self): + """When shipping outside of EU, an invoice number must be entered in Sendcloud. + This method gets out invoices of the sale order. + In case not any invoice is present and setting "Sendcloud_auto_create_invoice" + is enabled, create a 100% down-payment invoice automatically. + """ + self.ensure_one() + out_invoices = self.invoice_ids.filtered( + lambda i: i.move_type == "out_invoice" and i.state == "posted" + ) + + # sendcloud_auto_create_invoice is set + if self.company_id.sendcloud_auto_create_invoice: + # If shipping to outside the EU and not any invoice was posted + if not out_invoices and not self.partner_id.sendcloud_is_in_eu: + downpayment_wizard = ( + self.env["sale.advance.payment.inv"] + .with_context( + **{ + "active_model": "sale.order", + "active_ids": [self.id], + "active_id": self.id, + } + ) + .create( + { + "advance_payment_method": "percentage", + "amount": 100, + } + ) + ) + downpayment_wizard.create_invoices() + self.invoice_ids.action_post() + out_invoices = self.invoice_ids + + return out_invoices diff --git a/delivery_sendcloud_oca/models/sendcloud_action.py b/delivery_sendcloud_oca/models/sendcloud_action.py new file mode 100644 index 0000000000..f307a6f4ba --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_action.py @@ -0,0 +1,197 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +import json +import logging + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SendcloudAction(models.Model): + _name = "sendcloud.action" + _description = "Sendcloud Action" + _rec_name = "action" + _order = "id desc" + + company_id = fields.Many2one("res.company", required=True) + sendcloud_integration_id = fields.Many2one( + "sendcloud.integration" + ) # Could be empty, eg.: in case of public get + + exitcode = fields.Char() + action = fields.Char() + timestamp = fields.Char() + message_type = fields.Selection( + [("sent", "Sent"), ("received", "Received")], required=True + ) + model = fields.Char() + resid = fields.Integer() + record_id = fields.Reference( + selection="_reference_models", compute="_compute_resource_record", readonly=True + ) + + sent_payload = fields.Text() + message = fields.Text(string="Received Message") + response_time = fields.Float(string="Response Time (sec)") + + is_processed = fields.Boolean() + date_last_success = fields.Datetime() + + error_message = fields.Char() + error_on_parsing = fields.Boolean() + + @api.model + def _reference_models(self): + models = self.env["ir.model"].sudo().search([]) + return [(model.model, model.name) for model in models] + + @api.depends("model", "resid") + def _compute_resource_record(self): + for action in self: + if action.model and action.resid: + action.record_id = f"{action.model},{action.resid}" + else: + action.record_id = False + + # flake8: noqa: C901 + def parse_result(self): + self.ensure_one() + + _logger.info("Sendcloud parsing message:%s", self.message) + + try: + message = json.loads(self.message) + except Exception as e: + self.error_on_parsing = True + self.error_message = str(e) + return False + integration = self.sendcloud_integration_id + if message.get("action") == "integration_updated": + integration_data = message.get("integration") + if integration_data: + vals = self.env[ + "sendcloud.integration" + ]._prepare_sendcloud_integration_from_response(integration_data) + if integration: + integration.with_context(skip_update_in_sendcloud=True).write(vals) + else: + vals["company_id"] = self.company_id.id + integration = self.env["sendcloud.integration"].create(vals) + self._update_action_log(integration) + elif message.get("action") == "integration_connected": + integration_data = message.get("integration") + if integration_data: + code = integration_data["id"] + existing_integration = ( + self.env["sendcloud.integration"] + .with_context(active_test=False) + .search( + [ + ("sendcloud_code", "=", code), + ("company_id", "=", self.company_id.id), + ], + limit=1, + ) + ) + if existing_integration: + integration = existing_integration + else: + vals = self.env[ + "sendcloud.integration" + ]._prepare_sendcloud_integration_from_response(integration_data) + vals["company_id"] = self.company_id.id + integration = self.env["sendcloud.integration"].create(vals) + self._update_action_log(integration) + elif message.get("action") == "integration_deleted": + integration.write({"active": False}) + self._update_action_log(integration) + elif message.get("action") == "integration_credentials": + _logger.info("Sendcloud integration_credentials") + if integration: + _logger.info( + "Sendcloud integration_credentials integration_id:%s", + integration.id, + ) + integration.write( + { + "public_key": message["public_key"], + "secret_key": message["secret_key"], + "sendcloud_code": message["integration_id"], + } + ) + integration.with_context( + skip_raise_error_401=True, skip_sendcloud_check_response=True + ).action_sendcloud_update_integrations() + self._update_action_log(integration) + self.env["onboarding.onboarding.step"].action_validate_step( + "delivery_sendcloud_oca.onboarding_integration_step" + ) + elif message.get("action") == "parcel_status_changed": + parcel_data = message.get("parcel") + picking = self.env["stock.picking"] + if parcel_data.get("shipment_uuid"): + picking = self.env["stock.picking"].search( + [ + ("sendcloud_shipment_uuid", "=", parcel_data["shipment_uuid"]), + ], + limit=1, + ) + if not picking: + sendcloud_order_code = parcel_data.get("external_order_id") + sendcloud_shipment_code = parcel_data.get("external_shipment_id") + if sendcloud_shipment_code.isdigit() and sendcloud_order_code.isdigit(): + picking = self.env["stock.picking"].search( + [ + ("id", "=", int(sendcloud_shipment_code)), + ("sale_id", "=", int(sendcloud_order_code)), + ] + ) + elif sendcloud_shipment_code and sendcloud_order_code: + picking = self.env["stock.picking"].search( + [ + ("sendcloud_shipment_code", "=", sendcloud_shipment_code), + ("sale_id.sendcloud_order_code", "=", sendcloud_order_code), + ], + limit=1, + ) + if not picking: + sendcloud_code = parcel_data["id"] + picking = ( + self.env["sendcloud.parcel"] + .search([("sendcloud_code", "=", sendcloud_code)]) + .mapped("picking_id") + ) + if picking and picking.sendcloud_parcel_ids: + parcels = picking._sendcloud_create_update_received_parcels( + [parcel_data], self.company_id.id + ) + parcel = parcels.filtered( + lambda p: p.sendcloud_code == parcel_data["id"] + ) + self._update_action_log(parcel) + + def _update_action_log(self, record): + self.ensure_one() + self.write( + { + "model": record._name, + "resid": record.id, + "error_on_parsing": False, + "error_message": "", + "date_last_success": fields.Datetime.now(), + } + ) + + def reparse_message(self): + self.ensure_one() + self.parse_result() + self.is_processed = True + + @api.model + def sendcloud_delete_old_actions(self, days=7): + date = fields.Datetime.to_string(fields.Date.today() - relativedelta(days=days)) + self.search([("create_date", "<", date)]).unlink() diff --git a/delivery_sendcloud_oca/models/sendcloud_brand.py b/delivery_sendcloud_oca/models/sendcloud_brand.py new file mode 100644 index 0000000000..76a9e29959 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_brand.py @@ -0,0 +1,103 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import api, fields, models + + +class SendcloudBrand(models.Model): + _name = "sendcloud.brand" + _inherit = ["sendcloud.mixin"] + _description = "Sendcloud Brand" + + name = fields.Char(required=True) + sendcloud_code = fields.Integer(required=True) + color = fields.Char() + secondary_color = fields.Char() + website = fields.Char() + screen_thumb = fields.Char() + print_thumb = fields.Char() + notify_reply_to_email = fields.Integer() + domain = fields.Char() + return_portal_url = fields.Char(compute="_compute_return_portal_url") + notify_bcc_email = fields.Integer() + hide_powered_by = fields.Boolean() + company_id = fields.Many2one( + "res.company", required=True, default=lambda self: self.env.company + ) + active = fields.Boolean(default=True) + + def _compute_return_portal_url(self): + for brand in self: + url = f"https://{brand.domain}.shipping-portal.com/rp/" + brand.return_portal_url = url + + def action_create_return_parcel(self): + self.ensure_one() + action_name = ( + "delivery_sendcloud_oca.action_sendcloud_create_return_parcel_wizard" + ) + [action] = self.env.ref(action_name).read() + action["context"] = "{'default_brand_id': %s}" % (self.id) + return action + + @api.model + def _prepare_sendcloud_brands_from_response(self, records_data): + return { + "sendcloud_code": records_data.get("id"), + "name": records_data.get("name"), + "color": records_data.get("color"), + "secondary_color": records_data.get("secondary_color"), + "website": records_data.get("website"), + "screen_thumb": records_data.get("screen_thumb"), + "print_thumb": records_data.get("print_thumb"), + "notify_reply_to_email": records_data.get("notify_reply_to_email"), + "domain": records_data.get("domain"), + "notify_bcc_email": records_data.get("notify_bcc_email"), + "hide_powered_by": records_data.get("hide_powered_by"), + } + + @api.model + def sendcloud_update_brands(self, brand_data, company): + # All records + all_records = company.sendcloud_brand_ids + + # Existing records + existing_records = all_records.filtered( + lambda c: c.sendcloud_code in [record.get("id") for record in brand_data] + ) + + # Existing records map (internal code -> existing record) + existing_records_map = {} + for existing in existing_records: + if existing.sendcloud_code not in existing_records_map: + existing_records_map[existing.sendcloud_code] = self.env[ + "sendcloud.brand" + ] + existing_records_map[existing.sendcloud_code] |= existing + + # Disabled records + disabled_records = all_records - existing_records + disabled_records.write({"active": False}) + + # Created records + vals_list = [] + for record in brand_data: + vals = self._prepare_sendcloud_brands_from_response(record) + if record.get("id") in existing_records_map: + existing_records_map[record.get("id")].write(vals) + else: + vals["company_id"] = company.id + vals_list += [vals] + new_created_records = self.create(vals_list) + + # Updated records + updated_records = existing_records + new_created_records + updated_records.write({"active": True}) + + @api.model + def sendcloud_sync_brands(self): + for company in self.env["res.company"].search([]): + integration = company.sendcloud_default_integration_id + if integration: + brands_data = integration.get_brands() + self.sendcloud_update_brands(brands_data, company) diff --git a/delivery_sendcloud_oca/models/sendcloud_carrier.py b/delivery_sendcloud_oca/models/sendcloud_carrier.py new file mode 100644 index 0000000000..7b0f770571 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_carrier.py @@ -0,0 +1,23 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import api, fields, models + + +class SendcloudCarrier(models.Model): + _name = "sendcloud.carrier" + _description = "Sendcloud Carrier" + + name = fields.Char(required=True) + sendcloud_code = fields.Char(required=True) + + @api.model + def _create_update_carriers(self, retrieved_carriers): + all_carriers = self.search([]) + existing_carriers = all_carriers.mapped("sendcloud_code") + to_add_carriers = set(retrieved_carriers) - set(existing_carriers) + new_carrier_vals_list = [] + for new_carrier in list(to_add_carriers): + vals = {"sendcloud_code": new_carrier, "name": new_carrier.upper()} + new_carrier_vals_list.append(vals) + self.create(new_carrier_vals_list) diff --git a/delivery_sendcloud_oca/models/sendcloud_integration.py b/delivery_sendcloud_oca/models/sendcloud_integration.py new file mode 100644 index 0000000000..7bb6799c30 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_integration.py @@ -0,0 +1,207 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.safe_eval import safe_eval + +# Whether you want to pull all the existing integrations from Sendcloud. +SENDCLOUD_GET_ALL_EXISTING_INTEGRATIONS = False + + +class SendcloudIntegration(models.Model): + _name = "sendcloud.integration" + _description = "Sendcloud Integrations" + _inherit = "sendcloud.request" + _rec_name = "shop_name" + _order = "sequence, id" + _log_access = False + + shop_name = fields.Char(required=True) + sequence = fields.Integer(help="Determine the display order", default=10) + public_key = fields.Char(readonly=False) + secret_key = fields.Char(readonly=False) + sendcloud_code = fields.Integer(readonly=True) + shop_url = fields.Char() + service_point_enabled = fields.Boolean() + service_point_carriers = fields.Text(default="[]") + service_point_carrier_ids = fields.Many2many( + "sendcloud.carrier", + string="Carriers", + compute="_compute_service_point_carrier_ids", + inverse="_inverse_service_point_carrier_ids", + ) + webhook_active = fields.Boolean() + webhook_url = fields.Char() + database = fields.Char() # TODO alert in case db is copied + company_id = fields.Many2one( + "res.company", required=True, default=lambda self: self.env.company + ) + active = fields.Boolean(default=True) + + @api.onchange("company_id") + def _onchange_company_id(self): + base_url = self.env["sendcloud.request"]._param_web_base_url() + company_id = self.company_id.id + path = self._default_integration_webhook(company_id) + self.webhook_url = base_url + path + + @api.depends("service_point_carriers") + def _compute_service_point_carrier_ids(self): + for integration in self: + carriers_list = safe_eval(integration.service_point_carriers) + domain = [("sendcloud_code", "in", carriers_list)] + sendcloud_carriers = self.env["sendcloud.carrier"].search(domain) + integration.service_point_carrier_ids = sendcloud_carriers + + def _inverse_service_point_carrier_ids(self): + for integration in self: + carriers = integration.service_point_carrier_ids + carriers_list = carriers.mapped("sendcloud_code") + integration.service_point_carriers = str(carriers_list) + + @api.model + def _prepare_sendcloud_integration_from_response(self, integration): + return { + "shop_name": integration.get("shop_name"), + "sendcloud_code": integration.get("id"), + "shop_url": integration.get("shop_url"), + "service_point_enabled": integration.get("service_point_enabled"), + "service_point_carriers": integration.get("service_point_carriers"), + "webhook_active": integration.get("webhook_active"), + "webhook_url": integration.get("webhook_url"), + } + + @api.model + def sendcloud_create_update_integrations(self, req_integrations, company): + # Error in fetching integrations if its not a list + if not isinstance(req_integrations, list): + return + # All integrations + domain = [("company_id", "=", company.id)] + all_integrations = ( + self.env["sendcloud.integration"] + .with_context(active_test=False) + .search(domain) + ) + # Existing records + integrations_list = [integration.get("id") for integration in req_integrations] + existing_integrations = all_integrations.filtered( + lambda c: c.sendcloud_code and c.sendcloud_code in integrations_list + ) + + # Existing integrations map (internal code -> existing integration) + existing_integrations_map = {} + for existing in existing_integrations: + if ( + existing.sendcloud_code + and existing.sendcloud_code not in existing_integrations_map + ): + existing_integrations_map[existing.sendcloud_code] = self.env[ + "sendcloud.integration" + ] + existing_integrations_map[existing.sendcloud_code] |= existing + + # Empty integrations + empty_integrations = all_integrations.filtered(lambda c: not c.sendcloud_code) + + # Disabled integrations + disabled_integrations = ( + all_integrations - existing_integrations - empty_integrations + ) + disabled_integrations.write({"active": False}) + + # Created integrations + vals_list = [] + for integration in req_integrations: + vals = self._prepare_sendcloud_integration_from_response(integration) + vals["company_id"] = company.id + if integration.get("id") in existing_integrations_map: + existing_integrations_map[integration.get("id")].write(vals) + elif empty_integrations: + empty_integrations.write(vals) + + new_created_integrations = self.env["sendcloud.integration"] + if vals_list and SENDCLOUD_GET_ALL_EXISTING_INTEGRATIONS: + new_created_integrations = self.env["sendcloud.integration"].create( + vals_list + ) + + # Updated integrations + updated_integrations = existing_integrations + new_created_integrations + updated_integrations.write({"active": True}) + + # Carriers + self.sendcloud_update_carriers(updated_integrations) + + @api.model + def sendcloud_update_carriers(self, updated_integrations): + retrieved_carriers = [] + for integration in updated_integrations: + retrieved_carriers += safe_eval(integration.service_point_carriers) + self.env["sendcloud.carrier"]._create_update_carriers(retrieved_carriers) + + def action_sendcloud_update_integrations(self): + self.ensure_one() + integration = self.company_id.sendcloud_default_integration_id + req_integrations = integration.get_integrations() + self.sendcloud_create_update_integrations(req_integrations, self.company_id) + + def _update_in_sendcloud(self, vals): + self.ensure_one() + code = self.sendcloud_code + to_update_vals = {} + for name in self._sendcloud_updatable_fields(): + if name in vals: + to_update_vals.update({name: vals.get(name)}) + + integration = self.company_id.sendcloud_default_integration_id + response = integration.update_integration(code, to_update_vals) + if isinstance(response, dict): # TODO + for name in self._sendcloud_updatable_fields(): + if name in response: + vals[name] = response[name] + return vals + + @api.model + def _sendcloud_updatable_fields(self): + return [ + "webhook_active", + "webhook_url", + "shop_url", + "shop_name", + "service_point_enabled", + "service_point_carriers", + ] + + def write(self, vals): + if self.env.context.get("skip_update_in_sendcloud"): + return super().write(vals) + + if any(item not in self._sendcloud_updatable_fields() for item in vals): + return super().write(vals) + + for record in self: + formatted_vals = record._prepare_sendcloud_integration_from_record(vals) + updated_vals = record._update_in_sendcloud(formatted_vals) + super(SendcloudIntegration, record).write(updated_vals) + return self + + def _prepare_sendcloud_integration_from_record(self, vals): + self.ensure_one() + formatted_vals = vals + if "service_point_enabled" in vals and "service_point_carriers" not in vals: + vals["service_point_carriers"] = self.service_point_carriers + if "service_point_carriers" in vals and "service_point_enabled" not in vals: + vals["service_point_enabled"] = self.service_point_enabled + if vals.get("service_point_enabled") and not safe_eval( + vals.get("service_point_carriers") + ): + raise UserError(_("Sendcloud: select at least one service point carrier")) + if "service_point_carriers" in vals and isinstance( + vals["service_point_carriers"], str + ): + formatted_vals["service_point_carriers"] = safe_eval( + vals["service_point_carriers"] + ) + return formatted_vals diff --git a/delivery_sendcloud_oca/models/sendcloud_invoice.py b/delivery_sendcloud_oca/models/sendcloud_invoice.py new file mode 100644 index 0000000000..bd23d57d78 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_invoice.py @@ -0,0 +1,117 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from datetime import datetime + +from odoo import api, fields, models + + +class SendcloudInvoice(models.Model): + _name = "sendcloud.invoice" + _description = "Sendcloud Invoice" + _rec_name = "sendcloud_code" + + @api.model + def _selection_invoice_type(self): + return [ + ("periodic", "Periodical"), + ("forced", "Created manually"), + ("initial_payment", "Initial payment"), + ("other", "Other"), + ] + + sendcloud_code = fields.Integer(required=True) + invoice_date = fields.Datetime() + is_paid = fields.Boolean() + item_ids = fields.One2many("sendcloud.invoice.item", "sendcloud_invoice_id") + price_excl = fields.Float() + price_incl = fields.Float() + ref = fields.Char() + invoice_type = fields.Selection( + selection=lambda self: self._selection_invoice_type(), readonly=True + ) + company_id = fields.Many2one( + "res.company", required=True, default=lambda self: self.env.company + ) + active = fields.Boolean(default=True) + + @api.model + def _prepare_sendcloud_invoice_from_response(self, records_data): + invoice_date = records_data.get("date") + invoice_date = datetime.strptime(invoice_date, "%d-%m-%Y %H:%M:%S") + + vals = { + "sendcloud_code": records_data.get("id"), + "invoice_date": invoice_date, + "is_paid": records_data.get("isPayed"), + "price_excl": records_data.get("price_excl"), + "price_incl": records_data.get("price_incl"), + "ref": records_data.get("ref"), + "invoice_type": records_data.get("type"), + } + if isinstance(records_data.get("items"), list): + item_ids = [(5, False, False)] + item_ids += [ + (0, False, {"sendcloud_code": values["id"], "name": values["name"]}) + for values in records_data.get("items") + ] + vals.update({"item_ids": item_ids}) + return vals + + @api.model + def sendcloud_update_invoices(self, invoice_data, company): + # All records + all_records = company.sendcloud_invoice_ids + + # Existing records + existing_records = all_records.filtered( + lambda c: c.sendcloud_code in [record.get("id") for record in invoice_data] + ) + + # Existing records map (internal code -> existing record) + existing_records_map = {} + for existing in existing_records: + if existing.sendcloud_code not in existing_records_map: + existing_records_map[existing.sendcloud_code] = self.env[ + "sendcloud.invoice" + ] + existing_records_map[existing.sendcloud_code] |= existing + + # Disabled records + disabled_records = all_records - existing_records + disabled_records.write({"active": False}) + + # Created records + vals_list = [] + for record in invoice_data: + vals = self._prepare_sendcloud_invoice_from_response(record) + if record.get("id") in existing_records_map: + existing_records_map[record.get("id")].write(vals) + else: + vals["company_id"] = company.id + vals_list += [vals] + new_created_records = self.create(vals_list) + + # Updated records + updated_records = existing_records + new_created_records + updated_records.write({"active": True}) + + @api.model + def sendcloud_sync_invoices(self): + for company in self.env["res.company"].search([]): + integration = company.sendcloud_default_integration_id + if integration: + invoice_data = integration.get_user_invoices() + self.sendcloud_update_invoices(invoice_data, company) + + def sendcloud_update_invoice_details(self, invoice_data): + self.ensure_one() + self.write(self._prepare_sendcloud_invoice_from_response(invoice_data)) + + def button_get_invoice_details(self): + self.ensure_one() + integration = self.company_id.sendcloud_default_integration_id + if integration: + self.item_ids.unlink() + invoice_data = integration.get_user_invoice(self.sendcloud_code) + self.sendcloud_update_invoice_details(invoice_data) diff --git a/delivery_sendcloud_oca/models/sendcloud_invoice_item.py b/delivery_sendcloud_oca/models/sendcloud_invoice_item.py new file mode 100644 index 0000000000..b78c16bbe6 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_invoice_item.py @@ -0,0 +1,13 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import fields, models + + +class SendcloudInvoice(models.Model): + _name = "sendcloud.invoice.item" + _description = "Sendcloud Invoice Items" + + name = fields.Char() + sendcloud_code = fields.Integer(required=True) + sendcloud_invoice_id = fields.Many2one("sendcloud.invoice", ondelete="cascade") diff --git a/delivery_sendcloud_oca/models/sendcloud_parcel.py b/delivery_sendcloud_oca/models/sendcloud_parcel.py new file mode 100644 index 0000000000..d06a1adda2 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_parcel.py @@ -0,0 +1,413 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +import base64 + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools.safe_eval import safe_eval + + +class SendcloudParcel(models.Model): + _name = "sendcloud.parcel" + _inherit = ["sendcloud.mixin", "mail.thread", "mail.activity.mixin"] + _description = "Sendcloud Parcel" + + @api.model + def _selection_parcel_statuses(self): + statuses = self.env["sendcloud.parcel.status"].search([]) + return [(status.sendcloud_code, status.message) for status in statuses] + + partner_name = fields.Char() + address = fields.Char() + address_2 = fields.Char(help="An apartment or floor number.") + house_number = fields.Char() + street = fields.Char() + city = fields.Char() + postal_code = fields.Char() + company_name = fields.Char() + country_iso_2 = fields.Char() + email = fields.Char() + telephone = fields.Char() + name = fields.Char(required=True) + sendcloud_code = fields.Integer(required=True) + label = fields.Binary(related="attachment_id.datas", string="File Content") + tracking_url = fields.Char() + tracking_number = fields.Char() + label_printer_url = fields.Char() + return_portal_url = fields.Char() + external_reference = fields.Char( + help="A field to use as a reference for your order." + ) + insured_value = fields.Float(help="Insured Value is in Euro currency.") + total_insured_value = fields.Float(help="Total Insured Value is in Euro currency.") + weight = fields.Float(help="Weight unit of measure is KG.") + is_return = fields.Boolean(readonly=True) + collo_count = fields.Integer( + help="A number indicating the number of collos within a shipment. " + "For non-multi-collo shipments, this value will always be 1." + ) + collo_nr = fields.Integer( + help="A number indicating the collo number within a shipment. For a " + "non-multi-collo shipment, this value will always be 0. In a multi-collo" + " shipment with 3 collos, this number will range from 0 to 2." + ) + colli_uuid = fields.Char() + colli_tracking_number = fields.Char( + help="Multi-collo only. This is a tracking number assigned by the carrier to" + " identify the entire multi-collo shipment." + ) + external_shipment_id = fields.Char() + external_order_id = fields.Char() + shipping_method = fields.Integer() + shipment_uuid = fields.Char() + to_post_number = fields.Text() + parcel_item_ids = fields.One2many("sendcloud.parcel.item", "parcel_id") + documents = fields.Text( + string="Documents Data", + help="An array of documents. A parcel can contain multiple documents, for " + "instance labels and a customs form. This field returns an array of all" + " the available documents for this parcel.", + ) + note = fields.Text() + type = fields.Char( + help="Returns either ‘parcel’ or ‘letter’ by which you can determine the type" + " of your shipment." + ) + to_state = fields.Char() + order_number = fields.Char() + customs_invoice_nr = fields.Char() + shipment = fields.Char(string="Cached Shipment") + shipment_id = fields.Many2one("delivery.carrier", compute="_compute_shipment_id") + reference = fields.Char() + to_service_point = fields.Char( + help="The id of service point to which the shipment is going to be shipped." + ) + sendcloud_customs_shipment_type = fields.Selection( + selection="_get_sendcloud_customs_shipment_type" + ) + picking_id = fields.Many2one("stock.picking") + package_id = fields.Many2one("stock.quant.package") + sendcloud_status = fields.Selection( + selection=lambda self: self._selection_parcel_statuses(), readonly=True + ) + carrier = fields.Char() + company_id = fields.Many2one( + "res.company", + required=True, + compute="_compute_company_id", + store=True, + readonly=False, + ) + brand_id = fields.Many2one( + "sendcloud.brand", compute="_compute_brand_id", store=True, readonly=False + ) + attachment_id = fields.Many2one( + comodel_name="ir.attachment", + ondelete="cascade", + ) + document_ids = fields.One2many( + "sendcloud.parcel.document", + "parcel_id", + string="Documents", + ) + label_print_status = fields.Selection( + [ + ("generated", "Generated"), + ("printed", "Printed"), + ], + default="generated", + ) + + def action_parcel_documents(self): + self.mapped("document_ids").unlink() + skip_get_parcel_document = self.env.context.get("skip_get_parcel_document") + for parcel in self: + doc_vals = [] + for document_data in safe_eval(parcel.documents or "[]"): + doc_vals.append( + { + "name": document_data["type"], + "size": document_data["size"], + "link": document_data.get("link"), + "parcel_id": parcel.id, + } + ) + parcel.document_ids = self.env["sendcloud.parcel.document"].create(doc_vals) + if not skip_get_parcel_document: + parcel.document_ids._generate_parcel_document() + + @api.depends("shipment") + def _compute_shipment_id(self): + for parcel in self: + shipment_data = safe_eval(parcel.shipment or "{}") + shipment_code = shipment_data.get("id") + domain = parcel._get_shipment_domain_by_code(shipment_code) + parcel.shipment_id = self.env["delivery.carrier"].search(domain, limit=1) + + def _get_shipment_domain_by_code(self, shipment_code): + self.ensure_one() + return [ + ("company_id", "=", self.company_id.id), + ("sendcloud_code", "=", shipment_code), + ] + + @api.depends("picking_id.company_id") + def _compute_company_id(self): + for parcel in self: + parcel.company_id = parcel.picking_id.company_id or parcel.company_id + + @api.depends("company_id") + def _compute_brand_id(self): + for parcel in self: + brands = parcel.company_id.sendcloud_brand_ids + # TODO only brands with domain? + parcel.brand_id = fields.first(brands) + + @api.model + def _prepare_sendcloud_parcel_from_response(self, parcel): + res = { + "name": parcel.get("id"), + "sendcloud_code": parcel.get("id"), + "carrier": parcel.get("carrier", {}).get("code") or parcel.get("carrier"), + } + if parcel.get("status", {}).get("id"): + res["sendcloud_status"] = str(parcel.get("status", {}).get("id")) + if parcel.get("tracking_number"): + res["tracking_number"] = parcel.get("tracking_number") + if parcel.get("tracking_url"): + res["tracking_url"] = parcel.get("tracking_url") + if parcel.get("label", {}).get("label_printer"): + res["label_printer_url"] = parcel.get("label", {}).get("label_printer") + if parcel.get("external_reference"): + res["external_reference"] = parcel.get("external_reference", "") + if parcel.get("collo_count"): + res["collo_count"] = parcel.get("collo_count") + if parcel.get("collo_nr"): + res["collo_nr"] = parcel.get("collo_nr") + if parcel.get("colli_uuid"): + res["colli_uuid"] = parcel.get("colli_uuid") + if parcel.get("colli_tracking_number"): + res["colli_tracking_number"] = parcel.get("colli_tracking_number") + customs_shipment_type = parcel.get("customs_shipment_type") + res["sendcloud_customs_shipment_type"] = ( + str(customs_shipment_type) if customs_shipment_type else False + ) + res["to_service_point"] = parcel.get("to_service_point") + res["reference"] = parcel.get("reference") + res["shipment"] = parcel.get("shipment") + res["customs_invoice_nr"] = parcel.get("customs_invoice_nr") + res["order_number"] = parcel.get("order_number") + res["to_state"] = parcel.get("to_state") + res["type"] = parcel.get("type") + res["note"] = parcel.get("note") + res["documents"] = parcel.get("documents") + res["to_post_number"] = parcel.get("to_post_number") + res["shipment_uuid"] = parcel.get("shipment_uuid") + res["shipping_method"] = parcel.get("shipping_method") + res["external_order_id"] = parcel.get("external_order_id") + res["external_shipment_id"] = parcel.get("external_shipment_id") + res["is_return"] = parcel.get("is_return") + res["weight"] = parcel.get("weight") + res["total_insured_value"] = parcel.get("total_insured_value") + res["insured_value"] = parcel.get("insured_value") + res["return_portal_url"] = parcel.get("return_portal_url") + res["partner_name"] = parcel.get("name") + res["address"] = parcel.get("address") + if parcel.get("address_2"): + res["address_2"] = parcel.get("address_2") + if parcel.get("address_divided"): + res["house_number"] = parcel["address_divided"].get( + "house_number" + ) or parcel.get("house_number") + res["street"] = parcel["address_divided"].get("street") or parcel.get( + "street" + ) + else: + res["house_number"] = parcel.get("house_number") + res["street"] = parcel.get("street") + res["city"] = parcel.get("city") + res["postal_code"] = parcel.get("postal_code") + res["company_name"] = parcel.get("company_name") + res["country_iso_2"] = parcel.get("country", {}).get("iso_2") + res["email"] = parcel.get("email") + res["telephone"] = parcel.get("telephone") + if isinstance(parcel.get("parcel_items"), list): + res["parcel_item_ids"] = [(5, False, False)] + [ + (0, False, self._prepare_sendcloud_parcel_item_from_response(values)) + for values in parcel.get("parcel_items") + ] + return res + + def action_get_parcel_label(self): + self.ensure_one() + if not self.label_printer_url: + raise UserError(_("Label not available: no label printer url provided.")) + self._generate_parcel_labels() + + def _generate_parcel_labels(self): + for parcel in self.filtered(lambda p: p.label_printer_url): + integration = parcel.company_id.sendcloud_default_integration_id + label = integration.get_parcel_label(parcel.label_printer_url) + filename = parcel._generate_parcel_label_filename() + attachment_id = self.env["ir.attachment"].create( + { + "name": filename, + "res_id": parcel.id, + "res_model": parcel._name, + "datas": base64.b64encode(label), + "description": parcel.name, + } + ) + parcel.attachment_id = attachment_id + + def _generate_parcel_label_filename(self): + self.ensure_one() + if not self.name.lower().endswith(".pdf"): + return self.name + ".pdf" + return self.name + + def action_get_return_portal_url(self): + for parcel in self: + code = parcel.sendcloud_code + integration = parcel.company_id.sendcloud_default_integration_id + response = integration.get_return_portal_url(code) + if response.get("url") is None: + parcel.return_portal_url = "None" + else: + parcel.return_portal_url = response.get("url") + + @api.model + def sendcloud_create_update_parcels(self, parcels_data, company_id): + # All records + all_records = self.search([("company_id", "=", company_id)]) + + # Existing records + existing_records = all_records.filtered( + lambda c: c.sendcloud_code in [record["id"] for record in parcels_data] + ) + + # Existing records map (internal code -> existing record) + existing_records_map = {} + for existing in existing_records: + if existing.sendcloud_code not in existing_records_map: + existing_records_map[existing.sendcloud_code] = self.env[ + "sendcloud.parcel" + ] + existing_records_map[existing.sendcloud_code] |= existing + + # Created records + vals_list = [] + for record in parcels_data: + vals = self._prepare_sendcloud_parcel_from_response(record) + vals["company_id"] = company_id + if record["id"] in existing_records_map: + existing_records_map[record["id"]].write(vals) + else: + vals_list += [vals] + new_records = self.create(vals_list) + new_records.action_get_return_portal_url() + + return existing_records + new_records + + @api.model + def sendcloud_sync_parcels(self): + for company in self.env["res.company"].search([]): + integration = company.sendcloud_default_integration_id + if integration: + parcels = integration.get_parcels() + self.sendcloud_create_update_parcels(parcels, company.id) + + def button_sync_parcel(self): + self.ensure_one() + integration = self.company_id.sendcloud_default_integration_id + if integration: + parcel = integration.get_parcel(self.sendcloud_code) + parcels_vals = self.env[ + "sendcloud.parcel" + ]._prepare_sendcloud_parcel_from_response(parcel) + self.write(parcels_vals) + + def unlink(self): + if not self.env.context.get("skip_cancel_parcel"): + for parcel in self: + integration = parcel.company_id.sendcloud_default_integration_id + if integration: + res = integration.cancel_parcel(parcel.sendcloud_code) + if res.get("error"): + if res["error"]["code"] == 404: + continue # ignore "Not Found" error + raise UserError( + _("Sendcloud: %s") % res["error"].get("message") + ) + return super().unlink() + + def action_create_return_parcel(self): + self.ensure_one() + [action] = self.env.ref( + "delivery_sendcloud_oca.action_sendcloud_create_return_parcel_wizard" + ).read() + action["context"] = ( + f'{{"default_brand_id": "{self.brand_id.id}", ' + f'"default_parcel_id": "{self.id}"}}' + ) + return action + + @api.model + def _prepare_sendcloud_parcel_item_from_response(self, data): + return { + "description": data.get("description"), + "quantity": data.get("quantity"), + "weight": data.get("weight"), + "value": data.get("value"), + "hs_code": data.get("hs_code"), + "origin_country": data.get("origin_country"), + "product_id": data.get("product_id"), + "properties": data.get("properties"), + "sku": data.get("sku"), + "return_reason": data.get("return_reason"), + "return_message": data.get("return_message"), + } + + +class SendcloudParcelDocument(models.Model): + _name = "sendcloud.parcel.document" + _description = "Sendcloud Parcel Document" + + name = fields.Char(required=True) + size = fields.Char() + link = fields.Char() + parcel_id = fields.Many2one("sendcloud.parcel") + attachment_id = fields.Many2one( + comodel_name="ir.attachment", + ondelete="cascade", + ) + attachment = fields.Binary(related="attachment_id.datas", string="File Content") + + def action_get_parcel_document(self): + self.ensure_one() + if not self.link: + raise UserError(_("Document not available: no link provided.")) + self._generate_parcel_document() + + def _generate_parcel_document(self): + for document in self.filtered(lambda p: p.link): + integration = document.parcel_id.company_id.sendcloud_default_integration_id + content = integration.get_parcel_document(document.link) + filename = document.generate_parcel_document_filename() + attachment_id = self.env["ir.attachment"].create( + { + "name": filename, + "res_id": document.id, + "res_model": document._name, + "datas": base64.b64encode(content), + "description": document.name, + } + ) + document.attachment_id = attachment_id + + def generate_parcel_document_filename(self): + self.ensure_one() + if not self.name.lower().endswith(".pdf"): + return self.name + ".pdf" + return self.name diff --git a/delivery_sendcloud_oca/models/sendcloud_parcel_item.py b/delivery_sendcloud_oca/models/sendcloud_parcel_item.py new file mode 100644 index 0000000000..0d0b056f76 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_parcel_item.py @@ -0,0 +1,23 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import fields, models + + +class SendcloudParcelItem(models.Model): + _name = "sendcloud.parcel.item" + _description = "Sendcloud Parcel Items" + _rec_name = "description" + + description = fields.Char(required=True) + quantity = fields.Integer() + weight = fields.Float() + value = fields.Float() + hs_code = fields.Char() + origin_country = fields.Char() + product_id = fields.Char() + properties = fields.Char() + sku = fields.Char() + return_reason = fields.Char() + return_message = fields.Char() + parcel_id = fields.Many2one("sendcloud.parcel", ondelete="cascade") diff --git a/delivery_sendcloud_oca/models/sendcloud_parcel_status.py b/delivery_sendcloud_oca/models/sendcloud_parcel_status.py new file mode 100644 index 0000000000..e9de4a0958 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_parcel_status.py @@ -0,0 +1,58 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import api, fields, models + + +class SendcloudParcelStatus(models.Model): + _name = "sendcloud.parcel.status" + _description = "Sendcloud Parcel Statuses" + _rec_name = "message" + + message = fields.Char(required=True) + sendcloud_code = fields.Char(required=True) + + @api.model + def _prepare_sendcloud_parcel_statuses_from_response(self, records_data): + return { + "sendcloud_code": str(records_data.get("id")), + "message": records_data.get("message"), + } + + @api.model + def sendcloud_update_parcel_statuses(self, integration): + records_data = integration.get_parcels_statuses() + + # All records + all_records = self.search([]) + + # Existing records + existing_records = all_records.filtered( + lambda c: c.sendcloud_code in [str(record["id"]) for record in records_data] + ) + + # Existing records map (internal code -> existing record) + existing_records_map = {} + for existing in existing_records: + if existing.sendcloud_code not in existing_records_map: + existing_records_map[existing.sendcloud_code] = self.env[ + "sendcloud.parcel.status" + ] + existing_records_map[existing.sendcloud_code] |= existing + + # Created records + vals_list = [] + for record in records_data: + vals = self._prepare_sendcloud_parcel_statuses_from_response(record) + if str(record["id"]) in existing_records_map: + existing_records_map[str(record["id"])].write(vals) + else: + vals_list += [vals] + self.create(vals_list) + + @api.model + def sendcloud_sync_parcel_statuses(self): + for company in self.env["res.company"].search([]): + integration = company.sendcloud_default_integration_id + if integration: + self.sendcloud_update_parcel_statuses(integration) diff --git a/delivery_sendcloud_oca/models/sendcloud_return.py b/delivery_sendcloud_oca/models/sendcloud_return.py new file mode 100644 index 0000000000..91a2e51b62 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_return.py @@ -0,0 +1,219 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import api, fields, models + + +class SendcloudReturn(models.Model): + _name = "sendcloud.return" + _description = "Sendcloud Return" + _rec_name = "sendcloud_code" + + return_response_cache = fields.Text(default="{}") + sendcloud_code = fields.Integer(required=True) + email = fields.Char() + created_at = fields.Char() # TODO + reason = fields.Integer() + outgoing_parcel_code = fields.Integer() + incoming_parcel_code = fields.Integer() + outgoing_parcel_id = fields.Many2one( + "sendcloud.parcel", compute="_compute_parcel_id" + ) + incoming_parcel_id = fields.Many2one( + "sendcloud.parcel", compute="_compute_parcel_id" + ) + message = fields.Text() + status = fields.Char() + refund_type = fields.Char() + total_refund = fields.Float() + refunded_at = fields.Char() + refund_message = fields.Char() + status_display = fields.Char() + is_cancellable = fields.Boolean() + label_cost = fields.Float() + items_cost = fields.Float() + delivered_at = fields.Char() + delivery_option = fields.Selection( + [ + ("drop_off_point", "Drop Off Point"), + ("in_store", "In Store"), + ("drop_off_labelless", "Labelless Drop Off"), + ] + ) + + outgoing_parcel_tracking_url = fields.Char() + outgoing_parcel_tracking_number = fields.Char() + outgoing_parcel_parcel_status = fields.Char() + outgoing_parcel_global_status_slug = fields.Char() + outgoing_parcel_brand_name = fields.Char() + outgoing_parcel_order_number = fields.Char() + outgoing_parcel_from_email = fields.Char() + outgoing_parcel_deleted = fields.Boolean() + + incoming_parcel_tracking_url = fields.Char() + incoming_parcel_tracking_number = fields.Char() + incoming_parcel_parcel_status = fields.Char() + incoming_parcel_global_status_slug = fields.Char() + incoming_parcel_brand_name = fields.Char() + incoming_parcel_order_number = fields.Char() + incoming_parcel_from_email = fields.Char() + incoming_parcel_deleted = fields.Boolean() + + incoming_parcel_status_code = fields.Integer() + incoming_parcel_status_message = fields.Char() + incoming_parcel_status_global_status_slug = fields.Char() + + company_id = fields.Many2one( + "res.company", required=True, default=lambda self: self.env.company + ) + active = fields.Boolean(default=True) + + @api.depends("outgoing_parcel_code", "incoming_parcel_code") + def _compute_parcel_id(self): + for record in self: + record.outgoing_parcel_id = self.env["sendcloud.parcel"].search( + [("sendcloud_code", "=", record.outgoing_parcel_code)], limit=1 + ) + record.incoming_parcel_id = self.env["sendcloud.parcel"].search( + [("sendcloud_code", "=", record.incoming_parcel_code)], limit=1 + ) + + @api.model + def _prepare_sendcloud_return_from_response(self, record_data): + res = { + "sendcloud_code": record_data.get("id"), + "return_response_cache": record_data, + "email": record_data.get("email"), + "created_at": record_data.get("created_at"), + "outgoing_parcel_code": record_data.get("outgoing_parcel"), + "incoming_parcel_code": record_data.get("incoming_parcel"), + "reason": record_data.get("reason"), + "message": record_data.get("message"), + "status": record_data.get("status"), + "status_display": record_data.get("status_display"), + "is_cancellable": record_data.get("is_cancellable"), + "label_cost": record_data.get("label_cost"), + "items_cost": record_data.get("items_cost"), + "delivered_at": record_data.get("delivered_at"), + "delivery_option": record_data.get("delivery_option"), + "outgoing_parcel_tracking_url": record_data.get( + "outgoing_parcel_data", {} + ).get("tracking_url"), + "outgoing_parcel_tracking_number": record_data.get( + "outgoing_parcel_data", {} + ).get("tracking_number"), + "outgoing_parcel_parcel_status": record_data.get( + "outgoing_parcel_data", {} + ).get("parcel_status"), + "outgoing_parcel_global_status_slug": record_data.get( + "outgoing_parcel_data", {} + ).get("global_status_slug"), + "outgoing_parcel_brand_name": record_data.get( + "outgoing_parcel_data", {} + ).get("brand_name"), + "outgoing_parcel_order_number": record_data.get( + "outgoing_parcel_data", {} + ).get("order_number"), + "outgoing_parcel_from_email": record_data.get( + "outgoing_parcel_data", {} + ).get("from_email"), + "outgoing_parcel_deleted": record_data.get("outgoing_parcel_data", {}).get( + "deleted" + ), + "incoming_parcel_tracking_url": record_data.get( + "incoming_parcel_data", {} + ).get("tracking_url"), + "incoming_parcel_tracking_number": record_data.get( + "incoming_parcel_data", {} + ).get("tracking_number"), + "incoming_parcel_parcel_status": record_data.get( + "incoming_parcel_data", {} + ).get("parcel_status"), + "incoming_parcel_global_status_slug": record_data.get( + "incoming_parcel_data", {} + ).get("global_status_slug"), + "incoming_parcel_brand_name": record_data.get( + "incoming_parcel_data", {} + ).get("brand_name"), + "incoming_parcel_order_number": record_data.get( + "incoming_parcel_data", {} + ).get("order_number"), + "incoming_parcel_from_email": record_data.get( + "incoming_parcel_data", {} + ).get("from_email"), + "incoming_parcel_deleted": record_data.get("incoming_parcel_data", {}).get( + "deleted" + ), + "incoming_parcel_status_code": record_data.get( + "incoming_parcel_status", {} + ).get("id"), + "incoming_parcel_status_message": record_data.get( + "incoming_parcel_status", {} + ).get("message"), + "incoming_parcel_status_global_status_slug": record_data.get( + "incoming_parcel_status", {} + ).get("global_status_slug"), + } + + refund = record_data.get("refund") + if refund: + res.update( + { + "refund_type": refund["refund_type"]["code"], + "total_refund": refund["total_refund"], + "refunded_at": refund["refunded_at"], + "refund_message": refund["message"], + } + ) + + return res + + @api.model + def sendcloud_create_or_update_returns(self, return_data, company): + if isinstance(return_data, dict): + return_data = [return_data] + + # All records + all_records = company.sendcloud_return_ids + + # Existing records + existing_records = all_records.filtered( + lambda c: c.sendcloud_code in [record.get("id") for record in return_data] + ) + + # Existing records map (internal code -> existing record) + existing_records_map = {} + for existing in existing_records: + if existing.sendcloud_code not in existing_records_map: + existing_records_map[existing.sendcloud_code] = self.env[ + "sendcloud.return" + ] + existing_records_map[existing.sendcloud_code] |= existing + + # Disabled records + disabled_records = all_records - existing_records + disabled_records.write({"active": False}) + + # Created records + vals_list = [] + for record in return_data: + vals = self._prepare_sendcloud_return_from_response(record) + if record.get("id") in existing_records_map: + existing_records_map[record.get("id")].write(vals) + else: + vals["company_id"] = company.id + vals_list += [vals] + new_created_records = self.create(vals_list) + + # Updated records + updated_records = existing_records + new_created_records + updated_records.write({"active": True}) + return updated_records + + @api.model + def sendcloud_sync_returns(self): + for company in self.env["res.company"].search([]): + integration = company.sendcloud_default_integration_id + if integration: + return_data = integration.get_returns() # TODO paging + self.sendcloud_create_or_update_returns(return_data, company) diff --git a/delivery_sendcloud_oca/models/sendcloud_return_location.py b/delivery_sendcloud_oca/models/sendcloud_return_location.py new file mode 100644 index 0000000000..e46eb61c96 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_return_location.py @@ -0,0 +1,21 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import fields, models + + +class SendcloudReturn(models.Model): + _name = "sendcloud.return.location" + _description = "Sendcloud Return Location" + + name = fields.Char() + sendcloud_code = fields.Integer(required=True) + country_name = fields.Char() + company_name = fields.Char() + address_1 = fields.Char() + address_2 = fields.Char() + house_number = fields.Char() + city = fields.Char() + postal_code = fields.Char() + senderaddress_labels = fields.Text(default="[]") + brand_id = fields.Many2one("sendcloud.brand") diff --git a/delivery_sendcloud_oca/models/sendcloud_sender_address.py b/delivery_sendcloud_oca/models/sendcloud_sender_address.py new file mode 100644 index 0000000000..f7cd6d83c2 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_sender_address.py @@ -0,0 +1,94 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import api, fields, models + + +class SendcloudSenderAddress(models.Model): + _name = "sendcloud.sender.address" + _description = "Sendcloud Sender Address" + _rec_name = "company_name" + + sendcloud_code = fields.Integer(required=True) + company_name = fields.Char() + contact_name = fields.Char() + email = fields.Char() + telephone = fields.Char() + street = fields.Char() + house_number = fields.Char() + postal_box = fields.Char() + postal_code = fields.Char() + city = fields.Char() + country = fields.Char() + vat_number = fields.Char() + eori_number = fields.Char() + company_id = fields.Many2one( + "res.company", required=True, default=lambda self: self.env.company + ) + active = fields.Boolean(default=True) + + @api.model + def _prepare_sendcloud_addresses_from_response(self, addresses_data): + return { + "sendcloud_code": addresses_data.get("id"), + "company_name": addresses_data.get("company_name"), + "contact_name": addresses_data.get("contact_name"), + "email": addresses_data.get("email"), + "telephone": addresses_data.get("telephone"), + "street": addresses_data.get("street"), + "house_number": addresses_data.get("house_number"), + "postal_box": addresses_data.get("postal_box"), + "postal_code": addresses_data.get("postal_code"), + "city": addresses_data.get("city"), + "country": addresses_data.get("country"), + "vat_number": addresses_data.get("vat_number"), + "eori_number": addresses_data.get("eori_number"), + } + + @api.model + def sendcloud_update_sender_address(self, sender_addresses, company): + # All addresses + domain = [("company_id", "=", company.id)] + all_records = self.with_context(active_test=False).search(domain) + + # Existing records + addresses_list = [address.get("id") for address in sender_addresses] + existing_records = all_records.filtered( + lambda c: c.sendcloud_code in addresses_list + ) + + # Existing addresses map (internal code -> existing address) + existing_addresses_map = {} + for existing in existing_records: + if existing.sendcloud_code not in existing_addresses_map: + existing_addresses_map[existing.sendcloud_code] = self.env[ + "sendcloud.sender.address" + ] + existing_addresses_map[existing.sendcloud_code] |= existing + + # Disabled addresses + disabled_records = all_records - existing_records + disabled_records.write({"active": False}) + + # Created addresses + vals_list = [] + for address in sender_addresses: + vals = self._prepare_sendcloud_addresses_from_response(address) + if address.get("id") in existing_addresses_map.keys(): + existing_addresses_map[address.get("id")].write(vals) + else: + vals["company_id"] = company.id + vals_list += [vals] + new_created_records = self.create(vals_list) + + # Updated addresses + updated_records = existing_records + new_created_records + updated_records.write({"active": True}) + + @api.model + def sendcloud_sync_sender_address(self): + for company in self.env["res.company"].search([]): + integration = company.sendcloud_default_integration_id + if integration: + sender_addresses = integration.get_sender_address() + self.sendcloud_update_sender_address(sender_addresses, company) diff --git a/delivery_sendcloud_oca/models/sendcloud_shipping_method_country.py b/delivery_sendcloud_oca/models/sendcloud_shipping_method_country.py new file mode 100644 index 0000000000..9fe5429473 --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_shipping_method_country.py @@ -0,0 +1,143 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import _, api, fields, models + + +class SendcloudShippingMethodCountry(models.Model): + _name = "sendcloud.shipping.method.country" + _description = "Sendcloud Shipping Method Country" + + name = fields.Char(compute="_compute_country_id") + country_id = fields.Many2one( + "res.country", compute="_compute_country_id", string="To Country" + ) + sendcloud_code = fields.Integer(required=True) + iso_2 = fields.Char(required=True) + iso_3 = fields.Char() + from_name = fields.Char(compute="_compute_country_id") + from_country_id = fields.Many2one("res.country", compute="_compute_country_id") + from_iso_2 = fields.Char() + from_iso_3 = fields.Char() + price = fields.Float() + method_code = fields.Integer(required=True) + sendcloud_is_return = fields.Boolean() + product_id = fields.Many2one( + comodel_name="product.product", + compute="_compute_price_custom", + inverse="_inverse_price_custom", + string="Specific Delivery Product", + domain="[('type', '=', 'service')]", + help="This product will be used on the sale order line", + readonly=False, + ) + company_id = fields.Many2one("res.company", required=True) + enable_price_custom = fields.Boolean( + compute="_compute_price_custom", + inverse="_inverse_price_custom", + readonly=False, + ) + price_custom = fields.Float( + compute="_compute_price_custom", + inverse="_inverse_price_custom", + readonly=False, + string="Custom Price", + help="This price will override the standard price and will be applied " + "to the shipping price.", + ) + price_check = fields.Selection( + [ + ("standard", "Standard"), + ("custom", "Custom"), + ("unavailable", "Unavailable"), + ], + compute="_compute_price_custom", + ) + + @api.depends("iso_2", "company_id", "method_code") + def _compute_price_custom(self): + isos = self.mapped("iso_2") + companies = self.mapped("company_id") + method_codes = self.mapped("method_code") + custom_items = self.env["sendcloud.shipping.method.country.custom"].search( + [ + ("iso_2", "in", isos), + ("company_id", "in", companies.ids), + ("method_code", "in", method_codes), + ], + ) + for item in self: + custom = custom_items.filtered( + lambda ci, i=item: ci.iso_2 == i.iso_2 + and ci.method_code == i.method_code + and ci.company_id == i.company_id + ) + if custom: + item.price_check = "custom" + item.enable_price_custom = custom[0].enable_price_custom + if custom[0].enable_price_custom: + item.price_custom = custom[0].price + else: + item.price_custom = item.price + item.product_id = custom[0].product_id + else: + item.price_custom = item.price + item.price_check = "standard" + + def _inverse_price_custom(self): + for item in self: + shipping_method_country = self.env[ + "sendcloud.shipping.method.country.custom" + ].search( + [ + ("iso_2", "=", item.iso_2), + ("company_id", "=", item.company_id.id), + ("method_code", "=", item.method_code), + ], + limit=1, + ) + if shipping_method_country: + shipping_method_country.price = item.price_custom + shipping_method_country.product_id = item.product_id + shipping_method_country.enable_price_custom = item.enable_price_custom + else: + self.env["sendcloud.shipping.method.country.custom"].create( + { + "iso_2": item.iso_2, + "company_id": item.company_id.id, + "method_code": item.method_code, + "price": item.price_custom, + "product_id": item.product_id.id, + "enable_price_custom": item.enable_price_custom, + } + ) + + @api.depends("iso_2", "from_iso_2") + def _compute_country_id(self): + iso_2_list = self.mapped("iso_2") + from_iso_2_list = self.mapped("from_iso_2") + all_countries = self.env["res.country"].search( + [("code", "in", iso_2_list + from_iso_2_list)] + ) + for record in self: + to_countries = all_countries.filtered(lambda c, r=record: c.code == r.iso_2) + record.country_id = fields.first(to_countries) + record.name = record.country_id.name + from_countries = all_countries.filtered( + lambda c, r=record: c.code == r.from_iso_2 + ) + record.from_country_id = fields.first(from_countries) + record.from_name = record.from_country_id.name + + def sendcloud_custom_price_details(self): + self.ensure_one() + return { + "name": _("Custom Price Details"), + "type": "ir.actions.act_window", + "res_model": "sendcloud.custom.price.details.wizard", + "views": [[False, "form"]], + "target": "new", + "context": { + "default_shipping_method_country_id": self.id, + }, + } diff --git a/delivery_sendcloud_oca/models/sendcloud_shipping_method_country_custom.py b/delivery_sendcloud_oca/models/sendcloud_shipping_method_country_custom.py new file mode 100644 index 0000000000..510d7e083c --- /dev/null +++ b/delivery_sendcloud_oca/models/sendcloud_shipping_method_country_custom.py @@ -0,0 +1,22 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import fields, models + + +class SendcloudShippingMethodCountryCustom(models.Model): + _name = "sendcloud.shipping.method.country.custom" + _description = "Sendcloud Shipping Method Country Custom" + + iso_2 = fields.Char(required=True) + custom_price = fields.Boolean() + enable_price_custom = fields.Boolean() + price = fields.Float() + method_code = fields.Integer(required=True) + company_id = fields.Many2one("res.company", required=True) + product_id = fields.Many2one( + comodel_name="product.product", + string="Specific Delivery Product", + domain="[('type', '=', 'service')]", + help="This product will be used on the sale order line", + ) diff --git a/delivery_sendcloud_oca/models/stock_picking.py b/delivery_sendcloud_oca/models/stock_picking.py new file mode 100644 index 0000000000..72ac63251c --- /dev/null +++ b/delivery_sendcloud_oca/models/stock_picking.py @@ -0,0 +1,864 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +import json +import logging +import uuid +from collections import defaultdict + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_repr, float_round +from odoo.tools.safe_eval import safe_eval + +_logger = logging.getLogger(__name__) + + +class StockPicking(models.Model): + _name = "stock.picking" + _inherit = ["stock.picking", "sendcloud.mixin"] + + sendcloud_parcel_ids = fields.One2many("sendcloud.parcel", "picking_id") + sendcloud_parcel_count = fields.Integer( + string="Parcels", compute="_compute_sendcloud_parcel_count" + ) + sendcloud_shipment_uuid = fields.Char(copy=False) + sendcloud_last_cached = fields.Datetime(copy=False, readonly=True) + sendcloud_announce = fields.Boolean( + default=True, help="Should the parcel request a label." + ) + sendcloud_is_return = fields.Boolean() + sendcloud_insured_value = fields.Float( + help="Insured Value must be in Euro currency." + ) + sendcloud_shipping_method_checkout_name = fields.Char() + sendcloud_apply_shipping_rules = fields.Boolean( + help="When set to True configured shipping rules will be applied before " + "creating the label and announcing the Parcel" + ) + sendcloud_service_point_required = fields.Boolean( + related="carrier_id.sendcloud_service_point_required" + ) + sendcloud_customs_shipment_type = fields.Selection( + selection="_get_sendcloud_customs_shipment_type", + compute="_compute_sendcloud_customs_shipment_type", + readonly=False, + store=True, + ) + sendcloud_service_point_address = fields.Text( + compute="_compute_sendcloud_service_point_address", readonly=False, store=True + ) + sendcloud_shipment_code = fields.Char(index=True, copy=False) + sendcloud_sp_details = fields.Char(compute="_compute_sendcloud_sp_details") + + label_print_status = fields.Selection( + [ + ("generated", "Generated"), + ("printed", "Printed"), + ("partial", "Partially Printed"), + ], + compute="_compute_label_print_status", + store=True, + ) + + @api.depends("sendcloud_parcel_ids", "sendcloud_parcel_ids.label_print_status") + def _compute_label_print_status(self): + for picking in self.filtered(lambda x: x.sendcloud_parcel_ids): + if all( + parcel.label_print_status == "generated" + for parcel in picking.sendcloud_parcel_ids + ): + picking.label_print_status = "generated" + elif all( + parcel.label_print_status == "printed" + for parcel in picking.sendcloud_parcel_ids + ): + picking.label_print_status = "printed" + else: + picking.label_print_status = "partial" + for picking in self.filtered(lambda x: not x.sendcloud_parcel_ids): + picking.label_print_status = None + + @api.depends( + "carrier_id.sendcloud_integration_id", + "carrier_id.sendcloud_carrier", + "partner_id.country_id.code", + "partner_id.zip", + ) + def _compute_sendcloud_sp_details(self): + user_lang = self.env.user.lang.replace("_", "-").lower() + available_languages = [ + "en-us", + "de-de", + "en-gb", + "es-es", + "fr-fr", + "it-it", + "nl-nl", + ] + for picking in self: + vals = { + "api_key": picking.carrier_id.sendcloud_integration_id.public_key, + "country": picking.partner_id.country_id.code + and picking.partner_id.country_id.code.lower() + or "", + "postalcode": picking.partner_id.zip or "", + "language": user_lang if user_lang in available_languages else "en-us", + "carrier": picking.carrier_id.sendcloud_carrier or "", + } + picking.sendcloud_sp_details = json.dumps(vals) + + @api.depends("sale_id.sendcloud_customs_shipment_type", "sendcloud_is_return") + def _compute_sendcloud_customs_shipment_type(self): + for picking in self: + shipment_type = picking.sendcloud_customs_shipment_type + picking.sendcloud_customs_shipment_type = shipment_type + sale_shipment_type = picking.sale_id.sendcloud_customs_shipment_type + if not picking.sendcloud_is_return and sale_shipment_type: + picking.sendcloud_customs_shipment_type = sale_shipment_type + + @api.depends("sale_id.sendcloud_service_point_address", "sendcloud_is_return") + def _compute_sendcloud_service_point_address(self): + for picking in self: + service_point_address = picking.sendcloud_service_point_address + picking.sendcloud_service_point_address = service_point_address + sale_service_point_address = picking.sale_id.sendcloud_service_point_address + if not picking.sendcloud_is_return and sale_service_point_address: + picking.sendcloud_service_point_address = sale_service_point_address + + @api.depends("sendcloud_parcel_ids") + def _compute_sendcloud_parcel_count(self): + for picking in self: + picking.sendcloud_parcel_count = len(picking.sendcloud_parcel_ids) + + # flake8: noqa: C901 + def _prepare_sendcloud_vals_from_picking(self, package=False): + self.ensure_one() + + request_label = self.sendcloud_announce + apply_shipping_rules = self.sendcloud_apply_shipping_rules + is_return = False + + order = self.sale_id + warehouse = self.picking_type_id.warehouse_id + + service_point_data = {} + if self.sendcloud_service_point_required: + if not self.sendcloud_service_point_address: + raise ValidationError(_("Sendcloud Service Point is Required!")) + + service_point_data = json.loads(self.sendcloud_service_point_address) + + sender = self._get_sendcloud_recipient() + + vals = self.generate_sendcloud_ref_uuid_vals() + if self.sendcloud_shipment_uuid: + vals.update({"shipment_uuid": self.sendcloud_shipment_uuid}) + vals.update( + { + "created_at": self.create_date.isoformat(), + "updated_at": self.write_date.isoformat(), + } + ) + # Recipient address details (mandatory) + vals.update( + { + "name": sender.name or sender.display_name, + "address": sender.street_name, + "house_number": sender.street_number or "", + "city": sender.city, + "postal_code": sender.zip, + "country": sender.country_id.code or "", + } + ) + if sender.street_number2: + number_door = vals["house_number"] + " " + sender.street_number2 + vals["house_number"] = number_door + + # If sendcloud_auto_create_invoice, create invoice + out_invoices = order._sendcloud_order_invoice() + + # Recipient address details (mandatory when shipping outside of EU) + vals.update( + { + "country_state": sender.state_id.code or "", + "customs_invoice_nr": out_invoices[-1].name if out_invoices else "", + "customs_shipment_type": int(self.sendcloud_customs_shipment_type) + if self.sendcloud_customs_shipment_type + else None, + } + ) + vals.update({"to_state": sender.state_id.code or None}) + + # Recipient address details (optional) + vals.update( + { + "company_name": sender.name + if sender.is_company + else sender.parent_name or "", + "address_2": sender.street2 or "", + } + ) + if order: + vals.update( + { + "currency": order.currency_id.name, + } + ) + if sender.mobile or sender.phone: + vals.update({"telephone": sender.mobile or sender.phone}) + if sender.email: + vals.update({"email": sender.email}) + elif sender.parent_id and sender.parent_id.email: + vals.update({"email": sender.parent_id.email}) + vals.update({"to_post_number": service_point_data.get("postal_code", "")}) + if not warehouse.sencloud_sender_address_id: + sender_address = self.env[ + "delivery.carrier" + ]._get_default_sender_address_per_company(warehouse.company_id.id) + else: + sender_address = warehouse.sencloud_sender_address_id + if sender_address: + vals.update({"sender_address": sender_address.sendcloud_code}) + + # Shipping service (optional) + service_point_id = service_point_data.get("id") + if service_point_id: + service_point_id = int(service_point_data["id"]) + vals.update({"to_service_point": service_point_id}) + # TODO + shipping_method_checkout_name = ( + self.sendcloud_shipping_method_checkout_name or "" + ) + vals.update( + { + "shipping_method_checkout_name": shipping_method_checkout_name, + "order_status": None, + "payment_status": None, + } + ) + if self.sendcloud_insured_value: + vals.update({"insured_value": self.sendcloud_insured_value or None}) + + # Parcel properties (mandatory when shipping outside of EU) + parcel_items = [] + move_lines = self.move_ids.mapped("move_line_ids") + if package: + move_lines = move_lines.filtered( + lambda ml: ml.package_id == package or ml.result_package_id == package + ) + else: + move_lines = move_lines.filtered( + lambda ml: not ml.package_id and not ml.result_package_id + ) + if move_lines: + moves = move_lines.mapped("move_id") + else: + moves = self.move_ids # TODO should be never the case, raise an error? + total_weight = 0.0 + for move in moves: + line_vals = self._prepare_sendcloud_item_vals_from_moves( + move, package=package + ) + total_weight += line_vals["weight"] + line_vals["weight"] = ( + line_vals["weight"] / line_vals["quantity"] + if line_vals["quantity"] + else 0.0 + ) + parcel_items += [line_vals] + + vals["parcel_items"] = parcel_items + + # Parcel properties (optional) + if order.name: + vals.update({"order_number": order.name}) + if total_weight: + vals.update({"weight": total_weight}) + vals.update({"is_return": is_return}) + + # Announcement (optional) + vals.update({"request_label": request_label}) + + # Announcement (required if request_label is True) + vals.update( + { + "shipment": {"id": self.carrier_id.sendcloud_code}, + "apply_shipping_rules": apply_shipping_rules, + } + ) + return vals + + def _get_sendcloud_recipient(self): + self.ensure_one() + return self.partner_id or self.sale_id.partner_id + + def generate_sendcloud_ref_uuid_vals(self): + self.ensure_one() + order = self.sale_id + if not order.sendcloud_order_code: + force_order_code = self.env.context.get("force_sendcloud_order_code") + order.sendcloud_order_code = force_order_code or uuid.uuid4() + if not self.sendcloud_shipment_code: + force_shipment_code = self.env.context.get("force_sendcloud_shipment_code") + self.sendcloud_shipment_code = force_shipment_code or uuid.uuid4() + return { + "external_order_id": order.sendcloud_order_code, + "external_shipment_id": self.sendcloud_shipment_code, + } + + @api.model + def _check_state_requires_hs_code(self, country_code, state_code): + states = {"ES": ["TF", "GC"]} + return country_code in states and state_code in states[country_code] + + def _prepare_sendcloud_item_vals_from_moves(self, move, package=False): + self.ensure_one() + + weight = self._sendcloud_convert_weight_to_kg(move.weight) + if not package: + quantity = int(move.product_uom_qty) # TODO should be quantity_done ? + else: + move_lines = move.move_line_ids.filtered( + lambda ml: ml.result_package_id == package + ) + if move_lines: + quantity = sum(move_lines.mapped("qty_done")) + else: + quantity = sum(package.mapped("quant_ids.quantity")) + + partner_country = self.partner_id.country_id.code + is_outside_eu = not self.partner_id.sendcloud_is_in_eu + + partner_state = self.partner_id.state_id.code + state_requires_hs_code = self._check_state_requires_hs_code( + partner_country, partner_state + ) + + price = move.sale_line_id.price_unit + precision_digits = move.sale_line_id.currency_id.decimal_places + if ( + hasattr(move, "bom_line_id") + and move.bom_line_id + and move.bom_line_id.bom_id.type == "phantom" + ): + # We want to compute each subproduct price based on the kit product price + # and the total price of all components of the kit.Example. + # Kitprice is 30€, it is made up of 3 subproducts costing 15€, 15€ and 10€. + # Subproduct 1: Price ((15/40)*30) = 11,25€ + # Subproduct 2: Price ((15/40)*30) = 11,25€ + # Subproduct 3: Price ((10/40)*30) = 7,5€ + + total_price = 0.0 + for line in move.bom_line_id.bom_id.bom_line_ids: + if line._skip_bom_line(move.sale_line_id.product_id): + continue + total_price += line.product_id.lst_price * line.product_qty + + subproduct = move.bom_line_id.product_id + kit_product = move.sale_line_id.product_id + if kit_product.lst_price: # Prevent division by 0 + value = float_repr( + float_round( + (subproduct.lst_price / total_price) * price, + precision_digits=precision_digits, + ), + precision_digits=precision_digits, + ) + else: + value = 0 + else: + value = float_repr( + float_round(price, precision_digits=precision_digits), + precision_digits=precision_digits, + ) + + # Parcel items (mandatory) + line_vals = { + "description": move.product_id.display_name, + "quantity": quantity, + "weight": weight, + "value": value, + # not converted to euro as the currency is always set + } + + # Parcel items (mandatory when shipping outside of EU) + if is_outside_eu or state_requires_hs_code: + parcel_item_outside_eu = self._prepare_sendcloud_parcel_items_outside_eu( + move + ) + if not parcel_item_outside_eu.get("hs_code"): + raise ValidationError( + _( + "Harmonized System Code is mandatory when shipping outside of " + "EU and to some states.\nYou should set the HS Code for " + "product %s" + ) + % move.product_tmpl_id.name + ) + if not parcel_item_outside_eu.get("origin_country"): + raise ValidationError( + _( + "Origin Country is mandatory when shipping outside of EU and" + " to some states." + ) + ) + line_vals.update(parcel_item_outside_eu) + # Parcel items (optional) + if move.product_id.default_code: + line_vals.update( + {"sku": move.product_id.default_code} + ) # TODO product.barcode or product.id + line_vals.update({"product_id": ""}) + line_vals.update({"properties": {}}) + return line_vals + + def _prepare_sendcloud_parcel_items_outside_eu(self, move): + self.ensure_one() + product_tmplate = move.product_tmpl_id + hs_code = product_tmplate.hs_code + origin_country = product_tmplate.country_of_origin.code + is_product_harmonized_system_installed = self.env["ir.module.module"].search( + [("name", "=", "product_harmonized_system"), ("state", "=", "installed")], + limit=1, + ) + if is_product_harmonized_system_installed: + # use field provided by OCA module "product_harmonized_system" if installed + hs_code = product_tmplate.hs_code_id.hs_code + origin_country = product_tmplate.origin_country_id.code or origin_country + is_account_intrastat_installed = self.env["ir.module.module"].search( + [("name", "=", "account_intrastat"), ("state", "=", "installed")], limit=1 + ) + if is_account_intrastat_installed: + # use field provided by Enterprise module "account_intrastat" if installed + hs_code = product_tmplate.intrastat_code_id.code or hs_code + origin_country = ( + product_tmplate.intrastat_origin_country_id.code or origin_country + ) + return {"hs_code": hs_code, "origin_country": origin_country} + + def _prepare_sendcloud_parcels_from_picking(self): + self.ensure_one() + + vals_list = [] + + # multicollo parcels (one collo is the master) + colli = self.package_ids + + # in case only packages of a certain carrier should be considered + # invoke this method passing "sendcloud_only_packs_with_carrier" in its context + if self.env.context.get("sendcloud_only_packs_with_carrier"): + packs_no_carrier = self._get_packs_no_carrier(colli) + colli = colli - packs_no_carrier + + total_sendcloud_package_weight = 0.0 + for package in colli: + weight = ( + package.shipping_weight + or package.with_context(picking_id=self.id).weight + ) + weight = self._sendcloud_convert_weight_to_kg(weight) + weight = self._sendcloud_check_collo_weight(weight) + total_sendcloud_package_weight += weight + vals = self._prepare_sendcloud_vals_from_picking(package) + vals["weight"] = weight + vals["external_reference"] = self.name + "," + str(package.id) + vals_list += [vals] + + if self.weight_bulk or (self.package_ids - colli) or not vals_list: + weight = self._get_total_weight_bulk(total_sendcloud_package_weight) + weight = self._sendcloud_convert_weight_to_kg(weight) + weight = self._sendcloud_check_collo_weight(weight) + vals = self._prepare_sendcloud_vals_from_picking() + if vals: + vals["weight"] = weight + vals["external_reference"] = self.name + "," + str(0) + vals_list += [vals] + + return vals_list + + def _sendcloud_check_collo_weight(self, weight): + self.ensure_one() + min_weight = self.carrier_id.sendcloud_min_weight + max_weight = self.carrier_id.sendcloud_max_weight + if min_weight and max_weight and not (min_weight <= weight <= max_weight): + raise ValidationError( + _( + "Sendcloud shipping method not compatible with selected packaging." + "\nPlease select a shipping method such that the collis' weights " + "are between Min Weight and Max Weight." + ) + ) + return weight + + def _get_total_weight_bulk(self, total_sendcloud_package_weight): + self.ensure_one() + return (self.shipping_weight or self.weight) - total_sendcloud_package_weight + + @api.model + def _get_packs_no_carrier(self, colli): + return colli.filtered( + lambda p: p.packaging_id.package_carrier_type in [False, "none"] + ) + + def action_open_sendcloud_parcels(self): + self.ensure_one() + if len(self.sendcloud_parcel_ids) == 1: + return { + "type": "ir.actions.act_window", + "res_model": "sendcloud.parcel", + "res_id": self.sendcloud_parcel_ids.id, + "view_mode": "form", + "context": self.env.context, + } + return { + "type": "ir.actions.act_window", + "name": _("Sendcloud Parcels"), + "res_model": "sendcloud.parcel", + "domain": [("id", "in", self.sendcloud_parcel_ids.ids)], + "view_mode": "tree,form", + "context": self.env.context, + } + + def cancel_shipment(self): + if ( + not self.env.context.get("do_sendcloud_cancel_shipment") + and len(self) == 1 + and self.delivery_type == "sendcloud" + and self.picking_type_code == "outgoing" + ): + action = "delivery_sendcloud_oca.sendcloud_cancel_shipment_confirm_wizard" + return self.env.ref(action).read()[0] + return super().cancel_shipment() + + def button_delete_sendcloud_picking(self): + self.ensure_one() + to_delete_shipments = self.to_delete_sendcloud_pickings() + self.delete_sendcloud_pickings(to_delete_shipments) + + def to_delete_sendcloud_pickings(self): + res = {} + for picking in self.filtered( + lambda p: p.delivery_type == "sendcloud" + and p.carrier_id.delivery_type == "sendcloud" + and p.picking_type_code == "outgoing" + ): + integration = picking.carrier_id.sendcloud_integration_id + if picking.sendcloud_shipment_uuid: + vals = {"shipment_uuid": picking.sendcloud_shipment_uuid} + picking.with_context( + skip_sync_picking_to_sendcloud=True + ).sendcloud_shipment_uuid = None + else: + vals = picking.generate_sendcloud_ref_uuid_vals() + if integration.id not in res: + res[integration.id] = [] + res[integration.id] += [vals] + return res + + @api.model + def delete_sendcloud_pickings(self, to_delete_shipments): + for integration_id in to_delete_shipments: + integration = self.env["sendcloud.integration"].browse(integration_id) + vals_list = to_delete_shipments[integration_id] + for vals in vals_list: + response = integration.delete_shipments( + integration.sendcloud_code, vals + ) + if response.get("error"): + picking_id = vals.get("external_shipment_id") or vals.get( + "shipment_uuid" + ) + _logger.error( + "Sendcloud deleting picking %s error: %s", + picking_id, + response.get("error").get("message"), + ) + + def action_download_sendcloud_labels(self): + if self.mapped("sendcloud_parcel_ids").mapped("attachment_id"): + return { + "type": "ir.actions.act_url", + "url": "/sendcloud/picking/download_labels?ids=%s" + % (",".join([str(id) for id in self.ids])), + "target": "self", + } + + def action_multi_create_sendcloud_labels(self): + for picking in self: + picking.button_create_sendcloud_labels() + + def action_multi_create_sendcloud_labels_download(self): + self.action_multi_create_sendcloud_labels() + self.action_download_sendcloud_labels() + + def button_create_sendcloud_labels(self): + self.ensure_one() + if ( + self.picking_type_code == "outgoing" + and self.delivery_type == "sendcloud" + and self.sale_id + ): + integration = self.carrier_id.sendcloud_integration_id + vals = self._prepare_sendcloud_parcels_from_picking() + parcels_data = self._sendcloud_sync_multiple_parcels(integration, vals) + self._sendcloud_create_update_received_parcels( + parcels_data, integration.company_id.id + ) + parcels = self.mapped("sendcloud_parcel_ids") + parcels._generate_parcel_labels() + return self.action_open_sendcloud_parcels() + + @api.model + def _sendcloud_vals_triggering_sync(self): + return [ + "sendcloud_announce", + "sendcloud_is_return", + "sendcloud_insured_value", + "sendcloud_shipping_method_checkout_name", + "sendcloud_apply_shipping_rules", + "sendcloud_customs_shipment_type", + "sendcloud_service_point_address", + "partner_id", + "sale_id", + "move_ids", + ] + + @api.model_create_multi + def create(self, vals): + res = super().create(vals) + res._sync_picking_to_sendcloud() + return res + + def write(self, vals): + res = super().write(vals) + if not self.env.context.get("skip_sync_picking_to_sendcloud"): + if any(item in self._sendcloud_vals_triggering_sync() for item in vals): + to_sync = self.filtered(lambda p: p.carrier_id.sendcloud_integration_id) + to_sync._sync_picking_to_sendcloud() + return res + + def action_cancel(self): + to_delete_shipments = self.to_delete_sendcloud_pickings() + res = super().action_cancel() + self.delete_sendcloud_pickings(to_delete_shipments) + return res + + def unlink(self): + to_delete_shipments = self.to_delete_sendcloud_pickings() + res = super().unlink() + self.delete_sendcloud_pickings(to_delete_shipments) + return res + + @api.model + def _sendcloud_sync_multiple_parcels(self, integration, parcel_vals_list): + request_data = {"parcels": parcel_vals_list} + response = integration.create_parcels(request_data) + if response.get("error"): + err_msg = response.get("error").get("message") + raise UserError(_("Sendcloud: %s") % err_msg) + if response.get("failed_parcels"): + err_msg = "" + for failed in response.get("failed_parcels"): + err_msg += _("%(parcel)s:\n%(errors)s\n\n") % ( + { + "parcel": str(failed.get("parcel")), + "errors": str(failed.get("errors")), + } + ) + raise UserError(_("Sendcloud: %s") % err_msg) + return response["parcels"] + + def _sync_picking_to_sendcloud(self): + self = self.with_context(skip_sync_picking_to_sendcloud=True) + pickings = self.filtered( + lambda p: p.delivery_type == "sendcloud" + and p.picking_type_code == "outgoing" + and p.state != "cancel" + and p.sale_id + ) # TODo add "or uuid has a value" + integration_map = defaultdict(list) + for picking in pickings: + integration = picking.carrier_id.sendcloud_integration_id + shipment_vals_list = picking._prepare_sendcloud_parcels_from_picking() + integration_map[integration] += shipment_vals_list + err_msg = "" + for integration in integration_map: + vals = integration_map[integration] + err_msg = self._sync_shipment_to_sendcloud(err_msg, integration, vals) + if err_msg: + raise UserError(err_msg) + return pickings + + def _sendcloud_send_shipping(self): + self.ensure_one() + res = [] + if self.picking_type_code == "outgoing" and self.sale_id: + integration = self.carrier_id.sendcloud_integration_id + vals = self._prepare_sendcloud_parcels_from_picking() + parcels_data = self._sendcloud_sync_multiple_parcels(integration, vals) + for parcel in parcels_data: + # Compute price and tracking number + price_and_tracking = { + "exact_price": self._get_exact_price_of_parcel(parcel), + "tracking_number": parcel.get("tracking_number"), + } + res.append(price_and_tracking) + self._sendcloud_create_update_received_parcels( + parcels_data, integration.company_id.id + ) + if not res: + res.append({"exact_price": 0.0, "tracking_number": False}) + return res + + def _sync_shipment_to_sendcloud(self, err_msg, integration, vals): + _logger.info("Sendcloud create_shipments:%s", integration.sendcloud_code) + response = integration.with_context( + sendcloud_ok_response_status=(200, 201) + ).create_shipments(integration.sendcloud_code, vals) + for confirmation in response: + status = confirmation.get("status") + + sendcloud_shipment_uuid = confirmation.get("shipment_uuid") + if ( + len(self) == 1 + and self.sendcloud_shipment_uuid == sendcloud_shipment_uuid + ): + picking = self + else: + picking = self.search( + [("sendcloud_shipment_uuid", "=", sendcloud_shipment_uuid)], limit=1 + ) + if not picking: + external_shipment_id = confirmation.get("external_shipment_id") + if not external_shipment_id: + raise # TODO + if ( + len(self) == 1 + and self.sendcloud_shipment_uuid == sendcloud_shipment_uuid + ): + picking = self + else: + picking = self.env["stock.picking"].search( + [("sendcloud_shipment_code", "=", external_shipment_id)] + ) + if len(picking) != 1: + raise # TODO + + if status == "created": + picking.sendcloud_shipment_uuid = sendcloud_shipment_uuid + picking.sendcloud_last_cached = fields.Datetime.now() + elif status == "updated": + if not picking.sendcloud_shipment_uuid: + picking.sendcloud_shipment_uuid = sendcloud_shipment_uuid + picking.sendcloud_last_cached = fields.Datetime.now() + elif status == "error": + error = confirmation.get("error") + _logger.info( + "Sendcloud order %s shipments %s error:%s\n" + "picking id: %s\n" + "Sent payload: %s", + error.get("external_order_id"), + error.get("external_shipment_id"), + str(error), + str(picking.id), + str(vals), + ) + err_msg += _( + "Order %(external_order_id)s (shipment %" + "(external_shipment_id)s) returned an error:\n" + ) % ( + { + "external_order_id": error.get("external_order_id"), + "external_shipment_id": error.get("external_shipment_id"), + } + ) + err_msg += str(error) + "\n\n" + return err_msg + + def _get_exact_price_of_parcel(self, parcel): + pick, _ = parcel["external_reference"].rsplit(",", 1) + picking = self.filtered(lambda p: p.name == pick) + country = picking.partner_id.country_id + carrier = picking.sale_id.carrier_id + if carrier and country: + price, _ = carrier._sendcloud_get_price_per_country(country.code) + return price + return 0.0 + + def _sendcloud_create_update_received_parcels(self, parcels_data, company_id=False): + self.ensure_one() + + # Existing records + existing_records = self.sendcloud_parcel_ids + + # Existing records map (internal code -> existing record) + existing_records_map = {} + for existing in existing_records: + if existing.sendcloud_code not in existing_records_map: + existing_records_map[existing.sendcloud_code] = existing + else: + # TODO raise error? + pass + # Create/update Odoo parcels + res = self.env["sendcloud.parcel"] + odoo_parcels_vals = [] + for parcel in parcels_data: + # Prepare parcel vals list + parcels_vals = self.env[ + "sendcloud.parcel" + ]._prepare_sendcloud_parcel_from_response(parcel) + + if parcel.get("id") in existing_records_map: + existing_parcel = existing_records_map[parcel.get("id")] + res |= existing_parcel + existing_parcel.write(parcels_vals) + else: + parcels_vals["company_id"] = company_id or self.env.company.id + parcels_vals["picking_id"] = self.id + odoo_parcels_vals += [parcels_vals] + res += self.env["sendcloud.parcel"].create(odoo_parcels_vals) + res.action_get_return_portal_url() + return res + + def button_to_sendcloud_sync(self): + self.ensure_one() + if self.carrier_id.delivery_type != "sendcloud": + return + self._sync_picking_to_sendcloud() + + # ----------- # + # Constraints # + # ----------- # + + @api.constrains("state", "carrier_id", "sendcloud_service_point_address") + def _constrains_sendcloud_service_point_required(self): + for record in self.filtered( + lambda r: r.delivery_type == "sendcloud" + and r.picking_type_code == "outgoing" + and not r.carrier_id.sendcloud_is_return + and r.state == "done" + ): + carrier = record.carrier_id + if carrier.sendcloud_service_point_input == "required": + if not record.sendcloud_service_point_address: + raise ValidationError(_("Sendcloud Service Point is required.")) + + if ( + carrier.sendcloud_integration_id + and not carrier.sendcloud_integration_id.service_point_enabled + ): + raise ValidationError( + _("Sendcloud Service Point not enabled for this integration.") + ) + + carrier_names = carrier.sendcloud_integration_id.service_point_carriers + current_carrier = carrier.sendcloud_carrier + if ( + not current_carrier + or current_carrier not in safe_eval(carrier_names) + or [] + ): + raise ValidationError( + _("Sendcloud Carrier not enabled for this integration.") + ) diff --git a/delivery_sendcloud_oca/models/stock_warehouse.py b/delivery_sendcloud_oca/models/stock_warehouse.py new file mode 100644 index 0000000000..93907d6fed --- /dev/null +++ b/delivery_sendcloud_oca/models/stock_warehouse.py @@ -0,0 +1,12 @@ +# Copyright 2024 Onestein () +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) + +from odoo import fields, models + + +class StockWarehouse(models.Model): + _inherit = "stock.warehouse" + + sencloud_sender_address_id = fields.Many2one( + related="partner_id.sencloud_sender_address_id", readonly=False + ) diff --git a/delivery_sendcloud_oca/pyproject.toml b/delivery_sendcloud_oca/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/delivery_sendcloud_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/delivery_sendcloud_oca/readme/CONTRIBUTORS.md b/delivery_sendcloud_oca/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..dabe2d2b56 --- /dev/null +++ b/delivery_sendcloud_oca/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +* `Onestein `__ diff --git a/delivery_sendcloud_oca/readme/DESCRIPTION.md b/delivery_sendcloud_oca/readme/DESCRIPTION.md new file mode 100644 index 0000000000..98887b71e3 --- /dev/null +++ b/delivery_sendcloud_oca/readme/DESCRIPTION.md @@ -0,0 +1,7 @@ +This module provides sendcloud shipping integration with Odoo + +This module mostly implements what's described in https://docs.sendcloud.sc/api/v2/shipping/ + +Full documentation for developers is in https://docs.sendcloud.sc/. + +This module works for the Community Edition as well as the Enterprise Edition. \ No newline at end of file diff --git a/delivery_sendcloud_oca/readme/INSTALL.md b/delivery_sendcloud_oca/readme/INSTALL.md new file mode 100644 index 0000000000..e55c908d37 --- /dev/null +++ b/delivery_sendcloud_oca/readme/INSTALL.md @@ -0,0 +1,145 @@ +Create an account on sendcloud.com and choose a plan. +Go to integrations and select Odoo integration to use the Odoo integration or select +api integration if you only want to use the api integration (see readme for more +information). + +## Odoo Integration + + +Verify that the value of "web.base.url" parameter in System Parameters is set with +the correct url (eg.: "https://demo.onestein.eu" instead of "http://localhost:8069"). + +Go to Sendcloud > Configuration > Wizards > Setup the Sendcloud Integration. A wizard will pop up. + +![](../static/description/Image_10.png) + +Select Odoo Integration. Start Setup. You will be redirected to a Sendcloud page asking you +to authorize OdooShop to access your Sendcloud account. Click on Connect in the Sendcloud page. + +![](../static/description/Image_20.png) + +Go back to the Odoo Integration configuration. An integration "OdooShop" is now present +in the Integration list view. Open the OdooShop Integration form. Edit the OdooShop Integration. +The changes you make will be in sync, Sendcloud side, with the integration configuration. + +![](../static/description/Image_30.png) + +In case multiple integrations are present, sort the integrations by sequence, to allow +Odoo to choose the default one that will be used. +Please note that when using the Odoo integration an "incoming order" is created in +Sendcloud as soon as you validate the salesorder. The “incoming order” has status +“in process” in Sendcloud and is not forwarded to the carrier yet. + +![](../static/description/Image_40.png) + +When you validate the delivery in Odoo the label is created and the pick-up assignment is send to the carrier. + +![](../static/description/Image_50.png) + +In previous version there was a possibility to connect to the API integration instead of the Odoo integration. +To benefit from Sendcloud support we highly recommend you to upgrade to the latest version of this module with +the Odoo integration. + +## Sendcloud panel settings + + +When you configure the Integration settings in the online Sendcloud panel (https://panel.sendcloud.sc/) +those settings are also sync-ed with the Integration settings Odoo side. + + +## Synchronize Sendcloud objects + + +After the setup of the integration with Sendcloud server is completed, second step is +to synchronize the objects present in Sendcloud server to Odoo. +To synchronize Sendcloud objects for the first time: + +- Go to Sendcloud > Configuration > Wizards > Sync the Sendcloud Objects. A wizard will pop up. + +![](../static/description/Image_70.png) + +- Select all the objects. Confirm. This will retrieve the required data from Sendcloud server. + +![](../static/description/Image_80.png) + +Some Sendcloud objects will be automatically synchronized from the Sendcloud server to Odoo. +Those Sendcloud objects are: + +- Parcel Statuses +- Invoices +- Shipping Methods +- Sender Addresses + +To configure how often those objects should be retrieved from the Sendcloud server: + +- Go to Settings > Technical > Automation > Scheduled Actions. Search Scheduled Actions for "Sendcloud". + +![](../static/description/Image_90.png) + +- Set the "Execute Every" value according to your needs. + + +Sender Addresses and Warehouses + + +In case of multiple warehouses configured in Odoo (eg.: user belongs to group "Manage multiple Warehouse"): + +Go to Sendcloud > Configuration > Integration. Click on Configure Warehouse Addresses. A wizard will pop up. +Set the corresponding Sendcloud Sender Address for each of the warehouse addresses. + +![](../static/description/Image_100.png) + +Alternatively, in Inventory > Configuration > Warehouses, select an address. In the address form, go to Sales and Purchase tab and set the Sencloud Sender Address. +In Sale Order > Delivery: select the Warehouse. Check that the address of the Warehouse has a Sendcloud Senser Address. + +![](../static/description/Image_110.png) + +## Initial sync of past orders + + +Once all the previous configuration steps are completed, it is possible to synchronize +all the past Odoo outgoing shipments to Sendcloud. +Those shipments are the ones already setup with a Sendcloud shipping method. + +Go to Sendcloud > Configuration > Wizards > Sync past orders to Sendcloud. A wizard will pop up. +Select the date (by default set to 30 days back from today) from which the shipments +must be synchronized. + +Click on Confirm button: the shipments will be displayed in the Incoming Order View tab of the Sendcloud panel. +They will contain a status “Ready to Process” if they are ready to generate a label and the order fulfillment will continue. + +## Auto create invoice + + +When sending a product outside the EU, Sendcloud requires an invoice number. +In case shipment is made with a product that can be invoiced based on delivered quantities, +this combination of factors prevents the label being created in Sendcloud when confirming the SO. + +A possible solution is to automatically create a 100% down-payment invoice when shipping to outside the EU. +To enable this feature, go to the "General Settings": under the Sendcloud section you can find the "Auto create invoice" flag. +Notice: this feature is still in beta testing. + + +## Test Mode + + +To enable the Test Mode, go to the "General Settings": under the Sendcloud section you can find the "Enable Test Mode" flag. +Enabling the Test Mode allows you to access extra functionalities that are useful to test the connector. + +There is no seperate test environment available on the Sendcloud portal. This means that +as soon as you create labels the carries is given the order to pickup the goods. +You can use carrier "unstamped letter" for testing. +When testing with other carriers make sure that you cancel the labels in the Sendcloud portal +within a couple of hours otherwise the label will be billed and picked up. + +Since there is no test environment it's very important to know that Sendcloud stores it +records based on the delivery number, for instance WH/OUT/0001, this field is idempotent. +So when you start testing and you will use delivery number WH/OUT/00001 this number is +stored in Sendcloud. When you go live and use the same delivery numbers, in this case WH/OUT/00001, +Sendcloud will treat this as an update of the existing record and will send back the +shipping-address that was already stored (created while testing). To avoid this problem +you should set a different prefix on the sequence out in your testenvironment. +In debug mode, Technical/Sequences Identifiers/Sequences, select the sequence out and +adjust this to WH/OUT/TEST for instance. + +![](../static/description/Image_120.png) diff --git a/delivery_sendcloud_oca/readme/USAGE.md b/delivery_sendcloud_oca/readme/USAGE.md new file mode 100644 index 0000000000..5fb238a45b --- /dev/null +++ b/delivery_sendcloud_oca/readme/USAGE.md @@ -0,0 +1,57 @@ + +In short this is how the module works: + +- the user creates a sale order in Odoo; the user clicks on "Add shipping" button and selects one of the shipping methods provided by Sendcloud +- when confirming the sale order, a delivery document is generated (stock.picking) +- when confirming the picking, a parcel (or multiple parcels) for the specific sales order are created in Sendcloud under Shipping > Created labels +- the picking is updated with the information from Sendcloud (tracking number, tracking url, label etc...) + +## Map of Sendcloud-Odoo data models + + +| Sendcloud | Odoo | +| ----------- |-------------------| +| +| Brand | Website Shop | +| Order | Sales Order | +| Shipment | Picking | +| Parcel (colli) | Picking packs | +| Sender address | Warehouse address | +| Shipping Method | Shipping Method | + + + +## Multicollo parcels + + +In Inventory > Configuration > Delivery Packages, set the carrier to Sendcloud. +In the out picking, put the products in different Sendcloud packages to create Multicollo parcels. + +## Service Point Picker + + +The module contains a widget, the Service Point Picker, that allows the selection of the service point. +The widget is placed in the "Sendcloud Shipping" tab of the picking. The widget is visible in case the following is true: + + - the configuration in the Sendcloud panel has the Service Point flag to True (in the Sendcloud integration config) + - the Shipping Method selected in the picking is provided by Sendcloud + - the Shipping Method has field sendcloud_service_point_input == "required" + - all the criteria (from country, to country, weight) match with the current order + +## Cancel parcels + + +When canceling parcels a confirmation popup will ask for confirmation. + +## Delivery outside EU + + +Install either OCA module 'product_harmonized_system' or Enterprise module 'account_intrastat' for delivery outside of EU. +Both include extra field 'country of origin'. + + +## Troubleshooting + + +If the communication to the Sendcloud server fails (eg.: while creating a parcel), +the exchanged message is stored in a Log section, under Logging > Actions. \ No newline at end of file diff --git a/delivery_sendcloud_oca/security/ir.model.access.csv b/delivery_sendcloud_oca/security/ir.model.access.csv new file mode 100644 index 0000000000..2e74a26ee3 --- /dev/null +++ b/delivery_sendcloud_oca/security/ir.model.access.csv @@ -0,0 +1,30 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_sendcloud_parcel,sendcloud_parcel,model_sendcloud_parcel,,1,1,1,1 +access_sendcloud_parcel_item,sendcloud_parcel_item,model_sendcloud_parcel_item,,1,1,1,1 +access_sendcloud_brand,sendcloud_brand,model_sendcloud_brand,,1,1,1,0 +access_sendcloud_parcel_status,sendcloud_parcel_status,model_sendcloud_parcel_status,,1,1,1,0 +access_sendcloud_return,sendcloud_return,model_sendcloud_return,,1,1,1,0 +access_sendcloud_return_location,sendcloud_return_location,model_sendcloud_return_location,,1,1,1,0 +access_sendcloud_invoice,sendcloud_invoice,model_sendcloud_invoice,,1,1,1,0 +access_sendcloud_invoice_item,sendcloud_invoice_item,model_sendcloud_invoice_item,,1,1,1,1 +access_sendcloud_sender_address,sendcloud_sender_address,model_sendcloud_sender_address,,1,1,1,0 +access_sendcloud_carrier,sendcloud_carrier,model_sendcloud_carrier,base.group_user,1,1,1,,0 +access_sendcloud_action_webhook,sendcloud_action webhook,model_sendcloud_action,,0,1,1,0 +access_sendcloud_action_internal_user,sendcloud_action internal user,model_sendcloud_action,base.group_user,1,1,1,0 +access_sendcloud_integration,sendcloud_integration,model_sendcloud_integration,,1,1,1,0 +access_sendcloud_shipping_method_country,access_sendcloud_shipping_method_country,model_sendcloud_shipping_method_country,,1,1,1,1 +access_sendcloud_cancel_shipment_confirm_wizard,access_sendcloud_cancel_shipment_confirm_wizard,model_sendcloud_cancel_shipment_confirm_wizard,base.group_user,1,1,1,1 +access_sendcloud_create_return_parcel_wizard_return_location,access_sendcloud_create_return_parcel_wizard_return_location,model_sendcloud_create_return_parcel_wizard_return_location,base.group_user,1,1,1,1 +access_sendcloud_create_return_parcel_wizard_delivery_option,access_sendcloud_create_return_parcel_wizard_delivery_option,model_sendcloud_create_return_parcel_wizard_delivery_option,base.group_user,1,1,1,1 +access_sendcloud_create_return_parcel_wizard_refund_option,access_sendcloud_create_return_parcel_wizard_refund_option,model_sendcloud_create_return_parcel_wizard_refund_option,base.group_user,1,1,1,1 +access_sendcloud_create_return_parcel_wizard_reason,access_sendcloud_create_return_parcel_wizard_reason,model_sendcloud_create_return_parcel_wizard_reason,base.group_user,1,1,1,1 +access_sendcloud_create_return_parcel_wizard_line,access_sendcloud_create_return_parcel_wizard_line,model_sendcloud_create_return_parcel_wizard_line,base.group_user,1,1,1,1 +access_sendcloud_create_return_parcel_wizard,access_sendcloud_create_return_parcel_wizard,model_sendcloud_create_return_parcel_wizard,base.group_user,1,1,1,1 +access_sendcloud_integration_wizard,access_sendcloud_integration_wizard,model_sendcloud_integration_wizard,base.group_user,1,1,1,1 +access_sendcloud_warehouse_address_wizard,access_sendcloud_warehouse_address_wizard,model_sendcloud_warehouse_address_wizard,base.group_user,1,1,1,1 +access_sendcloud_change_warehouse_address_wizard,access_sendcloud_change_warehouse_address_wizard,model_sendcloud_change_warehouse_address_wizard,base.group_user,1,1,1,1 +access_sendcloud_sync_wizard,access_sendcloud_sync_wizard,model_sendcloud_sync_wizard,base.group_user,1,1,1,1 +access_sendcloud_sync_order_wizard,access_sendcloud_sync_order_wizard,model_sendcloud_sync_order_wizard,base.group_user,1,1,1,1 +access_sendcloud_shipping_method_country_custom,access_sendcloud_shipping_method_country_custom,model_sendcloud_shipping_method_country_custom,,1,1,1,1 +access_sendcloud_custom_price_details_wizard,access_sendcloud_custom_price_details_wizard,model_sendcloud_custom_price_details_wizard,base.group_user,1,1,1,1 +access_sendcloud_parcel_document,access_sendcloud_parcel_document,model_sendcloud_parcel_document,base.group_user,1,1,1,1 diff --git a/delivery_sendcloud_oca/security/sendcloud_security_rule.xml b/delivery_sendcloud_oca/security/sendcloud_security_rule.xml new file mode 100644 index 0000000000..afd782535c --- /dev/null +++ b/delivery_sendcloud_oca/security/sendcloud_security_rule.xml @@ -0,0 +1,69 @@ + + + + + + Sendcloud Action multicompany + + + ['|',('company_id','=',False),('company_id','in',company_ids)] + + + + Sendcloud Brand multicompany + + + ['|',('company_id','=',False),('company_id','in',company_ids)] + + + + Sendcloud Integration multicompany + + + ['|',('company_id','=',False),('company_id','in',company_ids)] + + + + Sendcloud Invoice multicompany + + + ['|',('company_id','=',False),('company_id','in',company_ids)] + + + + Sendcloud Parcel multicompany + + + ['|',('company_id','=',False),('company_id','in',company_ids)] + + + + Sendcloud Return multicompany + + + ['|',('company_id','=',False),('company_id','in',company_ids)] + + + + Sendcloud Sender Address multicompany + + + ['|',('company_id','=',False),('company_id','in',company_ids)] + + + diff --git a/delivery_sendcloud_oca/static/description/Image_10.png b/delivery_sendcloud_oca/static/description/Image_10.png new file mode 100644 index 0000000000..4aae52f781 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_10.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_100.png b/delivery_sendcloud_oca/static/description/Image_100.png new file mode 100644 index 0000000000..865cd50f8b Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_100.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_110.png b/delivery_sendcloud_oca/static/description/Image_110.png new file mode 100644 index 0000000000..939ef9094f Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_110.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_120.png b/delivery_sendcloud_oca/static/description/Image_120.png new file mode 100644 index 0000000000..7422ca396c Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_120.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_130.png b/delivery_sendcloud_oca/static/description/Image_130.png new file mode 100644 index 0000000000..eb81b14242 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_130.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_140.png b/delivery_sendcloud_oca/static/description/Image_140.png new file mode 100644 index 0000000000..ee711d5847 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_140.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_20.png b/delivery_sendcloud_oca/static/description/Image_20.png new file mode 100644 index 0000000000..5e9b2aa247 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_20.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_30.png b/delivery_sendcloud_oca/static/description/Image_30.png new file mode 100644 index 0000000000..9783b1ded8 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_30.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_40.png b/delivery_sendcloud_oca/static/description/Image_40.png new file mode 100644 index 0000000000..46f3057ba9 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_40.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_50.png b/delivery_sendcloud_oca/static/description/Image_50.png new file mode 100644 index 0000000000..7d0dee269f Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_50.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_70.png b/delivery_sendcloud_oca/static/description/Image_70.png new file mode 100644 index 0000000000..0d7f0497f2 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_70.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_80.png b/delivery_sendcloud_oca/static/description/Image_80.png new file mode 100644 index 0000000000..999adc51c6 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_80.png differ diff --git a/delivery_sendcloud_oca/static/description/Image_90.png b/delivery_sendcloud_oca/static/description/Image_90.png new file mode 100644 index 0000000000..2f9d90116c Binary files /dev/null and b/delivery_sendcloud_oca/static/description/Image_90.png differ diff --git a/delivery_sendcloud_oca/static/description/icon.png b/delivery_sendcloud_oca/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/icon.png differ diff --git a/delivery_sendcloud_oca/static/description/image_150.png b/delivery_sendcloud_oca/static/description/image_150.png new file mode 100644 index 0000000000..7f67810547 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/image_150.png differ diff --git a/delivery_sendcloud_oca/static/description/image_160.png b/delivery_sendcloud_oca/static/description/image_160.png new file mode 100644 index 0000000000..1a3ac2a223 Binary files /dev/null and b/delivery_sendcloud_oca/static/description/image_160.png differ diff --git a/delivery_sendcloud_oca/static/description/index.html b/delivery_sendcloud_oca/static/description/index.html new file mode 100644 index 0000000000..6a47852319 --- /dev/null +++ b/delivery_sendcloud_oca/static/description/index.html @@ -0,0 +1,663 @@ + + + + + +Sendcloud Shipping + + + +
+

Sendcloud Shipping

+ + +

Beta License: LGPL-3 OCA/delivery-carrier Translate me on Weblate Try me on Runboat

+

This module provides sendcloud shipping integration with Odoo

+

This module mostly implements what’s described in +https://docs.sendcloud.sc/api/v2/shipping/

+

Full documentation for developers is in https://docs.sendcloud.sc/.

+

This module works for the Community Edition as well as the Enterprise +Edition.

+

Table of contents

+ +
+

Installation

+

Create an account on sendcloud.com and choose a plan. Go to integrations +and select Odoo integration to use the Odoo integration or select api +integration if you only want to use the api integration (see readme for +more information).

+
+

Odoo Integration

+

Verify that the value of “web.base.url” parameter in System Parameters +is set with the correct url (eg.: “https://demo.onestein.eu” instead of +“http://localhost:8069”).

+

Go to Sendcloud > Configuration > Wizards > Setup the Sendcloud +Integration. A wizard will pop up.

+

image1

+

Select Odoo Integration. Start Setup. You will be redirected to a +Sendcloud page asking you to authorize OdooShop to access your Sendcloud +account. Click on Connect in the Sendcloud page.

+

image2

+

Go back to the Odoo Integration configuration. An integration “OdooShop” +is now present in the Integration list view. Open the OdooShop +Integration form. Edit the OdooShop Integration. The changes you make +will be in sync, Sendcloud side, with the integration configuration.

+

image3

+

In case multiple integrations are present, sort the integrations by +sequence, to allow Odoo to choose the default one that will be used. +Please note that when using the Odoo integration an “incoming order” is +created in Sendcloud as soon as you validate the salesorder. The +“incoming order” has status “in process” in Sendcloud and is not +forwarded to the carrier yet.

+

image4

+

When you validate the delivery in Odoo the label is created and the +pick-up assignment is send to the carrier.

+

image5

+

In previous version there was a possibility to connect to the API +integration instead of the Odoo integration. To benefit from Sendcloud +support we highly recommend you to upgrade to the latest version of this +module with the Odoo integration.

+
+
+

Sendcloud panel settings

+

When you configure the Integration settings in the online Sendcloud +panel (https://panel.sendcloud.sc/) those settings are also sync-ed with +the Integration settings Odoo side.

+
+
+

Synchronize Sendcloud objects

+

After the setup of the integration with Sendcloud server is completed, +second step is to synchronize the objects present in Sendcloud server to +Odoo. To synchronize Sendcloud objects for the first time:

+
    +
  • Go to Sendcloud > Configuration > Wizards > Sync the Sendcloud +Objects. A wizard will pop up.
  • +
+

image6

+
    +
  • Select all the objects. Confirm. This will retrieve the required data +from Sendcloud server.
  • +
+

image7

+

Some Sendcloud objects will be automatically synchronized from the +Sendcloud server to Odoo. Those Sendcloud objects are:

+
    +
  • Parcel Statuses
  • +
  • Invoices
  • +
  • Shipping Methods
  • +
  • Sender Addresses
  • +
+

To configure how often those objects should be retrieved from the +Sendcloud server:

+
    +
  • Go to Settings > Technical > Automation > Scheduled Actions. Search +Scheduled Actions for “Sendcloud”.
  • +
+

image8

+
    +
  • Set the “Execute Every” value according to your needs.
  • +
+

Sender Addresses and Warehouses

+

In case of multiple warehouses configured in Odoo (eg.: user belongs to +group “Manage multiple Warehouse”):

+

Go to Sendcloud > Configuration > Integration. Click on Configure +Warehouse Addresses. A wizard will pop up. Set the corresponding +Sendcloud Sender Address for each of the warehouse addresses.

+

image9

+

Alternatively, in Inventory > Configuration > Warehouses, select an +address. In the address form, go to Sales and Purchase tab and set the +Sencloud Sender Address. In Sale Order > Delivery: select the Warehouse. +Check that the address of the Warehouse has a Sendcloud Senser Address.

+

image10

+
+
+

Initial sync of past orders

+

Once all the previous configuration steps are completed, it is possible +to synchronize all the past Odoo outgoing shipments to Sendcloud. Those +shipments are the ones already setup with a Sendcloud shipping method.

+

Go to Sendcloud > Configuration > Wizards > Sync past orders to +Sendcloud. A wizard will pop up. Select the date (by default set to 30 +days back from today) from which the shipments must be synchronized.

+

Click on Confirm button: the shipments will be displayed in the Incoming +Order View tab of the Sendcloud panel. They will contain a status “Ready +to Process” if they are ready to generate a label and the order +fulfillment will continue.

+
+
+

Auto create invoice

+

When sending a product outside the EU, Sendcloud requires an invoice +number. In case shipment is made with a product that can be invoiced +based on delivered quantities, this combination of factors prevents the +label being created in Sendcloud when confirming the SO.

+

A possible solution is to automatically create a 100% down-payment +invoice when shipping to outside the EU. To enable this feature, go to +the “General Settings”: under the Sendcloud section you can find the +“Auto create invoice” flag. Notice: this feature is still in beta +testing.

+
+
+

Test Mode

+

To enable the Test Mode, go to the “General Settings”: under the +Sendcloud section you can find the “Enable Test Mode” flag. Enabling the +Test Mode allows you to access extra functionalities that are useful to +test the connector.

+

There is no seperate test environment available on the Sendcloud portal. +This means that as soon as you create labels the carries is given the +order to pickup the goods. You can use carrier “unstamped letter” for +testing. When testing with other carriers make sure that you cancel the +labels in the Sendcloud portal within a couple of hours otherwise the +label will be billed and picked up.

+

Since there is no test environment it’s very important to know that +Sendcloud stores it records based on the delivery number, for instance +WH/OUT/0001, this field is idempotent. So when you start testing and you +will use delivery number WH/OUT/00001 this number is stored in +Sendcloud. When you go live and use the same delivery numbers, in this +case WH/OUT/00001, Sendcloud will treat this as an update of the +existing record and will send back the shipping-address that was already +stored (created while testing). To avoid this problem you should set a +different prefix on the sequence out in your testenvironment. In debug +mode, Technical/Sequences Identifiers/Sequences, select the sequence out +and adjust this to WH/OUT/TEST for instance.

+

image11

+
+
+
+

Usage

+

In short this is how the module works:

+
    +
  • the user creates a sale order in Odoo; the user clicks on “Add +shipping” button and selects one of the shipping methods provided by +Sendcloud
  • +
  • when confirming the sale order, a delivery document is generated +(stock.picking)
  • +
  • when confirming the picking, a parcel (or multiple parcels) for the +specific sales order are created in Sendcloud under Shipping > +Created labels
  • +
  • the picking is updated with the information from Sendcloud (tracking +number, tracking url, label etc…)
  • +
+
+

Map of Sendcloud-Odoo data models

+ ++++ + + + + + + + + + + +
SendcloudOdoo
  
+

| | Brand | Website Shop | | Order | Sales Order | | Shipment | +Picking | | Parcel (colli) | Picking packs | | Sender address | +Warehouse address | | Shipping Method | Shipping Method |

+
+
+

Multicollo parcels

+

In Inventory > Configuration > Delivery Packages, set the carrier to +Sendcloud. In the out picking, put the products in different Sendcloud +packages to create Multicollo parcels.

+
+
+

Service Point Picker

+

The module contains a widget, the Service Point Picker, that allows the +selection of the service point. The widget is placed in the “Sendcloud +Shipping” tab of the picking. The widget is visible in case the +following is true:

+
    +
  • the configuration in the Sendcloud panel has the Service Point flag +to True (in the Sendcloud integration config)
  • +
  • the Shipping Method selected in the picking is provided by Sendcloud
  • +
  • the Shipping Method has field sendcloud_service_point_input == +“required”
  • +
  • all the criteria (from country, to country, weight) match with the +current order
  • +
+
+
+

Cancel parcels

+

When canceling parcels a confirmation popup will ask for confirmation.

+
+
+

Delivery outside EU

+

Install either OCA module ‘product_harmonized_system’ or Enterprise +module ‘account_intrastat’ for delivery outside of EU. Both include +extra field ‘country of origin’.

+
+
+

Troubleshooting

+

If the communication to the Sendcloud server fails (eg.: while creating +a parcel), the exchanged message is stored in a Log section, under +Logging > Actions.

+
+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Onestein
  • +
+
+
+

Contributors

+
    +
  • Onestein <https://www.onestein.nl>__
  • +
+
+
+

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.

+

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_sendcloud_oca/static/img/sendcloud_onboarding_bg.jpg b/delivery_sendcloud_oca/static/img/sendcloud_onboarding_bg.jpg new file mode 100644 index 0000000000..1d4f4d1845 Binary files /dev/null and b/delivery_sendcloud_oca/static/img/sendcloud_onboarding_bg.jpg differ diff --git a/delivery_sendcloud_oca/static/src/js/backend.esm.js b/delivery_sendcloud_oca/static/src/js/backend.esm.js new file mode 100644 index 0000000000..fcb5a222e8 --- /dev/null +++ b/delivery_sendcloud_oca/static/src/js/backend.esm.js @@ -0,0 +1,111 @@ +/** @odoo-module **/ +/* global sendcloud */ +import {Component, onWillStart} from "@odoo/owl"; +import {_lt} from "@web/core/l10n/translation"; +import {loadJS} from "@web/core/assets"; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; +import {useInputField} from "@web/views/fields/input_field_hook"; +import {useService} from "@web/core/utils/hooks"; +import {WarningDialog} from "@web/core/errors/error_dialogs"; + +export class ServicePointSelectorField extends Component { + setup() { + useInputField({getValue: () => this.props.value || ""}); + this.dialog = useService("dialog"); + onWillStart(() => + loadJS("/delivery_sendcloud_oca/static/src/lib/sendcloud/api.min.js") + ); + } + + async onClearClick() { + this.props.update(""); + } + + async _onServicePointError(errors) { + var irrelevantErrors = ["Closed"]; + var relevantErrors = _.difference(errors, irrelevantErrors); + + if (relevantErrors.length) { + this.dialog.add(WarningDialog, { + title: this.env._t("Failure in opening Service Point Selector"), + message: relevantErrors.join("\n"), + }); + } + } + + async _onServicePointSelected(servicePoint) { + this.props.update(JSON.stringify(servicePoint)); + } + + async onInputClick() { + var value = this.props.record.data.sendcloud_sp_details; + if (!value) { + return ""; + } + + var parsedValue = JSON.parse(value); + sendcloud.servicePoints.open( + { + apiKey: parsedValue.api_key, + country: parsedValue.country, + postalCode: parsedValue.postalcode, + language: parsedValue.language, + carriers: [parsedValue.carrier], + }, + this._onServicePointSelected.bind(this), + this._onServicePointError.bind(this) + ); + } + + get sp_name() { + try { + return JSON.parse(this.props.value).name; + } catch { + return ""; + } + } + + get street() { + try { + return JSON.parse(this.props.value).street; + } catch { + return ""; + } + } + + get house_number() { + try { + return JSON.parse(this.props.value).house_number; + } catch { + return ""; + } + } + + get postal_code() { + try { + return JSON.parse(this.props.value).postal_code; + } catch { + return ""; + } + } + + get city() { + try { + return JSON.parse(this.props.value).city; + } catch { + return ""; + } + } +} +ServicePointSelectorField.template = "delivery_sendcloud_oca.ServicePointField"; +ServicePointSelectorField.props = standardFieldProps; +export const servicePointSelectorField = { + component: ServicePointSelectorField, + supportedTypes: ["text"], + displayName: _lt("Service Point Selector"), +}; + +registry + .category("fields") + .add("sendcloud_service_point_selector", servicePointSelectorField); diff --git a/delivery_sendcloud_oca/static/src/lib/sendcloud/api.min.js b/delivery_sendcloud_oca/static/src/lib/sendcloud/api.min.js new file mode 100644 index 0000000000..631f58cdab --- /dev/null +++ b/delivery_sendcloud_oca/static/src/lib/sendcloud/api.min.js @@ -0,0 +1,24 @@ +/* */var sendcloud=window.sendcloud||{};sendcloud.servicePoints=(function(){'use strict';var baseDomain='https://servicepoints.sendcloud.sc';var updateBrowserMessage=JSON.parse('{"de-de": "Sie verwenden einen veralteten Browser. Aktualisieren Sie bitte Ihren Browser, um einen Paketshop auszuw\u00e4hlen.", "en-us": "You are using an outdated browser. Please upgrade your browser to select a service point location.", "en-gb": "You are using an outdated browser. Please upgrade your browser to select a service point location.", "es-es": "Est\u00e1s utilizando un navegador obsoleto. Actualiza tu navegador para seleccionar una ubicaci\u00f3n de punto de servicio.", "fr-fr": "Vous utilisez un navigateur obsol\u00e8te. Veuillez mettre \u00e0 jour votre navigateur pour s\u00e9lectionner une zone de Point Service.", "it-it": "Stai utilizzando un browser obsoleto. Aggiorna il tuo browser per selezionare la posizione di un Service Point.", "nl-nl": "Je browser is niet meer up-to-date. Update je internet browser om een afhaalpunt locatie te kunnen kiezen. "}');var iframeDiv=null;var loadingDiv=null;var successCallback=null;var failureCallback=null;function _noop(){} +function _callable(fn){return!fn||typeof fn!=='function'?_noop:fn;} +function serializeParams(obj){var str='';for(var key in obj){if(str!==''){str+='&';} +str+=key+'='+encodeURIComponent(obj[key]);} +return str;} +function removeElement(element){if(element.remove!==undefined){element.remove();}else{element&&element.parentNode&&element.parentNode.removeChild(element);}} +function validateLanguage(language){var allowedLanguages=[];for(var k in updateBrowserMessage){allowedLanguages.push(k);var short=k.replace(/-\w+/,'');if(allowedLanguages.indexOf(short)===-1){allowedLanguages.push(short);}} +return allowedLanguages.indexOf(language)!==-1;} +function validateRequired(value){return(value.length>0&&typeof value==='string')||value instanceof String;} +function validateConfig(config){var errors=[];if(!validateRequired(config.apiKey)){errors.push('Missing API key.');} +if(!validateLanguage(config.language)){errors.push('Invalid language set: '+config.language);} +if(!validateRequired(config.country)){errors.push('No country set.');} +if(errors.length>0){failureCallback(errors);return false;} +return true;} +function open(config,onSuccess,onFailure){successCallback=_callable(onSuccess);failureCallback=_callable(onFailure);if(!validateConfig(config)){return;} +var language=config.language||'en-us';var msie=document.documentMode;if(msie&&msie<=9){var msg=updateBrowserMessage[language];failureCallback([msg]);return;} +var params={'api-key':config.apiKey,country:config.country,'postal-code':config.postalCode||'',carrier:config.carriers||'',id:config.servicePointId||'',weight:config.weight||'',language:language,'close-button':1,'post-number':config.postNumber||''};var url=baseDomain+'/embed/v3/service-point-picker/?'+serializeParams(params);iframeDiv=document.createElement('div');iframeDiv.innerHTML='